From 813763a02ab981b73e561c3deefb5ecd1daccf3e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 28 Aug 2026 16:54:02 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/platformContainerCustoms.test.ts | 43 ++ .../api/platformContainerCustoms.ts | 108 ++++ .../components/ContainerCustomsPanel.test.ts | 32 + .../components/ContainerCustomsPanel.vue | 156 +++++ .../components/ContainerOverviewPanel.test.ts | 11 +- .../components/ContainerOverviewPanel.vue | 18 +- .../pages/ContainerDetailView.test.ts | 569 +----------------- .../pages/ContainerDetailView.vue | 129 ++-- .../pages/ContainersView.test.ts | 5 +- .../pages/ContainersView.vue | 1 - src/modules/container-loading/types.ts | 2 +- .../api/platformLoadingLists.test.ts | 27 - .../loading-lists/api/platformLoadingLists.ts | 49 -- .../components/LoadingListDetailDrawer.vue | 15 +- .../components/LoadingListsView.test.ts | 22 +- .../pages/ConfirmedLoadingListDetailView.vue | 109 +--- 16 files changed, 413 insertions(+), 883 deletions(-) create mode 100644 src/modules/container-loading/api/platformContainerCustoms.test.ts create mode 100644 src/modules/container-loading/api/platformContainerCustoms.ts create mode 100644 src/modules/container-loading/components/ContainerCustomsPanel.test.ts create mode 100644 src/modules/container-loading/components/ContainerCustomsPanel.vue diff --git a/src/modules/container-loading/api/platformContainerCustoms.test.ts b/src/modules/container-loading/api/platformContainerCustoms.test.ts new file mode 100644 index 00000000..2f7cbf90 --- /dev/null +++ b/src/modules/container-loading/api/platformContainerCustoms.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { http } from '@/shared/api/http' +import { completeAllBuyContainerCustoms, getContainerCustoms, startContainerCustoms, uploadContainerCustomsCompletion } from './platformContainerCustoms' + +const customs = { + aggregateId: 2092438843630391401n, loadingPlanId: 2092438843630391402n, containerBookingId: 2092438843630391403n, + planNo: 'LP-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'READY_TO_START', version: '3', taxRefundCount: 1, coveredTaxRefundCount: 1, + 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 }], + startedAt: null, completedAt: null, +} + +describe('platform container customs API', () => { + beforeEach(() => vi.restoreAllMocks()) + + it('uses the frozen customs-v1 paths and preserves long ids as strings', async () => { + const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: customs }) + const result = await getContainerCustoms('2092438843630391402') + expect(get).toHaveBeenCalledWith('/platform/v1/customs/loading-plans/2092438843630391402') + expect(result).toMatchObject({ aggregateId: '2092438843630391401', loadingPlanId: '2092438843630391402', containerBookingId: '2092438843630391403' }) + expect(result.inbounds[0]).toMatchObject({ inboundId: '2092438843630391404', orderId: '2092438843630391405', tenantId: '2092438843630391406' }) + expect(result.documents[0]?.documentId).toBe('2092438843630391407') + }) + + it('sends confirmed start and all-buy completion with optimistic version and idempotency', async () => { + const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: customs }) + await startContainerCustoms('2092438843630391402', 3) + await completeAllBuyContainerCustoms('2092438843630391402', 4) + expect(post.mock.calls[0]?.slice(0, 2)).toEqual(['/platform/v1/customs/loading-plans/2092438843630391402/start', { expectedVersion: 3, confirm: true, remark: null }]) + expect(post.mock.calls[1]?.slice(0, 2)).toEqual(['/platform/v1/customs/loading-plans/2092438843630391402/complete-all-buy', { expectedVersion: 4, confirm: true, remark: null }]) + expect(post.mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } }) + }) + + it('uploads one completion file with final-confirmation query parameters', async () => { + 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, true) + expect(post.mock.calls[0]?.[0]).toBe('/platform/v1/customs/loading-plans/documents/2092438843630391407/completion/upload') + 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) } }) + }) +}) diff --git a/src/modules/container-loading/api/platformContainerCustoms.ts b/src/modules/container-loading/api/platformContainerCustoms.ts new file mode 100644 index 00000000..895b6016 --- /dev/null +++ b/src/modules/container-loading/api/platformContainerCustoms.ts @@ -0,0 +1,108 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + +export type ContainerCustomsStatus = 'WAITING_CUSTOMER_DOCUMENTS' | 'READY_TO_START' | 'DECLARING' | 'COMPLETED' +export type ContainerCustomsTaxMode = 'TAX_REFUND' | 'BUY_DECLARATION' + +export interface ContainerCustomsFile { + fileId: string + fileName: string + fileHash: string + sizeBytes: number + version: number + uploadedAt: string +} + +export interface ContainerCustomsInbound { + inboundId: string + orderId: string + orderNo: string + inboundCode: string + tenantId: string + customerName: string + taxMode: ContainerCustomsTaxMode + covered: boolean +} + +export interface ContainerCustomsDocument { + documentId: string + tenantId: string + source: ContainerCustomsFile + inboundIds: string[] + inboundCodes: string[] + frozen: boolean + completion: ContainerCustomsFile | null +} + +export interface ContainerCustomsView { + aggregateId: string + loadingPlanId: string + containerBookingId: string + planNo: string + cabinetNo: string + containerNo: string | null + status: ContainerCustomsStatus + version: number + taxRefundCount: number + coveredTaxRefundCount: number + inbounds: ContainerCustomsInbound[] + documents: ContainerCustomsDocument[] + startedAt: string | null + completedAt: string | null +} + +interface RawContainerCustomsView extends Omit { + aggregateId: string | number + loadingPlanId: string | number + containerBookingId: string | number + version: string | number + inbounds: Array & { inboundId: string | number; orderId: string | number; tenantId: string | number }> + documents: Array & { documentId: string | number; tenantId: string | number; source: ContainerCustomsFile; completion: ContainerCustomsFile | null }> +} + +const basePath = '/platform/v1/customs/loading-plans' +function operationId(prefix: string) { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}` } +function file(value: ContainerCustomsFile | null): ContainerCustomsFile | null { + return value ? { ...value, fileId: String(value.fileId), sizeBytes: Number(value.sizeBytes), version: Number(value.version) } : null +} +function normalize(value: RawContainerCustomsView): ContainerCustomsView { + return { + ...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) })), + } +} + +export async function getContainerCustoms(loadingPlanId: string) { + const response = await http.get, ApiEnvelope>(`${basePath}/${encodeURIComponent(loadingPlanId)}`) + return normalize(unwrapApiResponse(response)) +} + +async function action(loadingPlanId: string, name: 'start' | 'complete-all-buy', expectedVersion: number, remark?: string) { + const response = await http.post, ApiEnvelope>( + `${basePath}/${encodeURIComponent(loadingPlanId)}/${name}`, + { expectedVersion, confirm: true, remark: remark?.trim() || null }, + { headers: { 'Idempotency-Key': operationId(`container-customs-${name}`) } }, + ) + return normalize(unwrapApiResponse(response)) +} + +export function startContainerCustoms(loadingPlanId: string, expectedVersion: number) { + return action(loadingPlanId, 'start', expectedVersion) +} + +export function completeAllBuyContainerCustoms(loadingPlanId: string, expectedVersion: number) { + return action(loadingPlanId, 'complete-all-buy', expectedVersion) +} + +export async function uploadContainerCustomsCompletion(documentId: string, fileValue: File, expectedVersion: number, finalConfirmation: boolean) { + const body = new FormData() + body.append('file', fileValue) + const response = await http.post, ApiEnvelope>( + `${basePath}/documents/${encodeURIComponent(documentId)}/completion/upload`, + body, + { params: { expectedVersion, finalConfirmation }, headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': operationId('container-customs-completion') } }, + ) + const result = unwrapApiResponse(response) + return { ...result, documentId: String(result.documentId), tenantId: String(result.tenantId), source: file(result.source)!, completion: file(result.completion) } +} diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.test.ts b/src/modules/container-loading/components/ContainerCustomsPanel.test.ts new file mode 100644 index 00000000..92ff007b --- /dev/null +++ b/src/modules/container-loading/components/ContainerCustomsPanel.test.ts @@ -0,0 +1,32 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/components/ContainerCustomsPanel.vue'), 'utf8') + +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") + }) + + it('gates start by server status and confirms frozen facts', () => { + expect(source).toContain("customs.status === 'READY_TO_START'") + expect(source).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('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') + }) +}) diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.vue b/src/modules/container-loading/components/ContainerCustomsPanel.vue new file mode 100644 index 00000000..1333ab2b --- /dev/null +++ b/src/modules/container-loading/components/ContainerCustomsPanel.vue @@ -0,0 +1,156 @@ + + + + + diff --git a/src/modules/container-loading/components/ContainerOverviewPanel.test.ts b/src/modules/container-loading/components/ContainerOverviewPanel.test.ts index 4767d2ce..f4137e24 100644 --- a/src/modules/container-loading/components/ContainerOverviewPanel.test.ts +++ b/src/modules/container-loading/components/ContainerOverviewPanel.test.ts @@ -47,11 +47,11 @@ describe('ContainerOverviewPanel', () => { expect(wrapper.text()).not.toContain('9007199254740999') }) - it('renders four compact document table rows with the operation column first', () => { + it('renders three compact non-customs document rows with the operation column first', () => { const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } }) const headers = wrapper.findAll('.document-table thead th').map((cell) => cell.text()) expect(headers).toEqual(['操作', '资料类型', '文件名', '上传人', '上传时间', '状态']) - expect(wrapper.findAll('.document-table tbody tr')).toHaveLength(4) + expect(wrapper.findAll('.document-table tbody tr')).toHaveLength(3) expect(wrapper.findAll('.document-item')).toHaveLength(0) expect(wrapper.find('.document-table-wrap').exists()).toBe(true) const deliveryRow = wrapper.findAll('.document-table tbody tr').find((row) => row.text().includes('DO 资料'))! @@ -158,11 +158,4 @@ describe('ContainerOverviewPanel', () => { expect(wrapper.text()).not.toContain('Authorization') }) - it('keeps a completed customs document labelled as an attachment, not formal customs release', () => { - const customs = { ...overview.documents[0], documentType: 'CUSTOMS_DECLARATION', completed: true, fileName: 'customs.pdf' } - const wrapper = mount(ContainerOverviewPanel, { props: { overview: { ...overview, pda: null, documents: [customs] }, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } }) - expect(wrapper.text()).toContain('国内出口报关附件') - expect(wrapper.text()).not.toContain('已完成报关') - expect(wrapper.text()).not.toContain('已放行') - }) }) diff --git a/src/modules/container-loading/components/ContainerOverviewPanel.vue b/src/modules/container-loading/components/ContainerOverviewPanel.vue index 5f54dd0c..8f7603d0 100644 --- a/src/modules/container-loading/components/ContainerOverviewPanel.vue +++ b/src/modules/container-loading/components/ContainerOverviewPanel.vue @@ -1,6 +1,6 @@ @@ -139,40 +58,16 @@ watch([planId, warehouseId], () => void loadDetail())