From aee562d9987568188932c814fa278f29e5ac8ffe Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 29 Aug 2026 10:19:35 +0800 Subject: [PATCH] fix(admin): expose container customs downloads --- .../api/platformContainerCustoms.test.ts | 17 +- .../api/platformContainerCustoms.ts | 8 + .../components/ContainerCustomsPanel.test.ts | 146 +++++++++++++++--- .../components/ContainerCustomsPanel.vue | 44 +++++- 4 files changed, 188 insertions(+), 27 deletions(-) diff --git a/src/modules/container-loading/api/platformContainerCustoms.test.ts b/src/modules/container-loading/api/platformContainerCustoms.test.ts index 2f7cbf90..1a64e112 100644 --- a/src/modules/container-loading/api/platformContainerCustoms.test.ts +++ b/src/modules/container-loading/api/platformContainerCustoms.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/shared/api/http' -import { completeAllBuyContainerCustoms, getContainerCustoms, startContainerCustoms, uploadContainerCustomsCompletion } from './platformContainerCustoms' +import { completeAllBuyContainerCustoms, downloadContainerCustomsCompletion, downloadContainerCustomsDocument, getContainerCustoms, startContainerCustoms, uploadContainerCustomsCompletion } from './platformContainerCustoms' const customs = { aggregateId: 2092438843630391401n, loadingPlanId: 2092438843630391402n, containerBookingId: 2092438843630391403n, @@ -40,4 +40,19 @@ describe('platform container customs API', () => { expect((post.mock.calls[0]?.[1] as FormData).get('file')).toBe(completion) expect(post.mock.calls[0]?.[2]).toMatchObject({ params: { expectedVersion: 0, finalConfirmation: true }, headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': expect.any(String) } }) }) + + it('keeps source and completion downloads isolated on the long document id', async () => { + const source = new Blob(['source']) + const completion = new Blob(['completion']) + const get = vi.spyOn(http, 'get').mockResolvedValueOnce(source).mockResolvedValueOnce(completion) + const documentId = '2092438843630391407' + + await expect(downloadContainerCustomsDocument(documentId)).resolves.toBe(source) + await expect(downloadContainerCustomsCompletion(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' }], + ]) + }) }) diff --git a/src/modules/container-loading/api/platformContainerCustoms.ts b/src/modules/container-loading/api/platformContainerCustoms.ts index 895b6016..92005b3a 100644 --- a/src/modules/container-loading/api/platformContainerCustoms.ts +++ b/src/modules/container-loading/api/platformContainerCustoms.ts @@ -78,6 +78,14 @@ export async function getContainerCustoms(loadingPlanId: string) { return normalize(unwrapApiResponse(response)) } +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' }) +} + async function action(loadingPlanId: string, name: 'start' | 'complete-all-buy', expectedVersion: number, remark?: string) { const response = await http.post, ApiEnvelope>( `${basePath}/${encodeURIComponent(loadingPlanId)}/${name}`, diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.test.ts b/src/modules/container-loading/components/ContainerCustomsPanel.test.ts index 92ff007b..3166eac1 100644 --- a/src/modules/container-loading/components/ContainerCustomsPanel.test.ts +++ b/src/modules/container-loading/components/ContainerCustomsPanel.test.ts @@ -1,32 +1,134 @@ -import { readFileSync } from 'node:fs' -import { resolve } from 'node:path' -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import ElementPlus from 'element-plus' +import { ApiResponseError } from '@/shared/api/http' +import type { ContainerCustomsStatus, ContainerCustomsView } from '../api/platformContainerCustoms' -const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/components/ContainerCustomsPanel.vue'), 'utf8') +const api = vi.hoisted(() => ({ + get: vi.fn(), + start: vi.fn(), + completeAllBuy: vi.fn(), + upload: vi.fn(), + downloadSource: vi.fn(), + downloadCompletion: vi.fn(), +})) -describe('ContainerCustomsPanel contract', () => { - it('renders compact coverage, missing inbound and frozen document facts with operation first', () => { - expect(source).toContain('退税总票') - expect(source).toContain('缺资料清单') - expect(source).toContain('操作关联入仓码版本客户') - expect(source).toContain("item.taxMode === 'TAX_REFUND' && !item.covered") +vi.mock('../api/platformContainerCustoms', () => ({ + getContainerCustoms: api.get, + startContainerCustoms: api.start, + completeAllBuyContainerCustoms: api.completeAllBuy, + uploadContainerCustomsCompletion: api.upload, + downloadContainerCustomsDocument: api.downloadSource, + downloadContainerCustomsCompletion: api.downloadCompletion, +})) +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, version: 3, taxRefundCount: 1, coveredTaxRefundCount: 1, + 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, + completion: hasCompletion ? { fileId: '2092438843630391409', fileName: '报关完成文件.pdf', fileHash: 'completion-hash', sizeBytes: 13, version: 1, uploadedAt: '2026-08-29T11:00:00' } : 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>) { + return wrapper.findAll('button').map((button) => button.text()) +} + +describe('ContainerCustomsPanel', () => { + beforeEach(() => { + vi.clearAllMocks() + api.downloadSource.mockResolvedValue(new Blob(['source'])) + api.downloadCompletion.mockResolvedValue(new Blob(['completion'])) + 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('gates start by server status and confirms frozen facts', () => { - expect(source).toContain("customs.status === 'READY_TO_START'") - expect(source).toContain('冻结客户当前资料版本、关联入仓码覆盖范围及平台买单准备事实') + 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.text()).toContain('客户退税资料.pdf') + 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('requires a second confirmation for the final missing completion and keeps departure independent', () => { - expect(source).toContain('missingCompletionCount.value === 1') - expect(source).toContain('确认上传并完成') - expect(source).toContain("departureState === 'DEPARTED' ? '已自动离仓' : '报关已完成,待其他离仓条件'") + 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('提交') + wrapper.unmount() + + wrapper = await render('DECLARING', true, true) + expect(wrapper.text()).toContain('覆盖完成文件') + expect(wrapper.text()).toContain('报关完成文件.pdf') + 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('creates the all-buy XLSX task with loadingPlanId as its only parameter', () => { - expect(source).toContain("exportType: 'LOADING_LIST_BUY_DECLARATION_ITEMS', format: 'XLSX'") - expect(source).toContain('parameters: { loadingPlanId: String(props.loadingPlanId) }') - expect(source).not.toContain('fileType') - expect(source).not.toContain('documentType') + 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('报关已完成,待其他离仓条件') + 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.downloadCompletion).not.toHaveBeenCalled() + + 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(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.downloadCompletion).not.toHaveBeenCalled() }) }) diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.vue b/src/modules/container-loading/components/ContainerCustomsPanel.vue index 1333ab2b..1da973a0 100644 --- a/src/modules/container-loading/components/ContainerCustomsPanel.vue +++ b/src/modules/container-loading/components/ContainerCustomsPanel.vue @@ -1,6 +1,6 @@