feat(web-antdv-next): migrate WMS module

pull/355/head
XuZhiqiang 2026-06-04 15:57:43 +08:00
parent e6e4d8ce1e
commit 82b22173c0
80 changed files with 13117 additions and 0 deletions

View File

@ -0,0 +1,66 @@
import { requestClient } from '#/api/request';
export namespace WmsHomeStatisticsApi {
export interface StatisticsReq {
goodsLimit?: number;
warehouseId?: number;
warehouseLimit?: number;
}
export interface OrderStatus {
count: number;
status: number;
}
export interface OrderSummary {
statuses: OrderStatus[];
total: number;
type: number;
}
export interface OrderTrend {
checkCount: number;
movementCount: number;
receiptCount: number;
shipmentCount: number;
time: number | string;
}
export interface InventoryRankItem {
id: number;
name: string;
quantity: number;
}
export interface InventorySummary {
goodsShareList: InventoryRankItem[];
totalQuantity: number;
warehouseDistributionList: InventoryRankItem[];
}
}
export function getOrderSummary(params?: WmsHomeStatisticsApi.StatisticsReq) {
return requestClient.get<WmsHomeStatisticsApi.OrderSummary[]>(
'/wms/home-statistics/order-summary',
{ params },
);
}
export function getOrderTrend(
days?: number,
params?: WmsHomeStatisticsApi.StatisticsReq,
) {
return requestClient.get<WmsHomeStatisticsApi.OrderTrend[]>(
'/wms/home-statistics/order-trend',
{ params: { ...params, days } },
);
}
export function getInventorySummary(
params?: WmsHomeStatisticsApi.StatisticsReq,
) {
return requestClient.get<WmsHomeStatisticsApi.InventorySummary>(
'/wms/home-statistics/inventory-summary',
{ params },
);
}

View File

@ -0,0 +1,37 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace WmsInventoryHistoryApi {
/** WMS 库存记录 */
export interface InventoryHistory {
id?: number;
itemId?: number;
itemCode?: string;
itemName?: string;
unit?: string;
skuId?: number;
skuCode?: string;
skuName?: string;
warehouseId?: number;
warehouseName?: string;
quantity?: number;
beforeQuantity?: number;
afterQuantity?: number;
price?: number;
totalPrice?: number;
remark?: string;
orderId?: number;
orderNo?: string;
orderType?: number;
createTime?: Date;
}
}
/** 查询库存记录分页 */
export function getInventoryHistoryPage(params: PageParam) {
return requestClient.get<PageResult<WmsInventoryHistoryApi.InventoryHistory>>(
'/wms/inventory-history/page',
{ params },
);
}

View File

@ -0,0 +1,42 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace WmsInventoryApi {
/** WMS 库存统计 */
export interface Inventory {
id?: number;
itemId?: number;
itemCode?: string;
itemName?: string;
unit?: string;
skuId?: number;
skuCode?: string;
skuName?: string;
warehouseId?: number;
warehouseName?: string;
quantity?: number;
remark?: string;
createTime?: Date;
}
/** WMS 库存统计列表请求 */
export interface InventoryListReq {
warehouseId: number;
}
}
/** 查询库存统计分页 */
export function getInventoryPage(params: PageParam) {
return requestClient.get<PageResult<WmsInventoryApi.Inventory>>(
'/wms/inventory/page',
{ params },
);
}
/** 查询库存统计列表 */
export function getInventoryList(params: WmsInventoryApi.InventoryListReq) {
return requestClient.get<WmsInventoryApi.Inventory[]>('/wms/inventory/list', {
params,
});
}

View File

@ -0,0 +1,55 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace WmsItemBrandApi {
/** WMS 商品品牌 */
export interface ItemBrand {
id?: number;
code?: string;
name?: string;
createTime?: Date;
}
}
/** 查询商品品牌分页 */
export function getItemBrandPage(params: PageParam) {
return requestClient.get<PageResult<WmsItemBrandApi.ItemBrand>>(
'/wms/item-brand/page',
{ params },
);
}
/** 查询商品品牌精简列表 */
export function getItemBrandSimpleList() {
return requestClient.get<WmsItemBrandApi.ItemBrand[]>(
'/wms/item-brand/simple-list',
);
}
/** 查询商品品牌详情 */
export function getItemBrand(id: number) {
return requestClient.get<WmsItemBrandApi.ItemBrand>(
`/wms/item-brand/get?id=${id}`,
);
}
/** 新增商品品牌 */
export function createItemBrand(data: WmsItemBrandApi.ItemBrand) {
return requestClient.post('/wms/item-brand/create', data);
}
/** 修改商品品牌 */
export function updateItemBrand(data: WmsItemBrandApi.ItemBrand) {
return requestClient.put('/wms/item-brand/update', data);
}
/** 删除商品品牌 */
export function deleteItemBrand(id: number) {
return requestClient.delete(`/wms/item-brand/delete?id=${id}`);
}
/** 导出商品品牌 */
export function exportItemBrand(params: any) {
return requestClient.download('/wms/item-brand/export-excel', { params });
}

View File

@ -0,0 +1,52 @@
import { requestClient } from '#/api/request';
export namespace WmsItemCategoryApi {
/** WMS 商品分类 */
export interface ItemCategory {
id?: number;
parentId?: number;
code?: string;
name?: string;
sort?: number;
status?: number;
createTime?: Date;
children?: ItemCategory[];
}
}
/** 查询商品分类列表 */
export function getItemCategoryList(params?: any) {
return requestClient.get<WmsItemCategoryApi.ItemCategory[]>(
'/wms/item-category/list',
{ params },
);
}
/** 查询商品分类精简列表 */
export function getItemCategorySimpleList() {
return requestClient.get<WmsItemCategoryApi.ItemCategory[]>(
'/wms/item-category/simple-list',
);
}
/** 查询商品分类详情 */
export function getItemCategory(id: number) {
return requestClient.get<WmsItemCategoryApi.ItemCategory>(
`/wms/item-category/get?id=${id}`,
);
}
/** 新增商品分类 */
export function createItemCategory(data: WmsItemCategoryApi.ItemCategory) {
return requestClient.post('/wms/item-category/create', data);
}
/** 修改商品分类 */
export function updateItemCategory(data: WmsItemCategoryApi.ItemCategory) {
return requestClient.put('/wms/item-category/update', data);
}
/** 删除商品分类 */
export function deleteItemCategory(id: number) {
return requestClient.delete(`/wms/item-category/delete?id=${id}`);
}

View File

@ -0,0 +1,61 @@
import type { PageParam, PageResult } from '@vben/request';
import type { WmsItemSkuApi } from './sku';
import { requestClient } from '#/api/request';
export namespace WmsItemApi {
/** WMS 商品 */
export interface Item {
id?: number;
code?: string;
name?: string;
categoryId?: number;
categoryName?: string;
unit?: string;
brandId?: number;
brandName?: string;
remark?: string;
skus?: WmsItemSkuApi.ItemSku[];
createTime?: Date;
}
}
/** 查询商品分页 */
export function getItemPage(params: PageParam) {
return requestClient.get<PageResult<WmsItemApi.Item>>('/wms/item/page', {
params,
});
}
/** 查询商品精简列表 */
export function getItemSimpleList(params?: any) {
return requestClient.get<WmsItemApi.Item[]>('/wms/item/simple-list', {
params,
});
}
/** 查询商品详情 */
export function getItem(id: number) {
return requestClient.get<WmsItemApi.Item>(`/wms/item/get?id=${id}`);
}
/** 新增商品 */
export function createItem(data: WmsItemApi.Item) {
return requestClient.post('/wms/item/create', data);
}
/** 修改商品 */
export function updateItem(data: WmsItemApi.Item) {
return requestClient.put('/wms/item/update', data);
}
/** 删除商品 */
export function deleteItem(id: number) {
return requestClient.delete(`/wms/item/delete?id=${id}`);
}
/** 导出商品 */
export function exportItem(params: any) {
return requestClient.download('/wms/item/export-excel', { params });
}

View File

@ -0,0 +1,37 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace WmsItemSkuApi {
/** WMS 商品 SKU */
export interface ItemSku {
id?: number;
name?: string;
itemId?: number;
itemCode?: string;
itemName?: string;
categoryId?: number;
categoryName?: string;
unit?: string;
brandId?: number;
brandName?: string;
barCode?: string;
code?: string;
length?: number;
width?: number;
height?: number;
grossWeight?: number;
netWeight?: number;
costPrice?: number;
sellingPrice?: number;
createTime?: Date;
}
}
/** 按 SKU 维度分页(支持商品 / 品牌 / 分类多表联查筛选) */
export function getItemSkuPage(params: PageParam) {
return requestClient.get<PageResult<WmsItemSkuApi.ItemSku>>(
'/wms/item-sku/page',
{ params },
);
}

View File

@ -0,0 +1,73 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace WmsMerchantApi {
/** WMS 往来企业 */
export interface Merchant {
id?: number;
code?: string;
name?: string;
type?: number;
level?: string;
bankName?: string;
bankAccount?: string;
address?: string;
mobile?: string;
telephone?: string;
contact?: string;
email?: string;
remark?: string;
createTime?: Date;
}
/** WMS 往来企业精简列表请求 */
export interface MerchantSimpleListReq {
types?: number[];
}
}
/** 查询往来企业分页 */
export function getMerchantPage(params: PageParam) {
return requestClient.get<PageResult<WmsMerchantApi.Merchant>>(
'/wms/merchant/page',
{ params },
);
}
/** 查询往来企业精简列表 */
export function getMerchantSimpleList(
params?: WmsMerchantApi.MerchantSimpleListReq,
) {
return requestClient.get<WmsMerchantApi.Merchant[]>(
'/wms/merchant/simple-list',
{ params },
);
}
/** 查询往来企业详情 */
export function getMerchant(id: number) {
return requestClient.get<WmsMerchantApi.Merchant>(
`/wms/merchant/get?id=${id}`,
);
}
/** 新增往来企业 */
export function createMerchant(data: WmsMerchantApi.Merchant) {
return requestClient.post('/wms/merchant/create', data);
}
/** 修改往来企业 */
export function updateMerchant(data: WmsMerchantApi.Merchant) {
return requestClient.put('/wms/merchant/update', data);
}
/** 删除往来企业 */
export function deleteMerchant(id: number) {
return requestClient.delete(`/wms/merchant/delete?id=${id}`);
}
/** 导出往来企业 */
export function exportMerchant(params: any) {
return requestClient.download('/wms/merchant/export-excel', { params });
}

View File

@ -0,0 +1,57 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace WmsWarehouseApi {
/** WMS 仓库 */
export interface Warehouse {
id?: number;
code?: string;
name?: string;
remark?: string;
sort?: number;
createTime?: Date;
}
}
/** 查询仓库分页 */
export function getWarehousePage(params: PageParam) {
return requestClient.get<PageResult<WmsWarehouseApi.Warehouse>>(
'/wms/warehouse/page',
{ params },
);
}
/** 查询仓库精简列表 */
export function getWarehouseSimpleList() {
return requestClient.get<WmsWarehouseApi.Warehouse[]>(
'/wms/warehouse/simple-list',
);
}
/** 查询仓库详情 */
export function getWarehouse(id: number) {
return requestClient.get<WmsWarehouseApi.Warehouse>(
`/wms/warehouse/get?id=${id}`,
);
}
/** 新增仓库 */
export function createWarehouse(data: WmsWarehouseApi.Warehouse) {
return requestClient.post('/wms/warehouse/create', data);
}
/** 修改仓库 */
export function updateWarehouse(data: WmsWarehouseApi.Warehouse) {
return requestClient.put('/wms/warehouse/update', data);
}
/** 删除仓库 */
export function deleteWarehouse(id: number) {
return requestClient.delete(`/wms/warehouse/delete?id=${id}`);
}
/** 导出仓库 */
export function exportWarehouse(params: any) {
return requestClient.download('/wms/warehouse/export-excel', { params });
}

View File

@ -0,0 +1,23 @@
export namespace WmsCheckOrderDetailApi {
/** WMS 盘库单明细 */
export interface CheckOrderDetail {
id?: number;
orderId?: number;
itemId?: number;
itemCode?: string;
itemName?: string;
unit?: string;
skuId?: number;
skuCode?: string;
skuName?: string;
inventoryId?: number;
warehouseId?: number;
warehouseName?: string;
receiptTime?: Date;
quantity?: number;
checkQuantity?: number;
availableQuantity?: number;
price?: number;
createTime?: Date;
}
}

View File

@ -0,0 +1,71 @@
import type { PageParam, PageResult } from '@vben/request';
import type { WmsCheckOrderDetailApi } from './detail';
import { requestClient } from '#/api/request';
export namespace WmsCheckOrderApi {
/** WMS 盘库单 */
export interface CheckOrder {
id?: number;
no?: string;
orderTime?: string;
status?: number;
remark?: string;
warehouseId?: number;
warehouseName?: string;
totalQuantity?: number;
totalPrice?: number;
actualPrice?: number;
details?: WmsCheckOrderDetailApi.CheckOrderDetail[];
createTime?: Date;
creator?: string;
creatorName?: string;
updateTime?: Date;
updater?: string;
updaterName?: string;
}
}
export function getCheckOrderPage(params: PageParam) {
return requestClient.get<PageResult<WmsCheckOrderApi.CheckOrder>>(
'/wms/check-order/page',
{ params },
);
}
export function getCheckOrder(id: number) {
return requestClient.get<WmsCheckOrderApi.CheckOrder>(
`/wms/check-order/get?id=${id}`,
);
}
export function getCheckOrderDetailListByOrderId(orderId: number) {
return requestClient.get<WmsCheckOrderDetailApi.CheckOrderDetail[]>(
`/wms/check-order-detail/list-by-order-id?orderId=${orderId}`,
);
}
export function createCheckOrder(data: WmsCheckOrderApi.CheckOrder) {
return requestClient.post('/wms/check-order/create', data);
}
export function updateCheckOrder(data: WmsCheckOrderApi.CheckOrder) {
return requestClient.put('/wms/check-order/update', data);
}
export function completeCheckOrder(id: number) {
return requestClient.put(`/wms/check-order/complete?id=${id}`);
}
export function cancelCheckOrder(id: number) {
return requestClient.put(`/wms/check-order/cancel?id=${id}`);
}
export function deleteCheckOrder(id: number) {
return requestClient.delete(`/wms/check-order/delete?id=${id}`);
}
export function exportCheckOrder(params: any) {
return requestClient.download('/wms/check-order/export-excel', { params });
}

View File

@ -0,0 +1,23 @@
export namespace WmsMovementOrderDetailApi {
/** WMS 移库单明细 */
export interface MovementOrderDetail {
id?: number;
orderId?: number;
itemId?: number;
itemCode?: string;
itemName?: string;
unit?: string;
skuId?: number;
skuCode?: string;
skuName?: string;
sourceWarehouseId?: number;
sourceWarehouseName?: string;
targetWarehouseId?: number;
targetWarehouseName?: string;
quantity?: number;
availableQuantity?: number;
price?: number;
totalPrice?: number;
createTime?: Date;
}
}

View File

@ -0,0 +1,72 @@
import type { PageParam, PageResult } from '@vben/request';
import type { WmsMovementOrderDetailApi } from './detail';
import { requestClient } from '#/api/request';
export namespace WmsMovementOrderApi {
/** WMS 移库单 */
export interface MovementOrder {
id?: number;
no?: string;
orderTime?: string;
status?: number;
remark?: string;
sourceWarehouseId?: number;
sourceWarehouseName?: string;
targetWarehouseId?: number;
targetWarehouseName?: string;
totalQuantity?: number;
totalPrice?: number;
details?: WmsMovementOrderDetailApi.MovementOrderDetail[];
createTime?: Date;
creator?: string;
creatorName?: string;
updateTime?: Date;
updater?: string;
updaterName?: string;
}
}
export function getMovementOrderPage(params: PageParam) {
return requestClient.get<PageResult<WmsMovementOrderApi.MovementOrder>>(
'/wms/movement-order/page',
{ params },
);
}
export function getMovementOrder(id: number) {
return requestClient.get<WmsMovementOrderApi.MovementOrder>(
`/wms/movement-order/get?id=${id}`,
);
}
export function getMovementOrderDetailListByOrderId(orderId: number) {
return requestClient.get<WmsMovementOrderDetailApi.MovementOrderDetail[]>(
`/wms/movement-order-detail/list-by-order-id?orderId=${orderId}`,
);
}
export function createMovementOrder(data: WmsMovementOrderApi.MovementOrder) {
return requestClient.post('/wms/movement-order/create', data);
}
export function updateMovementOrder(data: WmsMovementOrderApi.MovementOrder) {
return requestClient.put('/wms/movement-order/update', data);
}
export function completeMovementOrder(id: number) {
return requestClient.put(`/wms/movement-order/complete?id=${id}`);
}
export function cancelMovementOrder(id: number) {
return requestClient.put(`/wms/movement-order/cancel?id=${id}`);
}
export function deleteMovementOrder(id: number) {
return requestClient.delete(`/wms/movement-order/delete?id=${id}`);
}
export function exportMovementOrder(params: any) {
return requestClient.download('/wms/movement-order/export-excel', { params });
}

View File

@ -0,0 +1,20 @@
export namespace WmsReceiptOrderDetailApi {
/** WMS 入库单明细 */
export interface ReceiptOrderDetail {
id?: number;
orderId?: number;
itemId?: number;
itemCode?: string;
itemName?: string;
unit?: string;
skuId?: number;
skuCode?: string;
skuName?: string;
warehouseId?: number;
warehouseName?: string;
quantity?: number;
price?: number;
totalPrice?: number;
createTime?: Date;
}
}

View File

