This commit is contained in:
2026-08-12 12:18:59 +08:00
parent 5a26a84f78
commit 0fceece378
8 changed files with 113 additions and 31 deletions
@@ -21,8 +21,8 @@ test('fixture covers weight, volume, manual order, and container allocation cont
})
test('fee writes use verified Firstleg paths and do not fabricate historical IDs', () => {
assert.match(source, /\/api\/platform\/v1\/inbounds\/.*fee-details\?accountId=/)
assert.match(source, /\/api\/platform\/v1\/loading-plans\/.*fee-details\?accountId=/)
assert.match(source, /listPath = `\$\{basePath\}\?accountId=/)
assert.match(source, /const basePath = `\/api\/platform\/v1\/loading-plans\//)
assert.match(source, /sourceType: detail\?\.sourceType \|\| 'MANUAL'/)
assert.match(source, /created\?\.marker === context\.marker/)
assert.match(source, /\/fees`/)
@@ -91,13 +91,15 @@ test('run behavior emits consumer shape, both billing bases, distinct dates, sup
assert.equal(fixture.orders[0].waybillId, 'WB-1')
assert.equal(fixture.orders[0].actualWeightKg, 12.5)
assert.equal(fixture.orders[0].totalVolumeCbm, 0.072)
assert.deepEqual(new Set(fixture.feeSources.map((item) => item.billingBasis).filter((value) => value !== 'MANUAL_ORDER_FEE')),
assert.deepEqual(new Set(fixture.feeSources.map((item) => item.billingBasis).filter((value) => ['WEIGHT_KG', 'VOLUME_CBM'].includes(value))),
new Set(['WEIGHT_KG', 'VOLUME_CBM']))
assert.ok(new Set(fixture.feeSources.map((item) => item.businessDate)).size >= 2)
assert.equal(fixture.supplier.supplierId, 'PARTNER-1')
assert.equal(fixture.supplier.supplierName, '往来单位一')
assert.equal(fixture.voucherFileId, 'VOUCHER-1')
assert.ok(Array.isArray(fixture.allocations))
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)
})
test('run behavior preserves null IDs and deployment blockers when supplier, voucher and container are unavailable', async () => {
@@ -113,3 +115,16 @@ test('run behavior preserves null IDs and deployment blockers when supplier, vou
assert.ok(context.steps.some((step) => step.deploymentBlocker && step.name.includes('往来单位')))
assert.ok(context.steps.some((step) => step.deploymentBlocker && step.name.includes('voucherFileId')))
})
test('manual fee approval path keeps accountId as a query parameter', () => {
assert.match(source, /basePath = `\/api\/platform\/v1\/inbounds\/.*fee-details`/)
assert.match(source, /\$\{basePath\}\/\$\{feeId\}\/approve\?accountId=/)
assert.doesNotMatch(source, /fee-details\?accountId=\$\{encodeURIComponent\(resource\.tenantId\)\}`\)/)
})
test('fixture creates four real receivable manual sources before payable source', () => {
assert.match(source, /const receivableResources = measured\.slice\(0, 4\)/)
assert.match(source, /feeItems\.RECEIVABLE, 'RECEIVABLE'/)
assert.match(source, /'MANUAL_RECEIVABLE'/)
assert.match(source, /dateOffset\(-1 - \(index % 2\)\)/)
})
@@ -77,17 +77,18 @@ async function loadFeeItem(context, direction) {
async function createManualFee(context, resource, feeItem, direction, amount, basis, businessDate) {
if (context.financeFixtureHooks?.createManualFee) return context.financeFixtureHooks.createManualFee(context, resource, feeItem, direction, amount, basis, businessDate)
const path = `/api/platform/v1/inbounds/${encodeURIComponent(resource.inboundId)}/fee-details?accountId=${encodeURIComponent(resource.tenantId)}`
const basePath = `/api/platform/v1/inbounds/${encodeURIComponent(resource.inboundId)}/fee-details`
const listPath = `${basePath}?accountId=${encodeURIComponent(resource.tenantId)}`
const body = { feeItemId: Number(feeItem.id), direction, businessDate,
unitPrice: amount, currency: 'CNY', amount, settlementIncluded: true, remark: `${context.marker}-${basis}` }
const response = await request(context, 'POST', path, body)
const response = await request(context, 'POST', listPath, body)
const detail = context.apiSuccess(MODULE, `${basis}人工费用明细`, response, `inboundId=${resource.inboundId}`, OPTIONS)
const feeId = id(detail?.id)
context.assert(MODULE, `${basis}费用来源身份和金额`, response,
Boolean(feeId) && id(detail?.inboundId) === id(resource.inboundId) && Number(detail?.amount) === Number(amount),
`feeId=${feeId || 'missing'}, amount=${detail?.amount ?? 'missing'}`, OPTIONS)
if (!feeId) return null
const approve = await request(context, 'POST', `${path}/${feeId}/approve`)
const approve = await request(context, 'POST', `${basePath}/${feeId}/approve?accountId=${encodeURIComponent(resource.tenantId)}`)
context.apiSuccess(MODULE, `${basis}费用审核`, approve, '', OPTIONS)
return { feeSourceId: feeId, sourceType: detail?.sourceType || 'MANUAL', direction, amount: Number(amount), businessDate,
currency: 'CNY', billingBasis: basis, inboundId: id(resource.inboundId), orderId: id(resource.orderId),
@@ -218,10 +219,21 @@ export async function run(context) {
`activePriceVersions=${pricingEvidence.activeVersionIds?.join(',') || 'none'};本轮隔离订单未产生 ${basis} 来源,价格配置/订单条件不支持,不能伪装成功`)
}
}
const manualResource = measured.at(-1)
if (manualResource && feeItems.PAYABLE && supplier?.id) {
const fee = await createManualFee(context, manualResource, feeItems.PAYABLE, 'PAYABLE', 80, 'MANUAL_ORDER_FEE', dateOffset(-1))
if (fee) fixtures.push({ ...fee, supplierId: id(supplier.id), partnerId: id(supplier.id), supplierName: supplier.partnerName || null })
const receivableResources = measured.slice(0, 4)
if (feeItems.RECEIVABLE) {
for (const [index, resource] of receivableResources.entries()) {
const fee = await createManualFee(context, resource, feeItems.RECEIVABLE, 'RECEIVABLE', 100 + index * 10,
'MANUAL_RECEIVABLE', dateOffset(-1 - (index % 2)))
if (fee) fixtures.push(fee)
}
}
const manualResources = [measured.at(-1)].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,
'MANUAL_ORDER_FEE', dateOffset(-3 - index))
if (fee) fixtures.push({ ...fee, supplierId: id(supplier.id), partnerId: id(supplier.id), supplierName: supplier.partnerName || null })
}
}
const container = context.createdResources?.phase3ContainerLoading
@@ -229,12 +241,13 @@ export async function run(context) {
&& container?.loadingPlanId && container?.containerBookingId
let containerFixture = null
if (containerOwned && feeItems.PAYABLE && supplier?.id) {
const path = `/api/platform/v1/loading-plans/${encodeURIComponent(container.loadingPlanId)}/fee-details?accountId=${encodeURIComponent(container.tenantId)}`
const basePath = `/api/platform/v1/loading-plans/${encodeURIComponent(container.loadingPlanId)}/fee-details`
const path = `${basePath}?accountId=${encodeURIComponent(container.tenantId)}`
const body = { feeItemId: Number(feeItems.PAYABLE.id), direction: 'PAYABLE', businessDate: dateOffset(-2), amount: 300, currency: 'CNY', settlementIncluded: true, remark: `${context.marker}-CONTAINER_SHARED` }
const response = await request(context, 'POST', path, body)
const detail = context.apiSuccess(MODULE, '创建按柜费用来源', response, '', OPTIONS)
const feeId = id(detail?.id)
if (feeId) { const approval = await request(context, 'POST', `${path}/${feeId}/approve`); context.apiSuccess(MODULE, '审核按柜费用来源', approval, '', OPTIONS) }
if (feeId) { const approval = await request(context, 'POST', `${basePath}/${feeId}/approve?accountId=${encodeURIComponent(container.tenantId)}`); context.apiSuccess(MODULE, '审核按柜费用来源', approval, '', OPTIONS) }
containerFixture = { feeSourceId: feeId || null, sourceType: 'MANUAL', direction: 'PAYABLE', amount: 300,
businessDate: body.businessDate, currency: 'CNY', billingBasis: 'CONTAINER_SHARED',
supplierId: supplier?.id ? id(supplier.id) : null, partnerId: supplier?.id ? id(supplier.id) : null,
@@ -41,7 +41,7 @@ function fakeContext(overrides = {}) {
} }
}
test('values 兼容数组、records 和 named map', () => {
test('values helper supports nested arrays while run consumes frozen top-level arrays', () => {
assert.deepEqual(values([{ id: 1 }]), [{ id: 1 }])
assert.deepEqual(values({ records: [{ id: 2 }] }), [{ id: 2 }])
assert.deepEqual(values({ marker: 'x', selected: { id: 3 } }), [{ id: 3 }])
@@ -83,3 +83,20 @@ test('缺 fixture 或真实 voucher 时记录 blocker 且不写接口', async ()
assert.equal(noVoucher.steps.length, 1); assert.equal(noVoucher.steps[0].passed, false)
assert.equal(noVoucher.steps[0].options.deploymentBlocker, true)
})
test('AR/AP 数组为空或不完整时原子阻断且不查询、不写入', async () => {
const empty = fakeContext({ statements: [], receivableSettlements: [], payableSettlements: [] })
await run(empty)
assert.equal(empty.calls.length, 0)
assert.equal(empty.gaps.length, 1)
assert.equal(empty.steps[0].passed, false)
assert.match(empty.steps[0].evidence, /actual AR=0, AP=0/)
const partial = fakeContext({ receivableSettlements: [
{ settlementId: '101', statementId: '11', direction: 'RECEIVABLE', status: 'ISSUED' },
] })
await run(partial)
assert.equal(partial.calls.length, 0)
assert.equal(partial.gaps.length, 1)
assert.match(partial.steps[0].evidence, /actual AR=1, AP=1/)
})
@@ -54,8 +54,9 @@ export async function run(context) {
gap(context, '取得本轮财务 Fixture', 'financeFullFixtures', '缺少匹配 marker 的 financeFullFixtures,禁止借历史单据')
return
}
const statements = values(fixtures.statements)
const receivables = values(fixtures.receivableSettlements)
const statements = Array.isArray(fixtures.statements) ? fixtures.statements.filter(Boolean) : []
const receivables = Array.isArray(fixtures.receivableSettlements) ? fixtures.receivableSettlements.filter(Boolean) : []
const payables = Array.isArray(fixtures.payableSettlements) ? fixtures.payableSettlements.filter(Boolean) : []
const customerId = id(fixtures.customerId)
const voucherFileId = id(fixtures.voucherFileId ?? fixtures.voucher?.fileId ?? fixtures.voucher?.id)
if (!customerId || !voucherFileId) {
@@ -63,13 +64,26 @@ export async function run(context) {
return
}
const issuedStatementIds = new Set(statements.filter((item) => item.status === 'ISSUED').map(statementId).filter(Boolean))
const validReceivables = receivables.filter((item) => item.direction === 'RECEIVABLE'
&& settlementId(item) && statementId(item) && issuedStatementIds.has(statementId(item)))
const validPayables = payables.filter((item) => item.direction === 'PAYABLE' && settlementId(item) && item.supplierId)
const arSettlementIds = new Set(validReceivables.map(settlementId))
const arStatementIds = new Set(validReceivables.map(statementId))
if (validReceivables.length !== 3 || arSettlementIds.size !== 3 || arStatementIds.size !== 3 || validPayables.length !== 1) {
gap(context, '取得完整收付结算 Fixture', 'three AR and one AP settlements',
`需要 3 张关联本轮 ISSUED 对账单的独立 AR 和 1 张带 supplierId 的 AP;actual AR=${validReceivables.length}, AP=${validPayables.length}`,
'incomplete-settlement-fixture')
return
}
const manualIds = values(fixtures.feeSources).filter((fee) => fee.sourceType === 'MANUAL').map((fee) => id(fee.feeSourceId))
const usedIds = new Set(statements.flatMap((statement) => values(statement.feeSourceIds).map(id)))
context.assert(MODULE, '手动上传费用出账后来源完结', null,
manualIds.length > 0 && manualIds.every((feeId) => usedIds.has(feeId)),
`manual=${manualIds.length}, settled=${manualIds.filter((feeId) => usedIds.has(feeId)).length}`, OPTIONS)
const multiTargets = receivables.filter((item) => settlementId(item) && statementId(item)).slice(0, 3)
const multiTargets = validReceivables
if (multiTargets.length < 3) gap(context, '取得三张应收结算单', 'multi receipt allocation', '本轮没有三张独占应收单,禁止借历史单据')
else {
const details = await Promise.all(multiTargets.map((item) => detail(context, settlementId(item))))
@@ -81,7 +95,7 @@ export async function run(context) {
fixtures.receipts = { ...(fixtures.receipts || {}), multi: data(response) }
}
const primary = receivables.find((item) => settlementId(item) && statementId(item))
const primary = validReceivables[0]
if (!primary) gap(context, '取得应收结算单', 'receivable partial/full', 'receivableSettlements 未提供 settlementId 与 statementId')
else {
const initial = await detail(context, settlementId(primary))
@@ -95,7 +109,7 @@ export async function run(context) {
fixtures.settlementFinalStates = { ...(fixtures.settlementFinalStates || {}), receivable: final.settlement }
}
const payable = values(fixtures.payableSettlements).find((item) => item.direction === 'PAYABLE' && settlementId(item))
const payable = validPayables[0]
const payableId = settlementId(payable)
if (!payableId) gap(context, '取得应付结算单', 'payable partial/full/overpayment', '前置 Fixture 的 payableSettlements 数组为空或缺少 PAYABLE 结算单')
else {