feat(admin): confirm container departure from detail

This commit is contained in:
2026-08-26 12:04:50 +08:00
parent 8307510fc3
commit e8c41bca2d
5 changed files with 392 additions and 12 deletions
@@ -1,5 +1,96 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { changeOutboundDispatch, listOutboundDispatches } from './platformOutboundDispatch'
vi.mock('@/shared/api/http', async () => { const actual = await vi.importActual<typeof import('@/shared/api/http')>('@/shared/api/http'); return { ...actual, http: { get: vi.fn(), post: vi.fn() } } })
describe('platformOutboundDispatch', () => { beforeEach(() => vi.clearAllMocks()); it('normalizes aggregate ids', async () => { vi.mocked(http.get).mockResolvedValue({ code: '200', data: [{ orderId: 1, orderNo: 'O1', inboundId: 2, inboundCode: 'I1', customerId: 3, version: '4' }] }); const result = await listOutboundDispatches({}); expect(result[0]).toMatchObject({ orderId: '1', inboundId: '2', customerId: '3', version: 4 }) }); it('posts action with version and idempotency', async () => { vi.mocked(http.post).mockResolvedValue({ code: '200', data: { aggregate: { orderId: 1, orderNo: 'O1', inboundId: 2, inboundCode: 'I1', version: 5 }, timeline: [] } }); await changeOutboundDispatch('confirm-dispatch', { inboundId: '2', expectedVersion: 4 }); expect(vi.mocked(http.post).mock.calls[0]?.[1]).toEqual({ inboundId: '2', expectedVersion: 4 }); expect(vi.mocked(http.post).mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } }) }) })
import {
changeOutboundDispatch,
checkContainerOutboundGate,
confirmContainerOutboundDispatch,
getContainerOutboundDispatch,
listOutboundDispatches,
} from './platformOutboundDispatch'
vi.mock('@/shared/api/http', async () => {
const actual = await vi.importActual<typeof import('@/shared/api/http')>('@/shared/api/http')
return { ...actual, http: { get: vi.fn(), post: vi.fn() } }
})
const gate = {
passed: true,
checkedAt: '2026-08-26T12:00:00',
inbounds: [{
inboundId: '2092438843630391501',
inboundCode: 'IN-1',
gate: { passed: true, checkedAt: '2026-08-26T12:00:00', items: [] },
}],
}
const aggregate = {
containerBookingId: '2092438843630391297',
cabinetNo: 'CAB-1',
containerNo: 'C11111111',
loadingPlanId: '2092438843630391401',
loadingPlanStatus: 'COMPLETED',
oceanBookingId: '2092438843630391601',
truckAppointmentId: null,
dispatchStatus: 'READY_FOR_DISPATCH',
version: '4',
inbounds: [{
orderId: '2092438843630391701',
orderNo: 'O1',
inboundId: '2092438843630391501',
inboundCode: 'IN-1',
tenantId: '2092438843630391801',
customerId: '2092438843630391901',
inboundStatus: 'LOADED',
pickTaskId: null,
}],
gate,
}
describe('platformOutboundDispatch', () => {
beforeEach(() => vi.clearAllMocks())
it('normalizes aggregate ids', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', data: [{ orderId: 1, orderNo: 'O1', inboundId: 2, inboundCode: 'I1', customerId: 3, version: '4' }] })
const result = await listOutboundDispatches({})
expect(result[0]).toMatchObject({ orderId: '1', inboundId: '2', customerId: '3', version: 4 })
})
it('posts legacy action with version and idempotency', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { aggregate: { orderId: 1, orderNo: 'O1', inboundId: 2, inboundCode: 'I1', version: 5 }, timeline: [] } })
await changeOutboundDispatch('confirm-dispatch', { inboundId: '2', expectedVersion: 4 })
expect(vi.mocked(http.post).mock.calls[0]?.[1]).toEqual({ inboundId: '2', expectedVersion: 4 })
expect(vi.mocked(http.post).mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } })
})
it('gets exact container-level paths and normalizes all ids', async () => {
vi.mocked(http.get)
.mockResolvedValueOnce({ code: '200', data: { aggregate, timeline: [{ module: 'OUTBOUND', action: 'READY', operatorId: 9, occurredAt: '2026-08-26T12:00:00' }] } })
.mockResolvedValueOnce({ code: '200', data: gate })
const detail = await getContainerOutboundDispatch('2092438843630391297')
const resultGate = await checkContainerOutboundGate('2092438843630391297')
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/first-leg/outbound-dispatches/2092438843630391297')
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/first-leg/outbound-dispatches/2092438843630391297/gate')
expect(detail.aggregate).toMatchObject({
containerBookingId: String(aggregate.containerBookingId),
loadingPlanId: String(aggregate.loadingPlanId),
oceanBookingId: String(aggregate.oceanBookingId),
version: 4,
inbounds: [{ orderId: String(aggregate.inbounds[0].orderId), inboundId: String(aggregate.inbounds[0].inboundId), tenantId: String(aggregate.inbounds[0].tenantId), customerId: String(aggregate.inbounds[0].customerId) }],
})
expect(detail.timeline[0]?.operatorId).toBe('9')
expect(resultGate.inbounds[0]?.inboundId).toBe(String(gate.inbounds[0].inboundId))
})
it('confirms once at container level without inboundId and includes idempotency', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { aggregate, timeline: [] } })
await confirmContainerOutboundDispatch('2092438843630391297', { expectedVersion: 4, remark: '货柜详情确认国内出仓' })
expect(http.post).toHaveBeenCalledWith(
'/platform/v1/first-leg/outbound-dispatches/2092438843630391297/confirm',
{ expectedVersion: 4, remark: '货柜详情确认国内出仓' },
{ headers: { 'Idempotency-Key': expect.any(String) } },
)
expect(vi.mocked(http.post).mock.calls[0]?.[1]).not.toHaveProperty('inboundId')
})
})
@@ -1,5 +1,16 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { OutboundDispatchDetail, OutboundDispatchQuery, OutboundDispatchResource, OutboundGateResult } from '../types'
import type {
ContainerOutboundActionRequest,
ContainerOutboundAggregate,
ContainerOutboundDispatchDetail,
ContainerOutboundGateResult,
ContainerOutboundInboundBinding,
ContainerOutboundInboundGate,
OutboundDispatchDetail,
OutboundDispatchQuery,
OutboundDispatchResource,
OutboundGateResult,
} from '../types'
const basePath = '/platform/v1/first-leg/outbound-dispatches'; const keys = ['orderId', 'inboundId', 'containerBookingId', 'customerId'] as const
function item(raw: OutboundDispatchResource): OutboundDispatchResource { const value = { ...raw } as OutboundDispatchResource & Record<string, unknown>; for (const key of keys) if (value[key] != null) value[key] = String(value[key]); value.version = Number(value.version ?? 0); return value }
function idem() { return `outbound-dispatch-${Date.now()}-${Math.random().toString(36).slice(2)}` }
@@ -7,3 +18,70 @@ export async function listOutboundDispatches(query: OutboundDispatchQuery) { con
export async function getOutboundDispatchDetail(query: OutboundDispatchQuery) { const response = await http.get<ApiEnvelope<OutboundDispatchDetail>, ApiEnvelope<OutboundDispatchDetail>>(`${basePath}/detail`, { params: query }); const data = unwrapApiResponse(response); return { ...data, aggregate: item(data.aggregate) } }
export async function checkOutboundGate(query: OutboundDispatchQuery) { const response = await http.get<ApiEnvelope<OutboundGateResult>, ApiEnvelope<OutboundGateResult>>(`${basePath}/check-gate`, { params: query }); return unwrapApiResponse(response) }
export async function changeOutboundDispatch(action: 'confirm-dispatch' | 'reverse-dispatch', request: { inboundId: string; containerBookingId?: string | null; expectedVersion: number; remark?: string }) { const response = await http.post<ApiEnvelope<OutboundDispatchDetail>, ApiEnvelope<OutboundDispatchDetail>>(`${basePath}/${action}`, request, { headers: { 'Idempotency-Key': idem() } }); const data = unwrapApiResponse(response); return { ...data, aggregate: item(data.aggregate) } }
function optionalId(value: unknown): string | null {
return value == null ? null : String(value)
}
function inboundBinding(raw: ContainerOutboundInboundBinding): ContainerOutboundInboundBinding {
return {
...raw,
orderId: String(raw.orderId),
inboundId: String(raw.inboundId),
tenantId: String(raw.tenantId),
customerId: optionalId(raw.customerId),
pickTaskId: optionalId(raw.pickTaskId),
}
}
function inboundGate(raw: ContainerOutboundInboundGate): ContainerOutboundInboundGate {
return { ...raw, inboundId: String(raw.inboundId), gate: { ...raw.gate, items: raw.gate?.items || [] } }
}
function containerGate(raw: ContainerOutboundGateResult): ContainerOutboundGateResult {
return { ...raw, inbounds: (raw.inbounds || []).map(inboundGate) }
}
function containerAggregate(raw: ContainerOutboundAggregate): ContainerOutboundAggregate {
return {
...raw,
containerBookingId: String(raw.containerBookingId),
loadingPlanId: String(raw.loadingPlanId),
oceanBookingId: optionalId(raw.oceanBookingId),
truckAppointmentId: optionalId(raw.truckAppointmentId),
version: Number(raw.version ?? 0),
inbounds: (raw.inbounds || []).map(inboundBinding),
gate: containerGate(raw.gate),
}
}
function containerDetail(raw: ContainerOutboundDispatchDetail): ContainerOutboundDispatchDetail {
return {
...raw,
aggregate: containerAggregate(raw.aggregate),
timeline: (raw.timeline || []).map((entry) => ({ ...entry, operatorId: optionalId(entry.operatorId) })),
}
}
export async function getContainerOutboundDispatch(containerBookingId: string) {
const response = await http.get<ApiEnvelope<ContainerOutboundDispatchDetail>, ApiEnvelope<ContainerOutboundDispatchDetail>>(
`${basePath}/${encodeURIComponent(containerBookingId)}`,
)
return containerDetail(unwrapApiResponse(response))
}
export async function checkContainerOutboundGate(containerBookingId: string) {
const response = await http.get<ApiEnvelope<ContainerOutboundGateResult>, ApiEnvelope<ContainerOutboundGateResult>>(
`${basePath}/${encodeURIComponent(containerBookingId)}/gate`,
)
return containerGate(unwrapApiResponse(response))
}
export async function confirmContainerOutboundDispatch(containerBookingId: string, request: ContainerOutboundActionRequest) {
const response = await http.post<ApiEnvelope<ContainerOutboundDispatchDetail>, ApiEnvelope<ContainerOutboundDispatchDetail>>(
`${basePath}/${encodeURIComponent(containerBookingId)}/confirm`,
request,
{ headers: { 'Idempotency-Key': idem() } },
)
return containerDetail(unwrapApiResponse(response))
}
+72
View File
@@ -3,3 +3,75 @@ export interface OutboundGateResult { passed: boolean; items: OutboundGateItem[]
export interface OutboundDispatchResource { orderId: string; orderNo: string; inboundId: string; inboundCode: string; customerId?: string | null; customerName?: string | null; inboundStatus?: string | null; containerBookingId?: string | null; cabinetNo?: string | null; containerNo?: string | null; loadingPlanStatus?: string | null; pickTaskStatus?: string | null; oceanBookingStatus?: string | null; truckAppointmentStatus?: string | null; dispatchStatus?: string | null; version: number; gate?: OutboundGateResult | null }
export interface OutboundDispatchDetail { aggregate: OutboundDispatchResource; timeline: Array<{ module: string; action: string; fromStatus?: string | null; toStatus?: string | null; operatorName?: string | null; remark?: string | null; occurredAt: string }> }
export interface OutboundDispatchQuery { orderId?: string; inboundId?: string; containerBookingId?: string }
export interface ContainerOutboundGateItem {
code: string
name: string
passed: boolean
actual?: string | null
requirement?: string | null
}
export interface ContainerOutboundInboundGate {
inboundId: string
inboundCode: string
gate: {
passed: boolean
items: ContainerOutboundGateItem[]
checkedAt: string
}
}
export interface ContainerOutboundGateResult {
passed: boolean
inbounds: ContainerOutboundInboundGate[]
checkedAt: string
}
export interface ContainerOutboundInboundBinding {
orderId: string
orderNo: string
inboundId: string
inboundCode: string
tenantId: string
customerId?: string | null
customerName?: string | null
inboundStatus: string
pickTaskId?: string | null
pickTaskStatus?: string | null
}
export interface ContainerOutboundAggregate {
containerBookingId: string
cabinetNo: string
containerNo?: string | null
loadingPlanId: string
loadingPlanStatus: string
oceanBookingId?: string | null
oceanBookingStatus?: string | null
truckAppointmentId?: string | null
truckAppointmentStatus?: string | null
dispatchStatus: string
version: number
inbounds: ContainerOutboundInboundBinding[]
gate: ContainerOutboundGateResult
}
export interface ContainerOutboundDispatchDetail {
aggregate: ContainerOutboundAggregate
timeline: Array<{
module: string
action: string
fromStatus?: string | null
toStatus?: string | null
operatorId?: string | null
operatorName?: string | null
remark?: string | null
occurredAt: string
}>
}
export interface ContainerOutboundActionRequest {
expectedVersion: number
remark?: string
}