feat(admin): show single-remainder fulfillment aggregate

This commit is contained in:
2026-08-11 09:22:22 +08:00
parent c5295abb49
commit d967355272
6 changed files with 287 additions and 0 deletions
@@ -0,0 +1,50 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { getPlatformOrderFulfillmentTree, normalizeOrderFulfillmentTree } from './orderFulfillment'
vi.mock('@/shared/api/http', () => ({
http: { get: vi.fn() },
unwrapApiResponse: <T>(response: { data: T }) => response.data,
}))
describe('single-remainder order fulfillment contract', () => {
beforeEach(() => vi.mocked(http.get).mockReset())
it('normalizes the new source-fragment plus one remainder model without double counting', () => {
const tree = normalizeOrderFulfillmentTree({
rootOrderId: '10', rootOrderNo: 'CC-10', rootVersion: 3,
originalPackageCount: 4, cumulativeLoadedPackageCount: 2, currentRemainderPackageCount: 2,
fragments: [
{ orderId: '10', orderNo: 'CC-10', splitSequence: 0, splitRole: 'COMPLETED_FRAGMENT', plannedPackageCount: 4, pickedPackageCount: 1, unpickedPackageCount: 0 },
{ orderId: '11', orderNo: 'CC-10-1', splitSequence: 1, splitRole: 'COMPLETED_FRAGMENT', plannedPackageCount: 3, pickedPackageCount: 1, unpickedPackageCount: 0 },
{ orderId: '12', orderNo: 'CC-10-2', splitSequence: 2, splitRole: 'ACTIVE_REMAINDER', plannedPackageCount: 2, pickedPackageCount: 0, unpickedPackageCount: 2 },
],
}, '10')
expect(tree).toMatchObject({
rootOrderId: '10', originalPackageCount: 4,
cumulativeLoadedPackageCount: 2, currentRemainderPackageCount: 2,
historicalTwoChildModel: false,
})
expect(tree.fragments.at(-1)).toMatchObject({ orderNo: 'CC-10-2', pickedPackageCount: 0, unpickedPackageCount: 2 })
})
it('keeps historical LOADED plus REMAINDER trees readable and marks them as historical', () => {
const tree = normalizeOrderFulfillmentTree({
parentOrderId: 10, parentOrderNo: 'CC-10', parentVersion: 2,
allocatedPackageCount: 1, remainingPackageCount: 3,
children: [
{ orderId: 11, orderNo: 'CC-10-1', splitSequence: 1, splitRole: 'LOADED', packageCount: 1, sourceOrderId: 10 },
{ orderId: 12, orderNo: 'CC-10-2', splitSequence: 2, splitRole: 'REMAINDER', packageCount: 3, sourceOrderId: 10 },
],
}, '10')
expect(tree.historicalTwoChildModel).toBe(true)
expect(tree.originalPackageCount).toBe(4)
expect(tree.fragments.map((item) => [item.pickedPackageCount, item.unpickedPackageCount])).toEqual([[1, 0], [0, 3]])
})
it('queries the real platform fulfillment-tree with tenant isolation', async () => {
vi.mocked(http.get).mockResolvedValue({ data: { rootOrderId: '10', fragments: [] } })
await expect(getPlatformOrderFulfillmentTree('20', '10')).resolves.toMatchObject({ rootOrderId: '10' })
expect(http.get).toHaveBeenCalledWith('/platform/v1/orders/10/fulfillment-tree', { params: { tenantId: '20' } })
})
})
@@ -0,0 +1,123 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export interface UnifiedOrderFulfillmentFragment {
orderId: string
orderNo: string | null
splitSequence: number | null
splitRole: string | null
plannedPackageCount: number | null
pickedPackageCount: number | null
unpickedPackageCount: number | null
loadingPlanId: string | null
containerBookingId: string | null
sourceOrderId: string | null
status: string | null
historical: boolean
}
export interface UnifiedOrderFulfillmentTree {
rootOrderId: string
rootOrderNo: string | null
rootVersion: number | null
originalPackageCount: number | null
cumulativeLoadedPackageCount: number | null
currentRemainderPackageCount: number | null
fragments: UnifiedOrderFulfillmentFragment[]
historicalTwoChildModel: boolean
}
type UnknownRecord = Record<string, unknown>
function record(value: unknown): UnknownRecord {
return value && typeof value === 'object' ? value as UnknownRecord : {}
}
function stringValue(value: unknown) {
return value === null || value === undefined || value === '' ? null : String(value)
}
function numberValue(value: unknown) {
if (value === null || value === undefined || value === '') return null
const parsed = Number(value)
return Number.isFinite(parsed) ? Math.trunc(parsed) : null
}
function firstNumber(source: UnknownRecord, keys: string[]) {
for (const key of keys) {
const value = numberValue(source[key])
if (value !== null) return value
}
return null
}
function firstString(source: UnknownRecord, keys: string[]) {
for (const key of keys) {
const value = stringValue(source[key])
if (value !== null) return value
}
return null
}
export function normalizeOrderFulfillmentTree(payload: unknown, fallbackOrderId: string): UnifiedOrderFulfillmentTree {
const raw = record(payload)
const rootOrderId = firstString(raw, ['rootOrderId', 'parentOrderId', 'orderId']) || fallbackOrderId
const rootOrderNo = firstString(raw, ['rootOrderNo', 'parentOrderNo', 'orderNo'])
const children = Array.isArray(raw.fragments) ? raw.fragments : Array.isArray(raw.children) ? raw.children : []
const legacy = raw.historicalTwoChildModel === true
|| children.some((item) => {
const child = record(item)
const role = firstString(child, ['splitRole', 'role'])
const childId = firstString(child, ['orderId', 'childOrderId'])
const sourceId = firstString(child, ['sourceOrderId', 'parentOrderId'])
return role === 'LOADED' && childId !== null && sourceId !== null && childId !== sourceId
})
const original = firstNumber(raw, ['originalPackageCount', 'sourcePackageCount'])
?? (() => {
const allocated = firstNumber(raw, ['cumulativeLoadedPackageCount', 'allocatedPackageCount', 'loadedPackageCount'])
const remainder = firstNumber(raw, ['currentRemainderPackageCount', 'remainingPackageCount'])
return allocated === null || remainder === null ? null : allocated + remainder
})()
const cumulativeLoaded = firstNumber(raw, ['cumulativeLoadedPackageCount', 'allocatedPackageCount', 'loadedPackageCount'])
const currentRemainder = firstNumber(raw, ['currentRemainderPackageCount', 'remainingPackageCount'])
const fragments = children.map((item): UnifiedOrderFulfillmentFragment => {
const child = record(item)
const role = firstString(child, ['splitRole', 'fragmentRole', 'role'])
const planned = firstNumber(child, ['plannedPackageCount', 'sourcePackageCount', 'packageCount'])
const picked = firstNumber(child, ['pickedPackageCount', 'loadedPackageCount'])
?? (role === 'LOADED' ? firstNumber(child, ['packageCount']) : 0)
const unpicked = firstNumber(child, ['unpickedPackageCount', 'remainingPackageCount'])
?? (planned === null || picked === null ? null : Math.max(0, planned - picked))
return {
orderId: firstString(child, ['orderId', 'childOrderId', 'sourceOrderId']) || '',
orderNo: firstString(child, ['orderNo', 'childOrderNo', 'sourceOrderNo']),
splitSequence: firstNumber(child, ['splitSequence', 'sequence']),
splitRole: role,
plannedPackageCount: planned,
pickedPackageCount: picked,
unpickedPackageCount: unpicked,
loadingPlanId: firstString(child, ['loadingPlanId']),
containerBookingId: firstString(child, ['containerBookingId']),
sourceOrderId: firstString(child, ['sourceOrderId', 'parentOrderId']),
status: firstString(child, ['status', 'orderStatus', 'processStatus']),
historical: legacy,
}
}).filter((item) => item.orderId)
return {
rootOrderId,
rootOrderNo,
rootVersion: firstNumber(raw, ['rootVersion', 'parentVersion']),
originalPackageCount: original,
cumulativeLoadedPackageCount: cumulativeLoaded,
currentRemainderPackageCount: currentRemainder,
fragments,
historicalTwoChildModel: legacy,
}
}
export async function getPlatformOrderFulfillmentTree(tenantId: string, orderId: string) {
const response = await http.get<ApiEnvelope<unknown>, ApiEnvelope<unknown>>(
`/platform/v1/orders/${encodeURIComponent(orderId)}/fulfillment-tree`,
{ params: { tenantId } },
)
return normalizeOrderFulfillmentTree(unwrapApiResponse(response), orderId)
}