149 lines
14 KiB
TypeScript
149 lines
14 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
import { createOceanBooking, deleteOceanBookingDraft, downloadOceanBookingDocument, downloadOceanBookingPrint, invalidateOceanBookingDocument, listOceanBookingAgents, listOceanBookingDocuments, listOceanBookingLinkCandidates, listOceanBookings, listWarehouseLogisticsChannels, referenceOceanBookingDocument, releaseOceanBooking, updateOceanBookingActualCargo, updateOceanBookingDraft, uploadOceanBookingDocument } from './platformOceanBookings'
|
|
import { http } from '@/shared/api/http'
|
|
|
|
const detailResponse = (version = 0, status = 'DRAFT') => ({
|
|
oceanBooking: {
|
|
id: 123, bookingNo: 'OB123', status, version, customerId: 456, customerName: '客户', shipperName: '发货企业', consigneeName: '收货企业', containerCount: '2',
|
|
},
|
|
parties: [],
|
|
containerRequirements: [{ id: 9007199254740994, oceanBookingId: 123, containerType: '40HQ', containerKind: 'FCL', quantity: 2, capacityCbm: 68, maxWeightKg: 19500 }],
|
|
containers: [{ containerBookingId: 9007199254740993, cabinetNo: 'CAB-1', containerNo: 'MSCU1234567', requirementId: 9007199254740994, allocationId: 9007199254740995, containerType: '40HQ', containerKind: 'FCL', capacityCbm: 68, maxWeightKg: 19500, 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: [],
|
|
})
|
|
|
|
describe('ocean bookings api', () => {
|
|
it('sends actual cargo only to the versioned supplement endpoint and normalizes decimal responses', async () => {
|
|
const payload = { expectedVersion: 3, actualPackageCount: 480, actualGrossWeightKg: 14000.125, actualVolumeCbm: 65.321, reason: '客服补录' }
|
|
const response = detailResponse(4, 'RELEASED')
|
|
const put = vi.spyOn(http, 'put').mockResolvedValue({ code: '200', data: {
|
|
...response, oceanBooking: { ...response.oceanBooking, actualPackageCount: 480, actualGrossWeightKg: '14000.125', actualVolumeCbm: '65.321', declaredPackageCount: 500 },
|
|
} })
|
|
const saved = await updateOceanBookingActualCargo('123', payload)
|
|
expect(put).toHaveBeenCalledWith('/platform/v1/ocean-bookings/123/actual-cargo', payload, { headers: { 'Idempotency-Key': expect.any(String) } })
|
|
expect(saved).toMatchObject({ status: 'RELEASED', version: 4, actualPackageCount: 480, actualGrossWeightKg: 14000.125, actualVolumeCbm: 65.321, declaredPackageCount: 500 })
|
|
})
|
|
|
|
it('preserves the backend history creation timestamp when normalizing a detail response', async () => {
|
|
const createdAt = '2026-10-20T09:12:34'
|
|
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: {
|
|
...detailResponse(),
|
|
histories: [{ id: '9007199254740999', fromStatus: null, toStatus: 'DRAFT', action: 'CREATE', operatorName: '创建人', createdAt }],
|
|
} })
|
|
const result = await createOceanBooking({ expectedVersion: 0, data: { customerId: null, customerName: null, channelCode: null, warehouseId: null, billOfLadingNo: '', vesselName: '', voyageNo: '', estimatedSailingAt: null, estimatedArrivalAt: null, parties: [], containerRequirements: [] } })
|
|
expect(post).toHaveBeenCalledOnce()
|
|
expect(result.histories[0]).toMatchObject({ id: '9007199254740999', action: 'CREATE', createdAt })
|
|
expect(result.statusHistory).toEqual(result.histories)
|
|
})
|
|
|
|
it('normalizes pagination and long ids', async () => {
|
|
vi.spyOn(http, 'get').mockResolvedValueOnce({ code: '200', message: 'ok', data: { records: [{ id: 123, bookingNo: 'OB-260825-0183', customerId: 456, shipperName: '发货企业', consigneeName: '收货企业', containerCount: '1' }], page: '2', size: '20', total: '21', pages: '2' } })
|
|
const result = await listOceanBookings({ page: 1, size: 10 })
|
|
expect(result.page).toBe(2)
|
|
expect(result.total).toBe(21)
|
|
expect(result.records[0]?.id).toBe('123')
|
|
expect(result.records[0]?.customerId).toBe('456')
|
|
expect(result.records[0]).toMatchObject({ bookingNo: 'OB-260825-0183', shipperName: '发货企业', consigneeName: '收货企业', containerCount: 1 })
|
|
})
|
|
|
|
it('uses the frozen single-query link candidate contract and normalizes long ids', async () => {
|
|
vi.spyOn(http, 'get').mockResolvedValueOnce({ code: '200', data: { records: [{ oceanBookingId: 9007199254740993, bookingNo: 'OB-1', billOfLadingNo: 'BL-1', requirementId: 9007199254740994, containerType: '40HQ', capacityCbm: '68', maxWeightKg: '19500', requiredQuantity: '2', allocatedQuantity: '1', remainingQuantity: '1', customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班' }], page: '1', size: '200', total: '1', pages: '1' } })
|
|
const query = { warehouseId: '8', channelGroupCode: 'MATSON_CLX', containerType: '40HQ', page: 1, size: 200 }
|
|
|
|
const result = await listOceanBookingLinkCandidates(query)
|
|
|
|
expect(http.get).toHaveBeenCalledWith('/platform/v1/ocean-bookings/link-candidates', { params: query })
|
|
expect(result.records[0]).toMatchObject({ oceanBookingId: '9007199254740992', requirementId: '9007199254740994', capacityCbm: 68, maxWeightKg: 19500, requiredQuantity: 2, allocatedQuantity: 1, remainingQuantity: 1 })
|
|
})
|
|
|
|
it('uses version zero for create and extracts the id from the returned Detail', async () => {
|
|
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: detailResponse() })
|
|
const created = await createOceanBooking({ expectedVersion: 0, data: { customerId: null, customerName: null, channelCode: null, warehouseId: null, billOfLadingNo: '', vesselName: '', voyageNo: '', estimatedSailingAt: null, estimatedArrivalAt: null, parties: [], containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', quantity: 2, capacityCbm: 68, maxWeightKg: 19500 }] } })
|
|
const sentPayload = post.mock.calls[0]?.[1] as { expectedVersion: number; data: Record<string, unknown> }
|
|
expect(sentPayload).toMatchObject({ expectedVersion: 0, data: { customerId: null, customerName: null, channelCode: null, warehouseId: null } })
|
|
expect(sentPayload).not.toMatchObject({ data: { customerId: '', customerName: '' } })
|
|
expect(sentPayload.data).toMatchObject({ containerRequirements: [{ containerType: '40HQ', quantity: 2, capacityCbm: 68, maxWeightKg: 19500 }] })
|
|
expect(sentPayload.data).not.toHaveProperty('containers')
|
|
expect(sentPayload.data).not.toHaveProperty('warehouseName')
|
|
expect(post.mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } })
|
|
expect(created.id).toBe('123')
|
|
expect(created.version).toBe(0)
|
|
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 () => {
|
|
const put = vi.spyOn(http, 'put').mockResolvedValue({ code: '200', message: 'ok', data: detailResponse(4) })
|
|
const remove = vi.spyOn(http, 'delete').mockResolvedValue({ code: '200', message: 'ok', data: null })
|
|
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: detailResponse(5, 'RELEASED') })
|
|
const data = { customerId: null, customerName: null, channelCode: null, warehouseId: null, billOfLadingNo: '', vesselName: '', voyageNo: '', estimatedSailingAt: null, estimatedArrivalAt: null, parties: [], containerRequirements: [] }
|
|
await updateOceanBookingDraft('123', { expectedVersion: 3, data })
|
|
await deleteOceanBookingDraft('123', 4)
|
|
await releaseOceanBooking('123', { expectedVersion: 4 })
|
|
expect(put).toHaveBeenCalledWith('/platform/v1/ocean-bookings/123', { expectedVersion: 3, data }, { headers: { 'Idempotency-Key': expect.any(String) } })
|
|
expect(remove).toHaveBeenCalledWith('/platform/v1/ocean-bookings/123', { data: { expectedVersion: 4 }, headers: { 'Idempotency-Key': expect.any(String) } })
|
|
expect(post).toHaveBeenCalledWith('/platform/v1/ocean-bookings/123/release', { expectedVersion: 4 }, { headers: { 'Idempotency-Key': expect.any(String) } })
|
|
})
|
|
|
|
it('loads warehouse-scoped logistics channels from the booking contract', async () => {
|
|
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: [{ channelCode: 'SEA_FCL', channelName: '海运整箱' }] })
|
|
await expect(listWarehouseLogisticsChannels('8')).resolves.toEqual([{ channelCode: 'SEA_FCL', channelName: '海运整箱' }])
|
|
expect(http.get).toHaveBeenCalledWith('/platform/v1/ocean-bookings/channels', { params: { originWarehouseId: '8' } })
|
|
})
|
|
|
|
it('loads only server-filtered booking agents for the selected carrier and preserves long ids', async () => {
|
|
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: [{ supplierId: 9007199254740993n, name: '代理A', carrierCodes: ['MATSON', 'COSCO'] }] })
|
|
await expect(listOceanBookingAgents('MATSON')).resolves.toEqual([{ supplierId: '9007199254740993', name: '代理A', carrierCodes: ['MATSON', 'COSCO'] }])
|
|
expect(http.get).toHaveBeenCalledWith('/platform/v1/ocean-bookings/booking-agents', { params: { carrierCode: 'MATSON' } })
|
|
})
|
|
|
|
it('treats an empty server candidate response as a successful empty list', async () => {
|
|
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' })
|
|
})
|
|
})
|