import { readFile } from 'node:fs/promises' const MODULE = 'finance-demo-fixtures' const MARKER = 'FIN-DEMO-20260812' const BASE = '/api/platform/finance/v1' const OPTIONS = { scenario: 'finance-demo-layered-data', deploymentBlocker: true } const id = (value) => value == null ? '' : String(value) const rows = (value) => Array.isArray(value) ? value : Array.isArray(value?.records) ? value.records : [] const data = (response) => response?.body?.data ?? null const ok = (response) => response?.http === 200 && String(response?.body?.code) === '200' const statementId = (value) => id(value?.statementId ?? value?.id) const settlementId = (value) => id(value?.settlementId ?? value?.id) const outstanding = (value) => Number(value?.outstandingAmount ?? 0) const operationVersion = (value) => Number(value?.operationVersion ?? 0) const version = (value) => Number(value?.version ?? 0) const key = (context, operation) => `${context.marker}-${operation}`.replace(/[^A-Za-z0-9_.:-]/g, '-').slice(0, 128) const write = (context, method, path, body, operation) => context.request(method, path, body, context.token, { 'Idempotency-Key': key(context, operation) }) function record(context, name, response, condition, evidence) { return context.assert(MODULE, name, response, condition, evidence, OPTIONS) } function blocker(context, name, response, evidence, category) { context.addStep(MODULE, name, response, false, evidence, OPTIONS) context.addCoverageGap(MODULE, OPTIONS.scenario, name, evidence, category, { marker: context.marker, http: response?.http ?? null, code: response?.body?.code ?? null, requestId: response?.requestId ?? null, traceId: response?.traceId ?? null, }) } async function waitForSources(context, customerId, sourceFeeIds) { let pool = [] for (let attempt = 0; attempt < 30; attempt += 1) { const response = await context.request('GET', `${BASE}/fee-pool?customerId=${encodeURIComponent(customerId)}&direction=ALL`) pool = rows(data(response)).concat(rows(data(response)?.items)) const visible = new Set(pool.map((item) => id(item.sourceId))) if (sourceFeeIds.every((sourceId) => visible.has(sourceId))) return pool await new Promise((resolve) => setTimeout(resolve, 500)) } return pool } function financeId(poolItem) { const value = id(poolItem?.poolItemId) return value.includes(':') ? value.slice(value.indexOf(':') + 1) : id(poolItem?.feeSourceId ?? poolItem?.id) } async function sourceDetail(context, feeSourceId) { const response = await context.request('GET', `${BASE}/fee-sources/${feeSourceId}`) return { response, value: data(response) } } async function approveSource(context, source, role) { let current = await sourceDetail(context, source.feeSourceId) if (current.value?.reviewStatus === 'DRAFT' || current.value?.reviewStatus === 'REJECTED') { const response = await write(context, 'POST', `${BASE}/fee-sources/${source.feeSourceId}/submit`, { reason: `${MARKER}-${role}`, expectedVersion: operationVersion(current.value) }, `source-${role}-${source.feeSourceId}-submit`) current = { response, value: data(response) } } if (current.value?.reviewStatus === 'PENDING') { const response = await write(context, 'POST', `${BASE}/fee-sources/${source.feeSourceId}/review`, { action: 'APPROVE', reason: `${MARKER}-${role}`, expectedVersion: operationVersion(current.value) }, `source-${role}-${source.feeSourceId}-approve`) current = { response, value: data(response) } } record(context, `${role} 来源已审核并自动进入结算候选`, current.response, current.value?.reviewStatus === 'APPROVED' && current.value?.sourceStatus === 'AVAILABLE', `feeSourceId=${source.feeSourceId}, review=${current.value?.reviewStatus}`) return current.value } async function createExtraPayable(context, fixture, index) { const itemResponse = await context.request('GET', '/api/platform/v1/fee-items?page=1&pageSize=200') const feeItem = rows(data(itemResponse)).find((item) => item.status === 'ACTIVE' && item.warehousePayable === true) const inboundId = id(fixture.orders.at(-1)?.inboundId) const supplierId = id(fixture.supplier?.supplierId) if (!feeItem || !inboundId || !supplierId) 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', supplierId, remark: `${MARKER}-AP-${index}` } const basePath = `/api/platform/v1/inbounds/${inboundId}/fee-details` const customerId = id(fixture.customerId) const createdResponse = await write(context, 'POST', `${basePath}?accountId=${encodeURIComponent(customerId)}`, body, `extra-payable-${index}`) const created = data(createdResponse) if (!ok(createdResponse) || !created?.id) return null const approveResponse = await write(context, 'POST', `${basePath}/${created.id}/approve?accountId=${encodeURIComponent(customerId)}`, undefined, `extra-payable-${index}-approve`) record(context, `创建第 ${index} 条额外应付来源`, approveResponse, ok(approveResponse), `sourceFeeId=${created.id}, inboundId=${inboundId}, supplierId=${supplierId}`) return { sourceFeeId: id(created.id), direction: 'PAYABLE', businessDate: body.businessDate, supplierId, supplierName: fixture.supplier?.supplierName || null } } async function createStatement(context, source, role, targetStatus) { const body = { customerId: id(source.customerId), currency: 'CNY', generationMode: 'SELECTED', feeSourceIds: [source.feeSourceId], periodStart: source.businessDate, periodEnd: source.businessDate, remark: `${MARKER}-${role}` } const response = await write(context, 'POST', `${BASE}/statements`, body, `statement-${role}-create`) let statement = data(response) if (!ok(response) || !statementId(statement)) return { response, value: null } for (const [action, status] of [['SUBMIT', 'PENDING_REVIEW'], ['APPROVE', 'APPROVED'], ['ISSUE', 'ISSUED']]) { if (targetStatus === 'DRAFT' || statement.status === targetStatus) break const actionResponse = await write(context, 'POST', `${BASE}/reconciliation-statements/${statementId(statement)}/actions`, { action, expectedVersion: version(statement) }, `statement-${role}-${action.toLowerCase()}`) statement = data(actionResponse) if (!ok(actionResponse) || statement?.status !== status) return { response: actionResponse, value: null } } record(context, `${role} 对账单保留 ${targetStatus}`, response, statement?.status === targetStatus, `statementId=${statementId(statement)}, status=${statement?.status}`) return { response, value: statement } } async function createAr(context, statement, role) { const response = await write(context, 'POST', `${BASE}/receivable-settlements`, { reconciliationStatementId: Number(statementId(statement)), currency: 'CNY' }, `ar-${role}-create`) const value = data(response) record(context, `${role} 创建应收结算单`, response, ok(response) && settlementId(value), `settlementId=${settlementId(value)}, status=${value?.paymentStatus}`) return value } async function receive(context, fixture, settlement, amount, role) { const response = await write(context, 'POST', `${BASE}/receipts`, { customerId: id(fixture.customerId), receivedDate: new Date().toISOString().slice(0, 10), amount, currency: 'CNY', paymentMethod: 'BANK_TRANSFER', voucherFileIds: [fixture.voucherFileId], remark: `${MARKER}-${role}`, allocations: [{ billId: Number(settlement.reconciliationStatementId), amount }], }, `receipt-${role}`) const detailResponse = await context.request('GET', `${BASE}/settlements/${settlementId(settlement)}`) return { response, value: data(detailResponse) } } async function createAp(context, fixture, source, role) { const response = await write(context, 'POST', `${BASE}/payable-settlements`, { customerId: id(fixture.customerId), supplierId: id(source.supplierId), feeSourceIds: [Number(source.feeSourceId)], dueDate: new Date().toISOString().slice(0, 10), currency: 'CNY', }, `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}`) return value } async function recoverPayable(context, settlement, feeSourceId) { if (!settlementId(settlement)) return null const response = await context.request('GET', `${BASE}/settlements/${settlementId(settlement)}`) const value = data(response) return value?.direction === 'PAYABLE' && value?.lines?.some((line) => id(line.feeSourceId) === id(feeSourceId)) ? { response, value } : null } function verifySettlementState(context, direction, role, response, value, expectedStatus) { const remaining = outstanding(value) const status = value?.paymentStatus ?? value?.status const amountMatches = role.endsWith('Unpaid') ? remaining > 0 && Number(value?.paidAmount ?? 0) === 0 : role.endsWith('Partial') ? remaining > 0 && Number(value?.paidAmount ?? 0) > 0 : remaining === 0 && Number(value?.paidAmount ?? 0) > 0 const expected = amountMatches && status === expectedStatus if (expected) record(context, `${role} 金额和状态一致`, response, true, `settlementId=${settlementId(value)}, paid=${value?.paidAmount}, outstanding=${remaining}, status=${status}`) else blocker(context, `${role} 金额和状态一致`, response, `settlementId=${settlementId(value)}, direction=${direction}, paid=${value?.paidAmount}, outstanding=${remaining}, expectedStatus=${expectedStatus}, actualStatus=${status}`, 'finance-settlement-payment-status-not-deployed') } async function recoverExistingDemo(context, reportPath) { const report = JSON.parse(await readFile(reportPath, 'utf8')) const fixture = report?.createdResources?.financeFullFixtures const matrix = structuredClone(report?.createdResources?.financeDemo ?? {}) if (fixture?.marker !== matrix.runMarker || matrix.marker !== MARKER || !id(matrix.customerId) || id(matrix.customerId) !== id(fixture.customerId)) { throw new Error('恢复报告不是 FIN-DEMO-20260812 同一轮动态客户 fixture') } context.marker = matrix.runMarker await verifyUnfilteredLists(context) const containerPayable = await sourceDetail(context, '308') if (!id(containerPayable.value?.supplierId)) blocker(context, '柜次费用携带供应商快照', containerPayable.response, `feeSourceId=308, sourceFeeId=${containerPayable.value?.sourceFeeId ?? 'missing'}, supplierId=missing;需部署柜次 partner 新契约后补跑`, 'finance-container-partner-not-deployed') const payableIds = ['307', '310'] const roles = ['apUnpaid', 'apPartial'] matrix.payableSettlements ??= {} if (!settlementId(matrix.payableSettlements.apUnpaid)) { matrix.payableSettlements.apUnpaid = { settlementId: process.env.FINANCE_DEMO_AP_UNPAID_ID } } if (!settlementId(matrix.payableSettlements.apPartial)) { matrix.payableSettlements.apPartial = { settlementId: process.env.FINANCE_DEMO_AP_PARTIAL_ID } } for (let index = 0; index < roles.length; index += 1) { const role = roles[index] const detail = await sourceDetail(context, payableIds[index]) const source = detail.value if (!ok(detail.response) || source?.direction !== 'PAYABLE' || id(source?.customerId) !== id(matrix.customerId) || !id(source?.sourceFeeId)) { blocker(context, `${role} 恢复来源属于本轮 fixture`, detail.response, `feeSourceId=${payableIds[index]}, sourceFeeId=${source?.sourceFeeId ?? 'missing'}`, 'invalid-finance-demo-recovery-source') continue } const produced = fixture.feeSources.some((item) => id(item.feeSourceId) === id(source.sourceFeeId)) || report.steps?.some((step) => step.name === `创建第 ${index + 1} 条额外应付来源` && step.passed && step.evidence?.includes(`sourceFeeId=${source.sourceFeeId}`)) if (!produced) { blocker(context, `${role} 恢复来源属于本轮 fixture`, detail.response, `Finance feeSourceId=${payableIds[index]} 不属于 runMarker=${matrix.runMarker} 的来源,拒绝借历史数据`, 'invalid-finance-demo-recovery-source') continue } const approved = await approveSource(context, { feeSourceId: payableIds[index] }, role) const existing = await recoverPayable(context, matrix.payableSettlements[role], payableIds[index]) 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: recoverySupplierId, }, role) if (existing?.value) record(context, `${role} 回读已有应付结算单`, existing.response, true, `settlementId=${settlementId(existing.value)}, feeSourceId=${payableIds[index]}`) if (!settlementId(settlement)) continue if (role === 'apUnpaid') matrix.payableSettlements[role] = settlement else if (Number(settlement.paidAmount ?? 0) === 0) { const amount = Math.max(0.01, Math.floor(outstanding(settlement) * 50) / 100) matrix.payableSettlements[role] = (await pay(context, fixture, settlement, amount, role)).value } else matrix.payableSettlements[role] = settlement verifySettlementState(context, 'PAYABLE', role, detail.response, matrix.payableSettlements[role], role === 'apUnpaid' ? 'UNPAID' : 'PARTIALLY_PAID') } for (const [role, expectedStatus] of [['arUnpaid', 'UNPAID'], ['arPartial', 'PARTIALLY_PAID'], ['arPaid', 'PAID']]) { const current = matrix.receivableSettlements?.[role] if (!settlementId(current)) continue const response = await context.request('GET', `${BASE}/settlements/${settlementId(current)}`) const value = data(response) matrix.receivableSettlements[role] = value verifySettlementState(context, 'RECEIVABLE', role, response, value, expectedStatus) } matrix.feeSources.approvedUnbilled = await approveSource(context, { feeSourceId: '300' }, 'approved-unbilled') context.createdResources.financeFullFixtures = fixture context.createdResources.financeDemo = matrix } async function pay(context, fixture, settlement, amount, role) { const response = await write(context, 'POST', `${BASE}/settlements/${settlementId(settlement)}/transactions`, { transactionDate: new Date().toISOString().slice(0, 10), amount, currency: 'CNY', method: 'BANK_TRANSFER', referenceNo: `${MARKER}-${role}`, voucherFileIds: [fixture.voucherFileId], expectedVersion: version(settlement), remark: `${MARKER}-${role}`, }, `payment-${role}`) return { response, value: data(response) } } async function verifyUnfilteredLists(context) { for (const path of ['statements', 'receivable-settlements', 'payable-settlements', 'receipts', 'cost-entries']) { const response = await context.request('GET', `${BASE}/${path}`) if (!ok(response)) blocker(context, `平台 ${path} 默认查询全部客户`, response, `GET ${BASE}/${path} 未部署无 customerId 全量查询;HTTP=${response.http}, code=${response.body?.code ?? 'missing'}, requestId=${response.requestId ?? 'null'}, traceId=${response.traceId ?? 'null'}`, 'finance-unfiltered-list-not-deployed') else record(context, `平台 ${path} 默认查询全部客户`, response, Array.isArray(data(response)) || Array.isArray(data(response)?.records), `records=${rows(data(response)).length}`) } } export async function run(context) { const start = context.steps.length const recoveryReport = process.env.FINANCE_DEMO_RECOVERY_REPORT if (recoveryReport) { await recoverExistingDemo(context, recoveryReport) 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 }) return } const runMarker = `${MARKER}-${new Date().toISOString().replace(/\D/g, '').slice(8, 14)}` context.marker = runMarker await verifyUnfilteredLists(context) const producer = await import('./finance-full-fixtures.mjs') await producer.run(context) const fixture = context.createdResources.financeFullFixtures const qualificationFixture = context.context.merchantQualificationFixture const customerId = id(fixture?.customerId) if (!fixture || fixture.marker !== runMarker || !customerId || customerId !== id(qualificationFixture?.tenantId)) { blocker(context, '取得演示企业真实 Finance fixture', null, 'finance-full-fixtures 未返回与 merchant-qualification-fixture 同客户的本轮独立资源,禁止借历史来源', 'missing-finance-demo-fixture') return } const extraPayables = [] for (let index = 1; index <= 2; index += 1) { const value = await createExtraPayable(context, fixture, index) if (value) extraPayables.push(value) } const produced = [...fixture.feeSources.map((item) => ({ ...item, sourceFeeId: id(item.feeSourceId) })), ...extraPayables] const pool = await waitForSources(context, customerId, produced.map((item) => item.sourceFeeId).filter(Boolean)) const poolBySource = new Map(pool.map((item) => [id(item.sourceId), item])) const sources = produced.map((source) => ({ ...source, feeSourceId: financeId(poolBySource.get(source.sourceFeeId)), marker: runMarker, customerId, supplierId: id(source.supplierId ?? fixture.supplier?.supplierId) })) .filter((source) => source.feeSourceId) const receivables = sources.filter((source) => source.direction === 'RECEIVABLE') const payables = sources.filter((source) => source.direction === 'PAYABLE' && source.supplierId) if (receivables.length < 7 || payables.length < 3) { blocker(context, '分层状态来源数量门禁', null, `需要至少 7 条本轮应收和 3 条本轮应付;actual AR sources=${receivables.length}, AP sources=${payables.length}`, 'insufficient-demo-sources') return } const matrix = { marker: MARKER, runMarker, customerId, customerLabel: qualificationFixture?.tenantName || `动态验收客户 ${customerId}`, feeSources: {}, statements: {}, receivableSettlements: {}, payableSettlements: {}, voucherFileId: id(fixture.voucherFileId), container: fixture.container, allocations: fixture.allocations } const pending = await sourceDetail(context, receivables[0].feeSourceId) record(context, '保留费用待审核', pending.response, pending.value?.reviewStatus === 'PENDING', `feeSourceId=${receivables[0].feeSourceId}, review=${pending.value?.reviewStatus}`) matrix.feeSources.pendingReview = pending.value matrix.feeSources.approvedUnbilled = await approveSource(context, receivables[1], 'approved-unbilled') const statementRoles = [ ['draft', 'DRAFT'], ['pendingReview', 'PENDING_REVIEW'], ['arUnpaid', 'ISSUED'], ['arPartial', 'ISSUED'], ['arPaid', 'ISSUED'], ] for (let index = 0; index < statementRoles.length; index += 1) { const [role, status] = statementRoles[index] await approveSource(context, receivables[index + 2], role) const statement = await createStatement(context, receivables[index + 2], role, status) if (statement.value) matrix.statements[role] = statement.value } for (const role of ['arUnpaid', 'arPartial', 'arPaid']) { const statement = matrix.statements[role] if (!statement) continue const settlement = await createAr(context, statement, role) if (!settlement) continue if (role === 'arUnpaid') matrix.receivableSettlements[role] = settlement if (role === 'arPartial') { const amount = Math.max(0.01, Math.floor(outstanding(settlement) * 50) / 100) matrix.receivableSettlements[role] = (await receive(context, fixture, settlement, amount, role)).value } if (role === 'arPaid') matrix.receivableSettlements[role] = (await receive(context, fixture, settlement, outstanding(settlement), role)).value verifySettlementState(context, 'RECEIVABLE', role, settlement, matrix.receivableSettlements[role], role === 'arUnpaid' ? 'UNPAID' : role === 'arPartial' ? 'PARTIALLY_PAID' : 'PAID') } for (const [index, role] of ['apUnpaid', 'apPartial', 'apPaid'].entries()) { await approveSource(context, payables[index], role) const settlement = await createAp(context, fixture, payables[index], role) if (!settlement) continue if (role === 'apUnpaid') matrix.payableSettlements[role] = settlement if (role === 'apPartial') { const amount = Math.max(0.01, Math.floor(outstanding(settlement) * 50) / 100) matrix.payableSettlements[role] = (await pay(context, fixture, settlement, amount, role)).value } if (role === 'apPaid') matrix.payableSettlements[role] = (await pay(context, fixture, settlement, outstanding(settlement), role)).value verifySettlementState(context, 'PAYABLE', role, settlement, matrix.payableSettlements[role], role === 'apUnpaid' ? 'UNPAID' : role === 'apPartial' ? 'PARTIALLY_PAID' : 'PAID') } context.createdResources.financeDemo = matrix 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 }) }