diff --git a/src/modules/unified-orders/api/orderFulfillment.test.ts b/src/modules/unified-orders/api/orderFulfillment.test.ts new file mode 100644 index 00000000..b4030a47 --- /dev/null +++ b/src/modules/unified-orders/api/orderFulfillment.test.ts @@ -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: (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' } }) + }) +}) diff --git a/src/modules/unified-orders/api/orderFulfillment.ts b/src/modules/unified-orders/api/orderFulfillment.ts new file mode 100644 index 00000000..c4e35921 --- /dev/null +++ b/src/modules/unified-orders/api/orderFulfillment.ts @@ -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 + +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>( + `/platform/v1/orders/${encodeURIComponent(orderId)}/fulfillment-tree`, + { params: { tenantId } }, + ) + return normalizeOrderFulfillmentTree(unwrapApiResponse(response), orderId) +} diff --git a/src/modules/unified-orders/components/OrderFulfillmentTreePanel.test.ts b/src/modules/unified-orders/components/OrderFulfillmentTreePanel.test.ts new file mode 100644 index 00000000..4e0ed3e2 --- /dev/null +++ b/src/modules/unified-orders/components/OrderFulfillmentTreePanel.test.ts @@ -0,0 +1,23 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(resolve(process.cwd(), 'src/modules/unified-orders/components/OrderFulfillmentTreePanel.vue'), 'utf8') + +describe('OrderFulfillmentTreePanel contract', () => { + it('shows root conservation fields and fragment picking quantities', () => { + for (const label of ['原始总量', '累计已装', '当前余量', '本单计划', '已拣', '未拣']) { + expect(source).toContain(label) + } + expect(source).toContain('历史双子单模型') + expect(source).toContain('新拆分不会再生成已装子单') + }) + + it('covers loading, error, empty and responsive states', () => { + expect(source).toContain('正在加载拆分履约') + expect(source).toContain('拆分履约暂时无法加载') + expect(source).toContain(' +import { onMounted, ref } from 'vue' +import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' +import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' +import LoadErrorState from '@/shared/components/LoadErrorState.vue' +import StatusBadge from '@/shared/components/StatusBadge.vue' +import { getPlatformOrderFulfillmentTree, type UnifiedOrderFulfillmentTree } from '../api/orderFulfillment' + +const props = defineProps<{ tenantId: string; orderId: string; compact?: boolean }>() +const tree = ref(null) +const loading = ref(false) +const error = ref(null) + +async function load() { + loading.value = true + error.value = null + try { tree.value = await getPlatformOrderFulfillmentTree(props.tenantId, props.orderId) } + catch (cause) { error.value = getApiErrorDetails(cause) } + finally { loading.value = false } +} + +function roleLabel(role: string | null) { + if (role === 'REMAINDER' || role === 'ACTIVE_REMAINDER') return '当前余量' + if (role === 'LOADED' || role === 'COMPLETED_FRAGMENT' || role === 'SOURCE_COMPLETED') return '已完成分片' + return role || '分片' +} + +onMounted(() => { void load() }) + + + + + diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts index c938fd76..e88ede59 100644 --- a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts +++ b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts @@ -150,6 +150,14 @@ describe('UnifiedOrdersView contract', () => { expect(source).toContain('await loadOrders()') }) + it('exposes the single-remainder fulfillment aggregate in row expansion and detail', () => { + expect(source).toContain("{ key: 'fulfillment', type: 'expand', width: 52, fixed: 'left'") + expect(source).toContain(' + + +