feat(admin): complete customs from container detail
This commit is contained in:
@@ -3,15 +3,16 @@ 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 { completeLoadingDeclaration, 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'
|
||||
|
||||
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() }))
|
||||
vi.mock('@/modules/loading-lists', () => ({ uploadLoadingDeclaration: vi.fn(), completeLoadingDeclaration: 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(),
|
||||
@@ -43,6 +44,7 @@ function mountView(customs = true, outbound = true) {
|
||||
return mount(ContainerDetailView, { global: { plugins: [ElementPlus] } })
|
||||
}
|
||||
function documentArticle(wrapper: VueWrapper, label: string) { return wrapper.findAll('.document-item').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))! }
|
||||
|
||||
describe('ContainerDetailView', () => {
|
||||
beforeEach(() => {
|
||||
@@ -52,6 +54,7 @@ describe('ContainerDetailView', () => {
|
||||
vi.mocked(getContainerOverview).mockResolvedValue(overview as never)
|
||||
vi.mocked(getContainerClearance).mockResolvedValue(null)
|
||||
vi.mocked(uploadLoadingDeclaration).mockResolvedValue({} as never)
|
||||
vi.mocked(completeLoadingDeclaration).mockResolvedValue({} as never)
|
||||
vi.mocked(uploadContainerDocument).mockResolvedValue({} as never)
|
||||
vi.mocked(uploadContainerClearance).mockResolvedValue({} as never)
|
||||
vi.mocked(getContainerOutboundDispatch).mockResolvedValue(outboundDetail)
|
||||
@@ -95,6 +98,71 @@ describe('ContainerDetailView', () => {
|
||||
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()
|
||||
@@ -148,20 +216,20 @@ describe('ContainerDetailView', () => {
|
||||
...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: 'SAILING_SCHEDULE', name: '船期有效且未过期', passed: false, actual: 'EXPIRED', requirement: 'VALID' }] } }] },
|
||||
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('EXPIRED')
|
||||
expect(wrapper.text()).toContain('VALID')
|
||||
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: 'SAILING_SCHEDULE', name: '船期有效且未过期', passed: true, actual: 'VALID', requirement: 'VALID' }
|
||||
const passed = { code: 'LOADING_PLAN_COMPLETED', name: '柜次装柜计划完成', passed: true, actual: 'COMPLETED', requirement: 'COMPLETED' }
|
||||
vi.mocked(getContainerOutboundDispatch).mockResolvedValueOnce({
|
||||
...outboundDetail,
|
||||
aggregate: {
|
||||
@@ -218,7 +286,7 @@ describe('ContainerDetailView', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps truck status as reference only and never counts the legacy truck gate as blocking', async () => {
|
||||
it('keeps truck and sailing schedule as reference only and never counts legacy reference gates as blocking', async () => {
|
||||
vi.mocked(getContainerOutboundDispatch).mockResolvedValueOnce({
|
||||
...outboundDetail,
|
||||
aggregate: {
|
||||
@@ -229,6 +297,7 @@ describe('ContainerDetailView', () => {
|
||||
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' },
|
||||
] } }],
|
||||
},
|
||||
@@ -239,6 +308,7 @@ describe('ContainerDetailView', () => {
|
||||
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()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user