@ -0,0 +1,74 @@
import type { PageParam, PageResult } from '@vben/request';
import type { WmsReceiptOrderDetailApi } from './detail';
import { requestClient } from '#/api/request';
export namespace WmsReceiptOrderApi {
/** WMS 入库单 */
export interface ReceiptOrder {
id?: number;
no?: string;
type?: number;
orderTime?: string;
status?: number;
bizOrderNo?: string;
merchantId?: number;
merchantName?: string;
remark?: string;
warehouseId?: number;
warehouseName?: string;
totalQuantity?: number;
totalPrice?: number;
details?: WmsReceiptOrderDetailApi.ReceiptOrderDetail[];
createTime?: Date;
creator?: string;
creatorName?: string;
updateTime?: Date;
updater?: string;
updaterName?: string;
}
}
export function getReceiptOrderPage(params: PageParam) {
return requestClient.get<PageResult<WmsReceiptOrderApi.ReceiptOrder>>(
'/wms/receipt-order/page',
{ params },
);
}
export function getReceiptOrder(id: number) {
return requestClient.get<WmsReceiptOrderApi.ReceiptOrder>(
`/wms/receipt-order/get?id=${id}`,
);
}
export function getReceiptOrderDetailListByOrderId(orderId: number) {
return requestClient.get<WmsReceiptOrderDetailApi.ReceiptOrderDetail[]>(
`/wms/receipt-order-detail/list-by-order-id?orderId=${orderId}`,
);
}
export function createReceiptOrder(data: WmsReceiptOrderApi.ReceiptOrder) {
return requestClient.post('/wms/receipt-order/create', data);
}
export function updateReceiptOrder(data: WmsReceiptOrderApi.ReceiptOrder) {
return requestClient.put('/wms/receipt-order/update', data);
}
export function completeReceiptOrder(id: number) {
return requestClient.put(`/wms/receipt-order/complete?id=${id}`);
}
export function cancelReceiptOrder(id: number) {
return requestClient.put(`/wms/receipt-order/cancel?id=${id}`);
}
export function deleteReceiptOrder(id: number) {
return requestClient.delete(`/wms/receipt-order/delete?id=${id}`);
}
export function exportReceiptOrder(params: any) {
return requestClient.download('/wms/receipt-order/export-excel', { params });
}

View File

@ -0,0 +1,21 @@
export namespace WmsShipmentOrderDetailApi {
/** WMS 出库单明细 */
export interface ShipmentOrderDetail {
id?: number;
orderId?: number;
itemId?: number;
itemCode?: string;
itemName?: string;
unit?: string;
skuId?: number;
skuCode?: string;
skuName?: string;
warehouseId?: number;
warehouseName?: string;
quantity?: number;
availableQuantity?: number;
price?: number;
totalPrice?: number;
createTime?: Date;
}
}

View File

@ -0,0 +1,74 @@
import type { PageParam, PageResult } from '@vben/request';
import type { WmsShipmentOrderDetailApi } from './detail';
import { requestClient } from '#/api/request';
export namespace WmsShipmentOrderApi {
/** WMS 出库单 */
export interface ShipmentOrder {
id?: number;
no?: string;
type?: number;
orderTime?: string;
status?: number;
bizOrderNo?: string;
merchantId?: number;
merchantName?: string;
remark?: string;
warehouseId?: number;
warehouseName?: string;
totalQuantity?: number;
totalPrice?: number;
details?: WmsShipmentOrderDetailApi.ShipmentOrderDetail[];
createTime?: Date;
creator?: string;
creatorName?: string;
updateTime?: Date;
updater?: string;
updaterName?: string;
}
}
export function getShipmentOrderPage(params: PageParam) {
return requestClient.get<PageResult<WmsShipmentOrderApi.ShipmentOrder>>(
'/wms/shipment-order/page',
{ params },
);
}
export function getShipmentOrder(id: number) {
return requestClient.get<WmsShipmentOrderApi.ShipmentOrder>(
`/wms/shipment-order/get?id=${id}`,
);
}
export function getShipmentOrderDetailListByOrderId(orderId: number) {
return requestClient.get<WmsShipmentOrderDetailApi.ShipmentOrderDetail[]>(
`/wms/shipment-order-detail/list-by-order-id?orderId=${orderId}`,
);
}
export function createShipmentOrder(data: WmsShipmentOrderApi.ShipmentOrder) {
return requestClient.post('/wms/shipment-order/create', data);
}
export function updateShipmentOrder(data: WmsShipmentOrderApi.ShipmentOrder) {
return requestClient.put('/wms/shipment-order/update', data);
}
export function completeShipmentOrder(id: number) {
return requestClient.put(`/wms/shipment-order/complete?id=${id}`);
}
export function cancelShipmentOrder(id: number) {
return requestClient.put(`/wms/shipment-order/cancel?id=${id}`);
}
export function deleteShipmentOrder(id: number) {
return requestClient.delete(`/wms/shipment-order/delete?id=${id}`);
}
export function exportShipmentOrder(params: any) {
return requestClient.download('/wms/shipment-order/export-excel', { params });
}

View File

@ -0,0 +1,39 @@
import type { VbenFormSchema } from '#/adapter/form';
import { markRaw } from 'vue';
import NumberRangeInput from './number-range-input.vue';
export { default as NumberRangeInput } from './number-range-input.vue';
export type NumberRangeValue = [number | undefined, number | undefined];
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;
};
}
export function buildNumberRangeSchema(
label: string,
fieldName: string,
minFieldName: string,
maxFieldName: string,
precision: number,
): VbenFormSchema {
return {
component: markRaw(NumberRangeInput),
componentProps: {
min: 0,
precision,
},
fieldName,
label,
valueFormat: splitNumberRange(minFieldName, maxFieldName),
};
}

View File

@ -0,0 +1,73 @@
<script lang="ts" setup>
import { InputNumber } from 'antdv-next';
type NumberRangeValue = [number | undefined, number | undefined];
const props = withDefaults(
defineProps<{
maxPlaceholder?: string;
min?: number;
minPlaceholder?: string;
precision?: number;
value?: NumberRangeValue;
}>(),
{
maxPlaceholder: '最大值',
min: undefined,
minPlaceholder: '最小值',
precision: 2,
value: undefined,
},
);
const emit = defineEmits<{
'update:value': [value: NumberRangeValue | undefined];
}>();
function normalizeValue(value: unknown) {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : undefined;
}
if (typeof value === 'string' && value.trim() !== '') {
const numberValue = Number(value);
return Number.isFinite(numberValue) ? numberValue : undefined;
}
return undefined;
}
function updateValue(index: 0 | 1, value: unknown) {
const next: NumberRangeValue = [
props.value?.[0] ?? undefined,
props.value?.[1] ?? undefined,
];
next[index] = normalizeValue(value);
emit(
'update:value',
next[0] === undefined && next[1] === undefined ? undefined : next,
);
}
</script>
<template>
<div class="flex w-full items-center gap-2">
<InputNumber
:controls="false"
:min="min"
:placeholder="minPlaceholder"
:precision="precision"
:value="value?.[0]"
class="min-w-0 flex-1"
@update:value="updateValue(0, $event)"
/>
<span class="shrink-0 text-muted-foreground"></span>
<InputNumber
:controls="false"
:min="min"
:placeholder="maxPlaceholder"
:precision="precision"
:value="value?.[1]"
class="min-w-0 flex-1"
@update:value="updateValue(1, $event)"
/>
</div>
</template>

View File

@ -0,0 +1,80 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { DocAlert, Page } from '@vben/common-ui';
import { formatDateTime } from '@vben/utils';
import { Button, Card } from 'antdv-next';
import { WmsWarehouseSelect } from '#/views/wms/md/warehouse/components';
import WmsHomeInventoryCharts from './modules/wms-home-inventory-charts.vue';
import WmsHomeOrderSummaryCards from './modules/wms-home-order-summary-cards.vue';
import WmsHomeOrderTrendChart from './modules/wms-home-order-trend-chart.vue';
defineOptions({ name: 'WmsHome' });
const loading = ref(false);
const warehouseId = ref<number>();
const statTime = ref(formatDateTime(new Date()));
const orderSummaryCardsRef =
ref<InstanceType<typeof WmsHomeOrderSummaryCards>>();
const orderTrendChartRef = ref<InstanceType<typeof WmsHomeOrderTrendChart>>();
const inventoryChartsRef = ref<InstanceType<typeof WmsHomeInventoryCharts>>();
async function refresh() {
loading.value = true;
try {
await Promise.all([
orderSummaryCardsRef.value?.load(warehouseId.value),
orderTrendChartRef.value?.load(warehouseId.value),
inventoryChartsRef.value?.load(warehouseId.value),
]);
statTime.value = formatDateTime(new Date());
} finally {
loading.value = false;
}
}
onMounted(() => {
refresh();
});
</script>
<template>
<Page>
<template #doc>
<DocAlert
title="WMS 手册(功能开启)"
url="https://doc.iocoder.cn/wms/build/"
/>
</template>
<div class="flex flex-col gap-2">
<Card :body-style="{ padding: '16px' }">
<div class="flex flex-wrap items-center justify-between gap-4">
<div>
<div class="text-xl font-semibold">WMS 首页</div>
<div class="text-sm text-muted-foreground">
单据工作台 / 库存概览
</div>
</div>
<div class="flex items-center gap-2">
<WmsWarehouseSelect
v-model="warehouseId"
class="!w-[220px]"
placeholder="全部仓库"
@change="refresh"
/>
<Button :loading="loading" @click="refresh"></Button>
</div>
</div>
</Card>
<WmsHomeOrderSummaryCards ref="orderSummaryCardsRef" />
<WmsHomeOrderTrendChart ref="orderTrendChartRef" />
<WmsHomeInventoryCharts ref="inventoryChartsRef" />
<div class="text-center text-sm text-muted-foreground">
统计时间{{ statTime }}
</div>
</div>
</Page>
</template>

View File

@ -0,0 +1,116 @@
import type { EChartsOption } from '@vben/plugins/echarts';
import type { WmsHomeStatisticsApi } from '#/api/wms/home';
import { formatQuantity } from '#/views/wms/utils/format';
export interface InventoryChartItem {
name: string;
value: number;
}
/** 格式化库存数量展示文本 */
export function formatQuantityText(value?: number) {
return formatQuantity(value || 0) || '0.00';
}
/** 转换库存排行接口数据为 ECharts 可消费的 name/value 数据 */
export function buildInventoryChartItemList(
list: undefined | WmsHomeStatisticsApi.InventoryRankItem[],
emptyName: string,
) {
return (list || [])
.map((item) => ({
name: item.name || emptyName,
value: Number(item.quantity || 0),
}))
.filter((item) => item.value > 0);
}
/** 格式化货物占比图例,补充当前商品库存占比 */
function formatGoodsLegend(name: string, goodsShareList: InventoryChartItem[]) {
const total = goodsShareList.reduce((sum, item) => sum + item.value, 0);
const item = goodsShareList.find((goods) => goods.name === name);
if (!total || !item) {
return name;
}
return `${name} ${((item.value / total) * 100).toFixed(1)}%`;
}
/** 货物占比图表配置 */
export function getGoodsShareChartOptions(
goodsShareList: InventoryChartItem[],
): EChartsOption {
return {
color: ['#2f7df6', '#18a058', '#f59e0b', '#7c3aed', '#14b8a6'],
legend: {
formatter: (name: string) => formatGoodsLegend(name, goodsShareList),
itemHeight: 10,
itemWidth: 10,
orient: 'vertical',
right: 10,
top: 'middle',
type: 'scroll',
},
series: [
{
avoidLabelOverlap: true,
center: ['34%', '52%'],
data: goodsShareList,
label: { show: false },
labelLine: { show: false },
name: '货物占比',
radius: ['48%', '70%'],
type: 'pie',
},
],
tooltip: {
formatter: '{b}<br/>库存:{c} ({d}%)',
trigger: 'item',
},
};
}
/** 库存分布图表配置 */
export function getWarehouseDistributionChartOptions(
warehouseDistributionList: InventoryChartItem[],
): EChartsOption {
const sortedList = warehouseDistributionList.toReversed();
return {
color: ['#2f7df6'],
grid: { bottom: 16, containLabel: true, left: 24, right: 40, top: 12 },
series: [
{
barMaxWidth: 16,
data: sortedList.map((item) => item.value),
label: {
formatter: ({ value }) => formatQuantityText(Number(value || 0)),
position: 'right',
show: true,
},
name: '库存',
type: 'bar',
},
],
tooltip: {
formatter: (params: unknown) => {
const item = (Array.isArray(params) ? params[0] : params) as {
name?: string;
value?: number;
};
return `${item.name || '-'}<br/>库存:${formatQuantityText(item.value)}`;
},
trigger: 'axis',
},
xAxis: {
splitLine: { lineStyle: { color: '#eef2f7' } },
type: 'value',
},
yAxis: {
axisLine: { show: false },
axisTick: { show: false },
data: sortedList.map((item) => item.name),
type: 'category',
},
};
}

View File

@ -0,0 +1,116 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { InventoryChartItem } from './wms-home-inventory-chart-options';
import { nextTick, ref } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { Card } from 'antdv-next';
import { getInventorySummary } from '#/api/wms/home';
import {
buildInventoryChartItemList,
formatQuantityText,
getGoodsShareChartOptions,
getWarehouseDistributionChartOptions,
} from './wms-home-inventory-chart-options';
defineOptions({ name: 'WmsHomeInventoryCharts' });
const GOODS_SHARE_LIMIT = 5;
const WAREHOUSE_DISTRIBUTION_LIMIT = 8;
const loading = ref(false);
const totalQuantity = ref(0);
const goodsShareList = ref<InventoryChartItem[]>([]);
const warehouseDistributionList = ref<InventoryChartItem[]>([]);
const goodsShareChartRef = ref<EchartsUIType>();
const warehouseDistributionChartRef = ref<EchartsUIType>();
const { renderEcharts: renderGoodsShareEcharts } =
useEcharts(goodsShareChartRef);
const { renderEcharts: renderWarehouseDistributionEcharts } = useEcharts(
warehouseDistributionChartRef,
);
/** 使用最新库存汇总数据渲染首页库存图表 */
async function renderCharts() {
await nextTick();
await Promise.all([
renderGoodsShareEcharts(getGoodsShareChartOptions(goodsShareList.value)),
renderWarehouseDistributionEcharts(
getWarehouseDistributionChartOptions(warehouseDistributionList.value),
),
]);
}
/** 加载指定仓库的库存汇总和排行数据 */
async function load(warehouseId?: number) {
loading.value = true;
try {
const data = await getInventorySummary({
...(warehouseId ? { warehouseId } : {}),
goodsLimit: GOODS_SHARE_LIMIT,
warehouseLimit: WAREHOUSE_DISTRIBUTION_LIMIT,
});
totalQuantity.value = Number(data.totalQuantity || 0);
goodsShareList.value = buildInventoryChartItemList(
data.goodsShareList,
'未命名商品',
);
warehouseDistributionList.value = buildInventoryChartItemList(
data.warehouseDistributionList,
'未指定仓库',
);
await renderCharts();
} finally {
loading.value = false;
}
}
defineExpose({ load });
</script>
<template>
<div class="grid grid-cols-2 gap-4 max-lg:grid-cols-1">
<Card :body-style="{ padding: '12px 16px 16px' }">
<div class="mb-3">
<div class="font-semibold">货物占比</div>
<div class="text-sm text-muted-foreground">
按商品库存数量汇总 Top 5
</div>
</div>
<div class="relative min-h-[300px]">
<EchartsUI ref="goodsShareChartRef" height="300px" />
<div
v-if="loading"
class="absolute inset-0 flex items-center justify-center bg-card/70 text-sm text-muted-foreground"
>
加载中
</div>
</div>
</Card>
<Card :body-style="{ padding: '12px 16px 16px' }">
<div class="mb-3 flex justify-between">
<div>
<div class="font-semibold">库存分布</div>
<div class="text-sm text-muted-foreground">按仓库库存数量汇总</div>
</div>
<span class="font-semibold">
总库存 {{ formatQuantityText(totalQuantity) }}
</span>
</div>
<div class="relative min-h-[300px]">
<EchartsUI ref="warehouseDistributionChartRef" height="300px" />
<div
v-if="loading"
class="absolute inset-0 flex items-center justify-center bg-card/70 text-sm text-muted-foreground"
>
加载中
</div>
</div>
</Card>
</div>
</template>

View File

