Files
admin-web/src/modules/container-loading/pages/ContainersView.test.ts
T

853 lines
60 KiB
TypeScript
Raw Normal View History

2026-08-10 15:36:53 +08:00
import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
2026-09-09 12:50:39 +08:00
import { createMemoryHistory, createRouter, RouterView } from 'vue-router'
import { defineComponent } from 'vue'
2026-08-10 15:36:53 +08:00
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
2026-08-11 18:06:35 +08:00
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
2026-08-10 15:36:53 +08:00
import ContainersView from './ContainersView.vue'
import { listAllPlatformWarehouses } from '@/modules/warehouses'
2026-08-11 18:06:35 +08:00
import { listPlatformPartners } from '@/modules/master-data'
2026-08-15 11:12:22 +08:00
import { listSuppliers } from '@/modules/suppliers'
2026-08-25 19:56:37 +08:00
import { getOceanBooking, listOceanBookingLinkCandidates, listOceanBookings } from '@/modules/ocean-bookings'
2026-09-10 14:14:24 +08:00
import { getContainer, getContainerOverview, listContainers, updateContainer } from '../api/platformContainerLoading'
import OceanBookingLinkDialog from '../components/OceanBookingLinkDialog.vue'
2026-09-09 14:32:04 +08:00
import { getTerminalDrayage, getTerminalDrayageHistory, recordTerminalDrayageEmptyReturn, selectTerminalDrayageFleet, sendTerminalDrayageEmail, getTerminalDrayageEmails } from '../api/terminalDrayages'
2026-09-15 16:11:20 +08:00
import { listContainerArrivalTimes, updateContainerExpectedArrival } from '../api/containerArrivalTimes'
import AppDialog from '@/shared/components/AppDialog.vue'
2026-09-09 12:50:39 +08:00
import { containerLoadingRoutes } from '../routes'
2026-08-10 15:36:53 +08:00
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
2026-08-11 18:06:35 +08:00
vi.mock('@/modules/master-data', () => ({ listPlatformPartners: vi.fn() }))
2026-08-15 11:12:22 +08:00
vi.mock('@/modules/suppliers', () => ({ listSuppliers: vi.fn() }))
2026-08-25 19:56:37 +08:00
vi.mock('@/modules/ocean-bookings', () => ({ getOceanBooking: vi.fn(), listOceanBookingLinkCandidates: vi.fn(), listOceanBookings: vi.fn(), listWarehouseLogisticsChannels: vi.fn() }))
2026-10-20 23:34:11 +08:00
vi.mock('@/modules/iam', async importOriginal => ({
...await importOriginal<typeof import('@/modules/iam')>(), listPlatformTenants: vi.fn().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) }))
2026-08-11 18:06:35 +08:00
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() }))
2026-09-15 14:18:23 +08:00
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() }))
2026-09-15 16:11:20 +08:00
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',
}
2026-08-10 15:36:53 +08: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 />' } },
] })
}
2026-09-09 12:50:39 +08:00
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
}
2026-08-10 15:36:53 +08:00
describe('ContainersView', () => {
beforeEach(() => {
setActivePinia(createPinia())
2026-08-11 18:06:35 +08:00
accessCan.mockReset(); accessCan.mockReturnValue(true)
2026-08-10 15:36:53 +08:00
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([])
2026-08-11 18:06:35 +08:00
vi.mocked(listPlatformPartners).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 })
2026-08-15 11:12:22 +08:00
vi.mocked(listSuppliers).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 })
2026-08-11 18:06:35 +08:00
vi.mocked(listOceanBookings).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 })
2026-08-25 19:56:37 +08:00
vi.mocked(listOceanBookingLinkCandidates).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
2026-08-17 13:24:55 +08:00
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'] })
2026-09-15 16:11:20 +08:00
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, 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, 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', 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: [] })
2026-08-10 15:36:53 +08:00
})
it('loads the real paged contract and renders core container fields', async () => {
const router = createTestRouter()
2026-08-10 15:36:53 +08:00
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')
2026-08-11 11:52:30 +08:00
expect(wrapper.text()).toContain('待装柜')
2026-08-10 15:36:53 +08:00
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('报空预警')
2026-08-10 15:36:53 +08:00
})
2026-08-10 17:42:17 +08:00
2026-09-09 14:32:04 +08:00
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()
})
2026-09-09 13:33:34 +08:00
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 } },
})) })
2026-09-09 12:50:39 +08:00
const wrapper = await mountOverseasRoute()
expect(listContainers).toHaveBeenCalledWith(expect.objectContaining({ scope: 'ACTIVE' }), 'OVERSEAS')
2026-09-09 10:26:17 +08:00
const buttons = wrapper.findAll('button').filter((item) => item.text().includes('确认还柜'))
2026-09-09 10:18:12 +08:00
expect(buttons).toHaveLength(1)
await buttons[0].trigger('click')
await flushPromises()
const returnDialog = wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title').startsWith('确认还柜'))!
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 } } })) })
2026-09-09 10:18:12 +08:00
const confirm = [...document.body.querySelectorAll('button')].filter((item) => item.textContent?.includes('确认还柜')).at(-1) as HTMLButtonElement
confirm.click()
await flushPromises()
expect(recordTerminalDrayageEmptyReturn).toHaveBeenCalledTimes(1)
2026-09-09 13:33:34 +08:00
expect(recordTerminalDrayageEmptyReturn).toHaveBeenCalledWith('9001', { expectedVersion: version, actualAt: '2026-09-11T13:00:00', remark: '还柜凭证已核对' })
2026-09-09 10:18:12 +08:00
expect(wrapper.text()).toContain('已还柜')
expect(listContainers).toHaveBeenCalledTimes(callsBefore + 1)
expect(wrapper.findAll('.row-actions button').some((button) => button.text() === '确认还柜')).toBe(false)
wrapper.unmount()
})
2026-09-09 12:50:39 +08:00
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').startsWith('确认还柜') && dialog.props('modelValue'))).toBe(false)
2026-09-09 12:50:39 +08:00
expect(recordTerminalDrayageEmptyReturn).not.toHaveBeenCalled()
expect(getTerminalDrayage).not.toHaveBeenCalled()
if (missing === 'historical unloaded snapshot') {
const fleet = wrapper.findAllComponents({ name: 'ElSelect' }).find((select) =>
2026-09-09 18:29:47 +08:00
select.props('placeholder') === '输入车队名称或联系人搜索' && select.element.closest('.el-table__body'),
2026-09-09 12:50:39 +08:00
)
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').startsWith('确认还柜'))!
}
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()
2026-09-09 10:18:12 +08:00
})
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()
2026-09-09 10:26:17 +08:00
expect(wrapper.findAll('button').some((item) => item.text().includes('确认还柜'))).toBe(false)
2026-09-09 10:18:12 +08:00
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()
2026-09-09 10:26:17 +08:00
expect(allowedWrapper.findAll('button').some((item) => item.text().includes('确认还柜'))).toBe(false)
2026-09-09 10:18:12 +08:00
})
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)
2026-09-09 14:32:04 +08:00
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)
const editor = wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title').startsWith('编辑货柜资料'))!
expect(editor.props('title')).toBe('编辑货柜资料 · CB-1')
const editorFields = editor.findAllComponents({ name: 'ElFormItem' })
expect(editorFields).toHaveLength(17)
for (const label of ['真实集装箱号', '柜次号', '货柜容量(m³)', '重量上限(kg)']) expect(editorFields.find(field => field.props('label') === label)!.find('input').exists()).toBe(false)
const vessel = editorFields.find(field => field.props('label') === '船名')!.findComponent({ name: 'ElInput' })
vessel.vm.$emit('update:modelValue', '编辑中的船名')
await flushPromises()
await editor.find('button[aria-label="展开全屏"]').trigger('click')
expect(editor.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
expect(vessel.props('modelValue')).toBe('编辑中的船名')
expect(updateContainer).not.toHaveBeenCalled()
editor.vm.$emit('update:modelValue', false)
2026-09-09 14:32:04 +08:00
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()
2026-10-21 07:53:28 +08:00
expect(document.body.querySelector('.simulation-create-dialog')?.textContent).toContain('装柜仓库加载失败')
})
2026-08-10 17:42:17 +08:00
it('uses stable view labels when cabinetNo is missing', async () => {
const base = {
id: '9001', planId: null, cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'WAIT_LOADING' as const, version: 0,
2026-08-10 17:42:17 +08:00
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩',
2026-09-15 10:38:03 +08:00
destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500,
2026-08-25 19:56:37 +08:00
terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
2026-08-10 17:42:17 +08:00
estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
}
vi.mocked(listContainers).mockResolvedValueOnce({
records: [
{ ...base, id: '9002', cabinetNo: undefined, containerNo: 'MSCU-2' },
{ ...base, id: '9003', cabinetNo: '', containerNo: null },
{ ...base, id: '9004', cabinetNo: null, containerNo: null },
2026-08-10 17:42:17 +08:00
],
page: 1, size: 20, total: 3, pages: 1,
} as never)
const router = createTestRouter()
2026-08-10 17:42:17 +08:00
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(['查看 MSCU-2', '查看货柜详情', '查看货柜详情'])
2026-08-10 17:42:17 +08:00
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', 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: '长滩',
2026-09-15 10:38:03 +08:00
destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500,
2026-08-25 19:56:37 +08:00
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()
2026-09-09 14:32:04 +08:00
expect(wrapper.text()).toContain('装柜')
2026-08-13 11:57:44 +08:00
expect(wrapper.text()).toContain('预装清单')
expect(wrapper.text()).toContain('装柜清单')
2026-09-09 14:32:04 +08:00
await wrapper.findAll('button').find((button) => button.text().trim() === '装柜')!.trigger('click')
2026-08-13 11:57:44 +08:00
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' } })
})
2026-08-11 18:06:35 +08:00
2026-08-14 13:03:11 +08:00
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' })")
2026-08-14 14:27:03 +08:00
expect(source).toContain("label: customer.tenantName")
2026-08-14 13:03:11 +08:00
expect(source).toContain('customerId: applied.value.customerId || undefined')
expect(source).not.toContain('customer.tenantCode')
})
2026-08-13 11:57:44 +08:00
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)
2026-09-18 16:48:40 +08:00
expect(wrapper.find('.el-table__body button[aria-label="更多货柜操作"]').exists()).toBe(false)
2026-08-13 11:57:44 +08:00
expect(wrapper.findAll('button').some((button) => button.text().trim() === '编辑')).toBe(false)
})
it('does not accept the removed standalone view permission', async () => {
accessCan.mockReturnValue(false)
2026-08-11 18:06:35 +08:00
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)
2026-09-09 14:32:04 +08:00
expect(wrapper.find('.el-table__body button[aria-label="更多货柜操作"]').exists()).toBe(false)
2026-08-11 18:06:35 +08:00
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=')
})
2026-08-12 16:31:46 +08:00
2026-08-18 17:40:40 +08:00
it('opens container detail as an independent route while keeping create and edit dialogs', () => {
2026-08-12 16:31:46 +08:00
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain("'domestic-container-detail' : 'overseas-container-detail'")
2026-08-18 17:40:40 +08:00
expect(source).not.toContain('<AppDialog v-model="detailOpen"')
2026-10-20 23:34:11 +08:00
expect(source).toContain('<AppDialog v-model="editorOpen" class="container-editor-dialog admin-density-dialog admin-workbench is-dialog"')
expect(source).toContain('width="min(1080px, calc(100vw - 28px))"')
2026-09-14 18:34:34 +08:00
expect(source).toContain('admin-density-dialog')
expect(source).toContain('class="density-table density-table--editable container-editor-table"')
2026-08-17 18:32:41 +08:00
expect(source).not.toContain('AppDrawer')
2026-08-12 16:31:46 +08:00
expect(source).not.toContain('<AppDrawer v-model="editorOpen"')
})
2026-08-18 17:40:40 +08:00
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'")
2026-08-18 17:40:40 +08:00
expect(routes).toContain("permission: 'platform:firstleg:container:list'")
expect(detail).toContain('getContainer(containerId.value, props.workbench)')
2026-08-18 17:40:40 +08:00
expect(detail).toContain('getContainerOverview(containerId.value)')
expect(detail).toContain('<ContainerOverviewPanel')
expect(detail).toContain("@upload-document=\"openDocument\"")
expect(detail).toContain("@manage-exception=\"openException\"")
2026-08-26 11:10:48 +08:00
expect(detail).not.toContain("@preview-photo=\"previewPhoto\"")
expect(detail).toContain('<ContainerOverviewPanel')
2026-08-18 17:40:40 +08:00
})
2026-08-12 16:31:46 +08:00
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³)']
2026-08-12 16:31:46 +08:00
let previous = -1
for (const label of labels) {
const current = source.indexOf(`label="${label}"`, previous + 1)
expect(current, label).toBeGreaterThan(previous)
previous = current
}
})
2026-08-12 18:00:26 +08:00
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')
2026-09-18 16:48:40 +08:00
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')")
2026-09-30 14:01:58 +08:00
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')
2026-09-18 16:48:40 +08:00
expect(source).toContain("row.planStatus === 'EDITING' ? canSmartLoading.value")
2026-08-12 18:00:26 +08:00
expect(source).not.toContain('canSmartLoading && row.containerNo')
expect(source).not.toContain('link type="primary" :aria-label="containerViewLabel')
2026-09-30 14:01:58 +08:00
expect(source).toContain(':loading="loading" preference-key="container-loading-table"')
2026-10-21 13:22:42 +08:00
expect(source).toContain('appearance="integrated"')
expect(source).not.toContain(':stripe="false"')
expect(source).not.toContain(':stripe="false"')
2026-08-12 18:00:26 +08:00
})
2026-08-13 11:57:44 +08:00
it('updates an ACTIVE TRUCKING fleet inline and records actual empty return once', async () => {
2026-09-16 11:06:36 +08:00
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>>
2026-09-09 18:29:47 +08:00
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>
}
2026-09-09 14:32:04 +08:00
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] } })) })
2026-09-09 18:29:47 +08:00
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()
2026-09-09 10:26:17 +08:00
expect(recordTerminalDrayageEmptyReturn).toHaveBeenCalledWith('9001', { expectedVersion: 4, actualAt: '2026-09-11T13:00:00', remark: undefined })
expect(wrapper.text()).toContain('已还柜')
wrapper.unmount()
})
2026-09-09 18:29:47 +08:00
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('新车队')
2026-09-16 11:06:36 +08:00
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 })
2026-09-09 18:29:47 +08:00
await latestSearch
2026-09-16 11:06:36 +08:00
resolveOld({ records: [{ id: '502', name: '旧车队', category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' } as never], page: 1, size: 20, total: 1, pages: 1 })
2026-09-09 18:29:47 +08:00
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()
})
2026-09-10 14:14:24 +08:00
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')
2026-09-10 14:14:24 +08:00
expect(source).toContain('<OceanBookingLinkDialog v-model="linkOpen" :container="linkTarget" @linked="loadRows" />')
expect(source).not.toContain('linkContainerId')
})
2026-09-15 16:11:20 +08:00
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,
})
})
2026-09-09 13:51:14 +08:00
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')
2026-09-30 14:01:58 +08:00
expect(actions.findAll('button').map((button) => button.text())).toEqual(['查看', '编辑', ({ EDITING: '装柜', LOADING: '预装清单', COMPLETED: '装柜清单' })[planStatus], '更多'])
2026-09-09 13:51:14 +08:00
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').startsWith('关联已放舱订舱'))?.props('modelValue')).toBe(true)
2026-09-15 10:38:03 +08:00
expect(listOceanBookingLinkCandidates).toHaveBeenCalledWith({ warehouseId: '8', channelGroupCode: 'MATSON_CLX', containerType: '40HQ', page: 1, size: 200 })
2026-09-09 13:51:14 +08:00
} finally {
wrapper.unmount()
}
})
2026-08-26 11:10:48 +08:00
it('keeps document uploads on the routed detail page without duplicate list implementations', () => {
2026-08-13 11:57:44 +08:00
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
2026-08-26 11:10:48 +08:00
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')
2026-08-28 16:54:02 +08:00
expect(detailSource).not.toContain('uploadLoadingDeclaration')
expect(detailSource).toContain('ContainerCustomsPanel')
2026-08-26 11:10:48 +08:00
expect(detailSource).toContain('uploadContainerClearance')
expect(detailSource).toContain('uploadContainerDocument')
2026-08-13 11:57:44 +08:00
})
it('creates an internal simulation without asking for real booking transport facts', () => {
2026-08-13 11:57:44 +08:00
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
2026-09-09 15:26:43 +08:00
const simulationSource = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/components/ContainerSimulationCreate.vue'), 'utf8')
expect(source).toContain('ContainerSimulationCreate')
2026-08-13 11:57:44 +08:00
expect(source).not.toContain('新增货柜')
2026-09-09 15:26:43 +08:00
expect(simulationSource).toContain('新增模拟柜')
expect(simulationSource).toContain('createContainerSimulation({ ...simulationForm })')
2026-09-15 10:38:03 +08:00
expect(simulationSource).toContain("channelGroupCode: '', warehouseId: '', containerType: DEFAULT_CONTAINER_TYPE, capacityCbm: 68, maxWeightKg: 19500")
2026-08-17 12:31:27 +08:00
expect(source).toContain('CONTAINER_SPECIFICATIONS')
2026-09-09 15:26:43 +08:00
expect(simulationSource).toContain('selectSimulationContainerType')
2026-10-20 14:52:06 +08:00
expect(simulationSource).not.toContain('创建后自动生成柜次号')
expect(source).toContain("{{ form.cabinetNo || '—' }}")
2026-08-19 09:42:12 +08:00
expect(source).not.toContain('v-model="form.cabinetNo"')
expect(source).not.toContain('cabinetNo: form.cabinetNo.trim()')
2026-08-13 11:57:44 +08:00
expect(source).not.toContain('openCreate')
2026-09-15 10:38:03 +08:00
expect(source).not.toContain('20GP')
expect(source).not.toContain('40HC')
expect(source).not.toContain('40NR')
expect(source).not.toContain('45HC')
2026-08-17 12:31:27 +08:00
expect(source).not.toContain('v-model="form.capacityCbm"')
expect(source).not.toContain('v-model="form.maxWeightKg"')
2026-08-13 11:57:44 +08:00
})
it('filters OB-260825-0183 for CAB-260825-0215 because the real warehouses differ', async () => {
vi.mocked(listContainers).mockResolvedValue({ records: [{
id: '215', planId: null, cabinetNo: 'CAB-260825-0215', containerNo: null,
sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '193000000000200002', warehouseName: '东莞仓',
2026-09-15 10:38:03 +08:00
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()
2026-09-09 14:32:04 +08:00
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()
2026-09-15 10:38:03 +08:00
expect(listOceanBookingLinkCandidates).toHaveBeenCalledWith({ warehouseId: '193000000000200002', channelGroupCode: 'EXX', containerType: '45HQ', page: 1, size: 200 })
2026-09-10 14:14:24 +08:00
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: [{
2026-09-18 09:59:12 +08:00
oceanBookingId: '315', bookingNo: 'OB-OPTIONAL-CUSTOMER', billOfLadingNo: 'BL-OPTIONAL-CUSTOMER', requirementId: '45315',
2026-09-15 10:38:03 +08:00
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()
2026-09-18 09:59:12 +08:00
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, cabinetNo: 'CAB-CAPACITY-SNAPSHOT', containerNo: null,
sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '193000000000200002', warehouseName: '东莞仓',
2026-09-15 10:38:03 +08:00
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)
2026-08-25 19:56:37 +08:00
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()
2026-09-09 14:32:04 +08:00
await wrapper.find('.el-table__body button[aria-label="更多货柜操作"]').trigger('click')
2026-08-25 19:56:37 +08:00
await flushPromises()
const linkAction = Array.from(document.body.querySelectorAll<HTMLElement>('.el-dropdown-menu__item'))
.filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!
2026-08-25 19:56:37 +08:00
linkAction.click()
2026-09-10 14:14:24 +08:00
await flushPromises()
2026-08-25 19:56:37 +08:00
linkAction.click()
expect(listOceanBookingLinkCandidates).toHaveBeenCalledTimes(1)
2026-09-15 10:38:03 +08:00
expect(listOceanBookingLinkCandidates).toHaveBeenCalledWith({ warehouseId: '193000000000200002', channelGroupCode: 'EXX', containerType: '45HQ', page: 1, size: 200 })
2026-08-25 19:56:37 +08:00
expect(getOceanBooking).not.toHaveBeenCalled()
2026-09-18 09:59:12 +08:00
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 })
2026-08-25 19:56:37 +08:00
await flushPromises()
2026-09-18 09:59:12 +08:00
expect(document.body.textContent).toContain('OB-CAPACITY-SNAPSHOT · 提单号 BL-CAPACITY-SNAPSHOT · 45HQ × 1')
2026-09-10 14:14:24 +08:00
expect((wrapper.findComponent(OceanBookingLinkDialog).vm as unknown as { candidates: Array<{ key: string }> }).candidates[0]?.key).toBe('315:45315')
2026-08-25 19:56:37 +08:00
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()
2026-09-09 14:32:04 +08:00
await wrapper.find('.el-table__body button[aria-label="更多货柜操作"]').trigger('click')
2026-08-25 19:56:37 +08:00
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('.el-dropdown-menu__item'))
.filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!.click()
2026-08-25 19:56:37 +08:00
await flushPromises()
wrapper.findAllComponents(AppDialog)
.find((dialog) => dialog.props('title').startsWith('关联已放舱订舱'))!.vm.$emit('update:modelValue', false)
2026-08-25 19:56:37 +08:00
await flushPromises()
2026-09-09 14:32:04 +08:00
await wrapper.find('.el-table__body button[aria-label="更多货柜操作"]').trigger('click')
2026-08-25 19:56:37 +08:00
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('.el-dropdown-menu__item'))
.filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!.click()
2026-09-18 09:59:12 +08:00
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 })
2026-08-25 19:56:37 +08:00
await flushPromises()
2026-09-18 09:59:12 +08:00
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 })
2026-08-25 19:56:37 +08:00
await flushPromises()
expect(listOceanBookingLinkCandidates).toHaveBeenCalledTimes(2)
2026-09-10 14:14:24 +08:00
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)
2026-08-25 19:56:37 +08:00
wrapper.unmount()
})
it('backfills a legacy trucking supplier name and submits the frozen id-name pair', async () => {
2026-09-16 11:06:36 +08:00
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 })
2026-09-15 10:38:03 +08:00
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', allowedActions: ['EDIT_CONTAINER'], planStatus: 'WAIT_LOADING', containerType: '40HQ', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '测试拖车供应商' } as never)
2026-08-25 19:56:37 +08:00
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()
2026-09-09 14:32:04 +08:00
await wrapper.findAll('button').find((button) => button.text().trim() === '编辑')!.trigger('click')
2026-08-25 19:56:37 +08:00
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 () => {
2026-09-16 11:06:36 +08:00
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 })
2026-09-15 10:38:03 +08:00
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', allowedActions: ['EDIT_CONTAINER'], planStatus: 'WAIT_LOADING', containerType: '40HQ', warehouseId: '8', terminalTruckingCompanyId: '9007199254740998', terminalTruckingCompany: '测试拖车供应商' } as never)
2026-08-25 19:56:37 +08:00
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()
2026-09-09 14:32:04 +08:00
await wrapper.findAll('button').find((button) => button.text().trim() === '编辑')!.trigger('click')
2026-08-25 19:56:37 +08:00
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 () => {
2026-09-15 10:38:03 +08:00
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', allowedActions: ['EDIT_CONTAINER'], planStatus: 'WAIT_LOADING', containerType: '40HQ', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '已停用拖车供应商' } as never)
2026-08-25 19:56:37 +08:00
vi.mocked(updateContainer).mockClear()
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
2026-09-09 14:32:04 +08:00
await wrapper.findAll('button').find((button) => button.text().trim() === '编辑')!.trigger('click')
2026-08-25 19:56:37 +08:00
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('原拖车供应商已不可用,请重新选择合作中的拖车供应商')
})
2026-08-10 15:36:53 +08:00
})
2026-08-15 11:12:22 +08:00
describe('ContainersView trucking supplier contract', () => {
2026-08-17 20:01:51 +08:00
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']
2026-08-17 20:01:51 +08:00
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: '已完成'")
2026-08-18 17:40:40 +08:00
const detail = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8')
expect(detail).toContain("detail.planNo || '—'")
expect(detail).toContain("detail.exceptionActive ? '异常' : '正常'")
2026-08-17 20:01:51 +08:00
})
2026-08-15 11:12:22 +08:00
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')
2026-09-16 11:06:36 +08:00
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'")
2026-09-09 18:29:47 +08:00
expect(source).toContain('filterable remote reserve-keyword remote-show-suffix')
expect(source).toContain(':remote-method="searchTerminalFleets"')
2026-08-15 11:12:22 +08:00
expect(source).toContain('selectTruckingSupplier')
expect(source).toContain('请选择有效的合作中拖车供应商')
expect(source).not.toContain('allow-create')
})
})