feat:出库单一键设置功能及移库单CRUD

This commit is contained in:
zhouz
2026-07-28 16:21:22 +08:00
parent 9e43129c37
commit 04ffd865a3
56 changed files with 2638 additions and 23 deletions

View File

@@ -240,6 +240,13 @@ export function manualAllocate(data: {
return requestClient.post('/wms/iostor-inv/manual-allocate', data);
}
export function oneClickSet(iostorinvId: string, iostorinvdtlId: string) {
return requestClient.post('/wms/iostor-inv/one-click-set', {
iostorinvId,
iostorinvdtlId,
});
}
/** 新增出入库单主表 */
export function createIostorInv(data: WmsIostorInvApi.IostorInv) {
return requestClient.post('/wms/iostor-inv/create', data);

View File

@@ -0,0 +1,103 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace WmsMoveInvApi {
export interface Detail {
moveinvdtlId?: string;
storagevehicleCode: string;
turnoutSectCode?: string;
turnoutStructCode: string;
turninSectCode?: string;
turninStructCode: string;
materialCode?: string;
pcsn?: string;
qtyUnitId?: string;
qtyUnitName?: string;
qty: number;
sourceBillCode?: string;
remark?: string;
}
export interface MoveInv {
moveinvId?: string;
billCode?: string;
billType: string;
bizDate: string;
storId: string;
storName?: string;
totalQty?: number;
detailCount?: number;
billStatus?: string;
remark?: string;
creatorName?: string;
createTime?: string;
details: Detail[];
}
export interface Inventory {
structId: string;
structCode: string;
structName: string;
sectId: string;
sectCode: string;
sectName: string;
storId: string;
storagevehicleCode: string;
layerNum: number;
materialCode?: string;
pcsn?: string;
qtyUnitId?: string;
qtyUnitName?: string;
qty: number;
sourceBillCode?: string;
}
export interface Struct {
structId: string;
structCode: string;
structName: string;
sectCode: string;
sectName: string;
layerNum: number;
}
}
export function getMoveInvPage(params: PageParam) {
return requestClient.get<PageResult<WmsMoveInvApi.MoveInv>>(
'/wms/move-inv/page',
{ params },
);
}
export function getMoveInv(id: string) {
return requestClient.get<WmsMoveInvApi.MoveInv>('/wms/move-inv/get', {
params: { id },
});
}
export function createMoveInv(data: WmsMoveInvApi.MoveInv) {
return requestClient.post('/wms/move-inv/create', data);
}
export function updateMoveInv(data: WmsMoveInvApi.MoveInv) {
return requestClient.put('/wms/move-inv/update', data);
}
export function deleteMoveInv(id: string) {
return requestClient.delete('/wms/move-inv/delete', { params: { id } });
}
export function getMoveAvailableInventory(storId: string, sectId?: string) {
return requestClient.get<WmsMoveInvApi.Inventory[]>(
'/wms/move-inv/availableInventory',
{ params: { storId, sectId } },
);
}
export function getMoveTargetStructs(sourceStructCode: string) {
return requestClient.get<WmsMoveInvApi.Struct[]>(
'/wms/move-inv/availableTargetStructs',
{ params: { sourceStructCode } },
);
}

View File

@@ -14,6 +14,7 @@ import {
cancelAllAllocations,
getIostorInvDetails,
getOutboundAllocations,
oneClickSet,
} from '#/api/wms/iostorinv';
import { getSectAttrSimpleList } from '#/api/wms/sectattr';
@@ -204,8 +205,18 @@ async function handleManualSuccess() {
emit('success');
}
function todo(name: string) {
message.info(`${name}功能待开发`);
async function handleOneClickSet() {
if (!header.value || !requireSelectedDetail()) return;
loading.value = true;
try {
await oneClickSet(header.value.iostorinvId, selectedDetailId.value!);
await loadDetails();
await loadSelectedAllocations();
emit('success');
message.success('一键设置并下发任务完成');
} finally {
loading.value = false;
}
}
function statusLabel(value?: string) {
@@ -258,7 +269,6 @@ const [Modal, modalApi] = useVbenModal({
<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>
@@ -280,7 +290,12 @@ const [Modal, modalApi] = useVbenModal({
</template>
</Table>
<div class="mb-2 mt-4 text-base font-medium">分配明细</div>
<div class="mb-2 mt-4 flex items-center justify-between">
<span class="text-base font-medium">分配明细</span>
<Button type="primary" ghost :loading="loading" @click="handleOneClickSet">
一键设置
</Button>
</div>
<Table
bordered
:columns="allocationColumns"

View File

@@ -0,0 +1,65 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { getBsrealStorAttrSimpleList } from '#/api/wms/bsrealstorattr';
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
componentProps: { allowClear: true, placeholder: '请输入单据编码' },
fieldName: 'billCode',
label: '单据编码',
},
{
component: 'ApiSelect',
componentProps: {
allowClear: true,
api: getBsrealStorAttrSimpleList,
labelField: 'storName',
valueField: 'storId',
},
fieldName: 'storId',
label: '仓库',
},
{
component: 'Select',
componentProps: {
allowClear: true,
options: getDictOptions(DICT_TYPE.WMS_IO_BILL_STATUS),
},
fieldName: 'billStatus',
label: '单据状态',
},
];
}
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{ type: 'checkbox', width: 50 },
{ field: 'billCode', slots: { default: 'billCode' }, title: '单据编码', width: 160 },
{
field: 'billType',
title: '单据类型',
cellRender: { name: 'CellDict', props: { type: DICT_TYPE.WMS_ORDER_TYPE } },
width: 110,
},
{ field: 'bizDate', title: '业务日期', width: 120 },
{ field: 'storName', title: '仓库', width: 140 },
{ field: 'totalQty', title: '总数量', width: 110 },
{ field: 'detailCount', title: '明细数', width: 90 },
{
field: 'billStatus',
title: '单据状态',
cellRender: { name: 'CellDict', props: { type: DICT_TYPE.WMS_IO_BILL_STATUS } },
width: 100,
},
{ field: 'creatorName', title: '创建人', width: 100 },
{ field: 'createTime', formatter: 'formatDateTime', title: '创建时间', width: 170 },
{ field: 'remark', title: '备注', minWidth: 160 },
{ field: 'actions', fixed: 'right', slots: { default: 'actions' }, title: '操作', width: 170 },
];
}

View File

@@ -0,0 +1,127 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { WmsMoveInvApi } from '#/api/wms/moveinv';
import { Page, useVbenModal } from '@vben/common-ui';
import { Button, message } from 'antdv-next';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteMoveInv, getMoveInvPage } from '#/api/wms/moveinv';
import { useGridColumns, useGridFormSchema } from './data';
import Detail from './modules/detail.vue';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
const [DetailModal, detailModalApi] = useVbenModal({
connectedComponent: Detail,
destroyOnClose: true,
});
function refresh() {
gridApi.query();
}
function create() {
formModalApi.setData(null).open();
}
function edit(row: WmsMoveInvApi.MoveInv) {
if (row.billStatus !== '10') {
message.warning('只有生成状态的移库单允许修改');
return;
}
formModalApi.setData(row).open();
}
function view(row: WmsMoveInvApi.MoveInv) {
detailModalApi.setData(row).open();
}
async function remove(row: WmsMoveInvApi.MoveInv) {
if (row.billStatus !== '10') {
message.warning('只有生成状态的移库单允许删除');
return;
}
await deleteMoveInv(row.moveinvId!);
message.success('删除成功');
refresh();
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: { schema: useGridFormSchema() },
gridOptions: {
columns: useGridColumns(),
height: 'auto',
proxyConfig: {
ajax: {
query: async ({ page }, values) =>
await getMoveInvPage({
...values,
pageNo: page.currentPage,
pageSize: page.pageSize,
}),
},
},
rowConfig: { isHover: true, keyField: 'moveinvId' },
toolbarConfig: { refresh: true, search: true },
} as VxeTableGridOptions<WmsMoveInvApi.MoveInv>,
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="refresh" />
<DetailModal />
<Grid>
<template #billCode="{ row }">
<Button class="p-0" type="link" @click="view(row)">
{{ row.billCode }}
</Button>
</template>
<template #toolbar-tools>
<TableAction
:actions="[
{
label: '新增移库单',
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['wms:movement-order:create'],
onClick: create,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: '修改',
type: 'link',
icon: ACTION_ICON.EDIT,
disabled: row.billStatus !== '10',
auth: ['wms:movement-order:update'],
onClick: edit.bind(null, row),
},
{
label: '删除',
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
disabled: row.billStatus !== '10',
auth: ['wms:movement-order:delete'],
popConfirm: {
title: `确认删除移库单 ${row.billCode}`,
confirm: remove.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,57 @@
<script lang="ts" setup>
import type { WmsMoveInvApi } from '#/api/wms/moveinv';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Descriptions, DescriptionsItem, Table } from 'antdv-next';
import { getMoveInv } from '#/api/wms/moveinv';
const data = ref<WmsMoveInvApi.MoveInv>();
const columns = [
{ dataIndex: 'storagevehicleCode', title: '载具编码' },
{ dataIndex: 'turnoutSectCode', title: '来源库区' },
{ dataIndex: 'turnoutStructCode', title: '来源仓位' },
{ dataIndex: 'turninSectCode', title: '目标库区' },
{ dataIndex: 'turninStructCode', title: '目标仓位' },
{ dataIndex: 'materialCode', title: '物料编码' },
{ dataIndex: 'pcsn', title: '批次' },
{ dataIndex: 'qty', title: '数量' },
];
const [Modal, modalApi] = useVbenModal({
fullscreen: true,
footer: false,
async onOpenChange(open) {
if (!open) return;
const row = modalApi.getData<WmsMoveInvApi.MoveInv>();
if (row?.moveinvId) data.value = await getMoveInv(row.moveinvId);
},
});
</script>
<template>
<Modal title="移库单详情">
<div class="p-4">
<Descriptions bordered :column="3">
<DescriptionsItem label="单据编码">{{ data?.billCode }}</DescriptionsItem>
<DescriptionsItem label="业务日期">{{ data?.bizDate }}</DescriptionsItem>
<DescriptionsItem label="仓库">{{ data?.storName || data?.storId }}</DescriptionsItem>
<DescriptionsItem label="总数量">{{ data?.totalQty }}</DescriptionsItem>
<DescriptionsItem label="明细数">{{ data?.detailCount }}</DescriptionsItem>
<DescriptionsItem label="创建人">{{ data?.creatorName }}</DescriptionsItem>
<DescriptionsItem label="备注" :span="3">{{ data?.remark }}</DescriptionsItem>
</Descriptions>
<Table
class="mt-4"
:columns="columns"
:data-source="data?.details || []"
:pagination="false"
row-key="moveinvdtlId"
:scroll="{ x: 1100 }"
/>
</div>
</Modal>
</template>

View File

@@ -0,0 +1,296 @@
<script lang="ts" setup>
import type { WmsMoveInvApi } from '#/api/wms/moveinv';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { Button, message, Modal, Select, Table } from 'antdv-next';
import dayjs from 'dayjs';
import { useVbenForm } from '#/adapter/form';
import { getBsrealStorAttrSimpleList } from '#/api/wms/bsrealstorattr';
import {
createMoveInv,
getMoveAvailableInventory,
getMoveInv,
getMoveTargetStructs,
updateMoveInv,
} from '#/api/wms/moveinv';
const emit = defineEmits(['success']);
const editData = ref<WmsMoveInvApi.MoveInv>();
const details = ref<WmsMoveInvApi.Detail[]>([]);
const inventoryRows = ref<WmsMoveInvApi.Inventory[]>([]);
const selectedInventoryKeys = ref<string[]>([]);
const inventoryOpen = ref(false);
const targetOptions = ref<Record<string, WmsMoveInvApi.Struct[]>>({});
const isEdit = computed(() => Boolean(editData.value?.moveinvId));
const title = computed(() => (isEdit.value ? '修改移库单' : '新增移库单'));
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: { class: 'w-full' },
formItemClass: 'col-span-1',
labelWidth: 88,
},
layout: 'horizontal',
schema: [
{
component: 'Input',
componentProps: { disabled: true },
defaultValue: '保存时自动生成',
fieldName: 'billCode',
label: '单据编码',
},
{
component: 'ApiSelect',
componentProps: {
api: getBsrealStorAttrSimpleList,
labelField: 'storName',
placeholder: '请选择仓库',
valueField: 'storId',
},
fieldName: 'storId',
label: '仓库',
rules: 'required',
},
{
component: 'Select',
componentProps: {
disabled: true,
options: getDictOptions(DICT_TYPE.WMS_ORDER_TYPE),
},
defaultValue: '3',
fieldName: 'billType',
label: '单据类型',
rules: 'required',
},
{
component: 'Select',
componentProps: {
disabled: true,
options: getDictOptions(DICT_TYPE.WMS_IO_BILL_STATUS),
},
defaultValue: '10',
fieldName: 'billStatus',
label: '单据状态',
},
{
component: 'DatePicker',
componentProps: { format: 'YYYY-MM-DD', valueFormat: 'YYYY-MM-DD' },
defaultValue: dayjs().format('YYYY-MM-DD'),
fieldName: 'bizDate',
label: '业务日期',
rules: 'required',
},
{
component: 'Textarea',
componentProps: { rows: 2 },
fieldName: 'remark',
label: '备注',
},
],
showDefaultActions: false,
wrapperClass: 'grid grid-cols-1 gap-x-4 md:grid-cols-2 xl:grid-cols-3',
});
const detailColumns = [
{ key: 'index', title: '序号', width: 60 },
{ dataIndex: 'storagevehicleCode', title: '载具编码', width: 150 },
{ dataIndex: 'turnoutSectCode', title: '来源库区', width: 120 },
{ dataIndex: 'turnoutStructCode', title: '来源仓位', width: 130 },
{ dataIndex: 'materialCode', title: '物料编码', width: 130 },
{ dataIndex: 'pcsn', title: '批次', width: 150 },
{ dataIndex: 'qty', title: '数量', width: 100 },
{ dataIndex: 'turninStructCode', key: 'target', title: '目标仓位', width: 220 },
{ key: 'action', title: '操作', width: 80 },
];
const inventoryColumns = [
{ dataIndex: 'storagevehicleCode', title: '载具编码', width: 150 },
{ dataIndex: 'sectName', title: '库区', width: 130 },
{ dataIndex: 'structCode', title: '仓位', width: 130 },
{ dataIndex: 'layerNum', title: '层', width: 70 },
{ dataIndex: 'materialCode', title: '物料编码', width: 130 },
{ dataIndex: 'pcsn', title: '批次', width: 150 },
{ dataIndex: 'qty', title: '数量', width: 100 },
];
async function openInventory() {
const values = await formApi.getValues();
if (!values.storId) {
message.warning('请先选择仓库');
return;
}
inventoryRows.value = await getMoveAvailableInventory(values.storId);
selectedInventoryKeys.value = [];
inventoryOpen.value = true;
}
async function confirmInventory() {
const selected = inventoryRows.value.filter((item) =>
selectedInventoryKeys.value.includes(item.storagevehicleCode),
);
const existing = new Set(details.value.map((item) => item.storagevehicleCode));
for (const item of selected) {
if (existing.has(item.storagevehicleCode)) continue;
details.value.push({
storagevehicleCode: item.storagevehicleCode,
turnoutSectCode: item.sectCode,
turnoutStructCode: item.structCode,
turninStructCode: '',
materialCode: item.materialCode,
pcsn: item.pcsn,
qtyUnitId: item.qtyUnitId,
qtyUnitName: item.qtyUnitName,
qty: item.qty,
sourceBillCode: item.sourceBillCode,
});
targetOptions.value[item.storagevehicleCode] =
await getMoveTargetStructs(item.structCode);
}
inventoryOpen.value = false;
}
function usedTargetCodes(currentVehicle: string) {
return new Set(
details.value
.filter((item) => item.storagevehicleCode !== currentVehicle)
.map((item) => item.turninStructCode)
.filter(Boolean),
);
}
function targetSelectOptions(row: WmsMoveInvApi.Detail) {
const used = usedTargetCodes(row.storagevehicleCode);
return (targetOptions.value[row.storagevehicleCode] || [])
.filter((item) => !used.has(item.structCode))
.map((item) => ({
label: `${item.structCode}${item.structName || item.sectName}`,
value: item.structCode,
}));
}
function removeDetail(index: number) {
details.value.splice(index, 1);
}
function handleInventorySelection(keys: (number | string)[]) {
selectedInventoryKeys.value = keys.map(String);
}
const [ModalComponent, modalApi] = useVbenModal({
fullscreen: true,
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) return;
if (details.value.length === 0) {
message.warning('请至少选择一个来源载具');
return;
}
if (details.value.some((item) => !item.turninStructCode)) {
message.warning('请为每个来源载具选择目标仓位');
return;
}
const values = await formApi.getValues();
modalApi.lock();
try {
const payload = {
...values,
details: details.value,
moveinvId: editData.value?.moveinvId,
} as WmsMoveInvApi.MoveInv;
if (isEdit.value) await updateMoveInv(payload);
else await createMoveInv(payload);
message.success('保存成功');
await modalApi.close();
emit('success');
} finally {
modalApi.unlock();
}
},
async onOpenChange(open) {
if (!open) return;
editData.value = undefined;
details.value = [];
targetOptions.value = {};
await formApi.resetForm();
const data = modalApi.getData<WmsMoveInvApi.MoveInv>();
if (!data?.moveinvId) {
await formApi.setValues({
billCode: '保存时自动生成',
billStatus: '10',
billType: '3',
bizDate: dayjs().format('YYYY-MM-DD'),
});
return;
}
const result = await getMoveInv(data.moveinvId);
editData.value = result;
details.value = result.details || [];
await formApi.setValues(result);
await Promise.all(
details.value.map(async (item) => {
targetOptions.value[item.storagevehicleCode] =
await getMoveTargetStructs(item.turnoutStructCode);
}),
);
},
});
</script>
<template>
<ModalComponent :title="title">
<div class="p-4">
<Form />
<div class="mb-3 mt-4">
<Button type="primary" @click="openInventory">选择可用库存</Button>
</div>
<Table
:columns="detailColumns"
:data-source="details"
:pagination="false"
row-key="storagevehicleCode"
:scroll="{ x: 1200 }"
>
<template #bodyCell="{ column, index, record }">
<template v-if="column.key === 'index'">{{ index + 1 }}</template>
<template v-else-if="column.key === 'target'">
<Select
v-model:value="record.turninStructCode"
class="w-full"
:options="targetSelectOptions(record)"
placeholder="请选择同库区同层仓位"
show-search
/>
</template>
<template v-else-if="column.key === 'action'">
<Button danger type="link" @click="removeDetail(index)">删除</Button>
</template>
</template>
</Table>
</div>
<Modal
v-model:open="inventoryOpen"
title="选择移库库存"
width="1100px"
@ok="confirmInventory"
>
<Table
:columns="inventoryColumns"
:data-source="inventoryRows"
:pagination="false"
row-key="storagevehicleCode"
:row-selection="{
selectedRowKeys: selectedInventoryKeys,
onChange: handleInventorySelection,
}"
:scroll="{ x: 950, y: 500 }"
/>
</Modal>
</ModalComponent>
</template>

View File

@@ -0,0 +1,7 @@
<script lang="ts" setup>
import MoveInv from '../../moveinv/index.vue';
</script>
<template>
<MoveInv />
</template>