@ -0,0 +1,193 @@
<script lang="ts" setup>
import type { WmsHomeStatisticsApi } from '#/api/wms/home';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { DICT_TYPE, OrderStatusEnum, OrderTypeEnum } from '@vben/constants';
import { getDictLabel } from '@vben/hooks';
import { Button, Card, message } from 'antdv-next';
import { getOrderSummary } from '#/api/wms/home';
defineOptions({ name: 'WmsHomeOrderSummaryCards' });
interface StatusItem {
color: string;
label: string;
value: number;
}
interface OrderSummaryItem {
color: string;
routeName: string;
statuses?: WmsHomeStatisticsApi.OrderStatus[];
title: string;
total?: number;
type: number;
}
const router = useRouter();
const orderDefinitions: OrderSummaryItem[] = [
{
color: '#2f7df6',
routeName: 'WmsReceiptOrder',
title: getDictLabel(
DICT_TYPE.WMS_ORDER_TYPE,
OrderTypeEnum.RECEIPT,
).replace(/单$/, ''),
type: OrderTypeEnum.RECEIPT,
},
{
color: '#18a058',
routeName: 'WmsShipmentOrder',
title: getDictLabel(
DICT_TYPE.WMS_ORDER_TYPE,
OrderTypeEnum.SHIPMENT,
).replace(/单$/, ''),
type: OrderTypeEnum.SHIPMENT,
},
{
color: '#f59e0b',
routeName: 'WmsMovementOrder',
title: getDictLabel(
DICT_TYPE.WMS_ORDER_TYPE,
OrderTypeEnum.MOVEMENT,
).replace(/单$/, ''),
type: OrderTypeEnum.MOVEMENT,
},
{
color: '#7c3aed',
routeName: 'WmsCheckOrder',
title: getDictLabel(DICT_TYPE.WMS_ORDER_TYPE, OrderTypeEnum.CHECK).replace(
/单$/,
'',
),
type: OrderTypeEnum.CHECK,
},
];
const statusList: StatusItem[] = [
{
color: '#409eff',
label:
getDictLabel(DICT_TYPE.WMS_ORDER_STATUS, OrderStatusEnum.PREPARE) ||
'草稿',
value: OrderStatusEnum.PREPARE,
},
{
color: '#67c23a',
label:
getDictLabel(DICT_TYPE.WMS_ORDER_STATUS, OrderStatusEnum.FINISHED) ||
'已完成',
value: OrderStatusEnum.FINISHED,
},
{
color: '#909399',
label:
getDictLabel(DICT_TYPE.WMS_ORDER_STATUS, OrderStatusEnum.CANCELED) ||
'已作废',
value: OrderStatusEnum.CANCELED,
},
];
const loading = ref(false);
const summaryList = ref<OrderSummaryItem[]>(orderDefinitions);
function getStatusCount(item: OrderSummaryItem, status: number) {
return item.statuses?.find((row) => row.status === status)?.count ?? 0;
}
function getStatusPercent(item: OrderSummaryItem, status: number) {
const total = item.total ?? 0;
if (total <= 0) {
return '0%';
}
return `${(getStatusCount(item, status) / total) * 100}%`;
}
async function handleNavigate(routeName: string) {
try {
await router.push({ name: routeName });
} catch {
message.warning('当前菜单尚未加载,请从左侧菜单进入对应页面');
}
}
async function load(warehouseId?: number) {
loading.value = true;
try {
const data = await getOrderSummary(warehouseId ? { warehouseId } : {});
summaryList.value = orderDefinitions.map((definition) => {
const summary = data.find((item) => item.type === definition.type);
return {
...definition,
statuses: summary?.statuses,
total: summary?.total,
};
});
} finally {
loading.value = false;
}
}
defineExpose({ load });
</script>
<template>
<div class="grid grid-cols-4 gap-4 max-xl:grid-cols-2 max-sm:grid-cols-1">
<Card
v-for="item in summaryList"
:key="item.type"
:body-style="{ padding: '12px 16px 16px' }"
class="h-full shadow-sm"
:loading="loading"
:style="{ borderTop: `3px solid ${item.color}` }"
>
<div class="mb-3 flex items-center justify-between">
<div class="flex items-center gap-2 font-semibold">
<span
class="h-2 w-2 rounded-full"
:style="{ backgroundColor: item.color }"
></span>
{{ item.title }}
</div>
<Button
size="small"
type="link"
@click="handleNavigate(item.routeName)"
>
查看
</Button>
</div>
<div class="mb-1 flex items-baseline gap-2">
<span class="text-muted-foreground">合计</span>
<span class="text-3xl font-bold leading-9">
{{ item.total?.toLocaleString() ?? 0 }}
</span>
<span class="text-muted-foreground"></span>
</div>
<div class="my-3 flex h-2 overflow-hidden rounded-full bg-muted">
<span
v-for="status in statusList"
:key="status.value"
class="h-full"
:style="{
backgroundColor: status.color,
width: getStatusPercent(item, status.value),
}"
></span>
</div>
<div class="grid grid-cols-3 gap-2 text-sm">
<div v-for="status in statusList" :key="status.value">
<div class="truncate text-muted-foreground">{{ status.label }}</div>
<div class="font-semibold" :style="{ color: status.color }">
{{ getStatusCount(item, status.value).toLocaleString() }}
</div>
</div>
</div>
</Card>
</div>
</template>

View File

@ -0,0 +1,96 @@
import type { EChartsOption } from '@vben/plugins/echarts';
import type { WmsHomeStatisticsApi } from '#/api/wms/home';
import { DICT_TYPE, OrderTypeEnum } from '@vben/constants';
import { getDictLabel } from '@vben/hooks';
import { formatDate } from '@vben/utils';
interface OrderDefinition {
color: string;
title: string;
trendField: keyof Pick<
WmsHomeStatisticsApi.OrderTrend,
'checkCount' | 'movementCount' | 'receiptCount' | 'shipmentCount'
>;
type: number;
}
const orderDefinitions: OrderDefinition[] = [
{
color: '#2f7df6',
title: getDictLabel(
DICT_TYPE.WMS_ORDER_TYPE,
OrderTypeEnum.RECEIPT,
).replace(/单$/, ''),
trendField: 'receiptCount',
type: OrderTypeEnum.RECEIPT,
},
{
color: '#18a058',
title: getDictLabel(
DICT_TYPE.WMS_ORDER_TYPE,
OrderTypeEnum.SHIPMENT,
).replace(/单$/, ''),
trendField: 'shipmentCount',
type: OrderTypeEnum.SHIPMENT,
},
{
color: '#f59e0b',
title: getDictLabel(
DICT_TYPE.WMS_ORDER_TYPE,
OrderTypeEnum.MOVEMENT,
).replace(/单$/, ''),
trendField: 'movementCount',
type: OrderTypeEnum.MOVEMENT,
},
{
color: '#7c3aed',
title: getDictLabel(DICT_TYPE.WMS_ORDER_TYPE, OrderTypeEnum.CHECK).replace(
/单$/,
'',
),
trendField: 'checkCount',
type: OrderTypeEnum.CHECK,
},
];
/** 格式化趋势接口返回的时间戳为图表横轴日期 */
function formatTrendTime(time: number | string) {
const date = new Date(time);
return Number.isNaN(date.getTime())
? `${time}`
: (formatDate(date, 'MM-DD') as string);
}
/** 单据趋势图表配置 */
export function getOrderTrendChartOptions(
list: WmsHomeStatisticsApi.OrderTrend[],
): EChartsOption {
const labels = list.map((item) => formatTrendTime(item.time));
return {
color: orderDefinitions.map((item) => item.color),
grid: { bottom: 24, containLabel: true, left: 28, right: 24, top: 48 },
legend: { itemHeight: 10, itemWidth: 10, top: 6 },
series: orderDefinitions.map((item) => ({
barMaxWidth: 18,
data: list.map((row) => Number(row[item.trendField] || 0)),
emphasis: { focus: 'series' },
name: item.title,
type: 'bar',
})),
tooltip: { axisPointer: { type: 'shadow' }, trigger: 'axis' },
xAxis: {
axisLine: { lineStyle: { color: '#dcdfe6' } },
axisTick: { show: false },
data: labels,
type: 'category',
},
yAxis: {
minInterval: 1,
name: '单据数',
splitLine: { lineStyle: { color: '#eef2f7' } },
type: 'value',
},
};
}

View File

@ -0,0 +1,84 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { WmsHomeStatisticsApi } from '#/api/wms/home';
import { nextTick, ref } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { Card, Segmented } from 'antdv-next';
import { getOrderTrend } from '#/api/wms/home';
import { getOrderTrendChartOptions } from './wms-home-order-trend-chart-options';
defineOptions({ name: 'WmsHomeOrderTrendChart' });
const loading = ref(false);
const warehouseId = ref<number>();
const trendDays = ref(7);
const trendList = ref<WmsHomeStatisticsApi.OrderTrend[]>([]);
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const trendDayOptions = [
{ label: '近7天', value: 7 },
{ label: '近30天', value: 30 },
];
/** 使用最新趋势数据渲染单据趋势图 */
async function renderChart() {
await nextTick();
await renderEcharts(getOrderTrendChartOptions(trendList.value));
}
/** 加载指定仓库的单据趋势数据 */
async function load(selectedWarehouseId?: number) {
warehouseId.value = selectedWarehouseId;
loading.value = true;
try {
trendList.value = await getOrderTrend(
trendDays.value,
selectedWarehouseId ? { warehouseId: selectedWarehouseId } : {},
);
await renderChart();
} finally {
loading.value = false;
}
}
/** 切换趋势统计时间范围并刷新图表 */
async function handleTrendDaysChange(value: number | string) {
trendDays.value = Number(value);
await load(warehouseId.value);
}
defineExpose({ load });
</script>
<template>
<Card :body-style="{ padding: '12px 16px 16px' }">
<div class="mb-3 flex items-center justify-between">
<div>
<div class="font-semibold">单据趋势</div>
<div class="text-sm text-muted-foreground">
入库出库移库盘库单据数量
</div>
</div>
<Segmented
:options="trendDayOptions"
:value="trendDays"
@change="handleTrendDaysChange"
/>
</div>
<div class="relative min-h-[330px]">
<EchartsUI ref="chartRef" height="330px" />
<div
v-if="loading"
class="absolute inset-0 flex items-center justify-center bg-card/70 text-sm text-muted-foreground"
>
加载中
</div>
</div>
</Card>
</template>

View File

@ -0,0 +1,2 @@
export { default as WmsInventorySelect } from './select.vue';
export type { InventorySelectRow } from './select.vue';

View File

@ -0,0 +1,210 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { WmsInventoryApi } from '#/api/wms/inventory';
import { nextTick, ref } from 'vue';
import { message, Modal } from 'antdv-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getInventoryPage } from '#/api/wms/inventory';
import { formatQuantity } from '#/views/wms/utils/format';
import {
useInventorySelectGridColumns,
useInventorySelectGridFormSchema,
} from '../index/data';
export interface InventorySelectRow extends WmsInventoryApi.Inventory {
availableQuantity?: number;
price?: number;
}
defineOptions({ name: 'WmsInventorySelect' });
const props = defineProps<{
warehouseId?: number;
}>();
const emit = defineEmits<{
change: [list: InventorySelectRow[]];
}>();
const open = ref(false);
const disabledInventoryKeys = ref<Set<string>>(new Set());
interface CellDblclickEvent {
row: InventorySelectRow;
}
/** 获得行唯一标识 */
function getRowKey(row: InventorySelectRow) {
return `inventory-${row.id || `${row.skuId}-${row.warehouseId}`}`;
}
/** 获得业务库存标识 */
function getInventoryKey(
row: Pick<InventorySelectRow, 'skuId' | 'warehouseId'>,
) {
return row.skuId && row.warehouseId
? `${row.skuId}-${row.warehouseId}`
: undefined;
}
/** 判断库存是否可选 */
function isInventorySelectable(row: InventorySelectRow) {
const key = getInventoryKey(row);
return !key || !disabledInventoryKeys.value.has(key);
}
/** 合并当前页和跨页保留的选择记录 */
function mergeSelectedRows(rows: InventorySelectRow[]) {
const selectedMap = new Map<string, InventorySelectRow>();
rows
.filter((row) => isInventorySelectable(row))
.forEach((row) => selectedMap.set(getRowKey(row), row));
return [...selectedMap.values()];
}
/** 获取 VXE 当前页和 reserve 中的完整选择 */
function getSelectedRows() {
const records =
(gridApi.grid.getCheckboxRecords?.() as InventorySelectRow[] | undefined) ||
[];
const reserves =
(gridApi.grid.getCheckboxReserveRecords?.() as
| InventorySelectRow[]
| undefined) || [];
return mergeSelectedRows([...reserves, ...records]);
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useInventorySelectGridFormSchema(),
},
gridOptions: {
columns: useInventorySelectGridColumns(),
height: 560,
keepSource: true,
showOverflow: false,
checkboxConfig: {
checkMethod: ({ row }: { row: InventorySelectRow }) =>
isInventorySelectable(row),
highlight: true,
range: true,
reserve: true,
},
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
const data = await getInventoryPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
type: 'warehouse',
warehouseId: props.warehouseId,
onlyPositiveQuantity: true,
...formValues,
});
return {
...data,
list: (data.list || []).map((inventory) => ({
...inventory,
availableQuantity: inventory.quantity,
})),
};
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<InventorySelectRow>,
gridEvents: {
cellDblclick: handleCellDblclick,
},
});
async function openModal(selectedInventoryKeys: string[] = []) {
if (!props.warehouseId) {
message.warning('请先选择仓库');
return;
}
open.value = true;
disabledInventoryKeys.value = new Set(selectedInventoryKeys);
await nextTick();
await gridApi.grid.clearCheckboxRow();
await gridApi.grid.clearCheckboxReserve();
await gridApi.formApi.resetForm();
await gridApi.query();
}
async function closeModal() {
open.value = false;
disabledInventoryKeys.value = new Set();
await gridApi.grid.clearCheckboxRow();
await gridApi.grid.clearCheckboxReserve();
}
function confirmSelectedRows(rows: InventorySelectRow[]) {
const selectedList = mergeSelectedRows(rows);
if (selectedList.length === 0) {
message.warning('请选择库存');
return;
}
emit('change', selectedList);
closeModal();
}
function handleConfirm() {
confirmSelectedRows(getSelectedRows());
}
/** 双击行直接选择并确认 */
function handleCellDblclick({ row }: CellDblclickEvent) {
if (!isInventorySelectable(row)) {
message.warning('该库存已添加');
return;
}
confirmSelectedRows(mergeSelectedRows([...getSelectedRows(), row]));
}
defineExpose({ open: openModal });
</script>
<template>
<Modal
v-model:open="open"
title="库存选择"
width="80%"
:destroy-on-close="true"
@ok="handleConfirm"
@cancel="closeModal"
>
<Grid table-title="">
<template #itemInfo="{ row }">
<div class="flex flex-col gap-1 py-1 leading-5">
<div>{{ row.itemName || '-' }}</div>
<div v-if="row.itemCode" class="text-xs text-gray-500">
商品编号{{ row.itemCode }}
</div>
</div>
</template>
<template #skuInfo="{ row }">
<div class="flex flex-col gap-1 py-1 leading-5">
<div>{{ row.skuName || '-' }}</div>
<div v-if="row.skuCode" class="text-xs text-gray-500">
规格编号{{ row.skuCode }}
</div>
</div>
</template>
<template #availableQuantity="{ row }">
{{ formatQuantity(row.availableQuantity) }}
</template>
</Grid>
</Modal>
</template>

View File

@ -0,0 +1,159 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { getWarehouseSimpleList } from '#/api/wms/md/warehouse';
import { getRangePickerDefaultProps } from '#/utils';
/** 搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'orderType',
label: '单据类型',
component: 'Select',
componentProps: {
allowClear: true,
options: getDictOptions(DICT_TYPE.WMS_ORDER_TYPE, 'number'),
placeholder: '请选择单据类型',
},
},
{
fieldName: 'orderNo',
label: '单据号',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入单据号',
},
},
{
fieldName: 'warehouseId',
label: '仓库',
component: 'ApiSelect',
componentProps: {
allowClear: true,
api: getWarehouseSimpleList,
labelField: 'name',
placeholder: '请选择仓库',
showSearch: true,
valueField: 'id',
},
},
{
fieldName: 'itemCode',
label: '商品编号',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入商品编号',
},
},
{
fieldName: 'itemName',
label: '商品名称',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入商品名称',
},
},
{
fieldName: 'skuCode',
label: '规格编号',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入规格编号',
},
},
{
fieldName: 'skuName',
label: '规格名称',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入规格名称',
},
},
{
fieldName: 'createTime',
label: '操作时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
}
/** 库存流水列表字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'orderNo',
title: '单据号',
width: 180,
fixed: 'left',
},
{
field: 'orderType',
title: '单据类型',
width: 110,
fixed: 'left',
align: 'center',
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.WMS_ORDER_TYPE },
},
},
{
field: 'itemInfo',
title: '商品信息',
minWidth: 220,
slots: { default: 'itemInfo' },
},
{
field: 'skuInfo',
title: '规格信息',
minWidth: 220,
slots: { default: 'skuInfo' },
},
{
field: 'warehouseName',
title: '仓库',
minWidth: 160,
},
{
field: 'beforeQuantity',
title: '操作前',
align: 'right',
minWidth: 110,
slots: { default: 'beforeQuantity' },
},
{
field: 'afterQuantity',
title: '操作后',
align: 'right',
minWidth: 110,
slots: { default: 'afterQuantity' },
},
{
field: 'quantityPrice',
title: '数量/金额(元)',
minWidth: 180,
slots: { default: 'quantityPrice' },
},
{
field: 'createTime',
title: '操作时间',
width: 180,
fixed: 'right',
align: 'center',
formatter: 'formatDateTime',
},
];
}

View File

@ -0,0 +1,104 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { WmsInventoryHistoryApi } from '#/api/wms/inventory/history';
import { DocAlert, Page } from '@vben/common-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getInventoryHistoryPage } from '#/api/wms/inventory/history';
import { formatPrice, formatQuantity } from '#/views/wms/utils/format';
import { useGridColumns, useGridFormSchema } from './data';
defineOptions({ name: 'WmsInventoryHistory' });
function hasValue(value: unknown) {
return value !== undefined && value !== null;
}
const [Grid] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
showOverflow: false,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getInventoryHistoryPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<WmsInventoryHistoryApi.InventoryHistory>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【库存】库存记录、流水、统计"
url="https://doc.iocoder.cn/wms/inventory/"
/>
</template>
<Grid table-title="">
<template #itemInfo="{ row }">
<div class="flex flex-col gap-1 py-1 leading-5">
<div class="text-sm">{{ row.itemName || '-' }}</div>
<div v-if="row.itemCode" class="break-all text-xs text-gray-500">
商品编号{{ row.itemCode }}
</div>
</div>
</template>
<template #skuInfo="{ row }">
<div class="flex flex-col gap-1 py-1 leading-5">
<div class="text-sm">{{ row.skuName || '-' }}</div>
<div v-if="row.skuCode" class="break-all text-xs text-gray-500">
规格编号{{ row.skuCode }}
</div>
</div>
</template>
<template #beforeQuantity="{ row }">
{{ formatQuantity(row.beforeQuantity) || '-' }}
</template>
<template #afterQuantity="{ row }">
{{ formatQuantity(row.afterQuantity) || '-' }}
</template>
<template #quantityPrice="{ row }">
<div class="flex flex-col gap-1 py-1 leading-5">
<div class="flex justify-between gap-2">
<span class="shrink-0">数量</span>
<span>{{ formatQuantity(row.quantity) }}</span>
</div>
<div v-if="hasValue(row.price)" class="flex justify-between gap-2">
<span class="shrink-0">单价</span>
<span>{{ formatPrice(row.price) }}</span>
</div>
<div
v-if="hasValue(row.totalPrice)"
class="flex justify-between gap-2"
>
<span class="shrink-0">金额</span>
<span>{{ formatPrice(row.totalPrice) }}</span>
</div>
</div>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,249 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { getWarehouseSimpleList } from '#/api/wms/md/warehouse';
export const INVENTORY_DIMENSION = {
ITEM: 'item',
WAREHOUSE: 'warehouse',
} as const;
export type InventoryDimension =
(typeof INVENTORY_DIMENSION)[keyof typeof INVENTORY_DIMENSION];
const dimensionOptions = [
{ label: '仓库', value: INVENTORY_DIMENSION.WAREHOUSE },
{ label: '商品', value: INVENTORY_DIMENSION.ITEM },
];
interface DimensionChangeEvent {
target?: {
value?: InventoryDimension;
};
}
type DimensionChangeHandler = (
dimension: InventoryDimension,
) => Promise<void> | void;
/** 统一解析 antd 事件对象和原始维度值 */
function getDimensionChangeValue(
value?: DimensionChangeEvent | InventoryDimension,
): InventoryDimension {
if (
value === INVENTORY_DIMENSION.ITEM ||
value === INVENTORY_DIMENSION.WAREHOUSE
) {
return value;
}
return value?.target?.value ?? INVENTORY_DIMENSION.WAREHOUSE;
}
/** 搜索表单 */
export function useGridFormSchema(
onDimensionChange: DimensionChangeHandler,
): VbenFormSchema[] {
return [
{
fieldName: 'type',
label: '统计维度',
component: 'RadioGroup',
defaultValue: INVENTORY_DIMENSION.WAREHOUSE,
componentProps: {
buttonStyle: 'solid',
optionType: 'button',
options: dimensionOptions,
onChange: (value: DimensionChangeEvent | InventoryDimension) => {
void onDimensionChange(getDimensionChangeValue(value));
},
},
},
{
fieldName: 'warehouseId',
label: '仓库',
component: 'ApiSelect',
componentProps: {
allowClear: true,
api: getWarehouseSimpleList,
labelField: 'name',
placeholder: '请选择仓库',
showSearch: true,
valueField: 'id',
},
},
{
fieldName: 'itemName',
label: '商品名称',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入商品名称',
},
},
{
fieldName: 'itemCode',
label: '商品编号',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入商品编号',
},
},
{
fieldName: 'skuName',
label: '规格名称',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入规格名称',
},
},
{
fieldName: 'skuCode',
label: '规格编号',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入规格编号',
},
},
];
}
const warehouseDimensionColumns: VxeTableGridOptions['columns'] = [
{
field: 'warehouseId',
title: '仓库',
minWidth: 160,
slots: { default: 'warehouseName' },
},
{
field: 'warehouseItemId',
title: '商品信息',
minWidth: 240,
slots: { default: 'itemInfo' },
},
{
field: 'skuId',
title: '规格信息',
minWidth: 220,
slots: { default: 'skuInfo' },
},
{
field: 'quantity',
title: '库存',
align: 'right',
minWidth: 130,
slots: { default: 'quantity' },
},
];
const itemDimensionColumns: VxeTableGridOptions['columns'] = [
{
field: 'itemId',
title: '商品信息',
minWidth: 240,
slots: { default: 'itemInfo' },
},
{
field: 'skuId',
title: '规格信息',
minWidth: 220,
slots: { default: 'skuInfo' },
},
{
field: 'skuWarehouseId',
title: '仓库',
minWidth: 160,
slots: { default: 'warehouseName' },
},
{
field: 'quantity',
title: '库存',
align: 'right',
minWidth: 130,
slots: { default: 'quantity' },
},
];
/** 库存统计列表字段 */
export function useGridColumns(
dimension: InventoryDimension = INVENTORY_DIMENSION.WAREHOUSE,
): VxeTableGridOptions['columns'] {
return dimension === INVENTORY_DIMENSION.ITEM
? itemDimensionColumns
: warehouseDimensionColumns;
}
/** 库存选择弹窗搜索表单 */
export function useInventorySelectGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'itemName',
label: '商品名称',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入商品名称',
},
},
{
fieldName: 'itemCode',
label: '商品编号',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入商品编号',
},
},
{
fieldName: 'skuName',
label: '规格名称',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入规格名称',
},
},
{
fieldName: 'skuCode',
label: '规格编号',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入规格编号',
},
},
];
}
/** 库存选择弹窗列表字段 */
export function useInventorySelectGridColumns(): VxeTableGridOptions['columns'] {
return [
{ type: 'checkbox', width: 50 },
{
field: 'itemInfo',
title: '商品信息',
minWidth: 220,
slots: { default: 'itemInfo' },
},
{
field: 'skuInfo',
title: '规格信息',
minWidth: 220,
slots: { default: 'skuInfo' },
},
{
field: 'warehouseName',
title: '仓库',
minWidth: 180,
},
{
field: 'availableQuantity',
title: '可用库存',
align: 'right',
width: 130,
slots: { default: 'availableQuantity' },
},
];
}

