diff --git a/src/modules/container-loading/api/platformContainerCustoms.test.ts b/src/modules/container-loading/api/platformContainerCustoms.test.ts index 2cfc01a1..7e1a5eb7 100644 --- a/src/modules/container-loading/api/platformContainerCustoms.test.ts +++ b/src/modules/container-loading/api/platformContainerCustoms.test.ts @@ -1,12 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/shared/api/http' -import { completeAllBuyContainerCustoms, downloadContainerCustomsCompletion, downloadContainerCustomsDocument, getContainerCustoms, startContainerCustoms, uploadContainerCustomsCompletion } from './platformContainerCustoms' +import { completeAllBuyContainerCustoms, downloadContainerCustomsReceipt, downloadContainerCustomsDocument, getContainerCustoms, 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, completion: 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 }], startedAt: null, completedAt: null, } @@ -43,8 +43,8 @@ describe('platform container customs API', () => { const document = { ...customs.documents[0], documentId: '2092438843630391407', tenantId: '2092438843630391406' } const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: document }) const completion = new File(['done'], 'done.pdf') - await uploadContainerCustomsCompletion('2092438843630391407', completion, 0) - expect(post.mock.calls[0]?.[0]).toBe('/platform/v1/customs/loading-plans/documents/2092438843630391407/completion/upload') + await uploadContainerCustomsReceipt('2092438843630391407', completion, 0) + expect(post.mock.calls[0]?.[0]).toBe('/platform/v1/customs/loading-plans/documents/2092438843630391407/receipt/upload') expect((post.mock.calls[0]?.[1] as FormData).get('file')).toBe(completion) expect(post.mock.calls[0]?.[2]).toMatchObject({ params: { expectedVersion: 0 }, headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': expect.any(String) } }) }) @@ -56,11 +56,11 @@ describe('platform container customs API', () => { const documentId = '2092438843630391407' await expect(downloadContainerCustomsDocument(documentId)).resolves.toBe(source) - await expect(downloadContainerCustomsCompletion(documentId)).resolves.toBe(completion) + await expect(downloadContainerCustomsReceipt(documentId)).resolves.toBe(completion) expect(get.mock.calls).toEqual([ [`/platform/v1/customs/loading-plans/documents/${documentId}/download`, { responseType: 'blob' }], - [`/platform/v1/customs/loading-plans/documents/${documentId}/completion/download`, { responseType: 'blob' }], + [`/platform/v1/customs/loading-plans/documents/${documentId}/receipt/download`, { responseType: 'blob' }], ]) }) }) diff --git a/src/modules/container-loading/api/platformContainerCustoms.ts b/src/modules/container-loading/api/platformContainerCustoms.ts index e1d7bad2..594621db 100644 --- a/src/modules/container-loading/api/platformContainerCustoms.ts +++ b/src/modules/container-loading/api/platformContainerCustoms.ts @@ -30,9 +30,10 @@ export interface ContainerCustomsDocument { inboundIds: string[] inboundCodes: string[] frozen: boolean - completion: ContainerCustomsFile | null + receipt: ContainerCustomsFile | null receiptStatus?: 'PENDING' | 'CONFIRMED' | 'REJECTED' | null receiptRejectReason?: string | null + finalDocument: ContainerCustomsFile | null } export interface ContainerCustomsView { @@ -60,7 +61,7 @@ interface RawContainerCustomsView extends Omit & { inboundId: string | number; orderId: string | number; tenantId: string | number }> - documents: Array & { documentId: string | number; tenantId: string | number; source: ContainerCustomsFile; completion: ContainerCustomsFile | null }> + documents: Array & { documentId: string | number; tenantId: string | number; source: ContainerCustomsFile; receipt: ContainerCustomsFile | null; finalDocument: ContainerCustomsFile | null }> } const basePath = '/platform/v1/customs/loading-plans' @@ -73,7 +74,7 @@ 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)!, completion: file(item.completion) })), + 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) })), } } @@ -87,11 +88,15 @@ export function downloadContainerCustomsDocument(documentId: string) { return http.get(`${basePath}/documents/${encodeURIComponent(documentId)}/download`, { responseType: 'blob' }) } -export function downloadContainerCustomsCompletion(documentId: string) { - return http.get(`${basePath}/documents/${encodeURIComponent(documentId)}/completion/download`, { responseType: 'blob' }) +export function downloadContainerCustomsReceipt(documentId: string) { + return http.get(`${basePath}/documents/${encodeURIComponent(documentId)}/receipt/download`, { responseType: 'blob' }) } -async function action(loadingPlanId: string, name: 'start' | 'complete-all-buy', expectedVersion: number, remark?: string) { +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) { const response = await http.post, ApiEnvelope>( `${basePath}/${encodeURIComponent(loadingPlanId)}/${name}`, { expectedVersion, confirm: true, remark: remark?.trim() || null }, @@ -108,14 +113,26 @@ export function completeAllBuyContainerCustoms(loadingPlanId: string, expectedVe return action(loadingPlanId, 'complete-all-buy', expectedVersion) } -export async function uploadContainerCustomsCompletion(documentId: string, fileValue: File, expectedVersion: number) { +export function submitContainerCustomsToBroker(loadingPlanId: string, expectedVersion: number) { + return action(loadingPlanId, 'submit-to-broker', expectedVersion, '已将客户确认回执发送给报关行') +} + +async function uploadFile(documentId: string, fileValue: File, expectedVersion: number, kind: 'receipt' | 'final') { const body = new FormData() body.append('file', fileValue) const response = await http.post, ApiEnvelope>( - `${basePath}/documents/${encodeURIComponent(documentId)}/completion/upload`, + `${basePath}/documents/${encodeURIComponent(documentId)}/${kind}/upload`, body, - { params: { expectedVersion }, headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': operationId('container-customs-completion') } }, + { params: { expectedVersion }, headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': operationId(`container-customs-${kind}`) } }, ) const result = unwrapApiResponse(response) - return { ...result, documentId: String(result.documentId), tenantId: String(result.tenantId), source: file(result.source)!, completion: file(result.completion) } + return { ...result, documentId: String(result.documentId), tenantId: String(result.tenantId), source: file(result.source)!, receipt: file(result.receipt), finalDocument: file(result.finalDocument) } +} + +export function uploadContainerCustomsReceipt(documentId: string, fileValue: File, expectedVersion: number) { + return uploadFile(documentId, fileValue, expectedVersion, 'receipt') +} + +export function uploadContainerCustomsFinal(documentId: string, fileValue: File, expectedVersion: number) { + return uploadFile(documentId, fileValue, expectedVersion, 'final') } diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.test.ts b/src/modules/container-loading/components/ContainerCustomsPanel.test.ts index ef943649..86fce58b 100644 --- a/src/modules/container-loading/components/ContainerCustomsPanel.test.ts +++ b/src/modules/container-loading/components/ContainerCustomsPanel.test.ts @@ -10,16 +10,19 @@ const api = vi.hoisted(() => ({ completeAllBuy: vi.fn(), upload: vi.fn(), downloadSource: vi.fn(), - downloadCompletion: vi.fn(), + downloadReceipt: vi.fn(), + downloadFinal: vi.fn(), })) vi.mock('../api/platformContainerCustoms', () => ({ getContainerCustoms: api.get, startContainerCustoms: api.start, completeAllBuyContainerCustoms: api.completeAllBuy, - uploadContainerCustomsCompletion: api.upload, + uploadContainerCustomsReceipt: api.upload, downloadContainerCustomsDocument: api.downloadSource, - downloadContainerCustomsCompletion: api.downloadCompletion, + downloadContainerCustomsReceipt: api.downloadReceipt, + downloadContainerCustomsFinal: api.downloadFinal, + submitContainerCustomsToBroker: vi.fn(), })) vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() })) @@ -34,7 +37,8 @@ function customs(status: ContainerCustomsStatus, hasCompletion = false): Contain 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, - completion: hasCompletion ? { fileId: '2092438843630391409', fileName: '报关完成文件.pdf', fileHash: 'completion-hash', sizeBytes: 13, version: 1, uploadedAt: '2026-08-29T11:00:00' } : null, + 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, }], startedAt: status === 'DECLARING' || status === 'COMPLETED' ? '2026-08-29T10:30:00' : null, completedAt: status === 'COMPLETED' ? '2026-08-29T11:30:00' : null, @@ -59,7 +63,8 @@ describe('ContainerCustomsPanel', () => { beforeEach(() => { vi.clearAllMocks() api.downloadSource.mockResolvedValue(new Blob(['source'])) - api.downloadCompletion.mockResolvedValue(new Blob(['completion'])) + 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) @@ -113,7 +118,8 @@ describe('ContainerCustomsPanel', () => { wrapper = await render('DECLARING', true, true) expect(wrapper.text()).toContain('覆盖报关行回执') - expect(wrapper.text()).toContain('报关完成文件.pdf') + expect(wrapper.text()).toContain('报关行回执.pdf') + expect(wrapper.text()).toContain('真实报关完税文件.pdf') wrapper.unmount() wrapper = await render('DECLARING', false, true) @@ -126,7 +132,8 @@ describe('ContainerCustomsPanel', () => { 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('真实报关完税文件.pdf') expect(wrapper.text()).toContain('报关已完成,待其他离仓条件') expect(buttonTexts(wrapper)).not.toContain('上传报关行回执') expect(buttonTexts(wrapper)).not.toContain('覆盖报关行回执') @@ -138,13 +145,16 @@ describe('ContainerCustomsPanel', () => { await wrapper.get('[aria-label="下载客户退税资料 客户退税资料.pdf"]').trigger('click') await flushPromises() expect(api.downloadSource).toHaveBeenCalledWith('2092438843630391407') - expect(api.downloadCompletion).not.toHaveBeenCalled() + expect(api.downloadReceipt).not.toHaveBeenCalled() - await wrapper.get('[aria-label="下载报关行回执 报关完成文件.pdf"]').trigger('click') + await wrapper.get('[aria-label="下载报关行回执 报关行回执.pdf"]').trigger('click') await flushPromises() - expect(api.downloadCompletion).toHaveBeenCalledWith('2092438843630391407') - expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledTimes(2) - expect(URL.createObjectURL).toHaveBeenCalledTimes(2) + 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') }) @@ -159,6 +169,6 @@ describe('ContainerCustomsPanel', () => { expect(wrapper.text()).toContain('CUSTOMS_FILE_404') expect(wrapper.text()).toContain('request-customs-download') expect(wrapper.text()).toContain('trace-customs-download') - expect(api.downloadCompletion).not.toHaveBeenCalled() + expect(api.downloadReceipt).not.toHaveBeenCalled() }) }) diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.vue b/src/modules/container-loading/components/ContainerCustomsPanel.vue index 6002af16..b041e8f9 100644 --- a/src/modules/container-loading/components/ContainerCustomsPanel.vue +++ b/src/modules/container-loading/components/ContainerCustomsPanel.vue @@ -9,11 +9,14 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue' import OperationErrorToast from '@/shared/components/OperationErrorToast.vue' import { completeAllBuyContainerCustoms, - downloadContainerCustomsCompletion, + downloadContainerCustomsReceipt, + downloadContainerCustomsFinal, downloadContainerCustomsDocument, getContainerCustoms, startContainerCustoms, - uploadContainerCustomsCompletion, + submitContainerCustomsToBroker, + uploadContainerCustomsReceipt, + uploadContainerCustomsFinal, type ContainerCustomsDocument, type ContainerCustomsStatus, type ContainerCustomsView, @@ -33,7 +36,8 @@ const actionLoading = ref(false) const loadError = ref(null) const actionError = ref(null) const downloadingKey = ref('') -const completionFiles = reactive>({}) +const receiptFiles = reactive>({}) +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))) @@ -45,9 +49,14 @@ const statusText: Record = { function documentCustomers(document: ContainerCustomsDocument) { return [...new Set((customs.value?.inbounds || []).filter((item) => document.inboundIds.includes(item.inboundId)).map((item) => item.customerName))].join('、') || '—' } -function selectCompletion(documentId: string, upload: UploadFile) { +function selectReceipt(documentId: string, upload: UploadFile) { if (!props.canManage || customs.value?.status !== 'DECLARING') return - completionFiles[documentId] = upload.raw || null + receiptFiles[documentId] = upload.raw || null + actionError.value = null +} +function selectFinal(documentId: string, upload: UploadFile) { + if (!props.canManage || customs.value?.status !== 'DECLARING') return + finalFiles[documentId] = upload.raw || null actionError.value = null } @@ -71,18 +80,31 @@ async function start() { finally { actionLoading.value = false } } -async function uploadCompletion(document: ContainerCustomsDocument) { +async function uploadReceipt(document: ContainerCustomsDocument) { if (!props.canManage || customs.value?.status !== 'DECLARING') return - const selected = completionFiles[document.documentId] - if (!selected) { ElMessage.warning('请选择对应的报关完成文件'); return } + const selected = receiptFiles[document.documentId] + if (!selected) { ElMessage.warning('请选择对应的报关行回执'); return } actionLoading.value = true; actionError.value = null try { - await uploadContainerCustomsCompletion(document.documentId, selected, document.completion?.version || 0) - completionFiles[document.documentId] = null + await uploadContainerCustomsReceipt(document.documentId, selected, document.receipt?.version || 0) + receiptFiles[document.documentId] = null await load() emit('refreshed') ElMessage.success('报关行回执已上传,等待客户确认') - } catch (error) { actionError.value = getApiErrorDetails(error, '报关完成文件上传失败') } + } catch (error) { actionError.value = getApiErrorDetails(error, '报关行回执上传失败') } + finally { actionLoading.value = false } +} + +async function uploadFinal(document: ContainerCustomsDocument) { + if (!props.canManage || customs.value?.status !== 'DECLARING') return + const selected = finalFiles[document.documentId] + if (!selected) { ElMessage.warning('请选择真实报关完税文件'); return } + actionLoading.value = true; actionError.value = null + try { + await uploadContainerCustomsFinal(document.documentId, selected, document.finalDocument?.version || 0) + finalFiles[document.documentId] = null + await load(); emit('refreshed'); ElMessage.success('真实报关完税文件已上传') + } catch (error) { actionError.value = getApiErrorDetails(error, '真实报关完税文件上传失败') } finally { actionLoading.value = false } } @@ -95,8 +117,8 @@ function saveBlob(blob: Blob, fileName: string) { URL.revokeObjectURL(url) } -async function downloadFile(document: ContainerCustomsDocument, kind: 'source' | 'completion') { - const target = kind === 'source' ? document.source : document.completion +async function downloadFile(document: ContainerCustomsDocument, kind: 'source' | 'receipt' | 'final') { + const target = kind === 'source' ? document.source : kind === 'receipt' ? document.receipt : document.finalDocument if (!target) return const key = `${kind}:${document.documentId}` downloadingKey.value = key @@ -104,9 +126,9 @@ async function downloadFile(document: ContainerCustomsDocument, kind: 'source' | try { const blob = kind === 'source' ? await downloadContainerCustomsDocument(document.documentId) - : await downloadContainerCustomsCompletion(document.documentId) + : kind === 'receipt' ? await downloadContainerCustomsReceipt(document.documentId) : await downloadContainerCustomsFinal(document.documentId) saveBlob(blob, target.fileName) - ElMessage.success(`${kind === 'source' ? '客户退税资料' : '报关完成文件'}下载已开始`) + ElMessage.success(`${kind === 'source' ? '客户退税资料' : kind === 'receipt' ? '报关行回执' : '报关完税文件'}下载已开始`) } catch (error) { actionError.value = getApiErrorDetails(error, `${kind === 'source' ? '客户退税资料' : '报关完成文件'}下载失败`) } finally { @@ -114,6 +136,15 @@ async function downloadFile(document: ContainerCustomsDocument, kind: 'source' | } } +async function submitToBroker() { + if (!customs.value || customs.value.status !== 'DECLARING') 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('已发送给报关行执行') } + 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: '确认完成' }) } @@ -152,6 +183,7 @@ onMounted(load) {{ customs.hasActualCargo ? statusText[customs.status] : '无需报关' }} 导出买单 XLSX 开始报关 + 发送报关行执行 确认报关完成 @@ -167,13 +199,14 @@ onMounted(load)
客户订单号入仓码
{{ item.customerName }}{{ item.orderNo }}{{ item.inboundCode }}
-

已冻结退税资料与报关行回执

- +

已冻结退税资料、报关行回执与完税文件

+
操作客户退税资料报关行回执关联入仓码版本客户回执情况
- + - - + + +
操作客户退税资料报关行回执真实报关完税文件关联入仓码版本客户回执情况
{{ document.completion ? '覆盖报关行回执' : '上传报关行回执' }}提交
只读
{{ document.receipt ? '覆盖报关行回执' : '上传报关行回执' }}提交{{ document.finalDocument ? '覆盖完税文件' : '上传完税文件' }}提交
只读
{{ document.source.fileName }}下载
{{ document.completion.fileName }}下载
未上传
{{ document.inboundCodes.join('、') || '—' }}退税资料 v{{ document.source.version }} / 回执 v{{ document.completion.version }}{{ documentCustomers(document) }}{{ document.completion ? '已上传报关行回执' : '待上传报关行回执' }}
{{ document.receipt.fileName }}下载
未上传
{{ document.finalDocument.fileName }}下载
未上传
{{ document.inboundCodes.join('、') || '—' }}退税资料 v{{ document.source.version }} / 回执 v{{ document.receipt.version }} / 完税 v{{ document.finalDocument.version }}{{ documentCustomers(document) }}{{ document.receipt ? (document.receiptStatus === 'CONFIRMED' ? '客户已确认回执' : '已上传回执') : '待上传报关行回执' }}