This commit is contained in:
2026-08-12 17:34:03 +08:00
parent b5649cf8ae
commit b3b8d94957
10 changed files with 333 additions and 13 deletions
@@ -37,7 +37,7 @@ test('所有财务写操作使用 marker 幂等键并上传真实凭证', () =>
assert.match(source, /finance-full-fixtures\.mjs/)
assert.match(source, /context\.createdResources\.financeDemo = matrix/)
assert.match(source, /`source-\$\{role\}-\$\{source\.feeSourceId\}-include`/)
assert.match(source, /`ap-\$\{role\}-\$\{source\.feeSourceId\}-create`/)
assert.match(source, /`ap-\$\{role\}-\$\{source\.feeSourceId\}-\$\{source\.supplierId\}-create`/)
})
test('额外应付审核路径先追加 feeId 再追加 accountId 查询参数', () => {
@@ -49,13 +49,17 @@ test('费用来源使用 operationVersion,应付供应商长整型保持字符
assert.match(source, /expectedVersion: operationVersion\(current\.value\)/)
assert.match(source, /supplierId: id\(source\.supplierId\)/)
assert.doesNotMatch(source, /supplierId: Number\(source\.supplierId\)/)
assert.match(source, /partnerId, partnerName:/)
assert.doesNotMatch(source, /partnerId: Number\(partnerId\)/)
})
test('恢复模式只续补固定报告来源并校验 AR AP 金额状态', () => {
assert.match(source, /process\.env\.FINANCE_DEMO_RECOVERY_REPORT/)
assert.match(source, /恢复报告不是 FIN-DEMO-20260812/)
assert.match(source, /拒绝借历史数据/)
assert.match(source, /supplierId: id\(fixture\.supplier\?\.supplierId\)/)
assert.match(source, /const recoverySupplierId = role === 'apPartial'/)
assert.match(source, /matrix\.payableSettlements\?\.apPaid\?\.supplierId/)
assert.match(source, /pageSize=200/)
assert.match(source, /const payableIds = \['307', '310'\]/)
assert.match(source, /finance-container-partner-not-deployed/)
assert.match(source, /findPayableBySource/)
@@ -79,7 +79,7 @@ async function createExtraPayable(context, fixture, index) {
if (!feeItem || !inboundId || !partnerId) return null
const body = { feeItemId: Number(feeItem.id), direction: 'PAYABLE', businessDate: new Date().toISOString().slice(0, 10),
unitPrice: 90 + index * 10, amount: 90 + index * 10, currency: 'CNY', settlementIncluded: true,
partnerId: Number(partnerId), partnerName: fixture.supplier?.supplierName || null, remark: `${MARKER}-AP-${index}` }
partnerId, partnerName: fixture.supplier?.supplierName || null, remark: `${MARKER}-AP-${index}` }
const basePath = `/api/platform/v1/inbounds/${inboundId}/fee-details`
const createdResponse = await write(context, 'POST', `${basePath}?accountId=10001`, body, `extra-payable-${index}`)
const created = data(createdResponse)
@@ -133,7 +133,7 @@ async function createAp(context, fixture, source, role) {
const response = await write(context, 'POST', `${BASE}/payable-settlements`, {
customerId: 10001, supplierId: id(source.supplierId), feeSourceIds: [Number(source.feeSourceId)],
dueDate: new Date().toISOString().slice(0, 10), currency: 'CNY',
}, `ap-${role}-${source.feeSourceId}-create`)
}, `ap-${role}-${source.feeSourceId}-${source.supplierId}-create`)
const value = data(response)
record(context, `${role} 创建应付结算单`, response, ok(response) && settlementId(value),
`settlementId=${settlementId(value)}, status=${value?.paymentStatus}`)
@@ -141,7 +141,7 @@ async function createAp(context, fixture, source, role) {
}
async function findPayableBySource(context, feeSourceId) {
const listResponse = await context.request('GET', `${BASE}/payable-settlements?customerId=10001`)
const listResponse = await context.request('GET', `${BASE}/payable-settlements?customerId=10001&page=1&pageSize=200`)
for (const item of rows(data(listResponse))) {
const detailResponse = await context.request('GET', `${BASE}/settlements/${settlementId(item)}`)
const value = data(detailResponse)
@@ -205,8 +205,11 @@ async function recoverExistingDemo(context, reportPath) {
}
const approved = await approveAndInclude(context, { feeSourceId: payableIds[index] }, role)
const existing = approved?.statementGenerated === true ? await findPayableBySource(context, payableIds[index]) : null
const recoverySupplierId = role === 'apPartial'
? id(matrix.payableSettlements?.apPaid?.supplierId)
: id(fixture.supplier?.supplierId)
const settlement = existing?.value ?? await createAp(context, fixture, {
feeSourceId: payableIds[index], supplierId: id(fixture.supplier?.supplierId),
feeSourceId: payableIds[index], supplierId: recoverySupplierId,
}, role)
if (existing?.value) record(context, `${role} 回读已有应付结算单`, existing.response, true,
`settlementId=${settlementId(existing.value)}, feeSourceId=${payableIds[index]}`)
@@ -0,0 +1,52 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const here = path.dirname(fileURLToPath(import.meta.url))
const source = fs.readFileSync(path.join(here, 'finance-extended-fees.mjs'), 'utf8')
test('extended fee module is fixed-marker isolated and exports the acceptance entry', () => {
assert.match(source, /FIXED_MARKER = 'FIN-EXT-FEE-20260812'/)
assert.match(source, /export async function run\(context\)/)
assert.match(source, /baseFixture\.marker !== FIXED_MARKER/)
assert.match(source, /createdResources\.financeExtendedFees/)
})
test('extended fee module covers six orders and both automatic billing bases', () => {
assert.match(source, /创建第六张独立订单与入库单/)
assert.match(source, /resources\.length >= 6/)
assert.match(source, /'WEIGHT_KG', 'VOLUME_CBM'/)
assert.match(source, /length >= 2/)
})
test('manual status matrix covers receivable, payable, rejection, approval and void', () => {
for (const name of ['AR-PENDING', 'AR-REJECTED', 'AR-APPROVED', 'AP-PENDING', 'AP-APPROVED', 'AR-VOID']) {
assert.match(source, new RegExp(name))
}
assert.match(source, /expectedFinance: 'AVAILABLE'/)
assert.match(source, /expectedFinance: 'ABSENT'/)
assert.match(source, /作废来源不可选择出账/)
})
test('every source is traced through order, inbound, fee, and Finance pool reads', () => {
assert.match(source, /readOrderInboundAndFee/)
assert.match(source, /financeState/)
assert.match(source, /回读订单/)
assert.match(source, /回读入库单/)
assert.match(source, /回读费用/)
assert.match(source, /回读 Finance 池/)
})
test('writes carry marker idempotency and include replay and stale-version conflict', () => {
assert.match(source, /'Idempotency-Key': key\(action\)/)
assert.match(source, /同键同参人工费用创建幂等重放/)
assert.match(source, /expectedSourceVersion=999/)
assert.match(source, /错误 sourceVersion 调整产生并发版本冲突/)
})
test('uses existing ACTIVE partner and never creates one', () => {
assert.match(source, /\/api\/platform\/v1\/partners\?page=1&size=200&status=ACTIVE/)
assert.doesNotMatch(source, /request\(context, 'POST', '\/api\/platform\/v1\/partners'/)
})
@@ -0,0 +1,244 @@
const MODULE = 'finance-extended-fees'
const OPTIONS = { scenario: 'finance-extended-real-fee-statuses', deploymentBlocker: true }
const FIXED_MARKER = 'FIN-EXT-FEE-20260812'
const BASE = '/api/platform/finance/v1'
function id(value) { return value == null ? '' : String(value) }
function rows(value) { return Array.isArray(value) ? value : Array.isArray(value?.records) ? value.records : [] }
function dateOffset(days) { const value = new Date(); value.setUTCDate(value.getUTCDate() + days); return value.toISOString().slice(0, 10) }
function key(action) { return `${FIXED_MARKER}-${action}`.slice(0, 128) }
function request(context, method, path, body, action) {
return context.request(method, path, body, context.token, { 'Idempotency-Key': key(action) })
}
function blocker(context, name, evidence, response = null) {
context.addStep(MODULE, name, response, false, evidence, OPTIONS)
}
async function createSixthOrder(context) {
const originalMarker = context.marker
const original = context.createdResources.orders
context.marker = `${FIXED_MARKER}-STATUS6`
try {
const orders = await import(new URL('./orders.mjs', import.meta.url))
await orders.run(context)
const resource = context.createdResources.orders
return resource?.marker === context.marker && resource?.orderId && resource?.inboundId ? { ...resource } : null
} finally {
context.marker = originalMarker
if (original) context.createdResources.orders = original
else delete context.createdResources.orders
}
}
async function loadFeeItem(context, direction) {
const response = await context.request('GET', '/api/platform/v1/fee-items?page=1&pageSize=200')
const data = context.apiSuccess(MODULE, `读取 ${direction} ACTIVE 费用项目`, response, '', OPTIONS)
return rows(data).find((item) => item?.status === 'ACTIVE' && (direction === 'RECEIVABLE'
? item.warehouseReceivable === true : item.warehousePayable === true)) || null
}
async function loadActivePartner(context) {
const response = await context.request('GET', '/api/platform/v1/partners?page=1&size=200&status=ACTIVE')
const data = context.apiSuccess(MODULE, '读取现有 ACTIVE 往来单位', response, '', OPTIONS)
return rows(data).find((item) => item?.status === 'ACTIVE') || null
}
async function createFee(context, resource, item, spec, partner) {
const base = `/api/platform/v1/inbounds/${encodeURIComponent(resource.inboundId)}/fee-details`
const path = `${base}?accountId=${encodeURIComponent(resource.tenantId)}`
const body = {
feeItemId: Number(item.id), direction: spec.direction, businessDate: spec.businessDate,
unitPrice: spec.amount, currency: 'CNY', amount: spec.amount, settlementIncluded: true,
partnerId: spec.direction === 'PAYABLE' ? String(partner.id) : null,
partnerName: spec.direction === 'PAYABLE' ? (partner.partnerName || partner.name || null) : null,
remark: `${FIXED_MARKER}-${spec.name}`,
}
const response = await request(context, 'POST', path, body, `create-${spec.name}`)
const detail = context.apiSuccess(MODULE, `创建${spec.label}`, response,
`orderId=${resource.orderId}, inboundId=${resource.inboundId}`, OPTIONS)
const feeId = id(detail?.id)
context.assert(MODULE, `${spec.label}初始状态和归属`, response,
Boolean(feeId) && id(detail?.inboundId) === id(resource.inboundId)
&& detail?.reviewStatus === 'PENDING' && Number(detail?.amount) === spec.amount,
`feeId=${feeId || 'missing'}, review=${detail?.reviewStatus || 'missing'}, amount=${detail?.amount ?? 'missing'}`, OPTIONS)
return feeId ? { ...spec, feeId, base, resource, createResponse: response } : null
}
async function readOrderInboundAndFee(context, fixture) {
const orderResponse = await context.request('GET',
`/api/platform/v1/orders/first-leg/${encodeURIComponent(fixture.resource.orderId)}?tenantId=${encodeURIComponent(fixture.resource.tenantId)}`)
const order = context.apiSuccess(MODULE, `${fixture.label}回读订单`, orderResponse,
`orderId=${fixture.resource.orderId}`, OPTIONS)
const inboundResponse = await context.request('GET',
`/api/platform/inbounds/${encodeURIComponent(fixture.resource.inboundId)}?tenantId=${encodeURIComponent(fixture.resource.tenantId)}`)
const inbound = context.apiSuccess(MODULE, `${fixture.label}回读入库单`, inboundResponse,
`inboundId=${fixture.resource.inboundId}`, OPTIONS)
const feeResponse = await context.request('GET',
`${fixture.base}?accountId=${encodeURIComponent(fixture.resource.tenantId)}`)
const feeRows = context.apiSuccess(MODULE, `${fixture.label}回读费用`, feeResponse, '', OPTIONS)
const fee = rows(feeRows).find((item) => id(item.id) === fixture.feeId)
context.assert(MODULE, `${fixture.label}订单、入库和费用关联一致`, feeResponse,
Boolean(order) && Boolean(inbound) && id(fee?.inboundId) === id(fixture.resource.inboundId),
`order=${Boolean(order)}, inbound=${Boolean(inbound)}, fee=${Boolean(fee)}`, OPTIONS)
return { order, inbound, fee, responses: { orderResponse, inboundResponse, feeResponse } }
}
async function financeState(context, customerId, fixture) {
const response = await context.request('GET',
`${BASE}/fee-pool?customerId=${encodeURIComponent(customerId)}&direction=${fixture.direction}`)
const data = context.apiSuccess(MODULE, `${fixture.label}回读 Finance 池`, response, '', OPTIONS)
const source = rows(data).find((item) => id(item.feeSourceId || item.id) === fixture.feeId)
const shouldExist = fixture.expectedFinance === 'AVAILABLE'
context.assert(MODULE, `${fixture.label} Finance 池状态`, response,
shouldExist ? source?.sourceStatus === 'AVAILABLE' : !source,
`expected=${fixture.expectedFinance}, actual=${source?.sourceStatus || 'ABSENT'}, feeSourceId=${fixture.feeId}`, OPTIONS)
return { response, source: source || null }
}
export async function run(context) {
const start = context.steps.length
const originalMarker = context.marker
context.marker = FIXED_MARKER
try {
const full = await import(new URL('./finance-full-fixtures.mjs', import.meta.url))
await full.run(context)
const baseFixture = context.createdResources.financeFullFixtures
if (!baseFixture || baseFixture.marker !== FIXED_MARKER) {
blocker(context, '生成基础重量/体积/人工费用 fixture',
`financeFullFixtures.marker=${baseFixture?.marker || 'missing'};禁止消费其他 marker`)
return
}
const automatic = baseFixture.feeSources.filter((item) => item.sourceType === 'FBA_PRICING')
for (const basis of ['WEIGHT_KG', 'VOLUME_CBM']) {
context.assert(MODULE, `${basis} 至少两条真实自动费用`, null,
automatic.filter((item) => item.billingBasis === basis).length >= 2,
`count=${automatic.filter((item) => item.billingBasis === basis).length}`, OPTIONS)
}
const sixth = await createSixthOrder(context)
context.assert(MODULE, '创建第六张独立订单与入库单', null, Boolean(sixth),
sixth ? `orderId=${sixth.orderId}, inboundId=${sixth.inboundId}` : 'missing', OPTIONS)
const resources = [...baseFixture.orders.map((item) => ({ ...item, tenantId: baseFixture.customerId })), sixth].filter(Boolean)
context.assert(MODULE, '本轮至少六张订单/入库单且无重复', null,
resources.length >= 6 && new Set(resources.map((item) => id(item.orderId))).size >= 6
&& new Set(resources.map((item) => id(item.inboundId))).size >= 6,
`orders=${resources.length}, uniqueOrders=${new Set(resources.map((item) => id(item.orderId))).size}, uniqueInbounds=${new Set(resources.map((item) => id(item.inboundId))).size}`, OPTIONS)
const receivableItem = await loadFeeItem(context, 'RECEIVABLE')
const payableItem = await loadFeeItem(context, 'PAYABLE')
const partner = await loadActivePartner(context)
if (!receivableItem || !payableItem || !partner?.id) {
blocker(context, '费用状态矩阵前置主数据完整',
`receivableFeeItem=${id(receivableItem?.id) || 'missing'}, payableFeeItem=${id(payableItem?.id) || 'missing'}, activePartner=${id(partner?.id) || 'missing'}`)
return
}
const specs = [
{ name: 'AR-PENDING', label: '手工应收待审核', direction: 'RECEIVABLE', amount: 111, businessDate: dateOffset(-5), action: null, expectedReview: 'PENDING', expectedFinance: 'ABSENT' },
{ name: 'AR-REJECTED', label: '手工应收驳回', direction: 'RECEIVABLE', amount: 112, businessDate: dateOffset(-4), action: 'reject', expectedReview: 'REJECTED', expectedFinance: 'ABSENT' },
{ name: 'AR-APPROVED', label: '手工应收已审核待出账', direction: 'RECEIVABLE', amount: 113, businessDate: dateOffset(-3), action: 'approve', expectedReview: 'APPROVED', expectedFinance: 'AVAILABLE' },
{ name: 'AP-PENDING', label: '手工应付待审核', direction: 'PAYABLE', amount: 81, businessDate: dateOffset(-2), action: null, expectedReview: 'PENDING', expectedFinance: 'ABSENT' },
{ name: 'AP-APPROVED', label: '手工应付已审核', direction: 'PAYABLE', amount: 82, businessDate: dateOffset(-1), action: 'approve', expectedReview: 'APPROVED', expectedFinance: 'AVAILABLE' },
{ name: 'AR-VOID', label: '手工应收作废', direction: 'RECEIVABLE', amount: 114, businessDate: dateOffset(-6), action: 'approve-void', expectedReview: 'APPROVED', expectedFinance: 'ABSENT' },
]
const fixtures = []
for (const [index, spec] of specs.entries()) {
const resource = resources[index % resources.length]
const fixture = await createFee(context, resource,
spec.direction === 'RECEIVABLE' ? receivableItem : payableItem, spec, partner)
if (!fixture) continue
if (spec.action === 'reject') {
const response = await request(context, 'POST', `${fixture.base}/${fixture.feeId}/reject?accountId=${encodeURIComponent(resource.tenantId)}`,
{ reason: FIXED_MARKER }, `reject-${spec.name}`)
context.apiSuccess(MODULE, `${spec.label}执行驳回`, response, '', OPTIONS)
} else if (spec.action?.startsWith('approve')) {
const response = await request(context, 'POST', `${fixture.base}/${fixture.feeId}/approve?accountId=${encodeURIComponent(resource.tenantId)}`,
undefined, `approve-${spec.name}`)
context.apiSuccess(MODULE, `${spec.label}执行审核`, response, '', OPTIONS)
if (spec.action === 'approve-void') {
const voidResponse = await request(context, 'POST', `${fixture.base}/${fixture.feeId}/void?accountId=${encodeURIComponent(resource.tenantId)}`,
{ expectedSourceVersion: 1 }, `void-${spec.name}`)
context.apiSuccess(MODULE, `${spec.label}执行作废`, voidResponse, '', OPTIONS)
}
}
fixtures.push(fixture)
}
const conflictTarget = fixtures.find((item) => item.name === 'AR-APPROVED')
if (conflictTarget) {
const adjustBody = { feeItemId: Number(receivableItem.id), direction: 'RECEIVABLE', businessDate: conflictTarget.businessDate,
unitPrice: 115, currency: 'CNY', amount: 115, settlementIncluded: true,
partnerId: null, partnerName: null, remark: `${FIXED_MARKER}-CONFLICT` }
const conflict = await request(context, 'POST',
`${conflictTarget.base}/${conflictTarget.feeId}/adjust?accountId=${encodeURIComponent(conflictTarget.resource.tenantId)}&expectedSourceVersion=999`,
adjustBody, 'stale-adjust')
context.assert(MODULE, '错误 sourceVersion 调整产生并发版本冲突', conflict,
conflict?.body?.code !== '200',
`code=${conflict?.body?.code || 'missing'}, requestId=${conflict?.requestId || 'missing'}, traceId=${conflict?.traceId || 'missing'}`, OPTIONS)
}
const replayTarget = fixtures.find((item) => item.name === 'AR-PENDING')
if (replayTarget) {
const replayPath = `${replayTarget.base}?accountId=${encodeURIComponent(replayTarget.resource.tenantId)}`
const replayBody = { feeItemId: Number(receivableItem.id), direction: 'RECEIVABLE', businessDate: replayTarget.businessDate,
unitPrice: 116, currency: 'CNY', amount: 116, settlementIncluded: true,
partnerId: null, partnerName: null, remark: `${FIXED_MARKER}-IDEMPOTENT` }
const first = await request(context, 'POST', replayPath, replayBody, 'idempotent-create')
const second = await request(context, 'POST', replayPath, replayBody, 'idempotent-create')
const firstData = first?.body?.data; const secondData = second?.body?.data
context.assert(MODULE, '同键同参人工费用创建幂等重放', second,
first?.body?.code === '200' && second?.body?.code === '200' && id(firstData?.id) === id(secondData?.id),
`first=${id(firstData?.id) || 'missing'}, replay=${id(secondData?.id) || 'missing'}, requestId=${second?.requestId || 'missing'}`, OPTIONS)
}
const statusMatrix = []
for (const fixture of fixtures) {
const read = await readOrderInboundAndFee(context, fixture)
context.assert(MODULE, `${fixture.label}最终审核状态`, read.responses.feeResponse,
read.fee?.reviewStatus === fixture.expectedReview,
`expected=${fixture.expectedReview}, actual=${read.fee?.reviewStatus || 'missing'}, publication=${read.fee?.publicationStatus || 'missing'}`, OPTIONS)
const finance = await financeState(context, baseFixture.customerId, fixture)
statusMatrix.push({ name: fixture.name, label: fixture.label, orderId: id(fixture.resource.orderId),
orderNo: fixture.resource.orderNo || null, inboundId: id(fixture.resource.inboundId), feeId: fixture.feeId,
direction: fixture.direction, businessDate: fixture.businessDate, amount: fixture.amount,
reviewStatus: read.fee?.reviewStatus || null, publicationStatus: read.fee?.publicationStatus || null,
financeState: finance.source?.sourceStatus || 'ABSENT', requestId: finance.response?.requestId || null,
traceId: finance.response?.traceId || null })
}
const voided = fixtures.find((item) => item.name === 'AR-VOID')
if (voided) {
const create = await request(context, 'POST', `${BASE}/statements`, {
customerId: String(baseFixture.customerId), currency: 'CNY', generationMode: 'SELECTED',
feeSourceIds: [voided.feeId], periodStart: voided.businessDate, periodEnd: voided.businessDate,
remark: `${FIXED_MARKER}-VOID-STATEMENT`,
}, 'voided-statement')
context.assert(MODULE, '作废来源不可选择出账', create, create?.body?.code !== '200',
`code=${create?.body?.code || 'missing'}, requestId=${create?.requestId || 'missing'}, traceId=${create?.traceId || 'missing'}`, OPTIONS)
}
const orderFeeCounts = new Map()
for (const item of [...baseFixture.feeSources, ...fixtures]) {
if (!item.orderId) continue
orderFeeCounts.set(id(item.orderId), (orderFeeCounts.get(id(item.orderId)) || 0) + 1)
}
context.assert(MODULE, '至少一张订单包含多个费用项目', null,
[...orderFeeCounts.values()].some((count) => count >= 2),
`maxFeeCount=${Math.max(0, ...orderFeeCounts.values())}`, OPTIONS)
context.assert(MODULE, '状态矩阵覆盖多个业务日期', null,
new Set(statusMatrix.map((item) => item.businessDate)).size >= 4,
`dates=${[...new Set(statusMatrix.map((item) => item.businessDate))].join(',')}`, OPTIONS)
context.createdResources.financeExtendedFees = {
marker: FIXED_MARKER, customerId: id(baseFixture.customerId),
orders: resources.map((item) => ({ orderId: id(item.orderId), orderNo: item.orderNo || null, inboundId: id(item.inboundId) })),
automaticFees: automatic, statusMatrix,
baseDeploymentBlockers: baseFixture.deploymentBlocker || [],
}
} finally {
context.marker = originalMarker
const steps = context.steps.slice(start)
context.scenarios.push({ name: OPTIONS.scenario, module: MODULE,
success: steps.every((step) => step.passed), deploymentBlocked: steps.some((step) => step.deploymentBlocker),
passed: steps.filter((step) => step.passed).length, failed: steps.filter((step) => !step.passed).length })
}
}