feat: 增加出库库存选择和手工汇总弹窗

This commit is contained in:
zhouz
2026-07-22 15:15:47 +08:00
parent 36d0be1500
commit d4a28480f1
5 changed files with 418 additions and 0 deletions

View File

@@ -49,6 +49,13 @@ export namespace WmsIostorInvApi {
remark?: string; // 备注
}
/** 出库明细页面展示模型(提交时应转换为 OutboundDetail */
export interface OutboundDisplayDetail extends OutboundDetail {
materialName?: string;
sapBatchNo?: string;
vehicleCode?: string;
}
/** 出库单新增请求 */
export interface OutboundCreateReq {
billType: string; // 单据类型

View File

@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest';
import {
buildManualDetail,
collectVehicleCodes,
mapExpandedInventory,
} from './detail-builders';
describe('mapExpandedInventory', () => {
it('逐条映射展开后的全部子卷,且不把 pcsn 当作 SAP 批次', () => {
const rows = mapExpandedInventory([
{
availableQty: 12.5,
extCode: 'OUT-1',
extDtlCode: 'DTL-1',
extType: 'SALE',
materialCode: 'M-1',
materialId: '1',
materialName: '物料一',
pcsn: 'P-1',
qtyUnitId: 'kg',
qtyUnitName: '千克',
storId: 'S-1',
vehicleCode: 'BOX-1',
},
{
availableQty: 8,
materialCode: 'M-1',
materialId: '1',
pcsn: 'P-2',
storId: 'S-1',
vehicleCode: 'BOX-1',
},
]);
expect(rows).toHaveLength(2);
expect(rows[0]).toMatchObject({
pcsn: 'P-1',
planQty: 12.5,
sapBatchNo: undefined,
sourceBillCode: 'OUT-1',
sourceBillType: 'SALE',
sourceBilldtlId: 'DTL-1',
});
expect(rows[1]?.pcsn).toBe('P-2');
});
});
describe('buildManualDetail', () => {
it('构造手工汇总行并显式清空箱号、子卷号和 SAP 批次', () => {
expect(
buildManualDetail(
{
baseUnitId: 7,
materialCode: 'M-1',
materialId: 1,
materialName: '物料一',
},
20,
'备注',
),
).toMatchObject({
materialCode: 'M-1',
materialId: '1',
materialName: '物料一',
pcsn: undefined,
planQty: 20,
qtyUnitId: '7',
qtyUnitName: undefined,
remark: '备注',
sapBatchNo: undefined,
vehicleCode: undefined,
});
});
});
describe('collectVehicleCodes', () => {
it('去重并过滤空箱号', () => {
expect(
collectVehicleCodes([
{ vehicleCode: ' BOX-1 ' },
{ vehicleCode: '' },
{ vehicleCode: 'BOX-1' },
{ vehicleCode: 'BOX-2' },
]),
).toEqual(['BOX-1', 'BOX-2']);
});
it('允许 500 个箱号并拒绝 501 个', () => {
expect(
collectVehicleCodes(
Array.from({ length: 500 }, (_, index) => ({
vehicleCode: `BOX-${index}`,
})),
),
).toHaveLength(500);
expect(() =>
collectVehicleCodes(
Array.from({ length: 501 }, (_, index) => ({
vehicleCode: `BOX-${index}`,
})),
),
).toThrow('箱号数量不能超过 500 个');
});
it('拒绝没有有效箱号的选择', () => {
expect(() => collectVehicleCodes([{ vehicleCode: ' ' }])).toThrow(
'请选择包含有效箱号的库存',
);
});
});

View File

@@ -0,0 +1,61 @@
import type { WmsIostorInvApi } from '#/api/wms/iostorinv';
/** 物料选择弹窗返回行中,手工明细所需的真实字段 */
export interface MaterialSelection {
baseUnitId?: number;
materialCode?: string;
materialId: number | string;
materialName?: string;
}
export function mapExpandedInventory(
inventory: WmsIostorInvApi.AvailableInventory[],
): WmsIostorInvApi.OutboundDisplayDetail[] {
return inventory.map((row) => ({
materialCode: row.materialCode,
materialId: row.materialId,
materialName: row.materialName ?? undefined,
pcsn: row.pcsn ?? undefined,
planQty: row.availableQty,
qtyUnitId: row.qtyUnitId ?? undefined,
qtyUnitName: row.qtyUnitName ?? undefined,
sapBatchNo: undefined,
sourceBillCode: row.extCode ?? undefined,
sourceBillType: row.extType ?? undefined,
sourceBilldtlId: row.extDtlCode ?? undefined,
vehicleCode: row.vehicleCode,
}));
}
export function collectVehicleCodes(rows: Array<{ vehicleCode?: string }>) {
const vehicleCodes = [
...new Set(rows.map(({ vehicleCode }) => vehicleCode?.trim()).filter(Boolean)),
] as string[];
if (vehicleCodes.length === 0) {
throw new Error('请选择包含有效箱号的库存');
}
if (vehicleCodes.length > 500) {
throw new Error('箱号数量不能超过 500 个');
}
return vehicleCodes;
}
export function buildManualDetail(
material: MaterialSelection,
planQty: number,
remark?: string,
): WmsIostorInvApi.OutboundDisplayDetail {
return {
materialCode: material.materialCode ?? '',
materialId: String(material.materialId),
materialName: material.materialName,
pcsn: undefined,
planQty,
qtyUnitId:
material.baseUnitId === undefined ? undefined : String(material.baseUnitId),
qtyUnitName: undefined,
remark: remark || undefined,
sapBatchNo: undefined,
vehicleCode: undefined,
};
}

View File

@@ -0,0 +1,129 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { WmsIostorInvApi } from '#/api/wms/iostorinv';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'antdv-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
expandAvailableInventory,
getAvailableInventoryPage,
} from '#/api/wms/iostorinv';
import { collectVehicleCodes, mapExpandedInventory } from './detail-builders';
interface InventoryGridRow extends WmsIostorInvApi.AvailableInventory {
selectionKey: string;
}
const emit = defineEmits<{
select: [rows: WmsIostorInvApi.OutboundDisplayDetail[]];
}>();
const storId = ref('');
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: [
{
component: 'Input',
componentProps: { allowClear: true, placeholder: '请输入物料编码' },
fieldName: 'materialCode',
label: '物料编码',
},
{
component: 'Input',
componentProps: { allowClear: true, placeholder: '请输入箱号' },
fieldName: 'vehicleCode',
label: '箱号',
},
{
component: 'Input',
componentProps: { allowClear: true, placeholder: '请输入子卷号' },
fieldName: 'pcsn',
label: '子卷号',
},
],
},
gridOptions: {
columns: [
{ type: 'checkbox', width: 44 },
{ field: 'materialCode', minWidth: 130, title: '物料编码' },
{ field: 'materialName', minWidth: 160, title: '物料名称' },
{ field: 'vehicleCode', minWidth: 130, title: '箱号' },
{ field: 'pcsn', minWidth: 130, title: '子卷号' },
{ field: 'availableQty', minWidth: 110, title: '可用重量' },
{ field: 'qtyUnitName', minWidth: 90, title: '单位' },
],
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
const result = await getAvailableInventoryPage({
...formValues,
pageNo: page.currentPage,
pageSize: page.pageSize,
storId: storId.value,
});
return {
...result,
list: result.list.map((row) => ({
...row,
selectionKey: `${row.vehicleCode}\u0000${row.pcsn ?? ''}\u0000${row.materialId}`,
})),
};
},
},
},
rowConfig: { isHover: true, keyField: 'selectionKey' },
toolbarConfig: { refresh: true, search: true },
} as VxeTableGridOptions<InventoryGridRow>,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const selected = gridApi.grid.getCheckboxRecords() as InventoryGridRow[];
if (selected.length === 0) {
message.warning('请至少选择一条库存信息');
return;
}
let vehicleCodes: string[];
try {
vehicleCodes = collectVehicleCodes(selected);
} catch (error) {
message.warning((error as Error).message);
return;
}
modalApi.lock();
try {
const expanded = await expandAvailableInventory({
storId: storId.value,
vehicleCodes,
});
emit('select', mapExpandedInventory(expanded));
await modalApi.close();
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen) {
if (!isOpen) {
storId.value = '';
return;
}
const data = modalApi.getData<{ storId: string }>();
storId.value = data?.storId ?? '';
await gridApi.query();
},
});
</script>
<template>
<Modal class="w-[1100px]" title="选择可用库存">
<Grid />
</Modal>
</template>

