This commit is contained in:
2026-08-12 10:59:39 +08:00
parent 91c6ebca5c
commit 4d0c245b60
16 changed files with 1368 additions and 12 deletions
@@ -0,0 +1,62 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import http from 'node:http'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { AcceptanceRun } from './admin-test-env-acceptance.mjs'
const here = path.dirname(fileURLToPath(import.meta.url))
const source = fs.readFileSync(path.join(here, 'admin-test-env-acceptance.mjs'), 'utf8')
test('默认全量按 fixture、reconciliation、settlement 顺序接入且不重复旧 finance', () => {
const match = source.match(/const DEFAULT_MODULES = \[(.*?)\]/s)
assert.ok(match)
const modules = [...match[1].matchAll(/'([^']+)'/g)].map((item) => item[1])
assert.deepEqual(modules.slice(-3), ['finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full'])
assert.equal(modules.filter((item) => item === 'finance').length, 1)
assert.ok(modules.indexOf('finance') < modules.indexOf('finance-full-fixtures'))
assert.match(source, /fullFinanceChain/)
})
test('multipart request preserves FormData boundary and unified response evidence', () => {
assert.match(source, /async requestMultipart\(/)
assert.match(source, /form\.append\('file'/)
assert.match(source, /body instanceof FormData \? body : JSON\.stringify\(body\)/)
assert.doesNotMatch(source, /requestMultipart[\s\S]{0,500}Content-Type.*multipart/i)
})
test('multipart helper sends the File upload contract and parses unified evidence', async (t) => {
let received = null
const server = http.createServer((request, response) => {
const chunks = []
request.on('data', (chunk) => chunks.push(chunk))
request.on('end', () => {
received = { headers: request.headers, body: Buffer.concat(chunks).toString('latin1') }
response.writeHead(200, { 'Content-Type': 'application/json', 'X-Request-Id': 'request-1', 'X-Trace-Id': 'trace-1' })
response.end(JSON.stringify({ code: '200', data: { fileId: '9' } }))
})
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
t.after(() => server.close())
const address = server.address()
const run = new AcceptanceRun({ gateway: `http://127.0.0.1:${address.port}` })
run.token = 'contract-token'
const result = await run.requestMultipart('POST', '/api/platform/finance/v1/vouchers?customerId=7', {
name: 'voucher.txt', type: 'text/plain', content: new TextEncoder().encode('voucher fixture'),
})
assert.equal(result.http, 200)
assert.equal(result.body.data.fileId, '9')
assert.equal(result.requestId, 'request-1')
assert.equal(result.traceId, 'trace-1')
assert.equal(received.headers.authorization, 'Bearer contract-token')
assert.match(received.headers['content-type'], /^multipart\/form-data; boundary=/)
assert.match(received.body, /name="file"; filename="voucher\.txt"/)
assert.match(received.body, /voucher fixture/)
})
test('module failures, coverage gaps and deployment blockers remain report surfaces', () => {
for (const field of ['this.modules.push', 'coverageGaps', 'deploymentBlockers']) assert.match(source, new RegExp(field.replace('.', '\\.'), 's'))
assert.match(source, /async executeModule\(name, handler\)/)
assert.match(source, /const extension = await import\(new URL\(`\.\/admin-test-env-modules\/\$\{module\}/)
})
+96 -3
View File
@@ -6,12 +6,13 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import process from 'node:process'
const DEFAULT_GATEWAY = 'http://8.163.30.128:9000'
const DEFAULT_USERNAME = 'platform_admin'
const DEFAULT_PASSWORD = 'ChangeMe123!'
const DEFAULT_MODULES = ['customers', 'overseas-storage-fees', 'system', 'master-data', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'phase3-container-loading', 'phase3-loading', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'finance']
const DEFAULT_MODULES = ['customers', 'overseas-storage-fees', 'system', 'master-data', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'phase3-container-loading', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full']
const WEIGHT_IMAGE_PATH = 'C:/Users/BY-OY/Desktop/上传图片包/测量图片/测量图片/orig_1cf27695-b2c3-44b3-98e3-c0be8b80f79a.JPG'
const CUSTOMER_FIELDS = [
'id', 'tenantCode', 'tenantName', 'status', 'legalName', 'legalRepresentative',
@@ -82,7 +83,7 @@ function derived(length, width, height, edge) {
return { outerLength, outerWidth, outerHeight, volume: halfUp3(outerLength * outerWidth * outerHeight / 1_000_000) }
}
class AcceptanceRun {
export class AcceptanceRun {
constructor(options) {
this.options = options
this.marker = `ADMIN-API-${new Date().toISOString().replace(/\D/g, '').slice(0, 17)}`
@@ -121,6 +122,36 @@ class AcceptanceRun {
}
}
async requestMultipart(method, path, file, token = this.token, extraHeaders = {}) {
const form = file instanceof FormData ? file : new FormData()
if (!(file instanceof FormData)) {
if (!file || file.content === undefined) throw new TypeError('multipart file requires content')
const content = file.content instanceof Uint8Array ? file.content : new Uint8Array(file.content)
form.append('file', new Blob([content], { type: file.type || 'application/octet-stream' }), file.name || 'fixture.bin')
}
return this.requestWithBody(method, path, form, token, extraHeaders)
}
async requestWithBody(method, path, body, token = this.token, extraHeaders = {}) {
const headers = { Accept: 'application/json', ...extraHeaders }
if (token) headers.Authorization = `Bearer ${token}`
if (!(body instanceof FormData) && body !== undefined) headers['Content-Type'] = 'application/json; charset=utf-8'
try {
const response = await fetch(`${this.options.gateway}${path}`, {
method, headers, body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body),
signal: AbortSignal.timeout(60_000),
})
const content = await response.text(); let payload = null
try { payload = content ? JSON.parse(content) : null } catch { /* bounded raw response */ }
return { method, path, http: response.status, body: payload,
raw: payload === null ? content.slice(0, 500) : null,
requestId: payload?.requestId || response.headers.get('x-request-id'),
traceId: payload?.traceId || response.headers.get('x-trace-id') }
} catch (error) {
return { method, path, http: 0, body: null, raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null }
}
}
async requestBinary(method, path) {
const headers = { Accept: '*/*', Authorization: `Bearer ${this.token}` }
try {
@@ -476,6 +507,51 @@ class AcceptanceRun {
}
}
async preparePhase3LoadingFixture() {
const originalMarker = this.marker
const fixtureMarker = `${originalMarker}-LOAD`
const createdKeys = ['orders', 'ocrInbound', 'testInbound', 'inboundMeasurement']
const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']
const createdSnapshot = new Map(createdKeys.map((key) => [key, {
present: Object.hasOwn(this.createdResources, key), value: this.createdResources[key],
}]))
const contextSnapshot = new Map(contextKeys.map((key) => [key, {
present: Object.hasOwn(this.context, key), value: this.context[key],
}]))
this.marker = fixtureMarker
const orders = await import(new URL('./admin-test-env-modules/orders.mjs', import.meta.url))
await this.executeModule('orders-phase3-loading-fixture', () => orders.run(this))
const measurement = await import(new URL('./admin-test-env-modules/inbound-measurement.mjs', import.meta.url))
await this.executeModule('inbound-measurement-phase3-loading-fixture', () => measurement.run(this))
const fixture = this.createdResources.inboundMeasurement || this.createdResources.orders
const loadingFixture = fixture ? {
marker: originalMarker,
sourceMarker: fixtureMarker,
tenantId: fixture.tenantId,
orderId: fixture.orderId || this.createdResources.orders?.orderId,
inboundId: fixture.inboundId,
} : { marker: originalMarker, sourceMarker: fixtureMarker, failed: true }
this.createdResources.orchestrationFixtures = {
...(this.createdResources.orchestrationFixtures || {}),
phase3Loading: loadingFixture,
inboundMeasurement: loadingFixture,
}
this.marker = originalMarker
return () => {
this.marker = originalMarker
for (const [key, snapshot] of createdSnapshot) {
if (snapshot.present) this.createdResources[key] = snapshot.value
else delete this.createdResources[key]
}
for (const [key, snapshot] of contextSnapshot) {
if (snapshot.present) this.context[key] = snapshot.value
else delete this.context[key]
}
}
}
async run() {
const loginResponse = await this.request('POST', '/api/auth/login', { username: this.options.username, password: this.options.password }, '')
const login = this.apiSuccess('authentication', '平台管理员登录', loginResponse)
@@ -485,6 +561,11 @@ class AcceptanceRun {
const handlers = { customers: () => this.runCustomers() }
for (let moduleIndex = 0; moduleIndex < this.options.modules.length; moduleIndex += 1) {
const module = this.options.modules[moduleIndex]
const fullFinanceChain = ['finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full'].every((name) => this.options.modules.includes(name))
if (module === 'finance' && fullFinanceChain) {
this.modules.push({ name: module, startedAt: new Date().toISOString(), finishedAt: new Date().toISOString(), success: true, passed: 0, failed: 0, fatal: null, skipped: 'finance-full-chain owns the finance source locks' })
continue
}
let restoreFixture = null
const ocrIndex = this.options.modules.indexOf('inbound-weight-ocr')
if (module === 'inbound-measurement' && ocrIndex >= 0 && ocrIndex < moduleIndex) {
@@ -505,6 +586,16 @@ class AcceptanceRun {
{ scenario: 'phase3-container-resource-isolation' })
}
}
if (module === 'phase3-loading') {
try {
restoreFixture = await this.preparePhase3LoadingFixture()
} catch (error) {
this.marker = this.marker.replace(/-LOAD$/, '')
const reason = error instanceof Error ? error.message : String(error)
this.addStep('phase3-loading-fixture', '创建独立 WAIT_SPLIT 装柜入库单', null, false, reason,
{ scenario: 'phase3-loading-resource-isolation' })
}
}
let handler = handlers[module]
if (!handler) {
try {
@@ -575,4 +666,6 @@ async function main() {
process.exitCode = report.success ? 0 : 1
}
main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1 })
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1 })
}
@@ -0,0 +1,115 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { run } from './finance-full-fixtures.mjs'
const here = path.dirname(fileURLToPath(import.meta.url))
const source = fs.readFileSync(path.join(here, 'finance-full-fixtures.mjs'), 'utf8')
test('finance fixture module is independently exported and marker isolated', () => {
assert.match(source, /export async function run\(context\)/)
assert.match(source, /context\.marker = `\$\{originalMarker\}-\$\{suffix\}`/)
assert.match(source, /createdResources\.financeFullFixtures/)
})
test('fixture covers weight, volume, manual order, and container allocation contracts', () => {
for (const basis of ['WEIGHT_KG', 'VOLUME_CBM', 'MANUAL_ORDER_FEE', 'CONTAINER_SHARED']) assert.match(source, new RegExp(basis))
assert.match(source, /request\(context, 'POST', '\/api\/platform\/finance\/v1\/cost-entries'/)
assert.match(source, /no-marker-owned-container-snapshot/)
})
test('fee writes use verified Firstleg paths and do not fabricate historical IDs', () => {
assert.match(source, /\/api\/platform\/v1\/inbounds\/.*fee-details\?accountId=/)
assert.match(source, /\/api\/platform\/v1\/loading-plans\/.*fee-details\?accountId=/)
assert.match(source, /sourceType: detail\?\.sourceType \|\| 'MANUAL'/)
assert.match(source, /created\?\.marker === context\.marker/)
assert.match(source, /\/fees`/)
assert.match(source, /chargeUnit === 'KG'/)
assert.match(source, /chargeUnit === 'CBM'/)
})
test('shared fixture source names the finance full acceptance contract', () => {
for (const field of ['marker', 'customerId', 'orders', 'feeSources', 'container', 'allocations', 'supplier', 'voucherFileId', 'deploymentBlocker']) assert.match(source, new RegExp(`\\b${field}\\b`))
for (const field of ['feeSourceId', 'sourceType', 'direction', 'currency', 'amount', 'businessDate', 'billingBasis']) {
assert.match(source, new RegExp(field))
}
})
function fakeContext(overrides = {}) {
const steps = []
const context = {
marker: 'FIN-BEHAVIOR', token: 'test-token', options: { gateway: 'http://invalid.local' },
steps, scenarios: [], createdResources: {}, context: {},
addStep(module, name, response, passed, evidence, options = {}) {
steps.push({ module, name, response, passed, evidence, deploymentBlocker: Boolean(options.deploymentBlocker && !passed) })
},
assert(module, name, response, passed, evidence, options) { this.addStep(module, name, response, passed, evidence, options) },
addCoverageGap(module, name, coverage, evidence, code) { this.addStep(module, name, null, false, `${code}: ${evidence}`, { deploymentBlocker: true }) },
apiSuccess(module, name, response) { this.addStep(module, name, response, true, 'fake'); return response?.body?.data },
async request() { throw new Error('behavior test must use hooks') },
financeFixtureHooks: {}, ...overrides,
}
return context
}
function hookSet({ missing = false } = {}) {
let automaticIndex = 0
return {
loadPricingEvidence: async () => ({ activeVersionIds: ['PRICE-1'] }),
createOrder: async (context, suffix) => ({ marker: `${context.marker}-${suffix}`, tenantId: '701',
orderId: `O-${suffix}`, orderNo: `NO-${suffix}`, waybillId: suffix === 'AUTO1' ? 'WB-1' : null, inboundId: `I-${suffix}` }),
measureOrder: async (context, resource) => ({ ...resource, measurementId: `M-${resource.orderId}`, actualWeightKg: 12.5, totalVolumeCbm: 0.072 }),
loadFeeItem: async (context, direction) => ({ id: direction === 'PAYABLE' ? '902' : '901', status: 'ACTIVE' }),
loadSupplier: async () => missing ? null : ({ id: 'PARTNER-1', partnerName: '往来单位一', status: 'ACTIVE' }),
uploadVoucher: async () => missing ? null : 'VOUCHER-1',
calculateAutomaticFee: async (context, resource) => {
automaticIndex += 1
const weight = automaticIndex % 2 === 1
return { feeSourceId: `F-${automaticIndex}`, orderId: resource.orderId, inboundId: resource.inboundId,
sourceType: 'FBA_PRICING', direction: 'RECEIVABLE', currency: 'CNY', amount: 100 + automaticIndex,
businessDate: weight ? '2026-08-10' : '2026-08-11', billingBasis: weight ? 'WEIGHT_KG' : 'VOLUME_CBM',
chargeUnit: weight ? 'KG' : 'CBM', chargeableQuantity: automaticIndex,
actualWeightKg: 54.4, actualVolumeCbm: 0.748 }
},
createManualFee: async (context, resource, item, direction, amount, basis, businessDate) => ({
feeSourceId: 'F-MANUAL', orderId: resource.orderId, inboundId: resource.inboundId, sourceType: 'MANUAL',
direction, currency: 'CNY', amount, businessDate, billingBasis: basis,
}),
}
}
test('run behavior emits consumer shape, both billing bases, distinct dates, supplier and voucher', async () => {
const context = fakeContext()
context.financeFixtureHooks = hookSet()
await run(context)
const fixture = context.createdResources.financeFullFixtures
assert.equal(fixture.customerId, '701')
assert.equal(fixture.orders.length, 5)
assert.equal(fixture.orders[0].orderNo, 'NO-AUTO1')
assert.equal(fixture.orders[0].waybillId, 'WB-1')
assert.equal(fixture.orders[0].actualWeightKg, 12.5)
assert.equal(fixture.orders[0].totalVolumeCbm, 0.072)
assert.deepEqual(new Set(fixture.feeSources.map((item) => item.billingBasis).filter((value) => value !== 'MANUAL_ORDER_FEE')),
new Set(['WEIGHT_KG', 'VOLUME_CBM']))
assert.ok(new Set(fixture.feeSources.map((item) => item.businessDate)).size >= 2)
assert.equal(fixture.supplier.supplierId, 'PARTNER-1')
assert.equal(fixture.supplier.supplierName, '往来单位一')
assert.equal(fixture.voucherFileId, 'VOUCHER-1')
assert.ok(Array.isArray(fixture.allocations))
})
test('run behavior preserves null IDs and deployment blockers when supplier, voucher and container are unavailable', async () => {
const context = fakeContext()
context.financeFixtureHooks = hookSet({ missing: true })
await run(context)
const fixture = context.createdResources.financeFullFixtures
assert.equal(fixture.supplier, null)
assert.equal(fixture.voucherFileId, null)
assert.deepEqual(fixture.container, { containerBookingId: null, containerNo: null })
assert.deepEqual(fixture.allocations, [])
assert.ok(fixture.deploymentBlocker.length >= 3)
assert.ok(context.steps.some((step) => step.deploymentBlocker && step.name.includes('往来单位')))
assert.ok(context.steps.some((step) => step.deploymentBlocker && step.name.includes('voucherFileId')))
})
@@ -0,0 +1,284 @@
const MODULE = 'finance-full-fixtures'
const OPTIONS = { scenario: 'finance-full-fixture-production', deploymentBlocker: true }
function id(value) { return value == null ? '' : String(value) }
function key(context, operation) { return `${context.marker}-${operation}`.slice(0, 128) }
function request(context, method, path, body, token = context.token, extra = {}) {
return context.request(method, path, body, token, { 'Idempotency-Key': key(context, path.replaceAll('/', '_')), ...extra })
}
function rows(value) { return Array.isArray(value) ? value : Array.isArray(value?.records) ? value.records : [] }
function dateOffset(days) { const value = new Date(); value.setUTCDate(value.getUTCDate() + days); return value.toISOString().slice(0, 10) }
function blocker(context, name, evidence) { context.addStep(MODULE, name, null, false, evidence, OPTIONS) }
async function createOrder(context, suffix) {
if (context.financeFixtureHooks?.createOrder) return context.financeFixtureHooks.createOrder(context, suffix)
const originalMarker = context.marker
const original = context.createdResources.orders
context.marker = `${originalMarker}-${suffix}`
try {
const orders = await import(new URL('./orders.mjs', import.meta.url))
await orders.run(context)
const created = context.createdResources.orders
return created?.marker === context.marker && created?.orderId && created?.inboundId ? { ...created } : null
} finally {
context.marker = originalMarker
if (original) context.createdResources.orders = original
else delete context.createdResources.orders
}
}
async function measureOrder(context, resource) {
if (context.financeFixtureHooks?.measureOrder) return context.financeFixtureHooks.measureOrder(context, resource)
const originalMarker = context.marker
const createdKeys = ['orders', 'inboundMeasurement', 'ocrInbound', 'testInbound']
const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']
const createdSnapshot = new Map(createdKeys.map((key) => [key, { present: Object.hasOwn(context.createdResources, key), value: context.createdResources[key] }]))
const contextSnapshot = new Map(contextKeys.map((key) => [key, { present: Object.hasOwn(context.context, key), value: context.context[key] }]))
context.marker = resource.marker
context.createdResources.orders = resource
context.createdResources.ocrInbound = resource
context.createdResources.testInbound = resource
context.context.inboundWeightOcr = resource
context.context.ocrInbound = resource
context.context.testInbound = resource
context.context.createdInbound = resource
try {
const measurement = await import(new URL('./inbound-measurement.mjs', import.meta.url))
await measurement.run(context)
const result = context.createdResources.inboundMeasurement
const detailResponse = await context.request('GET', `/api/platform/inbounds/${encodeURIComponent(resource.inboundId)}?tenantId=${encodeURIComponent(resource.tenantId)}`)
const detail = context.apiSuccess(MODULE, '回读审核测量实重与总体积', detailResponse, `inboundId=${resource.inboundId}`, OPTIONS)
const summary = detail?.summary || {}
const actualWeightKg = Number(summary.actualGrossWeightKg ?? summary.actualWeightKg)
const totalVolumeCbm = Number(summary.actualVolumeCbm ?? summary.totalVolumeCbm)
context.assert(MODULE, '审核测量快照实重和总体积均大于零', detailResponse,
Number.isFinite(actualWeightKg) && actualWeightKg > 0 && Number.isFinite(totalVolumeCbm) && totalVolumeCbm > 0,
`actualWeightKg=${actualWeightKg || 'missing'}, totalVolumeCbm=${totalVolumeCbm || 'missing'}`, OPTIONS)
return result?.marker === resource.marker ? { ...resource, ...result, actualWeightKg, totalVolumeCbm } : null
} finally {
context.marker = originalMarker
for (const [key, snapshot] of createdSnapshot) {
if (snapshot.present) context.createdResources[key] = snapshot.value
else delete context.createdResources[key]
}
for (const [key, snapshot] of contextSnapshot) {
if (snapshot.present) context.context[key] = snapshot.value
else delete context.context[key]
}
}
}
async function loadFeeItem(context, direction) {
if (context.financeFixtureHooks?.loadFeeItem) return context.financeFixtureHooks.loadFeeItem(context, direction)
const response = await context.request('GET', '/api/platform/v1/fee-items?page=1&pageSize=200')
const list = context.apiSuccess(MODULE, `查询${direction}人工费用项目`, response, '', OPTIONS)
return rows(list).find((item) => item?.status === 'ACTIVE' && (direction === 'RECEIVABLE'
? item.warehouseReceivable === true : item.warehousePayable === true)) || null
}
async function createManualFee(context, resource, feeItem, direction, amount, basis, businessDate) {
if (context.financeFixtureHooks?.createManualFee) return context.financeFixtureHooks.createManualFee(context, resource, feeItem, direction, amount, basis, businessDate)
const path = `/api/platform/v1/inbounds/${encodeURIComponent(resource.inboundId)}/fee-details?accountId=${encodeURIComponent(resource.tenantId)}`
const body = { feeItemId: Number(feeItem.id), direction, businessDate,
unitPrice: amount, currency: 'CNY', amount, settlementIncluded: true, remark: `${context.marker}-${basis}` }
const response = await request(context, 'POST', path, body)
const detail = context.apiSuccess(MODULE, `${basis}人工费用明细`, response, `inboundId=${resource.inboundId}`, OPTIONS)
const feeId = id(detail?.id)
context.assert(MODULE, `${basis}费用来源身份和金额`, response,
Boolean(feeId) && id(detail?.inboundId) === id(resource.inboundId) && Number(detail?.amount) === Number(amount),
`feeId=${feeId || 'missing'}, amount=${detail?.amount ?? 'missing'}`, OPTIONS)
if (!feeId) return null
const approve = await request(context, 'POST', `${path}/${feeId}/approve`)
context.apiSuccess(MODULE, `${basis}费用审核`, approve, '', OPTIONS)
return { feeSourceId: feeId, sourceType: detail?.sourceType || 'MANUAL', direction, amount: Number(amount), businessDate,
currency: 'CNY', billingBasis: basis, inboundId: id(resource.inboundId), orderId: id(resource.orderId),
chargeableWeightKg: detail?.chargeableWeightKg ?? null, chargeableVolumeCbm: detail?.chargeableVolumeCbm ?? null }
}
async function calculateAutomaticFee(context, resource) {
if (context.financeFixtureHooks?.calculateAutomaticFee) return context.financeFixtureHooks.calculateAutomaticFee(context, resource)
const pricingInboundResponse = await context.request('GET',
`/api/platform/inbounds/${encodeURIComponent(resource.inboundId)}?tenantId=${encodeURIComponent(resource.tenantId)}`)
const pricingInbound = context.apiSuccess(MODULE, '计费前回读入库主表计费快照', pricingInboundResponse,
`inboundId=${resource.inboundId}`, OPTIONS)
const pricingSummary = pricingInbound?.summary || {}
const actualWeightKg = Number(pricingSummary.actualGrossWeightKg ?? pricingSummary.actualWeightKg)
const actualVolumeCbm = Number(pricingSummary.actualVolumeCbm ?? pricingSummary.totalVolumeCbm)
if (!(actualWeightKg > 0) || !(actualVolumeCbm > 0)) {
blocker(context, '自动计费前审核测量快照完整',
`服务端计费实际读取 fl_inbound_order.actual_gross_weight_kg/actual_volume_cbm;inboundId=${resource.inboundId}, actualWeightKg=${actualWeightKg || 'missing'}, actualVolumeCbm=${actualVolumeCbm || 'missing'};测量表有值但主表未同步`)
return null
}
const base = `/api/platform/v1/inbounds/${encodeURIComponent(resource.inboundId)}/fees`
const query = `tenantId=${encodeURIComponent(resource.tenantId)}`
const calculateResponse = await request(context, 'POST', `${base}/calculate?${query}`, {})
const calculated = context.apiSuccess(MODULE, '按审核测量快照执行真实自动计费', calculateResponse,
`orderId=${resource.orderId}, inboundId=${resource.inboundId}`, OPTIONS)
const feeSourceId = id(calculated?.feeId)
const billingBasis = calculated?.chargeUnit === 'KG' ? 'WEIGHT_KG'
: calculated?.chargeUnit === 'CBM' ? 'VOLUME_CBM' : null
const responseWeightKg = Number(calculated?.actualWeightKg)
const responseVolumeCbm = Number(calculated?.actualVolumeCbm)
context.assert(MODULE, '自动费用返回来源、金额和计费依据', calculateResponse,
Boolean(feeSourceId) && Boolean(billingBasis) && Number(calculated?.totalAmount) >= 0
&& responseWeightKg > 0 && responseVolumeCbm > 0,
`feeSourceId=${feeSourceId || 'missing'}, chargeUnit=${calculated?.chargeUnit || 'missing'}, amount=${calculated?.totalAmount ?? 'missing'}, actualWeightKg=${responseWeightKg || 'missing'}, actualVolumeCbm=${responseVolumeCbm || 'missing'}`, OPTIONS)
if (!feeSourceId || !billingBasis) return null
const confirmResponse = await request(context, 'POST', `${base}/confirm?${query}`)
const confirmed = context.apiSuccess(MODULE, '确认真实自动费用快照', confirmResponse, '', OPTIONS)
const sendResponse = await request(context, 'POST', `${base}/send?${query}`)
const sent = context.apiSuccess(MODULE, '发布真实自动费用来源', sendResponse, '', OPTIONS)
return { feeSourceId, orderId: id(resource.orderId), inboundId: id(resource.inboundId), sourceType: 'FBA_PRICING',
direction: 'RECEIVABLE', currency: sent?.currency || confirmed?.currency || calculated.currency,
amount: Number(sent?.totalAmount ?? confirmed?.totalAmount ?? calculated.totalAmount),
businessDate: String(sent?.sentAt || confirmed?.confirmedAt || calculated.calculatedAt || '').slice(0, 10),
billingBasis, chargeableQuantity: sent?.chargeableQuantity ?? calculated.chargeableQuantity,
chargeUnit: sent?.chargeUnit || calculated.chargeUnit,
actualWeightKg: responseWeightKg, actualVolumeCbm: responseVolumeCbm }
}
async function loadSupplier(context) {
if (context.financeFixtureHooks?.loadSupplier) return context.financeFixtureHooks.loadSupplier(context)
const response = await context.request('GET', '/api/platform/v1/partners?page=1&size=200&status=ACTIVE')
const page = context.apiSuccess(MODULE, '查询真实供应商往来单位', response, '', OPTIONS)
return rows(page).find((item) => item?.status === 'ACTIVE') || null
}
async function uploadVoucher(context, customerId) {
if (context.financeFixtureHooks?.uploadVoucher) return context.financeFixtureHooks.uploadVoucher(context, customerId)
const path = `/api/platform/finance/v1/vouchers?customerId=${encodeURIComponent(customerId)}`
const form = new FormData()
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')
form.append('file', new Blob([png], { type: 'image/png' }), `finance-${context.marker}.png`)
try {
const response = await fetch(`${context.options.gateway}${path}`, { method: 'POST',
headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` }, body: form,
signal: AbortSignal.timeout(30_000) })
const body = await response.json().catch(() => null)
const evidence = { method: 'POST', path, http: response.status, body, raw: null,
requestId: body?.requestId || response.headers.get('x-request-id'), traceId: body?.traceId || response.headers.get('x-trace-id') }
const data = context.apiSuccess(MODULE, '上传本轮 Finance 凭证', evidence, '', OPTIONS)
return id(data?.fileId) || null
} catch (error) {
blocker(context, '上传本轮 Finance 凭证', `multipart request failed: ${String(error)}`)
return null
}
}
async function loadPricingEvidence(context) {
if (context.financeFixtureHooks?.loadPricingEvidence) return context.financeFixtureHooks.loadPricingEvidence(context)
const response = await context.request('GET', '/api/platform/v1/fba-pricing/carrier-rates/price-books?status=ACTIVE')
const versions = context.apiSuccess(MODULE, '读取真实生效价格配置', response, '', OPTIONS)
return { activeVersionIds: rows(versions).map((item) => id(item.id)).filter(Boolean) }
}
async function createContainerCost(context, customerId, container, voucherFileId) {
if (context.financeFixtureHooks?.createContainerCost) return context.financeFixtureHooks.createContainerCost(context, customerId, container, voucherFileId)
const body = { customerId: Number(customerId), costType: 'CONTAINER_SHARED', orderId: null,
containerId: Number(container.containerBookingId), expenseName: `${context.marker}-整柜成本`, amount: 300,
currency: 'CNY', voucherFileIds: [voucherFileId], remark: context.marker }
const response = await request(context, 'POST', '/api/platform/finance/v1/cost-entries', body)
const cost = context.apiSuccess(MODULE, '创建整柜成本并触发真实体积分摊', response, '', OPTIONS)
return cost?.id ? { costId: id(cost.id), allocations: Array.isArray(cost.allocations) ? cost.allocations : null } : null
}
export async function run(context) {
const start = context.steps.length
const pricingEvidence = await loadPricingEvidence(context)
const resources = []
for (const suffix of ['AUTO1', 'AUTO2', 'AUTO3', 'AUTO4', 'MANUAL']) {
const resource = await createOrder(context, suffix)
context.assert(MODULE, `创建 ${suffix} marker 隔离订单与入库单`, null, Boolean(resource),
resource ? `orderId=${resource.orderId}, inboundId=${resource.inboundId}` : `marker=${context.marker}-${suffix} 未返回独立入库单`, OPTIONS)
if (resource) resources.push(resource)
}
const measured = []
for (const resource of resources) {
const result = await measureOrder(context, resource)
if (result) measured.push({ ...resource, ...result })
}
const feeItems = {
RECEIVABLE: await loadFeeItem(context, 'RECEIVABLE'),
PAYABLE: await loadFeeItem(context, 'PAYABLE'),
}
const supplier = await loadSupplier(context)
if (!supplier?.id) blocker(context, '取得真实 PAYABLE 往来单位', '未找到 ACTIVE 往来单位;不能伪造 supplierId/partnerId')
for (const direction of ['RECEIVABLE', 'PAYABLE']) {
if (!feeItems[direction]) context.addCoverageGap(MODULE, `missing-${direction.toLowerCase()}-fee-item`, `${direction} 人工费用 fixture`,
`没有找到带 warehouse${direction === 'RECEIVABLE' ? 'Receivable' : 'Payable'}=true 的 ACTIVE 费用项目,禁止猜测费用项目 ID`, 'missing-deployed-fee-item')
}
const fixtures = []
for (const resource of measured.slice(0, 4)) {
const fee = await calculateAutomaticFee(context, resource)
if (fee) fixtures.push(fee)
}
for (const basis of ['WEIGHT_KG', 'VOLUME_CBM']) {
if (!fixtures.some((fee) => fee.billingBasis === basis)) {
blocker(context, `${basis} 自动计费 fixture`,
`activePriceVersions=${pricingEvidence.activeVersionIds?.join(',') || 'none'};本轮隔离订单未产生 ${basis} 来源,价格配置/订单条件不支持,不能伪装成功`)
}
}
const manualResource = measured.at(-1)
if (manualResource && feeItems.PAYABLE && supplier?.id) {
const fee = await createManualFee(context, manualResource, feeItems.PAYABLE, 'PAYABLE', 80, 'MANUAL_ORDER_FEE', dateOffset(-1))
if (fee) fixtures.push({ ...fee, supplierId: id(supplier.id), partnerId: id(supplier.id), supplierName: supplier.partnerName || null })
}
const container = context.createdResources?.phase3ContainerLoading
const containerOwned = String(container?.marker || '').startsWith(`${context.marker}-`)
&& container?.loadingPlanId && container?.containerBookingId
let containerFixture = null
if (containerOwned && feeItems.PAYABLE && supplier?.id) {
const path = `/api/platform/v1/loading-plans/${encodeURIComponent(container.loadingPlanId)}/fee-details?accountId=${encodeURIComponent(container.tenantId)}`
const body = { feeItemId: Number(feeItems.PAYABLE.id), direction: 'PAYABLE', businessDate: dateOffset(-2), amount: 300, currency: 'CNY', settlementIncluded: true, remark: `${context.marker}-CONTAINER_SHARED` }
const response = await request(context, 'POST', path, body)
const detail = context.apiSuccess(MODULE, '创建按柜费用来源', response, '', OPTIONS)
const feeId = id(detail?.id)
if (feeId) { const approval = await request(context, 'POST', `${path}/${feeId}/approve`); context.apiSuccess(MODULE, '审核按柜费用来源', approval, '', OPTIONS) }
containerFixture = { feeSourceId: feeId || null, sourceType: 'MANUAL', direction: 'PAYABLE', amount: 300,
businessDate: body.businessDate, currency: 'CNY', billingBasis: 'CONTAINER_SHARED',
supplierId: supplier?.id ? id(supplier.id) : null, partnerId: supplier?.id ? id(supplier.id) : null,
containerBookingId: id(container.containerBookingId), containerNo: container.containerNo || null,
loadingPlanId: id(container.loadingPlanId) }
} else {
context.addStep(MODULE, '取得同 marker 柜次用于整柜费用分摊', null, false,
'没有同 marker 的已完成柜次和可核验 containerBookingId;不读取历史柜次、不猜测柜号', OPTIONS)
context.addCoverageGap(MODULE, 'container-shared-cost-allocation', '按柜添加费用并按订单/运单体积分摊',
'当前上下文没有同 marker 的已完成柜次及真实 Firstleg 体积快照,禁止借用历史柜次或模拟 Finance 分摊成功', 'no-marker-owned-container-snapshot')
}
if (containerFixture) fixtures.push(containerFixture)
const customerId = id(measured[0]?.tenantId) || null
const voucherFileId = customerId ? await uploadVoucher(context, customerId) : null
if (!voucherFileId) blocker(context, '取得本轮 Finance voucherFileId',
'未通过 /api/platform/finance/v1/vouchers multipart 上传取得真实 fileId;整柜成本不得模拟成功')
let allocations = []
if (containerFixture?.feeSourceId && voucherFileId && supplier?.id) {
const cost = await createContainerCost(context, customerId, container, voucherFileId)
if (Array.isArray(cost?.allocations)) allocations = cost.allocations
else blocker(context, '回读整柜成本分摊结果',
`costId=${cost?.costId || 'missing'};Finance cost-entries 创建响应未返回逐订单/运单 allocations,禁止从金额和体积自行伪造持久化结果`)
}
if (allocations.length === 0) blocker(context, '输出真实订单/运单分摊',
'缺少已完成柜次、凭证、供应商或 Finance 分摊响应;不伪造 allocation ID/金额')
const distinctBusinessDates = new Set(fixtures.map((item) => item.businessDate).filter(Boolean))
if (distinctBusinessDates.size < 2) blocker(context, '费用来源覆盖不同业务日期',
`businessDates=${[...distinctBusinessDates].join(',') || 'none'};至少需要两个真实日期以验收按日期排除`)
if (measured.length > 0 && measured.every((item) => !item.waybillId)) blocker(context, '取得订单真实 waybillId',
'当前订单/入库接口未返回独立 waybillId;输出 null,不使用 orderId、orderNo 或 inboundId 冒充运单 ID')
context.createdResources.financeFullFixtures = {
marker: context.marker, customerId,
orders: measured.map((item) => ({ orderId: id(item.orderId), orderNo: item.orderNo || null,
waybillId: item.waybillId ? id(item.waybillId) : null, inboundId: id(item.inboundId),
actualWeightKg: item.actualWeightKg, totalVolumeCbm: item.totalVolumeCbm,
businessDate: fixtures.find((fee) => fee.orderId === id(item.orderId))?.businessDate || null })),
feeSources: fixtures,
container: containerFixture ? { containerBookingId: containerFixture.containerBookingId,
containerNo: containerFixture.containerNo } : { containerBookingId: null, containerNo: null },
allocations, supplier: supplier?.id ? { supplierId: id(supplier.id), partnerId: id(supplier.id), supplierName: supplier.partnerName || null } : null,
voucherFileId, pricingEvidence,
deploymentBlocker: context.steps.slice(start).filter((step) => step.deploymentBlocker).map((step) => step.evidence),
}
const steps = context.steps.slice(start)
context.scenarios.push({ name: OPTIONS.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 })
}
@@ -0,0 +1,172 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { run } from './finance-reconciliation-full.mjs'
const here = path.dirname(fileURLToPath(import.meta.url))
const source = fs.readFileSync(path.join(here, 'finance-reconciliation-full.mjs'), 'utf8')
function ok(data) {
return { http: 200, body: { code: '200', data }, requestId: 'request-test', traceId: 'trace-test' }
}
function fakeContext(withFixture = true) {
const marker = 'FIN-TEST-MARKER'
const receivables = [
{ feeSourceId: '101', orderId: '201', direction: 'RECEIVABLE', currency: 'CNY', businessDate: '2026-08-10' },
{ feeSourceId: '102', orderId: '202', direction: 'RECEIVABLE', currency: 'CNY', businessDate: '2026-08-11' },
{ feeSourceId: '103', orderId: '203', direction: 'RECEIVABLE', currency: 'CNY', businessDate: '2026-08-11' },
{ feeSourceId: '104', orderId: '204', direction: 'RECEIVABLE', currency: 'CNY', businessDate: '2026-08-11' },
]
const feeSources = [...receivables,
{ feeSourceId: '105', orderId: '205', direction: 'PAYABLE', currency: 'CNY',
businessDate: '2026-08-11', partnerId: '501', partnerName: 'Fixture Partner' }]
const fixture = { marker, customerId: '11',
orders: feeSources.map((item) => ({ orderId: item.orderId, shipmentId: `SHIP-${item.orderId}` })),
feeSources: feeSources.map((item) => ({ ...item, marker })) }
const statements = new Map()
const settlements = new Map()
const idempotency = new Map()
let statementSequence = 900
let settlementSequence = 1200
const calls = []
const steps = []
const coverageGaps = []
function responseForStatement(statement) { return structuredClone(statement) }
function applyAction(statement, body) {
const status = { SUBMIT: 'PENDING_REVIEW', APPROVE: 'APPROVED', REJECT: 'REJECTED',
ISSUE: 'ISSUED', ADJUST: 'ISSUED', VOID: 'VOIDED', REOPEN: 'DRAFT' }[body.action]
statement.revisions.push({ action: `RECONCILIATION_${body.action}` })
statement.status = status
statement.version += 1
if (body.action === 'ADJUST') {
for (const adjustment of body.adjustmentLines) {
const line = statement.lines.find((item) => item.statementLineId === adjustment.statementLineId)
line.statementAmount = adjustment.adjustedAmount
line.adjustmentReason = adjustment.reason
}
}
return responseForStatement(statement)
}
const context = {
marker, token: 'token', steps, scenarios: [], coverageGaps, calls,
createdResources: withFixture ? { financeFullFixtures: fixture } : {},
async request(method, requestPath, body, token, headers = {}) {
calls.push({ method, path: requestPath, body, headers })
const idem = headers['Idempotency-Key']
if (idem && idempotency.has(idem)) return ok(structuredClone(idempotency.get(idem)))
if (method === 'GET' && requestPath.includes('/fee-pool')) {
return ok(receivables.map((item) => ({ ...item, reviewStatus: 'APPROVED',
settlementIncluded: true, sourceStatus: 'AVAILABLE' })))
}
if (method === 'POST' && requestPath.endsWith('/statements')) {
if (body.currency !== 'CNY') return { http: 200, body: { code: 'FIN_400' } }
const selected = body.generationMode === 'BY_CUTOFF'
? receivables.filter((item) => item.businessDate >= body.periodStart && item.businessDate <= body.periodEnd)
: feeSources.filter((item) => body.feeSourceIds.includes(item.feeSourceId))
if (selected.some((item) => item.direction !== 'RECEIVABLE')) return { http: 200, body: { code: 'FIN_409' } }
const statementId = String(++statementSequence)
const statement = { statementId, status: 'DRAFT', version: 0, revisions: [],
lines: selected.map((item, index) => ({ statementLineId: `${statementId}${index + 1}`,
feeSourceId: item.feeSourceId, statementAmount: 100 + index, adjustmentReason: null })) }
statements.set(statementId, statement)
if (idem) idempotency.set(idem, responseForStatement(statement))
return ok(responseForStatement(statement))
}
const actionMatch = requestPath.match(/reconciliation-statements\/(\d+)\/actions$/)
if (method === 'POST' && actionMatch) {
const result = applyAction(statements.get(actionMatch[1]), body)
if (idem) idempotency.set(idem, result)
return ok(result)
}
if (method === 'POST' && requestPath.endsWith('/receivable-settlements')) {
const statement = statements.get(String(body.reconciliationStatementId))
assert.equal(statement.status, 'ISSUED')
const settlement = { settlementId: String(++settlementSequence), direction: 'RECEIVABLE',
reconciliationStatementId: body.reconciliationStatementId, status: 'OPEN',
paymentStatus: 'UNPAID', version: 0 }
settlements.set(settlement.settlementId, settlement)
if (idem) idempotency.set(idem, settlement)
return ok(structuredClone(settlement))
}
if (method === 'POST' && requestPath.endsWith('/payable-settlements')) {
const settlement = { settlementId: String(++settlementSequence), direction: 'PAYABLE',
supplierId: String(body.supplierId), status: 'OPEN', paymentStatus: 'UNPAID', version: 0 }
if (idem) idempotency.set(idem, settlement)
return ok(structuredClone(settlement))
}
throw new Error(`Unexpected request: ${method} ${requestPath}`)
},
addStep(module, name, response, passed, evidence = '', options = {}) {
steps.push({ module, name, passed: Boolean(passed), deploymentBlocker: Boolean(options.deploymentBlocker && !passed), evidence })
return Boolean(passed)
},
addCoverageGap(module, scenario, name, reason, category) {
coverageGaps.push({ module, scenario, name, reason, category })
},
apiSuccess(module, name, response, evidence = '', options = {}) {
const passed = response.http === 200 && String(response.body?.code) === '200'
this.addStep(module, name, response, passed, evidence, options)
return passed ? response.body.data : null
},
assert(module, name, response, condition, evidence = '', options = {}) {
return this.addStep(module, name, response, condition, evidence, options)
},
}
return context
}
test('静态契约覆盖日期、SELECTED、三 AR、AP、状态机和数组输出', () => {
assert.match(source, /generationMode === 'SELECTED'/)
assert.match(source, /businessDates\.size < 2/)
assert.match(source, /receivables\.length < 4/)
assert.match(source, /const groups = \[\[receivables\[0\], receivables\[1\]\]/)
assert.match(source, /\/payable-settlements/)
assert.match(source, /fixture\.root\.statements = statementRows/)
assert.match(source, /fixture\.root\.receivableSettlements = arRows/)
assert.match(source, /http: response\?\.http \?\? null/)
assert.match(source, /requestId: response\?\.requestId \?\? null/)
assert.match(source, /traceId: response\?\.traceId \?\? null/)
for (const action of ['SUBMIT', 'APPROVE', 'REJECT', 'ISSUE', 'ADJUST', 'VOID', 'REOPEN']) {
assert.match(source, new RegExp(`['"]${action}['"]`))
}
})
test('run 发布一条日期链、三张独立已出账单、三张 AR 和一张 AP', async () => {
const context = fakeContext()
await run(context)
const fixture = context.createdResources.financeFullFixtures
assert.equal(fixture.statements.length, 4)
assert.equal(fixture.statements[0].kind, 'BY_CUTOFF')
assert.equal(fixture.statements[0].status, 'VOIDED')
assert.deepEqual(fixture.statements.slice(1).map((item) => item.status), ['ISSUED', 'ISSUED', 'ISSUED'])
assert.equal(fixture.receivableSettlements.length, 3)
assert.equal(new Set(fixture.receivableSettlements.map((item) => item.statementId)).size, 3)
assert.equal(fixture.payableSettlements.length, 1)
assert.equal(fixture.payableSettlements[0].supplierId, '501')
assert.equal(fixture.payableSettlements[0].partnerId, '501')
assert.equal(fixture.payableSettlements[0].supplierName, 'Fixture Partner')
assert.equal(context.steps.filter((step) => !step.passed).length, 0)
})
test('run 在 fixture 缺失时记录 blocker 且不发送 HTTP 请求', async () => {
const context = fakeContext(false)
await run(context)
assert.equal(context.calls.length, 0)
assert.equal(context.steps.length, 1)
assert.equal(context.steps[0].passed, false)
assert.equal(context.steps[0].deploymentBlocker, true)
assert.equal(context.coverageGaps[0].category, 'missing-marker-owned-finance-fixture')
})
test('PAYABLE 接受 partnerId 及名称快照,不依赖 SUPPLIER 类型', async () => {
const context = fakeContext()
await run(context)
const payableRequest = context.calls.find((call) => call.path.endsWith('/payable-settlements'))
assert.equal(payableRequest.body.supplierId, '501')
assert.doesNotMatch(source, /partnerType|types\?\.includes|SUPPLIER.*阻断/)
})
@@ -0,0 +1,328 @@
const MODULE = 'finance-reconciliation-full'
const SCENARIO = 'finance-full-reconciliation'
const BASE = '/api/platform/finance/v1'
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
function id(value) { return value == null ? '' : String(value) }
function list(value) { return Array.isArray(value) ? value : [] }
function rows(value) { return Array.isArray(value) ? value : list(value?.records) }
function statementId(value) { return id(value?.statementId ?? value?.id ?? value?.billId) }
function settlementId(value) { return id(value?.settlementId ?? value?.id) }
function sourceId(value) { return id(value?.feeSourceId ?? value?.financeFeeSourceId ?? value?.id) }
function sourceDate(value) { return value?.businessDate ?? value?.billingDate ?? value?.feeDate ?? '' }
function sourceWaybill(value) { return id(value?.waybillId ?? value?.shipmentId ?? value?.orderId ?? value?.inboundId ?? value?.sourceNo) }
function lineSourceIds(value) { return list(value?.lines).map(sourceId).filter(Boolean) }
function apiRejected(response) { return response?.http === 200 && response?.body && String(response.body.code) !== '200' }
function sameIds(actual, expected) {
const left = [...new Set(actual)].sort()
const right = [...new Set(expected)].sort()
return left.length === right.length && left.every((value, index) => value === right[index])
}
function dates(sources) {
const values = sources.map((source) => source.businessDate).sort()
return { start: values[0], end: values.at(-1) }
}
function key(context, action) {
return `${context.marker}-finance-reconciliation-${action}`.replace(/[^A-Za-z0-9_.:-]/g, '-').slice(0, 128)
}
function write(context, method, path, body, action) {
return context.request(method, path, body, context.token, { 'Idempotency-Key': key(context, action) })
}
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,
})
}
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 normalizeFixture(context) {
const root = context.createdResources?.financeFullFixtures
if (!root || root.marker !== context.marker || !root.customerId
|| !Array.isArray(root.orders) || !Array.isArray(root.feeSources)) return null
const customerId = id(root.customerId)
const orders = new Map(root.orders.map((order) => [id(order.orderId ?? order.id), order]))
const sources = root.feeSources.map((raw) => {
const order = orders.get(id(raw.orderId))
return {
raw, feeSourceId: sourceId(raw), customerId: id(raw.customerId ?? customerId),
direction: String(raw.direction ?? ''), currency: String(raw.currency ?? raw.currencyCode ?? '').toUpperCase(),
businessDate: sourceDate(raw), waybillId: sourceWaybill(raw) || sourceWaybill(order),
marker: raw.marker ?? root.marker,
supplierId: id(raw.supplierId ?? raw.partnerId ?? root.supplierId ?? root.partnerId),
supplierName: raw.supplierName ?? raw.partnerName ?? root.supplierName ?? root.partnerName ?? null,
}
})
return { root, customerId, sources }
}
async function createStatement(context, sources, action, name, generationMode = 'SELECTED') {
const period = dates(sources)
const body = {
customerId: sources[0].customerId, currency: 'CNY', generationMode,
feeSourceIds: generationMode === 'SELECTED' ? sources.map((source) => source.feeSourceId) : [],
periodStart: period.start, periodEnd: period.end, remark: `${context.marker}-${action}`,
}
const response = await write(context, 'POST', `${BASE}/statements`, body, action)
const statement = context.apiSuccess(MODULE, name, response,
`mode=${generationMode}, sources=${sources.length}`, OPTIONS)
if (!statement) return { body, response, statement: null }
const replayResponse = await write(context, 'POST', `${BASE}/statements`, body, action)
const replay = context.apiSuccess(MODULE, `${name}幂等重放`, replayResponse, '', OPTIONS)
context.assert(MODULE, `${name}幂等保持同一资源和版本`, replayResponse,
statementId(replay) === statementId(statement) && Number(replay?.version) === Number(statement.version),
`first=${statementId(statement)}, replay=${statementId(replay)}, version=${replay?.version ?? 'missing'}`, OPTIONS)
return { body, response, statement }
}
async function act(context, statement, action, suffix, reason, adjustmentLines) {
if (!statement) return null
const body = { action, expectedVersion: Number(statement.version) }
if (reason) body.reason = reason
if (adjustmentLines) body.adjustmentLines = adjustmentLines
const response = await write(context, 'POST',
`${BASE}/reconciliation-statements/${statementId(statement)}/actions`, body, suffix)
const next = context.apiSuccess(MODULE, `对账单执行 ${action}`, response,
`statementId=${statementId(statement)}, version=${statement.version}`, OPTIONS)
const expected = { SUBMIT: 'PENDING_REVIEW', APPROVE: 'APPROVED', REJECT: 'REJECTED',
ISSUE: 'ISSUED', ADJUST: 'ISSUED', VOID: 'VOIDED', REOPEN: 'DRAFT' }[action]
context.assert(MODULE, `${action} 状态与版本符合服务端状态机`, response,
next?.status === expected && Number(next?.version) === Number(statement.version) + 1,
`expected=${expected}, actual=${next?.status ?? 'missing'}, version=${next?.version ?? 'missing'}`, OPTIONS)
return next
}
async function issueSimple(context, statement, suffix) {
statement = await act(context, statement, 'SUBMIT', `${suffix}-submit`)
statement = await act(context, statement, 'APPROVE', `${suffix}-approve`)
return act(context, statement, 'ISSUE', `${suffix}-issue`)
}
async function issueCompleteChain(context, statement) {
statement = await act(context, statement, 'SUBMIT', 'chain-submit-1')
if (!statement) return null
const replayBody = { action: 'SUBMIT', expectedVersion: Number(statement.version) - 1 }
const replayResponse = await write(context, 'POST',
`${BASE}/reconciliation-statements/${statementId(statement)}/actions`, replayBody, 'chain-submit-1')
const replay = context.apiSuccess(MODULE, '状态动作幂等重放原结果', replayResponse, '', OPTIONS)
context.assert(MODULE, '动作幂等重放不重复递增版本', replayResponse,
statementId(replay) === statementId(statement) && Number(replay?.version) === Number(statement.version),
`version=${replay?.version ?? 'missing'}`, OPTIONS)
statement = await act(context, statement, 'REJECT', 'chain-reject', `${context.marker}-reject`)
statement = await act(context, statement, 'REOPEN', 'chain-reopen-rejected', `${context.marker}-reopen-rejected`)
statement = await act(context, statement, 'SUBMIT', 'chain-submit-2')
statement = await act(context, statement, 'APPROVE', 'chain-approve-1')
statement = await act(context, statement, 'ISSUE', 'chain-issue-1')
if (!statement) return null
const adjustable = list(statement.lines).slice(0, 2)
if (adjustable.length < 2) return null
const beforeRevisions = list(statement.revisions).length
const adjustments = adjustable.map((line, index) => ({
statementLineId: id(line.statementLineId ?? line.id),
adjustedAmount: Number(line.statementAmount ?? line.amount) + (index + 1) / 100,
reason: `${context.marker}-adjust-${index + 1}`,
}))
statement = await act(context, statement, 'ADJUST', 'chain-adjust', `${context.marker}-multi-line`, adjustments)
context.assert(MODULE, '多行调整写入行值、原因并新增 revision', null,
statement && adjustments.every((expected) => list(statement.lines).some((line) =>
id(line.statementLineId) === expected.statementLineId
&& Number(line.statementAmount) === expected.adjustedAmount
&& line.adjustmentReason === expected.reason))
&& list(statement?.revisions).length > beforeRevisions,
`lines=${adjustments.length}, revisions=${list(statement?.revisions).length}`, OPTIONS)
statement = await act(context, statement, 'VOID', 'chain-void', `${context.marker}-void`)
statement = await act(context, statement, 'REOPEN', 'chain-reopen-voided', `${context.marker}-reopen-voided`)
statement = await act(context, statement, 'SUBMIT', 'chain-submit-final')
statement = await act(context, statement, 'APPROVE', 'chain-approve-final')
statement = await act(context, statement, 'ISSUE', 'chain-issue-final')
const actions = new Set(list(statement?.revisions).map((revision) => revision.action))
context.assert(MODULE, '完整链保留驳回、重开、调整、作废和再次出账 revision', null,
['RECONCILIATION_REJECT', 'RECONCILIATION_REOPEN', 'RECONCILIATION_ADJUST',
'RECONCILIATION_VOID', 'RECONCILIATION_ISSUE'].every((action) => actions.has(action)),
`actions=${[...actions].join(',')}`, OPTIONS)
return statement
}
async function createReceivable(context, statement, index) {
const body = { reconciliationStatementId: statementId(statement), currency: 'CNY' }
const response = await write(context, 'POST', `${BASE}/receivable-settlements`, body, `ar-${index}`)
const settlement = context.apiSuccess(MODULE, `生成第 ${index} 张应收结算单`, response,
`statementId=${statementId(statement)}`, OPTIONS)
if (!settlement) return null
const replayResponse = await write(context, 'POST', `${BASE}/receivable-settlements`, body, `ar-${index}`)
const replay = context.apiSuccess(MODULE, `第 ${index} 张应收结算单幂等重放`, replayResponse, '', OPTIONS)
context.assert(MODULE, `第 ${index} 张 AR 幂等保持同一资源`, replayResponse,
settlementId(replay) === settlementId(settlement), `settlementId=${settlementId(settlement)}`, OPTIONS)
return settlement
}
export async function run(context) {
const start = context.steps.length
const fixture = normalizeFixture(context)
if (!fixture) {
blocker(context, '取得本轮财务全量 Fixture',
'financeFullFixtures 必须提供匹配 marker 的 customerId/orders/feeSources;禁止猜测旧字段或历史来源',
'missing-marker-owned-finance-fixture')
finish(context, start)
return
}
const invalid = fixture.sources.filter((source) => !source.feeSourceId || !source.customerId
|| !source.businessDate || !source.waybillId || source.marker !== context.marker)
if (invalid.length > 0) {
blocker(context, 'Fixture 来源契约完整',
`存在 ${invalid.length} 条来源缺少 feeSourceId/customerId/businessDate/运单标识/marker`,
'invalid-finance-fixture-contract')
finish(context, start)
return
}
const receivables = fixture.sources.filter((source) => source.customerId === fixture.customerId
&& source.direction === 'RECEIVABLE' && source.currency === 'CNY')
const businessDates = new Set(receivables.map((source) => source.businessDate))
if (receivables.length < 4 || businessDates.size < 2) {
blocker(context, '三张 AR 与日期范围 Fixture 门禁',
`至少需要同客户 4 条 CNY 应收来源且跨 2 个真实 businessDate;actual sources=${receivables.length}, dates=${businessDates.size}`,
'insufficient-receivable-date-fixture')
finish(context, start)
return
}
const foreign = fixture.sources.find((source) => source.customerId !== fixture.customerId)
if (foreign) {
const response = await write(context, 'POST', `${BASE}/statements`, {
customerId: fixture.customerId, currency: 'CNY', generationMode: 'SELECTED',
feeSourceIds: [foreign.feeSourceId], periodStart: foreign.businessDate,
periodEnd: foreign.businessDate, remark: `${context.marker}-foreign`,
}, 'cross-customer-rejected')
context.assert(MODULE, 'SELECTED 拒绝混入其他客户来源', response, apiRejected(response),
`code=${response.body?.code ?? 'missing'}`, OPTIONS)
} else {
context.addCoverageGap(MODULE, SCENARIO, 'cross-customer-isolation',
'Fixture 未提供其他客户 marker 来源,无法执行跨客户负向请求', 'missing-cross-customer-fixture')
}
const allPeriod = dates(receivables)
const currencyResponse = await write(context, 'POST', `${BASE}/statements`, {
customerId: fixture.customerId, currency: 'USD', generationMode: 'SELECTED',
feeSourceIds: receivables.map((source) => source.feeSourceId),
periodStart: allPeriod.start, periodEnd: allPeriod.end, remark: `${context.marker}-usd`,
}, 'currency-rejected')
context.assert(MODULE, '对账单拒绝非 CNY 币种', currencyResponse, apiRejected(currencyResponse),
`code=${currencyResponse.body?.code ?? 'missing'}`, OPTIONS)
const cutoffDate = [...businessDates].sort()[0]
const cutoffSources = receivables.filter((source) => source.businessDate === cutoffDate)
const poolResponse = await context.request('GET',
`${BASE}/fee-pool?customerId=${encodeURIComponent(fixture.customerId)}&direction=RECEIVABLE`)
const pool = context.apiSuccess(MODULE, 'BY_CUTOFF 前核对客户应收费用池', poolResponse, '', OPTIONS)
const eligible = rows(pool).filter((source) => sourceDate(source) === cutoffDate
&& String(source.currency ?? source.currencyCode) === 'CNY' && source.reviewStatus === 'APPROVED'
&& source.settlementIncluded === true && source.sourceStatus === 'AVAILABLE').map(sourceId)
if (!sameIds(eligible, cutoffSources.map((source) => source.feeSourceId))) {
blocker(context, 'BY_CUTOFF 历史来源隔离门禁',
'指定日期全部可结算来源必须与本轮 fixture 精确相等,禁止锁定历史来源', 'unsafe-cutoff-source-set', poolResponse)
finish(context, start)
return
}
const cutoff = await createStatement(context, cutoffSources, 'cutoff', '按真实业务日期生成对账单', 'BY_CUTOFF')
let cutoffStatement = cutoff.statement
context.assert(MODULE, 'BY_CUTOFF 仅纳入真实日期范围内本轮来源', cutoff.response,
cutoffStatement && cutoff.body.periodStart === cutoffDate && cutoff.body.periodEnd === cutoffDate
&& sameIds(lineSourceIds(cutoffStatement), cutoffSources.map((source) => source.feeSourceId)),
`date=${cutoffDate}, expected=${cutoffSources.length}, actual=${lineSourceIds(cutoffStatement).length}`, OPTIONS)
// ReconciliationStatementService explicitly permits VOID from DRAFT and releases LOCKED sources.
cutoffStatement = await act(context, cutoffStatement, 'VOID', 'cutoff-draft-void', `${context.marker}-release-cutoff`)
if (!cutoffStatement) { finish(context, start); return }
const groups = [[receivables[0], receivables[1]], [receivables[2]], receivables.slice(3)]
const issuedStatements = []
for (let index = 0; index < groups.length; index += 1) {
const created = await createStatement(context, groups[index], `selected-${index + 1}`,
`创建第 ${index + 1} 张独立 SELECTED 对账单`)
context.assert(MODULE, `第 ${index + 1} 张仅使用分组来源`, created.response,
created.statement && sameIds(lineSourceIds(created.statement), groups[index].map((source) => source.feeSourceId)),
`expected=${groups[index].length}, actual=${lineSourceIds(created.statement).length}`, OPTIONS)
const issued = index === 0
? await issueCompleteChain(context, created.statement)
: await issueSimple(context, created.statement, `selected-${index + 1}`)
if (!issued) {
blocker(context, `第 ${index + 1} 张对账单出账`, '状态流转或多行调整未返回有效结果', 'statement-issue-failed')
finish(context, start)
return
}
issuedStatements.push(issued)
}
const receivableSettlements = []
for (let index = 0; index < issuedStatements.length; index += 1) {
const settlement = await createReceivable(context, issuedStatements[index], index + 1)
if (settlement) receivableSettlements.push(settlement)
}
if (receivableSettlements.length !== 3) {
blocker(context, '生成三张独立 AR', `expected=3, actual=${receivableSettlements.length}`, 'three-ar-creation-failed')
}
const payables = fixture.sources.filter((source) => source.customerId === fixture.customerId
&& source.direction === 'PAYABLE' && source.currency === 'CNY')
const payableSupplierIds = new Set(payables.map((source) => source.supplierId).filter(Boolean))
let payableSettlement = null
if (payables.length > 0 && payableSupplierIds.size === 1 && payables.every((source) => source.supplierId)) {
const dueDate = [...new Set(payables.map((source) => source.businessDate))].sort().at(-1)
const supplierId = [...payableSupplierIds][0]
const supplierName = payables.find((source) => source.supplierId === supplierId)?.supplierName ?? null
const body = { customerId: fixture.customerId, supplierId,
feeSourceIds: payables.map((source) => source.feeSourceId), dueDate, currency: 'CNY' }
const response = await write(context, 'POST', `${BASE}/payable-settlements`, body, 'payable-settlement')
payableSettlement = context.apiSuccess(MODULE, '按 Fixture PAYABLE 与 partnerId/supplierId 生成应付结算单',
response, `sources=${payables.length}, supplierId=${supplierId}, supplierName=${supplierName ?? 'null'}`, OPTIONS)
if (!payableSettlement) {
blocker(context, 'PAYABLE 真实接口创建失败',
`HTTP=${response.http}, code=${response.body?.code ?? 'missing'}, requestId=${response.requestId ?? 'null'}, traceId=${response.traceId ?? 'null'}`,
'payable-settlement-api-failure', response)
}
} else {
blocker(context, 'PAYABLE 结算 Fixture 门禁',
`需要至少一条同客户 CNY PAYABLE,且全部提供相同 partnerId/supplierId;sources=${payables.length}, suppliers=${payableSupplierIds.size}`,
'insufficient-payable-supplier-fixture')
}
const statementRows = [
{ kind: 'BY_CUTOFF', statementId: statementId(cutoffStatement), status: cutoffStatement.status,
version: cutoffStatement.version, feeSourceIds: cutoffSources.map((source) => source.feeSourceId),
periodStart: cutoffDate, periodEnd: cutoffDate },
...issuedStatements.map((statement, index) => ({ kind: index === 0 ? 'SELECTED_FULL_CHAIN' : 'SELECTED',
statementId: statementId(statement), status: statement.status, version: statement.version,
feeSourceIds: groups[index].map((source) => source.feeSourceId),
periodStart: dates(groups[index]).start, periodEnd: dates(groups[index]).end })),
]
const arRows = receivableSettlements.map((settlement, index) => ({
settlementId: settlementId(settlement), statementId: statementId(issuedStatements[index]),
direction: settlement.direction, status: settlement.status, paymentStatus: settlement.paymentStatus,
version: settlement.version,
}))
fixture.root.statements = statementRows
fixture.root.statementMap = { byCutoff: statementRows[0], fullChain: statementRows[1],
selected: statementRows.slice(1) }
fixture.root.receivableSettlements = arRows
fixture.root.receivableSettlementMap = { byStatementId: Object.fromEntries(arRows.map((row) => [row.statementId, row])) }
fixture.root.payableSettlements = payableSettlement ? [{
settlementId: settlementId(payableSettlement), direction: payableSettlement.direction,
supplierId: id(payableSettlement.supplierId), partnerId: id(payableSettlement.partnerId ?? payableSettlement.supplierId),
supplierName: payableSettlement.supplierName ?? payables[0]?.supplierName ?? null,
status: payableSettlement.status,
paymentStatus: payableSettlement.paymentStatus, version: payableSettlement.version,
feeSourceIds: payables.map((source) => source.feeSourceId),
}] : []
finish(context, start)
}
@@ -0,0 +1,85 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { run, values } from './finance-settlement-full.mjs'
const response = (data, http = 200, code = '200') => ({ http, body: { code, data } })
function fakeContext(overrides = {}) {
const balances = new Map([['101', 100], ['102', 100], ['103', 100], ['201', 100]])
const settlementToBill = new Map([['101', '11'], ['102', '12'], ['103', '13']])
const billToSettlement = new Map([...settlementToBill].map(([s, b]) => [b, s]))
const fixture = { marker: 'T1', customerId: '9', voucherFileId: '77',
feeSources: [{ feeSourceId: '501', sourceType: 'MANUAL' }],
statements: [
{ kind: 'BY_CUTOFF', statementId: '10', status: 'VOIDED', feeSourceIds: [] },
{ kind: 'SELECTED_FULL_CHAIN', statementId: '11', status: 'ISSUED', feeSourceIds: ['501'] },
{ kind: 'SELECTED', statementId: '12', status: 'ISSUED', feeSourceIds: [] },
{ kind: 'SELECTED', statementId: '13', status: 'ISSUED', feeSourceIds: [] },
],
receivableSettlements: [
{ settlementId: '101', statementId: '11', direction: 'RECEIVABLE', status: 'ISSUED' },
{ settlementId: '102', statementId: '12', direction: 'RECEIVABLE', status: 'ISSUED' },
{ settlementId: '103', statementId: '13', direction: 'RECEIVABLE', status: 'ISSUED' },
],
payableSettlements: [{ settlementId: '201', direction: 'PAYABLE', supplierId: '301', status: 'ISSUED' }],
receivablePartialAmount: 40, payablePartialAmount: 40, multiBillAllocationAmount: 10,
...overrides }
const calls = []; const assertions = []; const gaps = []; const steps = []
return { marker: 'T1', token: 'token', createdResources: { financeFullFixtures: fixture }, calls, assertions, gaps, steps,
addStep(module, name, response, passed, evidence, options) { steps.push({ module, name, passed, evidence, options }) }, addCoverageGap(...args) { gaps.push(args) },
assert(module, name, res, condition) { assertions.push({ name, condition }); assert.equal(condition, true, name) },
async request(method, path, body, token, headers) {
calls.push({ method, path, body, headers })
const match = path.match(/\/settlements\/(\d+)/)
if (method === 'GET' && match) return response({ settlementId: match[1], outstandingAmount: balances.get(match[1]), status: balances.get(match[1]) === 0 ? 'PAID' : 'PARTIALLY_PAID', version: calls.length })
if (path.endsWith('/receipts')) { for (const allocation of body.allocations) balances.set(billToSettlement.get(String(allocation.billId)), balances.get(billToSettlement.get(String(allocation.billId))) - allocation.amount); return response({ receiptId: calls.length, allocations: body.allocations }) }
if (match && path.endsWith('/transactions')) {
const current = balances.get(match[1]); if (body.amount > current || current === 0) return response(null, 409, 'FIN_409')
balances.set(match[1], current - body.amount); return response({ settlementId: match[1], outstandingAmount: current - body.amount, status: current - body.amount === 0 ? 'PAID' : 'PARTIALLY_PAID', version: calls.length })
}
throw new Error(`unexpected ${method} ${path}`)
} }
}
test('values 兼容数组、records 和 named map', () => {
assert.deepEqual(values([{ id: 1 }]), [{ id: 1 }])
assert.deepEqual(values({ records: [{ id: 2 }] }), [{ id: 2 }])
assert.deepEqual(values({ marker: 'x', selected: { id: 3 } }), [{ id: 3 }])
})
test('run 使用 receipts 完成应收部分、全额及多单分配,使用 transactions 完成 AP', async () => {
const context = fakeContext()
await run(context)
const receiptCalls = context.calls.filter((call) => call.path.endsWith('/receipts'))
assert.equal(receiptCalls.length, 3)
assert.deepEqual(Object.keys(receiptCalls[0].body.allocations[0]).sort(), ['amount', 'billId'])
assert.equal(receiptCalls.find((call) => call.body.allocations.length === 3).body.allocations.length, 3)
const payments = context.calls.filter((call) => call.path.includes('/settlements/201/transactions'))
assert.equal(payments.length, 3)
assert.ok(payments.every((call) => call.body.method === 'BANK_TRANSFER' && call.body.transactionDate && call.body.expectedVersion != null))
assert.ok(context.calls.filter((call) => call.method === 'POST').every((call) => call.headers['Idempotency-Key'].startsWith('T1-')))
assert.ok(context.createdResources.financeFullFixtures.receipts.final)
assert.ok(context.createdResources.financeFullFixtures.payments.final)
assert.equal(context.createdResources.financeFullFixtures.settlementFinalStates.receivable.outstandingAmount, 0)
assert.equal(context.createdResources.financeFullFixtures.settlementFinalStates.payable.outstandingAmount, 0)
})
test('冻结共享输入使用 4 条 statements、3 条 AR 和 payableSettlements 数组', async () => {
const context = fakeContext()
assert.equal(context.createdResources.financeFullFixtures.statements.length, 4)
assert.equal(context.createdResources.financeFullFixtures.receivableSettlements.length, 3)
assert.equal(context.createdResources.financeFullFixtures.payableSettlements.length, 1)
await run(context)
assert.equal(context.gaps.length, 0)
})
test('缺 fixture 或真实 voucher 时记录 blocker 且不写接口', async () => {
const missing = fakeContext(); delete missing.createdResources.financeFullFixtures
await run(missing); assert.equal(missing.gaps.length, 1); assert.equal(missing.calls.length, 0)
assert.equal(missing.steps.length, 1); assert.equal(missing.steps[0].passed, false)
assert.equal(missing.steps[0].options.deploymentBlocker, true)
const noVoucher = fakeContext({ voucherFileId: null })
await run(noVoucher); assert.equal(noVoucher.gaps.length, 1); assert.equal(noVoucher.calls.length, 0)
assert.equal(noVoucher.steps.length, 1); assert.equal(noVoucher.steps[0].passed, false)
assert.equal(noVoucher.steps[0].options.deploymentBlocker, true)
})
@@ -0,0 +1,117 @@
const MODULE = 'finance-settlement-full'
const OPTIONS = { scenario: MODULE, deploymentBlocker: true }
const BASE = '/api/platform/finance/v1'
const id = (value) => value == null ? '' : String(value)
const data = (response) => response?.body?.data ?? response?.body
const ok = (response) => response?.http === 200 && (!response?.body?.code || String(response.body.code) === '200')
const key = (context, operation) => `${context.marker}-finance-settlement-${operation}`.replace(/[^A-Za-z0-9_.:-]/g, '-').slice(0, 128)
const write = (context, method, path, body, operation) => context.request(method, path, body, context.token, { 'Idempotency-Key': key(context, operation) })
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)
export function values(value) {
if (Array.isArray(value)) return value.filter(Boolean)
if (!value || typeof value !== 'object') return []
if (Array.isArray(value.records)) return value.records.filter(Boolean)
return Object.entries(value).filter(([name, item]) => name !== 'marker' && item && typeof item === 'object').map(([, item]) => item)
}
function gap(context, name, scope, reason, category = 'finance-settlement-fixture-unavailable') {
context.addStep?.(MODULE, name, null, false, reason, OPTIONS)
context.addCoverageGap?.(MODULE, OPTIONS.scenario, scope, reason, category, { marker: context.marker })
}
function receiptBody(context, customerId, voucherFileId, allocations) {
return { customerId: Number(customerId), receivedDate: new Date().toISOString().slice(0, 10),
amount: allocations.reduce((sum, item) => sum + item.amount, 0), currency: 'CNY',
paymentMethod: 'BANK_TRANSFER', voucherFileIds: [voucherFileId], remark: context.marker, allocations }
}
async function detail(context, targetId) {
const response = await context.request('GET', `${BASE}/settlements/${targetId}`)
return { response, value: data(response) }
}
async function receive(context, fixtures, item, requestedAmount, operation, label) {
const targetId = settlementId(item)
const billId = statementId(item) || id(item.reconciliationStatementId)
const before = await detail(context, targetId)
const applied = Math.min(Number(requestedAmount), outstanding(before.value))
const response = await write(context, 'POST', `${BASE}/receipts`, receiptBody(context, fixtures.customerId,
fixtures.voucherFileId, [{ billId: Number(billId), amount: applied }]), operation)
const after = await detail(context, targetId)
context.assert(MODULE, label, response, ok(response) && ok(after.response)
&& outstanding(after.value) === outstanding(before.value) - applied,
`before=${outstanding(before.value)}, applied=${applied}, after=${outstanding(after.value)}`, OPTIONS)
return { receipt: data(response), settlement: after.value }
}
export async function run(context) {
const fixtures = context.createdResources?.financeFullFixtures
if (!fixtures || fixtures.marker !== context.marker) {
gap(context, '取得本轮财务 Fixture', 'financeFullFixtures', '缺少匹配 marker 的 financeFullFixtures,禁止借历史单据')
return
}
const statements = values(fixtures.statements)
const receivables = values(fixtures.receivableSettlements)
const customerId = id(fixtures.customerId)
const voucherFileId = id(fixtures.voucherFileId ?? fixtures.voucher?.fileId ?? fixtures.voucher?.id)
if (!customerId || !voucherFileId) {
gap(context, '取得真实财务凭证', 'voucher-file', '前置 Fixture 未提供 File 服务创建且归属本客户的 FINANCE_VOUCHER fileId,收付款必须 fail-closed', 'missing-finance-voucher')
return
}
const manualIds = values(fixtures.feeSources).filter((fee) => fee.sourceType === 'MANUAL').map((fee) => id(fee.feeSourceId))
const usedIds = new Set(statements.flatMap((statement) => values(statement.feeSourceIds).map(id)))
context.assert(MODULE, '手动上传费用出账后来源完结', null,
manualIds.length > 0 && manualIds.every((feeId) => usedIds.has(feeId)),
`manual=${manualIds.length}, settled=${manualIds.filter((feeId) => usedIds.has(feeId)).length}`, OPTIONS)
const multiTargets = receivables.filter((item) => settlementId(item) && statementId(item)).slice(0, 3)
if (multiTargets.length < 3) gap(context, '取得三张应收结算单', 'multi receipt allocation', '本轮没有三张独占应收单,禁止借历史单据')
else {
const details = await Promise.all(multiTargets.map((item) => detail(context, settlementId(item))))
const allocations = multiTargets.map((item, index) => ({ billId: Number(statementId(item)), amount: Math.min(Number(fixtures.multiBillAllocationAmount || 1), outstanding(details[index].value)) }))
const response = await write(context, 'POST', `${BASE}/receipts`, receiptBody(context, customerId, voucherFileId, allocations), 'multi-bill-receipt')
const after = await Promise.all(multiTargets.map((item) => detail(context, settlementId(item))))
context.assert(MODULE, '一笔收款分配多张应收单', response, ok(response) && after.every((item, index) =>
outstanding(item.value) === outstanding(details[index].value) - allocations[index].amount), `allocations=${allocations.length}`, OPTIONS)
fixtures.receipts = { ...(fixtures.receipts || {}), multi: data(response) }
}
const primary = receivables.find((item) => settlementId(item) && statementId(item))
if (!primary) gap(context, '取得应收结算单', 'receivable partial/full', 'receivableSettlements 未提供 settlementId 与 statementId')
else {
const initial = await detail(context, settlementId(primary))
const partialAmount = Number(fixtures.receivablePartialAmount || Math.max(0.01, Math.floor(outstanding(initial.value) * 50) / 100))
const partial = await receive(context, fixtures, primary, partialAmount, 'ar-partial-receipt', '应收部分收款更新余额和状态')
context.assert(MODULE, '应收部分收款状态正确', null,
['PARTIALLY_PAID', 'PAID'].includes(id(partial.settlement?.status ?? partial.settlement?.paymentStatus)),
`status=${id(partial.settlement?.status ?? partial.settlement?.paymentStatus)}`, OPTIONS)
const final = await receive(context, fixtures, primary, outstanding(partial.settlement), 'ar-final-receipt', '应收全额收款完结')
fixtures.receipts = { ...(fixtures.receipts || {}), partial: partial.receipt, final: final.receipt }
fixtures.settlementFinalStates = { ...(fixtures.settlementFinalStates || {}), receivable: final.settlement }
}
const payable = values(fixtures.payableSettlements).find((item) => item.direction === 'PAYABLE' && settlementId(item))
const payableId = settlementId(payable)
if (!payableId) gap(context, '取得应付结算单', 'payable partial/full/overpayment', '前置 Fixture 的 payableSettlements 数组为空或缺少 PAYABLE 结算单')
else {
let current = (await detail(context, payableId)).value
const payment = (payAmount, expectedVersion) => ({ transactionDate: new Date().toISOString().slice(0, 10), amount: payAmount,
currency: 'CNY', method: 'BANK_TRANSFER', referenceNo: `${context.marker}-AP`, voucherFileIds: [voucherFileId], expectedVersion, remark: context.marker })
const firstAmount = Number(fixtures.payablePartialAmount || Math.max(0.01, Math.floor(outstanding(current) * 50) / 100))
const partialResponse = await write(context, 'POST', `${BASE}/settlements/${payableId}/transactions`, payment(firstAmount, version(current)), 'ap-partial-payment')
current = data(partialResponse)
context.assert(MODULE, '应付部分付款更新余额和状态', partialResponse, ok(partialResponse) && outstanding(current) >= 0, `outstanding=${outstanding(current)}`, OPTIONS)
const finalResponse = await write(context, 'POST', `${BASE}/settlements/${payableId}/transactions`, payment(outstanding(current), version(current)), 'ap-final-payment')
current = data(finalResponse)
context.assert(MODULE, '应付付清完结', finalResponse, ok(finalResponse) && outstanding(current) === 0, `status=${id(current?.status)}`, OPTIONS)
const overResponse = await write(context, 'POST', `${BASE}/settlements/${payableId}/transactions`, payment(0.01, version(current)), 'ap-overpayment')
context.assert(MODULE, '应付超额付款拦截', overResponse, !ok(overResponse), `HTTP=${overResponse.http}, code=${id(overResponse.body?.code)}`, OPTIONS)
fixtures.payments = { ...(fixtures.payments || {}), partial: data(partialResponse), final: current, overpayment: { http: overResponse.http, code: overResponse.body?.code } }
fixtures.settlementFinalStates = { ...(fixtures.settlementFinalStates || {}), payable: current }
}
}
@@ -251,6 +251,17 @@ export async function run(context) {
&& pickReplay?.items?.every?.((item) => Number(item.pickedPackageCount) === Number(item.plannedPackageCount)),
`taskStatus=${pickReplay?.taskStatus || 'missing'}`, OPTIONS)
const operationsResponse = await request(context, 'GET', `${PDA}/tasks/${taskId}/operations`)
const operations = context.apiSuccess(MODULE, '回查完成后的 PDA 操作审计', operationsResponse, '', OPTIONS)
context.assert(MODULE, 'PDA 完成审计保留领取和原子批次操作者记录', operationsResponse,
Array.isArray(operations)
&& operations.some((item) => item.actionType === 'CLAIM'
&& Boolean(item.operatorId) && Boolean(item.createdAt))
&& operations.some((item) => item.actionType === 'BATCH'
&& Boolean(item.operatorId) && Boolean(item.createdAt))
&& !operations.some((item) => item.actionType === 'REVIEW'),
`actions=${Array.isArray(operations) ? operations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS)
const reviewResponse = await request(context, 'POST', `${PDA}/tasks/${taskId}/review`, {
expectedTaskVersion: Number(picked?.version), operationId: `${context.marker}-pda-review`.slice(0, 128),
remark: context.marker,
@@ -19,8 +19,8 @@ export async function run(context) {
const voided = evidences.find((item) => id(item.id) === id(fixture.voidedEvidenceId))
context.assert(MODULE, '五类有效证据齐全且同类型多图追加不覆盖历史', response,
required.every((type) => active.some((item) => item.evidenceType === type))
&& active.filter((item) => item.evidenceType === 'PHOTO_STEP_1').length >= 1
&& active.some((item) => item.evidenceType === 'SEAL' && Boolean(String(item.sealNo || '').trim()))
&& evidences.filter((item) => item.evidenceType === 'PHOTO_STEP_1').length >= 2
&& active.some((item) => item.evidenceType === 'SEAL' && item.sealNo === fixture.sealNo)
&& active.every((item) => String(item.fileName || '').includes(context.marker)
&& String(item.remark || '').includes(context.marker)),
`active=${active.length}, types=${[...new Set(active.map((item) => item.evidenceType))].join(',')}`, OPTIONS)
@@ -55,7 +55,7 @@ export async function run(context) {
context.assert(MODULE, '最终装柜前保持 LOADING 且版本有效', beforeResponse,
before?.summary?.processStatus === 'LOADING' && Number.isInteger(version),
`status=${before?.summary?.processStatus || 'missing'}, version=${before?.summary?.version ?? 'missing'}`, OPTIONS)
if (!Number.isInteger(version)) return
if (before?.summary?.processStatus !== 'LOADING' || !Number.isInteger(version)) return
const base = `/api/platform/v1/inbounds/${inboundId}`
const completeResponse = await request(context, 'POST',
@@ -58,7 +58,8 @@ async function bindEvidence(context, base, tenantId, evidenceType, fileId, suffi
}
function fixture(context) {
return context.createdResources?.orchestrationFixtures?.inboundMeasurement
return context.createdResources?.orchestrationFixtures?.phase3Loading
|| context.createdResources?.orchestrationFixtures?.inboundMeasurement
|| context.createdResources?.inboundMeasurement
|| context.createdResources?.orders
}
@@ -655,10 +656,16 @@ export async function run(context) {
context.addStep(MODULE, '订舱拖车报关未齐备时 LOADED 硬门禁拒绝', blockedBeforeDownstream,
failure(blockedBeforeDownstream),
`HTTP=${blockedBeforeDownstream.http}, code=${codeOf(blockedBeforeDownstream)}`, OPTIONS)
const blockedDetailResponse = await context.request('GET', detailPath)
const blockedDetail = context.apiSuccess(MODULE, '回查 LOADED 硬门禁拒绝后的入库状态', blockedDetailResponse, '', OPTIONS)
context.assert(MODULE, 'LOADED 硬门禁拒绝后状态和版本零写入', blockedDetailResponse,
blockedDetail?.summary?.processStatus === 'LOADING'
&& Number(blockedDetail?.summary?.version) === loadingVersion,
`status=${blockedDetail?.summary?.processStatus || 'missing'}, version=${blockedDetail?.summary?.version ?? 'missing'}`, OPTIONS)
context.createdResources.phase3Loading = {
tenantId, inboundId, preloadPlanId: planId, evidenceIds: evidenceItems.map((item) => id(item.id)),
voidedEvidenceId: id(voidTarget?.id), evidenceTypes,
voidedEvidenceId: id(voidTarget?.id), evidenceTypes, sealNo: `SEAL-${suffix}`,
loadingPlanId, containerBookingId, containerNo, warehouseId, canonicalPdaTaskId: canonicalTaskId,
pickingTaskId: canonicalTaskId, inboundVersion: loadingVersion,
atomicBatchVerified: directPdaCompletion && atomicBatchReplayVerified,
@@ -49,6 +49,11 @@ export async function run(context) {
&& Boolean(item.operatorId) && Boolean(item.createdAt))
&& !operations.some((item) => item.actionType === 'REVIEW'),
`actions=${Array.isArray(operations) ? operations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS)
context.assert(MODULE, 'PDA 原子批次完成审计包含 BATCH 操作', operationsResponse,
Array.isArray(operations) && operations.some((item) => item.actionType === 'BATCH'
&& Boolean(item.operatorId) && Boolean(item.createdAt)),
`actions=${Array.isArray(operations) ? operations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS)
const forged = await request(context, 'POST',
`/api/platform/v1/inbounds/${inboundId}/loading/picking/start?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedVersion: Number(projection?.version), expectedCount: 999, pickedCount: 999, remark: 'forged' },
@@ -4,6 +4,7 @@ import test from 'node:test'
import { selectCustomsFlow } from './phase3-customs.mjs'
import { buildFinalizeResource } from './phase3-loading-finalize.mjs'
import { AcceptanceRun } from '../admin-test-env-acceptance.mjs'
function source(name) {
return readFileSync(new URL(name, import.meta.url), 'utf8')
@@ -118,6 +119,16 @@ test('PDA inventory snapshot gates batch and records the only safe mutation gap'
assert.match(convergence, /fixture\?\.inventoryMutationVerified !== true/)
})
test('PDA completion acceptance verifies immutable BATCH audit with operator metadata', () => {
const container = source('./phase3-container-loading.mjs')
const convergence = source('./phase3-pda-convergence.mjs')
assert.match(container, /回查完成后的 PDA 操作审计/)
assert.match(container, /item\.actionType === 'BATCH'/)
assert.match(container, /Boolean\(item\.operatorId\) && Boolean\(item\.createdAt\)/)
assert.match(convergence, /PDA 原子批次完成审计包含 BATCH 操作/)
assert.match(convergence, /item\.actionType === 'BATCH'/)
})
test('customs provisions released booking and completed truck for the selected loading container', () => {
const value = source('./phase3-customs.mjs')
assertOrder(value, [
@@ -138,3 +149,53 @@ test('downstream producers prefer the phase3Loading container fixture', () => {
assert.match(serviceCenter, /context\.createdResources\?\.phase3Loading\s*\|\|\s*context\.createdResources\?\.phase3ContainerLoading/)
assert.match(ocean, /await action\('放舱', 'release'/)
})
test('default acceptance order verifies loading evidence before finalizing LOADED', () => {
const entry = source('../admin-test-env-acceptance.mjs')
assertOrder(entry, [
"'phase3-loading'",
"'phase3-loading-model-bridge'",
"'phase3-pda-convergence'",
"'phase3-evidence-gate'",
"'phase3-customs'",
"'phase3-loading-finalize'",
])
})
test('phase3 loading provisions its own marker-isolated WAIT_SPLIT fixture', async () => {
const run = new AcceptanceRun({ gateway: 'http://127.0.0.1', modules: [], username: '', password: '', output: '' })
const entry = source('../admin-test-env-acceptance.mjs')
assert.match(entry, /module === 'phase3-loading'/)
assert.match(entry, /preparePhase3LoadingFixture\(\)/)
assert.match(entry, /orders-phase3-loading-fixture/)
assert.match(entry, /inbound-measurement-phase3-loading-fixture/)
assert.match(entry, /marker: originalMarker/)
assert.match(entry, /sourceMarker: fixtureMarker/)
assert.match(entry, /phase3Loading: loadingFixture,\s*\n\s*inboundMeasurement: loadingFixture/)
assert.equal(run.marker.includes('-LOAD'), false)
})
test('customs and finalize consume the same loading inbound identity', () => {
const loadingFixture = { marker: 'RUN-1', tenantId: 10001, orderId: 101, inboundId: 201 }
const loading = {
marker: 'RUN-1', tenantId: 10001, inboundId: 201,
containerBookingId: 301, loadingPlanId: 401, finalStatus: 'COMPLETED',
}
const selected = selectCustomsFlow({
orchestrationFixtures: {
phase3Loading: loadingFixture,
inboundMeasurement: loadingFixture,
},
phase3Loading: loading,
})
assert.equal(selected.fixture.marker, loading.marker)
assert.equal(selected.fixture.inboundId, loading.inboundId)
assert.equal(selected.container.inboundId, loading.inboundId)
const finalized = buildFinalizeResource(loading, {
summary: { processStatus: 'LOADED', version: 6 },
}, { http: 200, body: { code: '200' } })
assert.equal(finalized.marker, loading.marker)
assert.equal(finalized.inboundId, loading.inboundId)
assert.equal(finalized.finalStatus, 'LOADED')
})