diff --git a/src/modules/overseas-warehouse/api/lastMileOrders.ts b/src/modules/overseas-warehouse/api/lastMileOrders.ts index f6f74abe..16bb0080 100644 --- a/src/modules/overseas-warehouse/api/lastMileOrders.ts +++ b/src/modules/overseas-warehouse/api/lastMileOrders.ts @@ -211,7 +211,7 @@ export interface ConvertContainerInboundPlanPayload { snapshotHash: string } -export type ContainerInboundPlanPreviewReadiness = 'READY' | 'PLANNED' | 'BLOCKED' +export type ContainerInboundPlanPreviewReadiness = 'READY' | 'RESOURCE_PENDING' | 'PLANNED' | 'BLOCKED' export interface ContainerInboundPlanPreviewOrderDto { customerId: string @@ -224,6 +224,7 @@ export interface ContainerInboundPlanPreviewOrderDto { destinationWarehouseId: string | null destinationWarehouseCode: string | null destinationWarehouseName: string | null + fbaWarehouseCode: string | null packageCount: number volumeCbm: number suggestedPalletCount: number | null diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts index 0ebb0f12..fbeedd1b 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts @@ -120,6 +120,16 @@ async function openPending() { return wrapper } +async function openHiddenContainerWorkbench(wrapper: Awaited>) { + const view = wrapper.vm as unknown as { + statusTab: '全部' | '待分配' | '柜子' + changeStatusTab(): void + } + view.statusTab = '柜子' + view.changeStatusTab() + await flushPromises() +} + async function selectRows(wrapper: Awaited>, rows: object[]) { wrapper.findComponent(DataTable).vm.$emit('selection-change', rows) await wrapper.vm.$nextTick() @@ -178,6 +188,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { expect(wrapper.text()).toContain('按柜批量生成') 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: 'PENDING_ASSIGNMENT' })) expect(listContainerPlanProgress).not.toHaveBeenCalled() }) @@ -208,8 +219,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { await flushPromises() expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ currentStatus: 'PENDING_ASSIGNMENT', lxCode: 'LX-SEARCH' })) - await wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))!.trigger('click') - await flushPromises() + await openHiddenContainerWorkbench(wrapper) expect(listContainerPlanProgress).toHaveBeenLastCalledWith({ page: 1, size: 10, containerNo: 'CONT-SEARCH', createdFrom: '2026-09-01T00:00:00', createdTo: '2026-09-07T23:59:59', @@ -265,7 +275,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { expect(createManualInboundPlan).toHaveBeenCalledWith(expect.objectContaining({ customerId, orderId: '2092873463568596994', expectedOrderVersion: 3, - plannedArrivalDate: '2026-09-08', palletCount: 2, + plannedArrivalDate: '2026-09-08', palletCount: 5, palletType: 'WOOD', }), expect.stringContaining('manual-inbound-plan-')) const payload = vi.mocked(createManualInboundPlan).mock.calls[0][0] as unknown as Record expect(payload).not.toHaveProperty('inboundCode') @@ -373,11 +383,9 @@ describe('LastMileOrdersView pending order and container conversion', () => { 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 }) const wrapper = await mountView() - const containerTab = wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子')) - await containerTab!.trigger('click') - await flushPromises() + await openHiddenContainerWorkbench(wrapper) expect(listContainerPlanProgress).toHaveBeenLastCalledWith({ page: 1, size: 10, containerNo: undefined, createdFrom: undefined, createdTo: undefined }) - expect(wrapper.text()).toContain('柜子工作台(2)') + expect(wrapper.text()).not.toContain('柜子工作台(2)') expect(wrapper.text()).toContain('货柜工作台') expect(wrapper.text()).toContain('按柜查看和修改海外接柜仓,并为剩余入仓码生成独立入库计划') expect(wrapper.text()).toContain('5个入仓码待生成计划') @@ -401,8 +409,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { } vi.mocked(listContainerPlanProgress).mockResolvedValueOnce({ records: [ready], page: 1, size: 10, total: 1, pages: 1 }) const wrapper = await mountView() - await wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))!.trigger('click') - await flushPromises() + await openHiddenContainerWorkbench(wrapper) expect(wrapper.text()).toContain('CONT-15') expect(wrapper.text()).toContain('0个入仓码待生成计划') expect((button(wrapper, '生成入库计划')!.element as HTMLButtonElement).disabled).toBe(true) @@ -412,8 +419,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { it('renders the all-cabinet empty state and unit', async () => { vi.mocked(listContainerPlanProgress).mockResolvedValueOnce({ records: [], page: 1, size: 10, total: 0, pages: 0 }) const wrapper = await mountView() - await wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))!.trigger('click') - await flushPromises() + await openHiddenContainerWorkbench(wrapper) expect(wrapper.text()).toContain('暂无货柜数据') expect(wrapper.text()).not.toContain('已全部生成计划的柜不再展示') }) @@ -421,9 +427,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { it('uses independent cabinet pagination and opens the complete long-id detail in a dialog', async () => { vi.mocked(listContainerPlanProgress).mockResolvedValue({ records: [summary], page: 1, size: 10, total: 21, pages: 3 }) const wrapper = await mountView() - const containerTab = wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子')) - await containerTab!.trigger('click') - await flushPromises() + await openHiddenContainerWorkbench(wrapper) const pagination = wrapper.findComponent({ name: 'ElPagination' }) pagination.vm.$emit('current-change', 2) await flushPromises() @@ -445,12 +449,10 @@ describe('LastMileOrdersView pending order and container conversion', () => { it('starts cabinet plan generation directly from the workbench card', async () => { const wrapper = await mountView() - const containerTab = wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子')) - await containerTab!.trigger('click') - await flushPromises() + await openHiddenContainerWorkbench(wrapper) await button(wrapper, '生成入库计划')!.trigger('click') await flushPromises() - expect(document.body.textContent).toContain('预览已就绪,本次将新增 5 个计划、5 个托盘码') + 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() @@ -474,8 +476,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { vi.mocked(listContainerPlanProgress).mockResolvedValueOnce({ records: [invalid], page: 1, size: 10, total: 1, pages: 1 } as never) vi.mocked(getContainerPlanProgress).mockResolvedValueOnce({ summary: invalid, orders: [] } as never) const wrapper = await mountView() - await wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))!.trigger('click') - await flushPromises() + await openHiddenContainerWorkbench(wrapper) expect(wrapper.text()).toContain('柜内接柜仓业务快照不一致') expect((button(wrapper, '生成入库计划')!.element as HTMLButtonElement).disabled).toBe(true) @@ -489,8 +490,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { it('shows the receiving warehouse and changes it from ACTIVE OVERSEAS master data with versioning', async () => { vi.mocked(listAllPlatformWarehouses).mockResolvedValueOnce([currentReceivingWarehouse, receivingWarehouse] as never) const wrapper = await mountView() - await wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))!.trigger('click') - await flushPromises() + await openHiddenContainerWorkbench(wrapper) expect(wrapper.text()).toContain('海外接柜仓') expect(wrapper.text()).toContain('LAX01 · 洛杉矶接柜仓') expect(wrapper.get('.container-workbench-card__warehouse strong').attributes('title')).toBe('LAX01 · 洛杉矶接柜仓') @@ -526,8 +526,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { let resolveCandidates!: (value: typeof receivingWarehouse[]) => void vi.mocked(listAllPlatformWarehouses).mockReturnValueOnce(new Promise((resolve) => { resolveCandidates = resolve }) as never) const wrapper = await mountView() - await wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))!.trigger('click') - await flushPromises() + await openHiddenContainerWorkbench(wrapper) await button(wrapper, '修改接柜仓')!.trigger('click') await wrapper.vm.$nextTick() const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认修改')) as HTMLButtonElement @@ -544,8 +543,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { page: 1, size: 10, total: 1, pages: 1, }) const wrapper = await mountView() - await wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))!.trigger('click') - await flushPromises() + await openHiddenContainerWorkbench(wrapper) expect(wrapper.text()).toContain('海外仓已开始拆柜收货') const changeButton = button(wrapper, '修改接柜仓')! expect(changeButton.exists()).toBe(true) @@ -557,8 +555,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { it('keeps a disabled receiving-warehouse action discoverable without edit permission', async () => { canAccess.mockImplementation((permissionCode: string) => permissionCode !== 'platform:overseas-warehouse:last-mile-order:edit') const wrapper = await mountView() - await wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))!.trigger('click') - await flushPromises() + await openHiddenContainerWorkbench(wrapper) const changeButton = button(wrapper, '修改接柜仓')! expect(changeButton.exists()).toBe(true) expect((changeButton.element as HTMLButtonElement).disabled).toBe(true) @@ -574,8 +571,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { 'version conflict', 'A0409', 200, undefined, 'request-receiving', 'trace-receiving', )) const wrapper = await mountView() - await wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))!.trigger('click') - await flushPromises() + await openHiddenContainerWorkbench(wrapper) await button(wrapper, '修改接柜仓')!.trigger('click') await flushPromises() const warehouseSelect = wrapper.findAllComponents({ name: 'ElSelect' }).find((item) => item.props('placeholder') === '请选择 ACTIVE 海外接柜仓')! @@ -593,8 +589,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { it('renders an explicit empty state when there are no ACTIVE OVERSEAS receiving warehouses', async () => { vi.mocked(listAllPlatformWarehouses).mockResolvedValueOnce([]) const wrapper = await mountView() - await wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))!.trigger('click') - await flushPromises() + await openHiddenContainerWorkbench(wrapper) await button(wrapper, '修改接柜仓')!.trigger('click') await flushPromises() expect(document.body.textContent).toContain('暂无可用的 ACTIVE 海外接柜仓') @@ -606,8 +601,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { 'receiving warehouse candidate failed', 'A0503', 200, undefined, 'request-receiving-options', 'trace-receiving-options', )) const wrapper = await mountView() - await wrapper.findAll('.el-radio-button').find((item) => item.text().startsWith('柜子'))!.trigger('click') - await flushPromises() + await openHiddenContainerWorkbench(wrapper) await button(wrapper, '修改接柜仓')!.trigger('click') await flushPromises() expect(document.body.textContent).toContain('request-receiving-options') diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue index a7e6581f..155275ba 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue @@ -45,7 +45,8 @@ const inboundPlanOpen = ref(false) const inboundPlanWarehouseLabel = computed(() => [actionTarget.value?.overseasWarehouseCode, actionTarget.value?.warehouse] .filter((value) => value && value !== '—') .join(' · ')) -const inboundPlanForm = reactive({ plannedArrivalDate: '', inboundCode: '', palletCount: 1, palletType: 'STANDARD' as 'STANDARD' | 'WOOD' | 'PAPER', assigned: false, mergeRequired: false, urgent: false, remark: '' }) +const inboundPlanMinimumPalletCount = ref(1) +const inboundPlanForm = reactive({ plannedArrivalDate: '', inboundCode: '', palletCount: 1, palletType: 'WOOD' as 'STANDARD' | 'WOOD' | 'PAPER', assigned: false, mergeRequired: false, urgent: false, remark: '' }) const holdOpen = ref(false) const holdForm = reactive({ held: true, chargeable: true, reason: '' }) const manualNodesOpen = ref(false) @@ -472,14 +473,31 @@ function openInboundPlan(source?: object) { if (!target) { ElMessage.warning('生成入库计划时请选择 1 条尾程订单'); return } if (target.detained) { ElMessage.warning('订单处于暂扣状态,解除暂扣后才能生成入库计划'); return } actionTarget.value = target - Object.assign(inboundPlanForm, { plannedArrivalDate: '', inboundCode: target.inboundCode === '—' ? '' : target.inboundCode, palletCount: typeof target.pallets === 'number' && target.pallets > 0 ? target.pallets : 1, palletType: 'STANDARD', assigned: false, mergeRequired: false, urgent: false, remark: '' }) + const volumeMinimum = typeof target.volume === 'number' && target.volume > 0 ? Math.ceil(target.volume / 2) : 1 + inboundPlanMinimumPalletCount.value = Math.max(1, volumeMinimum, + typeof target.pallets === 'number' ? target.pallets : 0) + Object.assign(inboundPlanForm, { plannedArrivalDate: '', inboundCode: target.inboundCode === '—' ? '' : target.inboundCode, palletCount: inboundPlanMinimumPalletCount.value, palletType: 'WOOD', assigned: false, mergeRequired: false, urgent: false, remark: '' }) actionError.value = null inboundPlanOpen.value = true + void refreshManualPalletMinimum(target) +} +async function refreshManualPalletMinimum(target: LastMileOrderListItem) { + if (!target.sourceContainerBookingId) return + try { + const preview = await getContainerInboundPlanPreview(target.sourceContainerBookingId) + if (actionTarget.value?.id !== target.id || !inboundPlanOpen.value) return + const order = preview.groups.flatMap((group) => group.orders).find((item) => item.orderId === target.id) + const suggested = Number(order?.suggestedPalletCount || 0) + inboundPlanMinimumPalletCount.value = Math.max(inboundPlanMinimumPalletCount.value, suggested) + inboundPlanForm.palletCount = Math.max(inboundPlanForm.palletCount, inboundPlanMinimumPalletCount.value) + } catch { + // The backend repeats the same calculation and remains authoritative on submit. + } } async function submitInboundPlan() { const row = actionTarget.value const frozenInboundCode = String(row?.inboundCode || '').trim() - if (!row || !row.overseasWarehouseId || !inboundPlanForm.plannedArrivalDate || !frozenInboundCode || frozenInboundCode === '—' || inboundPlanForm.palletCount < 1) { + if (!row || !row.overseasWarehouseId || !inboundPlanForm.plannedArrivalDate || !frozenInboundCode || frozenInboundCode === '—' || inboundPlanForm.palletCount < inboundPlanMinimumPalletCount.value) { ElMessage.warning('当前柜须已分配接柜仓,并完整填写预计到仓日期和托数') return } @@ -621,7 +639,7 @@ async function confirmContainerInboundPlans() { if (!containerConversionIdempotencyKey) containerConversionIdempotencyKey = idempotencyKey('container-inbound-plan') const bookingId = preview.sourceContainerBookingId const existingPlanNos = new Set(preview.groups.flatMap((group) => group.orders) - .filter((order) => order.readiness === 'PLANNED' && order.existingPlanNo) + .filter((order) => order.readiness !== 'READY' && order.existingPlanNo) .map((order) => String(order.existingPlanNo))) actionLoading.value = true actionError.value = null @@ -671,11 +689,11 @@ function orderTypeText(orderType: string) { } function readinessText(readiness: ContainerInboundPlanPreviewOrderDto['readiness']) { - return readiness === 'READY' ? '可生成' : readiness === 'PLANNED' ? '已有计划' : '已阻断' + return readiness === 'READY' ? '可生成' : readiness === 'RESOURCE_PENDING' ? '待补托盘' : readiness === 'PLANNED' ? '已有计划' : '已阻断' } function readinessType(readiness: ContainerInboundPlanPreviewOrderDto['readiness']) { - return readiness === 'READY' ? 'success' : readiness === 'PLANNED' ? 'info' : 'danger' + return readiness === 'READY' ? 'success' : readiness === 'RESOURCE_PENDING' ? 'warning' : readiness === 'PLANNED' ? 'info' : 'danger' } function blockerText(blockers: string[]) { @@ -687,6 +705,8 @@ function blockerText(blockers: string[]) { RECEIVING_WAREHOUSE_INCONSISTENT: '收货仓不一致', DESTINATION_NOT_READY: '目的地未就绪', CARGO_FACT_INVALID: '货物数据无效', + PLAN_ALREADY_STARTED: '已有计划已开始作业,不能再补生成托盘', + PLAN_PALLET_BINDING_INCOMPLETE: '已有计划只绑定了部分真实托盘,请联系管理员处理', DROPSHIP_PALLET_SNAPSHOT_MISSING: '缺少一件代发托盘快照', PALLET_COUNT_NOT_READY: '计划托数暂未就绪', } @@ -719,7 +739,8 @@ onMounted(() => { void load() }) 全部 待分配 - 柜子工作台({{ containerTotal }}) + + @@ -808,12 +829,12 @@ onMounted(() => { void load() })
本次新增托盘码{{ containerPreview.pendingPalletCount }}
-
本次新增计划{{ containerPreview.pendingOrderCount }}
+
本次处理订单{{ containerPreview.pendingOrderCount }}
已有计划托盘{{ containerPreview.existingPlannedPalletCount }}
阻断订单{{ containerPreview.blockedOrderCount }}
- +
生成完成新建 {{ containerConversionResult.newPlanCount }} 个 / 已有 {{ containerConversionResult.existingPlanCount }} 个
@@ -828,7 +849,7 @@ onMounted(() => { void load() })
{{ previewGroupTitle(group) }}:{{ group.pendingPalletCount }}托 - 本次 {{ group.pendingOrderCount }} 票 / 已有 {{ group.existingPlannedPalletCount }} 托 / 共 {{ group.orderCount }} 票 + 本次处理 {{ group.pendingOrderCount }} 票 / 已有 {{ group.existingPlannedPalletCount }} 托 / 共 {{ group.orderCount }} 票
@@ -888,7 +909,7 @@ onMounted(() => { void load() }) -
接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。仅作为现场可选提示,不强制工人合托。
+
接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。按柜级预览至少 {{ inboundPlanMinimumPalletCount }} 托,可增加,不能减少。仅作为现场可选提示,不强制工人合托。