This commit is contained in:
2026-10-08 17:07:57 +08:00
parent 16d2d9111a
commit c1ddb658bd
111 changed files with 2914 additions and 941 deletions
+19
View File
@@ -3,6 +3,9 @@
`db/return-relabel-truck-incremental-20261008.sql` 为本地退货换标排车增量,显式指向 `baiyue_iam` / `baiyue_lastleg`,适用于 MySQL 8。补齐管理员正式司机、车次与审核权限,以及车辆容量和正式车队表;不修改退货库存或业务单据。凭据从本机配置读取,可重复执行,失败保留原错误和业务现场;重新登录后核对权限快照。结构同步到基线及两个统一增量,权限由正式 IAM 权限种子维护。
`db/return-packing-incremental-20261008.sql` 为本地退货合箱增量,明确指向 `baiyue_iam` 和 `baiyue_lastleg`,适用于 MySQL 8,可重复执行。初始化平台管理员的库存管理权限,补齐精确预占、原退货单来源追溯、完成结果幂等快照和箱级库位字段,移除“一签收单只能一箱”的旧唯一索引;不修改退货、库存等业务数据。数据库凭据从本机环境配置读取;执行失败应保留原错误,修复后重跑,不清理验收业务单据。同批结构已同步到基线、海外统一增量与 `db/incremental.sql`。
`fleet-capacity-pool-ui-fixture-gray.mjs` 仅准备本轮授权的 GRAY 拼车角色页面、报价引用和 PDA H5 验收数据,复用 TEST UI producer/core,默认 `--plan` 零网络。执行使用 `node --env-file=<jenkins.env> --env-file=<gray-acceptance.env> scripts/fleet-capacity-pool-ui-fixture-gray.mjs --execute --environment GRAY --run-id <新批次> --warehouse-id <实际收货仓ID> --destination-warehouse-id <FBA目的仓ID> --private-credentials-file <私有env目录直接子级的独占JSON绝对路径>`。保留实际 `CHONGCHONG_ENV=prod|gray`,配置沿 GRAY 拼车专项的 CHONGCHONG_ADMIN 与 JENKINS_*;强制生产身份只读reader和品牌transport,不接受producer/reader覆盖。只建两客户真实来货、自有18/30计划、4完整草稿、两个销售及最小manage调度;销售A另含quote:list,销售B不含。准备窗口按同一时刻固定为8小时截止、9小时后计划发车、10小时后派送预计到站和12小时后司机结束;LocalDateTime到站与Instant车次时间分别处理。保留本批唯一仓绑定PDA为ACTIVE供H5,私有文件标记用途/仓库,不自动停用;脚本会话仍注销。凭据首次写入前wx独占并fsync,报告 `reports/fleet-capacity-pool-ui-gray-<批次>/` 全部脱敏,使用共享写锁,未知结果停写且不自动重试或覆盖;不创建池商业单、不发车签收、不写SQL或部署。原TEST UI入口仍24草稿且自动停用PDA。直接离线保护为 `fleet-capacity-pool-ui-fixture-gray.test.mjs`,凭据独占及脱敏复用 `fleet-capacity-pool-ui-fixture-test-env.test.mjs`。
`fleet-capacity-pool-gray.mjs` 是明确 GRAY 的拼车专项入口,复用既有池全流程矩阵与两客户实货前置,不执行旧 FBA 下单、报关或装柜链。默认 `--plan` 零网络、零文件写入;执行必须传 `--execute --environment GRAY --run-id <新批次> --warehouse-id <实际海外仓ID> --destination-warehouse-id <FBA目的仓ID>`。命令前使用 `node --env-file=<独立jenkins.env> --env-file=<明确gray-acceptance.env>`,要求 `CHONGCHONG_ENV=prod` 或 `gray`、`CHONGCHONG_ADMIN_API_BASE_URL`(统一 HTTPS 源站加 `/api`)、`CHONGCHONG_ADMIN_USERNAME/PASSWORD` 和 `JENKINS_URL/USER/API_TOKEN`;不改私有配置、不冒充 TEST、不使用 TEST agent。库存证据由 `createGrayEvidenceReaders` 校验生产容器、profile及数据库身份后只读取得。报告独占 `reports/fleet-pool-gray-<新批次>/`,沿共享 `.fleet-acceptance.lock` 串行执行;每次写入先持久记录原键与意图,结果未知停写,失败保留样本,不自动续跑或覆盖。结束停用本轮临时销售/调度/PDA账号、退出会话;合成客户、司机/车辆、报价、文件和业务样本留存,费用仍由唯一真实派送来源产生,不发起支付。Admin 通用 GRAY CLI 的 F40 白名单不变,专用 transport factory 只能创建空模块且无故障注入的灰度上下文。直接合同 `fleet-capacity-pool-gray.test.mjs` 验证环境隔离、品牌门禁和复用范围;原 TEST CLI 仍拒绝 GRAY。本入口不部署、不写业务 SQL,不将专项通过冒充全部页面验收。
拼车主入口 `fleet-capacity-pool-test-env.mjs` 除两笔真实履约订单外,还各创建一笔本土散板/卡车的“已确认、未登记来货”取消样本,核对商业单与预订同步取消、三维容量恢复和原键重放;增加来货后取消/实收超量拒绝、绑定后占位转计划不双扣、事实失效须重新核对并显式恢复,以及跨销售写入/跨客户商业单拒绝。拒绝动作须回查原事实与库存不变,不能仅按错误码判定通过;不注入业务数据库故障。
@@ -192,6 +195,20 @@ node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env"
Client 全量、Client 入库测量与计费、待审核订单和批量待分柜均复用这套变量,不再维护各自的 Gateway、管理员或客户账号变量。外部文件必须保留在仓库之外,不得复制到项目目录、报告或提交记录。
### 灰度环境 FBA 定向闭环
`admin-test-env-acceptance.mjs --environment gray` 显式支持临时灰度环境,当前仅开放 `--modules phase5-root-fulfillment` 及自动展开的本轮客户资质 fixture。该链会通过正式 HTTP 新建隔离客户、两箱根订单及分柜子来源,推进国内测量、装柜、报关、人工船节点、海外收货、排车、出库和签收;不是默认全量验收。默认全量中包含 TEST 专用故障注入和未经灰度副作用核对的模块,因此 gray 拒绝省略模块、附加模块、批量订单和卸柜故障开关,也不会创建 TEST fault executor。`--f40-tax-mode TAX_REFUND` 覆盖退税文件确认链;真实邮件和供应商自动查询不在此闭环的完成声明中。
明确的仓库外环境文件须配置 `ADMIN_GRAY_GATEWAY`(统一 HTTPS 源站,不带 `/api`)、`ADMIN_GRAY_USERNAME`、`ADMIN_GRAY_PASSWORD`、`ADMIN_GRAY_PDA_USERNAME`、`ADMIN_GRAY_PDA_PASSWORD`;可选 `ADMIN_GRAY_CUSTOMER_NAME`。gray 不读取 TEST Gateway、管理员或 PDA 凭据。F40 始终创建本轮客户,不需要传已有 merchant 账号。数据库只读取证使用 `JENKINS_URL`、`JENKINS_USER`、`JENKINS_API_TOKEN` 及 Jenkins 已登记的 production SSH/host/known-hosts/env 凭据,由 `gray-regression-evidence.mjs` 实现,不调用 TEST agent 或写业务 SQL。
运行前核对国内 PDA 绑定仓与 `--origin-warehouse-name` 一致,且目标环境已有有效渠道报价、订舱代理、库位/托盘及业务月份 ACTIVE 的 USD→CNY 月度汇率。汇率归 Lastleg `master-data/exchange-rates`,只读月份筛选为 `monthFrom`/`monthTo`;缺少时派送费用发布会被明确拒绝,脚本不自行沿用上月或修改共享汇率。海外 PDA 则从本轮两柜实际尾程来源解析唯一接柜仓,不按仓库列表顺序选择。
```powershell
node --env-file="<仓库外灰度验收环境文件>" scripts/admin-test-env-acceptance.mjs --environment gray --modules phase5-root-fulfillment --f40-tax-mode TAX_REFUND --origin-warehouse-name "<PDA实际绑定国内仓名>" --output "reports/<新批次>/gray-fba-flow.json"
```
运行前须具备该灰度环境写入授权、所选仓的 PDA 账号、正式主档和有效价格簿;只读取证预检失败时不进入业务写入。报告显式标注 gray、使用 `GRAY-API-` 资源标记,并列出未覆盖范围。失败保留本轮资源和原始报告,不自动重放未知结果、不删样本、不重置数据库;先核对失败阶段再决定续跑,新运行使用独立报告路径和新样本。本链通过不代表默认全量、页面视觉或外部供应商已验收。
## 脚本目录归属
脚本按作用域放置,不要求全部集中到根 `scripts/`:
@@ -220,6 +237,8 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜
| 测试环境 HTTP | `client-test-env-acceptance.mjs` | Client 完整真实 HTTP 链路;写入客户、订单、入库、权限和消息数据 |
| 测试环境 HTTP | `client-quick-order-test-env-acceptance.mjs` | Client 快速下单定向真实 HTTP 验收;使用既有测试客户创建 FBA、海外仓和私人地址草稿,验证逐行失败与幂等重放,不提交订单 |
| 测试环境 HTTP | `client-phase2-test-env-acceptance.mjs` | Client 入库、测量、费用和权限专项;写测试数据 |
`client-phase2-measurement-evidence.mjs` 为该专项内部取证助手,核对平台已冻结的 calculatedOuter 尺寸、体积和客户发布的计费外尺寸及规则快照;不使用当前客户配置重算历史快照。对应 `client-phase2-measurement-evidence.test.mjs`,没有独立 CLI。新样本先沿 Admin `inbound-fees` 依赖链准备,再用专项的 `--order-id` 指定本轮订单;保留原失败报告。
| 测试环境 HTTP | `create-pending-review-orders.mjs` | 固定客户批量创建并提交待审核订单,不做平台审核和后续履约 |
| 测试环境 HTTP | `it-test-wait-split-bulk.mjs` | 固定“IT测试”与“义乌仓”,渠道等随机,逐票推进到 `WAIT_SPLIT`;失败继续并落报告。只复用 `TP-001`/`YW-TP-002`,不新增托盘或库位,库位不足使用公共位 `A0` |
| 本地真实 API | `relabel-closure-local-acceptance.mjs` | 仅调用本机海外 Gateway/Print/File/Lastleg,创建合成换标单并验证重复下单、三类 PDF、PDA 换标、拆托/合托、返工和换标出库;会写入本地业务数据,不代表现场照片或财务验收 |
+72 -20
View File
@@ -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', 'inbound-hold', '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-tail-order-controls', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-return-receipt', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment', 'phase5-terminal-drayage', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-direct-paid-isolation', 'finance-downloads', 'firstleg-container-fee-allocation', 'phase4-overseas-finance-cross-region', 'phase4-overseas-wallet-and-pallet-cost', 'customer-operating-statistics']
const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'dashboard', 'master-data', 'suppliers', 'domestic-container', 'orders', 'inbound-hold', '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-tail-order-controls', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-return-receipt', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment', 'phase5-terminal-drayage', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-direct-paid-isolation', 'finance-downloads', 'firstleg-container-fee-allocation', 'phase4-overseas-finance-cross-region', 'phase4-overseas-wallet-and-pallet-cost', 'customer-operating-statistics']
const OPTIONAL_MODULES = ['phase5-root-fulfillment', 'phase4-overseas-return-followups', 'manual-order-splitting-fixture', 'manual-order-splitting-verify',
'manual-preload-push-fixture', 'manual-preload-push-verify',
'manual-pda-ready-fixture', 'manual-pda-ready-verify',
@@ -291,7 +291,9 @@ function usage() {
--password <value> 必填,管理员密码
--pda-username <name> 国内装柜链必填,实际领取任务的 PDA 作业员账号
--pda-password <value> 国内装柜链必填,PDA 作业员密码
--environment <name> 默认 test;gray 仅允许显式 FBA 定向闭环
--modules <names> 逗号分隔,默认 ${DEFAULT_MODULES.join(',')}
--f40-tax-mode <mode> F40 税制:BUY_DECLARATION 或 TAX_REFUND
--customer-name <name> 指定本轮隔离客户名称,默认自动生成
--merchant-username <name> 复用既有已审核客户账号,不再创建客户
--merchant-password <value> 复用客户账号密码,不写入报告
@@ -317,7 +319,39 @@ function usage() {
也可使用环境变量: ADMIN_TEST_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD,
ADMIN_TEST_WEIGHT_IMAGE_PATH, ADMIN_TEST_CUSTOMER_NAME, MERCHANT_TEST_USERNAME,
MERCHANT_TEST_PASSWORD, ADMIN_PDA_USERNAME, ADMIN_PDA_PASSWORD, ADMIN_WAIT_SPLIT_COUNT`)
MERCHANT_TEST_PASSWORD, ADMIN_PDA_USERNAME, ADMIN_PDA_PASSWORD, ADMIN_WAIT_SPLIT_COUNT。
gray 使用独立 ADMIN_GRAY_GATEWAY/USERNAME/PASSWORD、ADMIN_GRAY_PDA_USERNAME/PASSWORD、
ADMIN_GRAY_CUSTOMER_NAME、MERCHANT_GRAY_USERNAME/PASSWORD;不回落 TEST 凭据。`)
}
const fleetPoolGrayOptions = new WeakSet()
/** Dedicated pool orchestrator transport only: no generic module execution or CLI scope switch. */
export function createFleetPoolGrayOptions(input) {
const keys = ['gateway', 'username', 'password', 'output', 'markerNamespace']
if (!input || Object.keys(input).some(key => !keys.includes(key))
|| keys.some(key => typeof input[key] !== 'string' || !input[key].trim())) throw Error('Explicit gray pool transport configuration required')
const target = new URL(input.gateway)
if (target.protocol !== 'https:' || target.username || target.password || target.search || target.hash || target.pathname !== '/')
throw Error('Gray pool requires the unified HTTPS origin')
const options = Object.freeze({ ...input, environment: 'gray', executionScope: 'FLEET_CAPACITY_POOL_GRAY',
modules: Object.freeze([]), requestedModules: Object.freeze([]), quiet: true, waitSplitCount: 1, verifyUnloadEventRecovery: false })
fleetPoolGrayOptions.add(options)
return options
}
function assertGrayExecutionScope(options) {
if (options.environment !== 'gray') return
if (options.verifyUnloadEventRecovery) throw new Error('gray 禁止 TEST 故障注入')
if (fleetPoolGrayOptions.has(options)) return
if (!Array.isArray(options.requestedModules) || options.requestedModules.length !== 1
|| options.requestedModules[0] !== 'phase5-root-fulfillment'
|| !Array.isArray(options.modules) || options.modules.length !== 2
|| options.modules[0] !== 'merchant-qualification-fixture'
|| options.modules[1] !== 'phase5-root-fulfillment') {
throw new Error('gray 仅支持显式 phase5-root-fulfillment 及其本轮客户依赖;其他模块尚未核对环境副作用')
}
if (options.waitSplitCount !== 1) throw new Error('gray 定向闭环只支持单轮订单,不运行批量造数')
}
export function parseArguments(argv) {
@@ -337,12 +371,15 @@ export function parseArguments(argv) {
}
}
if (values.has('overseas-gateway') || process.env.ADMIN_OVERSEAS_GATEWAY) throw new Error('Remove overseas-gateway / ADMIN_OVERSEAS_GATEWAY; use one TEST gateway')
if (values.has('environment') && values.get('environment') !== 'test') throw new Error('Only test acceptance is supported')
const environment = values.get('environment') || 'test'
if (!['test', 'gray'].includes(environment)) throw new Error('Only test or explicit gray acceptance is supported')
if (environment === 'gray' && !values.get('modules')) throw new Error('gray 必须显式指定 --modules phase5-root-fulfillment,不能运行默认全量')
const envValue = (testKey, grayKey) => process.env[environment === 'gray' ? grayKey : testKey] || ''
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 || ''
const merchantUsername = values.get('merchant-username') || envValue('MERCHANT_TEST_USERNAME', 'MERCHANT_GRAY_USERNAME')
const merchantPassword = values.get('merchant-password') || envValue('MERCHANT_TEST_PASSWORD', 'MERCHANT_GRAY_PASSWORD')
const requiresMarkerOwnedMerchantFixture = [
'phase5-root-fulfillment',
'phase4-overseas-business',
@@ -357,22 +394,22 @@ export function parseArguments(argv) {
if (fixtureIndex >= 0) modules.splice(fixtureIndex, 1)
}
return {
environment: 'test',
gateway: (values.get('gateway') || process.env.ADMIN_TEST_GATEWAY || '').replace(/\/$/, ''),
username: values.get('username') || process.env.ADMIN_TEST_USERNAME || '',
password: values.get('password') || process.env.ADMIN_TEST_PASSWORD || '',
pdaUsername: values.get('pda-username') || process.env.ADMIN_PDA_USERNAME || '',
pdaPassword: values.get('pda-password') || process.env.ADMIN_PDA_PASSWORD || '',
environment,
gateway: (values.get('gateway') || envValue('ADMIN_TEST_GATEWAY', 'ADMIN_GRAY_GATEWAY')).replace(/\/$/, ''),
username: values.get('username') || envValue('ADMIN_TEST_USERNAME', 'ADMIN_GRAY_USERNAME'),
password: values.get('password') || envValue('ADMIN_TEST_PASSWORD', 'ADMIN_GRAY_PASSWORD'),
pdaUsername: values.get('pda-username') || envValue('ADMIN_PDA_USERNAME', 'ADMIN_GRAY_PDA_USERNAME'),
pdaPassword: values.get('pda-password') || envValue('ADMIN_PDA_PASSWORD', 'ADMIN_GRAY_PDA_PASSWORD'),
modules,
requestedModules,
customerName: values.get('customer-name') || process.env.ADMIN_TEST_CUSTOMER_NAME || '',
customerName: values.get('customer-name') || envValue('ADMIN_TEST_CUSTOMER_NAME', 'ADMIN_GRAY_CUSTOMER_NAME'),
merchantUsername,
merchantPassword,
originWarehouseName: values.get('origin-warehouse-name') || '',
channelName: values.get('channel-name') || '',
channelCode: values.get('channel-code') || '',
orderProfileMode: values.get('order-profile-mode') || 'fixed',
waitSplitCount: Number(values.get('wait-split-count') || process.env.ADMIN_WAIT_SPLIT_COUNT
waitSplitCount: Number(values.get('wait-split-count') || envValue('ADMIN_WAIT_SPLIT_COUNT', 'ADMIN_GRAY_WAIT_SPLIT_COUNT')
|| (modules.includes('coload-groups') ? 8
: modules.includes('manual-order-splitting-fixture') ? 6 : 1)),
waitSplitPackageCount: Number(values.get('wait-split-package-count') || 0),
@@ -384,6 +421,7 @@ export function parseArguments(argv) {
quiet: values.get('quiet') === 'true',
returnMainChainOnly: values.get('return-main-chain-only') === 'true',
verifyUnloadEventRecovery: values.get('verify-unload-event-recovery') === 'true',
f40TaxMode: values.get('f40-tax-mode') || '',
manualSplitFixtureReport: values.get('manual-split-fixture-report') || '',
manualSplitPlanId: values.get('manual-split-plan-id') || '',
manualPreloadSourceReport: values.get('manual-preload-source-report') || '',
@@ -395,6 +433,13 @@ export function parseArguments(argv) {
}
export function validateExecutionOptions(options) {
assertGrayExecutionScope(options)
if (options.environment === 'gray') {
const target = new URL(options.gateway)
if (target.protocol !== 'https:' || target.username || target.password || target.search || target.hash
|| target.pathname !== '/') throw new Error('gray gateway 必须为统一 HTTPS 源站,不带 /api、凭据或查询参数')
if (!options.output?.trim()) throw new Error('gray 必须指定独立 --output 报告路径')
}
if (options.verifyUnloadEventRecovery && !options.modules.includes('phase4-overseas-pda')) {
throw new Error('卸柜事件恢复验收必须包含正式 phase4-overseas-pda 收货链')
}
@@ -403,7 +448,8 @@ export function validateExecutionOptions(options) {
for (const [name, value] of [['gateway', options.gateway], ['username', options.username], ['password', options.password]]) {
if (typeof value !== 'string' || value.trim() === '') throw new Error(`缺少必填执行参数: ${name}`)
}
if (options.environment && options.environment !== 'test') throw new Error('Only test acceptance is supported')
if (options.environment && !['test', 'gray'].includes(options.environment)) throw new Error('Only test or explicit gray acceptance is supported')
if (options.f40TaxMode && !['BUY_DECLARATION', 'TAX_REFUND'].includes(options.f40TaxMode)) throw new Error('f40-tax-mode 必须为 BUY_DECLARATION 或 TAX_REFUND')
if ('overseasGateway' in options) throw new Error('Use the single gateway option')
if (options.modules.some((module) => ['phase3-loading', 'phase3-container-loading', 'phase5-root-fulfillment'].includes(module))
&& (!options.pdaUsername?.trim() || !options.pdaPassword?.trim())) {
@@ -551,9 +597,10 @@ export function buildAcceptanceSummary(steps = [], modules = [], coverageGaps =
export class AcceptanceRun {
constructor(options) {
assertGrayExecutionScope(options)
this.options = options
const markerNamespace = String(options.markerNamespace || '').replace(/[^A-Za-z0-9_-]/g, '').slice(0, 12)
this.marker = `ADMIN-API-${new Date().toISOString().replace(/\D/g, '').slice(0, 17)}${markerNamespace ? `-${markerNamespace}` : ''}`
this.marker = `${options.environment === 'gray' ? 'GRAY' : 'ADMIN'}-API-${new Date().toISOString().replace(/\D/g, '').slice(0, 17)}${markerNamespace ? `-${markerNamespace}` : ''}`
this.startedAt = new Date().toISOString()
this.steps = []
this.modules = []
@@ -562,7 +609,7 @@ export class AcceptanceRun {
this.phase4ProducerAttempts = new Map()
this.deploymentBlockers = []
this.coverageGaps = []
this.context = {}
this.context = options.f40TaxMode ? { f40TaxMode: options.f40TaxMode } : {}
this.token = ''
this.pdaToken = ''
this.refreshToken = ''
@@ -1869,8 +1916,8 @@ export class AcceptanceRun {
const summary = buildAcceptanceSummary(this.steps, this.modules, this.coverageGaps)
return {
reportName: '后台管理测试环境 HTTP API 验收', marker: this.marker,
gateway: this.options.gateway, gateway: this.options.gateway,
reportName: this.options.environment === 'gray' ? '灰度环境 FBA 定向闭环 HTTP API 验收' : '后台管理测试环境 HTTP API 验收', marker: this.marker,
environment: this.options.environment || 'test', gateway: this.options.gateway,
generatedAt: new Date().toISOString(), startedAt: this.startedAt,
success: this.fatal === null && summary.failed === 0, fatal: this.fatal,
summary,
@@ -1878,8 +1925,9 @@ export class AcceptanceRun {
coverageGaps: this.coverageGaps,
createdResources: this.createdResources,
excluded: [
{ scope: 'dashboard', reason: '首页为静态演示数据,不调用后端 API' },
{ scope: 'hidden-prototypes', reason: '隐藏原型页面尚无已确认后端契约' },
...(this.options.environment === 'gray' ? [{ scope: 'full-regression-and-fault-injection',
reason: '仅执行显式 FBA 闭环及所需正式业务依赖;不覆盖默认全量、TEST 故障注入、真实邮件或供应商自动查询' }] : []),
],
securityNote: '报告不保存登录密码、Access Token、Refresh Token、图片二进制或 OCR 原文全文。',
steps: this.steps,
@@ -1898,12 +1946,16 @@ export function unloadRecoveryWasVerified(run) {
}
export async function configureReadOnlyEvidence(run, environment, createReaders, faultFactories) {
assertGrayExecutionScope(run.options)
if (!run.options.modules.some(name => ['phase4-overseas-pda', 'phase5-root-fulfillment', 'manual-state-matrix'].includes(name))) return
const factory = createReaders || (await import('./fba-core-regression-evidence.mjs')).createTestEvidenceReaders
const factory = createReaders || (run.options.environment === 'gray'
? (await import('./gray-regression-evidence.mjs')).createGrayEvidenceReaders
: (await import('./fba-core-regression-evidence.mjs')).createTestEvidenceReaders)
const readers = factory(environment)
await readers.preflight()
run.context.phase4ReceivingEvidenceReader = readers.readReceiving
run.context.f40ReadOnlyOutboxEvidence = readers.readCompletion
if (run.options.environment === 'gray') return
const faults = []
if (run.options.modules.includes('phase4-cross-region-events')) {
const { createTestFaultExecutor } = faultFactories || await import('./test-event-fault-executor.mjs')
+7 -4
View File
@@ -70,7 +70,7 @@
对应本地数据库事务测试为 Firstleg `CarrierScheduleExceptionPersistenceTest`,使用真实 Service/Repository/MyBatis 和 H2,验证四类异常及解除的同事件重放、异摘要冲突、正常实际/预计节点整行不变,以及异常历史写入失败时事务回滚。它属于本地持久化验证,不代替 TEST MySQL 或真实供应商调度链验收。
`phase5-root-fulfillment.mjs` 是显式 TEST 的 F40 两箱根/余量专项,通过总入口 `--modules phase5-root-fulfillment` 准备本轮资质客户;模块用正式下单、测量、国内 PDA 拆分装柜、船节点、海外 PDA 收货、拣货发车及文件签收链创建两柜真实来源。默认根先签,编程调用 `AcceptanceRun.context.f40SignatureOrder='CHILD_FIRST'` 切换子先签;分别核对第一片签收不得根完成、最后一片签收才根完成、真实签收时间、状态历史和重放零新增。
`phase5-root-fulfillment.mjs` 是 F40 两箱根/余量专项,通过总入口 `--modules phase5-root-fulfillment` 准备本轮资质客户;TEST 与显式 `--environment gray` 的环境参数、授权和排除范围见脚本根 README。模块用正式下单、测量、国内 PDA 拆分装柜、船节点、海外 PDA 收货、拣货发车及文件签收链创建两柜真实来源。海外 PDA 必须按本轮客户、柜次、来源入仓 ID 和入仓码确定实际唯一接柜仓,再核对 Warehouse 主档;不得按仓列表顺序选择另一仓。默认根先签,编程调用 `AcceptanceRun.context.f40SignatureOrder='CHILD_FIRST'` 切换子先签;分别核对第一片签收不得根完成、最后一片签收才根完成、真实签收时间、状态历史和重放零新增。
默认税制为 `BUY_DECLARATION`;编程调用显式设置 `AcceptanceRun.context.f40TaxMode='TAX_REFUND'` 可执行退税链。内部模块 `phase5-root-tax-customs.mjs` 在每个实装柜走客户上传、平台回执、客户确认、发送执行、人工正式申报与放行、完成文件,再进入尾程;订单最终完成后回查本客户资料并校验原件、回执、完成文件的实际下载字节和 SHA-256。该模块不能独立执行,不用 SQL 修改状态,不借历史资料;依赖相同 TEST 授权、当轮客户与只读取证器,失败及重跑保留各轮原始报告。直接合同为 `phase5-root-tax-customs-contract.test.mjs`,离线通过不代替实际 HTTP 或页面验收。
@@ -78,7 +78,7 @@
会写已授权测试环境,保留新客户、单据、文件及报告,临时海外 PDA 在 finally 停用;失败保留样本,不自动删除或重复执行部分已完成链。签收资料使用新 key 保存同文件时,按正式保存命令递增一次资料版本、新增一条版本历史及 `SAVE_DOCUMENTS` 审计;签收时间、状态、原完成记录、文件绑定和根子完成事件不得变化。同一新命令再用原 key、原请求重放,必须明确 `replayed=true` 且完整资料、派送事实、版本和审计历史零漂移。资料保存前后及原 key 重放响应保存在 `phase5RootFulfillment.receiptReplayProofs`,失败也保留已取得的快照。
配置仅来自显式测试环境文件,报告由总入口 `--output` 指定。完整验收还须向 `AcceptanceRun.context.f40ReadOnlyOutboxEvidence` 提供本轮客户/根/实际来源的只读完成 Outbox 计数(取消模式必须返回零事件子行);缺少该证据明确为 `HTTP_VERIFIED_OUTBOX_PENDING` 和阻断,不能报告全部通过。海外收货同时需要 `AcceptanceRun.context.phase4ReceivingEvidenceReader` 捕获首次完成和重放前后的完整库存证据,缺失明确阻断;四阶段原始库存快照通过 `phase5RootFulfillment.receivingCompletionEvidence` 原样保存,部分捕获与失败也保留,不用终态补造历史。离线合同为 `phase5-root-fulfillment-contract.test.mjs`;不能代替真实环境验收。
配置仅来自显式指定的目标环境文件,报告由总入口 `--output` 指定。完整验收还须向 `AcceptanceRun.context.f40ReadOnlyOutboxEvidence` 提供本轮客户/根/实际来源的只读完成 Outbox 计数(取消模式必须返回零事件子行);缺少该证据明确为 `HTTP_VERIFIED_OUTBOX_PENDING` 和阻断,不能报告全部通过。海外收货同时需要 `AcceptanceRun.context.phase4ReceivingEvidenceReader` 捕获首次完成和重放前后的完整库存证据,缺失明确阻断;四阶段原始库存快照通过 `phase5RootFulfillment.receivingCompletionEvidence` 原样保存,部分捕获与失败也保留,不用终态补造历史。离线合同为 `phase5-root-fulfillment-contract.test.mjs`;不能代替真实环境验收。
单票串行验收可显式传 `--modules phase4-overseas-return-followups --return-main-chain-only true`:仍由总入口准备本轮客户并完成退货签收、合箱、换标和出库;不执行第二客户、跨仓和并发扩展,报告 `excludedScope` 记录排除范围,不代表完整退货边界回归。目标由显式网关与账号参数指定;仅在已授权环境执行,保留业务单据及 `--output` 报告,失败先核对报告再重跑,不自动清理。
@@ -263,12 +263,13 @@ export async function run(context) {
| 订舱与拖车服务中心跨链路 | `service-center-flow.mjs`(依赖 `ocean-bookings,truck-appointments`;复用本轮 `RELEASED` 订舱创建订舱级拖车,验证派车单、离仓前状态链、历史、审计和关联零漂移) | 由入口依赖合同和拖车直接合同共同覆盖;真实 HTTP 缺本轮订舱或承运商时 fail-closed |
| Admin 补充能力与保护边界 | `supplemental.mjs`、`supplemental-boundaries.mjs` | `supplemental-boundaries-contract.test.mjs`;只按本轮资源验证补充能力、负向和保护边界 |
| 装柜清单三备注 | `orders.mjs`、`phase3-loading.mjs`(客户备注、最新内部备注、仓库备注;仓库备注 PATCH/清空/精确重放/旧版本零写) | `loading-list-remarks-contract.test.mjs` |
| 首页实时指标 | `dashboard.mjs` | `dashboard-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`;跨区事件持久化链在本轮获授权时另以只读数据库证据核对 |
| 客户钱包与拆柜打托费自动闭环 | `phase4-overseas-wallet-and-pallet-cost.mjs`(授信初始化、平台充值、采购凭证/费用冻结结算、换标冻结结算/释放、Client 余额账本、国内/海外托盘汇总、按体积分摊及跨区金额守恒) | `phase4-overseas-wallet-and-pallet-cost-contract.test.mjs`;默认全量已启用,定向入口自动展开已审核客户与海外 PDA 实收链 |
| 海外仓 HTTP 生命周期 | `phase4-overseas-warehouse.mjs` | `phase4-overseas-warehouse-contract.test.mjs` |
| 海外 PDA 柜号收货与库内操作 HTTP 生命周期 | `phase4-overseas-pda.mjs`(按柜完成签收和实收,成功后仅以内存上下文交接操作员会话和已完成库存事实)、`phase4-overseas-pda-operations.mjs`(紧随收货执行盘点、公共位核对、托盘拆装换绑和换标复核;只复用本轮入仓计划已有空托盘及公共位,不新增托盘或库位;finally 停用双操作员并清空临时 Token;任何真实 fixture 缺失均 fail-closed) | `phase4-overseas-pda-contract.test.mjs`、`phase4-overseas-pda-operations-contract.test.mjs` |
| 海外 PDA 柜号收货与库内操作 HTTP 生命周期 | `phase4-overseas-pda.mjs`(按柜完成签收和实收,成功后仅以内存上下文交接操作员会话和已完成库存事实)、`phase4-overseas-pda-operations.mjs`(紧随收货执行盘点、公共位核对、托盘拆装换绑和换标复核;只复用本轮入仓计划已有空托盘及公共位,不新增托盘或库位;finally 停用双操作员并清空临时 Token;任何真实 fixture 缺失均 fail-closed) | `phase4-overseas-pda-contract.test.mjs`、`phase4-overseas-pda-operations-contract.test.mjs`、`pda-picker-fixture-contract.test.mjs`(长场景名保留完整运行标识,避免造数账号跨轮重复) |
| 海外 PDA 退货入库、计费批次与退货合箱 | `phase4-overseas-return-receipt.mjs`(自包含创建本轮客户退货预报和绑定仓临时 PDA 操作员;覆盖退货单号/SKU 查询、正常空结果、分批签收、核平、超收、幂等、退货箱/SKU 库存、每次成功签收唯一仓储计费批次及合箱;finally 停用操作员) | `phase4-overseas-return-receipt-contract.test.mjs`;`ReturnReceiptServiceTest` 精确核对计费批次客户、仓库、签收单、起算时间和 `PENDING_PRICING` 初态 |
| 尾程单公网控制与人工计划 | `phase4-tail-order-controls.mjs`(PDA 收货前对本轮柜执行整柜接柜仓变更/恢复与幂等冲突、单/批 FBA 目的仓变更、单/批合托标记并取消、CSV 导出、订单费用回查、单票暂扣/解除、八个人工节点和逐票人工计划) | `phase4-tail-order-controls-contract.test.mjs`;只复用现有公共接柜仓/FBA 主档,不新增仓库、托盘或库位;为目标柜全部入仓码创建 `MANUAL` 计划后交给 PDA 继续消费,不借历史订单 |
| 海外入仓扩展、客户服务与排车 | `phase4-overseas-inbound-operations.mjs`、`phase4-overseas-client-services.mjs`、`phase4-overseas-dispatch.mjs` | 对应同名 `*-contract.test.mjs`;只消费本轮前置资源,缺失时 fail-closed |
@@ -288,7 +289,7 @@ export async function run(context) {
节点全链定向入口为 `--modules phase4-overseas-pda`。依赖展开固定先为本轮 `LOADED` 柜次完成国内 `CUSTOMS_DECLARATION` 与 PDA 装柜证据,再确认国内出仓;离仓后才维护 `DESTINATION_CLEARANCE`、`DELIVERY_ORDER` 两类目的港文件,PU 使用柜级数字码而不是文件,随后核对自动创建的 `OUTBOUND_CONFIRM` 船节点任务、人工补录至实际 `PICKUP_READY`,最后验证海外 PDA 候选可见。资料模块只写本轮 marker 柜次,覆盖上传/完成重放、同键异载荷 `A0409`、冲突后零漂移和聚合回读;任一 fixture 或 Trace 缺失均为 deployment blocker。
尾程单公网控制与目的港码头拖车定向入口为 `--modules phase4-carrier-nodes`。依赖会在海外 PDA 前插入 `phase4-tail-order-controls`,先对本轮柜验证接柜仓变更/恢复、目的仓变更、合托标记、CSV 导出、订单费用、暂扣阻断、解除和八个人工节点,再通过柜级预览与确认自动生成逐票计划和实体托盘;PDA 必须消费这些计划托盘,不得临时另建脱离计划的托盘。货柜到达 `PICKUP_READY` 后先复用公共 `ACTIVE + TRUCKING + truckingScope=OVERSEAS` 供应商完成选车队、预约和实际提柜;PDA 卸柜完成后,有界等待跨区卸柜事件回写国内聚合为 `UNLOADED`,再确认实际还柜。最后的船节点模块仅回读两次业务动作同步生成的 `PICKED_UP/EMPTY_RETURNED`,不得直接补录这两个节点。
尾程单公网控制与目的港码头拖车定向入口为 `--modules phase4-carrier-nodes`。依赖会在海外 PDA 前插入 `phase4-tail-order-controls`,先对本轮柜验证接柜仓变更/恢复、目的仓变更、合托标记、下载中心 XLSX 导出、订单费用、暂扣阻断、解除和八个人工节点,再通过柜级预览与确认自动生成逐票计划和实体托盘;PDA 必须消费这些计划托盘,不得临时另建脱离计划的托盘。货柜到达 `PICKUP_READY` 后先复用公共 `ACTIVE + TRUCKING + truckingScope=OVERSEAS` 供应商完成选车队、预约和实际提柜;PDA 卸柜完成后,有界等待跨区卸柜事件回写国内聚合为 `UNLOADED`,再确认实际还柜。最后的船节点模块仅回读两次业务动作同步生成的 `PICKED_UP/EMPTY_RETURNED`,不得直接补录这两个节点。
```powershell
node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/admin-test-env-acceptance.mjs --modules phase4-carrier-nodes
@@ -426,3 +427,5 @@ node --test scripts/admin-test-env-modules/merchant-qualification-fixture-contra
国内拖车自动完成与应付回归由 `phase3-outbound-dispatch.mjs` 在真实离仓后只读核验:订舱全部柜数、逐柜离仓、唯一 `AUTO_COMPLETE`、按拖车编号追溯柜次应付、金额守恒及 Finance 来源入池(有界等待 Outbox)。该模块的原有确认/撤销边界操作仍会写目标环境,执行须获得对应环境授权。`manual-state-ocean-trucking.mjs` 只构造可人工操作的状态,不伪造已完成拖车;自动终态由上述离仓链路覆盖。
`event-bridge-pending-recovery.mjs` 为纯离线证据校验器,文件名保留为索引入口;无事件桥调用或历史事件常量。合同覆盖 Compose DNS、三服务内部凭据版本一致、有限重试/DEAD、原事件摘要、ACK 丢失后 DUPLICATE 与单次业务效果。合成合同通过不代表环境故障演练通过;`phase4-cross-region-events` 保留真实重放证据阻断项。环境验收需本轮 marker 事件逐次投递记录、Inbox 与业务计数;仅使用受保护 `event-outbox-operations.mjs` 对 DEAD 原事件执行另行授权重试,不重放 SENT,不改表或伪造新事件。
下载中心导出共用内部助手:`download-center.mjs` 的 `verifyDownloadCenterExport` 由尾程订单、海外库存及主数据模块调用,创建 File XLSX 任务、等待终态、下载并核对字段及本轮业务数据;环境和写入授权沿所属模块。离线 `download-center-fixture.mjs` 仅生成合同测试使用的 XLSX 字节;`download-center-contract.test.mjs` 校验失败不继续下载及文件内容验证,不访问环境。
@@ -0,0 +1,40 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { run, validOperationalMetrics } from './dashboard.mjs'
import { readFileSync } from 'node:fs'
const metrics = () => ({ inTransitContainerCount: '3', inTransitTeu: '4.5', unknownContainerTypeCount: '1', pendingCustomsOrderCount: '2', asOf: '2026-10-06T01:00:00' })
test('实时指标拒绝缺失、负数、非整数计数及未知箱型超出总柜数', () => {
assert.equal(validOperationalMetrics(metrics()), true)
for (const field of ['inTransitContainerCount', 'unknownContainerTypeCount', 'pendingCustomsOrderCount', 'inTransitTeu', 'asOf']) {
for (const value of [null, undefined, '', -1, 'invalid']) assert.equal(validOperationalMetrics({ ...metrics(), [field]: value }), false)
}
assert.equal(validOperationalMetrics({ ...metrics(), pendingCustomsOrderCount: 1.5 }), false)
assert.equal(validOperationalMetrics({ ...metrics(), unknownContainerTypeCount: 4 }), false)
})
const fake = () => {
const calls = []
return { calls, createdResources: {}, assert(_m, _n, _r, passed) { assert.equal(passed, true) },
apiSuccess(_m, _n, response) { return response.body.data },
async request(method, path) { calls.push({ method, path }); return { http: 200, body: { code: '200', data: path.includes('/operational-metrics') ? metrics() : { total: '9', records: [{}] } } } } }
}
test('默认编排实时首页三次只读查询,今日范围和启用客户筛选与页面一致', async () => {
const context = fake(); await run(context)
assert.equal(context.createdResources.dashboard.status, 'VERIFIED')
assert.equal(context.calls.length, 3)
assert.ok(context.calls.every(row => row.method === 'GET'))
const query = new URL(context.calls[1].path, 'https://offline.invalid').searchParams
assert.equal(query.get('createdFrom'), context.createdResources.dashboard.businessDate + 'T00:00:00.000')
assert.equal(query.get('createdTo'), context.createdResources.dashboard.businessDate + 'T23:59:59.999')
assert.equal(query.get('size'), '1')
assert.ok(context.calls[2].path.endsWith('status=ACTIVE'))
const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')
assert.match(entry, /const CONFIGURED_MODULES = \[[^\n]*'dashboard'/)
assert.doesNotMatch(entry, /首页为静态演示数据/)
})
test('非成功响应和缺失服务端总数不得被记为看板通过', async () => {
for (const response of [{ http: 200, body: { code: 'S0500', data: metrics() } }, { http: 200, body: { code: '200', data: { records: [] } } }]) {
const context = fake(); context.request = async () => response
await assert.rejects(run(context)); assert.equal(context.createdResources.dashboard.status, 'CHECKING')
}
})
@@ -0,0 +1,44 @@
const MODULE = 'dashboard'
const OPTIONS = { scenario: 'platform-live-dashboard', deploymentBlocker: true }
const integer = value => (typeof value === 'number' || (typeof value === 'string' && /^\d+$/.test(value)))
&& Number.isSafeInteger(Number(value)) && Number(value) >= 0
const decimal = value => (typeof value === 'number' || (typeof value === 'string' && /^\d+(?:\.\d+)?$/.test(value)))
&& Number.isFinite(Number(value)) && Number(value) >= 0
export function validOperationalMetrics(value) {
return Boolean(value && ['inTransitContainerCount', 'unknownContainerTypeCount', 'pendingCustomsOrderCount']
.every(key => integer(value[key])) && decimal(value.inTransitTeu)
&& Number(value.unknownContainerTypeCount) <= Number(value.inTransitContainerCount)
&& typeof value.asOf === 'string' && Number.isFinite(Date.parse(value.asOf)))
}
export async function run(context) {
const proof = context.createdResources.dashboard = { status: 'CHECKING', readOnly: true }
const check = (label, response, condition) => {
context.assert(MODULE, label, response, Boolean(condition), '', OPTIONS)
if (!condition) throw Error(label)
}
const read = async (path, label) => {
const response = await context.request('GET', path)
const data = context.apiSuccess(MODULE, label, response, '', OPTIONS)
check(`${label}正式HTTP成功`, response, response.http === 200 && String(response.body?.code) === '200' && data != null)
return { response, data }
}
const metrics = await read('/api/platform/v1/first-leg/dashboard/operational-metrics', '读取首页实时运营指标')
check('在途柜量与待清关指标完整且非负,不以缺失值冒充零', metrics.response, validOperationalMetrics(metrics.data))
proof.operationalMetrics = metrics.data
// The deployed TEST services and the desktop page use the Shanghai business day.
const date = new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString().slice(0, 10)
const query = new URLSearchParams({ createdFrom: `${date}T00:00:00.000`, createdTo: `${date}T23:59:59.999`, page: '1', size: '1' })
for (const [key, path, label] of [
['todayNewOrderCount', `/api/platform/v1/orders?${query}`, '读取今日新运单总数'],
['activeCustomerCount', '/api/platform/v1/iam/tenants?page=1&size=1&status=ACTIVE', '读取启用客户总数'],
]) {
const { response, data } = await read(path, label)
check(`${label}使用服务端分页总数`, response, integer(data.total) && Array.isArray(data.records)
&& data.records.length <= 1 && Number(data.total) >= data.records.length)
proof[key] = data.total
}
proof.businessDate = date
proof.status = 'VERIFIED'
}
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { verifyDownloadCenterExport } from './download-center.mjs'
import { exportWorkbook } from './download-center-fixture.mjs'
function fixture(status = 'SUCCEEDED', values = ['LM-1']) {
const calls = []
return {
marker: 'export-test', calls,
async request(method, path, body) {
calls.push({ method, path, body })
return { http: 200, body: { code: '200', data: { id: 'EXP-1', status: method === 'POST' ? 'PENDING' : status } } }
},
apiSuccess(_module, _name, response) { return response.body.data },
assert(_module, name, _response, passed) { assert.ok(passed, name) },
async requestBinary(method, path) {
calls.push({ method, path })
return { http: 200, contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', bytes: exportWorkbook(['尾程单号'], values) }
},
}
}
test('creates, polls and validates the actual XLSX through File', async () => {
const context = fixture()
await verifyDownloadCenterExport(context, 'orders', 'LAST_MILE_ORDER_LIST', { orderNo: 'LM-1' }, ['尾程单号'], ['LM-1'])
assert.deepEqual(context.calls[0].body.parameters, { orderNo: 'LM-1' })
assert.equal(context.calls[0].body.format, 'XLSX')
assert.ok(context.calls.at(-1).path.endsWith('/EXP-1/download'))
})
test('failed generation cannot be reported as a successful download', async () => {
const context = fixture('FAILED')
await assert.rejects(verifyDownloadCenterExport(context, 'orders', 'LAST_MILE_ORDER_LIST', {}, ['尾程单号']))
assert.equal(context.calls.some(call => call.path.endsWith('/download')), false)
})
test('a file missing this run business data fails acceptance', async () => {
await assert.rejects(verifyDownloadCenterExport(fixture('SUCCEEDED', ['OTHER']), 'orders', 'LAST_MILE_ORDER_LIST', {}, ['尾程单号'], ['LM-1']))
})
@@ -0,0 +1,18 @@
// Offline XLSX fixture used only by download-center contract tests.
export function exportWorkbook(headers, values = []) {
const escape = value => String(value ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
const rows = [headers, values].map((row, index) => `<row r="${index + 1}">${row.map((value, col) => {
const letter = String.fromCharCode(65 + col)
return `<c r="${letter}${index + 1}" t="inlineStr"><is><t>${escape(value)}</t></is></c>`
}).join('')}</row>`).join('')
const name = Buffer.from('xl/worksheets/sheet1.xml')
const body = Buffer.from(`<worksheet><sheetData>${rows}</sheetData></worksheet>`)
const local = Buffer.alloc(30)
local.writeUInt32LE(0x04034b50); local.writeUInt32LE(body.length, 18); local.writeUInt32LE(body.length, 22); local.writeUInt16LE(name.length, 26)
const central = Buffer.alloc(46)
central.writeUInt32LE(0x02014b50); central.writeUInt32LE(body.length, 20); central.writeUInt32LE(body.length, 24); central.writeUInt16LE(name.length, 28)
const end = Buffer.alloc(22)
end.writeUInt32LE(0x06054b50); end.writeUInt16LE(1, 8); end.writeUInt16LE(1, 10)
end.writeUInt32LE(central.length + name.length, 12); end.writeUInt32LE(local.length + name.length + body.length, 16)
return Buffer.concat([local, name, body, central, name, end])
}
@@ -0,0 +1,31 @@
import { parseXlsx } from './finance-downloads.mjs'
// Internal acceptance helper; callers retain their own fixture and deployment authorization.
export async function verifyDownloadCenterExport(context, module, exportType, parameters, headers, tokens = [], expectedStatus = 'SUCCEEDED') {
const options = { scenario: 'download-center-export', deploymentBlocker: true }
const base = '/api/platform/v1/files/export-tasks'
const created = await context.request('POST', base, { exportType, format: 'XLSX', taskName: `${context.marker}-${exportType}`, parameters })
const task = context.apiSuccess(module, `${exportType}创建下载任务`, created, '', options)
if (!task?.id) throw new Error('下载任务创建失败')
let state = task
for (let attempt = 0; attempt < 45 && !['SUCCEEDED', 'FAILED', 'EXPIRED'].includes(state.status); attempt++) {
if (attempt) await new Promise(resolve => setTimeout(resolve, 2000))
const response = await context.request('GET', `${base}/${encodeURIComponent(String(task.id))}`)
state = context.apiSuccess(module, `${exportType}查询下载任务`, response, '', options)
if (!state) throw new Error('下载任务查询失败')
}
context.assert(module, `${exportType}后台生成完成`, created, state.status === expectedStatus, state.failureMessage || state.status, options)
if (expectedStatus === 'FAILED' && state.status === 'FAILED') {
context.assert(module, `${exportType}失败原因可见`, created, Boolean(state.failureMessage), state.failureMessage || '', options)
return created
}
if (state.status !== 'SUCCEEDED') throw new Error(`导出未完成:${state.status}`)
const response = await context.requestBinary('GET', `${base}/${encodeURIComponent(String(task.id))}/download`)
context.assert(module, `${exportType}下载XLSX`, response, response.http === 200 && String(response.contentType).includes('spreadsheetml'), `bytes=${response.bytes?.length || 0}`, options)
const workbook = parseXlsx(response.bytes)
const content = workbook.rows.flat().join('|')
const valid = headers.every(header => workbook.headers.includes(header)) && tokens.every(token => content.includes(String(token)))
context.assert(module, `${exportType}表头与marker内容`, response, valid, `headers=${workbook.headers.join('|')}`, options)
if (!valid) throw new Error('导出文件缺少预期字段或本轮业务数据')
return response
}
@@ -1,6 +1,8 @@
import { exportWorkbook } from './download-center-fixture.mjs'
import { parseXlsx } from './finance-downloads.mjs'
import assert from 'node:assert/strict'
import test from 'node:test'
import { run, runQuoteSession, verifyOperations, requireCurrentFixture, readCsv, fixtureContract } from './fleet-quotes-operations.mjs'
import { run, runQuoteSession, verifyOperations, requireCurrentFixture, fixtureContract } from './fleet-quotes-operations.mjs'
import { AcceptanceRun } from '../admin-test-env-acceptance.mjs'
const copy = value => structuredClone(value)
@@ -10,6 +12,7 @@ function harness(fault) {
let next = 0, supplierStatus = 'ACTIVE'
let measured = { tripNo, recordVersion: '0', details: { actualDistanceMiles: null, actualFuelLitres: null, remark: null, evidenceFiles: [] } }
const measuredHistory = []
const exportTasks = new Map()
const expected = { physicalPallets: 1, palletSpaces: 2, ownPackages: 11, externalPackages: 4, externalAgreedAmounts: [{ currency: 'USD', amount: 0 }], costs: [] }
const response = (data, code = '200', http = 200) => ({ http, body: { code, data: copy(data) }, requestId: 'offline-request', traceId: 'offline-trace' })
function snapshot(row, action) { histories.get(row.quoteNo).unshift({ action, recordVersion: row.recordVersion, snapshot: copy(row) }) }
@@ -31,6 +34,16 @@ function harness(fault) {
apiSuccess(_module, label, result) { assert.equal(result.http, 200, label); assert.equal(result.body.code, '200', label); return result.body.data },
async request(method, path, body) {
calls.push({ method, path, body: copy(body) }); const u = new URL(path, 'http://offline.invalid'), p = u.pathname
if (p === '/api/platform/v1/files/export-tasks' && method === 'POST') {
const taskId = String(exportTasks.size + 1)
exportTasks.set(taskId, body)
return response({ id: taskId, status: 'PENDING' })
}
if (p.startsWith('/api/platform/v1/files/export-tasks/')) {
const task = exportTasks.get(p.split('/').at(-1))
const failed = task.parameters.kind === 'INVALID' || (task.parameters.from && Date.parse(task.parameters.from) > Date.parse(task.parameters.to))
return response({ id: p.split('/').at(-1), status: failed ? 'FAILED' : 'SUCCEEDED', failureMessage: failed ? '筛选参数无效' : null })
}
if (p === '/api/platform/v1/last-leg/fleet/trips/TR-CURRENT') return response({ trip: { tripNo, warehouseId: '92', departedAt, status: 'IN_TRANSIT', remark: fault === 'historical trip' ? 'OLD' : marker } })
if (p === '/api/platform/v1/suppliers' && method === 'POST') {
for (const field of ['name', 'region', 'legalRepresentative', 'contactName', 'phone', 'settlementPeriod']) assert.ok(body[field], field)
@@ -90,11 +103,11 @@ function harness(fault) {
},
async requestBinary(method, path) {
calls.push({ method, path }); const u = new URL(path, 'http://offline.invalid')
const error = response(null, 'A0400')
if (u.pathname.includes('/quotes/') && u.searchParams.get('size') === '0') { const invalid = response(null, 'A0422'); return { ...invalid, contentType: 'application/json', bytes: Buffer.from(JSON.stringify(invalid.body)) } }
if (fault === 'json export masquerades as csv' || (u.searchParams.has('from') && Date.parse(u.searchParams.get('from')) > Date.parse(u.searchParams.get('to')))) return { ...error, contentType: fault ? 'text/csv' : 'application/json', bytes: Buffer.from(JSON.stringify(error.body)) }
const text = u.pathname.includes('/quotes/') ? `\uFEFF报价号,金额\r\n${[...rows.keys()].join('\r\n')}` : `\uFEFF车次号,实际里程(mile)\r\n${tripNo},25.5`
return { http: 200, body: null, bytes: Buffer.from(text), contentType: 'text/csv;charset=UTF-8', requestId: 'offline-request', traceId: 'offline-trace' }
assert.match(u.pathname, /files\/export-tasks\/[^/]+\/download$/)
if (fault === 'json export masquerades as csv') return { http: 200, contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', bytes: Buffer.from('{"code":"A0400"}') }
const task = exportTasks.get(u.pathname.split('/').at(-2))
const bytes = task.exportType === 'FLEET_QUOTE_LIST' ? exportWorkbook(['报价号'], [[...rows.keys()].join(' / ')]) : exportWorkbook(['车次号', '实际里程(mile)'], [tripNo, '25.5'])
return { http: 200, body: null, bytes, contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', requestId: 'offline-request', traceId: 'offline-trace' }
},
}
return { context, rows, calls, supplierStatus: () => supplierStatus }
@@ -127,7 +140,7 @@ test('missing or stale fixture fails before any HTTP and cannot be treated as sk
test('proof cannot be overwritten on rerun and CSV detector rejects misleading success content', async () => {
const { context } = harness(); await run(context); const proof = context.createdResources.fleetQuoteAcceptance
await assert.rejects(run(context), /Existing proof/); assert.equal(context.createdResources.fleetQuoteAcceptance, proof)
assert.throws(() => readCsv({ http: 200, contentType: 'text/csv', bytes: Buffer.from('{"code":"A0403"}') }, '报价号'), /not a valid CSV/)
assert.throws(() => parseXlsx(Buffer.from('{"code":"A0403"}')), /ZIP/)
assert.throws(() => requireCurrentFixture({ marker: 'x' }), /current-run/)
})
test('unknown expected money is rejected before HTTP instead of comparing as zero', async () => {
@@ -141,10 +154,10 @@ test('quote acceptance is independent of DEPART and never requests operations, i
assert.equal(context.createdResources.fleetQuoteAcceptance.operationsStatus, 'NOT_TESTED')
assert.ok(calls.every(call => !/operations|inventory|finance|\/trips\//.test(call.path)))
})
test('operations defaults to read-only and never creates customer, supplier or quote records', async () => {
test('operations only reads business data and creates download tasks, never customer, supplier or quote records', async () => {
const { context, calls, rows } = harness(); delete context.createdResources.merchantQualificationFixture
await verifyOperations(context)
assert.ok(calls.every(call => call.method === 'GET')); assert.equal(rows.size, 0)
assert.ok(calls.every(call => call.method === 'GET' || (call.method === 'POST' && call.path === '/api/platform/v1/files/export-tasks' && call.body.exportType === 'TRIP_OPERATIONS_LIST'))); assert.equal(rows.size, 0)
assert.equal(context.createdResources.fleetOperationsAcceptance.measuredStatus, 'NOT_TESTED')
})
test('measured writes require an explicit independent idempotency namespace', async () => {
@@ -1,3 +1,4 @@
import { verifyDownloadCenterExport } from './download-center.mjs'
import { createHash } from 'node:crypto'
import { expectedAllocations } from './fleet-trip-cost-acceptance.mjs'
@@ -116,14 +117,6 @@ function requireQuoteCustomer(context) {
return customer
}
export function readCsv(response, requiredHeader) {
const text = new TextDecoder().decode(response?.bytes || new Uint8Array()).replace(/^\uFEFF/, '')
if (response?.http !== 200 || /json/i.test(response?.contentType || '') || /^\s*[{[]/.test(text)
|| !/^text\/csv(?:;|$)/i.test(response?.contentType || '') || !text.split(/\r?\n/, 1)[0].includes(requiredHeader)) {
throw new Error(`Export is not a valid CSV: HTTP=${response?.http}, code=${response?.body?.code || 'n/a'}, requestId=${response?.requestId || 'n/a'}, traceId=${response?.traceId || 'n/a'}`)
}
return text
}
const currency = value => { if (!['CNY', 'USD'].includes(value)) throw new Error('Unsupported or missing currency fact'); return value }
const amounts = rows => rows.map(row => [currency(row.currency), number(row.amount)]).sort((a, b) => a[0].localeCompare(b[0]))
const costs = rows => rows.map(row => [currency(row.currency), number(row.directCost), row.ownCost == null ? null : number(row.ownCost), row.externalCost == null ? null : number(row.externalCost)]).sort((a, b) => a[0].localeCompare(b[0]))
@@ -197,6 +190,7 @@ async function execute(context, { scope, writeMeasured = false, idempotencyNames
check(label, response, response.http === 200 && id(response.body?.code) === expectedCode && response.requestId && response.traceId)
}
async function binary(path) { const response = await context.requestBinary('GET', path); recordResponse('GET', path, response); return response }
const exportContext = Object.assign(Object.create(context), { request, requestBinary: (_method, path) => binary(path) })
function retain(row) {
check('报价返回稳定业务号', null, typeof row?.quoteNo === 'string' && row.quoteNo.length > 0)
if (!proof.quotes.includes(row.quoteNo)) proof.quotes.push(row.quoteNo)
@@ -281,13 +275,8 @@ async function execute(context, { scope, writeMeasured = false, idempotencyNames
check('用途独立状态未串改', null, stillPurchase.status === 'ACTIVE' && id(stillPurchase.recordVersion) === id(purchase.recordVersion))
const history = await command('GET', `${QUOTES}/${encode(sale.quoteNo)}/history`, undefined, '读取报价不可变版本历史')
check('历史保留原始零价与更正版本', null, history.length === 3 && history.some(h => Number(h.snapshot?.details?.looseDeliveryPrice) === 12.5) && history.some(h => Number(h.snapshot?.details?.looseDeliveryPrice) === 15))
const quoteCsv = await binary(`${QUOTES}/export?${query({ keyword: details.originCity })}`)
const quoteText = readCsv(quoteCsv, '报价号')
check('报价CSV为真实文件并包含本轮业务号', quoteCsv, quoteText.includes(sale.quoteNo) && quoteText.includes(purchase.quoteNo))
const error = await binary(`${QUOTES}/export?size=0`)
check('报价导出参数错误保留业务码', error, error.http === 200 && id(error.body?.code) === 'A0422')
let rejected = false; try { readCsv(error, '报价号') } catch { rejected = true }
check('报价下载拒绝JSON错误文件', error, rejected)
await verifyDownloadCenterExport(exportContext, MODULE, 'FLEET_QUOTE_LIST', { keyword: details.originCity }, ['报价号'], [sale.quoteNo, purchase.quoteNo])
await verifyDownloadCenterExport(exportContext, MODULE, 'FLEET_QUOTE_LIST', { kind: 'INVALID' }, [], [], 'FAILED')
proof.quoteStatus = 'VERIFIED'
}
if (scope === 'OPERATIONS_ONLY') {
@@ -308,14 +297,8 @@ async function execute(context, { scope, writeMeasured = false, idempotencyNames
}
const finalReport = await report()
if (writeMeasured) check('运营列表批量回显最新实测记录', null, Number(finalReport.page.records[0].measured?.actualDistanceMiles) === 25.5 && Number(finalReport.page.records[0].measured?.actualFuelLitres) === 8.2)
const exported = await binary(`${OPERATIONS}/export?${query({ from, to, warehouseId: fixture.warehouseId, keyword: fixture.tripNo })}`)
const operationsCsv = readCsv(exported, '实际里程(mile)')
check('运营CSV包含本轮车次及实际里程字段', exported, operationsCsv.includes(fixture.tripNo) && (!writeMeasured || operationsCsv.includes('25.5')))
const exportError = await binary(`${OPERATIONS}/export?${query({ from: to, to: from })}`)
check('错误导出保留标准业务错误而非CSV成功', exportError, exportError.http === 200 && id(exportError.body?.code) === 'A0400')
let rejected = false
try { readCsv(exportError, '车次号') } catch { rejected = true }
check('下载检查器拒绝JSON错误响应', exportError, rejected)
await verifyDownloadCenterExport(exportContext, MODULE, 'TRIP_OPERATIONS_LIST', { from, to, warehouseId: fixture.warehouseId, keyword: fixture.tripNo }, ['车次号', '实际里程(mile)'], [fixture.tripNo, ...(writeMeasured ? ['25.5'] : [])])
await verifyDownloadCenterExport(exportContext, MODULE, 'TRIP_OPERATIONS_LIST', { from: to, to: from }, [], [], 'FAILED')
proof.operationsStatus = 'VERIFIED'
}
proof.status = 'VERIFIED'
@@ -0,0 +1,109 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
import { moneyCents, validateTripFinance, verifyFleetTripFinance } from './fleet-trip-finance-acceptance.mjs'
const scope = { tripNo: 'TR-CURRENT', dispatchNos: ['DS-CURRENT'], receiptsVisible: true,
businesses: [{ customerId: '9007199254740993', deliveryNo: 'DL-CURRENT' }] }
test('default fulfillment runs finance verification using its own departed trip and delivery numbers', () => {
const source = readFileSync(new URL('./phase5-overseas-fulfillment.mjs', import.meta.url), 'utf8')
assert.match(source, /import \{ verifyFleetTripFinance \} from '\.\/fleet-trip-finance-acceptance\.mjs'/)
assert.match(source, /verifyFleetTripFinance\(context, MODULE, \{ tripNo: tripProof\.tripNo, dispatchNos: \[dispatchNo\]/)
assert.match(source, /businesses: deliveryNos\.map\(deliveryNo => \(\{ customerId, deliveryNo \}\)\)/)
})
function fixture(status = 'AVAILABLE', received = null) {
return { tripNo: scope.tripNo, dispatchNos: [...scope.dispatchNos], receiptsVisible: true,
attributionStatus: 'ATTRIBUTED', attributionReason: '唯一发车来源', finance: {
status: 'AVAILABLE', reason: '有效财务来源',
sources: [{ customerId: scope.businesses[0].customerId, businessNo: 'DL-CURRENT', sourceNo: 'FS-CURRENT',
sourceVersion: 1, status, currency: 'USD', amount: '100.03' }],
totals: [{ currency: 'USD', receivableAmount: '100.03', receivedAmount: received }],
documents: received === null ? [] : [{ billNo: 'BILL-CURRENT', currency: 'USD', receivedAmount: received,
allocationStatus: 'ATTRIBUTED', reason: '已确认收款分配' }],
} }
}
test('decimal amounts use integer cents and refuse unknown or unsafe precision', () => {
assert.equal(moneyCents('100.03'), 10003n)
assert.equal(moneyCents('9007199254740993.00'), 900719925474099300n)
for (const value of [null, undefined, '', -1, NaN, Infinity, '1.005', '1e3', 9007199254740993]) {
assert.throws(() => moneyCents(value))
}
})
test('available, billed and settled sources survive partial and full payment readback', () => {
for (const [state, paid] of [['AVAILABLE', null], ['BILLED', '30.01'], ['SETTLED', '100.03']]) {
assert.equal(validateTripFinance(fixture(state, paid), scope), true)
}
})
test('currencies are reconciled separately without floating point addition', () => {
const value = fixture()
value.finance.sources.push({ ...value.finance.sources[0], sourceNo: 'FS-CNY', currency: 'CNY', amount: '0.20' })
value.finance.totals.push({ currency: 'CNY', receivableAmount: '0.20', receivedAmount: null })
assert.equal(validateTripFinance(value, scope), true)
value.finance.totals[0].receivableAmount = '100.23'
assert.throws(() => validateTripFinance(value, scope), /原币/)
})
test('pending allocation is explicitly unknown and never promoted to complete or zero', () => {
const value = fixture('BILLED')
value.finance.documents = [{ billNo: 'MIXED-BILL', currency: 'USD', receivedAmount: null,
allocationStatus: 'PENDING_ALLOCATION', reason: '账单含本车次以外来源' }]
assert.equal(validateTripFinance(value, scope), true)
value.finance.totals[0].receivedAmount = 0
assert.throws(() => validateTripFinance(value, scope), /完整实收/)
const empty = fixture()
Object.assign(empty.finance, { sources: [], totals: [], documents: [], status: 'PENDING_ALLOCATION' })
assert.equal(validateTripFinance(empty, scope), true)
empty.finance.status = 'AVAILABLE'
assert.throws(() => validateTripFinance(empty, scope))
})
test('receipt restricted projection cannot leak totals or documents', () => {
const value = fixture('BILLED'); value.receiptsVisible = false
const restricted = { ...scope, receiptsVisible: false }
assert.equal(validateTripFinance(value, restricted), true)
value.finance.totals[0].receivedAmount = '30.01'
assert.throws(() => validateTripFinance(value, restricted), /泄露金额/)
value.finance.totals[0].receivedAmount = null
value.finance.documents = fixture('BILLED', '30.01').finance.documents
assert.throws(() => validateTripFinance(value, restricted), /泄露收款/)
})
test('NON_NULL省略未知实收仍保持未知,明确归属的实收和应收不得缺失', () => {
const withoutNulls = value => JSON.parse(JSON.stringify(value, (_key, item) => item === null ? undefined : item))
assert.equal(validateTripFinance(withoutNulls(fixture()), scope), true)
const restricted = withoutNulls(fixture()); restricted.receiptsVisible = false
assert.equal(validateTripFinance(restricted, { ...scope, receiptsVisible: false }), true)
const pending = fixture('BILLED')
pending.finance.documents = [{ billNo: 'BILL-MIXED', currency: 'USD', receivedAmount: null, allocationStatus: 'PENDING_ALLOCATION', reason: '归属未完成' }]
assert.equal(validateTripFinance(withoutNulls(pending), scope), true)
const missingPaid = fixture('BILLED', '10.00'); delete missingPaid.finance.documents[0].receivedAmount
assert.throws(() => validateTripFinance(missingPaid, scope))
const missingAmount = fixture(); delete missingAmount.finance.sources[0].amount
assert.throws(() => validateTripFinance(missingAmount, scope))
})
test('wrong customer, trip, duplicate sources and invented receipts all reject', () => {
const mutations = [v => { v.tripNo = 'TR-FOREIGN' }, v => { v.dispatchNos = ['DS-FOREIGN'] },
v => { v.finance.sources[0].customerId = '9007199254740994' },
v => { v.finance.sources[0].businessNo = 'DL-FOREIGN' },
v => { v.finance.sources.push({ ...v.finance.sources[0] }) },
v => { v.finance.sources[0].status = 'VOIDED' }, v => { v.finance.sources[0].sourceVersion = 0 },
v => { v.finance.totals[0].receivedAmount = 25 },
v => { v.finance.totals.push({ ...v.finance.totals[0] }) }]
for (const mutate of mutations) { const value = fixture(); mutate(value); assert.throws(() => validateTripFinance(value, scope)) }
})
test('read-only orchestration retains proof and detects execution state mutation or HTTP failure', async () => {
for (const fault of ['', 'mutation', 'http']) {
const calls = [], checks = []; let reads = 0
const context = { marker: 'CURRENT', createdResources: {},
assert: (_m, _n, _r, passed) => checks.push(passed), apiSuccess: (_m, _n, r) => r.body.data,
request: async (method, path) => {
calls.push({ method, path })
if (path.endsWith('/finance')) return { http: fault === 'http' ? 503 : 200, body: { code: '200', data: fixture() } }
return { http: 200, body: { code: '200', data: { trip: { tripNo: scope.tripNo,
departedAt: '2026-10-05T00:00:00Z', recordVersion: ++reads === 2 && fault === 'mutation' ? 2 : 1 } } } }
} }
if (fault) await assert.rejects(verifyFleetTripFinance(context, 'offline', scope))
else assert.equal((await verifyFleetTripFinance(context, 'offline', scope)).status, 'READ_ONLY_ATTRIBUTION_VERIFIED')
assert.ok(calls.every(row => row.method === 'GET'))
assert.equal(context.createdResources.fleetTripFinance[0].status,
fault ? 'CHECKING' : 'READ_ONLY_ATTRIBUTION_VERIFIED')
}
})
@@ -0,0 +1,102 @@
const BASE = '/api/platform/v1/last-leg/fleet'
const OPTIONS = { scenario: 'fleet-trip-finance-attribution', deploymentBlocker: true }
const id = value => value == null ? '' : String(value)
const sameSet = (a, b) => a.length === new Set(a).size && b.length === new Set(b).size
&& JSON.stringify([...a].sort()) === JSON.stringify([...b].sort())
export function moneyCents(value) {
if (!['number', 'string'].includes(typeof value)) throw Error('金额必须是明确数值')
const match = /^(0|[1-9]\d*)(?:\.(\d{1,2}))?$/.exec(String(value))
if (!match || (typeof value === 'number' && !Number.isSafeInteger(value * 100)
&& Math.abs(value * 100 - Math.round(value * 100)) > 0.000001)) throw Error('金额格式或精度无效')
const cents = BigInt(match[1]) * 100n + BigInt((match[2] || '').padEnd(2, '0'))
if (typeof value === 'number' && cents > BigInt(Number.MAX_SAFE_INTEGER)) throw Error('金额超过安全精度')
return cents
}
// A pending amount stays unknown. This does not use agreed booking prices as paid amounts.
export function validateTripFinance(value, scope) {
const fail = message => { throw Error(`车次财务证据:${message}`) }
if (value?.tripNo !== scope.tripNo || value.receiptsVisible !== scope.receiptsVisible
|| !Array.isArray(value.dispatchNos) || !sameSet(value.dispatchNos, scope.dispatchNos)) fail('车次或权限范围漂移')
if (!['ATTRIBUTED', 'PENDING_ALLOCATION'].includes(value.attributionStatus)
|| typeof value.attributionReason !== 'string' || !value.attributionReason.trim()) fail('缺少业务归属判定')
const finance = value.finance
if (!['AVAILABLE', 'PENDING_ALLOCATION'].includes(finance?.status)
|| !Array.isArray(finance.sources) || !Array.isArray(finance.totals) || !Array.isArray(finance.documents)
|| typeof finance.reason !== 'string' || !finance.reason.trim()) fail('财务结果不完整')
const sourceKeys = new Set(), amounts = new Map(), covered = new Set()
for (const source of finance.sources) {
const business = `${id(source.customerId)}:${source.businessNo}`
if (!scope.businesses.some(row => `${row.customerId}:${row.deliveryNo}` === business)) fail('费用混入其他客户或派送')
const key = `${business}:${source.sourceNo}`
if (typeof source.sourceNo !== 'string' || !source.sourceNo.trim() || typeof source.customerId !== 'string'
|| !/^[1-9]\d*$/.test(source.customerId) || sourceKeys.has(key) || !Number.isSafeInteger(source.sourceVersion) || source.sourceVersion < 1
|| !['AVAILABLE', 'BILLED', 'SETTLED'].includes(source.status)) fail('费用重复、版本或生命周期无效')
if (!/^[A-Z]{3}$/.test(source.currency)) fail('费用币种无效')
sourceKeys.add(key); covered.add(business)
amounts.set(source.currency, (amounts.get(source.currency) || 0n) + moneyCents(source.amount))
}
if (finance.status === 'AVAILABLE' && scope.businesses.some(row => !covered.has(`${row.customerId}:${row.deliveryNo}`))) fail('缺失来源却声称应收完整')
if (!finance.sources.length && finance.status !== 'PENDING_ALLOCATION') fail('空来源不能冒充完整金额')
const totals = new Map()
for (const total of finance.totals) {
if (totals.has(total.currency) || !amounts.has(total.currency)
|| moneyCents(total.receivableAmount) !== amounts.get(total.currency)) fail('应收未按原币来源核平')
if (!scope.receiptsVisible && total.receivedAmount != null) fail('无实收权限泄露金额')
if (total.receivedAmount != null) moneyCents(total.receivedAmount)
totals.set(total.currency, total)
}
if (totals.size !== amounts.size) fail('币种汇总缺失')
if (!scope.receiptsVisible && finance.documents.length) fail('无实收权限泄露收款来源')
const documents = new Map(), paid = new Map(), pending = new Set()
for (const document of finance.documents) {
if (!document.billNo || documents.has(document.billNo) || !totals.has(document.currency)
|| !['ATTRIBUTED', 'PENDING_ALLOCATION'].includes(document.allocationStatus)) fail('收款单据归属或去重无效')
documents.set(document.billNo, document)
if (document.allocationStatus === 'PENDING_ALLOCATION') {
if (document.receivedAmount != null || !document.reason?.trim()) fail('未分配金额必须保留未知值及原因')
pending.add(document.currency)
} else {
paid.set(document.currency, (paid.get(document.currency) || 0n) + moneyCents(document.receivedAmount))
}
}
for (const [currency, total] of totals) {
if (pending.has(currency) && total.receivedAmount != null) fail('混合未分配来源却给出完整实收')
if (total.receivedAmount != null && (!paid.has(currency) || moneyCents(total.receivedAmount) !== paid.get(currency))) fail('实收与明确归属单据不一致')
}
return true
}
export async function verifyFleetTripFinance(context, module, scope) {
if (!scope?.tripNo || !scope.dispatchNos?.length || !scope.businesses?.length
|| scope.dispatchNos.some(no => !no) || scope.businesses.some(row => !/^[1-9]\d*$/.test(row.customerId) || !row.deliveryNo)) {
throw Error('车次财务检查缺少本轮实际发车及派送来源')
}
const proof = { marker: context.marker, tripNo: scope.tripNo, status: 'CHECKING', receiptsVisible: scope.receiptsVisible,
boundaries: '只读归属和原币核平;无账单/收款样本时不声称已验证部分及全额收款生命周期' }
context.createdResources.fleetTripFinance ??= []
context.createdResources.fleetTripFinance.push(proof)
const read = async (path, label) => {
const response = await context.request('GET', path)
const value = context.apiSuccess(module, label, response, '', OPTIONS)
if (response?.http !== 200 || String(response.body?.code) !== '200' || !value) throw Error(`${label}失败`)
return { response, value }
}
const path = `${BASE}/trips/${encodeURIComponent(scope.tripNo)}`
const before = await read(path, '财务查询前冻结本轮车次执行状态')
if (before.value.trip?.tripNo !== scope.tripNo || !before.value.trip.departedAt) throw Error('本轮车次尚无实际发车事实')
const result = await read(`${BASE}/operations/${encodeURIComponent(scope.tripNo)}/finance`, '按本轮实际车次查询Finance应收与实收')
proof.result = result.value
try { validateTripFinance(result.value, scope) }
catch (error) { context.assert(module, '车次财务来源、权限及金额严格核平', result.response, false, error.message, OPTIONS); throw error }
context.assert(module, '车次财务来源、权限及金额严格核平', result.response, true,
`sources=${result.value.finance.sources.length}, currencies=${result.value.finance.totals.length}, attribution=${result.value.attributionStatus}`, OPTIONS)
const after = await read(path, '财务查询后回查车次零写入')
const unchanged = JSON.stringify(before.value) === JSON.stringify(after.value)
context.assert(module, '只读财务查询不改变车次、资源与审核', after.response, unchanged, `tripNo=${scope.tripNo}`, OPTIONS)
if (!unchanged) throw Error('只读财务查询改变车次事实')
proof.result = result.value
proof.status = 'READ_ONLY_ATTRIBUTION_VERIFIED'
return proof
}
@@ -0,0 +1,93 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
import { groupedDispatchMatches, verifyGroupedDispatch } from './grouped-dispatch-acceptance.mjs'
const candidates = [1, 2].map(n => ({ planId: '11', planPalletId: `2${n}`, warehousePalletId: `3${n}`,
overseasWarehouseId: '44', customerId: '55', inboundCode: `IN-${n}`, packageCount: n }))
const groups = candidates.map((row, n) => ({ draftContainerId: `6${n}`, planPalletIds: [row.planPalletId], returnReceiptIds: [] }))
const results = () => groups.map((group, n) => ({ draftContainerId: group.draftContainerId,
dispatch: { dispatchNo: `DS-${n}`, status: 'PENDING_DEPARTURE', palletCount: 1,
packageCount: candidates[n].packageCount, pickedPackageCount: 0, shippedPackageCount: 0,
items: [{ ...candidates[n], id: `7${n}` }] } }))
test('registered dispatch flow consumes current-run selected candidates before its main draft', () => {
const source = readFileSync(new URL('./phase4-overseas-dispatch.mjs', import.meta.url), 'utf8')
const call = source.indexOf('await verifyGroupedDispatch(context, MODULE, selectedCandidates)')
assert.ok(call > source.indexOf('const selectedCandidates'))
assert.ok(call < source.indexOf('const draftToken = `${context.marker}-DISPATCH-DRAFT`'))
})
test('two loading groups preserve distinct dispatches and exact physical sources', () => {
assert.equal(groupedDispatchMatches(results(), groups, candidates), true)
assert.equal(groupedDispatchMatches(results().reverse(), groups, candidates), true)
for (const mutate of [r => r.pop(), r => { r[1].dispatch.dispatchNo = r[0].dispatch.dispatchNo },
r => { r[1].draftContainerId = r[0].draftContainerId },
r => { r[0].dispatch.items = r[1].dispatch.items },
r => { r[0].dispatch.packageCount++ }, r => { r[0].dispatch.items[0].warehousePalletId = '999' },
r => { r[0].dispatch.items[0].inboundCode = 'FOREIGN' }, r => { r[0].dispatch.status = 'PICKING' }]) {
const value = results(); mutate(value); assert.equal(groupedDispatchMatches(value, groups, candidates), false)
}
})
function server(fault = '') {
let draft, next = 0, arranged = false
const calls = [], checks = [], orders = new Map()
const response = (data, code = '200') => ({ http: 200, body: { code, data }, requestId: 'offline', traceId: 'offline' })
const context = { marker: 'CURRENT', createdResources: {},
assert: (_m, label, _r, passed) => checks.push({ label, passed }),
async request(method, path, body) {
calls.push({ method, path, body: structuredClone(body) })
if (path.endsWith('/drafts/snapshot')) { draft = { draftToken: body.draftToken, containers: [] }; return response(null) }
if (path.endsWith('/drafts/containers')) {
assert.equal(body.capacity, 15, 'formal draft supports 15 or 30 pallet vehicle capacity')
const container = { id: groups[next++].draftContainerId, palletIds: [], capacity: body.capacity }
draft.containers.push(container); return response(container)
}
if (path.endsWith('/draft-locks/lock')) {
draft.containers.find(row => row.id === body.draftContainerId).palletIds.push(body.planPalletId); return response(null)
}
if (path.endsWith('/drafts')) return response(structuredClone(draft ? [draft] : []))
if (path.endsWith('/drafts/pre-arrange')) {
if (body.groups[0].planPalletIds[0] !== candidates[0].planPalletId) {
if (fault === 'reject-mutates-draft') draft.containers[0].palletIds = []
return response(null, 'A0409')
}
if (fault === 'unknown-post') return { http: 0, body: null }
if (!arranged) { for (const row of results()) orders.set(row.dispatch.dispatchNo, row.dispatch); arranged = true }
const result = groups.map(group => ({ draftContainerId: group.draftContainerId,
dispatch: structuredClone(orders.get(`DS-${groups.indexOf(group)}`)) }))
if (fault === 'merged-groups') result[1].dispatch.dispatchNo = result[0].dispatch.dispatchNo
return response(result)
}
if (path.endsWith('/draft-locks/release')) { draft = null; return response(null) }
if (path.includes('/candidates?')) return response(candidates)
const match = path.match(/\/dispatches\/(DS-\d)(?:\/items\/(\d+))?$/)
const order = orders.get(match?.[1]); assert.ok(order, path)
if (method === 'DELETE') { order.items = []; order.palletCount = 0; order.packageCount = 0; return response(null) }
return response(structuredClone(order))
} }
return { context, calls, checks }
}
test('grouped orchestration proves rejection, same-group replay and releases owned unpicked sources', async () => {
const { context, calls, checks } = server()
const proof = await verifyGroupedDispatch(context, 'offline', candidates)
assert.equal(proof.status, 'GROUPED_PREARRANGE_VERIFIED')
assert.equal(proof.cleanup, 'OWNED_UNPICKED_SOURCES_RELEASED')
assert.equal(proof.results.length, 2)
assert.ok(checks.every(row => row.passed))
assert.equal(calls.filter(row => row.path.endsWith('/drafts/pre-arrange')).length, 3)
assert.equal(calls.filter(row => row.method === 'DELETE').length, 2)
assert.ok(calls.every(row => !/\/depart|\/pick|\/delivery/.test(row.path)))
})
test('rejected grouping that mutates the draft stops before any valid pre-arrangement', async () => {
const { context, calls } = server('reject-mutates-draft')
await assert.rejects(verifyGroupedDispatch(context, 'offline', candidates), /原草稿/)
assert.equal(calls.filter(row => row.path.endsWith('/drafts/pre-arrange')).length, 1)
assert.notEqual(context.createdResources.groupedDispatch.status, 'GROUPED_PREARRANGE_VERIFIED')
})
test('unknown successful-write transport result is not automatically retried or deleted', async () => {
const { context, calls } = server('unknown-post')
await assert.rejects(verifyGroupedDispatch(context, 'offline', candidates), /原子创建/)
assert.equal(calls.filter(row => row.path.endsWith('/drafts/pre-arrange')).length, 2)
assert.equal(calls.filter(row => row.method === 'DELETE').length, 0)
assert.equal(calls.filter(row => row.path.endsWith('/draft-locks/release')).length, 0)
assert.equal(context.createdResources.groupedDispatch.cleanup, 'PRESERVED_FOR_REVIEW')
})
@@ -0,0 +1,124 @@
const BASE = '/api/platform/v1/last-leg/dispatches'
const OPTIONS = { scenario: 'grouped-dispatch-atomic-draft', deploymentBlocker: true }
const id = value => value == null ? '' : String(value)
const ok = response => response?.http === 200 && String(response.body?.code) === '200'
const sorted = values => [...values].map(id).sort()
export function groupedDispatchMatches(result, groups, candidates) {
if (!Array.isArray(result) || result.length !== groups.length) return false
const dispatchNos = new Set(), seen = new Set()
return result.every(row => {
const group = groups.find(group => id(group.draftContainerId) === id(row.draftContainerId))
const dispatch = row.dispatch
if (!group || seen.has(id(row.draftContainerId)) || !dispatch?.dispatchNo || dispatchNos.has(dispatch.dispatchNo)
|| dispatch.status !== 'PENDING_DEPARTURE' || !Array.isArray(dispatch.items)) return false
seen.add(id(row.draftContainerId)); dispatchNos.add(dispatch.dispatchNo)
const expected = candidates.filter(candidate => group.planPalletIds.includes(id(candidate.planPalletId)))
if (JSON.stringify(sorted(dispatch.items.map(item => item.planPalletId))) !== JSON.stringify(sorted(group.planPalletIds))
|| dispatch.items.length !== group.planPalletIds.length || Number(dispatch.palletCount) !== expected.length
|| Number(dispatch.packageCount) !== expected.reduce((sum, item) => sum + Number(item.packageCount), 0)) return false
return dispatch.items.every(item => expected.some(candidate => id(candidate.planPalletId) === id(item.planPalletId)
&& id(candidate.warehousePalletId) === id(item.warehousePalletId)
&& id(candidate.overseasWarehouseId) === id(item.overseasWarehouseId)
&& candidate.inboundCode === item.inboundCode && Number(candidate.packageCount) === Number(item.packageCount)))
})
}
// Reuses only current-run unpicked stock; independent empty dispatch records remain as audit evidence.
export async function verifyGroupedDispatch(context, module, candidates) {
if (!Array.isArray(candidates) || candidates.length < 2
|| new Set(candidates.map(row => id(row.planPalletId))).size !== candidates.length
|| new Set(candidates.map(row => id(row.warehousePalletId))).size !== candidates.length
|| candidates.some(row => ['planId', 'planPalletId', 'warehousePalletId', 'overseasWarehouseId', 'customerId']
.some(key => typeof row[key] !== 'string' || !/^[1-9]\d*$/.test(row[key]))
|| typeof row.inboundCode !== 'string' || !row.inboundCode.trim()
|| !Number.isSafeInteger(Number(row.packageCount)) || Number(row.packageCount) < 1)) {
throw Error('分组预排缺少本轮两个独立实物托盘')
}
const draftToken = `${context.marker}-GROUPED`.slice(0, 64)
const proof = { marker: context.marker, draftToken, status: 'CHECKING', groups: [], results: [], cleanup: 'PENDING' }
context.createdResources.groupedDispatch = proof
const check = (label, response, passed) => {
context.assert(module, label, response, passed, `draftToken=${draftToken}`, OPTIONS)
if (!passed) throw Error(`分组预排:${label}`)
}
const request = async (method, path, body) => {
let response
try { response = await context.request(method, path, body) }
catch (error) {
if (!['GET', 'HEAD'].includes(method)) {
proof.outcomeUnknown = true
proof.cleanup = 'PRESERVED_FOR_REVIEW'
}
throw error
}
if (!['GET', 'HEAD'].includes(method) && (!response?.http || response.http >= 500)) {
proof.outcomeUnknown = true
proof.cleanup = 'PRESERVED_FOR_REVIEW'
}
return response
}
const command = async (method, path, body, label) => {
const response = await request(method, path, body)
check(label, response, ok(response)); return response.body.data
}
const ownDraft = async () => {
const rows = await command('GET', `${BASE}/drafts`, undefined, '读取分组预排草稿作用域')
if (!Array.isArray(rows)) throw Error('分组预排草稿返回格式无效')
return rows.filter(row => row.draftToken === draftToken)
}
try {
await command('POST', `${BASE}/drafts/snapshot`, { draftToken,
planIds: [...new Set(candidates.map(row => id(row.planId)))], containersJson: '[]' }, '保存本轮分组草稿')
for (const candidate of candidates) {
const container = await command('POST', `${BASE}/drafts/containers`, { draftToken, capacity: 15 }, '建立独立装车分组')
if (!container?.id) throw Error('分组缺少服务端柜ID')
const group = { draftContainerId: id(container.id), planPalletIds: [id(candidate.planPalletId)], returnReceiptIds: [] }
proof.groups.push(group)
await command('POST', `${BASE}/draft-locks/lock`, { draftToken, draftContainerId: group.draftContainerId,
planPalletId: group.planPalletIds[0] }, '将本轮托盘绑定到所属装车分组')
}
const before = JSON.stringify(await ownDraft())
const swapped = proof.groups.map((group, index) => ({ ...group,
planPalletIds: proof.groups[(index + 1) % proof.groups.length].planPalletIds }))
const rejected = await request('POST', `${BASE}/drafts/pre-arrange`, { draftToken, groups: swapped })
check('来源与分组不一致明确拒绝A0409', rejected,
[200, 409].includes(rejected.http) && String(rejected.body?.code) === 'A0409')
check('分组拒绝后原草稿全部来源与绑定不变', null, JSON.stringify(await ownDraft()) === before)
const body = { draftToken, groups: proof.groups }
const result = await command('POST', `${BASE}/drafts/pre-arrange`, body, '原子创建每组独立客户排车')
if (Array.isArray(result)) proof.results = result
check('每组一张排车且实物托盘与数量不跨组', null, groupedDispatchMatches(result, proof.groups, candidates))
const replay = await command('POST', `${BASE}/drafts/pre-arrange`, body, '同草稿同分组重放')
check('分组重放保留原排车业务号与明细', null, JSON.stringify(replay) === JSON.stringify(result))
proof.status = 'GROUPED_PREARRANGE_CHECKED'
} finally {
// Only release resources whose successful response identified an owned dispatch; never retry an unknown POST.
if (!proof.outcomeUnknown) {
for (const row of proof.results) {
const dispatchNo = row.dispatch?.dispatchNo
if (!dispatchNo) continue
const detail = await command('GET', `${BASE}/${encodeURIComponent(dispatchNo)}`, undefined, '清理前回查分组排车仍未拣货')
check('仅清理本轮分组未拣托盘', null, detail?.status === 'PENDING_DEPARTURE'
&& groupedDispatchMatches([{ ...row, dispatch: detail }],
proof.groups.filter(group => id(group.draftContainerId) === id(row.draftContainerId)), candidates)
&& detail.items.every(item => /^[1-9]\d*$/.test(id(item.id)))
&& Number(detail.pickedPackageCount) === 0 && Number(detail.shippedPackageCount) === 0)
for (const item of detail.items) {
await command('DELETE', `${BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(item.id))}`,
undefined, '移除分组测试明细释放正式托盘锁')
}
const empty = await command('GET', `${BASE}/${encodeURIComponent(dispatchNo)}`, undefined, '回查分组对照单明细归零')
check('分组对照排车释放后数量归零', null,
empty.items?.length === 0 && Number(empty.palletCount) === 0 && Number(empty.packageCount) === 0)
}
await command('POST', `${BASE}/draft-locks/release`, { draftToken }, '释放本轮分组草稿')
const available = await command('GET', `${BASE}/candidates?sourceStage=RECEIVED`, undefined, '分组检查后回查托盘可继续主链预排')
check('分组验收来源回到正式候选', null, Array.isArray(available)
&& candidates.every(candidate => available.some(row => id(row.planPalletId) === id(candidate.planPalletId))))
proof.cleanup = 'OWNED_UNPICKED_SOURCES_RELEASED'
}
}
proof.status = 'GROUPED_PREARRANGE_VERIFIED'
return proof
}
@@ -111,6 +111,8 @@ test('fixture-unavailable statuses stay supported without becoming hard requirem
const matrix = { pages: {
inbounds: { requiredStatuses: ['WAIT_MEASURE', 'MEASURE_REJECTED', 'WAIT_PRICING', 'FEE_CONFIRMING', 'INBOUNDING', 'VOIDED'], fixtures: {
WAIT_MEASURE: { id: '1' },
FEE_CONFIRMING: { id: '5' },
WAIT_PRICING: { id: '6' },
} },
unifiedOrders: { requiredStatuses: ['WAIT_SPLIT', 'LOADING', 'LOADED', 'DELIVERED'], fixtures: {
WAIT_SPLIT: { id: '2' }, LOADING: { id: '3' }, LOADED: { id: '4' },
@@ -121,9 +123,11 @@ test('fixture-unavailable statuses stay supported without becoming hard requirem
assert.deepEqual(matrix.pages.inbounds.supportedStatuses,
['WAIT_MEASURE', 'MEASURE_REJECTED', 'WAIT_PRICING', 'FEE_CONFIRMING', 'INBOUNDING', 'VOIDED'])
assert.deepEqual(matrix.pages.inbounds.requiredStatuses, ['WAIT_MEASURE'])
assert.deepEqual(matrix.pages.inbounds.requiredStatuses, ['WAIT_MEASURE', 'WAIT_PRICING', 'FEE_CONFIRMING'])
assert.equal(matrix.pages.inbounds.coverageGaps.FEE_CONFIRMING, undefined)
assert.deepEqual(matrix.pages.unifiedOrders.requiredStatuses, ['WAIT_SPLIT', 'LOADING', 'LOADED', 'DELIVERED'])
assert.equal(matrix.pages.inbounds.coverageGaps.WAIT_PRICING.category, 'fixture-unavailable')
assert.equal(matrix.pages.inbounds.coverageGaps.WAIT_PRICING, undefined)
assert.equal(matrix.pages.inbounds.coverageGaps.INBOUNDING.category, 'fixture-unavailable')
assert.equal(matrix.pages.unifiedOrders.coverageGaps, undefined)
assert.deepEqual(missingRequiredStateFixtures(matrix), ['unifiedOrders:DELIVERED'])
assert.match(source, /gap\.category, \{ marker: context\.marker, page, status, blocking: false \}/)
@@ -10,9 +10,7 @@ const GROUPS = [
const FIXTURE_UNAVAILABLE = {
inbounds: {
MEASURE_REJECTED: '当前稳定 Admin 契约未开放测量审核驳回接口,无法稳定构造该状态',
WAIT_PRICING: '自动计费会继续推进,无法稳定保留 WAIT_PRICING',
FEE_CONFIRMING: '当前无可稳定保留的费用确认前公开生成链路',
INBOUNDING: '自动入库在同一调用内继续推进 WAIT_SPLIT,无可暂停 API',
INBOUNDING: '当前公开接口没有可独立保留的 INBOUNDING 生成或暂停链路;不通过写业务SQL伪造中间状态',
VOIDED: '当前 Controller/Service 没有作废入库单的公开 API',
},
}
@@ -3,7 +3,92 @@ import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { createCompletedOrder, transition } from './order-inbound-status-fixtures.mjs'
import { createCompletedOrder, transition, retainFeeConfirmingFixture, retainWaitPricingFixture } from './order-inbound-status-fixtures.mjs'
test('待计费样本通过同维度真实渠道变更生成且退出独立会话', async () => {
const execute = async (override = {}) => {
const calls = []
let detailReads = 0
const order = { id: '9', version: 2, status: 'APPROVED', receivingWarehouseId: '10',
destinationWarehouseId: '11', amazonWarehouseCode: 'ABE4', destinationWarehouseName: 'ABE4',
destinationType: 'FBA', pricingTaxMode: 'TAX_INCLUDED', channelCode: 'OLD', customerRemark: 'current-run' }
const context = { marker: 'current-run', context: { merchantQualificationFixture: { username: 'fixture', password: 'fixture' } },
async request(method, url, body, token) {
calls.push({ method, url, body, token })
let data
if (url === '/api/platform/inbounds/8?tenantId=7') {
detailReads += 1
data = { summary: { id: '8', tenantId: '7', orderId: '9', inboundCode: 'IN-8',
processStatus: detailReads === 1 ? 'FEE_CONFIRMING' : 'WAIT_PRICING',
channelCode: 'NEW', feeStatus: 'UNPRICED', ...(detailReads === 1 ? override.before : override.after) } }
} else if (url.includes('/orders/first-leg/9')) data = { order: { ...order, ...override.order }, cargoes: [{ itemNo: 1 }] }
else if (url === '/api/auth/login') data = { accessToken: 'test-session', refreshToken: 'test-refresh' }
else if (url.includes('/tenant/profile')) data = { id: override.customerId || '7' }
else if (url.includes('/pricing/channels')) data = override.channels ?? [
{ channelCode: 'OLD', pricingTaxModes: ['TAX_INCLUDED'], priceBookVersionId: '12' },
{ channelCode: 'NEW', channelName: '真实新渠道', pricingTaxModes: ['TAX_INCLUDED'], priceBookVersionId: '13' } ]
else if (url.includes('/pricing/estimate')) data = { channelCode: 'NEW', pricingTaxMode: 'TAX_INCLUDED', priceBookVersionId: '13' }
else if (url.includes('/declaration')) data = { version: 3, channelChanged: true, ...override.changed }
else if (url.includes('processStatus=WAIT_PRICING')) data = { records: override.rows ?? [{ id: '8', processStatus: 'WAIT_PRICING' }] }
return { http: 200, body: { code: '200', data } }
}, apiSuccess: (_m, _n, r) => r.body.data, assert() {} }
try { await retainWaitPricingFixture(context, { inboundId: '8', tenantId: '7', orderId: '9' }) }
finally {
if (calls.some(row => row.url === '/api/auth/login')) assert.equal(calls.at(-1).url, '/api/auth/logout')
}
return calls
}
const calls = await execute()
const mutation = calls.find(row => row.method === 'PUT')
assert.equal(mutation.body.expectedVersion, 2)
assert.equal(mutation.body.channelCode, 'NEW')
assert.equal(calls.filter(row => row.method === 'PUT').length, 1)
assert.equal(calls.find(row => row.url.includes('/pricing/estimate')).body.originWarehouseId, '10')
for (const override of [{ before: { orderId: 'other' } }, { order: { status: 'PROCESSING' } },
{ customerId: 'other' }, { channels: [] }, { changed: { version: 9 } },
{ after: { tenantId: 'other' } }, { rows: [] }]) {
await assert.rejects(execute(override), /WAIT_PRICING/)
}
})
test('待费用确认样本必须经真实试算并独立回查费用、详情和筛选', async () => {
const execute = async (override = {}) => {
const calls = []
let reads = 0
const fee = { feeId: '90', feeVersion: 1, feeStatus: 'CALCULATED', nextProcessStatus: 'FEE_CONFIRMING' }
const context = { marker: 'current-run',
async request(method, url, body, token, headers) {
calls.push({ method, url, body, headers })
let data
if (url === '/api/platform/inbounds/8?tenantId=7') {
reads += 1
data = { summary: { id: '8', tenantId: '7', inboundCode: 'IN-TEST-8',
processStatus: reads === 1 ? 'MEASURE_PENDING' : 'FEE_CONFIRMING',
measureReviewStatus: 'PENDING', ...(reads === 1 ? override.before : override.after) } }
} else if (url.includes('/fees/calculate')) data = { ...fee, ...override.calculated }
else if (url.includes('/fees/latest')) data = { ...fee, ...override.latest }
else data = { records: override.rows ?? [{ id: '8', processStatus: 'FEE_CONFIRMING' }] }
return { http: 200, body: { code: '200', data } }
}, apiSuccess: (_m, _n, response) => response.body.data,
assert() {} }
const result = await retainFeeConfirmingFixture(context, { inboundId: '8', tenantId: '7' })
return { calls, result }
}
const { calls, result } = await execute()
assert.deepEqual(result, { feeId: '90', feeVersion: 1 })
assert.equal(calls.length, 5)
const writes = calls.filter(row => row.method !== 'GET')
assert.equal(writes.length, 1)
assert.deepEqual(writes[0].body, { weightSource: 'ACTUAL', volumeSource: 'ACTUAL' })
assert.ok(writes[0].headers['Idempotency-Key'].startsWith('current-run-'))
assert.match(calls.at(-1).url, /inboundCode=IN-TEST-8&processStatus=FEE_CONFIRMING/)
for (const override of [
{ before: { tenantId: 'other' } }, { before: { processStatus: 'WAIT_SPLIT' } },
{ calculated: { nextProcessStatus: 'WAIT_SPLIT' } },
{ after: { processStatus: 'MEASURE_PENDING' } }, { after: { measureReviewStatus: 'APPROVED' } },
{ latest: { feeId: '91' } }, { latest: { feeStatus: 'SENT' } }, { rows: [] },
]) await assert.rejects(execute(override), /FEE_CONFIRMING/)
})
test('dynamic Platform cancellation freezes version and key while reject body stays unchanged',async()=>{
const calls=[]
@@ -91,7 +176,7 @@ test('测量状态截停发生在正确的 HTTP 边界', () => {
assert.match(source, /await weightOcr\.run\(context\)/)
assert.match(source, /target !== 'MEASURING'/)
assert.doesNotMatch(source, /target === 'MEASURING' && ok\(response\)/)
assert.match(source, /target === 'MEASURE_PENDING' && isSubmit/)
assert.match(source, /\['MEASURE_PENDING', 'FEE_CONFIRMING', 'WAIT_PRICING'\]\.includes\(target\) && isSubmit/)
assert.doesNotMatch(source, /target === 'MEASURE_REJECTED' && isSubmit/)
})
@@ -99,8 +184,8 @@ test('入库模块只声明状态全集且不在后续专项生成前预写失
for (const status of ['WAIT_PRICING', 'FEE_CONFIRMING', 'INBOUNDING', 'VOIDED']) {
assert.match(source, new RegExp(`'${status}'`))
}
assert.match(source, /processStatus=WAIT_SPLIT/)
assert.match(source, /查询存量 WAIT_SPLIT 样本/)
assert.match(source, /本轮 WAIT_SPLIT 独立样本缺失/)
assert.doesNotMatch(source, /查询存量 WAIT_SPLIT 样本|legacyInbound|legacyPage/)
assert.doesNotMatch(source, /覆盖阻断|fixture-unavailable|recordCoverageGap|addCoverageGap/)
})
@@ -1,4 +1,4 @@
import { findPricedOrderRouteAcrossOrigins, freezeOrderCommand } from './orders.mjs'
import { findPricedOrderRouteAcrossOrigins, findPricedOrderRoute, freezeOrderCommand } from './orders.mjs'
const MODULE = 'order-inbound-status-fixtures'
const OPTIONS = { scenario: 'manual-order-inbound-status-matrix', deploymentBlocker: true }
@@ -178,19 +178,143 @@ async function waitForInbound(context, fixture, orderId, allowCompletedOrder = f
return { response: null, summary: null }
}
export async function retainFeeConfirmingFixture(context, { inboundId, tenantId }) {
const suffix = `${query(inboundId)}?tenantId=${query(tenantId)}`
const detailPath = `/api/platform/inbounds/${suffix}`
const beforeResponse = await context.request('GET', detailPath)
const before = context.apiSuccess(MODULE, '试算前核对独立待审核测量样本', beforeResponse, '', OPTIONS)
if (id(before?.summary?.id) !== id(inboundId)
|| id(before?.summary?.tenantId) !== id(tenantId)
|| before?.summary?.processStatus !== 'MEASURE_PENDING' || !before?.summary?.inboundCode) {
throw new Error('FEE_CONFIRMING 必须从本轮独立 MEASURE_PENDING 入库单生成')
}
const calculatedResponse = await context.request('POST',
`/api/platform/v1/inbounds/${query(inboundId)}/fees/calculate?tenantId=${query(tenantId)}`,
{ weightSource: 'ACTUAL', volumeSource: 'ACTUAL' }, undefined,
{ 'Idempotency-Key': key(context, `retain-fee-confirming-${inboundId}`) })
const calculated = context.apiSuccess(MODULE, '审核前真实试算并保留待费用确认样本', calculatedResponse, '', OPTIONS)
if (!calculated?.feeId || calculated?.feeStatus !== 'CALCULATED'
|| calculated?.nextProcessStatus !== 'FEE_CONFIRMING') {
throw new Error('真实费用试算未生成 FEE_CONFIRMING/CALCULATED')
}
const latestResponse = await context.request('GET',
`/api/platform/v1/inbounds/${query(inboundId)}/fees/latest?tenantId=${query(tenantId)}`)
const latest = context.apiSuccess(MODULE, '回读待确认费用的同一快照', latestResponse, '', OPTIONS)
const detailResponse = await context.request('GET', detailPath)
const detail = context.apiSuccess(MODULE, '回读待费用确认样本持久状态', detailResponse, '', OPTIONS)
const valid = id(detail?.summary?.id) === id(inboundId)
&& id(detail?.summary?.tenantId) === id(tenantId)
&& detail?.summary?.processStatus === 'FEE_CONFIRMING'
&& detail?.summary?.measureReviewStatus === 'PENDING'
&& id(latest?.feeId) === id(calculated.feeId)
&& latest?.feeVersion === calculated.feeVersion && latest?.feeStatus === 'CALCULATED'
context.assert(MODULE, '费用已持久化且测量尚未审核,保留 FEE_CONFIRMING', detailResponse, valid,
`status=${detail?.summary?.processStatus || 'missing'}, feeId=${id(latest?.feeId)}`, OPTIONS)
if (!valid) throw new Error('FEE_CONFIRMING 持久状态或费用快照不一致')
const listResponse = await context.request('GET',
`/api/platform/inbounds?tenantId=${query(tenantId)}&inboundCode=${query(before.summary.inboundCode)}&processStatus=FEE_CONFIRMING&page=1&size=20`)
const page = context.apiSuccess(MODULE, '按 FEE_CONFIRMING 查询保留样本', listResponse, '', OPTIONS)
const visible = rows(page).some(row => id(row.id) === id(inboundId) && row.processStatus === 'FEE_CONFIRMING')
context.assert(MODULE, '待费用确认筛选包含本轮真实样本', listResponse, visible,
`inboundId=${inboundId}`, OPTIONS)
if (!visible) throw new Error('FEE_CONFIRMING 列表未包含本轮样本')
return { feeId: id(latest.feeId), feeVersion: latest.feeVersion }
}
export async function retainWaitPricingFixture(context, { inboundId, tenantId, orderId }) {
const detailPath = `/api/platform/inbounds/${query(inboundId)}?tenantId=${query(tenantId)}`
const before = context.apiSuccess(MODULE, '渠道变更前核对独立费用确认样本',
await context.request('GET', detailPath), '', OPTIONS)
const orderDetail = context.apiSuccess(MODULE, '渠道变更前读取当前订单版本与申报',
await context.request('GET', `/api/platform/v1/orders/first-leg/${query(orderId)}?tenantId=${query(tenantId)}`), '', OPTIONS)
const order = orderDetail?.order
if (id(before?.summary?.id) !== id(inboundId) || id(before?.summary?.tenantId) !== id(tenantId)
|| id(before?.summary?.orderId) !== id(orderId) || before?.summary?.processStatus !== 'FEE_CONFIRMING'
|| id(order?.id) !== id(orderId) || order?.status !== 'APPROVED' || !orderDetail?.cargoes?.length) {
throw new Error('WAIT_PRICING 必须从本轮 APPROVED 订单及 FEE_CONFIRMING 入库单生成')
}
const fixture = context.context?.merchantQualificationFixture
const username = fixture?.username || context.options?.merchantUsername || process.env.MERCHANT_TEST_USERNAME
const password = fixture?.password || context.options?.merchantPassword || process.env.MERCHANT_TEST_PASSWORD
const login = context.apiSuccess(MODULE, '渠道样本客户独立登录',
await context.request('POST', '/api/auth/login', { loginId: username, password }, ''), '', OPTIONS)
if (!login?.accessToken) throw new Error('WAIT_PRICING 客户登录缺少会话')
const token = login.accessToken
try {
const profile = context.apiSuccess(MODULE, '核对渠道目录所属客户',
await context.request('GET', '/api/merchant/v1/iam/tenant/profile', undefined, token), '', OPTIONS)
if (id(profile?.id) !== id(tenantId)) throw new Error('WAIT_PRICING 渠道目录客户身份不一致')
const channels = context.apiSuccess(MODULE, '查询同仓可替换报价渠道',
await context.request('GET', `/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${query(order.receivingWarehouseId)}`,
undefined, token), '', OPTIONS)
const route = await findPricedOrderRoute({
receiving: { id: order.receivingWarehouseId },
destinations: [{ id: order.destinationWarehouseId, warehouseCode: order.amazonWarehouseCode,
warehouseName: order.destinationWarehouseName }],
channels: (Array.isArray(channels) ? channels : []).filter(channel => channel.channelCode !== order.channelCode
&& channel.pricingTaxModes?.includes(order.pricingTaxMode))
.map(channel => ({ ...channel, pricingTaxModes: [order.pricingTaxMode] })),
destinationType: order.destinationType, concurrency: 1,
estimate: body => context.request('POST', '/api/merchant/v1/first-leg/pricing/estimate', body, token),
})
if (!route?.priced?.channel) throw new Error('WAIT_PRICING 没有同仓同目的仓同税制的实际可计价替换渠道')
const channel = route.priced.channel
const changedResponse = await context.request('PUT',
`/api/platform/v1/orders/${query(orderId)}/declaration?tenantId=${query(tenantId)}`,
{ expectedVersion: Number(order.version), customerRemark: order.customerRemark,
expectedArrivalAt: order.expectedArrivalAt || null, estimatedVolumeCbm: order.estimatedVolumeCbm ?? null,
channelCode: channel.channelCode, channelName: channel.channelName,
cargoes: orderDetail.cargoes.map(cargo => Object.fromEntries([
'itemNo', 'cargoName', 'cargoNameEn', 'skuNo', 'model', 'packageCount', 'totalQuantity',
'lengthCm', 'widthCm', 'heightCm', 'grossWeightKg', 'volumeCbm', 'netWeightKg',
'hsCode', 'customsHsCode', 'unitsPerPackage', 'unitDeclaredValue', 'declaredValue',
'currency', 'material', 'usage', 'brand', 'cargoRemark',
].map(field => [field, cargo[field] ?? (field === 'cargoRemark' ? cargo.remark : null) ?? null]))) })
const changed = context.apiSuccess(MODULE, '通过正式申报接口变更本轮未发布费用订单渠道', changedResponse, '', OPTIONS)
if (Number(changed?.version) !== Number(order.version) + 1 || changed?.channelChanged !== true) {
throw new Error('WAIT_PRICING 渠道变更未确认当前订单版本')
}
let detail
let response
for (let attempt = 0; attempt < 30; attempt += 1) {
response = await context.request('GET', detailPath)
detail = context.apiSuccess(MODULE, '等待渠道变更及费用失效事实收敛', response, '', OPTIONS)
if (detail?.summary?.processStatus === 'WAIT_PRICING'
&& detail?.summary?.channelCode === channel.channelCode) break
if (attempt < 29) await wait(500)
}
const valid = id(detail?.summary?.id) === id(inboundId) && id(detail?.summary?.tenantId) === id(tenantId)
&& detail?.summary?.processStatus === 'WAIT_PRICING' && detail?.summary?.feeStatus === 'UNPRICED'
&& detail?.summary?.channelCode === channel.channelCode
context.assert(MODULE, '真实渠道变更保留 WAIT_PRICING 且旧费用失效', response, valid,
`status=${detail?.summary?.processStatus}, feeStatus=${detail?.summary?.feeStatus}`, OPTIONS)
if (!valid) throw new Error('WAIT_PRICING 渠道变更事实未收敛')
const listResponse = await context.request('GET',
`/api/platform/inbounds?tenantId=${query(tenantId)}&inboundCode=${query(before.summary.inboundCode)}&processStatus=WAIT_PRICING&page=1&size=20`)
const page = context.apiSuccess(MODULE, '按 WAIT_PRICING 筛选本轮样本', listResponse, '', OPTIONS)
const visible = rows(page).some(row => id(row.id) === id(inboundId) && row.processStatus === 'WAIT_PRICING')
context.assert(MODULE, '待计费筛选包含本轮真实样本', listResponse, visible, `inboundId=${inboundId}`, OPTIONS)
if (!visible) throw new Error('WAIT_PRICING 列表未包含本轮样本')
} finally {
context.apiSuccess(MODULE, '退出渠道目录客户独立会话',
await context.request('POST', '/api/auth/logout', { refreshToken: login.refreshToken }, token), '', OPTIONS)
}
}
async function prepareInboundStatus(context, target) {
const originalMarker = context.marker
const originalRequest = context.request
const fixtureMarker = `${originalMarker}-IN-${target}`
const stop = Symbol(target)
const createdKeys = ['orders', 'ocrInbound', 'testInbound', 'inboundMeasurement', 'inboundWarehouseResources']
const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']
const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound', 'orderStopAtApproved']
const createdSnapshot = new Map(createdKeys.map((name) => [name,
{ present: Object.hasOwn(context.createdResources, name), value: context.createdResources[name] }]))
const contextSnapshot = new Map(contextKeys.map((name) => [name,
{ present: Object.hasOwn(context.context, name), value: context.context[name] }]))
try {
context.marker = fixtureMarker
if (target === 'WAIT_PRICING') context.context.orderStopAtApproved = true
const orders = await import(new URL('./orders.mjs', import.meta.url))
await orders.run(context)
const order = { ...context.createdResources.orders }
@@ -206,7 +330,7 @@ async function prepareInboundStatus(context, target) {
context.request = async (method, path, body, token, headers) => {
const isSubmit = path.includes(`/inbounds/${inboundId}/measurement/submit`)
if (target === 'MEASURE_PENDING' && isSubmit) {
if (['MEASURE_PENDING', 'FEE_CONFIRMING', 'WAIT_PRICING'].includes(target) && isSubmit) {
const response = await originalRequest.call(context, method, path, body, token, headers)
if (!ok(response)) throw new Error(`MEASURE_PENDING 提交失败: HTTP=${response?.http}, code=${response?.body?.code}`)
throw stop
@@ -224,6 +348,13 @@ async function prepareInboundStatus(context, target) {
}
}
if (['FEE_CONFIRMING', 'WAIT_PRICING'].includes(target)) {
await retainFeeConfirmingFixture(context, { inboundId, tenantId })
}
if (target === 'WAIT_PRICING') {
await retainWaitPricingFixture(context, { inboundId, tenantId, orderId: id(order.orderId) })
}
let response = null
let detail = null
for (let attempt = 1; attempt <= 8; attempt += 1) {
@@ -320,7 +451,7 @@ export async function run(context, matrix = context.createdResources.manualState
id: waitMeasure.orderId, status: 'WAIT_MEASURE', detailPath: `/orders/${waitMeasure.orderId}`,
}
for (const status of ['MEASURING', 'MEASURE_PENDING', 'WAIT_SPLIT']) {
for (const status of ['MEASURING', 'MEASURE_PENDING', 'FEE_CONFIRMING', 'WAIT_PRICING', 'WAIT_SPLIT']) {
const sample = await prepareInboundStatus(context, status)
result.inbounds[status] = sample
matrix.pages.inbounds.fixtures[status] = {
@@ -335,22 +466,7 @@ export async function run(context, matrix = context.createdResources.manualState
}
if (!result.inbounds.WAIT_SPLIT) {
const legacyResponse = await context.request('GET',
`/api/platform/v1/inbounds?tenantId=${query(fixture.tenantId)}&processStatus=WAIT_SPLIT&page=1&size=20`)
const legacyPage = context.apiSuccess(MODULE, '查询存量 WAIT_SPLIT 样本', legacyResponse, '', OPTIONS)
const legacyInbound = rows(legacyPage)[0]
if (legacyInbound?.id && legacyInbound?.orderId) {
const sample = { inboundId: id(legacyInbound.id), orderId: id(legacyInbound.orderId), tenantId: fixture.tenantId,
status: 'WAIT_SPLIT', inboundCode: legacyInbound.inboundCode || null }
result.inbounds.WAIT_SPLIT = sample
result.unifiedOrders.WAIT_SPLIT = sample
matrix.pages.inbounds.fixtures.WAIT_SPLIT = {
id: sample.inboundId, status: 'WAIT_SPLIT', detailPath: `/orders/${sample.orderId}`,
}
matrix.pages.unifiedOrders.fixtures.WAIT_SPLIT = {
id: sample.orderId, status: 'WAIT_SPLIT', detailPath: `/orders/${sample.orderId}`,
}
}
throw new Error('本轮 WAIT_SPLIT 独立样本缺失,不允许借用历史数据')
}
context.addStep(MODULE, '登记订单/入库人工状态矩阵', null, true,
@@ -0,0 +1,15 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { pickerFixtureSuffix } from './phase4-overseas-pda.mjs'
test('长场景尾部相同时PDA账号仍保留完整运行标识的区别', () => {
const first = { marker: 'ADMIN-API-20261005134027677-COMPLETED-F40-PDA' }
const second = { marker: 'ADMIN-API-20261005170914227-COMPLETED-F40-PDA' }
const oldSuffix = value => value.marker.replace(/[^A-Za-z0-9]/g, '').slice(-16)
assert.equal(oldSuffix(first), oldSuffix(second))
assert.notEqual(pickerFixtureSuffix(first), pickerFixtureSuffix(second))
assert.equal(pickerFixtureSuffix(first), pickerFixtureSuffix({ ...first }))
assert.match(pickerFixtureSuffix(first), /^[0-9A-F]{32}$/)
assert.notEqual(pickerFixtureSuffix(first), pickerFixtureSuffix({ ...first, pdaContainerSuffix: 'B' }))
assert.throws(() => pickerFixtureSuffix({ marker: '' }))
})
@@ -7,6 +7,13 @@ import { parseArguments } from '../admin-test-env-acceptance.mjs'
const source = await readFile(fileURLToPath(new URL('./phase4-overseas-client-services.mjs', import.meta.url)), 'utf8')
test('discard proof follows reserved approval and keeps actual PDA confirmation separate', () => {
assert.match(source, /Approval retains the existing reservation without deducting stock/)
assert.match(source, /Actual PDA confirmation deducts the confirmed quantity/)
assert.doesNotMatch(source, /require approval-transaction deduction/)
assert.match(source, /completed-confirm recovery\/replay/)
})
test('client services module uses only stable merchant self-service paths', () => {
for (const path of [
'/api/merchant/v1/last-leg/orders',
@@ -77,14 +84,14 @@ test('focused client services keeps the marker-owned merchant fixture when reusa
assert.ok(fixture >= 0 && fixture < clientServices)
})
test('discard follows formal approval deduction and keeps physical ledger evidence pending', () => {
assert.match(source, /正式废弃合同要求审批事务扣减一箱/)
test('discard approval retains reservation and keeps actual confirmation evidence pending', () => {
assert.match(source, /批准只保留锁箱且库存数量流水不变/)
assert.match(source, /废弃拒绝释放原箱且全部库存数量流水不变/)
assert.match(source, /APPROVAL_HTTP_VERIFIED_CARTON_SQL_PENDING/)
assert.doesNotMatch(source, /INSERT INTO|UPDATE ll_|fleet-trip|\/api\/pda\//)
})
for (const fault of ['', 'pending deducts', 'reject does not release', 'approve does not deduct', 'create conflict mutates']) {
for (const fault of ['', 'pending deducts', 'reject does not release', 'approve deducts', 'create conflict mutates']) {
test(`discard approval stock boundaries: ${fault || 'formal approval contract'}`, async () => {
let state = 'PENDING_REVIEW', approved = false, conflictSeen = false
const baseline = { balances: [{customerId:'123',overseasWarehouseId:'42',availablePackages:'4'}], ledger:[],
@@ -103,7 +110,7 @@ for (const fault of ['', 'pending deducts', 'reject does not release', 'approve
if(body.remark.endsWith('-DIFFERENT')) {conflictSeen=true;return {http:200,body:{code:'A0409'},requestId:'req',traceId:'trace'}}
approved=true;state='PENDING_REVIEW';data={summary:{discardNo:'NEW'}}
} else if(path.endsWith('/balances')) {
const changed=(state==='PENDING_REVIEW' && fault==='pending deducts') || (state==='APPROVED' && fault!=='approve does not deduct') || (conflictSeen && fault==='create conflict mutates')
const changed=(state==='PENDING_REVIEW' && fault==='pending deducts') || (state==='APPROVED' && fault==='approve deducts') || (conflictSeen && fault==='create conflict mutates')
data={total:'1',records:[{...baseline.balances[0],availablePackages:changed?'3':'4'}]}
} else if(path.endsWith('/ledger')) data={total:'0',records:[]}
else if(path.endsWith('/inventory-candidates')) data=[{...baseline.source,availablePackages:state==='REJECTED' && fault!=='reject does not release'?'4':'3'}]
@@ -111,7 +118,7 @@ for (const fault of ['', 'pending deducts', 'reject does not release', 'approve
return {http:200,body:{code:'200',data}}
}}
await assert.rejects(verifyDiscardApproval(context,'merchant',{idempotencyKey:'key',remark:'fixture'},'OLD',proof),
fault ? /仅锁定|释放原箱|审批事务扣减|异载荷拒绝后/ : /CHECKPOINT_PASSED/)
fault ? /仅锁定|释放原箱|只保留锁箱|异载荷拒绝后/ : /CHECKPOINT_PASSED/)
assert.ok(!calls.some(call=>call.raw.includes('/pda/')))
})
}
@@ -249,7 +249,7 @@ export async function verifyDiscard(context, token, markerOrders) {
sourceOrderId: id(candidate.orderId), customerId: id(context.context.merchantQualificationFixture.tenantId),
warehouseId: candidate.overseasWarehouseId, inboundCode: candidate.inboundCode,
skuCode: sku.skuCode, snapshots: {}, status: 'RUNNING',
contract: 'Current stage-five and service AGENTS require approval-transaction deduction. Review has no idempotencyKey. An approval that only enables PDA must fail this contract.',
contract: 'Approval retains the existing reservation without deducting stock. Actual PDA confirmation deducts the confirmed quantity; completed confirmation replay must not write again. Review has no idempotencyKey.',
}
const before = await discardSnapshot(context, token, proof)
proof.snapshots.before = before
@@ -358,7 +358,7 @@ export async function verifyDiscardApproval(context, token, body, discardNo, pro
require('批准后反向审核拒绝且事实不变', conflict(reverse) && nonBlank(reverse.requestId) && nonBlank(reverse.traceId)
&& stable(await discardSnapshot(context,token,proof,approvedNo)) === stable(approved))
proof.status = 'APPROVAL_HTTP_VERIFIED_CARTON_SQL_PENDING'
proof.remainingEvidence = 'll_dropship_carton, ll_dropship_sku_inventory, ll_dropship_carton_ledger require independent read-only evidence; public ledger is not carton ledger'
proof.remainingEvidence = 'Approval reservation only. Actual PDA confirm, completed-confirm recovery/replay and independent ll_dropship_carton/ll_dropship_sku_inventory/ll_dropship_carton_ledger evidence remain required; public ledger is not carton ledger.'
}
@@ -1,5 +1,7 @@
import { settleRelabelFulfillment } from './phase4-overseas-pda-operations.mjs'
import { verifyGroupedDispatch } from './grouped-dispatch-acceptance.mjs'
const MODULE = 'phase4-overseas-dispatch'
const SCENARIO = 'phase4-overseas-dispatch-lifecycle'
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
@@ -308,6 +310,7 @@ export async function run(context) {
context.assert(MODULE, '正式锁释放后候选恢复', freed.response,
Array.isArray(freed.data) && freed.data.some(item => id(item.planPalletId) === planPalletId), trace(freed.response), OPTIONS)
context.createdResources.phase4OverseasDispatch.removalDispatchNo = removableNo
await verifyGroupedDispatch(context, MODULE, selectedCandidates)
const draftToken = `${context.marker}-DISPATCH-DRAFT`.slice(0, 64)
const containersJson = JSON.stringify([{ key: 'C1', capacity: Math.max(1, Number(candidate.packageCount)) }])
const snapshotBody = { draftToken, planIds, containersJson }
@@ -1,3 +1,4 @@
import { exportWorkbook } from './download-center-fixture.mjs'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
@@ -104,12 +105,20 @@ function fakeContext({ softAssertions = false, refreshEmpty = false, careLeak =
coverageGaps.push({ module, scenario, name, reason, category, ...metadata })
},
async request(...args) {
if (args[1].startsWith('/api/platform/v1/files/export-tasks')) {
calls.push({ gateway: 'domestic', method: args[0], path: args[1], body: args[2] })
return ok({ id: args[2]?.exportType || args[1].split('/').at(-1), status: args[0] === 'POST' ? 'PENDING' : 'SUCCEEDED' })
}
const route = /\/(?:last-leg|overseas-warehouse-resources)(?:\/|\?|$)/.test(String(args[1]));
const handler = route ? async function(method,path,body,token = 'admin-overseas-token',extraHeaders = {}) {
calls.push({ gateway: 'overseas', method, path, body, extraHeaders, tokenKind: token === 'deny-overseas-token' ? 'denied' : 'admin' })
assert.notEqual(method, 'DELETE')
if (token === 'deny-overseas-token') return forbidden()
if (path.includes('/fleet/vehicles/')) {
if (method === 'POST' && path.endsWith('/records/finance-status')) {
return ok(body.recordNos.map(recordNo => ({ recordNo, status: processingMarksPaid ? 'PAID' : 'UNLINKED',
sourceVersion: null, submittedRecordVersion: null, finance: null })))
}
const url = new URL(path, 'https://fixture.invalid'), parts = url.pathname.split('/records'), owner = parts[0].split('/').at(-1)
const suffix = parts[1], record = careRows.get(suffix.split('/')[1])
if (record && owner !== record.vehicleId && !careLeak) return { http: 200, body: { code: 'A0404' } }
@@ -117,7 +126,7 @@ function fakeContext({ softAssertions = false, refreshEmpty = false, careLeak =
const prior = [...careRows.values()].find(value => value.key === body.idempotencyKey && value.vehicleId === owner)
if (prior) return ok(structuredClone(prior))
const created = { recordNo: `VR-${careRows.size + 1}`, vehicleId: owner, details: structuredClone(body.details), files: [], recordVersion: 0, key: body.idempotencyKey,
paymentStatus: ['REPAIR', 'RESCUE'].includes(body.details.recordType) ? 'UNLINKED' : null }
paymentStatus: null }
careRows.set(created.recordNo, created); careHistory.set(created.recordNo, [{ snapshot: structuredClone(created) }]); return ok(structuredClone(created))
}
if (method === 'GET' && !suffix) {
@@ -130,7 +139,6 @@ function fakeContext({ softAssertions = false, refreshEmpty = false, careLeak =
if (method === 'PUT') {
if (Number(body.recordVersion) !== record.recordVersion) return conflict()
record.details = structuredClone(body.details); record.recordVersion++
if (processingMarksPaid && record.details.incident?.processingStatus === 'COMPLETED') record.paymentStatus = 'PAID'
careHistory.get(record.recordNo).unshift({ reason: body.reason, snapshot: structuredClone(record) }); return ok(structuredClone(record))
}
}
@@ -286,13 +294,20 @@ function fakeContext({ softAssertions = false, refreshEmpty = false, careLeak =
calls.push({ gateway: 'overseas-binary', method, path })
assert.equal(method, 'GET')
if (path.includes('/fleet/vehicles/')) return { http: 200, contentType: 'image/png', bytes: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64') }
assert.match(path, /\/(?:overseas-quotes|exchange-rates|vehicles)\/export\?/)
let csv = ''
if (path.includes('/overseas-quotes/')) csv = `报价ID,报价名称,合作仓编码,合作仓名称,报价方,币种,生效开始,生效结束,版本号,状态,更新时间\r\n${state.quote.summary.id},${state.quote.summary.name},${state.quote.summary.warehouseCode},${state.quote.summary.warehouseName},${state.quote.summary.providerName},USD,2098-01-01,2098-12-31,V1,ACTIVE,2026-08-21\r\n`
else if (path.includes('/exchange-rates/')) csv = `月份,基准币种,报价币种,月平均汇率,状态,更新人,更新时间\r\n${state.exchange.month},USD,CNY,${state.exchange.usdToCnyAverage},${state.exchange.status},admin,2026-08-21\r\n`
else csv = `车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,最大托位,最大载重(kg),可用容积(m³),更新时间\r\n${state.vehicle.plateNumber},${state.vehicle.model},18.75,101,Carrier,123,OVERSEAS,OWNED,${state.vehicle.status},${state.vehicle.maxPalletSpaces},${state.vehicle.maxLoadKg},${state.vehicle.maxVolumeCbm},2026-08-21\r\n`
return { method, path, http: 200, body: null, bytes: new TextEncoder().encode(`\uFEFF${csv}`),
contentType: 'text/csv;charset=UTF-8', requestId: 'req-csv' }
assert.match(path, /files\/export-tasks\/[^/]+\/download$/)
let headers, values
if (path.includes('OVERSEAS_QUOTE_LIST')) {
headers = ['报价名称', '合作仓编码', '合作仓名称', '报价方']
values = [state.quote.summary.name, state.quote.summary.warehouseCode, state.quote.summary.warehouseName, state.quote.summary.providerName]
} else if (path.includes('MONTHLY_EXCHANGE_RATE_LIST')) {
headers = ['月份', '月平均汇率', '状态']
values = [state.exchange.month, state.exchange.usdToCnyAverage, state.exchange.status]
} else {
headers = ['车牌号', '最大托位', '最大载重(kg)', '可用容积(m³)', '状态']
values = [state.vehicle.plateNumber, state.vehicle.maxPalletSpaces, state.vehicle.maxLoadKg, state.vehicle.maxVolumeCbm, state.vehicle.status]
}
return { method, path, http: 200, bytes: exportWorkbook(headers, values),
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }
},
}
return { context, state }
@@ -304,20 +319,20 @@ test('静态锁定统一Gateway主数据生命周期、CSV与安全边界', () =
assert.match(source, /context\.request\('POST'/)
assert.match(source, /context\.request\('PUT'/)
assert.match(source, /context\.request\('PATCH'/)
assert.match(source, /context\.requestBinary\('GET'/)
assert.match(source, /verifyDownloadCenterExport\(context/)
assert.match(source, /context\.expectConflict/)
assert.match(source, /warehouse-options\?refresh=true/)
assert.match(source, /空候选不负缓存30分钟/)
assert.match(source, /30分钟正缓存与刷新快照一致/)
assert.match(source, /无quote:list权限访问海外报价被拒绝/)
assert.match(source, /表头与marker内容/)
assert.match(source, /verifyDownloadCenterExport/)
assert.match(source, /finally\s*\{/)
assert.match(source, /context\.createdResources\.phase4OverseasMasterData/)
assert.doesNotMatch(source, /context\.addCoverageGap/)
assert.match(source, /2090 \+ Math\.floor/)
assert.doesNotMatch(source, /\/internal\/|overseas-partners|https?:\/\/|fetch\(|process\.env|request(?:Overseas)?\('DELETE'/)
for (const endpoint of ['overseas-quotes/warehouse-options', 'overseas-quotes/export',
'exchange-rates/export', 'vehicles/export']) assert.ok(source.includes(endpoint))
for (const endpoint of ['overseas-quotes/warehouse-options', 'OVERSEAS_QUOTE_LIST',
'MONTHLY_EXCHANGE_RATE_LIST', 'VEHICLE_LIST']) assert.ok(source.includes(endpoint))
assert.doesNotMatch(controllerSource, /Idempotency-Key|IdempotencyKey/)
assert.doesNotMatch(source, /Idempotency-Key/)
})
@@ -334,7 +349,7 @@ test('API dropping updated vehicle capacity fails even with a correct CSV and cl
}
await run(context)
assert.equal(context.steps.find(step => step.name === '车辆编辑保持三项实际容量')?.passed, false)
assert.equal(context.steps.find(step => step.name === '导出marker车辆CSV表头与marker内容')?.passed, true)
assert.equal(context.steps.find(step => step.name === 'VEHICLE_LIST表头与marker内容')?.passed, true)
assert.equal(context.createdResources.phase4OverseasMasterData.vehicle.finalStatus, 'INACTIVE')
})
@@ -358,7 +373,7 @@ test('marker资源完成创建、详情、编辑、冲突、状态、CSV并全
assert.equal(state.role.status, 'DISABLED')
assert.equal(state.user.status, 'DISABLED')
assert.equal(state.optionReads, 4)
assert.equal(context.calls.filter((call) => call.gateway === 'overseas-binary' && call.path.includes('/export?')).length, 3)
assert.equal(context.calls.filter((call) => call.gateway === 'overseas-binary' && call.path.includes('/export-tasks/') && call.path.endsWith('/download')).length, 3)
assert.equal(resource.vehicle.care.status, 'VERIFIED')
assert.equal(state.spareVehicle.status, 'INACTIVE')
assert.equal(context.calls.filter((call) => call.method === 'DELETE').length, 0)
@@ -395,7 +410,22 @@ test('事件处理完成被错误映射为已付款时,真实验收编排必
const { context } = fakeContext({ softAssertions: true, processingMarksPaid: true })
await run(context)
assert.equal(context.createdResources.phase4OverseasMasterData.vehicle.care.status, 'CHECKING')
assert.ok(context.steps.some(step => step.name === '处理完成保持独立的费用及付款事实' && !step.passed && step.deploymentBlocker))
assert.ok(context.steps.some(step => step.name === '处理完成未自动提交财务或标记付款' && !step.passed && step.deploymentBlocker))
})
test('UNLINKED付款响应夹带财务单据或提交版本仍阻断,不忽略独立查询证据', async () => {
for (const facts of [{ submittedRecordVersion: 1 }, { finance: { costNo: 'COST-UNEXPECTED' } }]) {
const { context } = fakeContext({ softAssertions: true })
const request = context.request.bind(context)
context.request = async (...args) => {
const response = await request(...args)
if (args[1].endsWith('/records/finance-status')) Object.assign(response.body.data[0], facts)
return response
}
await run(context)
assert.equal(context.createdResources.phase4OverseasMasterData.vehicle.care.status, 'CHECKING')
assert.ok(context.steps.some(step => step.name === '处理完成未自动提交财务或标记付款' && !step.passed && step.deploymentBlocker))
}
})
test('refresh后仍为空时报价前置明确deployment blocker且不产生coverageGap假PASS', async () => {
@@ -1,3 +1,4 @@
import { verifyDownloadCenterExport } from './download-center.mjs'
import { verifyVehicleCare } from './vehicle-care.mjs'
const MODULE = 'phase4-overseas-master-data'
@@ -16,19 +17,6 @@ function requireData(context, name, response) {
return value
}
function assertBinaryCsv(context, name, response, expectedHeader, expectedTokens) {
const text = response?.bytes instanceof Uint8Array ? new TextDecoder().decode(response.bytes).replace(/^\uFEFF/, '') : ''
const header = text.split(/\r?\n/, 1)[0]
context.assert(MODULE, name, response,
response?.http === 200
&& String(response.contentType || '').toLowerCase().startsWith('text/csv')
&& response.bytes instanceof Uint8Array && response.bytes.length > 0,
`HTTP=${response?.http ?? 0}, contentType=${response?.contentType || 'missing'}, bytes=${response?.bytes?.length || 0}`,
BLOCKER)
context.assert(MODULE, `${name}表头与marker内容`, response,
header === expectedHeader && expectedTokens.every((token) => text.includes(String(token))),
`header=${header || 'missing'}, expectedTokens=${expectedTokens.join('|')}`, BLOCKER)
}
const optionIds = (values) => Array.isArray(values) ? values.map((item) => asId(item?.warehouseId)).filter(Boolean) : []
@@ -218,10 +206,7 @@ async function quoteLifecycle(context, resource) {
context.assert(MODULE, '报价状态切换为ACTIVE', activateResponse,
detail.summary.status === 'ACTIVE', `status=${detail.summary.status}`, BLOCKER)
const csv = await context.requestBinary('GET', `${OVERSEAS_BASE}/overseas-quotes/export?keyword=${encodeURIComponent(context.marker)}`)
assertBinaryCsv(context, '导出海外报价CSV', csv,
'报价ID,报价名称,合作仓编码,合作仓名称,报价方,币种,生效开始,生效结束,版本号,状态,更新时间',
[id, context.marker])
await verifyDownloadCenterExport(context, MODULE, 'OVERSEAS_QUOTE_LIST', { keyword: context.marker }, ['报价名称', '合作仓编码', '合作仓名称', '报价方'], [context.marker])
return true
}
@@ -288,10 +273,7 @@ async function exchangeLifecycle(context, resource) {
Array.isArray(page?.records) && page.records.some((item) => item.month === month && recordVersion(item.recordVersion)),
`month=${month}, total=${page?.total ?? 'missing'}`, BLOCKER)
const csv = await context.requestBinary('GET',
`${OVERSEAS_BASE}/exchange-rates/export?monthFrom=${month}&monthTo=${month}`)
assertBinaryCsv(context, '导出marker月汇率CSV', csv,
'月份,基准币种,报价币种,月平均汇率,状态,更新人,更新时间', [month, 'INACTIVE'])
await verifyDownloadCenterExport(context, MODULE, 'MONTHLY_EXCHANGE_RATE_LIST', { monthFrom: month, monthTo: month }, ['月份', '月平均汇率', '状态'], [month, 'INACTIVE'])
}
function vehiclePayload(context, edited = false) {
@@ -378,10 +360,7 @@ async function vehicleLifecycle(context, resource) {
Array.isArray(page?.records) && page.records.some((item) => asId(item.id) === id && recordVersion(item.recordVersion)),
`id=${id}, total=${page?.total ?? 'missing'}`, BLOCKER)
const csv = await context.requestBinary('GET', `${OVERSEAS_BASE}/vehicles/export?keyword=${encodeURIComponent(createPayload.plateNumber)}`)
assertBinaryCsv(context, '导出marker车辆CSV', csv,
'车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,最大托位,最大载重(kg),可用容积(m³),更新时间',
[createPayload.plateNumber, 'MAINTENANCE', String(updatePayload.maxLoadKg)])
await verifyDownloadCenterExport(context, MODULE, 'VEHICLE_LIST', { keyword: createPayload.plateNumber }, ['车牌号', '最大托位', '最大载重(kg)', '可用容积(m³)'], [createPayload.plateNumber, 'MAINTENANCE', String(updatePayload.maxLoadKg)])
resource.vehicle.care = {}
await verifyVehicleCare(context, MODULE, vehicle, resource.vehicle.care)
}
@@ -1,4 +1,5 @@
import { prepareTerminalDrayage } from './phase5-terminal-drayage.mjs'
import { createHash } from 'node:crypto'
const MODULE = 'phase4-overseas-pda'
const SCENARIO = 'phase4-overseas-pda-container-receiving'
@@ -47,6 +48,10 @@ function forbidden(response) { return [401, 403].includes(response?.http) || (re
function trace(response) { return `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` }
function key(context, action, suffix = '') { return `${context.marker}-${context.pdaContainerSuffix || ''}-${action}-${suffix}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 120) }
function markerSuffix(context) { return `${context.marker}${context.pdaContainerSuffix || ''}`.replace(/[^A-Za-z0-9]/g, '').slice(-16).toUpperCase() }
export function pickerFixtureSuffix(context) {
if (typeof context.marker !== 'string' || !context.marker.trim()) throw Error('PDA账号缺少本轮运行标识')
return createHash('sha256').update(JSON.stringify([context.marker, context.pdaContainerSuffix || ''])).digest('hex').slice(0, 32).toUpperCase()
}
function records(data) { return Array.isArray(data?.records) ? data.records : Array.isArray(data) ? data : [] }
function receivingSnapshot(data) {
return JSON.stringify({
@@ -313,7 +318,7 @@ export async function resolvePickerAccess(context) {
export async function createPicker(context, index, overseasWarehouseId, access) {
const role = access.role
const suffix = markerSuffix(context)
const suffix = pickerFixtureSuffix(context)
const username = `pda_overseas_${suffix.toLowerCase()}_${index}`.slice(0, 64)
const password = `Pda${suffix.replace(/[^A-Za-z0-9]/g, '')}A${index}9`
const phone = await context.findAvailablePhone(MODULE)
@@ -1,3 +1,4 @@
import { exportWorkbook } from './download-center-fixture.mjs'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
@@ -17,6 +18,10 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou
const context = {
context: {}, createdResources: {}, marker: 'ACC-PHASE4-TEST', calls, steps, gaps,
async request(...args) {
if (args[1].startsWith('/api/platform/v1/files/export-tasks')) {
calls.push({ channel: 'domestic', method: args[0], path: args[1], body: args[2] })
return response({ id: args[2]?.exportType || args[1].split('/').at(-1), status: args[0] === 'POST' ? 'PENDING' : 'SUCCEEDED' })
}
const route = /\/(?:last-leg|overseas-warehouse-resources)(?:\/|\?|$)/.test(String(args[1]));
const handler = route ? async function(method,requestPath,body) {
calls.push({ channel: 'overseas', method, path: requestPath, body })
@@ -58,7 +63,7 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou
return handler.apply(this,args);
},
async requestBinary(method, requestPath) { calls.push({ channel: 'overseas-binary', method, path: requestPath }); return { http: 200, bytes: Buffer.from('id,status\n'), contentType: 'text/csv', requestId: 'request-binary', traceId: 'trace-binary' } },
async requestBinary(method, requestPath) { calls.push({ channel: 'overseas-binary', method, path: requestPath }); return { http: 200, bytes: exportWorkbook(['入库单号', 'SKU', '可用箱数', '发生时间', '业务单号', '变动箱数']), contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', requestId: 'request-binary', traceId: 'trace-binary' } },
apiSuccess(_module, _name, result) { return result?.http === 200 && String(result?.body?.code) === '200' ? result.body.data : null },
assert(module, name, result, passed, evidence, options) { steps.push({ module, name, result, passed: Boolean(passed), evidence, options }) },
addStep(module, name, result, passed, evidence, options) { steps.push({ module, name, result, passed: Boolean(passed), evidence, options }) },
@@ -70,7 +75,7 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou
test('模块只依赖注入请求,不读取环境或自行联网', () => {
assert.match(source, /export async function run\(context\)/)
assert.match(source, /context\.request\('GET'/)
assert.match(source, /context\.requestBinary\('GET'/)
assert.match(source, /verifyDownloadCenterExport\(context/)
assert.doesNotMatch(source, /https?:\/\//)
assert.doesNotMatch(source, /process\.env|fetch\(/)
})
@@ -80,7 +85,7 @@ test('既有仓库场景只写本轮库位与托盘生命周期', () => {
return run(context).then(() => {
assert.ok(context.calls.length >= 12)
assert.ok(context.calls.every((call) => ['GET', 'POST', 'PUT', 'DELETE'].includes(call.method)))
assert.equal(context.calls.filter((call) => call.channel === 'domestic').length, 3)
assert.equal(context.calls.filter((call) => call.channel === 'domestic' && !call.path.includes('/files/export-tasks')).length, 3)
assert.ok(context.calls.some((call) => call.channel === 'domestic' && call.path.startsWith('/api/platform/v1/warehouse-admin?')))
assert.ok(context.calls.some((call) => call.channel === 'domestic' && call.path.includes('/warehouse-admin/20001?')))
assert.equal(context.calls.some((call) => call.channel === 'overseas' && call.path === '/api/platform/v1/warehouse-admin'), false)
@@ -89,7 +94,7 @@ test('既有仓库场景只写本轮库位与托盘生命周期', () => {
assert.ok(writes.some((call) => call.method === 'DELETE' && call.path.includes('/locations/72002')))
assert.ok(writes.some((call) => call.path.includes('/overseas-warehouse-resources/20001/pallets')))
assert.equal(writes.some((call) => call.path.includes('/relabel-applications')), false)
assert.ok(writes.every((call) => call.path.includes('/overseas-warehouse-resources/20001/')))
assert.ok(writes.every((call) => call.path.includes('/overseas-warehouse-resources/20001/') || (call.method === 'POST' && call.path === '/api/platform/v1/files/export-tasks')))
for (const name of ['编辑本轮公共库位', '删除本轮公共库位', '报废本轮公共托盘']) {
const step = context.steps.find((item) => item.name === name)
assert.ok(step?.passed, name)
@@ -113,7 +118,7 @@ test('覆盖冻结的海外只读列表与有记录才查详情', async () => {
for (const detailToken of ['/orders/30001?', '/relabel-applications/RL-1?', '/inbound-plans/IP-1?', '/balances/60001/positions?', '/pallets/70001']) {
assert.ok(context.calls.some((call) => call.path.includes(detailToken)), detailToken)
}
for (const call of context.calls.filter((item) => item.path.includes('/inventory/summary?') || item.path.includes('/inventory/balances/export?') || item.path.includes('/inventory/ledger/export?'))) {
for (const call of context.calls.filter((item) => item.path.includes('/inventory/summary?'))) {
const params = new URL(`http://acceptance${call.path}`).searchParams
assert.equal(params.has('page'), false, call.path)
assert.equal(params.has('size'), false, call.path)
@@ -141,11 +146,11 @@ test('缺少客户时明确失败,缺少仓库时通过正式接口创建并
})
test('合同覆盖库存CSV并明确排除暂缓、离仓、PDA与内部路径', () => {
assert.match(source, /inventory\/balances\/export/)
assert.match(source, /inventory\/ledger\/export/)
assert.match(source, /OVERSEAS_INVENTORY_BALANCE/)
assert.match(source, /OVERSEAS_INVENTORY_LEDGER/)
assert.match(source, /inventory\/summary[^\n]+\{ customerId \}/)
assert.match(source, /inventory\/balances\/export[^\n]+\{ customerId \}/)
assert.match(source, /inventory\/ledger\/export[^\n]+\{ customerId \}/)
assert.match(source, /OVERSEAS_INVENTORY_BALANCE[^\n]+\{ customerId \}/)
assert.match(source, /OVERSEAS_INVENTORY_LEDGER[^\n]+\{ customerId \}/)
for (const forbidden of ['/cartons', '/products', '/outbound', '/dispatch', '/delivery', '/pda', '/internal']) assert.equal(source.includes(forbidden), false, forbidden)
assert.match(source, /requestId=/)
assert.match(source, /traceId=/)
@@ -1,3 +1,4 @@
import { verifyDownloadCenterExport } from './download-center.mjs'
const MODULE = 'phase4-overseas-warehouse'
const SCENARIO = 'phase4-overseas-warehouse'
const OPTIONS = { scenario: SCENARIO }
@@ -47,13 +48,6 @@ async function domesticMutation(context, method, name, path, body) {
context.addStep(MODULE, name, response, passed, trace(response), OPTIONS)
return { response, data: passed ? response.body?.data : null }
}
async function overseasCsv(context, name, path) {
const response = await context.requestBinary('GET', path)
context.addStep(MODULE, name, response,
response?.http === 200 && Number(response?.bytes?.length) > 0 && String(response?.contentType || '').toLowerCase().includes('csv'),
`bytes=${response?.bytes?.length ?? 0}, contentType=${response?.contentType || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`, OPTIONS)
return response
}
function assertPage(context, name, result) {
context.assert(MODULE, `${name}分页结构正确`, result.response, pageValid(result.data),
`records=${result.data?.records?.length ?? 'missing'}, page=${result.data?.page ?? 'missing'}, size=${result.data?.size ?? 'missing'}, total=${result.data?.total ?? 'missing'}; ${trace(result.response)}`, OPTIONS)
@@ -151,8 +145,8 @@ export async function run(context) {
&& Number.isFinite(Number(ledgerRow.beforePackages)) && Number.isFinite(Number(ledgerRow.deltaPackages))
&& Number.isFinite(Number(ledgerRow.afterPackages)) && nonBlank(ledgerRow.occurredAt),
`id=${id(ledgerRow.id) || 'missing'}, type=${ledgerRow.businessType || 'missing'}, delta=${ledgerRow.deltaPackages ?? 'missing'}; ${trace(ledger.response)}`, OPTIONS)
await overseasCsv(context, '下载SKU库存余额CSV(不传分页参数)', query('/api/platform/v1/last-leg/inventory/balances/export', { customerId }))
await overseasCsv(context, '下载进销存流水CSV(不传分页参数)', query('/api/platform/v1/last-leg/inventory/ledger/export', { customerId }))
await verifyDownloadCenterExport(context, MODULE, 'OVERSEAS_INVENTORY_BALANCE', { customerId }, ['入库单号', 'SKU', '可用箱数'])
await verifyDownloadCenterExport(context, MODULE, 'OVERSEAS_INVENTORY_LEDGER', { customerId }, ['发生时间', '业务单号', '变动箱数'])
let warehouse = await findWarehouse(context, records(tenantsResult.data), customerId)
let ownedWarehouse = false
@@ -25,7 +25,7 @@ test('tail order controls execute after current producer and before overseas PDA
})
test('module covers the public tail-order write surface before PDA receiving', () => {
for (const path of ['/receiving-warehouse', '/destination', '/pallet-merge-required', '/fees', '/export',
for (const path of ['/receiving-warehouse', '/destination', '/pallet-merge-required', '/fees', 'LAST_MILE_ORDER_LIST',
'/hold', '/manual-nodes', '/api/platform/v1/last-leg/inbound-plans', '/manual']) {
assert.ok(source.includes(path), `missing ${path}`)
}
@@ -33,7 +33,7 @@ test('module covers the public tail-order write surface before PDA receiving', (
'接柜仓变更同键同载荷重放', '接柜仓变更同键异载荷返回A0409', '接柜仓变更旧版本返回A0409',
'整柜恢复原公共接柜仓', '单票变更FBA目的仓', '批量变更FBA目的仓',
'单票开启PDA合托提示', '批量开启PDA合托分组', '批量取消PDA合托分组',
'尾程费用详情可追溯', '导出本轮尾程订单CSV',
'尾程费用详情可追溯', 'LAST_MILE_ORDER_LIST',
'暂扣命令同键同载荷重放', '暂扣同键异载荷返回A0409', '暂扣尾程订单不能生成人工计划',
'解除本轮单票尾程暂扣', '保存尾程八个人工节点快照', '人工节点同键同载荷重放',
'人工节点同键异载荷返回A0409', '人工节点读取固定为MANUAL且不回退柜级节点',
@@ -42,7 +42,7 @@ test('module covers the public tail-order write surface before PDA receiving', (
]) assert.ok(source.includes(evidence), `missing ${evidence}`)
assert.match(source, /nodes\.data\?\.nodes\?\.length === 8/)
assert.match(source, /creationMode === 'AUTO'/)
assert.match(source, /requestBinary\('GET'/)
assert.match(source, /verifyDownloadCenterExport\(context/)
assert.match(source, /palletMergeBatchId/)
assert.match(source, /feeItemCode/)
assert.match(source, /feeItemName/)
@@ -1,3 +1,4 @@
import { verifyDownloadCenterExport } from './download-center.mjs'
const MODULE = 'phase4-tail-order-controls'
const SCENARIO = 'phase4-tail-order-controls-lifecycle'
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
@@ -207,14 +208,9 @@ async function verifyDestinationMergeFeeAndExport(context, details, producer) {
&& row?.originalCurrency === 'USD' && Number(row?.originalAmount) === 0.01
&& row?.currency === 'CNY' && Number(row?.amount) > 0), trace(feeRead.response), OPTIONS)
const exported = await context.requestBinary('GET', query(`${ORDERS}/export`, {
const exported = await verifyDownloadCenterExport(context, MODULE, 'LAST_MILE_ORDER_LIST', {
customerId: id(representative.customerId), containerNo: producer.containerNo,
}))
const csv = new TextDecoder().decode(exported.bytes || new Uint8Array())
context.addStep(MODULE, '导出本轮尾程订单CSV', exported,
exported?.http === 200 && String(exported?.contentType || '').toLowerCase().includes('csv')
&& csv.includes(producer.containerNo) && csv.includes('尾程单号'),
`bytes=${exported?.bytes?.length ?? 0}, containerNo=${producer.containerNo}; ${trace(exported)}`, OPTIONS)
}, ['尾程单号', '集装箱号'], [producer.containerNo])
return success(batchCleared.response) && success(fee.response) && exported?.http === 200
}
function manualPlanBody(order, version, marker) {
@@ -1,6 +1,6 @@
import test from 'node:test'
import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, inventorySnapshot, dispatchInventorySources, inventorySourcesPresent, dispatchStockConservation, verifyDepartureReadiness, verifyPartialOrderCompletion, verifyFulfillmentRootStatus,
completeReturnedException, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer,
completeReturnedException, returnedExceptionFactsMatch, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer,
dispatchDestinationConservation } from './phase5-overseas-fulfillment.mjs'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
@@ -10,6 +10,23 @@ import { parseArguments } from '../admin-test-env-acceptance.mjs'
const source = await readFile(fileURLToPath(new URL('./phase5-overseas-fulfillment.mjs', import.meta.url)), 'utf8')
const entry = await readFile(fileURLToPath(new URL('../admin-test-env-acceptance.mjs', import.meta.url)), 'utf8')
test('一箱实收从四箱派送退回仅保持RETURN_PENDING,全部实收才RETURNED,缺失收货或来源证据仍拒绝', () => {
const expected = { customerId: '100', outboundNo: 'OB-1', deliveryNo: 'DL-1', inboundCode: 'IN-1', evidenceFileId: '91' }
const detail = { order: { ...expected, status: 'COMPLETED', packageCount: 1, evidenceFileIds: ['91'] },
returnSources: [], returnReceipts: [{ receiptNo: 'RSR-1', receivedPackages: '1', receivedAt: '2026-10-06T01:00:00' }] }
const delivery = { order: { customerId: '100', outboundNo: 'OB-1', deliveryNo: 'DL-1', packageCount: 4, status: 'RETURN_PENDING' } }
assert.equal(returnedExceptionFactsMatch(detail, delivery, expected), true)
assert.equal(returnedExceptionFactsMatch(detail, { order: { ...delivery.order, packageCount: 1, status: 'RETURNED' } }, expected), true)
assert.equal(returnedExceptionFactsMatch(detail, { order: { ...delivery.order, status: 'RETURNED' } }, expected), false)
assert.equal(returnedExceptionFactsMatch(detail, { order: { ...delivery.order, customerId: 'OTHER' } }, expected), false)
for (const mutate of [v => { v.returnReceipts = [] }, v => { v.returnReceipts[0].receivedPackages = 2 },
v => { v.order.outboundNo = 'OB-OTHER' }, v => { v.order.evidenceFileIds = [] },
v => { v.returnSources = [{ maxReturnPackages: 1 }] }, v => { v.returnReceipts.push({ ...v.returnReceipts[0] }) }]) {
const changed = structuredClone(detail); mutate(changed)
assert.equal(returnedExceptionFactsMatch(changed, delivery, expected), false)
}
})
test('route equals actual item destinations and frozen selected candidates, then conserves split deliveries',()=>{
const fixture={planPalletIds:['10','11','12'],destinationWarehouseCodes:['ONT8','LAX9']}
const detail={route:'ONT8,LAX9',items:[{planPalletId:'10',fbaWarehouseCode:'ONT8'},
@@ -20,6 +37,7 @@ test('route equals actual item destinations and frozen selected candidates, then
for(const mutate of [
value=>value.detail.route='ONT8',value=>value.detail.route='ONT8,LAX9,OTHER',
value=>value.detail.route='ONT8,ONT8,LAX9',value=>value.detail.items[1].destinationWarehouseCode='OTHER',
value=>value.detail.items[0].destinationWarehouseCode='REASSIGNED',
value=>value.detail.items[1].planPalletId='99',value=>value.detail.items[1].planPalletId='10',
value=>value.fixture.destinationWarehouseCodes.push('UNSELECTED'),value=>value.deliveries.pop(),
value=>value.deliveries.push({destinationWarehouseCode:'OTHER'}),
@@ -218,9 +236,10 @@ test('same dispatch-return exception reaches actual restored inventory and retri
const replayed = completed; completed = true
return ok({ status: 'COMPLETED', restoredPackages: 1, inventoryAdjustmentId: '81', replayed })
}
if (path.includes('/delivery-orders/')) return ok({ order: { status: 'RETURNED', outboundNo: 'OB-1' } })
if (path.includes('/delivery-orders/')) return ok({ order: { status: 'RETURNED', outboundNo: 'OB-1', deliveryNo: 'DEL-1', customerId: '100', packageCount: 1 } })
return ok({ order: { status: completed ? 'COMPLETED' : initialStatus, exceptionType: 'RETURN',
outboundNo: 'OB-1', inboundCode: 'IN-A', deliveryNo: 'DEL-1', version, evidenceFileIds: ['91'] }, returnSources: [source] })
outboundNo: 'OB-1', inboundCode: 'IN-A', deliveryNo: 'DEL-1', customerId: '100', packageCount: 1, version, evidenceFileIds: ['91'] },
returnSources: completed ? [] : [source], returnReceipts: completed ? [{ receiptNo: 'RSR-CURRENT', receivedPackages: 1, receivedAt: '2026-10-06T00:00:00' }] : [] })
})
context.requestMultipart = async () => { uploads += 1; return ok({ fileId: '91' }) }
context.createdResources.phase5OverseasFulfillment = { marker: context.marker, returnExceptionNo: 'EX-1', outboundNo: 'OB-1', inventoryProof: { sourceBalanceIds: ['1'] } }
@@ -1,6 +1,7 @@
import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overseas-pda.mjs'
import { run as createMerchant } from './merchant-qualification-fixture.mjs'
import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip } from './fleet-trip-fulfillment.mjs'
import { verifyFleetTripFinance } from './fleet-trip-finance-acceptance.mjs'
const MODULE = 'phase5-overseas-fulfillment'
const SCENARIO = 'phase5-dispatch-outbound-delivery-exception-visit'
@@ -68,6 +69,8 @@ export function dispatchDestinationConservation(detail, fixture, deliveries = nu
const actualIds = items.map(row => id(row.planPalletId)), expectedIds=fixture.planPalletIds.map(id)
if (!actualIds.every(nonBlank) || actualIds.length !== expectedIds.length
|| new Set(actualIds).size !== actualIds.length || !sameSet(actualIds,expectedIds)) return false
if (items.some(row => nonBlank(row.fbaWarehouseCode) && nonBlank(row.destinationWarehouseCode)
&& id(row.fbaWarehouseCode).trim().toUpperCase() !== id(row.destinationWarehouseCode).trim().toUpperCase())) return false
const actual = codes(items.map(row => row.fbaWarehouseCode || row.destinationWarehouseCode))
const frozen = codes(fixture.destinationWarehouseCodes), route = codes(detail.route.split(','))
if (!sameSet(actual,frozen) || !sameSet(route,actual) || route.length !== new Set(route).size) return false
@@ -444,6 +447,26 @@ export async function verifyPartialOrderCompletion(context, { orderId, customerI
return sameOrder && blocked
}
export function returnedExceptionFactsMatch(detail, delivery, expected) {
const order = detail?.order, shipment = delivery?.order
const receipts = detail?.returnReceipts
if (order?.status !== 'COMPLETED' || order.outboundNo !== expected.outboundNo
|| order.deliveryNo !== expected.deliveryNo || order.inboundCode !== expected.inboundCode
|| id(order.customerId) !== expected.customerId
|| !order.evidenceFileIds?.some(fileId => id(fileId) === expected.evidenceFileId)
|| !Array.isArray(detail.returnSources) || detail.returnSources.length !== 0
|| !Array.isArray(receipts) || receipts.length === 0
|| new Set(receipts.map(row => row.receiptNo)).size !== receipts.length
|| receipts.some(row => !nonBlank(row.receiptNo) || !nonBlank(row.receivedAt)
|| !Number.isSafeInteger(Number(row.receivedPackages)) || Number(row.receivedPackages) <= 0)) return false
const received = receipts.reduce((sum, row) => sum + Number(row.receivedPackages), 0)
const sent = Number(shipment?.packageCount)
return received === Number(order.packageCount) && Number.isSafeInteger(sent) && sent >= received
&& shipment.outboundNo === expected.outboundNo && shipment.deliveryNo === expected.deliveryNo
&& id(shipment.customerId) === expected.customerId
&& shipment.status === (received === sent ? 'RETURNED' : 'RETURN_PENDING')
}
export async function completeReturnedException(context, { customerId, warehouseId, exceptionNo, outboundNo, resume = null }) {
const owned = context.createdResources?.phase5OverseasFulfillment
if (owned?.marker !== context.marker || owned?.returnExceptionNo !== exceptionNo || owned?.outboundNo !== outboundNo) {
@@ -566,13 +589,10 @@ export async function completeReturnedException(context, { customerId, warehouse
const final = await platform(context, 'GET', '回查同派送退回异常完成及来源证据', exceptionPath)
const delivery = await platform(context, 'GET', '回查退回实收后的原派送单',
query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(order.deliveryNo)}`, { customerId }))
context.assert(MODULE, '退回异常完成保留原出库库存来源与实收证据且派送RETURNED', final.response,
final.data?.order?.status === 'COMPLETED' && final.data.order.outboundNo === outboundNo
&& final.data.order.evidenceFileIds?.some(fileId => id(fileId) === evidenceFileId)
&& final.data.returnSources?.some(row => id(row.outboundPalletItemId) === id(source.outboundPalletItemId)
&& id(row.inventoryPositionId) === id(source.inventoryPositionId))
&& delivery.data?.order?.status === 'RETURNED' && delivery.data.order.outboundNo === outboundNo,
trace(final.response), OPTIONS)
context.assert(MODULE, '退回实收保留原出库和收货证据,派送状态按实际退回数量推进', final.response,
returnedExceptionFactsMatch(final.data, delivery.data, { customerId, outboundNo,
deliveryNo: order.deliveryNo, inboundCode: order.inboundCode, evidenceFileId }),
`approved=${final.data?.order?.packageCount}, sent=${delivery.data?.order?.packageCount}, status=${delivery.data?.order?.status}; ${trace(final.response)}`, OPTIONS)
return { exceptionNo, targetPalletId, targetPalletNo: palletCode, evidenceFileId,
inventoryAdjustmentId: id(completed.data.inventoryAdjustmentId), restoredPackages: Number(completed.data.restoredPackages),
targetPositionIds: targetPositions.map(row => id(row.id)) }
@@ -1232,6 +1252,8 @@ export async function run(context) {
exceptionNo: returnExceptionNo, outboundNo })
context.createdResources.phase5OverseasFulfillment.returnReceipt = returnReceipt
context.createdResources.phase5OverseasFulfillment.tripClosure = await finishFleetTrip(context, MODULE, tripProof)
await verifyFleetTripFinance(context, MODULE, { tripNo: tripProof.tripNo, dispatchNos: [dispatchNo],
businesses: deliveryNos.map(deliveryNo => ({ customerId, deliveryNo })), receiptsVisible: true })
context.createdResources.phase5OverseasFulfillment.finalStatus = returnReceipt
? 'RETURNED_WITH_COMPLETED_VISIT' : 'RETURN_PENDING_WITH_COMPLETED_VISIT'
}
@@ -2,16 +2,29 @@ import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
import { fulfillmentHistorySnapshot, validTwoFragmentTree, validSignatureGate,
completionCounts, exactCompletionOutboxProof, resolveF40MerchantCredentials,
completionCounts, exactCompletionOutboxProof, rootCompletionTimeMatches, resolveF40MerchantCredentials,
f40TransferBody, f40LocalDateTime, validFragmentCabinetIdentity, validUnloadedRemainder,
validRemainderCancellationGate, exactCancelledRemainderOutboxProof, cancelUnloadedRemainder,
verifyRootProjections, verifyRemainderTaxIdentity, validFreshReceiptSave, preserveReceivingEvidence, run } from './phase5-root-fulfillment.mjs'
verifyRootProjections, verifyRemainderTaxIdentity, validFreshReceiptSave, preserveReceivingEvidence,
resolveF40ReceivingWarehouse, run } from './phase5-root-fulfillment.mjs'
import { loadOneFragment } from './phase3-order-splitting.mjs'
import { verifyPalletLabels } from './phase3-loading-finalize.mjs'
import { awaitProducerConsumption, convertReceivingContainers, runReceiving,
resolveSharedWarehouse } from './phase4-overseas-pda.mjs'
const source = readFileSync(new URL('./phase5-root-fulfillment.mjs', import.meta.url),'utf8')
test('根完成UTC时刻与上海本地签收最大值一致,拒绝时区偏移、较早来源和缺失证据', () => {
const times = ['2026-10-05T21:55:00.000', '2026-10-05T21:56:07.922']
assert.equal(rootCompletionTimeMatches('2026-10-05T13:56:07.922', times), true)
assert.equal(rootCompletionTimeMatches('2026-10-05T13:56:07.922Z', [...times].reverse()), true)
assert.equal(rootCompletionTimeMatches('2026-10-05T13:56:07.922Z', ['2026-10-05T21:56:07.922+08:00']), true)
for (const value of ['2026-10-05T21:56:07.922', '2026-10-05T13:55:00', null]) {
assert.equal(rootCompletionTimeMatches(value, times), false)
}
assert.equal(rootCompletionTimeMatches('2026-10-05T13:56:07.922', []), false)
assert.equal(rootCompletionTimeMatches('2026-10-05T13:56:07.922', ['invalid']), false)
})
const history = (...actions) => actions.map((action,index) => ({id:String(index+1),action}))
const tree = () => ({parentOrderId:'100',originalPackageCount:2,cumulativeLoadedPackageCount:2,
currentRemainderPackageCount:0,children:[
@@ -285,6 +298,85 @@ test('exported receiving warehouse resolver continues to choose only configured
]}}})
assert.deepEqual(await resolveSharedWarehouse(context),{id:'193000000000200003',name:'offline'})
})
function warehouseSelectionFixture() {
const producers = [1, 2].map(n => ({ tenantId: '90', containerNo: `CONTAINER-${n}`,
containerBookingId: `BOOKING-${n}`, sourceInboundId: `INBOUND-${n}`, inboundCode: `CODE-${n}` }))
const orders = producers.map((p, n) => ({ id: `LASTLEG-${n}`, customerId: p.tenantId,
sourceContainerBookingId: p.containerBookingId, sourceInboundId: p.sourceInboundId,
inboundCode: p.inboundCode, containerNo: p.containerNo, overseasWarehouseId: 'actual-warehouse',
overseasWarehouseCode: 'ACTUAL' }))
const warehouses = [{ id: 'other-warehouse', warehouseName: 'other', warehouseCode: 'OTHER',
warehouseType: 'OVERSEAS', status: 'ACTIVE' },
{ id: 'actual-warehouse', warehouseName: 'actual', warehouseCode: 'ACTUAL',
warehouseType: 'OVERSEAS', status: 'ACTIVE' }]
const calls = [], context = fixture()
context.request = async (method, path) => {
calls.push({ method, path })
const rows = path.includes('/warehouse-admin?') ? warehouses
: orders.filter(row => row.containerNo === new URL(path, 'https://offline.invalid').searchParams.get('containerNo'))
return { http: 200, body: { code: 200, data: { records: rows, total: rows.length } } }
}
return { producers, orders, warehouses, calls, context }
}
test('F40 selects actual source warehouse irrespective of authority list order and only performs GET', async () => {
for (const reverse of [false, true]) {
const f = warehouseSelectionFixture()
if (reverse) { f.warehouses.reverse(); f.orders.reverse(); f.producers.reverse() }
const warehouse = await resolveF40ReceivingWarehouse(f.context, f.producers)
assert.equal(warehouse.id, 'actual-warehouse')
assert.equal(warehouse.code, 'ACTUAL')
assert.equal(warehouse.sourceOrders.length, 2)
assert.ok(f.calls.every(row => row.method === 'GET'))
}
assert.ok(source.indexOf('await awaitProducerConsumption(scope,producers)')
< source.indexOf('await resolveF40ReceivingWarehouse(scope,producers)'))
assert.ok(source.indexOf('await resolveF40ReceivingWarehouse(scope,producers)')
< source.indexOf('await createPicker(scope, 7'))
assert.doesNotMatch(source, /resolveSharedWarehouse|19300000000020000[34]/)
})
test('F40 warehouse resolution rejects wrong customer, booking, inbound identity, duplicates and missing assignment', async () => {
for (const mutate of [
f => { f.orders[0].customerId = 'foreign' },
f => { f.orders[0].sourceContainerBookingId = 'foreign' },
f => { f.orders[0].sourceInboundId = 'foreign' },
f => { f.orders[0].inboundCode = 'foreign' },
f => { f.orders[0].containerNo = 'foreign' },
f => { f.orders[0].overseasWarehouseId = '' },
f => { f.orders.push({ ...f.orders[0] }) },
]) {
const f = warehouseSelectionFixture(); mutate(f)
await assert.rejects(resolveF40ReceivingWarehouse(f.context, f.producers), /来源柜客户入仓身份/)
assert.ok(f.calls.every(row => !row.path.includes('/warehouse-admin?')))
}
})
test('F40 warehouse resolution blocks different receiving warehouses and invalid or ambiguous official master data', async () => {
const mixed = warehouseSelectionFixture(); mixed.orders[1].overseasWarehouseId = 'other-warehouse'
await assert.rejects(resolveF40ReceivingWarehouse(mixed.context, mixed.producers), /同一实际接柜仓/)
for (const mutate of [
f => { f.warehouses.pop() },
f => { f.warehouses[1].status = 'DISABLED' },
f => { f.warehouses[1].warehouseType = 'DOMESTIC' },
f => { f.warehouses[1].warehouseCode = 'WRONG' },
f => { f.warehouses.push({ ...f.warehouses[1] }) },
]) {
const f = warehouseSelectionFixture(); mutate(f)
await assert.rejects(resolveF40ReceivingWarehouse(f.context, f.producers), /唯一启用海外收货仓/)
assert.ok(f.calls.every(row => row.method === 'GET'))
}
})
test('F40 warehouse resolution rejects empty or duplicate producers before HTTP', async () => {
for (const select of [() => [], f => [f.producers[0], f.producers[0]],
f => [{ ...f.producers[0], sourceInboundId: '' }]]) {
const f = warehouseSelectionFixture()
await assert.rejects(resolveF40ReceivingWarehouse(f.context, select(f)), /唯.*本轮实装来源/)
assert.equal(f.calls.length, 0)
}
})
test('complete orchestrator calls actual source chain and blocks missing Outbox evidence; no fake callback or SQL',() => {
assert.equal(typeof run,'function')
for(const name of ['orders','inbound-measurement','phase3-customs','phase3-outbound-dispatch',
@@ -2,7 +2,7 @@ import { loadOneFragment } from './phase3-order-splitting.mjs'
import { freezeOrderCommand } from './orders.mjs'
import { verifyPalletLabels } from './phase3-loading-finalize.mjs'
import { stableLoadedCartonMarks } from './phase3-loading.mjs'
import { awaitProducerConsumption, convertReceivingContainers, runReceiving, resolveSharedWarehouse,
import { awaitProducerConsumption, convertReceivingContainers, runReceiving,
resolvePickerAccess, createPicker, disablePicker } from './phase4-overseas-pda.mjs'
import { pickAll, verifyDepartureReadiness, saveReceiptAndComplete } from './phase5-overseas-fulfillment.mjs'
import { isDeepStrictEqual } from 'node:util'
@@ -19,6 +19,47 @@ const id = value => value == null ? '' : String(value)
const success = response => response?.http === 200 && String(response?.body?.code) === '200'
const query = (path, values) => `${path}?${new URLSearchParams(values)}`
/** Resolve only this run's consumed physical sources, never a fixed warehouse ID or list position. */
export async function resolveF40ReceivingWarehouse(context, producers) {
check(context, 'F40 选仓仅接受唯一的本轮实装来源', Array.isArray(producers) && producers.length > 0
&& producers.every(row => row?.containerNo && row?.inboundCode && id(row.tenantId)
&& id(row.containerBookingId) && id(row.sourceInboundId))
&& new Set(producers.map(row => id(row.containerBookingId))).size === producers.length
&& new Set(producers.map(row => id(row.tenantId))).size === 1, 'exact-producer-identities')
const sources = []
for (const producer of producers) {
const page = await api(context, 'GET', 'F40 读取本柜实际尾程来源与接柜仓',
query('/api/platform/v1/last-leg/orders', { page: 1, size: 200, containerNo: producer.containerNo }))
const rows = Array.isArray(page?.records) ? page.records : []
const matching = rows.filter(row => row.containerNo === producer.containerNo
&& id(row.sourceContainerBookingId) === id(producer.containerBookingId)
&& id(row.customerId) === id(producer.tenantId)
&& id(row.sourceInboundId) === id(producer.sourceInboundId) && row.inboundCode === producer.inboundCode)
check(context, 'F40 尾程来源柜客户入仓身份唯一且接柜仓明确', matching.length === 1
&& (page?.total == null || Number(page.total) <= rows.length)
&& Boolean(id(matching[0]?.id) && id(matching[0]?.overseasWarehouseId)),
`container=${producer.containerNo}, exactSources=${matching.length}`)
sources.push(matching[0])
}
const warehouseId = id(sources[0].overseasWarehouseId)
check(context, 'F40 全部实装来源属于同一实际接柜仓',
sources.every(row => id(row.overseasWarehouseId) === warehouseId), 'unique-source-receiving-warehouse')
const page = await api(context, 'GET', 'F40 按来源仓精确核验Warehouse权威收货主档',
'/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE')
const rows = Array.isArray(page?.records) ? page.records : []
const matching = rows.filter(row => id(row.id) === warehouseId)
const warehouse = matching[0]
check(context, 'F40 实际接柜仓为唯一启用海外收货仓且编码一致', matching.length === 1
&& warehouse.warehouseType === 'OVERSEAS' && warehouse.status === 'ACTIVE'
&& sources.every(row => !row.overseasWarehouseCode || row.overseasWarehouseCode === warehouse.warehouseCode),
`warehouseId=${warehouseId}`)
return { id: warehouseId, name: warehouse.warehouseName || warehouse.name,
code: warehouse.warehouseCode, sourceOrders: sources.map(row => ({ orderId: id(row.id),
customerId: id(row.customerId), sourceContainerBookingId: id(row.sourceContainerBookingId),
sourceInboundId: id(row.sourceInboundId), inboundCode: row.inboundCode, containerNo: row.containerNo,
overseasWarehouseId: id(row.overseasWarehouseId) })) }
}
export function f40LocalDateTime(value) { return value.toISOString().slice(0,19) }
export function preserveReceivingEvidence(proof, scope) {
proof.receivingCompletionEvidence = scope.createdResources?.phase4OverseasPda?.receivingCompletionEvidence || {}
@@ -128,6 +169,19 @@ export function validSignatureGate(snapshot, firstSource, allSigned = false) {
&& snapshot?.rootInbound?.processStatus === 'DELIVERED'
&& snapshot?.rootInbound?.orderStatusSnapshot !== 'COMPLETED'
}
// TEST Lastleg runs with TZ=Asia/Shanghai. Its public signedAt is local;
// the completion event and Order projection preserve the same instant in UTC.
export function rootCompletionTimeMatches(completedAt, signedTimes) {
const instant = (value, offset) => {
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})?$/.test(value)) return NaN
return Date.parse(value + (/(?:Z|[+-]\d{2}:\d{2})$/.test(value) ? '' : offset))
}
if (!Array.isArray(signedTimes) || signedTimes.length === 0) return false
const signatures = signedTimes.map(value => instant(value, '+08:00'))
return signatures.every(Number.isFinite) && Number.isFinite(instant(completedAt, 'Z'))
&& instant(completedAt, 'Z') === Math.max(...signatures)
}
export function exactCompletionOutboxProof(evidence, customerId, rootOrderId, sourceOrderIds) {
const expected = [...new Set(sourceOrderIds.map(id))].sort()
const rows = Array.isArray(evidence?.orders) ? evidence.orders : []
@@ -613,8 +667,13 @@ async function runRootFulfillment(context) {
&& id(before.root.rootOrderId) === rootOrderId && before.root.status !== 'COMPLETED'
&& before.child.status !== 'COMPLETED' && completionCounts(before.root).own === 0, 'before-signatures')
proof.snapshots.push({ phase:'BEFORE_SIGNATURES', ...before })
const producers = cancelRemainder ? [first] : [first,second]
proof.currentStage = 'OVERSEAS_SOURCE_WAREHOUSE'
check(scope, 'F40 实装来源真实装柜事件已消费', await awaitProducerConsumption(scope,producers), `producers=${producers.length}`)
const warehouse = await resolveF40ReceivingWarehouse(scope,producers)
proof.receivingWarehouseResolution = warehouse
proof.currentStage = 'OVERSEAS_PDA_IDENTITY'
const warehouse = await resolveSharedWarehouse(scope), access = await resolvePickerAccess(scope)
const access = await resolvePickerAccess(scope)
check(scope, 'F40 实际海外接柜仓和PDA权限有效', Boolean(warehouse?.id && access), 'resourcePresent')
scope.marker = `${scope.context.f40BaseMarker}-PDA`
const picker = await createPicker(scope, 7, warehouse.id, access)
@@ -622,7 +681,6 @@ async function runRootFulfillment(context) {
check(scope, 'F40 海外PDA操作员会话有效', Boolean(picker?.token), 'tokenPresent')
picker.warehouseId = warehouse.id
check(scope, 'F40 客户会话属于该根订单客户', id(profile.id) === customerId, 'customerOwned')
const producers = cancelRemainder ? [first] : [first,second]
scope.createdResources.phase4InboundProducerFixtures = { marker: scope.marker, containers:producers }
scope.createdResources.phase4OverseasPda = { marker:scope.marker, generatedPalletsByInbound:{},
palletIds:[],locationIds:[],completedContainerFacts:[], completedContainers:[],
@@ -630,7 +688,6 @@ async function runRootFulfillment(context) {
receivingCompletionEvidence:{} }
preserveReceivingEvidence(proof,scope)
proof.currentStage = 'OVERSEAS_PLAN_CONVERSION'
check(scope, 'F40 实装来源真实装柜事件已消费', await awaitProducerConsumption(scope,producers), `producers=${producers.length}`)
check(scope, 'F40 实装来源真实计划及托盘转换成功', await convertReceivingContainers(scope,producers), `producers=${producers.length}`)
const signatureSources = signatureOrder === 'CHILD_FIRST' ? [second,first] : producers
for (const [index, producer] of signatureSources.entries()) {
@@ -663,7 +720,7 @@ async function runRootFulfillment(context) {
const stable = await state(scope,customerId,rootOrderId,childOrderId,first.inboundId)
proof.finalProjections = await verifyRootProjections(scope,customerId,rootOrderId,login.accessToken,stable)
const finalSignedAt = proof.signatures.map(row => row.signedAt).sort().at(-1)
check(scope,'F40 根完成时间为实际有效来源签收时间最大值', stable.root.rawOrder?.completedAt === finalSignedAt,
check(scope,'F40 根完成时间为实际有效来源签收时间最大值', rootCompletionTimeMatches(stable.root.rawOrder?.completedAt, proof.signatures.map(row => row.signedAt)),
`completedAt=${stable.root.rawOrder?.completedAt}, maxSignedAt=${finalSignedAt}`)
const reader = context.context?.f40ReadOnlyOutboxEvidence
const outboxRequest = {customerId,rootOrderId,sourceOrderIds:[rootOrderId,childOrderId],
@@ -80,7 +80,9 @@ test('container API uses one stable long string identity', () => {
assert.match(controller, /convertContainer/)
assert.match(adminApi, /sourceContainerBookingId: string/)
assert.match(adminView, /const bookingId = preview\.sourceContainerBookingId/)
assert.match(adminView, /convertLastMileContainerToInboundPlans\(\{\s*sourceContainerBookingId: bookingId,\s*snapshotVersion: preview\.snapshotVersion,\s*snapshotHash: preview\.snapshotHash,/)
assert.match(adminView, /const commandSnapshot = preview\.resourceRetry \?\? preview/)
assert.match(adminView, /containerConversionIdempotencyKey = preview\.resourceRetry\.idempotencyKey/)
assert.match(adminView, /convertLastMileContainerToInboundPlans\(\{\s*sourceContainerBookingId: bookingId,\s*snapshotVersion: commandSnapshot\.snapshotVersion,\s*snapshotHash: commandSnapshot\.snapshotHash,/)
})
test('backend batch conversion is one transaction and never merges same-customer orders', () => {
@@ -65,7 +65,15 @@ export async function verifyVehicleCare(context, module, vehicle, proof) {
incident = await command('PUT', `${base}/${incident.recordNo}`, { recordVersion: Number(incident.recordVersion), reason: '受控任务实际处理完成',
details: { ...details, incident: { ...details.incident, handler: '本轮测试处理人', processingStatus: 'COMPLETED' } } }, `完成本轮${type}处理记录`)
check('处理完成保持独立的费用及付款事实', null, incident.details.incident.processingStatus === 'COMPLETED'
&& (!priced || (Number(incident.details.incident.reportedCost) === details.incident.reportedCost && incident.paymentStatus === 'UNLINKED')))
&& incident.paymentStatus == null
&& (!priced || (Number(incident.details.incident.reportedCost) === details.incident.reportedCost
&& incident.details.incident.currency === details.incident.currency)))
if (priced) {
const financial = await command('POST', `${base}/finance-status`, { recordNos: [incident.recordNo] }, '独立批量查询本轮处理记录付款事实')
check('处理完成未自动提交财务或标记付款', null, Array.isArray(financial) && financial.length === 1
&& financial[0].recordNo === incident.recordNo && financial[0].status === 'UNLINKED'
&& financial[0].sourceVersion == null && financial[0].submittedRecordVersion == null && financial[0].finance == null)
}
const refreshed = await command('GET', `${base}/${incident.recordNo}`, undefined, `重新读取本轮${type}记录`)
check('处理记录重新读取保持全部现场字段', null, (refreshed.details.incident.item ?? null) === details.incident.item
&& (refreshed.details.incident.place ?? null) === details.incident.place && refreshed.details.incident.handler === '本轮测试处理人')
+3
View File
@@ -9,6 +9,7 @@ $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$pom = Join-Path $repoRoot "pom.xml"
$mapperScanContract = Join-Path $repoRoot "scripts/mapper-scan-contract.mjs"
$requiredTests = @(
"scripts/acceptance-evidence-audit.test.mjs",
"scripts/gateway-route-contract.test.mjs",
"scripts/mapper-scan-contract.test.mjs",
"firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java",
@@ -69,6 +70,8 @@ Push-Location $repoRoot
try {
& node --test (Join-Path $repoRoot "scripts/gateway-route-contract.test.mjs")
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
& node --test (Join-Path $repoRoot "scripts/acceptance-evidence-audit.test.mjs")
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
& node $mapperScanContract
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
& $MavenCommand @mavenArgs
@@ -8,7 +8,10 @@ const scriptsDir = dirname(fileURLToPath(import.meta.url))
const source = readFileSync(resolve(scriptsDir, 'client-test-env-acceptance.mjs'), 'utf8')
test('Client full acceptance executes the real notification inbox flow', () => {
assert.match(source, /await isolationModule\(\)\s+await notificationInboxModule\(\)\s+await accountSecurityModule\(\)/)
const isolation = source.indexOf('await isolationModule()')
const notifications = source.indexOf('await notificationInboxModule()')
const security = source.indexOf('await accountSecurityModule()')
assert.ok(isolation >= 0 && notifications > isolation && security > notifications)
assert.match(source, /async function notificationInboxModule\(\)/)
for (const permission of [
'merchant:notification:view',
@@ -0,0 +1,29 @@
const positive = value => ['number', 'string'].includes(typeof value)
&& String(value).trim() !== '' && Number.isFinite(Number(value)) && Number(value) > 0
export function frozenOuterMeasurement(item) {
const outer = [item?.calculatedOuterLengthCm, item?.calculatedOuterWidthCm, item?.calculatedOuterHeightCm]
if (!outer.every(positive)) throw Error('测量缺少已冻结的计费外尺寸;请准备当前基线的新样本')
const volume = outer.reduce((total, value) => total * Number(value), 1) / 1_000_000
return { outer: outer.map(Number), unitVolumeCbm: Math.round((volume + Number.EPSILON) * 1000) / 1000 }
}
export function publishedMeasurementItemsMatch(published, platform) {
const exactId = item => typeof item?.id === 'string' && /^[1-9]\d*$/.test(item.id)
if (!Array.isArray(published) || !Array.isArray(platform) || platform.length === 0
|| !published.every(exactId) || !platform.every(exactId) || published.length !== platform.length
|| new Set(platform.map(item => String(item.id))).size !== platform.length
|| new Set(published.map(item => String(item.id))).size !== published.length) return false
return published.every(item => {
const source = platform.find(row => String(row.id) === String(item.id))
if (!source) return false
const frozen = frozenOuterMeasurement(source)
const numbers = { packageCount: source.packageCount, lengthCm: frozen.outer[0], widthCm: frozen.outer[1],
heightCm: frozen.outer[2], unitVolumeCbm: source.unitVolumeCbm,
totalVolumeCbm: source.totalVolumeCbm, volumeWeightKg: source.volumeWeightKg }
return Object.entries(numbers).every(([key, expected]) => positive(item[key]) && positive(expected)
&& Math.abs(Number(item[key]) - Number(expected)) <= 0.000001)
&& String(item.chargeRuleId ?? '') === String(source.chargeRuleId ?? '')
&& item.coefficientMode === source.coefficientMode && item.calculationRuleVersion === source.calculationRuleVersion
})
}
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { frozenOuterMeasurement, publishedMeasurementItemsMatch } from './client-phase2-measurement-evidence.mjs'
const source = () => ({ id: '9007199254740993', packageCount: 1, lengthCm: 49.5, widthCm: 39.5, heightCm: 38.5,
calculatedOuterLengthCm: 53.5, calculatedOuterWidthCm: 43.5, calculatedOuterHeightCm: 42.5,
unitVolumeCbm: 0.099, totalVolumeCbm: 0.099, volumeWeightKg: 16.533,
chargeRuleId: '9007199254740994', coefficientMode: 'ADD', calculationRuleVersion: 'FROZEN-1' })
const published = () => ({ ...source(), lengthCm: 53.5, widthCm: 43.5, heightCm: 42.5 })
test('客户计费读取已冻结外尺寸,不把原始测量或当前箱规当成发布尺寸', () => {
assert.deepEqual(frozenOuterMeasurement(source()), { outer: [53.5, 43.5, 42.5], unitVolumeCbm: 0.099 })
assert.equal(publishedMeasurementItemsMatch([published()], [source()]), true)
assert.equal(publishedMeasurementItemsMatch([source()], [source()]), false)
})
test('发布值、计费规则快照及精确长整数来源不一致时拒绝', () => {
for (const mutate of [v => { v.id = '9007199254740992' }, v => { v.lengthCm++ },
v => { v.unitVolumeCbm += 0.001 }, v => { v.chargeRuleId = 'OTHER' },
v => { v.coefficientMode = 'MULTIPLY' }, v => { v.calculationRuleVersion = 'NEW' }]) {
const item = published(); mutate(item)
assert.equal(publishedMeasurementItemsMatch([item], [source()]), false)
}
assert.equal(publishedMeasurementItemsMatch([published(), published()], [source(), source()]), false)
assert.equal(publishedMeasurementItemsMatch([], []), false)
assert.equal(publishedMeasurementItemsMatch([{ ...published(), id: null }], [{ ...source(), id: null }]), false)
assert.equal(publishedMeasurementItemsMatch([{ ...published(), id: 9007199254740993 }], [source()]), false)
assert.throws(() => frozenOuterMeasurement({ ...source(), calculatedOuterLengthCm: null }))
})
+10 -52
View File
@@ -14,6 +14,7 @@ import { mkdir, writeFile } from 'node:fs/promises'
import { randomUUID } from 'node:crypto'
import { resolve } from 'node:path'
import { createInterface } from 'node:readline/promises'
import { frozenOuterMeasurement, publishedMeasurementItemsMatch } from './client-phase2-measurement-evidence.mjs'
const args = parseArgs(process.argv.slice(2))
const gateway = trimSlash(args.gateway ?? process.env.ADMIN_TEST_GATEWAY)
@@ -75,7 +76,6 @@ let pendingFixture
let foreignFixture
let platformTerminalDetail
let platformMeasurement
let dimensionRules = []
let merchantBilling
try {
@@ -192,7 +192,7 @@ async function fixtureDiscoveryModule() {
}))
platformTerminalDetail = detail.json.data
const dimensionConfig = await apiStep('phase2-measurement-fixture',
await apiStep('phase2-measurement-fixture',
'load four customer dimension weighting rules', {
method: 'GET',
path: `/api/platform/v1/first-leg/customer-dimension-config?tenantId=${report.resources.tenantId}`,
@@ -214,7 +214,6 @@ async function fixtureDiscoveryModule() {
version: rule.version,
})),
}))
dimensionRules = dimensionConfig.json.data.rules
const measurement = await apiStep('phase2-measurement-fixture',
'load approved terminal measurement detail', {
@@ -297,9 +296,9 @@ async function fixtureDiscoveryModule() {
})
await softLocalStep('phase2-measurement-fixture',
'carton billing volume uses current customer dimension weighting', () => {
'carton billing volume agrees with frozen calculated outer dimensions', () => {
for (const item of platformMeasurement.items) {
const expected = resolveWeightedDimensions(item, dimensionRules)
const expected = frozenOuterMeasurement(item)
assertApproxEqual(item.unitVolumeCbm, expected.unitVolumeCbm, 0.000001,
`${item.itemNo} weighted unit volume`)
}
@@ -310,7 +309,7 @@ async function fixtureDiscoveryModule() {
measured: dimensions(item),
storedOuter: dimensions(item, 'outer'),
unitVolumeCbm: item.unitVolumeCbm,
expected: resolveWeightedDimensions(item, dimensionRules),
expected: frozenOuterMeasurement(item),
})),
})
@@ -486,22 +485,13 @@ async function terminalBillingModule() {
await softLocalStep('phase2-client-billing', 'published carton measurements match platform snapshot', () => {
const publishedItems = arrayData(merchantBilling.measurementItems)
assertEqual(publishedItems.length, platformMeasurement.items.length, 'published measurement item count')
const platformById = new Map(platformMeasurement.items.map((item) => [String(item.id), item]))
for (const item of publishedItems) {
assertStringId(item.id, 'published measurement item id')
const expected = platformById.get(String(item.id))
assertPresent(expected, `platform measurement item ${item.id}`)
for (const field of ['packageCount', 'lengthCm', 'widthCm', 'heightCm',
'unitVolumeCbm', 'totalVolumeCbm', 'volumeWeightKg']) {
assertApproxEqual(item[field], expected[field], 0.000001, `${item.itemNo} ${field}`)
}
}
assertTrue(publishedMeasurementItemsMatch(publishedItems, platformMeasurement.items), 'published outer dimensions and frozen calculation metadata')
}, { measurementItemCount: arrayData(merchantBilling.measurementItems).length })
await softLocalStep('phase2-client-billing', 'published carton volumes follow current weighting rules', () => {
await softLocalStep('phase2-client-billing', 'published carton volumes follow frozen outer dimensions', () => {
const platformById = new Map(platformMeasurement.items.map(item => [String(item.id), item]))
for (const item of arrayData(merchantBilling.measurementItems)) {
const expected = resolveWeightedDimensions(item, dimensionRules)
const expected = frozenOuterMeasurement(platformById.get(String(item.id)))
assertApproxEqual(item.unitVolumeCbm, expected.unitVolumeCbm, 0.000001,
`${item.itemNo} published weighted unit volume`)
}
@@ -510,7 +500,7 @@ async function terminalBillingModule() {
itemNo: item.itemNo,
measured: dimensions(item),
unitVolumeCbm: item.unitVolumeCbm,
expected: resolveWeightedDimensions(item, dimensionRules),
frozenOuter: [item.lengthCm, item.widthCm, item.heightCm],
})),
})
@@ -816,33 +806,6 @@ function assertDimensionRule(rule) {
0.0005, `${rule.boxType} configured volume`)
}
function resolveWeightedDimensions(item, rules) {
const measured = [Number(item.lengthCm), Number(item.widthCm), Number(item.heightCm)]
.sort((left, right) => right - left)
const candidates = [...arrayData(rules)].sort((left, right) =>
Number(left.innerLengthCm) * Number(left.innerWidthCm) * Number(left.innerHeightCm)
- Number(right.innerLengthCm) * Number(right.innerWidthCm) * Number(right.innerHeightCm))
const rule = candidates.find((candidate) => {
const inner = [Number(candidate.innerLengthCm), Number(candidate.innerWidthCm),
Number(candidate.innerHeightCm)].sort((left, right) => right - left)
return measured.every((value, index) => value <= inner[index])
})
if (!rule) {
const rawVolume = measured.reduce((total, value) => total * value, 1) / 1_000_000
return { boxType: null, ruleVersion: null, outer: dimensions(item),
unitVolumeCbm: round(rawVolume, 3) }
}
const edgePair = Number(rule.edgeAdditionCm) * 2
const outer = [Number(rule.innerLengthCm) + edgePair, Number(rule.innerWidthCm) + edgePair,
Number(rule.innerHeightCm) + edgePair]
return {
boxType: rule.boxType,
ruleVersion: rule.version,
outer,
unitVolumeCbm: round(outer.reduce((total, value) => total * value, 1) / 1_000_000, 3),
}
}
function dimensions(value, prefix = '') {
const field = (name) => `${prefix}${prefix ? name[0].toUpperCase() + name.slice(1) : name}`
return [value?.[field('lengthCm')] ?? null, value?.[field('widthCm')] ?? null,
@@ -853,11 +816,6 @@ function sum(values, field) {
return arrayData(values).reduce((total, value) => total + Number(value?.[field] ?? 0), 0)
}
function round(value, scale) {
const factor = 10 ** scale
return Math.round((Number(value) + Number.EPSILON) * factor) / factor
}
function statusCounts(values) {
const counts = {}
for (const row of arrayData(values)) {
+5 -1
View File
@@ -78,7 +78,11 @@ const report = {
orderIds: [],
},
steps: [],
futureModules: ['measurement', 'ocr', 'mojia', 'billing'],
supplementalAcceptance: [{
script: 'client-phase2-test-env-acceptance.mjs',
scope: ['measurement', 'ocr', 'mojia', 'billing'],
reason: '当前入库测量与计费客户能力由专项入口验收;本报告不将其计为已验证。',
}],
}
let merchantToken
@@ -122,13 +122,13 @@ test('数据库迁移计划包含授权版本和事件可靠投递的正式目
})
const targets = Object.fromEntries(plan.regions.flatMap((region) => region.schemas.map((schema) => [schema.schema, schema])))
assert.equal(targets.baiyue_iam.target, '3')
assert.equal(targets.baiyue_firstleg.target, '6')
assert.equal(targets.baiyue_firstleg.target, '7')
assert.equal(targets.baiyue_finance.target, '2')
assert.equal(targets.baiyue_lastleg.target, '7')
assert.equal(targets.baiyue_lastleg.target, '9')
assert.equal(targets.baiyue_lastlag_warehouse.target, '2')
assert.deepEqual(targets.baiyue_firstleg.migrations.slice(-3).map((migration) => migration.path), [
'migrations/cn/baiyue_firstleg/V4__add_sailing_calendar_schema.sql',
'migrations/cn/baiyue_firstleg/V5__event_delivery_retry_audit.sql',
'migrations/cn/baiyue_firstleg/V6__event_delivery_attempt.sql',
'migrations/cn/baiyue_firstleg/V7__settle_partial_loaded_cargo.sql',
])
})
+7 -2
View File
@@ -10,6 +10,12 @@ export function selectSteps(report, predicate) {
export function requirePassed(steps) {
return steps.length > 0 && steps.every(s => s.passed === true)
}
export function discardCoverageBoundary() {
return { node: 'F33-discard',
conclusion: 'Historical rejection-release evidence is retained. Current approval keeps reservations; actual PDA confirmation deducts the confirmed quantity. Approval alone does not prove completion.',
category: '需真实操作',
next: 'Prepare a fresh owned approved application and bound PDA actor; freeze carton/SKU/ledger evidence. Verify actual confirmation, final-pallet response recovery, identical replay with zero new writes, changed quantity rejection, and warehouse confirmation separately. Do not deduct stock at approval or borrow unrelated completed samples.' }
}
export function transferConclusion(detail) {
const shipped = Boolean(detail.actualShipAt)
const terminal = detail.status === 'RECEIVED'
@@ -56,8 +62,7 @@ export async function auditRemainingNodes({ root, output, env, live = false }) {
evidence: [evidence(files[1], s => (s.request?.path || '').includes('WT-WH-20261002-430594'))],
originalFixture: full.createdResources.phase4OverseasClientServices,
next: 'Continue this exact original transfer only after current source stock, holds, destination and allocations are frozen. Verify allocation, actual outbound and target receipt with per-position balances/unique movements and same-command replay. Incoming transfer fixtures are separate sources and cannot replace this ticket.' })
result.nodes.push({ node: 'F33-discard', conclusion: 'Rejection release supported; approval/decrement decision pending',
category: '缺用户输入', evidenceFiles: [files[5]], next: 'Await approval-time versus PDA actual-quantity decrement decision; no approval or decrement issued.' })
result.nodes.push({ ...discardCoverageBoundary(), evidenceFiles: [files[5]] })
result.nodes.push({ node: 'F26', conclusion: 'Existing 68-check source/transit scope is already evidenced; no repeat run',
category: '可立即关闭指定分支', evidenceFiles: [files[4]], boundary: 'Only 11 FBA sources/13 actual cartons and pre-receipt mapping; not all event faults or UI.' })
result.nodes.push({ node: 'F24-external', conclusion: 'External carrier lifecycle lacks valid input',
@@ -1,6 +1,16 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { requirePassed, selectSteps, transferConclusion } from './fba-remaining-nodes-acceptance.mjs'
import { requirePassed, selectSteps, transferConclusion, discardCoverageBoundary } from './fba-remaining-nodes-acceptance.mjs'
test('discard audit follows approval reservation and PDA actual confirmation without claiming closure', () => {
const boundary = discardCoverageBoundary()
assert.equal(boundary.category, '需真实操作')
assert.match(boundary.conclusion, /approval keeps reservations/)
assert.match(boundary.conclusion, /PDA confirmation deducts/)
assert.match(boundary.next, /identical replay with zero new writes/)
assert.match(boundary.next, /changed quantity rejection/)
assert.doesNotMatch(boundary.conclusion, /decision pending/)
})
test('missing and partially failed evidence cannot close a branch', () => {
assert.equal(requirePassed([]), false)
+46
View File
@@ -0,0 +1,46 @@
import { dirname, resolve } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { runPoolAcceptance, poolPlan } from './fleet-capacity-pool-test-env.mjs'
import { runFleetPoolGrayCrossCustomer } from './fleet-cross-customer-test-env.mjs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
export function parseGrayPoolArguments(args) {
const config = { execute: false, environment: 'GRAY', runId: 'plan', warehouseId: '', destinationWarehouseId: '' }
const seen = new Set()
for (let index = 0; index < args.length; index++) {
const name = args[index]
if (['--execute', '--plan'].includes(name)) {
if (seen.has('action')) throw Error('Duplicate execution mode')
seen.add('action'); config.execute = name === '--execute'; continue
}
const field = { '--environment': 'environment', '--run-id': 'runId', '--warehouse-id': 'warehouseId',
'--destination-warehouse-id': 'destinationWarehouseId' }[name]
if (!field || seen.has(name) || !args[index + 1] || args[index + 1].startsWith('--')) throw Error('Unsupported or duplicate gray pool argument')
seen.add(name); config[field] = args[++index]
}
if (config.environment !== 'GRAY' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId)
|| [config.warehouseId, config.destinationWarehouseId].some(value => value && !/^[1-9][0-9]{0,19}$/.test(value))
|| config.execute && (!seen.has('--environment') || config.runId === 'plan' || !config.warehouseId || !config.destinationWarehouseId))
throw Error('Explicit GRAY, fresh run-id and warehouse/destination IDs required')
return config
}
export async function runGrayPoolAcceptance(config, { env = process.env, outputRoot = ROOT, execute = runPoolAcceptance } = {}) {
if (config.environment !== 'GRAY') throw Error('Dedicated GRAY pool configuration required')
if (!config.execute) return { ...poolPlan(config), scope: 'FLEET_CAPACITY_POOL_GRAY', fullAcceptance: false,
evidence: 'createGrayEvidenceReaders: registered production SSH target, immutable container/profile/database identity checks; no TEST agent',
excluded: ['old FBA order/container/customs flow', 'generic Admin module runner', 'fault injection', 'deployment', 'business SQL writes'] }
if (!['prod', 'gray'].includes(env.CHONGCHONG_ENV)) throw Error('Dedicated GRAY pool requires actual gray or prod runtime profile; TEST is refused')
return execute(config, { env, outputRoot, runner: runFleetPoolGrayCrossCustomer })
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
try {
const config = parseGrayPoolArguments(process.argv.slice(2)), result = await runGrayPoolAcceptance(config)
console.log(JSON.stringify(config.execute ? { success: result.success, environment: 'GRAY', runtimeProfile: result.runtimeProfile,
error: result.error, summary: result.summary, fullAcceptance: false, output: poolPlan(config).output,
covered: result.createdResources?.fleetCapacityPool?.covered, remaining: result.remainingSeparateAcceptance } : result, null, 2))
if (config.execute && !result.success) process.exitCode = 1
} catch (error) { console.error(error.message); process.exitCode = 1 }
}
+120
View File
@@ -0,0 +1,120 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, dirname, resolve, basename } from 'node:path'
import { parseGrayPoolArguments, runGrayPoolAcceptance } from './fleet-capacity-pool-gray.mjs'
import { parseCrossCustomerArguments, runCrossCustomer, runFleetPoolGrayCrossCustomer } from './fleet-cross-customer-test-env.mjs'
import { parsePoolArguments, runPoolAcceptance, poolReportName, POOL_COVERAGE } from './fleet-capacity-pool-test-env.mjs'
import { AcceptanceRun, parseArguments, validateExecutionOptions, createFleetPoolGrayOptions } from './admin-test-env-acceptance.mjs'
const args = ['--execute', '--environment', 'GRAY', '--run-id', 'pool-gray-offline', '--warehouse-id', '900', '--destination-warehouse-id', '901']
const env = { CHONGCHONG_ENV: 'prod', CHONGCHONG_ADMIN_API_BASE_URL: 'https://gray.example.invalid/api',
CHONGCHONG_ADMIN_USERNAME: 'offline-user', CHONGCHONG_ADMIN_PASSWORD: 'offline-secret' }
const transport = { gateway: 'https://gray.example.invalid', username: 'offline-user', password: 'offline-secret',
output: 'reports/fleet-pool-gray-offline', markerNamespace: 'gray-offline' }
test('GRAY plan is zero-network and preserves exact existing pool coverage without F40', async () => {
const original = globalThis.fetch; globalThis.fetch = () => assert.fail('network forbidden')
try {
for (const argv of [[], ['--plan']]) {
const plan = await runGrayPoolAcceptance(parseGrayPoolArguments(argv), { env: {}, execute: () => assert.fail('execution forbidden') })
assert.equal(plan.environment, 'GRAY'); assert.equal(plan.execute, false)
assert.equal(plan.output, 'reports/fleet-pool-gray-plan')
assert.deepEqual(plan.coveredByThisEntry, POOL_COVERAGE)
assert.equal(plan.fullAcceptance, false)
assert.ok(plan.excluded.includes('old FBA order/container/customs flow'))
}
} finally { globalThis.fetch = original }
})
test('GRAY CLI requires explicit environment fresh run and exact IDs and rejects all modules/fault switches', () => {
assert.equal(parseGrayPoolArguments(args).execute, true)
for (const bad of [['--execute'], args.filter((_, index) => index !== 1 && index !== 2),
args.map(value => value === 'GRAY' ? 'TEST' : value), args.map(value => value === 'pool-gray-offline' ? '../escape' : value),
[...args, '--modules', 'phase5-root-fulfillment'], [...args, '--verify-unload-event-recovery', 'true'],
[...args, '--readers', 'test'], [...args, '--plan'], [...args, '--run-id', 'duplicate']]) {
assert.throws(() => parseGrayPoolArguments(bad))
}
})
test('existing TEST CLI and runner still refuse GRAY and production process profile', async () => {
assert.throws(() => parseCrossCustomerArguments(args), /TEST/)
assert.throws(() => parsePoolArguments(args), /TEST/)
const config = parseGrayPoolArguments(args)
await assert.rejects(runCrossCustomer(config, { env }), /TEST configuration/)
await assert.rejects(runPoolAcceptance(config, { env }), /TEST configuration/)
await assert.rejects(runGrayPoolAcceptance(config, { env: { ...env, CHONGCHONG_ENV: 'test' } }), /TEST is refused/)
})
test('dedicated constructor factory is branded frozen gray and carries no generic modules', () => {
const options = createFleetPoolGrayOptions(transport), context = new AcceptanceRun(options)
assert.equal(context.options.environment, 'gray'); assert.match(context.marker, /^GRAY-API-/)
assert.equal(options.executionScope, 'FLEET_CAPACITY_POOL_GRAY')
assert.deepEqual(options.modules, []); assert.deepEqual(options.requestedModules, [])
assert.equal(options.verifyUnloadEventRecovery, false)
assert.throws(() => options.modules.push('phase5-root-fulfillment'), TypeError)
assert.throws(() => new AcceptanceRun({ ...options }), /gray 仅支持/)
assert.throws(() => createFleetPoolGrayOptions({ ...transport, modules: ['phase5-root-fulfillment'] }), /configuration/)
assert.throws(() => createFleetPoolGrayOptions({ ...transport, verifyUnloadEventRecovery: true }), /configuration/)
assert.throws(() => createFleetPoolGrayOptions({ ...transport, gateway: 'http://gray.example.invalid' }), /HTTPS/)
assert.throws(() => createFleetPoolGrayOptions({ ...transport, gateway: 'https://gray.example.invalid/api' }), /HTTPS/)
})
test('ordinary Admin gray CLI and string-forged scope cannot opt into dedicated empty modules', () => {
assert.throws(() => validateExecutionOptions({ environment: 'gray', executionScope: 'FLEET_CAPACITY_POOL_GRAY',
requestedModules: [], modules: [], waitSplitCount: 1 }), /gray 仅支持/)
assert.throws(() => parseArguments(['--environment', 'gray']), /显式/)
assert.throws(() => validateExecutionOptions(parseArguments(['--environment', 'gray', '--modules', 'merchant-qualification-fixture',
'--execution-scope', 'FLEET_CAPACITY_POOL_GRAY'])), /gray 仅支持/)
})
test('GRAY delegates unchanged pool config and preserves prod profile using only dedicated runner', async () => {
const config = parseGrayPoolArguments(args), original = { ...env }
const result = await runGrayPoolAcceptance(config, { env, execute: async (actual, dependencies) => {
assert.equal(actual, config); assert.equal(actual.environment, 'GRAY')
assert.equal(dependencies.env, env); assert.equal(dependencies.env.CHONGCHONG_ENV, 'prod')
assert.equal(dependencies.runner, runFleetPoolGrayCrossCustomer)
return { scope: 'FLEET_CAPACITY_POOL_GRAY', success: true }
} })
assert.equal(result.success, true); assert.deepEqual(env, original)
assert.equal(poolReportName(config), 'fleet-pool-gray-pool-gray-offline')
assert.notEqual(poolReportName(config), poolReportName({ ...config, environment: 'TEST' }))
})
test('GRAY rejects injected TEST evidence and alternate fixture or cleanup even before filesystem writes', async () => {
const config = parseGrayPoolArguments(args), dependencies = { env, verify: async () => {}, contextFactory: () => {} }
for (const field of ['readers', 'prepare', 'finish', 'pickerAccess', 'pickerCreate', 'pickerDisable']) {
await assert.rejects(runFleetPoolGrayCrossCustomer(config, { ...dependencies, [field]: {} }), /cannot override/)
}
await assert.rejects(runFleetPoolGrayCrossCustomer(config, { env }), /dedicated scenario/)
await assert.rejects(runFleetPoolGrayCrossCustomer(config, dependencies), /Missing gray evidence configuration/)
await assert.rejects(runFleetPoolGrayCrossCustomer(config, { ...dependencies,
env: { ...env, CHONGCHONG_ADMIN_API_BASE_URL: 'http://gray.example.invalid/api' } }), /Invalid GRAY gateway/)
})
test('real gray reader is selected and failed production preflight blocks every business request', async () => {
const root = mkdtempSync(join(tmpdir(), 'fleet-gray-contract-')), original = globalThis.fetch, calls = []
globalThis.fetch = async (url, init) => {
calls.push(String(url))
assert.equal(String(url), 'https://jenkins.example.invalid/scriptText')
assert.match(init.body.get('script'), /firstleg-production-ssh/)
assert.doesNotMatch(init.body.get('script'), /jenkins-test-agent/)
return { ok: false, status: 403 }
}
try {
const report = await runFleetPoolGrayCrossCustomer(parseGrayPoolArguments(args), { env: { ...env,
JENKINS_URL: 'https://jenkins.example.invalid', JENKINS_USER: 'offline-jenkins', JENKINS_API_TOKEN: 'offline-token' }, outputRoot: root,
verify: () => assert.fail('no business verification before preflight'), contextFactory: () => assert.fail('no business context before preflight') })
assert.equal(report.success, false); assert.match(report.error, /Gray evidence HTTP 403/)
assert.equal(report.environment, 'GRAY'); assert.equal(report.runtimeProfile, 'prod'); assert.equal(calls.length, 1)
assert.deepEqual(readdirSync(join(root, 'reports')), ['fleet-pool-gray-pool-gray-offline'])
const manifest = JSON.parse(readFileSync(join(root, 'reports/fleet-pool-gray-pool-gray-offline/manifest.json'), 'utf8'))
assert.ok(manifest.sources.some(source => source.path === 'scripts/gray-regression-evidence.mjs'))
assert.equal(manifest.sources.some(source => source.path === 'scripts/fba-core-regression-evidence.mjs'), false)
} finally {
globalThis.fetch = original
assert.equal(dirname(resolve(root)), resolve(tmpdir())); assert.ok(basename(root).startsWith('fleet-gray-contract-'))
rmSync(root, { recursive: true, force: true })
}
})
+14 -8
View File
@@ -42,12 +42,18 @@ export const POOL_REMAINING = Object.freeze([
])
export function parsePoolArguments(args) { return parseCrossCustomerArguments(args) }
export function poolReportName(config) {
requireThat(['TEST', 'GRAY'].includes(config.environment) && /^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId), 'EXPLICIT_POOL_REPORT_SCOPE_REQUIRED')
return `${config.environment === 'GRAY' ? 'fleet-pool-gray' : 'fleet-cross'}-${config.runId}`
}
export function poolPlan(config) {
return { environment: 'TEST', execute: Boolean(config.execute), runId: config.runId,
output: `reports/fleet-cross-${config.runId}`, coveredByThisEntry: POOL_COVERAGE, remainingSeparateAcceptance: POOL_REMAINING,
return { environment: config.environment || 'TEST', execute: Boolean(config.execute), runId: config.runId,
output: `reports/${poolReportName({ ...config, environment: config.environment || 'TEST' })}`, coveredByThisEntry: POOL_COVERAGE, remainingSeparateAcceptance: POOL_REMAINING,
sample: 'Two fresh qualified customers and owned one-box pallets, one 30-space trip. Each pool business type has one safely cancelled pre-incoming sample and one fulfilled order with one real incoming/pallet. Planning uses 18 spaces; actual physical load is separately confirmed.',
prerequisites: ['explicit TEST private env with CHONGCHONG_ENV=test and CHONGCHONG_ADMIN_*', 'current Warehouse/FBA IDs',
'TEST read-only inventory evidence credentials', 'current capacity-offer permission seeds and backend deployed'],
prerequisites: [config.environment === 'GRAY' ? 'explicit GRAY CLI with CHONGCHONG_ENV=gray|prod and CHONGCHONG_ADMIN_*'
: 'explicit TEST private env with CHONGCHONG_ENV=test and CHONGCHONG_ADMIN_*', 'current Warehouse/FBA IDs',
config.environment === 'GRAY' ? 'GRAY read-only evidence via JENKINS_* and verified production container/database identity'
: 'TEST read-only inventory evidence credentials', 'current capacity-offer permission seeds and backend deployed'],
boundary: 'Default plan performs no network or filesystem writes. Execute retains every sample and original command, blocks all further writes on an unknown outcome, never deploys or writes business SQL. No automatic rerun or resume.' }
}
@@ -613,7 +619,7 @@ export async function runPoolAcceptance(config, { env = process.env, outputRoot
}
return response
}
const directory = join(outputRoot, 'reports', `fleet-cross-${config.runId}`, 'pool-write-journal')
const directory = join(outputRoot, 'reports', poolReportName(config), 'pool-write-journal')
mkdirSync(directory)
restoreWrites = guardFleetWrites(context, async entry => {
const file = openSync(join(directory, `${entry.sequence}.json`), 'w')
@@ -622,10 +628,10 @@ export async function runPoolAcceptance(config, { env = process.env, outputRoot
return context
},
verify: (context, fixture, options) => poolScenario(context, fixture, { ...options, credentials }) })
result.scope = 'FLEET_CAPACITY_POOL_HTTP'; result.fullAcceptance = false
result.scope = config.environment === 'GRAY' ? 'FLEET_CAPACITY_POOL_GRAY' : 'FLEET_CAPACITY_POOL_HTTP'; result.fullAcceptance = false
result.remainingSeparateAcceptance = [...POOL_REMAINING]
const output = join(outputRoot, 'reports', `fleet-cross-${config.runId}`)
const sources = ['scripts/fleet-capacity-pool-test-env.mjs'].map(path => ({ path, sha256: createHash('sha256').update(readFileSync(join(ROOT, path))).digest('hex') }))
const output = join(outputRoot, 'reports', poolReportName(config))
const sources = ['scripts/fleet-capacity-pool-test-env.mjs', ...(config.environment === 'GRAY' ? ['scripts/fleet-capacity-pool-gray.mjs'] : [])].map(path => ({ path, sha256: createHash('sha256').update(readFileSync(join(ROOT, path))).digest('hex') }))
writeFileSync(join(output, 'pool-scope.json'), JSON.stringify({ plan: poolPlan(config), sources, success: result.success,
fullAcceptance: false, covered: result.createdResources?.fleetCapacityPool?.covered || [], remaining: POOL_REMAINING }, null, 2) + '\n', { flag: 'wx' })
return result
@@ -0,0 +1,26 @@
import { resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { parseGrayPoolArguments } from './fleet-capacity-pool-gray.mjs'
import { privateCredentialsPath, runGrayUiFixture } from './fleet-capacity-pool-ui-fixture-test-env.mjs'
export function parseGrayUiFixtureArguments(args) {
const ordinary = []; let privateFile
for (let index = 0; index < args.length; index++) {
if (args[index] !== '--private-credentials-file') { ordinary.push(args[index]); continue }
if (privateFile !== undefined || !args[index + 1] || args[index + 1].startsWith('--')) throw Error('One explicit private credential file required')
privateFile = privateCredentialsPath(args[++index])
}
const config = parseGrayPoolArguments(ordinary)
if (config.execute && !privateFile) throw Error('Exclusive private credentials file required for GRAY UI execution')
return { ...config, privateCredentialsFile: privateFile || null }
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
try {
const config = parseGrayUiFixtureArguments(process.argv.slice(2)), result = await runGrayUiFixture(config)
console.log(JSON.stringify(config.execute ? { success: result.success, fixtureReady: result.fixtureReady, environment: result.environment,
runtimeProfile: result.runtimeProfile, fullAcceptance: false, error: result.error, summary: result.summary,
retainedPda: result.retainedPda, output: result.output, privateCredentialsFile: result.privateCredentialsFile } : result, null, 2))
if (config.execute && !result.success) process.exitCode = 1
} catch { console.error('GRAY UI fixture preparation failed; preserve existing private credentials and report for review.'); process.exitCode = 1 }
}
@@ -0,0 +1,78 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { join } from 'node:path'
import { parseGrayUiFixtureArguments } from './fleet-capacity-pool-ui-fixture-gray.mjs'
import { PRIVATE_DIRECTORY, runGrayUiFixture, runUiFixture, uiActorPermissions, grayUiSchedule,
applyGrayUiTransportWindow, uiInquiryBody } from './fleet-capacity-pool-ui-fixture-test-env.mjs'
const args = ['--execute', '--environment', 'GRAY', '--run-id', 'ui-gray-offline', '--warehouse-id', '900',
'--destination-warehouse-id', '901', '--private-credentials-file', join(PRIVATE_DIRECTORY, 'ui-gray-offline.json')]
const env = { CHONGCHONG_ENV: 'prod', CHONGCHONG_ADMIN_API_BASE_URL: 'https://gray.example.invalid/api',
CHONGCHONG_ADMIN_USERNAME: 'offline', CHONGCHONG_ADMIN_PASSWORD: 'offline-only' }
test('GRAY UI default plan does not access network or private credential files and keeps only four drafts', async () => {
const original = globalThis.fetch; globalThis.fetch = () => assert.fail('no network')
try {
const plan = await runGrayUiFixture(parseGrayUiFixtureArguments([]), { env: {} })
assert.equal(plan.execute, false); assert.equal(plan.environment, 'GRAY'); assert.equal(plan.fullAcceptance, false)
assert.equal(plan.stopsAt, 'OPEN_AND_4_DRAFTS_READY_FOR_UI'); assert.equal(plan.retainedPda, true)
assert.equal(plan.output, 'reports/fleet-capacity-pool-ui-gray-plan')
assert.equal(plan.privateCredentialsFile, null)
assert.match(plan.boundary, /all six UI logins/)
} finally { globalThis.fetch = original }
})
test('GRAY UI CLI requires explicit scope and private exclusive file without arbitrary lifecycle switches', () => {
assert.equal(parseGrayUiFixtureArguments(args).environment, 'GRAY')
for (const bad of [args.slice(0, -2), args.map(value => value === 'GRAY' ? 'TEST' : value),
[...args, '--private-credentials-file', args.at(-1)], [...args, '--modules', 'orders'],
[...args, '--retain-pda', 'false'], [...args, '--draft-count', '24']]) assert.throws(() => parseGrayUiFixtureArguments(bad))
})
test('GRAY UI refuses TEST profile or evidence overrides and TEST runner rejects GRAY', async () => {
const config = parseGrayUiFixtureArguments(args)
await assert.rejects(runUiFixture(config, { env }), /TEST/)
await assert.rejects(runGrayUiFixture(config, { env: { ...env, CHONGCHONG_ENV: 'test' } }), /GRAY_FIXTURE_CONFIGURATION/)
for (const name of ['readers', 'prepare', 'contextFactory', 'pickerDisable']) {
await assert.rejects(runGrayUiFixture(config, { env, [name]: {} }), /CANNOT_OVERRIDE/)
}
await assert.rejects(runGrayUiFixture(config, { env }), /Missing gray evidence configuration/)
await assert.rejects(runGrayUiFixture(config, { env: { ...env, CHONGCHONG_ADMIN_API_BASE_URL: 'http://gray.example.invalid/api' } }), /INVALID_UI_API_BASE/)
})
test('only sales A receives quotation-read while dispatcher and sales B remain minimal', () => {
const prefix = 'platform:fleet:capacity-offer:'
const sales = ['list', 'inquiry-create', 'inquiry-edit', 'inquiry-cancel'].map(value => prefix + value)
assert.deepEqual(uiActorPermissions('SALES_A', true), [...sales, 'platform:fleet:quote:list'])
assert.deepEqual(uiActorPermissions('SALES_B', true), sales)
assert.deepEqual(uiActorPermissions('MANAGER', true), [prefix + 'manage'])
assert.deepEqual(uiActorPermissions('SALES_A', false), sales)
})
test('eight-hour cutoff has coherent later departure delivery and driver end without rewriting other requests', () => {
const now = Date.parse('2030-01-01T00:00:00Z'), schedule = grayUiSchedule(now)
assert.equal(Date.parse(schedule.cutoffAt), now + 8 * 3600000)
assert.equal(Date.parse(schedule.plannedDepartureAt), now + 9 * 3600000)
assert.equal(Date.parse(schedule.plannedDeliveryAt), now + 10 * 3600000)
assert.equal(Date.parse(schedule.expectedDriverEndAt), now + 12 * 3600000)
assert.deepEqual(grayUiSchedule(now + 999), schedule)
assert.match(schedule.cutoffAt, /Z$/); assert.match(schedule.plannedDepartureAt, /Z$/)
assert.doesNotMatch(schedule.plannedDeliveryAt, /Z$/)
const body = { idempotencyKey: 'stable', plannedDeliveryAt: 'old', fees: [{ amount: 30 }] }
const applied = applyGrayUiTransportWindow('POST', '/api/platform/v1/last-leg/dispatches/DS-1/transfer-delivery', body, schedule)
assert.equal(applied.plannedDeliveryAt, schedule.plannedDeliveryAt)
assert.equal(applied.idempotencyKey, body.idempotencyKey); assert.deepEqual(applied.fees, body.fees); assert.equal(body.plannedDeliveryAt, 'old')
assert.equal(applyGrayUiTransportWindow('POST', '/api/auth/login', body, schedule), body)
assert.equal(applyGrayUiTransportWindow('GET', '/api/platform/v1/last-leg/dispatches/DS-1/transfer-delivery', body, schedule), body)
assert.throws(() => grayUiSchedule(NaN), /CLOCK/)
})
test('four GRAY drafts are complete for both commercial types without creating reservations or commercial orders', () => {
const offer = { status: 'OPEN', canCreateInquiry: true, tripRecordVersion: 1, recordVersion: 2, destinations: [{ destinationKey: 'FBA:ONT8' }] }
const bodies = Array.from({ length: 4 }, (_, index) => uiInquiryBody(offer, index < 2 ? 'A' : 'B', index, `key-${index}`, true))
assert.deepEqual(bodies.map(body => body.businessType), ['LOCAL_LOOSEBOARD', 'US_TRUCK', 'LOCAL_LOOSEBOARD', 'US_TRUCK'])
for (const body of bodies) {
assert.equal(body.expectedPalletCount, 1); assert.equal(body.cargo.packageCount, 1); assert.equal(body.cargo.palletSpaces, 1)
assert.equal('businessOrderNo' in body, false); assert.equal('status' in body, false)
}
})
@@ -4,11 +4,12 @@ import { resolve, dirname, join, isAbsolute, extname } from 'node:path'
import { homedir } from 'node:os'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { createHash, randomUUID } from 'node:crypto'
import { AcceptanceRun, parseArguments, validateExecutionOptions, buildAcceptanceSummary } from './admin-test-env-acceptance.mjs'
import { AcceptanceRun, parseArguments, validateExecutionOptions, buildAcceptanceSummary, createFleetPoolGrayOptions } from './admin-test-env-acceptance.mjs'
import { parseCrossCustomerArguments } from './fleet-cross-customer-test-env.mjs'
import { createFleetCrossCustomerFixture } from './admin-test-env-modules/fleet-cross-customer-fixture.mjs'
import { controlledPalletMeasurements } from './admin-test-env-modules/fleet-trip-fulfillment.mjs'
import { createTestEvidenceReaders } from './fba-core-regression-evidence.mjs'
import { createGrayEvidenceReaders } from './gray-regression-evidence.mjs'
import { guardFleetWrites, redactFleetWrite, findFleetActorPhone } from './fleet-test-env-acceptance.mjs'
import { createPicker, disablePicker, resolvePickerAccess } from './admin-test-env-modules/phase4-overseas-pda.mjs'
@@ -41,6 +42,13 @@ export function parseUiFixtureArguments(args) {
}
export function uiFixturePlan(config) {
if (config.environment === 'GRAY') return { ...uiFixturePlan({ ...config, environment: 'TEST' }), environment: 'GRAY',
output: `reports/fleet-capacity-pool-ui-gray-${config.runId}`,
purpose: 'GRAY UI fixture only: one OPEN 30/18-space trip, four complete non-reserving drafts, two customer logins, sales A with quote:list, sales B without quote permission, manage dispatcher and one warehouse-bound PDA for H5.',
permissions: { salesA: uiActorPermissions('SALES_A', true), salesB: uiActorPermissions('SALES_B', true), manager: uiActorPermissions('MANAGER', true) },
stopsAt: 'OPEN_AND_4_DRAFTS_READY_FOR_UI', retainedPda: true,
schedule: 'From preparation: cutoff +8h, departure +9h, delivery ETA +10h, driver expected end +12h; no actual departure.',
boundary: 'No automatic rerun/resume, SQL mutations, deployment, pool confirmation, PDA picking, departure or receipt signature. Retain business samples and all six UI logins including the warehouse-bound PDA; logout script sessions. Actual profile remains prod/gray; production-identity read-only evidence required.' }
return { execute: Boolean(config.execute), environment: 'TEST', runId: config.runId,
output: `reports/fleet-capacity-pool-ui-${config.runId}`, privateCredentialsFile: config.privateCredentialsFile,
purpose: 'UI fixture only: one OPEN 30-space trip with valid 18-space owned plan and 24 non-reserving drafts, two minimal sales logins, one manage login, two qualified customer logins.',
@@ -50,6 +58,29 @@ export function uiFixturePlan(config) {
boundary: 'No automatic rerun/resume, SQL mutations, deployment, pool confirmation, PDA picking, departure, receipt signature or completed-order fabrication. Retain business samples and UI accounts; disable the fixture-only PDA and logout script sessions.' }
}
export function uiActorPermissions(kind, gray = false) {
demand(['SALES_A', 'SALES_B', 'MANAGER'].includes(kind), 'UI_ACTOR_KIND_REQUIRED')
if (kind === 'MANAGER') return [PREFIX + 'manage']
return [...['list', 'inquiry-create', 'inquiry-edit', 'inquiry-cancel'].map(name => PREFIX + name),
...(gray && kind === 'SALES_A' ? ['platform:fleet:quote:list'] : [])]
}
export function grayUiSchedule(now = Date.now()) {
demand(Number.isFinite(now), 'VALID_GRAY_UI_CLOCK_REQUIRED')
// Persisted business DATETIME columns have second precision.
const base = Math.floor(now / 1000) * 1000
const instant = hours => new Date(base + hours * 3600000).toISOString()
const delivery = new Date(base + 10 * 3600000)
return { cutoffAt: instant(8), plannedDepartureAt: instant(9), expectedDriverEndAt: instant(12),
plannedDeliveryAt: new Date(delivery.getTime() - delivery.getTimezoneOffset() * 60000).toISOString().slice(0, 19) }
}
export function applyGrayUiTransportWindow(method, path, body, schedule) {
if (method !== 'POST' || !/^\/api\/platform\/v1\/last-leg\/dispatches\/[^/]+\/transfer-delivery$/.test(path)) return body
demand(body && schedule?.plannedDeliveryAt, 'GRAY_UI_DELIVERY_WINDOW_REQUIRED')
return { ...body, plannedDeliveryAt: schedule.plannedDeliveryAt }
}
/** Private directory override is for isolated offline tests, never exposed as a CLI option. */
export function createPrivateCredentialStore(file, initial, { privateDirectory = PRIVATE_DIRECTORY } = {}) {
const target = privateCredentialsPath(file, privateDirectory)
@@ -75,36 +106,50 @@ export function publicUiEvidence(value, secrets = []) {
return stripped(redactFleetWrite(value, secrets))
}
export function uiInquiryBody(offer, customerId, index, key) {
export function uiInquiryBody(offer, customerId, index, key, complete = false) {
const destination = offer.destinations?.find(row => row.destinationKey?.startsWith('FBA:'))
demand(offer.status === 'OPEN' && offer.canCreateInquiry && destination, 'OPEN_FBA_OFFER_REQUIRED')
return { tripRecordVersion: offer.tripRecordVersion, offerRecordVersion: offer.recordVersion,
customerId, businessType: index % 2 === 0 ? 'LOCAL_LOOSEBOARD' : 'US_TRUCK', deliveryMode: 'SELF_DELIVERY',
expectedPalletCount: index === 0 ? null : 1,
expectedPalletCount: index === 0 && !complete ? null : 1,
cargo: { destinationKey: destination.destinationKey, cargoName: `UI验收意向-${index + 1}`,
packageCount: index === 0 ? null : 1, palletSpaces: index === 0 ? null : 1, weightKg: 100, volumeCbm: 1,
packageCount: index === 0 && !complete ? null : 1, palletSpaces: index === 0 && !complete ? null : 1, weightKg: 100, volumeCbm: 1,
priceBasis: 'PALLET', unitPrice: 30, currency: 'CNY', contactName: 'UI验收联系人', contactPhone: '19900000000', remark: '独立UI验收草稿,不占位' },
idempotencyKey: key }
}
export async function runUiFixture(config, { env = process.env, outputRoot = ROOT, readers,
prepare = createFleetCrossCustomerFixture, contextFactory = options => new AcceptanceRun(options) } = {}) {
export async function runUiFixture(config, dependencies = {}) {
demand(config.environment === 'TEST', 'EXPLICIT_TEST_FIXTURE_CONFIGURATION_REQUIRED')
return runUiFixtureCore(config, dependencies, false)
}
export async function runGrayUiFixture(config, dependencies = {}) {
demand(config.environment === 'GRAY', 'EXPLICIT_GRAY_UI_FIXTURE_CONFIGURATION_REQUIRED')
demand(Object.keys(dependencies).every(key => ['env', 'outputRoot'].includes(key)), 'GRAY_UI_CANNOT_OVERRIDE_EVIDENCE_OR_LIFECYCLE')
return runUiFixtureCore(config, dependencies, true)
}
async function runUiFixtureCore(config, { env = process.env, outputRoot = ROOT, readers,
prepare = createFleetCrossCustomerFixture, contextFactory = options => new AcceptanceRun(options) } = {}, gray = false) {
if (!config.execute) return uiFixturePlan(config)
demand(config.environment === 'TEST' && env.CHONGCHONG_ENV === 'test' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId)
demand((gray ? ['prod', 'gray'].includes(env.CHONGCHONG_ENV) : env.CHONGCHONG_ENV === 'test') && /^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId)
&& config.runId !== 'plan' && /^[1-9]\d*$/.test(config.warehouseId) && /^[1-9]\d*$/.test(config.destinationWarehouseId)
&& env.CHONGCHONG_ADMIN_USERNAME && env.CHONGCHONG_ADMIN_PASSWORD, 'EXPLICIT_TEST_FIXTURE_CONFIGURATION_REQUIRED')
&& env.CHONGCHONG_ADMIN_USERNAME && env.CHONGCHONG_ADMIN_PASSWORD, `EXPLICIT_${gray ? 'GRAY' : 'TEST'}_FIXTURE_CONFIGURATION_REQUIRED`)
const privateFile = privateCredentialsPath(config.privateCredentialsFile)
const endpoint = new URL(env.CHONGCHONG_ADMIN_API_BASE_URL)
demand(['http:', 'https:'].includes(endpoint.protocol) && ['/api', '/api/'].includes(endpoint.pathname)
&& !endpoint.username && !endpoint.password && !endpoint.hash && !endpoint.search, 'INVALID_TEST_API_BASE')
readers ||= createTestEvidenceReaders(env)
const options = validateExecutionOptions({ ...parseArguments(['--modules', 'merchant-qualification-fixture', '--quiet', 'true']),
environment: 'test', gateway: endpoint.origin, username: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD,
markerNamespace: createHash('sha256').update(config.runId).digest('hex').slice(0, 12) })
const secretDocument = { schemaVersion: 1, environment: 'TEST', runId: config.runId, status: 'PREPARING', createdAt: new Date().toISOString(),
demand((gray ? endpoint.protocol === 'https:' : ['http:', 'https:'].includes(endpoint.protocol)) && ['/api', '/api/'].includes(endpoint.pathname)
&& !endpoint.username && !endpoint.password && !endpoint.hash && !endpoint.search, 'INVALID_UI_API_BASE')
if (gray) readers = createGrayEvidenceReaders(env)
else readers ||= createTestEvidenceReaders(env)
const reports = join(outputRoot, 'reports'), output = resolve(outputRoot, uiFixturePlan(config).output)
const transport = { gateway: endpoint.origin, username: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD,
output, markerNamespace: createHash('sha256').update(`${config.environment}:${config.runId}`).digest('hex').slice(0, 12) }
const options = gray ? createFleetPoolGrayOptions(transport) : validateExecutionOptions({
...parseArguments(['--modules', 'merchant-qualification-fixture', '--quiet', 'true']), ...transport, environment: 'test' })
const secretDocument = { schemaVersion: 1, environment: config.environment, runtimeProfile: env.CHONGCHONG_ENV, runId: config.runId, status: 'PREPARING', createdAt: new Date().toISOString(),
gatewayOrigin: endpoint.origin, accounts: [], business: {} }
const vault = createPrivateCredentialStore(privateFile, secretDocument)
const reports = join(outputRoot, 'reports'), output = join(reports, `fleet-capacity-pool-ui-${config.runId}`)
const schedule = gray ? grayUiSchedule() : null, draftCount = gray ? 4 : 24, perSales = draftCount / 2
const secrets = Object.entries(env).filter(([key]) => /PASSWORD|TOKEN|SECRET/.test(key)).map(([, value]) => value).filter(Boolean)
const sessions = new Map(), kinds = new Map()
let context, picker, lock, restore, initialized = false, requestNumber = 0, inventoryNumber = 0, report
@@ -129,10 +174,12 @@ export async function runUiFixture(config, { env = process.env, outputRoot = ROO
for (const method of ['request', 'requestWithBody']) {
const original = context[method]
context[method] = async function (...args) {
const sequence = ++requestNumber, [verb, path, body] = args
const sequence = ++requestNumber, [verb, path] = args
if (gray) args[2] = applyGrayUiTransportWindow(verb, path, args[2], schedule)
const body = args[2]
if (verb === 'POST' && path === `${IAM}/users` && body?.username && body.password) {
remember({ kind: kinds.get(body.username) || (body.username.startsWith('pda_overseas_') ? 'FIXTURE_PDA' : 'FIXTURE_USER'),
username: body.username, password: body.password, roleIds: body.roleIds, status: 'PREPARED' })
username: body.username, password: body.password, roleIds: body.roleIds, warehouseId: body.overseasWarehouseId || null, status: 'PREPARED' })
}
save(`requests/${sequence}.json`, { stage: 'INTENT', sequence, method: verb, path, body: body instanceof FormData ? '[MULTIPART]' : body })
const response = await original.apply(this, args)
@@ -184,10 +231,19 @@ export async function runUiFixture(config, { env = process.env, outputRoot = ROO
check('Existing producer created exactly two untouched customer dispatches', fixture.members.length === 2 && trip)
let detail = await request('GET', tripPath), plan = await request('GET', `${tripPath}/capacity-plan`)
check('Owned fixture vehicle has actual 30-space capacity', Number(detail.trip.maxPalletSpaces) === 30 && plan.cargo.length === 2)
// Keep UI logins useful beyond the producer's short smoke-test window, before its existing six-hour delivery ETA.
// GRAY delivery ETA was set on the producer's real transfer command, so all four times stay ordered.
detail = await request('PUT', `${tripPath}/plan`, { recordVersion: detail.trip.recordVersion,
plannedDepartureAt: new Date(Date.now() + 4 * 3600000).toISOString(), expectedDriverEndAt: detail.trip.expectedDriverEndAt,
stops: detail.trip.stops, reason: 'UI验收窗口:四小时后计划发车,不执行发车' })
plannedDepartureAt: schedule?.plannedDepartureAt || new Date(Date.now() + 4 * 3600000).toISOString(),
expectedDriverEndAt: schedule?.expectedDriverEndAt || detail.trip.expectedDriverEndAt,
stops: detail.trip.stops, reason: gray ? '灰度UI验收:统一截止、发车、到站与司机结束时间,不执行发车' : 'UI验收窗口:四小时后计划发车,不执行发车' })
if (gray) {
check('GRAY trip departure and driver end follow the explicit UI window', Date.parse(detail.trip.plannedDepartureAt) === Date.parse(schedule.plannedDepartureAt)
&& Date.parse(detail.trip.expectedDriverEndAt) === Date.parse(schedule.expectedDriverEndAt))
for (const member of fixture.members) {
const delivery = await request('GET', `${API}/delivery-orders/${member.deliveryNo}?customerId=${member.customerId}`)
check('Original customer delivery ETA follows the same valid UI window', delivery.order.plannedDeliveryAt === schedule.plannedDeliveryAt)
}
}
plan = await request('GET', `${tripPath}/capacity-plan`)
await request('POST', `${tripPath}/capacity-plan`, { recordVersion: plan.recordVersion, manifestHash: plan.manifestHash,
pallets: controlledPalletMeasurements(plan.cargo, detail.trip.stops).map(row => ({ ...row, palletSpaces: 9 })), reason: '仅UI验收:自有计划18/总30,不作为实际发车计量' })
@@ -195,7 +251,7 @@ export async function runUiFixture(config, { env = process.env, outputRoot = ROO
const key = suffix => createHash('sha256').update(`${context.marker}:UI:${suffix}`).digest('hex')
for (const kind of ['SALES_A', 'SALES_B', 'MANAGER']) {
const suffix = key(kind).slice(0, 16), username = `pool_ui_${kind.toLowerCase()}_${suffix}`
const expected = kind === 'MANAGER' ? [PREFIX + 'manage'] : ['list', 'inquiry-create', 'inquiry-edit', 'inquiry-cancel'].map(name => PREFIX + name)
const expected = uiActorPermissions(kind, gray)
const candidates = expected.map(code => permissions.filter(row => row.permissionCode === code && row.status === 'ACTIVE'))
check('Exact fixture permissions are installed', candidates.every(rows => rows.length === 1 && rows[0].id))
const role = await request('POST', `${IAM}/roles`, { roleCode: `POOL_UI_${kind}_${suffix}`.toUpperCase(), roleName: `${context.marker}-${kind}`, description: '仅本轮UI验收账号' })
@@ -213,28 +269,33 @@ export async function runUiFixture(config, { env = process.env, outputRoot = ROO
}
detail = await request('GET', tripPath)
const draft = await request('POST', POOL, { tripNo: fixture.tripNo, tripRecordVersion: detail.trip.recordVersion,
cutoffAt: new Date(Math.min(Date.now() + 3 * 3600000, Date.parse(detail.trip.plannedDepartureAt) - 60000)).toISOString(),
cutoffAt: schedule?.cutoffAt || new Date(Math.min(Date.now() + 3 * 3600000, Date.parse(detail.trip.plannedDepartureAt) - 60000)).toISOString(),
contactName: 'UI验收调度', contactPhone: '19900000000', remark: context.marker, idempotencyKey: key('OFFER') }, actorTokens[2])
let offer = (await request('POST', `${POOL}/${draft.offer.offerNo}/publish`, { tripRecordVersion: draft.offer.tripRecordVersion,
offerRecordVersion: draft.offer.recordVersion, idempotencyKey: key('PUBLISH') }, actorTokens[2])).offer
check('OPEN UI offer is available with exactly 12 planned spaces', offer.status === 'OPEN' && offer.canCreateInquiry && offer.canConfirm
&& offer.destinations.length === 1 && Number(offer.destinations[0].remaining.palletSpaces) === 12)
proof.offerNo = offer.offerNo; proof.tripNo = fixture.tripNo
for (let index = 0; index < 24; index++) {
const owner = index < 12 ? 0 : 1, member = fixture.members[owner]
const created = await request('POST', `${POOL}/${offer.offerNo}/inquiries`, uiInquiryBody(offer, member.customerId, index, key(`DRAFT-${index}`)), actorTokens[owner])
for (let index = 0; index < draftCount; index++) {
const owner = index < perSales ? 0 : 1, member = fixture.members[owner]
const body = uiInquiryBody(offer, member.customerId, index, key(`DRAFT-${index}`), gray)
if (gray) body.estimatedArrivalAt = new Date(Date.now() + 3600000).toISOString()
const created = await request('POST', `${POOL}/${offer.offerNo}/inquiries`, body, actorTokens[owner])
check('UI inquiry is DRAFT with no commercial order or reservation', created.inquiry.status === 'DRAFT' && !created.inquiry.businessOrderNo
&& !created.inquiry.incomingNo && !created.inquiry.dispatchNo && Number(created.offer.destinations[0].remaining.palletSpaces) === 12)
proof.drafts.push({ bookingNo: created.inquiry.bookingNo, customerId: member.customerId, owner: owner === 0 ? 'SALES_A' : 'SALES_B' })
offer = created.offer
}
const firstPage = await request('GET', `${POOL}/inquiries?page=1&size=20&scope=ALL&offerNo=${offer.offerNo}`, undefined, actorTokens[2])
const secondPage = await request('GET', `${POOL}/inquiries?page=2&size=20&scope=ALL&offerNo=${offer.offerNo}`, undefined, actorTokens[2])
check('Manager can exercise real page two of 24 drafts', Number(firstPage.total) === 24 && firstPage.records.length === 20
&& secondPage.records.length === 4 && new Set([...firstPage.records, ...secondPage.records].map(row => row.bookingNo)).size === 24)
if (gray) check('Manager sees exactly four current GRAY UI drafts', Number(firstPage.total) === 4 && firstPage.records.length === 4)
else {
const secondPage = await request('GET', `${POOL}/inquiries?page=2&size=20&scope=ALL&offerNo=${offer.offerNo}`, undefined, actorTokens[2])
check('Manager can exercise real page two of 24 drafts', Number(firstPage.total) === 24 && firstPage.records.length === 20
&& secondPage.records.length === 4 && new Set([...firstPage.records, ...secondPage.records].map(row => row.bookingNo)).size === 24)
}
for (let owner = 0; owner < 2; owner++) {
const mine = await request('GET', `${POOL}/inquiries?page=1&size=20&scope=MINE&offerNo=${offer.offerNo}`, undefined, actorTokens[owner])
check('Each sales login sees exactly its own twelve drafts', Number(mine.total) === 12 && mine.records.length === 12
check(`Each sales login sees exactly its own ${perSales} drafts`, Number(mine.total) === perSales && mine.records.length === perSales
&& mine.records.every(row => proof.drafts.some(draft => draft.bookingNo === row.bookingNo && draft.owner === (owner === 0 ? 'SALES_A' : 'SALES_B'))))
}
for (const member of fixture.members) {
@@ -256,11 +317,12 @@ export async function runUiFixture(config, { env = process.env, outputRoot = ROO
check('Original real cargo remains unpicked', Number(dispatch.pickedPackageCount) === 0 && Number(dispatch.shippedPackageCount) === 0)
}
const final = await request('GET', `${POOL}/${offer.offerNo}`, undefined, actorTokens[2])
check('UI handoff keeps publication OPEN and 24 drafts untouched', final.offer.status === 'OPEN' && final.offer.canCreateInquiry && Number(final.inquiries.total) === 24)
proof.status = 'OPEN_AND_24_DRAFTS_READY_FOR_UI'
check(`UI handoff keeps publication OPEN and ${draftCount} drafts untouched`, final.offer.status === 'OPEN' && final.offer.canCreateInquiry && Number(final.inquiries.total) === draftCount)
proof.status = `OPEN_AND_${draftCount}_DRAFTS_READY_FOR_UI`
secretDocument.business = { offerNo: proof.offerNo, tripNo: fixture.tripNo, warehouseId: fixture.warehouseId,
destinationWarehouseId: config.destinationWarehouseId, cutoffAt: final.offer.cutoffAt,
plannedDepartureAt: final.offer.plannedDepartureAt, customers: fixture.members.map(row => ({ customerId: row.customerId, incomingNo: row.incomingNo, dispatchNo: row.dispatchNo })) }
plannedDepartureAt: final.offer.plannedDepartureAt, ...(gray ? { uiSchedule: schedule } : {}),
customers: fixture.members.map(row => ({ customerId: row.customerId, incomingNo: row.incomingNo, dispatchNo: row.dispatchNo })) }
secretDocument.status = 'READY_FOR_UI'; writePrivate()
report = { success: true, fixtureReady: true, fullAcceptance: false, proof }
} catch (error) {
@@ -270,7 +332,11 @@ export async function runUiFixture(config, { env = process.env, outputRoot = ROO
try {
if (context) {
const pdaAccount = secretDocument.accounts.find(row => row.kind === 'FIXTURE_PDA' && row.membershipId)
if (picker?.membershipId || pdaAccount?.membershipId) {
if (gray && pdaAccount) {
pdaAccount.status = 'RETAINED_FOR_UI'; pdaAccount.purpose = 'GRAY_WAREHOUSE_BOUND_PDA_H5_ACCEPTANCE'
pdaAccount.warehouseId = config.warehouseId
report.retainedPda = { membershipId: pdaAccount.membershipId, warehouseId: config.warehouseId, purpose: pdaAccount.purpose }
} else if (!gray && (picker?.membershipId || pdaAccount?.membershipId)) {
try {
const previousSteps = context.steps.length
await disablePicker(context, picker?.membershipId ? picker : pdaAccount, 93)
@@ -296,6 +362,7 @@ export async function runUiFixture(config, { env = process.env, outputRoot = ROO
if (!report.success) secretDocument.status = 'FAILED_RETAINED'
writePrivate()
report.privateCredentialsFile = privateFile; report.requestCount = requestNumber; report.inventorySnapshots = inventoryNumber
report.environment = config.environment; report.runtimeProfile = env.CHONGCHONG_ENV
report.output = output
if (initialized) save('report.json', report)
} finally {
+35 -15
View File
@@ -3,8 +3,9 @@ import { execFileSync } from 'node:child_process'
import { dirname, resolve, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { createHash } from 'node:crypto'
import { AcceptanceRun, parseArguments, validateExecutionOptions, buildAcceptanceSummary } from './admin-test-env-acceptance.mjs'
import { AcceptanceRun, parseArguments, validateExecutionOptions, buildAcceptanceSummary, createFleetPoolGrayOptions } from './admin-test-env-acceptance.mjs'
import { createTestEvidenceReaders } from './fba-core-regression-evidence.mjs'
import { createGrayEvidenceReaders } from './gray-regression-evidence.mjs'
import { redactEvidence } from './fba-core-regression-test-env.mjs'
import { resolvePickerAccess, createPicker, disablePicker } from './admin-test-env-modules/phase4-overseas-pda.mjs'
import { verifyFleetCrossCustomer } from './admin-test-env-modules/fleet-cross-customer-acceptance.mjs'
@@ -38,20 +39,36 @@ export function crossCustomerPlan(config) {
limits: 'Prerequisite rejection is not mid-transaction rollback. No deployment, configuration change, SQL writes or automatic rerun. Retain business samples; disable temporary PDA in finally.' }
}
export async function runCrossCustomer(config, { env = process.env, outputRoot = ROOT, readers, prepare, verify = verifyFleetCrossCustomer,
finish = finishFleetTrip, contextFactory = options => new AcceptanceRun(options), pickerAccess = resolvePickerAccess, pickerCreate = createPicker, pickerDisable = disablePicker } = {}) {
const plan = crossCustomerPlan(config)
export async function runCrossCustomer(config, dependencies = {}) {
return runCrossCustomerCore(config, dependencies, false)
}
/** Not a CLI mode: called only by the explicit GRAY pool entry with its verified pool scenario. */
export async function runFleetPoolGrayCrossCustomer(config, dependencies = {}) {
if (Object.keys(dependencies).some(key => !['env', 'outputRoot', 'verify', 'contextFactory'].includes(key)))
throw Error('Gray pool cannot override evidence readers or fixture lifecycle')
if (config.execute && (typeof dependencies.verify !== 'function' || typeof dependencies.contextFactory !== 'function'))
throw Error('Gray pool requires its dedicated scenario and journaled context')
return runCrossCustomerCore(config, dependencies, true)
}
async function runCrossCustomerCore(config, { env = process.env, outputRoot = ROOT, readers, prepare, verify = verifyFleetCrossCustomer,
finish = finishFleetTrip, contextFactory = options => new AcceptanceRun(options), pickerAccess = resolvePickerAccess, pickerCreate = createPicker, pickerDisable = disablePicker } = {}, gray = false) {
const environment = gray ? 'GRAY' : 'TEST', reportName = `${gray ? 'fleet-pool-gray' : 'fleet-cross'}-${config.runId}`
const plan = { ...crossCustomerPlan(config), environment, output: `reports/${reportName}`,
...(gray ? { scope: 'FLEET_CAPACITY_POOL_GRAY', stages: ['current pool fixture and full pool HTTP matrix', 'trip completion and separate resource release'] } : {}) }
if (!config.execute) return plan
if (config.environment !== 'TEST' || env.CHONGCHONG_ENV !== 'test' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId)
if (config.environment !== environment || !(gray ? ['gray', 'prod'].includes(env.CHONGCHONG_ENV) : env.CHONGCHONG_ENV === 'test') || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId)
|| config.runId === 'plan' || !/^[1-9][0-9]{0,19}$/.test(config.warehouseId) || !/^[1-9][0-9]{0,19}$/.test(config.destinationWarehouseId)
|| !env.CHONGCHONG_ADMIN_USERNAME || !env.CHONGCHONG_ADMIN_PASSWORD) throw Error('Explicit TEST configuration required')
|| !env.CHONGCHONG_ADMIN_USERNAME || !env.CHONGCHONG_ADMIN_PASSWORD) throw Error(`Explicit ${environment} configuration required`)
const base = new URL(env.CHONGCHONG_ADMIN_API_BASE_URL)
if (!['http:', 'https:'].includes(base.protocol) || base.username || base.password || base.search || base.hash || !['/api', '/api/'].includes(base.pathname)) throw Error('Invalid TEST gateway')
readers ||= createTestEvidenceReaders(env)
if (!(gray ? base.protocol === 'https:' : ['http:', 'https:'].includes(base.protocol)) || base.username || base.password || base.search || base.hash || !['/api', '/api/'].includes(base.pathname)) throw Error(`Invalid ${environment} gateway`)
if (gray) readers = createGrayEvidenceReaders(env)
else readers ||= createTestEvidenceReaders(env)
// Load the reviewed producer before creating any resources.
prepare ||= (await import('./admin-test-env-modules/fleet-cross-customer-fixture.mjs')).createFleetCrossCustomerFixture
if (typeof prepare !== 'function' || typeof readers.readFleetInventory !== 'function') throw Error('Fixture producer and inventory reader required')
const reports = resolve(outputRoot, 'reports'), output = join(reports, `fleet-cross-${config.runId}`)
const reports = resolve(outputRoot, 'reports'), output = join(reports, reportName)
mkdirSync(reports, { recursive: true })
const lockPath = join(reports, '.fleet-acceptance.lock'), lock = openSync(lockPath, 'wx')
let context, picker, createdPicker, initialized = false, report, evidenceIndex = 0, requestIndex = 0
@@ -61,14 +78,15 @@ export async function runCrossCustomer(config, { env = process.env, outputRoot =
try {
writeFileSync(lock, JSON.stringify({ pid: process.pid, runId: config.runId }))
mkdirSync(output); initialized = true; mkdirSync(join(output, 'evidence')); mkdirSync(join(output, 'requests'))
const sources = ['scripts/fleet-cross-customer-test-env.mjs', 'scripts/fleet-inventory-evidence.mjs', 'scripts/fba-core-regression-evidence.mjs',
const sources = ['scripts/fleet-cross-customer-test-env.mjs', 'scripts/fleet-inventory-evidence.mjs', gray ? 'scripts/gray-regression-evidence.mjs' : 'scripts/fba-core-regression-evidence.mjs',
'scripts/admin-test-env-modules/fleet-cross-customer-fixture.mjs', 'scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs',
'scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs'].map(path => ({ path, sha256: createHash('sha256').update(readFileSync(join(ROOT, path))).digest('hex') }))
save('manifest.json', { ...plan, sourceCommit: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: ROOT, encoding: 'utf8' }).trim(), sources })
save('manifest.json', { ...plan, runtimeProfile: env.CHONGCHONG_ENV, sourceCommit: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: ROOT, encoding: 'utf8' }).trim(), sources })
await readers.preflight()
const options = validateExecutionOptions({ ...parseArguments(['--modules', 'merchant-qualification-fixture', '--quiet', 'true']),
environment: 'test', gateway: base.origin, username: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD,
markerNamespace: createHash('sha256').update(config.runId).digest('hex').slice(0, 12) })
const transport = { gateway: base.origin, username: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD,
output, markerNamespace: createHash('sha256').update(`${environment}:${config.runId}`).digest('hex').slice(0, 12) }
const options = gray ? createFleetPoolGrayOptions(transport) : validateExecutionOptions({
...parseArguments(['--modules', 'merchant-qualification-fixture', '--quiet', 'true']), ...transport, environment: 'test' })
context = contextFactory(options)
for (const method of ['request', 'requestWithBody']) {
const original = context[method]
@@ -92,7 +110,7 @@ export async function runCrossCustomer(config, { env = process.env, outputRoot =
}
}
const login = await context.request('POST', '/api/auth/login', { loginId: options.username, password: options.password }, '')
if (!ok(login) || !login.body.data?.accessToken) throw Error('TEST administrator login failed')
if (!ok(login) || !login.body.data?.accessToken) throw Error(`${environment} administrator login failed`)
context.token = login.body.data.accessToken; context.refreshToken = login.body.data.refreshToken
const warehouses = await context.request('GET', '/api/platform/v1/last-leg/fleet/drivers/warehouse-options?region=OVERSEAS')
if (!ok(warehouses) || !warehouses.body.data?.some(row => String(row.id) === config.warehouseId)) throw Error('Requested warehouse not available')
@@ -137,6 +155,8 @@ export async function runCrossCustomer(config, { env = process.env, outputRoot =
report.marker = context.marker; report.createdResources = context.createdResources; report.steps = context.steps
}
report ||= { success: false }
report.environment = environment; report.runtimeProfile = env.CHONGCHONG_ENV
if (gray) report.scope = 'FLEET_CAPACITY_POOL_GRAY'
report.requestCount = requestIndex; report.inventorySnapshots = evidenceIndex; report.goalComplete = false
try { if (initialized) save('report.json', report) } finally { closeSync(lock); unlinkSync(lockPath) }
}
@@ -0,0 +1,88 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { AcceptanceRun, configureReadOnlyEvidence, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs'
const args = ['--environment', 'gray', '--modules', 'phase5-root-fulfillment',
'--gateway', 'https://gray.example.invalid', '--username', 'contract', '--password', 'contract',
'--pda-username', 'contract-pda', '--pda-password', 'contract-pda',
'--f40-tax-mode', 'TAX_REFUND', '--output', 'reports/gray-contract.json']
test('gray is explicit, keeps real environment identity and creates its own customer', () => {
const options = validateExecutionOptions(parseArguments(args))
assert.equal(options.environment, 'gray')
assert.deepEqual(options.modules, ['merchant-qualification-fixture', 'phase5-root-fulfillment'])
const run = new AcceptanceRun(options)
assert.match(run.marker, /^GRAY-API-/)
assert.equal(run.context.f40TaxMode, 'TAX_REFUND')
})
test('gray cannot run unbounded full suite, extra modules, bulk data or TEST faults', () => {
assert.throws(() => parseArguments(['--environment', 'gray']), /显式指定/)
for (const extra of [
['--modules', 'phase4-cross-region-events'],
['--modules', 'phase5-root-fulfillment,customers'],
['--verify-unload-event-recovery', 'true'], ['--wait-split-count', '2'],
]) assert.throws(() => validateExecutionOptions(parseArguments([...args, ...extra])), /gray/)
assert.throws(() => validateExecutionOptions(parseArguments([...args, '--f40-tax-mode', 'UNKNOWN'])), /f40-tax-mode/)
})
test('gray endpoint must be a single HTTPS origin and needs a named report', () => {
for (const endpoint of ['http://gray.example.invalid', 'https://gray.example.invalid/api',
'https://user:secret@gray.example.invalid', 'https://gray.example.invalid?debug=1']) {
assert.throws(() => validateExecutionOptions(parseArguments([...args, '--gateway', endpoint])), /HTTPS/)
}
const options = parseArguments(args)
options.output = ''
assert.throws(() => validateExecutionOptions(options), /output/)
})
test('gray has no fallback to TEST account, gateway, customer or PDA environment variables', () => {
const keys = ['ADMIN_TEST_GATEWAY', 'ADMIN_TEST_USERNAME', 'ADMIN_TEST_PASSWORD', 'ADMIN_PDA_USERNAME',
'ADMIN_PDA_PASSWORD', 'MERCHANT_TEST_USERNAME', 'MERCHANT_TEST_PASSWORD', 'ADMIN_TEST_CUSTOMER_NAME',
'ADMIN_GRAY_GATEWAY', 'ADMIN_GRAY_USERNAME', 'ADMIN_GRAY_PASSWORD', 'ADMIN_GRAY_PDA_USERNAME',
'ADMIN_GRAY_PDA_PASSWORD', 'MERCHANT_GRAY_USERNAME', 'MERCHANT_GRAY_PASSWORD', 'ADMIN_GRAY_CUSTOMER_NAME']
const saved = new Map(keys.map(key => [key, process.env[key]]))
try {
for (const key of keys) {
if (key.includes('GRAY')) delete process.env[key]
else process.env[key] = 'must-not-be-used'
}
const options = parseArguments(['--environment', 'gray', '--modules', 'phase5-root-fulfillment'])
for (const key of ['gateway', 'username', 'password', 'pdaUsername', 'pdaPassword',
'merchantUsername', 'merchantPassword', 'customerName']) assert.equal(options[key], '')
process.env.ADMIN_GRAY_GATEWAY = 'https://gray.example.invalid'
assert.equal(parseArguments(['--environment', 'gray', '--modules', 'phase5-root-fulfillment']).gateway,
'https://gray.example.invalid')
} finally {
for (const [key, value] of saved) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
}
})
test('gray evidence preflight attaches only read-only readers, never constructs fault executors', async () => {
const run = new AcceptanceRun(validateExecutionOptions(parseArguments(args)))
const readReceiving = () => {}, readCompletion = () => {}
let preflight = false
const faults = new Proxy({}, { get() { throw Error('TEST fault factory must not be read') } })
assert.equal(await configureReadOnlyEvidence(run, {}, () => ({ readReceiving, readCompletion,
preflight: async () => { preflight = true } }), faults), undefined)
assert.equal(preflight, true)
assert.equal(run.context.phase4ReceivingEvidenceReader, readReceiving)
assert.equal(run.context.f40ReadOnlyOutboxEvidence, readCompletion)
assert.equal(run.context.beforeLoadingComplete, undefined)
assert.equal(run.context.beforeReceivingComplete, undefined)
assert.equal(run.context.f40DeliveryEventFaultExecutor, undefined)
})
test('direct gray evidence use cannot bypass module/fault restrictions or failed preflight', async () => {
const options = validateExecutionOptions(parseArguments(args))
const bad = { options: { ...options, verifyUnloadEventRecovery: true }, context: {} }
await assert.rejects(configureReadOnlyEvidence(bad, {}, () => { throw Error('must not get here') }), /故障注入/)
const run = new AcceptanceRun(options)
await assert.rejects(configureReadOnlyEvidence(run, {}, () => ({
preflight: async () => { throw Error('gray SSH unavailable') },
})), /gray SSH unavailable/)
assert.equal(run.context.phase4ReceivingEvidenceReader, undefined)
})
+118
View File
@@ -0,0 +1,118 @@
// Gray evidence uses the registered production SSH target, never the TEST agent.
import { assertReadOnlySql, buildReceivingEvidenceSql, receivingEvidenceFromRows,
buildCompletionEvidenceSql, completionEvidenceFromRows } from './fba-core-regression-evidence.mjs'
import { buildFleetInventorySql, fleetInventoryFromRows } from './fleet-inventory-evidence.mjs'
const transactionStart = ['SET NAMES utf8mb4 COLLATE utf8mb4_bin;',
'SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;',
'START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;']
export const GRAY_EVIDENCE_ENV_KEYS = ['JENKINS_URL', 'JENKINS_USER', 'JENKINS_API_TOKEN']
export function validateGrayEvidenceConfig(env) {
if (GRAY_EVIDENCE_ENV_KEYS.some(key => typeof env[key] !== 'string' || !env[key].trim()))
throw Error('Missing gray evidence configuration')
const url = new URL(env.JENKINS_URL)
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash)
throw Error('Invalid gray evidence configuration')
}
const encodedExpression = value => '[' + Buffer.from(value).toString('base64').match(/.{1,4000}/g)
.map(part => `'${part}'`).join(',') + "].join('')"
export function buildGrayEvidenceRemoteScript(service, sql) {
assertReadOnlySql(sql)
if (!['order', 'lastleg'].includes(service)) throw Error('Invalid gray evidence target')
const prefix = service.toUpperCase(), container = `firstleg-production-${service}`
const schema = `baiyue_${service}`
const composeService = service === 'lastleg' ? 'lastleg-service' : service
const python = `import base64,json,os,re,subprocess,urllib.parse
stage='CONFIG'
try:
expected=json.loads(base64.b64decode(expected_encoded))
stage='CONTAINER_IDENTITY'
c=json.loads(subprocess.check_output(['docker','inspect','${container}'],text=True,stderr=subprocess.DEVNULL,timeout=10))[0]
labels=c.get('Config',{}).get('Labels',{})
if c.get('Name')!='/${container}' or labels.get('com.docker.compose.project')!='firstleg-production' or labels.get('com.docker.compose.service')!='${composeService}' or c.get('State',{}).get('Running') is not True: raise RuntimeError('target')
e=dict(v.split('=',1) for v in c['Config']['Env'] if '=' in v)
stage='PROFILE'
if e.get('DEPLOY_ENV')!='production' or e.get('SPRING_PROFILES_ACTIVE')!='production': raise RuntimeError('profile')
stage='DATABASE_SOURCE'
for key in ['${prefix}_DB_URL','${prefix}_DB_USERNAME','${prefix}_DB_PASSWORD']:
if not expected.get(key) or e.get(key)!=expected[key]: raise RuntimeError('database source')
stage='DATABASE_IDENTITY'
u=urllib.parse.urlsplit(e['${prefix}_DB_URL'].removeprefix('jdbc:'))
if u.scheme!='mysql' or not u.hostname or u.path!='/'+ '${schema}' or u.username or u.password: raise RuntimeError('database identity')
childEnv=dict(os.environ,MYSQL_PWD=e['${prefix}_DB_PASSWORD'])
host='127.0.0.1' if u.hostname=='host.docker.internal' else u.hostname
sql=base64.b64decode('${Buffer.from(sql).toString('base64')}').decode('utf-8')
stage='MYSQL_EXECUTION'
p=subprocess.run(['mysql','--connect-timeout=8','--host='+host,'--port='+str(u.port or 3306),'--user='+e['${prefix}_DB_USERNAME'],'--batch','--raw','--skip-column-names','${schema}'],input=sql,capture_output=True,text=True,env=childEnv,timeout=35)
if p.returncode:
code=re.search(r'ERROR ([0-9]{3,5})',p.stderr)
print('GRAY_EVIDENCE_JSON='+json.dumps({'ok':False,'error':'MYSQL_'+(code.group(1) if code else 'UNKNOWN')}))
else: print('GRAY_EVIDENCE_JSON='+json.dumps({'ok':True,'rows':[json.loads(line) for line in p.stdout.splitlines() if line.strip()]}))
except Exception:
print('GRAY_EVIDENCE_JSON='+json.dumps({'ok':False,'error':'GRAY_'+stage+'_FAILED'}))
`
return `def cs=com.cloudbees.plugins.credentials.CredentialsProvider.lookupCredentials(com.cloudbees.plugins.credentials.common.StandardCredentials.class,jenkins.model.Jenkins.instance,null,null)
def key=cs.find{it.id=='firstleg-production-ssh'}
def hostCredential=cs.find{it.id=='firstleg-production-host'}
def known=cs.find{it.id=='firstleg-production-known-hosts'}
def environment=cs.find{it.id=='firstleg-production-env'}
if(key==null || hostCredential==null || known==null || environment==null) throw new RuntimeException('Gray evidence credentials unavailable')
def host=hostCredential.secret.plainText
if(!(host==~/[A-Za-z0-9][A-Za-z0-9.:-]*/) || !(key.username==~/[A-Za-z_][A-Za-z0-9_.-]*/)) throw new RuntimeException('Invalid gray SSH target')
def wanted=['${prefix}_DB_URL','${prefix}_DB_USERNAME','${prefix}_DB_PASSWORD']
def expected=[:]
environment.content.getText('UTF-8').readLines().each{line ->
if(line.contains('=')){def pair=line.split('=',2); if(wanted.contains(pair[0]))expected[pair[0]]=pair[1]}
}
if(expected.size()!=wanted.size()) throw new RuntimeException('Gray evidence database configuration unavailable')
def script="expected_encoded='"+java.util.Base64.encoder.encodeToString(groovy.json.JsonOutput.toJson(expected).getBytes('UTF-8'))+"'\\n"+new String(java.util.Base64.decoder.decode(${encodedExpression(python)}),'UTF-8')
def dir=java.nio.file.Files.createTempDirectory('gray-evidence-ssh-').toFile()
try {
def k=new File(dir,'key'); k.text=key.privateKeys[0]; k.setReadable(false,false); k.setReadable(true,true); k.setWritable(false,false); k.setWritable(true,true)
def h=new File(dir,'known_hosts'); h.bytes=known.content.bytes
def p=new ProcessBuilder(['ssh','-i',k.path,'-o','BatchMode=yes','-o','ConnectTimeout=10','-o','ServerAliveInterval=5','-o','ServerAliveCountMax=2','-o','StrictHostKeyChecking=yes','-o','UserKnownHostsFile='+h.path,key.username+'@'+host,'python3','-']).redirectErrorStream(true).start()
p.outputStream.write(script.getBytes('UTF-8')); p.outputStream.close()
def output=new StringBuffer(); def reader=p.consumeProcessOutputStream(output)
if(!p.waitFor(50,java.util.concurrent.TimeUnit.SECONDS)){p.destroyForcibly(); throw new RuntimeException('Gray evidence SSH timeout')}
if(p.exitValue()!=0) throw new RuntimeException('Gray evidence SSH failed')
reader.join(1000)
if(reader.isAlive()) throw new RuntimeException('Gray evidence SSH output incomplete')
print(output.toString())
} finally {dir.listFiles().each{it.delete()};dir.delete()}`
}
export function createGrayEvidenceReaders(env, fetcher = globalThis.fetch) {
validateGrayEvidenceConfig(env)
async function execute(service, sql) {
const script = buildGrayEvidenceRemoteScript(service, sql)
let response
try {
response = await fetcher(new URL('scriptText', env.JENKINS_URL.replace(/\/?$/, '/')), {
method: 'POST', headers: { Authorization: 'Basic ' + Buffer.from(env.JENKINS_USER + ':' + env.JENKINS_API_TOKEN).toString('base64'),
'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ script }), signal: AbortSignal.timeout(55000),
})
} catch { throw Error('Gray evidence transport failed') }
if (!response.ok) throw Error('Gray evidence HTTP ' + response.status)
let raw
try { raw = await response.text() } catch { throw Error('Gray evidence response failed') }
const lines = raw.split(/\r?\n/).filter(line => line.startsWith('GRAY_EVIDENCE_JSON='))
if (lines.length !== 1) throw Error('Gray evidence JSON missing or ambiguous')
let result
try { result = JSON.parse(lines[0].slice('GRAY_EVIDENCE_JSON='.length)) } catch { throw Error('Gray evidence JSON invalid') }
if (result?.ok !== true || !Array.isArray(result.rows))
throw Error(/^(MYSQL_[0-9]{3,5}|GRAY_(CONFIG|CONTAINER_IDENTITY|PROFILE|DATABASE_SOURCE|DATABASE_IDENTITY|MYSQL_EXECUTION)_FAILED)$/.test(result?.error) ? result.error : 'Gray evidence query failed')
return result.rows
}
return {
readReceiving: async scope => receivingEvidenceFromRows(scope, await execute('lastleg', buildReceivingEvidenceSql(scope))),
readCompletion: async scope => completionEvidenceFromRows(scope, await execute('order', buildCompletionEvidenceSql(scope))),
readFleetInventory: async scope => fleetInventoryFromRows(scope, await execute('lastleg', buildFleetInventorySql(scope))),
preflight: async () => {
for (const service of ['order', 'lastleg']) {
const rows = await execute(service, [...transactionStart, "SELECT JSON_OBJECT('grayProbe',1);", 'COMMIT;'].join('\n'))
if (rows.length !== 1 || rows[0].grayProbe !== 1) throw Error('Gray evidence preflight failed')
}
},
}
}
+54
View File
@@ -0,0 +1,54 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { buildGrayEvidenceRemoteScript, createGrayEvidenceReaders, validateGrayEvidenceConfig } from './gray-regression-evidence.mjs'
const env = { JENKINS_URL: 'https://jenkins.example.invalid/', JENKINS_USER: 'reader', JENKINS_API_TOKEN: 'private-test-token' }
const probeSql = "SET NAMES utf8mb4 COLLATE utf8mb4_bin;\nSET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;\nSTART TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;\nSELECT JSON_OBJECT('grayProbe',1);\nCOMMIT;"
test('gray reader refuses missing credentials, embedded URL credentials and SQL mutations', () => {
assert.throws(() => validateGrayEvidenceConfig({}), /Missing/)
assert.throws(() => validateGrayEvidenceConfig({ ...env, JENKINS_URL: 'https://secret@host.invalid' }), /Invalid/)
assert.throws(() => buildGrayEvidenceRemoteScript('auth', probeSql), /target/)
assert.throws(() => buildGrayEvidenceRemoteScript('order', probeSql.replace('SELECT JSON_OBJECT', 'DELETE JSON_OBJECT')), /Read-only/)
})
test('gray SSH reader pins registered production host, known hosts, runtime and database credentials', () => {
const script = buildGrayEvidenceRemoteScript('order', probeSql)
for (const credential of ['ssh', 'host', 'known-hosts', 'env']) assert.ok(script.includes(`firstleg-production-${credential}`))
assert.ok(script.includes('StrictHostKeyChecking=yes'))
assert.ok(script.includes('finally {dir.listFiles().each{it.delete()};dir.delete()}'))
assert.ok(!script.includes('java-test-agent') && !script.includes('RemotingDiagnostics'))
const encodedPython = [...script.matchAll(/\['([A-Za-z0-9+/=]+)'(?:,'[A-Za-z0-9+/=]+')*\]\.join\(''\)/g)]
.map(match => [...match[0].matchAll(/'([A-Za-z0-9+/=]+)'/g)].map(part => part[1]).join(''))
const python = encodedPython.map(value => Buffer.from(value, 'base64').toString()).find(value => value.includes('subprocess'))
assert.ok(python?.includes("labels.get('com.docker.compose.project')!='firstleg-production'"))
assert.ok(python.includes("e.get(key)!=expected[key]"))
assert.ok(python.includes("u.path!='/'+ 'baiyue_order'"))
assert.ok(python.includes('READ ONLY') === false, 'SQL is encoded independently')
const lastleg = buildGrayEvidenceRemoteScript('lastleg', probeSql)
const payload = [...lastleg.matchAll(/\['([A-Za-z0-9+/=]+)'(?:,'[A-Za-z0-9+/=]+')*\]\.join\(''\)/g)]
.map(match => [...match[0].matchAll(/'([A-Za-z0-9+/=]+)'/g)].map(part => part[1]).join(''))
.map(value => Buffer.from(value, 'base64').toString()).find(value => value.includes('subprocess'))
assert.ok(payload.includes("labels.get('com.docker.compose.service')!='lastleg-service'"))
})
test('preflight performs only two scoped gray probes and never exposes remote raw errors', async () => {
const requests = []
const readers = createGrayEvidenceReaders(env, async (url, options) => {
requests.push({ url: String(url), script: options.body.get('script') })
return { ok: true, text: async () => 'GRAY_EVIDENCE_JSON={"ok":true,"rows":[{"grayProbe":1}]}\n' }
})
await readers.preflight()
assert.equal(requests.length, 2)
assert.ok(requests.every(request => request.url === 'https://jenkins.example.invalid/scriptText'))
const failed = createGrayEvidenceReaders(env, async () => ({ ok: true, text: async () => 'MYSQL_PWD=hidden\nTraceback secret-value' }))
await assert.rejects(failed.preflight(), error => error.message === 'Gray evidence JSON missing or ambiguous')
})
test('preflight rejects failed probes and ambiguous results', async () => {
for (const raw of ['GRAY_EVIDENCE_JSON={"ok":true,"rows":[{"grayProbe":0}]}',
'GRAY_EVIDENCE_JSON={"ok":false,"error":"MYSQL_1146"}',
'GRAY_EVIDENCE_JSON={}\nGRAY_EVIDENCE_JSON={}']) {
const readers = createGrayEvidenceReaders(env, async () => ({ ok: true, text: async () => raw }))
await assert.rejects(readers.preflight())
}
const sourceFailure = createGrayEvidenceReaders(env, async () => ({ ok: true,
text: async () => 'GRAY_EVIDENCE_JSON={"ok":false,"error":"GRAY_DATABASE_SOURCE_FAILED"}' }))
await assert.rejects(sourceFailure.preflight(), { message: 'GRAY_DATABASE_SOURCE_FAILED' })
})
+144 -44
View File
@@ -6,54 +6,154 @@
"spotbugsMavenPlugin": "4.9.8.5"
},
"topology": {
"modules": 22,
"codeModules": 21,
"services": 15
"modules": 20,
"codeModules": 19,
"services": 13
},
"openapi": {
"documents": 49,
"coveredServices": 10,
"codeFirstServices": 5
"documents": 69,
"coveredServices": 11,
"codeFirstServices": 2
},
"reports": {
"jacocoPresent": 21,
"spotbugsPresent": 21,
"spotbugsFindings": 780,
"spotbugsByPriority": {
"1": 1,
"2": 779
},
"spotbugsByCategory": {
"BAD_PRACTICE": 10,
"CORRECTNESS": 3,
"EXPERIMENTAL": 4,
"MALICIOUS_CODE": 699,
"MT_CORRECTNESS": 2,
"PERFORMANCE": 18,
"STYLE": 44
}
"jacocoPresent": 19,
"spotbugsPresent": 19,
"spotbugsFindings": 777
},
"modules": [
{ "module": "common-core", "jacocoCovered": 32, "jacocoTotal": 78, "jacocoPercentage": 41.03, "spotbugsFindings": 6 },
{ "module": "common-web", "jacocoCovered": 164, "jacocoTotal": 188, "jacocoPercentage": 87.23, "spotbugsFindings": 1 },
{ "module": "common-security", "jacocoCovered": 109, "jacocoTotal": 202, "jacocoPercentage": 53.96, "spotbugsFindings": 6 },
{ "module": "common-openapi", "jacocoCovered": 17, "jacocoTotal": 37, "jacocoPercentage": 45.95, "spotbugsFindings": 0 },
{ "module": "common-audit", "jacocoCovered": 168, "jacocoTotal": 244, "jacocoPercentage": 68.85, "spotbugsFindings": 8 },
{ "module": "common-event-contract", "jacocoCovered": 146, "jacocoTotal": 164, "jacocoPercentage": 89.02, "spotbugsFindings": 18 },
{ "module": "gateway", "jacocoCovered": 110, "jacocoTotal": 156, "jacocoPercentage": 70.51, "spotbugsFindings": 4 },
{ "module": "gateway-overseas", "jacocoCovered": 134, "jacocoTotal": 272, "jacocoPercentage": 49.26, "spotbugsFindings": 2 },
{ "module": "auth", "jacocoCovered": 372, "jacocoTotal": 619, "jacocoPercentage": 60.10, "spotbugsFindings": 5 },
{ "module": "iam", "jacocoCovered": 1872, "jacocoTotal": 3032, "jacocoPercentage": 61.74, "spotbugsFindings": 60 },
{ "module": "order", "jacocoCovered": 1116, "jacocoTotal": 1605, "jacocoPercentage": 69.53, "spotbugsFindings": 47 },
{ "module": "firstleg", "jacocoCovered": 7891, "jacocoTotal": 11592, "jacocoPercentage": 68.07, "spotbugsFindings": 223 },
{ "module": "warehouse", "jacocoCovered": 1767, "jacocoTotal": 2922, "jacocoPercentage": 60.47, "spotbugsFindings": 64 },
{ "module": "warehouse-overseas-service", "jacocoCovered": 403, "jacocoTotal": 778, "jacocoPercentage": 51.80, "spotbugsFindings": 8 },
{ "module": "lastleg-service", "jacocoCovered": 5088, "jacocoTotal": 7832, "jacocoPercentage": 64.96, "spotbugsFindings": 162 },
{ "module": "file", "jacocoCovered": 1046, "jacocoTotal": 1614, "jacocoPercentage": 64.81, "spotbugsFindings": 33 },
{ "module": "print", "jacocoCovered": 596, "jacocoTotal": 695, "jacocoPercentage": 85.76, "spotbugsFindings": 12 },
{ "module": "audit-service", "jacocoCovered": 76, "jacocoTotal": 153, "jacocoPercentage": 49.67, "spotbugsFindings": 0 },
{ "module": "finance", "jacocoCovered": 1665, "jacocoTotal": 2236, "jacocoPercentage": 74.46, "spotbugsFindings": 97 },
{ "module": "integration-service", "jacocoCovered": 1040, "jacocoTotal": 1420, "jacocoPercentage": 73.24, "spotbugsFindings": 23 },
{ "module": "event-bridge-service", "jacocoCovered": 318, "jacocoTotal": 419, "jacocoPercentage": 75.89, "spotbugsFindings": 1 }
]
{
"module": "common-core",
"jacocoCovered": 32,
"jacocoTotal": 78,
"jacocoPercentage": 41.03,
"spotbugsFindings": 6
},
{
"module": "common-web",
"jacocoCovered": 164,
"jacocoTotal": 188,
"jacocoPercentage": 87.23,
"spotbugsFindings": 1
},
{
"module": "common-security",
"jacocoCovered": 109,
"jacocoTotal": 202,
"jacocoPercentage": 53.96,
"spotbugsFindings": 6
},
{
"module": "common-openapi",
"jacocoCovered": 17,
"jacocoTotal": 37,
"jacocoPercentage": 45.95,
"spotbugsFindings": 0
},
{
"module": "common-audit",
"jacocoCovered": 168,
"jacocoTotal": 244,
"jacocoPercentage": 68.85,
"spotbugsFindings": 8
},
{
"module": "common-event-contract",
"jacocoCovered": 146,
"jacocoTotal": 164,
"jacocoPercentage": 89.02,
"spotbugsFindings": 18
},
{
"module": "gateway",
"jacocoCovered": 110,
"jacocoTotal": 156,
"jacocoPercentage": 70.51,
"spotbugsFindings": 4
},
{
"module": "auth",
"jacocoCovered": 372,
"jacocoTotal": 619,
"jacocoPercentage": 60.1,
"spotbugsFindings": 5
},
{
"module": "iam",
"jacocoCovered": 1872,
"jacocoTotal": 3032,
"jacocoPercentage": 61.74,
"spotbugsFindings": 60
},
{
"module": "order",
"jacocoCovered": 1116,
"jacocoTotal": 1605,
"jacocoPercentage": 69.53,
"spotbugsFindings": 47
},
{
"module": "firstleg",
"jacocoCovered": 7891,
"jacocoTotal": 11592,
"jacocoPercentage": 68.07,
"spotbugsFindings": 223
},
{
"module": "warehouse",
"jacocoCovered": 1767,
"jacocoTotal": 2922,
"jacocoPercentage": 60.47,
"spotbugsFindings": 64
},
{
"module": "warehouse-overseas-service",
"jacocoCovered": 403,
"jacocoTotal": 778,
"jacocoPercentage": 51.8,
"spotbugsFindings": 8
},
{
"module": "lastleg-service",
"jacocoCovered": 5088,
"jacocoTotal": 7832,
"jacocoPercentage": 64.96,
"spotbugsFindings": 162
},
{
"module": "file",
"jacocoCovered": 1046,
"jacocoTotal": 1614,
"jacocoPercentage": 64.81,
"spotbugsFindings": 33
},
{
"module": "print",
"jacocoCovered": 596,
"jacocoTotal": 695,
"jacocoPercentage": 85.76,
"spotbugsFindings": 12
},
{
"module": "audit-service",
"jacocoCovered": 76,
"jacocoTotal": 153,
"jacocoPercentage": 49.67,
"spotbugsFindings": 0
},
{
"module": "finance",
"jacocoCovered": 1665,
"jacocoTotal": 2236,
"jacocoPercentage": 74.46,
"spotbugsFindings": 97
},
{
"module": "integration-service",
"jacocoCovered": 1040,
"jacocoTotal": 1420,
"jacocoPercentage": 73.24,
"spotbugsFindings": 23
}
],
"measurementNote": "保留服务的历史质量观测,仅用于报告比较;拓扑与OpenAPI按当前源码核对,不代表当前覆盖率或静态分析已重新测量。"
}
+10 -5
View File
@@ -64,18 +64,23 @@ test('aggregates code modules, report presence and the OpenAPI service matrix',
test('repository freezes the current topology and non-blocking aggregate summary wiring', async () => {
const summary = await buildQualitySummary(repositoryRoot)
const baseline = JSON.parse(await readFile(path.join(repositoryRoot, 'scripts', 'java-quality-baseline.json'), 'utf8'))
assert.deepEqual(summary.topology, { modules: 22, codeModules: 21, services: 15 })
assert.equal(summary.openapi.documents, 49)
assert.deepEqual(summary.topology, { modules: 20, codeModules: 19, services: 13 })
assert.equal(summary.openapi.documents, 69)
assert.ok(summary.openapi.files.includes('lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml'))
assert.ok(summary.openapi.files.includes('lastleg-service/src/main/resources/openapi/fleet-drivers-v1.yaml'))
assert.ok(summary.openapi.files.includes('file/src/main/resources/openapi/fleet-driver-license-v1.yaml'))
assert.ok(summary.modules.every(row => !['gateway-overseas', 'event-bridge-service'].includes(row.module)))
assert.ok(summary.openapi.files.includes('file/src/main/resources/openapi/loading-evidence-v1.yaml'))
assert.ok(summary.openapi.files.includes('file/src/main/resources/openapi/overseas-relabel-document-v1.yaml'))
assert.ok(summary.openapi.files.includes('finance/src/main/resources/openapi/finance-container-settlement-v1.yaml'))
assert.ok(summary.openapi.files.includes('warehouse-overseas-service/src/main/resources/openapi/warehouse-pallet-batch-internal-v1.yaml'))
assert.equal(baseline.openapi.documents, summary.openapi.documents)
assert.equal(summary.openapi.coveredServices.length, 10)
assert.equal(summary.openapi.codeFirstServices.length, 5)
assert.equal(summary.openapi.coveredServices.length, 11)
assert.deepEqual(summary.openapi.codeFirstServices, ['audit-service', 'gateway'])
assert.equal(baseline.mode, 'report-only')
assert.deepEqual(baseline.topology, summary.topology)
assert.equal(baseline.modules.length, 21)
assert.equal(baseline.modules.length, 19)
assert.deepEqual(baseline.modules.map(row => row.module).sort(), summary.modules.map(row => row.module).sort())
assert.equal(baseline.modules.reduce((total, row) => total + row.spotbugsFindings, 0), baseline.reports.spotbugsFindings)
const gitlab = (await readFile(path.join(repositoryRoot, '.gitlab-ci.yml'), 'utf8')).replaceAll('\r\n', '\n')
@@ -15,6 +15,9 @@ const http = (path, source, target, contexts, access, idempotency, note) => ({
})
export const SYNCHRONOUS_CLIENTS = Object.freeze([
feign('lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripFinanceClient.java',
'lastleg-service', 'finance-service', ['REQUEST'], 'READ', 'SAFE_READ',
'车次财务按不可变发车客户及派送来源批量只读查询,POST无业务写入;内部服务令牌保护,实收另按公网权限裁剪,不逐行查询或推算未分配收款'),
feign('finance/src/main/java/com/baiyue/finance/fleet/VehicleCostFileClient.java',
'finance-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED',
'公司车辆付款凭证按平台及费用ID校验;上传创建不可变对象且无远端幂等键,读取可重复,禁止传输层自动重试上传'),
@@ -102,7 +102,7 @@ test('all Feign clients have reviewed context, access and idempotency metadata',
const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'FEIGN')
.map(({ path, source, target }) => ({ path, source, target }))
.sort((left, right) => left.path.localeCompare(right.path))
assert.equal(reviewed.length, 70)
assert.equal(reviewed.length, 71)
assert.deepEqual(await discoverFeignEdges(), reviewed)
})
@@ -215,6 +215,8 @@ test('unified authority checks remain infrastructure edges and direct events rem
'lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceFeeCoverageClient.java', 'READ', 'SAFE_READ'
], [
'lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceClient.java', 'READ', 'SAFE_READ'
], [
'lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripFinanceClient.java', 'READ', 'SAFE_READ'
]])
assert.ok(!SYNCHRONOUS_CLIENTS.some(client => ['event-bridge-service','gateway-overseas'].includes(client.source) || ['event-bridge-service','gateway-overseas'].includes(client.target)))
})
@@ -15,6 +15,29 @@ const serviceDockerfile = (await readFile(new URL('../deploy/docker/Dockerfile',
const serviceCompose = (await readFile(new URL('../deploy/docker-compose.yml', import.meta.url), 'utf8'))
.replaceAll('\r\n', '\n')
test('both pipelines reject the placeholder registry from failed build 662 before Maven or Docker', () => {
for (const source of [coordinatorPipeline, pipeline]) {
assert.match(source, /string\(name: 'IMAGE_REPOSITORY', defaultValue: ''/)
const guard = source.match(/(?:requestedRepository|imageRepository)\.tokenize\('\/'\)\[0\] ==~ \/\(\?i\)([^\n]+)\//)
assert.ok(guard, 'registry host must reject reserved example domains')
const reservedHost = new RegExp(`^(?:${guard[1]})$`, 'i')
for (const repository of [
'registry.example.com/team/baiyue-firstleg',
'registry.example.com/your-group/baiyue-firstleg',
'EXAMPLE.COM:5000/team/image',
'registry.example.net/team/image',
'example.org/team/image'
]) assert.ok(reservedHost.test(repository.split('/')[0]), repository)
for (const host of ['registry.company.com:5000', '10.0.0.10:10082', 'example.company.com']) {
assert.equal(reservedHost.test(host), false, host)
}
const mavenStage = source.match(/stage\('[^']*Maven[^']*'\)/)
assert.ok(mavenStage)
assert.ok(source.indexOf(guard[0]) < mavenStage.index,
'invalid registry must fail before expensive Java builds')
}
})
test('service pipeline keeps boolean operators on parseable Groovy continuation lines', () => {
assert.doesNotMatch(pipeline, /^\s*(?:&&|\|\|)/m)
})