feat: add tail order controls and terminal drayage UI
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import {
|
||||
getTerminalDrayageHistory,
|
||||
listTerminalDrayages,
|
||||
recordTerminalDrayageEmptyReturn,
|
||||
recordTerminalDrayagePickup,
|
||||
scheduleTerminalDrayagePickup,
|
||||
selectTerminalDrayageFleet,
|
||||
} from './terminalDrayages'
|
||||
|
||||
vi.mock('@/shared/api/http', () => ({
|
||||
http: { get: vi.fn(), post: vi.fn() },
|
||||
unwrapApiResponse: <T>(response: { data: T }) => response.data,
|
||||
}))
|
||||
|
||||
const view = {
|
||||
containerBookingId: '9007199254740993', containerNo: 'MSCU1234567', oceanBookingId: '9007199254740994',
|
||||
status: 'UNLOADED', version: '3', fleetSupplierId: '9007199254740995', fleetSupplierName: '港运车队',
|
||||
pickupAppointmentAt: '2026-09-07T08:00:00', actualPickupAt: '2026-09-07T09:00:00', estimatedEmptyReturnAt: '2026-09-10T10:00:00',
|
||||
actualEmptyReturnAt: null, unloadingCompletedAt: '2026-09-07T10:00:00',
|
||||
emptyReturnWarning: { status: 'OVERDUE', deadlineAt: '2026-09-10T10:00:00', overdueHours: '26', overdueDays: '1' },
|
||||
allowedActions: ['SELECT_FLEET', 'RECORD_EMPTY_RETURN'], updatedAt: '2026-09-11T12:00:00',
|
||||
}
|
||||
|
||||
describe('terminal drayages API', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('sends repeated container ids and preserves long identifiers as strings', async () => {
|
||||
vi.mocked(http.get).mockResolvedValueOnce({ data: [view] })
|
||||
const result = await listTerminalDrayages(['9007199254740993', '9007199254740994'])
|
||||
const config = vi.mocked(http.get).mock.calls[0][1] as { params: URLSearchParams }
|
||||
expect(config.params.getAll('containerBookingIds')).toEqual(['9007199254740993', '9007199254740994'])
|
||||
expect(result[0]).toMatchObject({ containerBookingId: '9007199254740993', oceanBookingId: '9007199254740994', fleetSupplierId: '9007199254740995', version: 3 })
|
||||
expect(result[0].emptyReturnWarning).toMatchObject({ overdueHours: 26, overdueDays: 1 })
|
||||
})
|
||||
|
||||
it('uses frozen command paths, versions and idempotency keys', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ data: view })
|
||||
await selectTerminalDrayageFleet('101', { expectedVersion: 3, fleetSupplierId: '501', fleetSupplierName: '港运车队' })
|
||||
await scheduleTerminalDrayagePickup('101', { expectedVersion: 4, pickupAppointmentAt: '2026-09-07T08:00:00' })
|
||||
await recordTerminalDrayagePickup('101', { expectedVersion: 5, actualAt: '2026-09-07T09:00:00' })
|
||||
await recordTerminalDrayageEmptyReturn('101', { expectedVersion: 6, actualAt: '2026-09-10T09:00:00' })
|
||||
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/terminal-drayages/101/fleet', expect.objectContaining({ expectedVersion: 3, fleetSupplierId: '501' }), expect.objectContaining({ headers: { 'Idempotency-Key': expect.stringContaining('terminal-drayage-fleet-') } }))
|
||||
expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/terminal-drayages/101/pickup-appointment', expect.objectContaining({ expectedVersion: 4 }), expect.any(Object))
|
||||
expect(http.post).toHaveBeenNthCalledWith(3, '/platform/v1/terminal-drayages/101/pickup', expect.objectContaining({ expectedVersion: 5 }), expect.any(Object))
|
||||
expect(http.post).toHaveBeenNthCalledWith(4, '/platform/v1/terminal-drayages/101/empty-return', expect.objectContaining({ expectedVersion: 6 }), expect.any(Object))
|
||||
})
|
||||
|
||||
it('loads operation history from the container business id', async () => {
|
||||
vi.mocked(http.get).mockResolvedValueOnce({ data: [{ action: 'SELECT_FLEET', fromStatus: 'ARRIVED', toStatus: 'ARRIVED', fromVersion: 0, toVersion: 1, reason: null, operatorId: '9007199254740993', operatorName: '平台管理员', createdAt: '2026-09-07T08:00:00' }] })
|
||||
const result = await getTerminalDrayageHistory('101')
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/terminal-drayages/101/history')
|
||||
expect(result[0].operatorId).toBe('9007199254740993')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
export type TerminalDrayageStatus = 'WAITING_FOR_ARRIVAL' | 'ARRIVED' | 'PICKUP_SCHEDULED' | 'PICKED_UP' | 'UNLOADED' | 'EMPTY_RETURNED'
|
||||
export type TerminalDrayageAction = 'SELECT_FLEET' | 'SCHEDULE_PICKUP' | 'RECORD_PICKUP' | 'RECORD_EMPTY_RETURN'
|
||||
export type EmptyReturnWarningStatus = 'NOT_APPLICABLE' | 'PENDING' | 'OVERDUE' | 'RESOLVED'
|
||||
|
||||
export interface EmptyReturnWarning {
|
||||
status: EmptyReturnWarningStatus
|
||||
deadlineAt: string | null
|
||||
overdueHours: number
|
||||
overdueDays: number
|
||||
}
|
||||
|
||||
export interface TerminalDrayageView {
|
||||
containerBookingId: string
|
||||
containerNo: string | null
|
||||
oceanBookingId: string | null
|
||||
status: TerminalDrayageStatus
|
||||
version: number
|
||||
fleetSupplierId: string | null
|
||||
fleetSupplierName: string | null
|
||||
pickupAppointmentAt: string | null
|
||||
actualPickupAt: string | null
|
||||
estimatedEmptyReturnAt: string | null
|
||||
actualEmptyReturnAt: string | null
|
||||
unloadingCompletedAt: string | null
|
||||
emptyReturnWarning: EmptyReturnWarning
|
||||
allowedActions: TerminalDrayageAction[]
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface TerminalDrayageHistory {
|
||||
action: TerminalDrayageAction
|
||||
fromStatus: string
|
||||
toStatus: string
|
||||
fromVersion: number
|
||||
toVersion: number
|
||||
reason: string | null
|
||||
operatorId: string
|
||||
operatorName: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const path = '/platform/v1/terminal-drayages'
|
||||
function operationId(action: string) { return `terminal-drayage-${action}-${Date.now()}-${Math.random().toString(36).slice(2)}` }
|
||||
function id(value: unknown): string { return value == null ? '' : String(value) }
|
||||
function optionalId(value: unknown): string | null { return value == null ? null : String(value) }
|
||||
function normalize(raw: TerminalDrayageView): TerminalDrayageView {
|
||||
return {
|
||||
...raw,
|
||||
containerBookingId: id(raw.containerBookingId),
|
||||
oceanBookingId: optionalId(raw.oceanBookingId),
|
||||
fleetSupplierId: optionalId(raw.fleetSupplierId),
|
||||
version: Number(raw.version || 0),
|
||||
allowedActions: raw.allowedActions || [],
|
||||
emptyReturnWarning: {
|
||||
...raw.emptyReturnWarning,
|
||||
overdueHours: Number(raw.emptyReturnWarning?.overdueHours || 0),
|
||||
overdueDays: Number(raw.emptyReturnWarning?.overdueDays || 0),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function listTerminalDrayages(containerBookingIds: string[]) {
|
||||
const params = new URLSearchParams()
|
||||
containerBookingIds.forEach((containerBookingId) => params.append('containerBookingIds', containerBookingId))
|
||||
const response = await http.get<ApiEnvelope<TerminalDrayageView[]>, ApiEnvelope<TerminalDrayageView[]>>(path, { params })
|
||||
return (unwrapApiResponse(response) || []).map(normalize)
|
||||
}
|
||||
|
||||
export async function getTerminalDrayage(containerBookingId: string) {
|
||||
const response = await http.get<ApiEnvelope<TerminalDrayageView>, ApiEnvelope<TerminalDrayageView>>(`${path}/${encodeURIComponent(containerBookingId)}`)
|
||||
return normalize(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function getTerminalDrayageHistory(containerBookingId: string) {
|
||||
const response = await http.get<ApiEnvelope<TerminalDrayageHistory[]>, ApiEnvelope<TerminalDrayageHistory[]>>(`${path}/${encodeURIComponent(containerBookingId)}/history`)
|
||||
return (unwrapApiResponse(response) || []).map((item) => ({ ...item, operatorId: id(item.operatorId) }))
|
||||
}
|
||||
|
||||
async function command(containerBookingId: string, action: string, body: object) {
|
||||
const response = await http.post<ApiEnvelope<TerminalDrayageView>, ApiEnvelope<TerminalDrayageView>>(
|
||||
`${path}/${encodeURIComponent(containerBookingId)}/${action}`,
|
||||
body,
|
||||
{ headers: { 'Idempotency-Key': operationId(action) } },
|
||||
)
|
||||
return normalize(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export function selectTerminalDrayageFleet(containerBookingId: string, body: { expectedVersion: number; fleetSupplierId: string; fleetSupplierName?: string; reason?: string }) {
|
||||
return command(containerBookingId, 'fleet', body)
|
||||
}
|
||||
export function scheduleTerminalDrayagePickup(containerBookingId: string, body: { expectedVersion: number; pickupAppointmentAt: string; reason?: string }) {
|
||||
return command(containerBookingId, 'pickup-appointment', body)
|
||||
}
|
||||
export function recordTerminalDrayagePickup(containerBookingId: string, body: { expectedVersion: number; actualAt: string; reason?: string }) {
|
||||
return command(containerBookingId, 'pickup', body)
|
||||
}
|
||||
export function recordTerminalDrayageEmptyReturn(containerBookingId: string, body: { expectedVersion: number; actualAt: string; reason?: string }) {
|
||||
return command(containerBookingId, 'empty-return', body)
|
||||
}
|
||||
Reference in New Issue
Block a user