feat(wms):完成 antd、ele 的 shipment 的迁移

pull/345/head
YunaiV 2026-05-18 13:13:29 +08:00
parent 88515705dc
commit 1bbb7eb1d5
10 changed files with 3338 additions and 0 deletions

View File

@ -0,0 +1,437 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { WmsWarehouseApi } from '#/api/wms/md/warehouse';
import type { DescriptionItemSchema } from '#/components/description';
import { h, markRaw } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { formatDate, formatDateTime } from '@vben/utils';
import { z } from '#/adapter/form';
import { getSimpleUserList } from '#/api/system/user';
import { DictTag } from '#/components/dict-tag';
import { buildNumberRangeSchema } from '#/components/number-range-input';
import { getRangePickerDefaultProps } from '#/utils';
import { WmsMerchantSelect } from '#/views/wms/md/merchant/components';
import { WmsWarehouseSelect } from '#/views/wms/md/warehouse/components';
import {
formatPrice,
formatQuantity,
formatSumPrice,
formatSumQuantity,
PRICE_PRECISION,
QUANTITY_PRECISION,
} from '#/views/wms/utils/format';
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入出库单号',
},
fieldName: 'no',
label: '出库单号',
},
{
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入业务单号',
},
fieldName: 'bizOrderNo',
label: '业务单号',
},
{
component: 'Select',
componentProps: {
allowClear: true,
options: getDictOptions(DICT_TYPE.WMS_ORDER_STATUS, 'number'),
placeholder: '请选择单据状态',
},
fieldName: 'status',
label: '单据状态',
},
{
component: markRaw(WmsWarehouseSelect),
fieldName: 'warehouseId',
label: '仓库',
},
{
component: markRaw(WmsMerchantSelect),
componentProps: {
customer: true,
placeholder: '请选择客户',
},
fieldName: 'merchantId',
label: '客户',
},
{
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
fieldName: 'orderTime',
label: '单据日期',
},
buildNumberRangeSchema(
'数量',
'totalQuantityRange',
'totalQuantityMin',
'totalQuantityMax',
QUANTITY_PRECISION,
),
buildNumberRangeSchema(
'总金额',
'totalPriceRange',
'totalPriceMin',
'totalPriceMax',
PRICE_PRECISION,
),
{
component: 'Select',
componentProps: {
allowClear: true,
options: getDictOptions(DICT_TYPE.WMS_SHIPMENT_ORDER_TYPE, 'number'),
placeholder: '请选择出库类型',
},
fieldName: 'type',
label: '出库类型',
},
{
component: 'ApiSelect',
componentProps: {
allowClear: true,
api: getSimpleUserList,
labelField: 'nickname',
placeholder: '请选择创建用户',
showSearch: true,
valueField: 'id',
},
fieldName: 'creator',
label: '创建用户',
},
{
component: 'ApiSelect',
componentProps: {
allowClear: true,
api: getSimpleUserList,
labelField: 'nickname',
placeholder: '请选择更新用户',
showSearch: true,
valueField: 'id',
},
fieldName: 'updater',
label: '更新用户',
},
{
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
fieldName: 'createTime',
label: '创建时间',
},
{
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
fieldName: 'updateTime',
label: '更新时间',
},
];
}
/** 列表表格列 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
fixed: 'left',
slots: { content: 'expand_content' },
type: 'expand',
width: 48,
},
{
field: 'no',
fixed: 'left',
slots: { default: 'no' },
title: '单号/业务单号',
width: 260,
},
{
align: 'center',
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.WMS_ORDER_STATUS },
},
field: 'status',
fixed: 'left',
title: '出库状态',
width: 110,
},
{
align: 'center',
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.WMS_SHIPMENT_ORDER_TYPE },
},
field: 'type',
title: '出库类型',
width: 120,
},
{
field: 'warehouseName',
minWidth: 180,
title: '仓库',
},
{
field: 'quantityAmount',
minWidth: 180,
slots: { default: 'quantityAmount' },
title: '总数量/总金额(元)',
},
{
field: 'merchantName',
minWidth: 160,
title: '客户',
},
{
field: 'operateInfo',
minWidth: 260,
slots: { default: 'operateInfo' },
title: '操作信息',
},
{
field: 'remark',
minWidth: 160,
title: '备注',
},
{
field: 'actions',
fixed: 'right',
slots: { default: 'actions' },
title: '操作',
width: 220,
},
];
}
/** 详情的字段 */
export function useDetailSchema(): DescriptionItemSchema[] {
return [
{
field: 'no',
label: '出库单号',
render: (val) => val || '-',
},
{
field: 'type',
label: '出库类型',
render: (val) =>
val === undefined || val === null
? '-'
: h(DictTag, {
type: DICT_TYPE.WMS_SHIPMENT_ORDER_TYPE,
value: val,
}),
},
{
field: 'warehouseName',
label: '仓库',
render: (val) => val || '-',
},
{
field: 'status',
label: '单据状态',
render: (val) =>
val === undefined || val === null
? '-'
: h(DictTag, {
type: DICT_TYPE.WMS_ORDER_STATUS,
value: val,
}),
},
{
field: 'orderTime',
label: '单据日期',
render: (val) => formatDate(val, 'YYYY-MM-DD') || '-',
},
{
field: 'merchantName',
label: '客户',
render: (val) => val || '-',
},
{
field: 'bizOrderNo',
label: '业务单号',
render: (val) => val || '-',
},
{
field: 'totalQuantity',
label: '总数量',
render: (val) => formatQuantity(val) || '-',
},
{
field: 'totalPrice',
label: '总金额',
render: (val) => formatPrice(val) || '-',
},
{
field: 'createTime',
label: '创建时间',
render: (val) => formatDateTime(val) || '-',
},
{
field: 'creatorName',
label: '创建人',
render: (val, data) => val || data?.creator || '-',
},
{
field: 'updateTime',
label: '更新时间',
render: (val) => formatDateTime(val) || '-',
},
{
field: 'updaterName',
label: '更新人',
render: (val, data) => val || data?.updater || '-',
},
{
field: 'remark',
label: '备注',
render: (val) => val || '-',
span: 2,
},
];
}
interface ShipmentFormSchemaOptions {
onWarehouseChange: (warehouse?: WmsWarehouseApi.Warehouse) => void;
}
/** 表单的配置项 */
export function useFormSchema({
onWarehouseChange,
}: ShipmentFormSchemaOptions): VbenFormSchema[] {
return [
{
component: 'Input',
dependencies: {
show: () => false,
triggerFields: [''],
},
fieldName: 'id',
},
{
component: 'Input',
componentProps: {
maxLength: 64,
placeholder: '请输入出库单号',
},
fieldName: 'no',
label: '出库单号',
rules: z.string().min(1, '出库单号不能为空').max(64),
},
{
component: 'Select',
componentProps: {
allowClear: true,
options: getDictOptions(DICT_TYPE.WMS_SHIPMENT_ORDER_TYPE, 'number'),
placeholder: '请选择出库类型',
},
fieldName: 'type',
label: '出库类型',
rules: 'required',
},
{
component: markRaw(WmsWarehouseSelect),
componentProps: {
onChange: onWarehouseChange,
},
fieldName: 'warehouseId',
label: '仓库',
rules: 'required',
},
{
component: 'DatePicker',
componentProps: {
class: 'w-full',
format: 'YYYY-MM-DD',
placeholder: '请选择单据日期',
valueFormat: 'x',
},
fieldName: 'orderTime',
label: '单据日期',
rules: 'required',
},
{
component: markRaw(WmsMerchantSelect),
componentProps: {
customer: true,
placeholder: '请选择客户',
},
fieldName: 'merchantId',
label: '客户',
},
{
component: 'Input',
componentProps: {
maxLength: 64,
placeholder: '请输入业务单号',
},
fieldName: 'bizOrderNo',
label: '业务单号',
},
{
component: 'Textarea',
componentProps: {
maxLength: 255,
placeholder: '请输入备注',
},
fieldName: 'remark',
formItemClass: 'col-span-2',
label: '备注',
},
];
}
interface ShipmentOrderDetailFooterRow {
quantity?: number;
totalPrice?: number;
}
type ShipmentOrderDetailFooterColumn = Pick<
NonNullable<NonNullable<VxeTableGridOptions['columns']>[number]>,
'field'
>;
/** 明细表格的合计行 */
export function getDetailFooter({
columns,
data,
}: {
columns: ShipmentOrderDetailFooterColumn[];
data: ShipmentOrderDetailFooterRow[];
}) {
return [
columns.map((column, index) => {
if (index === 0) {
return '合计';
}
if (column.field === 'quantity') {
return formatSumQuantity(data, (detail) => detail.quantity);
}
if (column.field === 'totalPrice') {
return formatSumPrice(data, (detail) => detail.totalPrice);
}
return '';
}),
];
}

View File

