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

pull/345/head
YunaiV 2026-05-18 21:56:44 +08:00
parent 584370358e
commit 4cded0a674
10 changed files with 4097 additions and 0 deletions

View File

@ -0,0 +1,483 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { DescriptionItemSchema } from '#/components/description';
import type { NumberRangeValue } from '#/components/number-range-input';
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,
NumberRangeInput,
} from '#/components/number-range-input';
import { getRangePickerDefaultProps } from '#/utils';
import { WmsWarehouseSelect } from '#/views/wms/md/warehouse/components';
import {
formatPrice,
formatQuantity,
formatSumPrice,
formatSumQuantity,
getLossClass,
PRICE_PRECISION,
QUANTITY_PRECISION,
roundPrice,
sumPrice,
sumQuantity,
} from '#/views/wms/utils/format';
/** 拆分数量/金额区间字段,适配后端 Min/Max 查询参数 */
function splitNumberRange(minFieldName: string, maxFieldName: string) {
return (
value: NumberRangeValue | undefined,
setValue: (fieldName: string, value: number | undefined) => void,
) => {
setValue(minFieldName, value?.[0]);
setValue(maxFieldName, value?.[1]);
return undefined;
};
}
/** 构建允许负数的区间搜索项,盘库盈亏数量需要支持盘亏 */
function buildSignedNumberRangeSchema(
label: string,
fieldName: string,
minFieldName: string,
maxFieldName: string,
precision: number,
): VbenFormSchema {
return {
component: markRaw(NumberRangeInput),
componentProps: {
precision,
},
fieldName,
label,
valueFormat: splitNumberRange(minFieldName, maxFieldName),
};
}
/** 计算单据盈亏金额 */
export function getOrderDifferencePrice(order: {
actualPrice?: number;
totalPrice?: number;
}) {
return roundPrice(Number(order.actualPrice || 0) - Number(order.totalPrice || 0));
}
/** 计算明细盈亏数量 */
export function getDetailDifferenceQuantity(detail: {
checkQuantity?: number;
quantity?: number;
}) {
return Number(detail.checkQuantity || 0) - Number(detail.quantity || 0);
}
/** 计算明细实际金额 */
export function getDetailActualPrice(detail: {
checkQuantity?: number;
price?: number;
}) {
if (
detail.checkQuantity === undefined ||
detail.checkQuantity === null ||
detail.price === undefined ||
detail.price === null
) {
return undefined;
}
return roundPrice(Number(detail.checkQuantity) * Number(detail.price));
}
/** 计算明细盈亏金额 */
export function getDetailDifferencePrice(detail: {
checkQuantity?: number;
price?: number;
quantity?: number;
}) {
if (detail.price === undefined || detail.price === null) {
return undefined;
}
return roundPrice(getDetailDifferenceQuantity(detail) * Number(detail.price));
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入盘库单号',
},
fieldName: 'no',
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: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
fieldName: 'orderTime',
label: '单据日期',
},
buildSignedNumberRangeSchema(
'盈亏数量',
'totalQuantityRange',
'totalQuantityMin',
'totalQuantityMax',
QUANTITY_PRECISION,
),
buildNumberRangeSchema(
'总金额',
'totalPriceRange',
'totalPriceMin',
'totalPriceMax',
PRICE_PRECISION,
),
buildNumberRangeSchema(
'实际金额',
'actualPriceRange',
'actualPriceMin',
'actualPriceMax',
PRICE_PRECISION,
),
{
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: 210,
},
{
align: 'center',
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.WMS_ORDER_STATUS },
},
field: 'status',
fixed: 'left',
title: '盘库状态',
width: 110,
},
{
field: 'warehouseName',
minWidth: 180,
title: '仓库',
},
{
field: 'quantityAmount',
minWidth: 200,
slots: { default: 'quantityAmount' },
title: '盈亏/金额(元)',
},
{
field: 'operateInfo',
minWidth: 280,
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: 'warehouseName',
label: '仓库',
render: (val) => val || '-',
},
{
field: 'orderTime',
label: '单据日期',
render: (val) => formatDate(val, 'YYYY-MM-DD') || '-',
},
{
field: 'status',
label: '单据状态',
render: (val) =>
val === undefined || val === null
? '-'
: h(DictTag, {
type: DICT_TYPE.WMS_ORDER_STATUS,
value: val,
}),
},
{
field: 'totalQuantity',
label: '盈亏数量',
render: (val) =>
h('span', { class: getLossClass(val) }, formatQuantity(val) || '-'),
},
{
field: 'totalPrice',
label: '总金额',
render: (val) => formatPrice(val) || '-',
},
{
field: 'actualPrice',
label: '实际金额',
render: (val) => formatPrice(val) || '-',
},
{
field: 'differencePrice',
label: '实际盈亏金额',
render: (_val, data) => {
const differencePrice = getOrderDifferencePrice(data || {});
return h(
'span',
{ class: getLossClass(differencePrice) },
formatPrice(differencePrice) || '-',
);
},
},
{
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,
},
];
}
/** 表单的配置项 */
export function useFormSchema(): 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: markRaw(WmsWarehouseSelect),
componentProps: {
disabled: true,
},
fieldName: 'warehouseId',
label: '仓库',
rules: 'required',
},
{
component: 'DatePicker',
componentProps: {
class: 'w-full',
format: 'YYYY-MM-DD',
placeholder: '请选择单据日期',
valueFormat: 'x',
},
fieldName: 'orderTime',
label: '单据日期',
rules: 'required',
},
{
component: 'Input',
componentProps: {
disabled: true,
},
fieldName: 'actualPrice',
label: '实际金额',
},
{
component: 'Textarea',
componentProps: {
maxLength: 255,
placeholder: '请输入备注',
},
fieldName: 'remark',
formItemClass: 'col-span-2',
label: '备注',
},
];
}
/** 选择盘库仓库表单的配置项 */
export function useWarehouseFormSchema(
onWarehouseChange: (warehouse: unknown) => void,
): VbenFormSchema[] {
return [
{
component: markRaw(WmsWarehouseSelect),
componentProps: {
onChange: onWarehouseChange,
},
fieldName: 'warehouseId',
label: '仓库',
rules: 'required',
},
];
}
interface CheckOrderDetailFooterRow {
actualPrice?: number;
checkQuantity?: number;
differencePrice?: number;
differenceQuantity?: number;
quantity?: number;
}
type CheckOrderDetailFooterColumn = Pick<
NonNullable<NonNullable<VxeTableGridOptions['columns']>[number]>,
'field'
>;
/** 明细表格的合计行 */
export function getCheckDetailFooter({
columns,
data,
}: {
columns: CheckOrderDetailFooterColumn[];
data: CheckOrderDetailFooterRow[];
}) {
return [
columns.map((column, index) => {
if (index === 0) {
return '合计';
}
if (column.field === 'quantity') {
return formatSumQuantity(data, (detail) => detail.quantity);
}
if (column.field === 'checkQuantity') {
return formatSumQuantity(data, (detail) => detail.checkQuantity);
}
if (column.field === 'actualPrice') {
return formatSumPrice(data, (detail) => detail.actualPrice);
}
if (column.field === 'differenceQuantity') {
return formatQuantity(
sumQuantity(data, (detail) => detail.differenceQuantity),
);
}
if (column.field === 'differencePrice') {
return formatPrice(sumPrice(data, (detail) => detail.differencePrice));
}
return '';
}),
];
}

View File

