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 } 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' }) }) })