This commit is contained in:
2026-08-10 15:36:53 +08:00
parent 5d97c7517a
commit f6556c57b5
54 changed files with 4107 additions and 170 deletions
@@ -0,0 +1,60 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import {
adjustSmartLoadingItem, assignSmartLoadingItem, createSmartLoadingPlan, getContainer,
listContainers, listSmartLoadingCandidates, pushSmartLoadingPreload, removeSmartLoadingItem,
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,
}))
const container = { id: 9007199254740993, planId: 9007199254740994, warehouseId: 9007199254740995, shippingCompanyId: 9007199254740996, version: '2', capacityCbm: '68', maxWeightKg: '28000' }
const plan = { id: 9007199254740993, bookingId: 9007199254740994, version: '3', status: 'EDITING', items: [{ id: 9007199254740995, loadingPlanId: 9007199254740993, accountId: 1, inboundId: 2, inboundDetailId: 3, orderId: 4, destinationWarehouseId: 5, packageCount: '6', version: '1' }] }
describe('platform container loading API', () => {
beforeEach(() => vi.clearAllMocks())
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 })
expect(result.records[0]).toMatchObject({ id: String(container.id), planId: String(container.planId), warehouseId: String(container.warehouseId), version: 2, capacityCbm: 68 })
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 })
const data = { cabinetNo: 'CB-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', 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: null, maxWeightKg: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }
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' })
})
it('normalizes every long id in candidates and plans', async () => {
vi.mocked(http.get)
.mockResolvedValueOnce({ code: '200', data: container })
.mockResolvedValueOnce({ code: '200', data: [{ accountId: 1, inboundId: 2, inboundDetailId: 3, orderId: 4, warehouseId: 5, destinationWarehouseId: 6, availablePackageCount: '7' }] })
expect((await getContainer('9')).id).toBe(String(container.id))
expect(await listSmartLoadingCandidates('5')).toMatchObject([{ accountId: '1', inboundDetailId: '3', destinationWarehouseId: '6', availablePackageCount: 7 }])
})
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 })
await createSmartLoadingPlan('7', null)
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 })
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-') } })
})
})
@@ -0,0 +1,128 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type {
ContainerBookingResource,
ContainerPage,
ContainerQuery,
ContainerSaveRequest,
SmartLoadingCandidate,
SmartLoadingItem,
SmartLoadingPlan,
} from '../types'
const containerPath = '/platform/v1/containers'
const smartLoadingPath = '/platform/v1/first-leg/smart-loading'
function id(value: unknown): string { return value == null ? '' : String(value) }
function optionalId(value: unknown): string | null { return value == null ? null : String(value) }
function numberValue(value: unknown, fallback = 0): number {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : fallback
}
function operationId(prefix: string) { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}` }
function container(raw: ContainerBookingResource): ContainerBookingResource {
return {
...raw,
id: id(raw.id),
planId: optionalId(raw.planId),
warehouseId: id(raw.warehouseId),
shippingCompanyId: optionalId(raw.shippingCompanyId),
version: numberValue(raw.version),
capacityCbm: raw.capacityCbm == null ? null : numberValue(raw.capacityCbm),
maxWeightKg: raw.maxWeightKg == null ? null : numberValue(raw.maxWeightKg),
}
}
function item(raw: SmartLoadingItem): SmartLoadingItem {
return {
...raw,
id: id(raw.id), loadingPlanId: id(raw.loadingPlanId), accountId: id(raw.accountId),
inboundId: id(raw.inboundId), inboundDetailId: id(raw.inboundDetailId), orderId: id(raw.orderId),
destinationWarehouseId: optionalId(raw.destinationWarehouseId), version: numberValue(raw.version),
packageCount: numberValue(raw.packageCount),
productQuantity: raw.productQuantity == null ? null : numberValue(raw.productQuantity),
grossWeightKg: raw.grossWeightKg == null ? null : numberValue(raw.grossWeightKg),
volumeCbm: raw.volumeCbm == null ? null : numberValue(raw.volumeCbm),
}
}
function plan(raw: SmartLoadingPlan): SmartLoadingPlan {
return { ...raw, id: id(raw.id), bookingId: id(raw.bookingId), version: numberValue(raw.version), items: (raw.items || []).map(item) }
}
export async function listContainers(query: ContainerQuery): Promise<ContainerPage> {
const response = await http.get<ApiEnvelope<ContainerPage>, ApiEnvelope<ContainerPage>>(containerPath, { params: query })
const result = unwrapApiResponse(response)
return { ...result, records: (result.records || []).map(container), page: numberValue(result.page, query.page), size: numberValue(result.size, query.size), total: numberValue(result.total), pages: numberValue(result.pages) }
}
export async function getContainer(containerId: string) {
const response = await http.get<ApiEnvelope<ContainerBookingResource>, ApiEnvelope<ContainerBookingResource>>(`${containerPath}/${encodeURIComponent(containerId)}`)
return container(unwrapApiResponse(response))
}
export async function createContainer(payload: ContainerSaveRequest) {
const response = await http.post<ApiEnvelope<ContainerBookingResource>, ApiEnvelope<ContainerBookingResource>>(containerPath, payload)
return container(unwrapApiResponse(response))
}
export async function updateContainer(containerId: string, version: number, data: ContainerSaveRequest) {
const response = await http.put<ApiEnvelope<ContainerBookingResource>, ApiEnvelope<ContainerBookingResource>>(`${containerPath}/${encodeURIComponent(containerId)}`, { version, data })
return container(unwrapApiResponse(response))
}
export async function updateContainerTransport(containerId: string, version: number, payload: { containerNo: string | null; voyageNo: string | null }) {
const response = await http.patch<ApiEnvelope<ContainerBookingResource>, ApiEnvelope<ContainerBookingResource>>(`${containerPath}/${encodeURIComponent(containerId)}/transport`, { ...payload, version })
return container(unwrapApiResponse(response))
}
export async function listSmartLoadingCandidates(warehouseId: string) {
const response = await http.get<ApiEnvelope<SmartLoadingCandidate[]>, ApiEnvelope<SmartLoadingCandidate[]>>(`${smartLoadingPath}/candidates`, { params: { warehouseId } })
return unwrapApiResponse(response).map((raw) => ({
...raw, accountId: id(raw.accountId), inboundId: id(raw.inboundId), inboundDetailId: id(raw.inboundDetailId),
orderId: id(raw.orderId), warehouseId: id(raw.warehouseId), destinationWarehouseId: optionalId(raw.destinationWarehouseId),
availablePackageCount: numberValue(raw.availablePackageCount),
unitWeightKg: raw.unitWeightKg == null ? null : numberValue(raw.unitWeightKg),
unitVolumeCbm: raw.unitVolumeCbm == null ? null : numberValue(raw.unitVolumeCbm),
}))
}
export async function getSmartLoadingPlanByBooking(bookingId: string) {
const response = await http.get<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(`${smartLoadingPath}/plans/by-booking/${encodeURIComponent(bookingId)}`)
return plan(unwrapApiResponse(response))
}
export async function getSmartLoadingPlan(planId: string) {
const response = await http.get<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(`${smartLoadingPath}/plans/${encodeURIComponent(planId)}`)
return plan(unwrapApiResponse(response))
}
export async function createSmartLoadingPlan(bookingId: string, remark: string | null) {
const response = await http.post<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(`${smartLoadingPath}/plans`, { bookingId, remark })
return plan(unwrapApiResponse(response))
}
export async function assignSmartLoadingItem(planId: string, payload: { inboundId: string; inboundDetailId: string; packageCount: number; expectedVersion: number }) {
const response = await http.post<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/assignments`, { ...payload, operationId: operationId('assign') })
return plan(unwrapApiResponse(response))
}
export async function recommendSmartLoading(planId: string, expectedVersion: number) {
const response = await http.post<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/recommend`, { expectedVersion, operationId: operationId('recommend') })
return plan(unwrapApiResponse(response))
}
export async function adjustSmartLoadingItem(planId: string, itemId: string, packageCount: number, expectedPlanVersion: number, expectedItemVersion: number) {
const response = await http.patch<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/items/${encodeURIComponent(itemId)}`, { packageCount, expectedPlanVersion, expectedItemVersion, operationId: operationId('adjust') })
return plan(unwrapApiResponse(response))
}
export async function removeSmartLoadingItem(planId: string, itemId: string, expectedPlanVersion: number, expectedItemVersion: number) {
const response = await http.delete<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/items/${encodeURIComponent(itemId)}`, { data: { expectedPlanVersion, expectedItemVersion, operationId: operationId('remove') } })
return plan(unwrapApiResponse(response))
}
export async function pushSmartLoadingPreload(planId: string, expectedVersion: number) {
const response = await http.post<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/push-preload`, { expectedVersion }, { headers: { 'Idempotency-Key': operationId('push-preload') } })
return plan(unwrapApiResponse(response))
}