@ -0,0 +1,377 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { WmsCheckOrderApi } from '#/api/wms/order/check';
import type { WmsCheckOrderDetailApi } from '#/api/wms/order/check/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 {
deleteCheckOrder,
exportCheckOrder,
getCheckOrderDetailListByOrderId,
getCheckOrderPage,
} from '#/api/wms/order/check';
import { $t } from '#/locales';
import {
OrderDeleteStatusList,
OrderStatusEnum,
OrderUpdateStatusList,
} from '#/views/wms/utils/constants';
import {
formatPrice,
formatQuantity,
getLossClass,
} from '#/views/wms/utils/format';
import {
getDetailActualPrice,
getDetailDifferencePrice,
getDetailDifferenceQuantity,
getOrderDifferencePrice,
useGridColumns,
useGridFormSchema,
} from './data';
import CheckOrderDetail from './modules/detail.vue';
import CheckOrderForm from './modules/form.vue';
import CheckOrderPrint from './modules/print.vue';
defineOptions({ name: 'WmsCheckOrder' });
const printRef = ref<InstanceType<typeof CheckOrderPrint>>();
const detailMap = reactive<Record<number, WmsCheckOrderDetailApi.CheckOrderDetail[]>>(
{},
);
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: CheckOrderForm,
destroyOnClose: true,
});
const [DetailModal, detailModalApi] = useVbenModal({
connectedComponent: CheckOrderDetail,
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: WmsCheckOrderApi.CheckOrder) {
formModalApi.setData({ id: row.id!, type: 'update' }).open();
}
/** 查看盘库单详情 */
function handleDetail(row: WmsCheckOrderApi.CheckOrder) {
detailModalApi.setData({ id: row.id! }).open();
}
/** 获取已展开行的明细 */
function getExpandedDetails(row: WmsCheckOrderApi.CheckOrder) {
return detailMap[row.id!] || [];
}
/** 展开列表行时懒加载盘库明细 */
async function handleExpandChange(
row: WmsCheckOrderApi.CheckOrder,
expanded: boolean,
) {
if (!expanded) {
return;
}
delete detailMap[row.id!];
detailMap[row.id!] = await getCheckOrderDetailListByOrderId(row.id!);
}
/** 判断盘库单是否可修改 */
function canUpdateCheckOrder(status?: number) {
return status !== undefined && OrderUpdateStatusList.includes(status);
}
/** 判断盘库单是否可删除 */
function canDeleteCheckOrder(status?: number) {
return status !== undefined && OrderDeleteStatusList.includes(status);
}
/** 获取修改按钮禁用提示 */
function getCheckOrderUpdateTip(status?: number) {
if (canUpdateCheckOrder(status)) {
return undefined;
}
if (status === OrderStatusEnum.FINISHED) {
return '已盘库,无法修改';
}
if (status === OrderStatusEnum.CANCELED) {
return '已作废,无法修改';
}
return '当前状态无法修改';
}
/** 获取删除按钮禁用提示 */
function getCheckOrderDeleteTip(status?: number) {
if (canDeleteCheckOrder(status)) {
return undefined;
}
if (status === OrderStatusEnum.FINISHED) {
return '已盘库,无法删除';
}
return '当前状态无法删除';
}
/** 删除盘库单 */
async function handleDelete(row: WmsCheckOrderApi.CheckOrder) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.no]),
duration: 0,
});
try {
await deleteCheckOrder(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.no]));
handleRefresh();
} finally {
hideLoading();
}
}
/** 导出盘库单 */
async function handleExport() {
const data = await exportCheckOrder(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 getCheckOrderPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
isHover: true,
keyField: 'id',
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<WmsCheckOrderApi.CheckOrder>,
gridEvents: {
toggleRowExpand: ({
expanded,
row,
}: {
expanded: boolean;
row: WmsCheckOrderApi.CheckOrder;
}) => {
handleExpandChange(row, expanded);
},
},
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="【单据】盘库" url="https://doc.iocoder.cn/wms/order/check/" />
</template>
<FormModal @success="handleRefresh" />
<DetailModal />
<CheckOrderPrint 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 }">
{{ formatQuantity(detail.checkQuantity) }}
</template>
</VxeColumn>
<VxeColumn title="单价(元)" align="right" width="120">
<template #default="{ row: detail }">
{{ formatPrice(detail.price) || '-' }}
</template>
</VxeColumn>
<VxeColumn title="实际金额(元)" align="right" width="140">
<template #default="{ row: detail }">
{{ formatPrice(getDetailActualPrice(detail)) || '-' }}
</template>
</VxeColumn>
<VxeColumn title="盈亏数量" align="right" width="120">
<template #default="{ row: detail }">
<span :class="getLossClass(getDetailDifferenceQuantity(detail))">
{{ formatQuantity(getDetailDifferenceQuantity(detail)) }}
</span>
</template>
</VxeColumn>
<VxeColumn title="实际盈亏金额(元)" align="right" width="160">
<template #default="{ row: detail }">
<span :class="getLossClass(getDetailDifferencePrice(detail))">
{{ formatPrice(getDetailDifferencePrice(detail)) || '-' }}
</span>
</template>
</VxeColumn>
</VxeTable>
</template>
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['盘库单']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['wms:check-order:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['wms:check-order:export'],
onClick: handleExport,
},
]"
/>
</template>
<template #no="{ row }">
<div>
单号
<a class="text-primary" @click="handleDetail(row)">{{ row.no }}</a>
</div>
</template>
<template #quantityAmount="{ row }">
<div class="flex items-center justify-between">
<span>盈亏数</span>
<span :class="getLossClass(row.totalQuantity)">
{{ formatQuantity(row.totalQuantity) }}
</span>
</div>
<div class="flex items-center justify-between">
<span>总金额</span>
<span>{{ formatPrice(row.totalPrice) }}</span>
</div>
<div class="flex items-center justify-between">
<span>实际金额</span>
<span>{{ formatPrice(row.actualPrice) }}</span>
</div>
<div class="flex items-center justify-between">
<span>盈亏金额</span>
<span :class="getLossClass(getOrderDifferencePrice(row))">
{{ formatPrice(getOrderDifferencePrice(row)) }}
</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: !canUpdateCheckOrder(row.status),
tooltip: getCheckOrderUpdateTip(row.status),
auth: ['wms:check-order:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
disabled: !canDeleteCheckOrder(row.status),
tooltip: getCheckOrderDeleteTip(row.status),
auth: ['wms:check-order:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.no]),
confirm: handleDelete.bind(null, row),
},
},
{
label: '打印',
type: 'link',
auth: ['wms:check-order:query'],
onClick: () => printRef?.print(row.id!),
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,160 @@
<script lang="ts" setup>
import type { WmsCheckOrderApi } from '#/api/wms/order/check';
import type { WmsCheckOrderDetailApi } from '#/api/wms/order/check/detail';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { VxeColumn, VxeTable } from '#/adapter/vxe-table';
import {
getCheckOrder,
getCheckOrderDetailListByOrderId,
} from '#/api/wms/order/check';
import { useDescription } from '#/components/description';
import {
formatPrice,
formatQuantity,
getLossClass,
} from '#/views/wms/utils/format';
import {
getCheckDetailFooter,
getDetailActualPrice,
getDetailDifferencePrice,
getDetailDifferenceQuantity,
useDetailSchema,
} from '../data';
interface DetailRow extends WmsCheckOrderDetailApi.CheckOrderDetail {
actualPrice?: number;
differencePrice?: number;
differenceQuantity?: number;
}
defineOptions({ name: 'WmsCheckOrderDetail' });
const detailData = ref<WmsCheckOrderApi.CheckOrder>({});
/** 详情明细补齐实际金额和盈亏字段,便于表格与 footer 统一渲染 */
const detailRows = computed<DetailRow[]>(() =>
(detailData.value.details || []).map((detail) => ({
...detail,
actualPrice: getDetailActualPrice(detail),
differencePrice: getDetailDifferencePrice(detail),
differenceQuantity: getDetailDifferenceQuantity(detail),
})),
);
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 getCheckOrder(data.id);
const details =
order.details || (await getCheckOrderDetailListByOrderId(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="getCheckDetailFooter"
:show-overflow="true"
show-footer
size="small"
>
<VxeColumn title="商品信息" min-width="200">
<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="200">
<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 title="单价(元)" align="right" width="120">
<template #default="{ row }">
{{ formatPrice(row.price) || '-' }}
</template>
</VxeColumn>
<VxeColumn field="checkQuantity" title="实盘数量" align="right" width="120">
<template #default="{ row }">
{{ formatQuantity(row.checkQuantity) || '-' }}
</template>
</VxeColumn>
<VxeColumn field="actualPrice" title="实际金额(元)" align="right" width="140">
<template #default="{ row }">
{{ formatPrice(row.actualPrice) || '-' }}
</template>
</VxeColumn>
<VxeColumn
field="differenceQuantity"
title="盈亏数量"
align="right"
width="120"
>
<template #default="{ row }">
<span :class="getLossClass(row.differenceQuantity)">
{{ formatQuantity(row.differenceQuantity) || '-' }}
</span>
</template>
</VxeColumn>
<VxeColumn
field="differencePrice"
title="实际盈亏金额(元)"
align="right"
width="160"
>
<template #default="{ row }">
<span :class="getLossClass(row.differencePrice)">
{{ formatPrice(row.differencePrice) || '-' }}
</span>
</template>
</VxeColumn>
</VxeTable>
</div>
</Modal>
</template>

View File

@ -0,0 +1,733 @@
<script lang="ts" setup>
import type { VxeTableInstance } from '#/adapter/vxe-table';
import type { WmsInventoryApi } from '#/api/wms/inventory';
import type { WmsItemSkuApi } from '#/api/wms/md/item/sku';
import type { WmsWarehouseApi } from '#/api/wms/md/warehouse';
import type { WmsCheckOrderApi } from '#/api/wms/order/check';
import type { WmsCheckOrderDetailApi } from '#/api/wms/order/check/detail';
import { computed, nextTick, ref, watch } 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 { getInventoryList } from '#/api/wms/inventory';
import {
cancelCheckOrder,
completeCheckOrder,
createCheckOrder,
getCheckOrder,
getCheckOrderDetailListByOrderId,
updateCheckOrder,
} from '#/api/wms/order/check';
import { $t } from '#/locales';
import { WmsItemSkuSelect } from '#/views/wms/md/item/sku/components';
import {
OrderStatusEnum,
OrderUpdateStatusList,
} from '#/views/wms/utils/constants';
import {
dividePrice,
formatPrice,
formatQuantity,
getLossClass,
multiplyPrice,
PRICE_PRECISION,
QUANTITY_PRECISION,
sumPrice,
} from '#/views/wms/utils/format';
import { generateOrderNo } from '#/views/wms/utils/order';
import {
getCheckDetailFooter,
getDetailDifferencePrice,
getDetailDifferenceQuantity,
useFormSchema,
useWarehouseFormSchema,
} from '../data';
interface CheckInventoryRow extends WmsInventoryApi.Inventory {
availableQuantity?: number;
price?: number;
}
interface DetailRow extends WmsCheckOrderDetailApi.CheckOrderDetail {
actualPrice?: number;
differencePrice?: number;
differenceQuantity?: number;
seq: number;
}
defineOptions({ name: 'WmsCheckOrderForm' });
const emit = defineEmits<{
success: [];
}>();
const formData = ref<WmsCheckOrderApi.CheckOrder>({});
const formMode = ref('create');
const originalSubmitData = ref<WmsCheckOrderApi.CheckOrder>();
const details = ref<DetailRow[]>([]);
const detailTableRef = ref<VxeTableInstance>();
const skuSelectRef = ref<InstanceType<typeof WmsItemSkuSelect>>();
const selectingWarehouse = ref(false);
const warehouseName = ref<string>();
let detailSeq = 0; // id使 VXE
const getTitle = computed(() => {
return formMode.value === 'update'
? $t('ui.actionTitle.edit', ['盘库单'])
: $t('ui.actionTitle.create', ['盘库单']);
});
const modalTitle = computed(() => {
return selectingWarehouse.value ? '选择盘库仓库' : getTitle.value;
});
const modalClass = computed(() => {
return selectingWarehouse.value ? 'w-[420px]' : 'w-3/4';
});
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)
);
});
/** 表单顶部实际金额来自明细合计,保持和 vue3 的只读汇总字段一致 */
const actualPrice = computed(() =>
sumPrice(details.value, (detail) => getDetailActualPrice(detail)),
);
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
labelWidth: 100,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
wrapperClass: 'grid-cols-3',
});
const [WarehouseForm, warehouseFormApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
labelWidth: 80,
},
layout: 'horizontal',
schema: useWarehouseFormSchema(handleWarehouseSelect),
showDefaultActions: false,
});
/** 记录新增盘库单前置选择的仓库名称 */
function handleWarehouseSelect(warehouse: unknown) {
warehouseName.value = (warehouse as undefined | WmsWarehouseApi.Warehouse)
?.name;
}
/** 初始化新增盘库单草稿 */
async function initCreateForm(warehouse: {
warehouseId?: number;
warehouseName?: string;
}) {
formData.value = {
details: [],
no: generateOrderNo('PK'),
status: OrderStatusEnum.PREPARE,
warehouseId: warehouse.warehouseId,
warehouseName: warehouse.warehouseName,
};
setDetails([]);
await formApi.resetForm();
await formApi.setValues(formData.value);
await nextTick();
syncActualPriceField();
originalSubmitData.value = await buildSubmitData();
}
/** 确认前置选择仓库,进入主表单 */
async function handleWarehouseConfirm() {
const { valid } = await warehouseFormApi.validate();
if (!valid) {
return;
}
const values = (await warehouseFormApi.getValues()) as {
warehouseId?: number;
};
selectingWarehouse.value = false;
await nextTick();
await initCreateForm({
warehouseId: values.warehouseId,
warehouseName: warehouseName.value,
});
}
/** 同步表单顶部只读实际金额 */
function syncActualPriceField() {
void formApi.setFieldValue(
'actualPrice',
formatPrice(actualPrice.value) || '0.00',
);
}
watch(actualPrice, syncActualPriceField);
/** 计算明细实际金额 */
function getDetailActualPrice(detail: DetailRow) {
return detail.actualPrice ?? multiplyPrice(detail.checkQuantity, detail.price);
}
/** 刷新明细行的盈亏数据 */
function refreshDetailCalculatedFields(detail: DetailRow) {
detail.differenceQuantity = getDetailDifferenceQuantity(detail);
detail.differencePrice = getDetailDifferencePrice(detail);
}
/** 标准化明细行,补齐本地序号和计算字段 */
function normalizeDetail(
detail: WmsCheckOrderDetailApi.CheckOrderDetail & { actualPrice?: number },
): DetailRow {
detailSeq += 1;
const row: DetailRow = {
...detail,
actualPrice:
detail.actualPrice ?? multiplyPrice(detail.checkQuantity, detail.price),
seq: detailSeq,
};
refreshDetailCalculatedFields(row);
return row;
}
/** 根据库存构建盘库明细 */
function buildDetail(inventory: CheckInventoryRow): DetailRow {
return normalizeDetail({
actualPrice: multiplyPrice(inventory.availableQuantity, inventory.price),
availableQuantity: inventory.availableQuantity,
checkQuantity: inventory.availableQuantity,
id: undefined,
inventoryId: inventory.id,
itemCode: inventory.itemCode,
itemId: inventory.itemId,
itemName: inventory.itemName,
price: inventory.price,
quantity: inventory.availableQuantity,
skuCode: inventory.skuCode,
skuId: inventory.skuId,
skuName: inventory.skuName,
unit: inventory.unit,
warehouseId: inventory.warehouseId,
warehouseName: inventory.warehouseName,
});
}
/** 构建零库存盘库明细,用于盘点仓库内暂无库存的商品 */
function buildZeroInventoryDetail(sku: WmsItemSkuApi.ItemSku): DetailRow {
return normalizeDetail({
actualPrice: 0,
availableQuantity: 0,
checkQuantity: 0,
id: undefined,
inventoryId: undefined,
itemCode: sku.itemCode,
itemId: sku.itemId,
itemName: sku.itemName,
price: sku.costPrice,
quantity: 0,
skuCode: sku.code,
skuId: sku.id,
skuName: sku.name,
unit: sku.unit,
warehouseId: formData.value.warehouseId,
warehouseName: formData.value.warehouseName,
});
}
/** 设置盘库明细 */
function setDetails(list?: WmsCheckOrderDetailApi.CheckOrderDetail[]) {
detailSeq = 0;
details.value = (list || []).map((detail) => normalizeDetail(detail));
void refreshDetailFooter();
}
/** 刷新明细合计行 */
async function refreshDetailFooter() {
await nextTick();
await detailTableRef.value?.updateFooter();
}
/** 导入当前仓库的全部库存余额 */
async function handleImportAllInventory() {
if (!formData.value.warehouseId) {
message.warning('请先选择仓库');
return;
}
if (details.value.length > 0) {
await confirm('导入仓库库存会覆盖当前盘库明细,是否继续?');
}
modalApi.lock();
try {
const inventories = await loadWarehouseInventoryList();
details.value = inventories.map((inventory) =>
buildDetail({ ...inventory, availableQuantity: inventory.quantity }),
);
await refreshDetailFooter();
} finally {
modalApi.unlock();
}
}
/** 打开盘点商品添加弹窗,已导入/已添加 SKU 在选择器内禁选 */
function handleAddSkuInventory() {
if (!formData.value.warehouseId) {
message.warning('请先选择仓库');
return;
}
skuSelectRef.value?.open(getSelectedSkuIds(), {
multiple: false,
});
}
/** 选择商品 SKU */
async function handleSelectSku(skus: WmsItemSkuApi.ItemSku[]) {
if (skus.length === 0) {
return;
}
modalApi.lock();
try {
const warehouseInventoryMap = await getWarehouseInventoryMap();
const selectedSkuIds = new Set(getSelectedSkuIds());
let changed = false;
for (const sku of skus) {
if (!sku.id || selectedSkuIds.has(sku.id)) {
continue;
}
const inventory = warehouseInventoryMap.get(sku.id);
details.value.push(
inventory
? buildDetail({ ...inventory, availableQuantity: inventory.quantity })
: buildZeroInventoryDetail(sku),
);
selectedSkuIds.add(sku.id);
changed = true;
}
if (changed) {
await refreshDetailFooter();
}
} finally {
modalApi.unlock();
}
}
/** 获得已导入/已添加 SKU 编号,避免重复盘点同一规格 */
function getSelectedSkuIds() {
return details.value
.map((detail) => detail.skuId)
.filter((id): id is number => id !== undefined);
}
/** 查询当前仓库全部库存余额 */
async function loadWarehouseInventoryList(): Promise<CheckInventoryRow[]> {
return (await getInventoryList({
warehouseId: formData.value.warehouseId!,
})) as CheckInventoryRow[];
}
/** 获得当前仓库 SKU 对应库存余额,用于添加单个 SKU 时带入账面库存 */
async function getWarehouseInventoryMap(): Promise<Map<number, CheckInventoryRow>> {
const inventories = await loadWarehouseInventoryList();
return new Map(
inventories
.filter((inventory) => !!inventory.skuId)
.map((inventory) => [inventory.skuId!, inventory] as const),
);
}
/** 删除商品明细 */
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 handleDetailCheckQuantityChange(detail: DetailRow) {
if (detail.price !== undefined && detail.price !== null) {
detail.actualPrice = multiplyPrice(detail.checkQuantity, detail.price);
} else {
detail.price = dividePrice(detail.actualPrice, detail.checkQuantity);
}
refreshDetailCalculatedFields(detail);
void refreshDetailFooter();
}
/** 明细单价变化 */
function handleDetailPriceChange(detail: DetailRow) {
detail.actualPrice = multiplyPrice(detail.checkQuantity, detail.price);
refreshDetailCalculatedFields(detail);
void refreshDetailFooter();
}
/** 明细实际金额变化 */
function handleDetailActualPriceChange(detail: DetailRow) {
detail.price = dividePrice(detail.actualPrice, detail.checkQuantity);
refreshDetailCalculatedFields(detail);
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.checkQuantity === undefined ||
detail.checkQuantity === null ||
detail.checkQuantity < 0
) {
message.error(`${index + 1} 行明细实盘数量不能小于 0`);
return false;
}
}
return true;
}
/** 构建提交用的明细数据 */
function buildSubmitDetails() {
return details.value.map((row) => {
const {
actualPrice: _actualPrice,
availableQuantity: _availableQuantity,
differencePrice: _differencePrice,
differenceQuantity: _differenceQuantity,
seq: _seq,
...detail
} = row;
return detail;
});
}
/** 构建提交用的单据数据 */
async function buildSubmitData(): Promise<WmsCheckOrderApi.CheckOrder> {
const values = (await formApi.getValues()) as WmsCheckOrderApi.CheckOrder;
const {
actualPrice: _formActualPrice,
details: _formDetails,
totalPrice: _formTotalPrice,
totalQuantity: _formTotalQuantity,
...submitValues
} = values;
const {
actualPrice: _actualPrice,
details: _details,
totalPrice: _totalPrice,
totalQuantity: _totalQuantity,
...order
} = formData.value;
return {
...order,
...submitValues,
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 updateCheckOrder(data);
}
await completeCheckOrder(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 cancelCheckOrder(formData.value.id);
await modalApi.close();
emit('success');
message.success('作废成功');
} finally {
modalApi.unlock();
}
}
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
if (selectingWarehouse.value) {
await handleWarehouseConfirm();
return;
}
const { valid } = await formApi.validate();
if (!valid || !validateDetails(false) || !isPrepareOrder.value) {
return;
}
modalApi.lock();
//
const data = await buildSubmitData();
try {
await (formMode.value === 'update'
? updateCheckOrder(data)
: createCheckOrder(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;
selectingWarehouse.value = false;
warehouseName.value = undefined;
setDetails([]);
return;
}
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 getCheckOrder(data.id);
const orderDetails =
order.details || (await getCheckOrderDetailListByOrderId(data.id));
formData.value = { ...order, details: orderDetails };
setDetails(orderDetails);
// values
await formApi.setValues(formData.value);
await nextTick();
syncActualPriceField();
originalSubmitData.value = await buildSubmitData();
} finally {
modalApi.unlock();
}
return;
}
//
selectingWarehouse.value = true;
warehouseName.value = undefined;
formData.value = {};
setDetails([]);
await warehouseFormApi.resetForm();
},
});
</script>
<template>
<Modal
:title="modalTitle"
:class="modalClass"
:show-confirm-button="selectingWarehouse || isPrepareOrder"
>
<template v-if="selectingWarehouse" #confirmText>开始盘库</template>
<WarehouseForm v-if="selectingWarehouse" class="mx-4" />
<div v-else 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: '导入仓库库存',
disabled: !formData.warehouseId,
onClick: handleImportAllInventory,
type: 'primary',
},
{
label: '添加盘点商品',
disabled: !formData.warehouseId,
onClick: handleAddSkuInventory,
type: 'primary',
},
]"
/>
</div>
<VxeTable
ref="detailTableRef"
:data="details"
border
empty-text="暂无商品明细"
:footer-method="getCheckDetailFooter"
:show-overflow="true"
show-footer
size="small"
>
<VxeColumn title="商品信息" min-width="210">
<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="210">
<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="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="checkQuantity" title="实际库存" width="150">
<template #default="{ row }">
<InputNumber
v-model:value="row.checkQuantity"
:controls="false"
:min="0"
:precision="QUANTITY_PRECISION"
class="!w-full"
placeholder="数量"
@change="handleDetailCheckQuantityChange(row)"
/>
</template>
</VxeColumn>
<VxeColumn field="actualPrice" title="实际金额(元)" width="150">
<template #default="{ row }">
<InputNumber
v-model:value="row.actualPrice"
:controls="false"
:min="0"
:precision="PRICE_PRECISION"
class="!w-full"
placeholder="金额"
@change="handleDetailActualPriceChange(row)"
/>
</template>
</VxeColumn>
<VxeColumn
field="differenceQuantity"
title="盈亏数"
align="right"
width="120"
>
<template #default="{ row }">
<span :class="getLossClass(row.differenceQuantity)">
{{ formatQuantity(row.differenceQuantity) }}
</span>
</template>
</VxeColumn>
<VxeColumn
field="differencePrice"
title="实际盈亏金额(元)"
align="right"
width="160"
>
<template #default="{ row }">
<span :class="getLossClass(row.differencePrice)">
{{ formatPrice(row.differencePrice) || '-' }}
</span>
</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="!selectingWarehouse && isSavedPrepareOrder"
class="flex flex-auto items-center gap-2"
>
<TableAction
:actions="[
{
label: '完成盘库',
type: 'primary',
auth: ['wms:check-order:complete'],
onClick: handleFormComplete,
},
{
label: '作废',
type: 'primary',
danger: true,
auth: ['wms:check-order:cancel'],
onClick: handleFormCancel,
},
]"
/>
</div>
</template>
<WmsItemSkuSelect ref="skuSelectRef" @change="handleSelectSku" />
</Modal>
</template>

