feat(admin): bind booking documents and truck containers
This commit is contained in:
@@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${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<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${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<Blob, Blob>(`${basePath}/${encodeURIComponent(id)}/print`, { params: { type }, responseType: 'blob' }) }
|
||||
const printPaths: Record<OceanBookingPrintType, string> = {
|
||||
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<ApiEnvelope<unknown>>
|
||||
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<Blob, Blob>(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<OceanBookingDocument[]>, ApiEnvelope<OceanBookingDocument[]>>(`${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<OceanBookingDocumentMutation>, ApiEnvelope<OceanBookingDocumentMutation>>(`${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<OceanBookingDocumentMutation>, ApiEnvelope<OceanBookingDocumentMutation>>(`${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<OceanBookingDocumentInvalidation>, ApiEnvelope<OceanBookingDocumentInvalidation>>(`${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<OceanLogisticsChannel[]> {
|
||||
const response = await http.get<ApiEnvelope<OceanLogisticsChannel[]>, ApiEnvelope<OceanLogisticsChannel[]>>(`${basePath}/channels`, { params: { originWarehouseId: warehouseId } })
|
||||
return (unwrapApiResponse(response) || []).map((item) => ({ channelCode: String(item.channelCode), channelName: String(item.channelName) }))
|
||||
|
||||
Reference in New Issue
Block a user