From 9de72cbc5c28d6ee58220549e73c52971a646a2f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 26 Oct 2026 15:46:14 +0800 Subject: [PATCH 1/5] fix(admin): add inbound shortage confirmation action --- .../overseas-warehouse/PROTOTYPE_MATRIX.md | 1 + .../api/inboundPlans.test.ts | 10 ++- .../overseas-warehouse/api/inboundPlans.ts | 16 +++++ .../InboundShortageConfirmation.test.ts | 57 ++++++++++++++++ .../InboundShortageConfirmation.vue | 68 +++++++++++++++++++ .../lastMilePresentation.test.ts | 1 + .../lastMilePresentation.ts | 1 + .../InboundOrderDetailView.shortage.test.ts | 25 +++++++ .../pages/InboundOrderDetailView.vue | 3 +- .../pages/InboundOrdersView.vue | 2 +- 10 files changed, 180 insertions(+), 4 deletions(-) create mode 100644 src/modules/overseas-warehouse/components/InboundShortageConfirmation.test.ts create mode 100644 src/modules/overseas-warehouse/components/InboundShortageConfirmation.vue create mode 100644 src/modules/overseas-warehouse/pages/InboundOrderDetailView.shortage.test.ts diff --git a/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md b/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md index f2fc6cef..a442fa83 100644 --- a/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md +++ b/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md @@ -317,6 +317,7 @@ | 尾程订单 | 出库计划 | `modal-lmo-outbound-plan`,927 行 | 居中弹框,约 760px | 无正式实现 | 明确属于库存离仓,排除,不实现 | | 入库单 | 列表/筛选 | `page-fba-inbound-order`,491–525 行;`fbioSearch()`、`fbioReset()` | 独立列表页,查询区、批量预约/排车、横向宽表 | `/prototype/fba-returns/inbound-orders` 占位 | 已接真实分页、显式客户、筛选、状态、加载/错误/空态;排车字段后端未提供,保持只读空值 | | 入库单 | 详情 | `page-fba-inbound-order-detail`,684–691 行;`openFbaInboundOrderDetail()` 8567–8575 行 | 独立详情页;单据信息、仓储信息、预约与排车三组字段,应收费用明细位于正文 | 正式参数路由与 `InboundOrderDetailView.vue` | 已接计划详情和 `sourceOrderId`;接口未提供的预约/排车时间、备注和费用继续显示空值 | +| 入库单 | 确认短少入库 | 用户确认实际短少后的处理要求;Admin规范“简单确认、原因填写” | 详情托盘明细标题区操作,560px居中确认弹框;应收、实收和短少只读,原因必填 | `InboundShortageConfirmation.vue`;仅待差异核对的FBA短少和入库编辑权限可用 | 定向组件/API/页面测试与类型检查通过;1920×1080、100%待人工截图确认 | | 入库单 | 编辑 | `modal-fbio-edit`,527 行 | 居中弹框,约 880px | 无正式实现 | 待海外区域写接口与状态机冻结后实现 | | 入库单 | 预约 | `modal-fbio-appointment`,526 行 | 居中弹框,约 720px | 无正式实现 | 待预约/排车接口与幂等契约冻结后实现 | | 入库单 | 新增 | 页面按钮存在但原型未定义对应可达表单 | 原型未定义 | 无正式实现 | 标记待确认,不自行选弹框/抽屉/独立页 | diff --git a/src/modules/overseas-warehouse/api/inboundPlans.test.ts b/src/modules/overseas-warehouse/api/inboundPlans.test.ts index 407687ae..aafc6452 100644 --- a/src/modules/overseas-warehouse/api/inboundPlans.test.ts +++ b/src/modules/overseas-warehouse/api/inboundPlans.test.ts @@ -1,10 +1,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/shared/api/http' -import { getInboundPlan, listInboundPlans } from './inboundPlans' +import { confirmInboundShortage, getInboundPlan, listInboundPlans } from './inboundPlans' -vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal()), http: { get: vi.fn() } })) +vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal()), http: { get: vi.fn(), post: vi.fn() } })) describe('inbound plan overseas API', () => { beforeEach(() => vi.mocked(http.get).mockReset()) + it('confirms the current shortage with customer, version and idempotency key', async () => { + vi.mocked(http.post).mockResolvedValue({ code: '200', data: { status: 'PARTIAL_INBOUND' } }) + const payload = { customerId: '190000000000000001', version: 4, expectedPackageCount: 21, receivedPackageCount: 20, reason: '短少1箱' } + await confirmInboundShortage('IP/1', payload, 'request-key') + expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/IP%2F1/confirm-shortage', payload, { headers: { 'Idempotency-Key': 'request-key' } }) + }) it('pages plans with explicit customer scope', async () => { vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'ok', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } }) await listInboundPlans({ customerId: '190000000000000001', page: 1, size: 20, inboundCode: 'IN-1' }) diff --git a/src/modules/overseas-warehouse/api/inboundPlans.ts b/src/modules/overseas-warehouse/api/inboundPlans.ts index e31767d2..aa9ab80b 100644 --- a/src/modules/overseas-warehouse/api/inboundPlans.ts +++ b/src/modules/overseas-warehouse/api/inboundPlans.ts @@ -45,6 +45,7 @@ export interface InboundPlanSummaryDto { } export interface InboundPlanDetailDto { + version?: number id: string planNo: string customerId: string @@ -81,6 +82,21 @@ export async function getInboundPlan(planNo: string, customerId: string) { return unwrapApiResponse(await http.get, ApiEnvelope>(`/platform/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}`, { params: { customerId } })) } +export interface ConfirmInboundShortagePayload { + customerId: string + version: number + expectedPackageCount: number + receivedPackageCount: number + reason: string +} + +export async function confirmInboundShortage(planNo: string, payload: ConfirmInboundShortagePayload, idempotencyKey: string) { + return unwrapApiResponse(await http.post, ApiEnvelope<{ status: string }>>( + `/platform/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/confirm-shortage`, payload, + { headers: { 'Idempotency-Key': idempotencyKey } }, + )) +} + export interface PlatformInventoryMoveRequest { overseasWarehouseId: string sourcePalletCode: string diff --git a/src/modules/overseas-warehouse/components/InboundShortageConfirmation.test.ts b/src/modules/overseas-warehouse/components/InboundShortageConfirmation.test.ts new file mode 100644 index 00000000..b4af8b14 --- /dev/null +++ b/src/modules/overseas-warehouse/components/InboundShortageConfirmation.test.ts @@ -0,0 +1,57 @@ +import ElementPlus from 'element-plus' +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { confirmInboundShortage, type InboundPlanDetailDto } from '../api/inboundPlans' +import InboundShortageConfirmation from './InboundShortageConfirmation.vue' + +const access = vi.hoisted(() => ({ can: vi.fn(() => true) })) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => access })) +vi.mock('../api/inboundPlans', () => ({ confirmInboundShortage: vi.fn() })) +const dialog = { name: 'AppDialog', props: ['modelValue', 'confirmDisabled', 'confirmLoading'], emits: ['confirm'], template: '
' } +function detail(overrides: Partial = {}): InboundPlanDetailDto { + return { id: '1', planNo: 'IP-1', customerId: '9007199254741001', sourceOrderId: '2', version: 4, + orderType: 'FIRST_LEG', status: 'PENDING_RECONCILIATION', expectedPackageCount: 21, pickedPackageCount: 20, + lineCount: 1, palletCount: 2, totalGrossWeightKg: 0, totalNetWeightKg: 0, totalVolumeCbm: 0, + lines: [{ id: '3', orderId: '2', fbaWarehouseCode: 'ONT8', requiredPackageCount: 21, plannedPalletCount: 2 }], + pallets: [], allocations: [], ...overrides } +} +function render(value = detail()) { + return mount(InboundShortageConfirmation, { props: { detail: value }, global: { plugins: [ElementPlus], stubs: { AppDialog: dialog } } }) +} +describe('入库短少确认', () => { + beforeEach(() => { access.can.mockReturnValue(true); vi.mocked(confirmInboundShortage).mockReset().mockResolvedValue({ status: 'PARTIAL_INBOUND' }) }) + it('requires a reason and submits actual counts without overwriting the expected count', async () => { + const wrapper = render() + await wrapper.find('button').trigger('click') + expect(wrapper.text()).toContain('21') + expect(wrapper.text()).toContain('20') + expect(wrapper.get('[data-confirm]').attributes('disabled')).toBeDefined() + await wrapper.get('textarea').setValue('现场清点短少1箱') + await wrapper.get('[data-confirm]').trigger('click') + await flushPromises() + expect(confirmInboundShortage).toHaveBeenCalledWith('IP-1', { customerId: '9007199254741001', version: 4, expectedPackageCount: 21, receivedPackageCount: 20, reason: '现场清点短少1箱' }, expect.any(String)) + expect(wrapper.emitted('refresh')).toHaveLength(1) + expect(wrapper.find('textarea').exists()).toBe(false) + wrapper.unmount() + }) + it('preserves the reason on failure and uses fresh facts and a new key for retry', async () => { + vi.mocked(confirmInboundShortage).mockRejectedValueOnce(new Error('记录已变化')) + const wrapper = render() + await wrapper.find('button').trigger('click'); await wrapper.get('textarea').setValue('短少1箱') + await wrapper.get('[data-confirm]').trigger('click'); await flushPromises() + expect((wrapper.get('textarea').element as HTMLTextAreaElement).value).toBe('短少1箱') + await wrapper.setProps({ detail: detail({ version: 5 }) }) + await wrapper.get('[data-confirm]').trigger('click'); await flushPromises() + const calls = vi.mocked(confirmInboundShortage).mock.calls + expect(calls[1]?.[1].version).toBe(5) + expect(calls[1]?.[2]).not.toBe(calls[0]?.[2]) + wrapper.unmount() + }) + it('hides the action without edit permission', () => { + access.can.mockReturnValue(false) + const wrapper = render(); expect(wrapper.find('button').exists()).toBe(false); wrapper.unmount() + }) + it.each([{ status: 'PARTIAL_INBOUND' }, { pickedPackageCount: 21 }, { pickedPackageCount: 22 }, { pickedPackageCount: 0 }, { version: undefined }, { orderType: 'ONE_CLICK_DROPSHIP' }])('rejects ineligible facts %o', (overrides) => { + const wrapper = render(detail(overrides)); expect(wrapper.find('button').exists()).toBe(false); wrapper.unmount() + }) +}) diff --git a/src/modules/overseas-warehouse/components/InboundShortageConfirmation.vue b/src/modules/overseas-warehouse/components/InboundShortageConfirmation.vue new file mode 100644 index 00000000..3bf237c9 --- /dev/null +++ b/src/modules/overseas-warehouse/components/InboundShortageConfirmation.vue @@ -0,0 +1,68 @@ + + + diff --git a/src/modules/overseas-warehouse/lastMilePresentation.test.ts b/src/modules/overseas-warehouse/lastMilePresentation.test.ts index 97f8d09f..b913af9d 100644 --- a/src/modules/overseas-warehouse/lastMilePresentation.test.ts +++ b/src/modules/overseas-warehouse/lastMilePresentation.test.ts @@ -129,6 +129,7 @@ describe('last-mile order presentation adapter', () => { ['SIGNED', '已签收'], ['PICKING', '拣货中'], ['PARTIAL_INBOUND', '部分入库'], + ['PENDING_RECONCILIATION', '待差异核对'], ['COMPLETED', '全部入库'], ])('maps inbound plan status %s to %s', (status, expected) => { expect(inboundPlanStatusLabel(status)).toBe(expected) diff --git a/src/modules/overseas-warehouse/lastMilePresentation.ts b/src/modules/overseas-warehouse/lastMilePresentation.ts index 55af9b4c..0191d68c 100644 --- a/src/modules/overseas-warehouse/lastMilePresentation.ts +++ b/src/modules/overseas-warehouse/lastMilePresentation.ts @@ -46,6 +46,7 @@ const inboundPlanStatusLabels: Record = { PDA_PENDING: '待入库', SIGNED: '已签收', PICKING: '拣货中', + PENDING_RECONCILIATION: '待差异核对', PARTIAL_INBOUND: '部分入库', COMPLETED: '全部入库', } diff --git a/src/modules/overseas-warehouse/pages/InboundOrderDetailView.shortage.test.ts b/src/modules/overseas-warehouse/pages/InboundOrderDetailView.shortage.test.ts new file mode 100644 index 00000000..2e141647 --- /dev/null +++ b/src/modules/overseas-warehouse/pages/InboundOrderDetailView.shortage.test.ts @@ -0,0 +1,25 @@ +import ElementPlus from 'element-plus' +import { flushPromises, mount } from '@vue/test-utils' +import { expect, it, vi } from 'vitest' +import { getInboundPlan } from '../api/inboundPlans' +import InboundOrderDetailView from './InboundOrderDetailView.vue' + +vi.mock('vue-router', () => ({ useRoute: () => ({ params: { orderId: 'IP-1' }, query: { customerId: '9007199254741001' } }), useRouter: () => ({ push: vi.fn() }) })) +vi.mock('../api/inboundPlans', () => ({ getInboundPlan: vi.fn(), moveInboundPallet: vi.fn() })) +vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null })) +vi.mock('../components/InboundShortageConfirmation.vue', () => ({ default: { name: 'InboundShortageConfirmation', props: ['detail'], emits: ['refresh'], template: '' } })) + +it('provides the scoped plan facts and reloads after shortage confirmation', async () => { + const detail = { id: '1', planNo: 'IP-1', customerId: '9007199254741001', sourceOrderId: '2', version: 4, + status: 'PENDING_RECONCILIATION', expectedPackageCount: 21, pickedPackageCount: 20, + lineCount: 0, palletCount: 0, totalGrossWeightKg: 0, totalNetWeightKg: 0, totalVolumeCbm: 0, lines: [], pallets: [], allocations: [] } + vi.mocked(getInboundPlan).mockResolvedValue(detail) + const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus], stubs: { DataTable: true, AppDialog: true } } }) + await flushPromises() + expect(wrapper.findComponent({ name: 'InboundShortageConfirmation' }).props('detail')).toEqual(detail) + expect(wrapper.text()).toContain('待差异核对') + await wrapper.get('[data-shortage]').trigger('click'); await flushPromises() + expect(getInboundPlan).toHaveBeenCalledTimes(2) + expect(getInboundPlan).toHaveBeenLastCalledWith('IP-1', '9007199254741001') + wrapper.unmount() +}) diff --git a/src/modules/overseas-warehouse/pages/InboundOrderDetailView.vue b/src/modules/overseas-warehouse/pages/InboundOrderDetailView.vue index 1e82212c..8dc3e62c 100644 --- a/src/modules/overseas-warehouse/pages/InboundOrderDetailView.vue +++ b/src/modules/overseas-warehouse/pages/InboundOrderDetailView.vue @@ -14,6 +14,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue' import { getInboundPlan, moveInboundPallet, type InboundPlanDetailDto } from '../api/inboundPlans' import { listOverseasLocations, type OverseasLocationDto } from '../api/locations' import OverseasDetailShell from '../components/OverseasDetailShell.vue' +import InboundShortageConfirmation from '../components/InboundShortageConfirmation.vue' import { formatOverseasDateTime, inboundPlanStatusLabel, lastMileOrderTypeLabel } from '../lastMilePresentation' const route = useRoute(); const router = useRouter() @@ -98,7 +99,7 @@ onMounted(load) diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts index 60524e88..02557bde 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts @@ -157,10 +157,10 @@ async function mountView() { return wrapper } -async function openPending() { +async function openAfterDeparture() { const wrapper = await mountView() - const pendingTab = wrapper.findAll('[role="tab"]').find((item) => item.text() === '开船前') - await pendingTab!.trigger('click') + const departedTab = wrapper.findAll('[role="tab"]').find((item) => item.text() === '开船后') + await departedTab!.trigger('click') await flushPromises() return wrapper } @@ -281,9 +281,13 @@ describe('LastMileOrdersView pending order and container conversion', () => { await flushPromises() expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ orderType: 'FIRST_LEG', sailingStage: stage, detained: false })) expect(toolbar.props('availableFields')?.some((field) => field.key === 'orderType')).toBe(false) - await button(wrapper, '导出订单')!.trigger('click') - await flushPromises() - expect(submitExport).toHaveBeenLastCalledWith('LAST_MILE_ORDER_LIST', '尾程订单', vi.mocked(listLastMileOrders).mock.lastCall![0]) + if (stage === 'BEFORE_DEPARTURE') { + expect(button(wrapper, '导出订单')).toBeUndefined() + } else { + await button(wrapper, '导出订单')!.trigger('click') + await flushPromises() + expect(submitExport).toHaveBeenLastCalledWith('LAST_MILE_ORDER_LIST', '尾程订单', vi.mocked(listLastMileOrders).mock.lastCall![0]) + } } await wrapper.findAll('[role="tab"]').find((tab) => tab.text() === '全部')!.trigger('click') await flushPromises() @@ -332,6 +336,18 @@ describe('LastMileOrdersView pending order and container conversion', () => { expect(button(wrapper, '高级筛选')).toBeUndefined() }) + it.each([null, 'ESTIMATED_DEPARTURE'])('keeps a first-leg order without actual departure read-only: %s', async containerNode => { + vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ id: 'before-1', customerId, orderNo: 'LM-BEFORE', currentStatus: 'PENDING_ASSIGNMENT', orderType: 'FIRST_LEG', containerNode }], page: 1, size: 10, total: 1, pages: 1 }) + const wrapper = await mountView() + const actions = wrapper.get('.el-table__body-wrapper .row-actions') + expect(actions.findAll('button').map(button => button.text().trim())).toEqual(['查看']) + await selectRows(wrapper, [(wrapper.findComponent(DataTable).props('data') as object[])[0]]) + expect((wrapper.vm as unknown as { selectedRows: object[] }).selectedRows).toEqual([expect.objectContaining({ orderTypeCode: 'FIRST_LEG', containerNode: containerNode || '—' })]) + expect(wrapper.find('.data-table__business-actions').exists()).toBe(false) + expect(createManualInboundPlan).not.toHaveBeenCalled() + expect(transferLastMileOrders).not.toHaveBeenCalled() + }) + it('keeps the original carton count and adds only the signed difference in the list', async () => { canAccess.mockReturnValue(false) vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ @@ -354,7 +370,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { [['inbound-order:create', 'last-mile-order:edit'], ['查看', '入库', '修改节点'], false], ])('only collapses row actions above three for %j', async (allowed, labels, overflow) => { canAccess.mockImplementation(code => allowed.some(action => code === 'platform:overseas-warehouse:' + action)) - vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ id: 'order-1', customerId, orderNo: 'LM-1', currentStatus: 'PENDING_ASSIGNMENT', orderType: 'FIRST_LEG' }], page: 1, size: 10, total: 1, pages: 1 }) + vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ id: 'order-1', customerId, orderNo: 'LM-1', currentStatus: 'PENDING_ASSIGNMENT', orderType: 'FIRST_LEG', containerNode: 'ACTUAL_DEPARTURE' }], page: 1, size: 10, total: 1, pages: 1 }) const wrapper = await mountView() const actions = wrapper.get('.el-table__body-wrapper .row-actions') expect(actions.findAll('button').map(button => button.text().trim())).toEqual(labels) @@ -480,7 +496,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { ])('only shows a new hold after inbound completion for %s', async (currentStatus, visible) => { canAccess.mockImplementation(code => code === 'platform:overseas-warehouse:last-mile-order:edit') vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ - id: 'order-1', customerId, orderNo: 'LM-1', currentStatus, orderType: 'FIRST_LEG', detained: false, + id: 'order-1', customerId, orderNo: 'LM-1', currentStatus, orderType: 'FIRST_LEG', containerNode: 'ACTUAL_DEPARTURE', detained: false, }], page: 1, size: 10, total: 1, pages: 1 }) const wrapper = await mountView() @@ -493,7 +509,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { it('keeps release hold available for an already detained order', async () => { canAccess.mockImplementation(code => code === 'platform:overseas-warehouse:last-mile-order:edit') vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ - id: 'order-held', customerId, orderNo: 'LM-HELD', currentStatus: 'READY_FOR_DISPATCH', orderType: 'FIRST_LEG', detained: true, + id: 'order-held', customerId, orderNo: 'LM-HELD', currentStatus: 'READY_FOR_DISPATCH', orderType: 'FIRST_LEG', containerNode: 'ACTUAL_DEPARTURE', detained: true, }], page: 1, size: 10, total: 1, pages: 1 }) const wrapper = await mountView() @@ -506,7 +522,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { it('removes generation and hold actions after an inbound plan has been submitted', async () => { canAccess.mockReturnValue(true) vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ - id: 'order-planned', customerId, orderNo: 'LM-PLANNED', currentStatus: 'INBOUND_PLANNED', orderType: 'FIRST_LEG', detained: false, + id: 'order-planned', customerId, orderNo: 'LM-PLANNED', currentStatus: 'INBOUND_PLANNED', orderType: 'FIRST_LEG', containerNode: 'ACTUAL_DEPARTURE', detained: false, }], page: 1, size: 10, total: 1, pages: 1 }) const wrapper = await mountView() @@ -519,8 +535,8 @@ describe('LastMileOrdersView pending order and container conversion', () => { expect((button(wrapper, '入库')!.element as HTMLButtonElement).disabled).toBe(true) }) - it('keeps the original pending order table and does not render a view switch or container table', async () => { - const wrapper = await openPending() + it('keeps after-departure order actions in the existing table without a container view switch', async () => { + const wrapper = await openAfterDeparture() expect(wrapper.text()).toContain('尾程单号') expect(wrapper.text()).toContain('海外仓') expect(wrapper.text()).toContain('转仓') @@ -529,7 +545,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { expect(wrapper.text()).not.toContain('按订单') expect(wrapper.text()).not.toContain('待分配整柜') expect(wrapper.text()).not.toContain('柜子工作台') - expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, size: 10, currentStatus: undefined, orderType: 'FIRST_LEG', sailingStage: 'BEFORE_DEPARTURE' })) + expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, size: 10, currentStatus: undefined, orderType: 'FIRST_LEG', sailingStage: 'AFTER_DEPARTURE' })) expect(listContainerPlanProgress).not.toHaveBeenCalled() }) @@ -586,7 +602,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { }) it('opens the required single-order inbound-plan form without invoking the cabinet conversion', async () => { - const wrapper = await openPending() + const wrapper = await openAfterDeparture() await selectRows(wrapper, [selectedRow()]) await button(wrapper, '入库')!.trigger('click') await flushPromises() @@ -606,7 +622,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { }) it('shows cabinet-frozen facts but never sends arrival time or per-order warehouse overrides', async () => { - const wrapper = await openPending() + const wrapper = await openAfterDeparture() await selectRows(wrapper, [selectedRow()]) await button(wrapper, '入库')!.trigger('click') await flushPromises() @@ -628,7 +644,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { }) it('blocks a held order from generating its inbound plan', async () => { - const wrapper = await openPending() + const wrapper = await openAfterDeparture() await selectRows(wrapper, [selectedRow({ detained: true, orderTypeCode: 'ONE_CLICK_DROPSHIP', @@ -649,7 +665,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { [{ ...arrivalTime, estimatedWarehouseArrivalAt: null, version: 0, planCount: 1, plannedDate: '2026-11-02' }, '2026-11-02'], ])('reads the latest cabinet arrival on opening and permits planning with %s', async (arrival, expectedText) => { vi.mocked(listContainerArrivalTimes).mockResolvedValue([arrival]) - const wrapper = await openPending() + const wrapper = await openAfterDeparture() await openArrivalPlan(wrapper, arrival.estimatedWarehouseArrivalAt ? selectedRow() : selectedRow({ estimatedWarehouseAt: '', warehouseEta: '—' })) expect(listContainerArrivalTimes).toHaveBeenCalledWith([bookingId]) @@ -670,7 +686,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { { arrivals: [{ ...arrivalTime, version: -1 }] }, ])('blocks manual planning when the authoritative cabinet arrival is missing or inconsistent: $arrivals', async ({ arrivals }) => { vi.mocked(listContainerArrivalTimes).mockResolvedValue(arrivals) - const wrapper = await openPending() + const wrapper = await openAfterDeparture() await openArrivalPlan(wrapper) const confirm = [...document.body.querySelectorAll('button')] @@ -681,7 +697,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { it('shows arrival read failures and does not reuse the list timestamp', async () => { vi.mocked(listContainerArrivalTimes).mockRejectedValue(new ApiResponseError('到仓来源暂不可读', 'A0503', 503)) - const wrapper = await openPending() + const wrapper = await openAfterDeparture() await openArrivalPlan(wrapper) const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('入库计划 ·'))! expect(dialog.text()).toContain('到仓来源暂不可读') @@ -694,7 +710,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { it('keeps confirmation blocked during arrival loading and ignores an older opening of the same order', async () => { let resolveOld!: (value: typeof arrivalTime[]) => void vi.mocked(listContainerArrivalTimes).mockReturnValueOnce(new Promise(resolve => { resolveOld = resolve })) - const wrapper = await openPending() + const wrapper = await openAfterDeparture() await openArrivalPlan(wrapper) const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('入库计划 ·'))! expect(dialog.props('confirmDisabled')).toBe(true) @@ -723,7 +739,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { }], }], } as never) - const wrapper = await openPending() + const wrapper = await openAfterDeparture() await selectRows(wrapper, [selectedRow({ orderTypeCode: 'ONE_CLICK_DROPSHIP', pallets: 2 })]) await button(wrapper, '入库')!.trigger('click') await flushPromises() @@ -741,7 +757,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { }) it('opens a cabinet selector without requiring order selection and loads server summaries only then', async () => { - const wrapper = await openPending() + const wrapper = await openAfterDeparture() await openContainerDialog(wrapper) expect(listContainerPlanProgress).toHaveBeenCalledTimes(1) expect(listContainerPlanProgress).toHaveBeenCalledWith({ page: 1, size: 50, containerNo: undefined }) @@ -754,7 +770,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { }) it('selects one cabinet and sends one long-id convert-container request without using current-page row counts', async () => { - const wrapper = await openPending() + const wrapper = await openAfterDeparture() await openContainerDialog(wrapper) await chooseContainer(wrapper) const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认生成')) as HTMLButtonElement @@ -780,7 +796,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { vi.mocked(convertLastMileContainerToInboundPlans).mockRejectedValueOnce(new ApiResponseError( '柜级生成失败', code, 200, undefined, `request-${code}`, `trace-${code}`, )) - const wrapper = await openPending() + const wrapper = await openAfterDeparture() await openContainerDialog(wrapper) await chooseContainer(wrapper) const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认生成')) as HTMLButtonElement @@ -799,7 +815,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { if (id === bookingId) resolveFirst = resolve else resolveSecond = resolve }) as never) - const wrapper = await openPending() + const wrapper = await openAfterDeparture() await openContainerDialog(wrapper) const selects = wrapper.findAllComponents({ name: 'ElSelect' }) const containerSelect = selects[selects.length - 1] @@ -821,7 +837,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { 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() + const wrapper = await openAfterDeparture() await openContainerDialog(wrapper) await chooseContainer(wrapper) const findConfirm = () => [...document.body.querySelectorAll('button')] @@ -904,7 +920,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { .mockResolvedValueOnce(resourceCommittedPreview) .mockResolvedValue({ ...resourceCommittedPreview, resourceRetry: undefined }) vi.mocked(convertLastMileContainerToInboundPlans).mockResolvedValue(originalResourcePlans as never) - const wrapper = await openPending() + const wrapper = await openAfterDeparture() await openContainerDialog(wrapper); await chooseContainer(wrapper) cabinetDialog(wrapper).vm.$emit('update:modelValue', false); await flushPromises() await openContainerDialog(wrapper); await chooseContainer(wrapper) @@ -928,7 +944,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { it('keeps ordinary zero-pending previews without resourceRetry disabled in both UI and handler', async () => { vi.mocked(getContainerInboundPlanPreview).mockResolvedValue({ ...resourceCommittedPreview, resourceRetry: undefined }) - const wrapper = await openPending(); await openContainerDialog(wrapper); await chooseContainer(wrapper) + const wrapper = await openAfterDeparture(); await openContainerDialog(wrapper); await chooseContainer(wrapper) expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(true) expect(cabinetDialog(wrapper).props('confirmText')).toBe('确认生成') cabinetDialog(wrapper).vm.$emit('confirm'); await flushPromises() @@ -937,7 +953,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { it('uses the visible missing-container label in the preview tooltip without exposing the booking ID', async () => { vi.mocked(getContainerInboundPlanPreview).mockResolvedValue({ ...preview, containerNo: null } as never) - const wrapper = await openPending(); await openContainerDialog(wrapper); await chooseContainer(wrapper) + const wrapper = await openAfterDeparture(); await openContainerDialog(wrapper); await chooseContainer(wrapper) const identity = document.body.querySelector('.container-preview__identity')! expect(identity.textContent).toBe('未录入货柜号') expect(identity.getAttribute('title')).toBe('未录入货柜号') @@ -946,7 +962,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { it('keeps server readiness blocks effective even for a resource retry', async () => { vi.mocked(getContainerInboundPlanPreview).mockResolvedValue({ ...resourceCommittedPreview, ready: false, blockedOrderCount: 1 }) - const wrapper = await openPending(); await openContainerDialog(wrapper); await chooseContainer(wrapper) + const wrapper = await openAfterDeparture(); await openContainerDialog(wrapper); await chooseContainer(wrapper) expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(true) cabinetDialog(wrapper).vm.$emit('confirm'); await flushPromises() expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled() @@ -955,7 +971,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { it('guards resource retry at the handler when the caller lacks create permission', async () => { canAccess.mockImplementation(code => code !== 'platform:overseas-warehouse:inbound-order:create') vi.mocked(getContainerInboundPlanPreview).mockResolvedValue(resourceCommittedPreview) - const wrapper = await openPending() + const wrapper = await openAfterDeparture() const view = wrapper.vm as unknown as { openContainerConversion(): void } view.openContainerConversion(); await flushPromises(); await chooseContainer(wrapper) expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(true) @@ -968,7 +984,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { let resolveOptions!: (value: Awaited>) => void vi.mocked(getContainerInboundPlanPreview).mockReturnValue(new Promise(resolve => { resolvePreview = resolve })) vi.mocked(listContainerPlanProgress).mockReturnValue(new Promise(resolve => { resolveOptions = resolve })) - const wrapper = await openPending(); await openContainerDialog(wrapper); await chooseContainer(wrapper) + const wrapper = await openAfterDeparture(); await openContainerDialog(wrapper); await chooseContainer(wrapper) expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(true) cabinetDialog(wrapper).vm.$emit('confirm'); await flushPromises() expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled() @@ -984,7 +1000,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { const success = vi.spyOn(ElMessage, 'success').mockImplementation(() => ({ close: vi.fn() } as never)) vi.mocked(getContainerInboundPlanPreview).mockResolvedValue(resourceCommittedPreview) vi.mocked(convertLastMileContainerToInboundPlans).mockRejectedValueOnce(new ApiResponseError('资源确认失败', 'S0503', 503)) - const wrapper = await openPending(); await openContainerDialog(wrapper); await chooseContainer(wrapper) + const wrapper = await openAfterDeparture(); await openContainerDialog(wrapper); await chooseContainer(wrapper) cabinetDialog(wrapper).vm.$emit('confirm'); await flushPromises() expect(cabinetDialog(wrapper).props('modelValue')).toBe(true) expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(false) @@ -1206,7 +1222,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { }) it('loads only active FBA master data and submits exact string fields for a cross-customer transfer', async () => { - const wrapper = await openPending() + const wrapper = await openAfterDeparture() await selectRows(wrapper, [selectedRow(), selectedRow({ id: '2092873463568596995', customerId: '2092618717861048322' })]) await button(wrapper, '转仓')!.trigger('click') await flushPromises() @@ -1230,7 +1246,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { }) it('clears all selected warehouse facts when the FBA candidate is cleared', async () => { - const wrapper = await openPending() + const wrapper = await openAfterDeparture() await selectRows(wrapper, [selectedRow()]) await button(wrapper, '转仓')!.trigger('click') await flushPromises() @@ -1246,7 +1262,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { it('renders an explicit empty state when there are no active FBA candidates', async () => { vi.mocked(listAllPlatformWarehouses).mockResolvedValueOnce([]) - const wrapper = await openPending() + const wrapper = await openAfterDeparture() await selectRows(wrapper, [selectedRow()]) await button(wrapper, '转仓')!.trigger('click') await flushPromises() @@ -1258,7 +1274,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { vi.mocked(listAllPlatformWarehouses).mockRejectedValueOnce(new ApiResponseError( 'FBA candidate failed', 'A0503', 200, undefined, 'request-fba', 'trace-fba', )) - const wrapper = await openPending() + const wrapper = await openAfterDeparture() await selectRows(wrapper, [selectedRow()]) await button(wrapper, '转仓')!.trigger('click') await flushPromises() @@ -1328,21 +1344,22 @@ describe('LastMileOrdersView pending order and container conversion', () => { await dialog.findAll('button').find(item => item.text() === '保存')!.trigger('click'); await flushPromises() expect(updateLastMileOrderHold).toHaveBeenCalledWith(row.id, { held: !detained, chargeable: false, reason: '原因', expectedVersion: 0 }, expect.stringContaining('last-mile-hold-')) }) - it('renders all eight node times and submits the complete set with zero version', async () => { + it('edits only customs nodes while preserving hidden node facts and zero version', async () => { + vi.mocked(getLastMileOrderManualNodes).mockResolvedValue({ orderId: '2092873463568596994', manualOverrideEnabled: true, nodes: [{ nodeCode: 'ACTUAL_DEPARTURE', nodeAt: '2026-10-19T08:00:00' }], version: 0 }) const wrapper = await mountView() const row = selectedRow() await (wrapper.vm as unknown as { openManualNodes(row: object): Promise }).openManualNodes(row) await flushPromises() const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('修改节点 ·'))! - expect(dialog.findAll('.manual-nodes-editor tbody tr')).toHaveLength(8) + expect(dialog.findAll('.manual-nodes-editor tbody tr').map(row => row.find('th').text())).toEqual(['国内报关完成', '国外清关完成']) const dates = dialog.findAllComponents({ name: 'ElDatePicker' }) - expect(dates).toHaveLength(8) + expect(dates).toHaveLength(2) dates[0]!.vm.$emit('update:modelValue', '2026-10-20T08:00:00') await dialog.get('button[aria-label="展开全屏"]').trigger('click') await dialog.findAll('button').find(item => item.text() === '保存')!.trigger('click'); await flushPromises() expect(updateLastMileOrderManualNodes).toHaveBeenCalledWith(row.id, 0, [ { nodeCode: 'DOMESTIC_CUSTOMS_COMPLETED', nodeAt: '2026-10-20T08:00:00' }, - { nodeCode: 'ESTIMATED_DEPARTURE', nodeAt: null }, { nodeCode: 'ACTUAL_DEPARTURE', nodeAt: null }, + { nodeCode: 'ESTIMATED_DEPARTURE', nodeAt: null }, { nodeCode: 'ACTUAL_DEPARTURE', nodeAt: '2026-10-19T08:00:00' }, { nodeCode: 'ESTIMATED_ARRIVAL', nodeAt: null }, { nodeCode: 'OVERSEAS_CUSTOMS_COMPLETED', nodeAt: null }, { nodeCode: 'ACTUAL_ARRIVAL_WAITING_UNLOAD', nodeAt: null }, { nodeCode: 'PICKUP_APPOINTED', nodeAt: null }, { nodeCode: 'UNLOADED', nodeAt: null }, ], expect.stringContaining('last-mile-manual-nodes-')) @@ -1354,7 +1371,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { groups: Array.from({ length: 13 }, (_, index) => ({ ...group, destinationWarehouseCode: 'FBA-' + index, orderCount: 1, pendingOrderCount: 1, pendingPalletCount: 1, sourcePalletCount: 1, supplementPalletCount: 0, orders: [{ ...group.orders[0]!, orderId: 'order-' + index, businessOrderNo: '业务单-' + index, sourcePalletCount: 1, targetPalletCount: 1, supplementPalletCount: 0 }] })), }) - const wrapper = await openPending() + const wrapper = await openAfterDeparture() await openContainerDialog(wrapper); await chooseContainer(wrapper) const dialog = cabinetDialog(wrapper) expect(dialog.findAll('[aria-label="目的地分组明细"]')).toHaveLength(13) @@ -1367,7 +1384,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { it('keeps transfer drafts locked during submission and editable after rejection', async () => { let reject!: (reason: unknown) => void vi.mocked(transferLastMileOrders).mockReturnValueOnce(new Promise((_, no) => { reject = no })) - const wrapper = await openPending() + const wrapper = await openAfterDeparture() await selectRows(wrapper, [selectedRow()]) await button(wrapper, '转仓')!.trigger('click'); await flushPromises() const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('转仓 ·'))! diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue index 5789eb10..8d891e50 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue @@ -143,6 +143,7 @@ const manualNodeDefinitions: Array<{ code: ManualNodeCode; label: string }> = [ { code: 'PICKUP_APPOINTED', label: '预约提柜' }, { code: 'UNLOADED', label: '卸柜' }, ] +const visibleManualNodeDefinitions = manualNodeDefinitions.filter(({ code }) => ['DOMESTIC_CUSTOMS_COMPLETED', 'OVERSEAS_CUSTOMS_COMPLETED'].includes(code)) const manualNodeValues = reactive>(Object.fromEntries(manualNodeDefinitions.map(({ code }) => [code, null])) as Record) const transferOpen = ref(false) const transferWarehouseLoading = ref(false) @@ -1321,7 +1322,6 @@ onMounted(() => { void load() })
- @@ -1333,7 +1333,7 @@ onMounted(() => { void load() })

计划设置

- + @@ -1351,8 +1351,7 @@ onMounted(() => { void load() })
{{ inboundPlanForm.palletCount }}一件代发固定按头程已封托托盘数整体入库,不可增减。按柜级预览至少 {{ inboundPlanMinimumPalletCount }} 托,可增加,不能减少。
{{ inboundPlanForm.palletCount }}一件代发固定按头程已封托托盘数整体入库,不可增减。按柜级预览至少 {{ inboundPlanMinimumPalletCount }} 托,可增加,不能减少。
仅作为现场可选提示,不强制工人合托。
- -

人工节点

节点节点时间
{{ node.label }}
+

人工节点

节点节点时间
{{ node.label }}

退回重做

@@ -1373,7 +1372,9 @@ onMounted(() => { void load() }) .inbound-plan-arrival { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); } .inbound-plan-form :deep(.el-form-item__content) { min-width: 0; } .inbound-plan-form :deep(.el-select) { width: 100%; } -.inbound-plan-pallet-note { flex-basis: 100%; margin-top: var(--admin-space-2); } +.inbound-plan-pallet-field { display: flex; flex-wrap: wrap; align-items: center; gap: var(--admin-space-2) var(--admin-space-3); min-width: 0; } +.inbound-plan-pallet-field :deep(.el-input-number) { width: 140px; flex: 0 0 140px; } +.inbound-plan-pallet-note { margin: 0; line-height: 1.5; } .manual-nodes-editor th { width: 180px; } .relabel-suborder { min-height: 30px; line-height: 30px; white-space: nowrap; } .last-mile-toolbar { min-width: 0; } diff --git a/src/modules/port-schedules/components/ContainerPortSchedulePanel.test.ts b/src/modules/port-schedules/components/ContainerPortSchedulePanel.test.ts index 83af0c7f..7e2ae58b 100644 --- a/src/modules/port-schedules/components/ContainerPortSchedulePanel.test.ts +++ b/src/modules/port-schedules/components/ContainerPortSchedulePanel.test.ts @@ -68,7 +68,7 @@ describe('original container schedule', () => { wrapper.unmount() }) it('missing original ownership blocks initialization and late read cannot populate another container', async () => { - const first = setup({ ...container, currentPortScheduleId: '', oceanBookingId: '' }); await first.vm.begin(); expect(getPortSchedule).not.toHaveBeenCalled(); expect(first.wrapper.text()).toContain('不能维护船期'); first.wrapper.unmount() + const first = setup({ ...container, currentPortScheduleId: '', oceanBookingId: '' }); await first.vm.begin(); expect(getPortSchedule).not.toHaveBeenCalled(); expect(first.wrapper.text()).not.toContain('不能维护船期'); expect(first.wrapper.find('button').attributes('disabled')).toBeDefined(); first.wrapper.unmount() let resolve!: (value: ReturnType) => void vi.mocked(getPortSchedule).mockReturnValue(new Promise(done => { resolve = done })) const { wrapper, vm } = setup(); const pending = vm.begin() diff --git a/src/modules/port-schedules/components/ContainerPortSchedulePanel.vue b/src/modules/port-schedules/components/ContainerPortSchedulePanel.vue index 21776d17..855ae172 100644 --- a/src/modules/port-schedules/components/ContainerPortSchedulePanel.vue +++ b/src/modules/port-schedules/components/ContainerPortSchedulePanel.vue @@ -105,8 +105,8 @@ onBeforeUnmount(() => { generation++ })