This commit is contained in:
2026-08-13 11:57:44 +08:00
parent 7beca3f985
commit 835ac0ad12
87 changed files with 3721 additions and 295 deletions
@@ -3,6 +3,8 @@ import { http } from '@/shared/api/http'
import {
adjustSmartLoadingItem, assignSmartLoadingItem, createSmartLoadingPlan, getContainer,
listContainers, listSmartLoadingCandidates, pushSmartLoadingPreload, removeSmartLoadingItem,
getContainerClearance, uploadContainerClearance, completeContainerClearance,
getContainerOverview, markContainerException, cancelContainerException, uploadContainerDocument, downloadContainerDocument, downloadContainerPdaPhoto,
updateContainer, updateContainerTransport,
} from './platformContainerLoading'
@@ -58,4 +60,57 @@ describe('platform container loading API', () => {
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-') } })
})
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,
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/files/loading-evidence/21/22/23' }], exceptionReasons: [] },
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' })
})
it('downloads PDA photos only from the backend-provided protected evidence path', async () => {
vi.mocked(http.get).mockResolvedValueOnce(new Blob(['photo']))
await downloadContainerPdaPhoto('/api/platform/v1/files/loading-evidence/21/22/23')
expect(http.get).toHaveBeenCalledWith('/api/platform/v1/files/loading-evidence/21/22/23', { responseType: 'blob' })
expect(() => downloadContainerPdaPhoto('https://example.com/photo.jpg')).toThrow('invalid container photo path')
})
})
@@ -7,6 +7,10 @@ import type {
SmartLoadingCandidate,
SmartLoadingItem,
SmartLoadingPlan,
BusinessDocument,
ContainerDocumentType,
ContainerOverview,
ContainerOverviewDocument,
} from '../types'
const containerPath = '/platform/v1/containers'
@@ -19,6 +23,35 @@ function numberValue(value: unknown, fallback = 0): number {
return Number.isFinite(parsed) ? parsed : fallback
}
function operationId(prefix: string) { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}` }
function document(raw: BusinessDocument | null | undefined): BusinessDocument | null {
if (!raw) return null
return { ...raw, id: raw.id == null ? null : id(raw.id), fileId: id(raw.fileId), uploadedBy: raw.uploadedBy == null ? null : id(raw.uploadedBy), completedBy: raw.completedBy == null ? null : id(raw.completedBy), version: numberValue(raw.version) }
}
function overviewDocument(raw: ContainerOverviewDocument): ContainerOverviewDocument {
return { ...raw, id: id(raw.id), fileId: id(raw.fileId), uploadedBy: optionalId(raw.uploadedBy), version: numberValue(raw.version) }
}
function overview(raw: ContainerOverview): ContainerOverview {
return {
...raw,
container: { ...raw.container, id: id(raw.container.id), warehouseId: id(raw.container.warehouseId), version: numberValue(raw.container.version) },
loadingPlanId: optionalId(raw.loadingPlanId),
pda: raw.pda ? {
...raw.pda, taskId: id(raw.pda.taskId), loadingPlanId: id(raw.pda.loadingPlanId),
containerBookingId: id(raw.pda.containerBookingId), taskVersion: numberValue(raw.pda.taskVersion),
itemCount: numberValue(raw.pda.itemCount), completedItemCount: numberValue(raw.pda.completedItemCount),
unresolvedItemCount: numberValue(raw.pda.unresolvedItemCount),
photoCounts: (raw.pda.photoCounts || []).map((item) => ({ ...item, count: numberValue(item.count) })),
photos: (raw.pda.photos || []).map((item) => ({ ...item, evidenceId: id(item.evidenceId), tenantId: id(item.tenantId), inboundId: id(item.inboundId), fileId: id(item.fileId) })),
exceptionReasons: raw.pda.exceptionReasons || [],
} : null,
documents: (raw.documents || []).map(overviewDocument),
exception: { ...raw.exception, markedBy: optionalId(raw.exception?.markedBy) },
exceptionHistory: (raw.exceptionHistory || []).map((entry) => ({ ...entry, id: id(entry.id), operatorId: optionalId(entry.operatorId) })),
timeline: (raw.timeline || []).map((entry) => ({ ...entry, operatorId: optionalId(entry.operatorId) })),
}
}
function container(raw: ContainerBookingResource): ContainerBookingResource {
return {
@@ -30,6 +63,7 @@ function container(raw: ContainerBookingResource): ContainerBookingResource {
version: numberValue(raw.version),
capacityCbm: raw.capacityCbm == null ? null : numberValue(raw.capacityCbm),
maxWeightKg: raw.maxWeightKg == null ? null : numberValue(raw.maxWeightKg),
clearanceDocument: document(raw.clearanceDocument),
}
}
@@ -61,6 +95,39 @@ export async function getContainer(containerId: string) {
return container(unwrapApiResponse(response))
}
export async function getContainerOverview(containerId: string) {
const response = await http.get<ApiEnvelope<ContainerOverview>, ApiEnvelope<ContainerOverview>>(`${containerPath}/${encodeURIComponent(containerId)}/overview`)
return overview(unwrapApiResponse(response))
}
export async function markContainerException(containerId: string, expectedVersion: number, reason: string) {
const response = await http.post<ApiEnvelope<null>, ApiEnvelope<null>>(`${containerPath}/${encodeURIComponent(containerId)}/exception`, { expectedVersion, reason }, { headers: { 'Idempotency-Key': operationId('container-exception-mark') } })
unwrapApiResponse(response)
}
export async function cancelContainerException(containerId: string, expectedVersion: number, reason: string) {
const response = await http.delete<ApiEnvelope<null>, ApiEnvelope<null>>(`${containerPath}/${encodeURIComponent(containerId)}/exception`, { data: { expectedVersion, reason }, headers: { 'Idempotency-Key': operationId('container-exception-cancel') } })
unwrapApiResponse(response)
}
export async function uploadContainerDocument(containerId: string, documentType: Extract<ContainerDocumentType, 'DELIVERY_ORDER' | 'PICKUP_ORDER'>, file: File, expectedVersion: number, operationReason?: string | null) {
const body = new FormData()
body.append('file', file)
body.append('expectedVersion', String(expectedVersion))
if (operationReason) body.append('operationReason', operationReason)
const response = await http.post<ApiEnvelope<BusinessDocument>, ApiEnvelope<BusinessDocument>>(`${containerPath}/${encodeURIComponent(containerId)}/documents/${documentType}/upload`, body, { headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': operationId(`container-${documentType.toLowerCase()}-upload`) } })
return document(unwrapApiResponse(response))
}
export function downloadContainerDocument(containerId: string, documentType: Extract<ContainerDocumentType, 'DELIVERY_ORDER' | 'PICKUP_ORDER'>, fileId: string) {
return http.get<Blob, Blob>(`${containerPath}/${encodeURIComponent(containerId)}/documents/${documentType}/${encodeURIComponent(fileId)}/download`, { responseType: 'blob' })
}
export function downloadContainerPdaPhoto(downloadPath: string) {
if (!downloadPath.startsWith('/api/platform/v1/files/loading-evidence/')) throw new Error('invalid container photo path')
return http.get<Blob, Blob>(downloadPath, { responseType: 'blob' })
}
export async function createContainer(payload: ContainerSaveRequest) {
const response = await http.post<ApiEnvelope<ContainerBookingResource>, ApiEnvelope<ContainerBookingResource>>(containerPath, payload)
return container(unwrapApiResponse(response))
@@ -76,6 +143,25 @@ export async function updateContainerTransport(containerId: string, version: num
return container(unwrapApiResponse(response))
}
export async function getContainerClearance(containerId: string) {
const response = await http.get<ApiEnvelope<BusinessDocument | null>, ApiEnvelope<BusinessDocument | null>>(`${containerPath}/${encodeURIComponent(containerId)}/clearance`)
return document(unwrapApiResponse(response))
}
export async function uploadContainerClearance(containerId: string, file: File, expectedVersion: number, operationReason?: string | null) {
const body = new FormData()
body.append('file', file)
body.append('expectedVersion', String(expectedVersion))
if (operationReason) body.append('operationReason', operationReason)
const response = await http.post<ApiEnvelope<BusinessDocument>, ApiEnvelope<BusinessDocument>>(`${containerPath}/${encodeURIComponent(containerId)}/clearance/document/upload`, body, { headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': operationId('container-clearance-upload') } })
return document(unwrapApiResponse(response))
}
export async function completeContainerClearance(containerId: string, expectedVersion: number, operationReason?: string | null) {
const response = await http.post<ApiEnvelope<BusinessDocument>, ApiEnvelope<BusinessDocument>>(`${containerPath}/${encodeURIComponent(containerId)}/clearance/complete`, { expectedVersion, operationReason }, { headers: { 'Idempotency-Key': operationId('container-clearance-complete') } })
return document(unwrapApiResponse(response))
}
export async function listSmartLoadingCandidates(bookingId: string, warehouseId: string) {
const response = await http.get<ApiEnvelope<SmartLoadingCandidate[]>, ApiEnvelope<SmartLoadingCandidate[]>>(`${smartLoadingPath}/candidates`, { params: { bookingId, warehouseId } })
return unwrapApiResponse(response).map((raw) => ({