282 lines
15 KiB
TypeScript
282 lines
15 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { flushPromises, mount } from '@vue/test-utils'
|
|
import ElementPlus, { ElMessageBox } from 'element-plus'
|
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
|
import { ApiResponseError } from '@/shared/api/http'
|
|
import type { ContainerCustomsStatus, ContainerCustomsView, DeclarationBatch, ClearanceResultType } from '../api/platformContainerCustoms'
|
|
|
|
const api = vi.hoisted(() => ({
|
|
get: vi.fn(),
|
|
start: vi.fn(),
|
|
completeAllBuy: vi.fn(),
|
|
sendToBroker: vi.fn(),
|
|
getDeclarationBatches: vi.fn(),
|
|
upload: vi.fn(),
|
|
downloadSource: vi.fn(),
|
|
downloadReceipt: vi.fn(),
|
|
downloadFinal: vi.fn(),
|
|
recordResult: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('../api/platformContainerCustoms', () => ({
|
|
getContainerCustoms: api.get,
|
|
startContainerCustoms: api.start,
|
|
completeAllBuyContainerCustoms: api.completeAllBuy,
|
|
uploadContainerCustomsReceipt: api.upload,
|
|
downloadContainerCustomsDocument: api.downloadSource,
|
|
downloadContainerCustomsReceipt: api.downloadReceipt,
|
|
downloadContainerCustomsFinal: api.downloadFinal,
|
|
submitContainerCustomsToBroker: vi.fn(),
|
|
sendContainerCustomsToBroker: api.sendToBroker,
|
|
getDeclarationBatches: api.getDeclarationBatches,
|
|
recordClearanceResult: api.recordResult,
|
|
}))
|
|
vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
|
|
|
|
import ContainerCustomsPanel from './ContainerCustomsPanel.vue'
|
|
|
|
function customs(status: ContainerCustomsStatus, hasCompletion = false): ContainerCustomsView {
|
|
return {
|
|
aggregateId: '2092438843630391401', loadingPlanId: '2092438843630391402', containerBookingId: '2092438843630391403',
|
|
planNo: 'LP-1', cabinetNo: 'MCR08290001', containerNo: 'MSCU-1', status, hasActualCargo: true, version: 3, taxRefundCount: 1, coveredTaxRefundCount: 1, actualBoxCount: 12,
|
|
inbounds: [{ inboundId: '2092438843630391404', orderId: '2092438843630391405', orderNo: 'O-1', inboundCode: 'RK-1', tenantId: '2092438843630391406', customerName: '客户一', taxMode: 'TAX_REFUND', covered: true }],
|
|
documents: [{
|
|
documentId: '2092438843630391407', tenantId: '2092438843630391406',
|
|
source: { fileId: '2092438843630391408', fileName: '客户退税资料.pdf', fileHash: 'source-hash', sizeBytes: 12, version: 2, uploadedAt: '2026-08-29T10:00:00' },
|
|
inboundIds: ['2092438843630391404'], inboundCodes: ['RK-1'], frozen: true,
|
|
receipt: hasCompletion ? { fileId: '2092438843630391409', fileName: '报关行回执.pdf', fileHash: 'receipt-hash', sizeBytes: 13, version: 1, uploadedAt: '2026-08-29T11:00:00' } : null,
|
|
finalDocument: hasCompletion ? { fileId: '2092438843630391410', fileName: '真实报关完税文件.pdf', fileHash: 'final-hash', sizeBytes: 14, version: 1, uploadedAt: '2026-08-29T12:00:00' } : null,
|
|
confirmationHistory: hasCompletion ? [{ id: '2092438843630391411', receiptVersion: 1, action: 'CONFIRM', reason: null, operatorName: '客户一', createdAt: '2026-08-29T11:15:00' }] : [],
|
|
}],
|
|
brokerSubmission: hasCompletion ? { id: '2092438843630391412', customsContainerId: '2092438843630391401', loadingPlanId: '2092438843630391402', batchNo: 'BS-20260829-001', status: 'SENT', sourceVersion: 3, sentByName: '平台员', sentAt: '2026-08-29T10:45:00', version: 1 } : null,
|
|
startedAt: status === 'DECLARING' || status === 'COMPLETED' ? '2026-08-29T10:30:00' : null,
|
|
completedAt: status === 'COMPLETED' ? '2026-08-29T11:30:00' : null,
|
|
}
|
|
}
|
|
|
|
async function render(status: ContainerCustomsStatus, canManage = true, hasCompletion = false) {
|
|
api.get.mockResolvedValue(customs(status, hasCompletion))
|
|
const wrapper = mount(ContainerCustomsPanel, {
|
|
props: { loadingPlanId: '2092438843630391402', canList: true, canManage, departureState: 'WAITING_OTHER_GATES' },
|
|
global: { plugins: [ElementPlus] },
|
|
})
|
|
await flushPromises()
|
|
return wrapper
|
|
}
|
|
|
|
function buttonTexts(wrapper: Awaited<ReturnType<typeof render>>) {
|
|
return wrapper.findAll('button').map((button) => button.text())
|
|
}
|
|
|
|
describe('ContainerCustomsPanel', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
api.getDeclarationBatches.mockResolvedValue([])
|
|
api.downloadSource.mockResolvedValue(new Blob(['source']))
|
|
api.downloadReceipt.mockResolvedValue(new Blob(['receipt']))
|
|
api.downloadFinal.mockResolvedValue(new Blob(['final']))
|
|
Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:customs-file') })
|
|
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() })
|
|
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined)
|
|
})
|
|
|
|
it('保留其他原因必填与批次版本,统一窗口确认后仍提交原始结果', async () => {
|
|
const batch: DeclarationBatch = {
|
|
id: '101', customsContainerId: '102', loadingPlanId: '2092438843630391402',
|
|
batchNo: 'DEC-TEST-001', status: 'SUBMITTED', sourceVersion: 3,
|
|
customsEntryNo: 'ENTRY-001', version: 7, submittedAt: null,
|
|
submittedByName: null, acknowledgedAt: null, acknowledgedByName: null, remark: null, results: [],
|
|
}
|
|
api.getDeclarationBatches.mockResolvedValue([batch])
|
|
api.get.mockResolvedValue({ ...customs('DECLARING', true), brokerSubmittedAt: '2026-10-19T09:00:00' })
|
|
const wrapper = mount(ContainerCustomsPanel, {
|
|
props: { loadingPlanId: batch.loadingPlanId, canList: true, canManage: true, departureState: 'WAITING_OTHER_GATES' },
|
|
global: { plugins: [ElementPlus] },
|
|
})
|
|
await flushPromises()
|
|
await wrapper.findAll('button').find(button => button.text() === '标记海关异常/结果')!.trigger('click')
|
|
const state = (wrapper.vm.$ as unknown as { setupState: {
|
|
clearanceBatch: DeclarationBatch; clearanceResultType: ClearanceResultType; clearanceRemark: string; clearanceDialogVisible: boolean
|
|
} }).setupState
|
|
state.clearanceResultType = 'OTHER'
|
|
await flushPromises()
|
|
const dialog = wrapper.getComponent(AppDialog)
|
|
expect(dialog.props('title')).toBe('标记海关异常/结果 · DEC-TEST-001')
|
|
expect(dialog.props('lockWhileSubmitting')).toBe(false)
|
|
dialog.vm.$emit('confirm')
|
|
await flushPromises()
|
|
expect(api.recordResult).not.toHaveBeenCalled()
|
|
state.clearanceRemark = ' 已核实海关通知 '
|
|
dialog.vm.$emit('confirm')
|
|
await flushPromises()
|
|
expect(api.recordResult).toHaveBeenCalledWith(batch, 'OTHER', '已核实海关通知')
|
|
expect(dialog.props('modelValue')).toBe(false)
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('开始报关确认显示柜次号,取消不会冻结资料或调用开始接口', async () => {
|
|
const confirm = vi.spyOn(ElMessageBox, 'confirm').mockRejectedValueOnce('cancel')
|
|
const wrapper = await render('READY_TO_START')
|
|
await wrapper.findAll('button').find(button => button.text() === '开始报关')!.trigger('click')
|
|
await flushPromises()
|
|
expect(confirm).toHaveBeenCalledWith(expect.any(String), '开始整柜报关 · MCR08290001', expect.objectContaining({ customClass: 'admin-density-confirm' }))
|
|
expect(api.start).not.toHaveBeenCalled()
|
|
confirm.mockRestore()
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('renders all customs result reasons in Chinese with the recorded remarks', async () => {
|
|
const reasons: Array<[ClearanceResultType, string]> = [
|
|
['INSPECTION', '查验'], ['SUPPLEMENT_REQUIRED', '补资料'],
|
|
['DETAINED', '扣货'], ['RELEASED', '放行'], ['OTHER', '其他'],
|
|
]
|
|
const batch: DeclarationBatch = {
|
|
id: '101', customsContainerId: '102', loadingPlanId: '2092438843630391402',
|
|
batchNo: 'DEC-TEST-001', status: 'RESULT_PENDING', sourceVersion: 1,
|
|
customsEntryNo: 'ENTRY-TEST-001', version: 1, submittedAt: null,
|
|
submittedByName: null, acknowledgedAt: null, acknowledgedByName: null, remark: null,
|
|
results: reasons.map(([resultType], index) => ({
|
|
id: String(200 + index), declarationBatchId: '101', customsContainerId: '102',
|
|
resultType, resultStatus: 'OPEN', customsEntryNo: 'ENTRY-TEST-001',
|
|
occurredAt: '2026-09-29T10:00:00', resolution: `人工备注${index}`,
|
|
evidenceFileId: null, recordedByName: '操作员', recordedAt: '2026-09-29T10:00:00',
|
|
previousResultId: null,
|
|
})),
|
|
}
|
|
api.getDeclarationBatches.mockResolvedValue([batch])
|
|
api.get.mockResolvedValue({ ...customs('COMPLETED', true), brokerSubmittedAt: '2026-09-29T09:00:00' })
|
|
const wrapper = mount(ContainerCustomsPanel, {
|
|
props: { loadingPlanId: batch.loadingPlanId, canList: true, canManage: false, departureState: 'WAITING_OTHER_GATES' },
|
|
global: { plugins: [ElementPlus] },
|
|
})
|
|
await flushPromises()
|
|
const rows = wrapper.findAll('.declaration-result-row')
|
|
expect(rows).toHaveLength(reasons.length)
|
|
for (const [index, [code, label]] of reasons.entries()) {
|
|
expect(rows[index]!.text()).toContain(label)
|
|
expect(rows[index]!.text()).toContain(`人工备注${index}`)
|
|
expect(rows[index]!.text()).not.toContain(code)
|
|
expect(rows[index]!.find('button').exists()).toBe(false)
|
|
}
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('keeps READY_TO_START read-only while exposing source file name and download', async () => {
|
|
const wrapper = await render('READY_TO_START')
|
|
expect(wrapper.text()).toContain('开始报关')
|
|
expect(wrapper.get('[aria-label="报关概况"] dt').text()).toBe('实际箱数')
|
|
expect(wrapper.get('[aria-label="报关概况"] dd').text()).toBe('12')
|
|
expect(wrapper.text()).toContain('客户退税资料.pdf')
|
|
expect(wrapper.text()).toContain('客户退税资料')
|
|
expect(wrapper.text()).toContain('报关行回执')
|
|
expect(wrapper.text()).not.toContain('客户报关资料')
|
|
expect(wrapper.find('[aria-label="下载客户退税资料 客户退税资料.pdf"]').exists()).toBe(true)
|
|
expect(buttonTexts(wrapper)).not.toContain('上传报关行回执')
|
|
expect(buttonTexts(wrapper)).not.toContain('覆盖报关行回执')
|
|
expect(buttonTexts(wrapper)).not.toContain('提交')
|
|
})
|
|
|
|
it('shows a successful empty state and hides customs actions when the completed plan has no actual cargo', async () => {
|
|
api.get.mockResolvedValue({ ...customs('READY_TO_START'), hasActualCargo: false, taxRefundCount: 0, coveredTaxRefundCount: 0, actualBoxCount: 0, inbounds: [], documents: [] })
|
|
const wrapper = mount(ContainerCustomsPanel, {
|
|
props: { loadingPlanId: '2092438843630391402', canList: true, canManage: true, departureState: 'WAITING_OTHER_GATES' },
|
|
global: { plugins: [ElementPlus] },
|
|
})
|
|
await flushPromises()
|
|
|
|
expect(wrapper.text()).toContain('当前装柜计划没有实际装柜货物,无需报关')
|
|
expect(wrapper.text()).toContain('无需报关')
|
|
expect(buttonTexts(wrapper)).not.toContain('开始报关')
|
|
expect(buttonTexts(wrapper)).not.toContain('导出买单 XLSX')
|
|
})
|
|
|
|
it('shows a pending state instead of an error before loading is completed', async () => {
|
|
api.get.mockResolvedValue(null)
|
|
const wrapper = mount(ContainerCustomsPanel, {
|
|
props: { loadingPlanId: '2097922185010147329', canList: true, canManage: false, departureState: 'WAITING_OTHER_GATES' },
|
|
global: { plugins: [ElementPlus] },
|
|
})
|
|
await flushPromises()
|
|
|
|
expect(wrapper.text()).toContain('当前装柜计划尚未完成,完成装柜后可处理报关')
|
|
expect(wrapper.text()).not.toContain('整柜报关加载失败')
|
|
})
|
|
|
|
it('allows completion upload and overwrite only while DECLARING with manage permission', async () => {
|
|
let wrapper = await render('DECLARING', true, false)
|
|
expect(wrapper.text()).toContain('首次发送报关行')
|
|
expect(wrapper.text()).toContain('首次发送后上传回执')
|
|
expect(buttonTexts(wrapper)).not.toContain('上传报关行回执')
|
|
wrapper.unmount()
|
|
|
|
wrapper = await render('DECLARING', true, true)
|
|
expect(wrapper.text()).toContain('发送报关行执行')
|
|
expect(wrapper.text()).toContain('覆盖报关行回执')
|
|
expect(wrapper.text()).toContain('报关行回执.pdf')
|
|
expect(wrapper.text()).toContain('真实报关完税文件.pdf')
|
|
expect(wrapper.text()).toContain('确认 · 2026/8/29 11:15:00 · 客户一')
|
|
wrapper.unmount()
|
|
|
|
wrapper = await render('DECLARING', false, true)
|
|
expect(wrapper.text()).toContain('只读')
|
|
expect(buttonTexts(wrapper)).not.toContain('上传报关行回执')
|
|
expect(buttonTexts(wrapper)).not.toContain('覆盖报关行回执')
|
|
expect(buttonTexts(wrapper)).not.toContain('提交')
|
|
})
|
|
|
|
it('loads declaration batches during DECLARING after broker execution was sent', async () => {
|
|
const value = { ...customs('DECLARING', true), brokerSubmittedAt: '2026-08-29T11:20:00' }
|
|
api.get.mockResolvedValue(value)
|
|
const wrapper = mount(ContainerCustomsPanel, {
|
|
props: { loadingPlanId: '2092438843630391402', canList: true, canManage: true, departureState: 'WAITING_OTHER_GATES' },
|
|
global: { plugins: [ElementPlus] },
|
|
})
|
|
await flushPromises()
|
|
expect(api.getDeclarationBatches).toHaveBeenCalledWith('2092438843630391402')
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('keeps COMPLETED strictly read-only with both files visible and downloadable', async () => {
|
|
const wrapper = await render('COMPLETED', true, true)
|
|
expect(wrapper.text()).toContain('客户退税资料.pdf')
|
|
expect(wrapper.text()).toContain('报关行回执.pdf')
|
|
expect(wrapper.text()).toContain('真实报关完税文件.pdf')
|
|
expect(wrapper.text()).toContain('报关已完成,待其他离仓条件')
|
|
expect(buttonTexts(wrapper)).not.toContain('上传报关行回执')
|
|
expect(buttonTexts(wrapper)).not.toContain('覆盖报关行回执')
|
|
expect(buttonTexts(wrapper)).not.toContain('提交')
|
|
})
|
|
|
|
it('routes source and completion downloads independently by the long document id and original names', async () => {
|
|
const wrapper = await render('COMPLETED', false, true)
|
|
await wrapper.get('[aria-label="下载客户退税资料 客户退税资料.pdf"]').trigger('click')
|
|
await flushPromises()
|
|
expect(api.downloadSource).toHaveBeenCalledWith('2092438843630391407')
|
|
expect(api.downloadReceipt).not.toHaveBeenCalled()
|
|
|
|
await wrapper.get('[aria-label="下载报关行回执 报关行回执.pdf"]').trigger('click')
|
|
await flushPromises()
|
|
expect(api.downloadReceipt).toHaveBeenCalledWith('2092438843630391407')
|
|
await wrapper.get('[aria-label="下载报关完税文件 真实报关完税文件.pdf"]').trigger('click')
|
|
await flushPromises()
|
|
expect(api.downloadFinal).toHaveBeenCalledWith('2092438843630391407')
|
|
expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledTimes(3)
|
|
expect(URL.createObjectURL).toHaveBeenCalledTimes(3)
|
|
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:customs-file')
|
|
})
|
|
|
|
it('preserves backend error code, request id and trace id when one download fails', async () => {
|
|
api.downloadSource.mockRejectedValueOnce(new ApiResponseError('文件不可用', 'CUSTOMS_FILE_404', 404, undefined, 'request-customs-download', 'trace-customs-download'))
|
|
const wrapper = await render('COMPLETED', false, true)
|
|
|
|
await wrapper.get('[aria-label="下载客户退税资料 客户退税资料.pdf"]').trigger('click')
|
|
await flushPromises()
|
|
|
|
expect(wrapper.text()).toContain('文件不可用')
|
|
expect(wrapper.text()).toContain('CUSTOMS_FILE_404')
|
|
expect(wrapper.text()).toContain('request-customs-download')
|
|
expect(wrapper.text()).toContain('trace-customs-download')
|
|
expect(api.downloadReceipt).not.toHaveBeenCalled()
|
|
})
|
|
})
|