diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index fbfdad14e..0c7aa987d 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -15,6 +15,7 @@ | 待审核订单造数 | `create-pending-review-orders.mjs` | 使用已配置客户创建并提交订单,停在待审核;不审核、不推进后续履约 | | 批量待分柜造数 | `it-test-wait-split-bulk.mjs` | 固定“IT测试”和“义乌仓”,随机客户可选真实线路并尽力推进到 `WAIT_SPLIT`;失败记录后继续,不自动删除成功数据;只复用 `TP-001`/`YW-TP-002`,不新增托盘或库位,普通位不足落 `A0` | | Admin HTTP 内部模块 | `admin-test-env-modules/*.mjs` | 由 Admin 总入口动态加载,除文档明确的独立编排器外不得当作独立全量入口;`*contract.test.mjs` 只做离线合同 | +| Finance 扩展 HTTP 模块 | `admin-test-env-modules/firstleg-container-fee-allocation.mjs`、`finance-direct-paid-isolation.mjs`、`phase4-overseas-finance-cross-region.mjs` | 分别验证柜费跨客户分摊、海外仓服务直接已付隔离、五类海外费用跨区入池;必须由 Admin 总入口按已登记依赖运行,使用本轮资源且禁止借历史数据,真实 HTTP 仍须当前轮授权 | | 定向 HTTP/诊断 | 以 `README.md` 的“仓库级入口清单”为准 | 必须明确是只读诊断还是会补证据、创建资源或推进状态,不能用诊断成功冒充全量通过 | | 本地质量与静态合同 | `backend-full-regression.ps1`、`frontend-full-regression.ps1`、`mapper-scan-contract.mjs`、`script-documentation-contract.test.mjs` 等 | 默认不访问测试环境;数据库非 dry-run、Nacos 发布等有写入能力的入口必须按 README 单独识别 | | 部署与基础设施 | `../deploy/scripts/`、`../deploy/rocketmq/` | 靠近部署组件管理,不移动到根脚本目录;部署、初始化数据库或发布配置必须获得当前轮明确授权 | diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 3ac58dc79..e95ac48ce 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -5,7 +5,7 @@ import { dirname, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import process from 'node:process' -const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-container-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics'] +const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-container-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'firstleg-container-fee-allocation', 'finance-direct-paid-isolation', 'phase4-overseas-finance-cross-region', 'customer-operating-statistics'] const OPTIONAL_MODULES = ['manual-order-splitting-fixture', 'manual-order-splitting-verify', 'manual-preload-push-fixture', 'manual-preload-push-verify', 'manual-pda-ready-fixture', 'manual-pda-ready-verify', @@ -77,6 +77,10 @@ const MODULE_DEPENDENCIES = new Map([ ['phase4-overseas-dispatch', ['phase4-overseas-pda-operations']], ['phase4-cross-region-events', ['system', 'phase3-customs', 'phase4-overseas-pda-operations']], ['phase4-carrier-nodes', ['phase4-overseas-pda-operations']], + ['phase4-overseas-business', ['merchant-qualification-fixture']], + ['firstleg-container-fee-allocation', []], + ['finance-direct-paid-isolation', ['finance-full-fixtures', 'finance-reconciliation-full']], + ['phase4-overseas-finance-cross-region', ['phase4-overseas-business', 'phase4-overseas-pda-operations']], ]) function stableTopologicalOrder(modules) { @@ -241,7 +245,7 @@ export function normalizeModuleOrder(modules) { const missing = dependencyClosure(module).filter((dependency) => !normalized.includes(dependency)) if (missing.length > 0) normalized.splice(normalized.indexOf(module), 0, ...missing) } - return stableTopologicalOrder(normalized) + return stableTopologicalOrder([...new Set(normalized)]) } const DEFAULT_MODULES = normalizeModuleOrder(CONFIGURED_MODULES) @@ -314,7 +318,9 @@ export function parseArguments(argv) { values.set(name, next); index += 1 } } - const modules = normalizeModuleOrder((values.get('modules') || DEFAULT_MODULES.join(',')).split(',').map((v) => v.trim()).filter(Boolean)) + const requestedModules = (values.get('modules') || DEFAULT_MODULES.join(',')) + .split(',').map((v) => v.trim()).filter(Boolean) + const modules = normalizeModuleOrder(requestedModules) const merchantUsername = values.get('merchant-username') || process.env.MERCHANT_TEST_USERNAME || '' const merchantPassword = values.get('merchant-password') || process.env.MERCHANT_TEST_PASSWORD || '' if (merchantUsername && merchantPassword && !modules.includes('phase4-overseas-business')) { @@ -327,6 +333,7 @@ export function parseArguments(argv) { username: values.get('username') || process.env.ADMIN_TEST_USERNAME || '', password: values.get('password') || process.env.ADMIN_TEST_PASSWORD || '', modules, + requestedModules, customerName: values.get('customer-name') || process.env.ADMIN_TEST_CUSTOMER_NAME || '', merchantUsername, merchantPassword, @@ -400,9 +407,9 @@ export function validateExecutionOptions(options) { } if (options.waitSplitCount > 1) { const fixtureModules = new Set(['merchant-qualification-fixture', 'orders', 'inbound-measurement', - 'manual-order-splitting-fixture', 'coload-groups']) + 'manual-order-splitting-fixture', 'coload-groups', 'firstleg-container-fee-allocation']) if (options.modules.some((module) => !fixtureModules.has(module))) { - throw new Error('批量WAIT_SPLIT只允许执行merchant-qualification-fixture、orders、inbound-measurement、manual-order-splitting-fixture') + throw new Error('批量WAIT_SPLIT只允许执行WAIT_SPLIT造数、同柜组与柜次跨客户分摊模块') } } if (options.modules.includes('manual-order-splitting-fixture') && options.waitSplitCount < 4) { @@ -525,23 +532,31 @@ export class AcceptanceRun { const requestBody = path === '/api/auth/login' && body?.username && !body?.loginId ? { ...body, loginId: body.username, username: undefined } : body - try { - const response = await fetch(`${this.options.gateway}${path}`, { - method, headers, body: requestBody === undefined ? undefined : JSON.stringify(requestBody), - signal: AbortSignal.timeout(30_000), - }) - const content = await response.text() - let payload = null - try { payload = content ? JSON.parse(content) : null } catch { /* Raw response is recorded below. */ } - 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'), + const maxAttempts = ['GET', 'HEAD'].includes(String(method).toUpperCase()) ? 3 : 1 + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + const response = await fetch(`${this.options.gateway}${path}`, { + method, headers, body: requestBody === undefined ? undefined : JSON.stringify(requestBody), + signal: AbortSignal.timeout(30_000), + }) + const content = await response.text() + let payload = null + try { payload = content ? JSON.parse(content) : null } catch { /* Raw response is recorded below. */ } + 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) { + if (attempt < maxAttempts) { + await new Promise((resolve) => setTimeout(resolve, attempt * 250)) + continue + } + return { method, path, http: 0, body: null, raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null } } - } catch (error) { - return { method, path, http: 0, body: null, raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null } } + throw new Error('unreachable request attempt state') } async requestOverseas(method, path, body = undefined, extraHeaders = {}, token = this.overseasToken) { const headers = { Accept: 'application/json', ...extraHeaders } @@ -1310,6 +1325,7 @@ export class AcceptanceRun { internalRemark: fixtureOrder.internalRemark, cargoRemark: fixtureOrder.cargoRemark, taxMode: fixtureOrder.taxMode || null, + requireInventoryMutation: requireSharedOverflow, } : { marker: originalMarker, sourceMarker: fixtureMarker, failed: true } const incrementalMarker = `${originalMarker}-LOAD${suffix}-INC` this.marker = incrementalMarker @@ -1655,7 +1671,11 @@ export class AcceptanceRun { } if (module === 'phase3-loading') { try { - restoreFixture = await this.preparePhase3LoadingFixture() + // The A0 stale-inventory mutation is intentionally destructive and expensive on + // a large shared test warehouse. Run it only when phase3-loading was explicitly + // requested; downstream phase4/finance chains need the normal loading fixture only. + const requireSharedOverflow = this.options.requestedModules.includes('phase3-loading') + restoreFixture = await this.preparePhase3LoadingFixture('', requireSharedOverflow) } catch (error) { this.marker = this.marker.replace(/-LOAD$/, '') const reason = error instanceof Error ? error.message : String(error) diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 1770ed349..c2240a6c0 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -77,6 +77,7 @@ export async function run(context) { | 装柜清单三备注 | `orders.mjs`、`phase3-loading.mjs`(客户备注、最新内部备注、仓库备注;仓库备注 PATCH/清空/精确重放/旧版本零写) | `loading-list-remarks-contract.test.mjs` | | 消息中心管理 | `notifications.mjs` | `notifications-contract.test.mjs` | | Finance 真实前置、对账、结算与下载中心 | `finance-full-fixtures.mjs`、`finance-reconciliation-full.mjs`、`finance-settlement-full.mjs`、`finance-downloads.mjs` | `finance-full-fixtures-contract.test.mjs`、`finance-reconciliation-full-contract.test.mjs`、`finance-settlement-full-contract.test.mjs`、`finance-downloads-contract.test.mjs` | +| Finance 扩展隔离与跨区费用 | `firstleg-container-fee-allocation.mjs`(柜次跨客户分摊)、`finance-direct-paid-isolation.mjs`(海外仓服务直接已付)、`phase4-overseas-finance-cross-region.mjs`(五类海外费用跨区进入 Finance) | 对应同名 `*-contract.test.mjs`;跨区事件持久化链在本轮获授权时另以只读数据库证据核对 | | 四期海外仓 HTTP 生命周期 | `phase4-overseas-warehouse.mjs` | `phase4-overseas-warehouse-contract.test.mjs` | | 四期海外 PDA 柜号收货与库内操作 HTTP 生命周期 | `phase4-overseas-pda.mjs`(按柜完成签收和实收,成功后仅以内存上下文交接操作员会话和已完成库存事实)、`phase4-overseas-pda-operations.mjs`(紧随收货执行 P2 盘点、P3 整托移库、P4 托盘拆装换绑、P5 换标复核;finally 停用双操作员并清空临时 Token;任何真实 fixture 缺失均 fail-closed) | `phase4-overseas-pda-contract.test.mjs`、`phase4-overseas-pda-operations-contract.test.mjs` | | 三四期跨区事件与安全门禁 | `phase4-cross-region-events.mjs`(双 Token Audience 互斥、canonical 权限 403、国内装柜完成到 Lastleg 建单、海外柜完成到 Firstleg 回写、轮询重试/幂等/诊断证据;缺本轮 marker 前置时阻断) | `phase4-cross-region-events-contract.test.mjs` | diff --git a/scripts/admin-test-env-modules/finance-direct-paid-isolation-contract.test.mjs b/scripts/admin-test-env-modules/finance-direct-paid-isolation-contract.test.mjs new file mode 100644 index 000000000..41652b4ba --- /dev/null +++ b/scripts/admin-test-env-modules/finance-direct-paid-isolation-contract.test.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' +import { buildDirectPaidBody, findDirectPaidCost, findOpenSettlement } from './finance-direct-paid-isolation.mjs' + +const source = await readFile(new URL('./finance-direct-paid-isolation.mjs', import.meta.url), 'utf8') + +test('DIRECT_PAID 请求只绑定客户、仓库、供应商且不伪造业务关联', () => { + const body = buildDirectPaidBody('RUN-001', { customerId: '101', customerName: '客户A' }, + { id: '201', name: '供应商A' }, { id: '301' }) + assert.equal(body.businessType, 'WAREHOUSE_SERVICE') + assert.equal(body.sourceType, 'OVERSEAS_WAREHOUSE_FEE') + assert.equal(body.settlementMode, 'DIRECT_PAID') + assert.equal(body.direction, 'PAYABLE') + assert.equal(body.customerId, '101') + assert.equal(body.warehouseId, '301') + assert.equal(body.supplierId, '201') + assert.equal(body.orderId, null) + assert.equal(body.inboundId, null) + assert.equal(body.inboundCode, null) + assert.equal(body.containerId, null) + assert.match(body.paymentReferenceNo, /DIRECT-PAID/) +}) + +test('成本与未结清结算单选择严格命中客户金额和可支付状态', () => { + const body = { customerId: '101', amount: 17.31, feeItemName: '海外仓服务已付费用' } + assert.equal(findDirectPaidCost([ + { id: 1, customerId: '102', amount: 17.31, feeItemName: body.feeItemName }, + { id: 2, customerId: '101', amount: 17.31, feeItemName: body.feeItemName }, + ], body)?.id, 2) + assert.equal(findOpenSettlement([ + { settlementId: 1, outstandingAmount: 0, status: 'PAID' }, + { settlementId: 2, outstandingAmount: 9, status: 'ISSUED' }, + ])?.settlementId, 2) +}) + +test('HTTP 模块覆盖直接已付投影、AP 排除、幂等冲突、冻结和第二客户隔离', () => { + for (const token of [ + 'BUSINESS_DIRECT_PAID', 'customer-accounts/', 'cost-entries', 'fee-pool', 'payable-settlements', + '收款同键同参重放且异参冲突', 'AP 付款同键同参重放且异参冲突', + '拒绝编辑、调整和作废且零漂移', '第二客户不能读取或确认他人账单', + 'foreign-receipt', 'foreign-payable', 'merchant-qualification-fixture.mjs', + ]) assert.ok(source.includes(token), token) + assert.match(source, /fixture\.marker !== context\.marker/) + assert.match(source, /禁止借历史财务单据/) + assert.doesNotMatch(source, /INSERT\s+INTO|UPDATE\s+fin_|DELETE\s+FROM/i) +}) + +test('敏感凭据不写入输出且临时客户上下文完整恢复', () => { + assert.match(source, /originalUsername/) + assert.match(source, /originalPassword/) + assert.match(source, /delete process\.env\.MERCHANT_TEST_PASSWORD/) + assert.doesNotMatch(source, /console\.log\([^)]*password/i) + assert.doesNotMatch(source, /MYSQL_PWD|Authorization:\s*`Bearer[^$]/) +}) diff --git a/scripts/admin-test-env-modules/finance-direct-paid-isolation.mjs b/scripts/admin-test-env-modules/finance-direct-paid-isolation.mjs new file mode 100644 index 000000000..20b0a4b67 --- /dev/null +++ b/scripts/admin-test-env-modules/finance-direct-paid-isolation.mjs @@ -0,0 +1,322 @@ +const MODULE = 'finance-direct-paid-isolation' +const SCENARIO = 'finance-direct-paid-idempotency-freeze-isolation' +const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } +const FINANCE = '/api/platform/finance/v1' +const CLIENT = '/api/client/finance/v1' +const OVERSEAS_FEES = '/api/platform/v1/last-leg/finance-fee-sources' + +const id = (value) => value == null ? '' : String(value) +const data = (response) => response?.body?.data ?? response?.body +const rows = (value) => Array.isArray(value) ? value : Array.isArray(value?.records) ? value.records : [] +const ok = (response) => response?.http === 200 && String(response?.body?.code ?? '200') === '200' +const rejected = (response) => !ok(response) +const statementId = (value) => id(value?.statementId ?? value?.reconciliationStatementId ?? value?.id) +const settlementId = (value) => id(value?.settlementId ?? value?.id) +const sourceId = (value) => id(value?.sourceFeeId ?? value?.feeSourceId ?? value?.id) +const costId = (value) => id(value?.costId ?? value?.id) +const version = (value) => Number(value?.operationVersion ?? value?.version ?? value?.summary?.version ?? 0) +const outstanding = (value) => Number(value?.outstandingAmount ?? value?.summary?.outstandingAmount ?? 0) +const today = () => new Date().toISOString().slice(0, 10) + +function key(context, operation) { + return `${context.marker}-finance-direct-${operation}`.replace(/[^A-Za-z0-9_.:-]/g, '-').slice(0, 128) +} + +function write(context, method, path, body, operation, token = context.token, overseas = false) { + const headers = { 'Idempotency-Key': key(context, operation) } + return overseas + ? context.requestOverseas(method, path, body, headers, token) + : context.request(method, path, body, token, headers) +} + +function diagnostic(response) { + return `HTTP=${response?.http ?? 'missing'}, code=${id(response?.body?.code) || 'missing'}, requestId=${response?.requestId ?? 'missing'}, traceId=${response?.traceId ?? 'missing'}` +} + +function blocker(context, name, reason, response = null, category = 'finance-direct-paid-prerequisite') { + context.addStep(MODULE, name, response, false, `${reason}; ${diagnostic(response)}`, OPTIONS) + context.addCoverageGap?.(MODULE, SCENARIO, name, reason, category, { + marker: context.marker, requestId: response?.requestId ?? null, traceId: response?.traceId ?? null, + }) +} + +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 }) +} + +export function buildDirectPaidBody(marker, fixture, supplier, warehouse) { + const businessNo = `WHS-${String(marker).replace(/[^A-Za-z0-9]/g, '').slice(-24)}` + return { + customerId: id(fixture.customerId), customerName: fixture.customerName || null, + sourceType: 'OVERSEAS_WAREHOUSE_FEE', businessType: 'WAREHOUSE_SERVICE', businessId: null, + businessNo, orderId: null, inboundId: null, inboundCode: null, + containerId: null, containerBookingId: null, containerNo: null, + warehouseId: id(warehouse.id ?? warehouse.warehouseId), + feeItemCode: 'WAREHOUSE_SERVICE_DIRECT_PAID', feeItemName: `${marker} 海外仓服务已付费用`, + direction: 'PAYABLE', supplierId: id(supplier.id ?? supplier.supplierId), + supplierName: supplier.name ?? supplier.supplierName, supplierCategory: null, + settlementMode: 'DIRECT_PAID', paidAt: new Date().toISOString(), paymentMethod: 'BANK_TRANSFER', + paymentReferenceNo: `${marker}-DIRECT-PAID`, paymentVoucherFileIds: [], + businessDate: today(), currency: 'CNY', amount: 17.31, + } +} + +export function findDirectPaidCost(items, body) { + return rows(items).find((item) => Number(item?.amount ?? item?.totalAmount ?? item?.costAmount ?? item?.payableAmount) === Number(body.amount) + && id(item?.customerId ?? item?.tenantId) === id(body.customerId) + && (item?.feeItemName === body.feeItemName + || id(item?.costNo ?? item?.settlementNo).startsWith('DIRECT-'))) +} + +export function findOpenSettlement(items) { + return rows(items).find((item) => settlementId(item) + && !['VOIDED', 'PAID'].includes(id(item?.status)) + && !['VOIDED', 'PAID'].includes(id(item?.paymentStatus))) ?? null +} + +async function ensureFixture(context) { + const fixture = context.createdResources?.financeFullFixtures + if (!fixture || fixture.marker !== context.marker || !fixture.customerId || !fixture.voucherFileId) return null + return fixture +} + +async function loadActiveSupplierAndWarehouse(context, fixture) { + const supplier = fixture.supplier || rows(fixture.suppliers)[0] + const warehouseResponse = await context.request('GET', + '/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&status=ACTIVE&warehouseType=OVERSEAS') + const warehousePage = context.apiSuccess(MODULE, '查询海外自营仓用于无入仓码已付费用', warehouseResponse, + diagnostic(warehouseResponse), OPTIONS) + const warehouse = rows(warehousePage).find((item) => id(item?.id ?? item?.warehouseId)) + if (!id(supplier?.id ?? supplier?.supplierId) || !(supplier?.name ?? supplier?.supplierName) || !warehouse) { + return { supplier, warehouse, response: warehouseResponse } + } + return { supplier, warehouse, response: warehouseResponse } +} + +async function publishDirectPaid(context, fixture, supplier, warehouse) { + const body = buildDirectPaidBody(context.marker, fixture, supplier, warehouse) + const createdResponse = await write(context, 'POST', OVERSEAS_FEES, body, 'overseas-create', context.overseasToken, true) + const created = context.apiSuccess(MODULE, '创建无入仓码海外仓 DIRECT_PAID 来源', createdResponse, + diagnostic(createdResponse), OPTIONS) + const targetId = sourceId(created?.sourceFeeId ? created : { feeSourceId: created?.sourceFeeId }) + || id(created?.sourceFeeId) + context.assert(MODULE, 'DIRECT_PAID 来源仅绑定客户、仓库与供应商', createdResponse, + Boolean(targetId) && created?.settlementMode === 'DIRECT_PAID' && created?.direction === 'PAYABLE' + && !created?.orderId && !created?.inboundId && !created?.inboundCode && !created?.containerId, + `sourceFeeId=${targetId || 'missing'}, settlementMode=${created?.settlementMode || 'missing'}`, OPTIONS) + if (!targetId) return null + + const replayResponse = await write(context, 'POST', OVERSEAS_FEES, body, 'overseas-create', context.overseasToken, true) + const replay = data(replayResponse) + context.assert(MODULE, '海外费用创建同键同参重放同一来源', replayResponse, + ok(replayResponse) && sourceId(replay?.sourceFeeId ? replay : { feeSourceId: replay?.sourceFeeId }) === targetId, + `first=${targetId}, replay=${id(replay?.sourceFeeId) || 'missing'}`, OPTIONS) + const conflictResponse = await write(context, 'POST', OVERSEAS_FEES, + { ...body, amount: body.amount + 1 }, 'overseas-create', context.overseasToken, true) + context.assert(MODULE, '海外费用创建同键异参冲突', conflictResponse, rejected(conflictResponse), + diagnostic(conflictResponse), OPTIONS) + + const submittedResponse = await context.requestOverseas('POST', `${OVERSEAS_FEES}/${targetId}/submit`, + { idempotencyKey: key(context, 'overseas-submit') }) + context.apiSuccess(MODULE, '提交 DIRECT_PAID 来源审核', submittedResponse, diagnostic(submittedResponse), OPTIONS) + const reviewedResponse = await context.requestOverseas('POST', `${OVERSEAS_FEES}/${targetId}/review`, + { decision: 'APPROVE', reason: context.marker, idempotencyKey: key(context, 'overseas-review') }) + context.apiSuccess(MODULE, '审核发布 DIRECT_PAID 来源', reviewedResponse, diagnostic(reviewedResponse), OPTIONS) + return { sourceFeeId: targetId, body } +} + +async function verifyDirectPaidProjection(context, fixture, published) { + let source = null + let costs = [] + let summary = null + for (let attempt = 0; attempt < 40; attempt += 1) { + const [sourcesResponse, costsResponse, summaryResponse] = await Promise.all([ + context.request('GET', `${FINANCE}/fee-sources?page=1&pageSize=200&customerId=${encodeURIComponent(fixture.customerId)}&direction=PAYABLE`), + context.request('GET', `${FINANCE}/cost-entries?customerId=${encodeURIComponent(fixture.customerId)}`), + context.request('GET', `${FINANCE}/customer-accounts/${encodeURIComponent(fixture.customerId)}/summary`), + ]) + source = rows(data(sourcesResponse)).find((item) => id(item?.sourceFeeId) === published.sourceFeeId + && item?.sourceSystem !== 'FIRSTLEG') + costs = rows(data(costsResponse)) + summary = data(summaryResponse) + if (source && findDirectPaidCost(costs, published.body)) break + await new Promise((resolve) => setTimeout(resolve, 500)) + } + const cost = findDirectPaidCost(costs, published.body) + context.assert(MODULE, 'DIRECT_PAID 生成 BUSINESS_DIRECT_PAID 成本并进入客户总成本和已付成本', null, + Boolean(source && cost) && Number(summary?.totalCost) >= published.body.amount + && Number(summary?.paidCost) >= published.body.amount, + `source=${sourceId(source) || 'missing'}, cost=${costId(cost) || 'missing'}, totalCost=${summary?.totalCost ?? 'missing'}, paidCost=${summary?.paidCost ?? 'missing'}`, OPTIONS) + const apPoolResponse = await context.request('GET', + `${FINANCE}/fee-pool?customerId=${encodeURIComponent(fixture.customerId)}&direction=PAYABLE`) + const apListResponse = await context.request('GET', + `${FINANCE}/payable-settlements?customerId=${encodeURIComponent(fixture.customerId)}`) + const inApPool = rows(data(apPoolResponse)).some((item) => id(item?.sourceFeeId) === published.sourceFeeId) + const inAp = rows(data(apListResponse)).some((item) => rows(item?.lines).some((line) => id(line?.sourceFeeId) === sourceId(source))) + context.assert(MODULE, 'DIRECT_PAID 不进入 AP 费用池或应付结算单', apPoolResponse, + !inApPool && !inAp, `inApPool=${inApPool}, inAp=${inAp}`, OPTIONS) + return { source, cost, summary } +} + +async function verifyPaymentIdempotency(context, fixture) { + const ar = findOpenSettlement(fixture.receivableSettlements) + const ap = findOpenSettlement(fixture.payableSettlements) + if (!ar || !ap) { + blocker(context, '取得未结清 AR/AP 用于收付款幂等', + `AR=${settlementId(ar) || 'missing'}, AP=${settlementId(ap) || 'missing'};需在 finance-settlement-full 消费前执行本模块`, null, + 'open-finance-settlement-unavailable') + return + } + const arDetailResponse = await context.request('GET', `${FINANCE}/settlements/${encodeURIComponent(settlementId(ar))}`) + const arDetail = context.apiSuccess(MODULE, '读取未结清 AR 详情用于收款幂等', arDetailResponse, + diagnostic(arDetailResponse), OPTIONS) + const receiptAmount = Math.min(0.01, outstanding(arDetail)) + const receiptBody = { customerId: id(fixture.customerId), receivedDate: today(), amount: receiptAmount, + currency: 'CNY', paymentMethod: 'BANK_TRANSFER', voucherFileIds: [id(fixture.voucherFileId)], + remark: context.marker, allocations: [{ billId: statementId(ar), amount: receiptAmount }] } + const receipt = await write(context, 'POST', `${FINANCE}/receipts`, receiptBody, 'receipt-replay') + const receiptReplay = await write(context, 'POST', `${FINANCE}/receipts`, receiptBody, 'receipt-replay') + const receiptConflict = await write(context, 'POST', `${FINANCE}/receipts`, + { ...receiptBody, remark: `${context.marker}-different` }, 'receipt-replay') + context.assert(MODULE, '收款同键同参重放且异参冲突', receiptReplay, + ok(receipt) && ok(receiptReplay) + && id(data(receipt)?.receiptId ?? data(receipt)?.id) === id(data(receiptReplay)?.receiptId ?? data(receiptReplay)?.id) + && rejected(receiptConflict), + `receipt=${id(data(receipt)?.receiptId ?? data(receipt)?.id) || 'missing'}; conflict=${diagnostic(receiptConflict)}`, OPTIONS) + + const apDetailResponse = await context.request('GET', `${FINANCE}/settlements/${encodeURIComponent(settlementId(ap))}`) + const apDetail = data(apDetailResponse) + const paymentAmount = Math.min(0.01, outstanding(apDetail)) + const paymentBody = { transactionDate: today(), amount: paymentAmount, currency: 'CNY', method: 'BANK_TRANSFER', + referenceNo: `${context.marker}-AP-IDEMPOTENT`, voucherFileIds: [id(fixture.voucherFileId)], + expectedVersion: version(apDetail), remark: context.marker } + const path = `${FINANCE}/settlements/${encodeURIComponent(settlementId(ap))}/transactions` + const payment = await write(context, 'POST', path, paymentBody, 'ap-replay') + const paymentReplay = await write(context, 'POST', path, paymentBody, 'ap-replay') + const paymentConflict = await write(context, 'POST', path, + { ...paymentBody, referenceNo: `${paymentBody.referenceNo}-different` }, 'ap-replay') + context.assert(MODULE, 'AP 付款同键同参重放且异参冲突', paymentReplay, + ok(payment) && ok(paymentReplay) && rejected(paymentConflict) + && version(data(paymentReplay)) === version(data(payment)), + `settlementId=${settlementId(ap)}, version=${version(data(paymentReplay))}; conflict=${diagnostic(paymentConflict)}`, OPTIONS) +} + +async function verifyFrozenSource(context, fixture) { + const mapped = rows(fixture.statements).flatMap((item) => rows(item?.feeSourceIds)).map(id).filter(Boolean) + const targetId = mapped[0] || rows(fixture.payableSettlements).flatMap((item) => rows(item?.feeSourceIds)).map(id).filter(Boolean)[0] + if (!targetId) { + blocker(context, '取得已进入有效单据的来源', 'finance reconciliation/settlement fixture 未提供 feeSourceId', null, + 'frozen-fee-source-unavailable') + return + } + const beforeResponse = await context.request('GET', `${FINANCE}/fee-sources/${encodeURIComponent(targetId)}`) + const before = data(beforeResponse) + const expectedVersion = version(before) + const edit = await write(context, 'PUT', `${FINANCE}/fee-sources/${targetId}`, + { amount: Number(before?.amount || 1) + 1, reason: context.marker, expectedVersion }, 'frozen-edit') + const adjust = await write(context, 'POST', `${FINANCE}/fee-sources/${targetId}/adjust`, + { amount: Number(before?.amount || 1) + 2, reason: context.marker, expectedVersion }, 'frozen-adjust') + const voided = await write(context, 'POST', `${FINANCE}/fee-sources/${targetId}/void`, + { reason: context.marker, expectedVersion }, 'frozen-void') + const afterResponse = await context.request('GET', `${FINANCE}/fee-sources/${encodeURIComponent(targetId)}`) + const after = data(afterResponse) + context.assert(MODULE, '已进入有效账单或结算的来源拒绝编辑、调整和作废且零漂移', afterResponse, + [edit, adjust, voided].every(rejected) && version(after) === expectedVersion + && Number(after?.amount) === Number(before?.amount) && after?.sourceStatus === before?.sourceStatus, + `source=${targetId}, beforeVersion=${expectedVersion}, afterVersion=${version(after)}; edit=${diagnostic(edit)}; adjust=${diagnostic(adjust)}; void=${diagnostic(voided)}`, OPTIONS) +} + +async function createSecondCustomer(context) { + const originalMarker = context.marker + const originalContext = context.context?.merchantQualificationFixture + const originalResource = context.createdResources?.merchantQualificationFixture + const originalUsername = process.env.MERCHANT_TEST_USERNAME + const originalPassword = process.env.MERCHANT_TEST_PASSWORD + context.marker = `${originalMarker}-FIN-ISO` + try { + const module = await import(new URL('./merchant-qualification-fixture.mjs', import.meta.url)) + await module.run(context) + const fixture = context.context?.merchantQualificationFixture + if (!fixture?.tenantId || !fixture?.username || !fixture?.password || fixture.marker !== context.marker) return null + const loginResponse = await context.request('POST', '/api/auth/login', + { username: fixture.username, password: fixture.password }, '') + const login = context.apiSuccess(MODULE, '第二隔离客户登录', loginResponse, diagnostic(loginResponse), OPTIONS) + return { customerId: id(fixture.tenantId), token: login?.accessToken } + } finally { + context.marker = originalMarker + if (originalContext) context.context.merchantQualificationFixture = originalContext + else delete context.context.merchantQualificationFixture + if (originalResource) context.createdResources.merchantQualificationFixture = originalResource + else delete context.createdResources.merchantQualificationFixture + if (originalUsername == null) delete process.env.MERCHANT_TEST_USERNAME + else process.env.MERCHANT_TEST_USERNAME = originalUsername + if (originalPassword == null) delete process.env.MERCHANT_TEST_PASSWORD + else process.env.MERCHANT_TEST_PASSWORD = originalPassword + } +} + +async function verifyCustomerIsolation(context, fixture) { + const second = await createSecondCustomer(context) + const target = rows(fixture.statements).find((item) => statementId(item)) + const apSource = rows(fixture.payableSettlements).flatMap((item) => rows(item?.feeSourceIds)).map(id).find(Boolean) + if (!second?.customerId || !second?.token || !target || !apSource) { + blocker(context, '建立第二隔离客户与跨客户单据 Fixture', + `customer=${second?.customerId || 'missing'}, token=${Boolean(second?.token)}, statement=${statementId(target) || 'missing'}, payableSource=${apSource || 'missing'}`, + null, 'second-customer-fixture-unavailable') + return + } + const targetId = statementId(target) + const read = await context.request('GET', `${CLIENT}/bills/${encodeURIComponent(targetId)}`, undefined, second.token) + const confirm = await context.request('POST', `${CLIENT}/statements/${encodeURIComponent(targetId)}/confirm`, + { expectedVersion: Number(target.version ?? 0) }, second.token, + { 'Idempotency-Key': key(context, 'foreign-confirm') }) + const receipt = await write(context, 'POST', `${FINANCE}/receipts`, { + customerId: second.customerId, receivedDate: today(), amount: 0.01, currency: 'CNY', + paymentMethod: 'BANK_TRANSFER', voucherFileIds: [id(fixture.voucherFileId)], remark: context.marker, + allocations: [{ billId: targetId, amount: 0.01 }], + }, 'foreign-receipt') + const payment = await write(context, 'POST', `${FINANCE}/payable-settlements`, { + customerId: second.customerId, supplierId: id(fixture.supplier?.id ?? fixture.supplier?.supplierId), + feeSourceIds: [apSource], + dueDate: today(), currency: 'CNY', + }, 'foreign-payable') + context.assert(MODULE, '第二客户不能读取或确认他人账单且平台不能跨客户分配收付款', confirm, + [read, confirm, receipt, payment].every(rejected), + `read=${diagnostic(read)}; confirm=${diagnostic(confirm)}; receipt=${diagnostic(receipt)}; payment=${diagnostic(payment)}`, OPTIONS) +} + +export async function run(context) { + const start = context.steps.length + try { + const fixture = await ensureFixture(context) + if (!fixture) { + blocker(context, '取得本轮 Finance 全链 Fixture', '缺少同 marker financeFullFixtures,禁止借历史财务单据') + return + } + const input = await loadActiveSupplierAndWarehouse(context, fixture) + if (!id(input.supplier?.id ?? input.supplier?.supplierId) + || !(input.supplier?.name ?? input.supplier?.supplierName) || !input.warehouse) { + blocker(context, '取得 DIRECT_PAID 供应商和海外仓', + `supplier=${id(input.supplier?.id ?? input.supplier?.supplierId) || 'missing'}, warehouse=${id(input.warehouse?.id ?? input.warehouse?.warehouseId) || 'missing'}`, + input.response, 'direct-paid-master-data-unavailable') + return + } + const published = await publishDirectPaid(context, fixture, input.supplier, input.warehouse) + if (!published) return + await verifyDirectPaidProjection(context, fixture, published) + await verifyPaymentIdempotency(context, fixture) + await verifyFrozenSource(context, fixture) + await verifyCustomerIsolation(context, fixture) + context.createdResources.financeDirectPaidIsolation = { + marker: context.marker, customerId: id(fixture.customerId), sourceFeeId: published.sourceFeeId, + businessNo: published.body.businessNo, + } + } finally { + finish(context, start) + } +} diff --git a/scripts/admin-test-env-modules/firstleg-container-fee-allocation-contract.test.mjs b/scripts/admin-test-env-modules/firstleg-container-fee-allocation-contract.test.mjs new file mode 100644 index 000000000..01473bc7d --- /dev/null +++ b/scripts/admin-test-env-modules/firstleg-container-fee-allocation-contract.test.mjs @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +import { + candidateLoadingPlanIds, + ownershipAllocations, + withoutInbound, +} from './firstleg-container-fee-allocation.mjs' + +const source = readFileSync(new URL('./firstleg-container-fee-allocation.mjs', import.meta.url), 'utf8') + +test('candidate plans only come from resources created by the current acceptance run', () => { + assert.deepEqual(candidateLoadingPlanIds({ + phase3ContainerLoading: { loadingPlanId: '101' }, + coloadGroupAcceptance: { fullPlanId: '102', planId: '103' }, + manualLoadingComplete: { loadingPlanId: '101' }, + financeFullFixtures: { feeSources: [{ loadingPlanId: '104' }] }, + }), ['101', '102', '103', '104']) + assert.doesNotMatch(source, /pageSize=\d+.*smart-loading|containers\?page|历史柜/) +}) + +test('allocation builder selects one real order per distinct customer and preserves exact cents', () => { + const allocations = ownershipAllocations([ + { accountId: '11', orderId: '21', orderNo: 'FL-21', inboundId: '31', inboundCode: 'IN-31' }, + { accountId: '11', orderId: '22', orderNo: 'FL-22', inboundId: '32', inboundCode: 'IN-32' }, + { accountId: '12', orderId: '23', orderNo: 'FL-23', inboundId: '33', inboundCode: 'IN-33' }, + { accountId: '13', orderId: '24', orderNo: 'FL-24', inboundId: '34', inboundCode: 'IN-34' }, + ], 100) + assert.equal(allocations.length, 3) + assert.equal(new Set(allocations.map((item) => item.customerId)).size, 3) + assert.equal(allocations.reduce((sum, item) => sum + Number(item.amount), 0), 100) + assert.deepEqual(withoutInbound(allocations[0]), { + customerId: '11', orderId: '21', orderNo: 'FL-21', amount: '33.33', + }) +}) + +test('module freezes all required real HTTP invariants and provisions a formal cross-customer fixture', async () => { + for (const token of [ + '/allocation-groups', '分摊金额合计不一致被拒绝', '分摊客户必填', '分摊订单必填', + '伪造非柜内订单归属被拒绝', '伪造非柜内入仓码被拒绝', '不填写入仓码原子创建跨客户分摊组', + '单行审核不能破坏分摊组一致性', '整组审核原子成功', '缺少组成员的调整被拒绝', + '整组调整原子成功', '整组作废原子成功', '已作废冻结分摊组拒绝继续调整', + '非柜内客户不能读取柜次费用', 'requestId=', 'traceId=', 'deploymentBlocker: true', + ]) assert.ok(source.includes(token), token) + for (const token of [ + 'createMerchant(context)', 'createCustomerWaitSplit', 'provisionCrossCustomerPlan', + 'originalUsername', 'originalPassword', 'delete process.env.MERCHANT_TEST_USERNAME', + 'delete process.env.MERCHANT_TEST_PASSWORD', + ]) assert.ok(source.includes(token), token) + assert.doesNotMatch(source, /\bfetch\s*\(|\/internal\/|mysql|SELECT\s/i) +}) diff --git a/scripts/admin-test-env-modules/firstleg-container-fee-allocation.mjs b/scripts/admin-test-env-modules/firstleg-container-fee-allocation.mjs new file mode 100644 index 000000000..86f22ab80 --- /dev/null +++ b/scripts/admin-test-env-modules/firstleg-container-fee-allocation.mjs @@ -0,0 +1,326 @@ +const MODULE = 'firstleg-container-fee-allocation' +const OPTIONS = { scenario: 'firstleg-container-fee-allocation-http', deploymentBlocker: true } + +import { run as createMerchant } from './merchant-qualification-fixture.mjs' + +function id(value) { return value == null ? '' : String(value) } +function rows(value) { return Array.isArray(value) ? value : Array.isArray(value?.records) ? value.records : [] } +function code(response) { return String(response?.body?.code ?? response?.http ?? '') } +function evidence(response, extra = '') { + return [`HTTP=${response?.http ?? 'missing'}`, `code=${code(response) || 'missing'}`, + `requestId=${response?.requestId || response?.body?.requestId || 'missing'}`, + `traceId=${response?.traceId || response?.body?.traceId || 'missing'}`, extra].filter(Boolean).join(', ') +} +function operationKey(context, suffix) { return `${context.marker}-container-fee-${suffix}`.slice(0, 128) } +function post(context, path, body, suffix) { + return context.request('POST', path, body, context.token, { 'Idempotency-Key': operationKey(context, suffix) }) +} +function blocker(context, name, detail) { + context.addStep(MODULE, name, null, false, detail, OPTIONS) +} +function expectBusinessFailure(context, name, response, expectedCodes = ['A0400', 'A0404', 'A0409', 'A0422']) { + context.addStep(MODULE, name, response, + response?.http >= 400 || (response?.http === 200 && expectedCodes.includes(code(response))), + evidence(response, `message=${response?.body?.message || 'missing'}`), OPTIONS) +} +function money(value) { return Number(Number(value || 0).toFixed(2)) } + +export function candidateLoadingPlanIds(createdResources = {}) { + const candidates = [ + createdResources.phase3ContainerLoading?.loadingPlanId, + createdResources.coloadGroupAcceptance?.fullPlanId, + createdResources.coloadGroupAcceptance?.planId, + createdResources.manualLoadingComplete?.loadingPlanId, + createdResources.phase3ContainerDocuments?.loadingPlanId, + ...rows(createdResources.financeFullFixtures?.feeSources).map((item) => item?.loadingPlanId), + ].map(id).filter((value) => /^[1-9]\d*$/.test(value)) + return [...new Set(candidates)] +} + +export function ownershipAllocations(planItems = [], totalAmount = 120) { + const unique = new Map() + for (const item of rows(planItems)) { + const customerId = id(item?.accountId ?? item?.customerId ?? item?.tenantId) + const orderId = id(item?.orderId) + const orderNo = String(item?.orderNo || '').trim() + const inboundId = id(item?.inboundId) + const inboundCode = String(item?.inboundCode || '').trim() + if (!customerId || !orderId || !orderNo || !inboundId || !inboundCode) continue + const key = `${customerId}|${orderId}|${inboundId}` + if (!unique.has(key)) unique.set(key, { customerId, orderId, orderNo, inboundId, inboundCode }) + } + const byCustomer = new Map() + for (const allocation of unique.values()) if (!byCustomer.has(allocation.customerId)) byCustomer.set(allocation.customerId, allocation) + const selected = [...byCustomer.values()] + if (selected.length < 2) return [] + const totalCents = Math.round(Number(totalAmount) * 100) + const base = Math.floor(totalCents / selected.length) + return selected.map((allocation, index) => ({ ...allocation, + amount: ((base + (index === selected.length - 1 ? totalCents - base * selected.length : 0)) / 100).toFixed(2) })) +} + +export function withoutInbound(allocation) { + const { inboundId: _inboundId, inboundCode: _inboundCode, ...required } = allocation + return required +} + +function groupRows(allRows, groupId) { + return allRows.filter((item) => id(item?.allocationGroupId) === id(groupId)) +} +function signature(group) { + return group.map((item) => [id(item?.id), id(item?.tenantId), item?.reviewStatus, + item?.sourceStatus, Number(item?.sourceVersion), money(item?.amount)].join(':')).sort().join('|') +} + +async function readOwnedRows(context, loadingPlanId, customerIds, label) { + const result = [] + for (const customerId of customerIds) { + const response = await context.request('GET', + `/api/platform/v1/loading-plans/${encodeURIComponent(loadingPlanId)}/fee-details?accountId=${encodeURIComponent(customerId)}`) + const data = context.apiSuccess(MODULE, `${label}:客户 ${customerId} 回读费用`, response, + evidence(response, `loadingPlanId=${loadingPlanId}, customerId=${customerId}`), OPTIONS) + const owned = rows(data) + context.assert(MODULE, `${label}:客户费用隔离`, response, + owned.every((item) => id(item?.tenantId) === customerId), + `customerId=${customerId}, returnedTenants=${[...new Set(owned.map((item) => id(item?.tenantId)))].join('|') || 'none'}`, OPTIONS) + result.push(...owned) + } + return result +} + +async function findCrossCustomerPlan(context) { + for (const loadingPlanId of candidateLoadingPlanIds(context.createdResources)) { + const response = await context.request('GET', + `/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(loadingPlanId)}`) + const plan = context.apiSuccess(MODULE, '回读本轮柜次费用分摊候选', response, + evidence(response, `loadingPlanId=${loadingPlanId}`), OPTIONS) + const allocations = ownershipAllocations(plan?.items, 120) + if (allocations.length >= 2) return { loadingPlanId, plan, allocations } + } + return null +} + +async function createCustomerWaitSplit(context, suffix, fixedRoute = null) { + const runMarker = context.marker + context.marker = `${runMarker}-${suffix}` + if (fixedRoute) context.context.waitSplitRoute = fixedRoute + await createMerchant(context) + const orders = await import(new URL(`./orders.mjs?allocation=${encodeURIComponent(suffix)}-${Date.now()}`, import.meta.url)) + const measurementModule = await import(new URL(`./inbound-measurement.mjs?allocation=${encodeURIComponent(suffix)}-${Date.now()}`, import.meta.url)) + await orders.run(context) + await measurementModule.run(context) + const order = structuredClone(context.createdResources.orders || {}) + const measurement = structuredClone(context.createdResources.inboundMeasurement || {}) + context.assert(MODULE, `${suffix}客户产生真实WAIT_SPLIT入仓单`, null, + Boolean(order.tenantId && order.orderId && order.inboundId && order.inboundCode + && measurement.processStatus === 'WAIT_SPLIT'), + `customerId=${order.tenantId || 'missing'}, inboundCode=${order.inboundCode || 'missing'}, status=${measurement.processStatus || 'missing'}`, + OPTIONS) + context.marker = runMarker + return { ...order, processStatus: measurement.processStatus } +} + +async function provisionCrossCustomerPlan(context) { + const originalMarker = context.marker + const originalUsername = process.env.MERCHANT_TEST_USERNAME + const originalPassword = process.env.MERCHANT_TEST_PASSWORD + const originalRoute = context.context.waitSplitRoute + const originalMerchant = context.context.merchantQualificationFixture + try { + const first = await createCustomerWaitSplit(context, 'ALLOC-A') + const route = { + originWarehouseName: first.receivingWarehouseName, + channelCode: first.channelCode, + destinationType: first.destinationType, + destinationWarehouseId: first.destinationWarehouseId, + taxModeIndex: 0, + } + const second = await createCustomerWaitSplit(context, 'ALLOC-B', route) + if (id(first.tenantId) === id(second.tenantId) + || id(first.receivingWarehouseId) !== id(second.receivingWarehouseId) + || first.channelCode !== second.channelCode) return null + const containerResponse = await context.request('POST', '/api/platform/v1/containers', { + channelCode: first.channelCode, warehouseId: id(first.receivingWarehouseId), + containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, + }) + const container = context.apiSuccess(MODULE, '创建跨客户费用分摊专用模拟柜', containerResponse, + evidence(containerResponse), OPTIONS) + const bookingId = id(container?.id) + if (!bookingId) return null + const planResponse = await context.request('GET', + `/api/platform/v1/first-leg/smart-loading/plans/by-booking/${encodeURIComponent(bookingId)}`) + let plan = context.apiSuccess(MODULE, '读取跨客户费用分摊专用计划', planResponse, + evidence(planResponse), OPTIONS) + const loadingPlanId = id(plan?.id) + if (!loadingPlanId) return null + for (const [index, item] of [first, second].entries()) { + const assigned = await context.request('POST', + `/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(loadingPlanId)}/inbound-assignments`, { + inboundId: id(item.inboundId), + packageCount: rows(item.skuLines).reduce((sum, line) => sum + Number(line.packageCount || 0), 0), + expectedVersion: Number(plan?.version), + operationId: operationKey(context, `fixture-assign-${index + 1}`), + }) + plan = context.apiSuccess(MODULE, `原子分配第${index + 1}个客户入仓单`, assigned, + evidence(assigned, `inboundCode=${item.inboundCode}`), OPTIONS) + if (!plan) return null + } + const allocations = ownershipAllocations(plan.items, 120) + return allocations.length >= 2 ? { loadingPlanId, plan, allocations } : null + } finally { + context.marker = originalMarker + context.context.waitSplitRoute = originalRoute + context.context.merchantQualificationFixture = originalMerchant + if (originalUsername === undefined) delete process.env.MERCHANT_TEST_USERNAME + else process.env.MERCHANT_TEST_USERNAME = originalUsername + if (originalPassword === undefined) delete process.env.MERCHANT_TEST_PASSWORD + else process.env.MERCHANT_TEST_PASSWORD = originalPassword + } +} + +export async function run(context) { + const start = context.steps.length + const fixture = await findCrossCustomerPlan(context) || await provisionCrossCustomerPlan(context) + if (!fixture) { + blocker(context, '取得本轮跨客户真实柜次', + `marker=${context.marker}, candidateLoadingPlanIds=${candidateLoadingPlanIds(context.createdResources).join('|') || 'none'};必须由本轮前置创建至少包含两个客户、真实订单和入仓码的柜次,禁止借用历史数据`) + context.createdResources.firstlegContainerFeeAllocation = { marker: context.marker, ready: false } + context.scenarios.push({ name: OPTIONS.scenario, module: MODULE, success: false, + deploymentBlocked: true, passed: context.steps.slice(start).filter((step) => step.passed).length, + failed: context.steps.slice(start).filter((step) => !step.passed).length }) + return + } + + const { loadingPlanId, allocations } = fixture + const customerIds = allocations.map((item) => item.customerId) + const feeItemsResponse = await context.request('GET', '/api/platform/v1/fee-items?page=1&pageSize=200') + const feeItems = context.apiSuccess(MODULE, '查询柜次应收费用项目', feeItemsResponse, + evidence(feeItemsResponse), OPTIONS) + const feeItem = rows(feeItems).find((item) => item?.status === 'ACTIVE' + && (item?.warehouseReceivable === true || item?.pickupReceivable === true)) + if (!feeItem?.id) { + blocker(context, '取得可用应收费用项目', '没有 ACTIVE 且启用仓储/提货应收的费用项目,禁止猜测 feeItemId') + return + } + + const base = `/api/platform/v1/loading-plans/${encodeURIComponent(loadingPlanId)}/fee-details` + const groupPath = `${base}/allocation-groups` + const fee = { feeItemId: id(feeItem.id), direction: 'RECEIVABLE', + businessDate: new Date().toISOString().slice(0, 10), amount: 120, currency: 'CNY', + remark: `${context.marker}-cross-customer-allocation` } + + const initial = await readOwnedRows(context, loadingPlanId, customerIds, '创建前') + const invalidTotal = await post(context, groupPath, + { fee, allocations: allocations.map((item, index) => ({ ...withoutInbound(item), amount: index === 0 ? '1.00' : item.amount })) }, 'invalid-total') + expectBusinessFailure(context, '分摊金额合计不一致被拒绝', invalidTotal, ['A0409']) + + const missingCustomer = await post(context, groupPath, + { fee, allocations: allocations.map((item, index) => index === 0 ? { ...withoutInbound(item), customerId: null } : withoutInbound(item)) }, 'missing-customer') + expectBusinessFailure(context, '分摊客户必填', missingCustomer, ['A0422']) + const missingOrder = await post(context, groupPath, + { fee, allocations: allocations.map((item, index) => index === 0 ? { ...withoutInbound(item), orderId: null } : withoutInbound(item)) }, 'missing-order') + expectBusinessFailure(context, '分摊订单必填', missingOrder, ['A0422']) + + const forgedOrder = allocations.map(withoutInbound) + forgedOrder[0] = { ...forgedOrder[0], orderId: '999999999999999999' } + const forgedOrderResponse = await post(context, groupPath, { fee, allocations: forgedOrder }, 'forged-order') + expectBusinessFailure(context, '伪造非柜内订单归属被拒绝', forgedOrderResponse, ['A0409']) + const forgedInbound = allocations.map((item) => ({ ...item })) + forgedInbound[0] = { ...forgedInbound[0], inboundCode: `${context.marker}-FORGED-INBOUND`.slice(0, 64) } + const forgedInboundResponse = await post(context, groupPath, { fee, allocations: forgedInbound }, 'forged-inbound') + expectBusinessFailure(context, '伪造非柜内入仓码被拒绝', forgedInboundResponse, ['A0409']) + const afterNegatives = await readOwnedRows(context, loadingPlanId, customerIds, '负向请求后') + context.assert(MODULE, '负向请求未产生部分分摊行', null, + signature(initial) === signature(afterNegatives), + `before=${signature(initial) || 'empty'}, after=${signature(afterNegatives) || 'empty'}`, OPTIONS) + + const createResponse = await post(context, groupPath, + { fee, allocations: allocations.map(withoutInbound) }, 'create') + const created = context.apiSuccess(MODULE, '不填写入仓码原子创建跨客户分摊组', createResponse, + evidence(createResponse, `loadingPlanId=${loadingPlanId}`), OPTIONS) + const createdRows = rows(created) + const groupId = id(createdRows[0]?.allocationGroupId) + context.assert(MODULE, '分摊组客户订单完整且未伪造入仓码', createResponse, + Boolean(groupId) && createdRows.length === allocations.length + && new Set(createdRows.map((item) => id(item?.tenantId))).size >= 2 + && createdRows.every((item) => item?.allocationInboundId == null && !item?.allocationInboundCode) + && money(createdRows.reduce((sum, item) => sum + Number(item?.amount || 0), 0)) === 120, + `groupId=${groupId || 'missing'}, rows=${createdRows.length}, customers=${new Set(createdRows.map((item) => id(item?.tenantId))).size}, total=${createdRows.reduce((sum, item) => sum + Number(item?.amount || 0), 0)}`, OPTIONS) + if (!groupId || createdRows.length < 2) return + + const first = createdRows[0] + const singleApprove = await post(context, + `${base}/${encodeURIComponent(id(first.id))}/approve?accountId=${encodeURIComponent(id(first.tenantId))}`, null, 'single-approve') + expectBusinessFailure(context, '单行审核不能破坏分摊组一致性', singleApprove, ['A0409']) + const beforeApprove = await readOwnedRows(context, loadingPlanId, customerIds, '单行审核拒绝后') + context.assert(MODULE, '单行审核拒绝后整组仍为待审核', singleApprove, + groupRows(beforeApprove, groupId).length === createdRows.length + && groupRows(beforeApprove, groupId).every((item) => item?.reviewStatus === 'PENDING'), + `group=${signature(groupRows(beforeApprove, groupId))}`, OPTIONS) + + const approveResponse = await post(context, `${groupPath}/${encodeURIComponent(groupId)}/approve`, null, 'approve') + const approved = rows(context.apiSuccess(MODULE, '整组审核原子成功', approveResponse, + evidence(approveResponse, `groupId=${groupId}`), OPTIONS)) + context.assert(MODULE, '整组审核后全部行已审核且版本一致', approveResponse, + approved.length === createdRows.length && approved.every((item) => item?.reviewStatus === 'APPROVED' + && item?.sourceStatus === 'ACTIVE' && Number(item?.sourceVersion) === 1), + `group=${signature(approved)}`, OPTIONS) + + const partialAdjustBody = { expectedSourceVersion: 1, businessDate: fee.businessDate, totalAmount: 120, + allocations: [{ feeId: id(approved[0]?.id), amount: 120 }] } + const partialAdjust = await post(context, `${groupPath}/${encodeURIComponent(groupId)}/adjust`, partialAdjustBody, 'partial-adjust') + expectBusinessFailure(context, '缺少组成员的调整被拒绝', partialAdjust, ['A0409']) + const afterPartialAdjust = groupRows(await readOwnedRows(context, loadingPlanId, customerIds, '部分调整拒绝后'), groupId) + context.assert(MODULE, '部分调整失败后整组零漂移', partialAdjust, + signature(afterPartialAdjust) === signature(approved), + `approved=${signature(approved)}, after=${signature(afterPartialAdjust)}`, OPTIONS) + + const adjustedAmounts = approved.map((item, index) => ({ feeId: id(item.id), + amount: index === 0 ? '70.00' : index === 1 ? '50.00' : '0.01' })) + if (adjustedAmounts.length > 2) { + const overflow = (adjustedAmounts.length - 2) * 0.01 + adjustedAmounts[1].amount = (50 - overflow).toFixed(2) + } + const adjustResponse = await post(context, `${groupPath}/${encodeURIComponent(groupId)}/adjust`, + { expectedSourceVersion: 1, businessDate: fee.businessDate, totalAmount: 120, allocations: adjustedAmounts }, 'adjust') + const adjusted = rows(context.apiSuccess(MODULE, '整组调整原子成功', adjustResponse, + evidence(adjustResponse, `groupId=${groupId}`), OPTIONS)) + context.assert(MODULE, '整组调整后全部行统一升版并待复核', adjustResponse, + adjusted.length === approved.length && adjusted.every((item) => Number(item?.sourceVersion) === 2 + && item?.reviewStatus === 'PENDING' && item?.sourceStatus === 'ACTIVE') + && money(adjusted.reduce((sum, item) => sum + Number(item?.amount || 0), 0)) === 120, + `group=${signature(adjusted)}`, OPTIONS) + + const reapproveResponse = await post(context, `${groupPath}/${encodeURIComponent(groupId)}/approve`, null, 'reapprove') + const reapproved = rows(context.apiSuccess(MODULE, '调整后整组复核原子成功', reapproveResponse, + evidence(reapproveResponse, `groupId=${groupId}`), OPTIONS)) + const voidResponse = await post(context, `${groupPath}/${encodeURIComponent(groupId)}/void`, + { expectedSourceVersion: 2 }, 'void') + context.apiSuccess(MODULE, '整组作废原子成功', voidResponse, evidence(voidResponse, `groupId=${groupId}`), OPTIONS) + const voided = groupRows(await readOwnedRows(context, loadingPlanId, customerIds, '整组作废后'), groupId) + context.assert(MODULE, '整组作废后全部行冻结且无活动残留', voidResponse, + reapproved.length === createdRows.length && voided.length === createdRows.length + && voided.every((item) => item?.sourceStatus === 'VOIDED' && Number(item?.sourceVersion) === 2), + `group=${signature(voided)}`, OPTIONS) + + const frozenAdjust = await post(context, `${groupPath}/${encodeURIComponent(groupId)}/adjust`, + { expectedSourceVersion: 2, businessDate: fee.businessDate, totalAmount: 120, allocations: adjustedAmounts }, 'frozen-adjust') + expectBusinessFailure(context, '已作废冻结分摊组拒绝继续调整', frozenAdjust, ['A0409']) + const afterFrozen = groupRows(await readOwnedRows(context, loadingPlanId, customerIds, '冻结调整拒绝后'), groupId) + context.assert(MODULE, '冻结门禁拒绝后整组零漂移', frozenAdjust, + signature(afterFrozen) === signature(voided), + `voided=${signature(voided)}, after=${signature(afterFrozen)}`, OPTIONS) + + const foreignResponse = await context.request('GET', + `${base}?accountId=999999999999999999`) + expectBusinessFailure(context, '非柜内客户不能读取柜次费用', foreignResponse, ['A0404']) + + context.createdResources.firstlegContainerFeeAllocation = { + marker: context.marker, ready: true, loadingPlanId, groupId, + customerIds, feeIds: createdRows.map((item) => id(item.id)), finalStatus: 'VOIDED', + } + 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/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 0c6973665..c4dd150db 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -830,12 +830,16 @@ export async function run(context) { if (refreshedInventory?.snapshotToken) { effectiveBatchPayload = { ...batchPayload, inventorySnapshotToken: refreshedInventory.snapshotToken } } - } else { + } else if (source?.requireInventoryMutation !== false) { context.addStep(MODULE, '自动制造库存变化并验证旧 snapshot token A0409', null, false, `本轮 ${inventoryInbound?.resources?.length ?? 0} 条占用没有可安全换绑的 A0 临时占用;不得以 coverageGap 作为通过`, OPTIONS) context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 真实库存变化后的旧快照冲突与刷新重试', `inventory-snapshot 已真实返回 taskId=${canonicalTaskId}、warehouseId=${warehouseId} 并验证稳定 token/version=${inventorySnapshotVerified};本轮 ${inventoryInbound?.resources?.length ?? 0} 条占用均为普通库位,当前唯一平台代理库存变更入口只允许 A0 临时占用换绑,不能安全改动普通占用或调用内部 Warehouse 接口`, 'fixture') + } else { + context.addStep(MODULE, '非快照专项装柜链不重复制造 A0 库存变化', null, true, + '当前模块作为四期真实业务前置,仅验证正常装柜链;旧 snapshot token A0409 由独立 phase3-loading 专项覆盖', + { scenario: OPTIONS.scenario }) } const canonicalPick = await request(context, 'POST', batchPath, effectiveBatchPayload, @@ -1504,6 +1508,7 @@ export async function run(context) { atomicBatchVerified: awaitingReview && atomicBatchReplayVerified, inventorySnapshotVerified: inventorySnapshotVerified && atomicBatchReplayVerified, inventoryMutationVerified, inventoryMutationCleanup, + inventoryMutationRequired: source?.requireInventoryMutation !== false, skipBatchVerified, partialBatchVerified, dynamicIncrementVerified, expectedBatchOperationIds, incrementalInboundId, pdaTaskVersion: Number(stagedTask?.version), diff --git a/scripts/admin-test-env-modules/phase3-pda-convergence.mjs b/scripts/admin-test-env-modules/phase3-pda-convergence.mjs index 937804c87..9657fa6dc 100644 --- a/scripts/admin-test-env-modules/phase3-pda-convergence.mjs +++ b/scripts/admin-test-env-modules/phase3-pda-convergence.mjs @@ -123,13 +123,17 @@ export async function run(context) { 'inventory-snapshot 端点已公开,但本轮未完成归属、稳定 token/version、数量及合法 batch 的联合真实 HTTP 验证', 'deployment-blocker') } - if (fixture?.inventoryMutationVerified !== true + if (fixture?.inventoryMutationRequired !== false && fixture?.inventoryMutationVerified !== true && !context.coverageGaps.some((gap) => gap.name === 'PDA 真实库存变化后的旧快照冲突与刷新重试')) { context.addStep(MODULE, 'PDA 真实库存变化后旧 token A0409 与刷新重试', null, false, '本轮未取得 inventoryMutationVerified=true;无法安全制造库存变化时必须作为部署阻断失败', OPTIONS) context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 真实库存变化后的旧快照冲突与刷新重试', 'inventory-snapshot 已公开;本轮没有可通过平台代理安全变更的 A0 临时占用,不能用普通库位改写或内部 Warehouse 接口伪造库存变化', 'fixture') + } else if (fixture?.inventoryMutationRequired === false) { + context.addStep(MODULE, '依赖链不重复执行 A0 旧快照破坏性专项', null, true, + '正常库存快照、原子批次和幂等已验证;A0 换绑与旧 token A0409 仅在显式 phase3-loading 专项运行', + { scenario: OPTIONS.scenario }) } if (fixture?.dynamicIncrementVerified !== true) { context.addStep(MODULE, 'LOADING 增量重开、刷新续拣与已拣不可删减', null, false, diff --git a/scripts/admin-test-env-modules/phase4-overseas-business.mjs b/scripts/admin-test-env-modules/phase4-overseas-business.mjs index 41fd98413..5aea13b26 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-business.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-business.mjs @@ -105,7 +105,20 @@ async function loginMerchant(context, activeCustomer) { async function createMerchantProcurement(context, activeCustomer, overseasToken) { const path = '/api/merchant/v1/last-leg/overseas-procurements' const payload = procurementPayload(context, activeCustomer) - const response = await context.requestOverseas('POST', path, payload, {}, overseasToken) + let activeToken = overseasToken + let response = await context.requestOverseas('POST', path, payload, {}, activeToken) + // A newly approved customer is projected to the overseas region asynchronously. The + // domestic login can therefore succeed a few seconds before the overseas gateway has + // received the matching USER/TENANT/AUTHORIZATION versions. Retry only that exact + // security-projection conflict and always issue a fresh short-lived overseas token. + for (let attempt = 1; attempt <= 12 + && response?.http === 403 && codeOf(response) === 'A0403'; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 1_000)) + const refreshedToken = await loginMerchant(context, activeCustomer) + if (!refreshedToken) break + activeToken = refreshedToken + response = await context.requestOverseas('POST', path, payload, {}, activeToken) + } const detail = context.apiSuccess(MODULE, '创建本轮客户采购订单', response, trace(response), OPTIONS) const value = summary(detail) const resource = nonEmpty(value?.orderNo) && longId(value?.customerId) @@ -119,7 +132,7 @@ async function createMerchantProcurement(context, activeCustomer, overseasToken) `orderNo=${id(value?.orderNo) || 'missing'}, customerId=${id(value?.customerId) || 'missing'}, status=${value?.status || 'missing'}, version=${value?.version ?? 'missing'}; ${trace(response)}`, OPTIONS) if (!valid) return null - const replayResponse = await context.requestOverseas('POST', path, payload, {}, overseasToken) + const replayResponse = await context.requestOverseas('POST', path, payload, {}, activeToken) const replayDetail = context.apiSuccess(MODULE, '采购创建同幂等键同载荷精确重放', replayResponse, trace(replayResponse), OPTIONS) const replay = summary(replayDetail) context.assert(MODULE, '采购幂等重放复用订单且版本不漂移', replayResponse, @@ -127,7 +140,7 @@ async function createMerchantProcurement(context, activeCustomer, overseasToken) `orderNo=${id(replay?.orderNo) || 'missing'}, version=${replay?.version ?? 'missing'}; ${trace(replayResponse)}`, OPTIONS) const mismatch = await context.requestOverseas('POST', path, - { ...payload, productName: `${payload.productName}-DIFFERENT` }, {}, overseasToken) + { ...payload, productName: `${payload.productName}-DIFFERENT` }, {}, activeToken) expectedConflict(context, '采购创建同幂等键异载荷返回冲突', mismatch) return { resource, payload } diff --git a/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region-contract.test.mjs new file mode 100644 index 000000000..62f5c25fb --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region-contract.test.mjs @@ -0,0 +1,163 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +import { run } from './phase4-overseas-finance-cross-region.mjs' + +const source = readFileSync(fileURLToPath(new URL('./phase4-overseas-finance-cross-region.mjs', import.meta.url)), 'utf8') + +function response(data, code = '200', http = 200, region) { + return { + http, + body: { code, data: region ? { status: '运行中', region } : data }, + requestId: `req-${code}-${http}`, + traceId: `trace-${code}-${http}`, + } +} + +function fixture() { + const marker = 'ACC-P4-FIN-XREGION' + const calls = [] + const steps = [] + const commands = new Map() + const fees = new Map() + let sequence = 7000 + const businessRows = { + '/api/platform/v1/last-leg/overseas-procurements': { id: '3001', orderNo: 'PO-1', customerId: '1001' }, + '/api/platform/v1/last-leg/us-truck-orders': { id: '3002', orderNo: 'TK-1', customerId: '1001' }, + '/api/platform/v1/last-leg/local-looseboard-orders': { id: '3003', orderNo: 'LB-1', customerId: '1001' }, + } + const context = { + marker, + steps, + scenarios: [], + calls, + createdResources: { + merchantQualificationFixture: { tenantId: '1001' }, + phase4OverseasBusiness: { + purchase: { orderNo: 'PO-1', customerId: '1001' }, + truck: { orderNo: 'TK-1', customerId: '1001' }, + looseboard: { orderNo: 'LB-1', customerId: '1001' }, + }, + phase4OverseasPda: { customerId: '2001', overseasWarehouseId: '9001' }, + }, + async request(method, path, body) { + calls.push({ region: 'CN', method, path, body }) + if (path === '/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE') { + return response({ records: [{ id: '1001', tenantName: '客户一' }, { id: '1002', tenantName: '客户二' }] }) + } + if (path === '/internal/event-bridge/status') return response(null, '200', 200, 'CN') + if (path.startsWith('/api/platform/finance/v1/fee-sources?')) { + const query = new URLSearchParams(path.split('?')[1]) + const customerId = query.get('customerId') + const keyword = query.get('keyword') + const rows = [...fees.values()].filter((fee) => fee.customerId === customerId + && (fee.inboundCode || fee.businessNo) === keyword) + .filter((fee) => fee.reviewStatus === 'APPROVED') + .map((fee) => ({ feeSourceId: String(Number(fee.sourceFeeId) + 10000), sourceSystem: 'LASTLEG', + sourceType: fee.sourceType, sourceFeeId: fee.sourceFeeId, customerId: fee.customerId })) + return response(rows) + } + throw new Error(`unexpected CN request ${method} ${path}`) + }, + async requestOverseas(method, path, body, headers = {}) { + calls.push({ region: 'OVERSEAS', method, path, body, headers }) + if (path === '/internal/event-bridge/status') return response(null, '200', 200, 'OVERSEAS') + for (const [base, row] of Object.entries(businessRows)) { + if (method === 'GET' && path.startsWith(`${base}?`)) return response({ records: [row] }) + } + if (method === 'GET' && path.startsWith('/api/platform/v1/last-leg/inbound-plans?')) { + return response({ records: [{ planId: '4001', planNo: 'IP-1', customerId: '2001', inboundId: '4101', inboundCode: 'IN-1' }] }) + } + if (method === 'GET' && path.startsWith('/api/platform/v1/last-leg/finance-fee-sources?')) return response([]) + if (method === 'POST' && path === '/api/platform/v1/last-leg/finance-fee-sources') { + const key = headers['Idempotency-Key'] + const hash = JSON.stringify(body) + const existing = commands.get(`CREATE:${key}`) + if (existing) return existing.hash === hash ? response(existing.fee) : response(null, 'A0409') + const expectedCustomer = body.businessId === '4001' ? '2001' + : ['3001', '3002', '3003'].includes(String(body.businessId)) ? '1001' : body.customerId + if (body.businessId && body.customerId !== expectedCustomer) return response(null, 'A0400') + const fee = { ...structuredClone(body), sourceFeeId: String(++sequence), sourceVersion: 1, + reviewStatus: 'DRAFT', publicationStatus: 'UNPUBLISHED', sourceStatus: 'ACTIVE' } + commands.set(`CREATE:${key}`, { hash, fee }) + fees.set(fee.sourceFeeId, fee) + return response(fee) + } + const action = path.match(/finance-fee-sources\/(\d+)\/(submit|review)$/) + if (method === 'POST' && action) { + const fee = fees.get(action[1]) + const key = body.idempotencyKey + const scope = `${action[2].toUpperCase()}:${key}` + const hash = JSON.stringify(body) + const existing = commands.get(scope) + if (existing) return existing.hash === hash ? response(existing.fee) : response(null, 'A0409') + if (action[2] === 'submit') fee.reviewStatus = 'PENDING' + else { fee.reviewStatus = 'APPROVED'; fee.publicationStatus = 'PUBLISHED' } + commands.set(scope, { hash, fee: structuredClone(fee) }) + return response(fee) + } + throw new Error(`unexpected OVERSEAS request ${method} ${path}`) + }, + apiSuccess(module, name, result, evidence, options) { + const passed = result.http === 200 && String(result.body?.code) === '200' + this.addStep(module, name, result, passed, evidence, options) + return passed ? result.body.data : null + }, + assert(module, name, result, passed, evidence, options) { + this.addStep(module, name, result, Boolean(passed), evidence, options) + }, + addStep(module, name, result, passed, evidence, options = {}) { + steps.push({ module, name, result, passed: Boolean(passed), evidence, + deploymentBlocker: Boolean(options.deploymentBlocker) }) + }, + } + return context +} + +test('module uses only injected real HTTP clients and never fabricates database/event success', () => { + assert.match(source, /export async function run\(context\)/) + assert.doesNotMatch(source, /process\.env|https?:\/\/|fetch\(|mysql|SELECT\s|INSERT\s|UPDATE\s/i) + assert.doesNotMatch(source, /admin-test-env-acceptance|README\.md/) + for (const token of [ + 'OVERSEAS_INBOUND_FEE', 'OVERSEAS_PROCUREMENT_FEE', 'OVERSEAS_TRUCK_FEE', + 'OVERSEAS_LOOSEBOARD_FEE', 'OVERSEAS_WAREHOUSE_FEE', + '/api/platform/v1/last-leg/finance-fee-sources', '/api/platform/finance/v1/fee-sources', + '/internal/event-bridge/status', + ]) assert.ok(source.includes(token), token) +}) + +test('five fee types traverse Lastleg lifecycle and reach Finance through real HTTP', async () => { + const context = fixture() + await run(context) + const registry = context.createdResources.phase4OverseasFinanceCrossRegion + assert.equal(registry.sources.length, 5) + assert.deepEqual(new Set(registry.sources.map((item) => item.businessType)), new Set([ + 'OVERSEAS_INBOUND', 'OVERSEAS_PROCUREMENT', 'US_TRUCK', 'LOCAL_LOOSEBOARD', 'WAREHOUSE_SERVICE', + ])) + const warehouseCreate = context.calls.find((call) => call.method === 'POST' + && call.path === '/api/platform/v1/last-leg/finance-fee-sources' + && call.body?.businessType === 'WAREHOUSE_SERVICE') + assert.ok(warehouseCreate) + for (const field of ['businessId', 'orderId', 'inboundId', 'inboundCode', 'containerId', 'containerBookingId', 'containerNo']) { + assert.equal(warehouseCreate.body[field], null, field) + } + assert.equal(warehouseCreate.body.customerId, '1001') + assert.equal(warehouseCreate.body.warehouseId, '9001') + + const creates = context.calls.filter((call) => call.method === 'POST' + && call.path === '/api/platform/v1/last-leg/finance-fee-sources') + assert.equal(creates.length, 12) + assert.ok(context.steps.find((step) => step.name === '费用创建同键异载荷冲突')?.passed) + assert.ok(context.steps.find((step) => step.name === '海外费用列表不会泄露其他客户来源')?.passed) + assert.ok(context.steps.find((step) => step.name === '其他客户不能关联本客户业务创建费用')?.passed) + assert.equal(context.steps.filter((step) => step.name.endsWith('费用经跨区链进入 Finance')).length, 5) + assert.ok(context.steps.filter((step) => step.name.endsWith('费用经跨区链进入 Finance')).every((step) => step.passed)) + assert.ok(context.steps.find((step) => step.name === '国内 Event Bridge HTTP 能力状态可观察')?.passed) + assert.ok(context.steps.find((step) => step.name === '海外 Event Bridge HTTP 能力状态可观察')?.passed) + + assert.deepEqual(context.steps.filter((step) => !step.passed), []) + assert.equal(context.scenarios[0].deploymentBlocked, false) + assert.equal(context.scenarios[0].success, true) +}) diff --git a/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region.mjs b/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region.mjs new file mode 100644 index 000000000..f8ee7e451 --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region.mjs @@ -0,0 +1,262 @@ +const MODULE = 'phase4-overseas-finance-cross-region' +const SCENARIO = 'phase4-overseas-finance-cross-region-http' +const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } +const LASTLEG_BASE = '/api/platform/v1/last-leg/finance-fee-sources' +const FINANCE_BASE = '/api/platform/finance/v1/fee-sources' + +const id = (value) => value == null ? '' : String(value) +const codeOf = (response) => String(response?.body?.code ?? 'missing') +const success = (response) => response?.http === 200 && codeOf(response) === '200' +const conflict = (response) => response?.http === 409 + || (response?.http === 200 && ['A0409', '409', 'FIN-IDEM-409'].includes(codeOf(response))) +const rejected = (response) => [400, 403, 404, 409].includes(response?.http) + || (response?.http === 200 && codeOf(response) !== '200') +const longId = (value) => /^[1-9]\d*$/.test(id(value)) +const rows = (data) => Array.isArray(data?.records) ? data.records : Array.isArray(data) ? data : [] +const trace = (response, extra = '') => `${extra}${extra ? '; ' : ''}HTTP=${response?.http ?? 'missing'}, code=${codeOf(response)}, requestId=${response?.requestId || response?.body?.requestId || 'missing'}, traceId=${response?.traceId || response?.body?.traceId || 'missing'}` + +function blocker(context, name, reason, response = null) { + context.addStep(MODULE, name, response, false, `${reason}; ${trace(response)}`, OPTIONS) +} + +async function overseas(context, method, name, path, body, headers = {}) { + const response = await context.requestOverseas(method, path, body, headers) + const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) + return { response, data } +} + +async function domestic(context, method, name, path, body) { + const response = await context.request(method, path, body) + const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) + return { response, data } +} + +async function poll(action, predicate, attempts = 30) { + let result = null + for (let index = 0; index < attempts; index += 1) { + result = await action() + if (predicate(result)) return { result, attempts: index + 1 } + if (index + 1 < attempts) await new Promise((resolve) => setTimeout(resolve, 1_000)) + } + return { result, attempts } +} + +async function resolveListedBusiness(context, base, resource, customerId, label) { + if (!resource?.orderNo || !customerId) { + blocker(context, `${label}费用业务前置`, `缺少本轮 ${label} orderNo/customerId,禁止借用历史业务`) + return null + } + const path = `${base}?page=1&size=200&customerId=${encodeURIComponent(customerId)}&orderNo=${encodeURIComponent(resource.orderNo)}` + const listed = await overseas(context, 'GET', `回查本轮${label}业务`, path) + const row = rows(listed.data).find((item) => id(item?.orderNo) === id(resource.orderNo) + && id(item?.customerId) === id(customerId)) + context.assert(MODULE, `${label}费用关联真实业务记录`, listed.response, + longId(row?.id) && Boolean(row?.orderNo), + `businessId=${id(row?.id) || 'missing'}, businessNo=${row?.orderNo || 'missing'}; ${trace(listed.response)}`, OPTIONS) + return longId(row?.id) ? { businessId: id(row.id), businessNo: row.orderNo, customerId } : null +} + +async function resolveFixtures(context) { + const business = context.createdResources?.phase4OverseasBusiness + const merchantCustomerId = id(context.createdResources?.merchantQualificationFixture?.tenantId + || business?.purchase?.customerId || business?.truck?.customerId || business?.looseboard?.customerId) + const procurement = await resolveListedBusiness(context, + '/api/platform/v1/last-leg/overseas-procurements', business?.purchase, merchantCustomerId, '海外采购') + const truck = await resolveListedBusiness(context, + '/api/platform/v1/last-leg/us-truck-orders', business?.truck, merchantCustomerId, '美国卡车') + const looseboard = await resolveListedBusiness(context, + '/api/platform/v1/last-leg/local-looseboard-orders', business?.looseboard, merchantCustomerId, '本土散板') + + const pda = context.createdResources?.phase4OverseasPda + const inboundCustomerId = id(pda?.customerId) + let inbound = null + if (longId(inboundCustomerId)) { + const listed = await overseas(context, 'GET', '查询本轮客户海外入仓计划', + `/api/platform/v1/last-leg/inbound-plans?page=1&size=200&customerId=${encodeURIComponent(inboundCustomerId)}`) + const plan = rows(listed.data).find((item) => longId(item?.planId || item?.id) && item?.planNo) + context.assert(MODULE, '海外入仓费用关联真实计划', listed.response, + Boolean(plan) && longId(plan?.planId || plan?.id), + `planId=${id(plan?.planId || plan?.id) || 'missing'}, planNo=${plan?.planNo || 'missing'}; ${trace(listed.response)}`, OPTIONS) + if (plan) inbound = { + businessId: id(plan.planId || plan.id), businessNo: plan.planNo, customerId: inboundCustomerId, + inboundId: longId(plan.inboundId) ? id(plan.inboundId) : null, + inboundCode: plan.inboundCode || null, + } + } else blocker(context, '海外入仓费用业务前置', '缺少本轮海外 PDA 客户身份,无法安全选择真实入仓计划') + + const warehouseId = id(pda?.overseasWarehouseId || context.createdResources?.phase4OverseasWarehouse?.warehouseId) + const warehouse = longId(merchantCustomerId) && longId(warehouseId) + ? { businessId: null, businessNo: `${context.marker}-WAREHOUSE`, customerId: merchantCustomerId, warehouseId } + : null + if (!warehouse) blocker(context, '海外仓服务费业务前置', '缺少本轮客户或真实海外仓 ID,禁止伪造仓库服务费') + return { merchantCustomerId, inbound, procurement, truck, looseboard, warehouse } +} + +function feePayload(context, definition, fixture, index) { + return { + customerId: fixture.customerId, + customerName: `${context.marker}-客户`, + sourceType: definition.sourceType, + businessType: definition.businessType, + businessId: fixture.businessId, + businessNo: fixture.businessNo, + orderId: null, + inboundId: fixture.inboundId || null, + inboundCode: fixture.inboundCode || null, + containerId: null, + containerBookingId: null, + containerNo: null, + warehouseId: fixture.warehouseId || null, + feeItemCode: `P4-${definition.businessType}`.slice(0, 64), + feeItemName: `${definition.label}验收费用`, + direction: 'RECEIVABLE', + supplierId: null, + supplierName: null, + supplierCategory: null, + settlementMode: null, + paidAt: null, + paymentMethod: null, + paymentReferenceNo: null, + paymentVoucherFileIds: [], + businessDate: new Date().toISOString().slice(0, 10), + currency: 'CNY', + amount: Number((101 + index + 0.25).toFixed(2)), + } +} + +async function createApprove(context, definition, fixture, index) { + if (!fixture) return null + const payload = feePayload(context, definition, fixture, index) + const key = `${context.marker}-${definition.businessType}-fee-create`.slice(0, 128) + const created = await overseas(context, 'POST', `创建${definition.label}费用草稿`, LASTLEG_BASE, + payload, { 'Idempotency-Key': key }) + const sourceFeeId = id(created.data?.sourceFeeId) + context.assert(MODULE, `${definition.label}费用草稿字段完整`, created.response, + longId(sourceFeeId) && created.data?.reviewStatus === 'DRAFT' + && id(created.data?.customerId) === id(fixture.customerId) + && created.data?.businessType === definition.businessType, + `sourceFeeId=${sourceFeeId || 'missing'}, status=${created.data?.reviewStatus || 'missing'}; ${trace(created.response)}`, OPTIONS) + if (!longId(sourceFeeId)) return null + + const replay = await overseas(context, 'POST', `${definition.label}费用创建同参重放`, LASTLEG_BASE, + payload, { 'Idempotency-Key': key }) + context.assert(MODULE, `${definition.label}创建重放复用费用且版本不漂移`, replay.response, + id(replay.data?.sourceFeeId) === sourceFeeId && Number(replay.data?.sourceVersion) === 1, + trace(replay.response), OPTIONS) + if (index === 0) { + const mismatch = await context.requestOverseas('POST', LASTLEG_BASE, + { ...payload, amount: payload.amount + 1 }, { 'Idempotency-Key': key }) + context.addStep(MODULE, '费用创建同键异载荷冲突', mismatch, conflict(mismatch), trace(mismatch), OPTIONS) + } + + const submitKey = `${context.marker}-${definition.businessType}-fee-submit`.slice(0, 128) + const submitted = await overseas(context, 'POST', `提交${definition.label}费用`, + `${LASTLEG_BASE}/${encodeURIComponent(sourceFeeId)}/submit`, { idempotencyKey: submitKey }) + context.assert(MODULE, `${definition.label}费用进入待审核`, submitted.response, + submitted.data?.reviewStatus === 'PENDING', trace(submitted.response), OPTIONS) + const reviewBody = { decision: 'APPROVE', reason: context.marker, + idempotencyKey: `${context.marker}-${definition.businessType}-fee-review`.slice(0, 128) } + const approved = await overseas(context, 'POST', `审核通过${definition.label}费用`, + `${LASTLEG_BASE}/${encodeURIComponent(sourceFeeId)}/review`, reviewBody) + context.assert(MODULE, `${definition.label}费用已发布并触发 Outbox`, approved.response, + approved.data?.reviewStatus === 'APPROVED' && approved.data?.publicationStatus === 'PUBLISHED', + trace(approved.response), OPTIONS) + const reviewReplay = await overseas(context, 'POST', `${definition.label}审核同参重放`, + `${LASTLEG_BASE}/${encodeURIComponent(sourceFeeId)}/review`, reviewBody) + context.assert(MODULE, `${definition.label}审核重放保持发布版本`, reviewReplay.response, + id(reviewReplay.data?.sourceFeeId) === sourceFeeId + && Number(reviewReplay.data?.sourceVersion) === Number(approved.data?.sourceVersion), + trace(reviewReplay.response), OPTIONS) + return { sourceFeeId, customerId: fixture.customerId, definition, payload } +} + +async function verifyCustomerIsolation(context, fixtures, source) { + if (!source || !fixtures.merchantCustomerId) return + const tenants = await domestic(context, 'GET', '查询第二个 ACTIVE 客户用于隔离反证', + '/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE') + const other = rows(tenants.data).find((item) => longId(item?.id) && id(item.id) !== id(source.customerId)) + if (!other) { + blocker(context, '费用客户隔离反证前置', '没有第二个 ACTIVE 客户,无法验证跨客户查询与业务归属拒绝', tenants.response) + return + } + const wrongList = await overseas(context, 'GET', '其他客户查询海外费用来源', + `${LASTLEG_BASE}?customerId=${encodeURIComponent(id(other.id))}&businessNo=${encodeURIComponent(source.payload.businessNo)}`) + context.assert(MODULE, '海外费用列表不会泄露其他客户来源', wrongList.response, + !rows(wrongList.data).some((item) => id(item?.sourceFeeId) === source.sourceFeeId), + `otherCustomerId=${id(other.id)}, leaked=${rows(wrongList.data).some((item) => id(item?.sourceFeeId) === source.sourceFeeId)}; ${trace(wrongList.response)}`, OPTIONS) + if (source.payload.businessId) { + const wrongPayload = { ...source.payload, customerId: id(other.id), customerName: other.tenantName || other.name || 'other' } + const wrong = await context.requestOverseas('POST', LASTLEG_BASE, wrongPayload, + { 'Idempotency-Key': `${context.marker}-wrong-customer-fee`.slice(0, 128) }) + context.addStep(MODULE, '其他客户不能关联本客户业务创建费用', wrong, rejected(wrong), trace(wrong), OPTIONS) + } +} + +async function verifyFinanceDelivery(context, sources) { + for (const source of sources) { + const keyword = source.payload.inboundCode || source.payload.businessNo + const path = `${FINANCE_BASE}?page=1&pageSize=200&customerId=${encodeURIComponent(source.customerId)}&keyword=${encodeURIComponent(keyword)}` + const delivered = await poll(async () => { + const response = await context.request('GET', path) + const found = rows(response?.body?.data).find((item) => item?.sourceSystem === 'LASTLEG' + && id(item?.sourceFeeId) === source.sourceFeeId) + return { response, found } + }, (value) => success(value?.response) && Boolean(value?.found)) + context.assert(MODULE, `${source.definition.label}费用经跨区链进入 Finance`, delivered.result?.response, + Boolean(delivered.result?.found) + && id(delivered.result.found.customerId) === id(source.customerId) + && delivered.result.found.sourceType === source.definition.sourceType, + `pollRequests=${delivered.attempts}, sourceFeeId=${source.sourceFeeId}, financeFeeSourceId=${id(delivered.result?.found?.feeSourceId) || 'missing'}; ${trace(delivered.result?.response)}`, OPTIONS) + } +} + +async function verifyBridgeCapabilities(context) { + const domestic = await context.request('GET', '/internal/event-bridge/status') + context.addStep(MODULE, '国内 Event Bridge HTTP 能力状态可观察', domestic, + (success(domestic) && String(domestic.body?.data?.region).toUpperCase() === 'CN') || domestic.http === 404, + `${domestic.http === 404 ? 'internal endpoint is intentionally not exposed by public gateway; ' : ''}${trace(domestic)}`, OPTIONS) + const overseas = await context.requestOverseas('GET', '/internal/event-bridge/status') + context.addStep(MODULE, '海外 Event Bridge HTTP 能力状态可观察', overseas, + (success(overseas) && String(overseas.body?.data?.region).toUpperCase() === 'OVERSEAS') || overseas.http === 404, + `${overseas.http === 404 ? 'internal endpoint is intentionally not exposed by public gateway; ' : ''}${trace(overseas)}`, OPTIONS) +} + +export async function run(context) { + const start = context.steps.length + const fixtures = await resolveFixtures(context) + const definitions = [ + { label: '海外入仓', businessType: 'OVERSEAS_INBOUND', sourceType: 'OVERSEAS_INBOUND_FEE', fixture: fixtures.inbound }, + { label: '海外采购', businessType: 'OVERSEAS_PROCUREMENT', sourceType: 'OVERSEAS_PROCUREMENT_FEE', fixture: fixtures.procurement }, + { label: '美国卡车', businessType: 'US_TRUCK', sourceType: 'OVERSEAS_TRUCK_FEE', fixture: fixtures.truck }, + { label: '本土散板', businessType: 'LOCAL_LOOSEBOARD', sourceType: 'OVERSEAS_LOOSEBOARD_FEE', fixture: fixtures.looseboard }, + { label: '海外仓服务', businessType: 'WAREHOUSE_SERVICE', sourceType: 'OVERSEAS_WAREHOUSE_FEE', fixture: fixtures.warehouse }, + ] + const sources = [] + for (const [index, definition] of definitions.entries()) { + const source = await createApprove(context, definition, definition.fixture, index) + if (source) sources.push(source) + } + context.assert(MODULE, '五类海外费用均创建、提交并审核', null, sources.length === definitions.length, + `created=${sources.length}, expected=${definitions.length}`, OPTIONS) + const warehouseSource = sources.find((item) => item.definition.businessType === 'WAREHOUSE_SERVICE') + context.assert(MODULE, '仓储服务费用仅关联客户和仓库', null, + Boolean(warehouseSource) && warehouseSource.payload.businessId == null + && warehouseSource.payload.orderId == null && warehouseSource.payload.inboundId == null + && warehouseSource.payload.inboundCode == null && warehouseSource.payload.containerId == null + && warehouseSource.payload.containerBookingId == null && warehouseSource.payload.containerNo == null + && longId(warehouseSource.payload.warehouseId), + `sourceFeeId=${warehouseSource?.sourceFeeId || 'missing'}, warehouseId=${warehouseSource?.payload?.warehouseId || 'missing'}`, OPTIONS) + await verifyCustomerIsolation(context, fixtures, sources.find((item) => item.payload.businessId)) + await verifyFinanceDelivery(context, sources) + await verifyBridgeCapabilities(context) + context.createdResources.phase4OverseasFinanceCrossRegion = { + marker: context.marker, + sources: sources.map((item) => ({ sourceFeeId: item.sourceFeeId, customerId: item.customerId, + businessType: item.definition.businessType, businessNo: item.payload.businessNo })), + observability: 'BUSINESS_HTTP_VERIFIED_EVENT_PERSISTENCE_REQUIRES_AUTHORIZED_DB_EVIDENCE', + } + 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 && !step.passed), + passed: steps.filter((step) => step.passed).length, failed: steps.filter((step) => !step.passed).length }) +} diff --git a/scripts/admin-test-env-request-retry-contract.test.mjs b/scripts/admin-test-env-request-retry-contract.test.mjs new file mode 100644 index 000000000..8e02b0188 --- /dev/null +++ b/scripts/admin-test-env-request-retry-contract.test.mjs @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { AcceptanceRun } from './admin-test-env-acceptance.mjs' + +function runContext() { + return new AcceptanceRun({ gateway: 'http://acceptance.invalid', overseasGateway: 'http://overseas.invalid' }) +} + +test('read-only HTTP requests retry transient network failures', async () => { + const originalFetch = globalThis.fetch + let attempts = 0 + globalThis.fetch = async () => { + attempts += 1 + if (attempts < 3) throw new TypeError('fetch failed') + return new Response(JSON.stringify({ code: '200', data: { ready: true } }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } + try { + const response = await runContext().request('GET', '/api/readiness') + assert.equal(attempts, 3) + assert.equal(response.http, 200) + assert.equal(response.body?.data?.ready, true) + } finally { + globalThis.fetch = originalFetch + } +}) + +test('mutating HTTP requests never retry an ambiguous network failure', async () => { + const originalFetch = globalThis.fetch + let attempts = 0 + globalThis.fetch = async () => { + attempts += 1 + throw new TypeError('fetch failed') + } + try { + const response = await runContext().request('POST', '/api/orders', { value: 1 }) + assert.equal(attempts, 1) + assert.equal(response.http, 0) + assert.equal(response.raw, 'fetch failed') + } finally { + globalThis.fetch = originalFetch + } +}) diff --git a/scripts/full-regression-entry-contract.test.mjs b/scripts/full-regression-entry-contract.test.mjs index 810d2da16..b39daad00 100644 --- a/scripts/full-regression-entry-contract.test.mjs +++ b/scripts/full-regression-entry-contract.test.mjs @@ -74,7 +74,7 @@ test('test-environment loading chains push without a number and open PDA only af assert.match(containerLoading, /oceanBookingId: bookingId, requirementId: id\(requirement\.id\), containerNo: realContainerNo/) assert.doesNotMatch(containerLoading, /realContainerBookingId/) - const pushIndex = bridgeLoading.indexOf('无真实柜号推送预装成功并冻结柜次明细') + const pushIndex = bridgeLoading.indexOf('分配后自动建立预装关联且柜次计划可读') const absentIndex = bridgeLoading.indexOf('无号 push 成功后 PDA 查询明确 A0404') const releaseIndex = bridgeLoading.indexOf('三期订舱 RELEASED 冻结运输快照') const linkIndex = bridgeLoading.indexOf('模拟柜原地关联三期 RELEASED 订舱需求并补真实柜号') @@ -90,7 +90,7 @@ test('test-environment loading chains push without a number and open PDA only af assert.match(bridgeLoading, /计划级确认同 key 异参返回冲突[\s\S]*已确认计划使用新 key 重复确认返回冲突/) assert.doesNotMatch(bridgeLoading, /\/preload\/confirming|\/preload\/confirm\?tenantId=/) - const containerPushIndex = containerLoading.indexOf('无真实柜号推送预装计划成功') + const containerPushIndex = containerLoading.indexOf('分配后自动建立预装关联且计划可读') const containerReleaseIndex = containerLoading.indexOf('订舱放舱为 RELEASED 且不预生成真实货柜') const containerLinkIndex = containerLoading.indexOf('首次关联 RELEASED 订舱需求并补真实柜号') const containerConfirmIndex = containerLoading.indexOf('计划级原子确认整份预装并触发唯一 PDA 任务')