feat: 按参考布局实现出库单新增页
This commit is contained in:
@@ -51,7 +51,9 @@ export namespace WmsIostorInvApi {
|
||||
|
||||
/** 出库明细页面展示模型(提交时应转换为 OutboundDetail) */
|
||||
export interface OutboundDisplayDetail extends OutboundDetail {
|
||||
groupId?: number | string;
|
||||
materialName?: string;
|
||||
rowKey?: string;
|
||||
sapBatchNo?: string;
|
||||
vehicleCode?: string;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user