const MODULE = 'finance-reconciliation-full' const SCENARIO = 'finance-full-reconciliation' const BASE = '/api/platform/finance/v1' const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } const STATEMENT_LIST_PERMISSION = 'platform:finance:customer-statement:list' const LEGACY_STATEMENT_VIEW_PERMISSION = 'platform:finance:bill:view' const REQUIRED_PERMISSIONS = [ 'platform:finance:customer-account:list', STATEMENT_LIST_PERMISSION, 'platform:finance:reconciliation:create', 'platform:finance:reconciliation:review', 'platform:finance:reconciliation:issue', 'platform:finance:reconciliation:adjust', 'platform:finance:reconciliation:void', 'platform:finance:settlement-ar:create', 'platform:finance:settlement-ar:list', 'platform:finance:settlement-ap:create', 'platform:finance:settlement-ap:list', ] 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?.orderNo ?? value?.sourceNo) } function financePoolId(value) { const poolItemId = id(value?.poolItemId) if (poolItemId) return poolItemId.includes(':') ? poolItemId.slice(poolItemId.indexOf(':') + 1) : poolItemId return id(value?.feeSourceId ?? value?.financeFeeSourceId ?? value?.id) } function isUnboundContainerCost(raw) { return raw?.sourceType === 'MANUAL' && raw?.billingBasis === 'CONTAINER_SHARED' && !sourceWaybill(raw) } function lineSourceIds(value) { return list(value?.lines).map(sourceId).filter(Boolean) } function rejectedWith(response, code) { return response?.http === 200 && String(response?.body?.code) === code } function accessDenied(response) { return rejectedWith(response, 'A0403') } function financeSubjectMissing(response) { return rejectedWith(response, 'FIN_409') && String(response?.body?.message ?? '').includes('平台财务主体未配置') } 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 present(value) { return value !== undefined && value !== null && String(value).trim() !== '' } async function verifyIssuedStatementReadback(context, fixture, statement, label) { const customerId = id(fixture.customerId) const targetId = statementId(statement) if (!customerId || !targetId) { context.addCoverageGap(MODULE, SCENARIO, `${label} 对账单列表/详情查回`, `必须使用本轮 fixture.customerId 和刚创建且已出账的 statementId;customerId=${customerId || 'missing'}, statementId=${targetId || 'missing'}`, 'missing-issued-statement-readback-fixture', { marker: context.marker, customerId, statementId: targetId }) return } const listResponse = await context.request('GET', `${BASE}/statements?customerId=${encodeURIComponent(customerId)}`) const summaries = context.apiSuccess(MODULE, `${label} 从正式对账单列表查回`, listResponse, `customerId=${customerId}, statementId=${targetId}, permission=${STATEMENT_LIST_PERMISSION}`, OPTIONS) const statementRows = rows(summaries) const found = statementRows.find((item) => statementId(item) === targetId) context.assert(MODULE, `${label} 正式对账单列表仅返回本轮客户且包含同一 statementId`, listResponse, statementRows.length > 0 && statementRows.every((item) => id(item?.customerId) === customerId) && Boolean(found), `customerId=${customerId}, statementId=${targetId}, rows=${statementRows.length}, found=${statementId(found) || 'missing'}`, OPTIONS) const detailResponse = await context.request('GET', `${BASE}/reconciliation-statements/${encodeURIComponent(targetId)}`) const detail = context.apiSuccess(MODULE, `${label} 从正式对账单详情查回`, detailResponse, `customerId=${customerId}, statementId=${targetId}, permission=${STATEMENT_LIST_PERMISSION}`, OPTIONS) const auditShape = ['createdBy', 'createdAt', 'updatedBy', 'updatedAt'] .every((field) => Object.hasOwn(detail ?? {}, field) && present(detail?.[field])) context.assert(MODULE, `${label} 对账单详情归属、明细、revision 与审计结构完整`, detailResponse, id(detail?.customerId) === customerId && statementId(detail) === targetId && Array.isArray(detail?.lines) && detail.lines.length > 0 && Array.isArray(detail?.revisions) && detail.revisions.length > 0 && auditShape, `customerId=${id(detail?.customerId) || 'missing'}, statementId=${statementId(detail) || 'missing'}, lines=${list(detail?.lines).length}, revisions=${list(detail?.revisions).length}, auditShape=${auditShape}`, OPTIONS) } async function findSettlementInOfficialList(context, direction, customerId, targetId, label) { const segment = direction === 'RECEIVABLE' ? 'receivable-settlements' : 'payable-settlements' const response = await context.request('GET', `${BASE}/${segment}?customerId=${encodeURIComponent(customerId)}`) const summaries = context.apiSuccess(MODULE, `从正式${label}列表查回结算单`, response, `settlementId=${targetId}, customerId=${customerId}`, OPTIONS) const found = rows(summaries).find((item) => settlementId(item) === targetId) context.assert(MODULE, `正式${label}列表包含同一 settlementId`, response, found?.direction === direction && id(found?.customerId) === id(customerId), `expected=${targetId}, actual=${settlementId(found) || 'missing'}, direction=${found?.direction ?? 'missing'}`, OPTIONS) return found ?? null } 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 accountRows(value) { const data = value?.body?.data return rows(data).concat(rows(data?.items)) } function accountCustomerId(value) { return id(value?.customerId ?? value?.tenantId ?? value?.accountId ?? value?.id) } function hasWalletField(value) { return value && typeof value === 'object' && ['balance', 'recharge', 'prepayment'].some((field) => Object.hasOwn(value, field)) } function accountInformationComplete(value) { return ['customerCode', 'customerName', 'contactName', 'contactPhone', 'status', 'lastChangedAt'] .every((field) => present(value?.[field])) && ['outstandingReceivable', 'pendingBilling', 'receivedInPeriod', 'outstandingPayable'] .every((field) => Number.isFinite(Number(value?.[field]))) } function ledgerInformationComplete(value) { return ['ledgerId', 'occurredAt', 'businessType', 'documentType', 'documentId', 'documentNo', 'operatorName'] .every((field) => present(value?.[field])) && ['receivableChange', 'payableChange', 'outstandingReceivableAfter'] .every((field) => Number.isFinite(Number(value?.[field]))) } async function verifyCustomerAccountAndLedger(context, fixture) { const customerId = id(fixture.customerId) const accountResponse = await context.request('GET', `${BASE}/accounts?page=1&pageSize=20&customerId=${encodeURIComponent(customerId)}`) const accounts = accountRows(accountResponse) const account = accounts.find((item) => accountCustomerId(item) === customerId) context.assert(MODULE, '客户账户列表包含本轮 marker 客户且不暴露钱包字段', accountResponse, Boolean(account) && !accounts.some(hasWalletField), `customerId=${customerId}, accountCount=${accounts.length}, requestId=${accountResponse.requestId ?? 'null'}, traceId=${accountResponse.traceId ?? 'null'}`, OPTIONS) context.assert(MODULE, '客户账户基本信息、四项金额和最近变动时间完整', accountResponse, accountInformationComplete(account), `customerId=${customerId}, customerCode=${account?.customerCode ?? 'missing'}, contact=${account?.contactName ?? 'missing'}/${account?.contactPhone ?? 'missing'}, lastChangedAt=${account?.lastChangedAt ?? 'missing'}`, OPTIONS) const ledgerResponse = await context.request('GET', `${BASE}/accounts/${encodeURIComponent(customerId)}/ledger?page=1&pageSize=200`) const ledger = accountRows(ledgerResponse) const rowsForCustomer = ledger.filter((item) => { const itemCustomer = accountCustomerId(item) return !itemCustomer || itemCustomer === customerId }) const expectedIds = new Set([ ...(fixture.statements || []).map((item) => statementId(item)), ...(fixture.receivableSettlements || []).map((item) => settlementId(item)), ...(fixture.payableSettlements || []).map((item) => settlementId(item)), ].filter(Boolean)) const containsRoundTrip = rowsForCustomer.some((item) => expectedIds.has(statementId(item) || settlementId(item) || id(item?.documentId ?? item?.businessId ?? item?.sourceId ?? item?.referenceId)) || String(item?.remark ?? '').includes(context.marker)) context.assert(MODULE, '客户账户流水仅属于本轮客户并包含账单/收款或结算流水', ledgerResponse, rowsForCustomer.length === ledger.length && containsRoundTrip, `customerId=${customerId}, ledgerCount=${ledger.length}, requestId=${ledgerResponse.requestId ?? 'null'}, traceId=${ledgerResponse.traceId ?? 'null'}`, OPTIONS) const incompleteLedger = rowsForCustomer.filter((item) => !ledgerInformationComplete(item)) context.assert(MODULE, '客户账户流水时间、业务类型、关联单据、金额和操作人完整', ledgerResponse, rowsForCustomer.length > 0 && incompleteLedger.length === 0, `customerId=${customerId}, ledgerCount=${rowsForCustomer.length}, incomplete=${incompleteLedger.length}`, OPTIONS) fixture.root.accountReadback = { account: account || null, accountDiagnostic: diagnostic(accountResponse), ledgerCount: ledger.length, ledgerDiagnostic: diagnostic(ledgerResponse) } } 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) }) } async 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 expectedSourceIds = new Set(root.feeSources.map(sourceId).filter(Boolean)) let pool = [] for (let attempt = 0; attempt < 20; attempt += 1) { const poolResponse = await context.request('GET', `${BASE}/fee-pool?customerId=${encodeURIComponent(customerId)}&direction=ALL`) const poolData = poolResponse?.body?.data pool = rows(poolData).concat(rows(poolData?.items)) const visible = new Set(pool.map((item) => id(item?.sourceId))) if ([...expectedSourceIds].every((source) => visible.has(source))) break await new Promise((resolve) => setTimeout(resolve, 500)) } const poolBySource = new Map(pool.map((item) => [id(item?.sourceId), item])) const attemptedSources = root.feeSources.map((raw) => { const financeSource = poolBySource.get(sourceId(raw)) return { raw, feeSourceId: financePoolId(financeSource), sourceFeeId: sourceId(raw), customerId: id(raw.customerId), direction: String(raw.direction ?? ''), currency: String(raw.currency ?? raw.currencyCode ?? '').toUpperCase(), businessDate: sourceDate(raw), waybillId: sourceWaybill(raw), marker: raw.marker, supplierId: id(raw.supplierId ?? root.supplierId), supplierName: raw.supplierName ?? root.supplierName ?? null, } }) const hasContainerAllocations = list(root.allocations).length > 0 const uncreatedSources = attemptedSources.filter((source) => !source.feeSourceId || (isUnboundContainerCost(source.raw) && !hasContainerAllocations)) return { root, customerId, sources: attemptedSources.filter((source) => source.feeSourceId && (!isUnboundContainerCost(source.raw) || hasContainerAllocations)), uncreatedSources, sourceMappings: attemptedSources.filter((source) => source.feeSourceId && (!isUnboundContainerCost(source.raw) || hasContainerAllocations)) .map((source) => ({ sourceFeeId: source.sourceFeeId, feeSourceId: source.feeSourceId, sourceType: source.raw?.sourceType ?? null, direction: source.direction })), } } 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) const listed = await findSettlementInOfficialList(context, 'RECEIVABLE', settlement.customerId, settlementId(settlement), `应收第 ${index} 张`) context.assert(MODULE, `第 ${index} 张 AR 正式列表金额与创建结果一致`, null, listed && Number(listed.totalAmount) === Number(settlement.totalAmount) && Number(listed.outstandingAmount) === Number(settlement.outstandingAmount), `createdTotal=${settlement.totalAmount ?? 'missing'}, listedTotal=${listed?.totalAmount ?? 'missing'}`, OPTIONS) return settlement } export async function run(context) { const start = context.steps.length const fixture = await normalizeFixture(context) if (!fixture) { blocker(context, '取得本轮财务全量 Fixture', 'financeFullFixtures 必须提供匹配 marker 的 customerId/orders/feeSources;禁止猜测旧字段或历史来源', 'missing-marker-owned-finance-fixture') finish(context, start) return } fixture.root.reconciliationPrerequisite = null const permissionResponse = await context.request('GET', '/api/platform/v1/iam/authorization/permissions') const permissions = context.apiSuccess(MODULE, '查询当前账号 Finance 有效权限', permissionResponse, '', OPTIONS) const granted = new Set(list(permissions).map(String)) if (granted.has(LEGACY_STATEMENT_VIEW_PERMISSION)) { fixture.root.reconciliationPrerequisite = { ready: false, category: 'permission-prerequisite', required: [STATEMENT_LIST_PERMISSION], legacy: [LEGACY_STATEMENT_VIEW_PERMISSION] } blocker(context, '客户对账单旧 view 权限兼容阻断', `检测到旧权限=${LEGACY_STATEMENT_VIEW_PERMISSION};列表和详情只能以正式权限=${STATEMENT_LIST_PERMISSION} 验收,旧 view 不得作为通过依据;需由 IAM 角色初始化为验收账号授予 ${STATEMENT_LIST_PERMISSION} 并移除旧资源,Finance 脚本不兼容旧权限`, 'legacy-customer-statement-view-permission', permissionResponse) finish(context, start) return } const missingPermissions = REQUIRED_PERMISSIONS.filter((permission) => !granted.has(permission)) if (missingPermissions.length > 0) { fixture.root.reconciliationPrerequisite = { ready: false, category: 'permission-prerequisite', required: missingPermissions, legacy: [] } blocker(context, 'Finance 写权限门禁', `缺少正式权限资源=${missingPermissions.join(',')}; 需由 IAM 角色初始化授予验收账号;对账接口将返回 A0403,禁止继续写入`, 'missing-finance-write-permissions', permissionResponse) finish(context, start) return } const incompleteContainerSources = fixture.uncreatedSources.filter((source) => isUnboundContainerCost(source.raw)) if (incompleteContainerSources.length > 0) { const evidence = incompleteContainerSources.map((source) => { const raw = source.raw return `sourceType=${raw.sourceType ?? 'missing'}, billingBasis=${raw.billingBasis ?? 'missing'}, feeSourceId=${source.feeSourceId || 'missing'}, containerBookingId=${id(raw.containerBookingId) || 'missing'}` }).join('; ') context.addCoverageGap(MODULE, SCENARIO, '忽略不完整的柜次成本尝试', `Finance 未返回 feeSourceId 或逐订单/运单 allocation 关联,不得作为费用来源消费:${evidence}`, 'incomplete-container-cost-attempt', { marker: context.marker, count: incompleteContainerSources.length }) } const unpublishedSources = fixture.uncreatedSources.filter((source) => !isUnboundContainerCost(source.raw)) if (unpublishedSources.length > 0) { blocker(context, '本轮费用来源进入 Finance 池', `等待 Finance fee-pool 后仍有 ${unpublishedSources.length} 条 marker-owned 来源不可见;禁止忽略来源或改用 MANUAL_COST ID`, 'unpublished-marker-owned-finance-source') finish(context, start) return } const invalid = fixture.sources.filter((source) => !source.feeSourceId || !source.customerId || !source.businessDate || !source.waybillId || source.marker !== context.marker || (source.direction === 'PAYABLE' && (!source.supplierId || !source.supplierName))) if (invalid.length > 0) { fixture.root.reconciliationPrerequisite = { ready: false, category: 'finance-source-prerequisite', invalidSourceCount: invalid.length } 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) { fixture.root.reconciliationPrerequisite = { ready: false, category: 'finance-source-prerequisite', receivableCount: receivables.length, businessDateCount: businessDates.size } blocker(context, '三张 AR 与日期范围 Fixture 门禁', `至少需要同客户 4 条 CNY 应收来源且跨 2 个真实 businessDate;actual sources=${receivables.length}, dates=${businessDates.size}`, 'insufficient-receivable-date-fixture') finish(context, start) return } 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') if (accessDenied(currencyResponse)) { blocker(context, 'Finance 对账写权限与当前令牌不一致', `POST ${BASE}/statements 返回 A0403 Access denied;customerId=${fixture.customerId}, currency=USD, requestId=${currencyResponse.requestId ?? 'null'}, traceId=${currencyResponse.traceId ?? 'null'};有效权限查询与 Finance JWT authority 不一致,禁止继续写入`, 'stale-finance-token-authorities', currencyResponse) finish(context, start) return } context.assert(MODULE, '对账单拒绝非 CNY 币种', currencyResponse, rejectedWith(currencyResponse, 'FIN_400'), `code=${currencyResponse.body?.code ?? 'missing'}`, OPTIONS) const cutoffDate = [...businessDates].sort()[0] const cutoffSources = receivables.filter((source) => source.businessDate === cutoffDate) if (cutoffSources.length < 2) { blocker(context, '日期筛选多行来源门禁', `最早真实 businessDate=${cutoffDate} 仅有 ${cutoffSources.length} 条来源,无法同时验证日期排除和多行调整`, 'insufficient-same-date-receivable-sources') finish(context, start) return } // Date coverage intentionally uses SELECTED. BY_CUTOFF queries all eligible customer history. const remainingSources = receivables.filter((source) => source.businessDate !== cutoffDate) if (remainingSources.length < 2) { blocker(context, '跨日期三组来源门禁', `最早 businessDate=${cutoffDate} 之外仅有 ${remainingSources.length} 条本轮来源,无法生成三张非空独立对账单`, 'insufficient-cross-date-receivable-sources') finish(context, start) return } const groups = [cutoffSources, ...(() => { return [remainingSources.slice(0, 1), remainingSources.slice(1)] })()] const issuedStatements = [] for (let index = 0; index < groups.length; index += 1) { const created = await createStatement(context, groups[index], `selected-${index + 1}`, `创建第 ${index + 1} 张独立 SELECTED 对账单`) if (accessDenied(created.response)) { blocker(context, `第 ${index + 1} 张 SELECTED 写权限`, `POST ${BASE}/statements 返回 A0403 Access denied;customerId=${created.body.customerId}, currency=${created.body.currency}, sourceIds=${created.body.feeSourceIds.join(',')}, requestId=${created.response.requestId ?? 'null'}, traceId=${created.response.traceId ?? 'null'};请求符合 DTO,但当前令牌缺少 platform:finance:reconciliation:create authority`, 'finance-reconciliation-create-forbidden', created.response) finish(context, start) return } if (financeSubjectMissing(created.response)) { blocker(context, '平台财务主体 Fixture 门禁', `POST ${BASE}/statements 返回 FIN_409 平台财务主体未配置;sourceIds=${created.body.feeSourceIds.join(',')}, requestId=${created.response.requestId ?? 'null'}, traceId=${created.response.traceId ?? 'null'};需在测试库 fin_subject 中提供 ACTIVE 主体`, 'missing-active-finance-subject', created.response) finish(context, start) return } if (index === 0) { const customerResponse = await context.request('GET', '/api/platform/v1/iam/tenants?page=1&size=100&status=ACTIVE') const otherCustomer = rows(customerResponse?.body?.data) .find((item) => id(item?.id ?? item?.tenantId) !== fixture.customerId) if (otherCustomer) { const otherCustomerId = id(otherCustomer.id ?? otherCustomer.tenantId) const isolationResponse = await write(context, 'POST', `${BASE}/statements`, { customerId: otherCustomerId, currency: 'CNY', generationMode: 'SELECTED', feeSourceIds: [groups[index][0].feeSourceId], periodStart: groups[index][0].businessDate, periodEnd: groups[index][0].businessDate, remark: `${context.marker}-foreign`, }, 'cross-customer-rejected') context.assert(MODULE, 'SELECTED 拒绝归入其他客户的本轮来源', isolationResponse, rejectedWith(isolationResponse, 'FIN_404'), `sourceCustomerId=${fixture.customerId}, requestedCustomerId=${otherCustomerId}, code=${isolationResponse.body?.code ?? 'missing'}`, OPTIONS) } else { context.addCoverageGap(MODULE, SCENARIO, 'cross-customer-isolation', '未找到另一个 ACTIVE 客户,无法用本轮来源执行跨客户归属负向请求', 'missing-cross-customer-fixture') } } 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 } await verifyIssuedStatementReadback(context, fixture, issued, `第 ${index + 1} 张已出账对账单`) issuedStatements.push(issued) if (index === 0) { context.assert(MODULE, 'SELECTED 日期范围只包含指定真实日期来源', created.response, created.body.periodStart === cutoffDate && created.body.periodEnd === cutoffDate && sameIds(lineSourceIds(created.statement), cutoffSources.map((source) => source.feeSourceId)) && lineSourceIds(created.statement).every((source) => !receivables .filter((item) => item.businessDate !== cutoffDate) .some((item) => item.feeSourceId === source)), `date=${cutoffDate}, expected=${cutoffSources.length}, actual=${lineSourceIds(created.statement).length}`, OPTIONS) } } 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 与 supplierId 生成应付结算单', response, `sources=${payables.length}, supplierId=${supplierId}, supplierName=${supplierName ?? 'null'}`, OPTIONS) if (payableSettlement) { const listed = await findSettlementInOfficialList(context, 'PAYABLE', fixture.customerId, settlementId(payableSettlement), '应付') context.assert(MODULE, 'AP 正式列表供应商与金额和创建结果一致', null, listed && id(listed.supplierId) === id(payableSettlement.supplierId) && Number(listed.totalAmount) === Number(payableSettlement.totalAmount), `createdSupplier=${id(payableSettlement.supplierId)}, listedSupplier=${id(listed?.supplierId) || 'missing'}`, 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,且全部提供相同 supplierId;sources=${payables.length}, suppliers=${payableSupplierIds.size}`, 'insufficient-payable-supplier-fixture') } const statementRows = [ { kind: 'SELECTED_DATE', statementId: statementId(issuedStatements[0]), status: issuedStatements[0].status, version: issuedStatements[0].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), supplierName: payableSettlement.supplierName ?? payables[0]?.supplierName ?? null, status: payableSettlement.status, paymentStatus: payableSettlement.paymentStatus, version: payableSettlement.version, feeSourceIds: payables.map((source) => source.feeSourceId), }] : [] fixture.root.sourceMappings = fixture.sourceMappings await verifyCustomerAccountAndLedger(context, fixture) finish(context, start) }