1450 lines
88 KiB
TypeScript
1450 lines
88 KiB
TypeScript
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)
|
||
})
|
||
})
|