View File

@@ -0,0 +1,110 @@
<script lang="ts" setup>
import type { WmsIostorInvApi } from '#/api/wms/iostorinv';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Button, message } from 'antdv-next';
import { useVbenForm, z } from '#/adapter/form';
import MaterialSelectModalComponent from '../../../base/materialbase/components/MaterialSelectModal.vue';
import { buildManualDetail, type MaterialSelection } from './detail-builders';
const emit = defineEmits<{
select: [rows: WmsIostorInvApi.OutboundDisplayDetail[]];
}>();
const selectedMaterial = ref<MaterialSelection>();
const [MaterialSelectModal, materialSelectModalApi] = useVbenModal({
connectedComponent: MaterialSelectModalComponent,
});
function openMaterialSelect() {
materialSelectModalApi.setData({ multiple: false }).open();
}
async function handleMaterialSelect(rows: MaterialSelection[]) {
selectedMaterial.value = rows[0];
if (selectedMaterial.value) {
await formApi.setValues({
materialCode: selectedMaterial.value.materialCode,
materialName: selectedMaterial.value.materialName,
});
}
}
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: { class: 'w-full' },
formItemClass: 'col-span-2',
labelWidth: 100,
},
layout: 'horizontal',
schema: [
{
component: 'Input',
componentProps: { readonly: true, placeholder: '请点击右侧按钮选择' },
fieldName: 'materialCode',
label: '物料编码',
rules: 'required',
},
{
component: 'Input',
componentProps: { readonly: true },
fieldName: 'materialName',
label: '物料名称',
},
{
component: 'InputNumber',
componentProps: { min: 0, placeholder: '请输入总出库重量' },
fieldName: 'planQty',
label: '总出库重量',
rules: z.number().positive('总出库重量必须大于 0'),
},
{
component: 'Textarea',
componentProps: { allowClear: true, placeholder: '请输入备注' },
fieldName: 'remark',
label: '备注',
},
],
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) return;
if (!selectedMaterial.value) {
message.warning('请选择物料');
return;
}
const values = await formApi.getValues();
emit('select', [
buildManualDetail(
selectedMaterial.value,
Number(values.planQty),
values.remark,
),
]);
await modalApi.close();
},
async onOpenChange(isOpen) {
if (!isOpen) return;
selectedMaterial.value = undefined;
await formApi.resetForm();
},
});
</script>
<template>
<Modal title="手工汇总出库">
<div class="mx-4 mb-4 text-right">
<Button type="primary" @click="openMaterialSelect">选择物料</Button>
</div>
<Form class="mx-4" />
<MaterialSelectModal @select="handleMaterialSelect" />
</Modal>
</template>