From 4d0c245b606b6fc18cd308118e570e89d784e3d8 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 12 Aug 2026 10:59:39 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/CartonMeasurementService.java | 11 +- .../service/CartonMeasurementServiceTest.java | 13 +- .../acceptance-finance-full-contract.test.mjs | 62 ++++ scripts/admin-test-env-acceptance.mjs | 99 +++++- .../finance-full-fixtures-contract.test.mjs | 115 ++++++ .../finance-full-fixtures.mjs | 284 +++++++++++++++ ...ance-reconciliation-full-contract.test.mjs | 172 +++++++++ .../finance-reconciliation-full.mjs | 328 ++++++++++++++++++ .../finance-settlement-full-contract.test.mjs | 85 +++++ .../finance-settlement-full.mjs | 117 +++++++ .../phase3-container-loading.mjs | 11 + .../phase3-evidence-gate.mjs | 4 +- .../phase3-loading-finalize.mjs | 2 +- .../admin-test-env-modules/phase3-loading.mjs | 11 +- .../phase3-pda-convergence.mjs | 5 + .../phase3-producer-chain-contract.test.mjs | 61 ++++ 16 files changed, 1368 insertions(+), 12 deletions(-) create mode 100644 scripts/acceptance-finance-full-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/finance-full-fixtures.mjs create mode 100644 scripts/admin-test-env-modules/finance-reconciliation-full-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/finance-reconciliation-full.mjs create mode 100644 scripts/admin-test-env-modules/finance-settlement-full-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/finance-settlement-full.mjs diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java index 27a43a37d..5b5ef2a9b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java @@ -124,12 +124,21 @@ public class CartonMeasurementService { if (!repository.updateItem(item)) { throw concurrentModification(); } - recalculateMeasurement(measurement, repository.listItems(tenantId, measurement.getId())); + List 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)) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java index f6e4641a8..75a0a891b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java @@ -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); } diff --git a/scripts/acceptance-finance-full-contract.test.mjs b/scripts/acceptance-finance-full-contract.test.mjs new file mode 100644 index 000000000..c89ee67d8 --- /dev/null +++ b/scripts/acceptance-finance-full-contract.test.mjs @@ -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\}/) +}) diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 78fed36ad..cb648472d 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -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 }) +} diff --git a/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs b/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs new file mode 100644 index 000000000..616b4775d --- /dev/null +++ b/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs @@ -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'))) +}) diff --git a/scripts/admin-test-env-modules/finance-full-fixtures.mjs b/scripts/admin-test-env-modules/finance-full-fixtures.mjs new file mode 100644 index 000000000..6d1d2b99d --- /dev/null +++ b/scripts/admin-test-env-modules/finance-full-fixtures.mjs @@ -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 }) +} diff --git a/scripts/admin-test-env-modules/finance-reconciliation-full-contract.test.mjs b/scripts/admin-test-env-modules/finance-reconciliation-full-contract.test.mjs new file mode 100644 index 000000000..19003697c --- /dev/null +++ b/scripts/admin-test-env-modules/finance-reconciliation-full-contract.test.mjs @@ -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.*阻断/) +}) diff --git a/scripts/admin-test-env-modules/finance-reconciliation-full.mjs b/scripts/admin-test-env-modules/finance-reconciliation-full.mjs new file mode 100644 index 000000000..721fab79c --- /dev/null +++ b/scripts/admin-test-env-modules/finance-reconciliation-full.mjs @@ -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) +} diff --git a/scripts/admin-test-env-modules/finance-settlement-full-contract.test.mjs b/scripts/admin-test-env-modules/finance-settlement-full-contract.test.mjs new file mode 100644 index 000000000..169f4195e --- /dev/null +++ b/scripts/admin-test-env-modules/finance-settlement-full-contract.test.mjs @@ -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) +}) diff --git a/scripts/admin-test-env-modules/finance-settlement-full.mjs b/scripts/admin-test-env-modules/finance-settlement-full.mjs new file mode 100644 index 000000000..d7b46225e --- /dev/null +++ b/scripts/admin-test-env-modules/finance-settlement-full.mjs @@ -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 } + } +} diff --git a/scripts/admin-test-env-modules/phase3-container-loading.mjs b/scripts/admin-test-env-modules/phase3-container-loading.mjs index dd81baec1..53eafd43b 100644 --- a/scripts/admin-test-env-modules/phase3-container-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-container-loading.mjs @@ -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, diff --git a/scripts/admin-test-env-modules/phase3-evidence-gate.mjs b/scripts/admin-test-env-modules/phase3-evidence-gate.mjs index 82ffcb7c5..e3498c783 100644 --- a/scripts/admin-test-env-modules/phase3-evidence-gate.mjs +++ b/scripts/admin-test-env-modules/phase3-evidence-gate.mjs @@ -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) diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs index f6c3a61c2..f8d157d69 100644 --- a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -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', diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 44e6e41aa..ecdd4b12b 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -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, diff --git a/scripts/admin-test-env-modules/phase3-pda-convergence.mjs b/scripts/admin-test-env-modules/phase3-pda-convergence.mjs index 3e76e81e1..68316d692 100644 --- a/scripts/admin-test-env-modules/phase3-pda-convergence.mjs +++ b/scripts/admin-test-env-modules/phase3-pda-convergence.mjs @@ -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' }, diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 8c288f7ad..caa6d1937 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -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') +})