Files
baiyue-firstleg/scripts/admin-test-env-modules/fleet-trip-finance-acceptance.mjs
T

103 lines
7.1 KiB
JavaScript
Raw Normal View History

2026-10-08 17:07:57 +08:00
const BASE = '/api/platform/v1/last-leg/fleet'
const OPTIONS = { scenario: 'fleet-trip-finance-attribution', deploymentBlocker: true }
const id = value => value == null ? '' : String(value)
const sameSet = (a, b) => a.length === new Set(a).size && b.length === new Set(b).size
&& JSON.stringify([...a].sort()) === JSON.stringify([...b].sort())
export function moneyCents(value) {
if (!['number', 'string'].includes(typeof value)) throw Error('金额必须是明确数值')
const match = /^(0|[1-9]\d*)(?:\.(\d{1,2}))?$/.exec(String(value))
if (!match || (typeof value === 'number' && !Number.isSafeInteger(value * 100)
&& Math.abs(value * 100 - Math.round(value * 100)) > 0.000001)) throw Error('金额格式或精度无效')
const cents = BigInt(match[1]) * 100n + BigInt((match[2] || '').padEnd(2, '0'))
if (typeof value === 'number' && cents > BigInt(Number.MAX_SAFE_INTEGER)) throw Error('金额超过安全精度')
return cents
}
// A pending amount stays unknown. This does not use agreed booking prices as paid amounts.
export function validateTripFinance(value, scope) {
const fail = message => { throw Error(`车次财务证据:${message}`) }
if (value?.tripNo !== scope.tripNo || value.receiptsVisible !== scope.receiptsVisible
|| !Array.isArray(value.dispatchNos) || !sameSet(value.dispatchNos, scope.dispatchNos)) fail('车次或权限范围漂移')
if (!['ATTRIBUTED', 'PENDING_ALLOCATION'].includes(value.attributionStatus)
|| typeof value.attributionReason !== 'string' || !value.attributionReason.trim()) fail('缺少业务归属判定')
const finance = value.finance
if (!['AVAILABLE', 'PENDING_ALLOCATION'].includes(finance?.status)
|| !Array.isArray(finance.sources) || !Array.isArray(finance.totals) || !Array.isArray(finance.documents)
|| typeof finance.reason !== 'string' || !finance.reason.trim()) fail('财务结果不完整')
const sourceKeys = new Set(), amounts = new Map(), covered = new Set()
for (const source of finance.sources) {
const business = `${id(source.customerId)}:${source.businessNo}`
if (!scope.businesses.some(row => `${row.customerId}:${row.deliveryNo}` === business)) fail('费用混入其他客户或派送')
const key = `${business}:${source.sourceNo}`
if (typeof source.sourceNo !== 'string' || !source.sourceNo.trim() || typeof source.customerId !== 'string'
|| !/^[1-9]\d*$/.test(source.customerId) || sourceKeys.has(key) || !Number.isSafeInteger(source.sourceVersion) || source.sourceVersion < 1
|| !['AVAILABLE', 'BILLED', 'SETTLED'].includes(source.status)) fail('费用重复、版本或生命周期无效')
if (!/^[A-Z]{3}$/.test(source.currency)) fail('费用币种无效')
sourceKeys.add(key); covered.add(business)
amounts.set(source.currency, (amounts.get(source.currency) || 0n) + moneyCents(source.amount))
}
if (finance.status === 'AVAILABLE' && scope.businesses.some(row => !covered.has(`${row.customerId}:${row.deliveryNo}`))) fail('缺失来源却声称应收完整')
if (!finance.sources.length && finance.status !== 'PENDING_ALLOCATION') fail('空来源不能冒充完整金额')
const totals = new Map()
for (const total of finance.totals) {
if (totals.has(total.currency) || !amounts.has(total.currency)
|| moneyCents(total.receivableAmount) !== amounts.get(total.currency)) fail('应收未按原币来源核平')
if (!scope.receiptsVisible && total.receivedAmount != null) fail('无实收权限泄露金额')
if (total.receivedAmount != null) moneyCents(total.receivedAmount)
totals.set(total.currency, total)
}
if (totals.size !== amounts.size) fail('币种汇总缺失')
if (!scope.receiptsVisible && finance.documents.length) fail('无实收权限泄露收款来源')
const documents = new Map(), paid = new Map(), pending = new Set()
for (const document of finance.documents) {
if (!document.billNo || documents.has(document.billNo) || !totals.has(document.currency)
|| !['ATTRIBUTED', 'PENDING_ALLOCATION'].includes(document.allocationStatus)) fail('收款单据归属或去重无效')
documents.set(document.billNo, document)
if (document.allocationStatus === 'PENDING_ALLOCATION') {
if (document.receivedAmount != null || !document.reason?.trim()) fail('未分配金额必须保留未知值及原因')
pending.add(document.currency)
} else {
paid.set(document.currency, (paid.get(document.currency) || 0n) + moneyCents(document.receivedAmount))
}
}
for (const [currency, total] of totals) {
if (pending.has(currency) && total.receivedAmount != null) fail('混合未分配来源却给出完整实收')
if (total.receivedAmount != null && (!paid.has(currency) || moneyCents(total.receivedAmount) !== paid.get(currency))) fail('实收与明确归属单据不一致')
}
return true
}
export async function verifyFleetTripFinance(context, module, scope) {
if (!scope?.tripNo || !scope.dispatchNos?.length || !scope.businesses?.length
|| scope.dispatchNos.some(no => !no) || scope.businesses.some(row => !/^[1-9]\d*$/.test(row.customerId) || !row.deliveryNo)) {
throw Error('车次财务检查缺少本轮实际发车及派送来源')
}
const proof = { marker: context.marker, tripNo: scope.tripNo, status: 'CHECKING', receiptsVisible: scope.receiptsVisible,
boundaries: '只读归属和原币核平;无账单/收款样本时不声称已验证部分及全额收款生命周期' }
context.createdResources.fleetTripFinance ??= []
context.createdResources.fleetTripFinance.push(proof)
const read = async (path, label) => {
const response = await context.request('GET', path)
const value = context.apiSuccess(module, label, response, '', OPTIONS)
if (response?.http !== 200 || String(response.body?.code) !== '200' || !value) throw Error(`${label}失败`)
return { response, value }
}
const path = `${BASE}/trips/${encodeURIComponent(scope.tripNo)}`
const before = await read(path, '财务查询前冻结本轮车次执行状态')
if (before.value.trip?.tripNo !== scope.tripNo || !before.value.trip.departedAt) throw Error('本轮车次尚无实际发车事实')
const result = await read(`${BASE}/operations/${encodeURIComponent(scope.tripNo)}/finance`, '按本轮实际车次查询Finance应收与实收')
proof.result = result.value
try { validateTripFinance(result.value, scope) }
catch (error) { context.assert(module, '车次财务来源、权限及金额严格核平', result.response, false, error.message, OPTIONS); throw error }
context.assert(module, '车次财务来源、权限及金额严格核平', result.response, true,
`sources=${result.value.finance.sources.length}, currencies=${result.value.finance.totals.length}, attribution=${result.value.attributionStatus}`, OPTIONS)
const after = await read(path, '财务查询后回查车次零写入')
const unchanged = JSON.stringify(before.value) === JSON.stringify(after.value)
context.assert(module, '只读财务查询不改变车次、资源与审核', after.response, unchanged, `tripNo=${scope.tripNo}`, OPTIONS)
if (!unchanged) throw Error('只读财务查询改变车次事实')
proof.result = result.value
proof.status = 'READ_ONLY_ATTRIBUTION_VERIFIED'
return proof
}