View File

@ -0,0 +1,190 @@
<script lang="ts" setup>
import type { InventoryDimension } from './data';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { WmsInventoryApi } from '#/api/wms/inventory';
import { ref } from 'vue';
import { DocAlert, Page } from '@vben/common-ui';
import { Checkbox } from 'antdv-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getInventoryPage } from '#/api/wms/inventory';
import { formatQuantity } from '#/views/wms/utils/format';
import { INVENTORY_DIMENSION, useGridColumns, useGridFormSchema } from './data';
interface InventoryRow extends WmsInventoryApi.Inventory {
skuWarehouseId?: string;
warehouseItemId?: string;
}
defineOptions({ name: 'WmsInventory' });
const currentRows = ref<InventoryRow[]>([]);
const currentDimension = ref<InventoryDimension>(INVENTORY_DIMENSION.WAREHOUSE);
const filterZero = ref(false);
/** 补充合并单元格需要的复合 key */
function buildRows(items: WmsInventoryApi.Inventory[]) {
return items.map((item) => ({
...item,
skuWarehouseId: `${item.skuId || 0}-${item.warehouseId || 0}`,
warehouseItemId: `${item.warehouseId || 0}-${item.itemId || 0}`,
}));
}
/** 切换统计维度时同步表单、表头和查询参数 */
async function handleDimensionChange(dimension: InventoryDimension) {
// spanMethod
currentDimension.value = dimension;
//
await gridApi.grid.reloadColumn(useGridColumns(dimension) || []);
// VXE proxy type
await gridApi.formApi.setFieldValue('type', dimension);
const formValues = await gridApi.formApi.getValues();
gridApi.formApi.setLatestSubmissionValues(formValues);
// 使
await gridApi.reload(formValues);
}
/** 切换零库存过滤时按当前搜索条件重新查询 */
function handleFilterZeroChange() {
gridApi.query();
}
/** 按列字段读取行值,兼容 VXE 的 field/property */
function getRowPropertyValue(row: InventoryRow | undefined, property: string) {
return row?.[property as keyof InventoryRow];
}
/** 根据统计维度返回需要合并单元格的字段 */
function getRowSpanProperties() {
if (currentDimension.value === INVENTORY_DIMENSION.ITEM) {
return ['itemId', 'skuId', 'skuWarehouseId'];
}
return ['warehouseId', 'warehouseItemId'];
}
/** 合并库存统计的维度单元格 */
function handleSpanMethod({
column,
rowIndex,
}: {
column: { field?: string; property?: string };
rowIndex: number;
}) {
const property = column.field || column.property;
if (!property || !getRowSpanProperties().includes(property)) {
return { colspan: 1, rowspan: 1 };
}
const row = currentRows.value[rowIndex];
if (
rowIndex > 0 &&
getRowPropertyValue(currentRows.value[rowIndex - 1], property) ===
getRowPropertyValue(row, property)
) {
return { colspan: 0, rowspan: 0 };
}
let rowspan = 1;
for (let index = rowIndex + 1; index < currentRows.value.length; index += 1) {
if (
getRowPropertyValue(currentRows.value[index], property) !==
getRowPropertyValue(row, property)
) {
break;
}
rowspan += 1;
}
return { colspan: 1, rowspan };
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(handleDimensionChange),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
// reload(formValues) type
const nextDimension = (formValues.type ||
currentDimension.value ||
INVENTORY_DIMENSION.WAREHOUSE) as InventoryDimension;
currentDimension.value = nextDimension;
await gridApi.grid.reloadColumn(useGridColumns(nextDimension) || []);
// type
const queryParams = { ...formValues };
Reflect.deleteProperty(queryParams, 'type');
const data = await getInventoryPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...queryParams,
type: nextDimension,
onlyPositiveQuantity: filterZero.value ? true : undefined,
});
// spanMethod rowspan
currentRows.value = buildRows(data.list || []);
return {
...data,
list: currentRows.value,
};
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
spanMethod: handleSpanMethod,
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<InventoryRow>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【库存】库存记录、流水、统计"
url="https://doc.iocoder.cn/wms/inventory/"
/>
</template>
<Grid table-title="">
<template #toolbar-tools>
<Checkbox v-model:checked="filterZero" @change="handleFilterZeroChange">
过滤掉库存为 0 的商品
</Checkbox>
</template>
<template #warehouseName="{ row }">
{{ row.warehouseName || '-' }}
</template>
<template #itemInfo="{ row }">
<div>{{ row.itemName || '-' }}</div>
<div v-if="row.itemCode" class="text-xs text-gray-500">
商品编号{{ row.itemCode }}
</div>
</template>
<template #skuInfo="{ row }">
<div>{{ row.skuName || '-' }}</div>
<div v-if="row.skuCode" class="text-xs text-gray-500">
规格编号{{ row.skuCode }}
</div>
</template>
<template #quantity="{ row }">
{{ formatQuantity(row.quantity) }}
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1 @@
export { default as WmsItemBrandSelect } from './select.vue';

View File

@ -0,0 +1,85 @@
<script lang="ts" setup>
import type { SelectValue } from 'antdv-next';
import type { WmsItemBrandApi } from '#/api/wms/md/item/brand';
import { computed, onMounted, ref } from 'vue';
import { Select } from 'antdv-next';
import { getItemBrandSimpleList } from '#/api/wms/md/item/brand';
defineOptions({ name: 'WmsItemBrandSelect', inheritAttrs: false });
withDefaults(
defineProps<{
allowClear?: boolean;
disabled?: boolean;
modelValue?: number;
placeholder?: string;
}>(),
{
allowClear: true,
disabled: false,
modelValue: undefined,
placeholder: '请选择商品品牌',
},
);
const emit = defineEmits<{
change: [brand: undefined | WmsItemBrandApi.ItemBrand];
'update:modelValue': [value: number | undefined];
}>();
const loading = ref(false);
const brandList = ref<WmsItemBrandApi.ItemBrand[]>([]);
const options = computed(() =>
brandList.value
.filter((brand) => brand.id !== undefined)
.map((brand) => ({
label: brand.name,
value: brand.id,
})),
);
/** 选中变化 */
function handleChange(value: SelectValue) {
const brandId = typeof value === 'number' ? value : undefined;
emit('update:modelValue', brandId);
emit(
'change',
brandList.value.find((brand) => brand.id === brandId),
);
}
/** 查询商品品牌精简列表 */
async function getList() {
loading.value = true;
try {
brandList.value = await getItemBrandSimpleList();
} finally {
loading.value = false;
}
}
onMounted(() => {
getList();
});
</script>
<template>
<Select
v-bind="$attrs"
:allow-clear="allowClear"
:disabled="disabled"
:loading="loading"
:options="options"
:placeholder="placeholder"
:value="modelValue"
class="w-full"
option-filter-prop="label"
show-search
@change="handleChange"
/>
</template>

View File

