Files
baiyue-firstleg/scripts/admin-test-env-modules/finance-extended-settlement.mjs
T
2026-08-12 18:00:17 +08:00

308 lines
18 KiB
JavaScript

const MODULE = 'finance-extended-settlement'
const SCENARIO = 'finance-extended-settlement-real-api'
const REQUIRED_MARKER = 'FIN-EXT-SETTLE-20260812'
const BASE = '/api/platform/finance/v1'
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
const id = (value) => value == null ? '' : String(value)
const data = (response) => response?.body?.data ?? response?.body
const rows = (value) => Array.isArray(value) ? value : Array.isArray(value?.records) ? value.records : []
const ok = (response) => response?.http === 200 && String(response?.body?.code ?? '200') === '200'
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 version = (value) => Number(value?.version ?? value?.summary?.version ?? 0)
const paymentStatus = (value) => id(value?.paymentStatus ?? value?.summary?.paymentStatus ?? value?.status)
const dateOffset = (days) => { const value = new Date(); value.setUTCDate(value.getUTCDate() + days); return value.toISOString().slice(0, 10) }
function key(context, operation) {
return `${context.marker}-extended-${operation}`.replace(/[^A-Za-z0-9_.:-]/g, '-').slice(0, 128)
}
function write(context, method, path, body, operation) {
return context.request(method, path, body, context.token, { 'Idempotency-Key': key(context, operation) })
}
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 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 })
}
async function runOwnedFixture(context) {
const fixtureModule = await import(new URL('./finance-full-fixtures.mjs', import.meta.url))
await fixtureModule.run(context)
const fixture = context.createdResources.financeFullFixtures
return fixture?.marker === context.marker ? fixture : null
}
async function loadPayableInputs(context) {
const [feeItemsResponse, partnersResponse] = await Promise.all([
context.request('GET', '/api/platform/v1/fee-items?page=1&pageSize=200'),
context.request('GET', '/api/platform/v1/partners?page=1&size=200&status=ACTIVE'),
])
const feeItems = rows(data(feeItemsResponse))
const partners = rows(data(partnersResponse))
return {
feeItem: feeItems.find((item) => item?.status === 'ACTIVE' && item?.warehousePayable === true) || null,
supplier: partners.find((item) => item?.status === 'ACTIVE' && item?.id) || null,
response: !ok(feeItemsResponse) ? feeItemsResponse : partnersResponse,
}
}
async function createWaybillPayable(context, fixture, order, input, index) {
const inboundId = id(order?.inboundId)
if (!inboundId) return null
const path = `/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fee-details?accountId=${encodeURIComponent(fixture.customerId)}`
const body = { feeItemId: Number(input.feeItem.id), direction: 'PAYABLE', businessDate: dateOffset(-3 - index),
unitPrice: 90 + index * 10, amount: 90 + index * 10, currency: 'CNY', settlementIncluded: true,
partnerId: id(input.supplier.id), partnerName: input.supplier.partnerName || null,
remark: `${context.marker}-WAYBILL-PAYABLE-${index + 1}` }
const response = await write(context, 'POST', path, body, `waybill-payable-${index + 1}`)
const detail = context.apiSuccess(MODULE, `创建第 ${index + 1} 条独占运单 PAYABLE 来源`, response,
`inboundId=${inboundId}`, OPTIONS)
const sourceFeeId = id(detail?.id)
if (!sourceFeeId) return null
const approvePath = `/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fee-details/${sourceFeeId}/approve?accountId=${encodeURIComponent(fixture.customerId)}`
const approveResponse = await write(context, 'POST', approvePath, {}, `waybill-payable-${index + 1}-approve`)
context.apiSuccess(MODULE, `审核第 ${index + 1} 条独占运单 PAYABLE 来源`, approveResponse, '', OPTIONS)
if (!ok(approveResponse)) return null
return { sourceFeeId, inboundId, orderId: id(order.orderId),
waybillId: id(order.waybillId ?? order.shipmentId ?? order.orderNo),
businessDate: body.businessDate, amount: body.amount,
supplierId: id(input.supplier.id), supplierName: input.supplier.partnerName || null }
}
async function resolvePoolSources(context, fixture, created) {
let poolResponse = null
for (let attempt = 0; attempt < 20; attempt += 1) {
poolResponse = await context.request('GET', `${BASE}/fee-pool?customerId=${encodeURIComponent(fixture.customerId)}&direction=PAYABLE`)
const poolData = data(poolResponse)
const pool = rows(poolData).concat(rows(poolData?.items))
const bySource = new Map(pool.map((item) => [id(item?.sourceId), item]))
const resolved = created.map((item) => {
const row = bySource.get(item.sourceFeeId)
const poolItemId = id(row?.poolItemId)
return row ? { ...item, feeSourceId: poolItemId.includes(':') ? poolItemId.slice(poolItemId.indexOf(':') + 1)
: id(row?.feeSourceId ?? row?.financeFeeSourceId ?? row?.id) } : null
})
if (resolved.every((item) => item?.feeSourceId)) return { resolved, response: poolResponse }
await new Promise((resolve) => setTimeout(resolve, 500))
}
return { resolved: [], response: poolResponse }
}
async function runReconciliation(context, fixture, payableForLegacyModule) {
const original = fixture.feeSources
fixture.feeSources = [...original.filter((source) => source.direction !== 'PAYABLE'), payableForLegacyModule]
try {
const module = await import(new URL('./finance-reconciliation-full.mjs', import.meta.url))
await module.run(context)
} finally {
fixture.feeSources = original
}
}
async function createExtraPayableSettlement(context, fixture, source, index) {
const body = { customerId: id(fixture.customerId), supplierId: id(source.supplierId),
feeSourceIds: [Number(source.feeSourceId)], dueDate: source.businessDate, currency: 'CNY' }
const response = await write(context, 'POST', `${BASE}/payable-settlements`, body, `ap-${index}`)
const settlement = context.apiSuccess(MODULE, `创建第 ${index} 张独立 AP`, response,
`feeSourceId=${source.feeSourceId}`, OPTIONS)
if (!settlement) return null
const replayResponse = await write(context, 'POST', `${BASE}/payable-settlements`, body, `ap-${index}`)
const replay = context.apiSuccess(MODULE, `第 ${index} 张 AP 幂等重放`, replayResponse, '', OPTIONS)
context.assert(MODULE, `第 ${index} 张 AP 幂等保持同一资源`, replayResponse,
settlementId(replay) === settlementId(settlement), `settlementId=${settlementId(settlement)}`, OPTIONS)
return settlement
}
async function detail(context, targetId) {
const response = await context.request('GET', `${BASE}/settlements/${encodeURIComponent(targetId)}`)
return { response, value: data(response) }
}
function receiptBody(context, fixture, allocations) {
return { customerId: id(fixture.customerId), receivedDate: new Date().toISOString().slice(0, 10),
amount: allocations.reduce((sum, item) => sum + item.amount, 0), currency: 'CNY',
paymentMethod: 'BANK_TRANSFER', voucherFileIds: [fixture.voucherFileId],
remark: `${context.marker}-MULTI-AR`, allocations }
}
async function settleReceivables(context, fixture, receivables) {
const details = await Promise.all(receivables.map((item) => detail(context, settlementId(item))))
const partialBefore = outstanding(details[1].value)
const paidBefore = outstanding(details[2].value)
const partialAmount = Math.max(0.01, Math.floor(partialBefore * 50) / 100)
const allocations = [
{ billId: Number(statementId(receivables[1])), amount: partialAmount },
{ billId: Number(statementId(receivables[2])), amount: paidBefore },
]
const response = await write(context, 'POST', `${BASE}/receipts`, receiptBody(context, fixture, allocations), 'multi-ar-receipt')
const replayResponse = await write(context, 'POST', `${BASE}/receipts`, receiptBody(context, fixture, allocations), 'multi-ar-receipt')
const receipt = data(response)
const replay = data(replayResponse)
context.assert(MODULE, '一笔真实收款分配两张 AR 且携带凭证', response, ok(response)
&& id(receipt?.receiptId ?? receipt?.id) && allocations.length === 2,
`receiptId=${id(receipt?.receiptId ?? receipt?.id) || 'missing'}, voucherFileId=${fixture.voucherFileId}`, OPTIONS)
context.assert(MODULE, '收款幂等重放不重复入账', replayResponse,
ok(replayResponse) && id(replay?.receiptId ?? replay?.id) === id(receipt?.receiptId ?? receipt?.id),
`first=${id(receipt?.receiptId ?? receipt?.id)}, replay=${id(replay?.receiptId ?? replay?.id)}`, OPTIONS)
const after = await Promise.all(receivables.map((item) => detail(context, settlementId(item))))
context.assert(MODULE, '三张 AR 分别保留未收、部分收、已收清', response,
outstanding(after[0].value) > 0 && outstanding(after[1].value) > 0
&& outstanding(after[1].value) < partialBefore && outstanding(after[2].value) === 0
&& paymentStatus(after[0].value) === 'UNPAID'
&& paymentStatus(after[1].value) === 'PARTIALLY_PAID'
&& paymentStatus(after[2].value) === 'PAID',
`paymentStatus=${after.map((item) => paymentStatus(item.value)).join(',')}, outstanding=${after.map((item) => outstanding(item.value)).join(',')}`, OPTIONS)
return { receipt, states: after.map((item) => item.value) }
}
function paymentBody(context, fixture, amount, expectedVersion, suffix) {
return { transactionDate: new Date().toISOString().slice(0, 10), amount, currency: 'CNY',
method: 'BANK_TRANSFER', referenceNo: `${context.marker}-${suffix}`, voucherFileIds: [fixture.voucherFileId],
expectedVersion, remark: context.marker }
}
async function settlePayables(context, fixture, payables) {
const initial = await Promise.all(payables.map((item) => detail(context, settlementId(item))))
const partialAmount = Math.max(0.01, Math.floor(outstanding(initial[1].value) * 50) / 100)
const partialBody = paymentBody(context, fixture, partialAmount, version(initial[1].value), 'AP-PARTIAL')
const partialResponse = await write(context, 'POST', `${BASE}/settlements/${settlementId(payables[1])}/transactions`, partialBody, 'ap-partial')
const partial = data(partialResponse)
context.assert(MODULE, '第二张 AP 部分付款', partialResponse, ok(partialResponse)
&& outstanding(partial) > 0 && outstanding(partial) < outstanding(initial[1].value),
`outstanding=${outstanding(partial)}`, OPTIONS)
const paidBody = paymentBody(context, fixture, outstanding(initial[2].value), version(initial[2].value), 'AP-PAID')
const paidResponse = await write(context, 'POST', `${BASE}/settlements/${settlementId(payables[2])}/transactions`, paidBody, 'ap-paid')
const paidReplay = await write(context, 'POST', `${BASE}/settlements/${settlementId(payables[2])}/transactions`, paidBody, 'ap-paid')
const paid = data(paidResponse)
const replay = data(paidReplay)
context.assert(MODULE, '第三张 AP 全额付款且幂等重放', paidReplay, ok(paidResponse) && ok(paidReplay)
&& outstanding(paid) === 0 && outstanding(replay) === 0,
`settlementId=${settlementId(paid)}, replaySettlementId=${settlementId(replay) || 'response-omitted'}, outstanding=${outstanding(paid)}, replayCode=${id(paidReplay.body?.code)}, requestId=${paidReplay.requestId ?? 'null'}, traceId=${paidReplay.traceId ?? 'null'}`, OPTIONS)
const overBody = paymentBody(context, fixture, 0.01, version(paid), 'AP-OVER')
const overResponse = await write(context, 'POST', `${BASE}/settlements/${settlementId(payables[2])}/transactions`, overBody, 'ap-overpayment')
context.assert(MODULE, '已付清 AP 拒绝超额付款', overResponse, !ok(overResponse),
`HTTP=${overResponse.http}, code=${id(overResponse.body?.code)}, requestId=${overResponse.requestId ?? 'null'}, traceId=${overResponse.traceId ?? 'null'}`, OPTIONS)
const final = await Promise.all(payables.map((item) => detail(context, settlementId(item))))
context.assert(MODULE, '三张 AP 分别保留未付、部分付、已付', paidResponse,
outstanding(final[0].value) > 0 && outstanding(final[1].value) > 0 && outstanding(final[2].value) === 0
&& paymentStatus(final[0].value) === 'UNPAID'
&& paymentStatus(final[1].value) === 'PARTIALLY_PAID'
&& paymentStatus(final[2].value) === 'PAID',
`paymentStatus=${final.map((item) => paymentStatus(item.value)).join(',')}, outstanding=${final.map((item) => outstanding(item.value)).join(',')}`, OPTIONS)
return { states: final.map((item) => item.value), overpayment: diagnostic(overResponse) }
}
async function assertDuplicateOccupancy(context, fixture) {
const first = fixture.statements?.find((item) => item?.feeSourceIds?.length)
if (!first) return
const body = { customerId: Number(fixture.customerId), currency: 'CNY', generationMode: 'SELECTED',
feeSourceIds: [Number(first.feeSourceIds[0])], periodStart: first.periodStart,
periodEnd: first.periodEnd, remark: `${context.marker}-DUPLICATE-OCCUPANCY` }
const response = await write(context, 'POST', `${BASE}/statements`, body, 'duplicate-source')
context.assert(MODULE, '已占用来源拒绝生成重复对账单', response, !ok(response),
`code=${id(response.body?.code)}, requestId=${response.requestId ?? 'null'}, traceId=${response.traceId ?? 'null'}`, OPTIONS)
}
export async function run(context) {
const start = context.steps.length
const runnerMarker = context.marker
context.marker = `${REQUIRED_MARKER}-${runnerMarker.slice(-5)}`
context.assert(MODULE, '使用指定独立 marker 前缀和本轮幂等后缀', null,
context.marker.startsWith(`${REQUIRED_MARKER}-`),
`baseMarker=${REQUIRED_MARKER}, executionMarker=${context.marker}`, OPTIONS)
const fixture = await runOwnedFixture(context)
if (!fixture?.customerId || !fixture?.voucherFileId || !Array.isArray(fixture.orders) || fixture.orders.length < 3) {
blocker(context, '建立真实独占订单、费用与凭证 Fixture',
`customerId=${fixture?.customerId || 'missing'}, orders=${fixture?.orders?.length || 0}, voucherFileId=${fixture?.voucherFileId || 'missing'};禁止借历史来源`,
'marker-owned-fixture-unavailable')
finish(context, start)
return
}
const input = await loadPayableInputs(context)
if (!input.feeItem?.id || !input.supplier?.id) {
blocker(context, '取得真实 PAYABLE 费用项目与供应商',
`feeItemId=${id(input.feeItem?.id) || 'missing'}, supplierId=${id(input.supplier?.id) || 'missing'}`,
'payable-master-data-unavailable', input.response)
finish(context, start)
return
}
const createdPayables = []
for (let index = 0; index < 3; index += 1) {
const source = await createWaybillPayable(context, fixture, fixture.orders[index], input, index)
if (source) createdPayables.push(source)
}
const pool = await resolvePoolSources(context, fixture, createdPayables)
if (pool.resolved.length !== 3) {
blocker(context, '三条本轮 PAYABLE 来源进入 Finance 费用池',
`created=${createdPayables.length}, visible=${pool.resolved.length}`, 'payable-fee-pool-sync-failed', pool.response)
finish(context, start)
return
}
const legacyPayable = { ...pool.resolved[0], feeSourceId: pool.resolved[0].sourceFeeId,
direction: 'PAYABLE', currency: 'CNY', sourceType: 'MANUAL',
marker: context.marker, partnerId: pool.resolved[0].supplierId }
await runReconciliation(context, fixture, legacyPayable)
const receivables = fixture.receivableSettlements || []
const firstPayable = fixture.payableSettlements?.[0]
if (receivables.length !== 3 || !firstPayable) {
blocker(context, '生成三张 SELECTED 对账单、三张 AR 与首张 AP',
`statements=${fixture.statements?.length || 0}, AR=${receivables.length}, AP=${fixture.payableSettlements?.length || 0}`,
'reconciliation-chain-incomplete')
finish(context, start)
return
}
await assertDuplicateOccupancy(context, fixture)
const secondPayable = await createExtraPayableSettlement(context, fixture, pool.resolved[1], 2)
const thirdPayable = await createExtraPayableSettlement(context, fixture, pool.resolved[2], 3)
const payables = [firstPayable, secondPayable, thirdPayable].filter(Boolean)
if (payables.length !== 3) {
blocker(context, '生成三张独立 AP', `expected=3, actual=${payables.length}`,
'three-payable-settlements-failed')
finish(context, start)
return
}
const arResult = await settleReceivables(context, fixture, receivables)
const apResult = await settlePayables(context, fixture, payables)
const statusMatrix = {
AR: arResult.states.map((item, index) => ({ settlementId: settlementId(item), settlementNo: item?.settlementNo ?? item?.summary?.settlementNo ?? null,
expectedState: ['UNPAID', 'PARTIALLY_PAID', 'PAID'][index], paymentStatus: paymentStatus(item), outstandingAmount: outstanding(item) })),
AP: apResult.states.map((item, index) => ({ settlementId: settlementId(item), settlementNo: item?.settlementNo ?? item?.summary?.settlementNo ?? null,
expectedState: ['UNPAID', 'PARTIALLY_PAID', 'PAID'][index], paymentStatus: paymentStatus(item), outstandingAmount: outstanding(item) })),
}
context.createdResources.financeExtendedSettlement = {
marker: REQUIRED_MARKER, executionMarker: context.marker,
customerId: id(fixture.customerId), orderIds: fixture.orders.map((item) => id(item.orderId)),
sourceIds: { receivable: fixture.feeSources.filter((item) => item.direction === 'RECEIVABLE').map((item) => id(item.feeSourceId)),
payable: pool.resolved.map((item) => item.feeSourceId) },
statementIds: fixture.statements.filter((item) => item.kind !== 'SELECTED_DATE').map((item) => id(item.statementId)),
receivableSettlementIds: receivables.map(settlementId), payableSettlementIds: payables.map(settlementId),
voucherFileId: id(fixture.voucherFileId), receiptId: id(arResult.receipt?.receiptId ?? arResult.receipt?.id),
statusMatrix, overpayment: apResult.overpayment,
containerPayableDeploymentBlocker: fixture.deploymentBlocker || [],
}
finish(context, start)
}