@ -0,0 +1,345 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { WmsShipmentOrderApi } from '#/api/wms/order/shipment';
import type { WmsShipmentOrderDetailApi } from '#/api/wms/order/shipment/detail';
import { reactive, ref } from 'vue';
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart, formatDateTime } from '@vben/utils';
import { message } from 'ant-design-vue';
import {
ACTION_ICON,
TableAction,
useVbenVxeGrid,
VxeColumn,
VxeTable,
} from '#/adapter/vxe-table';
import {
deleteShipmentOrder,
exportShipmentOrder,
getShipmentOrderDetailListByOrderId,
getShipmentOrderPage,
} from '#/api/wms/order/shipment';
import { $t } from '#/locales';
import {
OrderDeleteStatusList,
OrderStatusEnum,
OrderUpdateStatusList,
} from '#/views/wms/utils/constants';
import { formatPrice, formatQuantity, multiplyPrice } from '#/views/wms/utils/format';
import { useGridColumns, useGridFormSchema } from './data';
import ShipmentOrderDetail from './modules/detail.vue';
import ShipmentOrderForm from './modules/form.vue';
import ShipmentOrderPrint from './modules/print.vue';
defineOptions({ name: 'WmsShipmentOrder' });
const printRef = ref<InstanceType<typeof ShipmentOrderPrint>>();
const detailMap = reactive<
Record<number, WmsShipmentOrderDetailApi.ShipmentOrderDetail[]>
>({});
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: ShipmentOrderForm,
destroyOnClose: true,
});
const [DetailModal, detailModalApi] = useVbenModal({
connectedComponent: ShipmentOrderDetail,
destroyOnClose: true,
});
/** 清空展开明细缓存 */
function clearDetailMap() {
for (const id of Object.keys(detailMap)) {
delete detailMap[Number(id)];
}
}
/** 刷新表格 */
function handleRefresh() {
clearDetailMap();
gridApi.query();
}
/** 创建出库单 */
function handleCreate() {
formModalApi.setData({ type: 'create' }).open();
}
/** 编辑出库单 */
function handleEdit(row: WmsShipmentOrderApi.ShipmentOrder) {
formModalApi.setData({ id: row.id!, type: 'update' }).open();
}
/** 查看出库单详情 */
function handleDetail(row: WmsShipmentOrderApi.ShipmentOrder) {
detailModalApi.setData({ id: row.id! }).open();
}
/** 计算单据明细金额 */
function getDetailTotalPrice(
detail: WmsShipmentOrderDetailApi.ShipmentOrderDetail,
) {
return detail.totalPrice ?? multiplyPrice(detail.quantity, detail.price);
}
/** 获取已展开行的明细 */
function getExpandedDetails(row: WmsShipmentOrderApi.ShipmentOrder) {
return detailMap[row.id!] || [];
}
/** 展开列表行时懒加载出库明细 */
async function handleExpandChange(
row: WmsShipmentOrderApi.ShipmentOrder,
expanded: boolean,
) {
if (!expanded) {
return;
}
delete detailMap[row.id!];
detailMap[row.id!] = await getShipmentOrderDetailListByOrderId(row.id!);
}
/** 判断出库单是否可修改 */
function canUpdateShipmentOrder(status?: number) {
return status !== undefined && OrderUpdateStatusList.includes(status);
}
/** 判断出库单是否可删除 */
function canDeleteShipmentOrder(status?: number) {
return status !== undefined && OrderDeleteStatusList.includes(status);
}
/** 获取修改按钮禁用提示 */
function getShipmentOrderUpdateTip(status?: number) {
if (canUpdateShipmentOrder(status)) {
return undefined;
}
if (status === OrderStatusEnum.FINISHED) {
return '已出库,无法修改';
}
if (status === OrderStatusEnum.CANCELED) {
return '已作废,无法修改';
}
return '当前状态无法修改';
}
/** 获取删除按钮禁用提示 */
function getShipmentOrderDeleteTip(status?: number) {
if (canDeleteShipmentOrder(status)) {
return undefined;
}
if (status === OrderStatusEnum.FINISHED) {
return '已出库,无法删除';
}
return '当前状态无法删除';
}
/** 删除出库单 */
async function handleDelete(row: WmsShipmentOrderApi.ShipmentOrder) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.no]),
duration: 0,
});
try {
await deleteShipmentOrder(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.no]));
handleRefresh();
} finally {
hideLoading();
}
}
/** 导出出库单 */
async function handleExport() {
const data = await exportShipmentOrder(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '出库单.xls', source: data });
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
collapsed: true,
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
expandConfig: {
padding: true,
},
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getShipmentOrderPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
isHover: true,
keyField: 'id',
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<WmsShipmentOrderApi.ShipmentOrder>,
gridEvents: {
toggleRowExpand: ({
expanded,
row,
}: {
expanded: boolean;
row: WmsShipmentOrderApi.ShipmentOrder;
}) => {
handleExpandChange(row, expanded);
},
},
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="【单据】出库" url="https://doc.iocoder.cn/wms/order/shipment/" />
</template>
<FormModal @success="handleRefresh" />
<DetailModal />
<ShipmentOrderPrint ref="printRef" />
<Grid table-title="">
<template #expand_content="{ row }">
<VxeTable
:data="getExpandedDetails(row)"
border
:show-overflow="true"
size="small"
>
<VxeColumn title="商品信息" min-width="220">
<template #default="{ row: detail }">
<div>{{ detail.itemName || '-' }}</div>
<div v-if="detail.itemCode" class="text-xs text-gray-500">
商品编号{{ detail.itemCode }}
</div>
</template>
</VxeColumn>
<VxeColumn title="规格信息" min-width="220">
<template #default="{ row: detail }">
<div>{{ detail.skuName || '-' }}</div>
<div v-if="detail.skuCode" class="text-xs text-gray-500">
规格编号{{ detail.skuCode }}
</div>
</template>
</VxeColumn>
<VxeColumn title="出库数量" align="right" width="120">
<template #default="{ row: detail }">
{{ formatQuantity(detail.quantity) }}
</template>
</VxeColumn>
<VxeColumn title="单价(元)" align="right" width="120">
<template #default="{ row: detail }">
{{ formatPrice(detail.price) || '-' }}
</template>
</VxeColumn>
<VxeColumn title="金额(元)" align="right" width="120">
<template #default="{ row: detail }">
{{ formatPrice(getDetailTotalPrice(detail)) || '-' }}
</template>
</VxeColumn>
</VxeTable>
</template>
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['出库单']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['wms:shipment-order:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['wms:shipment-order:export'],
onClick: handleExport,
},
]"
/>
</template>
<template #no="{ row }">
<div>
单号
<a class="text-primary" @click="handleDetail(row)">{{ row.no }}</a>
</div>
<div v-if="row.bizOrderNo" class="text-xs text-gray-500">
业务{{ row.bizOrderNo }}
</div>
</template>
<template #quantityAmount="{ row }">
<div class="flex items-center justify-between">
<span>数量</span>
<span>{{ formatQuantity(row.totalQuantity) }}</span>
</div>
<div class="flex items-center justify-between">
<span>金额</span>
<span>{{ formatPrice(row.totalPrice) }}</span>
</div>
</template>
<template #operateInfo="{ row }">
<div>
创建{{ formatDateTime(row.createTime) || '-' }} /
{{ row.creatorName || row.creator || '-' }}
</div>
<div>
更新{{ formatDateTime(row.updateTime) || '-' }} /
{{ row.updaterName || row.updater || '-' }}
</div>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'link',
icon: ACTION_ICON.EDIT,
disabled: !canUpdateShipmentOrder(row.status),
tooltip: getShipmentOrderUpdateTip(row.status),
auth: ['wms:shipment-order:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
disabled: !canDeleteShipmentOrder(row.status),
tooltip: getShipmentOrderDeleteTip(row.status),
auth: ['wms:shipment-order:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.no]),
confirm: handleDelete.bind(null, row),
},
},
{
label: '打印',
type: 'link',
auth: ['wms:shipment-order:query'],
onClick: () => printRef?.print(row.id!),
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,121 @@
<script lang="ts" setup>
import type { WmsShipmentOrderApi } from '#/api/wms/order/shipment';
import type { WmsShipmentOrderDetailApi } from '#/api/wms/order/shipment/detail';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { VxeColumn, VxeTable } from '#/adapter/vxe-table';
import {
getShipmentOrder,
getShipmentOrderDetailListByOrderId,
} from '#/api/wms/order/shipment';
import { useDescription } from '#/components/description';
import {
formatPrice,
formatQuantity,
multiplyPrice,
} from '#/views/wms/utils/format';
import { getDetailFooter, useDetailSchema } from '../data';
interface DetailRow extends WmsShipmentOrderDetailApi.ShipmentOrderDetail {
totalPrice?: number;
}
defineOptions({ name: 'WmsShipmentOrderDetail' });
const detailData = ref<WmsShipmentOrderApi.ShipmentOrder>({});
const detailRows = computed<DetailRow[]>(() =>
(detailData.value.details || []).map((detail) => ({
...detail,
totalPrice: detail.totalPrice ?? multiplyPrice(detail.quantity, detail.price),
})),
);
const [Descriptions] = useDescription({
bordered: true,
column: 2,
schema: useDetailSchema(),
useCard: false,
});
const [Modal, modalApi] = useVbenModal({
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
detailData.value = {};
return;
}
const data = modalApi.getData<{ id?: number }>();
if (!data?.id) {
return;
}
modalApi.lock();
try {
const order = await getShipmentOrder(data.id);
const details =
order.details || (await getShipmentOrderDetailListByOrderId(data.id));
detailData.value = { ...order, details };
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal
title="出库单详情"
class="w-2/3"
:show-cancel-button="false"
:show-confirm-button="false"
>
<div class="mx-4 space-y-4">
<Descriptions :data="detailData" />
<VxeTable
:data="detailRows"
border
empty-text="暂无商品明细"
:footer-method="getDetailFooter"
:show-overflow="true"
show-footer
size="small"
>
<VxeColumn title="商品信息" min-width="220">
<template #default="{ row }">
<div>{{ row.itemName || '-' }}</div>
<div v-if="row.itemCode" class="text-xs text-gray-500">
商品编号{{ row.itemCode }}
</div>
</template>
</VxeColumn>
<VxeColumn title="规格信息" min-width="220">
<template #default="{ row }">
<div>{{ row.skuName || '-' }}</div>
<div v-if="row.skuCode" class="text-xs text-gray-500">
规格编号{{ row.skuCode }}
</div>
</template>
</VxeColumn>
<VxeColumn field="quantity" title="数量" align="right" width="120">
<template #default="{ row }">
{{ formatQuantity(row.quantity) || '-' }}
</template>
</VxeColumn>
<VxeColumn field="unit" title="单位" align="center" width="100" />
<VxeColumn title="单价" align="right" width="140">
<template #default="{ row }">
{{ formatPrice(row.price) || '-' }}
</template>
</VxeColumn>
<VxeColumn field="totalPrice" title="总价" align="right" width="140">
<template #default="{ row }">
{{ formatPrice(row.totalPrice) || '-' }}
</template>
</VxeColumn>
</VxeTable>
</div>
</Modal>
</template>

View File

@ -0,0 +1,535 @@
<script lang="ts" setup>
import type { VxeTableInstance } from '#/adapter/vxe-table';
import type { WmsWarehouseApi } from '#/api/wms/md/warehouse';
import type { WmsShipmentOrderApi } from '#/api/wms/order/shipment';
import type { WmsShipmentOrderDetailApi } from '#/api/wms/order/shipment/detail';
import type { InventorySelectRow } from '#/views/wms/inventory/components/inventory-select.vue';
import { computed, nextTick, ref } from 'vue';
import { confirm, useVbenModal } from '@vben/common-ui';
import { isEqual } from '@vben/utils';
import { InputNumber, message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { TableAction, VxeColumn, VxeTable } from '#/adapter/vxe-table';
import {
cancelShipmentOrder,
completeShipmentOrder,
createShipmentOrder,
getShipmentOrder,
getShipmentOrderDetailListByOrderId,
updateShipmentOrder,
} from '#/api/wms/order/shipment';
import { $t } from '#/locales';
import { WmsInventorySelect } from '#/views/wms/inventory/components';
import {
OrderStatusEnum,
OrderUpdateStatusList,
} from '#/views/wms/utils/constants';
import {
dividePrice,
formatQuantity,
multiplyPrice,
PRICE_PRECISION,
QUANTITY_PRECISION,
} from '#/views/wms/utils/format';
import { generateOrderNo } from '#/views/wms/utils/order';
import { getDetailFooter, useFormSchema } from '../data';
interface DetailRow extends WmsShipmentOrderDetailApi.ShipmentOrderDetail {
seq: number;
}
defineOptions({ name: 'WmsShipmentOrderForm' });
const emit = defineEmits<{
success: [];
}>();
const formData = ref<WmsShipmentOrderApi.ShipmentOrder>({});
const formMode = ref('create');
const originalSubmitData = ref<WmsShipmentOrderApi.ShipmentOrder>();
const details = ref<DetailRow[]>([]);
const detailTableRef = ref<VxeTableInstance>();
const inventorySelectRef = ref<InstanceType<typeof WmsInventorySelect>>();
const currentWarehouseId = ref<number>();
const initializing = ref(false);
let detailSeq = 0; // id使 VXE
const getTitle = computed(() => {
return formMode.value === 'update'
? $t('ui.actionTitle.edit', ['出库单'])
: $t('ui.actionTitle.create', ['出库单']);
});
const isPrepareOrder = computed(() => {
return (
!formData.value?.id ||
(formData.value.status !== undefined &&
OrderUpdateStatusList.includes(formData.value.status))
);
});
const isSavedPrepareOrder = computed(() => {
return (
!!formData.value?.id &&
formData.value.status !== undefined &&
OrderUpdateStatusList.includes(formData.value.status)
);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
labelWidth: 100,
},
layout: 'horizontal',
schema: useFormSchema({ onWarehouseChange: handleWarehouseChange }),
showDefaultActions: false,
wrapperClass: 'grid-cols-3',
});
/** 标准化明细行,补齐本地序号和金额 */
function normalizeDetail(
detail: WmsShipmentOrderDetailApi.ShipmentOrderDetail,
): DetailRow {
detailSeq += 1;
return {
...detail,
seq: detailSeq,
totalPrice: detail.totalPrice ?? multiplyPrice(detail.quantity, detail.price),
};
}
/** 根据库存构建新的出库明细 */
function buildDetail(inventory: InventorySelectRow): DetailRow {
return normalizeDetail({
availableQuantity: inventory.availableQuantity,
id: undefined,
itemCode: inventory.itemCode,
itemId: inventory.itemId,
itemName: inventory.itemName,
price: undefined,
quantity: undefined,
skuCode: inventory.skuCode,
skuId: inventory.skuId,
skuName: inventory.skuName,
totalPrice: undefined,
unit: inventory.unit,
warehouseId: inventory.warehouseId,
warehouseName: inventory.warehouseName,
});
}
/** 设置出库明细 */
function setDetails(list?: WmsShipmentOrderDetailApi.ShipmentOrderDetail[]) {
detailSeq = 0;
details.value = (list || []).map((detail) => normalizeDetail(detail));
void refreshDetailFooter();
}
/** 刷新明细合计行 */
async function refreshDetailFooter() {
await nextTick();
await detailTableRef.value?.updateFooter();
}
/** 添加商品明细 */
async function handleAddDetail() {
const values = (await formApi.getValues()) as WmsShipmentOrderApi.ShipmentOrder;
if (!values.warehouseId) {
message.warning('请先选择仓库');
return;
}
currentWarehouseId.value = values.warehouseId;
await nextTick();
inventorySelectRef.value?.open(getSelectedInventoryKeys());
}
/** 选择库存 */
function handleSelectInventory(inventories: InventorySelectRow[]) {
if (inventories.length === 0) {
return;
}
let changed = false;
for (const inventory of inventories) {
if (!inventory.skuId || isInventorySelected(inventory)) {
continue;
}
details.value.push(buildDetail(inventory));
changed = true;
}
if (changed) {
void refreshDetailFooter();
}
}
/** 判断库存是否已选择 */
function isInventorySelected(inventory: InventorySelectRow) {
return details.value.some((detail) => {
return (
detail.skuId === inventory.skuId &&
detail.warehouseId === inventory.warehouseId
);
});
}
/** 获得已选择的库存标识 */
function getSelectedInventoryKeys() {
return details.value
.map((detail) =>
detail.skuId && detail.warehouseId
? `${detail.skuId}-${detail.warehouseId}`
: undefined,
)
.filter((key): key is string => !!key);
}
/** 删除商品明细 */
function handleDeleteDetail(row: DetailRow) {
const index = details.value.findIndex((detail) => detail.seq === row.seq);
if (index !== -1) {
details.value.splice(index, 1);
void refreshDetailFooter();
}
}
/** 仓库变化时清空出库明细 */
function handleWarehouseChange(warehouse?: WmsWarehouseApi.Warehouse) {
if (initializing.value) {
return;
}
formData.value.warehouseId = warehouse?.id;
formData.value.warehouseName = warehouse?.name;
currentWarehouseId.value = warehouse?.id;
setDetails([]);
}
/** 明细数量变化 */
function handleDetailQuantityChange(detail: DetailRow) {
if (detail.price !== undefined && detail.price !== null) {
detail.totalPrice = multiplyPrice(detail.quantity, detail.price);
void refreshDetailFooter();
return;
}
detail.price = dividePrice(detail.totalPrice, detail.quantity);
void refreshDetailFooter();
}
/** 明细单价变化 */
function handleDetailPriceChange(detail: DetailRow) {
detail.totalPrice = multiplyPrice(detail.quantity, detail.price);
void refreshDetailFooter();
}
/** 明细金额变化 */
function handleDetailTotalPriceChange(detail: DetailRow) {
detail.price = dividePrice(detail.totalPrice, detail.quantity);
void refreshDetailFooter();
}
/** 校验商品明细 */
function validateDetails(required = false) {
if (details.value.length === 0) {
if (required) {
message.error('至少包含一条出库明细');
return false;
}
return true;
}
for (let index = 0; index < details.value.length; index += 1) {
const detail = details.value[index]!;
if (!detail.skuId) {
message.error(`${index + 1} 行明细请选择商品规格`);
return false;
}
if (!detail.quantity || detail.quantity <= 0) {
message.error(`${index + 1} 行明细出库数量必须大于 0`);
return false;
}
if (
detail.availableQuantity !== undefined &&
detail.quantity > detail.availableQuantity
) {
message.error(`${index + 1} 行明细出库数量不能大于可用库存`);
return false;
}
}
return true;
}
/** 构建提交用的明细数据 */
function buildSubmitDetails() {
return details.value.map((row) => {
const { seq: _seq, ...detail } = row;
return detail;
});
}
/** 构建提交用的单据数据 */
async function buildSubmitData(): Promise<WmsShipmentOrderApi.ShipmentOrder> {
const values = (await formApi.getValues()) as WmsShipmentOrderApi.ShipmentOrder;
const {
details: _details,
totalPrice: _totalPrice,
totalQuantity: _totalQuantity,
...order
} = formData.value;
return {
...order,
...values,
details: buildSubmitDetails(),
};
}
/** 完成出库 */
async function handleFormComplete() {
const { valid } = await formApi.validate();
if (!valid || !validateDetails(true) || !formData.value?.id) {
return;
}
await confirm('确认完成出库?完成后将更新库存。');
modalApi.lock();
try {
const data = await buildSubmitData();
if (!isEqual(data, originalSubmitData.value)) {
await updateShipmentOrder(data);
}
await completeShipmentOrder(formData.value.id);
await modalApi.close();
emit('success');
message.success('出库成功');
} finally {
modalApi.unlock();
}
}
/** 作废出库单 */
async function handleFormCancel() {
if (!formData.value?.id) {
return;
}
await confirm('确认作废该出库单?作废后不可恢复。');
modalApi.lock();
try {
await cancelShipmentOrder(formData.value.id);
await modalApi.close();
emit('success');
message.success('作废成功');
} finally {
modalApi.unlock();
}
}
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid || !validateDetails(false) || !isPrepareOrder.value) {
return;
}
modalApi.lock();
//
const data = await buildSubmitData();
try {
await (formMode.value === 'update'
? updateShipmentOrder(data)
: createShipmentOrder(data));
//
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = {};
originalSubmitData.value = undefined;
currentWarehouseId.value = undefined;
setDetails([]);
return;
}
initializing.value = true;
await formApi.resetForm();
const data = modalApi.getData<{
id?: number;
type?: string;
}>();
formMode.value = data?.type || (data?.id ? 'update' : 'create');
if (data?.id) {
modalApi.lock();
try {
//
const order = await getShipmentOrder(data.id);
const orderDetails =
order.details || (await getShipmentOrderDetailListByOrderId(data.id));
formData.value = { ...order, details: orderDetails };
currentWarehouseId.value = order.warehouseId;
setDetails(orderDetails);
// values
await formApi.setValues(formData.value);
await nextTick();
originalSubmitData.value = await buildSubmitData();
} finally {
initializing.value = false;
modalApi.unlock();
}
return;
}
//
formData.value = {
details: [],
no: generateOrderNo('CK'),
status: OrderStatusEnum.PREPARE,
};
currentWarehouseId.value = undefined;
setDetails([]);
await formApi.setValues(formData.value);
await nextTick();
originalSubmitData.value = await buildSubmitData();
initializing.value = false;
},
});
</script>
<template>
<Modal
:title="getTitle"
class="w-3/4"
:show-confirm-button="isPrepareOrder"
>
<div class="mx-4">
<Form />
<div class="mt-4">
<div class="mb-3 flex items-center justify-between">
<span class="text-sm font-semibold">出库明细</span>
<TableAction
:actions="[
{
label: '添加商品',
onClick: handleAddDetail,
type: 'primary',
},
]"
/>
</div>
<VxeTable
ref="detailTableRef"
:data="details"
border
empty-text="暂无商品明细"
:footer-method="getDetailFooter"
:show-overflow="true"
show-footer
size="small"
>
<VxeColumn title="商品信息" min-width="220">
<template #default="{ row }">
<div>{{ row.itemName || '-' }}</div>
<div v-if="row.itemCode" class="text-xs text-gray-500">
商品编号{{ row.itemCode }}
</div>
</template>
</VxeColumn>
<VxeColumn title="规格信息" min-width="220">
<template #default="{ row }">
<div>{{ row.skuName || '-' }}</div>
<div v-if="row.skuCode" class="text-xs text-gray-500">
规格编号{{ row.skuCode }}
</div>
</template>
</VxeColumn>
<VxeColumn field="availableQuantity" title="可用库存" align="right" width="120">
<template #default="{ row }">
{{ formatQuantity(row.availableQuantity) || '-' }}
</template>
</VxeColumn>
<VxeColumn field="quantity" title="出库数量" width="150">
<template #default="{ row }">
<InputNumber
v-model:value="row.quantity"
:controls="false"
:min="0"
:precision="QUANTITY_PRECISION"
class="!w-full"
placeholder="数量"
@change="handleDetailQuantityChange(row)"
/>
</template>
</VxeColumn>
<VxeColumn field="price" title="单价(元)" width="150">
<template #default="{ row }">
<InputNumber
v-model:value="row.price"
:controls="false"
:min="0"
:precision="PRICE_PRECISION"
class="!w-full"
placeholder="单价"
@change="handleDetailPriceChange(row)"
/>
</template>
</VxeColumn>
<VxeColumn field="totalPrice" title="金额(元)" width="150">
<template #default="{ row }">
<InputNumber
v-model:value="row.totalPrice"
:controls="false"
:min="0"
:precision="PRICE_PRECISION"
class="!w-full"
placeholder="金额"
@change="handleDetailTotalPriceChange(row)"
/>
</template>
</VxeColumn>
<VxeColumn title="操作" align="center" fixed="right" width="90">
<template #default="{ row }">
<TableAction
:actions="[
{
label: '删除',
type: 'link',
danger: true,
onClick: handleDeleteDetail.bind(null, row),
},
]"
/>
</template>
</VxeColumn>
</VxeTable>
</div>
</div>
<template #prepend-footer>
<div v-if="isSavedPrepareOrder" class="flex flex-auto items-center gap-2">
<TableAction
:actions="[
{
label: '完成出库',
type: 'primary',
auth: ['wms:shipment-order:complete'],
onClick: handleFormComplete,
},
{
label: '作废',
type: 'primary',
danger: true,
auth: ['wms:shipment-order:cancel'],
onClick: handleFormCancel,
},
]"
/>
</div>
</template>
<WmsInventorySelect
ref="inventorySelectRef"
:warehouse-id="currentWarehouseId"
@change="handleSelectInventory"
/>
</Modal>
</template>

