2026-08-10 15:36:53 +08:00
|
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
|
import { http } from '@/shared/api/http'
|
|
|
|
|
import {
|
|
|
|
|
adjustSmartLoadingItem, assignSmartLoadingItem, createSmartLoadingPlan, getContainer,
|
|
|
|
|
listContainers, listSmartLoadingCandidates, pushSmartLoadingPreload, removeSmartLoadingItem,
|
2026-08-13 11:57:44 +08:00
|
|
|
getContainerClearance, uploadContainerClearance, completeContainerClearance,
|
|
|
|
|
getContainerOverview, markContainerException, cancelContainerException, uploadContainerDocument, downloadContainerDocument, downloadContainerPdaPhoto,
|
2026-08-13 12:22:41 +08:00
|
|
|
getCustomerContainerTransitSummary,
|
2026-08-14 20:59:19 +08:00
|
|
|
createContainerSimulation, linkContainerOceanBooking, unlinkContainerOceanBooking,
|
2026-08-10 15:36:53 +08:00
|
|
|
updateContainer, updateContainerTransport,
|
|
|
|
|
} from './platformContainerLoading'
|
|
|
|
|
|
|
|
|
|
vi.mock('@/shared/api/http', () => ({
|
|
|
|
|
http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn(), delete: vi.fn() },
|
|
|
|
|
unwrapApiResponse: <T>(response: { data: T }) => response.data,
|
|
|
|
|
}))
|
|
|
|
|
|
2026-08-17 20:01:51 +08:00
|
|
|
const container = { id: 9007199254740993, planId: 9007199254740994, planNo: 'LP-001', warehouseId: 9007199254740995, shippingCompanyId: 9007199254740996, sourceType: 'SIMULATION', recordStatus: 'ACTIVE', mergedIntoContainerBookingId: null, channelCode: 'NY_EXX', channelName: '纽约EXX海铁', exceptionActive: true, destinationClearanceDocumentStatus: 'COMPLETED', customsDeclarationDocumentStatus: 'UPLOADED', deliveryOrderDocumentStatus: 'UPLOADED', pickupOrderDocumentStatus: 'MISSING', version: '2', capacityCbm: '76', maxWeightKg: '28000' }
|
2026-08-17 16:45:46 +08:00
|
|
|
const plan = {
|
|
|
|
|
id: 9007199254740993, bookingId: 9007199254740994, version: '3', status: 'LOADING',
|
|
|
|
|
canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED',
|
|
|
|
|
pdaTaskId: 9007199254740996, pdaTaskStatus: 'LOADING', pdaTaskVersion: '4',
|
|
|
|
|
items: [{ id: 9007199254740995, loadingPlanId: 9007199254740993, accountId: 1, inboundId: 2, inboundDetailId: 3, orderId: 4, destinationWarehouseId: 5, packageCount: '6', version: '1', pickedPackageCount: '2', minPackageCount: '2', canAdjust: true, canRemove: false }],
|
|
|
|
|
}
|
2026-08-10 15:36:53 +08:00
|
|
|
|
|
|
|
|
describe('platform container loading API', () => {
|
|
|
|
|
beforeEach(() => vi.clearAllMocks())
|
|
|
|
|
|
2026-08-13 12:22:41 +08:00
|
|
|
it('uses the customer transit summary and preserves fractional TEU', async () => {
|
|
|
|
|
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { customerId: '9007199254740993', containerCount: 2, totalTeu: '2.25', unknownContainerCount: 0, asOf: '2026-08-13T12:00:00', definition: '已开船未到仓' } })
|
|
|
|
|
|
|
|
|
|
const result = await getCustomerContainerTransitSummary('9007199254740993')
|
|
|
|
|
|
|
|
|
|
expect(http.get).toHaveBeenCalledWith('/platform/v1/customers/9007199254740993/container-transit-summary')
|
|
|
|
|
expect(result).toMatchObject({ customerId: '9007199254740993', containerCount: 2, totalTeu: '2.25', unknownContainerCount: 0 })
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-10 15:36:53 +08:00
|
|
|
it('normalizes paged container ids and numeric metadata', async () => {
|
|
|
|
|
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { records: [container], page: '1', size: '20', total: '1', pages: '1' } })
|
|
|
|
|
const result = await listContainers({ page: 1, size: 20 })
|
2026-08-17 20:01:51 +08:00
|
|
|
expect(result.records[0]).toMatchObject({ id: String(container.id), planId: String(container.planId), planNo: 'LP-001', warehouseId: String(container.warehouseId), channelCode: 'NY_EXX', channelName: '纽约EXX海铁', exceptionActive: true, destinationClearanceDocumentStatus: 'COMPLETED', customsDeclarationDocumentStatus: 'UPLOADED', deliveryOrderDocumentStatus: 'UPLOADED', pickupOrderDocumentStatus: 'MISSING', version: 2, capacityCbm: 76 })
|
2026-08-10 15:36:53 +08:00
|
|
|
expect(http.get).toHaveBeenCalledWith('/platform/v1/containers', { params: { page: 1, size: 20 } })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('uses full-edit and transport whitelist request shapes', async () => {
|
|
|
|
|
vi.mocked(http.put).mockResolvedValue({ code: '200', data: container })
|
|
|
|
|
vi.mocked(http.patch).mockResolvedValue({ code: '200', data: container })
|
2026-08-19 09:42:12 +08:00
|
|
|
const data = { warehouseId: '8', warehouseName: '义乌仓', containerType: '40HC', containerNo: null, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null, destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }
|
2026-08-10 15:36:53 +08:00
|
|
|
await updateContainer('7', 2, data)
|
|
|
|
|
await updateContainerTransport('7', 3, { containerNo: 'MSCU1', voyageNo: 'V8' })
|
|
|
|
|
expect(http.put).toHaveBeenCalledWith('/platform/v1/containers/7', { version: 2, data })
|
|
|
|
|
expect(http.patch).toHaveBeenCalledWith('/platform/v1/containers/7/transport', { version: 3, containerNo: 'MSCU1', voyageNo: 'V8' })
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-14 20:59:19 +08:00
|
|
|
it('uses the frozen simulation create and ocean booking link contracts', async () => {
|
|
|
|
|
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { ...container, oceanBookingId: 101, oceanBookingRequirementId: 202 } })
|
|
|
|
|
vi.mocked(http.delete).mockResolvedValue({ code: '200', data: { ...container, oceanBookingId: null, oceanBookingRequirementId: null } })
|
2026-08-17 12:31:27 +08:00
|
|
|
await createContainerSimulation({ channelCode: 'NY_EXX', warehouseId: '8', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 })
|
2026-08-18 16:09:43 +08:00
|
|
|
await linkContainerOceanBooking('7', { oceanBookingId: '101', requirementId: '202', containerNo: 'MSCU1234567', expectedVersion: 3 })
|
2026-08-14 20:59:19 +08:00
|
|
|
await unlinkContainerOceanBooking('7', 4, '空柜改配')
|
2026-08-17 12:31:27 +08:00
|
|
|
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/containers', { channelCode: 'NY_EXX', warehouseId: '8', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 })
|
2026-08-18 16:09:43 +08:00
|
|
|
expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/containers/7/ocean-booking-link', { oceanBookingId: '101', requirementId: '202', containerNo: 'MSCU1234567', expectedVersion: 3 })
|
2026-08-14 20:59:19 +08:00
|
|
|
expect(http.delete).toHaveBeenCalledWith('/platform/v1/containers/7/ocean-booking-link', { data: { expectedVersion: 4, reason: '空柜改配' } })
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-10 15:36:53 +08:00
|
|
|
it('normalizes every long id in candidates and plans', async () => {
|
|
|
|
|
vi.mocked(http.get)
|
|
|
|
|
.mockResolvedValueOnce({ code: '200', data: container })
|
2026-08-14 19:09:54 +08:00
|
|
|
.mockResolvedValueOnce({ code: '200', data: [{ accountId: 1, inboundId: 2, inboundDetailId: 3, orderId: 4, warehouseId: 5, destinationWarehouseId: 6, channelCode: 'NY_EXX', channelName: '纽约EXX海铁', availablePackageCount: '7' }] })
|
2026-08-10 15:36:53 +08:00
|
|
|
expect((await getContainer('9')).id).toBe(String(container.id))
|
2026-08-14 19:09:54 +08:00
|
|
|
expect(await listSmartLoadingCandidates('7', '5')).toMatchObject([{ accountId: '1', inboundDetailId: '3', destinationWarehouseId: '6', channelCode: 'NY_EXX', channelName: '纽约EXX海铁', availablePackageCount: 7 }])
|
2026-08-12 18:43:54 +08:00
|
|
|
expect(http.get).toHaveBeenLastCalledWith('/platform/v1/first-leg/smart-loading/candidates', { params: { bookingId: '7', warehouseId: '5' } })
|
2026-08-10 15:36:53 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('sends plan and item versions plus operation and idempotency keys', async () => {
|
|
|
|
|
vi.mocked(http.post).mockResolvedValue({ code: '200', data: plan })
|
|
|
|
|
vi.mocked(http.patch).mockResolvedValue({ code: '200', data: plan })
|
|
|
|
|
vi.mocked(http.delete).mockResolvedValue({ code: '200', data: plan })
|
2026-08-17 16:45:46 +08:00
|
|
|
const normalized = await createSmartLoadingPlan('7', null)
|
2026-08-10 15:36:53 +08:00
|
|
|
await assignSmartLoadingItem('10', { inboundId: '2', inboundDetailId: '3', packageCount: 4, expectedVersion: 5 })
|
|
|
|
|
await adjustSmartLoadingItem('10', '11', 6, 7, 8)
|
|
|
|
|
await removeSmartLoadingItem('10', '11', 9, 10)
|
|
|
|
|
await pushSmartLoadingPreload('10', 11)
|
|
|
|
|
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/first-leg/smart-loading/plans', { bookingId: '7', remark: null })
|
2026-08-17 16:45:46 +08:00
|
|
|
expect(normalized).toMatchObject({ id: String(plan.id), version: 3, canAssign: true, canEdit: true, pdaTaskId: String(plan.pdaTaskId), pdaTaskVersion: 4, items: [{ version: 1, pickedPackageCount: 2, minPackageCount: 2, canAdjust: true, canRemove: false }] })
|
2026-08-10 15:36:53 +08:00
|
|
|
expect(vi.mocked(http.post).mock.calls[1]?.[1]).toMatchObject({ expectedVersion: 5, operationId: expect.stringContaining('assign-') })
|
|
|
|
|
expect(vi.mocked(http.patch).mock.calls[0]?.[1]).toMatchObject({ expectedPlanVersion: 7, expectedItemVersion: 8, operationId: expect.stringContaining('adjust-') })
|
|
|
|
|
expect(vi.mocked(http.delete).mock.calls[0]?.[1]).toMatchObject({ data: { expectedPlanVersion: 9, expectedItemVersion: 10, operationId: expect.stringContaining('remove-') } })
|
|
|
|
|
expect(vi.mocked(http.post).mock.calls[2]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.stringContaining('push-preload-') } })
|
|
|
|
|
})
|
2026-08-13 11:57:44 +08:00
|
|
|
|
|
|
|
|
it('uses stable container id and versioned clearance document actions', async () => {
|
|
|
|
|
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: { id: 4, documentType: 'CUSTOMS_CLEARANCE', fileId: 8, fileName: 'clearance.pdf', version: 2 } })
|
|
|
|
|
vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: { id: 4, documentType: 'CUSTOMS_CLEARANCE', fileId: 8, fileName: 'clearance.pdf', version: 3 } })
|
|
|
|
|
expect((await getContainerClearance('7'))?.fileId).toBe('8')
|
|
|
|
|
const file = new File(['clearance'], 'clearance.pdf', { type: 'application/pdf' })
|
|
|
|
|
await uploadContainerClearance('7', file, 2, '上传资料')
|
|
|
|
|
await completeContainerClearance('7', 3, '完成清关')
|
|
|
|
|
expect(vi.mocked(http.get)).toHaveBeenCalledWith('/platform/v1/containers/7/clearance')
|
|
|
|
|
expect(vi.mocked(http.post).mock.calls[0]?.[0]).toBe('/platform/v1/containers/7/clearance/document/upload')
|
|
|
|
|
const body = vi.mocked(http.post).mock.calls[0]?.[1] as FormData
|
|
|
|
|
expect(body.get('file')).toBe(file)
|
|
|
|
|
expect(body.get('expectedVersion')).toBe('2')
|
|
|
|
|
expect(body.get('operationReason')).toBe('上传资料')
|
|
|
|
|
expect(vi.mocked(http.post).mock.calls[0]?.[2]).toMatchObject({ headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': expect.any(String) } })
|
|
|
|
|
expect(vi.mocked(http.post).mock.calls[1]?.[0]).toBe('/platform/v1/containers/7/clearance/complete')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('normalizes aggregate ids and uses the frozen overview and exception contracts', async () => {
|
|
|
|
|
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: {
|
|
|
|
|
container: { id: 9007199254740993, warehouseId: 9007199254740994, version: '4' }, loadingPlanId: 9007199254740995,
|
2026-08-14 14:50:44 +08:00
|
|
|
pda: { taskId: 9007199254740996, loadingPlanId: 9007199254740995, containerBookingId: 9007199254740993, taskVersion: '2', itemCount: '4', completedItemCount: '3', unresolvedItemCount: '1', photoCounts: [{ evidenceType: 'EMPTY_CONTAINER', count: '2' }], photos: [{ evidenceId: 20, tenantId: 21, inboundId: 22, fileId: 23, downloadPath: '/api/platform/v1/containers/9007199254740993/pda/photos/23/download' }], exceptionReasons: [] },
|
2026-08-13 11:57:44 +08:00
|
|
|
documents: [{ id: 9007199254740997, documentType: 'DELIVERY_ORDER', fileId: 9007199254740998, uploadedBy: 9, version: '2' }],
|
|
|
|
|
exception: { active: false, markedBy: null }, exceptionHistory: [{ id: 10, operatorId: 11 }], timeline: [{ operatorId: 12 }],
|
|
|
|
|
} })
|
|
|
|
|
const result = await getContainerOverview('7')
|
|
|
|
|
expect(result).toMatchObject({ container: { id: expect.any(String), warehouseId: expect.any(String), version: 4 }, loadingPlanId: expect.any(String), pda: { taskId: expect.any(String), itemCount: 4, photoCounts: [{ count: 2 }] }, documents: [{ fileId: expect.any(String), version: 2 }] })
|
|
|
|
|
expect(http.get).toHaveBeenCalledWith('/platform/v1/containers/7/overview')
|
|
|
|
|
|
|
|
|
|
vi.mocked(http.post).mockResolvedValueOnce({ code: '200', data: null })
|
|
|
|
|
vi.mocked(http.delete).mockResolvedValueOnce({ code: '200', data: null })
|
|
|
|
|
await markContainerException('7', 2, '查验')
|
|
|
|
|
await cancelContainerException('7', 3, '已解除')
|
|
|
|
|
expect(http.post).toHaveBeenCalledWith('/platform/v1/containers/7/exception', { expectedVersion: 2, reason: '查验' }, { headers: { 'Idempotency-Key': expect.stringContaining('container-exception-mark-') } })
|
|
|
|
|
expect(http.delete).toHaveBeenCalledWith('/platform/v1/containers/7/exception', { data: { expectedVersion: 3, reason: '已解除' }, headers: { 'Idempotency-Key': expect.stringContaining('container-exception-cancel-') } })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('uploads only DO or PU through the logistics endpoint and downloads through the protected proxy', async () => {
|
|
|
|
|
vi.mocked(http.post).mockResolvedValueOnce({ code: '200', data: { id: 1, documentType: 'DELIVERY_ORDER', fileId: 2, fileName: 'do.pdf', version: 1 } })
|
|
|
|
|
vi.mocked(http.get).mockResolvedValueOnce(new Blob(['do']))
|
|
|
|
|
const file = new File(['do'], 'do.pdf')
|
|
|
|
|
await uploadContainerDocument('7', 'DELIVERY_ORDER', file, 0, '上传DO')
|
|
|
|
|
await downloadContainerDocument('7', 'DELIVERY_ORDER', '2')
|
|
|
|
|
expect(vi.mocked(http.post).mock.calls[0]?.[0]).toBe('/platform/v1/containers/7/documents/DELIVERY_ORDER/upload')
|
|
|
|
|
expect(http.get).toHaveBeenCalledWith('/platform/v1/containers/7/documents/DELIVERY_ORDER/2/download', { responseType: 'blob' })
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-14 14:50:44 +08:00
|
|
|
it('downloads PDA photos only from the backend-provided protected container path', async () => {
|
2026-08-13 11:57:44 +08:00
|
|
|
vi.mocked(http.get).mockResolvedValueOnce(new Blob(['photo']))
|
2026-08-14 14:50:44 +08:00
|
|
|
await downloadContainerPdaPhoto('/api/platform/v1/containers/7/pda/photos/23/download')
|
|
|
|
|
expect(http.get).toHaveBeenCalledWith('/api/platform/v1/containers/7/pda/photos/23/download', { responseType: 'blob' })
|
|
|
|
|
expect(() => downloadContainerPdaPhoto('/api/platform/v1/files/loading-evidence/21/22/23')).toThrow('invalid container photo path')
|
2026-08-13 11:57:44 +08:00
|
|
|
expect(() => downloadContainerPdaPhoto('https://example.com/photo.jpg')).toThrow('invalid container photo path')
|
|
|
|
|
})
|
2026-08-10 15:36:53 +08:00
|
|
|
})
|