feat(admin): show single-remainder fulfillment aggregate
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user