View File

@ -0,0 +1,231 @@
<script lang="ts" setup>
import type { WmsShipmentOrderApi } from '#/api/wms/order/shipment';
import type { WmsShipmentOrderDetailApi } from '#/api/wms/order/shipment/detail';
import { computed, nextTick, ref } from 'vue';
import { Barcode, BarcodeFormatEnum } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictLabel } from '@vben/hooks';
import { formatDate, formatDateTime } from '@vben/utils';
import {
getShipmentOrder,
getShipmentOrderDetailListByOrderId,
} from '#/api/wms/order/shipment';
import {
formatPrice,
formatQuantity,
formatSumPrice,
formatSumQuantity,
multiplyPrice,
} from '#/views/wms/utils/format';
interface PrintRow extends WmsShipmentOrderDetailApi.ShipmentOrderDetail {
totalPrice?: number;
}
defineOptions({ name: 'WmsShipmentOrderPrint' });
const printData = ref<WmsShipmentOrderApi.ShipmentOrder>({});
const tableColumnCount = 5;
const printRows = computed<PrintRow[]>(() =>
(printData.value.details || []).map((detail) => ({
...detail,
totalPrice: detail.totalPrice ?? multiplyPrice(detail.quantity, detail.price),
})),
);
/** 等待条码和打印 DOM 完成绘制,避免浏览器打印到旧内容 */
function waitForPaint() {
return new Promise<void>((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => resolve());
});
});
}
/** 退出打印模式,恢复当前页面显示 */
function removePrintMode() {
document.body.classList.remove('wms-shipment-order-printing');
}
/** 获取打印用字典文案,空值统一显示为横杠 */
function getPrintDictLabel(dictType: string, value?: number) {
if (value === undefined || value === null) {
return '-';
}
return getDictLabel(dictType, value) || '-';
}
/** 打印出库单:加载数据后只展示打印区域,再调用浏览器打印 */
async function print(id: number) {
const order = await getShipmentOrder(id);
const details = order.details || (await getShipmentOrderDetailListByOrderId(id));
printData.value = { ...order, details };
await nextTick();
await waitForPaint();
document.body.classList.add('wms-shipment-order-printing');
window.addEventListener('afterprint', removePrintMode, { once: true });
window.print();
}
defineExpose({ print });
</script>
<template>
<Teleport to="body">
<div
id="wmsShipmentOrderPrint"
class="wms-shipment-order-print pointer-events-none fixed left-0 top-0 z-[-1] w-full bg-white text-[#303133] opacity-0"
>
<div class="relative mb-2">
<h2 class="m-0 text-center text-[1.5em] font-bold leading-[1.2]">
出库单
</h2>
<div v-if="printData.no" class="absolute right-0 top-0">
<Barcode
:content="printData.no"
:display-value="false"
:format="BarcodeFormatEnum.CODE39"
:height="40"
:width="180"
/>
</div>
</div>
<div class="mb-3 grid grid-cols-3 gap-x-6 gap-y-2 text-sm leading-[1.5]">
<div>出库单号{{ printData.no || '-' }}</div>
<div>
出库类型{{ getPrintDictLabel(DICT_TYPE.WMS_SHIPMENT_ORDER_TYPE, printData.type) }}
</div>
<div>仓库{{ printData.warehouseName || '-' }}</div>
<div>
出库状态{{ getPrintDictLabel(DICT_TYPE.WMS_ORDER_STATUS, printData.status) }}
</div>
<div>单据日期{{ formatDate(printData.orderTime, 'YYYY-MM-DD') || '-' }}</div>
<div>客户{{ printData.merchantName || '-' }}</div>
<div>业务单号{{ printData.bizOrderNo || '-' }}</div>
<div>总数量{{ formatQuantity(printData.totalQuantity) || '-' }}</div>
<div>总金额{{ formatPrice(printData.totalPrice) || '-' }}</div>
<div class="col-span-3 grid grid-cols-2 gap-x-6">
<div>
创建{{ formatDateTime(printData.createTime) || '-' }} /
{{ printData.creatorName || printData.creator || '-' }}
</div>
<div>
更新{{ formatDateTime(printData.updateTime) || '-' }} /
{{ printData.updaterName || printData.updater || '-' }}
</div>
</div>
<div class="col-span-3">备注{{ printData.remark || '-' }}</div>
</div>
<table class="w-full border-collapse text-[13px] leading-[1.5]">
<thead>
<tr>
<th class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-left font-bold">
商品信息
</th>
<th class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-left font-bold">
规格信息
</th>
<th class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-left font-bold">
数量
</th>
<th class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-left font-bold">
单价()
</th>
<th class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-left font-bold">
金额()
</th>
</tr>
</thead>
<tbody>
<tr v-for="detail in printRows" :key="detail.id || detail.skuId">
<td class="border border-solid border-[#dcdfe6] p-2">
<div>{{ detail.itemName || '-' }}</div>
<div v-if="detail.itemCode" class="text-xs">
编号{{ detail.itemCode }}
</div>
</td>
<td class="border border-solid border-[#dcdfe6] p-2">
<div>{{ detail.skuName || '-' }}</div>
<div v-if="detail.skuCode" class="text-xs">
编号{{ detail.skuCode }}
</div>
</td>
<td class="border border-solid border-[#dcdfe6] p-2 text-right">
{{ formatQuantity(detail.quantity) || '-' }}
</td>
<td class="border border-solid border-[#dcdfe6] p-2 text-right">
{{ formatPrice(detail.price) || '-' }}
</td>
<td class="border border-solid border-[#dcdfe6] p-2 text-right">
{{ formatPrice(detail.totalPrice) || '-' }}
</td>
</tr>
<tr v-if="printRows.length > 0">
<td
class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2"
colspan="2"
>
合计
</td>
<td class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-right">
{{ formatSumQuantity(printRows, (detail) => detail.quantity) }}
</td>
<td class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-right"></td>
<td class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-right">
{{ formatSumPrice(printRows, (detail) => detail.totalPrice) }}
</td>
</tr>
<tr v-if="printRows.length === 0">
<td
class="border border-solid border-[#dcdfe6] p-2 text-center"
:colspan="tableColumnCount"
>
暂无明细
</td>
</tr>
</tbody>
</table>
</div>
</Teleport>
</template>
<style scoped>
@page {
margin: 8mm 10mm;
}
@media print {
:global(body.wms-shipment-order-printing) {
-webkit-print-color-adjust: exact;
margin: 0 !important;
padding: 0 !important;
print-color-adjust: exact;
}
:global(body.wms-shipment-order-printing *) {
visibility: hidden !important;
}
:global(body.wms-shipment-order-printing .wms-shipment-order-print),
:global(body.wms-shipment-order-printing .wms-shipment-order-print *) {
visibility: visible !important;
}
:global(body.wms-shipment-order-printing .wms-shipment-order-print) {
pointer-events: auto;
position: absolute;
top: 0;
left: 0;
z-index: auto;
box-sizing: border-box;
width: 100%;
margin: 0 !important;
padding: 0 !important;
opacity: 1;
}
}
</style>