View File

@ -0,0 +1,296 @@
<script lang="ts" setup>
import type { WmsCheckOrderApi } from '#/api/wms/order/check';
import type { WmsCheckOrderDetailApi } from '#/api/wms/order/check/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 {
getCheckOrder,
getCheckOrderDetailListByOrderId,
} from '#/api/wms/order/check';
import {
formatPrice,
formatQuantity,
formatSumPrice,
formatSumQuantity,
getLossClass,
sumPrice,
sumQuantity,
} from '#/views/wms/utils/format';
import {
getDetailActualPrice,
getDetailDifferencePrice,
getDetailDifferenceQuantity,
getOrderDifferencePrice,
} from '../data';
interface PrintRow extends WmsCheckOrderDetailApi.CheckOrderDetail {
actualPrice?: number;
differencePrice?: number;
differenceQuantity?: number;
}
defineOptions({ name: 'WmsCheckOrderPrint' });
const printData = ref<WmsCheckOrderApi.CheckOrder>({});
const tableColumnCount = 8;
/** 打印明细补齐实际金额和盈亏字段,避免模板重复计算 */
const printRows = computed<PrintRow[]>(() =>
(printData.value.details || []).map((detail) => ({
...detail,
actualPrice: getDetailActualPrice(detail),
differencePrice: getDetailDifferencePrice(detail),
differenceQuantity: getDetailDifferenceQuantity(detail),
})),
);
/** 打印合计盈亏数量 */
const totalDifferenceQuantity = computed(() =>
sumQuantity(printRows.value, (detail) => detail.differenceQuantity),
);
/** 打印合计盈亏金额 */
const totalDifferencePrice = computed(() =>
sumPrice(printRows.value, (detail) => detail.differencePrice),
);
/** 等待条码和打印 DOM 完成绘制,避免浏览器打印到旧内容 */
function waitForPaint() {
return new Promise<void>((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => resolve());
});
});
}
/** 退出打印模式,恢复当前页面显示 */
function removePrintMode() {
document.body.classList.remove('wms-check-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 getCheckOrder(id);
const details = order.details || (await getCheckOrderDetailListByOrderId(id));
printData.value = { ...order, details };
await nextTick();
await waitForPaint();
document.body.classList.add('wms-check-order-printing');
window.addEventListener('afterprint', removePrintMode, { once: true });
window.print();
}
defineExpose({ print });
</script>
<template>
<Teleport to="body">
<div
id="wmsCheckOrderPrint"
class="wms-check-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>仓库{{ printData.warehouseName || '-' }}</div>
<div>
盘库状态{{ getPrintDictLabel(DICT_TYPE.WMS_ORDER_STATUS, printData.status) }}
</div>
<div>单据日期{{ formatDate(printData.orderTime, 'YYYY-MM-DD') || '-' }}</div>
<div>
盈亏数量
<span :class="getLossClass(printData.totalQuantity)">
{{ formatQuantity(printData.totalQuantity) || '-' }}
</span>
</div>
<div>总金额{{ formatPrice(printData.totalPrice) || '-' }}</div>
<div>实际金额{{ formatPrice(printData.actualPrice) || '-' }}</div>
<div>
实际盈亏金额
<span :class="getLossClass(getOrderDifferencePrice(printData))">
{{ formatPrice(getOrderDifferencePrice(printData)) || '-' }}
</span>
</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>
<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">
{{ formatQuantity(detail.checkQuantity) || '-' }}
</td>
<td class="border border-solid border-[#dcdfe6] p-2 text-right">
{{ formatPrice(detail.actualPrice) || '-' }}
</td>
<td class="border border-solid border-[#dcdfe6] p-2 text-right">
<span :class="getLossClass(detail.differenceQuantity)">
{{ formatQuantity(detail.differenceQuantity) || '-' }}
</span>
</td>
<td class="border border-solid border-[#dcdfe6] p-2 text-right">
<span :class="getLossClass(detail.differencePrice)">
{{ formatPrice(detail.differencePrice) || '-' }}
</span>
</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">
{{ formatSumQuantity(printRows, (detail) => detail.checkQuantity) }}
</td>
<td class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-right">
{{ formatSumPrice(printRows, (detail) => detail.actualPrice) }}
</td>
<td class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-right">
<span :class="getLossClass(totalDifferenceQuantity)">
{{ formatQuantity(totalDifferenceQuantity) }}
</span>
</td>
<td class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-right">
<span :class="getLossClass(totalDifferencePrice)">
{{ formatPrice(totalDifferencePrice) }}
</span>
</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-check-order-printing) {
-webkit-print-color-adjust: exact;
margin: 0 !important;
padding: 0 !important;
print-color-adjust: exact;
}
:global(body.wms-check-order-printing *) {
visibility: hidden !important;
}
:global(body.wms-check-order-printing .wms-check-order-print),
:global(body.wms-check-order-printing .wms-check-order-print *) {
visibility: visible !important;
}
:global(body.wms-check-order-printing .wms-check-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,483 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { DescriptionItemSchema } from '#/components/description';
import type { NumberRangeValue } from '#/components/number-range-input';
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,
NumberRangeInput,
} from '#/components/number-range-input';
import { getRangePickerDefaultProps } from '#/utils';
import { WmsWarehouseSelect } from '#/views/wms/md/warehouse/components';
import {
formatPrice,
formatQuantity,
formatSumPrice,
formatSumQuantity,
getLossClass,
PRICE_PRECISION,
QUANTITY_PRECISION,
roundPrice,
sumPrice,
sumQuantity,
} from '#/views/wms/utils/format';
/** 拆分数量/金额区间字段,适配后端 Min/Max 查询参数 */
function splitNumberRange(minFieldName: string, maxFieldName: string) {
return (
value: NumberRangeValue | undefined,
setValue: (fieldName: string, value: number | undefined) => void,
) => {
setValue(minFieldName, value?.[0]);
setValue(maxFieldName, value?.[1]);
return undefined;
};
}
/** 构建允许负数的区间搜索项,盘库盈亏数量需要支持盘亏 */
function buildSignedNumberRangeSchema(
label: string,
fieldName: string,
minFieldName: string,
maxFieldName: string,
precision: number,
): VbenFormSchema {
return {
component: markRaw(NumberRangeInput),
componentProps: {
precision,
},
fieldName,
label,
valueFormat: splitNumberRange(minFieldName, maxFieldName),
};
}
/** 计算单据盈亏金额 */
export function getOrderDifferencePrice(order: {
actualPrice?: number;
totalPrice?: number;
}) {
return roundPrice(Number(order.actualPrice || 0) - Number(order.totalPrice || 0));
}
/** 计算明细盈亏数量 */
export function getDetailDifferenceQuantity(detail: {
checkQuantity?: number;
quantity?: number;
}) {
return Number(detail.checkQuantity || 0) - Number(detail.quantity || 0);
}
/** 计算明细实际金额 */
export function getDetailActualPrice(detail: {
checkQuantity?: number;
price?: number;
}) {
if (
detail.checkQuantity === undefined ||
detail.checkQuantity === null ||
detail.price === undefined ||
detail.price === null
) {
return undefined;
}
return roundPrice(Number(detail.checkQuantity) * Number(detail.price));
}
/** 计算明细盈亏金额 */
export function getDetailDifferencePrice(detail: {
checkQuantity?: number;
price?: number;
quantity?: number;
}) {
if (detail.price === undefined || detail.price === null) {
return undefined;
}
return roundPrice(getDetailDifferenceQuantity(detail) * Number(detail.price));
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
componentProps: {
clearable: true,
placeholder: '请输入盘库单号',
},
fieldName: 'no',
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: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
clearable: true,
},
fieldName: 'orderTime',
label: '单据日期',
},
buildSignedNumberRangeSchema(
'盈亏数量',
'totalQuantityRange',
'totalQuantityMin',
'totalQuantityMax',
QUANTITY_PRECISION,
),
buildNumberRangeSchema(
'总金额',
'totalPriceRange',
'totalPriceMin',
'totalPriceMax',
PRICE_PRECISION,
),
buildNumberRangeSchema(
'实际金额',
'actualPriceRange',
'actualPriceMin',
'actualPriceMax',
PRICE_PRECISION,
),
{
component: 'ApiSelect',
componentProps: {
clearable: true,
api: getSimpleUserList,
labelField: 'nickname',
placeholder: '请选择创建用户',
filterable: true,
valueField: 'id',
},
fieldName: 'creator',
label: '创建用户',
},
{
component: 'ApiSelect',
componentProps: {
clearable: true,
api: getSimpleUserList,
labelField: 'nickname',
placeholder: '请选择更新用户',
filterable: true,
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: 210,
},
{
align: 'center',
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.WMS_ORDER_STATUS },
},
field: 'status',
fixed: 'left',
title: '盘库状态',
width: 110,
},
{
field: 'warehouseName',
minWidth: 180,
title: '仓库',
},
{
field: 'quantityAmount',
minWidth: 200,
slots: { default: 'quantityAmount' },
title: '盈亏/金额(元)',
},
{
field: 'operateInfo',
minWidth: 280,
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: 'warehouseName',
label: '仓库',
render: (val) => val || '-',
},
{
field: 'orderTime',
label: '单据日期',
render: (val) => formatDate(val, 'YYYY-MM-DD') || '-',
},
{
field: 'status',
label: '单据状态',
render: (val) =>
val === undefined || val === null
? '-'
: h(DictTag, {
type: DICT_TYPE.WMS_ORDER_STATUS,
value: val,
}),
},
{
field: 'totalQuantity',
label: '盈亏数量',
render: (val) =>
h('span', { class: getLossClass(val) }, formatQuantity(val) || '-'),
},
{
field: 'totalPrice',
label: '总金额',
render: (val) => formatPrice(val) || '-',
},
{
field: 'actualPrice',
label: '实际金额',
render: (val) => formatPrice(val) || '-',
},
{
field: 'differencePrice',
label: '实际盈亏金额',
render: (_val, data) => {
const differencePrice = getOrderDifferencePrice(data || {});
return h(
'span',
{ class: getLossClass(differencePrice) },
formatPrice(differencePrice) || '-',
);
},
},
{
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,
},
];
}
/** 表单的配置项 */
export function useFormSchema(): 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: markRaw(WmsWarehouseSelect),
componentProps: {
disabled: true,
},
fieldName: 'warehouseId',
label: '仓库',
rules: 'required',
},
{
component: 'DatePicker',
componentProps: {
class: 'w-full',
format: 'YYYY-MM-DD',
placeholder: '请选择单据日期',
valueFormat: 'x',
},
fieldName: 'orderTime',
label: '单据日期',
rules: 'required',
},
{
component: 'Input',
componentProps: {
disabled: true,
},
fieldName: 'actualPrice',
label: '实际金额',
},
{
component: 'Textarea',
componentProps: {
maxLength: 255,
placeholder: '请输入备注',
},
fieldName: 'remark',
formItemClass: 'col-span-2',
label: '备注',
},
];
}
/** 选择盘库仓库表单的配置项 */
export function useWarehouseFormSchema(
onWarehouseChange: (warehouse: unknown) => void,
): VbenFormSchema[] {
return [
{
component: markRaw(WmsWarehouseSelect),
componentProps: {
onChange: onWarehouseChange,
},
fieldName: 'warehouseId',
label: '仓库',
rules: 'required',
},
];
}
interface CheckOrderDetailFooterRow {
actualPrice?: number;
checkQuantity?: number;
differencePrice?: number;
differenceQuantity?: number;
quantity?: number;
}
type CheckOrderDetailFooterColumn = Pick<
NonNullable<NonNullable<VxeTableGridOptions['columns']>[number]>,
'field'
>;
/** 明细表格的合计行 */
export function getCheckDetailFooter({
columns,
data,
}: {
columns: CheckOrderDetailFooterColumn[];
data: CheckOrderDetailFooterRow[];
}) {
return [
columns.map((column, index) => {
if (index === 0) {
return '合计';
}
if (column.field === 'quantity') {
return formatSumQuantity(data, (detail) => detail.quantity);
}
if (column.field === 'checkQuantity') {
return formatSumQuantity(data, (detail) => detail.checkQuantity);
}
if (column.field === 'actualPrice') {
return formatSumPrice(data, (detail) => detail.actualPrice);
}
if (column.field === 'differenceQuantity') {
return formatQuantity(
sumQuantity(data, (detail) => detail.differenceQuantity),
);
}
if (column.field === 'differencePrice') {
return formatPrice(sumPrice(data, (detail) => detail.differencePrice));
}
return '';
}),
];
}

