Files

215 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const MODULE = 'finance-settlement-full'
const OPTIONS = { scenario: MODULE, deploymentBlocker: true }
const BASE = '/api/platform/finance/v1'
const id = (value) => value == null ? '' : String(value)
const data = (response) => response?.body?.data ?? response?.body
const ok = (response) => response?.http === 200 && (!response?.body?.code || String(response.body.code) === '200')
const key = (context, operation) => `${context.marker}-finance-settlement-${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) })
const settlementId = (value) => id(value?.settlementId ?? value?.summary?.settlementId ?? value?.id)
const statementId = (value) => id(value?.statementId ?? value?.reconciliationStatementId ?? value?.id)
const outstanding = (value) => Number(value?.outstandingAmount ?? value?.summary?.outstandingAmount ?? 0)
const paid = (value) => Number(value?.paidAmount ?? value?.summary?.paidAmount ?? 0)
const version = (value) => Number(value?.version ?? value?.summary?.version ?? 0)
const status = (value) => id(value?.status ?? value?.summary?.status ?? value?.paymentStatus)
export function values(value) {
if (Array.isArray(value)) return value.filter(Boolean)
if (!value || typeof value !== 'object') return []
if (Array.isArray(value.records)) return value.records.filter(Boolean)
return Object.entries(value).filter(([name, item]) => name !== 'marker' && item && typeof item === 'object').map(([, item]) => item)
}
function gap(context, name, scope, reason, category = 'finance-settlement-fixture-unavailable') {
context.addStep?.(MODULE, name, null, false, reason, OPTIONS)
context.addCoverageGap?.(MODULE, OPTIONS.scenario, scope, reason, category, { marker: context.marker })
}
export function receiptBody(context, customerId, voucherFileId, allocations) {
return { customerId: String(customerId), receivedDate: new Date().toISOString().slice(0, 10),
amount: allocations.reduce((sum, item) => sum + item.amount, 0), currency: 'CNY',
paymentMethod: 'BANK_TRANSFER', voucherFileIds: [voucherFileId], remark: context.marker, allocations }
}
async function detail(context, targetId) {
const response = await context.request('GET', `${BASE}/settlements/${targetId}`)
return { response, value: data(response) }
}
async function findActionTarget(context, items) {
for (const item of items) {
const current = await detail(context, settlementId(item))
if (ok(current.response) && status(current.value) === 'ISSUED'
&& outstanding(current.value) > 0 && paid(current.value) === 0) return item
}
return null
}
async function verifyVoidReopen(context, item) {
const targetId = settlementId(item)
const before = await detail(context, targetId)
if (!ok(before.response) || status(before.value) !== 'ISSUED' || outstanding(before.value) <= 0 || paid(before.value) !== 0) {
gap(context, '取得未收付款 ISSUED 结算单', 'settlement VOID/REOPEN',
`本轮 settlementId=${targetId || 'missing'} 不满足 ISSUED 且 paidAmount=0;status=${status(before.value)}, paid=${paid(before.value)}`,
'settlement-action-fixture-unavailable')
return null
}
const path = `${BASE}/settlements/${targetId}/actions`
const voidBody = { action: 'VOID', reason: context.marker, expectedVersion: version(before.value) }
const voidedResponse = await write(context, 'POST', path, voidBody, 'settlement-void')
const voided = data(voidedResponse)
context.assert(MODULE, '未收付款结算单作废', voidedResponse, ok(voidedResponse)
&& status(voided) === 'VOIDED' && version(voided) === version(before.value) + 1,
`settlementId=${targetId}, status=${status(voided)}, version=${version(voided)}`, OPTIONS)
const replayResponse = await write(context, 'POST', path, voidBody, 'settlement-void')
const replayed = data(replayResponse)
context.assert(MODULE, '结算单作废同键同参幂等重放', replayResponse, ok(replayResponse)
&& status(replayed) === 'VOIDED' && version(replayed) === version(voided),
`settlementId=${targetId}, version=${version(replayed)}`, OPTIONS)
const conflictResponse = await write(context, 'POST', path,
{ action: 'REOPEN', reason: `${context.marker}-conflict`, expectedVersion: version(voided) }, 'settlement-void')
context.assert(MODULE, '结算单动作同键异参冲突', conflictResponse, !ok(conflictResponse),
`HTTP=${conflictResponse.http}, code=${id(conflictResponse.body?.code)}`, OPTIONS)
const reopenBody = { action: 'REOPEN', reason: context.marker, expectedVersion: version(voided) }
const reopenedResponse = await write(context, 'POST', path, reopenBody, 'settlement-reopen')
const reopened = data(reopenedResponse)
context.assert(MODULE, '已作废结算单重开', reopenedResponse, ok(reopenedResponse)
&& status(reopened) === 'ISSUED' && version(reopened) === version(voided) + 1,
`settlementId=${targetId}, status=${status(reopened)}, version=${version(reopened)}`, OPTIONS)
return reopened
}
async function verifyPaidVoidRejected(context, item) {
const targetId = settlementId(item)
const before = await detail(context, targetId)
if (!ok(before.response) || paid(before.value) <= 0) {
gap(context, '取得已发生收付款结算单', 'paid settlement VOID rejection',
`本轮 settlementId=${targetId || 'missing'} 没有有效收付款;paid=${paid(before.value)}`,
'paid-settlement-fixture-unavailable')
return
}
const response = await write(context, 'POST', `${BASE}/settlements/${targetId}/actions`,
{ action: 'VOID', reason: context.marker, expectedVersion: version(before.value) }, 'paid-settlement-void')
const after = await detail(context, targetId)
context.assert(MODULE, '已收付款结算单拒绝作废且状态金额不变', response, !ok(response) && ok(after.response)
&& status(after.value) === status(before.value) && version(after.value) === version(before.value)
&& paid(after.value) === paid(before.value) && outstanding(after.value) === outstanding(before.value),
`settlementId=${targetId}, status=${status(after.value)}, paid=${paid(after.value)}, outstanding=${outstanding(after.value)}, version=${version(after.value)}`, OPTIONS)
}
async function receive(context, fixtures, item, requestedAmount, operation, label) {
const targetId = settlementId(item)
const billId = statementId(item) || id(item.reconciliationStatementId)
const before = await detail(context, targetId)
const applied = Math.min(Number(requestedAmount), outstanding(before.value))
const response = await write(context, 'POST', `${BASE}/receipts`, receiptBody(context, fixtures.customerId,
fixtures.voucherFileId, [{ billId: Number(billId), amount: applied }]), operation)
const after = await detail(context, targetId)
context.assert(MODULE, label, response, ok(response) && ok(after.response)
&& outstanding(after.value) === outstanding(before.value) - applied,
`before=${outstanding(before.value)}, applied=${applied}, after=${outstanding(after.value)}`, OPTIONS)
return { receipt: data(response), settlement: after.value }
}
export async function run(context) {
const fixtures = context.createdResources?.financeFullFixtures
if (!fixtures || fixtures.marker !== context.marker) {
gap(context, '取得本轮财务 Fixture', 'financeFullFixtures', '缺少匹配 marker 的 financeFullFixtures,禁止借历史单据')
return
}
const statements = Array.isArray(fixtures.statements) ? fixtures.statements.filter(Boolean) : []
const receivables = Array.isArray(fixtures.receivableSettlements) ? fixtures.receivableSettlements.filter(Boolean) : []
const payables = Array.isArray(fixtures.payableSettlements) ? fixtures.payableSettlements.filter(Boolean) : []
const customerId = id(fixtures.customerId)
const voucherFileId = id(fixtures.voucherFileId ?? fixtures.voucher?.fileId ?? fixtures.voucher?.id)
if (!customerId || !voucherFileId) {
gap(context, '取得真实财务凭证', 'voucher-file', '前置 Fixture 未提供 File 服务创建且归属本客户的 FINANCE_VOUCHER fileId,收付款必须 fail-closed', 'missing-finance-voucher')
return
}
const issuedStatementIds = new Set(statements.filter((item) => item.status === 'ISSUED').map(statementId).filter(Boolean))
const validReceivables = receivables.filter((item) => item.direction === 'RECEIVABLE'
&& settlementId(item) && statementId(item) && issuedStatementIds.has(statementId(item)))
const validPayables = payables.filter((item) => item.direction === 'PAYABLE' && settlementId(item) && item.supplierId)
const arSettlementIds = new Set(validReceivables.map(settlementId))
const arStatementIds = new Set(validReceivables.map(statementId))
if (validReceivables.length !== 3 || arSettlementIds.size !== 3 || arStatementIds.size !== 3 || validPayables.length !== 1) {
const upstream = fixtures.reconciliationPrerequisite?.ready === false
? fixtures.reconciliationPrerequisite
: Object.values(fixtures.prerequisites || {}).find((item) => item?.ready === false)
const missing = []
if (statements.filter((item) => item.status === 'ISSUED').length < 3) missing.push('reconciliation-issued-statement-prerequisite')
if (validReceivables.length !== 3) missing.push('receivable-settlement-prerequisite')
if (validPayables.length !== 1) missing.push('payable-settlement-prerequisite')
const category = upstream?.category || (missing.length === 1 ? missing[0] : 'upstream-finance-fixture-prerequisite')
gap(context, '取得完整收付结算 Fixture', 'three AR and one AP settlements',
`上游 Finance fixture 前置条件未满足,非结算服务故障;需要 3 张关联本轮 ISSUED 对账单的独立 AR 和 1 张带 supplierId 的 AP;actual AR=${validReceivables.length}, AP=${validPayables.length}; prerequisite=${category}; missing=${missing.join(',') || 'unknown'}`,
category)
return
}
const manualIds = values(fixtures.sourceMappings).filter((source) => source.sourceType === 'MANUAL')
.map((source) => id(source.feeSourceId)).filter(Boolean)
const usedIds = new Set([
...statements.flatMap((statement) => values(statement.feeSourceIds).map(id)),
...payables.flatMap((settlement) => values(settlement.feeSourceIds).map(id)),
])
context.assert(MODULE, '手动上传费用出账后来源完结', null,
manualIds.length > 0 && manualIds.every((feeId) => usedIds.has(feeId)),
`manual=${manualIds.length}, settled=${manualIds.filter((feeId) => usedIds.has(feeId)).length}`, OPTIONS)
const actionTarget = await findActionTarget(context, [...validReceivables, ...validPayables])
if (!actionTarget) gap(context, '取得结算单动作 Fixture', 'settlement VOID/REOPEN', '本轮没有未收付款且状态 ISSUED 的 AR/AP,禁止借历史单据')
else await verifyVoidReopen(context, actionTarget)
const multiTargets = validReceivables
if (multiTargets.length < 3) gap(context, '取得三张应收结算单', 'multi receipt allocation', '本轮没有三张独占应收单,禁止借历史单据')
else {
const details = await Promise.all(multiTargets.map((item) => detail(context, settlementId(item))))
const allocations = multiTargets.map((item, index) => ({ billId: Number(statementId(item)), amount: Math.min(Number(fixtures.multiBillAllocationAmount || 1), outstanding(details[index].value)) }))
const response = await write(context, 'POST', `${BASE}/receipts`, receiptBody(context, customerId, voucherFileId, allocations), 'multi-bill-receipt')
const after = await Promise.all(multiTargets.map((item) => detail(context, settlementId(item))))
context.assert(MODULE, '一笔收款分配多张应收单', response, ok(response) && after.every((item, index) =>
outstanding(item.value) === outstanding(details[index].value) - allocations[index].amount), `allocations=${allocations.length}`, OPTIONS)
fixtures.receipts = { ...(fixtures.receipts || {}), multi: data(response) }
}
const primary = validReceivables[0]
if (!primary) gap(context, '取得应收结算单', 'receivable partial/full', 'receivableSettlements 未提供 settlementId 与 statementId')
else {
const initial = await detail(context, settlementId(primary))
const partialAmount = Number(fixtures.receivablePartialAmount || Math.max(0.01, Math.floor(outstanding(initial.value) * 50) / 100))
const partial = await receive(context, fixtures, primary, partialAmount, 'ar-partial-receipt', '应收部分收款更新余额和状态')
context.assert(MODULE, '应收部分收款状态正确', null,
['PARTIALLY_PAID', 'PAID'].includes(id(partial.settlement?.status ?? partial.settlement?.paymentStatus)),
`status=${id(partial.settlement?.status ?? partial.settlement?.paymentStatus)}`, OPTIONS)
const final = await receive(context, fixtures, primary, outstanding(partial.settlement), 'ar-final-receipt', '应收全额收款完结')
fixtures.receipts = { ...(fixtures.receipts || {}), partial: partial.receipt, final: final.receipt }
fixtures.settlementFinalStates = { ...(fixtures.settlementFinalStates || {}), receivable: final.settlement }
await verifyPaidVoidRejected(context, primary)
}
const payable = validPayables[0]
const payableId = settlementId(payable)
if (!payableId) gap(context, '取得应付结算单', 'payable partial/full/overpayment', '前置 Fixture 的 payableSettlements 数组为空或缺少 PAYABLE 结算单')
else {
let current = (await detail(context, payableId)).value
const payment = (payAmount, expectedVersion) => ({ transactionDate: new Date().toISOString().slice(0, 10), amount: payAmount,
currency: 'CNY', method: 'BANK_TRANSFER', referenceNo: `${context.marker}-AP`, voucherFileIds: [voucherFileId], expectedVersion, remark: context.marker })
const firstAmount = Number(fixtures.payablePartialAmount || Math.max(0.01, Math.floor(outstanding(current) * 50) / 100))
const partialResponse = await write(context, 'POST', `${BASE}/settlements/${payableId}/transactions`, payment(firstAmount, version(current)), 'ap-partial-payment')
current = data(partialResponse)
context.assert(MODULE, '应付部分付款更新余额和状态', partialResponse, ok(partialResponse) && outstanding(current) >= 0, `outstanding=${outstanding(current)}`, OPTIONS)
const finalResponse = await write(context, 'POST', `${BASE}/settlements/${payableId}/transactions`, payment(outstanding(current), version(current)), 'ap-final-payment')
current = data(finalResponse)
context.assert(MODULE, '应付付清完结', finalResponse, ok(finalResponse) && outstanding(current) === 0, `status=${id(current?.status)}`, OPTIONS)
const overResponse = await write(context, 'POST', `${BASE}/settlements/${payableId}/transactions`, payment(0.01, version(current)), 'ap-overpayment')
context.assert(MODULE, '应付超额付款拦截', overResponse, !ok(overResponse), `HTTP=${overResponse.http}, code=${id(overResponse.body?.code)}`, OPTIONS)
fixtures.payments = { ...(fixtures.payments || {}), partial: data(partialResponse), final: current, overpayment: { http: overResponse.http, code: overResponse.body?.code } }
fixtures.settlementFinalStates = { ...(fixtures.settlementFinalStates || {}), payable: current }
}
}