View File

@ -0,0 +1,437 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { WmsWarehouseApi } from '#/api/wms/md/warehouse';
import type { DescriptionItemSchema } from '#/components/description';
import { h, markRaw } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { formatDate, formatDateTime } from '@vben/utils';
import { z } from '#/adapter/form';
import { getSimpleUserList } from '#/api/system/user';
import { DictTag } from '#/components/dict-tag';
import { buildNumberRangeSchema } from '#/components/number-range-input';
import { getRangePickerDefaultProps } from '#/utils';
import { WmsMerchantSelect } from '#/views/wms/md/merchant/components';
import { WmsWarehouseSelect } from '#/views/wms/md/warehouse/components';
import {
formatPrice,
formatQuantity,
formatSumPrice,
formatSumQuantity,
PRICE_PRECISION,
QUANTITY_PRECISION,
} from '#/views/wms/utils/format';
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
componentProps: {
clearable: true,
placeholder: '请输入出库单号',
},
fieldName: 'no',
label: '出库单号',
},
{
component: 'Input',
componentProps: {
clearable: true,
placeholder: '请输入业务单号',
},
fieldName: 'bizOrderNo',
label: '业务单号',
},
{
component: 'Select',
componentProps: {
clearable: true,
options: getDictOptions(DICT_TYPE.WMS_ORDER_STATUS, 'number'),
placeholder: '请选择单据状态',
},
fieldName: 'status',
label: '单据状态',
},
{
component: markRaw(WmsWarehouseSelect),
fieldName: 'warehouseId',
label: '仓库',
},
{
component: markRaw(WmsMerchantSelect),
componentProps: {
customer: true,
placeholder: '请选择客户',
},
fieldName: 'merchantId',
label: '客户',
},
{
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
clearable: true,
},
fieldName: 'orderTime',
label: '单据日期',
},
buildNumberRangeSchema(
'数量',
'totalQuantityRange',
'totalQuantityMin',
'totalQuantityMax',
QUANTITY_PRECISION,
),
buildNumberRangeSchema(
'总金额',
'totalPriceRange',
'totalPriceMin',
'totalPriceMax',
PRICE_PRECISION,
),
{
component: 'Select',
componentProps: {
clearable: true,
options: getDictOptions(DICT_TYPE.WMS_SHIPMENT_ORDER_TYPE, 'number'),
placeholder: '请选择出库类型',
},
fieldName: 'type',
label: '出库类型',
},
{
component: 'ApiSelect',
componentProps: {
api: getSimpleUserList,
clearable: true,
filterable: true,
labelField: 'nickname',
placeholder: '请选择创建用户',
valueField: 'id',
},
fieldName: 'creator',
label: '创建用户',
},
{
component: 'ApiSelect',
componentProps: {
api: getSimpleUserList,
clearable: true,
filterable: true,
labelField: 'nickname',
placeholder: '请选择更新用户',
valueField: 'id',
},
fieldName: 'updater',
label: '更新用户',
},
{
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
clearable: true,
},
fieldName: 'createTime',
label: '创建时间',
},
{
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
clearable: true,
},
fieldName: 'updateTime',
label: '更新时间',
},
];
}
/** 列表表格列 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
fixed: 'left',
slots: { content: 'expand_content' },
type: 'expand',
width: 48,
},
{
field: 'no',
fixed: 'left',
slots: { default: 'no' },
title: '单号/业务单号',
width: 260,
},
{
align: 'center',
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.WMS_ORDER_STATUS },
},
field: 'status',
fixed: 'left',
title: '出库状态',
width: 110,
},
{
align: 'center',
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.WMS_SHIPMENT_ORDER_TYPE },
},
field: 'type',
title: '出库类型',
width: 120,
},
{
field: 'warehouseName',
minWidth: 180,
title: '仓库',
},
{
field: 'quantityAmount',
minWidth: 180,
slots: { default: 'quantityAmount' },
title: '总数量/总金额(元)',
},
{
field: 'merchantName',
minWidth: 160,
title: '客户',
},
{
field: 'operateInfo',
minWidth: 260,
slots: { default: 'operateInfo' },
title: '操作信息',
},
{
field: 'remark',
minWidth: 160,
title: '备注',
},
{
field: 'actions',
fixed: 'right',
slots: { default: 'actions' },
title: '操作',
width: 220,
},
];
}
/** 详情的字段 */
export function useDetailSchema(): DescriptionItemSchema[] {
return [
{
field: 'no',
label: '出库单号',
render: (val) => val || '-',
},
{
field: 'type',
label: '出库类型',
render: (val) =>
val === undefined || val === null
? '-'
: h(DictTag, {
type: DICT_TYPE.WMS_SHIPMENT_ORDER_TYPE,
value: val,
}),
},
{
field: 'warehouseName',
label: '仓库',
render: (val) => val || '-',
},
{
field: 'status',
label: '单据状态',
render: (val) =>
val === undefined || val === null
? '-'
: h(DictTag, {
type: DICT_TYPE.WMS_ORDER_STATUS,
value: val,
}),
},
{
field: 'orderTime',
label: '单据日期',
render: (val) => formatDate(val, 'YYYY-MM-DD') || '-',
},
{
field: 'merchantName',
label: '客户',
render: (val) => val || '-',
},
{
field: 'bizOrderNo',
label: '业务单号',
render: (val) => val || '-',
},
{
field: 'totalQuantity',
label: '总数量',
render: (val) => formatQuantity(val) || '-',
},
{
field: 'totalPrice',
label: '总金额',
render: (val) => formatPrice(val) || '-',
},
{
field: 'createTime',
label: '创建时间',
render: (val) => formatDateTime(val) || '-',
},
{
field: 'creatorName',
label: '创建人',
render: (val, data) => val || data?.creator || '-',
},
{
field: 'updateTime',
label: '更新时间',
render: (val) => formatDateTime(val) || '-',
},
{
field: 'updaterName',
label: '更新人',
render: (val, data) => val || data?.updater || '-',
},
{
field: 'remark',
label: '备注',
render: (val) => val || '-',
span: 2,
},
];
}
interface ShipmentFormSchemaOptions {
onWarehouseChange: (warehouse?: WmsWarehouseApi.Warehouse) => void;
}
/** 表单的配置项 */
export function useFormSchema({
onWarehouseChange,
}: ShipmentFormSchemaOptions): VbenFormSchema[] {
return [
{
component: 'Input',
dependencies: {
show: () => false,
triggerFields: [''],
},
fieldName: 'id',
},
{
component: 'Input',
componentProps: {
maxLength: 64,
placeholder: '请输入出库单号',
},
fieldName: 'no',
label: '出库单号',
rules: z.string().min(1, '出库单号不能为空').max(64),
},
{
component: 'Select',
componentProps: {
clearable: true,
options: getDictOptions(DICT_TYPE.WMS_SHIPMENT_ORDER_TYPE, 'number'),
placeholder: '请选择出库类型',
},
fieldName: 'type',
label: '出库类型',
rules: 'required',
},
{
component: markRaw(WmsWarehouseSelect),
componentProps: {
onChange: onWarehouseChange,
},
fieldName: 'warehouseId',
label: '仓库',
rules: 'required',
},
{
component: 'DatePicker',
componentProps: {
class: 'w-full',
format: 'YYYY-MM-DD',
placeholder: '请选择单据日期',
valueFormat: 'x',
},
fieldName: 'orderTime',
label: '单据日期',
rules: 'required',
},
{
component: markRaw(WmsMerchantSelect),
componentProps: {
customer: true,
placeholder: '请选择客户',
},
fieldName: 'merchantId',
label: '客户',
},
{
component: 'Input',
componentProps: {
maxLength: 64,
placeholder: '请输入业务单号',
},
fieldName: 'bizOrderNo',
label: '业务单号',
},
{
component: 'Textarea',
componentProps: {
maxLength: 255,
placeholder: '请输入备注',
},
fieldName: 'remark',
formItemClass: 'col-span-2',
label: '备注',
},
];
}
interface ShipmentOrderDetailFooterRow {
quantity?: number;
totalPrice?: number;
}
type ShipmentOrderDetailFooterColumn = Pick<
NonNullable<NonNullable<VxeTableGridOptions['columns']>[number]>,
'field'
>;
/** 明细表格的合计行 */
export function getDetailFooter({
columns,
data,
}: {
columns: ShipmentOrderDetailFooterColumn[];
data: ShipmentOrderDetailFooterRow[];
}) {
return [
columns.map((column, index) => {
if (index === 0) {
return '合计';
}
if (column.field === 'quantity') {
return formatSumQuantity(data, (detail) => detail.quantity);
}
if (column.field === 'totalPrice') {
return formatSumPrice(data, (detail) => detail.totalPrice);
}
return '';
}),
];
}