View File

@ -0,0 +1,378 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { WmsCheckOrderApi } from '#/api/wms/order/check';
import type { WmsCheckOrderDetailApi } from '#/api/wms/order/check/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 {
deleteCheckOrder,
exportCheckOrder,
getCheckOrderDetailListByOrderId,
getCheckOrderPage,
} from '#/api/wms/order/check';
import { $t } from '#/locales';
import {
OrderDeleteStatusList,
OrderStatusEnum,
OrderUpdateStatusList,
} from '#/views/wms/utils/constants';
import {
formatPrice,
formatQuantity,
getLossClass,
} from '#/views/wms/utils/format';
import {
getDetailActualPrice,
getDetailDifferencePrice,
getDetailDifferenceQuantity,
getOrderDifferencePrice,
useGridColumns,
useGridFormSchema,
} from './data';
import CheckOrderDetail from './modules/detail.vue';
import CheckOrderForm from './modules/form.vue';
import CheckOrderPrint from './modules/print.vue';
defineOptions({ name: 'WmsCheckOrder' });
const printRef = ref<InstanceType<typeof CheckOrderPrint>>();
const detailMap = reactive<Record<number, WmsCheckOrderDetailApi.CheckOrderDetail[]>>(
{},
);
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: CheckOrderForm,
destroyOnClose: true,
});
const [DetailModal, detailModalApi] = useVbenModal({
connectedComponent: CheckOrderDetail,
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: WmsCheckOrderApi.CheckOrder) {
formModalApi.setData({ id: row.id!, type: 'update' }).open();
}
/** 查看盘库单详情 */
function handleDetail(row: WmsCheckOrderApi.CheckOrder) {
detailModalApi.setData({ id: row.id! }).open();
}
/** 获取已展开行的明细 */
function getExpandedDetails(row: WmsCheckOrderApi.CheckOrder) {
return detailMap[row.id!] || [];
}
/** 展开列表行时懒加载盘库明细 */
async function handleExpandChange(
row: WmsCheckOrderApi.CheckOrder,
expanded: boolean,
) {
if (!expanded) {
return;
}
delete detailMap[row.id!];
detailMap[row.id!] = await getCheckOrderDetailListByOrderId(row.id!);
}
/** 判断盘库单是否可修改 */
function canUpdateCheckOrder(status?: number) {
return status !== undefined && OrderUpdateStatusList.includes(status);
}
/** 判断盘库单是否可删除 */
function canDeleteCheckOrder(status?: number) {
return status !== undefined && OrderDeleteStatusList.includes(status);
}
/** 获取修改按钮禁用提示 */
function getCheckOrderUpdateTip(status?: number) {
if (canUpdateCheckOrder(status)) {
return undefined;
}
if (status === OrderStatusEnum.FINISHED) {
return '已盘库,无法修改';
}
if (status === OrderStatusEnum.CANCELED) {
return '已作废,无法修改';
}
return '当前状态无法修改';
}
/** 获取删除按钮禁用提示 */
function getCheckOrderDeleteTip(status?: number) {
if (canDeleteCheckOrder(status)) {
return undefined;
}
if (status === OrderStatusEnum.FINISHED) {
return '已盘库,无法删除';
}
return '当前状态无法删除';
}
/** 删除盘库单 */
async function handleDelete(row: WmsCheckOrderApi.CheckOrder) {
const loadingInstance = ElLoading.service({
text: $t('ui.actionMessage.deleting', [row.no]),
});
try {
await deleteCheckOrder(row.id!);
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.no]));
handleRefresh();
} finally {
loadingInstance.close();
}
}
/** 导出盘库单 */
async function handleExport() {
const data = await exportCheckOrder(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 getCheckOrderPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
isHover: true,
keyField: 'id',
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<WmsCheckOrderApi.CheckOrder>,
gridEvents: {
toggleRowExpand: ({
expanded,
row,
}: {
expanded: boolean;
row: WmsCheckOrderApi.CheckOrder;
}) => {
handleExpandChange(row, expanded);
},
},
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert title="【单据】盘库" url="https://doc.iocoder.cn/wms/order/check/" />
</template>
<FormModal @success="handleRefresh" />
<DetailModal />
<CheckOrderPrint 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 }">
{{ formatQuantity(detail.checkQuantity) }}
</template>
</VxeColumn>
<VxeColumn title="单价(元)" align="right" width="120">
<template #default="{ row: detail }">
{{ formatPrice(detail.price) || '-' }}
</template>
</VxeColumn>
<VxeColumn title="实际金额(元)" align="right" width="140">
<template #default="{ row: detail }">
{{ formatPrice(getDetailActualPrice(detail)) || '-' }}
</template>
</VxeColumn>
<VxeColumn title="盈亏数量" align="right" width="120">
<template #default="{ row: detail }">
<span :class="getLossClass(getDetailDifferenceQuantity(detail))">
{{ formatQuantity(getDetailDifferenceQuantity(detail)) }}
</span>
</template>
</VxeColumn>
<VxeColumn title="实际盈亏金额(元)" align="right" width="160">
<template #default="{ row: detail }">
<span :class="getLossClass(getDetailDifferencePrice(detail))">
{{ formatPrice(getDetailDifferencePrice(detail)) || '-' }}
</span>
</template>
</VxeColumn>
</VxeTable>
</template>
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['盘库单']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['wms:check-order:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['wms:check-order:export'],
onClick: handleExport,
},
]"
/>
</template>
<template #no="{ row }">
<div>
单号
<a class="text-primary" @click="handleDetail(row)">{{ row.no }}</a>
</div>
</template>
<template #quantityAmount="{ row }">
<div class="flex items-center justify-between">
<span>盈亏数</span>
<span :class="getLossClass(row.totalQuantity)">
{{ formatQuantity(row.totalQuantity) }}
</span>
</div>
<div class="flex items-center justify-between">
<span>总金额</span>
<span>{{ formatPrice(row.totalPrice) }}</span>
</div>
<div class="flex items-center justify-between">
<span>实际金额</span>
<span>{{ formatPrice(row.actualPrice) }}</span>
</div>
<div class="flex items-center justify-between">
<span>盈亏金额</span>
<span :class="getLossClass(getOrderDifferencePrice(row))">
{{ formatPrice(getOrderDifferencePrice(row)) }}
</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: !canUpdateCheckOrder(row.status),
tooltip: getCheckOrderUpdateTip(row.status),
auth: ['wms:check-order:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
disabled: !canDeleteCheckOrder(row.status),
tooltip: getCheckOrderDeleteTip(row.status),
auth: ['wms:check-order:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.no]),
confirm: handleDelete.bind(null, row),
},
},
{
label: '打印',
type: 'primary',
link: true,
auth: ['wms:check-order:query'],
onClick: () => printRef?.print(row.id!),
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,159 @@
<script lang="ts" setup>
import type { WmsCheckOrderApi } from '#/api/wms/order/check';
import type { WmsCheckOrderDetailApi } from '#/api/wms/order/check/detail';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { VxeColumn, VxeTable } from '#/adapter/vxe-table';
import {
getCheckOrder,
getCheckOrderDetailListByOrderId,
} from '#/api/wms/order/check';
import { useDescription } from '#/components/description';
import {
formatPrice,
formatQuantity,
getLossClass,
} from '#/views/wms/utils/format';
import {
getCheckDetailFooter,
getDetailActualPrice,
getDetailDifferencePrice,
getDetailDifferenceQuantity,
useDetailSchema,
} from '../data';
interface DetailRow extends WmsCheckOrderDetailApi.CheckOrderDetail {
actualPrice?: number;
differencePrice?: number;
differenceQuantity?: number;
}
defineOptions({ name: 'WmsCheckOrderDetail' });
const detailData = ref<WmsCheckOrderApi.CheckOrder>({});
/** 详情明细补齐实际金额和盈亏字段,便于表格与 footer 统一渲染 */
const detailRows = computed<DetailRow[]>(() =>
(detailData.value.details || []).map((detail) => ({
...detail,
actualPrice: getDetailActualPrice(detail),
differencePrice: getDetailDifferencePrice(detail),
differenceQuantity: getDetailDifferenceQuantity(detail),
})),
);
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 getCheckOrder(data.id);
const details =
order.details || (await getCheckOrderDetailListByOrderId(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="getCheckDetailFooter"
:show-overflow="true"
show-footer
size="small"
>
<VxeColumn title="商品信息" min-width="200">
<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="200">
<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 title="单价(元)" align="right" width="120">
<template #default="{ row }">
{{ formatPrice(row.price) || '-' }}
</template>
</VxeColumn>
<VxeColumn field="checkQuantity" title="实盘数量" align="right" width="120">
<template #default="{ row }">
{{ formatQuantity(row.checkQuantity) || '-' }}
</template>
</VxeColumn>
<VxeColumn field="actualPrice" title="实际金额(元)" align="right" width="140">
<template #default="{ row }">
{{ formatPrice(row.actualPrice) || '-' }}
</template>
</VxeColumn>
<VxeColumn
field="differenceQuantity"
title="盈亏数量"
align="right"
width="120"
>
<template #default="{ row }">
<span :class="getLossClass(row.differenceQuantity)">
{{ formatQuantity(row.differenceQuantity) || '-' }}
</span>
</template>
</VxeColumn>
<VxeColumn
field="differencePrice"
title="实际盈亏金额(元)"
align="right"
width="160"
>
<template #default="{ row }">
<span :class="getLossClass(row.differencePrice)">
{{ formatPrice(row.differencePrice) || '-' }}
</span>
</template>
</VxeColumn>
</VxeTable>
</div>
</Modal>
</template>

View File

@ -0,0 +1,732 @@
<script lang="ts" setup>
import type { VxeTableInstance } from '#/adapter/vxe-table';
import type { WmsInventoryApi } from '#/api/wms/inventory';
import type { WmsItemSkuApi } from '#/api/wms/md/item/sku';
import type { WmsWarehouseApi } from '#/api/wms/md/warehouse';
import type { WmsCheckOrderApi } from '#/api/wms/order/check';
import type { WmsCheckOrderDetailApi } from '#/api/wms/order/check/detail';
import { computed, nextTick, ref, watch } 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 { getInventoryList } from '#/api/wms/inventory';
import {
cancelCheckOrder,
completeCheckOrder,
createCheckOrder,
getCheckOrder,
getCheckOrderDetailListByOrderId,
updateCheckOrder,
} from '#/api/wms/order/check';
import { $t } from '#/locales';
import { WmsItemSkuSelect } from '#/views/wms/md/item/sku/components';
import {
OrderStatusEnum,
OrderUpdateStatusList,
} from '#/views/wms/utils/constants';
import {
dividePrice,
formatPrice,
formatQuantity,
getLossClass,
multiplyPrice,
PRICE_PRECISION,
QUANTITY_PRECISION,
sumPrice,
} from '#/views/wms/utils/format';
import { generateOrderNo } from '#/views/wms/utils/order';
import {
getCheckDetailFooter,
getDetailDifferencePrice,
getDetailDifferenceQuantity,
useFormSchema,
useWarehouseFormSchema,
} from '../data';
interface CheckInventoryRow extends WmsInventoryApi.Inventory {
availableQuantity?: number;
price?: number;
}
interface DetailRow extends WmsCheckOrderDetailApi.CheckOrderDetail {
actualPrice?: number;
differencePrice?: number;
differenceQuantity?: number;
seq: number;
}
defineOptions({ name: 'WmsCheckOrderForm' });
const emit = defineEmits<{
success: [];
}>();
const formData = ref<WmsCheckOrderApi.CheckOrder>({});
const formMode = ref('create');
const originalSubmitData = ref<WmsCheckOrderApi.CheckOrder>();
const details = ref<DetailRow[]>([]);
const detailTableRef = ref<VxeTableInstance>();
const skuSelectRef = ref<InstanceType<typeof WmsItemSkuSelect>>();
const selectingWarehouse = ref(false);
const warehouseName = ref<string>();
let detailSeq = 0; // id使 VXE
const getTitle = computed(() => {
return formMode.value === 'update'
? $t('ui.actionTitle.edit', ['盘库单'])
: $t('ui.actionTitle.create', ['盘库单']);
});
const modalTitle = computed(() => {
return selectingWarehouse.value ? '选择盘库仓库' : getTitle.value;
});
const modalClass = computed(() => {
return selectingWarehouse.value ? 'w-[420px]' : 'w-3/4';
});
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)
);
});
/** 表单顶部实际金额来自明细合计,保持和 vue3 的只读汇总字段一致 */
const actualPrice = computed(() =>
sumPrice(details.value, (detail) => getDetailActualPrice(detail)),
);
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
labelWidth: 100,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
wrapperClass: 'grid-cols-3',
});
const [WarehouseForm, warehouseFormApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
labelWidth: 80,
},
layout: 'horizontal',
schema: useWarehouseFormSchema(handleWarehouseSelect),
showDefaultActions: false,
});
/** 记录新增盘库单前置选择的仓库名称 */
function handleWarehouseSelect(warehouse: unknown) {
warehouseName.value = (warehouse as undefined | WmsWarehouseApi.Warehouse)
?.name;
}
/** 初始化新增盘库单草稿 */
async function initCreateForm(warehouse: {
warehouseId?: number;
warehouseName?: string;
}) {
formData.value = {
details: [],
no: generateOrderNo('PK'),
status: OrderStatusEnum.PREPARE,
warehouseId: warehouse.warehouseId,
warehouseName: warehouse.warehouseName,
};
setDetails([]);
await formApi.resetForm();
await formApi.setValues(formData.value);
await nextTick();
syncActualPriceField();
originalSubmitData.value = await buildSubmitData();
}
/** 确认前置选择仓库,进入主表单 */
async function handleWarehouseConfirm() {
const { valid } = await warehouseFormApi.validate();
if (!valid) {
return;
}
const values = (await warehouseFormApi.getValues()) as {
warehouseId?: number;
};
selectingWarehouse.value = false;
await nextTick();
await initCreateForm({
warehouseId: values.warehouseId,
warehouseName: warehouseName.value,
});
}
/** 同步表单顶部只读实际金额 */
function syncActualPriceField() {
void formApi.setFieldValue(
'actualPrice',
formatPrice(actualPrice.value) || '0.00',
);
}
watch(actualPrice, syncActualPriceField);
/** 计算明细实际金额 */
function getDetailActualPrice(detail: DetailRow) {
return detail.actualPrice ?? multiplyPrice(detail.checkQuantity, detail.price);
}
/** 刷新明细行的盈亏数据 */
function refreshDetailCalculatedFields(detail: DetailRow) {
detail.differenceQuantity = getDetailDifferenceQuantity(detail);
detail.differencePrice = getDetailDifferencePrice(detail);
}
/** 标准化明细行,补齐本地序号和计算字段 */
function normalizeDetail(
detail: WmsCheckOrderDetailApi.CheckOrderDetail & { actualPrice?: number },
): DetailRow {
detailSeq += 1;
const row: DetailRow = {
...detail,
actualPrice:
detail.actualPrice ?? multiplyPrice(detail.checkQuantity, detail.price),
seq: detailSeq,
};
refreshDetailCalculatedFields(row);
return row;
}
/** 根据库存构建盘库明细 */
function buildDetail(inventory: CheckInventoryRow): DetailRow {
return normalizeDetail({
actualPrice: multiplyPrice(inventory.availableQuantity, inventory.price),
availableQuantity: inventory.availableQuantity,
checkQuantity: inventory.availableQuantity,
id: undefined,
inventoryId: inventory.id,
itemCode: inventory.itemCode,
itemId: inventory.itemId,
itemName: inventory.itemName,
price: inventory.price,
quantity: inventory.availableQuantity,
skuCode: inventory.skuCode,
skuId: inventory.skuId,
skuName: inventory.skuName,
unit: inventory.unit,
warehouseId: inventory.warehouseId,
warehouseName: inventory.warehouseName,
});
}
/** 构建零库存盘库明细,用于盘点仓库内暂无库存的商品 */
function buildZeroInventoryDetail(sku: WmsItemSkuApi.ItemSku): DetailRow {
return normalizeDetail({
actualPrice: 0,
availableQuantity: 0,
checkQuantity: 0,
id: undefined,
inventoryId: undefined,
itemCode: sku.itemCode,
itemId: sku.itemId,
itemName: sku.itemName,
price: sku.costPrice,
quantity: 0,
skuCode: sku.code,
skuId: sku.id,
skuName: sku.name,
unit: sku.unit,
warehouseId: formData.value.warehouseId,
warehouseName: formData.value.warehouseName,
});
}
/** 设置盘库明细 */
function setDetails(list?: WmsCheckOrderDetailApi.CheckOrderDetail[]) {
detailSeq = 0;
details.value = (list || []).map((detail) => normalizeDetail(detail));
void refreshDetailFooter();
}
/** 刷新明细合计行 */
async function refreshDetailFooter() {
await nextTick();
await detailTableRef.value?.updateFooter();
}
/** 导入当前仓库的全部库存余额 */
async function handleImportAllInventory() {
if (!formData.value.warehouseId) {
ElMessage.warning('请先选择仓库');
return;
}
if (details.value.length > 0) {
await confirm('导入仓库库存会覆盖当前盘库明细,是否继续?');
}
modalApi.lock();
try {
const inventories = await loadWarehouseInventoryList();
details.value = inventories.map((inventory) =>
buildDetail({ ...inventory, availableQuantity: inventory.quantity }),
);
await refreshDetailFooter();
} finally {
modalApi.unlock();
}
}
/** 打开盘点商品添加弹窗,已导入/已添加 SKU 在选择器内禁选 */
function handleAddSkuInventory() {
if (!formData.value.warehouseId) {
ElMessage.warning('请先选择仓库');
return;
}
skuSelectRef.value?.open(getSelectedSkuIds(), {
multiple: false,
});
}
/** 选择商品 SKU */
async function handleSelectSku(skus: WmsItemSkuApi.ItemSku[]) {
if (skus.length === 0) {
return;
}
modalApi.lock();
try {
const warehouseInventoryMap = await getWarehouseInventoryMap();
const selectedSkuIds = new Set(getSelectedSkuIds());
let changed = false;
for (const sku of skus) {
if (!sku.id || selectedSkuIds.has(sku.id)) {
continue;
}
const inventory = warehouseInventoryMap.get(sku.id);
details.value.push(
inventory
? buildDetail({ ...inventory, availableQuantity: inventory.quantity })
: buildZeroInventoryDetail(sku),
);
selectedSkuIds.add(sku.id);
changed = true;
}
if (changed) {
await refreshDetailFooter();
}
} finally {
modalApi.unlock();
}
}
/** 获得已导入/已添加 SKU 编号,避免重复盘点同一规格 */
function getSelectedSkuIds() {
return details.value
.map((detail) => detail.skuId)
.filter((id): id is number => id !== undefined);
}
/** 查询当前仓库全部库存余额 */
async function loadWarehouseInventoryList(): Promise<CheckInventoryRow[]> {
return (await getInventoryList({
warehouseId: formData.value.warehouseId!,
})) as CheckInventoryRow[];
}
/** 获得当前仓库 SKU 对应库存余额,用于添加单个 SKU 时带入账面库存 */
async function getWarehouseInventoryMap(): Promise<Map<number, CheckInventoryRow>> {
const inventories = await loadWarehouseInventoryList();
return new Map(
inventories
.filter((inventory) => !!inventory.skuId)
.map((inventory) => [inventory.skuId!, inventory] as const),
);
}
/** 删除商品明细 */
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 handleDetailCheckQuantityChange(detail: DetailRow) {
if (detail.price !== undefined && detail.price !== null) {
detail.actualPrice = multiplyPrice(detail.checkQuantity, detail.price);
} else {
detail.price = dividePrice(detail.actualPrice, detail.checkQuantity);
}
refreshDetailCalculatedFields(detail);
void refreshDetailFooter();
}
/** 明细单价变化 */
function handleDetailPriceChange(detail: DetailRow) {
detail.actualPrice = multiplyPrice(detail.checkQuantity, detail.price);
refreshDetailCalculatedFields(detail);
void refreshDetailFooter();
}
/** 明细实际金额变化 */
function handleDetailActualPriceChange(detail: DetailRow) {
detail.price = dividePrice(detail.actualPrice, detail.checkQuantity);
refreshDetailCalculatedFields(detail);
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.checkQuantity === undefined ||
detail.checkQuantity === null ||
detail.checkQuantity < 0
) {
ElMessage.error(`${index + 1} 行明细实盘数量不能小于 0`);
return false;
}
}
return true;
}
/** 构建提交用的明细数据 */
function buildSubmitDetails() {
return details.value.map((row) => {
const {
actualPrice: _actualPrice,
availableQuantity: _availableQuantity,
differencePrice: _differencePrice,
differenceQuantity: _differenceQuantity,
seq: _seq,
...detail
} = row;
return detail;
});
}
/** 构建提交用的单据数据 */
async function buildSubmitData(): Promise<WmsCheckOrderApi.CheckOrder> {
const values = (await formApi.getValues()) as WmsCheckOrderApi.CheckOrder;
const {
actualPrice: _formActualPrice,
details: _formDetails,
totalPrice: _formTotalPrice,
totalQuantity: _formTotalQuantity,
...submitValues
} = values;
const {
actualPrice: _actualPrice,
details: _details,
totalPrice: _totalPrice,
totalQuantity: _totalQuantity,
...order
} = formData.value;
return {
...order,
...submitValues,
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 updateCheckOrder(data);
}
await completeCheckOrder(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 cancelCheckOrder(formData.value.id);
await modalApi.close();
emit('success');
ElMessage.success('作废成功');
} finally {
modalApi.unlock();
}
}
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
if (selectingWarehouse.value) {
await handleWarehouseConfirm();
return;
}
const { valid } = await formApi.validate();
if (!valid || !validateDetails(false) || !isPrepareOrder.value) {
return;
}
modalApi.lock();
//
const data = await buildSubmitData();
try {
await (formMode.value === 'update'
? updateCheckOrder(data)
: createCheckOrder(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;
selectingWarehouse.value = false;
warehouseName.value = undefined;
setDetails([]);
return;
}
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 getCheckOrder(data.id);
const orderDetails =
order.details || (await getCheckOrderDetailListByOrderId(data.id));
formData.value = { ...order, details: orderDetails };
setDetails(orderDetails);
// values
await formApi.setValues(formData.value);
await nextTick();
syncActualPriceField();
originalSubmitData.value = await buildSubmitData();
} finally {
modalApi.unlock();
}
return;
}
//
selectingWarehouse.value = true;
warehouseName.value = undefined;
formData.value = {};
setDetails([]);
await warehouseFormApi.resetForm();
},
});
</script>
<template>
<Modal
:title="modalTitle"
:class="modalClass"
:show-confirm-button="selectingWarehouse || isPrepareOrder"
>
<template v-if="selectingWarehouse" #confirmText>开始盘库</template>
<WarehouseForm v-if="selectingWarehouse" class="mx-4" />
<div v-else 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: '导入仓库库存',
disabled: !formData.warehouseId,
onClick: handleImportAllInventory,
type: 'primary',
},
{
label: '添加盘点商品',
disabled: !formData.warehouseId,
onClick: handleAddSkuInventory,
type: 'primary',
},
]"
/>
</div>
<VxeTable
ref="detailTableRef"
:data="details"
border
empty-text="暂无商品明细"
:footer-method="getCheckDetailFooter"
:show-overflow="true"
show-footer
size="small"
>
<VxeColumn title="商品信息" min-width="210">
<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="210">
<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="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="checkQuantity" title="实际库存" width="150">
<template #default="{ row }">
<ElInputNumber
v-model="row.checkQuantity"
:controls="false"
:min="0"
:precision="QUANTITY_PRECISION"
class="!w-full"
placeholder="数量"
@change="handleDetailCheckQuantityChange(row)"
/>
</template>
</VxeColumn>
<VxeColumn field="actualPrice" title="实际金额(元)" width="150">
<template #default="{ row }">
<ElInputNumber
v-model="row.actualPrice"
:controls="false"
:min="0"
:precision="PRICE_PRECISION"
class="!w-full"
placeholder="金额"
@change="handleDetailActualPriceChange(row)"
/>
</template>
</VxeColumn>
<VxeColumn
field="differenceQuantity"
title="盈亏数"
align="right"
width="120"
>
<template #default="{ row }">
<span :class="getLossClass(row.differenceQuantity)">
{{ formatQuantity(row.differenceQuantity) }}
</span>
</template>
</VxeColumn>
<VxeColumn
field="differencePrice"
title="实际盈亏金额(元)"
align="right"
width="160"
>
<template #default="{ row }">
<span :class="getLossClass(row.differencePrice)">
{{ formatPrice(row.differencePrice) || '-' }}
</span>
</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="!selectingWarehouse && isSavedPrepareOrder"
class="flex flex-auto items-center gap-2"
>
<TableAction
:actions="[
{
label: '完成盘库',
type: 'primary',
auth: ['wms:check-order:complete'],
onClick: handleFormComplete,
},
{
label: '作废',
type: 'danger',
auth: ['wms:check-order:cancel'],
onClick: handleFormCancel,
},
]"
/>
</div>
</template>
<WmsItemSkuSelect ref="skuSelectRef" @change="handleSelectSku" />
</Modal>
</template>

