This commit is contained in:
2026-09-09 12:50:39 +08:00
parent 7b75f40e28
commit c83d03e775
7 changed files with 395 additions and 25 deletions
@@ -5,11 +5,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ContainerDetailView from './ContainerDetailView.vue'
import { getContainer, getContainerOverview } from '../api/platformContainerLoading'
import { recordTerminalDrayageEmptyReturn } from '../api/terminalDrayages'
import { getConfirmedLoadingList, getPreloadPlan } from '@/modules/loading-lists'
import DomesticContainerDetailView from './DomesticContainerDetailView.vue'
const canAccess = vi.hoisted(() => vi.fn<(code: string) => boolean>(() => true))
const routeState = vi.hoisted(() => ({ params: { containerId: '9001' }, query: {} as Record<string, string> }))
const routerPush = vi.hoisted(() => vi.fn())
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canAccess }) }))
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { containerId: '9001' }, query: {} }), useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/modules/loading-lists', () => ({ ContainerFeesPanel: { template: '<div />' }, LoadingListDetailDrawer: { template: '<div />' }, getConfirmedLoadingList: vi.fn(), getPreloadPlan: vi.fn() }))
vi.mock('vue-router', () => ({ useRoute: () => routeState, useRouter: () => ({ push: routerPush }) }))
vi.mock('@/modules/loading-lists', () => ({ ContainerFeesPanel: { template: '<div />' }, LoadingListDetailDrawer: { name: 'LoadingListDetailDrawer', props: ['detail', 'error', 'loading'], template: '<div data-testid="loading-detail">{{ detail?.planNo }}</div>' }, getConfirmedLoadingList: vi.fn(), getPreloadPlan: vi.fn() }))
vi.mock('@/modules/outbound-dispatch', () => ({ confirmContainerOutboundDispatch: vi.fn(), getContainerOutboundDispatch: vi.fn() }))
vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '<div />' } }))
vi.mock('../api/platformContainerLoading', () => ({ getContainer: vi.fn(), getContainerOverview: vi.fn(), cancelContainerException: vi.fn(), completeContainerClearance: vi.fn(), downloadContainerClearance: vi.fn(), downloadContainerDocument: vi.fn(), getContainerClearance: vi.fn(), markContainerException: vi.fn(), uploadContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(), updateContainerPickupCode: vi.fn() }))
@@ -28,6 +32,8 @@ async function mountDetail(workbench: 'DOMESTIC' | 'OVERSEAS' = 'OVERSEAS') {
describe('ContainerDetailView empty return', () => {
beforeEach(() => {
routeState.query = {}
routerPush.mockReset()
canAccess.mockReset().mockReturnValue(true)
vi.mocked(getContainer).mockReset().mockResolvedValue(base as never)
vi.mocked(getContainerOverview).mockReset().mockResolvedValue(overview as never)
@@ -78,3 +84,123 @@ describe('ContainerDetailView empty return', () => {
expect(wrapper.findAll('button').some((item) => item.text() === '确认还柜')).toBe(false)
})
})
describe('domestic container loading list and booking link', () => {
const domestic = { ...base, containerNo: null, warehouseId: '8', sourceType: 'SIMULATION', oceanBookingId: null, planId: '7001', planStatus: 'EDITING', version: 1, allowedActions: ['EDIT_CONTAINER'], terminalDrayage: null }
const loadingDetail = { planNo: 'LP-1', items: [] }
beforeEach(() => {
routeState.query = {}
routerPush.mockReset()
canAccess.mockReset().mockImplementation((code) => ['platform:firstleg:container:list', 'platform:firstleg:container:edit', 'platform:firstleg:preload:list', 'platform:firstleg:loading-list:list'].includes(code))
vi.mocked(getContainer).mockReset().mockResolvedValue(domestic as never)
vi.mocked(getContainerOverview).mockReset().mockResolvedValue({ ...overview, loadingPlanId: '7001' } as never)
vi.mocked(getPreloadPlan).mockReset().mockResolvedValue(loadingDetail as never)
vi.mocked(getConfirmedLoadingList).mockReset().mockResolvedValue(loadingDetail as never)
})
async function mountDomestic() {
const wrapper = mount(DomesticContainerDetailView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus], stubs: { ContainerOverviewPanel: true, ContainerCustomsPanel: true, ContainerLastLegFeesPanel: true } } })
await flushPromises()
expect(getContainer).toHaveBeenLastCalledWith('9001', 'DOMESTIC')
return wrapper
}
it.each(['WAIT_LOADING', 'EDITING', 'LOADING', 'COMPLETED'])('opens the loading-list tab using the correct API for %s', async (planStatus) => {
vi.mocked(getContainer).mockResolvedValue({ ...domestic, planStatus } as never)
const wrapper = await mountDomestic()
expect(getPreloadPlan).not.toHaveBeenCalled()
expect(getConfirmedLoadingList).not.toHaveBeenCalled()
const tab = wrapper.findAll('[role="tab"]').find((item) => item.text() === '装柜清单')!
expect(tab).toBeDefined()
await tab.trigger('click')
await flushPromises()
expect(tab.attributes('aria-selected')).toBe('true')
expect(wrapper.get('[data-testid="loading-detail"]').text()).toBe('LP-1')
expect(planStatus === 'COMPLETED' ? getConfirmedLoadingList : getPreloadPlan).toHaveBeenCalledExactlyOnceWith('7001', '8')
expect(planStatus === 'COMPLETED' ? getPreloadPlan : getConfirmedLoadingList).not.toHaveBeenCalled()
})
it.each(['WAIT_LOADING', 'EDITING', 'LOADING', 'COMPLETED'])('enforces the matching read permission for a %s deep link', async (planStatus) => {
for (const preload of [false, true]) {
for (const confirmed of [false, true]) {
vi.mocked(getPreloadPlan).mockClear()
vi.mocked(getConfirmedLoadingList).mockClear()
routeState.query = { tab: 'loading-list' }
canAccess.mockImplementation((code) => code === 'platform:firstleg:preload:list' ? preload : code === 'platform:firstleg:loading-list:list' ? confirmed : code === 'platform:firstleg:container:list')
vi.mocked(getContainer).mockResolvedValue({ ...domestic, planStatus } as never)
const wrapper = await mountDomestic()
const permitted = planStatus === 'COMPLETED' ? confirmed : preload
const tabs = wrapper.findAll('[role="tab"]')
expect(tabs.some((tab) => tab.text() === '装柜清单')).toBe(permitted)
expect(tabs.find((tab) => tab.attributes('aria-selected') === 'true')?.text()).toBe(permitted ? '装柜清单' : '送柜报关')
expect(getPreloadPlan).toHaveBeenCalledTimes(permitted && planStatus !== 'COMPLETED' ? 1 : 0)
expect(getConfirmedLoadingList).toHaveBeenCalledTimes(permitted && planStatus === 'COMPLETED' ? 1 : 0)
wrapper.unmount()
}
}
})
it('does not expose the domestic loading-list deep link in the overseas workbench', async () => {
routeState.query = { tab: 'loading-list' }
vi.mocked(getContainer).mockResolvedValue(base as never)
const wrapper = await mountDetail('OVERSEAS')
expect(wrapper.findAll('[role="tab"]').map((tab) => tab.text())).not.toContain('装柜清单')
expect(wrapper.findAll('[role="tab"]').find((tab) => tab.attributes('aria-selected') === 'true')?.text()).toBe('清关提柜')
expect(getPreloadPlan).not.toHaveBeenCalled()
expect(getConfirmedLoadingList).not.toHaveBeenCalled()
})
it('keeps the tab and reports a loading failure without inventing a successful list', async () => {
routeState.query = { tab: 'loading-list' }
vi.mocked(getPreloadPlan).mockRejectedValue(new Error('清单读取失败'))
const wrapper = await mountDomestic()
const list = wrapper.findComponent({ name: 'LoadingListDetailDrawer' })
expect(list.props('detail')).toBeNull()
expect(list.props('error')).toMatchObject({ message: '装柜清单加载失败' })
expect(list.props('loading')).toBe(false)
expect(wrapper.findAll('[role="tab"]').find((tab) => tab.attributes('aria-selected') === 'true')?.text()).toBe('装柜清单')
})
it.each(['WAIT_LOADING', 'EDITING'])('retains the unlinked booking entry with an existing plan in %s', async (planStatus) => {
vi.mocked(getContainer).mockResolvedValue({ ...domestic, planStatus, version: 2 } as never)
const wrapper = await mountDomestic()
const link = wrapper.findAll('button').find((button) => button.text() === '关联已放舱订舱')!
expect(link).toBeDefined()
await link.trigger('click')
expect(routerPush).toHaveBeenCalledExactlyOnceWith({ name: 'domestic-container-workbench', query: { linkContainerId: '9001' } })
})
it('keeps the same cabinet link before and after an inbound assignment snapshot refresh', async () => {
// Assignment changes the version and plan items, not the booking association or EDITING status.
for (const [version, items] of [[1, []], [2, [{ inboundCode: 'IN-001' }]]] as const) {
routeState.query = { tab: 'loading-list' }
vi.mocked(getContainer).mockResolvedValue({ ...domestic, version } as never)
vi.mocked(getPreloadPlan).mockResolvedValue({ ...loadingDetail, items } as never)
const wrapper = await mountDomestic()
expect(wrapper.findComponent({ name: 'LoadingListDetailDrawer' }).props('detail').items).toEqual(items)
await wrapper.findAll('[role="tab"]').find((tab) => tab.text() === '送柜报关')!.trigger('click')
await flushPromises()
const link = wrapper.findAll('button').find((button) => button.text() === '关联已放舱订舱')!
expect(link.isVisible()).toBe(true)
routerPush.mockClear()
await link.trigger('click')
expect(routerPush).toHaveBeenCalledExactlyOnceWith({ name: 'domestic-container-workbench', query: { linkContainerId: '9001' } })
wrapper.unmount()
}
})
it.each([
{ label: 'already linked simulation', patch: { oceanBookingId: '8001' }, edit: true },
{ label: 'picking started', patch: { planStatus: 'LOADING' }, edit: true },
{ label: 'completed', patch: { planStatus: 'COMPLETED' }, edit: true },
{ label: 'server denies editing', patch: { allowedActions: [] }, edit: true },
{ label: 'account lacks edit permission', patch: {}, edit: false },
])('hides the booking link when $label', async ({ patch, edit }) => {
canAccess.mockImplementation((code) => code === 'platform:firstleg:container:list' || (edit && code === 'platform:firstleg:container:edit'))
vi.mocked(getContainer).mockResolvedValue({ ...domestic, ...patch } as never)
const wrapper = await mountDomestic()
expect(wrapper.findAll('button').some((button) => button.text() === '关联已放舱订舱')).toBe(false)
expect(routerPush).not.toHaveBeenCalled()
})
})
@@ -1,6 +1,6 @@
import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { createMemoryHistory, createRouter } from 'vue-router'
import { createMemoryHistory, createRouter, RouterView } from 'vue-router'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { readFileSync } from 'node:fs'
@@ -13,6 +13,7 @@ import { getOceanBooking, listOceanBookingLinkCandidates, listOceanBookings } fr
import { getContainer, getContainerOverview, linkContainerOceanBooking, listContainers, updateContainer } from '../api/platformContainerLoading'
import { getTerminalDrayage, getTerminalDrayageHistory, recordTerminalDrayageEmptyReturn, selectTerminalDrayageFleet } from '../api/terminalDrayages'
import AppDialog from '@/shared/components/AppDialog.vue'
import { containerLoadingRoutes } from '../routes'
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
vi.mock('@/modules/master-data', () => ({ listPlatformPartners: vi.fn() }))
@@ -42,6 +43,18 @@ function createTestRouter() {
] })
}
async function mountOverseasRoute() {
const router = createRouter({
history: createMemoryHistory(),
routes: containerLoadingRoutes.map((route) => ({ ...route, path: `/${route.path}` })),
})
await router.push('/container-loading/overseas')
await router.isReady()
const wrapper = mount(RouterView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
await flushPromises()
return wrapper
}
describe('ContainersView', () => {
beforeEach(() => {
setActivePinia(createPinia())
@@ -76,10 +89,8 @@ describe('ContainersView', () => {
})
it('shows one direct empty-return action only when permission and server actions allow it', 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 wrapper = await mountOverseasRoute()
expect(listContainers).toHaveBeenCalledWith(expect.objectContaining({ scope: 'ACTIVE' }), 'OVERSEAS')
const buttons = wrapper.findAll('button').filter((item) => item.text().includes('确认还柜'))
expect(buttons).toHaveLength(1)
await buttons[0].trigger('click')
@@ -101,6 +112,48 @@ describe('ContainersView', () => {
wrapper.unmount()
})
it.each(['permission', 'row action', 'nested action', 'nested object', 'already returned', 'historical unloaded snapshot'] as const)(
'keeps empty return unavailable on the real overseas route: %s',
async (missing) => {
const page = await listContainers({ page: 1, size: 20 }, 'OVERSEAS')
vi.mocked(listContainers).mockClear()
const nested = missing === 'nested object' ? null : {
...drayage,
allowedActions: missing === 'nested action' || missing === 'already returned' ? [] : [...drayage.allowedActions],
...(missing === 'already returned' ? { status: 'EMPTY_RETURNED' as const, actualEmptyReturnAt: '2026-09-11T13:00:00' } : {}),
...(missing === 'historical unloaded snapshot' ? { status: 'ARRIVED' as const, version: 0, actualPickupAt: null, fleetSupplierId: null, fleetSupplierName: null, allowedActions: ['SELECT_FLEET' as const] } : {}),
}
vi.mocked(listContainers).mockResolvedValue({ ...page, records: page.records.map((row) => ({
...row,
allowedActions: missing === 'row action' || missing === 'already returned' ? [] : ['RECORD_EMPTY_RETURN'],
terminalDrayage: nested,
...(missing === 'historical unloaded snapshot' ? { workbenchStage: 'UNLOADED', allowedActions: ['MANAGE_DESTINATION_DOCUMENTS' as const, 'SELECT_FLEET' as const] } : {}),
})) })
if (missing === 'permission') {
accessCan.mockImplementation((code) => code !== 'platform:firstleg:terminal-drayage:empty-return:confirm')
}
const wrapper = await mountOverseasRoute()
try {
expect(listContainers).toHaveBeenCalledTimes(1)
expect(listContainers).toHaveBeenCalledWith(expect.objectContaining({ scope: 'ACTIVE' }), 'OVERSEAS')
expect(wrapper.text()).toContain('MSCU-1')
expect(wrapper.findAll('.row-actions button').some((button) => button.text() === '确认还柜')).toBe(false)
expect(wrapper.findAllComponents(AppDialog).some((dialog) => dialog.props('title') === '确认还柜' && dialog.props('modelValue'))).toBe(false)
expect(recordTerminalDrayageEmptyReturn).not.toHaveBeenCalled()
expect(getTerminalDrayage).not.toHaveBeenCalled()
if (missing === 'historical unloaded snapshot') {
const fleet = wrapper.findAllComponents({ name: 'ElSelect' }).find((select) =>
select.props('placeholder') === '请选择车队' && select.element.closest('.el-table__body'),
)
expect(fleet).toBeDefined()
expect(fleet!.props('disabled')).toBe(false)
}
} finally {
wrapper.unmount()
}
},
)
it('cancels empty return without writing and preserves input on failure while preventing duplicate requests', async () => {
const router = createTestRouter()
await router.push('/'); await router.isReady()