@ -0,0 +1,108 @@
import type { VbenFormApi, VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { h } from 'vue';
import { generateWmsCode } from '@vben/constants';
import { Button } from 'antdv-next';
import { z } from '#/adapter/form';
/** 新增/修改商品品牌的表单 */
export function useFormSchema(formApi?: VbenFormApi): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'code',
label: '品牌编号',
component: 'Input',
componentProps: {
maxLength: 20,
placeholder: '请输入品牌编号',
},
rules: z.string().min(1, '品牌编号不能为空').max(20),
suffix: () => {
return h(
Button,
{
type: 'default',
onClick: () => {
formApi?.setFieldValue('code', generateWmsCode('B'));
},
},
{ default: () => '生成' },
);
},
},
{
fieldName: 'name',
label: '品牌名称',
component: 'Input',
componentProps: {
maxLength: 30,
placeholder: '请输入品牌名称',
},
rules: z.string().min(1, '品牌名称不能为空').max(30),
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'code',
label: '品牌编号',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入品牌编号',
},
},
{
fieldName: 'name',
label: '品牌名称',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入品牌名称',
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'code',
title: '品牌编号',
width: 160,
},
{
field: 'name',
title: '品牌名称',
minWidth: 160,
},
{
field: 'createTime',
title: '创建时间',
width: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 160,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -0,0 +1,145 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { WmsItemBrandApi } from '#/api/wms/md/item/brand';
import { Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { message } from 'antdv-next';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteItemBrand,
exportItemBrand,
getItemBrandPage,
} from '#/api/wms/md/item/brand';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
defineOptions({ name: 'WmsItemBrand' });
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 创建商品品牌 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑商品品牌 */
function handleEdit(row: WmsItemBrandApi.ItemBrand) {
formModalApi.setData(row).open();
}
/** 删除商品品牌 */
async function handleDelete(row: WmsItemBrandApi.ItemBrand) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
});
try {
await deleteItemBrand(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
handleRefresh();
} finally {
hideLoading();
}
}
/** 导出商品品牌 */
async function handleExport() {
const data = await exportItemBrand(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '商品品牌.xls', source: data });
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getItemBrandPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<WmsItemBrandApi.ItemBrand>,
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="handleRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['商品品牌']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['wms:item-brand:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['wms:item-brand:export'],
onClick: handleExport,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'link',
icon: ACTION_ICON.EDIT,
auth: ['wms:item-brand:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
auth: ['wms:item-brand:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,86 @@
<script lang="ts" setup>
import type { WmsItemBrandApi } from '#/api/wms/md/item/brand';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
import {
createItemBrand,
getItemBrand,
updateItemBrand,
} from '#/api/wms/md/item/brand';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
defineOptions({ name: 'WmsItemBrandForm' });
const emit = defineEmits(['success']);
const formData = ref<WmsItemBrandApi.ItemBrand>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['商品品牌'])
: $t('ui.actionTitle.create', ['商品品牌']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
labelWidth: 100,
},
layout: 'horizontal',
schema: [],
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
const data = (await formApi.getValues()) as WmsItemBrandApi.ItemBrand;
try {
await (formData.value?.id
? updateItemBrand(data)
: createItemBrand(data));
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
formApi.setState({ schema: useFormSchema(formApi) });
const data = modalApi.getData<WmsItemBrandApi.ItemBrand>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getItemBrand(data.id);
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle" class="w-1/3">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,2 @@
export { default as WmsItemCategorySelect } from './select.vue';
export { default as WmsItemCategoryTree } from './tree.vue';

View File

@ -0,0 +1,68 @@
<script lang="ts" setup>
import type { WmsItemCategoryApi } from '#/api/wms/md/item/category';
import { computed, onMounted, ref } from 'vue';
import { handleTree } from '@vben/utils';
import { TreeSelect } from 'antdv-next';
import { getItemCategorySimpleList } from '#/api/wms/md/item/category';
/** WMS 商品分类选择器 */
defineOptions({ name: 'WmsItemCategorySelect', inheritAttrs: false });
const props = withDefaults(
defineProps<{
allowClear?: boolean;
disabled?: boolean;
modelValue?: number;
placeholder?: string;
}>(),
{
allowClear: true,
disabled: false,
modelValue: undefined,
placeholder: '请选择商品分类',
},
);
const emit = defineEmits<{
'update:modelValue': [value: number | undefined];
}>();
const categoryTree = ref<WmsItemCategoryApi.ItemCategory[]>([]);
const selectValue = computed({
get: () => props.modelValue,
set: (value: number | undefined) => {
emit('update:modelValue', value);
},
});
/** 查询商品分类树 */
async function getCategoryTree() {
const data = await getItemCategorySimpleList();
categoryTree.value = handleTree(data, 'id', 'parentId');
}
onMounted(() => {
getCategoryTree();
});
</script>
<template>
<TreeSelect
v-bind="$attrs"
v-model:value="selectValue"
:allow-clear="allowClear"
:disabled="disabled"
:field-names="{ children: 'children', label: 'name', value: 'id' }"
:placeholder="placeholder"
:tree-data="categoryTree"
class="w-full"
tree-default-expand-all
tree-node-filter-prop="name"
show-search
/>
</template>

View File

@ -0,0 +1,123 @@
<script lang="ts" setup>
import type { WmsItemCategoryApi } from '#/api/wms/md/item/category';
import { onMounted, ref, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { handleTree } from '@vben/utils';
import { Input, Spin, Tree } from 'antdv-next';
import { getItemCategorySimpleList } from '#/api/wms/md/item/category';
/** WMS 商品分类树组件 */
defineOptions({ name: 'WmsItemCategoryTree' });
withDefaults(
defineProps<{
filterPlaceholder?: string;
}>(),
{
filterPlaceholder: '请输入分类名称',
},
);
const emit = defineEmits<{
nodeClick: [categoryId: number | undefined];
}>();
const loading = ref(false);
const filterText = ref('');
const currentNodeId = ref<number>();
const selectedKeys = ref<number[]>([]);
const categoryList = ref<WmsItemCategoryApi.ItemCategory[]>([]);
const categoryTree = ref<any[]>([]);
/** 加载分类树 */
async function loadTree() {
loading.value = true;
try {
const data = await getItemCategorySimpleList();
categoryList.value = data;
categoryTree.value = handleTree(data, 'id', 'parentId');
} finally {
loading.value = false;
}
}
/** 处理搜索逻辑 */
function handleSearch(value: string) {
filterText.value = value;
const filteredList = value
? categoryList.value.filter((item) => item.name?.includes(value))
: categoryList.value;
categoryTree.value = handleTree(filteredList, 'id', 'parentId');
}
/** 处理节点点击:支持点击同一节点取消选中 */
function handleSelect(_selectedKeys: any[], info: any) {
const row = info.node.dataRef as WmsItemCategoryApi.ItemCategory;
if (currentNodeId.value === row.id) {
currentNodeId.value = undefined;
selectedKeys.value = [];
emit('nodeClick', undefined);
return;
}
currentNodeId.value = row.id;
selectedKeys.value = row.id === undefined ? [] : [row.id];
emit('nodeClick', row.id);
}
/** 清空选中状态 */
function reset() {
currentNodeId.value = undefined;
filterText.value = '';
selectedKeys.value = [];
categoryTree.value = handleTree(categoryList.value, 'id', 'parentId');
}
/** 设置当前选中分类 */
function setCurrent(categoryId: number) {
currentNodeId.value = categoryId;
selectedKeys.value = [categoryId];
}
watch(filterText, (value) => {
handleSearch(value);
});
defineExpose({ reset, setCurrent });
onMounted(() => {
loadTree();
});
</script>
<template>
<div class="h-full">
<Input
v-model:value="filterText"
:placeholder="filterPlaceholder"
allow-clear
class="w-full"
>
<template #prefix>
<IconifyIcon class="size-4" icon="lucide:search" />
</template>
</Input>
<Spin :spinning="loading" wrapper-class-name="w-full">
<Tree
v-if="categoryTree.length > 0"
:default-expand-all="true"
:field-names="{ title: 'name', key: 'id', children: 'children' }"
:selected-keys="selectedKeys"
:tree-data="categoryTree"
class="pt-2"
@select="handleSelect"
/>
<div v-else-if="!loading" class="py-4 text-center text-gray-500">
暂无数据
</div>
</Spin>
</div>
</template>

View File

@ -0,0 +1,186 @@
import type { VbenFormApi, VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { WmsItemCategoryApi } from '#/api/wms/md/item/category';
import { h } from 'vue';
import { CommonStatusEnum, DICT_TYPE, generateWmsCode } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { handleTree } from '@vben/utils';
import { Button } from 'antdv-next';
import { z } from '#/adapter/form';
import { getItemCategorySimpleList } from '#/api/wms/md/item/category';
/** 新增/修改商品分类的表单 */
export function useFormSchema(formApi?: VbenFormApi): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'parentId',
label: '上级分类',
component: 'ApiTreeSelect',
componentProps: {
allowClear: true,
api: async () => {
const data = await getItemCategorySimpleList();
return [
{
id: 0,
name: '顶级分类',
children: handleTree(data),
},
];
},
childrenField: 'children',
labelField: 'name',
placeholder: '请选择上级分类',
treeDefaultExpandAll: true,
treeNodeFilterProp: 'name',
valueField: 'id',
},
rules: 'selectRequired',
},
{
fieldName: 'code',
label: '分类编号',
component: 'Input',
componentProps: {
maxLength: 20,
placeholder: '请输入分类编号',
},
rules: z.string().min(1, '分类编号不能为空').max(20),
suffix: () => {
return h(
Button,
{
type: 'default',
onClick: () => {
formApi?.setFieldValue('code', generateWmsCode('C'));
},
},
{ default: () => '生成' },
);
},
},
{
fieldName: 'name',
label: '分类名称',
component: 'Input',
componentProps: {
placeholder: '请输入分类名称',
},
rules: 'required',
},
{
fieldName: 'sort',
label: '显示排序',
component: 'InputNumber',
componentProps: {
class: '!w-full',
min: 0,
},
rules: z.number().default(0),
},
{
fieldName: 'status',
label: '状态',
component: 'RadioGroup',
componentProps: {
buttonStyle: 'solid',
optionType: 'button',
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'code',
label: '分类编号',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入分类编号',
},
},
{
fieldName: 'name',
label: '分类名称',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入分类名称',
},
},
{
fieldName: 'status',
label: '分类状态',
component: 'Select',
componentProps: {
allowClear: true,
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
placeholder: '请选择分类状态',
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions<WmsItemCategoryApi.ItemCategory>['columns'] {
return [
{
field: 'name',
title: '分类名称',
minWidth: 200,
align: 'left',
treeNode: true,
},
{
field: 'code',
title: '分类编号',
width: 160,
align: 'center',
},
{
field: 'sort',
title: '排序',
width: 120,
align: 'center',
},
{
field: 'status',
title: '状态',
width: 120,
align: 'center',
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'createTime',
title: '创建时间',
width: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 240,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -0,0 +1,162 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { WmsItemCategoryApi } from '#/api/wms/md/item/category';
import { ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { message } from 'antdv-next';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteItemCategory,
getItemCategoryList,
} from '#/api/wms/md/item/category';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
defineOptions({ name: 'WmsItemCategory' });
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 切换树形展开/收缩状态 */
const isExpanded = ref(true);
function handleExpand() {
isExpanded.value = !isExpanded.value;
gridApi.grid.setAllTreeExpand(isExpanded.value);
}
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 创建分类 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 添加下级分类 */
function handleAppend(row: WmsItemCategoryApi.ItemCategory) {
formModalApi.setData({ parentId: row.id }).open();
}
/** 编辑分类 */
function handleEdit(row: WmsItemCategoryApi.ItemCategory) {
formModalApi.setData(row).open();
}
/** 删除分类 */
async function handleDelete(row: WmsItemCategoryApi.ItemCategory) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
});
try {
await deleteItemCategory(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
handleRefresh();
} finally {
hideLoading();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
pagerConfig: {
enabled: false,
},
proxyConfig: {
ajax: {
query: async (_, formValues) => {
return await getItemCategoryList(formValues);
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
treeConfig: {
parentField: 'parentId',
rowField: 'id',
transform: true,
expandAll: true,
reserve: true,
},
} as VxeTableGridOptions<WmsItemCategoryApi.ItemCategory>,
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="handleRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['分类']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['wms:item-category:create'],
onClick: handleCreate,
},
{
label: isExpanded ? '收缩' : '展开',
type: 'primary',
onClick: handleExpand,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: '新增下级',
type: 'link',
icon: ACTION_ICON.ADD,
auth: ['wms:item-category:create'],
onClick: handleAppend.bind(null, row),
},
{
label: $t('common.edit'),
type: 'link',
icon: ACTION_ICON.EDIT,
auth: ['wms:item-category:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
auth: ['wms:item-category:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,98 @@
<script lang="ts" setup>
import type { WmsItemCategoryApi } from '#/api/wms/md/item/category';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { CommonStatusEnum } from '@vben/constants';
import { message } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
import {
createItemCategory,
getItemCategory,
updateItemCategory,
} from '#/api/wms/md/item/category';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
defineOptions({ name: 'WmsItemCategoryForm' });
const emit = defineEmits(['success']);
const formData = ref<WmsItemCategoryApi.ItemCategory>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['商品分类'])
: $t('ui.actionTitle.create', ['商品分类']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
},
layout: 'horizontal',
schema: [],
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data = (await formApi.getValues()) as WmsItemCategoryApi.ItemCategory;
try {
await (formData.value?.id
? updateItemCategory(data)
: createItemCategory(data));
//
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
formApi.setState({ schema: useFormSchema(formApi) });
const data = modalApi.getData<WmsItemCategoryApi.ItemCategory>();
if (!data || !data.id) {
formData.value = data;
await formApi.setValues({
sort: 0,
status: CommonStatusEnum.ENABLE,
...data,
});
return;
}
//
modalApi.lock();
try {
formData.value = await getItemCategory(data.id);
// values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle" class="w-1/4">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,261 @@
import type { VbenFormApi, VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { h, markRaw } from 'vue';
import { generateWmsCode } from '@vben/constants';
import { Button } from 'antdv-next';
import { z } from '#/adapter/form';
import { getItemBrandSimpleList } from '#/api/wms/md/item/brand';
import { WmsItemBrandSelect } from './brand/components';
import { WmsItemCategorySelect } from './category/components';
/** 新增/修改商品的表单 */
export function useFormSchema(formApi?: VbenFormApi): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '商品名称',
component: 'Input',
componentProps: {
maxLength: 60,
placeholder: '请输入商品名称',
},
rules: z.string().min(1, '商品名称不能为空').max(60),
},
{
fieldName: 'categoryId',
label: '商品分类',
component: markRaw(WmsItemCategorySelect),
rules: 'required',
},
{
fieldName: 'code',
label: '商品编号',
component: 'Input',
componentProps: {
maxLength: 20,
placeholder: '请输入商品编号',
},
rules: z.string().min(1, '商品编号不能为空').max(20),
suffix: () => {
return h(
Button,
{
type: 'default',
onClick: () => {
formApi?.setFieldValue('code', generateWmsCode('I'));
},
},
{ default: () => '生成' },
);
},
},
{
fieldName: 'unit',
label: '商品单位',
component: 'Input',
componentProps: {
maxLength: 20,
placeholder: '请输入单位',
},
},
{
fieldName: 'brandId',
label: '商品品牌',
component: markRaw(WmsItemBrandSelect),
},
{
fieldName: 'remark',
label: '备注',
component: 'Textarea',
componentProps: {
maxLength: 255,
placeholder: '请输入备注',
rows: 3,
},
formItemClass: 'col-span-2',
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'code',
label: '商品编号',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入商品编号',
},
},
{
fieldName: 'name',
label: '商品名称',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入商品名称',
},
},
{
fieldName: 'brandId',
label: '商品品牌',
component: 'ApiSelect',
componentProps: {
allowClear: true,
api: getItemBrandSimpleList,
labelField: 'name',
placeholder: '请选择商品品牌',
showSearch: true,
valueField: 'id',
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'itemInfo',
title: '商品信息',
minWidth: 220,
slots: { default: 'itemInfo' },
},
{
field: 'skuInfo',
title: '规格信息',
minWidth: 180,
slots: { default: 'skuInfo' },
},
{
field: 'priceInfo',
title: '金额(元)',
minWidth: 140,
slots: { default: 'priceInfo' },
},
{
field: 'weightInfo',
title: '重量(kg)',
minWidth: 140,
slots: { default: 'weightInfo' },
},
{
field: 'dimensionInfo',
title: '长宽高(cm)',
minWidth: 180,
align: 'right',
slots: { default: 'dimensionInfo' },
},
{
field: 'actions',
title: '操作',
width: 120,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
/** SKU 选择弹窗搜索表单 */
export function useSkuSelectGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'itemName',
label: '商品名称',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入商品名称',
},
},
{
fieldName: 'itemCode',
label: '商品编号',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入商品编号',
},
},
{
fieldName: 'name',
label: '规格名称',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入规格名称',
},
},
{
fieldName: 'code',
label: '规格编号',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入规格编号',
},
},
{
fieldName: 'barCode',
label: '条码',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入条码',
},
},
];
}
/** SKU 选择弹窗列表字段 */
export function useSkuSelectGridColumns(): VxeTableGridOptions['columns'] {
return [
{ type: 'checkbox', width: 50 },
{
field: 'itemInfo',
title: '商品信息',
minWidth: 220,
slots: { default: 'itemInfo' },
},
{
field: 'skuInfo',
title: '规格信息',
minWidth: 220,
slots: { default: 'skuInfo' },
},
{
field: 'priceInfo',
title: '金额(元)',
minWidth: 160,
slots: { default: 'priceInfo' },
},
{
field: 'weightInfo',
title: '重量(kg)',
minWidth: 160,
slots: { default: 'weightInfo' },
},
{
field: 'dimensionInfo',
title: '长宽高(cm)',
minWidth: 180,
align: 'right',
slots: { default: 'dimensionInfo' },
},
];
}

View File

@ -0,0 +1,280 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { WmsItemApi } from '#/api/wms/md/item';
import type { WmsItemSkuApi } from '#/api/wms/md/item/sku';
import { ref } from 'vue';
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Card, message } from 'antdv-next';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteItem, exportItem, getItemPage } from '#/api/wms/md/item';
import { $t } from '#/locales';
import { WmsItemCategoryTree } from '#/views/wms/md/item/category/components';
import {
formatDimensionText,
formatPrice,
formatWeight,
} from '#/views/wms/utils/format';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
type ItemSkuRow = WmsItemSkuApi.ItemSku;
defineOptions({ name: 'WmsItem' });
const currentRows = ref<ItemSkuRow[]>([]);
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 展开商品 SKU 列表 */
function buildItemSkuRows(items: WmsItemApi.Item[]) {
return items.flatMap((item) => {
const skus = item.skus?.length ? item.skus : [{}];
return skus.map((sku) => ({
...sku,
itemId: item.id,
itemCode: item.code,
itemName: item.name,
categoryId: item.categoryId,
categoryName: item.categoryName,
unit: item.unit,
brandId: item.brandId,
brandName: item.brandName,
}));
});
}
/** 创建商品 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑商品 */
function handleEdit(row: ItemSkuRow) {
formModalApi.setData({ id: row.itemId }).open();
}
/** 删除商品 */
async function handleDelete(row: ItemSkuRow) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.itemName]),
duration: 0,
});
try {
await deleteItem(row.itemId!);
message.success($t('ui.actionMessage.deleteSuccess', [row.itemName]));
handleRefresh();
} finally {
hideLoading();
}
}
/** 导出商品 */
async function handleExport() {
const data = await exportItem(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '商品.xls', source: data });
}
/** 分类树点击 */
const searchCategoryId = ref<number | undefined>(undefined);
function handleCategoryNodeClick(categoryId: number | undefined) {
searchCategoryId.value = categoryId;
handleRefresh();
}
/** 合并商品维度的重复单元格 */
function handleSpanMethod({
column,
rowIndex,
}: {
column: { field?: string; property?: string };
rowIndex: number;
}) {
const field = column.field || column.property;
if (!['actions', 'itemInfo'].includes(field || '')) {
return { colspan: 1, rowspan: 1 };
}
const row = currentRows.value[rowIndex];
if (rowIndex > 0 && currentRows.value[rowIndex - 1]?.itemId === row?.itemId) {
return { colspan: 0, rowspan: 0 };
}
let rowspan = 1;
for (let index = rowIndex + 1; index < currentRows.value.length; index += 1) {
if (currentRows.value[index]?.itemId !== row?.itemId) {
break;
}
rowspan += 1;
}
return { colspan: 1, rowspan };
}
function hasValue(value: unknown) {
return value !== undefined && value !== null;
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
showOverflow: false,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
const data = await getItemPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
categoryId: searchCategoryId.value,
});
currentRows.value = buildItemSkuRows(data.list || []);
return {
...data,
list: currentRows.value,
};
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
spanMethod: handleSpanMethod,
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<ItemSkuRow>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【基础】商品、SKU、分类、品牌"
url="https://doc.iocoder.cn/wms/md/item/"
/>
</template>
<FormModal @success="handleRefresh" />
<div class="flex h-full w-full">
<Card class="mr-4 h-full w-1/6">
<WmsItemCategoryTree @node-click="handleCategoryNodeClick" />
</Card>
<div class="w-5/6">
<Grid table-title="">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['商品']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['wms:item:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['wms:item:export'],
onClick: handleExport,
},
]"
/>
</template>
<template #itemInfo="{ row }">
<div class="flex flex-col gap-1 py-1 leading-5">
<div class="text-sm">{{ row.itemName || '-' }}</div>
<div v-if="row.itemCode" class="text-xs text-gray-500">
{{ row.itemCode }}
</div>
<div v-if="row.brandName" class="text-xs text-gray-500">
品牌{{ row.brandName }}
</div>
<div v-if="row.categoryName" class="text-xs text-gray-500">
分类{{ row.categoryName }}
</div>
</div>
</template>
<template #skuInfo="{ row }">
<div class="flex flex-col gap-1 py-1 leading-5">
<div class="text-sm">{{ row.name || '-' }}</div>
<div v-if="row.code" class="text-xs text-gray-500">
编号{{ row.code }}
</div>
<div v-if="row.barCode" class="text-xs text-gray-500">
条码{{ row.barCode }}
</div>
</div>
</template>
<template #priceInfo="{ row }">
<div class="flex flex-col gap-1 py-1 leading-5">
<div v-if="hasValue(row.costPrice)">
成本价{{ formatPrice(row.costPrice) }}
</div>
<div v-if="hasValue(row.sellingPrice)">
销售价{{ formatPrice(row.sellingPrice) }}
</div>
</div>
</template>
<template #weightInfo="{ row }">
<div class="flex flex-col gap-1 py-1 leading-5">
<div v-if="hasValue(row.netWeight)">
净重{{ formatWeight(row.netWeight) }}
</div>
<div v-if="hasValue(row.grossWeight)">
毛重{{ formatWeight(row.grossWeight) }}
</div>
</div>
</template>
<template #dimensionInfo="{ row }">
{{ formatDimensionText(row.length, row.width, row.height) || '-' }}
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'link',
icon: ACTION_ICON.EDIT,
auth: ['wms:item:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
auth: ['wms:item:delete'],
popConfirm: {
title: `确认删除商品【${row.itemName}】吗?`,
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</div>
</div>
</Page>
</template>

View File

@ -0,0 +1,102 @@
<script lang="ts" setup>
import type { WmsItemApi } from '#/api/wms/md/item';
import { computed, nextTick, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
import { createItem, getItem, updateItem } from '#/api/wms/md/item';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
import SkuForm from './sku-form.vue';
defineOptions({ name: 'WmsItemForm' });
const emit = defineEmits(['success']);
const formData = ref<WmsItemApi.Item>();
const skuFormRef = ref<any>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['商品'])
: $t('ui.actionTitle.create', ['商品']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
labelWidth: 88,
},
wrapperClass: 'grid-cols-2',
layout: 'horizontal',
schema: [],
showDefaultActions: false,
});
async function resetSkuForm(item?: WmsItemApi.Item) {
await nextTick();
await skuFormRef.value?.setRows(item?.skus);
}
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
try {
skuFormRef.value?.validate();
} catch (error) {
message.warning((error as Error).message);
return;
}
modalApi.lock();
//
const data = (await formApi.getValues()) as WmsItemApi.Item;
data.skus = skuFormRef.value?.getRows() || [];
try {
await (formData.value?.id ? updateItem(data) : createItem(data));
//
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
formApi.setState({ schema: useFormSchema(formApi) });
await resetSkuForm();
//
const data = modalApi.getData<WmsItemApi.Item>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getItem(data.id);
// values
await formApi.setValues(formData.value);
await resetSkuForm(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle" class="w-3/4">
<Form class="mx-4" />
<SkuForm ref="skuFormRef" class="mx-4 mt-4" />
</Modal>
</template>

View File

@ -0,0 +1,300 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { WmsItemSkuApi } from '#/api/wms/md/item/sku';
import { nextTick, ref } from 'vue';
import { generateWmsCode } from '@vben/constants';
import { Button, Input, InputNumber, message } from 'antdv-next';
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
DIMENSION_PRECISION,
PRICE_PRECISION,
WEIGHT_PRECISION,
} from '#/views/wms/utils/format';
interface SkuRow extends WmsItemSkuApi.ItemSku {
seq: number;
}
let skuSeq = 0; // SKU id使 VXE
const tableData = ref<SkuRow[]>([]);
function nextSkuSeq() {
skuSeq += 1;
return skuSeq;
}
function buildEmptySku(): SkuRow {
return {
seq: nextSkuSeq(),
id: undefined,
name: undefined,
barCode: undefined,
code: undefined,
length: undefined,
width: undefined,
height: undefined,
grossWeight: undefined,
netWeight: undefined,
costPrice: undefined,
sellingPrice: undefined,
};
}
function toSkuRow(sku: WmsItemSkuApi.ItemSku): SkuRow {
return {
...sku,
seq: nextSkuSeq(),
};
}
function toSku(row: SkuRow): WmsItemSkuApi.ItemSku {
const { seq: _seq, ...sku } = row;
return sku;
}
async function reloadGrid() {
await nextTick();
await gridApi.grid.reloadData(tableData.value);
}
async function setRows(skus?: WmsItemSkuApi.ItemSku[]) {
skuSeq = 0;
tableData.value = skus?.length
? skus.map((sku) => toSkuRow(sku))
: [buildEmptySku()];
await reloadGrid();
}
function getRows() {
return tableData.value.map((row) => toSku(row));
}
function validate() {
if (tableData.value.length === 0) {
throw new Error('至少包含一个商品规格');
}
for (let index = 0; index < tableData.value.length; index += 1) {
const row = tableData.value[index];
if (!row?.name) {
throw new Error(`${index + 1} 行:规格名称不能为空`);
}
}
}
async function handleAddSku() {
tableData.value.push(buildEmptySku());
await reloadGrid();
}
async function handleDeleteSku(row: SkuRow) {
if (tableData.value.length <= 1) {
message.error('至少包含一个商品规格');
return;
}
const index = tableData.value.findIndex((item) => item.seq === row.seq);
if (index !== -1) {
tableData.value.splice(index, 1);
}
await reloadGrid();
}
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: [
{
field: 'name',
title: '规格名称',
minWidth: 180,
slots: { default: 'name' },
},
{
field: 'codeBarCode',
title: '编号/条码',
width: 300,
slots: { default: 'codeBarCode' },
},
{
field: 'dimension',
title: '长/宽/高(cm)',
width: 300,
slots: { default: 'dimension' },
},
{
field: 'weight',
title: '净重/毛重(kg)',
width: 200,
slots: { default: 'weight' },
},
{
field: 'price',
title: '成本价/销售价',
width: 200,
slots: { default: 'price' },
},
{
field: 'actions',
title: '操作',
align: 'center',
width: 80,
slots: { default: 'actions' },
},
],
data: tableData.value,
minHeight: 260,
autoResize: true,
border: true,
showOverflow: false,
rowConfig: {
keyField: 'seq',
isHover: true,
},
pagerConfig: {
enabled: false,
},
toolbarConfig: {
enabled: false,
},
} as VxeTableGridOptions<SkuRow>,
});
defineExpose({
getRows,
setRows,
validate,
});
</script>
<template>
<div>
<div class="mb-3 flex items-center justify-between">
<span class="text-sm font-semibold">规格</span>
<TableAction
:actions="[
{
label: '新增规格',
type: 'primary',
onClick: handleAddSku,
},
]"
/>
</div>
<Grid class="w-full">
<template #name="{ row }">
<Input
v-model:value="row.name"
class="w-full"
:maxlength="255"
placeholder="请输入规格名称"
/>
</template>
<template #codeBarCode="{ row }">
<div class="flex flex-col gap-2 py-1">
<Input
v-model:value="row.code"
class="w-full"
:maxlength="64"
placeholder="编号"
>
<template #addonAfter>
<Button @click="row.code = generateWmsCode('S')">生成</Button>
</template>
</Input>
<Input
v-model:value="row.barCode"
class="w-full"
:maxlength="64"
placeholder="条码"
>
<template #addonAfter>
<Button @click="row.barCode = generateWmsCode()">生成</Button>
</template>
</Input>
</div>
</template>
<template #dimension="{ row }">
<div class="flex w-full gap-1">
<InputNumber
v-model:value="row.length"
:controls="false"
:min="0"
:precision="DIMENSION_PRECISION"
class="!w-1/3"
placeholder="长"
/>
<InputNumber
v-model:value="row.width"
:controls="false"
:min="0"
:precision="DIMENSION_PRECISION"
class="!w-1/3"
placeholder="宽"
/>
<InputNumber
v-model:value="row.height"
:controls="false"
:min="0"
:precision="DIMENSION_PRECISION"
class="!w-1/3"
placeholder="高"
/>
</div>
</template>
<template #weight="{ row }">
<div class="flex flex-col gap-2 py-1">
<InputNumber
v-model:value="row.netWeight"
:controls="false"
:min="0"
:precision="WEIGHT_PRECISION"
class="!w-full"
placeholder="净重"
/>
<InputNumber
v-model:value="row.grossWeight"
:controls="false"
:min="0"
:precision="WEIGHT_PRECISION"
class="!w-full"
placeholder="毛重"
/>
</div>
</template>
<template #price="{ row }">
<div class="flex flex-col gap-2 py-1">
<InputNumber
v-model:value="row.costPrice"
:controls="false"
:min="0"
:precision="PRICE_PRECISION"
class="!w-full"
placeholder="成本价"
/>
<InputNumber
v-model:value="row.sellingPrice"
:controls="false"
:min="0"
:precision="PRICE_PRECISION"
class="!w-full"
placeholder="销售价"
/>
</div>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: '删除',
type: 'link',
danger: true,
onClick: handleDeleteSku.bind(null, row),
},
]"
/>
</template>
</Grid>
</div>
</template>

