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 }