feat: 按参考布局实现出库单新增页

This commit is contained in:
zhouz
2026-07-22 15:37:44 +08:00
parent 733ef97a3b
commit e5f56057c3
5 changed files with 445 additions and 26 deletions

View File

@@ -51,7 +51,9 @@ export namespace WmsIostorInvApi {
/** 出库明细页面展示模型(提交时应转换为 OutboundDetail */
export interface OutboundDisplayDetail extends OutboundDetail {
groupId?: number | string;
materialName?: string;
rowKey?: string;
sapBatchNo?: string;
vehicleCode?: string;
}

View File

@@ -11,6 +11,7 @@ export function mapExpandedInventory(
inventory: WmsIostorInvApi.AvailableInventory[],
): WmsIostorInvApi.OutboundDisplayDetail[] {
return inventory.map((row) => ({
groupId: row.groupId,
materialCode: row.materialCode,
materialId: row.materialId,
materialName: row.materialName ?? undefined,

View File

@@ -4,28 +4,48 @@ import type { WmsIostorInvApi } from '#/api/wms/iostorinv';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { message } from 'antdv-next';
import { Button, Input, InputNumber, message, Space, Table } from 'antdv-next';
import dayjs from 'dayjs';
import { useVbenForm } from '#/adapter/form';
import { createIostorInv, getIostorInv, updateIostorInv } from '#/api/wms/iostorinv';
import { getBsrealStorAttrSimpleList } from '#/api/wms/bsrealstorattr';
import {
createOutbound,
getIostorInv,
updateIostorInv,
} from '#/api/wms/iostorinv';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
import { summarizeDetails } from './detail-summary';
import InventorySelectComponent from './inventory-select.vue';
import ManualDetailComponent from './manual-detail.vue';
import {
buildOutboundPayload,
mergeOutboundInventory,
type OutboundFormDetail,
shouldClearDetailsForWarehouseChange,
} from './outbound-form';
const emit = defineEmits(['success']);
const formData = ref<WmsIostorInvApi.IostorInv>();
const getTitle = computed(() => {
return formData.value?.iostorinvId
? $t('ui.actionTitle.edit', ['出入库单主表'])
: $t('ui.actionTitle.create', ['出入库单主表']);
});
const details = ref<OutboundFormDetail[]>([]);
const currentStorId = ref<string>();
let manualRowSequence = 0;
const [Form, formApi] = useVbenForm({
const isEdit = computed(() => Boolean(formData.value?.iostorinvId));
const getTitle = computed(() =>
isEdit.value
? $t('ui.actionTitle.edit', ['出入库单主表'])
: '出库单新增',
);
const [LegacyForm, legacyFormApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
componentProps: { class: 'w-full' },
formItemClass: 'col-span-2',
labelWidth: 80,
},
@@ -34,18 +54,218 @@ const [Form, formApi] = useVbenForm({
showDefaultActions: false,
});
async function handleWarehouseChange(nextStorId?: string) {
const shouldClear = shouldClearDetailsForWarehouseChange(
currentStorId.value,
nextStorId,
);
currentStorId.value = nextStorId;
if (shouldClear && details.value.length > 0) {
details.value = [];
await refreshSummary();
}
}
const [OutboundForm, outboundFormApi] = useVbenForm({
commonConfig: {
componentProps: { class: 'w-full' },
formItemClass: 'col-span-1',
labelWidth: 88,
},
handleValuesChange: (values) => {
if (Object.hasOwn(values, 'storId')) {
void handleWarehouseChange(values.storId);
}
},
layout: 'horizontal',
schema: [
{
component: 'Input',
componentProps: { disabled: true },
defaultValue: '保存时自动生成',
fieldName: 'billCode',
label: '单据号',
},
{
component: 'ApiSelect',
componentProps: {
allowClear: true,
api: getBsrealStorAttrSimpleList,
labelField: 'storName',
placeholder: '请选择仓库',
valueField: 'storId',
},
fieldName: 'storId',
label: '仓库',
rules: 'required',
},
{
component: 'Select',
componentProps: {
allowClear: true,
options: getDictOptions(DICT_TYPE.WMS_SHIPMENT_ORDER_TYPE, 'string'),
placeholder: '请选择业务类型',
},
fieldName: 'billType',
label: '业务类型',
rules: 'required',
},
{
component: 'Input',
componentProps: { disabled: true },
defaultValue: '生成',
fieldName: 'billStatus',
label: '单据状态',
},
{
component: 'InputNumber',
componentProps: { disabled: true, min: 0 },
defaultValue: 0,
fieldName: 'detailCount',
label: '明细数',
},
{
component: 'Input',
componentProps: { disabled: true },
defaultValue: '0.000',
fieldName: 'totalWeight',
label: '总重量',
},
{
component: 'DatePicker',
componentProps: { class: 'w-full', format: 'YYYY-MM-DD' },
defaultValue: dayjs(),
fieldName: 'bizDate',
label: '业务日期',
rules: 'required',
},
{
component: 'Textarea',
componentProps: { allowClear: true, placeholder: '请输入备注', rows: 1 },
fieldName: 'remark',
label: '备注',
},
],
showDefaultActions: false,
wrapperClass: 'grid grid-cols-1 gap-x-4 md:grid-cols-2 xl:grid-cols-4',
});
const [InventorySelect, inventorySelectApi] = useVbenModal({
connectedComponent: InventorySelectComponent,
destroyOnClose: true,
});
const [ManualDetail, manualDetailApi] = useVbenModal({
connectedComponent: ManualDetailComponent,
destroyOnClose: true,
});
const columns = [
{ key: 'index', title: '序号', width: 60 },
{ dataIndex: 'materialCode', key: 'materialCode', title: '物料编码', width: 130 },
{ dataIndex: 'materialName', key: 'materialName', title: '物料名称', width: 150 },
{ dataIndex: 'vehicleCode', key: 'vehicleCode', title: '箱号', width: 130 },
{ dataIndex: 'pcsn', key: 'pcsn', title: '子卷号', width: 130 },
{ dataIndex: 'sapBatchNo', key: 'sapBatchNo', title: 'SAP批次号', width: 130 },
{ dataIndex: 'planQty', key: 'planQty', title: '出库重量', width: 130 },
{ dataIndex: 'qtyUnitName', key: 'qtyUnitName', title: '单位', width: 80 },
{ dataIndex: 'sourceBillCode', key: 'sourceBillCode', title: '源单号', width: 140 },
{ dataIndex: 'remark', key: 'remark', title: '明细备注', width: 180 },
{ key: 'action', title: '操作', width: 80, fixed: 'right' as const },
];
async function refreshSummary() {
const summary = summarizeDetails(details.value);
await outboundFormApi.setValues({
detailCount: summary.detailCount,
totalWeight: summary.totalWeight.toFixed(3),
});
}
async function openInventorySelect() {
const values = await outboundFormApi.getValues();
if (!values.storId) {
message.warning('请先选择仓库');
return;
}
inventorySelectApi.setData({ storId: values.storId }).open();
}
function openManualDetail() {
manualDetailApi.open();
}
async function handleInventorySelect(rows: WmsIostorInvApi.OutboundDisplayDetail[]) {
const keyedRows = rows.map((row) => ({
...row,
rowKey: `inventory-${String(row.groupId)}`,
}));
details.value = mergeOutboundInventory(details.value, keyedRows);
await refreshSummary();
}
async function handleManualSelect(rows: WmsIostorInvApi.OutboundDisplayDetail[]) {
details.value.push(
...rows.map((row) => ({
...row,
rowKey: `manual-${Date.now()}-${++manualRowSequence}`,
})),
);
await refreshSummary();
}
async function removeDetail(index: number) {
details.value.splice(index, 1);
await refreshSummary();
}
function validateDetails() {
if (details.value.length === 0) {
message.warning('请至少添加一条出库明细');
return false;
}
const invalidIndex = details.value.findIndex(
(detail) =>
!detail.materialCode ||
!Number.isFinite(Number(detail.planQty)) ||
Number(detail.planQty) <= 0,
);
if (invalidIndex >= 0) {
message.warning(`${invalidIndex + 1} 条明细物料编码不能为空,出库重量必须大于 0`);
return false;
}
return true;
}
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
if (isEdit.value) {
const { valid } = await legacyFormApi.validate();
if (!valid) return;
modalApi.lock();
try {
await updateIostorInv(
(await legacyFormApi.getValues()) as WmsIostorInvApi.IostorInv,
);
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
return;
}
const { valid } = await outboundFormApi.validate();
if (!valid || !validateDetails()) return;
const values = await outboundFormApi.getValues();
modalApi.lock();
// 提交表单
const data = (await formApi.getValues()) as WmsIostorInvApi.IostorInv;
try {
await (formData.value?.iostorinvId ? updateIostorInv(data) : createIostorInv(data));
// 关闭并提示
await createOutbound(
buildOutboundPayload(
{ ...values, bizDate: dayjs(values.bizDate).valueOf() },
details.value,
),
);
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
@@ -53,21 +273,33 @@ const [Modal, modalApi] = useVbenModal({
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
async onOpenChange(open) {
if (!open) {
formData.value = undefined;
details.value = [];
currentStorId.value = undefined;
return;
}
// 加载数据
const data = modalApi.getData<WmsIostorInvApi.IostorInv>();
if (!data || !data.iostorinvId) {
if (!data?.iostorinvId) {
formData.value = undefined;
details.value = [];
currentStorId.value = undefined;
await outboundFormApi.resetForm();
await outboundFormApi.setValues({
billCode: '保存时自动生成',
billStatus: '生成',
bizDate: dayjs(),
detailCount: 0,
totalWeight: '0.000',
});
return;
}
modalApi.lock();
try {
formData.value = await getIostorInv(data.iostorinvId);
// 设置到 values
await formApi.setValues(formData.value);
formData.value = data;
formData.value = await getIostorInv(Number(data.iostorinvId));
await legacyFormApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
@@ -76,7 +308,46 @@ const [Modal, modalApi] = useVbenModal({
</script>
<template>
<Modal :title="getTitle">
<Form class="mx-4" />
<Modal class="w-[1400px] max-w-[96vw]" :title="getTitle">
<LegacyForm v-if="isEdit" class="mx-4" />
<div v-else class="px-4">
<OutboundForm />
<div class="mb-3 mt-1 flex items-center justify-between">
<span class="text-base font-medium">出库明细</span>
<Space>
<Button type="primary" @click="openInventorySelect">选择库存</Button>
<Button @click="openManualDetail">新增汇总</Button>
</Space>
</div>
<Table
bordered
:columns="columns"
:data-source="details"
:pagination="false"
:row-key="(row: OutboundFormDetail) => row.rowKey!"
:scroll="{ x: 1330, y: 360 }"
size="small"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'index'">{{ index + 1 }}</template>
<template v-else-if="column.key === 'planQty'">
<InputNumber
v-model:value="record.planQty"
class="w-full"
:min="0"
@change="refreshSummary"
/>
</template>
<template v-else-if="column.key === 'remark'">
<Input v-model:value="record.remark" allow-clear />
</template>
<template v-else-if="column.key === 'action'">
<Button danger type="link" @click="removeDetail(index)">删除</Button>
</template>
</template>
</Table>
</div>
<InventorySelect @select="handleInventorySelect" />
<ManualDetail @select="handleManualSelect" />
</Modal>
</template>

View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest';
import {
buildOutboundPayload,
mergeOutboundInventory,
shouldClearDetailsForWarehouseChange,
} from './outbound-form';
describe('buildOutboundPayload', () => {
it('将业务日期转换成毫秒,并剥离所有仅展示字段和汇总字段', () => {
const bizDate = new Date('2026-07-22T08:30:00+08:00');
const payload = buildOutboundPayload(
{ billType: 'SALE', bizDate, remark: '主备注', storId: 'S-1' },
[
{
groupId: 11,
materialCode: 'M-1',
materialId: '1',
materialName: '物料一',
pcsn: 'P-1',
planQty: '12.5',
qtyUnitId: 'kg',
qtyUnitName: '千克',
remark: '明细备注',
rowKey: 'inventory-11',
sapBatchNo: 'SAP-1',
sourceBillCode: 'SRC-1',
sourceBillType: 'SALE',
sourceBilldtlId: 'D-1',
vehicleCode: 'BOX-1',
},
],
);
expect(payload.bizDate).toBe(bizDate.valueOf());
expect(payload.details).toEqual([
{
materialCode: 'M-1',
materialId: '1',
pcsn: 'P-1',
planQty: 12.5,
qtyUnitId: 'kg',
qtyUnitName: '千克',
remark: '明细备注',
sourceBillCode: 'SRC-1',
sourceBillType: 'SALE',
sourceBilldtlId: 'D-1',
},
]);
expect(payload).not.toHaveProperty('detailCount');
expect(payload).not.toHaveProperty('totalWeight');
});
});
describe('mergeOutboundInventory', () => {
it('按 groupId 去除已添加库存和同批重复库存', () => {
const existing = [{ groupId: 1, materialCode: 'OLD', planQty: 1 }];
const incoming = [
{ groupId: '1', materialCode: 'DUP', planQty: 2 },
{ groupId: 2, materialCode: 'NEW', planQty: 3 },
{ groupId: '2', materialCode: 'NEW-DUP', planQty: 4 },
];
expect(mergeOutboundInventory(existing, incoming)).toEqual([
existing[0],
incoming[1],
]);
});
});
describe('shouldClearDetailsForWarehouseChange', () => {
it('初始化和同值回填不清空,实际变化才清空', () => {
expect(shouldClearDetailsForWarehouseChange(undefined, 'S-1')).toBe(false);
expect(shouldClearDetailsForWarehouseChange('S-1', 'S-1')).toBe(false);
expect(shouldClearDetailsForWarehouseChange('S-1', 'S-2')).toBe(true);
expect(shouldClearDetailsForWarehouseChange('S-1', undefined)).toBe(true);
});
});

View File

@@ -0,0 +1,67 @@
import type { WmsIostorInvApi } from '#/api/wms/iostorinv';
export interface OutboundFormValues {
billType?: string;
bizDate?: number | string | { valueOf: () => number };
remark?: string;
storId?: string;
}
export interface OutboundFormDetail
extends Omit<WmsIostorInvApi.OutboundDisplayDetail, 'planQty'> {
groupId?: number | string;
planQty: number | string;
rowKey?: string;
}
export function buildOutboundPayload(
values: OutboundFormValues,
details: OutboundFormDetail[],
): WmsIostorInvApi.OutboundCreateReq {
return {
billType: values.billType!,
bizDate: Number(values.bizDate?.valueOf()),
details: details.map((detail) => ({
materialCode: detail.materialCode,
materialId: detail.materialId,
pcsn: detail.pcsn,
planQty: Number(detail.planQty),
qtyUnitId: detail.qtyUnitId,
qtyUnitName: detail.qtyUnitName,
remark: detail.remark,
sourceBillCode: detail.sourceBillCode,
sourceBillType: detail.sourceBillType,
sourceBilldtlId: detail.sourceBilldtlId,
})),
remark: values.remark || undefined,
storId: values.storId!,
};
}
/** 库存行跨多次选择时,仍按后端组盘记录标识去重。 */
export function mergeOutboundInventory<T extends { groupId?: number | string }>(
existing: T[],
incoming: T[],
) {
const result = [...existing];
const groupIds = new Set(
existing
.map(({ groupId }) => groupId)
.filter((groupId) => groupId !== undefined)
.map(String),
);
incoming.forEach((row) => {
if (row.groupId === undefined || !groupIds.has(String(row.groupId))) {
result.push(row);
if (row.groupId !== undefined) groupIds.add(String(row.groupId));
}
});
return result;
}
export function shouldClearDetailsForWarehouseChange(
previousStorId?: string,
nextStorId?: string,
) {
return Boolean(previousStorId && previousStorId !== nextStorId);
}