const MODULE = 'finance-reconciliation-full' const SCENARIO = 'finance-full-reconciliation' const BASE = '/api/platform/finance/v1' const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } function id(value) { return value == null ? '' : String(value) } function list(value) { return Array.isArray(value) ? value : [] } function rows(value) { return Array.isArray(value) ? value : list(value?.records) } function statementId(value) { return id(value?.statementId ?? value?.id ?? value?.billId) } function settlementId(value) { return id(value?.settlementId ?? value?.id) } function sourceId(value) { return id(value?.feeSourceId ?? value?.financeFeeSourceId ?? value?.id) } function sourceDate(value) { return value?.businessDate ?? value?.billingDate ?? value?.feeDate ?? '' } function sourceWaybill(value) { return id(value?.waybillId ?? value?.shipmentId ?? value?.orderId ?? value?.inboundId ?? value?.sourceNo) } function lineSourceIds(value) { return list(value?.lines).map(sourceId).filter(Boolean) } function apiRejected(response) { return response?.http === 200 && response?.body && String(response.body.code) !== '200' } function sameIds(actual, expected) { const left = [...new Set(actual)].sort() const right = [...new Set(expected)].sort() return left.length === right.length && left.every((value, index) => value === right[index]) } function dates(sources) { const values = sources.map((source) => source.businessDate).sort() return { start: values[0], end: values.at(-1) } } function key(context, action) { return `${context.marker}-finance-reconciliation-${action}`.replace(/[^A-Za-z0-9_.:-]/g, '-').slice(0, 128) } function write(context, method, path, body, action) { return context.request(method, path, body, context.token, { 'Idempotency-Key': key(context, action) }) } function finish(context, start) { const steps = context.steps.slice(start) context.scenarios.push({ name: 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, }) } function diagnostic(response) { return { http: response?.http ?? null, code: response?.body?.code == null ? null : String(response.body.code), requestId: response?.requestId ?? null, traceId: response?.traceId ?? null } } function blocker(context, name, evidence, category, response = null) { context.addStep(MODULE, name, response, false, evidence, OPTIONS) context.addCoverageGap(MODULE, SCENARIO, name, evidence, category, { marker: context.marker, ...diagnostic(response) }) } function normalizeFixture(context) { const root = context.createdResources?.financeFullFixtures if (!root || root.marker !== context.marker || !root.customerId || !Array.isArray(root.orders) || !Array.isArray(root.feeSources)) return null const customerId = id(root.customerId) const orders = new Map(root.orders.map((order) => [id(order.orderId ?? order.id), order])) const sources = root.feeSources.map((raw) => { const order = orders.get(id(raw.orderId)) return { raw, feeSourceId: sourceId(raw), customerId: id(raw.customerId ?? customerId), direction: String(raw.direction ?? ''), currency: String(raw.currency ?? raw.currencyCode ?? '').toUpperCase(), businessDate: sourceDate(raw), waybillId: sourceWaybill(raw) || sourceWaybill(order), marker: raw.marker ?? root.marker, supplierId: id(raw.supplierId ?? raw.partnerId ?? root.supplierId ?? root.partnerId), supplierName: raw.supplierName ?? raw.partnerName ?? root.supplierName ?? root.partnerName ?? null, } }) return { root, customerId, sources } } async function createStatement(context, sources, action, name, generationMode = 'SELECTED') { const period = dates(sources) const body = { customerId: sources[0].customerId, currency: 'CNY', generationMode, feeSourceIds: generationMode === 'SELECTED' ? sources.map((source) => source.feeSourceId) : [], periodStart: period.start, periodEnd: period.end, remark: `${context.marker}-${action}`, } const response = await write(context, 'POST', `${BASE}/statements`, body, action) const statement = context.apiSuccess(MODULE, name, response, `mode=${generationMode}, sources=${sources.length}`, OPTIONS) if (!statement) return { body, response, statement: null } const replayResponse = await write(context, 'POST', `${BASE}/statements`, body, action) const replay = context.apiSuccess(MODULE, `${name}幂等重放`, replayResponse, '', OPTIONS) context.assert(MODULE, `${name}幂等保持同一资源和版本`, replayResponse, statementId(replay) === statementId(statement) && Number(replay?.version) === Number(statement.version), `first=${statementId(statement)}, replay=${statementId(replay)}, version=${replay?.version ?? 'missing'}`, OPTIONS) return { body, response, statement } } async function act(context, statement, action, suffix, reason, adjustmentLines) { if (!statement) return null const body = { action, expectedVersion: Number(statement.version) } if (reason) body.reason = reason if (adjustmentLines) body.adjustmentLines = adjustmentLines const response = await write(context, 'POST', `${BASE}/reconciliation-statements/${statementId(statement)}/actions`, body, suffix) const next = context.apiSuccess(MODULE, `对账单执行 ${action}`, response, `statementId=${statementId(statement)}, version=${statement.version}`, OPTIONS) const expected = { SUBMIT: 'PENDING_REVIEW', APPROVE: 'APPROVED', REJECT: 'REJECTED', ISSUE: 'ISSUED', ADJUST: 'ISSUED', VOID: 'VOIDED', REOPEN: 'DRAFT' }[action] context.assert(MODULE, `${action} 状态与版本符合服务端状态机`, response, next?.status === expected && Number(next?.version) === Number(statement.version) + 1, `expected=${expected}, actual=${next?.status ?? 'missing'}, version=${next?.version ?? 'missing'}`, OPTIONS) return next } async function issueSimple(context, statement, suffix) { statement = await act(context, statement, 'SUBMIT', `${suffix}-submit`) statement = await act(context, statement, 'APPROVE', `${suffix}-approve`) return act(context, statement, 'ISSUE', `${suffix}-issue`) } async function issueCompleteChain(context, statement) { statement = await act(context, statement, 'SUBMIT', 'chain-submit-1') if (!statement) return null const replayBody = { action: 'SUBMIT', expectedVersion: Number(statement.version) - 1 } const replayResponse = await write(context, 'POST', `${BASE}/reconciliation-statements/${statementId(statement)}/actions`, replayBody, 'chain-submit-1') const replay = context.apiSuccess(MODULE, '状态动作幂等重放原结果', replayResponse, '', OPTIONS) context.assert(MODULE, '动作幂等重放不重复递增版本', replayResponse, statementId(replay) === statementId(statement) && Number(replay?.version) === Number(statement.version), `version=${replay?.version ?? 'missing'}`, OPTIONS) statement = await act(context, statement, 'REJECT', 'chain-reject', `${context.marker}-reject`) statement = await act(context, statement, 'REOPEN', 'chain-reopen-rejected', `${context.marker}-reopen-rejected`) statement = await act(context, statement, 'SUBMIT', 'chain-submit-2') statement = await act(context, statement, 'APPROVE', 'chain-approve-1') statement = await act(context, statement, 'ISSUE', 'chain-issue-1') if (!statement) return null const adjustable = list(statement.lines).slice(0, 2) if (adjustable.length < 2) return null const beforeRevisions = list(statement.revisions).length const adjustments = adjustable.map((line, index) => ({ statementLineId: id(line.statementLineId ?? line.id), adjustedAmount: Number(line.statementAmount ?? line.amount) + (index + 1) / 100, reason: `${context.marker}-adjust-${index + 1}`, })) statement = await act(context, statement, 'ADJUST', 'chain-adjust', `${context.marker}-multi-line`, adjustments) context.assert(MODULE, '多行调整写入行值、原因并新增 revision', null, statement && adjustments.every((expected) => list(statement.lines).some((line) => id(line.statementLineId) === expected.statementLineId && Number(line.statementAmount) === expected.adjustedAmount && line.adjustmentReason === expected.reason)) && list(statement?.revisions).length > beforeRevisions, `lines=${adjustments.length}, revisions=${list(statement?.revisions).length}`, OPTIONS) statement = await act(context, statement, 'VOID', 'chain-void', `${context.marker}-void`) statement = await act(context, statement, 'REOPEN', 'chain-reopen-voided', `${context.marker}-reopen-voided`) statement = await act(context, statement, 'SUBMIT', 'chain-submit-final') statement = await act(context, statement, 'APPROVE', 'chain-approve-final') statement = await act(context, statement, 'ISSUE', 'chain-issue-final') const actions = new Set(list(statement?.revisions).map((revision) => revision.action)) context.assert(MODULE, '完整链保留驳回、重开、调整、作废和再次出账 revision', null, ['RECONCILIATION_REJECT', 'RECONCILIATION_REOPEN', 'RECONCILIATION_ADJUST', 'RECONCILIATION_VOID', 'RECONCILIATION_ISSUE'].every((action) => actions.has(action)), `actions=${[...actions].join(',')}`, OPTIONS) return statement } async function createReceivable(context, statement, index) { const body = { reconciliationStatementId: statementId(statement), currency: 'CNY' } const response = await write(context, 'POST', `${BASE}/receivable-settlements`, body, `ar-${index}`) const settlement = context.apiSuccess(MODULE, `生成第 ${index} 张应收结算单`, response, `statementId=${statementId(statement)}`, OPTIONS) if (!settlement) return null const replayResponse = await write(context, 'POST', `${BASE}/receivable-settlements`, body, `ar-${index}`) const replay = context.apiSuccess(MODULE, `第 ${index} 张应收结算单幂等重放`, replayResponse, '', OPTIONS) context.assert(MODULE, `第 ${index} 张 AR 幂等保持同一资源`, replayResponse, settlementId(replay) === settlementId(settlement), `settlementId=${settlementId(settlement)}`, OPTIONS) return settlement } export async function run(context) { const start = context.steps.length const fixture = normalizeFixture(context) if (!fixture) { blocker(context, '取得本轮财务全量 Fixture', 'financeFullFixtures 必须提供匹配 marker 的 customerId/orders/feeSources;禁止猜测旧字段或历史来源', 'missing-marker-owned-finance-fixture') finish(context, start) return } const invalid = fixture.sources.filter((source) => !source.feeSourceId || !source.customerId || !source.businessDate || !source.waybillId || source.marker !== context.marker) if (invalid.length > 0) { blocker(context, 'Fixture 来源契约完整', `存在 ${invalid.length} 条来源缺少 feeSourceId/customerId/businessDate/运单标识/marker`, 'invalid-finance-fixture-contract') finish(context, start) return } const receivables = fixture.sources.filter((source) => source.customerId === fixture.customerId && source.direction === 'RECEIVABLE' && source.currency === 'CNY') const businessDates = new Set(receivables.map((source) => source.businessDate)) if (receivables.length < 4 || businessDates.size < 2) { blocker(context, '三张 AR 与日期范围 Fixture 门禁', `至少需要同客户 4 条 CNY 应收来源且跨 2 个真实 businessDate;actual sources=${receivables.length}, dates=${businessDates.size}`, 'insufficient-receivable-date-fixture') finish(context, start) return } const foreign = fixture.sources.find((source) => source.customerId !== fixture.customerId) if (foreign) { const response = await write(context, 'POST', `${BASE}/statements`, { customerId: fixture.customerId, currency: 'CNY', generationMode: 'SELECTED', feeSourceIds: [foreign.feeSourceId], periodStart: foreign.businessDate, periodEnd: foreign.businessDate, remark: `${context.marker}-foreign`, }, 'cross-customer-rejected') context.assert(MODULE, 'SELECTED 拒绝混入其他客户来源', response, apiRejected(response), `code=${response.body?.code ?? 'missing'}`, OPTIONS) } else { context.addCoverageGap(MODULE, SCENARIO, 'cross-customer-isolation', 'Fixture 未提供其他客户 marker 来源,无法执行跨客户负向请求', 'missing-cross-customer-fixture') } const allPeriod = dates(receivables) const currencyResponse = await write(context, 'POST', `${BASE}/statements`, { customerId: fixture.customerId, currency: 'USD', generationMode: 'SELECTED', feeSourceIds: receivables.map((source) => source.feeSourceId), periodStart: allPeriod.start, periodEnd: allPeriod.end, remark: `${context.marker}-usd`, }, 'currency-rejected') context.assert(MODULE, '对账单拒绝非 CNY 币种', currencyResponse, apiRejected(currencyResponse), `code=${currencyResponse.body?.code ?? 'missing'}`, OPTIONS) const cutoffDate = [...businessDates].sort()[0] const cutoffSources = receivables.filter((source) => source.businessDate === cutoffDate) const poolResponse = await context.request('GET', `${BASE}/fee-pool?customerId=${encodeURIComponent(fixture.customerId)}&direction=RECEIVABLE`) const pool = context.apiSuccess(MODULE, 'BY_CUTOFF 前核对客户应收费用池', poolResponse, '', OPTIONS) const eligible = rows(pool).filter((source) => sourceDate(source) === cutoffDate && String(source.currency ?? source.currencyCode) === 'CNY' && source.reviewStatus === 'APPROVED' && source.settlementIncluded === true && source.sourceStatus === 'AVAILABLE').map(sourceId) if (!sameIds(eligible, cutoffSources.map((source) => source.feeSourceId))) { blocker(context, 'BY_CUTOFF 历史来源隔离门禁', '指定日期全部可结算来源必须与本轮 fixture 精确相等,禁止锁定历史来源', 'unsafe-cutoff-source-set', poolResponse) finish(context, start) return } const cutoff = await createStatement(context, cutoffSources, 'cutoff', '按真实业务日期生成对账单', 'BY_CUTOFF') let cutoffStatement = cutoff.statement context.assert(MODULE, 'BY_CUTOFF 仅纳入真实日期范围内本轮来源', cutoff.response, cutoffStatement && cutoff.body.periodStart === cutoffDate && cutoff.body.periodEnd === cutoffDate && sameIds(lineSourceIds(cutoffStatement), cutoffSources.map((source) => source.feeSourceId)), `date=${cutoffDate}, expected=${cutoffSources.length}, actual=${lineSourceIds(cutoffStatement).length}`, OPTIONS) // ReconciliationStatementService explicitly permits VOID from DRAFT and releases LOCKED sources. cutoffStatement = await act(context, cutoffStatement, 'VOID', 'cutoff-draft-void', `${context.marker}-release-cutoff`) if (!cutoffStatement) { finish(context, start); return } const groups = [[receivables[0], receivables[1]], [receivables[2]], receivables.slice(3)] const issuedStatements = [] for (let index = 0; index < groups.length; index += 1) { const created = await createStatement(context, groups[index], `selected-${index + 1}`, `创建第 ${index + 1} 张独立 SELECTED 对账单`) context.assert(MODULE, `第 ${index + 1} 张仅使用分组来源`, created.response, created.statement && sameIds(lineSourceIds(created.statement), groups[index].map((source) => source.feeSourceId)), `expected=${groups[index].length}, actual=${lineSourceIds(created.statement).length}`, OPTIONS) const issued = index === 0 ? await issueCompleteChain(context, created.statement) : await issueSimple(context, created.statement, `selected-${index + 1}`) if (!issued) { blocker(context, `第 ${index + 1} 张对账单出账`, '状态流转或多行调整未返回有效结果', 'statement-issue-failed') finish(context, start) return } issuedStatements.push(issued) } const receivableSettlements = [] for (let index = 0; index < issuedStatements.length; index += 1) { const settlement = await createReceivable(context, issuedStatements[index], index + 1) if (settlement) receivableSettlements.push(settlement) } if (receivableSettlements.length !== 3) { blocker(context, '生成三张独立 AR', `expected=3, actual=${receivableSettlements.length}`, 'three-ar-creation-failed') } const payables = fixture.sources.filter((source) => source.customerId === fixture.customerId && source.direction === 'PAYABLE' && source.currency === 'CNY') const payableSupplierIds = new Set(payables.map((source) => source.supplierId).filter(Boolean)) let payableSettlement = null if (payables.length > 0 && payableSupplierIds.size === 1 && payables.every((source) => source.supplierId)) { const dueDate = [...new Set(payables.map((source) => source.businessDate))].sort().at(-1) const supplierId = [...payableSupplierIds][0] const supplierName = payables.find((source) => source.supplierId === supplierId)?.supplierName ?? null const body = { customerId: fixture.customerId, supplierId, feeSourceIds: payables.map((source) => source.feeSourceId), dueDate, currency: 'CNY' } const response = await write(context, 'POST', `${BASE}/payable-settlements`, body, 'payable-settlement') payableSettlement = context.apiSuccess(MODULE, '按 Fixture PAYABLE 与 partnerId/supplierId 生成应付结算单', response, `sources=${payables.length}, supplierId=${supplierId}, supplierName=${supplierName ?? 'null'}`, OPTIONS) if (!payableSettlement) { blocker(context, 'PAYABLE 真实接口创建失败', `HTTP=${response.http}, code=${response.body?.code ?? 'missing'}, requestId=${response.requestId ?? 'null'}, traceId=${response.traceId ?? 'null'}`, 'payable-settlement-api-failure', response) } } else { blocker(context, 'PAYABLE 结算 Fixture 门禁', `需要至少一条同客户 CNY PAYABLE,且全部提供相同 partnerId/supplierId;sources=${payables.length}, suppliers=${payableSupplierIds.size}`, 'insufficient-payable-supplier-fixture') } const statementRows = [ { kind: 'BY_CUTOFF', statementId: statementId(cutoffStatement), status: cutoffStatement.status, version: cutoffStatement.version, feeSourceIds: cutoffSources.map((source) => source.feeSourceId), periodStart: cutoffDate, periodEnd: cutoffDate }, ...issuedStatements.map((statement, index) => ({ kind: index === 0 ? 'SELECTED_FULL_CHAIN' : 'SELECTED', statementId: statementId(statement), status: statement.status, version: statement.version, feeSourceIds: groups[index].map((source) => source.feeSourceId), periodStart: dates(groups[index]).start, periodEnd: dates(groups[index]).end })), ] const arRows = receivableSettlements.map((settlement, index) => ({ settlementId: settlementId(settlement), statementId: statementId(issuedStatements[index]), direction: settlement.direction, status: settlement.status, paymentStatus: settlement.paymentStatus, version: settlement.version, })) fixture.root.statements = statementRows fixture.root.statementMap = { byCutoff: statementRows[0], fullChain: statementRows[1], selected: statementRows.slice(1) } fixture.root.receivableSettlements = arRows fixture.root.receivableSettlementMap = { byStatementId: Object.fromEntries(arRows.map((row) => [row.statementId, row])) } fixture.root.payableSettlements = payableSettlement ? [{ settlementId: settlementId(payableSettlement), direction: payableSettlement.direction, supplierId: id(payableSettlement.supplierId), partnerId: id(payableSettlement.partnerId ?? payableSettlement.supplierId), supplierName: payableSettlement.supplierName ?? payables[0]?.supplierName ?? null, status: payableSettlement.status, paymentStatus: payableSettlement.paymentStatus, version: payableSettlement.version, feeSourceIds: payables.map((source) => source.feeSourceId), }] : [] finish(context, start) }