View File

@ -0,0 +1,296 @@
<script lang="ts" setup>
import type { WmsCheckOrderApi } from '#/api/wms/order/check';
import type { WmsCheckOrderDetailApi } from '#/api/wms/order/check/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 {
getCheckOrder,
getCheckOrderDetailListByOrderId,
} from '#/api/wms/order/check';
import {
formatPrice,
formatQuantity,
formatSumPrice,
formatSumQuantity,
getLossClass,
sumPrice,
sumQuantity,
} from '#/views/wms/utils/format';
import {
getDetailActualPrice,
getDetailDifferencePrice,
getDetailDifferenceQuantity,
getOrderDifferencePrice,
} from '../data';
interface PrintRow extends WmsCheckOrderDetailApi.CheckOrderDetail {
actualPrice?: number;
differencePrice?: number;
differenceQuantity?: number;
}
defineOptions({ name: 'WmsCheckOrderPrint' });
const printData = ref<WmsCheckOrderApi.CheckOrder>({});
const tableColumnCount = 8;
/** 打印明细补齐实际金额和盈亏字段,避免模板重复计算 */
const printRows = computed<PrintRow[]>(() =>
(printData.value.details || []).map((detail) => ({
...detail,
actualPrice: getDetailActualPrice(detail),
differencePrice: getDetailDifferencePrice(detail),
differenceQuantity: getDetailDifferenceQuantity(detail),
})),
);
/** 打印合计盈亏数量 */
const totalDifferenceQuantity = computed(() =>
sumQuantity(printRows.value, (detail) => detail.differenceQuantity),
);
/** 打印合计盈亏金额 */
const totalDifferencePrice = computed(() =>
sumPrice(printRows.value, (detail) => detail.differencePrice),
);
/** 等待条码和打印 DOM 完成绘制,避免浏览器打印到旧内容 */
function waitForPaint() {
return new Promise<void>((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => resolve());
});
});
}
/** 退出打印模式,恢复当前页面显示 */
function removePrintMode() {
document.body.classList.remove('wms-check-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 getCheckOrder(id);
const details = order.details || (await getCheckOrderDetailListByOrderId(id));
printData.value = { ...order, details };
await nextTick();
await waitForPaint();
document.body.classList.add('wms-check-order-printing');
window.addEventListener('afterprint', removePrintMode, { once: true });
window.print();
}
defineExpose({ print });
</script>
<template>
<Teleport to="body">
<div
id="wmsCheckOrderPrint"
class="wms-check-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>仓库{{ printData.warehouseName || '-' }}</div>
<div>
盘库状态{{ getPrintDictLabel(DICT_TYPE.WMS_ORDER_STATUS, printData.status) }}
</div>
<div>单据日期{{ formatDate(printData.orderTime, 'YYYY-MM-DD') || '-' }}</div>
<div>
盈亏数量
<span :class="getLossClass(printData.totalQuantity)">
{{ formatQuantity(printData.totalQuantity) || '-' }}
</span>
</div>
<div>总金额{{ formatPrice(printData.totalPrice) || '-' }}</div>
<div>实际金额{{ formatPrice(printData.actualPrice) || '-' }}</div>
<div>
实际盈亏金额
<span :class="getLossClass(getOrderDifferencePrice(printData))">
{{ formatPrice(getOrderDifferencePrice(printData)) || '-' }}
</span>
</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>
<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">
{{ formatQuantity(detail.checkQuantity) || '-' }}
</td>
<td class="border border-solid border-[#dcdfe6] p-2 text-right">
{{ formatPrice(detail.actualPrice) || '-' }}
</td>
<td class="border border-solid border-[#dcdfe6] p-2 text-right">
<span :class="getLossClass(detail.differenceQuantity)">
{{ formatQuantity(detail.differenceQuantity) || '-' }}
</span>
</td>
<td class="border border-solid border-[#dcdfe6] p-2 text-right">
<span :class="getLossClass(detail.differencePrice)">
{{ formatPrice(detail.differencePrice) || '-' }}
</span>
</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">
{{ formatSumQuantity(printRows, (detail) => detail.checkQuantity) }}
</td>
<td class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-right">
{{ formatSumPrice(printRows, (detail) => detail.actualPrice) }}
</td>
<td class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-right">
<span :class="getLossClass(totalDifferenceQuantity)">
{{ formatQuantity(totalDifferenceQuantity) }}
</span>
</td>
<td class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-right">
<span :class="getLossClass(totalDifferencePrice)">
{{ formatPrice(totalDifferencePrice) }}
</span>
</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-check-order-printing) {
-webkit-print-color-adjust: exact;
margin: 0 !important;
padding: 0 !important;
print-color-adjust: exact;
}
:global(body.wms-check-order-printing *) {
visibility: hidden !important;
}
:global(body.wms-check-order-printing .wms-check-order-print),
:global(body.wms-check-order-printing .wms-check-order-print *) {
visibility: visible !important;
}
:global(body.wms-check-order-printing .wms-check-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>