This commit is contained in:
2026-09-09 17:42:26 +08:00
parent 2b0e66ca7e
commit 88b489eb77
29 changed files with 1195 additions and 909 deletions
@@ -17,6 +17,10 @@ test('finance fixture module is independently exported and marker isolated', ()
test('fixture covers weight, volume, manual order, and container allocation contracts', () => {
for (const basis of ['WEIGHT_KG', 'VOLUME_CBM', 'MANUAL_ORDER_FEE', 'CONTAINER_SHARED']) assert.match(source, new RegExp(basis))
assert.match(source, /chargeMode: pricingProfile\.chargeUnit/)
assert.match(source, /customer-rules\/versions\?tenantId=/)
assert.match(source, /taxMode: 'TAX_EXCLUDED'/)
assert.match(source, /inbound\?\.summary\?\.businessType \|\| profile\.businessType/)
assert.match(source, /direction === 'PAYABLE' \? \{ settlementMode: 'ON_ACCOUNT' \}/)
assert.match(source, /supplierId: id\(supplier\.id\), settlementMode: 'ON_ACCOUNT'/)
assert.match(source, /request\(context, 'POST', '\/api\/platform\/finance\/v1\/cost-entries'/)
@@ -123,6 +127,7 @@ function hookSet({ missing = false } = {}) {
let automaticIndex = 0
return {
loadPricingEvidence: async () => ({ activeVersionIds: ['PRICE-1'] }),
ensureCustomerChargeRule: async () => ({ id: 'RULE-CBM', chargeMode: 'CBM' }),
createOrder: async (context, suffix) => ({ marker: `${context.marker}-${suffix}`, tenantId: '701',
orderId: `O-${suffix}`, orderNo: `NO-${suffix}`, waybillId: suffix === 'AUTO1' ? 'WB-1' : null, inboundId: `I-${suffix}` }),
measureOrder: async (context, resource) => ({ ...resource, measurementId: `M-${resource.orderId}`, actualWeightKg: 12.5, totalVolumeCbm: 0.072 }),
@@ -153,7 +158,7 @@ test('run behavior emits consumer shape, both billing bases, distinct dates, sup
await run(context)
const fixture = context.createdResources.financeFullFixtures
assert.equal(fixture.customerId, '701')
assert.equal(fixture.orders.length, 5)
assert.equal(fixture.orders.length, 6)
assert.equal(fixture.orders[0].orderNo, 'NO-AUTO1')
assert.equal(fixture.orders[0].waybillId, 'WB-1')
assert.equal(fixture.orders[0].actualWeightKg, 12.5)
@@ -55,8 +55,18 @@ async function createOrder(context, suffix, pricingProfile = null) {
// The orders module discovers candidates independently. Reorder only those
// real responses so its payload uses one exact active price-book row.
globalThis.fetch = async (input, init = {}) => {
const response = await originalFetch(input, init)
const url = String(input)
let effectiveInit = init
if ((init.method || 'GET') === 'POST'
&& url.includes('/api/merchant/v1/first-leg/pricing/estimate')
&& typeof init.body === 'string') {
const body = JSON.parse(init.body)
effectiveInit = {
...init,
body: JSON.stringify({ ...body, chargeMode: pricingProfile.chargeUnit })
}
}
const response = await originalFetch(input, effectiveInit)
if ((init.method || 'GET') === 'GET' && (url.includes('/api/merchant/v1/warehouses?')
|| url.includes('/api/merchant/v1/first-leg/pricing/channels?'))) {
const body = await response.clone().json().catch(() => null)
@@ -99,7 +109,7 @@ async function measureOrder(context, resource) {
if (context.financeFixtureHooks?.measureOrder) return context.financeFixtureHooks.measureOrder(context, resource)
const originalMarker = context.marker
const createdKeys = ['orders', 'inboundMeasurement', 'ocrInbound', 'testInbound']
const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound', 'allowAnyExistingPallet']
const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound', 'allowAnyExistingPallet', 'waitSplitVariant']
const createdSnapshot = new Map(createdKeys.map((key) => [key, { present: Object.hasOwn(context.createdResources, key), value: context.createdResources[key] }]))
const contextSnapshot = new Map(contextKeys.map((key) => [key, { present: Object.hasOwn(context.context, key), value: context.context[key] }]))
context.marker = resource.marker
@@ -114,6 +124,13 @@ async function measureOrder(context, resource) {
// Reuse that warehouse's real pallet master instead of requiring the bulk-data
// generator's two fixed Yiwu pallet codes.
context.context.allowAnyExistingPallet = true
context.context.waitSplitVariant = {
...(context.context.waitSplitVariant || {}),
// The production pricing service chooses the dominant real billing basis.
// Keep the CBM sample's measured gross weight below its measured volume
// weight so the fixture proves the actual VOLUME_CBM path.
grossWeightPerPalletKg: resource.pricingProfile?.chargeUnit === 'CBM' ? 20 : 100,
}
try {
const measurement = await import(new URL('./inbound-measurement.mjs', import.meta.url))
await measurement.run(context)
@@ -299,7 +316,8 @@ async function loadPricingEvidence(context) {
if (matches.length !== 1) continue
profiles.push({ versionId: id(version.id), rateId: id(rate.id), chargeUnit: rate.chargeUnit,
originWarehouseId: id(rate.originWarehouseId), channelCode: rate.channelCode,
pricingTaxMode: rate.pricingTaxMode, destinationCode: rate.destinationCode })
pricingTaxMode: rate.pricingTaxMode, destinationCode: rate.destinationCode,
businessType: rate.businessType || 'FBA' })
}
}
context.assert(MODULE, 'ACTIVE 价格簿包含 KG 与 CBM 可用真实组合', response,
@@ -308,6 +326,57 @@ async function loadPricingEvidence(context) {
return { activeVersionIds: active.map((item) => id(item.id)).filter(Boolean), profiles }
}
async function ensureCustomerChargeRule(context, resource, profile) {
if (context.financeFixtureHooks?.ensureCustomerChargeRule) {
return context.financeFixtureHooks.ensureCustomerChargeRule(context, resource, profile)
}
if (!resource?.tenantId || profile?.pricingTaxMode !== 'TAX_EXCLUDED') return null
const tenantId = id(resource.tenantId)
const inboundResponse = await context.request('GET',
`/api/platform/inbounds/${encodeURIComponent(resource.inboundId)}?tenantId=${encodeURIComponent(tenantId)}`)
const inbound = context.apiSuccess(MODULE, '读取不包税入库计费业务类型', inboundResponse,
`inboundId=${resource.inboundId}`, OPTIONS)
const businessType = inbound?.summary?.businessType || profile.businessType
if (!businessType) {
blocker(context, '取得不包税入库计费业务类型',
`inboundId=${resource.inboundId} 未返回 businessType,不能创建猜测的客户计费规则`)
return null
}
const listPath = `/api/platform/v1/fba-pricing/customer-rules?tenantId=${encodeURIComponent(tenantId)}&customerId=${encodeURIComponent(tenantId)}`
const listResponse = await context.request('GET', listPath)
const listed = context.apiSuccess(MODULE, '查询不包税客户计费规则', listResponse,
`customerId=${tenantId}, businessType=${businessType}`, OPTIONS)
const applicable = rows(listed).filter((rule) => rule?.status === 'ACTIVE'
&& rule?.businessType === businessType && rule?.taxMode === 'TAX_EXCLUDED')
let desired = applicable.find((rule) => rule?.chargeMode === profile.chargeUnit)
if (!desired) {
const effectiveFrom = new Date(Date.now() - 86_400_000).toISOString().slice(0, 19)
const body = {
ruleCode: `FIN_P1_${profile.chargeUnit}_${tenantId}`.slice(0, 50), customerId: tenantId,
customerName: `财务P1验收客户-${tenantId}`.slice(0, 128), businessType,
chargeMode: profile.chargeUnit, taxMode: 'TAX_EXCLUDED', conversionFactor: 1,
roundingScale: 3, roundingMode: 'HALF_UP', currency: 'CNY', effectiveFrom,
effectiveTo: null, remark: `${context.marker}-VOLUME_CBM`
}
const createResponse = await request(context, 'POST',
`/api/platform/v1/fba-pricing/customer-rules/versions?tenantId=${encodeURIComponent(tenantId)}`, body)
desired = context.apiSuccess(MODULE, '创建不包税客户计费规则', createResponse,
`customerId=${tenantId}, chargeMode=${profile.chargeUnit}`, OPTIONS)
}
const needsDefault = applicable.length > 1
|| (applicable.length === 1 && id(applicable[0]?.id) !== id(desired?.id))
if (desired?.id && needsDefault && desired.defaultRule !== true) {
const defaultResponse = await request(context, 'PUT',
`/api/platform/v1/fba-pricing/customer-rules/${encodeURIComponent(desired.id)}/default?tenantId=${encodeURIComponent(tenantId)}`)
context.apiSuccess(MODULE, '设置不包税客户唯一默认计费规则', defaultResponse,
`ruleId=${desired.id}, chargeMode=${profile.chargeUnit}`, OPTIONS)
}
context.assert(MODULE, '不包税自动计费具备客户计费方式', listResponse,
Boolean(desired?.id) && desired?.chargeMode === profile.chargeUnit,
`customerId=${tenantId}, chargeMode=${desired?.chargeMode || 'missing'}, ruleId=${id(desired?.id) || 'missing'}`, OPTIONS)
return desired || null
}
async function createMarkerContainer(context, resource) {
if (context.financeFixtureHooks?.createMarkerContainer) return context.financeFixtureHooks.createMarkerContainer(context, resource)
if (!resource) {
@@ -387,12 +456,15 @@ export async function run(context) {
const pricingEvidence = await loadPricingEvidence(context)
const resources = []
const profiles = pricingEvidence.profiles || []
for (const [index, suffix] of ['AUTO1', 'AUTO2', 'AUTO3', 'AUTO4', 'MANUAL'].entries()) {
for (const [index, suffix] of ['AUTO1', 'AUTO2', 'AUTO3', 'AUTO4', 'MANUAL', 'CONTAINER'].entries()) {
const resource = await createOrder(context, suffix, profiles[index % profiles.length] || null)
context.assert(MODULE, `创建 ${suffix} marker 隔离订单与入库单`, null, Boolean(resource),
resource ? `orderId=${resource.orderId}, inboundId=${resource.inboundId}` : `marker=${context.marker}-${suffix} 未返回独立入库单`, OPTIONS)
if (resource) resources.push(resource)
}
for (const resource of resources.filter((item) => item.pricingProfile?.pricingTaxMode === 'TAX_EXCLUDED')) {
await ensureCustomerChargeRule(context, resource, resource.pricingProfile)
}
const measured = []
for (const resource of resources) {
const result = await measureOrder(context, resource)
@@ -430,7 +502,7 @@ export async function run(context) {
if (fee) fixtures.push(fee)
}
}
const manualResources = [measured.at(-1)].filter(Boolean)
const manualResources = [measured[4]].filter(Boolean)
if (feeItems.PAYABLE && supplier?.id) {
for (const [index, manualResource] of manualResources.entries()) {
const fee = await createManualFee(context, manualResource, feeItems.PAYABLE, 'PAYABLE', 80 + index * 10,
@@ -440,7 +512,11 @@ export async function run(context) {
}
}
const container = (measured[0] ? await createMarkerContainer(context, measured[0]) : null)
// A source whose fee has already been sent is intentionally frozen against
// loading-plan membership changes. Use a dedicated measured order for the
// container fixture so finance setup never conflicts with that invariant.
const containerResource = measured[5]
const container = (containerResource ? await createMarkerContainer(context, containerResource) : null)
|| context.createdResources?.phase3ContainerLoading
const containerOwned = (container?.marker === context.marker || String(container?.marker || '').startsWith(`${context.marker}-`))
&& container?.loadingPlanId && container?.containerBookingId