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

522 lines
36 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 } from 'vue-router'
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, linkContainerOceanBooking, listContainers, updateContainer } from '../api/platformContainerLoading'
import AppDialog from '@/shared/components/AppDialog.vue'
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() }))
function createTestRouter() {
return createRouter({ history: createMemoryHistory(), routes: [
{ path: '/', component: ContainersView },
{ path: '/container-loading/:containerId/smart-loading', name: 'smart-loading-workbench', component: { template: '<div />' } },
{ path: '/loading-plans', name: 'loading-plans', component: { template: '<div />' } },
{ path: '/loading-lists', name: 'loading-lists', component: { template: '<div />' } },
] })
}
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(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', 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: 76, maxWeightKg: 28000, 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', oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', 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: 76, maxWeightKg: 28000, 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 }))
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)
})
it('keeps the cross-warehouse list usable when 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(wrapper.text()).toContain('CB-1')
expect(wrapper.text()).not.toContain('装柜仓库加载失败')
expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(true)
})
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: '40HC', 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: 76, maxWeightKg: 28000,
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: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', version: 0,
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩',
destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000,
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: 'loading-lists', query: { planId: '9103', warehouseId: '8' } })
})
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('button[aria-label="更多货柜操作"]').exists()).toBe(true)
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('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("router.push({ name: 'container-detail', params: { containerId: row.id } })")
expect(source).toContain("query: { action: command }")
expect(source).not.toContain('<AppDialog v-model="detailOpen"')
expect(source).toContain('<AppDialog v-model="editorOpen" class="container-editor-dialog"')
expect(source).toContain('max-height: 90vh')
expect(source).toContain('overflow-y: auto')
expect(source).toContain('grid-template-columns: 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/:containerId'")
expect(routes).toContain("name: 'container-detail'")
expect(routes).toContain("permission: 'platform:firstleg:container:list'")
expect(detail).toContain('getContainer(containerId.value)')
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('size="small" type="primary" plain :aria-label="containerViewLabel')
expect(source).toContain("['WAIT_LOADING', 'EDITING'].includes(row.planStatus)")
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")
expect(source).not.toContain('canSmartLoading && row.containerNo')
expect(source).not.toContain('link type="primary" :aria-label="containerViewLabel')
expect(source).toContain(':loading="loading" stripe preference-key="container-loading-table"')
})
it('opens the existing released-booking workflow when entered from container detail', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain('route.query.linkContainerId')
expect(source).toContain('const target = await getContainer(linkContainerId)')
expect(source).toContain('await openLink(target)')
expect(source).toContain("void router.replace({ name: 'container-loading' })")
})
it('normalizes an optional booking-link container number', async () => {
vi.mocked(linkContainerOceanBooking).mockResolvedValue({} as never)
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 {
linkTarget: { id: string; version: number }
linkCandidates: Array<{ key: string; oceanBookingId: string; requirementId: string; disabled: boolean }>
linkCandidateKey: string
linkContainerNo: string
saveLink: () => Promise<void>
}
vm.linkTarget = { id: '9001', version: 7 }
vm.linkCandidates = [{ key: '101:202', oceanBookingId: '101', requirementId: '202', disabled: false }]
vm.linkCandidateKey = '101:202'
vm.linkContainerNo = ' mscu1234567 '
await vm.saveLink()
expect(linkContainerOceanBooking).toHaveBeenCalledWith('9001', {
oceanBookingId: '101', requirementId: '202', containerNo: 'MSCU1234567', expectedVersion: 7,
})
expect(vm.linkContainerNo).toBe('MSCU1234567')
wrapper.unmount()
})
it('links a released booking while leaving the real container number pending until pickup', async () => {
vi.mocked(linkContainerOceanBooking).mockResolvedValue({} as never)
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 {
linkTarget: { id: string; version: number }
linkCandidates: Array<{ key: string; oceanBookingId: string; requirementId: string; disabled: boolean }>
linkCandidateKey: string
linkContainerNo: string
saveLink: () => Promise<void>
}
vm.linkTarget = { id: '9001', version: 7 }
vm.linkCandidates = [{ key: '101:202', oceanBookingId: '101', requirementId: '202', disabled: false }]
vm.linkCandidateKey = '101:202'
vm.linkContainerNo = ''
await vm.saveLink()
expect(linkContainerOceanBooking).toHaveBeenCalledWith('9001', {
oceanBookingId: '101', requirementId: '202', containerNo: null, expectedVersion: 7,
})
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain('真实集装箱号可待司机提柜时录入')
expect(source).toContain(':confirm-disabled="!linkCandidateKey"')
expect(source).toContain("row.containerNo || '待补录'")
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')
expect(source).not.toContain('新增货柜')
expect(source).toContain('新增模拟柜')
expect(source).toContain('createContainerSimulation({ ...simulationForm })')
expect(source).toContain("channelCode: '', warehouseId: '', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000")
expect(source).toContain('CONTAINER_SPECIFICATIONS')
expect(source).toContain('selectSimulationContainerType')
expect(source).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','40GP','40HQ','45HQ']")
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', oceanBookingId: null, warehouseId: '193000000000200002', warehouseName: '东莞仓',
channelCode: 'EXX_RAIL_NY', channelName: '纽约铁路', containerType: '45HC', 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('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', channelCode: 'EXX_RAIL_NY', containerType: '45HC', page: 1, size: 200 })
expect((wrapper.vm as unknown as { linkCandidates: unknown[] }).linkCandidates).toEqual([])
expect(readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8'))
.toContain('暂无符合当前装柜仓库、物流渠道和柜型的已放舱需求')
expect(getOceanBooking).not.toHaveBeenCalled()
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', oceanBookingId: null, warehouseId: '193000000000200002', warehouseName: '东莞仓',
channelCode: 'EXX_RAIL_NY', channelName: '纽约铁路', containerType: '45HC', 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('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()
linkAction.click()
expect(listOceanBookingLinkCandidates).toHaveBeenCalledTimes(1)
expect(listOceanBookingLinkCandidates).toHaveBeenCalledWith({ warehouseId: '193000000000200002', channelCode: 'EXX_RAIL_NY', containerType: '45HC', page: 1, size: 200 })
expect(getOceanBooking).not.toHaveBeenCalled()
resolveCandidates({ records: [{ oceanBookingId: '315', bookingNo: 'OB-CAPACITY-SNAPSHOT', requirementId: '45315', containerType: '45HC', 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 · 45HC × 1')
expect((wrapper.vm as unknown as { linkCandidates: Array<{ disabled: boolean }> }).linkCandidates[0]?.disabled).toBe(false)
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('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('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', requirementId: '404', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, 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', requirementId: '202', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, 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.vm as unknown as { linkCandidates: Array<{ label: string }> }).linkCandidates.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', 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', planStatus: 'WAIT_LOADING', containerType: '40HC', 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().includes('编辑运输信息'))!.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', 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', planStatus: 'WAIT_LOADING', containerType: '40HC', 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().includes('编辑运输信息'))!.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', planStatus: 'WAIT_LOADING', containerType: '40HC', 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().includes('编辑运输信息'))!.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', 'pickupOrderDocumentStatus']
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', status: 'ACTIVE' })")
expect(source).toContain("supplier.category === 'TRUCKING' && supplier.status === 'ACTIVE'")
expect(source).toContain('selectTruckingSupplier')
expect(source).toContain('请选择有效的合作中拖车供应商')
expect(source).not.toContain('allow-create')
})
})