View File

@ -0,0 +1,346 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { WmsShipmentOrderApi } from '#/api/wms/order/shipment';
import type { WmsShipmentOrderDetailApi } from '#/api/wms/order/shipment/detail';
import { reactive, ref } from 'vue';
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart, formatDateTime } from '@vben/utils';
import { ElLoading, ElMessage } from 'element-plus';
import {
ACTION_ICON,
TableAction,
useVbenVxeGrid,
VxeColumn,
VxeTable,
} from '#/adapter/vxe-table';
import {
deleteShipmentOrder,
exportShipmentOrder,
getShipmentOrderDetailListByOrderId,
getShipmentOrderPage,
} from '#/api/wms/order/shipment';
import { $t } from '#/locales';
import {
OrderDeleteStatusList,
OrderStatusEnum,
OrderUpdateStatusList,
} from '#/views/wms/utils/constants';
import { formatPrice, formatQuantity, multiplyPrice } from '#/views/wms/utils/format';
import { useGridColumns, useGridFormSchema } from './data';
import ShipmentOrderDetail from './modules/detail.vue';
import ShipmentOrderForm from './modules/form.vue';
import ShipmentOrderPrint from './modules/print.vue';
defineOptions({ name: 'WmsShipmentOrder' });
const printRef = ref<InstanceType<typeof ShipmentOrderPrint>>();
const detailMap = reactive<
Record<number, WmsShipmentOrderDetailApi.ShipmentOrderDetail[]>
>({});
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: ShipmentOrderForm,
destroyOnClose: true,
});
const [DetailModal, detailModalApi] = useVbenModal({
connectedComponent: ShipmentOrderDetail,
destroyOnClose: true,
});
/** 清空展开明细缓存 */
function clearDetailMap() {
for (const id of Object.keys(detailMap)) {
delete detailMap[Number(id)];
}
}
/** 刷新表格 */
function handleRefresh() {
clearDetailMap();
gridApi.query();
}
/** 创建出库单 */
function handleCreate() {
formModalApi.setData({ type: 'create' }).open();
}
/** 编辑出库单 */
function handleEdit(row: WmsShipmentOrderApi.ShipmentOrder) {
formModalApi.setData({ id: row.id!, type: 'update' }).open();
}
/** 查看出库单详情 */
function handleDetail(row: WmsShipmentOrderApi.ShipmentOrder) {
detailModalApi.setData({ id: row.id! }).open();
}
/** 计算单据明细金额 */
function getDetailTotalPrice(
detail: WmsShipmentOrderDetailApi.ShipmentOrderDetail,
) {
return detail.totalPrice ?? multiplyPrice(detail.quantity, detail.price);
}
/** 获取已展开行的明细 */
function getExpandedDetails(row: WmsShipmentOrderApi.ShipmentOrder) {
return detailMap[row.id!] || [];
}
/** 展开列表行时懒加载出库明细 */
async function handleExpandChange(
row: WmsShipmentOrderApi.ShipmentOrder,
expanded: boolean,
) {
if (!expanded) {
return;
}
delete detailMap[row.id!];
detailMap[row.id!] = await getShipmentOrderDetailListByOrderId(row.id!);
}
/** 判断出库单是否可修改 */
function canUpdateShipmentOrder(status?: number) {
return status !== undefined && OrderUpdateStatusList.includes(status);
}
/** 判断出库单是否可删除 */
function canDeleteShipmentOrder(status?: number) {
return status !== undefined && OrderDeleteStatusList.includes(status);
}
/** 获取修改按钮禁用提示 */
function getShipmentOrderUpdateTip(status?: number) {
if (canUpdateShipmentOrder(status)) {
return undefined;
}
if (status === OrderStatusEnum.FINISHED) {
return '已出库,无法修改';
}
if (status === OrderStatusEnum.CANCELED) {
return '已作废,无法修改';
}
return '当前状态无法修改';
}
/** 获取删除按钮禁用提示 */
function getShipmentOrderDeleteTip(status?: number) {
if (canDeleteShipmentOrder(status)) {
return undefined;
}
if (status === OrderStatusEnum.FINISHED) {
return '已出库,无法删除';
}
return '当前状态无法删除';
}
/** 删除出库单 */
async function handleDelete(row: WmsShipmentOrderApi.ShipmentOrder) {
const loadingInstance = ElLoading.service({
text: $t('ui.actionMessage.deleting', [row.no]),
});
try {
await deleteShipmentOrder(row.id!);
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.no]));
handleRefresh();
} finally {
loadingInstance.close();
}
}
/** 导出出库单 */
async function handleExport() {
const data = await exportShipmentOrder(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '出库单.xls', source: data });
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
collapsed: true,
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
expandConfig: {
padding: true,
},
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getShipmentOrderPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
isHover: true,
keyField: 'id',
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<WmsShipmentOrderApi.ShipmentOrder>,
gridEvents: {
toggleRowExpand: ({
expanded,
row,
}: {
expanded: boolean;
row: WmsShipmentOrderApi.ShipmentOrder;
}) => {
handleExpandChange(row, expanded);
},
},
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="【单据】出库" url="https://doc.iocoder.cn/wms/order/shipment/" />
</template>
<FormModal @success="handleRefresh" />
<DetailModal />
<ShipmentOrderPrint ref="printRef" />
<Grid table-title="">
<template #expand_content="{ row }">
<VxeTable
:data="getExpandedDetails(row)"
border
:show-overflow="true"
size="small"
>
<VxeColumn title="商品信息" min-width="220">
<template #default="{ row: detail }">
<div>{{ detail.itemName || '-' }}</div>
<div v-if="detail.itemCode" class="text-xs text-gray-500">
商品编号{{ detail.itemCode }}
</div>
</template>
</VxeColumn>
<VxeColumn title="规格信息" min-width="220">
<template #default="{ row: detail }">
<div>{{ detail.skuName || '-' }}</div>
<div v-if="detail.skuCode" class="text-xs text-gray-500">
规格编号{{ detail.skuCode }}
</div>
</template>
</VxeColumn>
<VxeColumn title="出库数量" align="right" width="120">
<template #default="{ row: detail }">
{{ formatQuantity(detail.quantity) }}
</template>
</VxeColumn>
<VxeColumn title="单价(元)" align="right" width="120">
<template #default="{ row: detail }">
{{ formatPrice(detail.price) || '-' }}
</template>
</VxeColumn>
<VxeColumn title="金额(元)" align="right" width="120">
<template #default="{ row: detail }">
{{ formatPrice(getDetailTotalPrice(detail)) || '-' }}
</template>
</VxeColumn>
</VxeTable>
</template>
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['出库单']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['wms:shipment-order:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['wms:shipment-order:export'],
onClick: handleExport,
},
]"
/>
</template>
<template #no="{ row }">
<div>
单号
<a class="text-primary" @click="handleDetail(row)">{{ row.no }}</a>
</div>
<div v-if="row.bizOrderNo" class="text-xs text-gray-500">
业务{{ row.bizOrderNo }}
</div>
</template>
<template #quantityAmount="{ row }">
<div class="flex items-center justify-between">
<span>数量</span>
<span>{{ formatQuantity(row.totalQuantity) }}</span>
</div>
<div class="flex items-center justify-between">
<span>金额</span>
<span>{{ formatPrice(row.totalPrice) }}</span>
</div>
</template>
<template #operateInfo="{ row }">
<div>
创建{{ formatDateTime(row.createTime) || '-' }} /
{{ row.creatorName || row.creator || '-' }}
</div>
<div>
更新{{ formatDateTime(row.updateTime) || '-' }} /
{{ row.updaterName || row.updater || '-' }}
</div>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
disabled: !canUpdateShipmentOrder(row.status),
tooltip: getShipmentOrderUpdateTip(row.status),
auth: ['wms:shipment-order:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
disabled: !canDeleteShipmentOrder(row.status),
tooltip: getShipmentOrderDeleteTip(row.status),
auth: ['wms:shipment-order:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.no]),
confirm: handleDelete.bind(null, row),
},
},
{
label: '打印',
type: 'primary',
link: true,
auth: ['wms:shipment-order:query'],
onClick: () => printRef?.print(row.id!),
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,120 @@
<script lang="ts" setup>
import type { WmsShipmentOrderApi } from '#/api/wms/order/shipment';
import type { WmsShipmentOrderDetailApi } from '#/api/wms/order/shipment/detail';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { VxeColumn, VxeTable } from '#/adapter/vxe-table';
import {
getShipmentOrder,
getShipmentOrderDetailListByOrderId,
} from '#/api/wms/order/shipment';
import { useDescription } from '#/components/description';
import {
formatPrice,
formatQuantity,
multiplyPrice,
} from '#/views/wms/utils/format';
import { getDetailFooter, useDetailSchema } from '../data';
interface DetailRow extends WmsShipmentOrderDetailApi.ShipmentOrderDetail {
totalPrice?: number;
}
defineOptions({ name: 'WmsShipmentOrderDetail' });
const detailData = ref<WmsShipmentOrderApi.ShipmentOrder>({});
const detailRows = computed<DetailRow[]>(() =>
(detailData.value.details || []).map((detail) => ({
...detail,
totalPrice: detail.totalPrice ?? multiplyPrice(detail.quantity, detail.price),
})),
);
const [Descriptions] = useDescription({
border: true,
column: 2,
schema: useDetailSchema(),
});
const [Modal, modalApi] = useVbenModal({
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
detailData.value = {};
return;
}
const data = modalApi.getData<{ id?: number }>();
if (!data?.id) {
return;
}
modalApi.lock();
try {
const order = await getShipmentOrder(data.id);
const details =
order.details || (await getShipmentOrderDetailListByOrderId(data.id));
detailData.value = { ...order, details };
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal
title="出库单详情"
class="w-2/3"
:show-cancel-button="false"
:show-confirm-button="false"
>
<div class="mx-4 space-y-4">
<Descriptions :data="detailData" />
<VxeTable
:data="detailRows"
border
empty-text="暂无商品明细"
:footer-method="getDetailFooter"
:show-overflow="true"
show-footer
size="small"
>
<VxeColumn title="商品信息" min-width="220">
<template #default="{ row }">
<div>{{ row.itemName || '-' }}</div>
<div v-if="row.itemCode" class="text-xs text-gray-500">
商品编号{{ row.itemCode }}
</div>
</template>
</VxeColumn>
<VxeColumn title="规格信息" min-width="220">
<template #default="{ row }">
<div>{{ row.skuName || '-' }}</div>
<div v-if="row.skuCode" class="text-xs text-gray-500">
规格编号{{ row.skuCode }}
</div>
</template>
</VxeColumn>
<VxeColumn field="quantity" title="数量" align="right" width="120">
<template #default="{ row }">
{{ formatQuantity(row.quantity) || '-' }}
</template>
</VxeColumn>
<VxeColumn field="unit" title="单位" align="center" width="100" />
<VxeColumn title="单价" align="right" width="140">
<template #default="{ row }">
{{ formatPrice(row.price) || '-' }}
</template>
</VxeColumn>
<VxeColumn field="totalPrice" title="总价" align="right" width="140">
<template #default="{ row }">
{{ formatPrice(row.totalPrice) || '-' }}
</template>
</VxeColumn>
</VxeTable>
</div>
</Modal>
</template>

View File

@ -0,0 +1,535 @@
<script lang="ts" setup>
import type { VxeTableInstance } from '#/adapter/vxe-table';
import type { WmsWarehouseApi } from '#/api/wms/md/warehouse';
import type { WmsShipmentOrderApi } from '#/api/wms/order/shipment';
import type { WmsShipmentOrderDetailApi } from '#/api/wms/order/shipment/detail';
import type { InventorySelectRow } from '#/views/wms/inventory/components/inventory-select.vue';
import { computed, nextTick, ref } from 'vue';
import { confirm, useVbenModal } from '@vben/common-ui';
import { isEqual } from '@vben/utils';
import { ElInputNumber, ElMessage } from 'element-plus';
import { useVbenForm } from '#/adapter/form';
import { TableAction, VxeColumn, VxeTable } from '#/adapter/vxe-table';
import {
cancelShipmentOrder,
completeShipmentOrder,
createShipmentOrder,
getShipmentOrder,
getShipmentOrderDetailListByOrderId,
updateShipmentOrder,
} from '#/api/wms/order/shipment';
import { $t } from '#/locales';
import { WmsInventorySelect } from '#/views/wms/inventory/components';
import {
OrderStatusEnum,
OrderUpdateStatusList,
} from '#/views/wms/utils/constants';
import {
dividePrice,
formatQuantity,
multiplyPrice,
PRICE_PRECISION,
QUANTITY_PRECISION,
} from '#/views/wms/utils/format';
import { generateOrderNo } from '#/views/wms/utils/order';
import { getDetailFooter, useFormSchema } from '../data';
interface DetailRow extends WmsShipmentOrderDetailApi.ShipmentOrderDetail {
seq: number;
}
defineOptions({ name: 'WmsShipmentOrderForm' });
const emit = defineEmits<{
success: [];
}>();
const formData = ref<WmsShipmentOrderApi.ShipmentOrder>({});
const formMode = ref('create');
const originalSubmitData = ref<WmsShipmentOrderApi.ShipmentOrder>();
const details = ref<DetailRow[]>([]);
const detailTableRef = ref<VxeTableInstance>();
const inventorySelectRef = ref<InstanceType<typeof WmsInventorySelect>>();
const currentWarehouseId = ref<number>();
const initializing = ref(false);
let detailSeq = 0; // id使 VXE
const getTitle = computed(() => {
return formMode.value === 'update'
? $t('ui.actionTitle.edit', ['出库单'])
: $t('ui.actionTitle.create', ['出库单']);
});
const isPrepareOrder = computed(() => {
return (
!formData.value?.id ||
(formData.value.status !== undefined &&
OrderUpdateStatusList.includes(formData.value.status))
);
});
const isSavedPrepareOrder = computed(() => {
return (
!!formData.value?.id &&
formData.value.status !== undefined &&
OrderUpdateStatusList.includes(formData.value.status)
);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
labelWidth: 100,
},
layout: 'horizontal',
schema: useFormSchema({ onWarehouseChange: handleWarehouseChange }),
showDefaultActions: false,
wrapperClass: 'grid-cols-3',
});
/** 标准化明细行,补齐本地序号和金额 */
function normalizeDetail(
detail: WmsShipmentOrderDetailApi.ShipmentOrderDetail,
): DetailRow {
detailSeq += 1;
return {
...detail,
seq: detailSeq,
totalPrice: detail.totalPrice ?? multiplyPrice(detail.quantity, detail.price),
};
}
/** 根据库存构建新的出库明细 */
function buildDetail(inventory: InventorySelectRow): DetailRow {
return normalizeDetail({
availableQuantity: inventory.availableQuantity,
id: undefined,
itemCode: inventory.itemCode,
itemId: inventory.itemId,
itemName: inventory.itemName,
price: undefined,
quantity: undefined,
skuCode: inventory.skuCode,
skuId: inventory.skuId,
skuName: inventory.skuName,
totalPrice: undefined,
unit: inventory.unit,
warehouseId: inventory.warehouseId,
warehouseName: inventory.warehouseName,
});
}
/** 设置出库明细 */
function setDetails(list?: WmsShipmentOrderDetailApi.ShipmentOrderDetail[]) {
detailSeq = 0;
details.value = (list || []).map((detail) => normalizeDetail(detail));
void refreshDetailFooter();
}
/** 刷新明细合计行 */
async function refreshDetailFooter() {
await nextTick();
await detailTableRef.value?.updateFooter();
}
/** 添加商品明细 */
async function handleAddDetail() {
const values = (await formApi.getValues()) as WmsShipmentOrderApi.ShipmentOrder;
if (!values.warehouseId) {
ElMessage.warning('请先选择仓库');
return;
}
currentWarehouseId.value = values.warehouseId;
await nextTick();
inventorySelectRef.value?.open(getSelectedInventoryKeys());
}
/** 选择库存 */
function handleSelectInventory(inventories: InventorySelectRow[]) {
if (inventories.length === 0) {
return;
}
let changed = false;
for (const inventory of inventories) {
if (!inventory.skuId || isInventorySelected(inventory)) {
continue;
}
details.value.push(buildDetail(inventory));
changed = true;
}
if (changed) {
void refreshDetailFooter();
}
}
/** 判断库存是否已选择 */
function isInventorySelected(inventory: InventorySelectRow) {
return details.value.some((detail) => {
return (
detail.skuId === inventory.skuId &&
detail.warehouseId === inventory.warehouseId
);
});
}
/** 获得已选择的库存标识 */
function getSelectedInventoryKeys() {
return details.value
.map((detail) =>
detail.skuId && detail.warehouseId
? `${detail.skuId}-${detail.warehouseId}`
: undefined,
)
.filter((key): key is string => !!key);
}
/** 删除商品明细 */
function handleDeleteDetail(row: DetailRow) {
const index = details.value.findIndex((detail) => detail.seq === row.seq);
if (index !== -1) {
details.value.splice(index, 1);
void refreshDetailFooter();
}
}
/** 仓库变化时清空出库明细 */
function handleWarehouseChange(warehouse?: WmsWarehouseApi.Warehouse) {
if (initializing.value) {
return;
}
formData.value.warehouseId = warehouse?.id;
formData.value.warehouseName = warehouse?.name;
currentWarehouseId.value = warehouse?.id;
setDetails([]);
}
/** 明细数量变化 */
function handleDetailQuantityChange(detail: DetailRow) {
if (detail.price !== undefined && detail.price !== null) {
detail.totalPrice = multiplyPrice(detail.quantity, detail.price);
void refreshDetailFooter();
return;
}
detail.price = dividePrice(detail.totalPrice, detail.quantity);
void refreshDetailFooter();
}
/** 明细单价变化 */
function handleDetailPriceChange(detail: DetailRow) {
detail.totalPrice = multiplyPrice(detail.quantity, detail.price);
void refreshDetailFooter();
}
/** 明细金额变化 */
function handleDetailTotalPriceChange(detail: DetailRow) {
detail.price = dividePrice(detail.totalPrice, detail.quantity);
void refreshDetailFooter();
}
/** 校验商品明细 */
function validateDetails(required = false) {
if (details.value.length === 0) {
if (required) {
ElMessage.error('至少包含一条出库明细');
return false;
}
return true;
}
for (let index = 0; index < details.value.length; index += 1) {
const detail = details.value[index]!;
if (!detail.skuId) {
ElMessage.error(`${index + 1} 行明细请选择商品规格`);
return false;
}
if (!detail.quantity || detail.quantity <= 0) {
ElMessage.error(`${index + 1} 行明细出库数量必须大于 0`);
return false;
}
if (
detail.availableQuantity !== undefined &&
detail.quantity > detail.availableQuantity
) {
ElMessage.error(`${index + 1} 行明细出库数量不能大于可用库存`);
return false;
}
}
return true;
}
/** 构建提交用的明细数据 */
function buildSubmitDetails() {
return details.value.map((row) => {
const { seq: _seq, ...detail } = row;
return detail;
});
}
/** 构建提交用的单据数据 */
async function buildSubmitData(): Promise<WmsShipmentOrderApi.ShipmentOrder> {
const values = (await formApi.getValues()) as WmsShipmentOrderApi.ShipmentOrder;
const {
details: _details,
totalPrice: _totalPrice,
totalQuantity: _totalQuantity,
...order
} = formData.value;
return {
...order,
...values,
details: buildSubmitDetails(),
};
}
/** 完成出库 */
async function handleFormComplete() {
const { valid } = await formApi.validate();
if (!valid || !validateDetails(true) || !formData.value?.id) {
return;
}
await confirm('确认完成出库?完成后将更新库存。');
modalApi.lock();
try {
const data = await buildSubmitData();
if (!isEqual(data, originalSubmitData.value)) {
await updateShipmentOrder(data);
}
await completeShipmentOrder(formData.value.id);
await modalApi.close();
emit('success');
ElMessage.success('出库成功');
} finally {
modalApi.unlock();
}
}
/** 作废出库单 */
async function handleFormCancel() {
if (!formData.value?.id) {
return;
}
await confirm('确认作废该出库单?作废后不可恢复。');
modalApi.lock();
try {
await cancelShipmentOrder(formData.value.id);
await modalApi.close();
emit('success');
ElMessage.success('作废成功');
} finally {
modalApi.unlock();
}
}
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid || !validateDetails(false) || !isPrepareOrder.value) {
return;
}
modalApi.lock();
//
const data = await buildSubmitData();
try {
await (formMode.value === 'update'
? updateShipmentOrder(data)
: createShipmentOrder(data));
//
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = {};
originalSubmitData.value = undefined;
currentWarehouseId.value = undefined;
setDetails([]);
return;
}
initializing.value = true;
await formApi.resetForm();
const data = modalApi.getData<{
id?: number;
type?: string;
}>();
formMode.value = data?.type || (data?.id ? 'update' : 'create');
if (data?.id) {
modalApi.lock();
try {
//
const order = await getShipmentOrder(data.id);
const orderDetails =
order.details || (await getShipmentOrderDetailListByOrderId(data.id));
formData.value = { ...order, details: orderDetails };
currentWarehouseId.value = order.warehouseId;
setDetails(orderDetails);
// values
await formApi.setValues(formData.value);
await nextTick();
originalSubmitData.value = await buildSubmitData();
} finally {
initializing.value = false;
modalApi.unlock();
}
return;
}
//
formData.value = {
details: [],
no: generateOrderNo('CK'),
status: OrderStatusEnum.PREPARE,
};
currentWarehouseId.value = undefined;
setDetails([]);
await formApi.setValues(formData.value);
await nextTick();
originalSubmitData.value = await buildSubmitData();
initializing.value = false;
},
});
</script>
<template>
<Modal
:title="getTitle"
class="w-3/4"
:show-confirm-button="isPrepareOrder"
>
<div class="mx-4">
<Form />
<div class="mt-4">
<div class="mb-3 flex items-center justify-between">
<span class="text-sm font-semibold">出库明细</span>
<TableAction
:actions="[
{
label: '添加商品',
onClick: handleAddDetail,
type: 'primary',
},
]"
/>
</div>
<VxeTable
ref="detailTableRef"
:data="details"
border
empty-text="暂无商品明细"
:footer-method="getDetailFooter"
:show-overflow="true"
show-footer
size="small"
>
<VxeColumn title="商品信息" min-width="220">
<template #default="{ row }">
<div>{{ row.itemName || '-' }}</div>
<div v-if="row.itemCode" class="text-xs text-gray-500">
商品编号{{ row.itemCode }}
</div>
</template>
</VxeColumn>
<VxeColumn title="规格信息" min-width="220">
<template #default="{ row }">
<div>{{ row.skuName || '-' }}</div>
<div v-if="row.skuCode" class="text-xs text-gray-500">
规格编号{{ row.skuCode }}
</div>
</template>
</VxeColumn>
<VxeColumn field="availableQuantity" title="可用库存" align="right" width="120">
<template #default="{ row }">
{{ formatQuantity(row.availableQuantity) || '-' }}
</template>
</VxeColumn>
<VxeColumn field="quantity" title="出库数量" width="150">
<template #default="{ row }">
<ElInputNumber
v-model="row.quantity"
:controls="false"
:min="0"
:precision="QUANTITY_PRECISION"
class="!w-full"
placeholder="数量"
@change="handleDetailQuantityChange(row)"
/>
</template>
</VxeColumn>
<VxeColumn field="price" title="单价(元)" width="150">
<template #default="{ row }">
<ElInputNumber
v-model="row.price"
:controls="false"
:min="0"
:precision="PRICE_PRECISION"
class="!w-full"
placeholder="单价"
@change="handleDetailPriceChange(row)"
/>
</template>
</VxeColumn>
<VxeColumn field="totalPrice" title="金额(元)" width="150">
<template #default="{ row }">
<ElInputNumber
v-model="row.totalPrice"
:controls="false"
:min="0"
:precision="PRICE_PRECISION"
class="!w-full"
placeholder="金额"
@change="handleDetailTotalPriceChange(row)"
/>
</template>
</VxeColumn>
<VxeColumn title="操作" align="center" fixed="right" width="90">
<template #default="{ row }">
<TableAction
:actions="[
{
label: '删除',
type: 'danger',
link: true,
onClick: handleDeleteDetail.bind(null, row),
},
]"
/>
</template>
</VxeColumn>
</VxeTable>
</div>
</div>
<template #prepend-footer>
<div v-if="isSavedPrepareOrder" class="flex flex-auto items-center gap-2">
<TableAction
:actions="[
{
label: '完成出库',
type: 'primary',
auth: ['wms:shipment-order:complete'],
onClick: handleFormComplete,
},
{
label: '作废',
type: 'danger',
link: true,
auth: ['wms:shipment-order:cancel'],
onClick: handleFormCancel,
},
]"
/>
</div>
</template>
<WmsInventorySelect
ref="inventorySelectRef"
:warehouse-id="currentWarehouseId"
@change="handleSelectInventory"
/>
</Modal>
</template>