View File

@ -0,0 +1 @@
export { default as WmsItemSkuSelect } from './select.vue';

View File

@ -0,0 +1,224 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { WmsItemSkuApi } from '#/api/wms/md/item/sku';
import { nextTick, ref } from 'vue';
import { message, Modal } from 'antdv-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getItemSkuPage } from '#/api/wms/md/item/sku';
import {
formatDimensionText,
formatPrice,
formatWeight,
} from '#/views/wms/utils/format';
import {
useSkuSelectGridColumns,
useSkuSelectGridFormSchema,
} from '../../data';
defineOptions({ name: 'WmsItemSkuSelect' });
const emit = defineEmits<{
change: [list: WmsItemSkuApi.ItemSku[]];
}>();
const open = ref(false);
const multiple = ref(true);
const syncingSingleSelection = ref(false);
const selectedRows = ref<WmsItemSkuApi.ItemSku[]>([]);
const disabledSelectedIds = ref<Set<number>>(new Set());
/** 判断 SKU 是否可勾选,已传入的业务明细 SKU 需要禁选 */
function isRowSelectable({ row }: { row: WmsItemSkuApi.ItemSku }) {
return row.id === undefined || !disabledSelectedIds.value.has(row.id);
}
/** 单选模式下同步 VXE 勾选状态,避免跨页残留多选 */
async function syncSingleSelection(row?: WmsItemSkuApi.ItemSku) {
syncingSingleSelection.value = true;
await nextTick();
await gridApi.grid.clearCheckboxRow();
if (row) {
await gridApi.grid.setCheckboxRow(row, true);
}
await nextTick();
syncingSingleSelection.value = false;
}
/** 处理勾选变化,单选模式只保留最后一条可选 SKU */
async function handleCheckboxChange({
checked,
records,
row,
}: {
checked: boolean;
records: WmsItemSkuApi.ItemSku[];
row?: WmsItemSkuApi.ItemSku;
}) {
if (syncingSingleSelection.value) {
return;
}
if (!multiple.value) {
const selected = checked && row ? [row] : [];
selectedRows.value = selected;
await syncSingleSelection(selected[0]);
return;
}
selectedRows.value = records.filter(
(item) => item.id === undefined || !disabledSelectedIds.value.has(item.id),
);
}
/** 处理全选变化,过滤掉已禁选的 SKU */
function handleCheckboxAll({ records }: { records: WmsItemSkuApi.ItemSku[] }) {
if (syncingSingleSelection.value) {
return;
}
selectedRows.value = records.filter(
(item) => item.id === undefined || !disabledSelectedIds.value.has(item.id),
);
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useSkuSelectGridFormSchema(),
},
gridOptions: {
columns: useSkuSelectGridColumns(),
height: 560,
keepSource: true,
showOverflow: false,
checkboxConfig: {
checkMethod: isRowSelectable,
highlight: true,
range: true,
reserve: true,
},
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getItemSkuPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<WmsItemSkuApi.ItemSku>,
gridEvents: {
checkboxAll: handleCheckboxAll,
checkboxChange: handleCheckboxChange,
},
});
/** 打开 SKU 选择弹窗 */
async function openModal(
selectedIds?: number[],
options?: { multiple?: boolean; preselectDisabled?: boolean },
) {
open.value = true;
multiple.value = options?.multiple ?? true;
selectedRows.value = [];
disabledSelectedIds.value =
options?.preselectDisabled === false
? new Set()
: new Set(selectedIds || []);
await nextTick();
await gridApi.grid.clearCheckboxRow();
await gridApi.formApi.resetForm();
await gridApi.query();
}
/** 关闭 SKU 选择弹窗并清空临时选择 */
async function closeModal() {
open.value = false;
selectedRows.value = [];
await gridApi.grid.clearCheckboxRow();
}
/** 确认选择并返回新增的 SKU 列表 */
function handleConfirm() {
const list = selectedRows.value.filter(
(sku) => sku.id !== undefined && !disabledSelectedIds.value.has(sku.id),
);
if (list.length === 0) {
message.warning('请至少选择一条数据');
return;
}
emit('change', list);
closeModal();
}
defineExpose({ open: openModal });
</script>
<template>
<Modal
v-model:open="open"
title="商品选择"
width="80%"
:destroy-on-close="true"
@ok="handleConfirm"
@cancel="closeModal"
>
<Grid table-title=" SKU ">
<template #itemInfo="{ row }">
<div class="flex flex-col gap-1 py-1 leading-5">
<div>{{ row.itemName || '-' }}</div>
<div v-if="row.itemCode" class="text-xs text-gray-500">
商品编号{{ row.itemCode }}
</div>
<div v-if="row.brandName" class="text-xs text-gray-500">
品牌{{ row.brandName }}
</div>
</div>
</template>
<template #skuInfo="{ row }">
<div class="flex flex-col gap-1 py-1 leading-5">
<div>{{ row.name || '-' }}</div>
<div v-if="row.code" class="text-xs text-gray-500">
编号{{ row.code }}
</div>
<div v-if="row.barCode" class="text-xs text-gray-500">
条码{{ row.barCode }}
</div>
</div>
</template>
<template #priceInfo="{ row }">
<div class="flex flex-col gap-1 py-1 leading-5">
<div v-if="row.costPrice !== undefined">
成本价{{ formatPrice(row.costPrice) }}
</div>
<div v-if="row.sellingPrice !== undefined">
销售价{{ formatPrice(row.sellingPrice) }}
</div>
</div>
</template>
<template #weightInfo="{ row }">
<div class="flex flex-col gap-1 py-1 leading-5">
<div v-if="row.netWeight !== undefined">
净重{{ formatWeight(row.netWeight) }}
</div>
<div v-if="row.grossWeight !== undefined">
毛重{{ formatWeight(row.grossWeight) }}
</div>
</div>
</template>
<template #dimensionInfo="{ row }">
{{ formatDimensionText(row.length, row.width, row.height) || '-' }}
</template>
</Grid>
</Modal>
</template>

View File

@ -0,0 +1 @@
export { default as WmsMerchantSelect } from './select.vue';

View File

@ -0,0 +1,109 @@
<script lang="ts" setup>
import type { SelectValue } from 'antdv-next';
import type { WmsMerchantApi } from '#/api/wms/md/merchant';
import { computed, onMounted, ref, watch } from 'vue';
import {
CustomerMerchantTypeList,
SupplierMerchantTypeList,
} from '@vben/constants';
import { Select } from 'antdv-next';
import { getMerchantSimpleList } from '#/api/wms/md/merchant';
defineOptions({ name: 'WmsMerchantSelect', inheritAttrs: false });
const props = withDefaults(
defineProps<{
allowClear?: boolean;
customer?: boolean;
disabled?: boolean;
modelValue?: number;
placeholder?: string;
supplier?: boolean;
}>(),
{
allowClear: true,
customer: false,
disabled: false,
modelValue: undefined,
placeholder: '请选择往来企业',
supplier: false,
},
);
const emit = defineEmits<{
change: [merchant: undefined | WmsMerchantApi.Merchant];
'update:modelValue': [value: number | undefined];
}>();
const loading = ref(false);
const merchantList = ref<WmsMerchantApi.Merchant[]>([]);
const options = computed(() =>
merchantList.value
.filter((merchant) => merchant.id !== undefined)
.map((merchant) => ({
label: merchant.name,
value: merchant.id,
})),
);
/** 选中变化 */
function handleChange(value: SelectValue) {
const merchantId = typeof value === 'number' ? value : undefined;
emit('update:modelValue', merchantId);
emit(
'change',
merchantList.value.find((merchant) => merchant.id === merchantId),
);
}
/** 查询往来企业精简列表 */
async function getList() {
let types: number[] | undefined;
if (props.supplier) {
types = SupplierMerchantTypeList;
} else if (props.customer) {
types = CustomerMerchantTypeList;
}
loading.value = true;
try {
merchantList.value = await getMerchantSimpleList(
types ? { types } : undefined,
);
} finally {
loading.value = false;
}
}
watch(
() => [props.supplier, props.customer],
() => {
getList();
},
);
onMounted(() => {
getList();
});
</script>
<template>
<Select
v-bind="$attrs"
:allow-clear="allowClear"
:disabled="disabled"
:loading="loading"
:options="options"
:placeholder="placeholder"
:value="modelValue"
class="w-full"
option-filter-prop="label"
show-search
@change="handleChange"
/>
</template>

View File

