feat(admin): confirm container departure from detail

This commit is contained in:
2026-08-26 12:04:50 +08:00
parent 8307510fc3
commit e8c41bca2d
5 changed files with 392 additions and 12 deletions
@@ -1,15 +1,18 @@
import ElementPlus from 'element-plus'
import ElementPlus, { ElMessageBox } from 'element-plus'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ContainerDetailView from './ContainerDetailView.vue'
import { getContainer, getContainerClearance, getContainerOverview, uploadContainerClearance, uploadContainerDocument } from '../api/platformContainerLoading'
import { uploadLoadingDeclaration } from '@/modules/loading-lists'
import { confirmContainerOutboundDispatch, getContainerOutboundDispatch } from '@/modules/outbound-dispatch/api/platformOutboundDispatch'
import type { ContainerOutboundDispatchDetail } from '@/modules/outbound-dispatch/types'
const { accessCan, routerPush } = vi.hoisted(() => ({ accessCan: vi.fn(), routerPush: vi.fn() }))
const { accessCan, routerPush, carrierMounts } = vi.hoisted(() => ({ accessCan: vi.fn(), routerPush: vi.fn(), carrierMounts: { value: 0 } }))
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { containerId: '2092438843630391297' }, query: {} }), useRouter: () => ({ push: routerPush }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: accessCan }) }))
vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '<div data-test="carrier-panel" />' } }))
vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '<div data-test="carrier-panel" />', mounted: () => { carrierMounts.value += 1 } } }))
vi.mock('@/modules/loading-lists', () => ({ uploadLoadingDeclaration: vi.fn() }))
vi.mock('@/modules/outbound-dispatch/api/platformOutboundDispatch', () => ({ getContainerOutboundDispatch: vi.fn(), confirmContainerOutboundDispatch: vi.fn() }))
vi.mock('../api/platformContainerLoading', () => ({
getContainer: vi.fn(), getContainerOverview: vi.fn(), getContainerClearance: vi.fn(),
uploadContainerClearance: vi.fn(), completeContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(),
@@ -23,9 +26,20 @@ const overview = {
documents: [{ id: '2092438843630391501', documentType: 'CUSTOMS_DECLARATION', fileId: '2092438843630391502', fileName: 'old.pdf', fileHash: null, uploadedBy: '1', uploadedByName: '操作员', uploadedAt: '2026-08-26T10:00:00', version: 5, completed: false }],
exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [],
}
const outboundDetail = {
aggregate: {
containerBookingId: '2092438843630391297', cabinetNo: 'CAB-1', containerNo: 'C11111111',
loadingPlanId: '2092438843630391401', loadingPlanStatus: 'COMPLETED', dispatchStatus: 'READY_FOR_DISPATCH', version: 4,
inbounds: [{ orderId: '11', orderNo: 'O-1', inboundId: '2092438843630391501', inboundCode: 'IN-1', tenantId: '22', inboundStatus: 'LOADED' }],
gate: { passed: true, checkedAt: '2026-08-26T12:00:00', inbounds: [{ inboundId: '2092438843630391501', inboundCode: 'IN-1', gate: { passed: true, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: true, actual: 'COMPLETED', requirement: 'COMPLETED' }] } }] },
},
timeline: [],
} as ContainerOutboundDispatchDetail
function mountView(customs = true) {
accessCan.mockImplementation((permission: string) => permission === 'platform:firstleg:container:edit' || (customs && permission === 'platform:firstleg:customs:manage'))
function mountView(customs = true, outbound = true) {
accessCan.mockImplementation((permission: string) => permission === 'platform:firstleg:container:edit'
|| (customs && permission === 'platform:firstleg:customs:manage')
|| (outbound && ['platform:firstleg:outbound-dispatch:view', 'platform:firstleg:outbound-dispatch:confirm'].includes(permission)))
return mount(ContainerDetailView, { global: { plugins: [ElementPlus] } })
}
function documentArticle(wrapper: VueWrapper, label: string) { return wrapper.findAll('.document-item').find((item) => item.text().includes(label))! }
@@ -33,12 +47,15 @@ function documentArticle(wrapper: VueWrapper, label: string) { return wrapper.fi
describe('ContainerDetailView', () => {
beforeEach(() => {
vi.clearAllMocks()
carrierMounts.value = 0
vi.mocked(getContainer).mockResolvedValue(detail)
vi.mocked(getContainerOverview).mockResolvedValue(overview as never)
vi.mocked(getContainerClearance).mockResolvedValue(null)
vi.mocked(uploadLoadingDeclaration).mockResolvedValue({} as never)
vi.mocked(uploadContainerDocument).mockResolvedValue({} as never)
vi.mocked(uploadContainerClearance).mockResolvedValue({} as never)
vi.mocked(getContainerOutboundDispatch).mockResolvedValue(outboundDetail)
vi.mocked(confirmContainerOutboundDispatch).mockResolvedValue({ ...outboundDetail, aggregate: { ...outboundDetail.aggregate, dispatchStatus: 'WAITING_FOR_LOADING', version: 5 } } as never)
})
it('opens domestic export customs upload and sends stable plan, warehouse, version and reason', async () => {
@@ -77,4 +94,62 @@ describe('ContainerDetailView', () => {
expect(wrapper.text()).toContain('目的港清关资料'); expect(uploadLoadingDeclaration).not.toHaveBeenCalled()
wrapper.unmount()
})
it('loads and displays the whole-container gate without querying an inbound id', async () => {
const wrapper = mountView(); await flushPromises()
expect(getContainerOutboundDispatch).toHaveBeenCalledOnce()
expect(getContainerOutboundDispatch).toHaveBeenCalledWith('2092438843630391297')
expect(wrapper.text()).toContain('国内出仓门禁')
expect(wrapper.text()).toContain('报关协同完成')
expect(wrapper.text()).toContain('IN-1')
expect(wrapper.text()).toContain('确认离仓')
wrapper.unmount()
})
it('confirms the physical container once with version and no inboundId then refreshes the carrier panel', async () => {
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValueOnce({ action: 'confirm' } as never)
const wrapper = mountView(); await flushPromises()
await wrapper.find('.outbound-panel__actions button').trigger('click'); await flushPromises()
expect(confirmContainerOutboundDispatch).toHaveBeenCalledOnce()
expect(confirmContainerOutboundDispatch).toHaveBeenCalledWith('2092438843630391297', { expectedVersion: 4, remark: '货柜详情确认国内出仓' })
expect(vi.mocked(confirmContainerOutboundDispatch).mock.calls[0]?.[1]).not.toHaveProperty('inboundId')
expect(getContainerOverview).toHaveBeenCalledTimes(2)
expect(carrierMounts.value).toBe(2)
expect(wrapper.text()).toContain('已确认离仓')
wrapper.unmount()
})
it('prevents a repeated confirmation wave while the first prompt is open', async () => {
vi.spyOn(ElMessageBox, 'confirm').mockReturnValueOnce(new Promise(() => {}) as never)
const wrapper = mountView(); await flushPromises()
const button = wrapper.find('.outbound-panel__actions button')
await button.trigger('click')
await button.trigger('click')
expect(ElMessageBox.confirm).toHaveBeenCalledOnce()
expect(confirmContainerOutboundDispatch).not.toHaveBeenCalled()
wrapper.unmount()
})
it('hides the outbound gate and never calls its API without view permission', async () => {
const wrapper = mountView(true, false); await flushPromises()
expect(wrapper.text()).not.toContain('国内出仓门禁')
expect(getContainerOutboundDispatch).not.toHaveBeenCalled()
wrapper.unmount()
})
it('shows gate failure reasons and does not expose confirm when any gate is blocked', async () => {
vi.mocked(getContainerOutboundDispatch).mockResolvedValueOnce({
...outboundDetail,
aggregate: {
...outboundDetail.aggregate,
gate: { passed: false, checkedAt: '2026-08-26T12:00:00', inbounds: [{ inboundId: '2092438843630391501', inboundCode: 'IN-1', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'TRUCK_DEPARTED', name: '关联拖车已离仓', passed: false, actual: 'ARRIVED', requirement: 'DEPARTED或COMPLETED' }] } }] },
},
} as never)
const wrapper = mountView(); await flushPromises()
expect(wrapper.text()).toContain('关联拖车已离仓')
expect(wrapper.text()).toContain('ARRIVED')
expect(wrapper.text()).toContain('DEPARTED或COMPLETED')
expect(wrapper.find('.outbound-panel__actions button').exists()).toBe(false)
wrapper.unmount()
})
})