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

236 lines
16 KiB
TypeScript
Raw Normal View History

2026-08-10 15:36:53 +08:00
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'
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'
import { listOceanBookings } from '@/modules/ocean-bookings'
2026-08-13 11:57:44 +08:00
import { getContainer, getContainerOverview, listContainers } from '../api/platformContainerLoading'
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() }))
vi.mock('@/modules/ocean-bookings', () => ({ listOceanBookings: vi.fn() }))
2026-08-14 12:50:50 +08:00
vi.mock('@/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 }) }))
2026-08-13 11:57:44 +08:00
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(), createContainer: vi.fn(), updateContainer: vi.fn(), updateContainerTransport: vi.fn() }))
2026-08-10 15:36:53 +08:00
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 />' } },
] })
}
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 })
vi.mocked(listOceanBookings).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 })
2026-08-11 11:52:30 +08:00
vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', 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: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 })
2026-08-13 11:57:44 +08:00
vi.mocked(getContainer).mockResolvedValue({ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', 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: 28000, 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: [] })
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 }))
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)
})
2026-08-10 17:42:17 +08:00
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)
})
2026-08-10 17:42:17 +08:00
it('uses stable view labels when cabinetNo is missing', async () => {
const base = {
2026-08-11 11:52:30 +08:00
id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'WAIT_LOADING' as const, version: 0,
2026-08-10 17:42:17 +08:00
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩',
destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 28000,
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()
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(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: '义乌仓', containerType: '40HQ', version: 0,
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩',
destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 28000,
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('排柜')
2026-08-13 11:57:44 +08:00
expect(wrapper.text()).toContain('预装清单')
expect(wrapper.text()).toContain('装柜清单')
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: 'loading-lists', query: { planId: '9103', warehouseId: '8' } })
})
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)
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)
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-08-13 11:57:44 +08:00
expect(wrapper.find('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
it('uses the frozen prototype modal contract for create and edit while retaining the detail drawer', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain('<AppDrawer v-model="detailOpen"')
expect(source).toContain('<AppDialog v-model="editorOpen" class="container-editor-dialog"')
expect(source).toContain('width="min(820px, calc(100vw - 28px))"')
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 v-model="editorOpen"')
})
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
}
})
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')
expect(source).toContain('size="small" type="primary" plain :aria-label="containerViewLabel')
2026-08-14 14:27:03 +08:00
expect(source).toContain("row.containerNo ? '修改集装箱号' : '录入集装箱号'")
2026-08-12 18:00:26 +08:00
expect(source).toContain("row.planStatus === 'EDITING' && canSmartLoading")
expect(source).not.toContain('canSmartLoading && row.containerNo')
expect(source).not.toContain('link type="primary" :aria-label="containerViewLabel')
2026-08-12 18:16:44 +08:00
expect(source).toContain(':loading="loading" stripe preference-key="container-loading-table"')
2026-08-12 18:00:26 +08:00
})
2026-08-13 11:57:44 +08:00
it('uploads clearance documents from a file picker without exposing file ids', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain('<el-upload :auto-upload="false" :limit="1"')
expect(source).toContain('@confirm="uploadClearance"')
expect(source).toContain('uploadContainerClearance(detailTarget.value.id, clearanceFile.value')
expect(source).toContain(':show-confirm="!clearanceDocument?.completedAt"')
expect(source).toContain('<el-form v-else label-position="top"')
expect(source).not.toContain('文件 ID')
expect(source).not.toContain('上传入口待')
})
it('does not expose manual container creation without an ocean-booking channel snapshot', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).not.toContain('新增货柜')
expect(source).not.toContain('createContainer(')
expect(source).not.toContain('openCreate')
})
2026-08-10 15:36:53 +08:00
})