@ -0,0 +1,231 @@
import type { VbenFormApi, VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { h } from 'vue';
import { DICT_TYPE, generateWmsCode } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { Button } from 'antdv-next';
import { z } from '#/adapter/form';
/** 新增/修改往来企业的表单 */
export function useFormSchema(formApi?: VbenFormApi): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'code',
label: '往来企业编号',
component: 'Input',
componentProps: {
maxLength: 20,
placeholder: '请输入往来企业编号',
},
rules: z.string().min(1, '往来企业编号不能为空').max(20),
suffix: () => {
return h(
Button,
{
type: 'default',
onClick: () => {
formApi?.setFieldValue('code', generateWmsCode('M'));
},
},
{ default: () => '生成' },
);
},
},
{
fieldName: 'name',
label: '往来企业名称',
component: 'Input',
componentProps: {
maxLength: 60,
placeholder: '请输入往来企业名称',
},
rules: z.string().min(1, '往来企业名称不能为空').max(60),
},
{
fieldName: 'type',
label: '往来企业类型',
component: 'Select',
componentProps: {
allowClear: true,
options: getDictOptions(DICT_TYPE.WMS_MERCHANT_TYPE, 'number'),
placeholder: '请选择往来企业类型',
},
rules: 'required',
},
{
fieldName: 'level',
label: '级别',
component: 'Input',
componentProps: {
maxLength: 10,
placeholder: '请输入级别',
},
},
{
fieldName: 'bankName',
label: '开户行',
component: 'Input',
componentProps: {
maxLength: 255,
placeholder: '请输入开户行',
},
},
{
fieldName: 'bankAccount',
label: '银行账户',
component: 'Input',
componentProps: {
maxLength: 40,
placeholder: '请输入银行账户',
},
},
{
fieldName: 'address',
label: '地址',
component: 'Input',
componentProps: {
maxLength: 200,
placeholder: '请输入地址',
},
},
{
fieldName: 'contact',
label: '联系人',
component: 'Input',
componentProps: {
maxLength: 30,
placeholder: '请输入联系人',
},
},
{
fieldName: 'mobile',
label: '手机号',
component: 'Input',
componentProps: {
maxLength: 13,
placeholder: '请输入手机号',
},
},
{
fieldName: 'telephone',
label: '座机号',
component: 'Input',
componentProps: {
maxLength: 13,
placeholder: '请输入座机号',
},
},
{
fieldName: 'email',
label: 'Email',
component: 'Input',
componentProps: {
maxLength: 50,
placeholder: '请输入 Email',
},
},
{
fieldName: 'remark',
label: '备注',
component: 'Input',
componentProps: {
maxLength: 255,
placeholder: '请输入备注',
},
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'code',
label: '往来企业编号',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入往来企业编号',
},
},
{
fieldName: 'name',
label: '往来企业名称',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入往来企业名称',
},
},
{
fieldName: 'type',
label: '往来企业类型',
component: 'Select',
componentProps: {
allowClear: true,
options: getDictOptions(DICT_TYPE.WMS_MERCHANT_TYPE, 'number'),
placeholder: '请选择往来企业类型',
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'code',
title: '往来企业编号',
width: 160,
},
{
field: 'name',
title: '往来企业名称',
minWidth: 160,
},
{
field: 'type',
title: '往来企业类型',
width: 120,
align: 'center',
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.WMS_MERCHANT_TYPE },
},
},
{
field: 'level',
title: '级别',
width: 100,
align: 'center',
},
{
field: 'contact',
title: '联系人',
width: 120,
},
{
field: 'remark',
title: '备注',
minWidth: 160,
},
{
title: '操作',
width: 160,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -0,0 +1,152 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { WmsMerchantApi } from '#/api/wms/md/merchant';
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { message } from 'antdv-next';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteMerchant,
exportMerchant,
getMerchantPage,
} from '#/api/wms/md/merchant';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
defineOptions({ name: 'WmsMerchant' });
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 创建往来企业 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑往来企业 */
function handleEdit(row: WmsMerchantApi.Merchant) {
formModalApi.setData(row).open();
}
/** 删除往来企业 */
async function handleDelete(row: WmsMerchantApi.Merchant) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
});
try {
await deleteMerchant(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
handleRefresh();
} finally {
hideLoading();
}
}
/** 导出往来企业 */
async function handleExport() {
const data = await exportMerchant(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '往来企业.xls', source: data });
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getMerchantPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<WmsMerchantApi.Merchant>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【基础】往来企业(供应商、客户)"
url="https://doc.iocoder.cn/wms/md/merchant/"
/>
</template>
<FormModal @success="handleRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['往来企业']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['wms:merchant:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['wms:merchant:export'],
onClick: handleExport,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'link',
icon: ACTION_ICON.EDIT,
auth: ['wms:merchant:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
auth: ['wms:merchant:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,89 @@
<script lang="ts" setup>
import type { WmsMerchantApi } from '#/api/wms/md/merchant';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
import {
createMerchant,
getMerchant,
updateMerchant,
} from '#/api/wms/md/merchant';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
defineOptions({ name: 'WmsMerchantForm' });
const emit = defineEmits(['success']);
const formData = ref<WmsMerchantApi.Merchant>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['往来企业'])
: $t('ui.actionTitle.create', ['往来企业']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
labelWidth: 120,
},
wrapperClass: 'grid-cols-2',
layout: 'horizontal',
schema: [],
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data = (await formApi.getValues()) as WmsMerchantApi.Merchant;
try {
await (formData.value?.id ? updateMerchant(data) : createMerchant(data));
//
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
formApi.setState({ schema: useFormSchema(formApi) });
const data = modalApi.getData<WmsMerchantApi.Merchant>();
if (!data || !data.id) {
return;
}
//
modalApi.lock();
try {
formData.value = await getMerchant(data.id);
// values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle" class="w-1/2">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1 @@
export { default as WmsWarehouseSelect } from './select.vue';

View File

@ -0,0 +1,85 @@
<script lang="ts" setup>
import type { SelectValue } from 'antdv-next';
import type { WmsWarehouseApi } from '#/api/wms/md/warehouse';
import { computed, onMounted, ref } from 'vue';
import { Select } from 'antdv-next';
import { getWarehouseSimpleList } from '#/api/wms/md/warehouse';
defineOptions({ name: 'WmsWarehouseSelect', inheritAttrs: false });
withDefaults(
defineProps<{
allowClear?: boolean;
disabled?: boolean;
modelValue?: number;
placeholder?: string;
}>(),
{
allowClear: true,
disabled: false,
modelValue: undefined,
placeholder: '请选择仓库',
},
);
const emit = defineEmits<{
change: [warehouse: undefined | WmsWarehouseApi.Warehouse];
'update:modelValue': [value: number | undefined];
}>();
const loading = ref(false);
const warehouseList = ref<WmsWarehouseApi.Warehouse[]>([]);
const options = computed(() =>
warehouseList.value
.filter((warehouse) => warehouse.id !== undefined)
.map((warehouse) => ({
label: warehouse.name,
value: warehouse.id,
})),
);
/** 选中变化 */
function handleChange(value: SelectValue) {
const warehouseId = typeof value === 'number' ? value : undefined;
emit('update:modelValue', warehouseId);
emit(
'change',
warehouseList.value.find((warehouse) => warehouse.id === warehouseId),
);
}
/** 查询仓库精简列表 */
async function getList() {
loading.value = true;
try {
warehouseList.value = await getWarehouseSimpleList();
} finally {
loading.value = false;
}
}
onMounted(() => {
getList();
});
</script>
<template>
<Select
v-bind="$attrs"
:allow-clear="allowClear"
:disabled="disabled"
:loading="loading"
:options="options"
:placeholder="placeholder"
:value="modelValue"
class="w-full"
option-filter-prop="label"
show-search
@change="handleChange"
/>
</template>

View File

@ -0,0 +1,139 @@
import type { VbenFormApi, VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { h } from 'vue';
import { generateWmsCode } from '@vben/constants';
import { Button } from 'antdv-next';
import { z } from '#/adapter/form';
/** 新增/修改仓库的表单 */
export function useFormSchema(formApi?: VbenFormApi): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '仓库名称',
component: 'Input',
componentProps: {
maxLength: 50,
placeholder: '请输入仓库名称',
},
rules: z.string().min(1, '仓库名称不能为空').max(50),
},
{
fieldName: 'code',
label: '仓库编号',
component: 'Input',
componentProps: {
maxLength: 20,
placeholder: '请输入仓库编号',
},
rules: z.string().min(1, '仓库编号不能为空').max(20),
suffix: () => {
return h(
Button,
{
type: 'default',
onClick: () => {
formApi?.setFieldValue('code', generateWmsCode('W'));
},
},
{ default: () => '生成' },
);
},
},
{
fieldName: 'sort',
label: '排序',
component: 'InputNumber',
componentProps: {
class: '!w-full',
min: 0,
},
rules: z.number().default(0),
},
{
fieldName: 'remark',
label: '备注',
component: 'Textarea',
componentProps: {
maxLength: 255,
placeholder: '请输入备注',
rows: 3,
},
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '仓库名称',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入仓库名称',
},
},
{
fieldName: 'code',
label: '仓库编号',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入仓库编号',
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'name',
title: '仓库名称',
minWidth: 160,
},
{
field: 'code',
title: '仓库编号',
minWidth: 140,
},
{
field: 'remark',
title: '备注',
minWidth: 220,
},
{
field: 'sort',
title: '排序',
width: 100,
align: 'center',
},
{
field: 'createTime',
title: '创建时间',
width: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 160,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -0,0 +1,152 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { WmsWarehouseApi } from '#/api/wms/md/warehouse';
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { message } from 'antdv-next';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteWarehouse,
exportWarehouse,
getWarehousePage,
} from '#/api/wms/md/warehouse';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
defineOptions({ name: 'WmsWarehouse' });
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 创建仓库 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑仓库 */
function handleEdit(row: WmsWarehouseApi.Warehouse) {
formModalApi.setData(row).open();
}
/** 删除仓库 */
async function handleDelete(row: WmsWarehouseApi.Warehouse) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
});
try {
await deleteWarehouse(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
handleRefresh();
} finally {
hideLoading();
}
}
/** 导出仓库 */
async function handleExport() {
const data = await exportWarehouse(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '仓库.xls', source: data });
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getWarehousePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<WmsWarehouseApi.Warehouse>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【基础】仓库"
url="https://doc.iocoder.cn/wms/md/warehouse/"
/>
</template>
<FormModal @success="handleRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['仓库']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['wms:warehouse:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['wms:warehouse:export'],
onClick: handleExport,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'link',
icon: ACTION_ICON.EDIT,
auth: ['wms:warehouse:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
auth: ['wms:warehouse:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,92 @@
<script lang="ts" setup>
import type { WmsWarehouseApi } from '#/api/wms/md/warehouse';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
import {
createWarehouse,
getWarehouse,
updateWarehouse,
} from '#/api/wms/md/warehouse';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
defineOptions({ name: 'WmsWarehouseForm' });
const emit = defineEmits(['success']);
const formData = ref<WmsWarehouseApi.Warehouse>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['仓库'])
: $t('ui.actionTitle.create', ['仓库']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
},
layout: 'horizontal',
schema: [],
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data = (await formApi.getValues()) as WmsWarehouseApi.Warehouse;
try {
await (formData.value?.id
? updateWarehouse(data)
: createWarehouse(data));
//
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
formApi.setState({ schema: useFormSchema(formApi) });
const data = modalApi.getData<WmsWarehouseApi.Warehouse>();
if (!data || !data.id) {
await formApi.setValues({ sort: 0 });
return;
}
//
modalApi.lock();
try {
formData.value = await getWarehouse(data.id);
// values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle" class="w-1/3">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,488 @@
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';
/** 表单类型 */
export type FormType = 'create' | 'update';
/** 拆分数量/金额区间字段,适配后端 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,380 @@
<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 {
OrderDeleteStatusList,
OrderStatusEnum,
OrderUpdateStatusList,
} from '@vben/constants';
import { downloadFileFromBlobPart, formatDateTime } from '@vben/utils';
import { message } from 'antdv-next';
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 {
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({ formType: 'create' }).open();
}
/** 编辑盘库单 */
function handleEdit(row: WmsCheckOrderApi.CheckOrder) {
formModalApi.setData({ id: row.id!, formType: '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,170 @@
<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,741 @@
<script lang="ts" setup>
import type { FormType } from '../data';
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 {
OrderStatusEnum,
OrderUpdateStatusList,
} from '@vben/constants';
import { isEqual } from '@vben/utils';
import { InputNumber, message } from 'antdv-next';
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 {
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 formType = ref<FormType>('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 formType.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 (formType.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;
}
const data = modalApi.getData<{ formType: FormType; id?: number }>();
formType.value = data.formType;
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,330 @@
<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) {
padding: 0 !important;
margin: 0 !important;
-webkit-print-color-adjust: exact;
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) {
position: absolute;
top: 0;
left: 0;
z-index: auto;
box-sizing: border-box;
width: 100%;
padding: 0 !important;
margin: 0 !important;
pointer-events: auto;
opacity: 1;
}
}
</style>

View File

@ -0,0 +1,372 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { WmsWarehouseApi } from '#/api/wms/md/warehouse';
import type { DescriptionItemSchema } from '#/components/description';
import { h, markRaw } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { formatDate, formatDateTime } from '@vben/utils';
import { z } from '#/adapter/form';
import { getSimpleUserList } from '#/api/system/user';
import { DictTag } from '#/components/dict-tag';
import { buildNumberRangeSchema } from '#/components/number-range-input';
import { getRangePickerDefaultProps } from '#/utils';
import { WmsWarehouseSelect } from '#/views/wms/md/warehouse/components';
import {
formatPrice,
formatQuantity,
formatSumPrice,
formatSumQuantity,
PRICE_PRECISION,
QUANTITY_PRECISION,
} from '#/views/wms/utils/format';
/** 表单类型 */
export type FormType = 'create' | 'update';
/** 列表的搜索表单 */
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: 'sourceWarehouseId',
label: '来源仓库',
},
{
component: markRaw(WmsWarehouseSelect),
fieldName: 'targetWarehouseId',
label: '目标仓库',
},
{
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
fieldName: 'orderTime',
label: '单据日期',
},
buildNumberRangeSchema(
'数量',
'totalQuantityRange',
'totalQuantityMin',
'totalQuantityMax',
QUANTITY_PRECISION,
),
buildNumberRangeSchema(
'总金额',
'totalPriceRange',
'totalPriceMin',
'totalPriceMax',
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: 'sourceWarehouseName',
minWidth: 180,
title: '来源仓库',
},
{
field: 'targetWarehouseName',
minWidth: 180,
title: '目标仓库',
},
{
field: 'quantityAmount',
minWidth: 180,
slots: { default: 'quantityAmount' },
title: '总数量/总金额(元)',
},
{
field: 'operateInfo',
minWidth: 260,
slots: { default: 'operateInfo' },
title: '操作信息',
},
{
field: 'remark',
minWidth: 160,
title: '备注',
},
{
field: 'actions',
fixed: 'right',
slots: { default: 'actions' },
title: '操作',
width: 220,
},
];
}
/** 详情的字段 */
export function useDetailSchema(): DescriptionItemSchema[] {
return [
{
field: 'no',
label: '移库单号',
render: (val) => val || '-',
},
{
field: 'sourceWarehouseName',
label: '来源仓库',
render: (val) => val || '-',
},
{
field: 'targetWarehouseName',
label: '目标仓库',
render: (val) => val || '-',
},
{
field: 'status',
label: '单据状态',
render: (val) =>
val === undefined || val === null
? '-'
: h(DictTag, {
type: DICT_TYPE.WMS_ORDER_STATUS,
value: val,
}),
},
{
field: 'orderTime',
label: '单据日期',
render: (val) => formatDate(val, 'YYYY-MM-DD') || '-',
},
{
field: 'totalQuantity',
label: '总数量',
render: (val) => formatQuantity(val) || '-',
},
{
field: 'totalPrice',
label: '总金额',
render: (val) => formatPrice(val) || '-',
},
{
field: 'createTime',
label: '创建时间',
render: (val) => formatDateTime(val) || '-',
},
{
field: 'creatorName',
label: '创建人',
render: (val, data) => val || data?.creator || '-',
},
{
field: 'updateTime',
label: '更新时间',
render: (val) => formatDateTime(val) || '-',
},
{
field: 'updaterName',
label: '更新人',
render: (val, data) => val || data?.updater || '-',
},
{
field: 'remark',
label: '备注',
render: (val) => val || '-',
span: 2,
},
];
}
interface MovementFormSchemaOptions {
onSourceWarehouseChange: (warehouse?: WmsWarehouseApi.Warehouse) => void;
onTargetWarehouseChange: (warehouse?: WmsWarehouseApi.Warehouse) => void;
}
/** 表单的配置项 */
export function useFormSchema({
onSourceWarehouseChange,
onTargetWarehouseChange,
}: MovementFormSchemaOptions): 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: {
onChange: onSourceWarehouseChange,
},
fieldName: 'sourceWarehouseId',
label: '来源仓库',
rules: 'required',
},
{
component: markRaw(WmsWarehouseSelect),
componentProps: {
onChange: onTargetWarehouseChange,
},
fieldName: 'targetWarehouseId',
label: '目标仓库',
rules: 'required',
},
{
component: 'DatePicker',
componentProps: {
class: 'w-full',
format: 'YYYY-MM-DD',
placeholder: '请选择单据日期',
valueFormat: 'x',
},
fieldName: 'orderTime',
label: '单据日期',
rules: 'required',
},
{
component: 'Textarea',
componentProps: {
maxLength: 255,
placeholder: '请输入备注',
},
fieldName: 'remark',
formItemClass: 'col-span-2',
label: '备注',
},
];
}
interface MovementOrderDetailFooterRow {
quantity?: number;
totalPrice?: number;
}
type MovementOrderDetailFooterColumn = Pick<
NonNullable<NonNullable<VxeTableGridOptions['columns']>[number]>,
'field'
>;
/** 明细表格的合计行 */
export function getDetailFooter({
columns,
data,
}: {
columns: MovementOrderDetailFooterColumn[];
data: MovementOrderDetailFooterRow[];
}) {
return [
columns.map((column, index) => {
if (index === 0) {
return '合计';
}
if (column.field === 'quantity') {
return formatSumQuantity(data, (detail) => detail.quantity);
}
if (column.field === 'totalPrice') {
return formatSumPrice(data, (detail) => detail.totalPrice);
}
return '';
}),
];
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,249 @@
<script lang="ts" setup>
import type { WmsMovementOrderApi } from '#/api/wms/order/movement';
import type { WmsMovementOrderDetailApi } from '#/api/wms/order/movement/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 {
getMovementOrder,
getMovementOrderDetailListByOrderId,
} from '#/api/wms/order/movement';
import {
formatPrice,
formatQuantity,
formatSumPrice,
formatSumQuantity,
multiplyPrice,
} from '#/views/wms/utils/format';
interface PrintRow extends WmsMovementOrderDetailApi.MovementOrderDetail {
totalPrice?: number;
}
defineOptions({ name: 'WmsMovementOrderPrint' });
const printData = ref<WmsMovementOrderApi.MovementOrder>({});
const tableColumnCount = 5;
const printRows = computed<PrintRow[]>(() =>
(printData.value.details || []).map((detail) => ({
...detail,
totalPrice:
detail.totalPrice ?? multiplyPrice(detail.quantity, detail.price),
})),
);
/** 等待条码和打印 DOM 完成绘制,避免浏览器打印到旧内容 */
function waitForPaint() {
return new Promise<void>((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => resolve());
});
});
}
/** 退出打印模式,恢复当前页面显示 */
function removePrintMode() {
document.body.classList.remove('wms-movement-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 getMovementOrder(id);
const details =
order.details || (await getMovementOrderDetailListByOrderId(id));
printData.value = { ...order, details };
await nextTick();
await waitForPaint();
document.body.classList.add('wms-movement-order-printing');
window.addEventListener('afterprint', removePrintMode, { once: true });
window.print();
}
defineExpose({ print });
</script>
<template>
<Teleport to="body">
<div
id="wmsMovementOrderPrint"
class="wms-movement-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.sourceWarehouseName || '-' }}</div>
<div>目标仓库{{ printData.targetWarehouseName || '-' }}</div>
<div>
移库状态{{
getPrintDictLabel(DICT_TYPE.WMS_ORDER_STATUS, printData.status)
}}
</div>
<div>
单据日期{{ formatDate(printData.orderTime, 'YYYY-MM-DD') || '-' }}
</div>
<div>总数量{{ formatQuantity(printData.totalQuantity) || '-' }}</div>
<div>总金额{{ formatPrice(printData.totalPrice) || '-' }}</div>
<div class="col-span-3 grid grid-cols-2 gap-x-6">
<div>
创建{{ formatDateTime(printData.createTime) || '-' }} /
{{ printData.creatorName || printData.creator || '-' }}
</div>
<div>
更新{{ formatDateTime(printData.updateTime) || '-' }} /
{{ printData.updaterName || printData.updater || '-' }}
</div>
</div>
<div class="col-span-3">备注{{ printData.remark || '-' }}</div>
</div>
<table class="w-full border-collapse text-[13px] leading-[1.5]">
<thead>
<tr>
<th
class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-left font-bold"
>
商品信息
</th>
<th
class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-left font-bold"
>
规格信息
</th>
<th
class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-left font-bold"
>
数量
</th>
<th
class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-left font-bold"
>
单价()
</th>
<th
class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-left font-bold"
>
金额()
</th>
</tr>
</thead>
<tbody>
<tr v-for="detail in printRows" :key="detail.id || detail.skuId">
<td class="border border-solid border-[#dcdfe6] p-2">
<div>{{ detail.itemName || '-' }}</div>
<div v-if="detail.itemCode" class="text-xs">
编号{{ detail.itemCode }}
</div>
</td>
<td class="border border-solid border-[#dcdfe6] p-2">
<div>{{ detail.skuName || '-' }}</div>
<div v-if="detail.skuCode" class="text-xs">
编号{{ detail.skuCode }}
</div>
</td>
<td class="border border-solid border-[#dcdfe6] p-2 text-right">
{{ formatQuantity(detail.quantity) || '-' }}
</td>
<td class="border border-solid border-[#dcdfe6] p-2 text-right">
{{ formatPrice(detail.price) || '-' }}
</td>
<td class="border border-solid border-[#dcdfe6] p-2 text-right">
{{ formatPrice(detail.totalPrice) || '-' }}
</td>
</tr>
<tr v-if="printRows.length > 0">
<td
class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2"
colspan="2"
>
合计
</td>
<td
class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-right"
>
{{ formatSumQuantity(printRows, (detail) => detail.quantity) }}
</td>
<td
class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-right"
></td>
<td
class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-right"
>
{{ formatSumPrice(printRows, (detail) => detail.totalPrice) }}
</td>
</tr>
<tr v-if="printRows.length === 0">
<td
class="border border-solid border-[#dcdfe6] p-2 text-center"
:colspan="tableColumnCount"
>
暂无明细
</td>
</tr>
</tbody>
</table>
</div>
</Teleport>
</template>
<style scoped>
@page {
margin: 8mm 10mm;
}
@media print {
:global(body.wms-movement-order-printing) {
padding: 0 !important;
margin: 0 !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
:global(body.wms-movement-order-printing *) {
visibility: hidden !important;
}
:global(body.wms-movement-order-printing .wms-movement-order-print),
:global(body.wms-movement-order-printing .wms-movement-order-print *) {
visibility: visible !important;
}
:global(body.wms-movement-order-printing .wms-movement-order-print) {
position: absolute;
top: 0;
left: 0;
z-index: auto;
box-sizing: border-box;
width: 100%;
padding: 0 !important;
margin: 0 !important;
pointer-events: auto;
opacity: 1;
}
}
</style>

View File

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

View File

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

View File

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

View File

@ -0,0 +1,476 @@
<script lang="ts" setup>
import type { FormType } from '../data';
import type { VxeTableInstance } from '#/adapter/vxe-table';
import type { WmsItemSkuApi } from '#/api/wms/md/item/sku';
import type { WmsReceiptOrderApi } from '#/api/wms/order/receipt';
import type { WmsReceiptOrderDetailApi } from '#/api/wms/order/receipt/detail';
import { computed, nextTick, ref } from 'vue';
import { confirm, useVbenModal } from '@vben/common-ui';
import {
OrderStatusEnum,
OrderUpdateStatusList,
} from '@vben/constants';
import { isEqual } from '@vben/utils';
import { InputNumber, message } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
import { TableAction, VxeColumn, VxeTable } from '#/adapter/vxe-table';
import {
cancelReceiptOrder,
completeReceiptOrder,
createReceiptOrder,
getReceiptOrder,
getReceiptOrderDetailListByOrderId,
updateReceiptOrder,
} from '#/api/wms/order/receipt';
import { $t } from '#/locales';
import { WmsItemSkuSelect } from '#/views/wms/md/item/sku/components';
import {
dividePrice,
multiplyPrice,
PRICE_PRECISION,
QUANTITY_PRECISION,
} from '#/views/wms/utils/format';
import { generateOrderNo } from '#/views/wms/utils/order';
import { getDetailFooter, useFormSchema } from '../data';
interface DetailRow extends WmsReceiptOrderDetailApi.ReceiptOrderDetail {
seq: number;
}
defineOptions({ name: 'WmsReceiptOrderForm' });
const emit = defineEmits<{
success: [];
}>();
const formData = ref<WmsReceiptOrderApi.ReceiptOrder>({});
const formType = ref<FormType>('create');
const originalSubmitData = ref<WmsReceiptOrderApi.ReceiptOrder>();
const details = ref<DetailRow[]>([]);
const detailTableRef = ref<VxeTableInstance>();
const skuSelectRef = ref<InstanceType<typeof WmsItemSkuSelect>>();
let detailSeq = 0; // id使 VXE
const getTitle = computed(() => {
return formType.value === 'update'
? $t('ui.actionTitle.edit', ['入库单'])
: $t('ui.actionTitle.create', ['入库单']);
});
const isPrepareOrder = computed(() => {
return (
!formData.value?.id ||
(formData.value.status !== undefined &&
OrderUpdateStatusList.includes(formData.value.status))
);
});
const isSavedPrepareOrder = computed(() => {
return (
!!formData.value?.id &&
formData.value.status !== undefined &&
OrderUpdateStatusList.includes(formData.value.status)
);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
labelWidth: 100,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
wrapperClass: 'grid-cols-3',
});
/** 标准化明细行,补齐本地序号和金额 */
function normalizeDetail(
detail: WmsReceiptOrderDetailApi.ReceiptOrderDetail,
): DetailRow {
detailSeq += 1;
return {
...detail,
seq: detailSeq,
totalPrice:
detail.totalPrice ?? multiplyPrice(detail.quantity, detail.price),
};
}
/** 根据商品 SKU 构建新的入库明细 */
function buildDetail(sku: WmsItemSkuApi.ItemSku): DetailRow {
return normalizeDetail({
id: undefined,
itemCode: sku.itemCode,
itemId: sku.itemId,
itemName: sku.itemName,
price: undefined,
quantity: undefined,
skuCode: sku.code,
skuId: sku.id,
skuName: sku.name,
totalPrice: undefined,
unit: sku.unit,
});
}
/** 设置入库明细 */
function setDetails(list?: WmsReceiptOrderDetailApi.ReceiptOrderDetail[]) {
detailSeq = 0;
details.value = (list || []).map((detail) => normalizeDetail(detail));
void refreshDetailFooter();
}
/** 刷新明细合计行 */
async function refreshDetailFooter() {
await nextTick();
await detailTableRef.value?.updateFooter();
}
/** 获取已选择的 SKU 编号,避免重复选择 */
function getSelectedSkuIds() {
return details.value
.map((detail) => detail.skuId)
.filter((id): id is number => !!id);
}
/** 添加商品明细 */
async function handleAddDetail() {
const values = (await formApi.getValues()) as WmsReceiptOrderApi.ReceiptOrder;
if (!values.warehouseId) {
message.warning('请先选择仓库');
return;
}
skuSelectRef.value?.open(getSelectedSkuIds());
}
/** 选择商品 SKU */
function handleSelectSku(skus: WmsItemSkuApi.ItemSku[]) {
if (skus.length === 0) {
return;
}
const selectedSkuIds = new Set(getSelectedSkuIds());
let changed = false;
for (const sku of skus) {
if (!sku.id || selectedSkuIds.has(sku.id)) {
continue;
}
details.value.push(buildDetail(sku));
selectedSkuIds.add(sku.id);
changed = true;
}
if (changed) {
void refreshDetailFooter();
}
}
/** 删除商品明细 */
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 handleDetailQuantityChange(detail: DetailRow) {
if (detail.price !== undefined && detail.price !== null) {
detail.totalPrice = multiplyPrice(detail.quantity, detail.price);
void refreshDetailFooter();
return;
}
detail.price = dividePrice(detail.totalPrice, detail.quantity);
void refreshDetailFooter();
}
/** 明细单价变化 */
function handleDetailPriceChange(detail: DetailRow) {
detail.totalPrice = multiplyPrice(detail.quantity, detail.price);
void refreshDetailFooter();
}
/** 明细金额变化 */
function handleDetailTotalPriceChange(detail: DetailRow) {
detail.price = dividePrice(detail.totalPrice, detail.quantity);
void refreshDetailFooter();
}
/** 校验商品明细 */
function validateDetails(required = false) {
if (details.value.length === 0) {
if (required) {
message.error('至少包含一条入库明细');
return false;
}
return true;
}
for (let index = 0; index < details.value.length; index += 1) {
const detail = details.value[index]!;
if (!detail.skuId) {
message.error(`${index + 1} 行明细请选择商品规格`);
return false;
}
if (!detail.quantity || detail.quantity <= 0) {
message.error(`${index + 1} 行明细入库数量必须大于 0`);
return false;
}
}
return true;
}
/** 构建提交用的明细数据 */
function buildSubmitDetails() {
return details.value.map((row) => {
const { seq: _seq, ...detail } = row;
return detail;
});
}
/** 构建提交用的单据数据 */
async function buildSubmitData(): Promise<WmsReceiptOrderApi.ReceiptOrder> {
const values = (await formApi.getValues()) as WmsReceiptOrderApi.ReceiptOrder;
const {
details: _details,
totalPrice: _totalPrice,
totalQuantity: _totalQuantity,
...order
} = formData.value;
return {
...order,
...values,
details: buildSubmitDetails(),
};
}
/** 完成入库 */
async function handleFormComplete() {
const { valid } = await formApi.validate();
if (!valid || !validateDetails(true) || !formData.value?.id) {
return;
}
await confirm('确认完成入库?完成后将更新库存。');
modalApi.lock();
try {
const data = await buildSubmitData();
if (!isEqual(data, originalSubmitData.value)) {
await updateReceiptOrder(data);
}
await completeReceiptOrder(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 cancelReceiptOrder(formData.value.id);
await modalApi.close();
emit('success');
message.success('作废成功');
} finally {
modalApi.unlock();
}
}
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid || !validateDetails(false) || !isPrepareOrder.value) {
return;
}
modalApi.lock();
//
const data = await buildSubmitData();
try {
await (formType.value === 'update'
? updateReceiptOrder(data)
: createReceiptOrder(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;
setDetails([]);
return;
}
const data = modalApi.getData<{ formType: FormType; id?: number }>();
formType.value = data.formType;
if (data?.id) {
modalApi.lock();
try {
//
const order = await getReceiptOrder(data.id);
const orderDetails =
order.details || (await getReceiptOrderDetailListByOrderId(data.id));
formData.value = { ...order, details: orderDetails };
setDetails(orderDetails);
// values
await formApi.setValues(formData.value);
await nextTick();
originalSubmitData.value = await buildSubmitData();
} finally {
modalApi.unlock();
}
return;
}
//
formData.value = {
details: [],
no: generateOrderNo('RK'),
status: OrderStatusEnum.PREPARE,
};
setDetails([]);
await formApi.setValues(formData.value);
await nextTick();
originalSubmitData.value = await buildSubmitData();
},
});
</script>
<template>
<Modal :title="getTitle" class="w-3/4" :show-confirm-button="isPrepareOrder">
<div class="mx-4">
<Form />
<div class="mt-4">
<div class="mb-3 flex items-center justify-between">
<span class="text-sm font-semibold">入库明细</span>
<TableAction
:actions="[
{
label: '添加商品',
onClick: handleAddDetail,
type: 'primary',
},
]"
/>
</div>
<VxeTable
ref="detailTableRef"
:data="details"
border
empty-text="暂无商品明细"
:footer-method="getDetailFooter"
:show-overflow="true"
show-footer
size="small"
>
<VxeColumn title="商品信息" min-width="220">
<template #default="{ row }">
<div>{{ row.itemName || '-' }}</div>
<div v-if="row.itemCode" class="text-xs text-gray-500">
商品编号{{ row.itemCode }}
</div>
</template>
</VxeColumn>
<VxeColumn title="规格信息" min-width="220">
<template #default="{ row }">
<div>{{ row.skuName || '-' }}</div>
<div v-if="row.skuCode" class="text-xs text-gray-500">
规格编号{{ row.skuCode }}
</div>
</template>
</VxeColumn>
<VxeColumn field="quantity" title="入库数量" width="150">
<template #default="{ row }">
<InputNumber
v-model:value="row.quantity"
:controls="false"
:min="0"
:precision="QUANTITY_PRECISION"
class="!w-full"
placeholder="数量"
@change="handleDetailQuantityChange(row)"
/>
</template>
</VxeColumn>
<VxeColumn field="price" title="单价(元)" width="150">
<template #default="{ row }">
<InputNumber
v-model:value="row.price"
:controls="false"
:min="0"
:precision="PRICE_PRECISION"
class="!w-full"
placeholder="单价"
@change="handleDetailPriceChange(row)"
/>
</template>
</VxeColumn>
<VxeColumn field="totalPrice" title="金额(元)" width="150">
<template #default="{ row }">
<InputNumber
v-model:value="row.totalPrice"
:controls="false"
:min="0"
:precision="PRICE_PRECISION"
class="!w-full"
placeholder="金额"
@change="handleDetailTotalPriceChange(row)"
/>
</template>
</VxeColumn>
<VxeColumn title="操作" align="center" fixed="right" width="90">
<template #default="{ row }">
<TableAction
:actions="[
{
label: '删除',
type: 'link',
danger: true,
onClick: handleDeleteDetail.bind(null, row),
},
]"
/>
</template>
</VxeColumn>
</VxeTable>
</div>
</div>
<template #prepend-footer>
<div v-if="isSavedPrepareOrder" class="flex flex-auto items-center gap-2">
<TableAction
:actions="[
{
label: '完成入库',
type: 'primary',
auth: ['wms:receipt-order:complete'],
onClick: handleFormComplete,
},
{
label: '作废',
type: 'primary',
danger: true,
auth: ['wms:receipt-order:cancel'],
onClick: handleFormCancel,
},
]"
/>
</div>
</template>
<WmsItemSkuSelect ref="skuSelectRef" @change="handleSelectSku" />
</Modal>
</template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,161 @@
/**
* WMS
*/
type DecimalValue = null | number | string | undefined;
/** 数量小数位 */
export const QUANTITY_PRECISION = 2;
/** 金额小数位 */
export const PRICE_PRECISION = 2;
/** 重量小数位 */
export const WEIGHT_PRECISION = 3;
/** 长宽高小数位 */
export const DIMENSION_PRECISION = 1;
function isNullOrUndefined(value: unknown) {
return value === null || value === undefined;
}
function toFiniteDecimal(value: DecimalValue) {
if (isNullOrUndefined(value)) {
return undefined;
}
if (typeof value === 'string' && value.trim() === '') {
return undefined;
}
const decimalValue = typeof value === 'string' ? Number(value) : value;
if (!Number.isFinite(decimalValue)) {
return undefined;
}
return decimalValue;
}
function sumDecimal<T>(list: T[], getter: (item: T) => DecimalValue) {
let sum = 0;
for (const item of list) {
const decimalValue = toFiniteDecimal(getter(item));
if (decimalValue !== undefined) {
sum += decimalValue;
}
}
return sum;
}
/** 格式化数量 */
export function formatQuantity(value?: null | number | string) {
const decimalValue = toFiniteDecimal(value);
return decimalValue === undefined
? ''
: decimalValue.toFixed(QUANTITY_PRECISION);
}
/** 格式化金额 */
export function formatPrice(value?: null | number | string) {
const decimalValue = toFiniteDecimal(value);
return decimalValue === undefined
? ''
: decimalValue.toFixed(PRICE_PRECISION);
}
/** 金额四舍五入 */
export function roundPrice(value: number) {
return Number.isFinite(value)
? Number(value.toFixed(PRICE_PRECISION))
: undefined;
}
/** 亏损数字样式 */
export function getLossClass(value?: null | number | string) {
const decimalValue = toFiniteDecimal(value);
return decimalValue !== undefined && decimalValue < 0 ? 'text-red-500' : '';
}
/** 数量 * 单价,计算金额 */
export function multiplyPrice(quantity?: number, price?: number) {
if (
quantity === undefined ||
quantity === null ||
price === undefined ||
price === null
) {
return undefined;
}
return roundPrice(Number(quantity) * Number(price));
}
/** 金额 / 数量,反算单价 */
export function dividePrice(totalPrice?: number, quantity?: number) {
if (totalPrice === undefined || totalPrice === null || !quantity) {
return undefined;
}
return roundPrice(Number(totalPrice) / Number(quantity));
}
/** 汇总数量 */
export function sumQuantity<T>(list: T[], getter: (item: T) => DecimalValue) {
return sumDecimal(list, getter);
}
/** 汇总金额 */
export function sumPrice<T>(list: T[], getter: (item: T) => DecimalValue) {
return sumDecimal(list, getter);
}
/** 格式化汇总数量 */
export function formatSumQuantity<T>(
list: T[],
getter: (item: T) => DecimalValue,
) {
return formatQuantity(sumQuantity(list, getter));
}
/** 格式化汇总金额 */
export function formatSumPrice<T>(
list: T[],
getter: (item: T) => DecimalValue,
) {
return formatPrice(sumPrice(list, getter));
}
/** 格式化重量 */
export function formatWeight(value?: null | number | string) {
const decimalValue = toFiniteDecimal(value);
return decimalValue === undefined
? ''
: decimalValue.toFixed(WEIGHT_PRECISION);
}
/** 格式化长宽高 */
export function formatDimension(value?: null | number | string) {
const decimalValue = toFiniteDecimal(value);
return decimalValue === undefined
? ''
: decimalValue.toFixed(DIMENSION_PRECISION);
}
/** 格式化长宽高组合 */
export function formatDimensionText(
length?: null | number | string,
width?: null | number | string,
height?: null | number | string,
) {
if (
!isNullOrUndefined(length) &&
!isNullOrUndefined(width) &&
!isNullOrUndefined(height)
) {
return [
formatDimension(length),
formatDimension(width),
formatDimension(height),
].join(' * ');
}
return [
isNullOrUndefined(length) ? undefined : `长:${formatDimension(length)}`,
isNullOrUndefined(width) ? undefined : `宽:${formatDimension(width)}`,
isNullOrUndefined(height) ? undefined : `高:${formatDimension(height)}`,
]
.filter(Boolean)
.join(' ');
}

View File

@ -0,0 +1,12 @@
/**
* WMS
*/
/** 生成业务单号:前缀 + 月日 + 4 位随机数 */
export function generateOrderNo(prefix: string) {
const now = new Date();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const randomNo = String(Math.floor(Math.random() * 10_000)).padStart(4, '0');
return `${prefix}${month}${day}${randomNo}`;
}