diff --git a/src/modules/ocean-bookings/api/platformOceanBookings.test.ts b/src/modules/ocean-bookings/api/platformOceanBookings.test.ts index d5900515..fc993162 100644 --- a/src/modules/ocean-bookings/api/platformOceanBookings.test.ts +++ b/src/modules/ocean-bookings/api/platformOceanBookings.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { createOceanBooking, deleteOceanBookingDraft, listOceanBookingAgents, listOceanBookingLinkCandidates, listOceanBookings, listWarehouseLogisticsChannels, releaseOceanBooking, updateOceanBookingDraft } from './platformOceanBookings' +import { createOceanBooking, deleteOceanBookingDraft, downloadOceanBookingDocument, downloadOceanBookingPrint, invalidateOceanBookingDocument, listOceanBookingAgents, listOceanBookingDocuments, listOceanBookingLinkCandidates, listOceanBookings, listWarehouseLogisticsChannels, referenceOceanBookingDocument, releaseOceanBooking, updateOceanBookingDraft, uploadOceanBookingDocument } from './platformOceanBookings' import { http } from '@/shared/api/http' const detailResponse = (version = 0, status = 'DRAFT') => ({ @@ -9,6 +9,7 @@ const detailResponse = (version = 0, status = 'DRAFT') => ({ parties: [], containerRequirements: [{ id: 9007199254740994, oceanBookingId: 123, containerType: '40HC', containerKind: 'FCL', quantity: 2, capacityCbm: 76, maxWeightKg: 28000 }], containers: [{ containerBookingId: 9007199254740993, cabinetNo: 'CAB-1', containerNo: 'MSCU1234567', requirementId: 9007199254740994, allocationId: 9007199254740995, containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000, warehouseId: 9007199254740996, warehouseName: '义乌仓', channelCode: 'SEA', channelName: '海运', customerId: 456, customerName: '客户' }], + documents: [{ id: 9007199254740997, oceanBookingId: 123, fileId: 9007199254740998, documentType: 'BOOKING_CONFIRMATION', fileName: '订舱确认.pdf', fileHash: null, sizeBytes: '128', contentType: 'application/pdf', valid: true, uploadedBy: 7, uploadedByName: '平台', uploadedAt: '2026-08-31T10:00:00', invalidatedBy: null, invalidatedByName: null, invalidatedAt: null }], histories: [], }) @@ -48,6 +49,7 @@ describe('ocean bookings api', () => { expect(created.status).toBe('DRAFT') expect(created.containerRequirements[0]).toMatchObject({ id: '9007199254740994', requirementId: '9007199254740994', quantity: 2 }) expect(created.containers[0]).toMatchObject({ containerBookingId: '9007199254740992', requirementId: '9007199254740994' }) + expect(created.documents[0]).toMatchObject({ id: '9007199254740996', oceanBookingId: '123', fileId: '9007199254740998', sizeBytes: 128, uploadedBy: '7' }) }) it('uses draft update, delete and release contracts without legacy actions', async () => { @@ -79,4 +81,45 @@ describe('ocean bookings api', () => { vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: [] }) await expect(listOceanBookingAgents('HEDE')).resolves.toEqual([]) }) + + it('downloads both booking PDF documents from their dedicated public endpoints', async () => { + const get = vi.spyOn(http, 'get').mockResolvedValue(new Blob(['pdf'], { type: 'application/pdf' })) + await downloadOceanBookingPrint('123/456', 'SHIPPING_INSTRUCTION') + await downloadOceanBookingPrint('123/456', 'BILL_OF_LADING_CONFIRMATION') + expect(get).toHaveBeenNthCalledWith(1, '/platform/v1/ocean-bookings/123%2F456/prints/shipping-instruction', { responseType: 'blob' }) + expect(get).toHaveBeenNthCalledWith(2, '/platform/v1/ocean-bookings/123%2F456/prints/bill-of-lading-confirmation', { responseType: 'blob' }) + }) + + it('uses the booking-owned document list, upload, reference, invalidate and download contracts', async () => { + const rawDocument = { id: 9007199254740993n, oceanBookingId: 123, fileId: 9007199254740995n, documentType: 'BOOKING_CONFIRMATION', fileName: '订舱确认.pdf', fileHash: null, sizeBytes: '128', contentType: 'application/pdf', valid: true, uploadedBy: 7, uploadedByName: '平台', uploadedAt: '2026-08-31T10:00:00', invalidatedBy: null, invalidatedByName: null, invalidatedAt: null } + const get = vi.spyOn(http, 'get') + .mockResolvedValueOnce({ code: '200', data: [rawDocument] }) + .mockResolvedValueOnce(new Blob(['pdf'], { type: 'application/pdf' })) + const post = vi.spyOn(http, 'post') + .mockResolvedValueOnce({ code: '200', data: { document: rawDocument, bookingVersion: '3' } }) + .mockResolvedValueOnce({ code: '200', data: { document: rawDocument, bookingVersion: '4' } }) + const remove = vi.spyOn(http, 'delete').mockResolvedValue({ code: '200', data: { documentId: 9007199254740993n, bookingVersion: '5' } }) + + await expect(listOceanBookingDocuments('123')).resolves.toMatchObject([{ id: '9007199254740993', oceanBookingId: '123', fileId: '9007199254740995', sizeBytes: 128, uploadedBy: '7' }]) + await expect(referenceOceanBookingDocument('123', 2, '9007199254740995', 'BOOKING_CONFIRMATION')).resolves.toMatchObject({ bookingVersion: 3 }) + const file = new File(['bill'], '提单.pdf', { type: 'application/pdf' }) + await expect(uploadOceanBookingDocument('123', 3, 'BILL_OF_LADING', file)).resolves.toMatchObject({ bookingVersion: 4 }) + const form = post.mock.calls[1]?.[1] as FormData + expect(form.get('expectedVersion')).toBe('3') + expect(form.get('documentType')).toBe('BILL_OF_LADING') + expect(form.get('file')).toBe(file) + await expect(invalidateOceanBookingDocument('123', '9007199254740993', 4)).resolves.toEqual({ documentId: '9007199254740993', bookingVersion: 5 }) + await downloadOceanBookingDocument('123', '9007199254740993') + + expect(get).toHaveBeenNthCalledWith(1, '/platform/v1/ocean-bookings/123/documents') + expect(post).toHaveBeenNthCalledWith(1, '/platform/v1/ocean-bookings/123/documents', { expectedVersion: 2, fileId: '9007199254740995', documentType: 'BOOKING_CONFIRMATION' }, { headers: { 'Idempotency-Key': expect.any(String) } }) + expect(post).toHaveBeenNthCalledWith(2, '/platform/v1/ocean-bookings/123/documents/upload', expect.any(FormData), { headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': expect.any(String) } }) + expect(remove).toHaveBeenCalledWith('/platform/v1/ocean-bookings/123/documents/9007199254740993', { data: { expectedVersion: 4 }, headers: { 'Idempotency-Key': expect.any(String) } }) + expect(get).toHaveBeenNthCalledWith(2, '/platform/v1/ocean-bookings/123/documents/9007199254740993/download', { responseType: 'blob' }) + }) + + it('preserves request and trace ids when a PDF endpoint returns a JSON business envelope as a blob', async () => { + vi.spyOn(http, 'get').mockResolvedValueOnce(new Blob([JSON.stringify({ code: 'B0409', message: '订舱资料不完整', requestId: 'req-pdf', traceId: 'trace-pdf' })], { type: 'application/json' })) + await expect(downloadOceanBookingPrint('123', 'SHIPPING_INSTRUCTION')).rejects.toMatchObject({ code: 'B0409', message: '订舱资料不完整', requestId: 'req-pdf', traceId: 'trace-pdf' }) + }) }) diff --git a/src/modules/ocean-bookings/api/platformOceanBookings.ts b/src/modules/ocean-bookings/api/platformOceanBookings.ts index d9ee1ab2..3f4d6ea8 100644 --- a/src/modules/ocean-bookings/api/platformOceanBookings.ts +++ b/src/modules/ocean-bookings/api/platformOceanBookings.ts @@ -1,7 +1,9 @@ -import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' -import type { OceanBookingAgentOption, OceanBookingCarrierCode, OceanBookingDetail, OceanBookingLinkCandidatePage, OceanBookingLinkCandidateQuery, OceanBookingPage, OceanBookingQuery, OceanBookingResource, OceanBookingSaveRequest, OceanBookingStatus, OceanBookingReleaseRequest } from '../types' +import axios from 'axios' +import { ApiResponseError, http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import type { OceanBookingAgentOption, OceanBookingCarrierCode, OceanBookingDetail, OceanBookingDocument, OceanBookingDocumentInvalidation, OceanBookingDocumentMutation, OceanBookingDocumentType, OceanBookingLinkCandidatePage, OceanBookingLinkCandidateQuery, OceanBookingPage, OceanBookingPrintType, OceanBookingQuery, OceanBookingResource, OceanBookingSaveRequest, OceanBookingStatus, OceanBookingReleaseRequest } from '../types' const basePath = '/platform/v1/ocean-bookings' +const maxErrorEnvelopeBytes = 1024 * 1024 export interface OceanLogisticsChannel { channelCode: string @@ -51,6 +53,7 @@ function detail(raw: OceanBookingDetail): OceanBookingDetail { capacityCbm: Number(container.capacityCbm), maxWeightKg: Number(container.maxWeightKg), })), + documents: (raw.documents || []).map(document), histories, statusHistory: histories, } @@ -87,7 +90,74 @@ export async function createOceanBooking(payload: OceanBookingSaveRequest) { con export async function updateOceanBookingDraft(id: string, payload: OceanBookingSaveRequest) { const response = await http.put, ApiEnvelope>(`${basePath}/${encodeURIComponent(id)}`, payload, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) } export async function deleteOceanBookingDraft(id: string, expectedVersion: number) { await http.delete(`${basePath}/${encodeURIComponent(id)}`, { data: { expectedVersion }, headers: { 'Idempotency-Key': idempotencyKey() } }) } export async function releaseOceanBooking(id: string, request: OceanBookingReleaseRequest) { const response = await http.post, ApiEnvelope>(`${basePath}/${encodeURIComponent(id)}/release`, request, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) } -export async function printOceanBooking(id: string, type: 'SHIPPING_INSTRUCTION' | 'BILL_CONFIRMATION') { return http.get(`${basePath}/${encodeURIComponent(id)}/print`, { params: { type }, responseType: 'blob' }) } +const printPaths: Record = { + SHIPPING_INSTRUCTION: 'shipping-instruction', + BILL_OF_LADING_CONFIRMATION: 'bill-of-lading-confirmation', +} +function document(raw: OceanBookingDocument): OceanBookingDocument { + return { + ...raw, + id: String(raw.id), + oceanBookingId: String(raw.oceanBookingId), + fileId: String(raw.fileId), + sizeBytes: raw.sizeBytes == null ? null : Number(raw.sizeBytes), + uploadedBy: String(raw.uploadedBy), + invalidatedBy: raw.invalidatedBy == null ? null : String(raw.invalidatedBy), + } +} +export async function downloadOceanBookingPrint(id: string, type: OceanBookingPrintType) { + return requestBlob(`${basePath}/${encodeURIComponent(id)}/prints/${printPaths[type]}`) +} +async function parseBlobError(blob: Blob, status?: number) { + if (!blob.type.toLowerCase().includes('json')) return null + if (blob.size > maxErrorEnvelopeBytes) return new ApiResponseError('文件接口返回了超出限制的 JSON 响应', 'INVALID_FILE_RESPONSE', status) + try { + const payload = JSON.parse(await blob.text()) as Partial> + return new ApiResponseError(payload.code === '200' ? '文件接口未返回文件内容' : payload.message || '文件请求失败', payload.code === '200' ? 'INVALID_FILE_RESPONSE' : payload.code || (status ? `HTTP_${status}` : 'FILE_REQUEST_FAILED'), status, payload.data, payload.requestId, payload.traceId) + } catch { + return new ApiResponseError('文件接口返回了无法识别的 JSON 响应', 'INVALID_FILE_RESPONSE', status) + } +} +async function requestBlob(path: string) { + try { + const blob = await http.get(path, { responseType: 'blob' }) + const responseError = await parseBlobError(blob, 200) + if (responseError) throw responseError + return blob + } catch (error) { + if (axios.isAxiosError(error) && error.response?.data instanceof Blob) { + const responseError = await parseBlobError(error.response.data, error.response.status) + if (responseError) throw responseError + } + throw error + } +} +export async function listOceanBookingDocuments(id: string) { + const response = await http.get, ApiEnvelope>(`${basePath}/${encodeURIComponent(id)}/documents`) + return (unwrapApiResponse(response) || []).map(document) +} +export async function referenceOceanBookingDocument(id: string, expectedVersion: number, fileId: string, documentType: OceanBookingDocumentType) { + const response = await http.post, ApiEnvelope>(`${basePath}/${encodeURIComponent(id)}/documents`, { expectedVersion, fileId, documentType }, { headers: { 'Idempotency-Key': idempotencyKey() } }) + const result = unwrapApiResponse(response) + return { document: document(result.document), bookingVersion: Number(result.bookingVersion) } +} +export async function uploadOceanBookingDocument(id: string, expectedVersion: number, documentType: OceanBookingDocumentType, file: File) { + const form = new FormData() + form.append('expectedVersion', String(expectedVersion)) + form.append('documentType', documentType) + form.append('file', file) + const response = await http.post, ApiEnvelope>(`${basePath}/${encodeURIComponent(id)}/documents/upload`, form, { headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': idempotencyKey() } }) + const result = unwrapApiResponse(response) + return { document: document(result.document), bookingVersion: Number(result.bookingVersion) } +} +export async function invalidateOceanBookingDocument(id: string, documentId: string, expectedVersion: number) { + const response = await http.delete, ApiEnvelope>(`${basePath}/${encodeURIComponent(id)}/documents/${encodeURIComponent(documentId)}`, { data: { expectedVersion }, headers: { 'Idempotency-Key': idempotencyKey() } }) + const result = unwrapApiResponse(response) + return { documentId: String(result.documentId), bookingVersion: Number(result.bookingVersion) } +} +export async function downloadOceanBookingDocument(id: string, documentId: string) { + return requestBlob(`${basePath}/${encodeURIComponent(id)}/documents/${encodeURIComponent(documentId)}/download`) +} export async function listWarehouseLogisticsChannels(warehouseId: string): Promise { const response = await http.get, ApiEnvelope>(`${basePath}/channels`, { params: { originWarehouseId: warehouseId } }) return (unwrapApiResponse(response) || []).map((item) => ({ channelCode: String(item.channelCode), channelName: String(item.channelName) })) diff --git a/src/modules/ocean-bookings/components/OceanBookingDocumentsPanel.test.ts b/src/modules/ocean-bookings/components/OceanBookingDocumentsPanel.test.ts new file mode 100644 index 00000000..ace5970e --- /dev/null +++ b/src/modules/ocean-bookings/components/OceanBookingDocumentsPanel.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/components/OceanBookingDocumentsPanel.vue'), 'utf8') + +describe('OceanBookingDocumentsPanel contract', () => { + it('owns the two confirmed document types under the booking', () => { + expect(source).toContain("value: 'BOOKING_CONFIRMATION', label: '订舱确认文件'") + expect(source).toContain("value: 'BILL_OF_LADING', label: '提单文件'") + expect(source).toContain('文件统一归属当前订舱') + expect(source).not.toContain('TRUCK_APPOINTMENT_ATTACHMENT') + }) + + it('supports upload, replacement, logical invalidation and download with version chaining', () => { + expect(source).toContain('uploadOceanBookingDocument(props.bookingId, props.bookingVersion, type, file)') + expect(source).toContain('invalidateOceanBookingDocument(props.bookingId, item.id, version)') + expect(source).toContain('version = invalidated.bookingVersion') + expect(source).toContain('确认替换') + expect(source).toContain('确认作废') + expect(source).toContain('downloadOceanBookingDocument') + expect(source).toContain('URL.createObjectURL(blob)') + }) + + it('makes released booking files permanently read-only and preserves traced errors', () => { + expect(source).toContain("props.bookingStatus === 'DRAFT' && props.canManage") + expect(source).toContain('已放舱 · 永久只读') + expect(source).toContain('manageReason') + expect(source).toContain('') + expect(source).toContain('订舱资料上传失败,请稍后重试') + expect(source).toContain('订舱资料下载失败,请稍后重试') + }) +}) diff --git a/src/modules/ocean-bookings/components/OceanBookingDocumentsPanel.vue b/src/modules/ocean-bookings/components/OceanBookingDocumentsPanel.vue new file mode 100644 index 00000000..84c89256 --- /dev/null +++ b/src/modules/ocean-bookings/components/OceanBookingDocumentsPanel.vue @@ -0,0 +1,127 @@ + + + + + diff --git a/src/modules/ocean-bookings/components/OceanBookingPrintDialog.test.ts b/src/modules/ocean-bookings/components/OceanBookingPrintDialog.test.ts new file mode 100644 index 00000000..801ff8a8 --- /dev/null +++ b/src/modules/ocean-bookings/components/OceanBookingPrintDialog.test.ts @@ -0,0 +1,57 @@ +import ElementPlus from 'element-plus' +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import OceanBookingPrintDialog from './OceanBookingPrintDialog.vue' +import { downloadOceanBookingPrint } from '../api/platformOceanBookings' +import { ApiResponseError } from '@/shared/api/http' + +vi.mock('../api/platformOceanBookings', () => ({ downloadOceanBookingPrint: vi.fn() })) + +describe('OceanBookingPrintDialog', () => { + beforeEach(() => { + vi.clearAllMocks() + document.body.innerHTML = '' + vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:booking-print'), revokeObjectURL: vi.fn() }) + }) + + it('offers the two confirmed documents and previews the selected PDF', async () => { + vi.mocked(downloadOceanBookingPrint).mockResolvedValue(new Blob(['pdf'], { type: 'application/pdf' })) + const wrapper = mount(OceanBookingPrintDialog, { + props: { modelValue: true, bookingId: '123', bookingNo: 'OB-123', allowed: true }, + global: { plugins: [ElementPlus] }, + attachTo: document.body, + }) + await flushPromises() + + expect(document.body.textContent).toContain('出口货物托运单') + expect(document.body.textContent).toContain('提单确认件') + await wrapper.findAll('.booking-print-option')[0]!.trigger('click') + await flushPromises() + await Array.from(document.body.querySelectorAll('button')).find((button) => button.textContent?.includes('预览 PDF'))?.click() + await flushPromises() + + expect(downloadOceanBookingPrint).toHaveBeenCalledWith('123', 'SHIPPING_INSTRUCTION') + expect(document.body.querySelector('iframe')?.getAttribute('src')).toBe('blob:booking-print') + wrapper.unmount() + }) + + it('shows permission and API errors without attempting a download', async () => { + const denied = mount(OceanBookingPrintDialog, { props: { modelValue: true, bookingId: '123', allowed: false }, global: { plugins: [ElementPlus] }, attachTo: document.body }) + await flushPromises() + expect(document.body.textContent).toContain('暂无订舱单据打印权限') + expect(downloadOceanBookingPrint).not.toHaveBeenCalled() + denied.unmount() + + vi.mocked(downloadOceanBookingPrint).mockRejectedValue(new ApiResponseError('缺少提单号', 'B0409', 200, undefined, 'req-print', 'trace-print')) + const failed = mount(OceanBookingPrintDialog, { props: { modelValue: true, bookingId: '123', allowed: true }, global: { plugins: [ElementPlus] }, attachTo: document.body }) + await flushPromises() + await failed.findAll('.booking-print-option')[1]!.trigger('click') + await flushPromises() + await Array.from(document.body.querySelectorAll('button')).find((button) => button.textContent?.includes('预览 PDF'))?.click() + await flushPromises() + expect(document.body.textContent).toContain('缺少提单号') + expect(document.body.textContent).toContain('req-print') + expect(document.body.textContent).toContain('trace-print') + failed.unmount() + }) +}) diff --git a/src/modules/ocean-bookings/components/OceanBookingPrintDialog.vue b/src/modules/ocean-bookings/components/OceanBookingPrintDialog.vue new file mode 100644 index 00000000..4927085f --- /dev/null +++ b/src/modules/ocean-bookings/components/OceanBookingPrintDialog.vue @@ -0,0 +1,108 @@ + + +