215 lines
15 KiB
JavaScript
215 lines
15 KiB
JavaScript
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 }
|
||
}
|
||
}
|