Files
admin-web/src/modules/container-loading/pages/ContainersView.test.ts
T
2026-09-30 14:01:58 +08:00

838 lines
59 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.
import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { createMemoryHistory, createRouter, RouterView } from 'vue-router'
import { defineComponent } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import ContainersView from './ContainersView.vue'
import { listAllPlatformWarehouses } from '@/modules/warehouses'
import { listPlatformPartners } from '@/modules/master-data'
import { listSuppliers } from '@/modules/suppliers'
import { getOceanBooking, listOceanBookingLinkCandidates, listOceanBookings } from '@/modules/ocean-bookings'
import { getContainer, getContainerOverview, listContainers, updateContainer } from '../api/platformContainerLoading'
import OceanBookingLinkDialog from '../components/OceanBookingLinkDialog.vue'
import { getTerminalDrayage, getTerminalDrayageHistory, recordTerminalDrayageEmptyReturn, selectTerminalDrayageFleet, sendTerminalDrayageEmail, getTerminalDrayageEmails } from '../api/terminalDrayages'
import { listContainerArrivalTimes, updateContainerExpectedArrival } from '../api/containerArrivalTimes'
import AppDialog from '@/shared/components/AppDialog.vue'
import { containerLoadingRoutes } from '../routes'
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
vi.mock('@/modules/master-data', () => ({ listPlatformPartners: vi.fn() }))
vi.mock('@/modules/suppliers', () => ({ listSuppliers: vi.fn() }))
vi.mock('@/modules/ocean-bookings', () => ({ getOceanBooking: vi.fn(), listOceanBookingLinkCandidates: vi.fn(), listOceanBookings: vi.fn(), listWarehouseLogisticsChannels: vi.fn() }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) }))
const accessCan = vi.hoisted(() => vi.fn())
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: accessCan }) }))
vi.mock('../api/platformContainerLoading', () => ({ listContainers: vi.fn(), getContainer: vi.fn(), getContainerOverview: vi.fn(), getContainerClearance: vi.fn(), uploadContainerClearance: vi.fn(), completeContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(), downloadContainerDocument: vi.fn(), downloadContainerPdaPhoto: vi.fn(), markContainerException: vi.fn(), cancelContainerException: vi.fn(), createContainerSimulation: vi.fn(), linkContainerOceanBooking: vi.fn(), unlinkContainerOceanBooking: vi.fn(), updateContainer: vi.fn() }))
vi.mock('../api/terminalDrayages', () => ({ sendTerminalDrayageEmail: vi.fn(), getTerminalDrayageEmails: vi.fn(), retryTerminalDrayageEmail: vi.fn(), listTerminalDrayages: vi.fn(), getTerminalDrayage: vi.fn(), getTerminalDrayageHistory: vi.fn(), selectTerminalDrayageFleet: vi.fn(), scheduleTerminalDrayagePickup: vi.fn(), recordTerminalDrayageEmptyReturn: vi.fn() }))
vi.mock('../api/containerArrivalTimes', () => ({ listContainerArrivalTimes: vi.fn(), updateContainerExpectedArrival: vi.fn() }))
const drayage = {
containerBookingId: '9001', containerNo: 'MSCU-1', oceanBookingId: '101', status: 'UNLOADED' as const, version: 3,
fleetSupplierId: '501', fleetSupplierName: '港运车队', pickupAppointmentAt: '2026-09-07T08:00:00', actualPickupAt: '2026-09-07T09:00:00',
estimatedEmptyReturnAt: '2026-09-10T10:00:00', actualEmptyReturnAt: null, unloadingCompletedAt: '2026-09-07T10:00:00',
emptyReturnWarning: { status: 'OVERDUE' as const, deadlineAt: '2026-09-10T10:00:00', overdueHours: 26, overdueDays: 1 },
allowedActions: ['SELECT_FLEET', 'RECORD_EMPTY_RETURN'] as const, updatedAt: '2026-09-11T12:00:00',
}
function createTestRouter() {
return createRouter({ history: createMemoryHistory(), routes: [
{ path: '/', name: 'domestic-container-workbench', component: ContainersView },
{ path: '/container-loading/domestic/:containerId/smart-loading', name: 'smart-loading-workbench', component: { template: '<div />' } },
{ path: '/loading-plans', name: 'loading-plans', component: { template: '<div />' } },
{ path: '/container-loading/domestic/:containerId', name: 'domestic-container-detail', component: { template: '<div />' } },
{ path: '/container-loading/overseas/:containerId', name: 'overseas-container-detail', component: { template: '<div />' } },
] })
}
async function mountOverseasRoute() {
const router = createRouter({
history: createMemoryHistory(),
routes: containerLoadingRoutes.map((route) => ({ ...route, path: `/${route.path}` })),
})
await router.push('/container-loading/overseas')
await router.isReady()
const wrapper = mount(RouterView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
await flushPromises()
return wrapper
}
describe('ContainersView', () => {
beforeEach(() => {
setActivePinia(createPinia())
accessCan.mockReset(); accessCan.mockReturnValue(true)
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([])
vi.mocked(listPlatformPartners).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 })
vi.mocked(listSuppliers).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 })
vi.mocked(listOceanBookings).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 })
vi.mocked(listOceanBookingLinkCandidates).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
vi.mocked(getOceanBooking).mockResolvedValue({} as never)
vi.mocked(getTerminalDrayage).mockResolvedValue({ ...drayage, allowedActions: [...drayage.allowedActions] })
vi.mocked(getTerminalDrayageHistory).mockResolvedValue([])
vi.mocked(selectTerminalDrayageFleet).mockResolvedValue({ ...drayage, fleetSupplierId: '502', fleetSupplierName: '新车队', version: 4, allowedActions: [...drayage.allowedActions] })
vi.mocked(recordTerminalDrayageEmptyReturn).mockReset().mockResolvedValue({ ...drayage, status: 'EMPTY_RETURNED', actualEmptyReturnAt: '2026-09-11T13:00:00', version: 4, emptyReturnWarning: { status: 'RESOLVED', deadlineAt: '2026-09-10T10:00:00', overdueHours: 0, overdueDays: 0 }, allowedActions: ['SELECT_FLEET'] })
vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0 }])
vi.mocked(updateContainerExpectedArrival).mockReset().mockResolvedValue({ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T16:00:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 1 })
vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER', 'SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_EMPTY_RETURN'], terminalDrayage: drayage, oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(getContainer).mockResolvedValue({ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null })
vi.mocked(getContainerOverview).mockResolvedValue({ container: { id: '9001', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'WAIT_LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: null, vesselName: null, version: 0 }, loadingPlanId: null, pda: null, documents: [], exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [] })
})
it('loads the real paged contract and renders core container fields', async () => {
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
expect(listContainers).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 20, scope: 'ACTIVE' }), 'DOMESTIC')
expect(wrapper.text()).toContain('待装柜')
expect(wrapper.text()).toContain('CB-1')
expect(wrapper.text()).toContain('MSCU-1')
expect(wrapper.text()).toContain('宁波 → 长滩')
expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(true)
expect(wrapper.text()).not.toContain('报空预警')
})
it('sends pickup mail from the list once while pending and shows disabled delivery in history', async () => {
const original = await listContainers({ page: 1, size: 20 }, 'OVERSEAS')
vi.mocked(listContainers).mockResolvedValue({ ...original, records: original.records.map((row) => ({
...row, allowedActions: ['SEND_PICKUP_EMAIL'], terminalDrayage: { ...drayage, allowedActions: ['SEND_PICKUP_EMAIL'] },
})) })
let resolveSend!: (value: Awaited<ReturnType<typeof sendTerminalDrayageEmail>>) => void
vi.mocked(sendTerminalDrayageEmail).mockReset().mockReturnValue(new Promise((resolve) => { resolveSend = resolve }))
vi.mocked(getTerminalDrayageEmails).mockResolvedValue({ deliveryEnabled: false, records: [] })
const wrapper = await mountOverseasRoute()
const button = wrapper.findAll('.row-actions button').find((item) => item.text().includes('发送邮件'))!
expect(button.exists()).toBe(true)
await button.trigger('click'); await button.trigger('click')
expect(sendTerminalDrayageEmail).toHaveBeenCalledTimes(1)
expect(sendTerminalDrayageEmail).toHaveBeenCalledWith('9001', 3)
resolveSend({ ...drayage, version: 4, allowedActions: ['SEND_PICKUP_EMAIL'] })
await flushPromises()
await wrapper.find('.el-table__body button[aria-label="更多货柜操作"]').trigger('click')
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('.el-dropdown-menu__item')).filter((item) => item.textContent?.includes('邮件记录')).at(-1)!.click()
await flushPromises()
expect(getTerminalDrayageEmails).toHaveBeenCalledWith('9001')
expect(document.body.textContent).toContain('发信尚未启用')
wrapper.unmount()
})
it.each([3, 0])('submits direct empty return with server version %s, including unload without pickup', async (version) => {
const original = await listContainers({ page: 1, size: 20 }, 'OVERSEAS')
vi.mocked(listContainers).mockClear().mockResolvedValue({ ...original, records: original.records.map((row) => ({
...row,
terminalDrayage: { ...drayage, version, actualPickupAt: version ? drayage.actualPickupAt : null,
fleetSupplierId: version ? drayage.fleetSupplierId : null, allowedActions: ['RECORD_EMPTY_RETURN'],
emptyReturnWarning: { status: 'PENDING', deadlineAt: drayage.estimatedEmptyReturnAt, overdueHours: 0, overdueDays: 0 } },
})) })
const wrapper = await mountOverseasRoute()
expect(listContainers).toHaveBeenCalledWith(expect.objectContaining({ scope: 'ACTIVE' }), 'OVERSEAS')
const buttons = wrapper.findAll('button').filter((item) => item.text().includes('确认还柜'))
expect(buttons).toHaveLength(1)
await buttons[0].trigger('click')
await flushPromises()
const returnDialog = wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title') === '确认还柜')!
returnDialog.findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-09-11T13:00:00')
await returnDialog.find('textarea').setValue('还柜凭证已核对')
const callsBefore = vi.mocked(listContainers).mock.calls.length
const page: Awaited<ReturnType<typeof listContainers>> = await vi.mocked(listContainers).mock.results.at(-1)!.value
vi.mocked(listContainers).mockResolvedValueOnce({ ...page, records: page.records.map((row) => ({ ...row, allowedActions: [], terminalDrayage: { ...drayage, status: 'EMPTY_RETURNED', version: 4, allowedActions: [], emptyReturnWarning: { status: 'RESOLVED', deadlineAt: null, overdueHours: 0, overdueDays: 0 } } })) })
const confirm = [...document.body.querySelectorAll('button')].filter((item) => item.textContent?.includes('确认还柜')).at(-1) as HTMLButtonElement
confirm.click()
await flushPromises()
expect(recordTerminalDrayageEmptyReturn).toHaveBeenCalledTimes(1)
expect(recordTerminalDrayageEmptyReturn).toHaveBeenCalledWith('9001', { expectedVersion: version, actualAt: '2026-09-11T13:00:00', remark: '还柜凭证已核对' })
expect(wrapper.text()).toContain('已还柜')
expect(listContainers).toHaveBeenCalledTimes(callsBefore + 1)
expect(wrapper.findAll('.row-actions button').some((button) => button.text() === '确认还柜')).toBe(false)
wrapper.unmount()
})
it.each(['permission', 'row action', 'nested action', 'nested object', 'already returned', 'historical unloaded snapshot'] as const)(
'keeps empty return unavailable on the real overseas route: %s',
async (missing) => {
const page = await listContainers({ page: 1, size: 20 }, 'OVERSEAS')
vi.mocked(listContainers).mockClear()
const nested = missing === 'nested object' ? null : {
...drayage,
allowedActions: missing === 'nested action' || missing === 'already returned' ? [] : [...drayage.allowedActions],
...(missing === 'already returned' ? { status: 'EMPTY_RETURNED' as const, actualEmptyReturnAt: '2026-09-11T13:00:00' } : {}),
...(missing === 'historical unloaded snapshot' ? { status: 'ARRIVED' as const, version: 0, actualPickupAt: null, fleetSupplierId: null, fleetSupplierName: null, allowedActions: ['SELECT_FLEET' as const] } : {}),
}
vi.mocked(listContainers).mockResolvedValue({ ...page, records: page.records.map((row) => ({
...row,
allowedActions: missing === 'row action' || missing === 'already returned' ? [] : ['RECORD_EMPTY_RETURN'],
terminalDrayage: nested,
...(missing === 'historical unloaded snapshot' ? { workbenchStage: 'UNLOADED', allowedActions: ['MANAGE_DESTINATION_DOCUMENTS' as const, 'SELECT_FLEET' as const] } : {}),
})) })
if (missing === 'permission') {
accessCan.mockImplementation((code) => code !== 'platform:firstleg:terminal-drayage:empty-return:confirm')
}
const wrapper = await mountOverseasRoute()
try {
expect(listContainers).toHaveBeenCalledTimes(1)
expect(listContainers).toHaveBeenCalledWith(expect.objectContaining({ scope: 'ACTIVE' }), 'OVERSEAS')
expect(wrapper.text()).toContain('MSCU-1')
expect(wrapper.findAll('.row-actions button').some((button) => button.text() === '确认还柜')).toBe(false)
expect(wrapper.findAllComponents(AppDialog).some((dialog) => dialog.props('title') === '确认还柜' && dialog.props('modelValue'))).toBe(false)
expect(recordTerminalDrayageEmptyReturn).not.toHaveBeenCalled()
expect(getTerminalDrayage).not.toHaveBeenCalled()
if (missing === 'historical unloaded snapshot') {
const fleet = wrapper.findAllComponents({ name: 'ElSelect' }).find((select) =>
select.props('placeholder') === '输入车队名称或联系人搜索' && select.element.closest('.el-table__body'),
)
expect(fleet).toBeDefined()
expect(fleet!.props('disabled')).toBe(false)
}
} finally {
wrapper.unmount()
}
},
)
it('cancels empty return without writing and preserves input on failure while preventing duplicate requests', async () => {
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { props: { workbench: 'OVERSEAS' }, attachTo: document.body, global: { plugins: [ElementPlus, router] } })
await flushPromises()
const open = async () => {
await wrapper.findAll('.row-actions button').find((button) => button.text() === '确认还柜')!.trigger('click')
await flushPromises()
return wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title') === '确认还柜')!
}
const first = await open()
first.vm.$emit('update:modelValue', false)
await flushPromises()
expect(recordTerminalDrayageEmptyReturn).not.toHaveBeenCalled()
const dialog = await open()
const state = wrapper.vm as unknown as { drayageActionAt: string; drayageReason: string; drayageActionOpen: boolean; drayageActionError: { message: string } | null }
state.drayageActionAt = '2026-09-11T13:00:00'
state.drayageReason = '还柜凭证已核对'
let reject!: (reason: Error) => void
vi.mocked(recordTerminalDrayageEmptyReturn).mockReturnValueOnce(new Promise((_resolve, rejectRequest) => { reject = rejectRequest }))
dialog.vm.$emit('confirm')
dialog.vm.$emit('confirm')
expect(recordTerminalDrayageEmptyReturn).toHaveBeenCalledTimes(1)
expect(recordTerminalDrayageEmptyReturn).toHaveBeenCalledWith('9001', { expectedVersion: 3, actualAt: '2026-09-11T13:00:00', remark: '还柜凭证已核对' })
reject(new Error('还柜时间不符合业务要求'))
await flushPromises()
expect(state.drayageActionOpen).toBe(true)
expect(state.drayageActionAt).toBe('2026-09-11T13:00:00')
expect(state.drayageReason).toBe('还柜凭证已核对')
expect(state.drayageActionError).not.toBeNull()
wrapper.unmount()
})
it('hides the direct empty-return action without permission or without the server action', async () => {
const router = createTestRouter()
await router.push('/'); await router.isReady()
accessCan.mockImplementation((code) => code !== 'platform:firstleg:terminal-drayage:empty-return:confirm')
const wrapper = mount(ContainersView, { props: { workbench: 'OVERSEAS' }, global: { plugins: [ElementPlus, router] } })
await flushPromises()
expect(wrapper.findAll('button').some((item) => item.text().includes('确认还柜'))).toBe(false)
wrapper.unmount()
accessCan.mockReturnValue(true)
const allowedWrapper = mount(ContainersView, { props: { workbench: 'OVERSEAS' }, global: { plugins: [ElementPlus, router] } })
await flushPromises()
const vm = allowedWrapper.vm as unknown as { rows: Array<{ allowedActions?: string[] }> }
vm.rows[0].allowedActions = vm.rows[0].allowedActions?.filter((action) => action !== 'RECORD_EMPTY_RETURN')
await allowedWrapper.vm.$nextTick()
expect(allowedWrapper.findAll('button').some((item) => item.text().includes('确认还柜'))).toBe(false)
})
it('defers form options until their dialogs open and caches successful loads', async () => {
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
await flushPromises()
expect(listAllPlatformWarehouses).not.toHaveBeenCalled()
expect(listPlatformPartners).not.toHaveBeenCalled()
expect(listSuppliers).not.toHaveBeenCalled()
expect(listOceanBookings).not.toHaveBeenCalled()
await wrapper.findAll('button').find((button) => button.text().includes('新增模拟柜'))!.trigger('click')
await flushPromises()
expect(listAllPlatformWarehouses).toHaveBeenCalledTimes(1)
expect(listPlatformPartners).not.toHaveBeenCalled()
expect(listSuppliers).not.toHaveBeenCalled()
expect(listOceanBookings).not.toHaveBeenCalled()
wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title') === '新增模拟柜')!.vm.$emit('update:modelValue', false)
await wrapper.findAll('button').find((button) => button.text().includes('新增模拟柜'))!.trigger('click')
await flushPromises()
expect(listAllPlatformWarehouses).toHaveBeenCalledTimes(1)
await wrapper.findAll('button').find((button) => button.text().trim() === '编辑')!.trigger('click')
await flushPromises()
expect(listAllPlatformWarehouses).toHaveBeenCalledTimes(1)
expect(listPlatformPartners).toHaveBeenCalledTimes(1)
expect(listSuppliers).toHaveBeenCalledTimes(1)
expect(listOceanBookings).toHaveBeenCalledTimes(1)
wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title') === '编辑')!.vm.$emit('update:modelValue', false)
await wrapper.findAll('button').find((button) => button.text().trim() === '编辑')!.trigger('click')
await flushPromises()
expect(listPlatformPartners).toHaveBeenCalledTimes(1)
expect(listSuppliers).toHaveBeenCalledTimes(1)
expect(listOceanBookings).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
it('keeps the cross-warehouse list usable when lazy warehouse form options fail', async () => {
vi.mocked(listAllPlatformWarehouses).mockRejectedValueOnce(new Error('warehouse unavailable'))
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
expect(listAllPlatformWarehouses).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('CB-1')
expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(true)
await wrapper.findAll('button').find((button) => button.text().includes('新增模拟柜'))!.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('装柜仓库加载失败')
})
it('uses stable view labels when cabinetNo is missing', async () => {
const base = {
id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'WAIT_LOADING' as const, version: 0,
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩',
destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500,
terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
}
vi.mocked(listContainers).mockResolvedValueOnce({
records: [
{ ...base, id: '9002', cabinetNo: undefined, containerNo: 'MSCU-2', bookingNo: 'BK-2' },
{ ...base, id: '9003', cabinetNo: '', containerNo: null, bookingNo: 'BK-3' },
{ ...base, id: '9004', cabinetNo: null, containerNo: null, bookingNo: '' },
],
page: 1, size: 20, total: 3, pages: 1,
} as never)
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
const labels = wrapper.findAll('.el-table__body button')
.map((button) => button.attributes('aria-label'))
.filter((label): label is string => Boolean(label))
expect(labels).toEqual(expect.arrayContaining(['查看 MSCU-2', '查看 BK-3', '查看货柜详情']))
expect(labels.every((label) => !/undefined|null/.test(label))).toBe(true)
})
it('routes each plan status to its owning workflow without reopening pushed drafts', async () => {
const base = {
id: '9001', planId: '9101', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', version: 0,
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩',
destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500,
terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
}
vi.mocked(listContainers).mockResolvedValueOnce({
records: [
{ ...base, planStatus: 'EDITING' as const },
{ ...base, id: '9002', planId: '9102', cabinetNo: 'CB-2', planStatus: 'LOADING' as const },
{ ...base, id: '9003', planId: '9103', cabinetNo: 'CB-3', planStatus: 'COMPLETED' as const },
],
page: 1, size: 20, total: 3, pages: 1,
})
const router = createTestRouter()
await router.push('/'); await router.isReady()
const push = vi.spyOn(router, 'push').mockResolvedValue(undefined as never)
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
expect(wrapper.text()).toContain('装柜')
expect(wrapper.text()).toContain('预装清单')
expect(wrapper.text()).toContain('装柜清单')
await wrapper.findAll('button').find((button) => button.text().trim() === '装柜')!.trigger('click')
await wrapper.findAll('button').find((button) => button.text().includes('预装清单'))!.trigger('click')
await wrapper.findAll('button').find((button) => button.text().includes('装柜清单'))!.trigger('click')
expect(push).toHaveBeenNthCalledWith(1, { name: 'smart-loading-workbench', params: { containerId: '9001' } })
expect(push).toHaveBeenNthCalledWith(2, { name: 'loading-plans', query: { planId: '9102', warehouseId: '8' } })
expect(push).toHaveBeenNthCalledWith(3, { name: 'domestic-container-detail', params: { containerId: '9003' }, query: { tab: 'loading-list' } })
})
it('uses active customer names and sends the stable customerId filter', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })")
expect(source).toContain("label: customer.tenantName")
expect(source).toContain('customerId: applied.value.customerId || undefined')
expect(source).not.toContain('customer.tenantCode')
})
it('uses the list permission for detail while keeping edit independent', async () => {
accessCan.mockImplementation((permission: string) => permission === 'platform:firstleg:container:list')
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(true)
expect(wrapper.find('.el-table__body button[aria-label="更多货柜操作"]').exists()).toBe(false)
expect(wrapper.findAll('button').some((button) => button.text().trim() === '编辑')).toBe(false)
})
it('does not accept the removed standalone view permission', async () => {
accessCan.mockReturnValue(false)
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(false)
expect(wrapper.find('.el-table__body button[aria-label="更多货柜操作"]').exists()).toBe(false)
expect(wrapper.findAll('button').some((button) => button.text().trim() === '编辑')).toBe(false)
})
it('uses business selectors and keeps code/name pairs internal', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain('placeholder="请选择船公司"')
expect(source).toContain('placeholder="请选择起运港"')
expect(source).toContain('placeholder="请选择目的港"')
expect(source).toContain('placeholder="请选择目的仓"')
expect(source).toContain("selectPort('departure'")
expect(source).toContain('selectDestinationWarehouse')
expect(source).toContain('重新加载业务选项')
expect(source).not.toContain('label="起运港代码"')
expect(source).not.toContain('label="起运港名称"')
expect(source).not.toContain('label="目的仓代码"')
expect(source).not.toContain('label="目的仓名称"')
})
it('keeps query and pagination on a single list request path', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain("if (page.value === 1) void loadRows(); else page.value = 1")
expect(source).toContain("if (nextPage !== previousPage || nextSize !== previousSize) void loadRows()")
expect(source).not.toContain('@reset=')
})
it('opens container detail as an independent route while keeping create and edit dialogs', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain("'domestic-container-detail' : 'overseas-container-detail'")
expect(source).not.toContain('<AppDialog v-model="detailOpen"')
expect(source).toContain('<AppDialog v-model="editorOpen" class="container-editor-dialog admin-density-dialog"')
expect(source).toContain('width="min(820px, calc(100vw - 28px))"')
expect(source).toContain('admin-density-dialog')
expect(source).toContain('grid-template-columns: repeat(2, minmax(0, 1fr))')
expect(source).not.toContain('AppDrawer')
expect(source).not.toContain('<AppDrawer v-model="editorOpen"')
})
it('registers a list-authorized independent container detail page', () => {
const routes = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/routes.ts'), 'utf8')
const detail = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8')
expect(routes).toContain("path: 'container-loading/domestic/:containerId'")
expect(routes).toContain("name: 'domestic-container-detail'")
expect(routes).toContain("name: 'overseas-container-detail'")
expect(routes).toContain("permission: 'platform:firstleg:container:list'")
expect(detail).toContain('getContainer(containerId.value, props.workbench)')
expect(detail).toContain('getContainerOverview(containerId.value)')
expect(detail).toContain('<ContainerOverviewPanel')
expect(detail).toContain("@upload-document=\"openDocument\"")
expect(detail).toContain("@manage-exception=\"openException\"")
expect(detail).not.toContain("@preview-photo=\"previewPhoto\"")
expect(detail).toContain('<ContainerOverviewPanel')
})
it('keeps the prototype field sequence ahead of contract-only supplemental fields', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
const labels = ['真实集装箱号', '柜次号', '装柜仓库', '提单号', '船名', '航次', '箱型(货柜类型)', '船公司', '起运港', '目的港', '目的仓', '货柜容量(m³)']
let previous = -1
for (const label of labels) {
const current = source.indexOf(`label="${label}"`, previous + 1)
expect(current, label).toBeGreaterThan(previous)
previous = current
}
})
it('renders container business actions as buttons without changing the transport gate', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain("import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'")
expect(source).toContain("{ key: 'view', label: '查看', ariaLabel: containerViewLabel(row), priority: 500")
expect(source).toContain("hasAction(row, 'EDIT_CONTAINER')")
expect(source).toContain('<RowActions text-actions :actions="containerRowActions(row as ContainerWorkbenchRow)"')
expect(source).not.toContain("row.containerNo ? '修改集装箱号' : '录入集装箱号'")
expect(source).toContain('货柜基础资料不提供柜号写入')
expect(source).toContain('containerNo: optional(detail.value?.containerNo || null)')
expect(source).not.toContain('containerNo: optional(form.containerNo)')
expect(source).not.toContain('updateContainerTransport')
expect(source).toContain("row.planStatus === 'EDITING' ? canSmartLoading.value")
expect(source).not.toContain('canSmartLoading && row.containerNo')
expect(source).not.toContain('link type="primary" :aria-label="containerViewLabel')
expect(source).toContain(':loading="loading" preference-key="container-loading-table"')
expect(source).toContain('appearance="integrated" :stripe="false"')
})
it('updates an ACTIVE TRUCKING fleet inline and records actual empty return once', async () => {
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '502', name: '新车队', category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { props: { workbench: 'OVERSEAS' }, global: { plugins: [ElementPlus, router] } })
await flushPromises()
const vm = wrapper.vm as unknown as {
rows: Array<Record<string, unknown>>
searchTerminalFleets: (keyword?: string) => Promise<void>
selectRowFleet: (row: Record<string, unknown>, supplierId: string) => Promise<void>
openDrayageAction: (row: Record<string, unknown>, action: 'RECORD_EMPTY_RETURN') => void
drayageActionAt: string
saveDrayageAction: () => Promise<void>
}
const beforeFleet = await listContainers({ page: 1, size: 20 }, 'OVERSEAS')
vi.mocked(listContainers).mockResolvedValueOnce({ ...beforeFleet, records: beforeFleet.records.map((row) => ({ ...row, terminalDrayage: { ...drayage, version: 4, fleetSupplierId: '502', allowedActions: [...drayage.allowedActions] } })) })
await vm.searchTerminalFleets('新车队')
await vm.selectRowFleet(vm.rows[0], '502')
expect(selectTerminalDrayageFleet).toHaveBeenCalledWith('9001', { expectedVersion: 3, fleetSupplierId: '502', fleetSupplierName: '新车队' })
vm.openDrayageAction(vm.rows[0], 'RECORD_EMPTY_RETURN')
vm.drayageActionAt = '2026-09-11T13:00:00'
const page: Awaited<ReturnType<typeof listContainers>> = await vi.mocked(listContainers).mock.results.at(-1)!.value
vi.mocked(listContainers).mockResolvedValueOnce({ ...page, records: page.records.map((row) => ({ ...row, allowedActions: [], terminalDrayage: { ...drayage, status: 'EMPTY_RETURNED', version: 5, allowedActions: [], emptyReturnWarning: { status: 'RESOLVED', deadlineAt: null, overdueHours: 0, overdueDays: 0 } } })) })
await vm.saveDrayageAction()
expect(recordTerminalDrayageEmptyReturn).toHaveBeenCalledWith('9001', { expectedVersion: 4, actualAt: '2026-09-11T13:00:00', remark: undefined })
expect(wrapper.text()).toContain('已还柜')
wrapper.unmount()
})
it('searches active terminal fleets remotely and ignores an older response', async () => {
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { props: { workbench: 'OVERSEAS' }, global: { plugins: [ElementPlus, router] } })
await flushPromises()
let resolveOld!: (value: Awaited<ReturnType<typeof listSuppliers>>) => void
let resolveLatest!: (value: Awaited<ReturnType<typeof listSuppliers>>) => void
vi.mocked(listSuppliers).mockReset()
.mockReturnValueOnce(new Promise((resolve) => { resolveOld = resolve }))
.mockReturnValueOnce(new Promise((resolve) => { resolveLatest = resolve }))
const vm = wrapper.vm as unknown as {
terminalFleetOptions: Array<{ id: string; name: string }>
searchTerminalFleets: (keyword?: string) => Promise<void>
}
const oldSearch = vm.searchTerminalFleets('旧关键词')
const latestSearch = vm.searchTerminalFleets('新车队')
expect(listSuppliers).toHaveBeenNthCalledWith(1, { page: 1, size: 20, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE', keyword: '旧关键词' })
expect(listSuppliers).toHaveBeenNthCalledWith(2, { page: 1, size: 20, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE', keyword: '新车队' })
resolveLatest({ records: [{ id: '503', name: '新车队', category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' } as never, { id: '504', name: '国内车队', category: 'TRUCKING', truckingScope: 'DOMESTIC', status: 'ACTIVE' } as never], page: 1, size: 20, total: 2, pages: 1 })
await latestSearch
resolveOld({ records: [{ id: '502', name: '旧车队', category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' } as never], page: 1, size: 20, total: 1, pages: 1 })
await oldSearch
expect(vm.terminalFleetOptions).toEqual([expect.objectContaining({ id: '503', name: '新车队' })])
wrapper.unmount()
})
it('loads terminal drayage detail and history only when requested', async () => {
vi.mocked(getTerminalDrayageHistory).mockResolvedValueOnce([{ action: 'SELECT_FLEET', fromStatus: 'ARRIVED', toStatus: 'ARRIVED', fromVersion: 0, toVersion: 1, reason: '到港安排', operatorId: '88', operatorName: '管理员', createdAt: '2026-09-07T08:00:00' }])
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
const vm = wrapper.vm as unknown as { rows: Array<Record<string, unknown>>; openDrayageHistory: (row: Record<string, unknown>) => Promise<void> }
await vm.openDrayageHistory(vm.rows[0])
await flushPromises()
expect(getTerminalDrayage).toHaveBeenCalledWith('9001')
expect(getTerminalDrayageHistory).toHaveBeenCalledWith('9001')
expect(wrapper.text()).toContain('到港安排')
wrapper.unmount()
})
it('owns the released-booking entry through the shared dialog without a detail redirect query', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain('<OceanBookingLinkDialog v-model="linkOpen" :container="linkTarget" @linked="loadRows" />')
expect(source).not.toContain('linkContainerId')
})
it('edits the whole-container expected arrival inline and shows PDA receiving start as actual arrival', async () => {
const wrapper = await mountOverseasRoute()
expect(wrapper.text()).toContain('预计到仓')
expect(wrapper.text()).toContain('实际到仓')
expect(wrapper.text()).toContain('2026-09-26 09:00')
const picker = wrapper.findAllComponents({ name: 'ElDatePicker' })
.find((item) => item.props('placeholder') === '选择预计到仓')
expect(picker).toBeDefined()
const vm = wrapper.findComponent(ContainersView).vm as unknown as {
rows: Array<Record<string, unknown>>
saveExpectedArrival: (row: Record<string, unknown>, value: string) => Promise<void>
}
await vm.saveExpectedArrival(vm.rows[0], '2026-09-25T16:00:00')
await flushPromises()
expect(updateContainerExpectedArrival).toHaveBeenCalledWith('9001', {
estimatedWarehouseArrivalAt: '2026-09-25T16:00:00', expectedVersion: 0,
})
})
it.each(['EDITING', 'LOADING', 'COMPLETED'] as const)('keeps released-booking linking reachable alongside the %s process action', async (planStatus) => {
const page = await listContainers({ page: 1, size: 20 }, 'DOMESTIC')
vi.mocked(listContainers).mockResolvedValue({ ...page, records: page.records.map((row) => ({
...row, planStatus, containerNo: null, allowedActions: ['EDIT_CONTAINER'],
})) })
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
try {
await flushPromises()
const actions = wrapper.find('.el-table__body .row-actions')
expect(actions.findAll('button').map((button) => button.text())).toEqual(['查看', '编辑', ({ EDITING: '装柜', LOADING: '预装清单', COMPLETED: '装柜清单' })[planStatus], '更多'])
await actions.find('button[aria-label="更多货柜操作"]').trigger('click')
await flushPromises()
const link = Array.from(document.body.querySelectorAll<HTMLElement>('.el-dropdown-menu__item'))
.filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!
expect(link).toBeDefined()
link.click()
await flushPromises()
expect(wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title') === '关联已放舱订舱')?.props('modelValue')).toBe(true)
expect(listOceanBookingLinkCandidates).toHaveBeenCalledWith({ warehouseId: '8', channelGroupCode: 'MATSON_CLX', containerType: '40HQ', page: 1, size: 200 })
} finally {
wrapper.unmount()
}
})
it('keeps document uploads on the routed detail page without duplicate list implementations', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
const detailSource = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8')
expect(source).not.toContain('uploadLoadingDeclaration')
expect(source).not.toContain('uploadContainerClearance')
expect(source).not.toContain('uploadContainerDocument')
expect(detailSource).not.toContain('uploadLoadingDeclaration')
expect(detailSource).toContain('ContainerCustomsPanel')
expect(detailSource).toContain('uploadContainerClearance')
expect(detailSource).toContain('uploadContainerDocument')
})
it('creates an internal simulation without asking for real booking transport facts', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
const simulationSource = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/components/ContainerSimulationCreate.vue'), 'utf8')
expect(source).toContain('ContainerSimulationCreate')
expect(source).not.toContain('新增货柜')
expect(simulationSource).toContain('新增模拟柜')
expect(simulationSource).toContain('createContainerSimulation({ ...simulationForm })')
expect(simulationSource).toContain("channelGroupCode: '', warehouseId: '', containerType: DEFAULT_CONTAINER_TYPE, capacityCbm: 68, maxWeightKg: 19500")
expect(source).toContain('CONTAINER_SPECIFICATIONS')
expect(simulationSource).toContain('selectSimulationContainerType')
expect(simulationSource).toContain('服务端会生成稳定的柜次号')
expect(source).toContain(':model-value="form.cabinetNo" disabled placeholder="系统生成"')
expect(source).not.toContain('v-model="form.cabinetNo"')
expect(source).not.toContain('cabinetNo: form.cabinetNo.trim()')
expect(source).not.toContain('openCreate')
expect(source).not.toContain('20GP')
expect(source).not.toContain('40HC')
expect(source).not.toContain('40NR')
expect(source).not.toContain('45HC')
expect(source).not.toContain('v-model="form.capacityCbm"')
expect(source).not.toContain('v-model="form.maxWeightKg"')
})
it('filters OB-260825-0183 for CAB-260825-0215 because the real warehouses differ', async () => {
vi.mocked(listContainers).mockResolvedValue({ records: [{
id: '215', planId: null, bookingNo: 'CB-260825-0215', cabinetNo: 'CAB-260825-0215', containerNo: null,
sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '193000000000200002', warehouseName: '东莞仓',
channelGroupCode: 'EXX', channelCode: null, channelName: null, containerType: '45HQ', planStatus: 'EDITING',
capacityCbm: 86, maxWeightKg: 29000, version: 7,
}], page: 1, size: 20, total: 1, pages: 1 } as never)
vi.mocked(listOceanBookingLinkCandidates).mockResolvedValueOnce({ records: [], page: 1, size: 200, total: 0, pages: 0 })
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
await flushPromises()
await wrapper.find('.el-table__body button[aria-label="更多货柜操作"]').trigger('click')
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('.el-dropdown-menu__item'))
.filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!.click()
await flushPromises()
expect(listOceanBookingLinkCandidates).toHaveBeenCalledWith({ warehouseId: '193000000000200002', channelGroupCode: 'EXX', containerType: '45HQ', page: 1, size: 200 })
expect((wrapper.findComponent(OceanBookingLinkDialog).vm as unknown as { candidates: unknown[] }).candidates).toEqual([])
expect(readFileSync(resolve(process.cwd(), 'src/modules/container-loading/components/OceanBookingLinkDialog.vue'), 'utf8'))
.toContain('暂无符合当前装柜仓库、物流渠道和柜型的已放舱需求')
expect(getOceanBooking).not.toHaveBeenCalled()
wrapper.unmount()
})
it.each([undefined, null, '', '客户一'])('renders optional booking customer %s without leaking a missing value', async (customerName) => {
vi.mocked(listOceanBookingLinkCandidates).mockResolvedValueOnce({ records: [{
oceanBookingId: '315', bookingNo: 'OB-OPTIONAL-CUSTOMER', billOfLadingNo: 'BL-OPTIONAL-CUSTOMER', requirementId: '45315',
containerType: '40HQ', remainingQuantity: 1, customerName, warehouseName: '义乌仓', channelName: 'EXX海卡',
}], page: 1, size: 200, total: 1, pages: 1 } as never)
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
await flushPromises()
await wrapper.find('button[aria-label="更多货柜操作"]').trigger('click')
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('.el-dropdown-menu__item'))
.filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!.click()
await flushPromises()
const label = `OB-OPTIONAL-CUSTOMER · 提单号 BL-OPTIONAL-CUSTOMER · 40HQ × 1 · ${customerName || '未关联客户'} · 义乌仓 · EXX海卡`
expect(document.body.textContent).toContain(label)
expect((wrapper.findComponent(OceanBookingLinkDialog).vm as unknown as { candidates: Array<{ label: string }> }).candidates[0]?.label).toBe(label)
wrapper.unmount()
})
it('keeps a three-field match selectable when only capacity snapshots differ and blocks duplicate waves', async () => {
let resolveCandidates!: (value: Awaited<ReturnType<typeof listOceanBookingLinkCandidates>>) => void
vi.mocked(listContainers).mockResolvedValue({ records: [{
id: '315', planId: null, bookingNo: 'CB-CAPACITY-SNAPSHOT', cabinetNo: 'CAB-CAPACITY-SNAPSHOT', containerNo: null,
sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '193000000000200002', warehouseName: '东莞仓',
channelGroupCode: 'EXX', channelCode: null, channelName: null, containerType: '45HQ', planStatus: 'WAIT_LOADING',
capacityCbm: 86, maxWeightKg: 29000, version: 7,
}], page: 1, size: 20, total: 1, pages: 1 } as never)
vi.mocked(listOceanBookingLinkCandidates).mockReturnValueOnce(new Promise((resolve) => { resolveCandidates = resolve }))
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
await flushPromises()
await wrapper.find('.el-table__body button[aria-label="更多货柜操作"]').trigger('click')
await flushPromises()
const linkAction = Array.from(document.body.querySelectorAll<HTMLElement>('.el-dropdown-menu__item'))
.filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!
linkAction.click()
await flushPromises()
linkAction.click()
expect(listOceanBookingLinkCandidates).toHaveBeenCalledTimes(1)
expect(listOceanBookingLinkCandidates).toHaveBeenCalledWith({ warehouseId: '193000000000200002', channelGroupCode: 'EXX', containerType: '45HQ', page: 1, size: 200 })
expect(getOceanBooking).not.toHaveBeenCalled()
resolveCandidates({ records: [{ oceanBookingId: '315', bookingNo: 'OB-CAPACITY-SNAPSHOT', billOfLadingNo: 'BL-CAPACITY-SNAPSHOT', requirementId: '45315', containerType: '45HQ', capacityCbm: 85, maxWeightKg: 28500, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '东莞仓', channelName: '纽约铁路' }], page: 1, size: 200, total: 1, pages: 1 })
await flushPromises()
expect(document.body.textContent).toContain('OB-CAPACITY-SNAPSHOT · 提单号 BL-CAPACITY-SNAPSHOT · 45HQ × 1')
expect((wrapper.findComponent(OceanBookingLinkDialog).vm as unknown as { candidates: Array<{ key: string }> }).candidates[0]?.key).toBe('315:45315')
wrapper.unmount()
})
it('discards a candidate response from a closed link session', async () => {
let resolveFirst!: (value: Awaited<ReturnType<typeof listOceanBookingLinkCandidates>>) => void
let resolveSecond!: (value: Awaited<ReturnType<typeof listOceanBookingLinkCandidates>>) => void
vi.mocked(listOceanBookingLinkCandidates)
.mockReturnValueOnce(new Promise((resolve) => { resolveFirst = resolve }))
.mockReturnValueOnce(new Promise((resolve) => { resolveSecond = resolve }))
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
await flushPromises()
await wrapper.find('.el-table__body button[aria-label="更多货柜操作"]').trigger('click')
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('.el-dropdown-menu__item'))
.filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!.click()
await flushPromises()
wrapper.findAllComponents(AppDialog)
.find((dialog) => dialog.props('title') === '关联已放舱订舱')!.vm.$emit('update:modelValue', false)
await flushPromises()
await wrapper.find('.el-table__body button[aria-label="更多货柜操作"]').trigger('click')
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('.el-dropdown-menu__item'))
.filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!.click()
resolveSecond({ records: [{ oceanBookingId: '303', bookingNo: 'OB-LATEST', billOfLadingNo: 'BL-LATEST', requirementId: '404', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班海卡包税' }], page: 1, size: 200, total: 1, pages: 1 })
await flushPromises()
resolveFirst({ records: [{ oceanBookingId: '101', bookingNo: 'OB-STALE', billOfLadingNo: 'BL-STALE', requirementId: '202', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班海卡包税' }], page: 1, size: 200, total: 1, pages: 1 })
await flushPromises()
expect(listOceanBookingLinkCandidates).toHaveBeenCalledTimes(2)
const candidateLabels = (wrapper.findComponent(OceanBookingLinkDialog).vm as unknown as { candidates: Array<{ label: string }> }).candidates.map((candidate) => candidate.label)
expect(candidateLabels.some((label) => label.includes('OB-LATEST'))).toBe(true)
expect(candidateLabels.some((label) => label.includes('OB-STALE'))).toBe(false)
wrapper.unmount()
})
it('backfills a legacy trucking supplier name and submits the frozen id-name pair', async () => {
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', truckingScope: 'OVERSEAS', carrierCodes: [], region: 'CN', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-25T10:00:00', updatedBy: null, updatedAt: '2026-08-25T10:00:00', deleted: false, version: 0 }], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', allowedActions: ['EDIT_CONTAINER'], planStatus: 'WAIT_LOADING', containerType: '40HQ', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '测试拖车供应商' } as never)
vi.mocked(updateContainer).mockResolvedValue({} as never)
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().trim() === '编辑')!.trigger('click')
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().trim() === '确定')!.trigger('click')
await flushPromises()
expect(updateContainer).toHaveBeenCalledWith('9001', 0, expect.objectContaining({
terminalTruckingCompanyId: '9007199254740998',
terminalTruckingCompany: '测试拖车供应商',
}))
})
it('clears the trucking supplier id and name together', async () => {
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', truckingScope: 'OVERSEAS', carrierCodes: [], region: 'CN', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-25T10:00:00', updatedBy: null, updatedAt: '2026-08-25T10:00:00', deleted: false, version: 0 }], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', allowedActions: ['EDIT_CONTAINER'], planStatus: 'WAIT_LOADING', containerType: '40HQ', warehouseId: '8', terminalTruckingCompanyId: '9007199254740998', terminalTruckingCompany: '测试拖车供应商' } as never)
vi.mocked(updateContainer).mockResolvedValue({} as never)
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().trim() === '编辑')!.trigger('click')
await flushPromises()
const truckingSelect = wrapper.findAllComponents({ name: 'ElSelect' }).find((select) => select.props('placeholder') === '请选择拖车供应商')
truckingSelect!.vm.$emit('update:modelValue', '')
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().trim() === '确定')!.trigger('click')
await flushPromises()
expect(updateContainer).toHaveBeenCalledWith('9001', 0, expect.objectContaining({
terminalTruckingCompanyId: null,
terminalTruckingCompany: null,
}))
})
it('blocks saving a legacy trucking supplier name that no longer matches an active option', async () => {
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', allowedActions: ['EDIT_CONTAINER'], planStatus: 'WAIT_LOADING', containerType: '40HQ', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '已停用拖车供应商' } as never)
vi.mocked(updateContainer).mockClear()
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().trim() === '编辑')!.trigger('click')
await flushPromises()
const truckingSelect = wrapper.findAllComponents({ name: 'ElSelect' }).find((select) => select.props('placeholder') === '请选择拖车供应商')
expect(truckingSelect?.props('modelValue')).toBe('')
await wrapper.findAll('button').find((button) => button.text().trim() === '确定')!.trigger('click')
await flushPromises()
expect(updateContainer).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('原拖车供应商已不可用,请重新选择合作中的拖车供应商')
})
})
describe('ContainersView trucking supplier contract', () => {
it('shows the frozen plan, exception and document aggregates without deriving them client-side', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
const fields = ['containerNo', 'planNo', 'planStatus', 'exceptionActive', 'cabinetNo', 'destinationClearanceDocumentStatus', 'deliveryOrderDocumentStatus', 'pickupCode']
let previous = source.indexOf("key: 'actions'")
for (const field of fields) {
const current = source.indexOf(`key: '${field}'`, previous + 1)
expect(current, field).toBeGreaterThan(previous)
previous = current
}
expect(source).toContain("MISSING: '未上传', UPLOADED: '已上传', COMPLETED: '已完成'")
const detail = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8')
expect(detail).toContain("detail.planNo || '—'")
expect(detail).toContain("detail.exceptionActive ? '异常' : '正常'")
})
it('uses active TRUCKING suppliers without allowing free-text carrier names', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain("listSuppliers({ page: 1, size: 200, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' })")
expect(source).toContain("page: 1, size: 20, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE', keyword: keyword.trim() || undefined")
expect(source).toContain("supplier.category === 'TRUCKING' && supplier.truckingScope === 'OVERSEAS' && supplier.status === 'ACTIVE'")
expect(source).toContain('filterable remote reserve-keyword remote-show-suffix')
expect(source).toContain(':remote-method="searchTerminalFleets"')
expect(source).toContain('selectTruckingSupplier')
expect(source).toContain('请选择有效的合作中拖车供应商')
expect(source).not.toContain('allow-create')
})
})