Files
admin-web/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts
T

1450 lines
88 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const { submitExport } = vi.hoisted(() => ({ submitExport: vi.fn().mockResolvedValue(undefined) }))
vi.mock('@/modules/downloads', () => ({ useExportTask: () => submitExport }))
import ElementPlus, { ElMessageBox, ElMessage } from 'element-plus'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { createPinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import DataTable from '@/shared/components/DataTable.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import { listAllPlatformWarehouses } from '@/modules/warehouses'
import { listContainerArrivalTimes } from '@/modules/container-loading'
import {
changeContainerReceivingWarehouse,
createManualInboundPlan,
convertLastMileContainerToInboundPlans,
getContainerInboundPlanPreview,
getContainerPlanProgress,
listContainerPlanProgress,
listLastMileOrders,
getReturnOrderStatistics,
reviewCargoDiscard,
transferLastMileOrders,
updateLastMileOrderPalletMerge,
getLastMileOrderManualNodes,
updateLastMileOrderHold,
updateLastMileOrderManualNodes,
} from '../api/lastMileOrders'
import LastMileOrdersView from './LastMileOrdersView.vue'
const canAccess = vi.hoisted(() => vi.fn<(permissionCode: string) => boolean>(() => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canAccess }) }))
vi.mock('@/shared/downloadCsv', () => ({ saveCsv: vi.fn() }))
const routerPush = vi.hoisted(() => vi.fn())
vi.mock('vue-router', () => ({ useRouter: () => ({ push: routerPush }) }))
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
vi.mock('@/modules/container-loading', async () => ({
...await import('../../container-loading/warehouseArrival'),
listContainerArrivalTimes: vi.fn(),
}))
vi.mock('../api/lastMileOrders', () => ({
getReturnOrderStatistics: vi.fn(),
listLastMileOrders: vi.fn(), listContainerPlanProgress: vi.fn(), getContainerPlanProgress: vi.fn(),
changeContainerReceivingWarehouse: vi.fn(),
createManualInboundPlan: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), getContainerInboundPlanPreview: vi.fn(), transferLastMileOrders: vi.fn(),
getLastMileOrderManualNodes: vi.fn(), updateLastMileOrderHold: vi.fn(), updateLastMileOrderManualNodes: vi.fn(),
reviewCargoDiscard: vi.fn(), updateLastMileOrderPalletMerge: vi.fn(),
}))
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
function memoryStorage(): Storage {
const values = new Map<string, string>()
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 bookingId = '2092873463568597001'
const arrivalTime = {
sourceContainerBookingId: bookingId, estimatedWarehouseArrivalAt: '2026-09-01T08:00:00',
actualWarehouseArrivalAt: null, version: 1, planCount: 0, plannedDate: '2026-09-01', isDefault: false,
}
const emptyReturnStatistics = { totals: { orderCount: 0, skuTypes: 0, expectedQuantity: 0, receivedQuantity: 0, remainingQuantity: 0, receivedBoxCount: 0, receivedParcelCount: 0, receivedPalletCount: 0 }, pageDimensions: [] }
beforeEach(() => {
vi.mocked(getReturnOrderStatistics).mockReset().mockResolvedValue(emptyReturnStatistics)
})
const customerId = '2092618717861048321'
const summary = {
sourceContainerBookingId: bookingId, containerNo: 'CONT-15', totalOrderCount: 15,
overseasWarehouseId: '9007199254740995', overseasWarehouseCode: 'LAX01', overseasWarehouseName: '洛杉矶接柜仓',
assignmentVersion: 7, canChangeReceivingWarehouse: true, receivingWarehouseLockReason: null,
inboundCodeCount: 15, missingInboundCodeCount: 0, planCount: 10, plannedInboundCodeCount: 10,
pendingPlanCount: 5, resourcePendingPlanCount: 0, plannedArrivalAt: '2026-09-01T08:00:00', allPlansReady: false,
blockingReason: '存在5票尚未生成有效入库计划',
}
const fbaWarehouse = {
id: '9007199254740993', tenantId: '0', warehouseCode: 'ONT8', warehouseName: 'Ontario FBA',
warehouseType: 'FBA', countryCode: 'US', timezone: 'America/Los_Angeles', contactName: null,
contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null,
primaryAddress: {
id: '9007199254740994', tenantId: '0', warehouseId: '9007199254740993', addressType: 'OPERATING',
countryCode: 'US', stateProvince: 'CA', city: 'Ontario', district: null, postalCode: '91761',
addressLine1: 'FBA address', addressLine2: null, latitude: null, longitude: null,
},
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, sourcePalletCount: 4, supplementPalletCount: 1,
existingPlannedPalletCount: 0, totalSuggestedPalletCount: 5,
totalPlannedPalletCount: 0, ready: true,
groups: [{
destinationType: 'FBA', destinationWarehouseId: null, destinationWarehouseCode: 'ONT8',
destinationWarehouseName: 'Ontario FBA', orderCount: 5, pendingOrderCount: 5, pendingPalletCount: 5,
sourcePalletCount: 4, supplementPalletCount: 1,
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, sourcePalletCount: 4, targetPalletCount: 5,
supplementPalletCount: 1, plannedPalletCount: null, existingPlanNo: null,
planStatus: null, orderVersion: 3, readiness: 'READY', blockers: [],
}],
}],
}
// Recorded LOCAL_COMMITTED shape: complete local plan/pallet, zero new work, PLANNED,
// and an explicit server resourceRetry holding the original command identity.
const resourceCommittedPreview = {
...preview, snapshotVersion: 10, snapshotHash: 'b'.repeat(64),
totalOrderCount: 1, readyOrderCount: 1, pendingOrderCount: 0, pendingPalletCount: 0,
sourcePalletCount: 0, supplementPalletCount: 0, existingPlannedPalletCount: 1,
totalSuggestedPalletCount: 1, totalPlannedPalletCount: 1,
groups: [{
...preview.groups[0], destinationType: 'FBA' as const,
orderCount: 1, pendingOrderCount: 0, pendingPalletCount: 0, sourcePalletCount: 0,
supplementPalletCount: 0, existingPlannedPalletCount: 1, suggestedPalletCount: 1, plannedPalletCount: 1,
orders: [{ ...preview.groups[0].orders[0], destinationType: 'FBA' as const, fbaWarehouseCode: 'ONT8',
packageCount: 1, suggestedPalletCount: 1, sourcePalletCount: 1, targetPalletCount: 1,
supplementPalletCount: 0, plannedPalletCount: 1, existingPlanNo: 'LIP-ORIGINAL',
planStatus: 'PENDING_ASSIGNMENT', readiness: 'PLANNED' as const, blockers: [],
}],
}],
resourceRetry: { idempotencyKey: 'original-container-command', snapshotVersion: 9, snapshotHash: 'a'.repeat(64) },
}
const originalResourcePlans = [{ planNo: 'LIP-ORIGINAL', pallets: [{ id: 'PALLET-ORIGINAL', palletNo: 'PLT-ORIGINAL' }] }]
const receivingWarehouse = {
...fbaWarehouse,
id: '9007199254740996', warehouseCode: 'LAX02', warehouseName: '洛杉矶二号接柜仓', warehouseType: 'OVERSEAS',
}
const currentReceivingWarehouse = {
...receivingWarehouse,
id: summary.overseasWarehouseId, warehouseCode: summary.overseasWarehouseCode, warehouseName: summary.overseasWarehouseName,
}
function selectedRow(overrides: Record<string, unknown> = {}) {
return {
id: '2092873463568596994', customerId, sourceContainerBookingId: bookingId, orderNo: 'LMO-260827-0001',
overseasWarehouseId: summary.overseasWarehouseId, overseasWarehouseCode: summary.overseasWarehouseCode,
warehouse: summary.overseasWarehouseName, inboundCode: 'IN-LOCKED', version: 3, pallets: 2,
estimatedWarehouseAt: '2026-09-01T08:00:00', warehouseEta: '2026-09-01 08:00',
...overrides,
}
}
enableAutoUnmount(afterEach)
afterAll(() => vi.unstubAllGlobals())
async function mountView() {
const wrapper = mount(LastMileOrdersView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } })
await flushPromises()
return wrapper
}
async function openAfterDeparture() {
const wrapper = await mountView()
const departedTab = wrapper.findAll('[role="tab"]').find((item) => item.text() === '开船后')
await departedTab!.trigger('click')
await flushPromises()
return wrapper
}
async function openHiddenContainerWorkbench(wrapper: Awaited<ReturnType<typeof mountView>>) {
const view = wrapper.vm as unknown as {
statusTab: '全部' | '开船前' | '开船后' | '柜子'
changeStatusTab(): void
}
view.statusTab = '柜子'
view.changeStatusTab()
await flushPromises()
}
async function selectRows(wrapper: Awaited<ReturnType<typeof mountView>>, rows: object[]) {
wrapper.findComponent(DataTable).vm.$emit('selection-change', rows)
await wrapper.vm.$nextTick()
}
/** 定向验证入库弹框,独立于列表页签和批量操作入口。 */
async function openArrivalPlan(wrapper: Awaited<ReturnType<typeof mountView>>, source = selectedRow()) {
;(wrapper.vm as unknown as { openInboundPlan(source: object): void }).openInboundPlan(source)
await flushPromises()
}
function button(wrapper: Awaited<ReturnType<typeof mountView>>, text: string) {
return wrapper.findAll('button').find((item) => item.text().includes(text))
}
function cabinetDialog(wrapper: Awaited<ReturnType<typeof mountView>>) {
return wrapper.findAllComponents(AppDialog).find(dialog => dialog.props('title').startsWith('按柜生成入库计划预览'))!
}
async function openContainerDialog(wrapper: Awaited<ReturnType<typeof mountView>>) {
await button(wrapper, '按柜批量生成')!.trigger('click')
await flushPromises()
}
async function chooseContainer(wrapper: Awaited<ReturnType<typeof mountView>>) {
const selects = wrapper.findAllComponents({ name: 'ElSelect' })
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()
}
describe('LastMileOrdersView pending order and container conversion', () => {
it('keeps return row statistics across pages without rendering the long summary blocks', async () => {
const totals = { orderCount: 3, skuTypes: 3, expectedQuantity: 19, receivedQuantity: 14, remainingQuantity: 5, receivedBoxCount: 3, receivedParcelCount: 2, receivedPalletCount: 1 }
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [], page: 1, size: 10, total: 30, pages: 3 })
vi.mocked(getReturnOrderStatistics).mockResolvedValue({ totals, pageDimensions: [] })
const wrapper = await mountView()
const summary = () => wrapper.find('[aria-label="退货全筛选汇总"]')
expect(summary().exists()).toBe(false)
expect(wrapper.find('[aria-label="仓储转运全筛选汇总"]').exists()).toBe(false)
expect(wrapper.text()).not.toContain('当前筛选中的退货')
const table = wrapper.findComponent(DataTable)
table.vm.$emit('update:page', 2)
await flushPromises()
expect(getReturnOrderStatistics).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 }))
expect(summary().exists()).toBe(false)
vi.mocked(getReturnOrderStatistics).mockRejectedValueOnce(new Error('统计暂不可用'))
table.vm.$emit('update:page', 3)
await flushPromises()
expect(wrapper.find('[aria-label="退货全筛选汇总"]').exists()).toBe(false)
expect(wrapper.text()).toContain('尾程订单加载失败')
expect(table.props('data')).toEqual([])
})
it('shows commercial source alongside fulfillment type and sends source filters to the server', async () => {
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ id: '1', customerId: 'A', orderNo: 'WT-1', orderType: 'WAREHOUSE_TRANSFER', businessSource: { businessType: 'LOCAL_LOOSEBOARD', label: '本土散板 · 拼车需求池', businessOrderNo: 'LB-1', externalBookingNo: 'FB-1' } }], page: 1, size: 10, total: 1, pages: 1 })
const wrapper = await mountView()
const table = wrapper.findComponent(DataTable)
expect(table.props('data')[0]).toMatchObject({ businessSource: '本土散板 · 拼车需求池', businessOrderNo: 'LB-1', externalBookingNo: 'FB-1', orderType: '仓储转运', orderTypeCode: 'WAREHOUSE_TRANSFER' })
const toolbar = wrapper.findComponent(QueryToolbar)
toolbar.vm.$emit('search', { ...toolbar.props('modelValue'), businessType: 'LOCAL_LOOSEBOARD', businessOrderNo: ' LB-1 ', externalBookingNo: ' FB-1 ' })
await flushPromises()
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ businessType: 'LOCAL_LOOSEBOARD', businessOrderNo: 'LB-1', externalBookingNo: 'FB-1' }))
toolbar.vm.$emit('reset'); await flushPromises()
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ businessType: undefined, businessOrderNo: undefined, externalBookingNo: undefined }))
})
it('supports keyboard status switching and keeps selection and queries in sync', async () => {
const wrapper = await mountView()
const tabs = wrapper.get('.data-table__toolbar-start .workbench-list-tabs').findAll('[role="tab"]')
expect(tabs.map((tab) => tab.text())).toEqual(['全部', '开船前', '开船后'])
expect(tabs[0]!.attributes('aria-selected')).toBe('true')
expect(tabs[1]!.attributes('tabindex')).toBe('-1')
await selectRows(wrapper, [selectedRow()])
await tabs[0]!.trigger('keydown', { key: 'ArrowRight' })
await flushPromises()
expect(tabs[1]!.attributes('aria-selected')).toBe('true')
expect(tabs[1]!.classes()).toContain('is-active')
expect(document.activeElement).toBe(tabs[1]!.element)
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, orderType: 'FIRST_LEG', sailingStage: 'BEFORE_DEPARTURE', currentStatus: undefined }))
expect((wrapper.vm as unknown as { selectedRows: unknown[] }).selectedRows).toEqual([])
const callCount = vi.mocked(listLastMileOrders).mock.calls.length
await tabs[1]!.trigger('click')
await flushPromises()
expect(vi.mocked(listLastMileOrders)).toHaveBeenCalledTimes(callCount)
await tabs[1]!.trigger('keydown', { key: 'Home' })
await flushPromises()
expect(tabs[0]!.attributes('aria-selected')).toBe('true')
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ currentStatus: undefined, sailingStage: undefined, orderType: undefined }))
await tabs[0]!.trigger('keydown', { key: 'End' })
await flushPromises()
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ sailingStage: 'AFTER_DEPARTURE', orderType: 'FIRST_LEG' }))
expect(tabs[2]!.attributes('aria-selected')).toBe('true')
})
it('exports the same stage and filters as the list and restores other types in all', async () => {
const wrapper = await mountView()
const toolbar = wrapper.findComponent(QueryToolbar)
toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), orderType: 'RETURN_RELABEL', detained: 'false' })
await wrapper.vm.$nextTick()
for (const [label, stage] of [['开船前', 'BEFORE_DEPARTURE'], ['开船后', 'AFTER_DEPARTURE']] as const) {
await wrapper.findAll('[role="tab"]').find((tab) => tab.text() === label)!.trigger('click')
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)
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()
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ orderType: 'RETURN_RELABEL', sailingStage: undefined }))
expect(toolbar.props('availableFields')?.some((field) => field.key === 'orderType')).toBe(true)
})
it('preserves advanced conditions when collapsed and clears them on reset', async () => {
const wrapper = await mountView()
const toolbar = wrapper.findComponent(QueryToolbar)
expect(toolbar.props('fields').map((field) => field.key)).toEqual(['orderNo', 'inboundCode', 'warehouse'])
const initialCalls = vi.mocked(listLastMileOrders).mock.calls.length
await button(wrapper, '高级筛选')!.trigger('click')
expect(toolbar.props('fields')).toHaveLength(16)
toolbar.vm.$emit('update:modelValue', {
...toolbar.props('modelValue'), containerNo: ' CONT-15 ', abnormal: 'false',
createdAt: ['2026-09-01', '2026-09-30'],
})
await wrapper.vm.$nextTick()
await button(wrapper, '收起筛选')!.trigger('click')
expect(vi.mocked(listLastMileOrders)).toHaveBeenCalledTimes(initialCalls)
expect(toolbar.props('modelValue').containerNo).toBe(' CONT-15 ')
await button(wrapper, '查询')!.trigger('click')
await flushPromises()
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({
containerNo: 'CONT-15', abnormal: false,
createdFrom: '2026-09-01T00:00:00', createdTo: '2026-09-30T23:59:59',
}))
await button(wrapper, '重置')!.trigger('click')
await flushPromises()
expect(vi.mocked(listLastMileOrders)).toHaveBeenCalledTimes(initialCalls + 2)
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({
page: 1, containerNo: undefined, abnormal: undefined, createdFrom: undefined, createdTo: undefined,
}))
await button(wrapper, '高级筛选')!.trigger('click')
expect(toolbar.props('modelValue').containerNo).toBe('')
expect(toolbar.props('modelValue').createdAt).toEqual([])
})
it('keeps container filters separate from the advanced order fields', async () => {
const wrapper = await mountView()
await button(wrapper, '高级筛选')!.trigger('click')
await openHiddenContainerWorkbench(wrapper)
expect(wrapper.findComponent(QueryToolbar).props('fields').map((field) => field.key)).toEqual(['containerNo', 'createdAt'])
expect(button(wrapper, '收起筛选')).toBeUndefined()
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: [{
id: 'transfer-1', customerId, orderNo: 'WT-1', orderType: 'WAREHOUSE_TRANSFER', currentStatus: 'WAIT_CUSTOMER_CONFIRMATION',
packageCount: 10, receivedPackageCount: 8, packageDifference: -2,
}], page: 1, size: 10, total: 1, pages: 1 })
const wrapper = await mountView()
const table = wrapper.findComponent(DataTable)
const columns = table.props('columns') as Array<{ key: string; label: string }>
expect(columns).toEqual(expect.arrayContaining([expect.objectContaining({ key: 'cartonDifference', label: '差异(箱)' })]))
expect(columns.some((column) => ['declaredCartons', 'receivedCartons'].includes(column.key))).toBe(false)
expect((table.props('data') as Array<{ cartons: unknown }>)[0]?.cartons).toBe(10)
expect(wrapper.get('.package-difference--nonzero').text()).toBe('-2')
})
it.each([
[[], ['查看'], false],
[['inbound-order:create'], ['查看', '入库'], false],
[['last-mile-order:edit'], ['查看', '修改节点'], false],
[['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', 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)
expect(actions.find('.el-dropdown').exists()).toBe(overflow)
})
beforeEach(() => {
vi.restoreAllMocks()
vi.mocked(reviewCargoDiscard).mockReset().mockResolvedValue({})
canAccess.mockReset().mockReturnValue(true)
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('localStorage', memoryStorage())
vi.mocked(listLastMileOrders).mockReset().mockResolvedValue({ records: [], page: 1, size: 10, total: 0, pages: 0 })
vi.mocked(listContainerPlanProgress).mockReset().mockResolvedValue({ records: [summary], page: 1, size: 50, total: 1, pages: 1 })
vi.mocked(getContainerPlanProgress).mockReset().mockResolvedValue({
summary,
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(listContainerArrivalTimes).mockReset().mockResolvedValue([arrivalTime])
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', 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)
})
async function openDiscard(overrides: Record<string, unknown> = {}) {
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{
id: '2092873463568596994', customerId, orderNo: 'CD-TEST-1', sourceOrderNo: 'CD-TEST-1',
orderType: 'CARGO_DISCARD', currentStatus: 'PENDING_REVIEW', inboundOrderNo: 'CD-TEST-1', ...overrides,
}], page: 1, size: 10, total: 1, pages: 1 })
return mountView()
}
it.each([
['lastleg:cargo-discard:approve', 'PENDING_REVIEW', true],
['platform:overseas-warehouse:last-mile-order:edit', 'PENDING_REVIEW', false],
['lastleg:cargo-discard:approve', 'APPROVED', false],
['lastleg:cargo-discard:approve', 'CANCELLED', false],
])('gates discard review on exact permission %s and status %s', async (code, currentStatus, visible) => {
canAccess.mockImplementation(permissionCode => permissionCode === code)
const wrapper = await openDiscard({ currentStatus })
expect(Boolean(button(wrapper, '审核'))).toBe(visible)
expect(reviewCargoDiscard).not.toHaveBeenCalled()
})
it.each([{ orderNo: '' }, { customerId: '' }])('does not expose discard review without a business target %j', async (overrides) => {
const wrapper = await openDiscard(overrides)
expect(button(wrapper, '审核')).toBeUndefined()
expect(reviewCargoDiscard).not.toHaveBeenCalled()
})
it('approves the selected discard once and reloads the server status', async () => {
const confirm = vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as Awaited<ReturnType<typeof ElMessageBox.confirm>>)
const wrapper = await openDiscard()
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{
id: '2092873463568596994', customerId, orderNo: 'CD-TEST-1',
orderType: 'CARGO_DISCARD', currentStatus: 'APPROVED', inboundOrderNo: 'CD-TEST-1',
}], page: 1, size: 10, total: 1, pages: 1 })
await button(wrapper, '审核')!.trigger('click')
await flushPromises()
expect(confirm).toHaveBeenCalledWith(expect.stringContaining('CD-TEST-1'), '废弃申请审核 · CD-TEST-1', expect.objectContaining({ distinguishCancelAndClose: true }))
expect(reviewCargoDiscard).toHaveBeenCalledExactlyOnceWith('CD-TEST-1', customerId, 'APPROVE', undefined)
expect(button(wrapper, '审核')).toBeUndefined()
})
it('rejects with a trimmed mandatory reason without approving first', async () => {
vi.spyOn(ElMessageBox, 'confirm').mockRejectedValue('cancel')
const prompt = vi.spyOn(ElMessageBox, 'prompt').mockResolvedValue({ value: ' 箱数不符 ', action: 'confirm' } as Awaited<ReturnType<typeof ElMessageBox.prompt>>)
const wrapper = await openDiscard()
await button(wrapper, '审核')!.trigger('click')
await flushPromises()
const options = prompt.mock.calls[0]?.[2]
expect(options?.inputValidator?.(' ')).not.toBe(true)
expect(options?.inputValidator?.('甲'.repeat(1001))).not.toBe(true)
expect(options?.inputValidator?.('箱数不符')).toBe(true)
expect(reviewCargoDiscard).toHaveBeenCalledExactlyOnceWith('CD-TEST-1', customerId, 'REJECT', '箱数不符')
})
it('closing review or cancelling the reason makes no request', async () => {
vi.spyOn(ElMessageBox, 'confirm').mockRejectedValueOnce('close').mockRejectedValueOnce('cancel')
vi.spyOn(ElMessageBox, 'prompt').mockRejectedValue('cancel')
const wrapper = await openDiscard()
await button(wrapper, '审核')!.trigger('click')
await flushPromises()
await button(wrapper, '审核')!.trigger('click')
await flushPromises()
expect(reviewCargoDiscard).not.toHaveBeenCalled()
expect(listLastMileOrders).toHaveBeenCalledTimes(1)
})
it('locks repeat clicks while review is pending and allows retry after an API failure', async () => {
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as Awaited<ReturnType<typeof ElMessageBox.confirm>>)
const errorToast = vi.spyOn(ElMessage, 'error')
let rejectRequest!: (error: Error) => void
vi.mocked(reviewCargoDiscard).mockImplementationOnce(() => new Promise((_, reject) => { rejectRequest = reject }))
const wrapper = await openDiscard()
await button(wrapper, '审核')!.trigger('click')
await flushPromises()
expect((button(wrapper, '审核')!.element as HTMLButtonElement).disabled).toBe(true)
await button(wrapper, '审核')!.trigger('click')
expect(reviewCargoDiscard).toHaveBeenCalledTimes(1)
rejectRequest(new Error('审批请求失败'))
await flushPromises()
expect(errorToast).toHaveBeenCalled()
expect((button(wrapper, '审核')!.element as HTMLButtonElement).disabled).toBe(false)
expect(listLastMileOrders).toHaveBeenCalledTimes(1)
await button(wrapper, '审核')!.trigger('click')
await flushPromises()
expect(reviewCargoDiscard).toHaveBeenCalledTimes(2)
})
it.each([
['PENDING_ASSIGNMENT', false],
['INBOUND_COMPLETED', true],
])('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', containerNode: 'ACTUAL_DEPARTURE', detained: false,
}], page: 1, size: 10, total: 1, pages: 1 })
const wrapper = await mountView()
const holdButton = wrapper.get('.el-table__body-wrapper .row-actions').findAll('button')
.find(item => item.text().includes('暂扣订单'))
expect(Boolean(holdButton)).toBe(visible)
if (holdButton) expect((holdButton.element as HTMLButtonElement).disabled).toBe(false)
})
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', containerNode: 'ACTUAL_DEPARTURE', detained: true,
}], page: 1, size: 10, total: 1, pages: 1 })
const wrapper = await mountView()
const releaseButton = wrapper.get('.el-table__body-wrapper .row-actions').findAll('button')
.find(item => item.text().includes('解除暂扣'))
expect(releaseButton).toBeTruthy()
expect((releaseButton!.element as HTMLButtonElement).disabled).toBe(false)
})
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', containerNode: 'ACTUAL_DEPARTURE', detained: false,
}], page: 1, size: 10, total: 1, pages: 1 })
const wrapper = await mountView()
const actions = wrapper.get('.el-table__body-wrapper .row-actions')
expect(actions.text()).toContain('查看')
expect(actions.text()).not.toContain('入库')
expect(actions.text()).not.toContain('暂扣订单')
await selectRows(wrapper, [(wrapper.findComponent(DataTable).props('data') as object[])[0]])
expect((button(wrapper, '入库')!.element as HTMLButtonElement).disabled).toBe(true)
})
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('转仓')
expect(wrapper.text()).toContain('入库')
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: undefined, orderType: 'FIRST_LEG', sailingStage: 'AFTER_DEPARTURE' }))
expect(listContainerPlanProgress).not.toHaveBeenCalled()
})
it('forwards complete searches and switches every status tab without stale filter failures', async () => {
const wrapper = await mountView()
const toolbar = wrapper.findComponent(QueryToolbar)
const model = {
orderNo: 'LMO-SEARCH', inboundCode: 'IN-SEARCH', lxCode: 'LX-SEARCH', orderType: 'FIRST_LEG',
warehouse: 'LAX', sourceOrderNo: 'SOURCE-SEARCH', containerNo: 'CONT-SEARCH', transferWarehouseCode: 'ONT8',
status: 'DELIVERED', abnormal: 'true', detained: 'false', createdAt: ['2026-09-01', '2026-09-07'],
}
toolbar.vm.$emit('search', model)
await flushPromises()
expect(listLastMileOrders).toHaveBeenLastCalledWith({
page: 1, size: 10, orderNo: 'LMO-SEARCH', inboundCode: 'IN-SEARCH', lxCode: 'LX-SEARCH', orderType: 'FIRST_LEG',
warehouse: 'LAX', sourceOrderNo: 'SOURCE-SEARCH', containerNo: 'CONT-SEARCH', transferWarehouseCode: 'ONT8',
currentStatus: 'DELIVERED', abnormal: true, detained: false,
createdFrom: '2026-09-01T00:00:00', createdTo: '2026-09-07T23:59:59',
})
const availableFields = toolbar.props('availableFields') as Array<{ key: string; label: string; placeholder?: string }>
expect(availableFields.find((field) => field.key === 'lxCode')).toMatchObject({
label: 'LX代码', placeholder: '请输入LX代码',
})
expect(availableFields.filter((field) => ['orderType', 'status', 'abnormal', 'detained'].includes(field.key)))
.toEqual(expect.arrayContaining([
expect.objectContaining({ key: 'orderType', placeholder: '请选择履约类型' }),
expect.objectContaining({ key: 'status', placeholder: '请选择当前状态' }),
expect.objectContaining({ key: 'abnormal', placeholder: '请选择是否异常' }),
expect.objectContaining({ key: 'detained', placeholder: '请选择是否暂扣' }),
]))
expect(availableFields.map((field) => field.key)).not.toEqual(expect.arrayContaining(['planNo', 'inboundOrderNo']))
await wrapper.findAll('[role="tab"]').find((item) => item.text() === '开船前')!.trigger('click')
await flushPromises()
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ currentStatus: 'DELIVERED', sailingStage: 'BEFORE_DEPARTURE', orderType: 'FIRST_LEG', lxCode: 'LX-SEARCH' }))
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',
})
await wrapper.findAll('[role="tab"]').find((item) => item.text() === '全部')!.trigger('click')
await flushPromises()
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ currentStatus: 'DELIVERED', lxCode: 'LX-SEARCH' }))
const callsBeforeReset = vi.mocked(listLastMileOrders).mock.calls.length
;(toolbar.vm as unknown as { reset(): void }).reset()
await flushPromises()
expect(vi.mocked(listLastMileOrders).mock.calls).toHaveLength(callsBeforeReset + 1)
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1 }))
expect(vi.mocked(listLastMileOrders).mock.calls.at(-1)?.[0].lxCode).toBeUndefined()
expect(wrapper.text()).not.toContain('尾程订单加载失败')
})
it('opens the required single-order inbound-plan form without invoking the cabinet conversion', async () => {
const wrapper = await openAfterDeparture()
await selectRows(wrapper, [selectedRow()])
await button(wrapper, '入库')!.trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('计划入库仓')
expect(document.body.textContent).toContain('预计到仓时间')
expect(document.body.textContent).toContain('托盘类型')
const sourceFacts = wrapper.get('[aria-label="入库来源"]')
expect(sourceFacts.find('input').exists()).toBe(false)
expect(sourceFacts.text()).toContain('IN-LOCKED')
expect(sourceFacts.text()).toContain('LAX01 · 洛杉矶接柜仓')
expect(sourceFacts.text()).toContain('2026-09-01 08:00')
expect(wrapper.find('button[aria-label="查看入库来源说明"]').exists()).toBe(true)
expect(listAllPlatformWarehouses).not.toHaveBeenCalled()
expect(createManualInboundPlan).not.toHaveBeenCalled()
expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled()
expect(listContainerPlanProgress).not.toHaveBeenCalled()
})
it('shows cabinet-frozen facts but never sends arrival time or per-order warehouse overrides', async () => {
const wrapper = await openAfterDeparture()
await selectRows(wrapper, [selectedRow()])
await button(wrapper, '入库')!.trigger('click')
await flushPromises()
const confirm = [...document.body.querySelectorAll('button')]
.find((item) => item.textContent?.includes('保存并生成入库单')) as HTMLButtonElement
confirm.click()
await flushPromises()
expect(createManualInboundPlan).toHaveBeenCalledWith(expect.objectContaining({
customerId, orderId: '2092873463568596994', expectedOrderVersion: 3,
palletCount: 5, palletType: 'WOOD',
}), expect.stringContaining('manual-inbound-plan-'))
const payload = vi.mocked(createManualInboundPlan).mock.calls[0][0] as unknown as Record<string, unknown>
expect(payload).not.toHaveProperty('inboundCode')
expect(payload).not.toHaveProperty('plannedArrivalDate')
expect(payload).not.toHaveProperty('plannedWarehouseId')
expect(payload).not.toHaveProperty('plannedWarehouseCode')
expect(payload).not.toHaveProperty('plannedWarehouseName')
})
it('blocks a held order from generating its inbound plan', async () => {
const wrapper = await openAfterDeparture()
await selectRows(wrapper, [selectedRow({
detained: true,
orderTypeCode: 'ONE_CLICK_DROPSHIP',
currentStatusCode: 'PENDING_ASSIGNMENT',
})])
await button(wrapper, '入库')!.trigger('click')
await flushPromises()
expect(getContainerInboundPlanPreview).not.toHaveBeenCalled()
const confirm = [...document.body.querySelectorAll('button')]
.find((item) => item.textContent?.includes('保存并生成入库单')) as HTMLButtonElement
expect(confirm).toBeUndefined()
})
it.each([
[{ ...arrivalTime, estimatedWarehouseArrivalAt: '2026-10-25T09:30:00', plannedDate: '2026-10-25' }, '2026-10-25 09:30'],
[{ ...arrivalTime, estimatedWarehouseArrivalAt: null, version: 0, plannedDate: '2026-11-01', isDefault: true }, '2026-11-01(默认推算)'],
[{ ...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 openAfterDeparture()
await openArrivalPlan(wrapper, arrival.estimatedWarehouseArrivalAt ? selectedRow() : selectedRow({ estimatedWarehouseAt: '', warehouseEta: '—' }))
expect(listContainerArrivalTimes).toHaveBeenCalledWith([bookingId])
const source = wrapper.get('[aria-label="入库来源"]')
expect(source.text()).toContain(expectedText)
if (!arrival.estimatedWarehouseArrivalAt) expect(source.text()).not.toContain('00:00')
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('入库计划 ·'))!
expect(dialog.props('confirmDisabled')).toBe(false)
dialog.vm.$emit('confirm')
await flushPromises()
expect(createManualInboundPlan).toHaveBeenCalledTimes(1)
expect(vi.mocked(createManualInboundPlan).mock.calls[0][0]).not.toHaveProperty('plannedArrivalDate')
})
it.each([
{ arrivals: [] },
{ arrivals: [{ ...arrivalTime, estimatedWarehouseArrivalAt: null, plannedDate: null }] },
{ 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 openAfterDeparture()
await openArrivalPlan(wrapper)
const confirm = [...document.body.querySelectorAll('button')]
.find((item) => item.textContent?.includes('保存并生成入库单')) as HTMLButtonElement
expect(confirm.disabled).toBe(true)
expect(createManualInboundPlan).not.toHaveBeenCalled()
})
it('shows arrival read failures and does not reuse the list timestamp', async () => {
vi.mocked(listContainerArrivalTimes).mockRejectedValue(new ApiResponseError('到仓来源暂不可读', 'A0503', 503))
const wrapper = await openAfterDeparture()
await openArrivalPlan(wrapper)
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('入库计划 ·'))!
expect(dialog.text()).toContain('到仓来源暂不可读')
expect(dialog.get('[aria-label="入库来源"]').text()).not.toContain('2026-09-01')
expect(dialog.props('confirmDisabled')).toBe(true)
dialog.vm.$emit('confirm'); await flushPromises()
expect(createManualInboundPlan).not.toHaveBeenCalled()
})
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 openAfterDeparture()
await openArrivalPlan(wrapper)
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('入库计划 ·'))!
expect(dialog.props('confirmDisabled')).toBe(true)
dialog.vm.$emit('confirm'); await flushPromises()
expect(createManualInboundPlan).not.toHaveBeenCalled()
dialog.vm.$emit('update:modelValue', false)
await wrapper.vm.$nextTick()
vi.mocked(listContainerArrivalTimes).mockResolvedValueOnce([{ ...arrivalTime, estimatedWarehouseArrivalAt: '2026-10-26T10:00:00', plannedDate: '2026-10-26' }])
await openArrivalPlan(wrapper)
expect(dialog.get('[aria-label="入库来源"]').text()).toContain('2026-10-26 10:00')
resolveOld([arrivalTime]); await flushPromises()
expect(dialog.get('[aria-label="入库来源"]').text()).toContain('2026-10-26 10:00')
expect(dialog.props('confirmDisabled')).toBe(false)
})
it('locks one-click dropship manual plans to the frozen source pallet count', async () => {
vi.mocked(getContainerInboundPlanPreview).mockResolvedValue({
...preview,
sourcePalletCount: 1,
groups: [{
...preview.groups[0], destinationType: 'ONE_CLICK_DROPSHIP', sourcePalletCount: 1,
orders: [{
...preview.groups[0].orders[0], orderType: 'ONE_CLICK_DROPSHIP',
destinationType: 'ONE_CLICK_DROPSHIP', sourcePalletCount: 1,
targetPalletCount: 1, supplementPalletCount: 0,
}],
}],
} as never)
const wrapper = await openAfterDeparture()
await selectRows(wrapper, [selectedRow({ orderTypeCode: 'ONE_CLICK_DROPSHIP', pallets: 2 })])
await button(wrapper, '入库')!.trigger('click')
await flushPromises()
expect(wrapper.find('.command-form').findComponent({ name: 'ElInputNumber' }).exists()).toBe(false)
expect(wrapper.get('.frozen-pallet-count').text()).toBe('1')
expect(document.body.textContent).toContain('固定按头程已封托托盘数整体入库')
;([...document.body.querySelectorAll('button')]
.find((item) => item.textContent?.includes('保存并生成入库单')) as HTMLButtonElement).click()
await flushPromises()
expect(createManualInboundPlan).toHaveBeenCalledWith(expect.objectContaining({ palletCount: 1 }),
expect.stringContaining('manual-inbound-plan-'))
})
it('opens a cabinet selector without requiring order selection and loads server summaries only then', async () => {
const wrapper = await openAfterDeparture()
await openContainerDialog(wrapper)
expect(listContainerPlanProgress).toHaveBeenCalledTimes(1)
expect(listContainerPlanProgress).toHaveBeenCalledWith({ page: 1, size: 50, containerNo: undefined })
expect(document.body.textContent).toContain('选择待分配柜')
await cabinetDialog(wrapper).get('button[aria-label="查看按柜生成说明"]').trigger('focus')
await new Promise(resolve => setTimeout(resolve, 200)); await flushPromises()
expect(document.body.textContent).toContain('确认后整柜只提交一次生成命令')
expect(getContainerInboundPlanPreview).not.toHaveBeenCalled()
expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled()
})
it('selects one cabinet and sends one long-id convert-container request without using current-page row counts', async () => {
const wrapper = await openAfterDeparture()
await openContainerDialog(wrapper)
await chooseContainer(wrapper)
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(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 个 / 已有 0 个')
expect(document.body.textContent).toContain('PLT-001')
expect(listLastMileOrders).toHaveBeenCalledTimes(3)
})
it.each(['A0404', 'A0409'])('keeps %s request evidence visible after cabinet conversion failure', async (code) => {
vi.mocked(convertLastMileContainerToInboundPlans).mockRejectedValueOnce(new ApiResponseError(
'柜级生成失败', code, 200, undefined, `request-${code}`, `trace-${code}`,
))
const wrapper = await openAfterDeparture()
await openContainerDialog(wrapper)
await chooseContainer(wrapper)
const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认生成')) as HTMLButtonElement
confirm.click()
await flushPromises()
expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain(code)
expect(wrapper.text()).toContain(`request-${code}`)
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 openAfterDeparture()
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 openAfterDeparture()
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 })
const wrapper = await mountView()
await openHiddenContainerWorkbench(wrapper)
expect(listContainerPlanProgress).toHaveBeenLastCalledWith({ page: 1, size: 10, containerNo: undefined, createdFrom: undefined, createdTo: undefined })
expect(wrapper.text()).not.toContain('柜子工作台(2)')
expect(wrapper.text()).toContain('货柜工作台')
expect(wrapper.text()).not.toContain('按柜查看和修改海外接柜仓;整柜计划齐全后自动生成托盘码')
expect(wrapper.text()).toContain('5个入仓码待生成计划')
expect(wrapper.text()).toContain('共 15 个入仓码')
expect(wrapper.text()).not.toContain('待补入仓码')
expect(wrapper.text()).not.toContain('缺入仓码')
expect(wrapper.text()).not.toContain('已生成 10 个计划')
expect(wrapper.text()).not.toContain('存在5票尚未生成有效入库计划')
expect(wrapper.text()).toContain('2 个货柜')
expect(wrapper.text().indexOf('CONT-15')).toBeLessThan(wrapper.text().indexOf('CONT-NULL'))
})
it('presents a resource-pending cabinet as an exceptional pallet-code retry', async () => {
const ready = {
...summary,
planCount: summary.inboundCodeCount,
plannedInboundCodeCount: summary.inboundCodeCount,
pendingPlanCount: 0,
resourcePendingPlanCount: 1,
allPlansReady: true,
blockingReason: null,
}
vi.mocked(listContainerPlanProgress).mockResolvedValueOnce({ records: [ready], page: 1, size: 10, total: 1, pages: 1 })
const wrapper = await mountView()
await openHiddenContainerWorkbench(wrapper)
expect(wrapper.text()).toContain('CONT-15')
expect(wrapper.text()).toContain('0个入仓码待生成计划')
expect(wrapper.text()).toContain('托盘码自动生成异常,可重试')
expect(wrapper.text()).not.toContain('待补真实托盘')
expect((button(wrapper, '重试生成托盘码')!.element as HTMLButtonElement).disabled).toBe(false)
expect((button(wrapper, '修改接柜仓')!.element as HTMLButtonElement).disabled).toBe(false)
})
it('restores the original resource command after reopening a locally committed cabinet', async () => {
vi.mocked(listContainerPlanProgress).mockResolvedValueOnce({
records: [{ ...summary, pendingPlanCount: 0, resourcePendingPlanCount: 1, allPlansReady: true }],
page: 1, size: 10, total: 1, pages: 1,
})
vi.mocked(getContainerInboundPlanPreview).mockResolvedValueOnce(resourceCommittedPreview)
vi.mocked(convertLastMileContainerToInboundPlans).mockResolvedValue(originalResourcePlans as never)
const wrapper = await mountView()
await openHiddenContainerWorkbench(wrapper)
await button(wrapper, '重试生成托盘码')!.trigger('click')
await flushPromises()
expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(false)
expect(cabinetDialog(wrapper).props('confirmText')).toBe('重试资源确认')
cabinetDialog(wrapper).vm.$emit('confirm')
await flushPromises()
expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledWith({
sourceContainerBookingId: bookingId, snapshotVersion: 9, snapshotHash: 'a'.repeat(64),
}, 'original-container-command')
})
it('reopens a complete local plan and confirms only the original resource key/version/hash without showing zero generation', async () => {
const success = vi.spyOn(ElMessage, 'success').mockImplementation(() => ({ close: vi.fn() } as never))
vi.mocked(getContainerInboundPlanPreview)
.mockResolvedValueOnce(resourceCommittedPreview)
.mockResolvedValueOnce(resourceCommittedPreview)
.mockResolvedValue({ ...resourceCommittedPreview, resourceRetry: undefined })
vi.mocked(convertLastMileContainerToInboundPlans).mockResolvedValue(originalResourcePlans as never)
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)
const dialog = cabinetDialog(wrapper)
expect(dialog.props('confirmDisabled')).toBe(false)
expect(dialog.props('confirmText')).toBe('重试资源确认')
expect(document.body.textContent).toContain('资源仍待确认')
expect(document.body.textContent).not.toContain('最终生成 0 个托盘')
expect(document.body.textContent).not.toContain('最终 0 托')
dialog.vm.$emit('confirm'); await flushPromises()
expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledExactlyOnceWith({
sourceContainerBookingId: bookingId, snapshotVersion: 9, snapshotHash: 'a'.repeat(64),
}, 'original-container-command')
expect(document.body.textContent).toContain('资源确认完成')
expect(document.body.textContent).toContain('PLT-ORIGINAL')
expect(cabinetDialog(wrapper).props('confirmText')).toBe('已确认')
expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(true)
expect(success).toHaveBeenCalledWith('已完成 1 个已有计划的资源确认')
expect(createManualInboundPlan).not.toHaveBeenCalled()
})
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 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()
expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled()
})
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 openAfterDeparture(); await openContainerDialog(wrapper); await chooseContainer(wrapper)
const identity = document.body.querySelector('.container-preview__identity')!
expect(identity.textContent).toBe('未录入货柜号')
expect(identity.getAttribute('title')).toBe('未录入货柜号')
expect(identity.getAttribute('title')).not.toContain(bookingId)
})
it('keeps server readiness blocks effective even for a resource retry', async () => {
vi.mocked(getContainerInboundPlanPreview).mockResolvedValue({ ...resourceCommittedPreview, ready: false, blockedOrderCount: 1 })
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()
})
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 openAfterDeparture()
const view = wrapper.vm as unknown as { openContainerConversion(): void }
view.openContainerConversion(); await flushPromises(); await chooseContainer(wrapper)
expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(true)
cabinetDialog(wrapper).vm.$emit('confirm'); await flushPromises()
expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled()
})
it('keeps preview and candidate loading guards effective for recovery commands', async () => {
let resolvePreview!: (value: typeof resourceCommittedPreview) => void
let resolveOptions!: (value: Awaited<ReturnType<typeof listContainerPlanProgress>>) => void
vi.mocked(getContainerInboundPlanPreview).mockReturnValue(new Promise(resolve => { resolvePreview = resolve }))
vi.mocked(listContainerPlanProgress).mockReturnValue(new Promise(resolve => { resolveOptions = resolve }))
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()
resolvePreview(resourceCommittedPreview); await flushPromises()
expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(true)
cabinetDialog(wrapper).vm.$emit('confirm'); await flushPromises()
expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled()
resolveOptions({ records: [summary], page: 1, size: 50, total: 1, pages: 1 }); await flushPromises()
expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(false)
})
it('preserves the original recovery command on failure and blocks duplicate or completed submissions', async () => {
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 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)
expect(success).not.toHaveBeenCalled()
let resolveCommand!: (value: Awaited<ReturnType<typeof convertLastMileContainerToInboundPlans>>) => void
vi.mocked(convertLastMileContainerToInboundPlans).mockReturnValueOnce(new Promise(resolve => { resolveCommand = resolve }))
cabinetDialog(wrapper).vm.$emit('confirm'); cabinetDialog(wrapper).vm.$emit('confirm'); await flushPromises()
expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledTimes(2)
expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(true)
resolveCommand(originalResourcePlans as never); await flushPromises()
cabinetDialog(wrapper).vm.$emit('confirm'); await flushPromises()
expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledTimes(2)
for (const args of vi.mocked(convertLastMileContainerToInboundPlans).mock.calls) expect(args).toEqual([
{ sourceContainerBookingId: bookingId, snapshotVersion: 9, snapshotHash: 'a'.repeat(64) }, 'original-container-command',
])
})
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 openHiddenContainerWorkbench(wrapper)
expect(wrapper.text()).toContain('暂无货柜数据')
expect(wrapper.text()).not.toContain('已全部生成计划的柜不再展示')
})
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()
await openHiddenContainerWorkbench(wrapper)
const pagination = wrapper.findComponent({ name: 'ElPagination' })
pagination.vm.$emit('current-change', 2)
await flushPromises()
expect(listContainerPlanProgress).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, size: 10 }))
await wrapper.get('.container-workbench-card__detail').trigger('click')
await flushPromises()
expect(getContainerPlanProgress).toHaveBeenCalledWith(bookingId)
expect(document.body.textContent).toContain('柜子详情 · CONT-15')
expect(document.body.textContent).toContain('LMO-1')
expect(document.body.textContent).toContain('IN-1')
expect(document.body.textContent).toContain('LIP-1')
expect(document.body.textContent).toContain('已生成入库计划')
expect(document.body.textContent).toContain('待收货')
expect(document.body.textContent).not.toContain('INBOUND_PLANNED')
expect(document.body.textContent).not.toContain('PENDING_RECEIVING')
expect(document.body.querySelector('.el-dialog')).not.toBeNull()
expect(document.body.querySelector('.el-drawer')).toBeNull()
})
it('starts cabinet plan generation directly from the workbench card', async () => {
const wrapper = await mountView()
await openHiddenContainerWorkbench(wrapper)
await button(wrapper, '入库')!.trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('头程已有 4 个托盘码,海外仓补充 1 个,最终生成 5 个托盘')
expect(document.body.textContent).toContain('头程已有 4 托 / 海外仓补充 1 托')
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,
snapshotVersion: 9,
snapshotHash: 'a'.repeat(64),
}, expect.stringContaining('container-inbound-plan-'))
expect(getContainerPlanProgress).not.toHaveBeenCalled()
})
it('keeps an invalid historical cabinet readable while blocking plan generation', async () => {
const invalid = {
...summary,
overseasWarehouseId: null,
overseasWarehouseCode: null,
overseasWarehouseName: null,
blockingReason: '柜内接柜仓业务快照不一致',
}
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 openHiddenContainerWorkbench(wrapper)
expect(wrapper.text()).toContain('柜内接柜仓业务快照不一致')
expect((button(wrapper, '入库')!.element as HTMLButtonElement).disabled).toBe(true)
await wrapper.get('.container-workbench-card__detail').trigger('click')
await flushPromises()
expect(getContainerPlanProgress).toHaveBeenCalledWith(bookingId)
expect(document.body.textContent).toContain('柜子详情 · CONT-15')
expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled()
})
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 openHiddenContainerWorkbench(wrapper)
expect(wrapper.text()).toContain('海外接柜仓')
expect(wrapper.text()).toContain('LAX01 · 洛杉矶接柜仓')
expect(wrapper.get('.container-workbench-card__warehouse strong').attributes('title')).toBe('LAX01 · 洛杉矶接柜仓')
expect(listAllPlatformWarehouses).not.toHaveBeenCalled()
await button(wrapper, '修改接柜仓')!.trigger('click')
await flushPromises()
const receivingDialog = wrapper.findAllComponents(AppDialog).find((item) => item.props('title') === '修改海外接柜仓')!
expect(receivingDialog.props('width')).toBe('min(600px, calc(100vw - 28px))')
expect(listAllPlatformWarehouses).toHaveBeenLastCalledWith({ warehouseType: 'OVERSEAS', status: 'ACTIVE' })
expect(listAllPlatformWarehouses).toHaveBeenCalledTimes(1)
const warehouseSelect = wrapper.findAllComponents({ name: 'ElSelect' }).find((item) => item.props('placeholder') === '请选择 ACTIVE 海外接柜仓')!
expect(warehouseSelect.props('modelValue')).toBe('')
expect(wrapper.text()).not.toContain('9007199254740995')
const warehouseOptions = wrapper.findAllComponents({ name: 'ElOption' })
expect(warehouseOptions.find((item) => item.props('value') === summary.overseasWarehouseId)?.props('disabled')).toBe(true)
const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认修改')) as HTMLButtonElement
warehouseSelect.vm.$emit('update:modelValue', summary.overseasWarehouseId)
await wrapper.vm.$nextTick()
expect(confirm.disabled).toBe(true)
expect(wrapper.text()).toContain('新的海外接柜仓必须与当前仓不同')
warehouseSelect.vm.$emit('update:modelValue', '9007199254740996')
await wrapper.vm.$nextTick()
expect(confirm.disabled).toBe(false)
confirm.click()
await flushPromises()
expect(changeContainerReceivingWarehouse).toHaveBeenCalledWith(bookingId, {
overseasWarehouseId: '9007199254740996', expectedVersion: 7, remark: undefined,
}, expect.stringMatching(/^container-receiving-warehouse-/))
expect(listContainerPlanProgress).toHaveBeenCalledTimes(2)
})
it('keeps the receiving-warehouse confirmation disabled while candidates are loading', async () => {
let resolveCandidates!: (value: typeof receivingWarehouse[]) => void
vi.mocked(listAllPlatformWarehouses).mockReturnValueOnce(new Promise((resolve) => { resolveCandidates = resolve }) as never)
const wrapper = await mountView()
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
expect(confirm.disabled).toBe(true)
expect(listAllPlatformWarehouses).toHaveBeenCalledTimes(1)
resolveCandidates([receivingWarehouse])
await flushPromises()
expect(confirm.disabled).toBe(true)
})
it('keeps a disabled receiving-warehouse action discoverable when the server locks the container', async () => {
vi.mocked(listContainerPlanProgress).mockResolvedValueOnce({
records: [{ ...summary, canChangeReceivingWarehouse: false, receivingWarehouseLockReason: '海外仓已开始拆柜收货' }],
page: 1, size: 10, total: 1, pages: 1,
})
const wrapper = await mountView()
await openHiddenContainerWorkbench(wrapper)
expect(wrapper.text()).toContain('海外仓已开始拆柜收货')
const changeButton = button(wrapper, '修改接柜仓')!
expect(changeButton.exists()).toBe(true)
expect((changeButton.element as HTMLButtonElement).disabled).toBe(true)
expect(changeButton.attributes('title')).toBe('海外仓已开始拆柜收货')
expect(changeContainerReceivingWarehouse).not.toHaveBeenCalled()
})
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 openHiddenContainerWorkbench(wrapper)
const changeButton = button(wrapper, '修改接柜仓')!
expect(changeButton.exists()).toBe(true)
expect((changeButton.element as HTMLButtonElement).disabled).toBe(true)
expect(changeButton.attributes('title')).toBe('暂无修改接柜仓权限,请联系管理员开通尾程订单编辑权限')
await changeButton.trigger('click')
expect(listAllPlatformWarehouses).not.toHaveBeenCalled()
expect(changeContainerReceivingWarehouse).not.toHaveBeenCalled()
})
it('keeps conflict metadata visible and refreshes the stale container summary', async () => {
vi.mocked(listAllPlatformWarehouses).mockResolvedValueOnce([receivingWarehouse] as never)
vi.mocked(changeContainerReceivingWarehouse).mockRejectedValueOnce(new ApiResponseError(
'version conflict', 'A0409', 200, undefined, 'request-receiving', 'trace-receiving',
))
const wrapper = await mountView()
await openHiddenContainerWorkbench(wrapper)
await button(wrapper, '修改接柜仓')!.trigger('click')
await flushPromises()
const warehouseSelect = wrapper.findAllComponents({ name: 'ElSelect' }).find((item) => item.props('placeholder') === '请选择 ACTIVE 海外接柜仓')!
warehouseSelect.vm.$emit('update:modelValue', '9007199254740996')
await wrapper.vm.$nextTick()
const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认修改')) as HTMLButtonElement
confirm.click()
await flushPromises()
expect(wrapper.text()).toContain('接柜仓信息已被其他操作更新,请刷新后重试')
expect(wrapper.text()).toContain('request-receiving')
expect(wrapper.text()).toContain('trace-receiving')
expect(listContainerPlanProgress).toHaveBeenCalledTimes(2)
})
it('renders an explicit empty state when there are no ACTIVE OVERSEAS receiving warehouses', async () => {
vi.mocked(listAllPlatformWarehouses).mockResolvedValueOnce([])
const wrapper = await mountView()
await openHiddenContainerWorkbench(wrapper)
await button(wrapper, '修改接柜仓')!.trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('暂无可用的 ACTIVE 海外接柜仓')
expect(changeContainerReceivingWarehouse).not.toHaveBeenCalled()
})
it('keeps receiving-warehouse candidate failure metadata visible without an FBA fallback', async () => {
vi.mocked(listAllPlatformWarehouses).mockRejectedValueOnce(new ApiResponseError(
'receiving warehouse candidate failed', 'A0503', 200, undefined, 'request-receiving-options', 'trace-receiving-options',
))
const wrapper = await mountView()
await openHiddenContainerWorkbench(wrapper)
await button(wrapper, '修改接柜仓')!.trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('request-receiving-options')
expect(document.body.textContent).toContain('trace-receiving-options')
expect(document.body.textContent).not.toContain('请选择目的 FBA 仓')
expect(changeContainerReceivingWarehouse).not.toHaveBeenCalled()
})
it('keeps dense cabinet fields and actions on one line with ellipsis at 1920px', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/LastMileOrdersView.vue'), 'utf8')
expect(source).toContain('.container-detail-summary > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }')
expect(source).toContain('.container-workbench-card__warehouse strong { min-width: 0; overflow: hidden; color: var(--workbench-text); font-size: var(--admin-font-section); text-overflow: ellipsis; white-space: nowrap; }')
expect(source).toContain('.container-workbench-card__actions { display: flex; justify-content: flex-end; padding-top: 2px; white-space: nowrap; }')
expect(source).toContain("{ key: 'actions', label: '操作', width: 330, fixed: 'left'")
})
it('loads only active FBA master data and submits exact string fields for a cross-customer transfer', async () => {
const wrapper = await openAfterDeparture()
await selectRows(wrapper, [selectedRow(), selectedRow({ id: '2092873463568596995', customerId: '2092618717861048322' })])
await button(wrapper, '转仓')!.trigger('click')
await flushPromises()
expect(listAllPlatformWarehouses).toHaveBeenCalledWith({ warehouseType: 'FBA', status: 'ACTIVE' })
const warehouseSelect = wrapper.findAllComponents({ name: 'ElSelect' }).find((item) => item.props('placeholder') === '请选择目的 FBA 仓')!
expect(warehouseSelect).toBeDefined()
const option = wrapper.findAllComponents({ name: 'ElOption' }).find((item) => item.props('value') === '9007199254740993')!
expect(option.props('label')).toBe('ONT8 · Ontario FBA · CA / Ontario')
warehouseSelect.vm.$emit('update:modelValue', '9007199254740993')
await wrapper.vm.$nextTick()
const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认转仓')) as HTMLButtonElement
expect(confirm.disabled).toBe(false)
confirm.click()
await flushPromises()
expect(transferLastMileOrders).toHaveBeenCalledWith({
orderIds: ['2092873463568596994', '2092873463568596995'],
warehouseId: '9007199254740993', warehouseCode: 'ONT8', warehouseName: 'Ontario FBA', remark: undefined,
})
expect(vi.mocked(transferLastMileOrders).mock.calls[0]?.[0].warehouseId).toBeTypeOf('string')
expect(wrapper.html()).not.toContain('请输入目的仓 ID')
})
it('clears all selected warehouse facts when the FBA candidate is cleared', async () => {
const wrapper = await openAfterDeparture()
await selectRows(wrapper, [selectedRow()])
await button(wrapper, '转仓')!.trigger('click')
await flushPromises()
const warehouseSelect = wrapper.findAllComponents({ name: 'ElSelect' }).find((item) => item.props('placeholder') === '请选择目的 FBA 仓')!
warehouseSelect.vm.$emit('update:modelValue', '9007199254740993')
await wrapper.vm.$nextTick()
warehouseSelect.vm.$emit('update:modelValue', '')
await wrapper.vm.$nextTick()
const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认转仓')) as HTMLButtonElement
expect(confirm.disabled).toBe(true)
expect(transferLastMileOrders).not.toHaveBeenCalled()
})
it('renders an explicit empty state when there are no active FBA candidates', async () => {
vi.mocked(listAllPlatformWarehouses).mockResolvedValueOnce([])
const wrapper = await openAfterDeparture()
await selectRows(wrapper, [selectedRow()])
await button(wrapper, '转仓')!.trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('暂无可用 FBA 仓候选')
expect(transferLastMileOrders).not.toHaveBeenCalled()
})
it('keeps candidate load request metadata visible and prevents free-form fallback', async () => {
vi.mocked(listAllPlatformWarehouses).mockRejectedValueOnce(new ApiResponseError(
'FBA candidate failed', 'A0503', 200, undefined, 'request-fba', 'trace-fba',
))
const wrapper = await openAfterDeparture()
await selectRows(wrapper, [selectedRow()])
await button(wrapper, '转仓')!.trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('A0503')
expect(document.body.textContent).toContain('request-fba')
expect(document.body.textContent).toContain('trace-fba')
expect(wrapper.html()).not.toContain('请输入目的仓 ID')
expect(wrapper.html()).not.toContain('请输入目的仓代码')
expect(wrapper.html()).not.toContain('请输入目的仓名称')
expect(transferLastMileOrders).not.toHaveBeenCalled()
})
it('keeps the return packing entry and excludes return orders from manual inbound generation', async () => {
const wrapper = await mountView()
await selectRows(wrapper, [selectedRow({ orderTypeCode: 'RETURN_RELABEL', currentStatusCode: 'INBOUND_COMPLETED', sourceOrderNo: 'RETURN-1' })])
const actions = wrapper.get('.data-table__business-actions')
expect(actions.text()).not.toContain('入库')
const packing = actions.findAll('button').find(action => action.text().includes('创建退货合箱计划(1)'))!
await packing.trigger('click')
await flushPromises()
expect(routerPush).toHaveBeenCalledWith({ name: 'overseas-return-inventory', query: expect.objectContaining({ packing: '1', returnNos: 'RETURN-1' }) })
})
it('keeps manual-plan drafts through native fullscreen with plain frozen source facts', async () => {
const wrapper = await mountView()
await openArrivalPlan(wrapper)
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('入库计划 ·'))!
expect(dialog.props('width')).toBe('min(1160px, calc(100vw - 48px))')
expect(dialog.findComponent({ name: 'ElDialog' }).props('top')).toBe('6vh')
expect(dialog.get('[aria-label="入库来源"] h3').text()).toContain('来源摘要')
expect(dialog.get('[aria-label="入库计划登记"] h3').text()).toBe('计划设置')
const sourceRows = dialog.findAll('.inbound-plan-source tbody tr')
expect(sourceRows[0].get('td').attributes('colspan')).toBe('3')
expect(sourceRows[1].findAll('th').map(cell => cell.text())).toEqual(['计划入库仓', '入仓码'])
expect(dialog.props('confirmText')).toBe('保存并生成入库单')
expect(dialog.props('title')).toContain('LMO-260827-0001')
expect(dialog.get('[data-tone="source"]').find('input').exists()).toBe(false)
await dialog.get('textarea').setValue('全屏后保留')
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe('全屏后保留')
})
it('locks manual-plan form edits and preserves a failed draft and request context', async () => {
let reject!: (reason: unknown) => void
vi.mocked(createManualInboundPlan).mockReturnValue(new Promise((_, no) => { reject = no }))
const wrapper = await mountView()
await openArrivalPlan(wrapper)
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('入库计划 ·'))!
await dialog.get('textarea').setValue('保留入库备注')
await dialog.findAll('button').find(item => item.text() === '保存并生成入库单')!.trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
expect(dialog.get('textarea').attributes('disabled')).toBeDefined()
reject(new ApiResponseError('入库失败', 'TEST_REJECTED', 409, null, 'plan-test-request'))
await flushPromises()
expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe('保留入库备注')
expect(dialog.props('modelValue')).toBe(true)
expect(document.body.textContent).toContain('plan-test-request')
})
it.each([false, true])('keeps hold/release flags, reason and zero version for detained=%s', async detained => {
const wrapper = await mountView()
const row = selectedRow({ detained, currentStatusCode: 'INBOUND_COMPLETED', version: 0 })
const vm = wrapper.vm as unknown as { openHold(row: object): void }
vm.openHold(row); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith(detained ? '解除暂扣 ·' : '暂扣订单 ·'))!
await dialog.get('textarea').setValue(' 原因 ')
dialog.findAllComponents({ name: 'ElRadioGroup' })[1]!.vm.$emit('update:modelValue', false)
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
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('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<void> }).openManualNodes(row)
await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('修改节点 ·'))!
expect(dialog.findAll('.manual-nodes-editor tbody tr').map(row => row.find('th').text())).toEqual(['国内报关完成', '国外清关完成'])
const dates = dialog.findAllComponents({ name: 'ElDatePicker' })
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: '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-'))
})
it('keeps every destination group in the main preview body through fullscreen', async () => {
const original = await getContainerInboundPlanPreview(bookingId)
const group = original.groups[0]!
vi.mocked(getContainerInboundPlanPreview).mockResolvedValue({ ...original, totalOrderCount: 13, readyOrderCount: 13, pendingOrderCount: 13, pendingPalletCount: 13, sourcePalletCount: 13, supplementPalletCount: 0,
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 openAfterDeparture()
await openContainerDialog(wrapper); await chooseContainer(wrapper)
const dialog = cabinetDialog(wrapper)
expect(dialog.findAll('[aria-label="目的地分组明细"]')).toHaveLength(13)
expect(dialog.findAll('.el-table__body-wrapper tr')).toHaveLength(13)
expect(dialog.text()).toContain('业务单-12')
expect(dialog.find('.container-preview__scroll').exists()).toBe(false)
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
})
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 openAfterDeparture()
await selectRows(wrapper, [selectedRow()])
await button(wrapper, '转仓')!.trigger('click'); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('转仓 ·'))!
dialog.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', fbaWarehouse.id)
await dialog.get('textarea').setValue('保留转仓备注')
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
await dialog.findAll('button').find(item => item.text() === '确认转仓')!.trigger('click'); await flushPromises()
expect(dialog.get('textarea').attributes('disabled')).toBeDefined()
reject(new ApiResponseError('转仓失败', 'TEST_REJECTED', 409, null, 'transfer-test-request')); await flushPromises()
expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe('保留转仓备注')
expect(dialog.props('modelValue')).toBe(true)
})
it('keeps rework selection and reason through fullscreen without issuing a command', async () => {
const wrapper = await mountView()
;(wrapper.vm as unknown as { reworkRelabel(row: object): void }).reworkRelabel(selectedRow({ relabelOrderNo: 'RL-TEST-1', relabelStatusCode: 'CUSTOMER_CONFIRM_PENDING' }))
await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('换标退回重做 ·'))!
expect(dialog).toBeDefined()
await dialog.get('textarea').setValue('保留重做原因')
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe('保留重做原因')
expect(dialog.findComponent({ name: 'ElSelect' }).props('modelValue')).toBe('RL-TEST-1')
})
})
describe('tail-leg pallet merge toolbar', () => {
beforeEach(() => {
canAccess.mockImplementation(() => true)
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [], page: 1, size: 10, total: 0, pages: 0 })
vi.mocked(updateLastMileOrderPalletMerge).mockReset().mockResolvedValue(null)
})
it('submits the selected group and cancellation with the customer scope', async () => {
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as Awaited<ReturnType<typeof ElMessageBox.confirm>>)
const wrapper = await mountView()
const row = { id: '2092873463568596994', customerId, currentStatusCode: 'PENDING_ASSIGNMENT', palletMergeFlag: '' }
expect(wrapper.findAll('button').find(b => b.text() === '合托')!.attributes('disabled')).toBeDefined()
await selectRows(wrapper, [row])
await wrapper.findAll('button').find(b => b.text() === '合托')!.trigger('click')
await flushPromises()
expect(updateLastMileOrderPalletMerge).toHaveBeenLastCalledWith([row.id], customerId, true)
await selectRows(wrapper, [{ ...row, palletMergeFlag: 'REQUIRED' }])
await wrapper.findAll('button').find(b => b.text() === '取消合托')!.trigger('click')
await flushPromises()
expect(updateLastMileOrderPalletMerge).toHaveBeenLastCalledWith([row.id], customerId, false)
})
it('blocks completed and mixed-customer selections and hides unauthorized actions', async () => {
const wrapper = await mountView()
for (const rows of [
[{ id: '1', customerId, currentStatusCode: 'INBOUND_COMPLETED', palletMergeFlag: 'REQUIRED' }],
[{ id: '1', customerId, palletMergeFlag: 'MERGED' }],
[{ id: '1', customerId }, { id: '2', customerId: 'other' }],
]) {
await selectRows(wrapper, rows)
expect(wrapper.findAll('button').find(b => b.text() === '合托')!.attributes('disabled')).toBeDefined()
expect(wrapper.findAll('button').find(b => b.text() === '取消合托')!.attributes('disabled')).toBeDefined()
}
canAccess.mockImplementation(() => false)
const denied = await mountView()
expect(denied.findAll('button').some(b => b.text() === '合托' || b.text() === '取消合托')).toBe(false)
})
})