diff --git a/src/modules/overseas-warehouse/API_INTEGRATION_MATRIX.md b/src/modules/overseas-warehouse/API_INTEGRATION_MATRIX.md index 2a1ebc29..29434ca2 100644 --- a/src/modules/overseas-warehouse/API_INTEGRATION_MATRIX.md +++ b/src/modules/overseas-warehouse/API_INTEGRATION_MATRIX.md @@ -16,7 +16,7 @@ | 排车列表/详情 | `GET /platform/v1/last-leg/dispatches`;`GET /dispatches/{dispatchNo}` | list query:`page,size,dispatchNo,vehiclePlate,status,fbaAppointmentNo`;不传客户 | `...:inbound-order:list`;Controller 受 `lastleg.dispatch-query-api.enabled` 控制 | | 排车候选 | `GET /platform/v1/last-leg/dispatches/candidates` | query:`planIds?`(逗号分隔长 ID)、`orderNo?`,`fbaWarehouseCode?`,`sourceStage?`,`draftToken?` | 同 inbound list;仅发 `PLAN/RECEIVED` | | 草稿/锁/预排 | `GET /dispatches/drafts`;`POST /drafts/containers`;`DELETE /drafts/{draftToken}/containers/{containerId}`;`POST /draft-locks/lock|unlock|release`;`POST /pre-arrange` | 车厢:`draftToken,capacity`;锁:`planPalletId,draftToken,draftContainerId?`;释放:`draftToken`;预排:`planPalletIds:string[],draftToken` | `...:inbound-order:smart-dispatch`;Controller 受 `lastleg.smart-dispatch-api.enabled` 控制;409 锁冲突显示真实错误;不调用编辑、取消、depart、PDA 或派送 | -| 库存汇总/余额 | `GET /platform/v1/last-leg/inventory/summary`;`GET /balances` | summary 强制 `page=1,size=200`;余额保留页面分页;支持 `skuCode,productName` 与仓库/入库/批次/库位筛选;不传客户 | `...:inventory:list`;只读,不调用 allocate/unallocate/pick/ship | +| 库存汇总/余额 | `GET /platform/v1/last-leg/inventory/summary`;`GET /balances` | summary 强制 `page=1,size=200`;余额保留页面分页;支持 `skuCode,productName` 与仓库/入库/批次/库位筛选;不传客户;列表/详情客户只显示 `customerName`,海外仓只显示 `overseasWarehouseName`,空值为 `—` | `...:inventory:list`;只读,不调用 allocate/unallocate/pick/ship;不以客户 ID、本地客户候选、国内来源仓或目的仓猜名称 | | 库存位置/进销存 | `GET /inventory/balances/{balanceId}/positions`;`GET /inventory/ledger` | position:`palletNo?,batchNo?,inventoryStatus?`;ledger 含 `page,size,skuCode` 与业务单/仓库/时间筛选 | 同 inventory list;`balanceId` 编码;只展示后端事实 | | 公共海外收货仓候选(国内 HTTP) | `GET /platform/v1/warehouse-admin` | `tenantId=0,page=1,size=200,capabilityCode=RECEIVING,warehouseType=OVERSEAS,status=ACTIVE` | 国内 Warehouse 是唯一仓库主数据源;托盘、库存和换标复用公共候选并保留字符串 ID,不请求海外仓库根路径 | | 托盘列表/详情 | `GET /platform/v1/warehouse-admin/{warehouseId}/pallets`;`GET /pallets/{palletId}` | list:`page,size,keyword?,palletType?,status?`;无客户参数 | `...:pallet:list`;仓库/托盘长 ID 编码 | diff --git a/src/modules/overseas-warehouse/CONTRACT_GAPS.md b/src/modules/overseas-warehouse/CONTRACT_GAPS.md index c5c1bca2..a17a62f0 100644 --- a/src/modules/overseas-warehouse/CONTRACT_GAPS.md +++ b/src/modules/overseas-warehouse/CONTRACT_GAPS.md @@ -4,6 +4,7 @@ ## 需后端或产品继续确认 +- **库存客户/海外仓快照**:正式余额 DTO 已返回 `customerName` 与 `overseasWarehouseName`,Admin 列表和详情只显示这两个服务端快照,空值为 `—`,不以 `customerId`、客户候选、国内来源仓、目的仓/FBA 仓或固定值补齐。当前测试数据“客户全为 — / 海外仓显示义乌仓”属于后端库存事实阻断:`LoadingCompletedConsumer#registerInTransit` 的客户名来自可能为空的跨区 `source.customerName`,并把国内装柜 `payload.warehouseName` 传入库存的 `overseasWarehouseName`;后续命中既有余额时 `InventoryCommandService#balance` 不会修订快照。后端需保证正式客户名称快照,并在实际海外收货仓冻结前保持海外仓为空、冻结后写入真实海外收货仓;前端不修复或猜测历史/错误事实。 - **换标原型字段**:列表 Summary 仍不含 `productNameZh/labelRequirement/relabelFile/budgetTotal/relabelCost`;本模块没有可用的换标文件上传端点。页面不伪造金额或文件名。创建 DTO 额外要求海外仓、截止时间、联系人、电话、货件号和新 SKU,这是完成真实创建的最小扩展。 - **托盘新增服务端权限**:前端新增按钮按 `platform:overseas-warehouse:pallet:create` 控制,但当前审计的 `PlatformWarehouseResourceController#createPallet` 未见对应 `@PreAuthorize`;需后端补齐或确认由网关等价强制。原型要求“托盘重量”,当前 Save DTO 无该字段,页面不伪造或发送。 - **托盘状态语义**:原型只给闲置/占用,DTO 还允许 `SCRAPPED`。是否允许 Admin 在普通编辑中直接改为报废仍需确认;不新增独立停用/报废动作。PUT 已按冻结契约携带详情 `version`,旧版本 `A0409`。 diff --git a/src/modules/overseas-warehouse/inventoryPresentation.test.ts b/src/modules/overseas-warehouse/inventoryPresentation.test.ts index 6de47bde..c14e2d30 100644 --- a/src/modules/overseas-warehouse/inventoryPresentation.test.ts +++ b/src/modules/overseas-warehouse/inventoryPresentation.test.ts @@ -29,6 +29,8 @@ describe('overseas inventory presentation adapter', () => { expect(row).toMatchObject({ id: '2089652067743272963', customerId: '990018', + owner: 'Acceptance Customer', + warehouse: 'Acceptance US Warehouse', sku: 'SKU-ACCEPT-1', productName: '验收商品', location: '—', @@ -39,6 +41,30 @@ describe('overseas inventory presentation adapter', () => { }) }) + it('shows an unknown owner as a dash and never substitutes customerId', () => { + const row = toInventoryListItem({ + id: '1', customerId: '2092618717861048321', customerName: null, + overseasWarehouseId: '3', inboundOrderId: '4', inboundCode: 'IN-1', + totalPackages: 1, availablePackages: 1, holdPackages: 0, allocatedPackages: 0, + pickedPackages: 0, inTransitPackages: 0, receivingPackages: 0, procurementPackages: 0, + }) + expect(row.owner).toBe('—') + expect(row.owner).not.toBe(row.customerId) + }) + + it('uses only the formal overseas warehouse name snapshot', () => { + const row = toInventoryListItem({ + id: '1', customerId: '2', customerName: '客户甲', overseasWarehouseId: '3', + overseasWarehouseCode: 'LAX', overseasWarehouseName: null, destinationWarehouseName: 'ONT8目的仓', + inboundOrderId: '4', inboundCode: 'IN-1', totalPackages: 1, availablePackages: 1, + holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, inTransitPackages: 0, + receivingPackages: 0, procurementPackages: 0, + }) + expect(row.warehouse).toBe('—') + expect(row.warehouse).not.toBe('LAX') + expect(row.warehouse).not.toBe('ONT8目的仓') + }) + it('falls back to the English product name only when the Chinese name is absent', () => { expect(inventoryProductName({ id: '1', customerId: '2', overseasWarehouseId: '3', inboundOrderId: '4', inboundCode: 'IN-1', diff --git a/src/modules/overseas-warehouse/inventoryPresentation.ts b/src/modules/overseas-warehouse/inventoryPresentation.ts index bd962467..9605e636 100644 --- a/src/modules/overseas-warehouse/inventoryPresentation.ts +++ b/src/modules/overseas-warehouse/inventoryPresentation.ts @@ -29,7 +29,7 @@ export function toInventoryListItem(dto: InventoryBalanceDto): InventoryListItem inboundOrderNo: text(dto.inboundOrderNo) === '—' ? '' : String(dto.inboundOrderNo), inboundCode: text(dto.inboundCode) === '—' ? '' : String(dto.inboundCode), owner: text(dto.customerName), - warehouse: text(dto.overseasWarehouseName || dto.overseasWarehouseCode), + warehouse: text(dto.overseasWarehouseName), sku: text(dto.skuCode), productName: inventoryProductName(dto), location: '—', diff --git a/src/modules/overseas-warehouse/pages/InventoryCustomerName.behavior.test.ts b/src/modules/overseas-warehouse/pages/InventoryCustomerName.behavior.test.ts new file mode 100644 index 00000000..4a52f479 --- /dev/null +++ b/src/modules/overseas-warehouse/pages/InventoryCustomerName.behavior.test.ts @@ -0,0 +1,70 @@ +import ElementPlus from 'element-plus' +import { createPinia } from 'pinia' +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getInventorySummary, listInventoryBalances, listInventoryLedger, listInventoryPositions } from '../api/inventory' +import InventoryDetailView from './InventoryDetailView.vue' +import InventoryView from './InventoryView.vue' + +const routing = vi.hoisted(() => ({ push: vi.fn(), route: { params: {} as Record, query: {} as Record } })) +vi.mock('vue-router', () => ({ useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) })) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => false }) })) +vi.mock('../api/pallets', () => ({ listReceivingOverseasWarehouses: vi.fn() })) +vi.mock('../api/inventory', () => ({ + listInventoryBalances: vi.fn(), getInventorySummary: vi.fn(), + listInventoryPositions: vi.fn(), listInventoryLedger: vi.fn(), +})) + +class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} } +function memoryStorage(): Storage { + const values = new Map() + return { + get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, + key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value), + } +} + +const customerId = '2092618717861048321' +const balance = { + id: '2092873463568596994', customerId, customerName: '华远贸易', overseasWarehouseId: '200', + overseasWarehouseName: '洛杉矶仓', inboundOrderId: '300', inboundOrderNo: 'LIP-1', inboundCode: 'IN-1', + destinationWarehouseName: 'ONT8目的仓', + skuCode: 'SKU-1', productNameZh: '验收商品', totalPackages: 10, availablePackages: 10, + holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, inTransitPackages: 0, + receivingPackages: 0, procurementPackages: 0, updatedAt: '2026-08-28T10:00:00', +} + +enableAutoUnmount(afterEach) +afterAll(() => vi.unstubAllGlobals()) + +describe('inventory customer-name snapshots', () => { + beforeEach(() => { + vi.stubGlobal('ResizeObserver', ResizeObserverStub) + vi.stubGlobal('localStorage', memoryStorage()) + routing.push.mockReset(); routing.route.params = {}; routing.route.query = {} + vi.mocked(listInventoryBalances).mockReset().mockResolvedValue({ records: [balance], page: 1, size: 10, total: 1, pages: 1 }) + vi.mocked(getInventorySummary).mockReset().mockResolvedValue({ inboundOrderCount: 1, palletCount: 1, totalPackages: 10, availablePackages: 10, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, inTransitPackages: 0, receivingPackages: 0, procurementPackages: 0 }) + vi.mocked(listInventoryPositions).mockReset().mockResolvedValue([]) + vi.mocked(listInventoryLedger).mockReset().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) + }) + + it('renders the formal server customerName in the inventory list', async () => { + const wrapper = mount(InventoryView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } }) + await flushPromises() + expect(wrapper.text()).toContain('华远贸易') + expect(wrapper.text()).toContain('洛杉矶仓') + expect(wrapper.text()).not.toContain('ONT8目的仓') + expect(wrapper.text()).not.toContain(customerId) + }) + + it('renders the same server snapshot in inventory detail', async () => { + routing.route.params = { inventoryId: balance.id } + routing.route.query = { overseasWarehouseId: balance.overseasWarehouseId, inboundOrderNo: balance.inboundOrderNo, inboundCode: balance.inboundCode, skuCode: balance.skuCode } + const wrapper = mount(InventoryDetailView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } }) + await flushPromises() + expect(wrapper.text()).toContain('华远贸易') + expect(wrapper.text()).toContain('洛杉矶仓') + expect(wrapper.text()).not.toContain('ONT8目的仓') + expect(wrapper.text()).not.toContain(customerId) + }) +})