diff --git a/src/modules/overseas-warehouse/lastMilePresentation.test.ts b/src/modules/overseas-warehouse/lastMilePresentation.test.ts index 8fbed2c5..9090e149 100644 --- a/src/modules/overseas-warehouse/lastMilePresentation.test.ts +++ b/src/modules/overseas-warehouse/lastMilePresentation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { formatOverseasDateTime, lastMileOrderReference, toLastMileOrderListItem } from './lastMilePresentation' +import { formatOverseasDateTime, inventoryBusinessTypeLabel, lastMileOrderReference, toLastMileOrderListItem } from './lastMilePresentation' describe('last-mile order presentation adapter', () => { it('formats overseas timestamps with zero-padded seconds', () => { @@ -11,6 +11,26 @@ describe('last-mile order presentation adapter', () => { expect(formatOverseasDateTime('not-a-date')).toBe('not-a-date') }) + it.each([ + ['IN_TRANSIT', '在途入账'], + ['STOCK_IN', '入库'], + ['STOCK_IN_TRANSIT_RELEASE', '在途库存转入'], + ['HOLD', '库存暂扣'], + ['RELEASE', '解除暂扣'], + ['ALLOCATE', '库存分配'], + ['UNALLOCATE', '取消分配'], + ['PICK', '拣货'], + ['SHIP', '出库'], + ['ADJUST', '库存调整'], + ['STOCKTAKE_ADJUST', '盘点调整'], + ['PALLET_ASSEMBLE', '组托'], + ['PALLET_DISASSEMBLE', '拆托'], + ['PALLET_SWAP', '换托'], + ['PALLET_UNBIND', '解托'], + ])('maps inventory business type %s to %s', (code, label) => { + expect(inventoryBusinessTypeLabel(code)).toBe(label) + }) + it('maps backend codes, associations and long ids without inventing data', () => { const row = toLastMileOrderListItem({ id: '190000000000000099', customerId: '190000000000000001', orderNo: 'LL-001', orderType: 'ONE_CLICK_DROPSHIP', currentStatus: 'PENDING_ASSIGNMENT', diff --git a/src/modules/overseas-warehouse/lastMilePresentation.ts b/src/modules/overseas-warehouse/lastMilePresentation.ts index 9d567714..c7bd35a4 100644 --- a/src/modules/overseas-warehouse/lastMilePresentation.ts +++ b/src/modules/overseas-warehouse/lastMilePresentation.ts @@ -22,6 +22,24 @@ const statusLabels: Record = { CANCELLED: '已取消', } +const inventoryBusinessTypeLabels: Record = { + IN_TRANSIT: '在途入账', + STOCK_IN: '入库', + STOCK_IN_TRANSIT_RELEASE: '在途库存转入', + HOLD: '库存暂扣', + RELEASE: '解除暂扣', + ALLOCATE: '库存分配', + UNALLOCATE: '取消分配', + PICK: '拣货', + SHIP: '出库', + ADJUST: '库存调整', + STOCKTAKE_ADJUST: '盘点调整', + PALLET_ASSEMBLE: '组托', + PALLET_DISASSEMBLE: '拆托', + PALLET_SWAP: '换托', + PALLET_UNBIND: '解托', +} + function text(value: unknown) { const normalized = String(value ?? '').trim() return normalized || '—' @@ -45,6 +63,11 @@ export function lastMileOrderTypeLabel(value: unknown) { return orderTypeLabels[code] || text(code) } +export function inventoryBusinessTypeLabel(value: unknown) { + const code = String(value ?? '').trim() + return inventoryBusinessTypeLabels[code] || text(code) +} + export function lastMileOrderReference(value: unknown, emptyLabel = '—') { const serverValue = value == null ? '' : String(value) return serverValue || emptyLabel diff --git a/src/modules/overseas-warehouse/pages/InventoryDetailView.vue b/src/modules/overseas-warehouse/pages/InventoryDetailView.vue index 5432e529..6eae5485 100644 --- a/src/modules/overseas-warehouse/pages/InventoryDetailView.vue +++ b/src/modules/overseas-warehouse/pages/InventoryDetailView.vue @@ -3,7 +3,7 @@ import { computed, onMounted, ref } from 'vue' import { useRoute, useRouter } from 'vue-router' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'; import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'; import LoadErrorState from '@/shared/components/LoadErrorState.vue' -import { listInventoryBalances, listInventoryLedger, listInventoryPositions, type InventoryBalanceDto, type InventoryLedgerDto, type InventoryPositionDto } from '../api/inventory'; import { inventoryProductName } from '../inventoryPresentation'; import { formatOverseasDateTime } from '../lastMilePresentation'; import OverseasDetailShell from '../components/OverseasDetailShell.vue' +import { listInventoryBalances, listInventoryLedger, listInventoryPositions, type InventoryBalanceDto, type InventoryLedgerDto, type InventoryPositionDto } from '../api/inventory'; import { inventoryProductName } from '../inventoryPresentation'; import { formatOverseasDateTime, inventoryBusinessTypeLabel } from '../lastMilePresentation'; import OverseasDetailShell from '../components/OverseasDetailShell.vue' const route = useRoute(); const router = useRouter(); const inventoryId = computed(() => String(route.params.inventoryId || '')) const balance = ref(null); const positions = ref([]); const ledger = ref([]); const loading = ref(false); const loadError = ref(null) @@ -13,7 +13,7 @@ const stockValues = computed(() => ({ 在库总量: Number(balance.value?.totalP const productFields = ['客户', '海外仓', 'SKU编码', '产品名称', '更新时间']; const stockFields = ['在库总量', '可用库存', '暂扣库存', '美本土散版', '在途库存', '采购库存', '预计总量'] const locationRows = computed(() => positions.value.map((item) => ({ id: String(item.id), location: text(item.warehouseLocationCode), status: text(item.status), palletNo: text(item.palletNo), cartons: Number(item.totalPackages || 0), quantity: Number(item.totalPackages || 0), available: Number(item.availablePackages || 0), inboundDate: formatOverseasDateTime(item.updatedAt), ageDays: '—' }))) const palletRows = computed(() => positions.value.map((item) => ({ id: String(item.id), palletNo: text(item.palletNo), cartons: Number(item.totalPackages || 0), cartonRange: '—', location: text(item.warehouseLocationCode), quantity: Number(item.totalPackages || 0) }))) -const ledgerRows = computed(() => ledger.value.map((item) => ({ id: String(item.id), occurredAt: formatOverseasDateTime(item.occurredAt), businessType: text(item.businessType), businessOrderNo: text(item.businessOrderNo), inboundOrderNo: text(item.inboundOrderNo), outboundOrderNo: text(item.outboundOrderNo), location: text(item.warehouseLocationCode), inboundQuantity: item.deltaPackages > 0 ? item.deltaPackages : 0, outboundQuantity: item.deltaPackages < 0 ? Math.abs(item.deltaPackages) : 0, balance: Number(item.afterPackages || 0), operator: text(item.operatorId), remark: text(item.remark) }))) +const ledgerRows = computed(() => ledger.value.map((item) => ({ id: String(item.id), occurredAt: formatOverseasDateTime(item.occurredAt), businessType: inventoryBusinessTypeLabel(item.businessType), businessOrderNo: text(item.businessOrderNo), inboundOrderNo: text(item.inboundOrderNo), outboundOrderNo: text(item.outboundOrderNo), location: text(item.warehouseLocationCode), inboundQuantity: item.deltaPackages > 0 ? item.deltaPackages : 0, outboundQuantity: item.deltaPackages < 0 ? Math.abs(item.deltaPackages) : 0, balance: Number(item.afterPackages || 0), operator: '—', remark: text(item.remark) }))) const locationColumns: DataTableColumn[] = [{ key: 'location', prop: 'location', label: '库位', minWidth: 130 }, { key: 'status', prop: 'status', label: '库存状态', width: 120 }, { key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 150 }, { key: 'cartons', prop: 'cartons', label: '箱数', width: 90, align: 'right' }, { key: 'quantity', prop: 'quantity', label: '库存数量', width: 110, align: 'right' }, { key: 'available', prop: 'available', label: '可用数量', width: 110, align: 'right' }, { key: 'inboundDate', prop: 'inboundDate', label: '最近更新', width: 160 }, { key: 'ageDays', prop: 'ageDays', label: '库龄(天)', width: 100, align: 'right' }] const palletColumns: DataTableColumn[] = [{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 150 }, { key: 'cartons', prop: 'cartons', label: '关联箱数', width: 110, align: 'right' }, { key: 'cartonRange', prop: 'cartonRange', label: '箱号范围', minWidth: 200 }, { key: 'location', prop: 'location', label: '库位', minWidth: 130 }, { key: 'quantity', prop: 'quantity', label: '库存数量', width: 110, align: 'right' }] const ledgerColumns: DataTableColumn[] = [{ key: 'occurredAt', prop: 'occurredAt', label: '发生时间', minWidth: 160 }, { key: 'businessType', prop: 'businessType', label: '业务类型', width: 120 }, { key: 'businessOrderNo', prop: 'businessOrderNo', label: '业务订单号', minWidth: 170 }, { key: 'inboundOrderNo', prop: 'inboundOrderNo', label: '入库单号', minWidth: 160 }, { key: 'outboundOrderNo', prop: 'outboundOrderNo', label: '出库单号', minWidth: 160 }, { key: 'location', prop: 'location', label: '库位', minWidth: 120 }, { key: 'inboundQuantity', prop: 'inboundQuantity', label: '入库数量', width: 110, align: 'right' }, { key: 'outboundQuantity', prop: 'outboundQuantity', label: '出库数量', width: 110, align: 'right' }, { key: 'balance', prop: 'balance', label: '结存', width: 100, align: 'right' }, { key: 'operator', prop: 'operator', label: '操作人', minWidth: 120 }, { key: 'remark', prop: 'remark', label: '备注', minWidth: 180 }] diff --git a/src/modules/overseas-warehouse/pages/InventoryTransactionsView.vue b/src/modules/overseas-warehouse/pages/InventoryTransactionsView.vue index 9c6c2c8d..485acbb3 100644 --- a/src/modules/overseas-warehouse/pages/InventoryTransactionsView.vue +++ b/src/modules/overseas-warehouse/pages/InventoryTransactionsView.vue @@ -8,7 +8,7 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview' import AdminPage from '@/shared/components/AdminPage.vue'; import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'; import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'; import ListPageLayout from '@/shared/components/ListPageLayout.vue'; import LoadErrorState from '@/shared/components/LoadErrorState.vue'; import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' import InboundCodeRelationshipDialog from '@/shared/components/InboundCodeRelationshipDialog.vue' import { saveCsv } from '@/shared/downloadCsv' -import { downloadInventoryLedger, listInventoryLedger } from '../api/inventory'; import { formatOverseasDateTime } from '../lastMilePresentation'; import { overseasWarehousePermissions as permission } from '../permissions'; import type { InventoryTransactionListItem } from '../types' +import { downloadInventoryLedger, listInventoryLedger } from '../api/inventory'; import { formatOverseasDateTime, inventoryBusinessTypeLabel } from '../lastMilePresentation'; import { overseasWarehousePermissions as permission } from '../permissions'; import type { InventoryTransactionListItem } from '../types' const route = useRoute(); const router = useRouter(); const access = useAccessPreviewStore(); const rows = ref([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const loadError = ref(null) const exportLoading = ref(false) @@ -18,7 +18,12 @@ const fields = computed(() => [ { key: 'occurredDate', type: 'daterange', label: '发生日期', width: 260 }, { key: 'customerName', type: 'input', label: '客户名称', placeholder: '请输入客户名称', width: 190 }, { key: 'warehouse', type: 'input', label: '目的仓代码', placeholder: '请输入目的仓代码', width: 190 }, { key: 'sku', type: 'input', label: 'SKU编码', placeholder: '请输入 SKU 编码', width: 170 }, - { key: 'businessType', type: 'input', label: '业务类型', placeholder: '请输入业务类型代码', width: 170 }, { key: 'businessOrderNo', type: 'input', label: '业务订单号', placeholder: '请输入业务订单号', width: 180 }, + { key: 'businessType', type: 'select', label: '业务类型', placeholder: '全部业务类型', width: 170, options: [ + { label: '在途入账', value: 'IN_TRANSIT' }, { label: '入库', value: 'STOCK_IN' }, { label: '库存暂扣', value: 'HOLD' }, { label: '解除暂扣', value: 'RELEASE' }, + { label: '库存分配', value: 'ALLOCATE' }, { label: '取消分配', value: 'UNALLOCATE' }, { label: '拣货', value: 'PICK' }, { label: '出库', value: 'SHIP' }, + { label: '库存调整', value: 'ADJUST' }, { label: '盘点调整', value: 'STOCKTAKE_ADJUST' }, { label: '组托', value: 'PALLET_ASSEMBLE' }, + { label: '拆托', value: 'PALLET_DISASSEMBLE' }, { label: '换托', value: 'PALLET_SWAP' }, { label: '解托', value: 'PALLET_UNBIND' }, + ] }, { key: 'businessOrderNo', type: 'input', label: '业务订单号', placeholder: '请输入业务订单号', width: 180 }, { key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 190 }, { key: 'inboundOrderNo', type: 'input', label: '入库单号', placeholder: '请输入入库单号', width: 180 }, { key: 'outboundOrderNo', type: 'input', label: '出库单号', placeholder: '只读关联筛选', width: 180 }, ]) const columns: DataTableColumn[] = [ @@ -31,14 +36,14 @@ const columns: DataTableColumn[] = [ ] function text(value: unknown) { return String(value ?? '').trim() || '—' } function openRelationship(row: InventoryTransactionListItem) { if (row.inboundCode === '—') return; relationshipDialogInboundCode.value = row.inboundCode; relationshipDialogOpen.value = true } -async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); const range = Array.isArray(filters.value.occurredDate) ? filters.value.occurredDate.map(String) : []; loading.value = true; loadError.value = null; try { const result = await listInventoryLedger({ page: page.value, size: pageSize.value, customerName: String(filters.value.customerName || '').trim() || undefined, destinationWarehouseCode: String(filters.value.warehouse || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, businessType: String(filters.value.businessType || '').trim() || undefined, businessOrderNo: String(filters.value.businessOrderNo || '').trim() || undefined, inboundCode: String(filters.value.inboundCode || '').trim() || undefined, inboundOrderNo: String(filters.value.inboundOrderNo || '').trim() || undefined, outboundOrderNo: String(filters.value.outboundOrderNo || '').trim() || undefined, occurredFrom: range[0] ? `${range[0]}T00:00:00` : undefined, occurredTo: range[1] ? `${range[1]}T23:59:59` : undefined }); rows.value = result.records.map((item) => ({ id: String(item.id), occurredAt: formatOverseasDateTime(item.occurredAt), owner: text(item.customerName || '客户信息待补充'), warehouse: text(item.destinationWarehouseCode), sku: text(item.skuCode), productName: '—', businessType: text(item.businessType), businessOrderNo: text(item.businessOrderNo), inboundCode: text(item.inboundCode), inboundOrderNo: text(item.inboundOrderNo), outboundOrderNo: text(item.outboundOrderNo), inboundQuantity: item.deltaPackages > 0 ? item.deltaPackages : 0, outboundQuantity: item.deltaPackages < 0 ? Math.abs(item.deltaPackages) : 0, balance: Number(item.afterPackages || 0), operator: item.operatorId ? '操作人名称待补充' : '—', remark: text(item.remark) })); total.value = result.total } catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '进销存记录加载失败,请重试') } finally { loading.value = false } } +async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); const range = Array.isArray(filters.value.occurredDate) ? filters.value.occurredDate.map(String) : []; loading.value = true; loadError.value = null; try { const result = await listInventoryLedger({ page: page.value, size: pageSize.value, customerName: String(filters.value.customerName || '').trim() || undefined, destinationWarehouseCode: String(filters.value.warehouse || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, businessType: String(filters.value.businessType || '').trim() || undefined, businessOrderNo: String(filters.value.businessOrderNo || '').trim() || undefined, inboundCode: String(filters.value.inboundCode || '').trim() || undefined, inboundOrderNo: String(filters.value.inboundOrderNo || '').trim() || undefined, outboundOrderNo: String(filters.value.outboundOrderNo || '').trim() || undefined, occurredFrom: range[0] ? `${range[0]}T00:00:00` : undefined, occurredTo: range[1] ? `${range[1]}T23:59:59` : undefined }); rows.value = result.records.map((item) => ({ id: String(item.id), occurredAt: formatOverseasDateTime(item.occurredAt), owner: text(item.customerName), warehouse: text(item.destinationWarehouseCode), sku: text(item.skuCode), productName: '—', businessType: inventoryBusinessTypeLabel(item.businessType), businessOrderNo: text(item.businessOrderNo), inboundCode: text(item.inboundCode), inboundOrderNo: text(item.inboundOrderNo), outboundOrderNo: text(item.outboundOrderNo), inboundQuantity: item.deltaPackages > 0 ? item.deltaPackages : 0, outboundQuantity: item.deltaPackages < 0 ? Math.abs(item.deltaPackages) : 0, balance: Number(item.afterPackages || 0), operator: '—', remark: text(item.remark) })); total.value = result.total } catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '进销存记录加载失败,请重试') } finally { loading.value = false } } function search(model: QueryModel) { page.value = 1; void load(model) }; function reset(model: QueryModel) { page.value = 1; void load(model) }; function back() { void router.push({ name: 'overseas-inventory' }) }; onMounted(load) async function exportLedger() { exportLoading.value = true; try { const range = Array.isArray(filters.value.occurredDate) ? filters.value.occurredDate.map(String) : []; saveCsv(await downloadInventoryLedger({ customerName: String(filters.value.customerName || '').trim() || undefined, destinationWarehouseCode: String(filters.value.warehouse || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, businessType: String(filters.value.businessType || '').trim() || undefined, businessOrderNo: String(filters.value.businessOrderNo || '').trim() || undefined, inboundCode: String(filters.value.inboundCode || '').trim() || undefined, inboundOrderNo: String(filters.value.inboundOrderNo || '').trim() || undefined, outboundOrderNo: String(filters.value.outboundOrderNo || '').trim() || undefined, occurredFrom: range[0] ? `${range[0]}T00:00:00` : undefined, occurredTo: range[1] ? `${range[1]}T23:59:59` : undefined }) as Blob, '进销存记录.csv') } catch { ElMessage.error('进销存导出失败,请重试') } finally { exportLoading.value = false } } diff --git a/src/modules/overseas-warehouse/pages/prototypeContract.test.ts b/src/modules/overseas-warehouse/pages/prototypeContract.test.ts index a0f797a2..000987a0 100644 --- a/src/modules/overseas-warehouse/pages/prototypeContract.test.ts +++ b/src/modules/overseas-warehouse/pages/prototypeContract.test.ts @@ -272,7 +272,8 @@ describe('overseas warehouse first-batch prototype contract', () => { expect(transactions).toContain('业务订单号') expect(transactions).toContain('入库单号') expect(transactions).toContain('出库单号') - expect(transactions).toContain('出库相关字段仅展示既有库存流水事实') + expect(transactions).toContain('不提供出库单跳转或库存变更操作') + expect(transactions).not.toContain('当前流水 DTO') expect(transactions).toContain("router.push({ name: 'overseas-inventory' })") expect(transactions).not.toContain('保存') expect(transactions).not.toContain('ElMessage.success') diff --git a/src/modules/overseas-warehouse/pages/realApiMount.test.ts b/src/modules/overseas-warehouse/pages/realApiMount.test.ts index f1cb0aa4..0b3c45a1 100644 --- a/src/modules/overseas-warehouse/pages/realApiMount.test.ts +++ b/src/modules/overseas-warehouse/pages/realApiMount.test.ts @@ -29,6 +29,6 @@ describe('overseas warehouse mounted real API pages', () => { vi.mocked(getLastMileOrder).mockReset().mockResolvedValue({ order: { id: '9007199254740999', customerId: '10001', customerName: '华远贸易', orderNo: 'LMO-260827-0001', currentStatus: 'PENDING_ASSIGNMENT' }, cargo: [], statusHistory: [], fees: [], operationHistory: [] }) }) it('mounts inbound detail with a string-safe source order id', async () => { routing.route.params = { orderId: 'IP-1' }; routing.route.query = { customerId: '10001' }; const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises(); expect(getInboundPlan).toHaveBeenCalledWith('IP-1', '10001'); expect(wrapper.text()).toContain('9007199254740995'); expect(wrapper.text()).toContain('洛杉矶仓') }) - it('mounts inventory ledger as read-only business facts without exposing customer ids', async () => { const wrapper = mount(InventoryTransactionsView, { global: { plugins: [createPinia(), ElementPlus] } }); await flushPromises(); expect(listInventoryLedger).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 10 })); expect(listInventoryLedger).not.toHaveBeenCalledWith(expect.objectContaining({ customerId: expect.anything() })); expect(wrapper.text()).toContain('客户甲'); expect(wrapper.text()).toContain('NYEXX2608300001'); expect(wrapper.text()).not.toContain('10001'); expect(wrapper.text()).toContain('IP-1'); expect(wrapper.text()).toContain('10'); expect(wrapper.text()).toContain('不提供出库单跳转') }) + it('mounts inventory ledger as localized read-only business facts without exposing internal ids', async () => { const wrapper = mount(InventoryTransactionsView, { global: { plugins: [createPinia(), ElementPlus] } }); await flushPromises(); expect(listInventoryLedger).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 10 })); expect(listInventoryLedger).not.toHaveBeenCalledWith(expect.objectContaining({ customerId: expect.anything() })); expect(wrapper.text()).toContain('客户甲'); expect(wrapper.text()).toContain('NYEXX2608300001'); expect(wrapper.text()).not.toContain('10001'); expect(wrapper.text()).toContain('IP-1'); expect(wrapper.text()).toContain('10'); expect(wrapper.text()).toContain('入库'); expect(wrapper.text()).not.toContain('STOCK_IN'); expect(wrapper.text()).toContain('不提供出库单跳转'); expect(wrapper.text()).not.toContain('当前流水 DTO'); expect(wrapper.text()).not.toContain('操作人名称待补充') }) it('renders the backend owner and complete short order number in last-mile detail', async () => { routing.route.params = { orderId: '9007199254740999' }; const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [createPinia(), ElementPlus] } }); await flushPromises(); expect(getLastMileOrder).toHaveBeenCalledWith('9007199254740999'); expect(wrapper.text()).toContain('华远贸易'); expect(wrapper.text()).toContain('LMO-260827-0001'); expect(wrapper.get('.order-reference-text').attributes('title')).toBe('LMO-260827-0001') }) })