test: normalize finance container allocation sources
This commit is contained in:
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict'
|
|||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import { buildContainerCostBody, run } from './finance-full-fixtures.mjs'
|
import { buildContainerCostBody, normalizeFinanceFixtureSources, run } from './finance-full-fixtures.mjs'
|
||||||
|
|
||||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||||
const source = fs.readFileSync(path.join(here, 'finance-full-fixtures.mjs'), 'utf8')
|
const source = fs.readFileSync(path.join(here, 'finance-full-fixtures.mjs'), 'utf8')
|
||||||
@@ -52,6 +52,28 @@ test('shared fixture source names the finance full acceptance contract', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('fixture source normalization uses only marker-owned orders and real allocation order links', () => {
|
||||||
|
const sources = [
|
||||||
|
{ feeSourceId: 'F-ORDER', orderId: 'O-1', businessDate: '2026-08-10' },
|
||||||
|
{ feeSourceId: 'F-CONTAINER', billingBasis: 'CONTAINER_SHARED', businessDate: '2026-08-11' },
|
||||||
|
]
|
||||||
|
const orders = [
|
||||||
|
{ orderId: 'O-1', orderNo: 'ORDER-1', waybillId: 'WB-1' },
|
||||||
|
{ orderId: 'O-2', orderNo: 'ORDER-2', shipmentId: 'SHIP-2' },
|
||||||
|
]
|
||||||
|
const normalized = normalizeFinanceFixtureSources(sources, orders, [{ orderId: 'O-2', allocatedAmount: 30 }],
|
||||||
|
'701', 'FIN-MARKER')
|
||||||
|
|
||||||
|
assert.deepEqual(normalized.map((source) => ({ feeSourceId: source.feeSourceId, customerId: source.customerId,
|
||||||
|
marker: source.marker, orderId: source.orderId, waybill: source.waybillId || source.shipmentId || source.orderNo })), [
|
||||||
|
{ feeSourceId: 'F-ORDER', customerId: '701', marker: 'FIN-MARKER', orderId: 'O-1', waybill: 'WB-1' },
|
||||||
|
{ feeSourceId: 'F-CONTAINER', customerId: '701', marker: 'FIN-MARKER', orderId: 'O-2', waybill: 'SHIP-2' },
|
||||||
|
])
|
||||||
|
const unbound = normalizeFinanceFixtureSources([sources[1]], orders, [], '701', 'FIN-MARKER')[0]
|
||||||
|
assert.equal(unbound.orderId, null)
|
||||||
|
assert.equal(unbound.waybillId || unbound.shipmentId || unbound.orderNo, null)
|
||||||
|
})
|
||||||
|
|
||||||
function fakeContext(overrides = {}) {
|
function fakeContext(overrides = {}) {
|
||||||
const steps = []
|
const steps = []
|
||||||
const context = {
|
const context = {
|
||||||
@@ -116,6 +138,8 @@ test('run behavior emits consumer shape, both billing bases, distinct dates, sup
|
|||||||
assert.ok(Array.isArray(fixture.allocations))
|
assert.ok(Array.isArray(fixture.allocations))
|
||||||
assert.ok(fixture.feeSources.filter((item) => item.direction === 'RECEIVABLE').length >= 4)
|
assert.ok(fixture.feeSources.filter((item) => item.direction === 'RECEIVABLE').length >= 4)
|
||||||
assert.ok(new Set(fixture.feeSources.filter((item) => item.direction === 'RECEIVABLE').map((item) => item.businessDate)).size >= 2)
|
assert.ok(new Set(fixture.feeSources.filter((item) => item.direction === 'RECEIVABLE').map((item) => item.businessDate)).size >= 2)
|
||||||
|
assert.equal(fixture.feeSources.every((item) => item.feeSourceId && item.customerId === fixture.customerId
|
||||||
|
&& item.businessDate && (item.waybillId || item.shipmentId || item.orderNo) && item.marker === context.marker), true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('run preserves real price-row profiles when creating automatic orders', async () => {
|
test('run preserves real price-row profiles when creating automatic orders', async () => {
|
||||||
|
|||||||
@@ -9,6 +9,24 @@ function rows(value) { return Array.isArray(value) ? value : Array.isArray(value
|
|||||||
function dateOffset(days) { const value = new Date(); value.setUTCDate(value.getUTCDate() + days); return value.toISOString().slice(0, 10) }
|
function dateOffset(days) { const value = new Date(); value.setUTCDate(value.getUTCDate() + days); return value.toISOString().slice(0, 10) }
|
||||||
function blocker(context, name, evidence) { context.addStep(MODULE, name, null, false, evidence, OPTIONS) }
|
function blocker(context, name, evidence) { context.addStep(MODULE, name, null, false, evidence, OPTIONS) }
|
||||||
|
|
||||||
|
export function normalizeFinanceFixtureSources(sources, orders, allocations, customerId, marker) {
|
||||||
|
const orderById = new Map(rows(orders).map((order) => [id(order?.orderId ?? order?.id), order]))
|
||||||
|
const allocationOrders = rows(allocations).map((allocation) => orderById.get(id(allocation?.orderId))).filter(Boolean)
|
||||||
|
return rows(sources).map((source) => {
|
||||||
|
const sourceOrder = orderById.get(id(source?.orderId))
|
||||||
|
|| (source?.billingBasis === 'CONTAINER_SHARED' ? allocationOrders[0] : null)
|
||||||
|
return {
|
||||||
|
...source,
|
||||||
|
customerId: id(source?.customerId ?? customerId) || null,
|
||||||
|
marker: source?.marker ?? marker,
|
||||||
|
orderId: id(source?.orderId ?? sourceOrder?.orderId ?? sourceOrder?.id) || null,
|
||||||
|
waybillId: id(source?.waybillId ?? sourceOrder?.waybillId) || null,
|
||||||
|
shipmentId: id(source?.shipmentId ?? sourceOrder?.shipmentId) || null,
|
||||||
|
orderNo: source?.orderNo ?? sourceOrder?.orderNo ?? null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function createOrder(context, suffix, pricingProfile = null) {
|
async function createOrder(context, suffix, pricingProfile = null) {
|
||||||
if (context.financeFixtureHooks?.createOrder) return context.financeFixtureHooks.createOrder(context, suffix, pricingProfile)
|
if (context.financeFixtureHooks?.createOrder) return context.financeFixtureHooks.createOrder(context, suffix, pricingProfile)
|
||||||
const originalMarker = context.marker
|
const originalMarker = context.marker
|
||||||
@@ -438,22 +456,28 @@ export async function run(context) {
|
|||||||
context.assert(MODULE, '取得订单真实运单标识', null,
|
context.assert(MODULE, '取得订单真实运单标识', null,
|
||||||
measured.length > 0 && measured.every((item) => item.waybillId || item.shipmentId || item.orderNo),
|
measured.length > 0 && measured.every((item) => item.waybillId || item.shipmentId || item.orderNo),
|
||||||
'使用订单域真实 waybillId、shipmentId 或 orderNo,不使用数据库主键冒充运单标识', OPTIONS)
|
'使用订单域真实 waybillId、shipmentId 或 orderNo,不使用数据库主键冒充运单标识', OPTIONS)
|
||||||
|
const fixtureOrders = measured.map((item) => ({ orderId: id(item.orderId), orderNo: item.orderNo || null,
|
||||||
|
shipmentId: item.shipmentId ? id(item.shipmentId) : null,
|
||||||
|
waybillId: item.waybillId ? id(item.waybillId) : (item.shipmentId ? id(item.shipmentId) : null), inboundId: id(item.inboundId),
|
||||||
|
actualWeightKg: item.actualWeightKg, totalVolumeCbm: item.totalVolumeCbm,
|
||||||
|
businessDate: fixtures.find((fee) => fee.orderId === id(item.orderId))?.businessDate || null }))
|
||||||
|
const fixtureSources = normalizeFinanceFixtureSources(fixtures, fixtureOrders, allocations, customerId, context.marker)
|
||||||
|
const invalidFixtureSources = fixtureSources.filter((source) => !source.feeSourceId || !source.customerId
|
||||||
|
|| !source.businessDate || !(source.waybillId || source.shipmentId || source.orderNo) || source.marker !== context.marker)
|
||||||
|
context.assert(MODULE, '输出完整 Finance 来源契约', null, invalidFixtureSources.length === 0,
|
||||||
|
`sources=${fixtureSources.length}, invalid=${invalidFixtureSources.length};字段必须来自本轮费用来源、客户、订单/运单和真实柜次分摊`, OPTIONS)
|
||||||
context.createdResources.financeFullFixtures = {
|
context.createdResources.financeFullFixtures = {
|
||||||
marker: context.marker, customerId,
|
marker: context.marker, customerId,
|
||||||
prerequisites: {
|
prerequisites: {
|
||||||
container: containerOwned && Boolean(containerFixture?.feeSourceId) && allocations.length > 0
|
container: containerOwned && Boolean(containerFixture?.feeSourceId) && allocations.length > 0
|
||||||
? { ready: true, category: null }
|
? { ready: true, category: null }
|
||||||
: { ready: false, category: 'container-prerequisite', evidence: '缺少同 marker 已完成柜次、有效 containerBookingId、按柜费用来源或真实分摊' },
|
: { ready: false, category: 'container-prerequisite', evidence: '缺少同 marker 已完成柜次、有效 containerBookingId、按柜费用来源或真实分摊' },
|
||||||
financeSources: fixtures.length > 0
|
financeSources: fixtureSources.length > 0 && invalidFixtureSources.length === 0
|
||||||
? { ready: true, category: null }
|
? { ready: true, category: null }
|
||||||
: { ready: false, category: 'finance-source-prerequisite', evidence: '本轮 marker 未产生可消费 Finance 费用来源' },
|
: { ready: false, category: 'finance-source-prerequisite', evidence: '本轮 marker 未产生字段完整的可消费 Finance 费用来源' },
|
||||||
},
|
},
|
||||||
orders: measured.map((item) => ({ orderId: id(item.orderId), orderNo: item.orderNo || null,
|
orders: fixtureOrders,
|
||||||
shipmentId: item.shipmentId ? id(item.shipmentId) : null,
|
feeSources: fixtureSources,
|
||||||
waybillId: item.waybillId ? id(item.waybillId) : (item.shipmentId ? id(item.shipmentId) : null), inboundId: id(item.inboundId),
|
|
||||||
actualWeightKg: item.actualWeightKg, totalVolumeCbm: item.totalVolumeCbm,
|
|
||||||
businessDate: fixtures.find((fee) => fee.orderId === id(item.orderId))?.businessDate || null })),
|
|
||||||
feeSources: fixtures,
|
|
||||||
container: containerFixture ? { containerBookingId: containerFixture.containerBookingId,
|
container: containerFixture ? { containerBookingId: containerFixture.containerBookingId,
|
||||||
containerNo: containerFixture.containerNo } : { containerBookingId: null, containerNo: null },
|
containerNo: containerFixture.containerNo } : { containerBookingId: null, containerNo: null },
|
||||||
allocations, supplier: supplier?.id ? { supplierId: id(supplier.id), partnerId: id(supplier.id), supplierName: supplier.name || null } : null,
|
allocations, supplier: supplier?.id ? { supplierId: id(supplier.id), partnerId: id(supplier.id), supplierName: supplier.name || null } : null,
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ function fakeContext(withFixture = true, permissions = financePermissions, denyF
|
|||||||
const fixture = { marker, customerId: '11',
|
const fixture = { marker, customerId: '11',
|
||||||
orders: feeSources.filter((item) => item.orderId)
|
orders: feeSources.filter((item) => item.orderId)
|
||||||
.map((item) => ({ orderId: item.orderId, shipmentId: `SHIP-${item.orderId}` })),
|
.map((item) => ({ orderId: item.orderId, shipmentId: `SHIP-${item.orderId}` })),
|
||||||
feeSources: feeSources.map((item) => ({ ...item, marker })) }
|
feeSources: feeSources.map((item) => ({ ...item, customerId: '11',
|
||||||
|
waybillId: item.orderId ? `SHIP-${item.orderId}` : null, marker })) }
|
||||||
const statements = new Map()
|
const statements = new Map()
|
||||||
const settlements = new Map()
|
const settlements = new Map()
|
||||||
const idempotency = new Map()
|
const idempotency = new Map()
|
||||||
@@ -287,6 +288,18 @@ test('PAYABLE 接受 partnerId 及名称快照,不依赖 SUPPLIER 类型', asy
|
|||||||
assert.doesNotMatch(source, /partnerType|types\?\.includes|SUPPLIER.*阻断/)
|
assert.doesNotMatch(source, /partnerType|types\?\.includes|SUPPLIER.*阻断/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('来源缺失逐条 customerId 时不得由 fixture 根字段静默补齐', async () => {
|
||||||
|
const context = fakeContext()
|
||||||
|
delete context.createdResources.financeFullFixtures.feeSources[0].customerId
|
||||||
|
|
||||||
|
await run(context)
|
||||||
|
|
||||||
|
assert.equal(context.steps.some((step) => step.name === 'Fixture 来源契约完整'
|
||||||
|
&& !step.passed && step.deploymentBlocker), true)
|
||||||
|
assert.equal(context.coverageGaps.some((gap) => gap.category === 'invalid-finance-fixture-contract'), true)
|
||||||
|
assert.equal(context.calls.some((call) => call.method !== 'GET'), false)
|
||||||
|
})
|
||||||
|
|
||||||
test('缺少 Finance 创建权限时 fail-closed 且不发送财务写请求', async () => {
|
test('缺少 Finance 创建权限时 fail-closed 且不发送财务写请求', async () => {
|
||||||
const context = fakeContext(true, financePermissions.filter((item) => item !== 'platform:finance:reconciliation:create'))
|
const context = fakeContext(true, financePermissions.filter((item) => item !== 'platform:finance:reconciliation:create'))
|
||||||
await run(context)
|
await run(context)
|
||||||
|
|||||||
@@ -168,7 +168,6 @@ async function normalizeFixture(context) {
|
|||||||
if (!root || root.marker !== context.marker || !root.customerId
|
if (!root || root.marker !== context.marker || !root.customerId
|
||||||
|| !Array.isArray(root.orders) || !Array.isArray(root.feeSources)) return null
|
|| !Array.isArray(root.orders) || !Array.isArray(root.feeSources)) return null
|
||||||
const customerId = id(root.customerId)
|
const customerId = id(root.customerId)
|
||||||
const orders = new Map(root.orders.map((order) => [id(order.orderId ?? order.id), order]))
|
|
||||||
const expectedSourceIds = new Set(root.feeSources.map(sourceId).filter(Boolean))
|
const expectedSourceIds = new Set(root.feeSources.map(sourceId).filter(Boolean))
|
||||||
let pool = []
|
let pool = []
|
||||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||||
@@ -182,15 +181,13 @@ async function normalizeFixture(context) {
|
|||||||
}
|
}
|
||||||
const poolBySource = new Map(pool.map((item) => [id(item?.sourceId), item]))
|
const poolBySource = new Map(pool.map((item) => [id(item?.sourceId), item]))
|
||||||
const attemptedSources = root.feeSources.map((raw) => {
|
const attemptedSources = root.feeSources.map((raw) => {
|
||||||
const order = orders.get(id(raw.orderId))
|
|
||||||
const financeSource = poolBySource.get(sourceId(raw))
|
const financeSource = poolBySource.get(sourceId(raw))
|
||||||
|| pool.find((item) => String(item?.direction || '').toUpperCase() === 'PAYABLE'
|
|| pool.find((item) => String(item?.direction || '').toUpperCase() === 'PAYABLE'
|
||||||
&& [raw.containerBookingId, raw.loadingPlanId].map(id).includes(id(item?.containerId)))
|
&& [raw.containerBookingId, raw.loadingPlanId].map(id).includes(id(item?.containerId)))
|
||||||
return {
|
return {
|
||||||
raw, feeSourceId: financePoolId(financeSource), sourceFeeId: sourceId(raw), customerId: id(raw.customerId ?? customerId),
|
raw, feeSourceId: financePoolId(financeSource), sourceFeeId: sourceId(raw), customerId: id(raw.customerId),
|
||||||
direction: String(raw.direction ?? ''), currency: String(raw.currency ?? raw.currencyCode ?? '').toUpperCase(),
|
direction: String(raw.direction ?? ''), currency: String(raw.currency ?? raw.currencyCode ?? '').toUpperCase(),
|
||||||
businessDate: sourceDate(raw), waybillId: sourceWaybill(raw) || sourceWaybill(order),
|
businessDate: sourceDate(raw), waybillId: sourceWaybill(raw), marker: raw.marker,
|
||||||
marker: raw.marker ?? root.marker,
|
|
||||||
supplierId: id(raw.supplierId ?? raw.partnerId ?? root.supplierId ?? root.partnerId),
|
supplierId: id(raw.supplierId ?? raw.partnerId ?? root.supplierId ?? root.partnerId),
|
||||||
supplierName: raw.supplierName ?? raw.partnerName ?? root.supplierName ?? root.partnerName ?? null,
|
supplierName: raw.supplierName ?? raw.partnerName ?? root.supplierName ?? root.partnerName ?? null,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user