309 lines
19 KiB
JavaScript
309 lines
19 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, suppliersResponse] = await Promise.all([
|
|
context.request('GET', '/api/platform/v1/fee-items?page=1&pageSize=200'),
|
|
context.request('GET', '/api/platform/v1/suppliers?page=1&size=200&status=ACTIVE&category=BOOKING'),
|
|
])
|
|
const feeItems = rows(data(feeItemsResponse))
|
|
const suppliers = rows(data(suppliersResponse))
|
|
return {
|
|
feeItem: feeItems.find((item) => item?.status === 'ACTIVE' && item?.warehousePayable === true
|
|
&& /BOOKING|FREIGHT|OCEAN|SHIPPING|订舱|海运|运费/i.test(`${item.feeCode || item.code || ''} ${item.feeName || item.name || ''}`)) || null,
|
|
supplier: suppliers.find((item) => item?.status === 'ACTIVE' && item?.category === 'BOOKING' && item?.id) || null,
|
|
response: !ok(feeItemsResponse) ? feeItemsResponse : suppliersResponse,
|
|
}
|
|
}
|
|
|
|
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.name || 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.name || 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)
|
|
}
|