110 lines
7.0 KiB
JavaScript
110 lines
7.0 KiB
JavaScript
import assert from 'node:assert/strict'
|
|||
|
|
import test from 'node:test'
|
||
|
|
import { readFileSync } from 'node:fs'
|
||
|
|
import { moneyCents, validateTripFinance, verifyFleetTripFinance } from './fleet-trip-finance-acceptance.mjs'
|
||
|
|
|
||
|
|
const scope = { tripNo: 'TR-CURRENT', dispatchNos: ['DS-CURRENT'], receiptsVisible: true,
|
||
|
|
businesses: [{ customerId: '9007199254740993', deliveryNo: 'DL-CURRENT' }] }
|
||
|
|
test('default fulfillment runs finance verification using its own departed trip and delivery numbers', () => {
|
||
|
|
const source = readFileSync(new URL('./phase5-overseas-fulfillment.mjs', import.meta.url), 'utf8')
|
||
|
|
assert.match(source, /import \{ verifyFleetTripFinance \} from '\.\/fleet-trip-finance-acceptance\.mjs'/)
|
||
|
|
assert.match(source, /verifyFleetTripFinance\(context, MODULE, \{ tripNo: tripProof\.tripNo, dispatchNos: \[dispatchNo\]/)
|
||
|
|
assert.match(source, /businesses: deliveryNos\.map\(deliveryNo => \(\{ customerId, deliveryNo \}\)\)/)
|
||
|
|
})
|
||
|
|
function fixture(status = 'AVAILABLE', received = null) {
|
||
|
|
return { tripNo: scope.tripNo, dispatchNos: [...scope.dispatchNos], receiptsVisible: true,
|
||
|
|
attributionStatus: 'ATTRIBUTED', attributionReason: '唯一发车来源', finance: {
|
||
|
|
status: 'AVAILABLE', reason: '有效财务来源',
|
||
|
|
sources: [{ customerId: scope.businesses[0].customerId, businessNo: 'DL-CURRENT', sourceNo: 'FS-CURRENT',
|
||
|
|
sourceVersion: 1, status, currency: 'USD', amount: '100.03' }],
|
||
|
|
totals: [{ currency: 'USD', receivableAmount: '100.03', receivedAmount: received }],
|
||
|
|
documents: received === null ? [] : [{ billNo: 'BILL-CURRENT', currency: 'USD', receivedAmount: received,
|
||
|
|
allocationStatus: 'ATTRIBUTED', reason: '已确认收款分配' }],
|
||
|
|
} }
|
||
|
|
}
|
||
|
|
test('decimal amounts use integer cents and refuse unknown or unsafe precision', () => {
|
||
|
|
assert.equal(moneyCents('100.03'), 10003n)
|
||
|
|
assert.equal(moneyCents('9007199254740993.00'), 900719925474099300n)
|
||
|
|
for (const value of [null, undefined, '', -1, NaN, Infinity, '1.005', '1e3', 9007199254740993]) {
|
||
|
|
assert.throws(() => moneyCents(value))
|
||
|
|
}
|
||
|
|
})
|
||
|
|
test('available, billed and settled sources survive partial and full payment readback', () => {
|
||
|
|
for (const [state, paid] of [['AVAILABLE', null], ['BILLED', '30.01'], ['SETTLED', '100.03']]) {
|
||
|
|
assert.equal(validateTripFinance(fixture(state, paid), scope), true)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
test('currencies are reconciled separately without floating point addition', () => {
|
||
|
|
const value = fixture()
|
||
|
|
value.finance.sources.push({ ...value.finance.sources[0], sourceNo: 'FS-CNY', currency: 'CNY', amount: '0.20' })
|
||
|
|
value.finance.totals.push({ currency: 'CNY', receivableAmount: '0.20', receivedAmount: null })
|
||
|
|
assert.equal(validateTripFinance(value, scope), true)
|
||
|
|
value.finance.totals[0].receivableAmount = '100.23'
|
||
|
|
assert.throws(() => validateTripFinance(value, scope), /原币/)
|
||
|
|
})
|
||
|
|
test('pending allocation is explicitly unknown and never promoted to complete or zero', () => {
|
||
|
|
const value = fixture('BILLED')
|
||
|
|
value.finance.documents = [{ billNo: 'MIXED-BILL', currency: 'USD', receivedAmount: null,
|
||
|
|
allocationStatus: 'PENDING_ALLOCATION', reason: '账单含本车次以外来源' }]
|
||
|
|
assert.equal(validateTripFinance(value, scope), true)
|
||
|
|
value.finance.totals[0].receivedAmount = 0
|
||
|
|
assert.throws(() => validateTripFinance(value, scope), /完整实收/)
|
||
|
|
const empty = fixture()
|
||
|
|
Object.assign(empty.finance, { sources: [], totals: [], documents: [], status: 'PENDING_ALLOCATION' })
|
||
|
|
assert.equal(validateTripFinance(empty, scope), true)
|
||
|
|
empty.finance.status = 'AVAILABLE'
|
||
|
|
assert.throws(() => validateTripFinance(empty, scope))
|
||
|
|
})
|
||
|
|
test('receipt restricted projection cannot leak totals or documents', () => {
|
||
|
|
const value = fixture('BILLED'); value.receiptsVisible = false
|
||
|
|
const restricted = { ...scope, receiptsVisible: false }
|
||
|
|
assert.equal(validateTripFinance(value, restricted), true)
|
||
|
|
value.finance.totals[0].receivedAmount = '30.01'
|
||
|
|
assert.throws(() => validateTripFinance(value, restricted), /泄露金额/)
|
||
|
|
value.finance.totals[0].receivedAmount = null
|
||
|
|
value.finance.documents = fixture('BILLED', '30.01').finance.documents
|
||
|
|
assert.throws(() => validateTripFinance(value, restricted), /泄露收款/)
|
||
|
|
})
|
||
|
|
|
||
|
|
test('NON_NULL省略未知实收仍保持未知,明确归属的实收和应收不得缺失', () => {
|
||
|
|
const withoutNulls = value => JSON.parse(JSON.stringify(value, (_key, item) => item === null ? undefined : item))
|
||
|
|
assert.equal(validateTripFinance(withoutNulls(fixture()), scope), true)
|
||
|
|
const restricted = withoutNulls(fixture()); restricted.receiptsVisible = false
|
||
|
|
assert.equal(validateTripFinance(restricted, { ...scope, receiptsVisible: false }), true)
|
||
|
|
const pending = fixture('BILLED')
|
||
|
|
pending.finance.documents = [{ billNo: 'BILL-MIXED', currency: 'USD', receivedAmount: null, allocationStatus: 'PENDING_ALLOCATION', reason: '归属未完成' }]
|
||
|
|
assert.equal(validateTripFinance(withoutNulls(pending), scope), true)
|
||
|
|
const missingPaid = fixture('BILLED', '10.00'); delete missingPaid.finance.documents[0].receivedAmount
|
||
|
|
assert.throws(() => validateTripFinance(missingPaid, scope))
|
||
|
|
const missingAmount = fixture(); delete missingAmount.finance.sources[0].amount
|
||
|
|
assert.throws(() => validateTripFinance(missingAmount, scope))
|
||
|
|
})
|
||
|
|
test('wrong customer, trip, duplicate sources and invented receipts all reject', () => {
|
||
|
|
const mutations = [v => { v.tripNo = 'TR-FOREIGN' }, v => { v.dispatchNos = ['DS-FOREIGN'] },
|
||
|
|
v => { v.finance.sources[0].customerId = '9007199254740994' },
|
||
|
|
v => { v.finance.sources[0].businessNo = 'DL-FOREIGN' },
|
||
|
|
v => { v.finance.sources.push({ ...v.finance.sources[0] }) },
|
||
|
|
v => { v.finance.sources[0].status = 'VOIDED' }, v => { v.finance.sources[0].sourceVersion = 0 },
|
||
|
|
v => { v.finance.totals[0].receivedAmount = 25 },
|
||
|
|
v => { v.finance.totals.push({ ...v.finance.totals[0] }) }]
|
||
|
|
for (const mutate of mutations) { const value = fixture(); mutate(value); assert.throws(() => validateTripFinance(value, scope)) }
|
||
|
|
})
|
||
|
|
test('read-only orchestration retains proof and detects execution state mutation or HTTP failure', async () => {
|
||
|
|
for (const fault of ['', 'mutation', 'http']) {
|
||
|
|
const calls = [], checks = []; let reads = 0
|
||
|
|
const context = { marker: 'CURRENT', createdResources: {},
|
||
|
|
assert: (_m, _n, _r, passed) => checks.push(passed), apiSuccess: (_m, _n, r) => r.body.data,
|
||
|
|
request: async (method, path) => {
|
||
|
|
calls.push({ method, path })
|
||
|
|
if (path.endsWith('/finance')) return { http: fault === 'http' ? 503 : 200, body: { code: '200', data: fixture() } }
|
||
|
|
return { http: 200, body: { code: '200', data: { trip: { tripNo: scope.tripNo,
|
||
|
|
departedAt: '2026-10-05T00:00:00Z', recordVersion: ++reads === 2 && fault === 'mutation' ? 2 : 1 } } } }
|
||
|
|
} }
|
||
|
|
if (fault) await assert.rejects(verifyFleetTripFinance(context, 'offline', scope))
|
||
|
|
else assert.equal((await verifyFleetTripFinance(context, 'offline', scope)).status, 'READ_ONLY_ATTRIBUTION_VERIFIED')
|
||
|
|
assert.ok(calls.every(row => row.method === 'GET'))
|
||
|
|
assert.equal(context.createdResources.fleetTripFinance[0].status,
|
||
|
|
fault ? 'CHECKING' : 'READ_ONLY_ATTRIBUTION_VERIFIED')
|
||
|
|
}
|
||
|
|
})
|