优化
This commit is contained in:
@@ -124,12 +124,21 @@ public class CartonMeasurementService {
|
||||
if (!repository.updateItem(item)) {
|
||||
throw concurrentModification();
|
||||
}
|
||||
recalculateMeasurement(measurement, repository.listItems(tenantId, measurement.getId()));
|
||||
List<InboundMeasurementItemEntity> items = repository.listItems(tenantId, measurement.getId());
|
||||
recalculateMeasurement(measurement, items);
|
||||
if (!repository.updateMeasurement(measurement)) {
|
||||
throw concurrentModification();
|
||||
}
|
||||
// 提交审核后仍允许平台人工修正代表箱数,但订单原始总箱数不可被测量汇总覆盖。
|
||||
if ("MEASURE_PENDING".equals(inbound.getProcessStatus())) {
|
||||
BigDecimal totalWeight = items.stream()
|
||||
.map(InboundMeasurementItemEntity::getTotalWeightKg)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add)
|
||||
.setScale(3, RoundingMode.CEILING);
|
||||
if (positive(totalWeight)) {
|
||||
inbound.setActualGrossWeightKg(totalWeight);
|
||||
}
|
||||
inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm());
|
||||
inbound.setVolumeWeightKg(measurement.getVolumeWeightKg());
|
||||
if (!repository.updateInbound(inbound)) {
|
||||
|
||||
+10
-3
@@ -230,13 +230,19 @@ class CartonMeasurementServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmedPackageCountCanBeCorrectedAfterSubmissionWithoutChangingOriginalPackageCount() {
|
||||
void confirmedPackageCountSynchronizesActualWeightAndVolumeForPricingAfterSubmission() {
|
||||
InboundOrderEntity inbound = inbound("MEASURE_PENDING");
|
||||
InboundMeasurementEntity measurement = measurement(30001L, "SUBMITTED", 2);
|
||||
measurement.setTotalPackageCount(1);
|
||||
measurement.setTotalVolumeCbm(new BigDecimal("0.0600"));
|
||||
measurement.setVolumeWeightKg(new BigDecimal("10.020"));
|
||||
InboundMeasurementItemEntity item = item(40001L, 30001L, 1, 99001L);
|
||||
item.setUnitWeightKg(new BigDecimal("10.880"));
|
||||
item.setOuterLengthCm(new BigDecimal("50"));
|
||||
item.setOuterWidthCm(new BigDecimal("40"));
|
||||
item.setOuterHeightCm(new BigDecimal("30"));
|
||||
item.setUnitVolumeCbm(new BigDecimal("0.1496"));
|
||||
item.setCalculationRuleVersion("ACTUAL_DIMENSION_V1");
|
||||
when(repository.findInbound(10001L, 70001L)).thenReturn(inbound);
|
||||
when(repository.findItem(10001L, 40001L)).thenReturn(item);
|
||||
when(repository.findMeasurement(10001L, 70001L, 30001L)).thenReturn(measurement);
|
||||
@@ -248,8 +254,9 @@ class CartonMeasurementServiceTest {
|
||||
service.updateConfirmedPackageCount(10001L, 70001L, 40001L, 2, 5);
|
||||
|
||||
assertThat(inbound.getTotalPackageCount()).isEqualTo(5);
|
||||
assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("0.5250");
|
||||
assertThat(inbound.getVolumeWeightKg()).isEqualByComparingTo("87.675");
|
||||
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("54.400");
|
||||
assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("0.7480");
|
||||
assertThat(inbound.getVolumeWeightKg()).isEqualByComparingTo("124.916");
|
||||
verify(repository).updateInbound(inbound);
|
||||
}
|
||||
|
||||
|
||||
@@ -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\}/)
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user