From e5f56057c3241b2d17564df5ea2753dc7af85a09 Mon Sep 17 00:00:00 2001 From: zhouz <> Date: Wed, 22 Jul 2026 15:37:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8C=89=E5=8F=82=E8=80=83=E5=B8=83?= =?UTF-8?q?=E5=B1=80=E5=AE=9E=E7=8E=B0=E5=87=BA=E5=BA=93=E5=8D=95=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/api/wms/iostorinv/index.ts | 2 + .../wms/iostorinv/modules/detail-builders.ts | 1 + .../src/views/wms/iostorinv/modules/form.vue | 323 ++++++++++++++++-- .../iostorinv/modules/outbound-form.test.ts | 78 +++++ .../wms/iostorinv/modules/outbound-form.ts | 67 ++++ 5 files changed, 445 insertions(+), 26 deletions(-) create mode 100644 nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/outbound-form.test.ts create mode 100644 nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/outbound-form.ts diff --git a/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/api/wms/iostorinv/index.ts b/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/api/wms/iostorinv/index.ts index 197dc02b..30bff1d0 100644 --- a/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/api/wms/iostorinv/index.ts +++ b/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/api/wms/iostorinv/index.ts @@ -51,7 +51,9 @@ export namespace WmsIostorInvApi { /** 出库明细页面展示模型(提交时应转换为 OutboundDetail) */ export interface OutboundDisplayDetail extends OutboundDetail { + groupId?: number | string; materialName?: string; + rowKey?: string; sapBatchNo?: string; vehicleCode?: string; } diff --git a/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/detail-builders.ts b/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/detail-builders.ts index df077843..0d3556be 100644 --- a/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/detail-builders.ts +++ b/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/detail-builders.ts @@ -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, diff --git a/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/form.vue b/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/form.vue index dc52fb96..2261c764 100644 --- a/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/form.vue +++ b/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/form.vue @@ -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(); -const getTitle = computed(() => { - return formData.value?.iostorinvId - ? $t('ui.actionTitle.edit', ['出入库单主表']) - : $t('ui.actionTitle.create', ['出入库单主表']); -}); +const details = ref([]); +const currentStorId = ref(); +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(); - 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({ diff --git a/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/outbound-form.test.ts b/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/outbound-form.test.ts new file mode 100644 index 00000000..502f3e99 --- /dev/null +++ b/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/outbound-form.test.ts @@ -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); + }); +}); diff --git a/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/outbound-form.ts b/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/outbound-form.ts new file mode 100644 index 00000000..a47ba6ea --- /dev/null +++ b/nl-ui/nl-ui-admin-vben/yudao-ui-admin-vben/apps/web-antdv-next/src/views/wms/iostorinv/modules/outbound-form.ts @@ -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 { + 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( + 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); +}