diff --git a/src/modules/overseas-warehouse/api/lastMileOrders.test.ts b/src/modules/overseas-warehouse/api/lastMileOrders.test.ts index e7f63d5c..7e348c3e 100644 --- a/src/modules/overseas-warehouse/api/lastMileOrders.test.ts +++ b/src/modules/overseas-warehouse/api/lastMileOrders.test.ts @@ -114,11 +114,17 @@ describe('last-mile order overseas API', () => { it('uses one atomic container request without looping individual conversions', async () => { vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: [] }) - await expect(convertLastMileContainerToInboundPlans({ sourceContainerBookingId: '2092873463568597001' })).resolves.toEqual([]) + await expect(convertLastMileContainerToInboundPlans({ + sourceContainerBookingId: '2092873463568597001', + snapshotVersion: 9, + snapshotHash: 'a'.repeat(64), + }, 'container-plan-idempotency-key')).resolves.toEqual([]) expect(overseasHttp.post).toHaveBeenCalledTimes(1) expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/convert-container', { sourceContainerBookingId: '2092873463568597001', - }) + snapshotVersion: 9, + snapshotHash: 'a'.repeat(64), + }, { headers: { 'Idempotency-Key': 'container-plan-idempotency-key' } }) }) it('loads server-side container progress with pagination and filters', async () => { diff --git a/src/modules/overseas-warehouse/api/lastMileOrders.ts b/src/modules/overseas-warehouse/api/lastMileOrders.ts index 53e11db4..601fc96b 100644 --- a/src/modules/overseas-warehouse/api/lastMileOrders.ts +++ b/src/modules/overseas-warehouse/api/lastMileOrders.ts @@ -210,6 +210,8 @@ export interface ContainerPlanProgressDetailDto { export interface ConvertContainerInboundPlanPayload { sourceContainerBookingId: string + snapshotVersion: number + snapshotHash: string } export type ContainerInboundPlanPreviewReadiness = 'READY' | 'PLANNED' | 'BLOCKED' @@ -460,9 +462,10 @@ export async function getContainerInboundPlanPreview(sourceContainerBookingId: s return unwrapApiResponse(response) } -export async function convertLastMileContainerToInboundPlans(payload: ConvertContainerInboundPlanPayload) { +export async function convertLastMileContainerToInboundPlans(payload: ConvertContainerInboundPlanPayload, idempotencyKey: string) { const response = await overseasHttp.post, ApiEnvelope>( '/platform/v1/last-leg/inbound-plans/convert-container', payload, + { headers: { 'Idempotency-Key': idempotencyKey } }, ) return unwrapApiResponse(response) } diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts index 7a80717b..0ebb0f12 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts @@ -13,6 +13,7 @@ import { changeContainerReceivingWarehouse, createManualInboundPlan, convertLastMileContainerToInboundPlans, + getContainerInboundPlanPreview, getContainerPlanProgress, listContainerPlanProgress, listLastMileOrders, @@ -30,7 +31,7 @@ vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() })) vi.mock('../api/lastMileOrders', () => ({ listLastMileOrders: vi.fn(), listContainerPlanProgress: vi.fn(), getContainerPlanProgress: vi.fn(), changeContainerReceivingWarehouse: vi.fn(), - createManualInboundPlan: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), transferLastMileOrders: vi.fn(), + createManualInboundPlan: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), getContainerInboundPlanPreview: vi.fn(), transferLastMileOrders: vi.fn(), getLastMileOrderManualNodes: vi.fn(), updateLastMileOrderHold: vi.fn(), updateLastMileOrderManualNodes: vi.fn(), downloadLastMileOrders: vi.fn(), })) @@ -65,6 +66,26 @@ const fbaWarehouse = { }, addressSummary: 'CA Ontario', } +const preview = { + sourceContainerBookingId: bookingId, containerNo: 'CONT-15', + overseasWarehouseId: summary.overseasWarehouseId, overseasWarehouseCode: summary.overseasWarehouseCode, + overseasWarehouseName: summary.overseasWarehouseName, snapshotVersion: 9, snapshotHash: 'a'.repeat(64), + totalOrderCount: 5, readyOrderCount: 5, blockedOrderCount: 0, pendingOrderCount: 5, + pendingPalletCount: 5, existingPlannedPalletCount: 0, totalSuggestedPalletCount: 5, + totalPlannedPalletCount: 0, ready: true, + groups: [{ + destinationType: 'FBA', destinationWarehouseId: null, destinationWarehouseCode: 'ONT8', + destinationWarehouseName: 'Ontario FBA', orderCount: 5, pendingOrderCount: 5, pendingPalletCount: 5, + existingPlannedPalletCount: 0, suggestedPalletCount: 5, plannedPalletCount: 0, + orders: [{ + customerId, customerName: '测试客户', orderId: '2092873463568596994', businessOrderNo: 'LMO-1', + inboundCode: 'IN-1', orderType: 'FIRST_LEG', destinationType: 'FBA', destinationWarehouseId: null, + destinationWarehouseCode: 'ONT8', destinationWarehouseName: 'Ontario FBA', packageCount: 10, + volumeCbm: 1, suggestedPalletCount: 5, plannedPalletCount: null, existingPlanNo: null, + planStatus: null, orderVersion: 3, readiness: 'READY', blockers: [], + }], + }], +} const receivingWarehouse = { ...fbaWarehouse, id: '9007199254740996', warehouseCode: 'LAX02', warehouseName: '洛杉矶二号接柜仓', warehouseType: 'OVERSEAS', @@ -118,6 +139,8 @@ async function chooseContainer(wrapper: Awaited>) { const containerSelect = selects[selects.length - 1] expect(containerSelect).toBeDefined() containerSelect.vm.$emit('update:modelValue', bookingId) + containerSelect.vm.$emit('change', bookingId) + await flushPromises() await wrapper.vm.$nextTick() } @@ -133,10 +156,14 @@ describe('LastMileOrdersView pending order and container conversion', () => { orders: [{ orderId: '2092873463568596994', customerId, orderNo: 'LMO-1', inboundCode: 'IN-1', orderStatus: 'INBOUND_PLANNED', planId: '2092873463568597010', planNo: 'LIP-1', planStatus: 'PENDING_RECEIVING', planValid: true, plannedArrivalAt: '2026-09-01T08:00:00' }], }) vi.mocked(createManualInboundPlan).mockReset().mockResolvedValue({} as never) + vi.mocked(getContainerInboundPlanPreview).mockReset().mockResolvedValue(preview as never) vi.mocked(getLastMileOrderManualNodes).mockReset().mockResolvedValue({ orderId: '2092873463568596994', manualOverrideEnabled: false, nodes: [], version: 0 }) vi.mocked(updateLastMileOrderHold).mockReset().mockResolvedValue({ orderId: '2092873463568596994', held: true, chargeable: true, version: 1 }) vi.mocked(updateLastMileOrderManualNodes).mockReset().mockResolvedValue({ orderId: '2092873463568596994', manualOverrideEnabled: true, nodes: [], version: 1 }) - vi.mocked(convertLastMileContainerToInboundPlans).mockReset().mockResolvedValue([{ planNo: 'LIP-1' }, { planNo: 'LIP-2' }] as never) + vi.mocked(convertLastMileContainerToInboundPlans).mockReset().mockResolvedValue([ + { planNo: 'LIP-1', pallets: [{ id: 'PALLET-1', palletNo: 'PLT-001' }] }, + { planNo: 'LIP-2', pallets: [{ id: 'PALLET-2', palletNo: 'PLT-002' }] }, + ] as never) vi.mocked(changeContainerReceivingWarehouse).mockReset().mockResolvedValue({ ...summary, overseasWarehouseId: '9007199254740996', overseasWarehouseCode: 'LAX02', overseasWarehouseName: '洛杉矶二号接柜仓', assignmentVersion: 8 }) vi.mocked(listAllPlatformWarehouses).mockReset().mockResolvedValue([fbaWarehouse] as never) vi.mocked(transferLastMileOrders).mockReset().mockResolvedValue(2) @@ -253,7 +280,8 @@ describe('LastMileOrdersView pending order and container conversion', () => { expect(listContainerPlanProgress).toHaveBeenCalledTimes(1) expect(listContainerPlanProgress).toHaveBeenCalledWith({ page: 1, size: 50, containerNo: undefined }) expect(document.body.textContent).toContain('选择待分配柜') - expect(document.body.textContent).toContain('将为所选柜内所有待分配尾程订单分别生成独立入库计划') + expect(document.body.textContent).toContain('确认后整柜只提交一次生成命令') + expect(getContainerInboundPlanPreview).not.toHaveBeenCalled() expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled() }) @@ -261,14 +289,22 @@ describe('LastMileOrdersView pending order and container conversion', () => { const wrapper = await openPending() await openContainerDialog(wrapper) await chooseContainer(wrapper) - const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认按柜生成')) as HTMLButtonElement + const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认生成')) as HTMLButtonElement expect(confirm.disabled).toBe(false) confirm.click() await flushPromises() expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledTimes(1) - expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledWith({ sourceContainerBookingId: bookingId }) + expect(getContainerInboundPlanPreview).toHaveBeenCalledTimes(2) + expect(getContainerInboundPlanPreview).toHaveBeenNthCalledWith(1, bookingId) + expect(getContainerInboundPlanPreview).toHaveBeenNthCalledWith(2, bookingId) + expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledWith({ + sourceContainerBookingId: bookingId, + snapshotVersion: 9, + snapshotHash: 'a'.repeat(64), + }, expect.stringContaining('container-inbound-plan-')) expect(createManualInboundPlan).not.toHaveBeenCalled() - expect(document.body.textContent).toContain('已按柜生成 2 个独立入库计划') + expect(document.body.textContent).toContain('新建 2 个 / 已有 0 个') + expect(document.body.textContent).toContain('PLT-001') expect(listLastMileOrders).toHaveBeenCalledTimes(3) }) @@ -279,7 +315,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { const wrapper = await openPending() await openContainerDialog(wrapper) await chooseContainer(wrapper) - const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认按柜生成')) as HTMLButtonElement + const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认生成')) as HTMLButtonElement confirm.click() await flushPromises() expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledTimes(1) @@ -288,6 +324,51 @@ describe('LastMileOrdersView pending order and container conversion', () => { expect(wrapper.text()).toContain(`trace-${code}`) }) + it('keeps the latest cabinet preview when an older request resolves last', async () => { + let resolveFirst!: (value: typeof preview) => void + let resolveSecond!: (value: typeof preview) => void + vi.mocked(getContainerInboundPlanPreview).mockImplementation((id) => new Promise((resolve) => { + if (id === bookingId) resolveFirst = resolve + else resolveSecond = resolve + }) as never) + const wrapper = await openPending() + await openContainerDialog(wrapper) + const selects = wrapper.findAllComponents({ name: 'ElSelect' }) + const containerSelect = selects[selects.length - 1] + containerSelect.vm.$emit('update:modelValue', bookingId) + containerSelect.vm.$emit('change', bookingId) + await wrapper.vm.$nextTick() + containerSelect.vm.$emit('update:modelValue', '2092873463568597002') + containerSelect.vm.$emit('change', '2092873463568597002') + await wrapper.vm.$nextTick() + resolveSecond({ ...preview, sourceContainerBookingId: '2092873463568597002', containerNo: 'CONT-NEW', pendingPalletCount: 2 }) + await flushPromises() + expect(document.body.querySelector('.container-preview__identity')?.textContent).toContain('CONT-NEW') + resolveFirst(preview) + await flushPromises() + expect(document.body.querySelector('.container-preview__identity')?.textContent).toContain('CONT-NEW') + }) + + it('reuses the same idempotency key when an uncertain confirmation is retried', async () => { + vi.mocked(convertLastMileContainerToInboundPlans) + .mockRejectedValueOnce(new Error('network uncertain')) + .mockResolvedValueOnce([{ planNo: 'LIP-RETRY', pallets: [{ id: 'PALLET-R', palletNo: 'PLT-RETRY' }] }] as never) + const wrapper = await openPending() + await openContainerDialog(wrapper) + await chooseContainer(wrapper) + const findConfirm = () => [...document.body.querySelectorAll('button')] + .find((item) => item.textContent?.includes('确认生成')) as HTMLButtonElement + findConfirm().click() + await flushPromises() + findConfirm().click() + await flushPromises() + expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledTimes(2) + expect(vi.mocked(convertLastMileContainerToInboundPlans).mock.calls[0][1]).toBe( + vi.mocked(convertLastMileContainerToInboundPlans).mock.calls[1][1], + ) + expect(document.body.textContent).toContain('PLT-RETRY') + }) + it('renders all cabinets as an arrival-ordered workbench with the remaining inbound-code count', async () => { const nullArrival = { ...summary, sourceContainerBookingId: '2092873463568597002', containerNo: 'CONT-NULL', plannedArrivalAt: null, planCount: 0, pendingPlanCount: 15 } vi.mocked(listContainerPlanProgress).mockResolvedValueOnce({ records: [summary, nullArrival], page: 1, size: 10, total: 2, pages: 1 }) @@ -369,12 +450,16 @@ describe('LastMileOrdersView pending order and container conversion', () => { await flushPromises() await button(wrapper, '生成入库计划')!.trigger('click') await flushPromises() - expect(document.body.textContent).toContain('确认后将为该柜剩余 5 个入仓码分别生成独立入库计划') - const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认按柜生成')) as HTMLButtonElement + expect(document.body.textContent).toContain('预览已就绪,本次将新增 5 个计划、5 个托盘码') + const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认生成')) as HTMLButtonElement expect(confirm.disabled).toBe(false) confirm.click() await flushPromises() - expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledWith({ sourceContainerBookingId: bookingId }) + expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledWith({ + sourceContainerBookingId: bookingId, + snapshotVersion: 9, + snapshotHash: 'a'.repeat(64), + }, expect.stringContaining('container-inbound-plan-')) expect(getContainerPlanProgress).not.toHaveBeenCalled() }) diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue index 2c4bcadc..a7e6581f 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue @@ -1,5 +1,5 @@