203 lines
18 KiB
JavaScript
203 lines
18 KiB
JavaScript
import { randomUUID } from 'node:crypto'
|
|
import { setTimeout as delay } from 'node:timers/promises'
|
|
|
|
const MODULE = 'fleet-company-cost-acceptance'
|
|
const OPTIONS = { scenario: 'company-vehicle-cost-payment', deploymentBlocker: true }
|
|
const FINANCE = '/api/platform/finance/v1'
|
|
const MASTER = '/api/platform/v1/last-leg/master-data'
|
|
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64')
|
|
const id = value => value == null ? '' : String(value)
|
|
const ok = response => response?.http === 200 && id(response?.body?.code) === '200'
|
|
function scaled(value, scale) {
|
|
if (!new RegExp(`^\\d+(\\.\\d{1,${scale}})?$`).test(id(value))) throw new Error('验收金额或汇率格式无效')
|
|
const [whole, fraction = ''] = id(value).split('.')
|
|
return BigInt(whole) * 10n ** BigInt(scale) + BigInt(fraction.padEnd(scale, '0'))
|
|
}
|
|
const cents = value => scaled(value, 2)
|
|
const amount = value => Number(`${value / 100n}.${id(value % 100n).padStart(2, '0')}`)
|
|
function canonical(value) {
|
|
if (Array.isArray(value)) return value.map(canonical).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)))
|
|
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])]))
|
|
return value
|
|
}
|
|
const same = (a, b) => JSON.stringify(canonical(a)) === JSON.stringify(canonical(b))
|
|
|
|
/** Internal authorized TEST module; entrypoint owns registration, credentials and execution. No direct fetch, SQL or historical fixtures. */
|
|
export async function run(context, { pollAttempts = 60, pollDelayMs = 1000 } = {}) {
|
|
if (!context.marker || !context.createdResources) throw new Error('公司车辆成本验收须有独立marker和本轮资源上下文')
|
|
if (context.createdResources.fleetCompanyCostAcceptance) throw new Error('本轮已存在公司成本验收记录;保留原证据,不自动重新造数')
|
|
if (!Number.isInteger(pollAttempts) || pollAttempts < 1 || pollAttempts > 120 || !Number.isFinite(pollDelayMs) || pollDelayMs < 0 || pollDelayMs > 5000)
|
|
throw new Error('公司成本轮询范围无效')
|
|
const proof = { marker: context.marker, status: 'CHECKING', cases: [], requests: [], cleanup: 'NOT_CREATED', isolationScope: 'Only marker-owned company fees must be absent from the customer fee pool. Customer-specific cost/account records and unrelated concurrent changes are not inspected or compared.' }
|
|
context.createdResources.fleetCompanyCostAcceptance = proof
|
|
function check(label, condition, response = null) {
|
|
context.assert(MODULE, label, response, Boolean(condition), '', OPTIONS)
|
|
if (!condition) throw new Error(`${MODULE}: ${label}`)
|
|
}
|
|
function evidence(label, method, path, response) {
|
|
proof.requests.push({ label, method, path, http: response?.http, code: response?.body?.code,
|
|
requestId: response?.requestId, traceId: response?.traceId })
|
|
}
|
|
async function request(method, path, body, label, { key, reject } = {}) {
|
|
const planned = { label, method, path, requestBody: body, idempotencyKey: key, state: 'PLANNED' }
|
|
proof.requests.push(planned)
|
|
await context.checkpoint?.()
|
|
const response = await context.request(method, path, body, context.token, key ? { 'Idempotency-Key': key } : {})
|
|
Object.assign(planned, { state: 'RETURNED', http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId })
|
|
await context.checkpoint?.()
|
|
if (reject) {
|
|
check(label, response?.http === 200 && reject.includes(id(response?.body?.code)), response)
|
|
return response.body?.data
|
|
}
|
|
const data = context.apiSuccess(MODULE, label, response, '', OPTIONS)
|
|
check(label, ok(response) && data != null, response)
|
|
return data
|
|
}
|
|
async function snapshotCustomer(label) {
|
|
const pool = await request('GET', `${FINANCE}/fee-pool?direction=ALL`, undefined, `${label}只读核对公司来源未进入客户费用池`)
|
|
check('客户费用池隔离查询形状完整', Array.isArray(pool))
|
|
// Do not retain or operate other tasks' customer records. Only count this run's unique marker.
|
|
return { companyFeePoolRows: pool.filter(row => JSON.stringify(row).includes(context.marker)).length }
|
|
}
|
|
async function page(path, label) {
|
|
const result = await request('GET', `${path}?page=1&size=100`, undefined, label)
|
|
check(`${label}分页完整`, Array.isArray(result.records) && Number(result.page) === 1 && Number(result.size) === 100
|
|
&& Number(result.total) === result.records.length && Number(result.total) <= 100)
|
|
return result.records
|
|
}
|
|
let failure
|
|
try {
|
|
const businessDate = new Date().toISOString().slice(0, 10), month = businessDate.slice(0, 7)
|
|
const exchange = await request('GET', `${MASTER}/exchange-rates/${month}`, undefined, '读取本月有效USD汇率,不修改共享汇率')
|
|
check('USD汇率月份有效', exchange.month === month && exchange.status === 'ACTIVE' && scaled(exchange.usdToCnyAverage, 6) > 0n)
|
|
proof.exchangeRate = { month, usdToCnyAverage: id(exchange.usdToCnyAverage), recordVersion: id(exchange.recordVersion) }
|
|
proof.customerBefore = await snapshotCustomer('操作前')
|
|
check('本轮marker尚无客户费用污染,禁止覆盖旧验收', proof.customerBefore.companyFeePoolRows === 0)
|
|
proof.supplierRequest = { name: `${context.marker} 公司成本专用供应商`, category: 'TRUCKING', carrierCodes: [], region: 'TEST-OVERSEAS',
|
|
legalRepresentative: '受控验收', contactName: '受控验收', phone: '00000000000', email: null, address: '受控测试地点',
|
|
businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'IMMEDIATE', remark: context.marker, truckingScope: 'OVERSEAS' }
|
|
proof.supplierId = id(await request('POST', '/api/platform/v1/suppliers?tenantId=0', proof.supplierRequest, '创建本轮独占公司成本供应商'))
|
|
check('供应商创建返回真实标识', /^[1-9]\d*$/.test(proof.supplierId))
|
|
const supplier = await request('GET', `/api/platform/v1/suppliers/${encodeURIComponent(proof.supplierId)}?tenantId=0`, undefined, '核对本轮独占供应商')
|
|
check('供应商确属本轮独占样本', id(supplier.id) === proof.supplierId && supplier.status === 'ACTIVE' && supplier.remark === context.marker && supplier.name === proof.supplierRequest.name)
|
|
const vehicle = await request('POST', `${MASTER}/vehicles`, { plateNumber: `FC-${randomUUID().slice(0, 12)}`, model: 'Company cost TEST', fuelConsumptionPer100Km: 10,
|
|
mileageKm: 100, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: context.marker }, '创建本轮公司成本专用车辆')
|
|
check('车辆返回稳定身份', Boolean(vehicle.id && vehicle.plateNumber))
|
|
proof.vehicleId = id(vehicle.id); proof.plateNumber = vehicle.plateNumber; proof.cleanup = 'PENDING'
|
|
const care = `/api/platform/v1/last-leg/fleet/vehicles/${encodeURIComponent(proof.vehicleId)}/records`
|
|
for (const [recordType, currency, originalAmount] of [['REPAIR', 'CNY', 27.31], ['RESCUE', 'USD', 10]]) {
|
|
const scenario = { recordType, status: 'CHECKING', commands: [] }; proof.cases.push(scenario)
|
|
const details = { recordType, occurredAt: new Date(Date.now() - 1000).toISOString(), distanceUnit: null, odometer: null,
|
|
lastServiceOdometer: null, nextServiceOdometer: null, fuelQuantity: null, fuelUnit: null, verificationStatus: null, remark: context.marker,
|
|
incident: { description: `${context.marker} 受控验收事件`, item: `${context.marker} ${recordType}公司费用`, place: '受控测试地点', handler: '验收操作人',
|
|
processingStatus: 'COMPLETED', exceptionType: null, reportedCost: originalAmount, currency } }
|
|
const creation = { idempotencyKey: randomUUID(), details }; scenario.creation = creation
|
|
const record = await request('POST', care, creation, `创建本轮${recordType}记录`)
|
|
check('仅操作当前车辆当前运行记录', id(record.vehicleId) === proof.vehicleId && record.details?.remark === context.marker && record.details?.recordType === recordType && record.recordNo)
|
|
scenario.recordNo = record.recordNo
|
|
const recordPath = `${care}/${encodeURIComponent(record.recordNo)}`
|
|
const rate = currency === 'CNY' ? 1000000n : scaled(exchange.usdToCnyAverage, 6)
|
|
const total = (cents(originalAmount) * rate + 500000n) / 1000000n
|
|
check('受控金额可分为两笔付款', total > 1n && total <= BigInt(Number.MAX_SAFE_INTEGER))
|
|
let submission
|
|
async function submit() {
|
|
const command = { recordVersion: record.recordVersion, supplierId: proof.supplierId, businessDate, idempotencyKey: randomUUID() }
|
|
scenario.commands.push({ action: 'SUBMIT', request: command })
|
|
submission = await request('POST', `${recordPath}/finance`, command, '提交真实公司费用来源')
|
|
check('提交不冒充入账或付款', submission.recordNo === record.recordNo && submission.status === 'PENDING_FINANCE' && BigInt(submission.sourceVersion) > 0n)
|
|
const replay = await request('POST', `${recordPath}/finance`, command, '原请求重放提交')
|
|
check('重放保持同一来源版本', replay.replayed === true && id(replay.sourceVersion) === id(submission.sourceVersion))
|
|
}
|
|
async function awaitFinance() {
|
|
for (let attempt = 0; attempt < pollAttempts; attempt++) {
|
|
const rows = await request('POST', `${care}/finance-status`, { recordNos: [record.recordNo] }, '轮询本条已提交Finance来源')
|
|
check('来源状态严格按本车本记录返回', Array.isArray(rows) && rows.length === 1 && rows[0].recordNo === record.recordNo)
|
|
const state = rows[0]
|
|
check('投递未进入需人工处理状态', state.status !== 'FINANCE_ATTENTION')
|
|
if (state.finance) {
|
|
const cost = state.finance, source = cost.source
|
|
check('Finance来源与提交版本、车辆、供应商及费用完全匹配', source?.sourceNo === record.recordNo && source.sourceType === recordType
|
|
&& id(source.sourceVersion) === id(submission.sourceVersion) && id(source.vehicleId) === proof.vehicleId
|
|
&& id(source.supplierId) === proof.supplierId && source.businessDate === businessDate && source.exchangeRateMonth === month
|
|
&& source.originalCurrency === currency && cents(source.originalAmount) === cents(originalAmount)
|
|
&& scaled(source.exchangeRate, 6) === rate && cents(source.amount) === total && source.remark === context.marker
|
|
&& cost.subjectCode === 'PLATFORM_DEFAULT' && cost.currency === 'CNY' && Boolean(cost.costNo))
|
|
check('首次财务接收仍未入账付款', state.status === 'PENDING' && cost.status === 'PENDING' && cents(cost.paidAmount) === 0n && cost.outstandingAmount == null)
|
|
return cost
|
|
}
|
|
check('未接收时明确等待,不接受假成功', state.status === 'PENDING_FINANCE')
|
|
if (attempt + 1 < pollAttempts) await delay(pollDelayMs)
|
|
}
|
|
throw new Error('Finance来源轮询超时,不能认定已接收或完成验收')
|
|
}
|
|
await submit(); let cost = await awaitFinance(); scenario.costNo = cost.costNo
|
|
const costPath = `${FINANCE}/vehicle-costs/${encodeURIComponent(cost.costNo)}`
|
|
async function write(action, body, label) {
|
|
const key = randomUUID(); scenario.commands.push({ action, key, request: body })
|
|
const result = await request('POST', `${costPath}/${action}`, body, label, { key })
|
|
const replay = await request('POST', `${costPath}/${action}`, body, `${label}原键重放`, { key })
|
|
check(`${label}重放不重复推进`, same(result, replay))
|
|
return result
|
|
}
|
|
async function approve() {
|
|
cost = await write('approve', { expectedVersion: cost.recordVersion, reason: '受控验收入账' }, '确认公司成本入账')
|
|
check('入账只形成未付应付', cost.status === 'APPROVED' && cents(cost.paidAmount) === 0n && cents(cost.outstandingAmount) === total)
|
|
}
|
|
await approve()
|
|
if (recordType === 'REPAIR') {
|
|
cost = await write('void', { expectedVersion: cost.recordVersion, reason: '受控验证未付款作废后重建' }, '未付款费用作废')
|
|
check('作废保留原金额且未付款', cost.status === 'VOIDED' && cents(cost.paidAmount) === 0n && cost.outstandingAmount == null)
|
|
const previousVersion = BigInt(submission.sourceVersion)
|
|
await submit(); cost = await awaitFinance()
|
|
check('作废重建保留费用号并增加来源版本', cost.costNo === scenario.costNo && BigInt(submission.sourceVersion) === previousVersion + 1n)
|
|
await approve()
|
|
}
|
|
const uploadPath = `${costPath}/vouchers`
|
|
const uploaded = await context.requestMultipart('POST', uploadPath, { name: `${recordType.toLowerCase()}-controlled-payment.png`, type: 'image/png', content: PNG })
|
|
evidence('上传合成测试付款凭证', 'POST', uploadPath, uploaded)
|
|
const voucher = context.apiSuccess(MODULE, '上传合成测试付款凭证', uploaded, '', OPTIONS)
|
|
check('付款凭证为公司专属真实文件', ok(uploaded) && voucher?.fileId && voucher.ownerScope === 'PLATFORM' && id(voucher.tenantId) === '0'
|
|
&& voucher.businessType === 'FINANCE_VEHICLE_VOUCHER' && voucher.contentType === 'image/png' && Number(voucher.sizeBytes) === PNG.length, uploaded)
|
|
scenario.voucherFileId = id(voucher.fileId)
|
|
const binary = await context.requestBinary('GET', `${costPath}/vouchers/${encodeURIComponent(voucher.fileId)}/content`)
|
|
check('付款凭证下载字节与本轮合成图片一致', binary?.http === 200 && id(binary.contentType).startsWith('image/png') && binary.bytes instanceof Uint8Array && Buffer.from(binary.bytes).equals(PNG), binary)
|
|
const partial = total / 2n
|
|
const payment = { expectedVersion: cost.recordVersion, amount: amount(partial), paidAt: new Date(Date.now() - 1000).toISOString(),
|
|
method: 'BANK_TRANSFER', referenceNo: `${context.marker}-${recordType}`, voucherFileIds: [id(voucher.fileId)], remark: '受控验收模拟线下付款凭证,不发起银行转账' }
|
|
cost = await write('payments', payment, '登记部分付款')
|
|
check('部分付款金额守恒', cost.status === 'PARTIALLY_PAID' && cents(cost.paidAmount) === partial && cents(cost.outstandingAmount) === total - partial)
|
|
await request('POST', `${costPath}/payments`, { ...payment, expectedVersion: cost.recordVersion, amount: amount(total - partial + 1n) }, '拒绝超过未付余额的付款', { key: randomUUID(), reject: ['FIN_409'] })
|
|
const unchanged = await request('GET', costPath, undefined, '超付失败后回查余额及版本')
|
|
check('超付拒绝没有改变财务事实', same(unchanged, cost))
|
|
cost = await write('payments', { ...payment, expectedVersion: cost.recordVersion, amount: amount(total - partial) }, '登记剩余实际付款')
|
|
check('全额付款终态金额守恒', cost.status === 'PAID' && cents(cost.paidAmount) === total && cents(cost.outstandingAmount) === 0n)
|
|
await request('POST', `${costPath}/void`, { expectedVersion: cost.recordVersion, reason: '负向验证已付款不可作废' }, '已付款作废被拒绝', { key: randomUUID(), reject: ['FIN_409'] })
|
|
await request('PUT', recordPath, { recordVersion: record.recordVersion, reason: '负向验证已付款不可改金额', details: { ...details, incident: { ...details.incident, reportedCost: originalAmount + 1 } } }, '已付款来源修改被拒绝', { reject: ['A0409'] })
|
|
check('已付款负向动作不改变费用', same(await request('GET', costPath, undefined, '负向动作后回查已付费用'), cost))
|
|
const careAfter = await request('GET', recordPath, undefined, '回查运维申报金额未被覆盖')
|
|
check('负向修改不改变运维版本或金额', id(careAfter.recordVersion) === id(record.recordVersion) && cents(careAfter.details?.incident?.reportedCost) === cents(originalAmount))
|
|
const paid = await page(`${costPath}/payments`, '核对实际付款流水')
|
|
check('两次付款重放仅留下两条不同流水及原凭证', paid.length === 2 && new Set(paid.map(row => row.paymentNo)).size === 2
|
|
&& paid.reduce((sum, row) => sum + cents(row.amount), 0n) === total && paid.every(row => row.currency === 'CNY' && same(row.voucherFileIds.map(id), [id(voucher.fileId)])))
|
|
const history = await page(`${costPath}/history`, '核对不可变财务操作历史')
|
|
check('历史保留接收、入账、两次付款及作废过程', history.filter(row => row.action === 'PAY').length === 2
|
|
&& history.some(row => row.action === 'RECEIVE') && history.some(row => row.action === 'APPROVE')
|
|
&& (recordType !== 'REPAIR' || history.some(row => row.action === 'VOID')))
|
|
scenario.finance = cost; scenario.payments = paid; scenario.history = history; scenario.status = 'VERIFIED'
|
|
check('本轮公司费用未进入客户费用池', same(proof.customerBefore, await snapshotCustomer(`${recordType}完成后`)))
|
|
}
|
|
proof.status = 'VERIFIED'
|
|
} catch (error) { failure = error; proof.status = 'FAILED'; proof.error = error instanceof Error ? error.message : String(error) }
|
|
finally {
|
|
if (proof.vehicleId) {
|
|
try {
|
|
const path = `${MASTER}/vehicles/${encodeURIComponent(proof.vehicleId)}`
|
|
const vehicle = await request('GET', path, undefined, '结束前读取本轮专用车辆')
|
|
const ended = await request('PATCH', `${path}/status`, { recordVersion: vehicle.recordVersion, status: 'INACTIVE' }, '停用本轮专用车辆,保留财务与运维证据')
|
|
check('本轮专用车辆已停用', ended.status === 'INACTIVE'); proof.cleanup = 'INACTIVE'
|
|
} catch (error) { proof.cleanup = 'FAILED'; proof.cleanupError = error instanceof Error ? error.message : String(error); proof.status = 'FAILED'; failure ??= error }
|
|
}
|
|
}
|
|
if (failure) throw failure
|
|
}
|