feat:出库单分配功能

This commit is contained in:
zhouz
2026-07-27 10:26:58 +08:00
parent ac19f00697
commit 66dca91e7d
25 changed files with 1765 additions and 286 deletions

View File

@@ -57,6 +57,11 @@ export namespace WmsIostorInvApi {
/** 出库明细页面展示模型(提交时应转换为 OutboundDetail */
export interface OutboundDisplayDetail extends OutboundDetail {
iostorinvdtlId?: string;
seqNo?: number;
billStatus?: string;
assignQty?: number;
unassignQty?: number;
materialName?: string;
rowKey?: string;
sapBatchNo?: string;
@@ -109,6 +114,39 @@ export namespace WmsIostorInvApi {
storId: string; // 仓库标识
vehicleCodes: string[]; // 箱号列表
}
export interface OutboundAllocation {
iostorinvdisId: string;
iostorinvdtlId: string;
workStatus: string;
materialCode: string;
materialName?: string;
pcsn?: string;
storagevehicleCode?: string;
planQty: number;
qtyUnitName?: string;
sectId?: string;
sectName?: string;
structCode?: string;
structName?: string;
}
export interface ManualAllocationInventory {
groupId: string;
vehicleCode: string;
materialId: string;
materialCode: string;
materialName?: string;
pcsn?: string;
availableQty: number;
qtyUnitId?: string;
qtyUnitName?: string;
sectId?: string;
sectName?: string;
structId?: string;
structCode?: string;
structName?: string;
}
}
/** 查询出入库单主表分页 */
@@ -133,6 +171,75 @@ export function getIostorInvDetails(iostorinvId: string) {
);
}
export function getOutboundAllocations(
iostorinvId: string,
iostorinvdtlId?: string,
sectId?: string,
) {
return requestClient.get<WmsIostorInvApi.OutboundAllocation[]>(
'/wms/iostor-inv/allocations',
{ params: { iostorinvId, iostorinvdtlId, sectId } },
);
}
export function allocateAll(iostorinvId: string, sectId?: string) {
return requestClient.post('/wms/iostor-inv/allocate-all', {
iostorinvId,
sectId,
});
}
export function cancelAllAllocations(iostorinvId: string, sectId?: string) {
return requestClient.post('/wms/iostor-inv/cancel-all-allocations', {
iostorinvId,
sectId,
});
}
export function autoAllocate(
iostorinvId: string,
iostorinvdtlId: string,
sectId?: string,
) {
return requestClient.post('/wms/iostor-inv/auto-allocate', {
iostorinvId,
iostorinvdtlId,
sectId,
});
}
export function autoCancelAllocation(
iostorinvId: string,
iostorinvdtlId: string,
sectId?: string,
) {
return requestClient.post('/wms/iostor-inv/auto-cancel-allocation', {
iostorinvId,
iostorinvdtlId,
sectId,
});
}
export function getManualAllocationInventory(
iostorinvId: string,
iostorinvdtlId: string,
sectId?: string,
) {
return requestClient.get<WmsIostorInvApi.ManualAllocationInventory[]>(
'/wms/iostor-inv/manual-allocation-inventory',
{ params: { iostorinvId, iostorinvdtlId, sectId } },
);
}
export function manualAllocate(data: {
groupIds: string[];
iostorinvdtlId: string;
iostorinvId: string;
sectId?: string;
}) {
return requestClient.post('/wms/iostor-inv/manual-allocate', data);
}
/** 新增出入库单主表 */
export function createIostorInv(data: WmsIostorInvApi.IostorInv) {
return requestClient.post('/wms/iostor-inv/create', data);
@@ -149,12 +256,12 @@ export function updateOutbound(data: WmsIostorInvApi.OutboundUpdateReq) {
}
/** 删除出入库单主表 */
export function deleteIostorInv(id: number) {
export function deleteIostorInv(id: string) {
return requestClient.delete(`/wms/iostor-inv/delete?id=${id}`);
}
/** 批量删除出入库单主表 */
export function deleteIostorInvList(ids: number[]) {
export function deleteIostorInvList(ids: string[]) {
return requestClient.delete(
`/wms/iostor-inv/delete-list?ids=${ids.join(',')}`,
);

View File

@@ -335,6 +335,7 @@ export function useGridColumns(): VxeTableGridOptions<WmsIostorInvApi.IostorInv>
field: 'billCode',
title: '单据编号',
minWidth: 120,
slots: { default: 'billCode' },
},
{
field: 'billType',
@@ -356,21 +357,6 @@ export function useGridColumns(): VxeTableGridOptions<WmsIostorInvApi.IostorInv>
title: '仓库',
minWidth: 120,
},
{
field: 'sourceId',
title: '来源方标识',
minWidth: 120,
},
{
field: 'sourceName',
title: '来源方名称',
minWidth: 120,
},
{
field: 'sourceType',
title: '来源方类型',
minWidth: 120,
},
{
field: 'totalWeight',
title: '总重量',
@@ -391,8 +377,18 @@ export function useGridColumns(): VxeTableGridOptions<WmsIostorInvApi.IostorInv>
},
},
{
field: 'remark',
title: '备注',
field: 'sourceId',
title: '来源方标识',
minWidth: 120,
},
{
field: 'sourceName',
title: '来源方名称',
minWidth: 120,
},
{
field: 'sourceType',
title: '来源方类型',
minWidth: 120,
},
{
@@ -437,16 +433,6 @@ export function useGridColumns(): VxeTableGridOptions<WmsIostorInvApi.IostorInv>
minWidth: 120,
formatter: 'formatDateTime',
},
{
field: 'sysdeptid',
title: '部门ID',
minWidth: 120,
},
{
field: 'syscompanyid',
title: '公司ID',
minWidth: 120,
},
{
field: 'isUpload',
title: '是否已上传',
@@ -462,6 +448,11 @@ export function useGridColumns(): VxeTableGridOptions<WmsIostorInvApi.IostorInv>
title: '回传时间',
minWidth: 120,
},
{
field: 'remark',
title: '备注',
minWidth: 120,
},
{
title: '操作',
width: 200,

View File

@@ -7,7 +7,7 @@ import { ref } from 'vue';
import { confirm, Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart, isEmpty } from '@vben/utils';
import { message } from 'antdv-next';
import { Button, message } from 'antdv-next';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
@@ -19,12 +19,22 @@ import {
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Allocation from './modules/allocation.vue';
import Detail from './modules/detail.vue';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
const [AllocationModal, allocationModalApi] = useVbenModal({
connectedComponent: Allocation,
destroyOnClose: true,
});
const [DetailModal, detailModalApi] = useVbenModal({
connectedComponent: Detail,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
@@ -41,6 +51,14 @@ function handleEdit(row: WmsIostorInvApi.IostorInv) {
formModalApi.setData(row).open();
}
function handleOutbound(row: WmsIostorInvApi.IostorInv) {
allocationModalApi.setData(row).open();
}
function handleView(row: WmsIostorInvApi.IostorInv) {
detailModalApi.setData(row).open();
}
/** 删除出入库单主表 */
async function handleDelete(row: WmsIostorInvApi.IostorInv) {
const hideLoading = message.loading({
@@ -73,7 +91,7 @@ async function handleDeleteBatch() {
}
}
const checkedIds = ref<number[]>([]);
const checkedIds = ref<string[]>([]);
function handleRowCheckboxChange({
records,
}: {
@@ -125,9 +143,16 @@ const [Grid, gridApi] = useVbenVxeGrid({
</script>
<template>
<Page auto-content-height>
<Page auto-content-height class="iostorinv-page">
<FormModal @success="handleRefresh" />
<Grid table-title="出入库单主表列表">
<AllocationModal @success="handleRefresh" />
<DetailModal />
<Grid>
<template #billCode="{ row }">
<Button type="link" class="p-0" @click="handleView(row)">
{{ row.billCode }}
</Button>
</template>
<template #toolbar-tools>
<TableAction
:actions="[
@@ -160,6 +185,13 @@ const [Grid, gridApi] = useVbenVxeGrid({
<template #actions="{ row }">
<TableAction
:actions="[
{
label: '分配',
type: 'link',
icon: ACTION_ICON.SEND,
auth: ['wms:iostor-inv:update'],
onClick: handleOutbound.bind(null, row),
},
{
label: $t('common.edit'),
type: 'link',
@@ -184,3 +216,23 @@ const [Grid, gridApi] = useVbenVxeGrid({
</Grid>
</Page>
</template>
<style scoped>
.iostorinv-page :deep(.vxe-cell--checkbox .vxe-checkbox--icon) {
color: #2563eb;
filter: drop-shadow(0 0 1px rgb(255 255 255 / 90%));
}
.iostorinv-page
:deep(.vxe-body--row.row--hover .vxe-cell--checkbox .vxe-checkbox--icon),
.iostorinv-page
:deep(.vxe-body--row.row--current .vxe-cell--checkbox .vxe-checkbox--icon) {
color: #1d4ed8;
filter: drop-shadow(0 0 2px #fff);
}
.iostorinv-page :deep(.vxe-cell--checkbox.is--checked .vxe-checkbox--icon),
.iostorinv-page :deep(.vxe-cell--checkbox.is--indeterminate .vxe-checkbox--icon) {
color: #1d4ed8;
}
</style>

View File

@@ -0,0 +1,304 @@
<script lang="ts" setup>
import type { WmsIostorInvApi } from '#/api/wms/iostorinv';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Button, Cascader, message, Space, Table } from 'antdv-next';
import {
allocateAll,
autoAllocate,
autoCancelAllocation,
cancelAllAllocations,
getIostorInvDetails,
getOutboundAllocations,
} from '#/api/wms/iostorinv';
import { getSectAttrSimpleList } from '#/api/wms/sectattr';
import ManualAllocationComponent from './manual-allocation.vue';
const emit = defineEmits(['success']);
const header = ref<WmsIostorInvApi.IostorInv>();
const details = ref<WmsIostorInvApi.OutboundDisplayDetail[]>([]);
const allocations = ref<WmsIostorInvApi.OutboundAllocation[]>([]);
const warehouseSect = ref<string[]>([]);
const warehouseSectOptions = ref<any[]>([]);
const loading = ref(false);
const selectedDetailId = ref<string>();
const sectId = computed(() => warehouseSect.value?.[1]);
const selectedDetail = computed(() =>
details.value.find(
(row) => row.iostorinvdtlId?.toString() === selectedDetailId.value,
),
);
const detailRowSelection = computed(() => ({
type: 'radio' as const,
selectedRowKeys: selectedDetailId.value ? [selectedDetailId.value] : [],
onChange: (keys: (number | string)[]) => {
selectedDetailId.value = keys[0]?.toString();
void loadSelectedAllocations();
},
}));
const detailColumns = [
{ dataIndex: 'billStatus', title: '状态', width: 90 },
{ dataIndex: 'materialCode', title: '物料编码', width: 130 },
{ dataIndex: 'materialName', title: '物料名称', width: 170 },
{ dataIndex: 'pcsn', title: '批次', width: 130 },
{ dataIndex: 'planQty', title: '重量', width: 110 },
{ dataIndex: 'assignQty', title: '已分配重量', width: 120 },
{ dataIndex: 'unassignQty', title: '未分配重量', width: 120 },
{ dataIndex: 'sourceBillCode', title: '源单编号', width: 150 },
{ dataIndex: 'remark', title: '备注', width: 160 },
];
const allocationColumns = [
{ key: 'index', title: '序号', width: 60 },
{ dataIndex: 'workStatus', title: '状态', width: 90 },
{ dataIndex: 'materialCode', title: '物料编码', width: 130 },
{ dataIndex: 'materialName', title: '物料名称', width: 170 },
{ dataIndex: 'storagevehicleCode', title: '箱号', width: 140 },
{ dataIndex: 'pcsn', title: '批次', width: 130 },
{ dataIndex: 'planQty', title: '出库重量', width: 120 },
{ dataIndex: 'structCode', title: '仓位编码', width: 130 },
{ dataIndex: 'structName', title: '仓位名称', width: 160 },
];
async function loadWarehouseSectOptions() {
if (!header.value?.storId) {
warehouseSectOptions.value = [];
return;
}
const currentHeader = header.value;
const sects = await getSectAttrSimpleList(currentHeader.storId);
warehouseSectOptions.value = [
{
label: currentHeader.storName || currentHeader.storCode || '当前仓库',
value: currentHeader.storId,
children: (sects || []).map((sect) => ({
label: sect.sectName,
value: sect.sectId,
})),
},
];
}
async function loadDetails() {
if (!header.value) return;
details.value =
(await getIostorInvDetails(header.value.iostorinvId)) || [];
}
async function loadSelectedAllocations() {
if (!header.value || !selectedDetailId.value) {
allocations.value = [];
return;
}
allocations.value =
(await getOutboundAllocations(
header.value.iostorinvId,
selectedDetailId.value,
sectId.value,
)) || [];
}
async function handleSectChange() {
await loadSelectedAllocations();
}
async function handleAllocateAll() {
if (!header.value) return;
loading.value = true;
try {
await allocateAll(header.value.iostorinvId, sectId.value);
await loadDetails();
await loadSelectedAllocations();
emit('success');
message.success('全部分配完成');
} finally {
loading.value = false;
}
}
async function handleCancelAll() {
if (!header.value) return;
loading.value = true;
try {
await cancelAllAllocations(header.value.iostorinvId, sectId.value);
await loadDetails();
await loadSelectedAllocations();
emit('success');
message.success('已取消生成状态的分配明细');
} finally {
loading.value = false;
}
}
function requireSelectedDetail() {
if (selectedDetailId.value) return true;
message.warning('请先选择一条出库明细');
return false;
}
async function handleAutoAllocate() {
if (!header.value || !requireSelectedDetail()) return;
loading.value = true;
try {
await autoAllocate(
header.value.iostorinvId,
selectedDetailId.value!,
sectId.value,
);
await loadDetails();
await loadSelectedAllocations();
emit('success');
message.success('自动分配完成');
} finally {
loading.value = false;
}
}
async function handleAutoCancel() {
if (!header.value || !requireSelectedDetail()) return;
loading.value = true;
try {
await autoCancelAllocation(
header.value.iostorinvId,
selectedDetailId.value!,
sectId.value,
);
await loadDetails();
await loadSelectedAllocations();
emit('success');
message.success('已取消当前明细的生成状态分配记录');
} finally {
loading.value = false;
}
}
const [ManualAllocation, manualAllocationApi] = useVbenModal({
connectedComponent: ManualAllocationComponent,
destroyOnClose: true,
});
function handleManualAllocate() {
if (!header.value || !requireSelectedDetail() || !selectedDetail.value) return;
if (!['10', '20'].includes(selectedDetail.value.billStatus || '')) {
message.warning('只有生成或分配中的明细允许手工分配');
return;
}
manualAllocationApi
.setData({
detail: selectedDetail.value,
header: header.value,
sectId: sectId.value,
})
.open();
}
async function handleManualSuccess() {
await loadDetails();
await loadSelectedAllocations();
emit('success');
}
function todo(name: string) {
message.info(`${name}功能待开发`);
}
function statusLabel(value?: string) {
const labels: Record<string, string> = {
'10': '生成',
'20': '分配中',
'30': '分配完',
'99': '完成',
};
return labels[value || ''] || value;
}
const [Modal, modalApi] = useVbenModal({
footer: false,
fullscreen: true,
async onOpenChange(open) {
if (!open) return;
header.value = modalApi.getData<WmsIostorInvApi.IostorInv>();
warehouseSect.value = [];
selectedDetailId.value = undefined;
allocations.value = [];
loading.value = true;
try {
await Promise.all([loadWarehouseSectOptions(), loadDetails()]);
} finally {
loading.value = false;
}
},
});
</script>
<template>
<Modal class="w-[1600px] max-w-[98vw]" title="出库分配">
<div class="px-3 pb-3">
<div class="mb-3 flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
<span class="text-base font-medium">出库明细</span>
<span>库区</span>
<Cascader
v-model:value="warehouseSect"
class="w-64"
:options="warehouseSectOptions"
placeholder="请选择仓库和库区"
@change="handleSectChange"
/>
</div>
<Space wrap>
<Button type="primary" :loading="loading" @click="handleAllocateAll">全部分配</Button>
<Button type="primary" :loading="loading" @click="handleCancelAll">全部取消</Button>
<Button :loading="loading" @click="handleAutoAllocate">自动分配</Button>
<Button :loading="loading" @click="handleAutoCancel">自动取消</Button>
<Button @click="handleManualAllocate">手工分配</Button>
<Button type="primary" ghost @click="todo('一键设置')">一键设置</Button>
</Space>
</div>
<Table
bordered
:columns="detailColumns"
:data-source="details"
:loading="loading"
:pagination="false"
:row-selection="detailRowSelection"
row-key="iostorinvdtlId"
:scroll="{ x: 1250, y: 330 }"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'billStatus'">
{{ statusLabel(record.billStatus) }}
</template>
</template>
</Table>
<div class="mb-2 mt-4 text-base font-medium">分配明细</div>
<Table
bordered
:columns="allocationColumns"
:data-source="allocations"
:loading="loading"
:pagination="false"
row-key="iostorinvdisId"
:scroll="{ x: 1320, y: 260 }"
size="small"
>
<template #bodyCell="{ column, index, record }">
<template v-if="column.key === 'index'">{{ index + 1 }}</template>
<template v-else-if="column.dataIndex === 'workStatus'">
{{ statusLabel(record.workStatus) }}
</template>
</template>
</Table>
</div>
<ManualAllocation @success="handleManualSuccess" />
</Modal>
</template>

View File

@@ -0,0 +1,137 @@
<script lang="ts" setup>
import type { WmsIostorInvApi } from '#/api/wms/iostorinv';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Descriptions, DescriptionsItem, Table } from 'antdv-next';
import {
getIostorInv,
getIostorInvDetails,
getOutboundAllocations,
} from '#/api/wms/iostorinv';
const header = ref<WmsIostorInvApi.IostorInv>();
const details = ref<WmsIostorInvApi.OutboundDisplayDetail[]>([]);
const allocations = ref<WmsIostorInvApi.OutboundAllocation[]>([]);
const loading = ref(false);
const detailColumns = [
{ key: 'index', title: '序号', width: 60 },
{ dataIndex: 'billStatus', title: '状态', width: 90 },
{ dataIndex: 'materialCode', title: '物料编码', width: 130 },
{ dataIndex: 'materialName', title: '物料名称', width: 170 },
{ dataIndex: 'pcsn', title: '批次', width: 140 },
{ dataIndex: 'planQty', title: '计划重量', width: 110 },
{ dataIndex: 'assignQty', title: '已分配重量', width: 120 },
{ dataIndex: 'unassignQty', title: '未分配重量', width: 120 },
{ dataIndex: 'sourceBillCode', title: '来源单号', width: 150 },
{ dataIndex: 'remark', title: '备注', width: 160 },
];
const allocationColumns = [
{ key: 'index', title: '序号', width: 60 },
{ dataIndex: 'workStatus', title: '状态', width: 90 },
{ dataIndex: 'materialCode', title: '物料编码', width: 130 },
{ dataIndex: 'materialName', title: '物料名称', width: 170 },
{ dataIndex: 'storagevehicleCode', title: '箱号', width: 150 },
{ dataIndex: 'pcsn', title: '批次', width: 140 },
{ dataIndex: 'planQty', title: '出库重量', width: 120 },
{ dataIndex: 'sectName', title: '库区', width: 130 },
{ dataIndex: 'structCode', title: '仓位编码', width: 130 },
{ dataIndex: 'structName', title: '仓位名称', width: 160 },
];
function statusLabel(value?: string) {
const labels: Record<string, string> = {
'10': '生成',
'20': '分配中',
'30': '分配完',
'99': '完成',
};
return labels[value || ''] || value || '-';
}
const [Modal, modalApi] = useVbenModal({
footer: false,
async onOpenChange(open) {
if (!open) return;
const row = modalApi.getData<WmsIostorInvApi.IostorInv>();
if (!row?.iostorinvId) return;
loading.value = true;
details.value = [];
allocations.value = [];
try {
const [headerResult, detailResult, allocationResult] = await Promise.all([
getIostorInv(row.iostorinvId),
getIostorInvDetails(row.iostorinvId),
getOutboundAllocations(row.iostorinvId),
]);
header.value = headerResult;
details.value = detailResult || [];
allocations.value = allocationResult || [];
} finally {
loading.value = false;
}
},
});
</script>
<template>
<Modal class="w-[1500px] max-w-[98vw]" title="出入库单详情">
<div class="px-3 pb-3">
<Descriptions bordered class="mb-4" size="small" :column="4">
<DescriptionsItem label="单据编号">{{ header?.billCode }}</DescriptionsItem>
<DescriptionsItem label="仓库">{{ header?.storName }}</DescriptionsItem>
<DescriptionsItem label="业务日期">{{ header?.bizDate }}</DescriptionsItem>
<DescriptionsItem label="单据状态">
{{ statusLabel(header?.billStatus) }}
</DescriptionsItem>
<DescriptionsItem label="总重量">{{ header?.totalWeight }}</DescriptionsItem>
<DescriptionsItem label="明细数">{{ header?.detailCount }}</DescriptionsItem>
<DescriptionsItem label="创建人">{{ header?.creatorName }}</DescriptionsItem>
<DescriptionsItem label="备注">{{ header?.remark }}</DescriptionsItem>
</Descriptions>
<div class="mb-2 text-base font-medium">单据明细</div>
<Table
bordered
:columns="detailColumns"
:data-source="details"
:loading="loading"
:pagination="false"
row-key="iostorinvdtlId"
:scroll="{ x: 1250, y: 300 }"
size="small"
>
<template #bodyCell="{ column, index, record }">
<template v-if="column.key === 'index'">{{ index + 1 }}</template>
<template v-else-if="column.dataIndex === 'billStatus'">
{{ statusLabel(record.billStatus) }}
</template>
</template>
</Table>
<div class="mb-2 mt-4 text-base font-medium">分配明细</div>
<Table
bordered
:columns="allocationColumns"
:data-source="allocations"
:loading="loading"
:pagination="false"
row-key="iostorinvdisId"
:scroll="{ x: 1350, y: 300 }"
size="small"
>
<template #bodyCell="{ column, index, record }">
<template v-if="column.key === 'index'">{{ index + 1 }}</template>
<template v-else-if="column.dataIndex === 'workStatus'">
{{ statusLabel(record.workStatus) }}
</template>
</template>
</Table>
</div>
</Modal>
</template>

View File

@@ -4,6 +4,8 @@ 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 { Button, Input, InputNumber, message, Space, Table } from 'antdv-next';
import dayjs from 'dayjs';
@@ -28,8 +30,6 @@ import {
shouldApplyEditResult,
shouldClearDetailsForWarehouseChange,
} from './outbound-form';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
const emit = defineEmits(['success']);
const formData = ref<WmsIostorInvApi.IostorInv>();
@@ -235,6 +235,7 @@ function validateDetails() {
}
const [Modal, modalApi] = useVbenModal({
fullscreen: true,
async onConfirm() {
const { valid } = await outboundFormApi.validate();
if (!valid || !validateDetails()) return;

View File

@@ -1,14 +1,12 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { WmsIostorInvApi } from '#/api/wms/iostorinv';
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'antdv-next';
import { Button, Input, message, Pagination, Space, Table } from 'antdv-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
expandAvailableInventory,
getAvailableInventoryPage,
@@ -17,112 +15,111 @@ import {
import {
collectVehicleCodes,
mapExpandedInventory,
mergeInventorySelections,
} from './detail-builders';
type InventoryGridRow = WmsIostorInvApi.AvailableInventory;
type InventoryRow = WmsIostorInvApi.AvailableInventory;
const emit = defineEmits<{
select: [rows: WmsIostorInvApi.OutboundDisplayDetail[]];
}>();
const storId = ref('');
const modalOpen = ref(false);
let openToken = 0;
let pageAbortController: AbortController | undefined;
const rows = ref<InventoryRow[]>([]);
const total = ref(0);
const pageNo = ref(1);
const pageSize = ref(10);
const loading = ref(false);
const materialCode = ref('');
const vehicleCode = ref('');
const pcsn = ref('');
const selectedKeys = ref<string[]>([]);
const selectedRows = new Map<string, InventoryRow>();
let requestSequence = 0;
let requestController: AbortController | undefined;
function abortPageRequest() {
pageAbortController?.abort();
pageAbortController = undefined;
const columns = [
{ dataIndex: 'materialCode', title: '物料编码', width: 140 },
{ dataIndex: 'materialName', title: '物料名称', width: 180 },
{ dataIndex: 'vehicleCode', title: '箱号', width: 160 },
{ dataIndex: 'pcsn', title: '子卷号', width: 150 },
{ dataIndex: 'availableQty', title: '可用重量', width: 120 },
{ dataIndex: 'qtyUnitName', title: '单位', width: 90 },
];
async function loadInventory() {
if (!storId.value) {
rows.value = [];
total.value = 0;
return;
}
const sequence = ++requestSequence;
requestController?.abort();
const controller = new AbortController();
requestController = controller;
loading.value = true;
try {
const result = await getAvailableInventoryPage(
{
materialCode: materialCode.value || undefined,
pageNo: pageNo.value,
pageSize: pageSize.value,
pcsn: pcsn.value || undefined,
storId: storId.value,
vehicleCode: vehicleCode.value || undefined,
},
controller.signal,
);
if (sequence !== requestSequence) return;
rows.value = result?.list || [];
total.value = result?.total || 0;
} catch (error) {
if ((error as Error).name !== 'AbortError') throw error;
} finally {
if (sequence === requestSequence) loading.value = false;
}
}
async function clearGridState(token: number) {
await gridApi.grid.clearCheckboxRow();
if (token !== openToken) return;
await gridApi.grid.clearCheckboxReserve();
if (token !== openToken) return;
await gridApi.grid.loadData([]);
if (token !== openToken) return;
async function handleSearch() {
pageNo.value = 1;
await loadInventory();
}
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: '子卷号',
},
],
async function handlePageChange(current: number, size: number) {
pageNo.value = current;
pageSize.value = size;
await loadInventory();
}
const rowSelection = computed(() => ({
preserveSelectedRowKeys: true,
selectedRowKeys: selectedKeys.value,
onSelect(record: InventoryRow, selected: boolean) {
const key = record.groupId.toString();
if (selected) {
selectedRows.set(key, record);
if (!selectedKeys.value.includes(key)) selectedKeys.value.push(key);
} else {
selectedRows.delete(key);
selectedKeys.value = selectedKeys.value.filter((item) => item !== key);
}
},
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,
checkboxConfig: { highlight: true, reserve: true },
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
const requestToken = openToken;
const requestStorId = storId.value;
if (!modalOpen.value || !requestStorId) {
throw new DOMException('库存选择弹窗已关闭', 'AbortError');
}
abortPageRequest();
const controller = new AbortController();
pageAbortController = controller;
const result = await getAvailableInventoryPage({
...formValues,
pageNo: page.currentPage,
pageSize: page.pageSize,
storId: requestStorId,
}, controller.signal);
if (
requestToken !== openToken ||
!modalOpen.value ||
requestStorId !== storId.value
) {
throw new DOMException('库存分页请求已失效', 'AbortError');
}
if (pageAbortController === controller) pageAbortController = undefined;
return result;
},
},
},
rowConfig: { isHover: true, keyField: 'groupId' },
toolbarConfig: { refresh: true, search: true },
} as VxeTableGridOptions<InventoryGridRow>,
});
onSelectAll(selected: boolean, _selected: InventoryRow[], changed: InventoryRow[]) {
changed.forEach((record) => {
const key = record.groupId.toString();
if (selected) {
selectedRows.set(key, record);
if (!selectedKeys.value.includes(key)) selectedKeys.value.push(key);
} else {
selectedRows.delete(key);
selectedKeys.value = selectedKeys.value.filter((item) => item !== key);
}
});
},
}));
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const confirmToken = openToken;
const confirmStorId = storId.value;
if (!modalOpen.value || !confirmStorId) return;
const selected = mergeInventorySelections(
gridApi.grid.getCheckboxRecords() as InventoryGridRow[],
gridApi.grid.getCheckboxReserveRecords() as InventoryGridRow[],
);
const selected = [...selectedRows.values()];
if (selected.length === 0) {
message.warning('请至少选择一条库存信息');
return;
@@ -137,16 +134,9 @@ const [Modal, modalApi] = useVbenModal({
modalApi.lock();
try {
const expanded = await expandAvailableInventory({
storId: confirmStorId,
storId: storId.value,
vehicleCodes,
});
if (
confirmToken !== openToken ||
!modalOpen.value ||
confirmStorId !== storId.value
) {
return;
}
emit('select', mapExpandedInventory(expanded));
await modalApi.close();
} finally {
@@ -154,32 +144,84 @@ const [Modal, modalApi] = useVbenModal({
}
},
async onOpenChange(open) {
const token = ++openToken;
abortPageRequest();
requestController?.abort();
if (!open) {
modalOpen.value = false;
storId.value = '';
await clearGridState(token);
++requestSequence;
return;
}
modalOpen.value = true;
storId.value = '';
await clearGridState(token);
if (token !== openToken || !modalOpen.value) return;
const data = modalApi.getData<{ storId: string }>();
if (!data?.storId) {
message.warning('请先选择仓库');
await modalApi.close();
return;
}
storId.value = data.storId;
await gridApi.query();
storId.value = data.storId.toString();
materialCode.value = '';
vehicleCode.value = '';
pcsn.value = '';
pageNo.value = 1;
pageSize.value = 10;
selectedKeys.value = [];
selectedRows.clear();
await loadInventory();
},
});
</script>
<template>
<Modal class="w-[1100px]" title="选择可用库存">
<Grid />
<Modal class="w-[1200px] max-w-[96vw]" title="选择可用库存">
<div class="px-3 pb-3">
<Space class="mb-3" wrap>
<Input
v-model:value="materialCode"
allow-clear
class="w-48"
placeholder="物料编码"
@press-enter="handleSearch"
/>
<Input
v-model:value="vehicleCode"
allow-clear
class="w-48"
placeholder="箱号"
@press-enter="handleSearch"
/>
<Input
v-model:value="pcsn"
allow-clear
class="w-48"
placeholder="子卷号"
@press-enter="handleSearch"
/>
<Button :loading="loading" type="primary" @click="handleSearch">
查询
</Button>
</Space>
<Table
bordered
:columns="columns"
:data-source="rows"
:loading="loading"
:pagination="false"
:row-selection="rowSelection"
row-key="groupId"
:scroll="{ x: 950, y: 480 }"
size="small"
/>
<div class="mt-3 flex justify-end">
<Pagination
:current="pageNo"
:page-size="pageSize"
:page-size-options="['10', '20', '50']"
show-size-changer
:show-total="(value: number) => `${value}`"
:total="total"
@change="handlePageChange"
@show-size-change="handlePageChange"
/>
</div>
</div>
</Modal>
</template>

View File

@@ -0,0 +1,173 @@
<script lang="ts" setup>
import type { WmsIostorInvApi } from '#/api/wms/iostorinv';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Input, message, Space, Table } from 'antdv-next';
import {
getManualAllocationInventory,
manualAllocate,
} from '#/api/wms/iostorinv';
type OpenData = {
detail: WmsIostorInvApi.OutboundDisplayDetail;
header: WmsIostorInvApi.IostorInv;
sectId?: string;
};
const emit = defineEmits(['success']);
const openData = ref<OpenData>();
const rows = ref<WmsIostorInvApi.ManualAllocationInventory[]>([]);
const selectedKeys = ref<string[]>([]);
const keyword = ref('');
const pcsn = ref('');
const loading = ref(false);
const filteredRows = computed(() => {
const keywordValue = keyword.value.trim().toLowerCase();
const pcsnValue = pcsn.value.trim().toLowerCase();
return rows.value.filter((row) => {
const matchesKeyword =
!keywordValue ||
[row.vehicleCode, row.materialCode, row.materialName, row.structCode]
.some((value) => value?.toLowerCase().includes(keywordValue));
const matchesPcsn =
!pcsnValue || row.pcsn?.toLowerCase().includes(pcsnValue);
return matchesKeyword && matchesPcsn;
});
});
const selectedWeight = computed(() => {
const selected = new Set(selectedKeys.value);
return rows.value
.filter((row) => selected.has(row.groupId.toString()))
.reduce((sum, row) => sum + Number(row.availableQty || 0), 0);
});
const rowSelection = computed(() => ({
selectedRowKeys: selectedKeys.value,
onSelect: (
record: WmsIostorInvApi.ManualAllocationInventory,
selected: boolean,
) => {
const vehicleKeys = rows.value
.filter((row) => row.vehicleCode === record.vehicleCode)
.map((row) => row.groupId.toString());
const next = new Set(selectedKeys.value);
vehicleKeys.forEach((key) => (selected ? next.add(key) : next.delete(key)));
selectedKeys.value = [...next];
},
onSelectAll: (selected: boolean) => {
const visibleKeys = filteredRows.value.map((row) => row.groupId.toString());
const next = new Set(selectedKeys.value);
visibleKeys.forEach((key) => (selected ? next.add(key) : next.delete(key)));
selectedKeys.value = [...next];
},
}));
const columns = [
{ key: 'index', title: '序号', width: 65 },
{ dataIndex: 'sectName', title: '库区', width: 120 },
{ dataIndex: 'structCode', title: '仓位', width: 130 },
{ dataIndex: 'vehicleCode', title: '箱号', width: 150 },
{ dataIndex: 'materialName', title: '物料名称', width: 180 },
{ dataIndex: 'pcsn', title: '子卷号', width: 150 },
{ dataIndex: 'availableQty', title: '可出重量', width: 120 },
{ dataIndex: 'qtyUnitName', title: '单位', width: 90 },
];
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
if (!openData.value || selectedKeys.value.length === 0) {
message.warning('请至少选择一箱库存');
return;
}
modalApi.lock();
try {
await manualAllocate({
groupIds: selectedKeys.value,
iostorinvId: openData.value.header.iostorinvId,
iostorinvdtlId: openData.value.detail.iostorinvdtlId!,
sectId: openData.value.sectId,
});
emit('success');
message.success('手工分配保存成功');
await modalApi.close();
} finally {
modalApi.unlock();
}
},
async onOpenChange(open) {
if (!open) return;
openData.value = modalApi.getData<OpenData>();
selectedKeys.value = [];
keyword.value = '';
pcsn.value = '';
loading.value = true;
try {
rows.value =
(await getManualAllocationInventory(
openData.value.header.iostorinvId,
openData.value.detail.iostorinvdtlId!,
openData.value.sectId,
)) || [];
} finally {
loading.value = false;
}
},
});
</script>
<template>
<Modal
class="w-[1500px] max-w-[98vw]"
confirm-text="保存"
title="出库手工分配"
>
<div class="px-3 pb-3">
<Space class="mb-3" wrap>
<span class="text-base font-medium">可分配库存</span>
<span>待分配</span>
<Input
class="w-32"
disabled
:value="openData?.detail.unassignQty ?? 0"
/>
<span>已选择</span>
<Input class="w-32" disabled :value="selectedWeight" />
<span>关键字</span>
<Input
v-model:value="keyword"
allow-clear
class="w-56"
placeholder="箱号/物料/仓位"
/>
<span>子卷号</span>
<Input
v-model:value="pcsn"
allow-clear
class="w-48"
placeholder="请输入子卷号"
/>
</Space>
<Table
bordered
:columns="columns"
:data-source="filteredRows"
:loading="loading"
:pagination="false"
:row-selection="rowSelection"
row-key="groupId"
:scroll="{ x: 1100, y: 520 }"
size="small"
>
<template #bodyCell="{ column, index }">
<template v-if="column.key === 'index'">{{ index + 1 }}</template>
</template>
</Table>
</div>
</Modal>
</template>