This commit is contained in:
2026-08-28 16:54:02 +08:00
parent 94f003153d
commit 813763a02a
16 changed files with 413 additions and 883 deletions
@@ -1,559 +1,22 @@
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 { completeContainerClearance, downloadContainerClearance, downloadContainerDocument, getContainer, getContainerClearance, getContainerOverview, uploadContainerClearance, uploadContainerDocument } from '../api/platformContainerLoading'
import { completeLoadingDeclaration, downloadLoadingDeclaration, getLoadingDeclaration, uploadLoadingDeclaration } from '@/modules/loading-lists'
import { confirmContainerOutboundDispatch, getContainerOutboundDispatch } from '@/modules/outbound-dispatch/api/platformOutboundDispatch'
import type { ContainerOutboundDispatchDetail } from '@/modules/outbound-dispatch/types'
import { ApiResponseError } from '@/shared/api/http'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
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" />', mounted: () => { carrierMounts.value += 1 } } }))
vi.mock('@/modules/loading-lists', () => ({ uploadLoadingDeclaration: vi.fn(), completeLoadingDeclaration: vi.fn(), downloadLoadingDeclaration: vi.fn(), getLoadingDeclaration: 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(),
downloadContainerDocument: vi.fn(), downloadContainerPdaPhoto: vi.fn(), markContainerException: vi.fn(), cancelContainerException: vi.fn(),
downloadContainerClearance: vi.fn(),
}))
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8')
const overviewSource = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/components/ContainerOverviewPanel.vue'), 'utf8')
const detail = { id: '2092438843630391297', planId: '2092438843630391401', planNo: 'LP-260825-0181', bookingNo: 'CB-1', cabinetNo: 'CAB-1', containerNo: 'C11111111', warehouseId: '193000000000200002', warehouseName: '东莞仓', containerType: '45HC', planStatus: 'COMPLETED', version: 8 } as never
const overview = {
container: { id: '2092438843630391297', bookingNo: 'CB-1', cabinetNo: 'CAB-1', containerNo: 'C11111111', status: 'COMPLETED', warehouseId: '193000000000200002', warehouseName: '东莞仓', voyageNo: null, vesselName: null, version: 8 },
loadingPlanId: '2092438843630391401', pda: null,
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', truckAppointmentStatus: 'ARRIVED', 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, outbound = true) {
accessCan.mockImplementation((permission: string) => permission === 'platform:firstleg:container:edit'
|| (customs && permission === 'platform:firstleg:customs:manage')
|| permission === 'platform:firstleg:loading-list:list'
|| (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-table tbody tr').find((item) => item.text().includes(label))! }
function documentButton(wrapper: VueWrapper, label: string, buttonText: string) { return documentArticle(wrapper, label).findAll('button').find((button) => button.text().includes(buttonText))! }
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => { resolve = resolvePromise })
return { promise, resolve }
}
type ClearanceVm = { clearanceOpen: boolean; clearanceFile: File | null; clearanceError: { code?: string; requestId?: string } | null; documentRefreshWarning: unknown; selectClearanceFile: (file: { raw: File }) => void; uploadClearance: () => Promise<void>; completeClearance: () => Promise<void> }
function clearanceVm(wrapper: VueWrapper) { return wrapper.vm as unknown as ClearanceVm }
type DocumentVm = { documentOpen: boolean; documentFile: File | null; documentError: { code?: string; requestId?: string } | null; documentUploadKey: number; documentRefreshWarning: { code?: string } | null; selectDocumentFile: (file: { raw: File }) => void; saveDocument: () => Promise<void> }
function documentVm(wrapper: VueWrapper) { return wrapper.vm as unknown as DocumentVm }
describe('ContainerDetailView', () => {
beforeEach(() => {
vi.clearAllMocks()
Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:document') })
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() })
carrierMounts.value = 0
vi.mocked(getContainer).mockResolvedValue(detail)
vi.mocked(getContainerOverview).mockResolvedValue(overview as never)
vi.mocked(getContainerClearance).mockResolvedValue(null)
vi.mocked(uploadLoadingDeclaration).mockResolvedValue({ id: '2092438843630391501', documentType: 'CUSTOMS_DECLARATION', fileId: '2092438843630391502', fileName: 'customs-new.pdf', fileHash: 'hash', uploadedBy: '1', uploadedByName: '操作员', uploadedAt: '2026-08-26T12:00:00', completedBy: null, completedByName: null, completedAt: null, version: 6 } as never)
vi.mocked(completeLoadingDeclaration).mockResolvedValue({} as never)
vi.mocked(downloadLoadingDeclaration).mockResolvedValue(new Blob(['customs']))
vi.mocked(downloadContainerClearance).mockResolvedValue(new Blob(['clearance']))
vi.mocked(getLoadingDeclaration).mockResolvedValue(null)
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)
describe('ContainerDetailView customs integration', () => {
it('mounts the new container customs panel only after loading completes', () => {
expect(source).toContain("detail.planStatus === 'COMPLETED' && overview?.loadingPlanId && canListCustoms")
expect(source).toContain('<ContainerCustomsPanel')
expect(source).toContain(':loading-plan-id="overview.loadingPlanId"')
expect(source).toContain(':can-manage="canManageCustoms"')
})
it('opens domestic export customs upload and sends stable plan, warehouse, version and reason', async () => {
const wrapper = mountView(); await flushPromises()
await documentButton(wrapper, '国内出口报关附件', '更新附件').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('国内出口报关附件')
const file = new File(['customs'], 'customs.pdf', { type: 'application/pdf' })
;(wrapper.vm as unknown as { selectDocumentFile: (file: { raw: File }) => void }).selectDocumentFile({ raw: file })
await (wrapper.vm as unknown as { saveDocument: () => Promise<void> }).saveDocument()
expect(uploadLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002', file, 5, '上传国内出口报关附件')
expect(uploadContainerDocument).not.toHaveBeenCalled(); expect(uploadContainerClearance).not.toHaveBeenCalled()
wrapper.unmount()
})
it('shows a readable error when the container has no loading plan', async () => {
vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, loadingPlanId: null } as never)
const wrapper = mountView(); await flushPromises()
await documentButton(wrapper, '国内出口报关附件', '更新附件').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('当前货柜没有装柜清单,无法上传国内出口报关附件')
expect(uploadLoadingDeclaration).not.toHaveBeenCalled(); wrapper.unmount()
})
it('hides customs and clearance upload without customs manage permission while keeping DO and PU', async () => {
const wrapper = mountView(false); await flushPromises()
expect(documentArticle(wrapper, '国内出口报关附件').findAll('button').map((button) => button.text())).toEqual(['下载'])
expect(documentArticle(wrapper, '目的港清关资料').find('button').exists()).toBe(false)
expect(documentArticle(wrapper, 'DO 资料').find('button').exists()).toBe(true)
expect(documentArticle(wrapper, 'PU 资料').find('button').exists()).toBe(true)
wrapper.unmount()
})
it('keeps destination clearance on its independent container clearance API', async () => {
const wrapper = mountView(); await flushPromises()
await documentArticle(wrapper, '目的港清关资料').find('button').trigger('click'); await flushPromises()
expect(getContainerClearance).toHaveBeenCalledWith('2092438843630391297')
expect(wrapper.text()).toContain('目的港清关资料'); expect(uploadLoadingDeclaration).not.toHaveBeenCalled()
wrapper.unmount()
})
it('downloads customs through the dedicated loading-list API and keeps DO PU isolated', async () => {
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
const wrapper = mountView(); await flushPromises()
await documentButton(wrapper, '国内出口报关附件', '下载').trigger('click'); await flushPromises()
expect(downloadLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002', '2092438843630391502')
expect(downloadContainerDocument).not.toHaveBeenCalled()
expect(URL.createObjectURL).toHaveBeenCalledWith(expect.any(Blob))
expect(click).toHaveBeenCalledOnce()
expect(URL.revokeObjectURL).toHaveBeenCalled()
wrapper.unmount()
})
it('keeps customs download after completion while hiding update and completion actions', async () => {
vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, documents: overview.documents.map((item) => ({ ...item, completed: true })) } as never)
const wrapper = mountView(); await flushPromises()
const row = documentArticle(wrapper, '国内出口报关附件')
expect(row.findAll('button').map((button) => button.text())).toEqual(['下载'])
expect(row.text()).toContain('已完成')
wrapper.unmount()
})
it('keeps the optimistic customs upload when overview refresh fails and shows a readable warning', async () => {
vi.mocked(getContainerOverview).mockResolvedValueOnce(overview as never).mockRejectedValueOnce(new Error('overview unavailable'))
const wrapper = mountView(); await flushPromises()
await documentButton(wrapper, '国内出口报关附件', '更新附件').trigger('click'); await flushPromises()
const file = new File(['customs-new'], 'customs-new.pdf', { type: 'application/pdf' })
;(wrapper.vm as unknown as { selectDocumentFile: (file: { raw: File }) => void }).selectDocumentFile({ raw: file })
await (wrapper.vm as unknown as { saveDocument: () => Promise<void> }).saveDocument(); await flushPromises()
const row = documentArticle(wrapper, '国内出口报关附件')
expect(row.text()).toContain('customs-new.pdf')
expect(row.text()).toContain('操作员')
expect(row.text()).toContain('2026-08-26 12:00')
expect(wrapper.text()).toContain('附件已上传,但最新资料刷新失败;当前已保留本次上传结果')
expect(uploadLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002', file, 5, '上传国内出口报关附件')
wrapper.unmount()
})
it('refreshes a hidden customs version after A0409 and preserves error evidence without auto retry', async () => {
const hidden = { id: '2092438843630392501', documentType: 'CUSTOMS_DECLARATION', fileId: '2092438843630392502', fileName: 'server-customs.pdf', fileHash: 'server-hash', uploadedBy: '2', uploadedByName: '服务端操作员', uploadedAt: '2026-08-26T12:30:00', completedBy: null, completedByName: null, completedAt: null, version: 7 }
const withoutCustoms = { ...overview, documents: [] }
vi.mocked(getContainerOverview).mockResolvedValue(withoutCustoms as never)
vi.mocked(getLoadingDeclaration).mockResolvedValueOnce(hidden as never)
vi.mocked(uploadLoadingDeclaration).mockRejectedValueOnce(new ApiResponseError('版本冲突', 'A0409', undefined, undefined, 'request-upload-409', 'trace-upload-409'))
const wrapper = mountView(); await flushPromises()
await documentButton(wrapper, '国内出口报关附件', '上传附件').trigger('click'); await flushPromises()
const file = new File(['next'], 'next.pdf', { type: 'application/pdf' })
;(wrapper.vm as unknown as { selectDocumentFile: (file: { raw: File }) => void }).selectDocumentFile({ raw: file })
await (wrapper.vm as unknown as { saveDocument: () => Promise<void> }).saveDocument(); await flushPromises()
expect(uploadLoadingDeclaration).toHaveBeenCalledOnce()
expect(uploadLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002', file, 0, '上传国内出口报关附件')
expect(getLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002')
expect(documentArticle(wrapper, '国内出口报关附件').text()).toContain('server-customs.pdf')
expect(documentArticle(wrapper, '国内出口报关附件').text()).toContain('服务端操作员')
expect(wrapper.text()).toContain('A0409')
expect(wrapper.text()).toContain('request-upload-409')
wrapper.unmount()
})
it('closes immediately after a valid destination upload response, clears dialog state and refreshes after optimistic table update', async () => {
const existing = { id: '301', documentType: 'DESTINATION_CLEARANCE', fileId: '302', fileName: 'clearance-v1.pdf', fileHash: null, uploadedBy: '3', uploadedByName: '清关员', uploadedAt: '2026-08-26T11:00:00', completedBy: null, completedByName: null, completedAt: null, version: 1 }
const updated = { ...existing, fileId: '303', fileName: 'clearance-v2.pdf', uploadedAt: '2026-08-26T13:00:00', version: 2 }
const refresh = deferred<typeof overview>()
vi.mocked(getContainerOverview).mockResolvedValueOnce(overview as never).mockReturnValueOnce(refresh.promise as never)
vi.mocked(getContainerClearance).mockResolvedValueOnce(existing as never)
vi.mocked(uploadContainerClearance).mockResolvedValueOnce(updated as never)
const wrapper = mountView(); await flushPromises()
await documentButton(wrapper, '目的港清关资料', '上传附件').trigger('click'); await flushPromises()
const file = new File(['clearance'], 'clearance-v2.pdf', { type: 'application/pdf' })
clearanceVm(wrapper).selectClearanceFile({ raw: file })
const request = clearanceVm(wrapper).uploadClearance(); await flushPromises()
expect(uploadContainerClearance).toHaveBeenCalledWith('2092438843630391297', file, 1, '上传目的港清关资料')
expect(clearanceVm(wrapper).clearanceOpen).toBe(false)
expect(clearanceVm(wrapper).clearanceFile).toBeNull()
expect(clearanceVm(wrapper).clearanceError).toBeNull()
expect(documentArticle(wrapper, '目的港清关资料').text()).toContain('clearance-v2.pdf')
refresh.resolve(overview)
await request; await flushPromises()
expect(clearanceVm(wrapper).documentRefreshWarning).toBeNull()
wrapper.unmount()
})
it.each([
['DELIVERY_ORDER', 'DO 资料'],
['PICKUP_ORDER', 'PU 资料'],
] as const)('optimistically replaces %s, closes before overview refresh and preserves the returned file over a stale overview', async (documentType, label) => {
const existing = { id: `old-${documentType}`, documentType, fileId: `old-file-${documentType}`, fileName: `old-${documentType}.pdf`, fileHash: null, uploadedBy: '1', uploadedByName: '旧操作员', uploadedAt: '2026-08-26T10:00:00', version: 1, completed: false }
const initial = { ...overview, documents: [...overview.documents, existing] }
const uploaded = { id: `new-${documentType}`, documentType, fileId: `new-file-${documentType}`, fileName: `new-${documentType}.pdf`, fileHash: 'hash', uploadedBy: '2', uploadedByName: '新操作员', uploadedAt: '2026-08-27T13:00:00', completedBy: null, completedByName: null, completedAt: null, version: 2 }
const refresh = deferred<typeof initial>()
vi.mocked(getContainerOverview).mockResolvedValueOnce(initial as never).mockReturnValueOnce(refresh.promise as never)
vi.mocked(uploadContainerDocument).mockResolvedValueOnce(uploaded as never)
const wrapper = mountView(); await flushPromises()
await documentButton(wrapper, label, '更新附件').trigger('click'); await flushPromises()
const file = new File([documentType], `new-${documentType}.pdf`, { type: 'application/pdf' })
documentVm(wrapper).selectDocumentFile({ raw: file })
const request = documentVm(wrapper).saveDocument(); await flushPromises()
expect(uploadContainerDocument).toHaveBeenCalledWith('2092438843630391297', documentType, file, 1, `上传${label}`)
expect(documentVm(wrapper).documentOpen).toBe(false)
expect(documentVm(wrapper).documentFile).toBeNull()
expect(documentArticle(wrapper, label).text()).toContain(`new-${documentType}.pdf`)
expect(documentArticle(wrapper, label).text()).toContain('新操作员')
refresh.resolve(initial); await request; await flushPromises()
expect(documentArticle(wrapper, label).text()).toContain(`new-${documentType}.pdf`)
expect(documentVm(wrapper).documentRefreshWarning).toMatchObject({ code: 'DOCUMENT_OVERVIEW_STALE' })
wrapper.unmount()
})
it('rebuilds the upload picker on reopen and keeps DO failures open with the selected file and API evidence', async () => {
const existing = { id: 'do-old', documentType: 'DELIVERY_ORDER', fileId: 'do-file-old', fileName: 'old-do.pdf', fileHash: null, uploadedBy: '1', uploadedByName: '旧操作员', uploadedAt: '2026-08-26T10:00:00', version: 1, completed: false }
vi.mocked(getContainerOverview).mockResolvedValue({ ...overview, documents: [...overview.documents, existing] } as never)
vi.mocked(uploadContainerDocument)
.mockRejectedValueOnce(new ApiResponseError('版本冲突', 'A0409', undefined, undefined, 'request-do-409', 'trace-do-409'))
.mockResolvedValueOnce(null)
const wrapper = mountView(); await flushPromises()
await documentButton(wrapper, 'DO 资料', '更新附件').trigger('click'); await flushPromises()
const firstKey = documentVm(wrapper).documentUploadKey
const file = new File(['do'], 'next-do.pdf', { type: 'application/pdf' })
documentVm(wrapper).selectDocumentFile({ raw: file })
await documentVm(wrapper).saveDocument(); await flushPromises()
expect(documentVm(wrapper).documentOpen).toBe(true)
expect(documentVm(wrapper).documentFile).toBe(file)
expect(documentVm(wrapper).documentError).toMatchObject({ code: 'A0409', requestId: 'request-do-409' })
expect(wrapper.text()).toContain('trace-do-409')
documentVm(wrapper).documentOpen = false; await flushPromises()
await documentButton(wrapper, 'PU 资料', '上传附件').trigger('click'); await flushPromises()
expect(documentVm(wrapper).documentUploadKey).toBeGreaterThan(firstKey)
expect(documentVm(wrapper).documentFile).toBeNull()
expect(documentVm(wrapper).documentError).toBeNull()
expect(wrapper.text()).not.toContain('request-do-409')
const puFile = new File(['pu'], 'next-pu.pdf', { type: 'application/pdf' })
documentVm(wrapper).selectDocumentFile({ raw: puFile })
await documentVm(wrapper).saveDocument(); await flushPromises()
expect(documentVm(wrapper).documentOpen).toBe(true)
expect(documentVm(wrapper).documentFile).toBe(puFile)
expect(uploadContainerDocument).toHaveBeenCalledTimes(2)
wrapper.unmount()
})
it('downloads destination clearance through its dedicated endpoint and keeps completed rows download-only', async () => {
const completed = { id: '351', documentType: 'DESTINATION_CLEARANCE', fileId: '2092438843630393502', fileName: 'destination-clearance.pdf', fileHash: null, uploadedBy: '3', uploadedByName: '清关员', uploadedAt: '2026-08-26T11:00:00', version: 2, completed: true }
vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, documents: [...overview.documents, completed] } as never)
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
const wrapper = mountView(); await flushPromises()
const row = documentArticle(wrapper, '目的港清关资料')
expect(row.findAll('button').map((button) => button.text())).toEqual(['下载'])
await documentButton(wrapper, '目的港清关资料', '下载').trigger('click'); await flushPromises()
expect(downloadContainerClearance).toHaveBeenCalledWith('2092438843630391297', '2092438843630393502')
expect(downloadLoadingDeclaration).not.toHaveBeenCalled()
expect(downloadContainerDocument).not.toHaveBeenCalled()
expect(click).toHaveBeenCalledOnce()
wrapper.unmount()
})
it('refreshes destination clearance after A0409 and preserves the server file and error metadata', async () => {
const existing = { id: '401', documentType: 'DESTINATION_CLEARANCE', fileId: '402', fileName: 'clearance-v1.pdf', fileHash: null, uploadedBy: '3', uploadedByName: '清关员', uploadedAt: '2026-08-26T11:00:00', completedBy: null, completedByName: null, completedAt: null, version: 1 }
const server = { ...existing, fileId: '403', fileName: 'server-clearance-v2.pdf', uploadedByName: '服务端清关员', version: 2 }
vi.mocked(getContainerClearance).mockResolvedValueOnce(existing as never).mockResolvedValueOnce(server as never).mockResolvedValueOnce(server as never)
vi.mocked(uploadContainerClearance).mockRejectedValueOnce(new ApiResponseError('版本冲突', 'A0409', undefined, undefined, 'request-clearance-409', 'trace-clearance-409'))
const wrapper = mountView(); await flushPromises()
await documentButton(wrapper, '目的港清关资料', '上传附件').trigger('click'); await flushPromises()
const file = new File(['clearance'], 'clearance-next.pdf', { type: 'application/pdf' })
clearanceVm(wrapper).selectClearanceFile({ raw: file })
await clearanceVm(wrapper).uploadClearance(); await flushPromises()
expect(uploadContainerClearance).toHaveBeenCalledOnce()
expect(clearanceVm(wrapper).clearanceOpen).toBe(true)
expect(clearanceVm(wrapper).clearanceFile).toBe(file)
expect(clearanceVm(wrapper).clearanceError).toMatchObject({ code: 'A0409', requestId: 'request-clearance-409' })
expect(documentArticle(wrapper, '目的港清关资料').text()).toContain('server-clearance-v2.pdf')
expect(wrapper.text()).toContain('A0409')
expect(wrapper.text()).toContain('request-clearance-409')
clearanceVm(wrapper).clearanceOpen = false; await flushPromises()
await documentButton(wrapper, '目的港清关资料', '更新附件').trigger('click'); await flushPromises()
expect(clearanceVm(wrapper).clearanceOpen).toBe(true)
expect(clearanceVm(wrapper).clearanceFile).toBeNull()
expect(clearanceVm(wrapper).clearanceError).toBeNull()
expect(wrapper.text()).not.toContain('request-clearance-409')
wrapper.unmount()
})
it('keeps the destination dialog and selected file for network and empty-response upload failures', async () => {
const existing = { id: '501', documentType: 'DESTINATION_CLEARANCE', fileId: '502', fileName: 'clearance-v1.pdf', fileHash: null, uploadedBy: '3', uploadedByName: '清关员', uploadedAt: '2026-08-26T11:00:00', completedBy: null, completedByName: null, completedAt: null, version: 1 }
vi.mocked(getContainerClearance).mockResolvedValueOnce(existing as never)
vi.mocked(uploadContainerClearance)
.mockRejectedValueOnce(new ApiResponseError('网络请求失败', 'NETWORK_ERROR', undefined, undefined, 'request-network', 'trace-network'))
.mockResolvedValueOnce(null)
const wrapper = mountView(); await flushPromises()
await documentButton(wrapper, '目的港清关资料', '上传附件').trigger('click'); await flushPromises()
const file = new File(['clearance'], 'clearance-next.pdf', { type: 'application/pdf' })
clearanceVm(wrapper).selectClearanceFile({ raw: file })
await clearanceVm(wrapper).uploadClearance(); await flushPromises()
expect(clearanceVm(wrapper).clearanceOpen).toBe(true)
expect(clearanceVm(wrapper).clearanceFile).toBe(file)
expect(wrapper.text()).toContain('request-network')
await clearanceVm(wrapper).uploadClearance(); await flushPromises()
expect(clearanceVm(wrapper).clearanceOpen).toBe(true)
expect(clearanceVm(wrapper).clearanceFile).toBe(file)
expect(uploadContainerClearance).toHaveBeenCalledTimes(2)
wrapper.unmount()
})
it('closes after successful destination clearance completion but keeps the dialog open on completion failure', async () => {
const existing = { id: '601', documentType: 'DESTINATION_CLEARANCE', fileId: '602', fileName: 'clearance.pdf', fileHash: null, uploadedBy: '3', uploadedByName: '清关员', uploadedAt: '2026-08-26T11:00:00', completedBy: null, completedByName: null, completedAt: null, version: 1 }
const completed = { ...existing, completedBy: '4', completedByName: '复核员', completedAt: '2026-08-27T12:00:00', version: 2 }
const refresh = deferred<typeof overview>()
vi.mocked(getContainerOverview).mockResolvedValueOnce(overview as never).mockReturnValueOnce(refresh.promise as never)
vi.mocked(getContainerClearance).mockResolvedValueOnce(existing as never)
vi.mocked(completeContainerClearance).mockResolvedValueOnce(completed as never)
let wrapper = mountView(); await flushPromises()
await documentButton(wrapper, '目的港清关资料', '上传附件').trigger('click'); await flushPromises()
const request = clearanceVm(wrapper).completeClearance(); await flushPromises()
expect(clearanceVm(wrapper).clearanceOpen).toBe(false)
expect(clearanceVm(wrapper).clearanceError).toBeNull()
expect(documentArticle(wrapper, '目的港清关资料').text()).toContain('已完成')
refresh.resolve(overview); await request; await flushPromises()
wrapper.unmount()
vi.mocked(getContainerOverview).mockResolvedValue(overview as never)
vi.mocked(getContainerClearance).mockResolvedValueOnce(existing as never)
vi.mocked(completeContainerClearance).mockRejectedValueOnce(new ApiResponseError('完成失败', 'A0409', undefined, undefined, 'request-complete-409', 'trace-complete-409'))
wrapper = mountView(); await flushPromises()
await documentButton(wrapper, '目的港清关资料', '上传附件').trigger('click'); await flushPromises()
await clearanceVm(wrapper).completeClearance(); await flushPromises()
expect(clearanceVm(wrapper).clearanceOpen).toBe(true)
expect(clearanceVm(wrapper).clearanceError).toMatchObject({ code: 'A0409', requestId: 'request-complete-409' })
expect(wrapper.text()).toContain('trace-complete-409')
wrapper.unmount()
})
it('keeps customs attachment upload and completion as two explicit steps with exact payload', async () => {
const completedOverview = { ...overview, documents: overview.documents.map((item) => ({ ...item, completed: true })) }
vi.mocked(getContainerOverview).mockResolvedValueOnce(overview as never).mockResolvedValue(completedOverview as never)
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValueOnce({ action: 'confirm' } as never)
const wrapper = mountView(); await flushPromises()
const customs = documentArticle(wrapper, '国内出口报关附件')
expect(customs.text()).toContain('附件已上传,待确认报关完成')
expect(customs.text()).toContain('更新附件')
await documentButton(wrapper, '国内出口报关附件', '确认报关完成').trigger('click'); await flushPromises()
expect(completeLoadingDeclaration).toHaveBeenCalledOnce()
expect(completeLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002', 5, '货柜详情确认国内出口报关完成')
expect(uploadLoadingDeclaration).not.toHaveBeenCalled()
expect(getContainerOverview).toHaveBeenCalledTimes(2)
expect(getContainerOutboundDispatch).toHaveBeenCalledTimes(2)
expect(documentArticle(wrapper, '国内出口报关附件').text()).toContain('已确认报关完成')
expect(documentButton(wrapper, '国内出口报关附件', '确认报关完成')).toBeUndefined()
wrapper.unmount()
})
it('shows customs completion only for an existing unfinished attachment with manage permission', async () => {
vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, documents: [] } as never)
let wrapper = mountView(); await flushPromises()
expect(documentArticle(wrapper, '国内出口报关附件').text()).toContain('第一步上传附件,第二步确认报关完成')
expect(documentButton(wrapper, '国内出口报关附件', '确认报关完成')).toBeUndefined()
wrapper.unmount()
vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, documents: overview.documents.map((item) => ({ ...item, completed: true })) } as never)
wrapper = mountView(); await flushPromises()
expect(documentButton(wrapper, '国内出口报关附件', '确认报关完成')).toBeUndefined()
wrapper.unmount()
wrapper = mountView(false); await flushPromises()
expect(documentButton(wrapper, '国内出口报关附件', '确认报关完成')).toBeUndefined()
wrapper.unmount()
})
it('shows a readable completion error without a loading plan and does not call the API', async () => {
vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, loadingPlanId: null } as never)
const wrapper = mountView(); await flushPromises()
await documentButton(wrapper, '国内出口报关附件', '确认报关完成').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('当前货柜没有装柜清单,无法确认报关完成')
expect(completeLoadingDeclaration).not.toHaveBeenCalled()
expect(ElMessageBox.confirm).not.toHaveBeenCalled()
wrapper.unmount()
})
it('prevents repeated customs completion and preserves A0409 evidence after refreshing facts', async () => {
let releasePrompt!: () => void
vi.spyOn(ElMessageBox, 'confirm').mockReturnValueOnce(new Promise<void>((resolve) => { releasePrompt = resolve }) as never)
const wrapper = mountView(); await flushPromises()
const button = documentButton(wrapper, '国内出口报关附件', '确认报关完成')
await button.trigger('click'); await button.trigger('click')
expect(ElMessageBox.confirm).toHaveBeenCalledOnce()
releasePrompt(); await flushPromises()
vi.mocked(completeLoadingDeclaration).mockRejectedValueOnce(new ApiResponseError('数据已变更', 'A0409', undefined, undefined, 'request-customs-409', 'trace-customs-409'))
vi.mocked(ElMessageBox.confirm).mockResolvedValueOnce({ action: 'confirm' } as never)
await documentButton(wrapper, '国内出口报关附件', '确认报关完成').trigger('click'); await flushPromises()
expect(completeLoadingDeclaration).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('A0409')
expect(wrapper.text()).toContain('request-customs-409')
expect(getContainerOverview).toHaveBeenCalledTimes(3)
expect(getContainerOutboundDispatch).toHaveBeenCalledTimes(3)
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.find('.gate-summary').text()).toContain('总门禁 1')
expect(wrapper.find('.gate-summary').text()).toContain('已通过 1')
expect(wrapper.find('.gate-summary').text()).toContain('阻断 0')
expect(wrapper.find('.gate-list').text()).not.toContain('报关协同完成')
await wrapper.find('.gate-summary button').trigger('click')
expect(wrapper.find('.gate-list').text()).toContain('报关协同完成')
expect(wrapper.find('.gate-list').text()).toContain('覆盖 1 个入库码')
expect(wrapper.find('.gate-list').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: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: false, actual: 'MISSING', requirement: 'COMPLETED' }] } }] },
},
} as never)
const wrapper = mountView(); await flushPromises()
expect(wrapper.text()).toContain('报关协同完成')
expect(wrapper.text()).toContain('MISSING')
expect(wrapper.text()).toContain('COMPLETED')
expect(wrapper.find('.outbound-panel__actions button').exists()).toBe(false)
wrapper.unmount()
})
it('deduplicates identical gates across inbounds and shows compact coverage counts', async () => {
const blocked = { code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: false, actual: 'MISSING', requirement: 'COMPLETED' }
const passed = { code: 'LOADING_PLAN_COMPLETED', name: '柜次装柜计划完成', passed: true, actual: 'COMPLETED', requirement: 'COMPLETED' }
vi.mocked(getContainerOutboundDispatch).mockResolvedValueOnce({
...outboundDetail,
aggregate: {
...outboundDetail.aggregate,
gate: {
passed: false,
checkedAt: '2026-08-26T12:00:00',
inbounds: [
{ inboundId: '101', inboundCode: 'IN-101', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [blocked, passed] } },
{ inboundId: '102', inboundCode: 'IN-102', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [blocked, passed] } },
],
},
},
} as never)
const wrapper = mountView(); await flushPromises()
expect(wrapper.find('.gate-summary').text()).toContain('总门禁 2')
expect(wrapper.find('.gate-summary').text()).toContain('已通过 1')
expect(wrapper.find('.gate-summary').text()).toContain('阻断 1')
expect(wrapper.findAll('.gate-row')).toHaveLength(1)
expect(wrapper.find('.gate-row').text()).toContain('影响 2 个入库码')
expect(wrapper.find('.gate-row').text()).toContain('IN-101')
expect(wrapper.find('.gate-row').text()).toContain('IN-102')
await wrapper.find('.gate-summary button').trigger('click')
expect(wrapper.findAll('.gate-row')).toHaveLength(2)
expect(wrapper.findAll('.gate-row')[1]?.text()).toContain('覆盖 2 个入库码')
wrapper.unmount()
})
it('keeps different facts for the same gate code separate and labels the affected inbound', async () => {
vi.mocked(getContainerOutboundDispatch).mockResolvedValueOnce({
...outboundDetail,
aggregate: {
...outboundDetail.aggregate,
gate: {
passed: false,
checkedAt: '2026-08-26T12:00:00',
inbounds: [
{ inboundId: '201', inboundCode: 'IN-MISSING', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: false, actual: 'MISSING', requirement: 'COMPLETED' }] } },
{ inboundId: '202', inboundCode: 'IN-PENDING', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: false, actual: 'PENDING', requirement: 'COMPLETED' }] } },
{ inboundId: '203', inboundCode: 'IN-DONE', gate: { passed: true, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: true, actual: 'COMPLETED', requirement: 'COMPLETED' }] } },
],
},
},
} as never)
const wrapper = mountView(); await flushPromises()
expect(wrapper.findAll('.gate-row')).toHaveLength(2)
expect(wrapper.find('.gate-list').text()).toContain('MISSING')
expect(wrapper.find('.gate-list').text()).toContain('IN-MISSING')
expect(wrapper.find('.gate-list').text()).toContain('PENDING')
expect(wrapper.find('.gate-list').text()).toContain('IN-PENDING')
await wrapper.find('.gate-summary button').trigger('click')
expect(wrapper.findAll('.gate-row')).toHaveLength(3)
expect(wrapper.find('.gate-list').text()).toContain('IN-DONE')
wrapper.unmount()
})
it('keeps truck and sailing schedule as reference only and never counts legacy reference gates as blocking', async () => {
vi.mocked(getContainerOutboundDispatch).mockResolvedValueOnce({
...outboundDetail,
aggregate: {
...outboundDetail.aggregate,
truckAppointmentStatus: 'ARRIVED',
gate: {
passed: false,
checkedAt: '2026-08-26T12:00:00',
inbounds: [{ inboundId: '301', inboundCode: 'IN-301', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [
{ code: 'TRUCK_DEPARTED', name: '关联拖车已离仓', passed: false, actual: 'ARRIVED', requirement: 'DEPARTED或COMPLETED' },
{ code: 'SAILING_SCHEDULE', name: '船期有效且未过期', passed: false, actual: 'EXPIRED', requirement: 'VALID' },
{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: true, actual: 'COMPLETED', requirement: 'COMPLETED' },
] } }],
},
},
} as never)
const wrapper = mountView(); await flushPromises()
expect(wrapper.find('.gate-summary').text()).toContain('总门禁 1')
expect(wrapper.find('.gate-summary').text()).toContain('阻断 0')
expect(wrapper.find('.gate-summary').text()).toContain('拖车状态(参考):已到仓')
expect(wrapper.text()).not.toContain('关联拖车已离仓')
expect(wrapper.text()).not.toContain('船期有效且未过期')
expect(wrapper.find('.outbound-panel__actions button').text()).toContain('确认离仓')
wrapper.unmount()
it('removes the retired loading-list customs UI and calls', () => {
for (const retired of ['getLoadingDeclaration', 'uploadLoadingDeclaration', 'completeLoadingDeclaration', 'downloadLoadingDeclaration', 'CUSTOMS_DECLARATION']) {
expect(source).not.toContain(retired)
expect(overviewSource).not.toContain(retired)
}
})
})
@@ -11,9 +11,9 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { CarrierScheduleQueryPanel } from '@/modules/port-schedules'
import { completeLoadingDeclaration, downloadLoadingDeclaration, getLoadingDeclaration, uploadLoadingDeclaration } from '@/modules/loading-lists'
import { confirmContainerOutboundDispatch, getContainerOutboundDispatch } from '@/modules/outbound-dispatch/api/platformOutboundDispatch'
import type { ContainerOutboundDispatchDetail, ContainerOutboundGateItem } from '@/modules/outbound-dispatch/types'
import ContainerCustomsPanel from '../components/ContainerCustomsPanel.vue'
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
import {
cancelContainerException,
@@ -35,7 +35,7 @@ const access = useAccessPreviewStore()
const containerId = computed(() => String(route.params.containerId || ''))
const canEdit = computed(() => access.can('platform:firstleg:container:edit'))
const canManageCustoms = computed(() => access.can('platform:firstleg:customs:manage'))
const canDownloadCustoms = computed(() => access.can('platform:firstleg:loading-list:list'))
const canListCustoms = computed(() => access.can('platform:firstleg:customs:list'))
const canViewOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:view'))
const canConfirmOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:confirm'))
const loading = ref(false)
@@ -44,15 +44,13 @@ const overview = ref<ContainerOverview | null>(null)
const error = ref<ApiErrorDetails | null>(null)
const overviewError = ref<ApiErrorDetails | null>(null)
const documentOpen = ref(false)
type UploadDocumentType = 'DELIVERY_ORDER' | 'PICKUP_ORDER' | 'CUSTOMS_DECLARATION'
type UploadDocumentType = 'DELIVERY_ORDER' | 'PICKUP_ORDER'
const documentType = ref<UploadDocumentType>('DELIVERY_ORDER')
const documentFile = ref<File | null>(null)
const documentUploadKey = ref(0)
const documentSaving = ref(false)
const documentDownloading = ref(false)
const documentError = ref<ApiErrorDetails | null>(null)
const customsCompleting = ref(false)
const customsActionError = ref<ApiErrorDetails | null>(null)
const documentRefreshWarning = ref<ApiErrorDetails | null>(null)
const clearanceOpen = ref(false)
const clearanceDocument = ref<BusinessDocument | null>(null)
@@ -72,7 +70,7 @@ const showPassedGates = ref(false)
const carrierRefreshKey = ref(0)
const selectedDocument = computed(() => overview.value?.documents.find((item) => item.documentType === documentType.value) || null)
const documentTitle = computed(() => ({ DELIVERY_ORDER: 'DO 资料', PICKUP_ORDER: 'PU 资料', CUSTOMS_DECLARATION: '国内出口报关附件' })[documentType.value])
const documentTitle = computed(() => ({ DELIVERY_ORDER: 'DO 资料', PICKUP_ORDER: 'PU 资料' })[documentType.value])
const title = computed(() => detail.value?.containerNo || detail.value?.cabinetNo || '货柜详情')
const exceptionTitle = computed(() => overview.value?.exception.active ? '取消货柜异常' : '标记货柜异常')
type AggregatedGateRow = ContainerOutboundGateItem & { key: string; inboundCodes: string[] }
@@ -100,6 +98,8 @@ const outboundGateSummary = computed(() => ({
const canConfirmDeparture = computed(() => canConfirmOutboundDispatch.value
&& outboundDetail.value?.aggregate.dispatchStatus === 'READY_FOR_DISPATCH'
&& outboundGateSummary.value.blocked === 0)
const customsDepartureState = computed<'DEPARTED' | 'WAITING_OTHER_GATES'>(() =>
outboundDetail.value?.aggregate.dispatchStatus === 'WAITING_FOR_LOADING' ? 'DEPARTED' : 'WAITING_OTHER_GATES')
function toOverviewDocument(document: BusinessDocument, documentType: string, fallback?: ContainerOverviewDocument | null): ContainerOverviewDocument {
return {
@@ -113,16 +113,14 @@ function replaceOverviewDocument(document: ContainerOverviewDocument) {
if (!overview.value) return
overview.value = { ...overview.value, documents: [...overview.value.documents.filter((item) => item.documentType !== document.documentType), document] }
}
async function refreshOverviewDocument(documentType: 'CUSTOMS_DECLARATION' | 'DESTINATION_CLEARANCE') {
async function refreshOverviewDocument(documentType: 'DESTINATION_CLEARANCE') {
let refreshCause: unknown
let refreshed: ContainerOverview | null = null
try { refreshed = await getContainerOverview(containerId.value) } catch (cause) { refreshCause = cause }
let current = refreshed?.documents.find((item) => item.documentType === documentType) || null
if (!current) {
try {
const business = documentType === 'CUSTOMS_DECLARATION'
? overview.value?.loadingPlanId && detail.value ? await getLoadingDeclaration(overview.value.loadingPlanId, detail.value.warehouseId) : null
: await getContainerClearance(containerId.value)
const business = await getContainerClearance(containerId.value)
if (business) {
if (documentType === 'DESTINATION_CLEARANCE') clearanceDocument.value = business
current = toOverviewDocument(business, documentType, overview.value?.documents.find((item) => item.documentType === documentType))
@@ -158,6 +156,10 @@ async function loadOutboundDispatch() {
catch (cause) { outboundDetail.value = null; outboundError.value = getApiErrorDetails(cause, '国内出仓门禁加载失败') }
finally { outboundLoading.value = false }
}
async function refreshAfterCustoms() {
if (canViewOutboundDispatch.value) await loadOutboundDispatch()
try { overview.value = await getContainerOverview(containerId.value) } catch { /* 报关操作已成功,保留组件返回事实 */ }
}
async function confirmDeparture() {
const aggregate = outboundDetail.value?.aggregate
@@ -192,13 +194,9 @@ function truckReferenceText(status?: string | null) {
function openDocument(type: string) {
if (type === 'DESTINATION_CLEARANCE') { if (canManageCustoms.value) void openClearance(); return }
if (!['DELIVERY_ORDER', 'PICKUP_ORDER', 'CUSTOMS_DECLARATION'].includes(type)) return
if (type === 'CUSTOMS_DECLARATION' ? !canManageCustoms.value : !canEdit.value) return
if (!['DELIVERY_ORDER', 'PICKUP_ORDER'].includes(type) || !canEdit.value) return
documentType.value = type as UploadDocumentType
documentFile.value = null; documentError.value = null; documentRefreshWarning.value = null; documentUploadKey.value += 1; documentOpen.value = true
if (type === 'CUSTOMS_DECLARATION' && !overview.value?.loadingPlanId) {
documentError.value = { code: 'LOADING_PLAN_REQUIRED', message: '当前货柜没有装柜清单,无法上传国内出口报关附件' }
}
}
function selectDocumentFile(file: UploadFile) { documentFile.value = file.raw || null; documentError.value = null }
function removeDocumentFile() { documentFile.value = null }
@@ -207,100 +205,41 @@ async function saveDocument() {
documentSaving.value = true; documentError.value = null; documentRefreshWarning.value = null
try {
const expectedVersion = selectedDocument.value?.version || 0
if (documentType.value === 'CUSTOMS_DECLARATION') {
if (!overview.value.loadingPlanId) throw new Error('当前货柜没有装柜清单,无法上传国内出口报关附件')
const uploaded = await uploadLoadingDeclaration(overview.value.loadingPlanId, detail.value.warehouseId, documentFile.value, expectedVersion, '上传国内出口报关附件')
if (!uploaded) throw new Error('报关附件上传成功但未返回附件信息')
const optimistic = toOverviewDocument(uploaded, 'CUSTOMS_DECLARATION', selectedDocument.value)
replaceOverviewDocument(optimistic)
try {
const refreshed = await getContainerOverview(containerId.value)
if (refreshed.documents.some((item) => item.documentType === 'CUSTOMS_DECLARATION')) overview.value = refreshed
else {
overview.value = { ...refreshed, documents: [...refreshed.documents, optimistic] }
documentRefreshWarning.value = { code: 'CUSTOMS_DOCUMENT_STALE', message: '附件已上传,但资料汇总尚未同步;当前已保留本次上传结果' }
}
const targetType = documentType.value
const targetTitle = documentTitle.value
const uploaded = await uploadContainerDocument(containerId.value, targetType, documentFile.value, expectedVersion, `上传${targetTitle}`)
if (!uploaded) throw new Error(`${targetTitle}上传成功但未返回附件信息`)
const optimistic = toOverviewDocument(uploaded, targetType, selectedDocument.value)
replaceOverviewDocument(optimistic)
documentOpen.value = false; documentFile.value = null; documentError.value = null; documentUploadKey.value += 1
ElMessage.success(`${targetTitle}已上传`)
try {
const refreshed = await getContainerOverview(containerId.value)
const current = refreshed.documents.find((item) => item.documentType === targetType)
if (current && (current.fileId === optimistic.fileId || current.version > optimistic.version)) overview.value = refreshed
else {
overview.value = { ...refreshed, documents: [...refreshed.documents.filter((item) => item.documentType !== targetType), optimistic] }
documentRefreshWarning.value = { code: 'DOCUMENT_OVERVIEW_STALE', message: `${targetTitle}已上传,但资料汇总尚未同步;当前已保留本次上传结果` }
}
catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, '附件已上传,但最新资料刷新失败;当前已保留本次上传结果') }
} else {
const targetType = documentType.value
const targetTitle = documentTitle.value
const uploaded = await uploadContainerDocument(containerId.value, targetType, documentFile.value, expectedVersion, `上传${targetTitle}`)
if (!uploaded) throw new Error(`${targetTitle}上传成功但未返回附件信息`)
const optimistic = toOverviewDocument(uploaded, targetType, selectedDocument.value)
replaceOverviewDocument(optimistic)
documentOpen.value = false; documentFile.value = null; documentError.value = null; documentUploadKey.value += 1
ElMessage.success(`${targetTitle}已上传`)
try {
const refreshed = await getContainerOverview(containerId.value)
const current = refreshed.documents.find((item) => item.documentType === targetType)
if (current && (current.fileId === optimistic.fileId || current.version > optimistic.version)) overview.value = refreshed
else {
overview.value = { ...refreshed, documents: [...refreshed.documents.filter((item) => item.documentType !== targetType), optimistic] }
documentRefreshWarning.value = { code: 'DOCUMENT_OVERVIEW_STALE', message: `${targetTitle}已上传,但资料汇总尚未同步;当前已保留本次上传结果` }
}
} catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, `${targetTitle}已上传,但最新资料刷新失败;当前已保留本次上传结果`) }
return
}
documentOpen.value = false; documentFile.value = null; documentUploadKey.value += 1; ElMessage.success(`${documentTitle.value}已上传`)
} catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, `${targetTitle}已上传,但最新资料刷新失败;当前已保留本次上传结果`) }
} catch (cause) {
const details = getApiErrorDetails(cause, `${documentTitle.value}上传失败`)
if (details.code === 'A0409') {
if (documentType.value === 'CUSTOMS_DECLARATION') {
try { await refreshOverviewDocument('CUSTOMS_DECLARATION') } catch { /* 保留原并发错误证据 */ }
} else {
try { overview.value = await getContainerOverview(containerId.value) } catch { /* 保留原并发错误证据 */ }
}
}
if (details.code === 'A0409') try { overview.value = await getContainerOverview(containerId.value) } catch { /* 保留原并发错误证据 */ }
documentError.value = details
}
finally { documentSaving.value = false }
}
async function refreshCustomsFacts() {
overview.value = await getContainerOverview(containerId.value)
await loadOutboundDispatch()
}
async function completeCustomsDeclaration(document: ContainerOverviewDocument) {
if (!canManageCustoms.value || customsCompleting.value || document.completed) return
if (!overview.value?.loadingPlanId || !detail.value) {
customsActionError.value = { code: 'LOADING_PLAN_REQUIRED', message: '当前货柜没有装柜清单,无法确认报关完成' }
return
}
customsCompleting.value = true; customsActionError.value = null; documentRefreshWarning.value = null
try {
await ElMessageBox.confirm('请确认装柜清单的国内出口报关协同已经完成。附件上传与确认报关完成是两个步骤;上传附件本身不代表正式报关放行。', '确认报关完成', { type: 'warning', confirmButtonText: '确认完成' })
} catch { customsCompleting.value = false; return }
try {
await completeLoadingDeclaration(overview.value.loadingPlanId, detail.value.warehouseId, document.version, '货柜详情确认国内出口报关完成')
try { await refreshCustomsFacts() }
catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, '报关完成已确认,但最新资料刷新失败,请稍后重新加载') }
ElMessage.success('已确认装柜清单报关协同完成;正式报关放行仍由报关业务事实核验')
} catch (cause) {
const details = getApiErrorDetails(cause, '确认报关完成失败')
if (details.code === 'A0409') {
try { await refreshCustomsFacts() } catch { /* 保留原并发错误证据 */ }
}
customsActionError.value = details
} finally { customsCompleting.value = false }
}
async function saveBlob(blob: Blob, fileName: string) { const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = fileName || '货柜资料'; anchor.click(); URL.revokeObjectURL(url) }
async function downloadDocument(item: ContainerOverviewDocument) {
if (!['DELIVERY_ORDER', 'PICKUP_ORDER', 'CUSTOMS_DECLARATION', 'DESTINATION_CLEARANCE'].includes(item.documentType)) return
if (item.documentType === 'CUSTOMS_DECLARATION' && (!overview.value?.loadingPlanId || !detail.value)) {
customsActionError.value = { code: 'LOADING_PLAN_REQUIRED', message: '当前货柜没有装柜清单,无法下载国内出口报关附件' }
return
}
if (!['DELIVERY_ORDER', 'PICKUP_ORDER', 'DESTINATION_CLEARANCE'].includes(item.documentType)) return
documentDownloading.value = true
try {
const blob = item.documentType === 'CUSTOMS_DECLARATION'
? await downloadLoadingDeclaration(overview.value!.loadingPlanId!, detail.value!.warehouseId, item.fileId)
: item.documentType === 'DESTINATION_CLEARANCE'
const blob = item.documentType === 'DESTINATION_CLEARANCE'
? await downloadContainerClearance(containerId.value, item.fileId)
: await downloadContainerDocument(containerId.value, item.documentType as 'DELIVERY_ORDER' | 'PICKUP_ORDER', item.fileId)
await saveBlob(blob, item.fileName); ElMessage.success('资料下载已开始')
} catch (cause) {
const details = getApiErrorDetails(cause, '资料下载失败')
if (item.documentType === 'CUSTOMS_DECLARATION') customsActionError.value = details
if (item.documentType === 'DESTINATION_CLEARANCE') clearanceError.value = details
ElMessage.error(details.message)
}
@@ -383,9 +322,9 @@ onMounted(async () => { await load(); if (route.query.action === 'documents') op
<template v-else-if="detail">
<section class="detail-fields surface-panel"><h2>运输与单证快照</h2><dl><div><dt>真实订舱</dt><dd>{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</dd></div><div><dt>异常状态</dt><dd>{{ detail.exceptionActive ? '异常' : '正常' }}</dd></div><div><dt>目的仓</dt><dd>{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</dd></div><div><dt>船公司</dt><dd>{{ detail.shippingCompanyName || '—' }}</dd></div><div><dt>船名 / 航次</dt><dd>{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</dd></div><div><dt>拖车公司</dt><dd>{{ detail.terminalTruckingCompany || '—' }}</dd></div><div><dt>预计开船</dt><dd>{{ formatTime(detail.estimatedSailingAt) }}</dd></div><div><dt>预计到港</dt><dd>{{ formatTime(detail.estimatedPortArrivalAt) }}</dd></div><div><dt>卸柜完成</dt><dd>{{ formatTime(detail.unloadingCompletedAt) }}</dd></div><div><dt>卸柜照片上传</dt><dd>{{ formatTime(detail.unloadingPhotoUploadedAt) }}</dd></div><div><dt>预计报空</dt><dd>{{ formatTime(detail.estimatedEmptyReportedAt) }}</dd></div><div><dt>实际报空</dt><dd>{{ formatTime(detail.actualEmptyReportedAt) }}</dd></div></dl></section>
<LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false" />
<ContainerOverviewPanel v-else-if="overview" :overview="overview" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-download-customs="canDownloadCustoms" :can-manage-exception="canEdit" :document-busy="documentDownloading" :customs-completing="customsCompleting" @upload-document="openDocument" @download-document="downloadDocument" @complete-customs="completeCustomsDeclaration" @manage-exception="openException" />
<ContainerOverviewPanel v-else-if="overview" :overview="overview" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" :document-busy="documentDownloading" @upload-document="openDocument" @download-document="downloadDocument" @manage-exception="openException" />
<el-alert v-if="documentRefreshWarning" type="warning" :closable="false" :title="documentRefreshWarning.message"><template #default><ApiErrorMeta :details="documentRefreshWarning" /></template></el-alert>
<LoadErrorState v-if="customsActionError" compact title="国内出口报关附件操作失败" :message="customsActionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="customsActionError" /></template></LoadErrorState>
<ContainerCustomsPanel v-if="detail.planStatus === 'COMPLETED' && overview?.loadingPlanId && canListCustoms" :loading-plan-id="overview.loadingPlanId" :can-list="canListCustoms" :can-manage="canManageCustoms" :departure-state="customsDepartureState" @refreshed="refreshAfterCustoms" />
<section v-if="detail.planStatus === 'COMPLETED' && canViewOutboundDispatch" class="outbound-panel surface-panel" aria-label="国内出仓门禁">
<header class="outbound-panel__header"><div><h2>国内出仓门禁</h2><p>按整柜核验全部入库绑定,仅确认一次离仓。</p></div><div v-if="outboundDetail" class="outbound-panel__actions"><el-tag :type="outboundGateSummary.blocked === 0 ? 'success' : 'danger'">{{ outboundGateSummary.blocked === 0 ? '门禁通过' : '门禁阻断' }}</el-tag><StatusBadge :status="outboundStatusText(outboundDetail.aggregate.dispatchStatus)" /><el-button v-if="canConfirmDeparture" type="primary" :loading="outboundConfirming" @click="confirmDeparture">确认离仓</el-button></div></header>
<LoadErrorState v-if="outboundError" compact title="国内出仓操作失败" :message="outboundError.message" :loading="outboundLoading" @retry="loadOutboundDispatch"><template #details><ApiErrorMeta :details="outboundError" /></template></LoadErrorState>
@@ -244,7 +244,8 @@ describe('ContainersView', () => {
expect(source).not.toContain('uploadLoadingDeclaration')
expect(source).not.toContain('uploadContainerClearance')
expect(source).not.toContain('uploadContainerDocument')
expect(detailSource).toContain('uploadLoadingDeclaration')
expect(detailSource).not.toContain('uploadLoadingDeclaration')
expect(detailSource).toContain('ContainerCustomsPanel')
expect(detailSource).toContain('uploadContainerClearance')
expect(detailSource).toContain('uploadContainerDocument')
})
@@ -426,7 +427,7 @@ describe('ContainersView', () => {
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', 'customsDeclarationDocumentStatus', 'deliveryOrderDocumentStatus', 'pickupOrderDocumentStatus']
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)
@@ -146,7 +146,6 @@ const columns: DataTableColumn[] = [
{ key: 'exceptionActive', label: '异常', width: 90, formatter: (row: ContainerBookingResource) => row.exceptionActive ? '异常' : '正常' },
{ key: 'cabinetNo', label: '柜次号', minWidth: 180, slotName: 'cabinet' },
{ key: 'destinationClearanceDocumentStatus', label: '清关资料', width: 110, formatter: (row: ContainerBookingResource) => documentStatusText(row.destinationClearanceDocumentStatus) },
{ key: 'customsDeclarationDocumentStatus', label: '国内出口报关附件', minWidth: 150, formatter: (row: ContainerBookingResource) => documentStatusText(row.customsDeclarationDocumentStatus) },
{ key: 'deliveryOrderDocumentStatus', label: 'DO资料', width: 100, formatter: (row: ContainerBookingResource) => documentStatusText(row.deliveryOrderDocumentStatus) },
{ key: 'pickupOrderDocumentStatus', label: 'PU资料', width: 100, formatter: (row: ContainerBookingResource) => documentStatusText(row.pickupOrderDocumentStatus) },
{ key: 'oceanBooking', label: '真实订舱', minWidth: 170, slotName: 'oceanBooking' },