diff --git a/src/modules/container-loading/api/platformContainerCustoms.test.ts b/src/modules/container-loading/api/platformContainerCustoms.test.ts index 7e1a5eb7..3aae0473 100644 --- a/src/modules/container-loading/api/platformContainerCustoms.test.ts +++ b/src/modules/container-loading/api/platformContainerCustoms.test.ts @@ -1,12 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/shared/api/http' -import { completeAllBuyContainerCustoms, downloadContainerCustomsReceipt, downloadContainerCustomsDocument, getContainerCustoms, startContainerCustoms, uploadContainerCustomsReceipt } from './platformContainerCustoms' +import { completeAllBuyContainerCustoms, downloadContainerCustomsReceipt, downloadContainerCustomsDocument, getContainerCustoms, sendContainerCustomsToBroker, startContainerCustoms, uploadContainerCustomsReceipt } from './platformContainerCustoms' const customs = { aggregateId: 2092438843630391401n, loadingPlanId: 2092438843630391402n, containerBookingId: 2092438843630391403n, planNo: 'LP-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'READY_TO_START', hasActualCargo: true, version: '3', taxRefundCount: 1, coveredTaxRefundCount: 1, actualBoxCount: 12, inbounds: [{ inboundId: 2092438843630391404n, orderId: 2092438843630391405n, orderNo: 'O-1', inboundCode: 'RK-1', tenantId: 2092438843630391406n, customerName: '客户一', taxMode: 'TAX_REFUND', covered: true }], - documents: [{ documentId: 2092438843630391407n, tenantId: 2092438843630391406n, source: { fileId: '2092438843630391408', fileName: 'source.pdf', fileHash: 'hash', sizeBytes: 1, version: 2, uploadedAt: '2026-08-28T10:00:00' }, inboundIds: ['2092438843630391404'], inboundCodes: ['RK-1'], frozen: true, receipt: null, finalDocument: null }], + documents: [{ documentId: 2092438843630391407n, tenantId: 2092438843630391406n, source: { fileId: '2092438843630391408', fileName: 'source.pdf', fileHash: 'hash', sizeBytes: 1, version: 2, uploadedAt: '2026-08-28T10:00:00' }, inboundIds: ['2092438843630391404'], inboundCodes: ['RK-1'], frozen: true, receipt: null, finalDocument: null, confirmationHistory: [] }], + brokerSubmission: null, startedAt: null, completedAt: null, } @@ -39,6 +40,13 @@ describe('platform container customs API', () => { expect(post.mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } }) }) + it('sends the frozen source documents to the broker through the first-send action', async () => { + const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: customs }) + await sendContainerCustomsToBroker('2092438843630391402', 3) + expect(post.mock.calls[0]?.slice(0, 2)).toEqual(['/platform/v1/customs/loading-plans/2092438843630391402/send-to-broker', { expectedVersion: 3, confirm: true, remark: '已将冻结退税报关资料首次发送给报关行' }]) + expect(post.mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } }) + }) + it('uploads one receipt file without completing the container', async () => { const document = { ...customs.documents[0], documentId: '2092438843630391407', tenantId: '2092438843630391406' } const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: document }) diff --git a/src/modules/container-loading/api/platformContainerCustoms.ts b/src/modules/container-loading/api/platformContainerCustoms.ts index e1a839d5..a8bd6067 100644 --- a/src/modules/container-loading/api/platformContainerCustoms.ts +++ b/src/modules/container-loading/api/platformContainerCustoms.ts @@ -19,6 +19,29 @@ export interface ContainerCustomsReceiptVersion { createdAt: string } +export type ContainerCustomsConfirmationAction = 'CONFIRM' | 'REJECT' + +export interface ContainerCustomsConfirmationHistory { + id: string + receiptVersion: number + action: ContainerCustomsConfirmationAction + reason: string | null + operatorName: string + createdAt: string +} + +export interface ContainerCustomsBrokerSubmission { + id: string + customsContainerId: string + loadingPlanId: string + batchNo: string + status: 'SENT' + sourceVersion: number + sentByName: string + sentAt: string + version: number +} + export interface ContainerCustomsInbound { inboundId: string orderId: string @@ -42,6 +65,7 @@ export interface ContainerCustomsDocument { receiptRejectReason?: string | null finalDocument: ContainerCustomsFile | null receiptHistory?: ContainerCustomsReceiptVersion[] + confirmationHistory: ContainerCustomsConfirmationHistory[] } export interface ContainerCustomsView { @@ -59,6 +83,7 @@ export interface ContainerCustomsView { actualBoxCount: number inbounds: ContainerCustomsInbound[] documents: ContainerCustomsDocument[] + brokerSubmission: ContainerCustomsBrokerSubmission | null startedAt: string | null completedAt: string | null brokerSubmittedAt?: string | null @@ -119,7 +144,24 @@ function normalize(value: RawContainerCustomsView): ContainerCustomsView { ...value, aggregateId: String(value.aggregateId), loadingPlanId: String(value.loadingPlanId), containerBookingId: String(value.containerBookingId), version: Number(value.version), inbounds: value.inbounds.map((item) => ({ ...item, inboundId: String(item.inboundId), orderId: String(item.orderId), tenantId: String(item.tenantId) })), - documents: value.documents.map((item) => ({ ...item, documentId: String(item.documentId), tenantId: String(item.tenantId), source: file(item.source)!, receipt: file(item.receipt), finalDocument: file(item.finalDocument), receiptHistory: (item.receiptHistory || []).map((version) => ({ ...version, version: Number(version.version), file: file(version.file)! })) })), + brokerSubmission: value.brokerSubmission ? { + ...value.brokerSubmission, + id: String(value.brokerSubmission.id), + customsContainerId: String(value.brokerSubmission.customsContainerId), + loadingPlanId: String(value.brokerSubmission.loadingPlanId), + sourceVersion: Number(value.brokerSubmission.sourceVersion), + version: Number(value.brokerSubmission.version), + } : null, + documents: value.documents.map((item) => ({ + ...item, + documentId: String(item.documentId), + tenantId: String(item.tenantId), + source: file(item.source)!, + receipt: file(item.receipt), + finalDocument: file(item.finalDocument), + receiptHistory: (item.receiptHistory || []).map((version) => ({ ...version, version: Number(version.version), file: file(version.file)! })), + confirmationHistory: (item.confirmationHistory || []).map((history) => ({ ...history, id: String(history.id), receiptVersion: Number(history.receiptVersion), reason: history.reason ?? null })), + })), } } @@ -174,7 +216,7 @@ export function downloadContainerCustomsFinal(documentId: string) { return http.get(`${basePath}/documents/${encodeURIComponent(documentId)}/final/download`, { responseType: 'blob' }) } -async function action(loadingPlanId: string, name: 'start' | 'submit-to-broker' | 'complete-all-buy', expectedVersion: number, remark?: string) { +async function action(loadingPlanId: string, name: 'start' | 'send-to-broker' | 'submit-to-broker' | 'complete-all-buy', expectedVersion: number, remark?: string) { const response = await http.post, ApiEnvelope>( `${basePath}/${encodeURIComponent(loadingPlanId)}/${name}`, { expectedVersion, confirm: true, remark: remark?.trim() || null }, @@ -187,6 +229,10 @@ export function startContainerCustoms(loadingPlanId: string, expectedVersion: nu return action(loadingPlanId, 'start', expectedVersion) } +export function sendContainerCustomsToBroker(loadingPlanId: string, expectedVersion: number) { + return action(loadingPlanId, 'send-to-broker', expectedVersion, '已将冻结退税报关资料首次发送给报关行') +} + export function completeAllBuyContainerCustoms(loadingPlanId: string, expectedVersion: number) { return action(loadingPlanId, 'complete-all-buy', expectedVersion) } diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.test.ts b/src/modules/container-loading/components/ContainerCustomsPanel.test.ts index 86fce58b..f5fd91b7 100644 --- a/src/modules/container-loading/components/ContainerCustomsPanel.test.ts +++ b/src/modules/container-loading/components/ContainerCustomsPanel.test.ts @@ -8,6 +8,8 @@ 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(), @@ -23,6 +25,8 @@ vi.mock('../api/platformContainerCustoms', () => ({ downloadContainerCustomsReceipt: api.downloadReceipt, downloadContainerCustomsFinal: api.downloadFinal, submitContainerCustomsToBroker: vi.fn(), + sendContainerCustomsToBroker: api.sendToBroker, + getDeclarationBatches: api.getDeclarationBatches, })) vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() })) @@ -39,7 +43,9 @@ function customs(status: ContainerCustomsStatus, hasCompletion = false): Contain 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, } @@ -62,6 +68,7 @@ function buttonTexts(wrapper: Awaited>) { 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'])) @@ -112,14 +119,17 @@ describe('ContainerCustomsPanel', () => { 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(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) @@ -129,6 +139,18 @@ describe('ContainerCustomsPanel', () => { 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') diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.vue b/src/modules/container-loading/components/ContainerCustomsPanel.vue index a12d80aa..d02b4a41 100644 --- a/src/modules/container-loading/components/ContainerCustomsPanel.vue +++ b/src/modules/container-loading/components/ContainerCustomsPanel.vue @@ -19,6 +19,7 @@ import { resolveClearanceResult, submitDeclarationBatch, startContainerCustoms, + sendContainerCustomsToBroker, submitContainerCustomsToBroker, uploadContainerCustomsReceipt, uploadContainerCustomsFinal, @@ -49,7 +50,9 @@ const finalFiles = reactive>({}) const missingInbounds = computed(() => customs.value?.inbounds.filter((item) => item.taxMode === 'TAX_REFUND' && !item.covered) || []) const missingCoverageCount = computed(() => Math.max(0, (customs.value?.taxRefundCount || 0) - (customs.value?.coveredTaxRefundCount || 0))) const allBuy = computed(() => customs.value?.hasActualCargo === true && customs.value.taxRefundCount === 0) +const brokerSubmission = computed(() => customs.value?.brokerSubmission || null) const brokerSubmitted = computed(() => Boolean(customs.value?.brokerSubmittedAt)) +const allReceiptsConfirmed = computed(() => Boolean(customs.value?.documents.length) && customs.value?.documents.every((document) => document.receipt && document.receiptStatus === 'CONFIRMED') === true) const openDeclarationBatch = computed(() => declarationBatches.value.find((item) => ['READY', 'SUBMITTED', 'ACKNOWLEDGED', 'RESULT_PENDING'].includes(item.status)) || null) const statusText: Record = { WAITING_CUSTOMER_DOCUMENTS: '待客户补齐退税资料', READY_TO_START: '可开始报关', DECLARING: '报关中', COMPLETED: '报关已完成', @@ -61,8 +64,16 @@ function documentCustomers(document: ContainerCustomsDocument) { function receiptHistoryLabel(document: ContainerCustomsDocument) { return (document.receiptHistory || []).map((item) => `v${item.version}`).join('、') || '—' } +function confirmationActionLabel(action: ContainerCustomsDocument['confirmationHistory'][number]['action']) { + return action === 'CONFIRM' ? '确认' : '打回' +} +function formatDateTime(value: string | null | undefined) { + if (!value) return '—' + const date = new Date(value) + return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false }) +} function selectReceipt(documentId: string, upload: UploadFile) { - if (!props.canManage || customs.value?.status !== 'DECLARING') return + if (!props.canManage || customs.value?.status !== 'DECLARING' || !brokerSubmission.value || brokerSubmitted.value) return receiptFiles[documentId] = upload.raw || null actionError.value = null } @@ -78,7 +89,7 @@ async function load() { loadError.value = null try { customs.value = await getContainerCustoms(props.loadingPlanId) - if (customs.value?.status === 'COMPLETED') { + if (customs.value?.status === 'COMPLETED' || customs.value?.brokerSubmittedAt) { try { declarationBatches.value = await getDeclarationBatches(props.loadingPlanId) } catch { declarationBatches.value = [] } } else declarationBatches.value = [] } @@ -147,7 +158,7 @@ async function start() { } async function uploadReceipt(document: ContainerCustomsDocument) { - if (!props.canManage || customs.value?.status !== 'DECLARING' || brokerSubmitted.value) return + if (!props.canManage || customs.value?.status !== 'DECLARING' || !brokerSubmission.value || brokerSubmitted.value) return const selected = receiptFiles[document.documentId] if (!selected) { ElMessage.warning('请选择对应的报关行回执'); return } actionLoading.value = true; actionError.value = null @@ -203,7 +214,7 @@ async function downloadFile(document: ContainerCustomsDocument, kind: 'source' | } async function submitToBroker() { - if (!customs.value || customs.value.status !== 'DECLARING') return + if (!customs.value || customs.value.status !== 'DECLARING' || !brokerSubmission.value || brokerSubmitted.value || !allReceiptsConfirmed.value) return try { await ElMessageBox.confirm('确认已核对全部回执并把确认结果发送给报关行执行?', '发送报关行执行', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return } actionLoading.value = true; actionError.value = null try { customs.value = await submitContainerCustomsToBroker(props.loadingPlanId, customs.value.version); ElMessage.success('已发送给报关行执行') } @@ -211,6 +222,15 @@ async function submitToBroker() { finally { actionLoading.value = false } } +async function sendToBroker() { + if (!customs.value || customs.value.status !== 'DECLARING' || brokerSubmission.value || brokerSubmitted.value) return + try { await ElMessageBox.confirm('确认将当前冻结的退税报关资料首次发送给报关行?发送后由报关行返回回执,资料版本和关联范围按当前快照处理。', '首次发送报关行', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return } + actionLoading.value = true; actionError.value = null + try { customs.value = await sendContainerCustomsToBroker(props.loadingPlanId, customs.value.version); ElMessage.success('已首次发送给报关行,等待回执') } + catch (error) { actionError.value = getApiErrorDetails(error, '首次发送报关行失败') } + finally { actionLoading.value = false } +} + async function completeAllBuy() { if (!customs.value || customs.value.status !== 'DECLARING' || !allBuy.value) return try { await ElMessageBox.confirm('确认全买单柜报关已经完成?报关完成不等同于国内离仓。', '完成全买单柜报关', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认完成' }) } @@ -249,7 +269,8 @@ onMounted(load) {{ customs.hasActualCargo ? statusText[customs.status] : '无需报关' }} 导出买单 XLSX 开始报关 - 发送报关行执行 + 首次发送报关行 + 发送报关行执行 确认报关完成 @@ -260,6 +281,7 @@ onMounted(load)