View File

@ -0,0 +1,231 @@
<script lang="ts" setup>
import type { WmsShipmentOrderApi } from '#/api/wms/order/shipment';
import type { WmsShipmentOrderDetailApi } from '#/api/wms/order/shipment/detail';
import { computed, nextTick, ref } from 'vue';
import { Barcode, BarcodeFormatEnum } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictLabel } from '@vben/hooks';
import { formatDate, formatDateTime } from '@vben/utils';
import {
getShipmentOrder,
getShipmentOrderDetailListByOrderId,
} from '#/api/wms/order/shipment';
import {
formatPrice,
formatQuantity,
formatSumPrice,
formatSumQuantity,
multiplyPrice,
} from '#/views/wms/utils/format';
interface PrintRow extends WmsShipmentOrderDetailApi.ShipmentOrderDetail {
totalPrice?: number;
}
defineOptions({ name: 'WmsShipmentOrderPrint' });
const printData = ref<WmsShipmentOrderApi.ShipmentOrder>({});
const tableColumnCount = 5;
const printRows = computed<PrintRow[]>(() =>
(printData.value.details || []).map((detail) => ({
...detail,
totalPrice: detail.totalPrice ?? multiplyPrice(detail.quantity, detail.price),
})),
);
/** 等待条码和打印 DOM 完成绘制,避免浏览器打印到旧内容 */
function waitForPaint() {
return new Promise<void>((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => resolve());
});
});
}
/** 退出打印模式,恢复当前页面显示 */
function removePrintMode() {
document.body.classList.remove('wms-shipment-order-printing');
}
/** 获取打印用字典文案,空值统一显示为横杠 */
function getPrintDictLabel(dictType: string, value?: number) {
if (value === undefined || value === null) {
return '-';
}
return getDictLabel(dictType, value) || '-';
}
/** 打印出库单:加载数据后只展示打印区域,再调用浏览器打印 */
async function print(id: number) {
const order = await getShipmentOrder(id);
const details = order.details || (await getShipmentOrderDetailListByOrderId(id));
printData.value = { ...order, details };
await nextTick();
await waitForPaint();
document.body.classList.add('wms-shipment-order-printing');
window.addEventListener('afterprint', removePrintMode, { once: true });
window.print();
}
defineExpose({ print });
</script>
<template>
<Teleport to="body">
<div
id="wmsShipmentOrderPrint"
class="wms-shipment-order-print pointer-events-none fixed left-0 top-0 z-[-1] w-full bg-white text-[#303133] opacity-0"
>
<div class="relative mb-2">
<h2 class="m-0 text-center text-[1.5em] font-bold leading-[1.2]">
出库单
</h2>
<div v-if="printData.no" class="absolute right-0 top-0">
<Barcode
:content="printData.no"
:display-value="false"
:format="BarcodeFormatEnum.CODE39"
:height="40"
:width="180"
/>
</div>
</div>
<div class="mb-3 grid grid-cols-3 gap-x-6 gap-y-2 text-sm leading-[1.5]">
<div>出库单号{{ printData.no || '-' }}</div>
<div>
出库类型{{ getPrintDictLabel(DICT_TYPE.WMS_SHIPMENT_ORDER_TYPE, printData.type) }}
</div>
<div>仓库{{ printData.warehouseName || '-' }}</div>
<div>
出库状态{{ getPrintDictLabel(DICT_TYPE.WMS_ORDER_STATUS, printData.status) }}
</div>
<div>单据日期{{ formatDate(printData.orderTime, 'YYYY-MM-DD') || '-' }}</div>
<div>客户{{ printData.merchantName || '-' }}</div>
<div>业务单号{{ printData.bizOrderNo || '-' }}</div>
<div>总数量{{ formatQuantity(printData.totalQuantity) || '-' }}</div>
<div>总金额{{ formatPrice(printData.totalPrice) || '-' }}</div>
<div class="col-span-3 grid grid-cols-2 gap-x-6">
<div>
创建{{ formatDateTime(printData.createTime) || '-' }} /
{{ printData.creatorName || printData.creator || '-' }}
</div>
<div>
更新{{ formatDateTime(printData.updateTime) || '-' }} /
{{ printData.updaterName || printData.updater || '-' }}
</div>
</div>
<div class="col-span-3">备注{{ printData.remark || '-' }}</div>
</div>
<table class="w-full border-collapse text-[13px] leading-[1.5]">
<thead>
<tr>
<th class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-left font-bold">
商品信息
</th>
<th class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-left font-bold">
规格信息
</th>
<th class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-left font-bold">
数量
</th>
<th class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-left font-bold">
单价()
</th>
<th class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-left font-bold">
金额()
</th>
</tr>
</thead>
<tbody>
<tr v-for="detail in printRows" :key="detail.id || detail.skuId">
<td class="border border-solid border-[#dcdfe6] p-2">
<div>{{ detail.itemName || '-' }}</div>
<div v-if="detail.itemCode" class="text-xs">
编号{{ detail.itemCode }}
</div>
</td>
<td class="border border-solid border-[#dcdfe6] p-2">
<div>{{ detail.skuName || '-' }}</div>
<div v-if="detail.skuCode" class="text-xs">
编号{{ detail.skuCode }}
</div>
</td>
<td class="border border-solid border-[#dcdfe6] p-2 text-right">
{{ formatQuantity(detail.quantity) || '-' }}
</td>
<td class="border border-solid border-[#dcdfe6] p-2 text-right">
{{ formatPrice(detail.price) || '-' }}
</td>
<td class="border border-solid border-[#dcdfe6] p-2 text-right">
{{ formatPrice(detail.totalPrice) || '-' }}
</td>
</tr>
<tr v-if="printRows.length > 0">
<td
class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2"
colspan="2"
>
合计
</td>
<td class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-right">
{{ formatSumQuantity(printRows, (detail) => detail.quantity) }}
</td>
<td class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-right"></td>
<td class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-right">
{{ formatSumPrice(printRows, (detail) => detail.totalPrice) }}
</td>
</tr>
<tr v-if="printRows.length === 0">
<td
class="border border-solid border-[#dcdfe6] p-2 text-center"
:colspan="tableColumnCount"
>
暂无明细
</td>
</tr>
</tbody>
</table>
</div>
</Teleport>
</template>
<style scoped>
@page {
margin: 8mm 10mm;
}
@media print {
:global(body.wms-shipment-order-printing) {
-webkit-print-color-adjust: exact;
margin: 0 !important;
padding: 0 !important;
print-color-adjust: exact;
}
:global(body.wms-shipment-order-printing *) {
visibility: hidden !important;
}
:global(body.wms-shipment-order-printing .wms-shipment-order-print),
:global(body.wms-shipment-order-printing .wms-shipment-order-print *) {
visibility: visible !important;
}
:global(body.wms-shipment-order-printing .wms-shipment-order-print) {
pointer-events: auto;
position: absolute;
top: 0;
left: 0;
z-index: auto;
box-sizing: border-box;
width: 100%;
margin: 0 !important;
padding: 0 !important;
opacity: 1;
}
}
</style>