diff --git a/scripts/README.md b/scripts/README.md index fe8eef497..16b59b197 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -53,11 +53,11 @@ node scripts/acceptance-overview.mjs --report reports/<明确的一次验收报 最新部署与已验/未验状态仍只维护在外层 `docs/测试环境单机部署与配置契约.md`;本索引不复制每日进度。总览不校验实际部署版本、不读取环境文件,执行前仍须经正式入口校验凭据和环境。直接离线测试为 `acceptance-overview.test.mjs`。 -`local-dispatch-transport-acceptance.mjs`:仅本机 LOCAL 的现有排车运输任务 HTTP 验收。显式 `node --env-file=<本机凭据文件>` 注入 `ADMIN_TEST_GATEWAY`、`ADMIN_TEST_USERNAME/PASSWORD`、`LOCAL_TRANSFER_PDA_PASSWORD`;拒绝远端网关。创建3张 TEST 仓储转运单、1张本土卡车原单、5张排车(完成/取消/重新安排),以及本轮专用调度/PDA账号、角色、车辆司机;缺失的正式排车权限仅补资源并授予专用角色,不修改既有角色。经正式网关验证原单审核、跨客户取货、资源冲突、凭证隔离、取消重排、发车完成回写、独立仓库清点和车辆司机释放。合成 PNG 只验文件链路,不代表实际运输或有效证照。默认报告 `reports/local-dispatch-transport.json`,可用 `TRANSPORT_ACCEPTANCE_OUTPUT` 指定;每步保留请求键、脱敏意图、错误码和关联标识,失败停止且保留数据。确认已知失败结果后显式 `TRANSPORT_ACCEPTANCE_RESUME=true` 复用同一报告继续;网络结果未知必须先核对,不自动清理或删除,不使用业务 SQL。已成功批次禁止重复运行;不能替代远端部署或人工截图验收。 +`local-dispatch-transport-acceptance.mjs`:仅本机 LOCAL 的现有排车运输任务 HTTP 验收。显式 `node --env-file=<本机凭据文件>` 注入 `ADMIN_TEST_GATEWAY`、`ADMIN_TEST_USERNAME/PASSWORD`、`LOCAL_TRANSFER_PDA_USERNAME/PASSWORD`;拒绝远端网关。创建3张 TEST 仓储转运单、1张本土卡车原单、5张排车(完成/取消/重新安排),以及车辆司机;调度和PDA复用明确配置的既有账号,缺少正式权限即阻断,不创建角色或账号。经正式网关验证原单审核、跨客户取货、资源冲突、凭证隔离、取消重排、发车完成回写、独立仓库清点和车辆司机释放。合成 PNG 只验文件链路,不代表实际运输或有效证照。默认报告 `reports/local-dispatch-transport.json`,可用 `TRANSPORT_ACCEPTANCE_OUTPUT` 指定;每步保留请求键、脱敏意图、错误码和关联标识,失败停止且保留数据。确认已知失败结果后显式 `TRANSPORT_ACCEPTANCE_RESUME=true` 复用同一报告继续;网络结果未知必须先核对,不自动清理或删除,不使用业务 SQL。已成功批次禁止重复运行;不能替代远端部署或人工截图验收。 `local-check.ps1`:Windows / PowerShell 7 本机重型检查统一入口,适用于前后端测试、类型检查、构建、安装依赖及 Maven。示例:`& ./scripts/local-check.ps1 -WorkingDirectory ../apps/admin-web -Label 'Admin typecheck' -Command @('pnpm', 'typecheck')`;`-Status` 查看占用。当前用户所有工作目录共用一个文件句柄锁,默认排队900秒、执行1200秒;超时分别退出75/124,其余透传命令退出码。Vitest workers=2,Node堆2048MB,Java堆1536MB/处理器2;直接 Maven 自动单线程/单fork,直接 Node 内置测试自动并发2。Windows Job Object 仅回收本次后代,不接管旧进程;嵌套调用拒绝,批次内部执行原始命令。无凭据、不主动联网或写业务数据;被包装命令的环境授权不变。状态和最近结果仅写 `%LOCALAPPDATA%/Chongchong/LocalChecks/`,不存命令参数或输出;不自动缓存成功结果。失败保留最近摘要,确认输入后重新排队;锁文件无需手工删除,状态不是验收报告。完整规则见 `../../docs/AI工程执行与协作约束.md` 的“本机重型检查与多对话限流”;离线行为测试为 `local-check.test.mjs`。 -`local-transfer-pickup-acceptance.mjs`:仅本机 LOCAL 仓储转运取货造数与 HTTP 验收,拒绝非 localhost/127.0.0.1 Gateway。通过 `node --env-file=<本轮明确的本地配置> scripts/local-transfer-pickup-acceptance.mjs` 注入 `ADMIN_TEST_GATEWAY`、`ADMIN_TEST_USERNAME`、`ADMIN_TEST_PASSWORD`、`LOCAL_TRANSFER_PDA_PASSWORD`,不猜测或读取其他环境文件。创建16张明确 TEST 标记的业务单、专用PDA账号、司机车辆和多状态取货车次,含客户下单、跨客户共车、取消改派、资源占用、凭证隔离、送仓门禁与完整PDA测量。所有业务均走正式 Gateway HTTP,不用业务 SQL;文件是合成测试 PNG,不代表实际拍照或有效证照。报告默认 `reports/PICKUP-<新批次>.json`,可用 `PICKUP_ACCEPTANCE_OUTPUT` 指定;写入前保存脱敏意图与幂等键,逐阶段落盘。失败立即停止并保留已成功数据及报告,未知结果先核对原请求,不自动重发;重新执行会创建新批次,不自动续跑或清理旧记录。人工核实原请求结果后,可显式用 `PICKUP_ACCEPTANCE_RESUME=<本轮报告>` 沿已知单号继续验收;已成功批次禁止重复执行。脚本记录每次执行范围与结果,旧失败不删除;本机缺失的正式车次取消/结案/释放权限仅补权限资源并授权新建专用验收角色,不修改现有角色授权。账号和单据保留供本地页面验收,不能作为远端 TEST/生产或视觉验收结论。 +`local-transfer-pickup-acceptance.mjs`:仅本机 LOCAL 仓储转运取货造数与 HTTP 验收,拒绝非 localhost/127.0.0.1 Gateway。通过 `node --env-file=<本轮明确的本地配置> scripts/local-transfer-pickup-acceptance.mjs` 注入 `ADMIN_TEST_GATEWAY`、`ADMIN_TEST_USERNAME`、`ADMIN_TEST_PASSWORD`、`LOCAL_TRANSFER_PDA_USERNAME`、`LOCAL_TRANSFER_PDA_PASSWORD`,不猜测或读取其他环境文件。复用配置的平台账号与已有仓绑定PDA,创建16张明确 TEST 标记的业务单、司机车辆和多状态取货车次,含客户下单、跨客户共车、取消改派、资源占用、凭证隔离、送仓门禁与完整PDA测量。所有业务均走正式 Gateway HTTP,不用业务 SQL;文件是合成测试 PNG,不代表实际拍照或有效证照。报告默认 `reports/PICKUP-<新批次>.json`,可用 `PICKUP_ACCEPTANCE_OUTPUT` 指定;写入前保存脱敏意图与幂等键,逐阶段落盘。失败立即停止并保留已成功数据及报告,未知结果先核对原请求,不自动重发;重新执行会创建新批次,不自动续跑或清理旧记录。人工核实原请求结果后,可显式用 `PICKUP_ACCEPTANCE_RESUME=<本轮报告>` 沿已知单号继续验收;已成功批次禁止重复执行。脚本记录每次执行范围与结果,旧失败不删除;本机固定账号缺失正式车次取消/结案/释放权限时停止,不补权限资源、不创建角色或用户。账号和单据保留供本地页面验收,不能作为远端 TEST/生产或视觉验收结论。 `db/fleet-transfer-pickup-20261017.sql`:本功能 MySQL8 可重复执行结构增量,显式指向 `baiyue_lastleg`。为原车次增加 `trip_purpose`,新增取货计划摘要、原单任务、命令幂等、上传归属及历史五表;不写业务数据。结构同步于模块 `lastleg.sql` 和统一部署增量。需在已授权环境用运行者显式数据库凭据执行;失败保留诊断,修正后可重复执行,不删旧结构或业务数据。 @@ -88,15 +88,15 @@ node scripts/acceptance-overview.mjs --report reports/<明确的一次验收报 `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= --env-file= scripts/fleet-capacity-pool-ui-fixture-gray.mjs --execute --environment GRAY --run-id <新批次> --warehouse-id <实际收货仓ID> --destination-warehouse-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-ui-fixture-gray.mjs` 仅准备本轮授权的 GRAY 拼车角色页面、报价引用和 PDA H5 验收数据,复用 TEST UI producer/core,默认 `--plan` 零网络。执行使用 `node --env-file= --env-file= scripts/fleet-capacity-pool-ui-fixture-gray.mjs --execute --environment GRAY --run-id <新批次> --warehouse-id <实际收货仓ID> --destination-warehouse-id --private-credentials-file <私有env目录直接子级的独占JSON绝对路径>`。保留实际 `CHONGCHONG_ENV=prod|gray`,配置沿 GRAY 拼车专项的 CHONGCHONG_ADMIN 与 JENKINS_*;强制生产身份只读reader和品牌transport,不接受producer/reader覆盖。复用两个客户、销售与最小manage调度账号,只建两客户真实来货、自有18/30计划及4完整草稿;销售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 `。命令前使用 `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-gray.mjs` 是明确 GRAY 的拼车专项入口,复用既有池全流程矩阵与两客户实货前置,不执行旧 FBA 下单、报关或装柜链。默认 `--plan` 零网络、零文件写入;执行必须传 `--execute --environment GRAY --run-id <新批次> --warehouse-id <实际海外仓ID> --destination-warehouse-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 只能创建空模块且无故障注入的灰度上下文。执行前还须提供两个不同的既有客户:主客户使用 `CHONGCHONG_ACCEPTANCE_CUSTOMER_ID` 与 `CHONGCHONG_MERCHANT_USERNAME/PASSWORD`,第二客户使用 `CHONGCHONG_ACCEPTANCE_SECONDARY_CUSTOMER_ID` 与 `CHONGCHONG_SECONDARY_MERCHANT_USERNAME/PASSWORD`;缺任一项在取证及造数前停止。通过 `CHONGCHONG_ACCEPTANCE_FLEET_RESOURCES`(车辆/司机 ID 组合数组)配置复用资源。灰度页面样本入口沿用同一客户配置,私有交接文件保存已配置且登录成功的既有账号;允许客户有历史库存及历史业务列表,本轮入库按原来货码、余额和实际位置核对。直接合同 `fleet-capacity-pool-gray.test.mjs` 验证环境隔离、品牌门禁和复用范围;原 TEST CLI 仍拒绝 GRAY。本入口不部署、不写业务 SQL,不将专项通过冒充全部页面验收。 -拼车主入口 `fleet-capacity-pool-test-env.mjs` 除两笔真实履约订单外,还各创建一笔本土散板/卡车的“已确认、未登记来货”取消样本,核对商业单与预订同步取消、三维容量恢复和原键重放;增加来货后取消/实收超量拒绝、绑定后占位转计划不双扣、事实失效须重新核对并显式恢复,以及跨销售写入/跨客户商业单拒绝。正常链通过拼车专属 `save-and-confirm`、`fulfillment`、`incoming`、`receipt-confirmation` 和 `arrange-dispatch` 接口验证简化操作及原命令重放,不再以脚本手工建排车再绑定代替自动关联;另建精确履约权限账号,验证仅有拼车管理权限不能安排来货、确认实收或生成排车,销售仅能读取本人履约。拒绝动作须回查原事实与库存不变,不能仅按错误码判定通过;不注入业务数据库故障。 +拼车主入口 `fleet-capacity-pool-test-env.mjs` 除两笔真实履约订单外,还各创建一笔本土散板/卡车的“已确认、未登记来货”取消样本,核对商业单与预订同步取消、三维容量恢复和原键重放;增加来货后取消/实收超量拒绝、绑定后占位转计划不双扣、事实失效须重新核对并显式恢复,以及跨销售写入/跨客户商业单拒绝。正常链通过拼车专属 `save-and-confirm`、`fulfillment`、`incoming`、`receipt-confirmation` 和 `arrange-dispatch` 接口验证简化操作及原命令重放,不再以脚本手工建排车再绑定代替自动关联;复用精确履约权限账号,验证仅有拼车管理权限不能安排来货、确认实收或生成排车,销售仅能读取本人履约。拒绝动作须回查原事实与库存不变,不能仅按错误码判定通过;不注入业务数据库故障。 -`fleet-capacity-pool-ui-fixture-test-env.mjs` 仅准备内部拼车池TEST页面验收数据,默认 `--plan` 零网络;实际命令沿 `--execute --environment TEST --run-id <新批次> --warehouse-id <实际海外仓ID> --destination-warehouse-id <目的仓ID>`,另必须显式传 `--private-credentials-file <用户.codex/env/chongchong-global目录直接子级的独占JSON绝对路径>`。通过明确TEST环境文件读取CHONGCHONG_ADMIN及Jenkins只读取证配置,创建两客户真实来货和计划车次、OPEN发布、两销售各12条不占位草稿及最小调度账号;停在可交互状态,不确认承运、不拣货发车、不声称全量验收。密码只在首次写入前独占创建的私有文件中保存,报告 `reports/fleet-capacity-pool-ui-<新批次>/` 不含密码/Token;失败保留现场和原请求,不自动重跑。退出脚本会话并停用自有fixture PDA,保留UI账号供浏览器验收;不部署、不写业务SQL、不删除业务样本。离线安全与作用域合同为 `fleet-capacity-pool-ui-fixture-test-env.test.mjs`。 +`fleet-capacity-pool-ui-fixture-test-env.mjs` 仅准备内部拼车池TEST页面验收数据,默认 `--plan` 零网络;实际命令沿 `--execute --environment TEST --run-id <新批次> --warehouse-id <实际海外仓ID> --destination-warehouse-id <目的仓ID>`,另必须显式传 `--private-credentials-file <用户.codex/env/chongchong-global目录直接子级的独占JSON绝对路径>`。通过明确TEST环境文件读取CHONGCHONG_ADMIN及Jenkins只读取证配置,复用固定客户、销售及调度账号,创建两客户真实来货和计划车次、OPEN发布、两销售各12条不占位草稿;停在可交互状态,不确认承运、不拣货发车、不声称全量验收。密码只在首次写入前独占创建的私有文件中保存,报告 `reports/fleet-capacity-pool-ui-<新批次>/` 不含密码/Token;失败保留现场和原请求,不自动重跑。退出脚本会话,固定fixture PDA保持原状态,保留UI账号供浏览器验收;不部署、不写业务SQL、不删除业务样本。离线安全与作用域合同为 `fleet-capacity-pool-ui-fixture-test-env.test.mjs`。 -`fleet-capacity-pool-test-env.mjs` 是内部拼车需求池 TEST 专项入口,默认 `--plan` 零网络;实际执行须显式 `--execute --environment TEST --run-id <新批次> --warehouse-id <实际海外仓ID> --destination-warehouse-id <目的仓ID>`,凭据由命令前的 `node --env-file=<明确TEST配置>` 提供,配置同时包含 Jenkins 只读取证凭据与 `FBA_CORE_JENKINS_AGENT`。创建隔离客户、销售、调度、预订及商业订单,验证容量争抢、销售隔离、命令重放、两类本土订单实际到仓/PDA收货/共享车次/出库签收和费用唯一;不部署、不写业务SQL。证据保存在 `reports/fleet-cross-<新批次>/`,包含 `pool-scope.json` 和写请求journal;未知结果停止,保留原样本与请求,不覆盖批次、不自动重造数据。结束退出独立会话并沿fixture清理其自有PDA访问,业务样本保留;页面及未覆盖矩阵单独报告,不将专项通过当成全量验收。离线保护由 `fleet-capacity-pool-test-env.test.mjs` 验证。 +`fleet-capacity-pool-test-env.mjs` 是内部拼车需求池 TEST 专项入口,默认 `--plan` 零网络;实际执行须显式 `--execute --environment TEST --run-id <新批次> --warehouse-id <实际海外仓ID> --destination-warehouse-id <目的仓ID>`,凭据由命令前的 `node --env-file=<明确TEST配置>` 提供,配置同时包含 Jenkins 只读取证凭据与 `FBA_CORE_JENKINS_AGENT`。复用两个固定客户及固定销售、调度和履约账号,创建预订及商业订单,验证容量争抢、销售隔离、命令重放、两类本土订单实际到仓/PDA收货/共享车次/出库签收和费用唯一;不部署、不写业务SQL。证据保存在 `reports/fleet-cross-<新批次>/`,包含 `pool-scope.json` 和写请求journal;未知结果停止,保留原样本与请求,不覆盖批次、不自动重造数据。结束退出独立会话并沿fixture清理其自有PDA访问,业务样本保留;页面及未覆盖矩阵单独报告,不将专项通过当成全量验收。离线保护由 `fleet-capacity-pool-test-env.test.mjs` 验证。 `inbound-plan-resource-fault-executor.mjs` 是单柜入仓计划资源确认故障的 TEST 环境执行入口,内部使用 `inbound-plan-resource-fault-runtime.py`。默认 `plan --plan <单柜计划.json>` 零网络;实际动作必须读取明确的TEST/Jenkins外部配置并传 `--confirm-test-runtime yes`。`preflight --routing <私有路由.json>` 只读校验Lastleg与Warehouse Overseas镜像/指纹、实际网络和Nacos common/lastleg配置摘要;`prepare`另要求 `--expected-image <已冻结镜像ID>` 及预检摘要。`activate`只临时改Lastleg的 `baiyue.warehouse.base-url`,配合 `mode --mode CONFIRM_ONCE --ttl 60` 精确注入一次confirm失败;业务转换和关闭重开页面由调用者独立操作,工具没有造数、SQL或业务写接口。`status/health/records`用于观测,结果未知先 `status/reconcile`,禁止因观察超时重复创建容器;`restore/verify-restored/finish`须按原镜像、完整配置/挂载/网络、Nacos及上游健康恢复后才停自有代理、清私有凭据。服务变更先记录意图,受限远端恢复状态目录为 `/tmp/chongchong-inbound-resource-`,由总控持独占Lastleg故障窗口;调用结果和业务证据另存本次独立 `reports/` 目录,失败保留、不覆盖,不与部署或其他共享业务写入并行。`inbound-plan-resource-fault-executor.test.mjs`、`inbound-plan-resource-fault-runtime.test.mjs` 为离线边界及恢复测试,不证明真实故障已跑通。 @@ -112,11 +112,11 @@ F33 分阶段助手的组合换标顺序为 `unit → unit-carton → bind`: `fba-remaining-nodes-acceptance.mjs` 是 FBA 剩余节点内部证据助手,显式调用 `auditRemainingNodes`,导入不运行;读取指定原报告并按 JSON pointer/SHA-256 保留证据。`live=true` 仅使用明确 TEST 配置的独立 Auth 会话和串行 GET,结束退出;不执行业务写命令、SQL、故障注入、部署或页面操作。报告位于 `reports/fba-remaining-nodes-20261004/`,拒绝覆盖既有报告,失败保留且不自动重试。对应离线测试为 `fba-remaining-nodes-acceptance.test.mjs`,只读核验不等于原业务已完成。 -`fba-remaining-nodes-acceptance-pda-operator.mjs` 是 F33 海外 PDA 的一次性 TEST 账号准备助手,导入不运行。显式调用 `createF33PdaOperator({env, credentialFile, reportFile})`,凭据来自已授权 TEST 环境;仅通过正式 IAM API 创建独立洛杉矶仓作业员、授予已存在的海外 PDA 角色并验证登录/仓绑定,不改角色权限或旧账号,不操作库存。随机凭据仅写入调用方指定的仓库外环境文件,报告建议保存 `reports/fba-remaining-nodes-20261004/pda-operator/`,不包含密码或 Token;已有文件、请求未知结果或首次强制改密均停止,禁止自动重造。退出自有管理员/PDA会话,账号保留供页面验收,报告给出精确停用接口,清理由总控在验收结束后执行。 +`fba-remaining-nodes-acceptance-pda-operator.mjs` 是 F33 海外 PDA 的一次性 TEST 账号准备助手,导入不运行。显式调用 `createF33PdaOperator({env, credentialFile, reportFile})`,凭据来自已授权 TEST 环境;从显式 `CHONGCHONG_ACCEPTANCE_PDA_ACTORS` 复用已有洛杉矶仓作业员,只读核对成员、启用角色和仓绑定,再验证登录,不改角色权限或旧账号,不操作库存。已配置凭据仅写入调用方指定的仓库外环境文件,报告建议保存 `reports/fba-remaining-nodes-20261004/pda-operator/`,不包含密码或 Token;已有文件、请求未知结果或首次强制改密均停止,禁止自动重造。退出自有管理员/PDA会话,固定账号保持原状态供页面验收,报告只交接本轮会话退出结果。 `fleet-same-route-cost-test-env.mjs` 是同路线自有/外接实运成本分摊的串行TEST入口,默认`--plan`零网络;执行参数为`--execute --environment TEST --run-id <新批次> --warehouse-id <实际海外仓ID> --destination-warehouse-id `,凭据仅通过显式TEST/Jenkins环境文件及`FBA_CORE_JENKINS_AGENT`提供。两新客户普通来货/PDA实收/客户确认各一托一箱,共用一张原生排车;真实外接预订、来货和运营确认实收后加到原主单,三托统一目的站,零扫码退回后重新两审、按各客户实拣、实际装载、平台逐组安排,再以当前版本整车发车。登记USD1.00合成成本,以不可变DEPART实测托位核对两条业务分摊:OWN按本张排车单汇总两实托,EXTERNAL按真实booking业务号计外接实托,两行共用同一dispatchNo。实际容量2:1时为0.67/0.33,非单位容量按实际比例及业务号顺序分配最大余数,不按托盘个数或预设比例替代。保留每客户来源、库存/SHIP、原币分币守恒、成本创建重放与唯一历史,逐派送单去重签收、正式结案并分别释放车辆/司机。不写业务SQL、不部署、不操作财务付款。报告沿`reports/fleet-cross-<新批次>/`,增加`same-route-source.json`和`createdResources.fleetSameRouteCost`;显式SAME_ROUTE_COST校验域要求本轮marker、排车、车次及原proof一致,不能借跨客户或容量池结果结案。失败保留原请求/样本、停止且不自动重跑,缺实测先阻断,finally停用自有PDA并退出会话。`fleet-same-route-cost-test-env.test.mjs`验证错误来源/目的地/冻结依据/分币及零网络计划;真实HTTP与页面结论分别记录。 -`fleet-booking-quotes-test-env.mjs` 是外接预订选用销售报价的独立 TEST HTTP 验收入口,使用显式 `node --env-file=<用户指定目录的test-acceptance.env>` 和 `--ready-commit=a6fd0ba3`,执行前须由总控核对实际部署版本与健康。只读公共仓城市/时区,创建自身全新隔离客户、销售报价及未分配资源的车次/询价草稿;验证候选日期/币种/计价、零价与缺价、手填价格、陈旧版本拒绝、刷新重选及来源快照,不创建库存、不发车、不证明承运成功或实收完成。请求前持久留证、敏感字段脱敏、未知结果停止且禁止自动重跑,独立登录并退出;保留自己的计划/报价/草稿,不删除资源。当前固定R2标识,独占报告 `reports/fleet-booking-quotes-live-20261003-r2/`。离线合同 `fleet-booking-quotes-test-env-contract.test.mjs`,运行前仍须总控放行对应健康版本。 +`fleet-booking-quotes-test-env.mjs` 是外接预订选用销售报价的独立 TEST HTTP 验收入口,使用显式 `node --env-file=<私有配置目录/test/acceptance.env>` 和 `--ready-commit=a6fd0ba3`,执行前须由总控核对实际部署版本与健康。只读公共仓城市/时区,创建自身全新隔离客户、销售报价及未分配资源的车次/询价草稿;验证候选日期/币种/计价、零价与缺价、手填价格、陈旧版本拒绝、刷新重选及来源快照,不创建库存、不发车、不证明承运成功或实收完成。请求前持久留证、敏感字段脱敏、未知结果停止且禁止自动重跑,独立登录并退出;保留自己的计划/报价/草稿,不删除资源。当前固定R2标识,独占报告 `reports/fleet-booking-quotes-live-20261003-r2/`。离线合同 `fleet-booking-quotes-test-env-contract.test.mjs`,运行前仍须总控放行对应健康版本。 `deploy/scripts/run-ci-maven.sh` 是 Linux Jenkins 的受限 Maven 构建入口,不部署、不访问业务数据库、不安装依赖。总控及单服务 Job 共用 `firstleg-test-maven` 锁;同一构建账号另用 `$HOME/.cache/chongchong-ci/maven.lock` 文件锁,已有本机持锁任务时失败退出,不删除锁文件或杀其他进程。入口禁止 `-T/--threads`,固定 Maven 堆768MiB、元空间256MiB、直接内存128MiB,启用根POM `ci-bounded`:单个测试JVM、堆1GiB、元空间384MiB、直接内存128MiB,保留JaCoCo迟绑定;OOM快速退出,父进程丢失检测、停止时kill、单测试JVM20分钟上限。独立于Jenkins连接的本机GNU timeout在45分钟后TERM、30秒后KILL进程组,流水线另设50分钟超时;缺少flock/timeout/mvn直接失败。构建输出及Surefire报告沿现有Jenkins归档;失败先确认原进程退出再重跑,不自动清理缓存。该预算不等于总RSS硬限额,代理本身和JVM原生内存仍需保留余量;节点执行器应设为1、保持systemd control-group清理,并在真实Linux节点验证峰值。`ci-maven-resource-contract.test.mjs` 检查双入口接线、参数拒绝/失败传播和子JVM配置,Shell替身检查不代替Linux实际锁与信号验收。 @@ -134,7 +134,7 @@ F33 分阶段助手的组合换标顺序为 `unit → unit-carton → bind`: `fleet-quotes-test-env.mjs` 是独立报价TEST验收入口:显式使用 `node --env-file=<用户指定目录的test-acceptance.env> scripts/fleet-quotes-test-env.mjs --ready-commit=a6fd0ba3`,仅在总控确认匹配服务健康后执行。使用独立登录和本轮客户/供应商/报价,不借其他任务会话;固定marker为`FLEET-QUOTE-20261003`,报告目录`reports/fleet-quote-live-20261003`独占。登录、客户前置和业务请求均由脱敏持久化日志包裹,发送前fsync原请求/幂等键与文件样本引用;结果未知停止业务,保留原样本,拒绝自动重跑/续跑/覆盖。结束退出独立会话,不执行SQL、部署或库存操作。该入口只证明报价范围,运营统计仍须本轮真实DEPART及独立预期,见模块README。 -`fleet-company-cost-test-env.mjs` 是公司车辆成本独立 TEST 验收入口,复用 Admin HTTP 客户端但不展开其他业务模块或复用其会话/样本。须在脚本前显式 `node --env-file=<用户指定目录的test-acceptance.env>`,并在总控确认服务就绪后传入 `--ready-commit=8700fc34238bc33339351c73c15f3af8de8ce517`。固定 marker 为 `FLEET-COST-20261003`,报告为 `reports/fleet-cost-live-20261003/api-report.json`;既有报告拒绝覆盖重跑。只创建本任务供应商、车辆、维修/救援、公司费用与合成付款凭证;不操作库存、不创建客户或客户费用、不修改共享汇率。客户费用池只读检查本 marker 来源未误入;不借用其他客户进行成本明细或账户查询,不保存其他客户数据。请求前落盘原内容及幂等键,失败保留证据;结束退出本任务会话、停用本任务车辆,不删除数据。真实执行仍须本轮 TEST 授权及服务就绪;离线检查见 `admin-test-env-modules/fleet-company-cost-acceptance-contract.test.mjs`。 +`fleet-company-cost-test-env.mjs` 是公司车辆成本独立 TEST 验收入口,复用 Admin HTTP 客户端但不展开其他业务模块或复用其会话/样本。须在脚本前显式 `node --env-file=<用户指定目录的test-acceptance.env>`,并在总控确认服务就绪后传入 `--ready-commit=8700fc34238bc33339351c73c15f3af8de8ce517`。固定 marker 为 `FLEET-COST-20261003`,报告为 `reports/fleet-cost-live-20261003/api-report.json`;既有报告拒绝覆盖重跑。必须通过该环境文件中的 `CHONGCHONG_ACCEPTANCE_COMPANY_COST_RESOURCES` JSON 对象配置既有 `supplierId` 与 `vehicleId`;只读核验启用的平台海外拖车供应商、海外自有在役车辆,缺配置或身份不符直接阻断,不创建或停用主档。只创建本任务维修/救援、公司费用与合成付款凭证;不操作库存、不创建客户或客户费用、不修改共享汇率。客户费用池只读检查本 marker 来源未误入;不借用其他客户进行成本明细或账户查询,不保存其他客户数据。请求前落盘原内容及幂等键,失败保留证据;结束退出本任务会话,复用供应商与车辆保持原状,不删除数据。真实执行仍须本轮 TEST 授权及服务就绪;离线检查见 `admin-test-env-modules/fleet-company-cost-acceptance-contract.test.mjs`。 `fee-void-recovery-test-env.mjs` 是费用作废确认响应丢失的 TEST 专项入口,默认 `--plan` 零网络。执行须显式传入 `--execute --environment TEST --run-id <新批次> --customer-id <测试客户ID> --control-customer-id <另一测试客户ID> --warehouse-id <海外仓ID>`,外部 TEST/Jenkins 配置及 `FBA_CORE_JENKINS_AGENT` 提供凭据与取证节点。创建一条新仓储服务应收,经提交审核和原发布确认后,对该来源版本的 VOIDED 事件注入 ACK 丢失;原事件 DEAD 后受保护重试,核对最终作废、同命令重放、另一客户费用与完整库存不变。不写业务SQL,不使用旧费用作废;故障窗口会临时重建同镜像 Lastleg,禁止并行发布或其他共享环境写入。报告在 `reports/fee-void-<新批次>/`,请求前保存意图,失败保留样本,不自动重跑或覆盖;finally 恢复原服务、停止代理并退出会话,恢复未证明则失败。`fee-void-recovery-test-env.test.mjs` 是离线编排与失败清理检查,不能代替真实环境结果。 @@ -293,9 +293,9 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜 测量审核页面准备使用 `--modules inbound-measurement --prepare-measurement-review true --output <独立报告>`,环境和凭据沿用本入口显式配置;会创建本轮订单与测量证据并提交待审,不审核通过。仅允许 TEST 单票最小依赖,不能混用其他准备模式或后续模块;报告标注 `MEASUREMENT_REVIEW_UI_FIXTURE/fullAcceptance=false`,失败保留数据,不自动重试或清理。具体样本字段见模块说明。 -`admin-test-env-acceptance.mjs --environment gray` 显式支持临时灰度环境,当前仅开放 `--modules phase5-root-fulfillment` 及自动展开的本轮客户资质 fixture。该链会通过正式 HTTP 新建隔离客户、两箱根订单及分柜子来源,推进国内测量、装柜、报关、人工船节点、海外收货、排车、出库和签收;不是默认全量验收。默认全量中包含 TEST 专用故障注入和未经灰度副作用核对的模块,因此 gray 拒绝省略模块、附加模块、批量订单和卸柜故障开关,也不会创建 TEST fault executor。`--f40-tax-mode TAX_REFUND` 覆盖退税文件确认链;真实邮件和供应商自动查询不在此闭环的完成声明中。 +`admin-test-env-acceptance.mjs --environment gray` 显式支持临时灰度环境,当前仅开放 `--modules phase5-root-fulfillment` 及自动展开的本轮客户资质 fixture。该链通过正式 HTTP 复用指定既有 ACTIVE 客户,创建两箱根订单及分柜子来源,推进国内测量、装柜、报关、人工船节点、海外收货、排车、出库和签收;不是默认全量验收。默认全量中包含 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。 +明确的仓库外环境文件须配置 `ADMIN_GRAY_GATEWAY`(统一 HTTPS 源站,不带 `/api`)、`ADMIN_GRAY_USERNAME`、`ADMIN_GRAY_PASSWORD`、`ADMIN_GRAY_PDA_USERNAME`、`ADMIN_GRAY_PDA_PASSWORD`、`ADMIN_GRAY_FIXED_CUSTOMER_ID`、`MERCHANT_GRAY_USERNAME/PASSWORD`;固定客户编号也可显式传 `--fixed-customer-id`。排车资源沿 `ADMIN_GRAY_FLEET_RESOURCES` 配置既有车辆司机组合。gray 不读取 TEST Gateway、客户、管理员或 PDA 凭据,客户配置缺失、归属错误或非 ACTIVE 时停止,不自动建客。数据库只读取证使用 `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 则从本轮两柜实际尾程来源解析唯一接柜仓,不按仓库列表顺序选择。 @@ -639,3 +639,7 @@ Admin 全量中的 `phase4-cross-region-events` 使用内部 `test-event-fault-e `db/warehouse-transfer-consolidated-incremental-20261015.sql`:仓储转运近期本地增量汇总,固定 `USE baiyue_lastleg`,适用已有 Lastleg 基线的本地/开发 MySQL 8。依次合入 `warehouse-transfer-outbound-incremental.sql` 和 `warehouse-transfer-carton-counting-incremental.sql`,前者已覆盖旧 `dev-warehouse-transfer-pallet-lifecycle-incremental.sql`,无须重复跑旧生命周期补丁。包含审核、打托/照片、换标、目的地/出库/派送结构、实收箱数,以及逐箱尺寸、重量、实点件数、清点幂等、差异原因、箱所属托盘和独立 SKU 申报件数。只执行 DDL/元数据查询,无业务 DML;不回填单据或修改库存。实例和凭据由操作者在数据库客户端外部提供,无内置连接信息或报告文件。需支持 DELIMITER 和 CREATE ROUTINE/ALTER/INDEX 权限,整文件串行执行,遇错停止,不使用 --force;可按缺失结构检查重复执行,不修复任意同名异构旧表。末尾 `missing_carton_and_quantity_columns=0`、`inbound_pallet_unique_index=READY`、`migration_result=WAREHOUSE_TRANSFER_RECENT_SCHEMA_READY` 表示本轮列/索引核对通过;同客户重复托盘号会报告 INDEX_BLOCKED,不擅自更改旧托盘号。仅整理文件不表示目标库已经执行;连接必须由操作者明确选中,不重放 `deploy/sql/海外区域统一增量-MySQL8.sql` 空库基线。 `db/warehouse-transfer-carton-multi-sku-incremental.sql`:固定指向 `baiyue_lastleg`,按列存在性检查新增 `ll_warehouse_transfer_carton.sku_items_json`(JSON),保存同一实收箱的多个预报 SKU 及逐项件数。可重复执行,不更改或回填既有单据;需在启用新版转运清点后端前执行。 + +固定角色/账号统一配置:TEST 使用显式环境文件中的 `ADMIN_TEST_FIXED_ACTORS` JSON 对象,GRAY 通用入口使用 `ADMIN_GRAY_FIXED_ACTORS`,GRAY 拼车和页面样本使用 `CHONGCHONG_ACCEPTANCE_FIXED_ACTORS`,不跨环境借用。槽位和字段见模块索引 `fixed-test-actors.mjs`;密码仅留在私有环境文件及明确的私有页面交接文件中。GRAY PDA 账号通过 `CHONGCHONG_ACCEPTANCE_PDA_ACTORS` 传递到专用入口(通用入口为 `ADMIN_GRAY_PDA_ACTORS`);只读核验并复用已有仓绑定账号。当前 Admin HTTP 客户端拒绝 POST 新增角色、账号、权限和供应商,所有上下文均不例外。角色权限不足、账号停用或供应商缺失均停止,不自动创建、启用、重置或扩大权限。拼车 HTTP 和成本探针完成后仅退出会话;页面固定账号保留原状态,不停用。 + +本机 `local-dispatch-transport-acceptance.mjs`、`local-transfer-pickup-acceptance.mjs` 也只复用配置的平台账号和 `LOCAL_TRANSFER_PDA_USERNAME/PASSWORD`,不再补权限、创建操作员角色或PDA账号;权限不足即停止。Client HTTP 客户端同样拒绝新增客户角色、成员与权限。 diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 6fe8976f8..c1d5c79f9 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -4,6 +4,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import process from 'node:process' +import { normalizeFixedCustomerId, requireFixedSecondaryCustomer } from './admin-test-env-modules/merchant-qualification-fixture.mjs' const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'pda-releases', 'audit', 'dashboard', 'self-operated-analytics', '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', 'sailing-calendar', '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', 'appointment-work', 'phase4-tail-order-controls', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-return-receipt', 'phase4-overseas-return-followups', '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', 'fleet-management', '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', 'manual-order-splitting-fixture', 'manual-order-splitting-verify', @@ -324,6 +325,7 @@ function usage() { --pda-username 国内装柜链必填,实际领取任务的 PDA 作业员账号 --pda-password 国内装柜链必填,PDA 作业员密码 --environment 默认 test;gray 仅允许显式 FBA 定向闭环 + --fixed-customer-id 复用既有客户编号,灰度必填或配置 ADMIN_GRAY_FIXED_CUSTOMER_ID --modules 逗号分隔,默认 ${DEFAULT_MODULES.join(',')} --f40-tax-mode F40 税制:BUY_DECLARATION 或 TAX_REFUND --customer-name 指定本轮隔离客户名称,默认自动生成 @@ -354,20 +356,39 @@ function usage() { 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。 gray 使用独立 ADMIN_GRAY_GATEWAY/USERNAME/PASSWORD、ADMIN_GRAY_PDA_USERNAME/PASSWORD、 -ADMIN_GRAY_CUSTOMER_NAME、MERCHANT_GRAY_USERNAME/PASSWORD;不回落 TEST 凭据。`) +ADMIN_GRAY_FIXED_CUSTOMER_ID、MERCHANT_GRAY_USERNAME/PASSWORD;必须复用既有客户,不回落 TEST 凭据。`) } const fleetPoolGrayOptions = new WeakSet() +/** 灰度拼车只从本次显式配置读取两个既有客户,不借用TEST凭据或自动建客。 */ +export function grayFixedCustomerOptions(env) { + const options = { fixedCustomerId: env.CHONGCHONG_ACCEPTANCE_CUSTOMER_ID, + merchantUsername: env.CHONGCHONG_MERCHANT_USERNAME, merchantPassword: env.CHONGCHONG_MERCHANT_PASSWORD, + fixedSecondaryCustomer: { customerId: env.CHONGCHONG_ACCEPTANCE_SECONDARY_CUSTOMER_ID, + username: env.CHONGCHONG_SECONDARY_MERCHANT_USERNAME, password: env.CHONGCHONG_SECONDARY_MERCHANT_PASSWORD } } + if (['fixedCustomerId', 'merchantUsername', 'merchantPassword'].some(key => typeof options[key] !== 'string' || !options[key].trim())) + throw Error('GRAY 必须配置 CHONGCHONG_ACCEPTANCE_CUSTOMER_ID 和 CHONGCHONG_MERCHANT_USERNAME/PASSWORD;禁止自动建客') + options.fixedCustomerId = normalizeFixedCustomerId(options.fixedCustomerId) + const secondary = requireFixedSecondaryCustomer(options) + options.fixedSecondaryCustomer = { ...secondary, customerId: normalizeFixedCustomerId(secondary.customerId) } + if (env.CHONGCHONG_ACCEPTANCE_FLEET_RESOURCES) options.fixedFleetResources = JSON.parse(env.CHONGCHONG_ACCEPTANCE_FLEET_RESOURCES) + if (env.CHONGCHONG_ACCEPTANCE_FIXED_ACTORS) options.fixedActors = JSON.parse(env.CHONGCHONG_ACCEPTANCE_FIXED_ACTORS) + if (env.CHONGCHONG_ACCEPTANCE_PDA_ACTORS) options.fixedPdaActors = JSON.parse(env.CHONGCHONG_ACCEPTANCE_PDA_ACTORS) + return options +} + /** 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)) + const keys = ['gateway', 'username', 'password', 'output', 'markerNamespace', 'fixedCustomerId', 'merchantUsername', 'merchantPassword'] + if (!input || Object.keys(input).some(key => ![...keys, 'fixedSecondaryCustomer', 'fixedFleetResources', 'fixedActors', 'fixedPdaActors'].includes(key)) || keys.some(key => typeof input[key] !== 'string' || !input[key].trim())) throw Error('Explicit gray pool transport configuration required') + const customerId = normalizeFixedCustomerId(input.fixedCustomerId), secondary = requireFixedSecondaryCustomer(input) 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', + const options = Object.freeze({ ...input, fixedCustomerId: customerId, + fixedSecondaryCustomer: Object.freeze({ ...secondary, customerId: normalizeFixedCustomerId(secondary.customerId) }), environment: 'gray', executionScope: 'FLEET_CAPACITY_POOL_GRAY', modules: Object.freeze([]), requestedModules: Object.freeze([]), quiet: true, waitSplitCount: 1, verifyUnloadEventRecovery: false }) fleetPoolGrayOptions.add(options) return options @@ -443,7 +464,7 @@ export function parseArguments(argv) { modules, requestedModules, stopFile: values.get('stop-file') || '', - fixedCustomerId: environment === 'test' ? process.env.ADMIN_TEST_FIXED_CUSTOMER_ID || '' : '', + fixedCustomerId: values.get('fixed-customer-id') || envValue('ADMIN_TEST_FIXED_CUSTOMER_ID', 'ADMIN_GRAY_FIXED_CUSTOMER_ID'), customerName: values.get('customer-name') || envValue('ADMIN_TEST_CUSTOMER_NAME', 'ADMIN_GRAY_CUSTOMER_NAME'), merchantUsername, merchantPassword, @@ -484,6 +505,9 @@ export function validateExecutionOptions(options) { 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.fixedCustomerId || !options.merchantUsername || !options.merchantPassword) + throw new Error('gray 必须配置 ADMIN_GRAY_FIXED_CUSTOMER_ID、MERCHANT_GRAY_USERNAME/PASSWORD;禁止自动创建客户') + normalizeFixedCustomerId(options.fixedCustomerId) } if (options.verifyUnloadEventRecovery && !options.modules.includes('phase4-overseas-pda')) { throw new Error('卸柜事件恢复验收必须包含正式 phase4-overseas-pda 收货链') @@ -655,6 +679,15 @@ export class AcceptanceRun { constructor(options) { assertGrayExecutionScope(options) this.options = { ...options } + this.fixedPdaLeases = new Set() + if (options.environment === 'gray') { + this.options.fixedCustomerId ||= process.env.ADMIN_GRAY_FIXED_CUSTOMER_ID || '' + this.options.merchantUsername ||= process.env.MERCHANT_GRAY_USERNAME || '' + this.options.merchantPassword ||= process.env.MERCHANT_GRAY_PASSWORD || '' + this.options.fixedFleetResources ??= JSON.parse(process.env.ADMIN_GRAY_FLEET_RESOURCES || '[]') + this.options.fixedActors ??= JSON.parse(process.env.ADMIN_GRAY_FIXED_ACTORS || '{}') + this.options.fixedPdaActors ??= JSON.parse(process.env.ADMIN_GRAY_PDA_ACTORS || '[]') + } if (options.environment === 'test') { this.options.fixedCustomerId ||= process.env.ADMIN_TEST_FIXED_CUSTOMER_ID || '' this.options.merchantUsername ||= process.env.MERCHANT_TEST_USERNAME || '' @@ -662,6 +695,7 @@ export class AcceptanceRun { this.options.fixedPdaActors ??= JSON.parse(process.env.ADMIN_TEST_OVERSEAS_PDA_ACTORS || '[]') this.options.fixedDomesticDenyActor ??= JSON.parse(process.env.ADMIN_TEST_DOMESTIC_DENY_ACTOR || 'null') this.options.fixedFleetResources ??= JSON.parse(process.env.ADMIN_TEST_FLEET_RESOURCES || '[]') + this.options.fixedActors ??= JSON.parse(process.env.ADMIN_TEST_FIXED_ACTORS || '{}') if (!this.options.fixedSecondaryCustomer && process.env.ADMIN_TEST_SECONDARY_CUSTOMER_ID) { this.options.fixedSecondaryCustomer = { customerId: process.env.ADMIN_TEST_SECONDARY_CUSTOMER_ID, username: process.env.ADMIN_TEST_SECONDARY_MERCHANT_USERNAME || '', password: process.env.ADMIN_TEST_SECONDARY_MERCHANT_PASSWORD || '' } @@ -686,12 +720,14 @@ export class AcceptanceRun { } assertCustomerWriteAllowed(method, path) { - if (this.options.environment !== 'test') return const pathname = new URL(path, 'https://acceptance.invalid').pathname.replace(/\/$/, '') + if (String(method).toUpperCase() === 'POST' + && /^\/api\/platform\/v1\/(iam\/(roles|users|permissions)|suppliers)$/.test(pathname)) + throw new Error('固定资源验收禁止新增角色、账号、权限或供应商;请配置并复用既有资源') if (String(method).toUpperCase() === 'POST' && (pathname === '/api/platform/v1/iam/tenants' || pathname === '/api/auth/register' || /^\/api\/platform\/v1\/iam\/tenants\/[^/]+\/(archive|submit-review)$/.test(pathname))) { - throw new Error('TEST 固定客户模式禁止创建、归档客户或重提资质;请复用已配置客户') + throw new Error(`${String(this.options.environment || 'acceptance').toUpperCase()} 固定客户模式禁止创建、归档客户或重提资质;请复用已配置客户`) } } @@ -1888,12 +1924,13 @@ export class AcceptanceRun { } async run() { - if (this.options.environment === 'test' && !this.options.fixedCustomerId) { - throw new Error('TEST 验收必须配置 ADMIN_TEST_FIXED_CUSTOMER_ID;禁止自动创建测试客户') + if (['test', 'gray'].includes(this.options.environment) && !this.options.fixedCustomerId) { + throw new Error(`${this.options.environment.toUpperCase()} 验收必须配置固定客户;禁止自动创建客户`) } if (this.options.merchantUsername && this.options.merchantPassword) { - process.env.MERCHANT_TEST_USERNAME = this.options.merchantUsername - process.env.MERCHANT_TEST_PASSWORD = this.options.merchantPassword + const prefix = this.options.environment === 'gray' ? 'MERCHANT_GRAY' : 'MERCHANT_TEST' + process.env[`${prefix}_USERNAME`] = this.options.merchantUsername + process.env[`${prefix}_PASSWORD`] = this.options.merchantPassword } if (this.options.waitSplitCount > 1 || this.options.waitSplitPackageCount > 0 || this.options.orderProfileMode === 'randomized' || this.options.routeMode === 'random' @@ -2053,6 +2090,7 @@ export class AcceptanceRun { createdResources: this.createdResources, }) const forbiddenValues = [this.options.password, this.options.merchantPassword, this.options.fixedSecondaryCustomer?.password, + ...Object.values(this.options.fixedActors || {}).map(actor => actor?.password), this.token, this.refreshToken] .filter((value) => typeof value === 'string' && value.length >= 8) const sensitivePatterns = [ diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index ec8ed97e4..ca6a893a8 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -72,7 +72,7 @@ 固定客户编排也将 `customers`、`supplemental-boundaries` 明确记为跳过,不运行建客或边界仓库/库位生命周期。Finance 供应商按启用状态与费用类别查询复用,详情变化或无候选时阻断,不补建;海外入仓费用只允许关联本轮 PDA `generatedPlanIds` 内且客户、批次均匹配的计划,禁止回退历史计划。混合报关柜缺第二固定客户时在准备订单和装柜前阻断。 -固定客户模式下,默认编排排除 `system`、`master-data`、`notifications`、`pricing`、`phase4-overseas-master-data` 的生命周期写入,报告使用 skipped 和 coverage gap,不计模块通过。海外仓模块复用现有仓及只读资源,缺仓直接阻断;不自动创建库位/托盘。业务订单与履约证据仍按批次创建,不能将历史已完成单据当作本轮新证据。其他业务模块内嵌的权限探针账号及专用业务资源仍可能创建,当前模式不代表零新增数据。 +固定客户模式下,默认编排排除 `system`、`master-data`、`notifications`、`pricing`、`phase4-overseas-master-data` 的生命周期写入,报告使用 skipped 和 coverage gap,不计模块通过。海外仓模块复用现有仓及只读资源,缺仓直接阻断;不自动创建库位/托盘。业务订单与履约证据仍按批次创建,不能将历史已完成单据当作本轮新证据。业务内权限探针必须显式配置固定角色和账号,缺配置即阻断;不允许新增角色、账号、权限或供应商。业务订单与实物履约证据仍会新增。 Admin TEST 入口必须通过显式环境文件配置 `ADMIN_TEST_FIXED_CUSTOMER_ID`(`TEN` 编号或数字 ID)及该客户的 `MERCHANT_TEST_USERNAME`、`MERCHANT_TEST_PASSWORD`。每次先核验账号归属、ACTIVE 状态和尺寸初始化,再复用客户造业务数据;校验失败停止后续模块,不自动建客、重提资质或重置密码。客户创建/归档生命周期明确排除,海外仓储费模块只读核验既有规则,不覆盖固定客户配置。需要第二客户的隔离用例在单客户模式下阻断,不用同一客户替代跨客户验证。该限制不代表这些生命周期或跨客户场景验收通过,也不清理历史客户。 @@ -122,13 +122,13 @@ OCR模块必须通过`ADMIN_TEST_OCR_IMAGE`显式指定本轮图片,不依赖 `fleet-trip-cost-acceptance.mjs` 是同轮实际发车后的车次直接成本内部验收助手,由车辆专项入口显式调用 `verifyFleetTripCosts(context, module, tripProof, inputs)`,不独立造车次、不修改IAM。调用方须提供真实DEPART、独立确认的自有/外接逐票托盘及托位预期、仅列表权限和无车次权限两个独立会话,并负责退出;缺前置在成本写入前停止。写入本车次两条成本并更正,最终CNY100.03、USD13.07分别按整数分和真实托位核验分摊,检查重放、历史、权限拒绝后事实不变;公司维修成本不并入。请求意图和响应通过调用方checkpoint脱敏落盘,失败保留原样本/键,不自动重跑、删除或清理。`fleet-trip-cost-acceptance-contract.test.mjs` 为离线合同,不证明真实HTTP已通过。 -`fleet-company-cost-acceptance.mjs` 是公司车辆成本内部验收模块,由 `../fleet-company-cost-test-env.mjs` 使用独立 marker 和 TEST 会话调用,不依赖或借用 `financeFullFixtures`。自行创建专用供应商、车辆及维修/救援记录,覆盖 CNY/USD 来源匹配、未付款作废重建、凭证字节、部分/全额付款重放、超付与已付改写拒绝;客户隔离只读检索本 marker 费用未进入客户费用池,不借用其他客户查询明细或账户,避免将其他并行任务的正常变化误判为污染。要求当月已有启用 USD 汇率,缺失则停止,不修改共享汇率。报告和清理边界见脚本根索引;`fleet-company-cost-acceptance-contract.test.mjs` 为离线替身测试,不调用 TEST。 +`fleet-company-cost-acceptance.mjs` 是公司车辆成本内部验收模块,由 `../fleet-company-cost-test-env.mjs` 使用独立 marker 和 TEST 会话调用,不依赖或借用 `financeFullFixtures`。从 `context.options.fixedCompanyCostResources` 读取既有 `supplierId`、`vehicleId`,只读核验供应商平台归属、启用状态及海外拖车类别,车辆须为海外自有在役;缺配置直接阻断,不创建、编辑或停用主档。维修/救援记录仍按本轮创建,覆盖 CNY/USD 来源匹配、未付款作废重建、凭证字节、部分/全额付款重放、超付与已付改写拒绝;客户隔离只读检索本 marker 费用未进入客户费用池,不借用其他客户查询明细或账户,避免将其他并行任务的正常变化误判为污染。要求当月已有启用 USD 汇率,缺失则停止,不修改共享汇率。报告和清理边界见脚本根索引;`fleet-company-cost-acceptance-contract.test.mjs` 为离线替身测试,不调用 TEST。 `fleet-driver-qualification.mjs` 是司机证照专项可选模块,从 `admin-test-env-acceptance.mjs --modules fleet-driver-qualification --output <报告路径>` 执行,凭据由调用者显式 `node --env-file=<授权TEST环境文件>` 提供。经正式API创建两名本轮受控司机、上传非真实证照PNG,验证缺件/审核/打回/重提、30天内提醒、任务结束跨到期日禁止、非必需资质不阻断、关键资料及附件变化重审、旧版本拒绝、附件跨司机归属和历史下载。仅操作本轮司机及文件,不改排车/库存/财务、不执行SQL或部署;成功失败均按最新版本停用本轮司机,保留样本和报告 `createdResources.fleetDriverQualification`。失败重跑使用新样本,原失败证据不改写;该专项不代替整车实际发车链和浏览器验收。 `fleet-vehicle-care.mjs` 是可选车辆运维专项,通过正式入口 `admin-test-env-acceptance.mjs --modules fleet-vehicle-care --output <报告路径>` 执行,不单独运行。仅用于获授权 TEST,凭据由调用者显式 `node --env-file=<指定环境文件>` 提供;复用总入口 gateway/username/password 参数。经正式 API 创建本轮专用车辆,再复用 `vehicle-care.mjs` 验证六类记录、版本、更正历史、照片幂等及跨车隔离;不依赖订单、库存、报价或历史样本。写入仅涉及测试车辆、运维记录和图片;成功/失败均保留报告 `createdResources.fleetVehicleCare` 和样本,finally 读取最新版本停用本轮主车辆,内部助手停用隔离车辆,不删除记录或文件。失败重跑创建新样本,不修改原失败证据;不执行 SQL 或部署。该场景不证明费用已经入账或付款。 -`fleet-cross-customer-fixture.mjs` 导出 `createFleetCrossCustomerFixture`,由上述专用 TEST 入口显式调用。默认只返回准备契约;执行时重新核对指定实际收货仓和FBA目的仓,通过正式客户资质、普通转运来货、PDA一托一箱测量/绑定/实收及客户确认生成两套独立本轮货物,再以真实来源一次 Create 建立同一原生排车主单、自动唯一 Fleet 资源记录并完成两级审核,停在 LOADING、零扫码、未生成出库和派送单。每条明细保留其真实客户、来货码、物理托盘和库位,主单不挂客户。客户上下文独立且不覆盖外层客户环境,退出本函数创建的客户会话,PDA启停归外层入口;无外接预订循环依赖、业务SQL、部署或自动恢复。`fleet-cross-customer-fixture-contract.test.mjs` 只证明生成编排及拒绝边界,真实运行另行留证。 +`fleet-cross-customer-fixture.mjs` 导出 `createFleetCrossCustomerFixture`,由上述专用 TEST 入口显式调用。默认只返回准备契约;TEST/GRAY 执行时先要求两个不同的固定客户及其凭据,通过实际登录核验 ACTIVE 归属和尺寸初始化;不创建客户。重新核对指定实际收货仓和FBA目的仓,经普通转运来货、PDA一托一箱测量/绑定/实收及客户确认生成两套独立本轮货物。固定客户允许已有历史库存,完整原始快照逐行验证客户/仓归属后,仅选择本轮来货的余额、位置与流水;再以真实来源一次 Create 建立同一原生排车主单、自动唯一 Fleet 资源记录并完成两级审核,停在 LOADING、零扫码、未生成出库和派送单。每条明细保留其真实客户、来货码、物理托盘和库位,主单不挂客户。客户上下文独立且不覆盖外层客户环境,退出本函数创建的客户会话,PDA启停归外层入口;无外接预订循环依赖、业务SQL、部署或自动恢复。`fleet-cross-customer-fixture-contract.test.mjs` 只证明生成编排及拒绝边界,真实运行另行留证。 `fleet-cross-customer-acceptance.mjs` 是跨客户同一原生排车主单的内部后半链验收函数,不是独立入口。默认只返回 fixture 契约、零网络;显式 `execute:true` 需要同批次两个独立客户、同仓各一托一箱已实收、同一主单已通过两级审核且未拣货,以及调用方提供的完整库存只读取证器。按各明细实际客户扫码,验证一个客户未核平时整车装车/发车拒绝及全部快照不变;全部实拣核平后按实际测量确认装车,在同一次命令自动生成客户目的地单据并发车;按详情 documents 核对真实单据,原键重放和分别签收。实托测量缺失明确阻断,不以计费/计划常量替代。前置门禁与事务中途回滚分别记录;默认后者为 `NOT_TESTED`,只有显式注入`verifyDepartureRollback`且返回精确本轮验证、完整一致性快照恢复相同后才记VERIFIED。专用`../fleet-trip-rollback-test-env.mjs`负责租约/脏读观察与持久化,普通入口不注入故障;钩子异常、未验证或快照漂移必须停止在正常发车前。失败保留当前样本与原请求,不自动续跑、补造或清理。对应合同 `fleet-cross-customer-acceptance-contract.test.mjs` 仅证明编排与反例断言,不能代替真实运行。临时账号生命周期、最小来货生成及报告保存由外层显式编排负责。 @@ -198,7 +198,7 @@ OCR模块必须通过`ADMIN_TEST_OCR_IMAGE`显式指定本轮图片,不依赖 退货预报创建后为 `PENDING_REVIEW`。签收前必须通过客户详情确认归属,再经平台 `/return-orders/{returnNo}/review` 携带 `X-Customer-Id` 审核,客户回查 `PENDING_RECEIPT` 且实收为零;审核失败立即停止签收。`return-review-contract.test.mjs` 覆盖归属拒绝、审核权限失败和回查未收敛。主签收场景按单 SKU 分甲3件、乙2件两批,使用正式 PDA 退货托盘生成接口返回的 `palletCode`,不复用国内基础托盘;每批对应一个计费批次。按 `PALLET` 提交每 SKU 箱数和托盘长宽高、重量,总箱数由服务端汇总;主场景验证不上传可选照片仍能完成。边界场景继续验证合成 PNG 上传及合法多 SKU 同托签收,不把图片称为真实现场照片。边界扩展与换标后续仍需按新协议独立验证,主签收通过不能证明全部退货链通过。主链合箱前通过PDA箱内清点接口确认本轮两只实收箱的SKU数量和箱规;签收及部分合箱成功后,换新键提交旧数量快照必须被拒绝,且立即回查订单、批次、箱和SKU均未变化。 -`return-boundary-scenarios.mjs` 是退货专项自动调用的内部扩展,不单独注册入口:本轮新建第二隔离客户,复用两个有效海外仓,覆盖跨客户单据/箱/标签/列表隔离、跨仓 PDA 隔离、两人争抢最后一件的签收及合箱、同键并发签收与成功后重试、多单同 SKU 全量合箱和实际来源追溯、耗尽箱清零、多单 SKU 候选和输入边界。异常写入逐次前后回查订单、签收批次、箱及 SKU;同键重试只模拟响应丢失后的客户端行为,不代表网络故障注入。每组并发严格为两个请求,不启动批量 worker;缺少第二仓或操作员属于失败,不能跳过后宣称通过。finally 停用新增 PDA,保留测试客户和业务记录;不触发部署、SQL、换标、出库或二次合箱。真实运行须明确授权目标环境及此小规模并发边界。`merchant-qualification-fixture.mjs` 的内部参数 `publishEnvironment: false` 只将第二客户凭据留在独立内存上下文,不覆盖主客户环境凭据,默认主 fixture 行为不变。 +`return-boundary-scenarios.mjs` 是退货专项自动调用的内部扩展,不单独注册入口:复用已配置的第二个固定客户及两个有效海外仓,覆盖跨客户单据/箱/标签/列表隔离、跨仓 PDA 隔离、两人争抢最后一件的签收及合箱、同键并发签收与成功后重试、多单同 SKU 全量合箱和实际来源追溯、耗尽箱清零、多单 SKU 候选和输入边界。异常写入逐次前后回查订单、签收批次、箱及 SKU;同键重试只模拟响应丢失后的客户端行为,不代表网络故障注入。每组并发严格为两个请求,不启动批量 worker;缺少第二仓或操作员属于失败,不能跳过后宣称通过。finally 退出复用 PDA 会话,仅停用本轮新增 PDA,保留业务记录;不触发部署、SQL、换标、出库或二次合箱。真实运行须明确授权目标环境及此小规模并发边界。`merchant-qualification-fixture.mjs` 的内部参数 `publishEnvironment: false` 只将第二客户凭据留在独立内存上下文,不覆盖主客户环境凭据,默认主 fixture 行为不变。 扩展离线检查:`node --test scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs scripts/script-documentation-contract.test.mjs`。这些校验验证脚本编排和断言,不代替真实 HTTP 验收;运行报告中的每个失败均保留阻断。 @@ -531,7 +531,7 @@ node --test scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-co ## 清库后的商户下单前置 -`merchant-qualification-fixture.mjs` 是默认全量验收的首个模块。它通过平台正式接口创建本轮隔离客户,经 File 上传真实营业执照和法人身份证文件,再走 IAM 提交审核和平台审核通过;审核完成后把本轮动态加载模块使用的商户账号切换到该客户。 +`merchant-qualification-fixture.mjs` 是默认全量验收的首个模块。它只登录已配置的既有固定客户,核对账号实际归属、ACTIVE 状态及默认尺寸初始化 READY,再供本轮业务模块复用。不包含建客、上传资质或审核代码;任何调用上下文缺少固定客户都在请求前阻断,不按环境回退造客。 依赖商户下单资格的模块不要单独运行。定向排查时至少保留前置模块,例如: @@ -587,3 +587,7 @@ Client 全量为单文件,新增的客户账单、海外仓自助、跨境采 - 合同数量与失败清单以同一源码快照的实际报告为准,不在模块索引维护固定失败数量;当前已验/未验范围统一查外层部署契约,历史报告不覆盖最新源码。 采购费用不再使用通用费用草稿、审核和发布链。`phase4-overseas-finance-cross-region.mjs` 对采购改为验证通用创建被拒绝,其他四类费用继续验证既有投递链;采购成功来源由完整样品流程核对实扣与来源关联。 + +`fixed-test-actors.mjs` 是固定测试角色与账号复用助手,不是独立入口。配置 `fixedActors` 按稳定槽位提供 `membershipId`、`username`、`password`、`roleId`、`roleCode`,ID 必须为十进制字符串。成员按权威目录分页并逐行匹配成员 ID 和登录名,不把登录名传作关键词(该接口的关键词只筛姓名、手机号、邮箱和成员号);HTTP 拼车使用 `pool.sales.0/1`、`pool.dispatch.0/1`、`pool.fulfillment.0`,页面样本使用 `pool-ui.SALES_A`、`pool-ui.SALES_B`、`pool-ui.MANAGER`,车队权限探针使用 `fleet-cost.reader/denied`。只读核对 ACTIVE 角色、平台成员及唯一角色绑定,登录后逐项核对实际权限;隔离槽位必须使用不同账号,角色可以按相同权限复用。缺项或权限漂移阻断,不新增或修改角色、账号、权限。结束只退出本轮会话,固定身份不停用,订单继续保留真实创建人。`fixed-test-actors-contract.test.mjs` 覆盖重复运行、权限越界、身份漂移和退出失败。Finance 的 `loadSupplier` 在所有上下文都只查询和复用已启用、类别及船司/拖车范围匹配的供应商,缺失不再创建替代主档。 + +固定账号目录核对统一按成员 ID 和登录名读取分页结果,PDA及国内权限拒绝探针同样不把登录名作为服务端关键词。`restoreFixedPicker` 只从当前固定池恢复检查点记录的同一成员及仓绑定;不按批次推算用户名或口令,不启用、重置或新建账号。Admin 根上下文共享 PDA 会话占用集合,子流程的租用可由父流程正常退出释放,避免同一固定操作员被子流程重复占用或遗留登录会话。 diff --git a/scripts/admin-test-env-modules/booking-supplier-scope-contract.test.mjs b/scripts/admin-test-env-modules/booking-supplier-scope-contract.test.mjs index 6ac1ed564..fc6d6b387 100644 --- a/scripts/admin-test-env-modules/booking-supplier-scope-contract.test.mjs +++ b/scripts/admin-test-env-modules/booking-supplier-scope-contract.test.mjs @@ -36,12 +36,12 @@ test('booking supplier fixtures keep ACTIVE and BOOKING but never invent a geogr assert.match(supplierFixture, /status:'ACTIVE'/) assert.doesNotMatch(supplierFixture, /scope\s*:/) - const supplierBody = financeFixture.slice(financeFixture.indexOf('export function buildSupplierBody'), - financeFixture.indexOf('async function createOrder')) - assert.match(supplierBody, /category,/) - assert.match(supplierBody, /status: 'ACTIVE'/) - assert.match(supplierBody, /truckingScope: category === 'TRUCKING' \? 'DOMESTIC' : null/) - assert.doesNotMatch(supplierBody, /bookingScope|category === 'BOOKING' \? 'DOMESTIC'/) + const selector = financeFixture.slice(financeFixture.indexOf('export async function loadSupplier'), financeFixture.indexOf('async function uploadVoucher')) + assert.match(selector, /status === 'ACTIVE'/) + assert.match(selector, /item.category === category/) + assert.match(selector, /item.truckingScope === truckingScope/) + assert.doesNotMatch(selector, /bookingScope/) + }) test('Warehouse supplier contract filters by active category without scope in query, save or snapshot', () => { diff --git a/scripts/admin-test-env-modules/domestic-container.mjs b/scripts/admin-test-env-modules/domestic-container.mjs index 6e97502c3..c254a8d31 100644 --- a/scripts/admin-test-env-modules/domestic-container.mjs +++ b/scripts/admin-test-env-modules/domestic-container.mjs @@ -235,7 +235,7 @@ export async function verifyPermissionDenial(context, marker, warehouseId, conta } let user for (let page = 1; ; page++) { - const response = await context.request('GET', `/api/platform/v1/iam/users?page=${page}&size=100&keyword=${encodeURIComponent(actor.username)}`) + const response = await context.request('GET', `/api/platform/v1/iam/users?page=${page}&size=100`) const result = context.apiSuccess(MODULE, '查询固定权限拒绝账号', response) user = result?.records?.find(item => id(item.membershipId) === id(actor.membershipId) && item.username === actor.username) if (user || !result?.records?.length || page * Number(result.size || 100) >= Number(result.total || 0)) break diff --git a/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs b/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs index a0fb5ea1b..61d775e51 100644 --- a/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict' import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' -import { buildContainerCostBody, buildSupplierBody, normalizeFinanceFixtureSources, run, +import { buildContainerCostBody, normalizeFinanceFixtureSources, run, supplierCategoryForFeeItem, loadSupplier, ensureCustomerChargeRule } from './finance-full-fixtures.mjs' for (const mode of ['single', 'default', 'missing', 'wrong mode', 'foreign customer', 'ambiguous', 'two defaults']) { @@ -115,15 +115,11 @@ test('PAYABLE fee selection uses only the explicit nullable supplierCategory fie assert.match(source, /payableSupplierCategory && source\.supplierCategory !== payableSupplierCategory/) }) -test('Finance PAYABLE fixture creates an isolated ACTIVE supplier through the formal API', () => { - const body = buildSupplierBody('ADMIN-API-20260815-FINANCE', 'BOOKING') - assert.equal(body.category, 'BOOKING') - assert.deepEqual(body.carrierCodes, ['MATSON']) - assert.equal(Object.hasOwn(body, 'scope'), false) - assert.equal(body.status, 'ACTIVE') - assert.match(body.name, /^财务验收供应商-/) - assert.match(source, /POST', '\/api\/platform\/v1\/suppliers\?tenantId=0'/) - assert.match(source, /detail\?\.status === 'ACTIVE' && \(!category \|\| detail\?\.category === category\)/) +test('Finance supplier helper always reuses existing masters and has no create fallback', () => { + const helper = source.slice(source.indexOf('export async function loadSupplier'), source.indexOf('async function uploadVoucher')) + assert.ok(!helper.includes("request(context, 'POST'")) + assert.ok(helper.includes('禁止自动创建')) + assert.ok(!helper.includes('fixedCustomerId')) }) test('Finance measurement can reuse real pallets from the selected pricing warehouse', () => { diff --git a/scripts/admin-test-env-modules/finance-full-fixtures.mjs b/scripts/admin-test-env-modules/finance-full-fixtures.mjs index 616a18e6c..19641f7f4 100644 --- a/scripts/admin-test-env-modules/finance-full-fixtures.mjs +++ b/scripts/admin-test-env-modules/finance-full-fixtures.mjs @@ -32,20 +32,6 @@ export function supplierCategoryForFeeItem(item) { ? item.supplierCategory : null } -export function buildSupplierBody(marker, category) { - const suffix = String(marker || '').replace(/[^0-9A-Za-z]/g, '').slice(-24) - return { - name: `财务验收供应商-${suffix}`, - category, - truckingScope: category === 'TRUCKING' ? 'DOMESTIC' : null, - ...(category === 'BOOKING' ? { carrierCodes: ['MATSON'] } : {}), - region: '宁波', legalRepresentative: '财务验收法人', contactName: '财务验收联系人', - phone: '13800000000', email: `finance-supplier-${suffix || 'fixture'}@example.com`, - address: '财务验收地址', businessLicenseNo: `FIN${suffix}`.slice(0, 64), - licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: marker, - } -} - async function createOrder(context, suffix, pricingProfile = null) { if (context.financeFixtureHooks?.createOrder) return context.financeFixtureHooks.createOrder(context, suffix, pricingProfile) const originalMarker = context.marker @@ -260,41 +246,25 @@ async function loadPublishedAutomaticFee(context, resource) { export async function loadSupplier(context, category, { carrierCode, truckingScope } = {}) { if (context.financeFixtureHooks?.loadSupplier) return context.financeFixtureHooks.loadSupplier(context, category) - if (context.options?.fixedCustomerId) { - const matches = item => id(item?.id) && item.status === 'ACTIVE' && (!category || item.category === category) - && (!carrierCode || item.carrierCodes?.includes(carrierCode)) && (!truckingScope || item.truckingScope === truckingScope) - for (let page = 1; ; page += 1) { - const response = await request(context, 'GET', `/api/platform/v1/suppliers?tenantId=0&status=ACTIVE&page=${page}&size=100`) - const data = context.apiSuccess(MODULE, '读取可复用的现有供应商', response, '', OPTIONS) - if (!data) return null - const records = rows(data) - const candidate = records.find(matches) - if (candidate) { - const detailResponse = await request(context, 'GET', `/api/platform/v1/suppliers/${encodeURIComponent(candidate.id)}?tenantId=0`) - const detail = context.apiSuccess(MODULE, '核对复用供应商状态与类别', detailResponse, '', OPTIONS) - if (id(detail?.id) === id(candidate.id) && matches(detail)) return detail - blocker(context, '复用供应商已变化', '候选详情不匹配,不创建替代供应商') - return null - } - if (!records.length || page * Number(data.size || 100) >= Number(data.total ?? records.length)) break + const matches = item => id(item?.id) && item.status === 'ACTIVE' && (!category || item.category === category) + && (!carrierCode || item.carrierCodes?.includes(carrierCode)) && (!truckingScope || item.truckingScope === truckingScope) + for (let page = 1; ; page += 1) { + const response = await request(context, 'GET', `/api/platform/v1/suppliers?tenantId=0&status=ACTIVE&page=${page}&size=100`) + const data = context.apiSuccess(MODULE, '读取可复用的现有供应商', response, '', OPTIONS) + if (!data) return null + const records = rows(data) + const candidate = records.find(matches) + if (candidate) { + const detailResponse = await request(context, 'GET', `/api/platform/v1/suppliers/${encodeURIComponent(candidate.id)}?tenantId=0`) + const detail = context.apiSuccess(MODULE, '核对复用供应商状态与类别', detailResponse, '', OPTIONS) + if (id(detail?.id) === id(candidate.id) && matches(detail)) return detail + blocker(context, '复用供应商已变化', '候选详情不匹配,不创建替代供应商') + return null } - blocker(context, '缺少可复用供应商', `需要 ACTIVE ${category || '任意类别'} 供应商;禁止自动创建`) - return null + if (!records.length || page * Number(data.size || 100) >= Number(data.total ?? records.length)) break } - const effectiveCategory = category || 'BOOKING' - const body = buildSupplierBody(context.marker, effectiveCategory) - const response = await request(context, 'POST', '/api/platform/v1/suppliers?tenantId=0', body) - const supplierId = id(context.apiSuccess(MODULE, '创建本轮 Finance 隔离供应商', response, - `category=${effectiveCategory}`, OPTIONS)) - if (!supplierId) return null - const detailResponse = await context.request('GET', `/api/platform/v1/suppliers/${encodeURIComponent(supplierId)}?tenantId=0`) - const detail = context.apiSuccess(MODULE, '回读本轮 Finance 隔离供应商', detailResponse, - `supplierId=${supplierId}`, OPTIONS) - context.assert(MODULE, 'Finance 供应商为 ACTIVE 且类别匹配费用项目', detailResponse, - id(detail?.id) === supplierId && detail?.status === 'ACTIVE' && (!category || detail?.category === category) - && detail?.name === body.name, - `supplierId=${supplierId}, status=${detail?.status || 'missing'}, category=${detail?.category || 'missing'}`, OPTIONS) - return detail?.status === 'ACTIVE' && (!category || detail?.category === category) ? detail : null + blocker(context, '缺少可复用供应商', `需要 ACTIVE ${category || '任意类别'} 供应商;禁止自动创建`) + return null } async function uploadVoucher(context, customerId) { diff --git a/scripts/admin-test-env-modules/fixed-test-actors-contract.test.mjs b/scripts/admin-test-env-modules/fixed-test-actors-contract.test.mjs new file mode 100644 index 000000000..275220bbe --- /dev/null +++ b/scripts/admin-test-env-modules/fixed-test-actors-contract.test.mjs @@ -0,0 +1,105 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { openFixedActor, requireFixedActors } from './fixed-test-actors.mjs' +import { AcceptanceRun } from '../admin-test-env-acceptance.mjs' +import { readFileSync } from 'node:fs' + +function fixture(fault = '') { + const calls = [], actor = { membershipId: '101', roleId: '201', roleCode: 'EXISTING_READER', username: 'existing-reader', password: 'private-fixed-password' } + const context = { token: 'admin', options: { fixedActors: { reader: actor } }, async request(method, path, body, token) { + calls.push({ method, path, body, token }) + let data + if (path.endsWith('/roles')) data = [{ id: '201', roleCode: actor.roleCode, status: fault === 'inactive role' ? 'DISABLED' : 'ACTIVE' }] + else if (path.includes('/users?')) { + const keyword = new URL(path, 'https://fixture.invalid').searchParams.get('keyword') + // The service does not search login names; its keyword filters profile fields/member numbers. + assert.equal(keyword, null) + data = { page: 1, size: 100, total: 1, records: [{ membershipId: '101', username: actor.username, + tenantId: fault === 'foreign user' ? '9' : '0', status: fault === 'inactive user' ? 'DISABLED' : 'ACTIVE', roleIds: fault === 'extra role' ? ['201', '999'] : ['201'] }] } + } + else if (path.endsWith('/login')) data = { accessToken: 'reader-session', refreshToken: 'reader-refresh' } + else if (path.endsWith('/authorization/permissions')) data = fault === 'extra permissions' ? ['read', 'write'] : ['read'] + else if (path.endsWith('/logout')) return { http: 200, body: { code: fault === 'logout fails' ? 'S0503' : '200' } } + else assert.fail(path) + return { http: 200, body: { code: '200', data } } + } } + return { context, calls, actor } +} + +test('repeated runs reuse the same configured membership and role, and logout is idempotent', async () => { + const { context, calls } = fixture() + for (let run = 0; run < 3; run++) { + const actor = await openFixedActor(context, 'reader', ['read']) + assert.equal(actor.membershipId, '101'); assert.equal(actor.roleId, '201'); assert.equal(actor.reused, true) + assert.equal(actor.password, undefined) + await actor.close(); const count = calls.length; await actor.close(); assert.equal(calls.length, count) + } + assert.ok(calls.every(call => call.method === 'GET' || ['/api/auth/login', '/api/auth/logout'].includes(call.path))) + assert.equal(calls.filter(call => call.path.endsWith('/logout')).length, 3) +}) + +for (const fault of ['inactive role', 'foreign user', 'inactive user', 'extra role', 'extra permissions']) test(`fixed actor rejects ${fault} without changing resources`, async () => { + const { context, calls } = fixture(fault) + await assert.rejects(openFixedActor(context, 'reader', ['read']), /不匹配/) + assert.equal(calls.some(call => ['PUT', 'PATCH', 'DELETE'].includes(call.method)), false) + assert.equal(calls.some(call => call.method === 'POST' && !call.path.startsWith('/api/auth/')), false) + if (fault === 'extra permissions') assert.equal(calls.at(-1).path, '/api/auth/logout') +}) + +test('missing configuration or reusing one account for two isolated actors stops before HTTP', async () => { + const { context, actor, calls } = fixture() + await assert.rejects(openFixedActor(context, 'missing', []), /禁止自动创建/) + assert.throws(() => requireFixedActors({ fixedActors: { a: actor, b: actor } }, ['a', 'b']), /不同的固定账号/) + assert.equal(calls.length, 0) +}) + +test('membership lookup finds a configured user on later pages without treating the login name as a searchable profile field', async () => { + const { context, calls } = fixture(), original = context.request + context.request = async (method, path, ...args) => { + if (path.includes('/users?')) { + const url = new URL(path, 'https://fixture.invalid') + assert.equal(url.searchParams.has('keyword'), false) + if (url.searchParams.get('page') === '1') return { http: 200, body: { code: '200', data: { + page: 1, size: 100, total: 101, records: Array.from({ length: 100 }, (_, index) => ({ membershipId: String(1000 + index), username: 'unrelated' })) } } } + const response = await original(method, path, ...args) + response.body.data.page = 2; response.body.data.total = 101 + return response + } + return original(method, path, ...args) + } + const actor = await openFixedActor(context, 'reader', ['read']) + assert.equal(actor.membershipId, '101'); await actor.close() + assert.equal(calls.some(call => call.path.includes('page=2')), true) +}) + +test('logout failures remain visible and can be checked again without creating accounts', async () => { + const { context, calls } = fixture('logout fails') + const actor = await openFixedActor(context, 'reader', ['read']) + await assert.rejects(actor.close(), /退出未确认/) + assert.equal(calls.filter(call => call.path.endsWith('/logout')).length, 1) +}) + +test('all Admin HTTP transports reject creation of roles, users, permissions and suppliers regardless of environment', async () => { + for (const environment of [undefined, 'test', 'dev', 'prod']) { + const context = new AcceptanceRun({ environment }) + for (const path of ['/api/platform/v1/iam/roles', '/api/platform/v1/iam/users', '/api/platform/v1/iam/permissions', '/api/platform/v1/suppliers?tenantId=0']) + for (const entry of ['request', 'requestWithBody', 'requestMultipart']) + await assert.rejects(context[entry]('POST', path, new FormData()), /禁止新增角色/) + } +}) + +test('local transport and F33 handoff have no master provisioning paths', () => { + for (const file of ['local-transfer-pickup-acceptance.mjs', 'local-dispatch-transport-acceptance.mjs', 'fba-remaining-nodes-acceptance-pda-operator.mjs']) { + const source = readFileSync(new URL(`../${file}`, import.meta.url), 'utf8') + assert.doesNotMatch(source, /['"]POST['"]\s*,\s*['"](?:\/api)?\/platform\/v1\/iam\/(roles|users|permissions)['"]/, file) + assert.doesNotMatch(source, /['"]PUT['"]\s*,\s*['"](?:\/api)?\/platform\/v1\/iam\/roles\/permissions['"]/, file) + } +}) + +test('report security scans fixed actor passwords even if hidden under ordinary evidence keys', async () => { + const context = new AcceptanceRun({ environment: 'test', fixedCustomerId: '123', quiet: true, modules: [], fixedActors: { reader: { password: 'fixed-sensitive-password' } } }) + context.request = async () => ({ http: 200, body: { code: 'A0401' } }) + context.createdResources.evidence = { note: 'fixed-sensitive-password' } + const report = await context.run() + assert.equal(report.steps.find(step => step.module === 'report-security').passed, false) +}) diff --git a/scripts/admin-test-env-modules/fixed-test-actors.mjs b/scripts/admin-test-env-modules/fixed-test-actors.mjs new file mode 100644 index 000000000..cf35bf87c --- /dev/null +++ b/scripts/admin-test-env-modules/fixed-test-actors.mjs @@ -0,0 +1,73 @@ +const IAM = '/api/platform/v1/iam' +const id = value => String(value ?? '') +const same = (a, b) => JSON.stringify([...a].sort()) === JSON.stringify([...b].sort()) + +export const POOL_ACTOR_SLOTS = ['pool.sales.0', 'pool.sales.1', 'pool.dispatch.0', 'pool.dispatch.1', 'pool.fulfillment.0'] +export const UI_ACTOR_SLOTS = ['pool-ui.SALES_A', 'pool-ui.SALES_B', 'pool-ui.MANAGER'] +export const COST_ACTOR_SLOTS = ['fleet-cost.reader', 'fleet-cost.denied'] + +export function requireFixedActors(options, slots) { + const actors = options?.fixedActors + const users = new Set(), memberships = new Set() + for (const slot of slots) { + const actor = actors?.[slot] + if (!actor || !['membershipId', 'roleId'].every(key => typeof actor[key] === 'string' && /^[1-9]\d*$/.test(actor[key])) + || !['username', 'password', 'roleCode'].every(key => typeof actor[key] === 'string' && actor[key].trim())) + throw Error(`固定测试账号 ${slot} 配置不完整;禁止自动创建角色或账号`) + if (users.has(actor.username) || memberships.has(actor.membershipId)) + throw Error('隔离场景必须使用不同的固定账号,不能以同一账号代替') + users.add(actor.username); memberships.add(actor.membershipId) + } + return actors +} + +/** Only validates existing platform identities and opens/closes its own sessions. Never changes roles or accounts. */ +export async function openFixedActor(context, slot, expectedCodes) { + const actor = requireFixedActors(context.options, [slot])[slot] + async function request(method, path, body, token = context.token) { + const response = await context.request(method, path, body, token) + if (response?.http !== 200 || String(response.body?.code) !== '200') throw Error(`固定账号 ${slot} 核验失败:${method} ${path.split('?')[0]}`) + return response.body.data + } + const roles = await request('GET', `${IAM}/roles`) + const matches = Array.isArray(roles) ? roles.filter(row => id(row.id) === actor.roleId) : [] + if (matches.length !== 1 || matches[0].roleCode !== actor.roleCode || matches[0].status !== 'ACTIVE') + throw Error(`固定账号 ${slot} 的角色身份或启用状态不匹配,不自动改角色`) + let user, total + for (let page = 1; page <= 100; page++) { + // IAM keyword filters profile fields/member numbers, not the login name. Match the configured identity in the authoritative directory. + const data = await request('GET', `${IAM}/users?${new URLSearchParams({ page: String(page), size: '100' })}`) + if (!Array.isArray(data?.records) || Number(data.page) !== page || Number(data.size) !== 100 + || !Number.isSafeInteger(Number(data.total)) || Number(data.total) < 0 || data.records.length > 100 + || (total !== undefined && total !== Number(data.total))) throw Error('固定账号查询分页不完整或发生变化') + total ??= Number(data.total) + const candidates = data.records.filter(row => id(row.membershipId) === actor.membershipId && row.username === actor.username) + if (candidates.length > 1) throw Error('固定账号查询身份重复') + if (candidates.length) { user = candidates[0]; break } + if (page * 100 >= total) break + if (!data.records.length) throw Error('固定账号查询分页缺页') + } + if (!user || user.status !== 'ACTIVE' || id(user.tenantId) !== '0' + || !Array.isArray(user.roleIds) || !same(user.roleIds.map(id), [actor.roleId])) + throw Error(`固定账号 ${slot} 的成员、平台归属、状态或角色绑定不匹配,不自动修改账号`) + const session = await request('POST', '/api/auth/login', { loginId: actor.username, password: actor.password }, '') + let closed = false + async function close() { + if (closed) return + if (!session?.accessToken || !session.refreshToken) throw Error('固定账号会话不完整,无法确认退出') + const response = await context.request('POST', '/api/auth/logout', { refreshToken: session.refreshToken }, session.accessToken) + if (!(response?.http === 200 && String(response.body?.code) === '200') + && !([200, 401].includes(response?.http) && String(response.body?.code) === 'A0401')) throw Error('固定账号退出未确认,保留现场') + closed = true + } + try { + if (!session?.accessToken || !session.refreshToken || session.accessToken === context.token) throw Error('固定账号缺少独立完整会话') + const codes = await request('GET', `${IAM}/authorization/permissions`, undefined, session.accessToken) + if (!Array.isArray(codes) || !same(codes, expectedCodes)) throw Error(`固定账号 ${slot} 实际权限不匹配,不自动授权或扩大权限`) + } catch (error) { + try { await close() } catch (cleanupError) { throw new AggregateError([error, cleanupError], '固定账号核验和退出均失败') } + throw error + } + return { membershipId: actor.membershipId, username: actor.username, roleId: actor.roleId, roleCode: actor.roleCode, + token: session.accessToken, refreshToken: session.refreshToken, reused: true, close } +} diff --git a/scripts/admin-test-env-modules/fleet-company-cost-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-company-cost-acceptance-contract.test.mjs index 83df4d699..0263fd1dc 100644 --- a/scripts/admin-test-env-modules/fleet-company-cost-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-company-cost-acceptance-contract.test.mjs @@ -12,28 +12,22 @@ function fixture(fault) { const history = row => ({ recordVersion: row.cost.recordVersion, action: row.action, snapshot: copy(row.cost) }) function save(row, action) { row.action = action; row.history.unshift(history(row)) } function current(row) { const { cost } = row; return copy(cost) } - let supplier - const context = { marker, token: 'offline-test-only', createdResources: {}, + const supplier = { id: '8', tenantId: fault === 'foreign supplier' ? '9' : '0', category: 'TRUCKING', truckingScope: 'OVERSEAS', status: fault === 'inactive supplier' ? 'INACTIVE' : 'ACTIVE' } + vehicles.set('71', { id: '71', plateNumber: 'EXISTING-TEST', region: 'OVERSEAS', ownership: fault === 'external vehicle' ? 'EXTERNAL' : 'OWNED', status: fault === 'inactive vehicle' ? 'INACTIVE' : 'IN_SERVICE', recordVersion: 0 }) + const context = { marker, token: 'offline-test-only', createdResources: {}, options: { fixedCompanyCostResources: { supplierId: '8', vehicleId: '71' } }, assert(_module, label, _response, condition) { assert.ok(condition, label) }, apiSuccess(_module, label, result) { assert.equal(result?.http, 200, label); assert.equal(result?.body?.code, '200', label); return result.body.data }, async request(method, raw, body, _token, headers) { calls.push({ method, raw, body: copy(body), headers: copy(headers) }); const url = new URL(raw, 'https://fixture.invalid'), path = url.pathname - if (method === 'POST' && path === '/api/platform/v1/suppliers') { supplier = { id: '8', ...copy(body) }; return response('8') } if (method === 'GET' && path === '/api/platform/v1/suppliers/8') return response(supplier) if (method === 'GET' && path.includes('/master-data/exchange-rates/')) return response({ month: path.split('/').at(-1), status: fault === 'inactive exchange' ? 'INACTIVE' : 'ACTIVE', usdToCnyAverage: '7.123456', recordVersion: 0 }) if (path.endsWith('/fee-pool')) return response(fault === 'customer pollution' && costs.size ? [{ poolItemId: 'LEAK', customerId: '9', amount: 27.31, feeItemName: marker }] : []) if (path.endsWith('/cost-entries')) return response([]) if (path.endsWith('/customer-accounts/9/summary')) return response({ customerId: '9', currency: 'CNY', totalReceivable: 100, receivedAmount: 30, outstandingReceivable: 70, pendingBilling: 0, totalCost: 0, paidCost: 0, outstandingPayable: 0, asOf: new Date().toISOString() }) - if (path === '/api/platform/v1/last-leg/master-data/vehicles' && method === 'POST') { - const row = { id: '71', ...copy(body), recordVersion: 0 }; vehicles.set(row.id, row); return response(row) - } if (path.startsWith('/api/platform/v1/last-leg/master-data/vehicles/')) { const row = vehicles.get(path.split('/vehicles/')[1].split('/')[0]); assert.ok(row) - if (method === 'GET') return response(row) - assert.equal(method, 'PATCH'); assert.equal(body.status, 'INACTIVE') - if (fault === 'cleanup failure') return response(null, 'S0503', 503) - row.status = 'INACTIVE'; row.recordVersion++; return response(row) + assert.equal(method, 'GET', 'shared vehicle masters must remain read-only'); return response(row) } const care = path.match(/^\/api\/platform\/v1\/last-leg\/fleet\/vehicles\/71\/records(?:\/(.*))?$/) if (care) { @@ -118,28 +112,29 @@ function fixture(fault) { const id = value => String(value) const options = { pollAttempts: 2, pollDelayMs: 0 } test('independent runner requires explicit service readiness and only the specified TEST profile', () => { - const env = 'C:/Users/BY-OY/.codex/env/chongchong-global/test-acceptance.env' + const env = 'C:/Users/BY-OY/.codex/env/chongchong-global/test/acceptance.env' assert.throws(() => requireReady([], [`--env-file=${env}`]), /就绪/) - assert.throws(() => requireReady([`--ready-commit=${READY_COMMIT}`], [`--env-file=${env.replace('test-acceptance', 'dev-acceptance')}`]), /授权目录/) - assert.ok(requireReady([`--ready-commit=${READY_COMMIT}`], [`--env-file=${env}`]).endsWith('test-acceptance.env')) + assert.throws(() => requireReady([`--ready-commit=${READY_COMMIT}`], [`--env-file=${env.replace('/test/', '/dev/')}`]), /授权目录/) + assert.ok(requireReady([`--ready-commit=${READY_COMMIT}`], [`--env-file=${env}`]).replaceAll('\\', '/').endsWith('/test/acceptance.env')) }) test('readiness gate accepts only the full production anchor, not old anchors, shorthand or another commit', () => { - const profile = ['--env-file=C:/Users/BY-OY/.codex/env/chongchong-global/test-acceptance.env'] + const profile = ['--env-file=C:/Users/BY-OY/.codex/env/chongchong-global/test/acceptance.env'] assert.equal(READY_COMMIT, '8700fc34238bc33339351c73c15f3af8de8ce517') for (const commit of ['a6fd0ba3', 'cb6c54ff', '8700fc34', 'd05ff1cd', `${READY_COMMIT}0`]) { assert.throws(() => requireReady([`--ready-commit=${commit}`], profile), /就绪/) } - assert.ok(requireReady([`--ready-commit=${READY_COMMIT}`], profile).endsWith('test-acceptance.env')) + assert.ok(requireReady([`--ready-commit=${READY_COMMIT}`], profile).replaceAll('\\', '/').endsWith('/test/acceptance.env')) }) test('company cost verifier exercises CNY/USD, void/rebuild, payment replay and customer isolation without direct transport', async () => { const { context, calls, vehicles, records, costs } = fixture() await run(context, options) const proof = context.createdResources.fleetCompanyCostAcceptance - assert.equal(proof.status, 'VERIFIED'); assert.equal(proof.cleanup, 'INACTIVE') + assert.equal(proof.status, 'VERIFIED'); assert.equal(proof.cleanup, 'REUSED_UNCHANGED'); assert.equal(proof.reusedMasterData, true) assert.equal(proof.cases.length, 2); assert.equal(records.size, 2) assert.ok([...costs.values()].every(row => row.cost.status === 'PAID' && row.payments.length === 2)) - assert.ok([...vehicles.values()].every(row => row.status === 'INACTIVE')) + assert.ok([...vehicles.values()].every(row => row.status === 'IN_SERVICE' && row.recordVersion === 0)) + assert.equal(calls.some(call => call.method !== 'GET' && /suppliers|master-data\/vehicles/.test(call.raw)), false) assert.ok(calls.every(call => call.raw.startsWith('/api/platform/'))) assert.equal(calls.some(call => /cost-entries|customer-accounts|customerId=/.test(call.raw)), false, 'must not borrow another task customer for evidence') assert.equal(calls.some(call => call.method === 'DELETE'), false) @@ -149,15 +144,21 @@ test('missing independent marker blocks before any write', async () => { const { context, calls } = fixture(); context.marker = '' await assert.rejects(run(context, options), /独立marker/); assert.equal(calls.length, 0) }) +for (const resources of [undefined, {}, { supplierId: '8' }, { supplierId: '8', vehicleId: '0' }, { supplierId: 8, vehicleId: 71 }]) test(`missing fixed company masters stop before HTTP: ${JSON.stringify(resources)}`, async () => { + const { context, calls } = fixture(); context.options.fixedCompanyCostResources = resources + await assert.rejects(run(context, options), /禁止自动创建主档/) + assert.equal(calls.length, 0) +}) for (const fault of ['inactive exchange', 'delivery exhausted', 'never delivered', 'wrong source vehicle', 'wrong source version', 'wrong conversion', 'premature paid', - 'duplicate payment replay', 'overpayment allowed', 'partial money drift', 'paid void allowed', 'paid source mutable', 'customer pollution', 'file outage', 'wrong file bytes', 'cleanup failure']) { + 'duplicate payment replay', 'overpayment allowed', 'partial money drift', 'paid void allowed', 'paid source mutable', 'customer pollution', 'file outage', 'wrong file bytes', + 'foreign supplier', 'inactive supplier', 'external vehicle', 'inactive vehicle']) { test(`company cost verifier rejects ${fault} and preserves failure evidence`, async () => { - const { context, vehicles } = fixture(fault) + const { context, vehicles, calls } = fixture(fault), before = copy([...vehicles.values()]) await assert.rejects(run(context, options)) const proof = context.createdResources.fleetCompanyCostAcceptance assert.equal(proof.status, 'FAILED') assert.ok(proof.error || proof.cleanupError) - if (vehicles.size && fault !== 'cleanup failure') assert.ok([...vehicles.values()].every(row => row.status === 'INACTIVE')) - if (fault === 'cleanup failure') assert.equal(proof.cleanup, 'FAILED') + assert.deepEqual([...vehicles.values()], before) + assert.equal(calls.some(call => call.method !== 'GET' && /suppliers|master-data\/vehicles/.test(call.raw)), false) }) } diff --git a/scripts/admin-test-env-modules/fleet-company-cost-acceptance.mjs b/scripts/admin-test-env-modules/fleet-company-cost-acceptance.mjs index cbe6b69e3..529c41b3e 100644 --- a/scripts/admin-test-env-modules/fleet-company-cost-acceptance.mjs +++ b/scripts/admin-test-env-modules/fleet-company-cost-acceptance.mjs @@ -25,6 +25,9 @@ const same = (a, b) => JSON.stringify(canonical(a)) === JSON.stringify(canonical /** Internal authorized TEST module; entrypoint owns registration, credentials and execution. No direct fetch, SQL or historical fixtures. */ export async function run(context, { pollAttempts = 60, pollDelayMs = 1000 } = {}) { if (!context.marker || !context.createdResources) throw new Error('公司车辆成本验收须有独立marker和本轮资源上下文') + const resources = context.options?.fixedCompanyCostResources + if (!resources || !['supplierId', 'vehicleId'].every(key => typeof resources[key] === 'string' && /^[1-9]\d*$/.test(resources[key]))) + throw new Error('公司成本验收必须配置既有供应商和车辆,禁止自动创建主档') if (context.createdResources.fleetCompanyCostAcceptance) throw new Error('本轮已存在公司成本验收记录;保留原证据,不自动重新造数') if (!Number.isInteger(pollAttempts) || pollAttempts < 1 || pollAttempts > 120 || !Number.isFinite(pollDelayMs) || pollDelayMs < 0 || pollDelayMs > 5000) throw new Error('公司成本轮询范围无效') @@ -73,17 +76,15 @@ export async function run(context, { pollAttempts = 60, pollDelayMs = 1000 } = { proof.exchangeRate = { month, usdToCnyAverage: id(exchange.usdToCnyAverage), recordVersion: id(exchange.recordVersion) } proof.customerBefore = await snapshotCustomer('操作前') check('本轮marker尚无客户费用污染,禁止覆盖旧验收', proof.customerBefore.companyFeePoolRows === 0) - proof.supplierRequest = { name: `${context.marker} 公司成本专用供应商`, category: 'TRUCKING', carrierCodes: [], region: 'TEST-OVERSEAS', - legalRepresentative: '受控验收', contactName: '受控验收', phone: '00000000000', email: null, address: '受控测试地点', - businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'IMMEDIATE', remark: context.marker, truckingScope: 'OVERSEAS' } - proof.supplierId = id(await request('POST', '/api/platform/v1/suppliers?tenantId=0', proof.supplierRequest, '创建本轮独占公司成本供应商')) - check('供应商创建返回真实标识', /^[1-9]\d*$/.test(proof.supplierId)) - const supplier = await request('GET', `/api/platform/v1/suppliers/${encodeURIComponent(proof.supplierId)}?tenantId=0`, undefined, '核对本轮独占供应商') - check('供应商确属本轮独占样本', id(supplier.id) === proof.supplierId && supplier.status === 'ACTIVE' && supplier.remark === context.marker && supplier.name === proof.supplierRequest.name) - const vehicle = await request('POST', `${MASTER}/vehicles`, { plateNumber: `FC-${randomUUID().slice(0, 12)}`, model: 'Company cost TEST', fuelConsumptionPer100Km: 10, - mileageKm: 100, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: context.marker }, '创建本轮公司成本专用车辆') - check('车辆返回稳定身份', Boolean(vehicle.id && vehicle.plateNumber)) - proof.vehicleId = id(vehicle.id); proof.plateNumber = vehicle.plateNumber; proof.cleanup = 'PENDING' + proof.supplierId = id(resources.supplierId) + const supplier = await request('GET', `/api/platform/v1/suppliers/${encodeURIComponent(proof.supplierId)}?tenantId=0`, undefined, '核对已配置的既有供应商') + check('复用供应商为平台启用海外拖车供应商', id(supplier.id) === proof.supplierId && id(supplier.tenantId) === '0' + && supplier.status === 'ACTIVE' && supplier.category === 'TRUCKING' && supplier.truckingScope === 'OVERSEAS') + const vehicle = await request('GET', `${MASTER}/vehicles/${encodeURIComponent(id(resources.vehicleId))}`, undefined, '核对已配置的既有公司车辆') + check('复用车辆为海外自有在役车辆', id(vehicle.id) === id(resources.vehicleId) && Boolean(vehicle.plateNumber) + && vehicle.region === 'OVERSEAS' && vehicle.ownership === 'OWNED' && vehicle.status === 'IN_SERVICE') + proof.vehicleId = id(vehicle.id); proof.plateNumber = vehicle.plateNumber + proof.reusedMasterData = true; proof.cleanup = 'REUSED_UNCHANGED' const care = `/api/platform/v1/last-leg/fleet/vehicles/${encodeURIComponent(proof.vehicleId)}/records` for (const [recordType, currency, originalAmount] of [['REPAIR', 'CNY', 27.31], ['RESCUE', 'USD', 10]]) { const scenario = { recordType, status: 'CHECKING', commands: [] }; proof.cases.push(scenario) @@ -188,15 +189,5 @@ export async function run(context, { pollAttempts = 60, pollDelayMs = 1000 } = { } proof.status = 'VERIFIED' } catch (error) { failure = error; proof.status = 'FAILED'; proof.error = error instanceof Error ? error.message : String(error) } - finally { - if (proof.vehicleId) { - try { - const path = `${MASTER}/vehicles/${encodeURIComponent(proof.vehicleId)}` - const vehicle = await request('GET', path, undefined, '结束前读取本轮专用车辆') - const ended = await request('PATCH', `${path}/status`, { recordVersion: vehicle.recordVersion, status: 'INACTIVE' }, '停用本轮专用车辆,保留财务与运维证据') - check('本轮专用车辆已停用', ended.status === 'INACTIVE'); proof.cleanup = 'INACTIVE' - } catch (error) { proof.cleanup = 'FAILED'; proof.cleanupError = error instanceof Error ? error.message : String(error); proof.status = 'FAILED'; failure ??= error } - } - } if (failure) throw failure } diff --git a/scripts/admin-test-env-modules/fleet-cross-customer-fixture-contract.test.mjs b/scripts/admin-test-env-modules/fleet-cross-customer-fixture-contract.test.mjs index 0e4649f55..056791ee5 100644 --- a/scripts/admin-test-env-modules/fleet-cross-customer-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-cross-customer-fixture-contract.test.mjs @@ -5,7 +5,7 @@ import { fixtureContract } from './fleet-cross-customer-acceptance.mjs' // Strict offline HTTP model executes the real qualification and prepareFleetTrip helpers too. function model(fault = '', countPatch = {}) { - const copy = value => structuredClone(value), calls = [], customers = [], transfers = [], dispatches = [] + const copy = value => structuredClone(value), calls = [], customers = [{ id: '100', username: 'existing-a', password: 'offline-a' }, { id: '101', username: 'existing-b', password: 'offline-b' }], transfers = [], dispatches = [] const options = { execute: true, warehouseId: '10', destinationWarehouseId: '20', pdaToken: 'caller-pda-secret' } const trip = { trip: { tripNo: 'TRIP', recordVersion: '0', warehouseId: '10', status: 'PLANNED', maxPalletSpaces: 30, vehiclePlate: 'TEST', driverName: 'TEST', driverPhone: '19900000000' }, dispatches: [] } @@ -28,7 +28,7 @@ function model(fault = '', countPatch = {}) { warehousePalletId: `PAL${n}`, palletNo: `PALLET${n}`, scheduledPackages: 1, pickedPackages: 0, destination: { key: `FBA:${transfer.destinationWarehouseCode}` } })) const parentFixture = { marker: 'PARENT', tenantId: 'PARENT-ID', password: 'parent-secret' } - const context = { marker: 'RUN-CROSS', token: 'admin-secret', options: { customerName: 'Parent customer' }, context: { merchantQualificationFixture: parentFixture }, + const context = { marker: 'RUN-CROSS', token: 'admin-secret', options: { customerName: 'Parent customer', fixedCustomerId: '100', merchantUsername: 'existing-a', merchantPassword: 'offline-a', fixedSecondaryCustomer: { customerId: '101', username: 'existing-b', password: 'offline-b' }, fixedFleetResources: [{ vehicleId: '11', driverId: '12' }] }, context: { merchantQualificationFixture: parentFixture }, createdResources: { merchantQualificationFixture: { marker: 'PARENT' } }, assert(_module, label, _response, passed) { assert.ok(passed, label) }, apiSuccess(_module, label, result) { assert.equal(result.http, 200, label); assert.equal(result.body.code, '200', label); return result.body.data }, @@ -59,17 +59,7 @@ function model(fault = '', countPatch = {}) { if (path.endsWith('/warehouse-admin/20')) return response(destination) if (path.endsWith('/warehouse-admin/21')) return response({ ...destination, warehouse: { ...destination.warehouse, id: '21', warehouseCode: 'FBA2', warehouseName: 'Second destination' } }) - if (path === '/api/platform/v1/iam/tenants') { - const n = customers.length, id = fault === 'reused customer' ? '100' : String(100 + n) - const customer = { id, username: `user${n}`, password: `fresh-secret-${n}`, marker: body.internalRemark } - customers.push(customer) - return response({ tenant: { id }, administratorUsername: customer.username, temporaryPassword: customer.password }) - } - if (path.endsWith('/submit-review') && path.includes('/iam/')) return response({ status: 'PENDING' }) - if (path === '/api/platform/v1/iam/reviews') return response(customers.map(row => ({ id: `REV${row.id}`, tenantId: row.id }))) if (path.endsWith('/dimension-initialization')) return response({ status: 'READY' }) - if (path.endsWith('/approve')) return response({}) - if (path.endsWith('/qualifications')) return response(['BUSINESS_LICENSE', 'LEGAL_PERSON_ID'].map(qualificationType => ({ qualificationType, status: 'APPROVED' }))) if (path === '/api/auth/login') { assert.equal(token, '') const customer = customers.find(row => row.username === body.loginId); assert.equal(body.password, customer.password) @@ -81,7 +71,7 @@ function model(fault = '', countPatch = {}) { return response(null) } const merchant = customers.find(row => token === `merchant-${row.id}-secret`) - if (path === '/api/merchant/v1/iam/tenant/profile') return response({ id: fault === 'foreign login' ? 'OTHER' : merchant.id }) + if (path === '/api/merchant/v1/iam/tenant/profile') return response({ id: fault === 'foreign login' ? 'OTHER' : fault === 'reused customer' ? '100' : merchant.id, status: 'ACTIVE', tenantName: 'Existing fixture customer' }) if (path === '/api/merchant/v1/last-leg/warehouse-transfers') { assert.ok(merchant); assert.equal(body.inboundCode, undefined); assert.deepEqual(body.sourceLastlegOrderIds, []) assert.equal(body.needRelabel, false); assert.equal(body.items.length, 1); assert.equal(body.items[0].packageCount, 1); assert.equal(body.items[0].quantity, 1) @@ -164,9 +154,9 @@ function model(fault = '', countPatch = {}) { .map(code => ({ name: code, destinationKey: `FBA:${code}` }))] }) dispatches.push(native); return response(native) } - if (path.endsWith('/master-data/vehicles')) { trip.trip.vehiclePlate = body.plateNumber.toUpperCase(); return response({ id: 'VEHICLE' }) } - if (path.endsWith('/fleet/drivers')) return response({ id: 'DRIVER', recordVersion: '0' }) - if (path.endsWith('/fleet/drivers/DRIVER/review')) return response({ reviewStatus: 'APPROVED' }) + if (path.endsWith('/master-data/vehicles/11')) return response({ id: '11', plateNumber: 'TEST', status: 'IN_SERVICE', region: 'OVERSEAS', ownership: 'OWNED' }) + if (path.endsWith('/fleet/drivers/12')) return response({ id: '12', active: true, reviewStatus: 'APPROVED', details: { ownership: 'OWNED' }, qualification: { canDispatch: true, reason: 'ELIGIBLE' } }) + if (raw.startsWith('/api/platform/v1/last-leg/fleet/trips?')) return response({ records: trip.trip.vehicleId ? [trip.trip] : [], total: trip.trip.vehicleId ? 1 : 0, size: 200, page: 1 }) if (path === '/api/platform/v1/last-leg/fleet/trips') return response({ records: [trip.trip], total: 1, size: 200, page: 1 }) if (path === '/api/platform/v1/last-leg/fleet/trips/TRIP') return response(trip) if (path === '/api/platform/v1/last-leg/dispatches/DS-ONE/items') return response({ dispatchNo: native.dispatchNo, recordVersion: native.recordVersion, @@ -178,7 +168,7 @@ function model(fault = '', countPatch = {}) { assert.deepEqual(body.driver.evidence.photoFileIds, ['902']) assert.deepEqual(body.driver.evidence.fileIds, ['903']) Object.assign(trip.trip, { vehicleId: body.vehicle.vehicleId, driverId: body.driver.driverId }) - native.vehicleInfo = { vehicle: { mode: 'MASTER', vehicleId: 'VEHICLE', plate: trip.trip.vehiclePlate }, driver: { driverId: 'DRIVER' } } + native.vehicleInfo = { vehicle: { mode: 'MASTER', vehicleId: '11', plate: trip.trip.vehiclePlate }, driver: { driverId: '12' } } native.recordVersion++ } else if (method === 'POST') { assert.equal(body.recordVersion, native.recordVersion); native.recordVersion++ @@ -222,7 +212,7 @@ test('real helpers build independent current-run customers, receipts and one com assert.deepEqual(m.context.createdResources.merchantQualificationFixture, { marker: 'PARENT' }) assert.equal(m.context.options.customerName, 'Parent customer') assert.deepEqual({ username: process.env.MERCHANT_TEST_USERNAME, password: process.env.MERCHANT_TEST_PASSWORD }, oldEnvironment) - assert.notEqual(m.customers[0].marker, m.customers[1].marker) + assert.equal(m.calls.some(row => row.raw === '/api/platform/v1/iam/tenants' || row.raw === '/api/auth/register'), false) const proof = m.context.createdResources.fleetCrossCustomerFixture assert.equal(proof.status, 'READY'); assert.deepEqual(proof.sessionCleanup.map(row => row.status), ['LOGGED_OUT', 'LOGGED_OUT']) assert.equal(m.trip.dispatches.length, 1); assert.equal(new Set(fixture.members.map(row => row.dispatchNo)).size, 1); assert.deepEqual(m.trip.trip.stops.map(row => row.destinationKey).filter(Boolean), ['FBA:FBA']) @@ -246,6 +236,32 @@ test('real helpers build independent current-run customers, receipts and one com for (const secret of ['caller-pda-secret', 'admin-secret', 'fresh-secret-', 'merchant-100-secret', 'refresh-100-secret']) assert.ok(!persisted.includes(secret)) }) +for (const history of [false, true]) test(`GRAY preparation reuses two customers and preserves the parent slot: history=${history}`, async () => { + const m = model() + Object.assign(m.context.options, { environment: 'gray', fixedCustomerId: 'TEN100', merchantUsername: 'existing-a', merchantPassword: 'offline-a', + fixedSecondaryCustomer: { customerId: 'TEN101', username: 'existing-b', password: 'offline-b' }, fixedFleetResources: [] }) + if (history) { + const read = m.options.readInventory + m.options.readInventory = async scope => { + const raw = await read(scope), balance = structuredClone(raw.balances[0]), position = structuredClone(raw.positions[0]), ledger = structuredClone(raw.ledger[0]) + Object.assign(balance, { id: 'HIST-B', inboundCode: 'OLD-RECEIPT' }) + Object.assign(position, { id: 'HIST-P', balanceId: 'HIST-B', palletId: 'HIST-PALLET', palletNo: 'HIST-PALLET' }) + Object.assign(ledger, { id: 'HIST-L', balanceId: 'HIST-B', positionId: 'HIST-P', inboundCode: 'OLD-RECEIPT', businessOrderNo: 'OLD-RECEIPT' }) + raw.balances.push(balance); raw.positions.push(position); raw.ledger.push(ledger) + return raw + } + } + await assert.rejects(createFleetCrossCustomerFixture(m.context, m.options), /固定车辆司机组合/) + const proof = m.context.createdResources.fleetCrossCustomerFixture + assert.deepEqual(proof.members.map(row => row.customerId), ['100', '101']) + assert.ok(proof.members.every(row => row.qualification.reused === true && row.receiptVerified === true)) + assert.equal(m.customers.length, 2) + assert.equal(m.calls.some(row => row.raw === '/api/platform/v1/iam/tenants' || row.raw === '/api/auth/register'), false) + assert.equal(m.context.context.merchantQualificationFixture, m.parentFixture) + assert.deepEqual(proof.sessionCleanup.map(row => row.status), ['LOGGED_OUT', 'LOGGED_OUT']) + if (history) assert.ok(proof.members.every(row => row.receipt.raw.balances.length === 2)) +}) + for (const fault of ['disabled receiving', 'reused customer', 'foreign login', 'missing forecast carton', 'missing declared quantity', 'foreign forecast item', 'uncounted carton', 'wrong counted pallet', 'wrong counted sku', 'missing carton measurement', 'zero counted pallet', 'wrong measured pallet weight', 'foreign position', 'truncated ledger', 'wrong raw scope', 'wrong raw ledger position', 'wrong raw ledger balance', 'foreign candidate', 'missing second cargo', 'foreign delivery', 'logout failure']) { test(`fail closed, retain partial resources and clean owned sessions: ${fault}`, async () => { const m = model(fault) diff --git a/scripts/admin-test-env-modules/fleet-cross-customer-fixture.mjs b/scripts/admin-test-env-modules/fleet-cross-customer-fixture.mjs index df6ad6966..8e6eef3d0 100644 --- a/scripts/admin-test-env-modules/fleet-cross-customer-fixture.mjs +++ b/scripts/admin-test-env-modules/fleet-cross-customer-fixture.mjs @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto' -import { run as createQualifiedMerchant } from './merchant-qualification-fixture.mjs' +import { run as createQualifiedMerchant, normalizeFixedCustomerId, requireFixedSecondaryCustomer } from './merchant-qualification-fixture.mjs' import { prepareFleetTrip } from './fleet-trip-fulfillment.mjs' import { fixtureContract } from './fleet-cross-customer-acceptance.mjs' @@ -27,7 +27,7 @@ const localTime = delay => { export const preparationContract = Object.freeze({ stage: fixtureContract.stage, inputs: 'execute:true, current-run context, warehouseId (public active OVERSEAS with RECEIVING), destinationWarehouseId (public active FBA), pdaToken bound to warehouseId, readInventory(scope) returning all raw balances/positions/ledger with CUSTOMER_WAREHOUSE_COMPLETE scope', - writes: 'Two fresh qualified customers; one ordinary WarehouseTransfer and one PDA-measured pallet/box per customer; one native cross-customer dispatch with one automatic Fleet resource record and a TEST vehicle/qualified driver; both reviews approved. No picking or delivery arrangement yet.', + writes: 'TEST/GRAY reuse two designated existing ACTIVE customers; one ordinary WarehouseTransfer and one PDA-measured pallet/box per customer; one native cross-customer dispatch with one automatic Fleet resource record and configured reusable vehicle/driver; both reviews approved. No picking or delivery arrangement yet.', lifecycle: 'Caller creates/disables PDA identity and supplies raw readFleetInventory for verification. This helper logs out its own merchant sessions, retains all business fixtures, and refuses automatic retry. No SQL, environment files or external bookings.', }) @@ -35,6 +35,8 @@ export const preparationContract = Object.freeze({ export async function createFleetCrossCustomerFixture(context, { execute = false, warehouseId, destinationWarehouseId, destinationWarehouseIds, shipmentReferences, prepareBatchAllocations, pdaToken, readInventory } = {}) { if (!execute) return preparationContract + if (!context?.options?.fixedCustomerId) throw Error('跨客户验收必须配置两个既有固定客户,禁止自动建客') + requireFixedSecondaryCustomer(context.options) if (!context?.marker || !id(warehouseId) || !id(destinationWarehouseId) || !pdaToken || typeof readInventory !== 'function' || ['request', 'requestWithBody', 'requestMultipart', 'findAvailablePhone', 'apiSuccess', 'assert'].some(name => typeof context[name] !== 'function')) throw new Error('Missing current-run context, public warehouse/destination, caller-owned PDA identity or raw inventory reader') @@ -95,11 +97,15 @@ export async function createFleetCrossCustomerFixture(context, { execute = false check('Raw receipt reader proves complete current customer and warehouse scope', raw?.scope?.marker === scope.marker && raw.scope.customerId === scope.customerId && raw.scope.warehouseId === scope.warehouseId && raw.scope.coverage === 'CUSTOMER_WAREHOUSE_COMPLETE') - for (const collection of ['balances', 'positions', 'ledger']) check(`Fresh customer raw ${collection} has exactly one original receipt row`, - Array.isArray(raw[collection]) && raw[collection].length === 1 && id(raw[collection][0].id) - && id(raw[collection][0].customerId) === member.customerId && id(raw[collection][0].overseasWarehouseId) === id(warehouseId) - && (collection === 'positions' || raw[collection][0].inboundCode === member.incomingNo)) - const rawBalance = raw.balances[0], rawPosition = raw.positions[0], rawMovement = raw.ledger[0] + // 固定客户可有历史库存;完整快照逐行核对归属,本轮只选原入仓码及其实际余额/位置。 + for (const collection of ['balances', 'positions', 'ledger']) check(`Complete customer raw ${collection} preserves scoped unique identities`, + Array.isArray(raw[collection]) && new Set(raw[collection].map(row => id(row.id))).size === raw[collection].length + && raw[collection].every(row => id(row.id) && id(row.customerId) === member.customerId && id(row.overseasWarehouseId) === id(warehouseId))) + const currentBalances = raw.balances.filter(row => row.inboundCode === member.incomingNo) + const currentPositions = raw.positions.filter(row => currentBalances.some(balance => id(balance.id) === id(row.balanceId))) + const currentMovements = raw.ledger.filter(row => row.inboundCode === member.incomingNo && row.businessOrderNo === member.incomingNo) + check('Current receipt has exactly one original raw balance position and movement', currentBalances.length === 1 && currentPositions.length === 1 && currentMovements.length === 1) + const rawBalance = currentBalances[0], rawPosition = currentPositions[0], rawMovement = currentMovements[0] check('Raw receipt identities and quantities match the public evidence', id(rawBalance.id) === id(balance.id) && id(rawPosition.id) === id(positions[0].id) && id(rawPosition.balanceId) === id(balance.id) && id(rawPosition.palletId) === member.palletId && rawPosition.palletNo === member.palletNo @@ -131,7 +137,9 @@ export async function createFleetCrossCustomerFixture(context, { execute = false for (const suffix of ['A', 'B']) { const memberDestinationId = id(memberDestinations[suffix === 'A' ? 0 : 1]) const { destination, deliveryAddress } = destinations.get(memberDestinationId) - const member = { marker: context.marker, customerMarker: `${context.marker}-CROSS-${suffix}`, receiptVerified: false } + const configuredCustomer = normalizeFixedCustomerId(suffix === 'A' ? context.options.fixedCustomerId : context.options.fixedSecondaryCustomer.customerId) + const member = { marker: context.marker, customerMarker: `${context.marker}-CROSS-${suffix}`, receiptVerified: false, + ...(configuredCustomer ? { customerId: configuredCustomer } : {}) } proof.members.push(member) // Qualification helper's mutable fixture, options and resources must not touch the parent's merchant slot. const child = Object.create(context) @@ -140,23 +148,21 @@ export async function createFleetCrossCustomerFixture(context, { execute = false child.context = {}; child.createdResources = {} child.request = async (method, path, body, token = context.token) => { const response = await context.request(method, path, body, token) - record(method, path, body, response, `Qualify fresh customer ${suffix}`) - if (method === 'POST' && path === '/api/platform/v1/iam/tenants') - member.customerId = id((response.body.data?.tenant || response.body.data?.customer || response.body.data)?.id || response.body.data?.tenantId) + record(method, path, body, response, `Verify customer ${suffix}`) return response } - await createQualifiedMerchant(child, { publishEnvironment: false }) + await createQualifiedMerchant(child, { publishEnvironment: false, requireDistinctCustomer: suffix === 'B' }) const credentials = child.context.merchantQualificationFixture check('Qualification belongs to this independent current-run context', credentials?.marker === member.customerMarker && id(credentials.tenantId) === member.customerId && proof.members.filter(row => row.customerId === member.customerId).length === 1) member.qualification = child.createdResources.merchantQualificationFixture let auth try { - auth = await request('POST', '/api/auth/login', { loginId: credentials.username, password: credentials.password }, `Login fresh customer ${suffix}`, '') + auth = await request('POST', '/api/auth/login', { loginId: credentials.username, password: credentials.password }, `Login customer ${suffix}`, '') } finally { delete child.context.merchantQualificationFixture } // Retain a returned session for cleanup even if the login contract is incomplete. if (auth?.accessToken) sessions.push({ customerId: member.customerId, accessToken: auth.accessToken, refreshToken: auth.refreshToken }) - check('Fresh customer session is complete', auth?.accessToken && auth.refreshToken) + check('Customer session is complete', auth?.accessToken && auth.refreshToken) const profile = await request('GET', '/api/merchant/v1/iam/tenant/profile', undefined, 'Verify newly authenticated customer identity', auth.accessToken) check('Merchant session belongs to the newly qualified customer', id(profile?.id) === member.customerId) const createBody = { sourceLastlegOrderIds: [], operatingWarehouseId: id(warehouseId), diff --git a/scripts/admin-test-env-modules/fleet-quotes-operations-contract.test.mjs b/scripts/admin-test-env-modules/fleet-quotes-operations-contract.test.mjs index d8d4fbce5..56240869e 100644 --- a/scripts/admin-test-env-modules/fleet-quotes-operations-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-quotes-operations-contract.test.mjs @@ -60,12 +60,8 @@ function harness(fault) { 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) - assert.equal(body.truckingScope, 'OVERSEAS'); return response('901') - } - if (p === '/api/platform/v1/suppliers/901/status') { supplierStatus = body.status; return response(null) } - if (p === '/api/platform/v1/suppliers/901') return response({ id: '901', status: supplierStatus }) + if (p === '/api/platform/v1/suppliers') { assert.equal(method, 'GET'); return response({ records: [{ id: '901', category: 'TRUCKING', truckingScope: 'OVERSEAS', status: supplierStatus }], total: 1 }) } + if (p === '/api/platform/v1/suppliers/901') { assert.equal(method, 'GET'); return response({ id: '901', category: 'TRUCKING', truckingScope: 'OVERSEAS', status: supplierStatus }) } if (p === '/api/platform/v1/last-leg/fleet/quotes/import') { const backup = { rows: copy(rows), histories: copy(histories), commands: copy(commands) }, result = [] for (let index = 0; index < body.rows.length; index++) { @@ -134,7 +130,7 @@ test('offline full flow checks quotes, atomic import, current departure and meas assert.equal(proof.status, 'VERIFIED'); assert.equal(proof.financePayment, 'NOT_TESTED'); assert.equal(proof.upstreamDeparture, 'NOT_TESTED') assert.equal(proof.operationsStatus, 'NOT_TESTED'); assert.equal(proof.quoteStatus, 'VERIFIED') assert.equal(context.createdResources.fleetOperationsAcceptance.operationsStatus, 'VERIFIED'); assert.equal(context.createdResources.fleetOperationsAcceptance.quoteStatus, 'NOT_TESTED') - assert.equal(rows.size, 4); assert.ok([...rows.values()].every(r => r.status === 'INACTIVE')); assert.equal(supplierStatus(), 'INACTIVE') + assert.equal(rows.size, 4); assert.ok([...rows.values()].every(r => r.status === 'INACTIVE')); assert.equal(supplierStatus(), 'ACTIVE') assert.ok(!calls.some(call => call.method === 'DELETE')); assert.ok(!JSON.stringify(proof).includes('password')) }) for (const fault of ['null price becomes zero', 'mutable creation replay', 'partial import leak', 'inactive leaks into active list', 'stale write accepted', 'counts destinations as trips', 'unknown cost becomes zero', 'measured replay adds history', 'json export masquerades as csv', 'historical trip']) { diff --git a/scripts/admin-test-env-modules/fleet-quotes-operations.mjs b/scripts/admin-test-env-modules/fleet-quotes-operations.mjs index 4f5dcaab2..6aebb9f52 100644 --- a/scripts/admin-test-env-modules/fleet-quotes-operations.mjs +++ b/scripts/admin-test-env-modules/fleet-quotes-operations.mjs @@ -227,14 +227,9 @@ async function execute(context, { scope, writeMeasured = false, idempotencyNames } if (scope === 'QUOTES_ONLY') { const customer = requireQuoteCustomer(context) - const supplier = context.options?.fixedCustomerId - ? await (await import('./finance-full-fixtures.mjs')).loadSupplier(context, 'TRUCKING', { truckingScope: 'OVERSEAS' }) - : await command('POST', '/api/platform/v1/suppliers?tenantId=0', { - name: `车辆报价验收-${context.marker}`, category: 'TRUCKING', truckingScope: 'OVERSEAS', carrierCodes: [], status: 'ACTIVE', remark: context.marker, - region: 'TEST', legalRepresentative: '测试法人', contactName: '测试联系人', phone: '19900000000', email: 'fleet-acceptance@example.com', settlementPeriod: 'MONTHLY_30', - }, '新建本轮海外车队供应商') + const supplier = await (await import('./finance-full-fixtures.mjs')).loadSupplier(context, 'TRUCKING', { truckingScope: 'OVERSEAS' }) if (!supplier) throw new Error('缺少现有海外拖车供应商,不自动创建') - proof.supplierReused = Boolean(context.options?.fixedCustomerId) + proof.supplierReused = true proof.supplierId = id(supplier.id ?? supplier) check('供应商返回明确标识', null, /^\d+$/.test(proof.supplierId)) const day = new Date().toISOString().slice(0, 10) @@ -315,17 +310,6 @@ async function execute(context, { scope, writeMeasured = false, idempotencyNames if (row.status === 'ACTIVE') await command('PATCH', `${QUOTES}/${encode(no)}/status`, { recordVersion: row.recordVersion, status: 'INACTIVE' }, '停用本轮专用报价并保留历史') } catch (error) { cleanupErrors.push(error.message) } } - if (proof.supplierId && !proof.supplierReused && !context.quoteRequestOutcomeUnknown) { - try { - const path = `/api/platform/v1/suppliers/${encode(proof.supplierId)}` - const response = await request('PATCH', `${path}/status?tenantId=0`, { status: 'INACTIVE' }) - context.apiSuccess(MODULE, '停用本轮车队供应商并保留证据', response, '', OPTIONS) - check('供应商停用命令成功', response, successful(response)) - const supplier = await command('GET', `${path}?tenantId=0`, undefined, '回查本轮车队供应商已停用') - check('本轮车队供应商最终停用', null, supplier.status === 'INACTIVE' && id(supplier.id) === proof.supplierId) - } catch (error) { cleanupErrors.push(error.message) } - } - proof.cleanup = context.quoteRequestOutcomeUnknown ? 'NOT_ATTEMPTED_UNKNOWN_OUTCOME' : cleanupErrors.length ? 'FAILED' : proof.quotes.length || proof.supplierId ? 'RETAINED_AND_DEACTIVATED' : 'NOTHING_CREATED' if (cleanupErrors.length) { proof.cleanupErrors = cleanupErrors; proof.status = 'FAILED'; failure ||= new Error('本轮报价资源停用失败') } } if (failure) throw failure diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs index 3b254de0d..d3c24b346 100644 --- a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' import vm from 'node:vm' -import { qualificationCreditCode, normalizeFixedCustomerId, run } from './merchant-qualification-fixture.mjs' +import { normalizeFixedCustomerId, run } from './merchant-qualification-fixture.mjs' import { AcceptanceRun } from '../admin-test-env-acceptance.mjs' function fixedContext(profileId = '2100000000000000001') { @@ -76,13 +76,26 @@ for (const fault of ['same customer', 'wrong profile', 'missing credentials']) t else assert.equal(context.calls.at(-1).path, '/api/auth/logout') }) -test('TEST fixture without fixed customer fails before any request', async () => { +for (const environment of ['test', 'gray']) test(`${environment} fixture without fixed customer fails before any request`, async () => { const context = fixedContext() - context.options = { environment: 'test' } + context.options = { environment } await assert.rejects(run(context), /必须配置固定客户/) assert.equal(context.calls.length, 0) }) +test('GRAY fixed customer reuses its credentials and does not publish them into TEST variables', async () => { + const context = fixedContext(); context.options.environment = 'gray' + const before = Object.fromEntries(['MERCHANT_TEST_USERNAME', 'MERCHANT_TEST_PASSWORD', 'MERCHANT_GRAY_USERNAME', 'MERCHANT_GRAY_PASSWORD'].map(key => [key, process.env[key]])) + try { + await run(context) + assert.equal(context.createdResources.merchantQualificationFixture.reused, true) + assert.equal(process.env.MERCHANT_TEST_USERNAME, before.MERCHANT_TEST_USERNAME) + assert.equal(process.env.MERCHANT_TEST_PASSWORD, before.MERCHANT_TEST_PASSWORD) + assert.equal(process.env.MERCHANT_GRAY_USERNAME, context.options.merchantUsername) + assert.equal(context.calls.some(call => call.path === '/api/platform/v1/iam/tenants'), false) + } finally { for (const [key, value] of Object.entries(before)) { if (value === undefined) delete process.env[key]; else process.env[key] = value } } +}) + test('cross-customer financial helpers stop before provisioning or writing', async () => { for (const [file, name, end] of [ ['finance-direct-paid-isolation.mjs', 'createSecondCustomer', 'async function verifyCustomerIsolation'], @@ -133,29 +146,15 @@ const acceptance = readFileSync(new URL('../admin-test-env-acceptance.mjs', impo const fixture = readFileSync(new URL('./merchant-qualification-fixture.mjs', import.meta.url), 'utf8') const orders = readFileSync(new URL('./orders.mjs', import.meta.url), 'utf8') -test('credit codes distinguish repeated CUSTOMER-B and SIGNED-B fixtures using the complete marker', () => { - const customerId = '2100000000000000001' - const values = [] - for (const ending of ['CUSTOMER-B', 'SIGNED-B']) { - const firstMarker = `ADMIN-API-20260930121500000-${ending}` - const secondMarker = `ADMIN-API-20260930121600000-${ending}` - const first = qualificationCreditCode(firstMarker, customerId) - const second = qualificationCreditCode(secondMarker, customerId) - assert.notEqual(first, second, `时间不同但后缀同为 ${ending} 必须生成不同代码`) - assert.equal(qualificationCreditCode(firstMarker, customerId), first) - for (const value of [first, second]) assert.match(value, /^91440300[0-9A-F]{10}$/) - values.push(first, second) +for (const environment of [undefined, 'dev', 'prod']) test(`unconfigured customer fixture never provisions even outside normal entry: ${environment}`, async () => { + const context = fixedContext(); context.options = { environment } + await assert.rejects(run(context), /必须配置固定客户/) + assert.equal(context.calls.length, 0) + const runner = new AcceptanceRun({ environment }) + for (const entry of ['request', 'requestWithBody', 'requestMultipart']) { + for (const path of ['/api/platform/v1/iam/tenants/', '/api/auth/register', '/api/platform/v1/iam/tenants/123/archive']) + await assert.rejects(runner[entry]('POST', path, new FormData()), /固定客户模式禁止/) } - assert.equal(new Set(values).size, values.length) - assert.match(fixture, /unifiedSocialCreditCode = qualificationCreditCode\(context\.marker, tenantId\)/) -}) - -test('a new real customer under the same marker receives a distinct stable code', () => { - const marker = 'ADMIN-API-20260930121500000-CUSTOMER-B' - assert.notEqual(qualificationCreditCode(marker, '2100000000000000001'), - qualificationCreditCode(marker, '2100000000000000002')) - assert.throws(() => qualificationCreditCode('', '2100000000000000001'), /完整批次标识/) - assert.throws(() => qualificationCreditCode(marker, ''), /真实客户编号/) }) test('默认全量验收先准备已审核商户再运行订单链路', () => { @@ -165,24 +164,12 @@ test('默认全量验收先准备已审核商户再运行订单链路', () => { assert.match(acceptance, /DEFAULT_MODULES\s*=\s*normalizeModuleOrder\(CONFIGURED_MODULES\)/) }) -test('商户夹具通过正式 File 和 IAM 审核流程建立下单资格', () => { - assert.match(fixture, /files\/qualifications\/\$\{encodeURIComponent\(tenantId\)\}\/required-package/) - assert.match(fixture, /required-package\?unifiedSocialCreditCode=\$\{encodeURIComponent\(unifiedSocialCreditCode\)\}/) - assert.doesNotMatch(fixture, /packageForm\.append\('unifiedSocialCreditCode'/) - assert.match(fixture, /iam\/tenants\/\$\{encodeURIComponent\(tenantId\)\}\/submit-review/) - assert.match(fixture, /iam\/tenants\/\$\{encodeURIComponent\(tenantId\)\}\/dimension-initialization/) +test('商户夹具只核验现有客户,不包含建客、上传资质和审核实现', () => { + assert.match(fixture, /dimension-initialization/) assert.match(fixture, /status === 'READY'/) - assert.match(fixture, /setTimeout\(resolve, 500\)/) - assert.match(fixture, /iam\/reviews\/\$\{encodeURIComponent\(review\.id\)\}\/approve/) - assert.match(fixture, /item\.status === 'APPROVED'/) - assert.match(fixture, /MERCHANT_TEST_USERNAME/) - assert.match(fixture, /MERCHANT_TEST_PASSWORD/) - assert.match(fixture, /context\.options\?\.customerName/) - assert.match(fixture, /tenantName/) - assert.doesNotMatch(fixture, /expectedProcurementPrefix/) - assert.match(acceptance, /administrator-phone-availability\?phone=/) - assert.match(acceptance, /attempt < 100/) - assert.doesNotMatch(acceptance, /phoneSequence % 10/) + assert.match(fixture, /environment === 'gray' \? 'MERCHANT_GRAY' : 'MERCHANT_TEST'/) + for (const removed of ['required-package', 'submit-review', '/approve', 'administratorUsername', 'temporaryPassword']) + assert.ok(!fixture.includes(removed), removed) assert.doesNotMatch(fixture, /internal\/iam|UPDATE\s+iam_|INSERT\s+INTO\s+iam_/i) }) diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs index fafb0a69b..1924afb9e 100644 --- a/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs @@ -1,25 +1,5 @@ -import { createHash } from 'node:crypto' - const MODULE = 'merchant-qualification-fixture' -const REQUIRED_TYPES = new Set(['BUSINESS_LICENSE', 'LEGAL_PERSON_ID']) -const QUALIFICATION_IMAGE = Uint8Array.from(Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', - 'base64', -)) - -function id(value) { - return value === undefined || value === null ? '' : String(value) -} - -export function qualificationCreditCode(marker, tenantId) { - const customerId = id(tenantId) - if (!String(marker || '').trim() || !customerId) { - throw new Error('测试资质代码必须使用完整批次标识和本轮真实客户编号') - } - const suffix = createHash('sha256').update(JSON.stringify([String(marker), customerId])) - .digest('hex').slice(0, 10).toUpperCase() - return `91440300${suffix}` -} +function id(value) { return value == null ? '' : String(value) } async function waitForDimensionInitialization(context, tenantId) { let response = null @@ -56,9 +36,10 @@ export function requireFixedSecondaryCustomer(options) { async function reuseFixedCustomer(context, publishEnvironment, requireDistinctCustomer) { const secondary = requireDistinctCustomer ? requireFixedSecondaryCustomer(context.options) : null const tenantId = normalizeFixedCustomerId(secondary?.customerId || context.options.fixedCustomerId) - const username = secondary ? secondary.username : context.options.merchantUsername || process.env.MERCHANT_TEST_USERNAME - const password = secondary ? secondary.password : context.options.merchantPassword || process.env.MERCHANT_TEST_PASSWORD - if (!username || !password) throw new Error('请配置固定客户的 MERCHANT_TEST_USERNAME / MERCHANT_TEST_PASSWORD;不会新建客户或重置密码') + const prefix = context.options.environment === 'gray' ? 'MERCHANT_GRAY' : 'MERCHANT_TEST' + const username = secondary ? secondary.username : context.options.merchantUsername || process.env[`${prefix}_USERNAME`] + const password = secondary ? secondary.password : context.options.merchantPassword || process.env[`${prefix}_PASSWORD`] + if (!username || !password) throw new Error(`请配置固定客户的 ${prefix}_USERNAME / ${prefix}_PASSWORD;不会新建客户或重置密码`) const loginResponse = await context.request('POST', '/api/auth/login', {loginId:username,password}, '') const login = context.apiSuccess(MODULE, '登录已配置的固定客户账号', loginResponse) if (!login?.accessToken) throw new Error('固定客户账号登录失败') @@ -75,8 +56,8 @@ async function reuseFixedCustomer(context, publishEnvironment, requireDistinctCu await context.request('POST', '/api/auth/logout', {refreshToken:login.refreshToken}, login.accessToken) } if (publishEnvironment) { - process.env.MERCHANT_TEST_USERNAME = username - process.env.MERCHANT_TEST_PASSWORD = password + process.env[`${prefix}_USERNAME`] = username + process.env[`${prefix}_PASSWORD`] = password } const fixture = {tenantId,tenantName:profile.tenantName,username,marker:context.marker,reused:true} context.context.merchantQualificationFixture = {...fixture,password} @@ -85,91 +66,6 @@ async function reuseFixedCustomer(context, publishEnvironment, requireDistinctCu } export async function run(context, { publishEnvironment = true, requireDistinctCustomer = !publishEnvironment } = {}) { - if (context.options?.fixedCustomerId) return reuseFixedCustomer(context, publishEnvironment, requireDistinctCustomer) - if (context.options?.environment === 'test') throw new Error('TEST 必须配置固定客户,禁止自动建客') - const suffix = context.marker.replace(/[^0-9A-Z]/gi, '').slice(-10).padStart(10, '0') - const customerName = String(context.options?.customerName || '').trim() - const tenantName = customerName || `全量验收下单客户-${suffix}` - const phone = await context.findAvailablePhone(MODULE) - const createResponse = await context.request('POST', '/api/platform/v1/iam/tenants', { - tenantName, - legalName: customerName ? `${customerName}有限公司` : `全量验收下单有限公司-${suffix}`, - legalRepresentative: '全量验收法人', - contactName: '全量验收管理员', - contactPhone: phone, - contactEmail: `merchant-fixture-${suffix.toLowerCase()}@example.com`, - servicePackage: '全量验收', - salesOwnerName: '全量验收', - paymentTermDays: 30, - enterpriseType: 'LIMITED_COMPANY', - establishedDate: '2020-01-02', - registeredCapital: 1000000, - registeredCapitalCurrency: 'CNY', - registeredAddress: '深圳市全量验收地址', - internalRemark: `${context.marker}-MERCHANT-QUALIFICATION`, - }) - const created = context.apiSuccess(MODULE, '创建隔离下单客户', createResponse) - const customer = created?.tenant || created?.customer || created - const tenantId = id(customer?.id || created?.tenantId) - const username = created?.administratorUsername - const password = created?.temporaryPassword - context.assert(MODULE, '下单客户和一次性管理员凭据完整', createResponse, - Boolean(tenantId && username && password), - `tenantId=${tenantId || 'missing'}, username=${username || 'missing'}, passwordGenerated=${Boolean(password)}`, - { deploymentBlocker: true }) - if (!tenantId || !username || !password) throw new Error('创建下单客户未返回完整管理员凭据') - - const packageForm = new FormData() - packageForm.append('businessLicense', new File([QUALIFICATION_IMAGE], `business-${suffix}.png`, { type: 'image/png' })) - packageForm.append('legalPersonId', new File([QUALIFICATION_IMAGE], `legal-person-${suffix}.png`, { type: 'image/png' })) - const unifiedSocialCreditCode = qualificationCreditCode(context.marker, tenantId) - const packageResponse = await context.requestWithBody('POST', - `/api/platform/v1/files/qualifications/${encodeURIComponent(tenantId)}/required-package?unifiedSocialCreditCode=${encodeURIComponent(unifiedSocialCreditCode)}`, - packageForm) - context.apiSuccess(MODULE, '通过 File 上传真实必填资质包', packageResponse, - `tenantId=${tenantId}`, { deploymentBlocker: true }) - - const submitResponse = await context.request('POST', - `/api/platform/v1/iam/tenants/${encodeURIComponent(tenantId)}/submit-review`) - const submitted = context.apiSuccess(MODULE, '提交企业资质审核', submitResponse, - `tenantId=${tenantId}`, { deploymentBlocker: true }) - context.assert(MODULE, '企业进入待审核状态', submitResponse, submitted?.status === 'PENDING', - `status=${submitted?.status ?? 'missing'}`, { deploymentBlocker: true }) - - const reviewsResponse = await context.request('GET', '/api/platform/v1/iam/reviews') - const reviews = context.apiSuccess(MODULE, '查询待审核企业', reviewsResponse, '', { deploymentBlocker: true }) - const review = Array.isArray(reviews) ? reviews.find((item) => id(item.tenantId) === tenantId) : null - context.assert(MODULE, '待审核列表包含隔离下单客户', reviewsResponse, Boolean(review?.id), - `tenantId=${tenantId}, reviewId=${id(review?.id) || 'missing'}`, { deploymentBlocker: true }) - if (!review?.id) throw new Error('未找到隔离下单客户的待审核单') - - await waitForDimensionInitialization(context, tenantId) - - const approveResponse = await context.request('POST', - `/api/platform/v1/iam/reviews/${encodeURIComponent(review.id)}/approve`, - { comment: `${context.marker} 全量验收正式资质审核通过` }) - context.apiSuccess(MODULE, '平台审核通过企业资质', approveResponse, - `tenantId=${tenantId}`, { deploymentBlocker: true }) - - const qualificationsResponse = await context.request('GET', - `/api/platform/v1/iam/tenants/${encodeURIComponent(tenantId)}/qualifications`) - const qualifications = context.apiSuccess(MODULE, '回查企业资质审核结果', qualificationsResponse) - const approvedTypes = new Set((Array.isArray(qualifications) ? qualifications : []) - .filter((item) => item.status === 'APPROVED') - .map((item) => item.qualificationType)) - const eligible = [...REQUIRED_TYPES].every((type) => approvedTypes.has(type)) - context.assert(MODULE, '全部必填企业资质已审核通过', qualificationsResponse, eligible, - `approved=${[...approvedTypes].join(',') || 'missing'}`, { deploymentBlocker: true }) - if (!eligible) throw new Error('隔离下单客户必填资质未全部审核通过') - - if (publishEnvironment) { - process.env.MERCHANT_TEST_USERNAME = username - process.env.MERCHANT_TEST_PASSWORD = password - } - context.context.merchantQualificationFixture = { - tenantId, tenantName, username, password, marker: context.marker, - } - context.createdResources.merchantQualificationFixture = { - tenantId, tenantName, username, marker: context.marker, - } + if (!context.options?.fixedCustomerId) throw new Error('必须配置固定客户,禁止自动建客、补交资质或自动审核') + return reuseFixedCustomer(context, publishEnvironment, requireDistinctCustomer) } diff --git a/scripts/admin-test-env-modules/pda-picker-fixture-contract.test.mjs b/scripts/admin-test-env-modules/pda-picker-fixture-contract.test.mjs index 412cb274d..bdf65ca2b 100644 --- a/scripts/admin-test-env-modules/pda-picker-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/pda-picker-fixture-contract.test.mjs @@ -1,7 +1,37 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { pickerFixtureSuffix, createPicker, disablePicker, pickerSessionHandoff } from './phase4-overseas-pda.mjs' +import { pickerFixtureSuffix, createPicker, disablePicker, pickerSessionHandoff, restoreFixedPicker } from './phase4-overseas-pda.mjs' import { verifyPdaWarehouseRevocation } from './unified-pda-session-revocation.mjs' +import { AcceptanceRun } from '../admin-test-env-acceptance.mjs' + +test('child workflows share PDA leases and recovery reuses the saved membership without enabling or recreating an account', async () => { + const actors = ['11', '12'].map(membershipId => ({ membershipId, warehouseId: '91', username: `test-${membershipId}`, password: 'fixture-only' })) + const context = new AcceptanceRun({ environment: 'test', fixedCustomerId: '101', fixedPdaActors: actors }), calls = [] + context.request = async (method, path, body) => { + calls.push({ method, path }) + let data = {} + if (path.includes('/iam/users?')) { + assert.equal(new URL(path, 'https://fixture.invalid').searchParams.has('keyword'), false) + data = { page: 1, size: 100, total: 2, records: actors.map(actor => ({ ...actor, overseasWarehouseId: '91', tenantId: '0', status: 'ACTIVE', roleIds: ['7'] })) } + } + if (path === '/api/auth/login') data = { accessToken: body.loginId, refreshToken: `refresh-${body.loginId}` } + return { http: 200, body: { code: '200', data } } + } + const full = await createPicker(Object.create(context), 161, '91', { role: { id: '7' } }) + const partial = await createPicker(Object.create(context), 71, '91', { role: { id: '7' } }) + assert.notEqual(full.membershipId, partial.membershipId) + await disablePicker(context, full, 161) + await disablePicker(context, partial, 71) + const restored = await restoreFixedPicker(context, { membershipId: '12', username: 'test-12' }, 71, '91', { role: { id: '7' } }) + assert.equal(restored.membershipId, '12') + await disablePicker(context, restored, 71) + assert.equal(calls.filter(call => call.path === '/api/auth/logout').length, 3) + assert.equal(context.fixedPdaLeases.size, 0) + assert.ok(calls.every(call => call.method === 'GET' || ['/api/auth/login', '/api/auth/logout'].includes(call.path))) + const before = calls.length + await assert.rejects(restoreFixedPicker(context, { membershipId: '999', username: 'foreign' }, 71, '91', { role: { id: '7' } }), /禁止推算临时账号/) + assert.equal(calls.length, before) +}) test('fixed PDA actors are leased separately and only logged out, never modified', async () => { const actors = ['11', '12'].map(membershipId => ({ membershipId, warehouseId: '91', username: `test-${membershipId}`, password: 'fixture-only' })) diff --git a/scripts/admin-test-env-modules/phase3-customs.mjs b/scripts/admin-test-env-modules/phase3-customs.mjs index daa625d01..be941c194 100644 --- a/scripts/admin-test-env-modules/phase3-customs.mjs +++ b/scripts/admin-test-env-modules/phase3-customs.mjs @@ -367,30 +367,17 @@ export async function run(context) { approved?.customsCase?.status === 'READY_TO_SUBMIT' && Number(approved?.customsCase?.version) === 4, `status=${approved?.customsCase?.status || 'missing'}, version=${approved?.customsCase?.version ?? 'missing'}`, OPTIONS) - let customsAgentId - if (context.options?.fixedCustomerId) { - const { loadSupplier } = await import('./finance-full-fixtures.mjs') - const existing = await loadSupplier(context, 'CUSTOMS') - if (!existing) throw new Error('缺少现有报关代理,不自动创建供应商') - customsAgentId = id(existing.id) - } else { - const supplierCreateResponse = await context.request('POST', '/api/platform/v1/suppliers?tenantId=0', { - name: `报关代理-${context.marker}`.slice(0, 128), category: 'CUSTOMS', region: '深圳', - legalRepresentative: '验收法人', contactName: '报关联系人', phone: '13800000000', - email: 'customs-acceptance@example.com', address: '深圳市验收地址', - businessLicenseNo: `CUSTOMS${context.marker.replace(/\D/g, '')}`.slice(0, 64), licenseFileId: null, - status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: context.marker, - }) - customsAgentId = id(context.apiSuccess(MODULE, '创建本轮正式 ACTIVE 报关代理供应商', - supplierCreateResponse, `marker=${context.marker}`, OPTIONS)) - } + const { loadSupplier } = await import('./finance-full-fixtures.mjs') + const existing = await loadSupplier(context, 'CUSTOMS') + if (!existing) throw new Error('缺少现有报关代理,不自动创建供应商') + const customsAgentId = id(existing.id) if (!customsAgentId) return const supplierDetailResponse = await context.request('GET', `/api/platform/v1/suppliers/${encodeURIComponent(customsAgentId)}?tenantId=0`) const customsAgent = context.apiSuccess(MODULE, '回查本轮报关代理供应商', supplierDetailResponse, '', OPTIONS) context.assert(MODULE, '代理提交使用本轮正式 CUSTOMS 供应商身份', supplierDetailResponse, id(customsAgent?.id) === customsAgentId && customsAgent?.status === 'ACTIVE' - && customsAgent?.category === 'CUSTOMS' && (context.options?.fixedCustomerId || customsAgent?.remark === context.marker), + && customsAgent?.category === 'CUSTOMS', `customsAgentId=${id(customsAgent?.id) || 'missing'}, customsAgentName=${customsAgent?.name || 'missing'}`, OPTIONS) if (id(customsAgent?.id) !== customsAgentId || customsAgent?.status !== 'ACTIVE' || customsAgent?.category !== 'CUSTOMS') return diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 283ab7ca6..2b84532ff 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -7,7 +7,6 @@ import { createHash } from 'node:crypto' const MODULE = 'phase3-loading' const OPTIONS = { scenario: 'phase3-split-preload-loading', deploymentBlocker: true } -const MATSON_SUPPLIER_NAME = 'MATSON 开发联调船司' const LATE_CONTAINER_GATE_ONLY = process.env.LATE_CONTAINER_GATE_ONLY === '1' function id(value) { return value == null ? '' : String(value) } @@ -41,31 +40,11 @@ function failure(response) { } function containerRecord(value) { return value?.container } -async function ensureMatsonSupplier(context, records) { - if (context.options?.fixedCustomerId) { - const { loadSupplier } = await import('./finance-full-fixtures.mjs') - const supplier = await loadSupplier(context, 'BOOKING', { carrierCode: 'MATSON' }) - if (!supplier) throw new Error('缺少启用的 MATSON 订舱供应商,不创建或启用主档') - return supplier - } - const existing = records.find((value) => value?.id && value?.name === MATSON_SUPPLIER_NAME) - if (existing?.status === 'ACTIVE') return existing - if (existing?.id) { - const activatedResponse = await context.request('PATCH', - `/api/platform/v1/suppliers/${encodeURIComponent(id(existing.id))}/status?tenantId=0`, { status: 'ACTIVE' }) - const activated = context.apiSuccess(MODULE, '启用稳定MATSON船司开发夹具', activatedResponse, - `supplierId=${id(existing.id)}`, OPTIONS) - return activated?.id ? activated : null - } - const createdResponse = await context.request('POST', '/api/platform/v1/suppliers?tenantId=0', { - name: MATSON_SUPPLIER_NAME, category: 'BOOKING', carrierCodes: ['MATSON'], region: '开发环境', legalRepresentative: '开发联调', - contactName: '开发联调', phone: '13800000000', email: 'matson-dev-fixture@example.com', - address: '开发环境稳定船司夹具', businessLicenseNo: 'DEV-CARRIER-MATSON', licenseFileId: null, - status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: '三四期开发环境HTTP验收稳定船司夹具', - }) - const supplierId = id(context.apiSuccess(MODULE, '创建稳定MATSON船司开发夹具', createdResponse, - `name=${MATSON_SUPPLIER_NAME}`, OPTIONS)) - return supplierId ? { id: supplierId, name: MATSON_SUPPLIER_NAME, status: 'ACTIVE' } : null +async function ensureMatsonSupplier(context) { + const { loadSupplier } = await import('./finance-full-fixtures.mjs') + const supplier = await loadSupplier(context, 'BOOKING', { carrierCode: 'MATSON' }) + if (!supplier) throw new Error('缺少启用的 MATSON 订舱供应商,不创建或启用主档') + return supplier } export function stableLoadedCartonMarks(items) { @@ -228,7 +207,7 @@ export async function run(context) { '/api/platform/v1/suppliers?tenantId=0&category=BOOKING&page=1&size=200') const supplierPage = context.apiSuccess(MODULE, '读取订舱代理与船司供应商事实源', supplierResponse, '', OPTIONS) const supplierRecords = Array.isArray(supplierPage?.records) ? supplierPage.records : [] - const supplier = await ensureMatsonSupplier(context, supplierRecords) + const supplier = await ensureMatsonSupplier(context) context.assert(MODULE, '存在可用于发布订舱的 ACTIVE 订舱代理', supplierResponse, Boolean(supplier?.id && supplier?.name), `supplierId=${id(supplier?.id) || 'missing'}`, OPTIONS) if (!supplier?.id) return diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 36215c2d6..7b0952e71 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -468,9 +468,9 @@ test('preload and loading versions use immediate baselines and stable automatic assert.match(loading, /自动关联保持 EDITING、身份稳定且无需额外推送版本/) assert.match(loading, /push exact replay 对计划版本历史关联和任务零漂移/) assert.match(loading, /Number\(claimedPlan\?\.version\) > beforeClaimPlanVersion/) - assert.match(loading, /MATSON_SUPPLIER_NAME = 'MATSON 开发联调船司'/) + assert.match(loading, /loadSupplier\(context, 'BOOKING', \{ carrierCode: 'MATSON' \}\)/) assert.match(loading, /category=BOOKING&page=1&size=200/) - assert.match(loading, /ensureMatsonSupplier\(context, supplierRecords\)/) + assert.match(loading, /ensureMatsonSupplier\(context\)/) assert.match(loading, /shippingCompanyId: id\(shippingCarrier\.id\), shippingCompanyName: shippingCarrier\.name/) assert.match(loading, /bookingAgentId: id\(supplier\.id\), bookingAgentName: supplier\.name/) assert.doesNotMatch(loading, /shippingCompanyId: id\(supplier\.id\), shippingCompanyName: supplier\.name/) @@ -649,8 +649,8 @@ test('customs reuses the loading chain released booking without pre-completing t assert.doesNotMatch(value, /oceanBookings\.run\(context\)/) assert.doesNotMatch(value, /serviceCenter\.run\(context\)/) assert.doesNotMatch(value, /truck\?\.finalStatus !== 'COMPLETED'/) - assert.match(value, /request\('POST', '\/api\/platform\/v1\/suppliers\?tenantId=0'/) - assert.match(value, /category: 'CUSTOMS'/) + assert.match(value, /loadSupplier\(context, 'CUSTOMS'\)/) + assert.doesNotMatch(value, /request\('POST', '\/api\/platform\/v1\/suppliers\?tenantId=0'/) assert.match(value, /context\.createdResources\.phase3CustomsSupplier/) assert.match(value, /const batchPayload = \{ customsAgentId,/) assert.doesNotMatch(value, /customsAgentName: `代理-/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index e0ed7c23a..ec389770a 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -330,7 +330,7 @@ async function reusePicker(context, index, overseasWarehouseId, access) { try { let user for (let page = 1; ; page++) { - const result = await context.request('GET', `/api/platform/v1/iam/users?page=${page}&size=100&keyword=${encodeURIComponent(actor.username)}`) + const result = await context.request('GET', `/api/platform/v1/iam/users?page=${page}&size=100`) const data = context.apiSuccess(MODULE, '核对固定PDA账号', result, '', OPTIONS) user = data?.records?.find(item => id(item.membershipId) === id(actor.membershipId) && item.username === actor.username) if (user || !data?.records?.length || page * Number(data.size || 100) >= Number(data.total || 0)) break @@ -404,6 +404,17 @@ export function pickerSessionHandoff(picker) { userUpdate: userUpdate ? { ...userUpdate, roleIds: [...userUpdate.roleIds] } : undefined } } +export async function restoreFixedPicker(context, savedPicker, index, warehouseId, access) { + const actor = context.options?.fixedCustomerId && context.options.fixedPdaActors?.find(row => + id(row.membershipId) === id(savedPicker?.membershipId) && row.username === savedPicker?.username + && id(row.warehouseId) === id(warehouseId)) + if (!actor) throw Error('原PDA身份不在当前固定账号池或仓绑定不匹配;禁止推算临时账号、启用账号或重建') + context.fixedPdaLeases ??= new Set() + const child = Object.create(context) + child.options = { ...context.options, fixedPdaActors: [actor] } + return createPicker(child, index, warehouseId, access) +} + export async function disablePicker(context, picker, index) { if (!longId(picker?.membershipId)) return if (picker.reused) { diff --git a/scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs b/scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs index ace25d8fc..2d3f51417 100644 --- a/scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs +++ b/scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs @@ -70,25 +70,23 @@ test('第二客户准备运行真实fixture编排但不替换主客户内存与 process.env.MERCHANT_TEST_PASSWORD = 'primary-placeholder' const primary = { context: { merchantQualificationFixture: { tenantId: 'A' } }, createdResources: {} } const secondary = Object.create(primary) - Object.assign(secondary, { marker: 'OFFLINE-20260915-B', options: {}, context: { ...primary.context }, createdResources: {}, + Object.assign(secondary, { marker: 'OFFLINE-20260915-B', options: { fixedCustomerId: '101', merchantUsername: 'secondary-user', merchantPassword: 'secondary-placeholder' }, context: { ...primary.context }, createdResources: {}, findAvailablePhone: async () => '00000000000', assert: (module, name, result, condition) => assert.ok(condition, name), apiSuccess: (module, name, result) => { assert.equal(result.body.code, '200', name); return result.body.data }, requestWithBody: async () => ({ body: { code: '200', data: {} } }), request: async (method, path) => { let data - if (path.endsWith('/iam/tenants')) data = { id: 'B', administratorUsername: 'secondary-user', temporaryPassword: 'secondary-placeholder' } - else if (path.endsWith('/submit-review')) data = { status: 'PENDING' } - else if (path.endsWith('/iam/reviews')) data = [{ id: 'review-B', tenantId: 'B' }] + if (path.endsWith('/login')) data = { accessToken: 'secondary-token', refreshToken: 'secondary-refresh' } + else if (path.endsWith('/profile')) data = { id: '101', status: 'ACTIVE', tenantName: 'Existing customer' } else if (path.endsWith('/dimension-initialization')) data = { status: 'READY' } - else if (path.endsWith('/approve')) data = {} - else if (path.endsWith('/qualifications')) data = ['BUSINESS_LICENSE', 'LEGAL_PERSON_ID'].map(qualificationType => ({ qualificationType, status: 'APPROVED' })) + else if (path.endsWith('/logout')) data = null else throw new Error(`Unexpected fixture request: ${method} ${path}`) return { http: 200, body: { code: '200', data } } } }) - await createMerchant(secondary, { publishEnvironment: false }) + await createMerchant(secondary, { publishEnvironment: false, requireDistinctCustomer: false }) assert.equal(primary.context.merchantQualificationFixture.tenantId, 'A') - assert.equal(secondary.context.merchantQualificationFixture.tenantId, 'B') + assert.equal(secondary.context.merchantQualificationFixture.tenantId, '101') assert.equal(process.env.MERCHANT_TEST_USERNAME, 'primary-user') assert.equal(process.env.MERCHANT_TEST_PASSWORD, 'primary-placeholder') assert.ok(!JSON.stringify(secondary.createdResources).includes('secondary-placeholder')) diff --git a/scripts/client-test-env-acceptance.mjs b/scripts/client-test-env-acceptance.mjs index d039bcd6d..f1ef2f498 100644 --- a/scripts/client-test-env-acceptance.mjs +++ b/scripts/client-test-env-acceptance.mjs @@ -3382,6 +3382,8 @@ async function softApiStep(module, name, request, validate, evidenceBuilder = de } async function httpRequest({ method = 'GET', path, token, headers = {}, body, responseType = 'json' }) { + if (String(method).toUpperCase() === 'POST' && /^\/api\/(platform|merchant)\/v1\/iam\/(roles|users|members|permissions)$/.test(new URL(path, 'https://acceptance.invalid').pathname.replace(/\/$/, ''))) + throw new Error('固定资源验收禁止新增角色、账号或权限,请复用已配置身份') if (String(method).toUpperCase() === 'POST' && ['/api/auth/register', '/api/platform/v1/iam/tenants'].includes(new URL(path, 'https://acceptance.invalid').pathname.replace(/\/$/, ''))) { throw new Error('固定客户模式禁止注册或创建客户') diff --git a/scripts/fba-remaining-nodes-acceptance-pda-operator.mjs b/scripts/fba-remaining-nodes-acceptance-pda-operator.mjs index b6d675547..6e75dd6d2 100644 --- a/scripts/fba-remaining-nodes-acceptance-pda-operator.mjs +++ b/scripts/fba-remaining-nodes-acceptance-pda-operator.mjs @@ -1,13 +1,15 @@ import fs from 'node:fs' import path from 'node:path' -import crypto from 'node:crypto' -// Explicit one-time TEST setup. No inventory operations, existing-user changes or role changes. +// Explicit TEST identity handoff. Reuses an existing PDA account without changing roles or account state. export async function createF33PdaOperator({ env, credentialFile, reportFile }) { if (String(env.CHONGCHONG_ENV).toUpperCase() !== 'TEST') throw Error('EXPLICIT_TEST_REQUIRED') if (fs.existsSync(credentialFile) || fs.existsSync(reportFile)) throw Error('EXISTING_RESULT_RECONCILE_NO_RECREATE') const warehouseId = '193000000000200003', roleCode = 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' - const report = { startedAt: new Date().toISOString(), warehouseId, roleCode, requests: [], success: false, + const actors = JSON.parse(env.CHONGCHONG_ACCEPTANCE_PDA_ACTORS || '[]') + const actor = actors.find(item => String(item.warehouseId) === warehouseId && item.membershipId && item.username && item.password) + if (!actor) throw Error('EXISTING_PDA_ACCOUNT_CONFIGURATION_REQUIRED_NO_CREATE') + const report = { reused: true, startedAt: new Date().toISOString(), warehouseId, roleCode, requests: [], success: false, existingAccountsChanged: false, rolePermissionsChanged: false, businessOperationsIssued: 0 } const save = () => { fs.mkdirSync(path.dirname(reportFile), {recursive:true}); fs.writeFileSync(reportFile, JSON.stringify(report,null,2)) } let admin, pda @@ -27,34 +29,30 @@ export async function createF33PdaOperator({ env, credentialFile, reportFile }) const role=roles.find(r=>r.roleCode===roleCode&&r.status==='ACTIVE') const operate=permissions.find(p=>p.permissionCode==='lastleg:pda:operate') if(!role||!operate||!(role.permissionIds||[]).some(id=>String(id)===String(operate.id)))throw Error('EXISTING_ROLE_OPERATE_PERMISSION_REQUIRED') - let phone - for(let i=0;i<10;i++){ - const candidate='18'+String(crypto.randomInt(0,1000000000)).padStart(9,'0') - if(await call('GET',`/platform/v1/iam/tenants/administrator-phone-availability?phone=${candidate}`)){phone=candidate;break} + let created + for (let page=1;page<=100;page++) { + const users=await call('GET',`/platform/v1/iam/users?page=${page}&size=100`) + if(!Array.isArray(users?.records)||!Number.isSafeInteger(Number(users.total)))throw Error('EXISTING_ACCOUNT_PAGE_INVALID') + created=users.records.find(row=>String(row.membershipId)===String(actor.membershipId)&&row.username===actor.username) + if(created||page*100>=Number(users.total))break } - if(!phone)throw Error('NO_AVAILABLE_SYNTHETIC_PHONE') - const username='pda_f33_20261004_'+crypto.randomBytes(5).toString('hex') - const password='Pda1!'+crypto.randomBytes(18).toString('hex') - const writeCredentials = membershipId => fs.writeFileSync(credentialFile, - `PDA_USERNAME=${username}\nPDA_PASSWORD=${password}\nPDA_MEMBERSHIP_ID=${membershipId}\n`,{mode:0o600}) - fs.mkdirSync(path.dirname(credentialFile),{recursive:true}) - fs.writeFileSync(credentialFile,`PDA_USERNAME=${username}\nPDA_PASSWORD=${password}\nPDA_MEMBERSHIP_ID=\n`,{flag:'wx',mode:0o600}) - const created=await call('POST','/platform/v1/iam/users',{username,password,phone, - displayName:'F33海外PDA独立验收-20261004',email:username+'@example.com',tenantId:'0',roleIds:[String(role.id)], - overseasWarehouseId:warehouseId,domesticWarehouseId:null,status:'ACTIVE'}) + if(!created)throw Error('EXISTING_ACCOUNT_NOT_FOUND_NO_CREATE') + const username=actor.username,password=actor.password report.subject={membershipId:String(created.membershipId),userId:String(created.userId),authUserId:String(created.authUserId), tenantId:String(created.tenantId),roleIds:(created.roleIds||[]).map(String),overseasWarehouseId:String(created.overseasWarehouseId), domesticWarehouseId:created.domesticWarehouseId??null,status:created.status,protectedAccount:created.protectedAccount} - writeCredentials(report.subject.membershipId);save() + save() if(report.subject.tenantId!=='0'||report.subject.overseasWarehouseId!==warehouseId||report.subject.status!=='ACTIVE' - ||report.subject.domesticWarehouseId!==null||report.subject.roleIds.length!==1||report.subject.roleIds[0]!==String(role.id))throw Error('CREATED_SUBJECT_SCOPE_MISMATCH') + ||report.subject.domesticWarehouseId!==null||report.subject.roleIds.length!==1||report.subject.roleIds[0]!==String(role.id))throw Error('EXISTING_SUBJECT_SCOPE_MISMATCH') + fs.mkdirSync(path.dirname(credentialFile),{recursive:true}) + fs.writeFileSync(credentialFile,`PDA_USERNAME=${username}\nPDA_PASSWORD=${password}\nPDA_MEMBERSHIP_ID=${actor.membershipId}\n`,{flag:'wx',mode:0o600}) pda=await call('POST','/auth/login',{loginId:username,password},'') if(pda.passwordChangeRequired||pda.mustChangePassword||pda.firstLogin||!pda.accessToken)throw Error('FIRST_PASSWORD_CHANGE_OR_NORMAL_LOGIN_BLOCKED') if(!(pda.authorities||[]).includes('lastleg:pda:operate')||!(pda.roles||[]).includes(roleCode)||(pda.roles||[]).length!==1)throw Error('NORMAL_LOGIN_ROLE_OR_PERMISSION_MISMATCH') const profile=await call('GET','/pda/v1/last-leg/receiving-containers/warehouse-profile',undefined,pda.accessToken) report.profile={warehouseId:String(profile.warehouseId),warehouseCode:profile.warehouseCode,warehouseName:profile.warehouseName} if(report.profile.warehouseId!==warehouseId||report.profile.warehouseCode!=='US-LAX-01')throw Error('FORMAL_WAREHOUSE_PROFILE_MISMATCH') - report.cleanup={method:'PUT',path:`/platform/v1/iam/users/${report.subject.membershipId}/status`,body:{status:'DISABLED'},executeNow:false} + report.cleanup={sessionLogoutOnly:true,sharedAccountStateUnchanged:true} report.status='ACTIVE_FOR_COORDINATOR_UI';report.loginVerified=true;report.warehouseProfileVerified=true;report.success=true }catch(error){report.failure=String(error.message).replace(/https?:\/\/\S+/g,'[redacted]')} finally{ diff --git a/scripts/fleet-capacity-pool-gray.test.mjs b/scripts/fleet-capacity-pool-gray.test.mjs index 58c3ea57a..f80bc6fe3 100644 --- a/scripts/fleet-capacity-pool-gray.test.mjs +++ b/scripts/fleet-capacity-pool-gray.test.mjs @@ -6,13 +6,16 @@ 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' +import { AcceptanceRun, parseArguments, validateExecutionOptions, createFleetPoolGrayOptions, grayFixedCustomerOptions } 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' } + CHONGCHONG_ADMIN_USERNAME: 'offline-user', CHONGCHONG_ADMIN_PASSWORD: 'offline-secret', + CHONGCHONG_ACCEPTANCE_CUSTOMER_ID: '100', CHONGCHONG_MERCHANT_USERNAME: 'existing-a', CHONGCHONG_MERCHANT_PASSWORD: 'offline-a', + CHONGCHONG_ACCEPTANCE_SECONDARY_CUSTOMER_ID: '101', CHONGCHONG_SECONDARY_MERCHANT_USERNAME: 'existing-b', CHONGCHONG_SECONDARY_MERCHANT_PASSWORD: 'offline-b', + CHONGCHONG_ACCEPTANCE_FLEET_RESOURCES: '[{"vehicleId":"501","driverId":"502"}]' } const transport = { gateway: 'https://gray.example.invalid', username: 'offline-user', password: 'offline-secret', - output: 'reports/fleet-pool-gray-offline', markerNamespace: 'gray-offline' } + output: 'reports/fleet-pool-gray-offline', markerNamespace: 'gray-offline', ...grayFixedCustomerOptions(env) } 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') @@ -53,6 +56,9 @@ test('dedicated constructor factory is branded frozen gray and carries no generi assert.equal(options.executionScope, 'FLEET_CAPACITY_POOL_GRAY') assert.deepEqual(options.modules, []); assert.deepEqual(options.requestedModules, []) assert.equal(options.verifyUnloadEventRecovery, false) + assert.equal(options.fixedCustomerId, '100'); assert.equal(options.fixedSecondaryCustomer.customerId, '101') + assert.ok(Object.isFrozen(options.fixedSecondaryCustomer)) + assert.deepEqual(context.options.fixedFleetResources, [{ vehicleId: '501', driverId: '502' }]) assert.throws(() => options.modules.push('phase5-root-fulfillment'), TypeError) assert.throws(() => new AcceptanceRun({ ...options }), /gray 仅支持/) assert.throws(() => createFleetPoolGrayOptions({ ...transport, modules: ['phase5-root-fulfillment'] }), /configuration/) @@ -61,6 +67,16 @@ test('dedicated constructor factory is branded frozen gray and carries no generi assert.throws(() => createFleetPoolGrayOptions({ ...transport, gateway: 'https://gray.example.invalid/api' }), /HTTPS/) }) +test('gray pool missing either existing customer or using the same customer stops before evidence, files or HTTP', async () => { + const config = parseGrayPoolArguments(args) + for (const key of ['CHONGCHONG_ACCEPTANCE_CUSTOMER_ID', 'CHONGCHONG_MERCHANT_PASSWORD', + 'CHONGCHONG_ACCEPTANCE_SECONDARY_CUSTOMER_ID', 'CHONGCHONG_SECONDARY_MERCHANT_PASSWORD']) { + const bad = { ...env }; delete bad[key] + await assert.rejects(runFleetPoolGrayCrossCustomer(config, { env: bad, verify: () => assert.fail('no verify'), contextFactory: () => assert.fail('no context') }), /固定客户|第二个客户|GRAY 必须配置/) + } + assert.throws(() => grayFixedCustomerOptions({ ...env, CHONGCHONG_ACCEPTANCE_SECONDARY_CUSTOMER_ID: 'TEN100' }), /必须不同/) +}) + 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 仅支持/) diff --git a/scripts/fleet-capacity-pool-test-env.mjs b/scripts/fleet-capacity-pool-test-env.mjs index 2760bfc97..512b18b68 100644 --- a/scripts/fleet-capacity-pool-test-env.mjs +++ b/scripts/fleet-capacity-pool-test-env.mjs @@ -1,10 +1,12 @@ +import { openFixedActor, requireFixedActors, POOL_ACTOR_SLOTS } from './admin-test-env-modules/fixed-test-actors.mjs' import { mkdirSync, openSync, writeFileSync, fsyncSync, closeSync, readFileSync } from 'node:fs' import { resolve, dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' -import { createHash, randomUUID } from 'node:crypto' +import { createHash } from 'node:crypto' import { AcceptanceRun } from './admin-test-env-acceptance.mjs' +import { normalizeFixedCustomerId } from './admin-test-env-modules/merchant-qualification-fixture.mjs' import { parseCrossCustomerArguments, runCrossCustomer } from './fleet-cross-customer-test-env.mjs' -import { guardFleetWrites, redactFleetWrite, findFleetActorPhone } from './fleet-test-env-acceptance.mjs' +import { guardFleetWrites, redactFleetWrite } from './fleet-test-env-acceptance.mjs' import { confirmFleetLoading } from './admin-test-env-modules/fleet-trip-fulfillment.mjs' import { testPlannedCapacity } from './admin-test-env-modules/fleet-booking-reservation-acceptance.mjs' import { pickAll, saveReceiptAndComplete, readAutomaticDispatchDelivery } from './admin-test-env-modules/phase5-overseas-fulfillment.mjs' @@ -291,25 +293,14 @@ async function poolScenario(context, fixture, { execute = false, readInventory, check(label, denied(response) && response.requestId && response.traceId, response) return response } - const permissions = await request('GET', `${IAM}/permissions`) + requireFixedActors(context.options, POOL_ACTOR_SLOTS) const actorSessions = [], merchantSessions = [] const businessPath = order => `${API}/${order.businessType === 'US_TRUCK' ? 'us-truck-orders' : 'local-looseboard-orders'}/${encode(order.businessOrderNo)}?customerId=${encode(order.customerId)}` const createActor = async (kind, index, codes) => { - const suffix = createHash('sha256').update(`${context.marker}:${kind}:${index}`).digest('hex').slice(0, 16) - const found = codes.map(code => permissions.filter(p => p.permissionCode === code && p.status === 'ACTIVE')) - check('Exact pool permissions exist', found.every(rows => rows.length === 1 && rows[0].id)) - const role = await request('POST', `${IAM}/roles`, { roleCode: `POOL_${kind}_${suffix}`.toUpperCase(), roleName: `${context.marker}-${kind}-${index}`, description: context.marker }) - const actor = { kind, index, roleId: id(role.id) }; proof.actors.push(actor) - await request('PUT', `${IAM}/roles/permissions`, { roleId: actor.roleId, permissionIds: found.map(rows => id(rows[0].id)) }) - const username = `pool_${kind}_${suffix}`, password = `Pool${randomUUID().replaceAll('-', '')}A9` - const user = await request('POST', `${IAM}/users`, { username, displayName: `${context.marker}-同名测试人员`, - phone: await findFleetActorPhone(context, suffix), email: `${username}@example.com`, tenantId: '0', roleIds: [actor.roleId], password, status: 'ACTIVE' }) - actor.membershipId = id(user.membershipId) - const login = await request('POST', '/api/auth/login', { loginId: username, password }, '') - actorSessions.push({ accessToken: login.accessToken, refreshToken: login.refreshToken }) - const effective = await request('GET', `${IAM}/authorization/permissions`, undefined, login.accessToken) - check('Temporary actor has exact permissions', Array.isArray(effective) && JSON.stringify([...effective].sort()) === JSON.stringify([...codes].sort())) - return login.accessToken + const session = await openFixedActor(context, `pool.${kind}.${index}`, codes) + actorSessions.push(session) + proof.actors.push({ kind, index, roleId: session.roleId, membershipId: session.membershipId, username: session.username, reused: true }) + return session.token } try { const sales = [], dispatchers = [] @@ -531,11 +522,8 @@ async function poolScenario(context, fixture, { execute = false, readInventory, return proof } catch (error) { proof.status = 'FAILED'; throw error } finally { - for (const actor of proof.actors) { - if (actor.membershipId) await request('PUT', `${IAM}/users/${actor.membershipId}/status`, { status: 'DISABLED' }) - if (actor.roleId) await request('PUT', `${IAM}/roles/${actor.roleId}/status`, { status: 'DISABLED' }) - } - // The outer runner tracks and logs out all successful login sessions, including these. + const closed = await Promise.allSettled(actorSessions.map(actor => actor.close())) + if (closed.some(row => row.status === 'rejected')) throw Error('固定拼车账号会话退出未全部确认') actorSessions.length = 0; merchantSessions.length = 0 } } @@ -653,14 +641,12 @@ export async function runPoolAcceptance(config, { env = process.env, outputRoot try { const result = await runner(config, { env, outputRoot, verificationScope: 'CAPACITY_POOL', contextFactory: options => { - const context = new AcceptanceRun(options), original = context.request - context.request = async function (...args) { - const response = await original.apply(this, args) - if (args[0] === 'POST' && args[1] === `${IAM}/tenants` && success(response)) { - const data = response.body.data, customer = data.tenant || data.customer || data - credentials.set(id(customer.id || data.tenantId), { username: data.administratorUsername, password: data.temporaryPassword }) - } - return response + const context = new AcceptanceRun(options) + requireFixedActors(context.options, POOL_ACTOR_SLOTS) + for (const customer of [{ customerId: context.options.fixedCustomerId, username: context.options.merchantUsername, + password: context.options.merchantPassword }, context.options.fixedSecondaryCustomer]) { + if (customer?.customerId && customer.username && customer.password) + credentials.set(normalizeFixedCustomerId(customer.customerId), { username: customer.username, password: customer.password }) } const directory = join(outputRoot, 'reports', poolReportName(config), 'pool-write-journal') mkdirSync(directory) @@ -670,7 +656,12 @@ export async function runPoolAcceptance(config, { env = process.env, outputRoot }, { guardFetch: true }) return context }, - verify: (context, fixture, options) => poolScenario(context, fixture, { ...options, credentials }) }) + verify: (context, fixture, options) => { + requireThat(fixture?.members?.length === 2 && new Set(fixture.members.map(member => id(member.customerId))).size === 2 + && fixture.members.every(member => credentials.has(id(member.customerId))), + 'CURRENT_FIXED_POOL_CUSTOMER_CREDENTIALS_REQUIRED') + return poolScenario(context, fixture, { ...options, credentials }) + } }) 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', poolReportName(config)) diff --git a/scripts/fleet-capacity-pool-test-env.test.mjs b/scripts/fleet-capacity-pool-test-env.test.mjs index 29500b083..1cea63947 100644 --- a/scripts/fleet-capacity-pool-test-env.test.mjs +++ b/scripts/fleet-capacity-pool-test-env.test.mjs @@ -1,6 +1,9 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' +import { readFileSync, mkdtempSync, mkdirSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { createFleetPoolGrayOptions, parseArguments } from './admin-test-env-acceptance.mjs' import { parsePoolArguments, poolPlan, runPoolAcceptance, assertPoolRemaining, assertSingleCommercial, shrinkingVehicleRequest, expiryWaitMs, poolLocalDateTime, poolPickScope, pickPoolDispatch, POOL_COVERAGE, POOL_REMAINING, poolInquiryWrite, poolReadIsDenied, assertPoolBoundCapacity, verifyPoolConfirmedCancellation, verifyPoolBindingRevalidation, @@ -315,3 +318,35 @@ test('pool execution explicitly selects capacity verification before handing off } }), /STOP_BEFORE_ENVIRONMENT_ACCESS/) assert.equal(selected, 'CAPACITY_POOL') }) + +test('TEST and GRAY pool reuse configured customer credentials without a creation response and reject unrelated fixture customers', async () => { + const original = globalThis.fetch + globalThis.fetch = async () => assert.fail('credential handoff must not need customer creation or HTTP') + const root = mkdtempSync(join(tmpdir(), 'pool-fixed-customer-contract-')) + try { + for (const environment of ['TEST', 'GRAY']) { + const config = { execute: true, environment, runId: 'existing-customers', warehouseId: '10', destinationWarehouseId: '11' } + const transport = { gateway: 'https://gray.example.invalid', username: 'admin', password: 'offline-admin', + output: 'reports/unused', markerNamespace: 'offline', fixedCustomerId: 'TEN100', merchantUsername: 'existing-a', + merchantPassword: 'offline-a', fixedSecondaryCustomer: { customerId: 'TEN101', username: 'existing-b', password: 'offline-b' }, + fixedActors: Object.fromEntries(['pool.sales.0', 'pool.sales.1', 'pool.dispatch.0', 'pool.dispatch.1', 'pool.fulfillment.0'].map((slot, index) => + [slot, { membershipId: String(500 + index), roleId: '600', roleCode: 'FIXED_OFFLINE', username: `fixed-${index}`, password: 'offline-password' }])) } + const options = environment === 'GRAY' ? createFleetPoolGrayOptions(transport) + : { ...parseArguments(['--modules', 'merchant-qualification-fixture']), ...transport, environment: 'test' } + const directory = environment === 'GRAY' ? 'fleet-pool-gray-existing-customers' : 'fleet-cross-existing-customers' + mkdirSync(join(root, 'reports', directory), { recursive: true }) + await assert.rejects(runPoolAcceptance(config, { outputRoot: root, runner: async (_config, dependencies) => { + const context = dependencies.contextFactory(options) + const fixture = { members: [{ customerId: '100' }, { customerId: '101' }] } + assert.equal((await dependencies.verify(context, fixture, { execute: false })).execute, false) + for (const invalid of [{ members: [{ customerId: '100' }, { customerId: '999' }] }, { members: [{ customerId: '100' }] }, + { members: [{ customerId: '100' }, { customerId: '100' }] }]) + assert.throws(() => dependencies.verify(context, invalid, { execute: true }), /CURRENT_FIXED_POOL_CUSTOMER_CREDENTIALS_REQUIRED/) + throw Error('STOP_AFTER_CREDENTIAL_HANDOFF') + } }), /STOP_AFTER_CREDENTIAL_HANDOFF/) + } + } finally { + globalThis.fetch = original + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/scripts/fleet-capacity-pool-ui-fixture-gray.test.mjs b/scripts/fleet-capacity-pool-ui-fixture-gray.test.mjs index 3360ecb72..dace00f11 100644 --- a/scripts/fleet-capacity-pool-ui-fixture-gray.test.mjs +++ b/scripts/fleet-capacity-pool-ui-fixture-gray.test.mjs @@ -8,7 +8,9 @@ import { PRIVATE_DIRECTORY, runGrayUiFixture, runUiFixture, uiActorPermissions, 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' } + CHONGCHONG_ADMIN_USERNAME: 'offline', CHONGCHONG_ADMIN_PASSWORD: 'offline-only', + CHONGCHONG_ACCEPTANCE_CUSTOMER_ID: '100', CHONGCHONG_MERCHANT_USERNAME: 'existing-a', CHONGCHONG_MERCHANT_PASSWORD: 'offline-a', + CHONGCHONG_ACCEPTANCE_SECONDARY_CUSTOMER_ID: '101', CHONGCHONG_SECONDARY_MERCHANT_USERNAME: 'existing-b', CHONGCHONG_SECONDARY_MERCHANT_PASSWORD: 'offline-b' } 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') @@ -40,6 +42,14 @@ test('GRAY UI refuses TEST profile or evidence overrides and TEST runner rejects await assert.rejects(runGrayUiFixture(config, { env: { ...env, CHONGCHONG_ADMIN_API_BASE_URL: 'http://gray.example.invalid/api' } }), /INVALID_UI_API_BASE/) }) +test('GRAY UI missing second fixed customer stops before context private files or network', async () => { + const original = globalThis.fetch; globalThis.fetch = () => assert.fail('no network') + try { + const missing = { ...env }; delete missing.CHONGCHONG_ACCEPTANCE_SECONDARY_CUSTOMER_ID + await assert.rejects(runGrayUiFixture(parseGrayUiFixtureArguments(args), { env: missing }), /第二个客户/) + } finally { globalThis.fetch = original } +}) + 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) diff --git a/scripts/fleet-capacity-pool-ui-fixture-test-env.mjs b/scripts/fleet-capacity-pool-ui-fixture-test-env.mjs index 3f3f3c303..96387d281 100644 --- a/scripts/fleet-capacity-pool-ui-fixture-test-env.mjs +++ b/scripts/fleet-capacity-pool-ui-fixture-test-env.mjs @@ -1,16 +1,17 @@ +import { openFixedActor, requireFixedActors, UI_ACTOR_SLOTS } from './admin-test-env-modules/fixed-test-actors.mjs' import { mkdirSync, openSync, closeSync, writeFileSync, writeSync, ftruncateSync, fsyncSync, unlinkSync, readFileSync, realpathSync } from 'node:fs' 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, createFleetPoolGrayOptions } from './admin-test-env-acceptance.mjs' +import { createHash } from 'node:crypto' +import { AcceptanceRun, parseArguments, validateExecutionOptions, buildAcceptanceSummary, createFleetPoolGrayOptions, grayFixedCustomerOptions } 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 { testPlannedCapacity } from './admin-test-env-modules/fleet-booking-reservation-acceptance.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 { guardFleetWrites, redactFleetWrite } from './fleet-test-env-acceptance.mjs' import { createPicker, disablePicker, resolvePickerAccess } from './admin-test-env-modules/phase4-overseas-pda.mjs' const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') @@ -139,19 +140,20 @@ async function runUiFixtureCore(config, { env = process.env, outputRoot = ROOT, const endpoint = new URL(env.CHONGCHONG_ADMIN_API_BASE_URL) 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') + const fixedCustomers = gray ? grayFixedCustomerOptions(env) : {} 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({ + const options = gray ? createFleetPoolGrayOptions({ ...transport, ...fixedCustomers }) : 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 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() + const sessions = new Map() let context, picker, lock, restore, initialized = false, requestNumber = 0, inventoryNumber = 0, report const save = (name, value) => writeFileSync(join(output, name), JSON.stringify(publicUiEvidence(value, secrets), null, 2) + '\n') const writePrivate = () => vault.save(secretDocument) @@ -169,6 +171,8 @@ async function runUiFixtureCore(config, { env = process.env, outputRoot = ROOT, save('manifest.json', { ...uiFixturePlan(config), sourceSha256: createHash('sha256').update(readFileSync(fileURLToPath(import.meta.url))).digest('hex') }) await readers.preflight() context = contextFactory(options) + requireFixedActors(context.options, UI_ACTOR_SLOTS) + secrets.push(...Object.values(context.options.fixedActors).map(actor => actor.password), ...(context.options.fixedPdaActors || []).map(actor => actor.password)) secretDocument.marker = context.marker; writePrivate() const proof = context.createdResources.fleetCapacityPoolUi = { marker: context.marker, status: 'PREPARING', actors: [], drafts: [], fullAcceptance: false } for (const method of ['request', 'requestWithBody']) { @@ -177,25 +181,16 @@ async function runUiFixtureCore(config, { env = process.env, outputRoot = ROOT, 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, 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) - if (ok(response) && verb === 'POST' && path === `${IAM}/tenants`) { - const data = response.body.data, customer = data.tenant || data.customer || data - remember({ kind: 'CUSTOMER', customerId: id(customer.id || data.tenantId), customerName: customer.tenantName || customer.customerName, - username: data.administratorUsername, password: data.temporaryPassword, status: 'CREATED' }) - } - if (ok(response) && verb === 'POST' && path === `${IAM}/users`) { - const data = response.body.data - remember({ kind: kinds.get(body.username) || (body.username.startsWith('pda_overseas_') ? 'FIXTURE_PDA' : 'FIXTURE_USER'), - username: body.username, password: body.password, membershipId: id(data.membershipId), roleIds: data.roleIds, status: 'ACTIVE' }) - } if (ok(response) && path === '/api/auth/login' && response.body.data?.accessToken) { const auth = response.body.data sessions.set(auth.accessToken, auth.refreshToken); secrets.push(auth.accessToken, auth.refreshToken) + // 固定客户不经过建客回执;只将显式配置且成功登录的既有凭据交接到私有文件。 + const customer = [{ customerId: context.options.fixedCustomerId, username: context.options.merchantUsername, password: context.options.merchantPassword }, context.options.fixedSecondaryCustomer] + .find(row => row?.customerId && row.username === body?.loginId && row.password === body?.password) + if (customer) remember({ kind: 'CUSTOMER', customerId: String(customer.customerId).replace(/^TEN/, ''), username: customer.username, + password: customer.password, status: 'REUSED' }) } save(`requests/${sequence}.json`, { stage: 'RESPONSE', sequence, method: verb, path, body: body instanceof FormData ? '[MULTIPART]' : body, response }) save('progress.json', { marker: context.marker, createdResources: context.createdResources, steps: context.steps }) @@ -221,6 +216,10 @@ async function runUiFixtureCore(config, { env = process.env, outputRoot = ROOT, check('Fixture PDA role exists', Boolean(access)) picker = await createPicker(context, 93, config.warehouseId, access) check('Fixture PDA authenticated', Boolean(picker?.token)) + const configuredPda = context.options.fixedPdaActors?.find(actor => String(actor.membershipId) === String(picker.membershipId)) + check('PDA handoff uses the configured existing account', picker.reused && configuredPda?.username === picker.username && configuredPda.password) + remember({ kind: 'FIXTURE_PDA', membershipId: picker.membershipId, username: picker.username, password: configuredPda.password, + warehouseId: config.warehouseId, status: 'REUSED' }) const readInventory = async scope => { const snapshot = await readers.readFleetInventory(scope); save(`evidence/inventory-${++inventoryNumber}.json`, snapshot); return snapshot } @@ -257,25 +256,16 @@ async function runUiFixtureCore(config, { env = process.env, outputRoot = ROOT, await request('POST', `${tripPath}/capacity-plan`, { recordVersion: plan.recordVersion, manifestHash: plan.manifestHash, pallets: plannedPallets, reason: '仅UI验收:自有计划18/总30,不作为实际发车计量' }) proof.planEvidence = { actual: false, pallets: plannedPallets, reason: 'TEST UI销售计划18/30,不是实载测量' } - const permissions = await request('GET', `${IAM}/permissions`), actorTokens = [] + requireFixedActors(context.options, UI_ACTOR_SLOTS) + const actorTokens = [] 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 = 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验收账号' }) - proof.actors.push({ kind, roleId: id(role.id) }) - await request('PUT', `${IAM}/roles/permissions`, { roleId: id(role.id), permissionIds: candidates.map(rows => id(rows[0].id)) }) - kinds.set(username, kind) - const password = `Ui${randomUUID().replaceAll('-', '')}A9`; remember({ kind, username, password, roleIds: [id(role.id)], status: 'PREPARED' }) - const user = await request('POST', `${IAM}/users`, { username, password, displayName: `${context.marker}-同名销售`, - phone: await findFleetActorPhone(context, suffix), email: `${username}@example.com`, tenantId: '0', roleIds: [id(role.id)], status: 'ACTIVE' }) - proof.actors.at(-1).membershipId = id(user.membershipId) - const auth = await request('POST', '/api/auth/login', { loginId: username, password }, '') - const effective = await request('GET', `${IAM}/authorization/permissions`, undefined, auth.accessToken) - check('UI actor has only its exact minimal permissions', JSON.stringify([...effective].sort()) === JSON.stringify([...expected].sort())) - actorTokens.push(auth.accessToken) + const configured = context.options.fixedActors[`pool-ui.${kind}`] + secrets.push(configured.password) + const actor = await openFixedActor(context, `pool-ui.${kind}`, uiActorPermissions(kind, gray)) + proof.actors.push({ kind, roleId: actor.roleId, membershipId: actor.membershipId, username: actor.username, reused: true }) + remember({ kind, username: actor.username, password: configured.password, membershipId: actor.membershipId, roleIds: [actor.roleId], status: 'REUSED' }) + actorTokens.push(actor.token) } detail = await request('GET', tripPath) const draft = await request('POST', POOL, { tripNo: fixture.tripNo, tripRecordVersion: detail.trip.recordVersion, @@ -316,7 +306,8 @@ async function runUiFixtureCore(config, { env = process.env, outputRoot = ROOT, check('Customer login belongs to this fixture customer', id(profile.id) === member.customerId) for (const type of ['local-looseboard-orders', 'us-truck-orders']) { const page = await request('GET', `/api/merchant/v1/last-leg/${type}?page=1&size=20`, undefined, auth.accessToken) - check('Fresh customer commercial order page provides real empty state before UI confirmation', Number(page.total) === 0 && page.records.length === 0) + check('Existing customer commercial order page is valid without requiring an empty account', Array.isArray(page.records) + && Number.isSafeInteger(Number(page.total)) && Number(page.total) >= page.records.length) } account.status = 'ACTIVE'; writePrivate() } @@ -344,8 +335,8 @@ async function runUiFixtureCore(config, { env = process.env, outputRoot = ROOT, try { if (context) { const pdaAccount = secretDocument.accounts.find(row => row.kind === 'FIXTURE_PDA' && row.membershipId) - if (gray && pdaAccount) { - pdaAccount.status = 'RETAINED_FOR_UI'; pdaAccount.purpose = 'GRAY_WAREHOUSE_BOUND_PDA_H5_ACCEPTANCE' + if (picker?.reused && pdaAccount) { + pdaAccount.status = 'RETAINED_FOR_UI'; pdaAccount.purpose = `${config.environment}_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)) { @@ -353,7 +344,7 @@ async function runUiFixtureCore(config, { env = process.env, outputRoot = ROOT, const previousSteps = context.steps.length await disablePicker(context, picker?.membershipId ? picker : pdaAccount, 93) demand(context.steps.length > previousSteps && context.steps.at(-1)?.passed === true, 'PDA_DISABLE_NOT_CONFIRMED') - if (pdaAccount) { pdaAccount.status = 'DISABLED_AFTER_FIXTURE'; writePrivate() } + if (pdaAccount) { pdaAccount.status = picker?.reused ? 'REUSED' : 'DISABLED_AFTER_FIXTURE'; writePrivate() } } catch { if (pdaAccount) pdaAccount.status = 'DISABLE_REQUIRES_REVIEW' report.success = false; report.cleanupError = 'PDA_DISABLE_REQUIRES_REVIEW' diff --git a/scripts/fleet-company-cost-test-env.mjs b/scripts/fleet-company-cost-test-env.mjs index 434795a29..c819895dd 100644 --- a/scripts/fleet-company-cost-test-env.mjs +++ b/scripts/fleet-company-cost-test-env.mjs @@ -18,8 +18,8 @@ export function requireTestProfile(execArgs) { const joined = execArgs.find(value => value.startsWith('--env-file=')) const index = execArgs.indexOf('--env-file') const file = joined?.slice('--env-file='.length) || (index >= 0 ? execArgs[index + 1] : '') - if (!file || !resolve(file).replaceAll('\\', '/').endsWith('/.codex/env/chongchong-global/test-acceptance.env')) - throw new Error('须在脚本前显式指定授权目录的test-acceptance.env,禁止复用其他环境或任务会话文件') + if (!file || !resolve(file).replaceAll('\\', '/').endsWith('/.codex/env/chongchong-global/test/acceptance.env')) + throw new Error('须在脚本前显式指定授权目录的test/acceptance.env,禁止复用其他环境或任务会话文件') return resolve(file) } @@ -35,8 +35,11 @@ export async function execute(args = process.argv.slice(2), execArgs = process.e const repository = resolve(dirname(fileURLToPath(import.meta.url)), '..') if (!repository.replaceAll('\\', '/').endsWith('/chongchong-global/baiyue-firstleg')) throw new Error('公司成本验收只允许在登记的TEST项目运行') const output = resolve(repository, `reports/fleet-cost-live-20261003/${preflight ? `preflight-${new Date().toISOString().replace(/\D/g, '').slice(0,17)}` : 'api-report'}.json`) + const fixedCompanyCostResources = JSON.parse(env.CHONGCHONG_ACCEPTANCE_COMPANY_COST_RESOURCES || 'null') + if (!preflight && (!fixedCompanyCostResources || !['supplierId', 'vehicleId'].every(key => typeof fixedCompanyCostResources[key] === 'string' && /^[1-9]\d*$/.test(fixedCompanyCostResources[key])))) + throw new Error('请配置 CHONGCHONG_ACCEPTANCE_COMPANY_COST_RESOURCES 的既有 supplierId/vehicleId,禁止自动创建') await mkdir(dirname(output), { recursive: true }) - const context = new AcceptanceRun({ gateway: base.origin, quiet: true, modules: [] }) + const context = new AcceptanceRun({ gateway: base.origin, quiet: true, modules: [], fixedCompanyCostResources }) context.marker = MARKER const report = { marker: MARKER, mode: preflight ? 'READ_ONLY_PREFLIGHT' : 'BUSINESS_ACCEPTANCE', expectedCommit: READY_COMMIT, status: 'PREPARING', startedAt: new Date().toISOString(), session: { login: 'NOT_STARTED', logout: 'NOT_STARTED' }, createdResources: context.createdResources, steps: context.steps } let refresh = '', failure diff --git a/scripts/fleet-cross-customer-test-env.mjs b/scripts/fleet-cross-customer-test-env.mjs index a09d3edc7..054155a77 100644 --- a/scripts/fleet-cross-customer-test-env.mjs +++ b/scripts/fleet-cross-customer-test-env.mjs @@ -3,7 +3,7 @@ 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, createFleetPoolGrayOptions } from './admin-test-env-acceptance.mjs' +import { AcceptanceRun, parseArguments, validateExecutionOptions, buildAcceptanceSummary, createFleetPoolGrayOptions, grayFixedCustomerOptions } 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' @@ -34,7 +34,7 @@ export function parseCrossCustomerArguments(args) { } export function crossCustomerPlan(config) { return { environment: 'TEST', execute: config.execute, runId: config.runId, warehouseId: config.warehouseId || null, destinationWarehouseId: config.destinationWarehouseId || null, - output: `reports/fleet-cross-${config.runId}`, stages: ['two new customers', 'two ordinary transfer receipts', 'one native cross-customer dispatch and its unique automatic trip', + output: `reports/fleet-cross-${config.runId}`, stages: ['reuse two existing customers', 'two ordinary transfer receipts', 'one native cross-customer dispatch and its unique automatic trip', 'unready customer gate rejection', 'shared departure and replay', 'independent receipts', 'trip completion and separate resource release'], 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.' } } @@ -65,6 +65,7 @@ async function runCrossCustomerCore(config, { env = process.env, outputRoot = RO || !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 (!(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`) + const fixedCustomers = gray ? grayFixedCustomerOptions(env) : {} if (gray) readers = createGrayEvidenceReaders(env) else readers ||= createTestEvidenceReaders(env) // Load the reviewed producer before creating any resources. @@ -91,7 +92,7 @@ async function runCrossCustomerCore(config, { env = process.env, outputRoot = RO await readers.preflight() 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({ + const options = gray ? createFleetPoolGrayOptions({ ...transport, ...fixedCustomers }) : validateExecutionOptions({ ...parseArguments(['--modules', 'merchant-qualification-fixture', '--quiet', 'true']), ...transport, environment: 'test' }) context = contextFactory(options) const previousCheckpoint = context.checkpoint diff --git a/scripts/fleet-test-env-acceptance.mjs b/scripts/fleet-test-env-acceptance.mjs index 25f510c1f..1e519aa96 100644 --- a/scripts/fleet-test-env-acceptance.mjs +++ b/scripts/fleet-test-env-acceptance.mjs @@ -1,3 +1,4 @@ +import { openFixedActor, requireFixedActors, COST_ACTOR_SLOTS } from './admin-test-env-modules/fixed-test-actors.mjs' import { mkdirSync, writeFileSync, openSync, closeSync, unlinkSync, readFileSync, fsyncSync } from 'node:fs' import { resolve, dirname, basename, relative, isAbsolute, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' @@ -15,7 +16,7 @@ import { verifyFleetExternalDispatch } from './admin-test-env-modules/fleet-exte import { verifyFleetHandover, cleanupFleetHandoverResources } from './admin-test-env-modules/fleet-handover-acceptance.mjs' import { verifyFleetTripCosts } from './admin-test-env-modules/fleet-trip-cost-acceptance.mjs' import { verifyFleetReturnRedispatch, waitForRedispatchSignatureAudit, selectReturnSources } from './admin-test-env-modules/fleet-return-redispatch-acceptance.mjs' -import { resolvePickerAccess, createPicker, disablePicker, pickerFixtureSuffix } from './admin-test-env-modules/phase4-overseas-pda.mjs' +import { resolvePickerAccess, createPicker, disablePicker, restoreFixedPicker } from './admin-test-env-modules/phase4-overseas-pda.mjs' import { saveReceiptAndComplete } from './admin-test-env-modules/phase5-overseas-fulfillment.mjs' const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') @@ -266,7 +267,7 @@ export function reviewedFullCheckpoint(report, stage = 'FULL_COVERAGE') { || row.path !== `/api/platform/v1/last-leg/delivery-orders/${original.deliveryNo}?customerId=${proof.customerId}` || JSON.stringify(row.result.order) !== JSON.stringify(original) }) || new Set(attempt.requests.map(row => row.result?.order?.deliveryNo)).size !== 3 - || resources.fleetCostActors?.status !== 'DISABLED' || failures?.length !== 1 || failures[0].name !== '模块执行' + || resources.fleetCostActors?.status !== 'SESSIONS_CLOSED' || failures?.length !== 1 || failures[0].name !== '模块执行' || failures[0].evidence !== attempt.failure) throw Error('Return source recovery requires the exact native candidate failure, three already-delivering documents and no return/signature writes') return { resources, marker: proof.marker, returnSourceSelection: true } @@ -295,7 +296,7 @@ export function reviewedFullCheckpoint(report, stage = 'FULL_COVERAGE') { || handover.requests.some(row => (row.method !== 'GET' && row !== creation?.[0]) || row.status !== 'RESPONDED' || row.http !== 200 || id(row.code) !== '200') || !costs.final?.page || costs.final.page.records?.length !== 2 : trip.handover) || trip.closure || proof.receipts || proof.closure || trip.returnRedispatch - || resources.fleetCostActors?.status !== 'DISABLED' || !proof.picker?.membershipId + || resources.fleetCostActors?.status !== 'SESSIONS_CLOSED' || !proof.picker?.membershipId || failures?.length !== 2 || !failures.some(row => row.name === failureName) || failures.some(row => ![failureName, '模块执行'].includes(row.name))) throw Error('Departed checkpoint must be the reviewed read-only cost assertion failure, with no later business writes') @@ -637,73 +638,40 @@ export function fleetCostActorJournalData(value) { export async function prepareFleetCostActors(context) { if (!context.marker || context.createdResources.fleetCostActors || typeof context.checkpoint !== 'function') throw Error('Cost actors require fresh current-run ownership and durable checkpoints') - const proof = context.createdResources.fleetCostActors = { marker: context.marker, status: 'CREATING', actors: [], requests: [] } - const base = '/api/platform/v1/iam', sessions = [] - async function request(method, path, body, token) { - const entry = { method, path, body: fleetCostActorJournalData(body), status: 'SENDING' } + requireFixedActors(context.options, COST_ACTOR_SLOTS) + const proof = context.createdResources.fleetCostActors = { marker: context.marker, status: 'CHECKING', actors: [], requests: [] } + const sessions = [] + const io = Object.create(context) + io.request = async (...args) => { + const entry = { method: args[0], path: args[1], body: fleetCostActorJournalData(args[2]), status: 'SENDING' } proof.requests.push(entry); await context.checkpoint() try { - const response = await context.request(method, path, body, token) - Object.assign(entry, { status: 'RESPONDED', http: response?.http, code: response?.body?.code, - requestId: response?.requestId, traceId: response?.traceId, result: fleetCostActorJournalData(response?.body?.data) }) + const response = await context.request(...args) + Object.assign(entry, { status: 'RESPONDED', http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId }) await context.checkpoint() - if (!success(response) || !response.requestId || !response.traceId) throw Error(`Cost actor request failed: ${method} ${path}`) - return response.body.data + return response } catch (error) { - if (entry.status === 'SENDING') { entry.status = 'UNKNOWN_RESULT'; await context.checkpoint() } - throw error + entry.status = 'UNKNOWN_RESULT'; await context.checkpoint(); throw error } } async function close() { - const errors = [] - for (const actor of proof.actors) { - try { - if (actor.membershipId && !actor.userDisabled) { - const result = await request('PUT', `${base}/users/${actor.membershipId}/status`, { status: 'DISABLED' }) - if (result?.status !== 'DISABLED') throw Error('Cost test account was not disabled') - actor.userDisabled = true - } - if (actor.roleId && !actor.roleDisabled) { - const result = await request('PUT', `${base}/roles/${actor.roleId}/status`, { status: 'DISABLED' }) - if (result?.status !== 'DISABLED') throw Error('Cost test role was not disabled') - actor.roleDisabled = true - } - } catch (error) { errors.push(error) } - } - proof.status = errors.length ? 'CLEANUP_FAILED' : 'DISABLED'; await context.checkpoint() - if (errors.length) throw new AggregateError(errors, 'Cost actor cleanup requires review; do not recreate accounts') + const result = await Promise.allSettled(sessions.map(actor => actor.close())) + proof.status = result.some(row => row.status === 'rejected') ? 'CLEANUP_FAILED' : 'SESSIONS_CLOSED' + await context.checkpoint() + if (proof.status === 'CLEANUP_FAILED') throw Error('固定车队权限账号会话退出未全部确认') } try { - const permissions = await request('GET', `${base}/permissions`) - const list = permissions.filter(row => row.permissionCode === 'platform:fleet:trip:list' && row.status === 'ACTIVE') - if (list.length !== 1 || !list[0].id) throw Error('Active trip list permission missing or ambiguous') for (const kind of ['reader', 'denied']) { - const suffix = randomUUID().replaceAll('-', '').slice(0, 16), username = `fleet_${kind}_${suffix}` - const actor = { kind, username }; proof.actors.push(actor) - const roleCode = `FLEET_${kind.toUpperCase()}_${suffix.toUpperCase()}` - const role = await request('POST', `${base}/roles`, { roleCode, roleName: `${context.marker}-${kind}`, description: context.marker }) - if (!id(role?.id) || role.roleCode !== roleCode || role.roleType !== 'CUSTOM') throw Error('Fresh cost role ownership mismatch') - actor.roleId = id(role.id) - await request('PUT', `${base}/roles/permissions`, { roleId: actor.roleId, permissionIds: kind === 'reader' ? [id(list[0].id)] : [] }) - const password = `Fleet${randomUUID().replaceAll('-', '')}A9` - const user = await request('POST', `${base}/users`, { username, displayName: `${context.marker}-${kind}`, - phone: await findFleetActorPhone(context, suffix), email: `${username}@example.com`, tenantId: '0', - roleIds: [actor.roleId], password, status: 'ACTIVE' }) - if (!id(user?.membershipId) || user.username !== username || id(user.tenantId) !== '0' - || user.roleIds?.length !== 1 || id(user.roleIds[0]) !== actor.roleId) throw Error('Fresh cost account ownership mismatch') - actor.membershipId = id(user.membershipId) - const login = await request('POST', '/api/auth/login', { loginId: username, password }, '') - if (!login?.accessToken || login.accessToken === context.token || sessions.includes(login.accessToken)) throw Error('Independent cost session missing') - sessions.push(login.accessToken) - const actual = await request('GET', `${base}/authorization/permissions`, undefined, login.accessToken) - const expected = kind === 'reader' ? ['platform:fleet:trip:list'] : [] - if (!Array.isArray(actual) || JSON.stringify([...actual].sort()) !== JSON.stringify(expected)) throw Error('Cost actor effective permissions differ from exact scope') + const actor = await openFixedActor(io, `fleet-cost.${kind}`, kind === 'reader' ? ['platform:fleet:trip:list'] : []) + sessions.push(actor) + proof.actors.push({ kind, username: actor.username, membershipId: actor.membershipId, roleId: actor.roleId, reused: true }) } + if (sessions[0].token === sessions[1].token) throw Error('固定车队权限账号必须有独立会话') proof.status = 'READY'; await context.checkpoint() - return { marker: context.marker, listOnlyToken: sessions[0], deniedToken: sessions[1], close } + return { marker: context.marker, listOnlyToken: sessions[0].token, deniedToken: sessions[1].token, close } } catch (error) { proof.status = 'FAILED'; await context.checkpoint() - try { await close() } catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Cost actor creation and cleanup failed') } + try { await close() } catch (cleanupError) { throw new AggregateError([error, cleanupError], '固定账号核验和退出均失败') } throw error } } @@ -881,17 +849,7 @@ export async function runFleetScenario(context, { wait = ms => new Promise(resol if (full.proof?.status !== 'VERIFIED' || proof.fullExecution?.closure?.status !== 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' || trip.reservation?.status !== 'VERIFIED' || partial.proof) throw Error('PARTIAL must retain its unpicked real main after independent FULL closure and reservation') if (resumePalletized || resumeReceiptConfirmed) { - const child = Object.create(context); child.marker = proof.pickerMarker - const suffix = pickerFixtureSuffix(child), username = `pda_overseas_${suffix.toLowerCase()}_71`.slice(0, 64) - const users = await nativeRequest(context, 'GET', `/api/platform/v1/iam/users?${new URLSearchParams({page:'1',size:'200',keyword:username})}`) - const user = users.records?.find(row => id(row.membershipId) === id(proof.picker?.membershipId) && row.username === username) - if (!user || user.protectedAccount || id(user.tenantId) !== '0' || id(user.overseasWarehouseId) !== warehouseId - || user.roleIds?.length !== 1 || id(user.roleIds[0]) !== id(access?.role?.id)) throw Error('Reviewed PDA identity or warehouse changed') - picker = { membershipId: user.membershipId, username, token: null } - const active = await nativeRequest(context, 'PUT', `/api/platform/v1/iam/users/${encodeURIComponent(id(user.membershipId))}/status`, {status:'ACTIVE'}) - if (active.status !== 'ACTIVE') throw Error('Original owned PDA not active') - const login = await nativeRequest(context, 'POST', '/api/auth/login', {loginId:username,password:`Pda${suffix}A719`}, '') - picker.token = login.accessToken + picker = access ? await restoreFixedPicker(context, proof.picker, 71, warehouseId, access) : null } else { const child = Object.create(context); proof.pickerMarker = `${context.marker}-FPDA-${randomUUID().slice(0,8)}`; child.marker = proof.pickerMarker picker = access ? await createPicker(child, 71, warehouseId, access) : null diff --git a/scripts/fleet-test-env-acceptance.test.mjs b/scripts/fleet-test-env-acceptance.test.mjs index 3f34fade6..3bf053bc9 100644 --- a/scripts/fleet-test-env-acceptance.test.mjs +++ b/scripts/fleet-test-env-acceptance.test.mjs @@ -21,7 +21,7 @@ function departedRecoveryFixture() { departure:{action:'LOADING_COMPLETE',dispatchNo:'OWN',status:'ACTIVE',stage:'DELIVERING',recordVersion:'31',result:{loadingCompletionHash:'HASH',loadingCompletedAt:snapshotTrip.departedAt,confirmedLoad:{manifestHash:'a'.repeat(64)}}}} const trip={marker:'RUN',tripNo:'TR-OWN',dispatchNo:'OWN',warehouseId:'W1',confirmedLoad:{manifestHash:'a'.repeat(64),pallets:measured},costAcceptance:{marker:'RUN',status:'FAILED',costs:[],departure, requests:[{method:'GET',status:'RESPONDED',http:200,code:'200',requestId:'r',traceId:'t'}]},externalReceipt:{status:'VERIFIED',bookingNo:'BK',warehousePalletId:'P2'},externalDispatch:{dispatchNo:'OWN',deliveryNo:'DL-EXT'}} - const report={success:false,steps:[{name:'独立预期覆盖全部实物且没有重叠托盘',passed:false},{name:'模块执行',passed:false}],createdResources:{fleetAcceptance:proof,fleetTrips:[trip],fleetCostActors:{status:'DISABLED'},fleetFeeCoverageCases:[{mode:'PARTIAL',dispatchNo:'OWN',proof:{deliveryNos:['DL-OWN','DL-EXT']}}]}} + const report={success:false,steps:[{name:'独立预期覆盖全部实物且没有重叠托盘',passed:false},{name:'模块执行',passed:false}],createdResources:{fleetAcceptance:proof,fleetTrips:[trip],fleetCostActors:{status:'SESSIONS_CLOSED'},fleetFeeCoverageCases:[{mode:'PARTIAL',dispatchNo:'OWN',proof:{deliveryNos:['DL-OWN','DL-EXT']}}]}} const base='/api/platform/v1/last-leg/fleet/trips/TR-OWN' const responses=new Map([[base,{trip:structuredClone(snapshotTrip),dispatches:[{dispatchNo:'OWN'}]}], [base+'/history?page=1&size=100',{total:1,records:[structuredClone(departure)]}], @@ -514,58 +514,31 @@ test('cost actor journal removes credential keys while retaining IDs, roles and assert.equal(source.nested.accessToken, 'actual-session') }) -for (const failLogin of [false, true]) test(`cost actors create exact isolated roles and disable only owned accounts; login failure=${failLogin}`, async () => { - const calls = [], saved = [], roles = [], users = [] - const context = { marker: 'RUN', token: 'admin-secret', createdResources: {}, async findAvailablePhone() { return '19900000000' }, +for (const failLogin of [false, true]) test(`cost actors reuse configured roles and only logout owned sessions: failure=${failLogin}`, async () => { + const calls = [], saved = [] + const actors = Object.fromEntries(['reader', 'denied'].map((kind, index) => [`fleet-cost.${kind}`, { membershipId: String(10 + index), roleId: String(20 + index), roleCode: kind, username: kind, password: `fixed-secret-${kind}` }])) + const context = { marker: 'RUN', token: 'admin-secret', options: { fixedActors: actors }, createdResources: {}, async checkpoint() { saved.push(structuredClone(context.createdResources)) }, async request(method, path, body, token) { - if (method === 'GET' && (path.startsWith('/api/platform/v1/iam/users?') || path.includes('/administrator-phone-availability?'))) - return { http: 200, body: { code: '200', data: path.includes('/administrator-phone-availability?') ? true : { records: [], total: 0 } }, requestId: 'r', traceId: 't' } - const pending = saved.at(-1).fleetCostActors.requests.at(-1) - assert.equal(pending.status, 'SENDING'); assert.equal(pending.path, path) - calls.push({ method, path, body, token }) - let data - if (path.endsWith('/authorization/permissions')) data = token === 'session-1' ? ['platform:fleet:trip:list'] : [] - else if (path.endsWith('/permissions') && method === 'GET') data = [{ id: '7', permissionCode: 'platform:fleet:trip:list', status: 'ACTIVE' }] - else if (path.endsWith('/roles') && method === 'POST') { data = { ...body, id: String(roles.length + 1), roleType: 'CUSTOM' }; roles.push(data) } - else if (path.endsWith('/roles/permissions')) data = body - else if (path.endsWith('/users') && method === 'POST') { data = { ...body, membershipId: String(users.length + 10) }; users.push(data) } - else if (path === '/api/auth/login') { if (failLogin) throw Error('login unavailable'); data = { accessToken: `session-${users.length}` } } - else if (path.endsWith('/status')) data = body + calls.push({ method, path, body, token }); let data + if (path.endsWith('/roles')) data = Object.values(actors).map(a => ({ id: a.roleId, roleCode: a.roleCode, status: 'ACTIVE' })) + else if (path.includes('/users?')) data = { page: 1, size: 100, total: 2, records: Object.values(actors).map(a => ({ membershipId: a.membershipId, username: a.username, tenantId: '0', roleIds: [a.roleId], status: 'ACTIVE' })) } + else if (path.endsWith('/login')) { if (failLogin) throw Error('login unavailable'); data = { accessToken: `session-${body.loginId}`, refreshToken: `refresh-${body.loginId}` } } + else if (path.endsWith('/authorization/permissions')) data = token === 'session-reader' ? ['platform:fleet:trip:list'] : [] + else if (path.endsWith('/logout')) data = null else assert.fail(path) return { http: 200, body: { code: '200', data }, requestId: 'request', traceId: 'trace' } } } if (failLogin) await assert.rejects(prepareFleetCostActors(context), /login unavailable/) else { - const actors = await prepareFleetCostActors(context) - assert.equal(actors.listOnlyToken, 'session-1'); assert.equal(actors.deniedToken, 'session-2') - assert.equal(calls.filter(row => row.path.endsWith('/roles/permissions'))[1].body.permissionIds.length, 0) - await actors.close() - const count = calls.length; await actors.close(); assert.equal(calls.length, count) + const actor = await prepareFleetCostActors(context) + assert.equal(actor.listOnlyToken, 'session-reader'); assert.equal(actor.deniedToken, 'session-denied') + await actor.close(); const count = calls.length; await actor.close(); assert.equal(calls.length, count) } - assert.equal(context.createdResources.fleetCostActors.status, 'DISABLED') - assert.equal(calls.filter(row => /\/users\/\d+\/status$/.test(row.path)).length, users.length) - assert.equal(calls.filter(row => /\/roles\/\d+\/status$/.test(row.path)).length, roles.length) + assert.equal(context.createdResources.fleetCostActors.status, 'SESSIONS_CLOSED') + assert.ok(calls.every(call => call.method === 'GET' || call.path.startsWith('/api/auth/'))) const evidence = JSON.stringify(saved) - assert.ok(!evidence.includes('session-1')); assert.ok(!evidence.includes('admin-secret')) - for (const user of users) assert.ok(!evidence.includes(user.password)) - assert.ok(!calls.some(row => row.method === 'DELETE')) -}) - -test('fresh cost actor phones avoid existing platform users and use an independent namespace under the same original marker', async () => { - const phones = [], ctx = { marker: 'ORIGINAL-RUN', async request(method, path) { - assert.equal(method, 'GET') - let data - if (path.includes('/users?')) { - const phone = new URL('https://test.invalid' + path).searchParams.get('keyword'); phones.push(phone) - data = { records: phones.length === 1 ? [{ phone }] : [], total: phones.length === 1 ? 1 : 0 } - } else data = true - return { http: 200, body: { code: '200', data }, requestId: 'r', traceId: 't' } - } } - const first = await findFleetActorPhone(ctx, 'a'.repeat(16)) - assert.notEqual(first, phones[0]); assert.match(first, /^18\d{9}$/) - const second = await findFleetActorPhone(ctx, 'b'.repeat(16)) - assert.notEqual(first, second) + for (const secret of ['session-reader', 'admin-secret', 'fixed-secret-reader', 'fixed-secret-denied']) assert.ok(!evidence.includes(secret)) }) test('phone lookup failure stops without any user creation or guessing', async () => { diff --git a/scripts/gray-acceptance-entry-contract.test.mjs b/scripts/gray-acceptance-entry-contract.test.mjs index f6ceb2300..2e5d200b9 100644 --- a/scripts/gray-acceptance-entry-contract.test.mjs +++ b/scripts/gray-acceptance-entry-contract.test.mjs @@ -5,15 +5,17 @@ import { AcceptanceRun, configureReadOnlyEvidence, parseArguments, validateExecu 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', + '--fixed-customer-id', 'TEN2100000000000000001', '--merchant-username', 'existing-gray', '--merchant-password', 'offline-gray-password', '--f40-tax-mode', 'TAX_REFUND', '--output', 'reports/gray-contract.json'] -test('gray is explicit, keeps real environment identity and creates its own customer', () => { +test('gray is explicit, keeps real environment identity and uses the designated existing 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') + assert.equal(run.options.fixedCustomerId, 'TEN2100000000000000001') }) test('gray cannot run unbounded full suite, extra modules, bulk data or TEST faults', () => { @@ -40,7 +42,8 @@ test('gray has no fallback to TEST account, gateway, customer or PDA environment 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'] + 'ADMIN_GRAY_PDA_PASSWORD', 'MERCHANT_GRAY_USERNAME', 'MERCHANT_GRAY_PASSWORD', 'ADMIN_GRAY_CUSTOMER_NAME', + 'ADMIN_GRAY_FIXED_CUSTOMER_ID', 'ADMIN_TEST_FIXED_CUSTOMER_ID'] const saved = new Map(keys.map(key => [key, process.env[key]])) try { for (const key of keys) { @@ -49,7 +52,7 @@ test('gray has no fallback to TEST account, gateway, customer or PDA environment } 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], '') + 'merchantUsername', 'merchantPassword', 'customerName', 'fixedCustomerId']) 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') @@ -61,6 +64,22 @@ test('gray has no fallback to TEST account, gateway, customer or PDA environment } }) +test('gray missing fixed identity or customer credentials fails without network; both transports forbid customer creation', async () => { + const original = globalThis.fetch + globalThis.fetch = () => assert.fail('missing configuration and customer writes must stop before HTTP') + try { + for (const field of ['fixedCustomerId', 'merchantUsername', 'merchantPassword']) { + const options = parseArguments(args); options[field] = '' + assert.throws(() => validateExecutionOptions(options), /禁止自动创建客户/) + } + const runner = new AcceptanceRun(validateExecutionOptions(parseArguments(args))) + for (const entry of ['request', 'requestWithBody']) { + for (const path of ['/api/platform/v1/iam/tenants', '/api/auth/register', '/api/platform/v1/iam/tenants/123/submit-review']) + await assert.rejects(runner[entry]('POST', path, {}), /GRAY.*禁止创建/) + } + } finally { globalThis.fetch = original } +}) + test('gray evidence preflight attaches only read-only readers, never constructs fault executors', async () => { const run = new AcceptanceRun(validateExecutionOptions(parseArguments(args))) const readReceiving = () => {}, readCompletion = () => {} diff --git a/scripts/local-dispatch-transport-acceptance.mjs b/scripts/local-dispatch-transport-acceptance.mjs index 6b3f7d63c..3fcb000f5 100644 --- a/scripts/local-dispatch-transport-acceptance.mjs +++ b/scripts/local-dispatch-transport-acceptance.mjs @@ -101,25 +101,15 @@ async function measure(source) { } try { const login=await api('','POST','/api/auth/login',{loginId:process.env.ADMIN_TEST_USERNAME,password:process.env.ADMIN_TEST_PASSWORD});admin=login.accessToken - // 本机旧权限目录只补正式排车资源,并授权本轮专用角色,不改既有用户或角色。 - const required=['list','create','edit','depart','cancel'].map(key=>`platform:overseas-warehouse:dispatch:${key}`) - if(!report.operatorUsername){ - const permissions=await call('GET','/api/platform/v1/iam/permissions') - const parent=permissions.find(row=>row.permissionCode===required[0]);check(parent,'存在正式排车列表权限') - for(const code of required)if(!permissions.some(row=>row.permissionCode===code))permissions.push(await call('POST','/api/platform/v1/iam/permissions',{type:'BUTTON',code,name:`排车${code.split(':').at(-1)}`,parentId:parent.id,httpMethod:'POST'})) - const role=await call('POST','/api/platform/v1/iam/roles',{roleCode:`${marker}_OPERATOR`,roleName:`${marker}本机排车验收`,description:'仅本轮本机业务验收'}) - const allowed=new Set([...login.authorities,...required]) - await call('PUT','/api/platform/v1/iam/roles/permissions',{roleId:role.id,permissionIds:permissions.filter(row=>allowed.has(row.permissionCode)).map(row=>row.id)}) - const username=`dispatch_dt_${marker.replace(/\D/g,'')}` - await call('POST','/api/platform/v1/iam/users',{username,password:process.env.LOCAL_TRANSFER_PDA_PASSWORD,displayName:`${marker}本机调度验收`,phone:`18${String(Date.now()).slice(-9)}`,tenantId:'0',roleIds:[role.id],status:'ACTIVE'}) - report.operatorUsername=username;await save() - } - admin=(await api('','POST','/api/auth/login',{loginId:report.operatorUsername,password:process.env.LOCAL_TRANSFER_PDA_PASSWORD})).accessToken + const required = ['list','create','edit','depart','cancel'].map(key => `platform:overseas-warehouse:dispatch:${key}`) + check(Array.isArray(login.authorities) && required.every(code => login.authorities.includes(code)), '固定平台账号缺少正式业务权限,不自动补权限或创建角色') + report.operatorUsername = process.env.ADMIN_TEST_USERNAME; await save() const customers=(await call('GET','/api/platform/v1/iam/tenants?page=1&size=200')).records.filter(row=>row.status==='ACTIVE') check(customers.length>=2,'有两个有效客户验证同仓跨客户取货') const warehouses=(await call('GET','/api/platform/v1/warehouse-admin?page=1&size=100&warehouseType=OVERSEAS&status=ACTIVE')).records const warehouse=warehouses.find(row=>row.warehouseCode==='US-LAX-01')||warehouses[0];report.warehouseId=warehouse.id - if(!report.pdaUsername){const role=(await call('GET','/api/platform/v1/iam/roles')).find(row=>row.roleCode==='OVERSEAS_WAREHOUSE_PDA_OPERATOR'&&row.status==='ACTIVE');check(role,'存在海外PDA角色');const username=`pda_dt_${marker.replace(/\D/g,'')}`;await call('POST','/api/platform/v1/iam/users',{username,password:process.env.LOCAL_TRANSFER_PDA_PASSWORD,displayName:`${marker}本机排车验收`,phone:`18${String(Date.now()).slice(-9)}`,tenantId:'0',roleIds:[role.id],overseasWarehouseId:warehouse.id,status:'ACTIVE'});report.pdaUsername=username;await save()} + report.pdaUsername = process.env.LOCAL_TRANSFER_PDA_USERNAME + check(Boolean(report.pdaUsername), '必须配置既有 LOCAL_TRANSFER_PDA_USERNAME,不自动创建PDA账号') pda=(await api('','POST','/api/auth/login',{loginId:report.pdaUsername,password:process.env.LOCAL_TRANSFER_PDA_PASSWORD})).accessToken const a=await transfer(customers[0],warehouse,'pickupA'),b=await transfer(customers[1],warehouse,'pickupB'),c=await transfer(customers[0],warehouse,'pickupCancelSource') let shared=await pickup([a,b],'pickupCompleted'),cancelled=await pickup([c],'pickupCancelled') diff --git a/scripts/local-transfer-pickup-acceptance.mjs b/scripts/local-transfer-pickup-acceptance.mjs index 35f68c727..b645f932e 100644 --- a/scripts/local-transfer-pickup-acceptance.mjs +++ b/scripts/local-transfer-pickup-acceptance.mjs @@ -144,25 +144,9 @@ async function measure(source) { try { const login = await api('', 'POST', '/api/auth/login', { loginId: process.env.ADMIN_TEST_USERNAME, password: process.env.ADMIN_TEST_PASSWORD }) admin = login.accessToken - // 本机旧权限目录可能未初始化取消/结案/释放资源;只补正式权限资源并授权本轮专用账号。 - let permissions = await api(admin, 'GET', '/api/platform/v1/iam/permissions') - for (const [suffix, name] of [['cancel', '取消车次'], ['complete', '车次货物结案'], ['release', '结束车辆司机任务']]) { - const code = `platform:fleet:trip:${suffix}` - if (!permissions.some(row => row.permissionCode === code)) { - const row = await api(admin, 'POST', '/api/platform/v1/iam/permissions', { type: 'BUTTON', code, name, parentId: permissions.find(item => item.permissionCode === 'platform:fleet:trip:list').id, httpMethod: 'POST' }) - permissions.push(row) - } - } - if (!report.operatorUsername) { - const existingRole = (await api(admin, 'GET', '/api/platform/v1/iam/roles')).find(row => row.roleCode === `${marker}_OPERATOR`) - const role = existingRole || await api(admin, 'POST', '/api/platform/v1/iam/roles', { roleCode: `${marker}_OPERATOR`, roleName: `${marker}本地取货验收角色`, description: '沿当前验收账号权限增加正式车次取消、结案和释放,仅供本轮本机验收' }) - const allowed = new Set([...login.authorities, 'platform:fleet:trip:cancel', 'platform:fleet:trip:complete', 'platform:fleet:trip:release']) - await api(admin, 'PUT', '/api/platform/v1/iam/roles/permissions', { roleId: role.id, permissionIds: permissions.filter(row => allowed.has(row.permissionCode)).map(row => row.id) }) - const username = `pickup_op_${marker.replace(/\D/g, '')}` - await api(admin, 'POST', '/api/platform/v1/iam/users', { username, password: process.env.LOCAL_TRANSFER_PDA_PASSWORD, displayName: `${marker}本地取货验收`, phone: `18${String(Date.now()).slice(-9)}`, tenantId: '0', roleIds: [role.id], status: 'ACTIVE' }) - report.operatorUsername = username; await save() - } - admin = (await api('', 'POST', '/api/auth/login', { loginId: report.operatorUsername, password: process.env.LOCAL_TRANSFER_PDA_PASSWORD })).accessToken + const required = ['cancel', 'complete', 'release'].map(key => `platform:fleet:trip:${key}`) + check(Array.isArray(login.authorities) && required.every(code => login.authorities.includes(code)), '固定平台账号缺少正式业务权限,不自动补权限或创建角色') + report.operatorUsername = process.env.ADMIN_TEST_USERNAME; await save() const tenants = await api(admin, 'GET', '/api/platform/v1/iam/tenants?page=1&size=200') const customers = tenants.records.filter(row => row.status === 'ACTIVE').sort((a, b) => Number(!String(a.tenantName || a.name).includes('测试')) - Number(!String(b.tenantName || b.name).includes('测试'))) check(customers.length >= 2, '本地至少两名有效客户用于共车隔离验收') @@ -173,21 +157,8 @@ try { const roles = await api(admin, 'GET', '/api/platform/v1/iam/roles'), role = roles.find(row => row.roleCode === 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' && row.status === 'ACTIVE') check(role, '存在海外PDA作业角色') const username = `pda_pickup_${marker.replace(/\D/g, '')}` - if (!report.pdaUsername) await api(admin, 'POST', '/api/platform/v1/iam/users', { username, password: process.env.LOCAL_TRANSFER_PDA_PASSWORD, displayName: `${marker}取货验收PDA`, phone: `18${String(Date.now()).slice(-9)}`, email: `${username}@example.com`, tenantId: '0', roleIds: [role.id], overseasWarehouseId: warehouse.id, status: 'ACTIVE' }) - pda = (await api('', 'POST', '/api/auth/login', { loginId: username, password: process.env.LOCAL_TRANSFER_PDA_PASSWORD })).accessToken - report.pdaUsername = username - const orders = [] - for (let i = 1; i <= 15; i++) orders.push(await order(customers[i % 2], warehouse, i, i >= 13 && i <= 14 ? 'CUSTOMER_DELIVERY' : 'WAREHOUSE_PICKUP', i !== 15)) - const differentWarehouse = await order(customers[0], otherWarehouse, 16) - const waiting = orders[0], sharedA = orders[1], sharedB = orders[2] - waiting.scenario = '待取货,供页面手工选单'; orders[12].scenario = '客户自送,不进入取货池'; orders[13].scenario = '客户自送,不进入取货池'; orders[14].scenario = '未审核,不进入取货池' - // 候选关键字查地址,取货地址中使用本轮标识;按单号定向复验不依赖全局数据量。 - const single = await api(admin, 'GET', `${pickupBase}?page=1&size=100&keyword=${waiting.transferNo}`) - check(single.records.some(row => row.transferNo === waiting.transferNo), '已审上门取货单自动进入候选') - for (const excluded of [orders[12], orders[13], orders[14]]) { - const result = await api(admin, 'GET', `${pickupBase}?page=1&size=100&keyword=${excluded.transferNo}`) - check(result.total === 0 || Number(result.total) === 0, '自送或未审核单不进入待取货池') - } + report.pdaUsername = process.env.LOCAL_TRANSFER_PDA_USERNAME + check(Boolean(report.pdaUsername), '必须配置既有 LOCAL_TRANSFER_PDA_USERNAME,不自动创建PDA账号') const invalid = { sources: [waiting, differentWarehouse].map(({ customerId, transferNo }) => ({ customerId, transferNo })), plannedDepartureAt: new Date().toISOString(), expectedDriverEndAt: new Date(Date.now() + 86400000).toISOString(), idempotencyKey: randomUUID() } await api(admin, 'POST', pickupBase, invalid, 'A0400') await api(pda, 'POST', `/api/pda/v1/last-leg/warehouse-transfers/${waiting.transferNo}/pallets/drafts`, { idempotencyKey: randomUUID() }, 'A0409')