test(fba): preserve verified fleet and fulfillment acceptance workflows
This commit is contained in:
@@ -1,14 +1,49 @@
|
||||
# Admin 测试环境 HTTP 模块
|
||||
|
||||
`TERMINAL_READONLY` 用于全部业务写入和结案助手均完成、仅专项入口汇总状态断言错误的已复核报告。结案助手成功状态为 `CARGO_COMPLETED_AND_RESOURCES_RELEASED`。该检查点登录后仅 GET 原车次、每张排车/派送、资源占用测试计划与精确财务来源版本,不重放任何业务写入,不覆盖原失败报告;真实状态、数量、签收或费用不符仍失败。它不补充生产者 ACK 或页面验收证据。
|
||||
|
||||
`EXTERNAL_RECEIPT_CONFIRMED` 仅接受原运营实收确认明确成功、后续只读/重放断言失败且尚未开始排车关联的样本。先核对原来货已可排车、实际容量及单一库存流水,再原键原载荷重放实收确认;不重新入库。重放比较以数据库持久化后的完整预订为基准,避免直接拿命令返回的纳秒 `updatedAt` 与数据库毫秒精度比较;货量、报价、版本及全部业务字段仍精确核对。实收总箱数字段为正式 `receivedPackages`。
|
||||
|
||||
`EXTERNAL_PALLETIZED` 仅用于原 PDA 入库命令明确成功、库存只读核对失败且尚未运营确认的原样本。要求保存原请求键、实托和本轮临时 PDA 账号,回查原容量预留、来货状态及账号仓绑定后,只重放原入库命令,不重新创建或收货;再继续运营核量。库存列表是分组汇总,来源归属须读取该组引用的余额详情及实物位置,并与本票不可变流水核对;不能假定汇总行包含来货码。每次显式续跑保存独立报告和原报告哈希,保留首次失败证据。
|
||||
|
||||
`EXTERNAL_RECEIPT_READY` 是另一处显式复核检查点:仅全/部分费用覆盖与占位均已验证、PDA账号创建明确因手机号冲突被拒绝、尚未创建外接来货时允许继续。继续前回查原车次分配、客户排车及未拣/未发事实;新测试PDA使用独立标识,不复用恢复前的联系电话生成序号。已有外接实收/关联或账号写入结果未知时不得使用此检查点。
|
||||
|
||||
若全覆盖命令已经返回成功、但后续只读核对失败,可在核对原出库/派送确实已保存且未执行后,显式使用 `--resume-stage FULL_COVERAGE_PERSISTED`。此模式要求原成功命令、出库号和完整派送集合,先查正式详情,再仅重放原键原载荷;不重新运行创建前的负向写用例,不更换请求键或重造业务单据。目的地以当前 `destinationWarehouseCode` 优先、`fbaWarehouseCode` 兜底,与车次及派送服务一致,不能用原始 FBA 快照否定已经变更的实际目的地。
|
||||
|
||||
车辆专项修复后可显式选择已复核的只读检查点:`--resume-report reports/<原批次>/report.json --resume-stage FULL_COVERAGE`,仍须新的 `--run-id`。只接受全覆盖阶段仅执行过 GET、尚无出库且无任何 POST 的失败记录;复核同一车次资源、排车货物及未拣/未发事实后继续原样本,原失败报告不覆盖,保留其哈希与旧检查记录。其他阶段及写入结果未知均拒绝自动续跑,须先按原命令核对实际结果。出库列表仅使用正式客户范围分页,再逐行匹配排车号;不假定该列表支持 `dispatchNo` 查询过滤。
|
||||
|
||||
车辆专项入口为 `../fleet-test-env-acceptance.mjs`,先使用原 `phase4-overseas-dispatch` 依赖链创建本轮客户、头程费用和海外库存,再执行专用车辆编排;不改变已通过的 F40 默认调用顺序。默认 `--plan` 无网络,实际运行须显式 `--execute --environment TEST --run-id <新批次>`。仅串行运行,会创建当前批次资源及执行正式未执行单据取消重排,不执行部署、配置修改或业务 SQL 写入;原收货取证沿用现有只读读取器。失败保留原样本、请求与报告,不自动重造或续跑。
|
||||
|
||||
四个内部助手及各自 `*-contract.test.mjs`:`fleet-booking-reservation-acceptance.mjs` 验证询价/确认/超卖/取消的逐段占位与重放;`fleet-fee-coverage-acceptance.mjs` 用独立排车验证全覆盖及多原入仓的部分覆盖,核对原费用版本、目的地、快照及重放不重复收费;`fleet-external-receipt-acceptance.mjs` 通过正式来货与 PDA 测量、生成托盘、扫码绑定、照片和完成接口入库,按客户/仓/来货码查询余额与流水,验证重放不再入库、运营核量改价和预留替换;`fleet-external-dispatch-acceptance.mjs` 将该实收货物预排并绑定原车次,重核容量并逐客户审核,验证约定运费来源与重放。后两者会产生新外接货,必须走专用编排,不能直接加入只拣一张排车的 F40 默认流程。专项入口继续逐单 PDA 拣货、整车发车、逐目的地签收及资源释放;报告将 Finance 消费入池与生产者 ACK 分别记录,消费行存在不能冒充生产者已收到 ACK。以上助手不能独立当作全链入口,离线合同通过不代表真实 HTTP 或页面验收通过。
|
||||
|
||||
头程提交、平台编辑与取消按首次原始详情版本冻结请求。原键原载荷重放成功;同键异载荷、旧版本及新键重复操作分别核验拒绝后事实不变。Platform 编辑使用平台权限与显式客户范围,客户 Token 不得调用。
|
||||
|
||||
`fleet-trip-fulfillment.mjs` 是内部车次验收助手,不是独立入口。五期与F40模块通过正式API建立本轮车辆容量、测试司机及PNG证照上传/审核、车次关联、目的站匹配及本单调度审核通过;拣货后确认明确标记为测试的实物测量,通过整车理货审核后携带最新车次版本统一发车。资源及文件ID保留到报告,不自动删除、不以SQL修改状态;失败停止,不能跳过资格或装载。`mixed-container-outbound-test-env.mjs` 与 `overseas-relabel-fulfillment-acceptance.mjs` 的留存场景须在拣货前已有正式车次关联和通过的调度审核,不能对已经开工的旧单补造关联;它们确认受控测试装载、理货审核并继续履约。改派检查保留拣货事实,但重新通过每张单的调度审核后再确认装载。`fleet-trip-fulfillment-contract.test.mjs` 离线验证重复实托、目的地及准备顺序,不代表HTTP验收。
|
||||
|
||||
`prepareFleetTrip` 创建的本轮车次必须完成发车前改派验证:`confirmFleetLoading` 先确认装载,再创建等容量替换车辆和经文件上传/审核的替班司机,经正式分配接口改派。回查客户关联、原货物、拣货数量、路线和到达时间未改变,原装载确认已清除;旧版本确认须被拒绝且不产生写入,最后按新版本重新确认。返回最终版本供既有 PDA 就绪检查和整车发车使用。报告 `createdResources.fleetTrips[].reassignment` 留存前后快照、替换资源、原请求和结果,只有全部通过才标记 `VERIFIED`;失败保留 `CHECKING`,同一证明对象禁止自动重试或重造。顶层资源引用随后更新为替换资源供结案/释放验证,原资源仍保留在快照中。留存场景不执行这段新建资源与改派流程。
|
||||
|
||||
五期和F40签收后继续调用 `finishFleetTrip`:正式结案并同键重放,通过两个无货计划及一辆隔离用测试车辆验证“结案仍占用车辆、只结束车辆仍占用司机、两项结束后可重新分配、旧结束请求重放不能清除新占用”。仅409不足以通过,必须是相应资源占用冲突;全部完成后以正式取消命令取消无货计划并保留历史。测试车辆/司机在末站结束,不含返程任务;地点、时间、原请求及计划号保存于 `createdResources.fleetTrips[].closure`。失败保留CHECKING及原资源,不自动重造或修复旧fixture;离线模拟通过不等于这些真实HTTP场景已执行。
|
||||
|
||||
`phase5-overseas-fulfillment.mjs` 的 `pickAll`、`saveReceiptAndComplete` 也供显式授权的 `../mixed-container-outbound-test-env.mjs` 留存混柜续跑入口复用;仍使用正式 PDA 拣货和签收文件绑定接口,不代替全模块验收。
|
||||
|
||||
总入口是 `../admin-test-env-acceptance.mjs`。每个业务模块使用独立的同名 `.mjs` 文件,避免多个对话同时修改总入口。
|
||||
|
||||
`phase3-loading.mjs` 的 PDA 原子证据上传在未取得 HTTP 响应时最多尝试三次,冻结原 URL、作业员身份、幂等键、文件名/内容、证据版本和封签号;不重试 HTTP/业务拒绝或已收到响应后的读取失败。`createdResources.loadingEvidenceTransport` 保存各次结果及白名单网络错误码,不保存凭据或原始错误对象。`phase3-evidence-transport-contract.test.mjs` 用持久幂等模型验证响应丢失重放仅一条证据、持续传输失败有界终止及拒绝不重试;离线模型不替代真实 HTTP。
|
||||
|
||||
`orders.mjs` 每次运行优先使用当前 `merchantQualificationFixture` 的完整账号对,其次为明确配置;不在模块 import 时缓存客户凭据。当前 fixture 不完整时停止,不与上一轮或环境账号拼接。`orders-session-contract.test.mjs` 在同进程重复调用真实订单登录入口,覆盖连续客户隔离,防止多模式串行时沿用前一轮身份。
|
||||
|
||||
`phase4-overseas-pda.mjs` 的收货入口对本轮 `containerBookingId + containerNo` 做有界候选收敛等待(最多 30 次、间隔 1 秒),只有唯一且可操作的原柜才继续;鉴权失败、畸形响应和重复候选立即阻断。它用于等待异步船节点投影,不构成事件重放证据。F40 在成功和失败时均保留 `receivingCandidateEvidence` 的逐次查询身份、结果和诊断信息,不覆盖已有收货会话,不借其他柜或历史柜继续。
|
||||
|
||||
多目的地排车 C52:`phase4-overseas-dispatch.mjs` 在本轮实际收货产生的同仓同客户托盘上验证多 FBA 目的地及重复目的地托盘的增加、移除、重放和拒绝后零漂移。路线期望从实际排车明细及本轮冻结候选独立推导,记录 `routeLifecycle`,最终恢复原主链排车并释放额外测试托盘;缺少至少两个目的地及同目的地双托前置时明确阻断,不借历史数据或用单目的地代替。复用 candidates、draft-locks/lock、pre-arrange、详情和 DELETE items 正式 API;定向入口仍为 `--modules phase4-overseas-dispatch`,会写 TEST,使用新报告路径,失败保留样本,不自动删除重跑。合同为 `phase4-overseas-dispatch-contract.test.mjs`;离线成功不代表实际 HTTP 生命周期通过。
|
||||
|
||||
船节点异常 C66:`phase3-carrier-schedule-queries.mjs` 的 `captureCarrierExceptionEvidence` 与 `verifyCarrierExceptionTransition` 对本轮原船任务采集只读 checkpoint,核对 CUSTOMS_HOLD/TERMINAL_HOLD/DOCUMENT_HOLD/UNKNOWN_HOLD 的追加、解除、重放及冲突前后正常实际/预计节点守恒。`phase3-carrier-exceptions-contract.test.mjs` 只验证离线比对逻辑;真实生命周期还需要 Integration 供应商结果经调度器消费的独立投递证据。默认 HTTP 链只采基线并保留该覆盖缺口;空异常数组、重复 GET、报关异常和合成快照均不能计为供应商异常生命周期通过。
|
||||
|
||||
对应本地数据库事务测试为 Firstleg `CarrierScheduleExceptionPersistenceTest`,使用真实 Service/Repository/MyBatis 和 H2,验证四类异常及解除的同事件重放、异摘要冲突、正常实际/预计节点整行不变,以及异常历史写入失败时事务回滚。它属于本地持久化验证,不代替 TEST MySQL 或真实供应商调度链验收。
|
||||
|
||||
`phase5-root-fulfillment.mjs` 是显式 TEST 的 F40 两箱根/余量专项,通过总入口 `--modules phase5-root-fulfillment` 准备本轮资质客户;模块用正式下单、测量、国内 PDA 拆分装柜、船节点、海外 PDA 收货、拣货发车及文件签收链创建两柜真实来源。默认根先签,编程调用 `AcceptanceRun.context.f40SignatureOrder='CHILD_FIRST'` 切换子先签;分别核对第一片签收不得根完成、最后一片签收才根完成、真实签收时间、状态历史和重放零新增。
|
||||
|
||||
默认税制为 `BUY_DECLARATION`;编程调用显式设置 `AcceptanceRun.context.f40TaxMode='TAX_REFUND'` 可执行退税链。内部模块 `phase5-root-tax-customs.mjs` 在每个实装柜走客户上传、平台回执、客户确认、发送执行、人工正式申报与放行、完成文件,再进入尾程;订单最终完成后回查本客户资料并校验原件、回执、完成文件的实际下载字节和 SHA-256。该模块不能独立执行,不用 SQL 修改状态,不借历史资料;依赖相同 TEST 授权、当轮客户与只读取证器,失败及重跑保留各轮原始报告。直接合同为 `phase5-root-tax-customs-contract.test.mjs`,离线通过不代替实际 HTTP 或页面验收。
|
||||
|
||||
`AcceptanceRun.context.f40SignatureOrder='ROOT_SIGNED_REMAINDER_CANCELLED'` 单独验证合法末票余量取消:本轮新根通过 `orderStopAtApproved=true` 停在原始 `APPROVED`,只装运签收首箱;回查真实履约树及未装子单原始 `APPROVED`、入仓 `WAIT_SPLIT` 和一箱余量后,调用公开取消接口。`PROCESSING` 子单不得作为取消样本。根完成事件必须唯一发布,取消子单完成事件显式为零;原键原版本原载荷取消重放成功;同键异载荷及新键重复取消返回 `A0409`,根子版本、历史、摘要、履约树及完成 Outbox 均不漂移。两种双签模式仍须两片实际签收,不能用取消替代第二签。
|
||||
|
||||
会写已授权测试环境,保留新客户、单据、文件及报告,临时海外 PDA 在 finally 停用;失败保留样本,不自动删除或重复执行部分已完成链。签收资料使用新 key 保存同文件时,按正式保存命令递增一次资料版本、新增一条版本历史及 `SAVE_DOCUMENTS` 审计;签收时间、状态、原完成记录、文件绑定和根子完成事件不得变化。同一新命令再用原 key、原请求重放,必须明确 `replayed=true` 且完整资料、派送事实、版本和审计历史零漂移。资料保存前后及原 key 重放响应保存在 `phase5RootFulfillment.receiptReplayProofs`,失败也保留已取得的快照。
|
||||
@@ -135,6 +170,10 @@ export async function run(context) {
|
||||
|
||||
## 约束
|
||||
|
||||
`unified-pda-session-revocation.mjs` 是海外库内作业模块的内部安全检查;在本轮操作员完成库存作业后修改其作业仓,分别验证旧 Access/Refresh 失效、新登录只绑定新仓、角色及仓绑定清空后旧会话失效且不能继续 PDA 作业。只更新本轮临时账号并由原 finally 停用,凭据保持在私有上下文;不改仓库主档或业务库存。合同为 `unified-pda-session-revocation-contract.test.mjs`。
|
||||
|
||||
`direct-delivery-replay-proof.mjs` 是事件模块的内部证据校验器,不是独立执行入口。要求本轮原装柜事件的 ID/hash、12 次失败至 DEAD、人工恢复审计、持久 Inbox 及 DUPLICATE、恢复前后业务订单身份集合零新增和环境恢复同时成立;单纯重复 GET 或历史故障报告不能满足条件。离线合同为 `direct-delivery-replay-proof-contract.test.mjs`。
|
||||
|
||||
- 不记录密码、Token、图片二进制或 OCR 原文全文。
|
||||
- 测试数据必须带 `context.marker`;可删除的清理,不能删除的最终停用。
|
||||
- 缺少已确认契约时记录失败或 `deploymentBlocker`,不得模拟成功。
|
||||
@@ -206,9 +245,9 @@ export async function run(context) {
|
||||
| 海外仓业务跨区事件与安全门禁 | `phase4-cross-region-events.mjs`(统一平台会话、匿名拒绝、canonical 权限 403、国内装柜完成到 Lastleg 建单、海外柜完成到 Firstleg 回写、轮询与诊断证据;重复 GET 仅验证读取稳定,缺真实投递重放证据仍阻断;缺本轮 marker 前置时阻断) | `phase4-cross-region-events-contract.test.mjs` |
|
||||
| 国内离仓至海外 PDA 可见人工验收节点 | `phase3-customs.mjs`(国内报关资料与整柜照片)、`phase3-outbound-dispatch.mjs`(柜级离仓)、`phase3-container-documents.mjs`(离仓后目的港清关、DO;PU 使用柜级数字码)、`phase3-carrier-schedule-queries.mjs`、`phase4-carrier-pickup-ready.mjs`、`phase4-overseas-pda.mjs`(同柜候选) | `phase3-phase4-departure-pickup-chain-contract.test.mjs` |
|
||||
| 国内出仓聚合、门禁、待装柜与仓库权限 | `phase3-outbound-aggregate.mjs`、`phase3-dispatch-gate.mjs`、`phase3-waiting-for-loading.mjs`、`phase3-warehouse-rbac.mjs` | 由容器回归、生产者链和出仓相关合同覆盖;只消费本轮主链资源 |
|
||||
| 人工状态矩阵 | `manual-state-matrix.mjs` | `manual-state-matrix-contract.test.mjs`;验证装柜完成、报关和订舱相关状态组合 |
|
||||
| 人工状态矩阵 | `manual-state-matrix.mjs` | `manual-state-matrix-contract.test.mjs`;依赖本轮海外签收,保留有有效余量根订单的 `DELIVERED`;完成订单样本经 `phase5-root-fulfillment.mjs` 两票真实签收和只读完成 Outbox 核验产生,不调用已移除的人工 `/complete`,并继续验证装柜完成、报关和订舱状态组合 |
|
||||
| 海外业务 HTTP 生命周期 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` |
|
||||
| 海外主数据 HTTP 生命周期 | `phase4-overseas-master-data.mjs` | `phase4-overseas-master-data-contract.test.mjs` |
|
||||
| 海外主数据 HTTP 生命周期 | `phase4-overseas-master-data.mjs`;车辆新建、详情、修改及列表均核对实际托位/载重/容积,修改使用不同容量值,CSV 同时核对修改后的值与业务车牌号;缺失或猜测容量形成阻断,finally 停用本轮资源。内部助手 `vehicle-care.mjs` 验证保养/加油创建重放、版本冲突、历史、单位、照片重放和字节下载,并逐类验证维修/故障/救援/异常项目地点回显、处理人、完成处理及费用/付款独立;另建本轮车辆验证跨车读取/修改/照片拒绝,结束停用隔离车辆并保留记录。报告在 `phase4OverseasMasterData.vehicle.care`,错误地以处理完成推定付款必须阻断;无SQL或旧数据修复,不作独立入口 | `phase4-overseas-master-data-contract.test.mjs` |
|
||||
| 目的港码头拖车与船节点终态 | `phase5-terminal-drayage.mjs`(PDA 卸柜前复用 `ACTIVE + TRUCKING + truckingScope=OVERSEAS` 主数据完成选车队、预约和实际提柜,卸柜后完成实际还柜,并覆盖四命令幂等/历史)、`phase4-carrier-nodes.mjs`(PDA 前补录至 `PICKUP_READY`;拖车完成后只读核验 `PICKED_UP/EMPTY_RETURNED` 和停止语义) | `phase5-terminal-drayage-contract.test.mjs`、`phase4-carrier-nodes-contract.test.mjs`;不再由船节点脚本直接伪造提柜和还柜 |
|
||||
| 海外排车、出库、派送、异常与回访 HTTP 生命周期 | `phase5-overseas-fulfillment.mjs`(复用本轮海外仓已收货托盘和预排单,正式创建临时 PDA 操作员并在 `finally` 停用;覆盖排车编辑、按 FBA 目的仓转出库/拆分派送单、PDA 全量拣货/发车、逐单签收资料保存自动完成、头程入仓与订单跨区回推、客户回访、人工异常及派送退回) | `phase5-overseas-fulfillment-contract.test.mjs` |
|
||||
| 初始化基线(仅离线) | — | `initialization-baseline-contract.test.mjs` |
|
||||
@@ -357,5 +396,3 @@ node --test scripts/admin-test-env-modules/merchant-qualification-fixture-contra
|
||||
国内拖车自动完成与应付回归由 `phase3-outbound-dispatch.mjs` 在真实离仓后只读核验:订舱全部柜数、逐柜离仓、唯一 `AUTO_COMPLETE`、按拖车编号追溯柜次应付、金额守恒及 Finance 来源入池(有界等待 Outbox)。该模块的原有确认/撤销边界操作仍会写目标环境,执行须获得对应环境授权。`manual-state-ocean-trucking.mjs` 只构造可人工操作的状态,不伪造已完成拖车;自动终态由上述离仓链路覆盖。
|
||||
|
||||
`event-bridge-pending-recovery.mjs` 为纯离线证据校验器,文件名保留为索引入口;无事件桥调用或历史事件常量。合同覆盖 Compose DNS、三服务内部凭据版本一致、有限重试/DEAD、原事件摘要、ACK 丢失后 DUPLICATE 与单次业务效果。合成合同通过不代表环境故障演练通过;`phase4-cross-region-events` 保留真实重放证据阻断项。环境验收需本轮 marker 事件逐次投递记录、Inbox 与业务计数;仅使用受保护 `event-outbox-operations.mjs` 对 DEAD 原事件执行另行授权重试,不重放 SENT,不改表或伪造新事件。
|
||||
|
||||
`direct-delivery-replay-proof.mjs` 是事件模块的内部证据校验器,不是独立执行入口。要求本轮原装柜事件的 ID/hash、12 次失败至 DEAD、人工恢复审计、持久 Inbox 及 DUPLICATE、恢复前后业务订单身份集合零新增和环境恢复同时成立;单纯重复 GET 或历史故障报告不能满足条件。离线合同为 `direct-delivery-replay-proof-contract.test.mjs`。
|
||||
|
||||
@@ -1,9 +1,32 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { verifyRevokedSessions } from './system.mjs'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
test('撤销检查逐会话核对Access和Refresh且403不能冒充会话失效', async () => {
|
||||
const records = [], calls = []
|
||||
const context = {
|
||||
assert: (_module, name, _response, passed, evidence) => records.push({ name, passed, evidence }),
|
||||
request: async (method, path, body, token) => {
|
||||
calls.push({ method, path, body, token })
|
||||
return { http: path.endsWith('/refresh') ? 200 : 401, body: { code: 'A0401' } }
|
||||
},
|
||||
}
|
||||
await verifyRevokedSessions(context, '密码重置', [{ accessToken: 'a-1', refreshToken: 'r-1' },
|
||||
{ accessToken: 'a-2', refreshToken: 'r-2' }])
|
||||
assert.equal(calls.length, 4)
|
||||
assert.ok(records.every(row => row.passed))
|
||||
assert.ok(records.every(row => !/a-1|r-1|a-2|r-2/.test(row.evidence)))
|
||||
assert.equal(calls[1].token, '')
|
||||
context.request = async () => ({ http: 403, body: { code: 'A0403' } })
|
||||
await verifyRevokedSessions(context, '停用', [{ accessToken: 'a', refreshToken: 'r' }])
|
||||
assert.equal(records.at(-2).passed, false)
|
||||
assert.equal(records.at(-1).passed, false)
|
||||
await assert.rejects(verifyRevokedSessions(context, '缺失', [{}]), /完整 Access\/Refresh/)
|
||||
})
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const acceptance = fs.readFileSync(path.join(here, '..', 'admin-test-env-acceptance.mjs'), 'utf8')
|
||||
const system = fs.readFileSync(path.join(here, 'system.mjs'), 'utf8')
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { verifyFleetBookingReservation } from './fleet-booking-reservation-acceptance.mjs'
|
||||
|
||||
function fixture(fault = '') {
|
||||
const baseline = [
|
||||
{ fromIndex: 0, toIndex: 1, occupied: { palletSpaces: 2, weightKg: 200, volumeCbm: 2 }, remaining: { palletSpaces: 28, weightKg: 17800, volumeCbm: 88 } },
|
||||
{ fromIndex: 1, toIndex: 2, occupied: { palletSpaces: 1, weightKg: 100, volumeCbm: 1 }, remaining: { palletSpaces: 29, weightKg: 17900, volumeCbm: 89 } },
|
||||
]
|
||||
const cargo = [
|
||||
{ dispatchNo: 'D-TEST', customerId: '900719925474099301', warehousePalletId: '900719925474099311', palletNo: 'P-A', destinationKey: 'FBA:A' },
|
||||
{ dispatchNo: 'D-TEST', customerId: '900719925474099301', warehousePalletId: '900719925474099312', palletNo: 'P-B', destinationKey: 'FBA:B' },
|
||||
]
|
||||
const tripProof = { tripNo: 'T-TEST', dispatchNo: 'D-TEST', vehicleId: 'V-TEST', driverId: 'DR-TEST',
|
||||
stops: [{ name: '起点' }, { name: 'A', destinationKey: 'FBA:A' }, { name: 'B', destinationKey: 'FBA:B' }] }
|
||||
let tripVersion = 10, planned = false, segments = structuredClone(baseline)
|
||||
const bookings = [], bodies = new Map(), calls = [], assertions = []
|
||||
const response = (data, rejected = false) => ({ http: 200, body: { code: rejected ? 'A0409' : '200', data,
|
||||
message: fault === 'wrong-conflict' ? '版本已变化' : '第1段路线超过车辆托位、重量或容积限制' },
|
||||
requestId: 'test-request', traceId: fault === 'missing-trace' ? null : 'test-trace' })
|
||||
const result = (booking, replayed = false) => response({ tripRecordVersion: tripVersion, booking: structuredClone(booking), replayed })
|
||||
const context = { marker: 'OFFLINE-CONTRACT', createdResources: { fleetTrips: [tripProof] },
|
||||
assert(module, name, res, passed, evidence, options) { assertions.push({ name, passed, options }) },
|
||||
async request(method, path, body) {
|
||||
const suffix = path.replace('/api/platform/v1/last-leg/fleet/trips/T-TEST', '')
|
||||
calls.push({ method, suffix, body })
|
||||
if (method === 'GET' && suffix === '') return response({ trip: { status: 'PLANNED', vehicleId: tripProof.vehicleId, driverId: tripProof.driverId }, dispatches: [{ dispatchNo: 'D-TEST' }] })
|
||||
if (method === 'GET' && suffix === '/loading') return response({ confirmedLoad: fault === 'fake-loaded' ? {} : null })
|
||||
if (suffix === '/capacity-plan') {
|
||||
if (method === 'POST') { planned = true; tripVersion++ }
|
||||
return response({ recordVersion: tripVersion, cargo, manifestHash: 'a'.repeat(64), blockReason: null,
|
||||
capacityPlan: planned ? { manifestHash: 'a'.repeat(64) } : null,
|
||||
planValid: planned, availableSegments: structuredClone(segments) })
|
||||
}
|
||||
if (method === 'GET' && suffix === '/bookings') return response({ tripRecordVersion: tripVersion, records: structuredClone(bookings) })
|
||||
if (method === 'POST' && suffix === '/bookings') {
|
||||
if (bodies.has(body.idempotencyKey)) {
|
||||
const old = bodies.get(body.idempotencyKey)
|
||||
if (JSON.stringify(old.body) !== JSON.stringify(body)) return response(null, true)
|
||||
return result(old.booking, true)
|
||||
}
|
||||
assert.equal(body.tripRecordVersion, tripVersion)
|
||||
const booking = { bookingNo: `FB-${bookings.length + 1}`, tripNo: tripProof.tripNo, customerId: body.customerId,
|
||||
cargo: body.cargo, status: 'DRAFT', recordVersion: 0 }
|
||||
bookings.push(booking); bodies.set(body.idempotencyKey, { body, booking }); tripVersion++
|
||||
return result(booking)
|
||||
}
|
||||
const match = suffix.match(/^\/bookings\/(FB-\d+)\/(confirm|cancel)$/)
|
||||
assert.ok(match, `unexpected ${method} ${suffix}`)
|
||||
const booking = bookings.find(row => row.bookingNo === match[1])
|
||||
if (bodies.has(body.idempotencyKey)) {
|
||||
if (fault === 'replay-reoccupies' && match[2] === 'confirm') segments[0].remaining.palletSpaces--
|
||||
return result(booking, true)
|
||||
}
|
||||
assert.equal(body.tripRecordVersion, tripVersion)
|
||||
assert.equal(body.recordVersion, booking.recordVersion)
|
||||
if (match[2] === 'confirm') {
|
||||
if (booking.cargo.palletSpaces > segments[0].remaining.palletSpaces) return response(null, true)
|
||||
booking.status = 'CONFIRMED'; booking.agreedAmount = 25
|
||||
for (const index of (fault === 'wrong-segment' ? [0, 1] : [0])) {
|
||||
segments[index].occupied.palletSpaces++; segments[index].occupied.weightKg++
|
||||
segments[index].occupied.volumeCbm += 0.01
|
||||
segments[index].remaining.palletSpaces--; segments[index].remaining.weightKg--
|
||||
segments[index].remaining.volumeCbm -= 0.01
|
||||
}
|
||||
} else {
|
||||
if (booking.status === 'CONFIRMED' && fault !== 'cancel-does-not-release') segments = structuredClone(baseline)
|
||||
booking.status = 'CANCELLED'
|
||||
}
|
||||
tripVersion++; booking.recordVersion++; bodies.set(body.idempotencyKey, { body, booking })
|
||||
return result(booking)
|
||||
},
|
||||
}
|
||||
const measurements = (actual, stops) => {
|
||||
assert.deepEqual(actual, cargo); assert.deepEqual(stops, tripProof.stops)
|
||||
return cargo.map((row, index) => ({ warehousePalletId: row.warehousePalletId, palletSpaces: 1, weightKg: 100, volumeCbm: 1, deliveryIndex: index + 1 }))
|
||||
}
|
||||
return { context, tripProof, measurements, calls, bookings, assertions }
|
||||
}
|
||||
|
||||
test('reservation HTTP helper verifies actual segments, original requests and cancellation without receiving or charging', async () => {
|
||||
const f = fixture()
|
||||
const proof = await verifyFleetBookingReservation(f.context, 'fleet-test', f.tripProof, f.measurements)
|
||||
assert.equal(proof.status, 'VERIFIED')
|
||||
assert.equal(proof.bookings.length, 2)
|
||||
assert.ok(f.bookings.every(row => row.status === 'CANCELLED'))
|
||||
assert.deepEqual(proof.finalSegments, proof.initialSegments)
|
||||
assert.ok(proof.requests.every(row => row.requestId && row.traceId))
|
||||
assert.ok(f.assertions.every(row => row.passed && row.options.deploymentBlocker))
|
||||
assert.ok(f.calls.every(row => !/incoming|receipt-confirmation|transfer-delivery|departure/.test(row.suffix)))
|
||||
assert.equal(proof.createRequest.customerId, '900719925474099301')
|
||||
await assert.rejects(() => verifyFleetBookingReservation(f.context, 'fleet-test', f.tripProof, f.measurements), /本轮新建/)
|
||||
})
|
||||
|
||||
for (const fault of ['wrong-segment', 'replay-reoccupies', 'cancel-does-not-release', 'fake-loaded', 'missing-trace', 'wrong-conflict']) {
|
||||
test(`reservation helper fails closed on ${fault} even when HTTP reports success`, async () => {
|
||||
const f = fixture(fault)
|
||||
await assert.rejects(() => verifyFleetBookingReservation(f.context, 'fleet-test', f.tripProof, f.measurements))
|
||||
assert.equal(f.tripProof.reservation.status, 'CHECKING')
|
||||
assert.ok(f.assertions.some(row => !row.passed && row.options.deploymentBlocker))
|
||||
if (['wrong-segment', 'replay-reoccupies'].includes(fault)) assert.equal(f.calls.some(row => row.suffix.endsWith('/cancel')), false)
|
||||
})
|
||||
}
|
||||
|
||||
test('reservation helper rejects foreign fixture before making any request', async () => {
|
||||
const f = fixture()
|
||||
await assert.rejects(() => verifyFleetBookingReservation(f.context, 'fleet-test', { ...f.tripProof }, f.measurements), /本轮新建/)
|
||||
assert.equal(f.calls.length, 0)
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
const OPTIONS = { scenario: 'fleet-external-reservation', deploymentBlocker: true }
|
||||
const text = value => value == null ? '' : String(value)
|
||||
const success = response => response?.http === 200 && text(response?.body?.code) === '200'
|
||||
const conflict = response => response?.http === 409 || (response?.http === 200 && text(response?.body?.code) === 'A0409')
|
||||
const key = () => `fleet-reservation-${randomUUID()}`
|
||||
const stable = value => JSON.stringify(value)
|
||||
const version = value => /^(0|[1-9]\d*)$/.test(text(value))
|
||||
const trace = response => ({ http: response?.http, code: response?.body?.code,
|
||||
requestId: response?.requestId, traceId: response?.traceId })
|
||||
|
||||
/** Internal HTTP helper. Operates only on the current run's trip before picking; no SQL or cleanup on failure. */
|
||||
export async function verifyFleetBookingReservation(context, module, tripProof, measurements) {
|
||||
if (!context.createdResources?.fleetTrips?.includes(tripProof) || !tripProof.tripNo || tripProof.reservation)
|
||||
throw new Error('外接占位验收仅接受本轮新建且尚未开始验收的车次;失败后保留原证据')
|
||||
const proof = tripProof.reservation = { status: 'CHECKING', marker: context.marker, requests: [], bookings: [] }
|
||||
const base = `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(tripProof.tripNo)}`
|
||||
async function check(name, passed, response, evidence = '') {
|
||||
context.assert(module, name, response, Boolean(passed), evidence, OPTIONS)
|
||||
if (!passed) throw new Error(`${module}: ${name}`)
|
||||
}
|
||||
async function request(method, suffix, body, label, expectedConflict = false) {
|
||||
const response = await context.request(method, `${base}${suffix}`, body)
|
||||
proof.requests.push({ method, suffix, label, ...trace(response) })
|
||||
const expectedResult = expectedConflict ? conflict(response)
|
||||
&& (!(expectedConflict instanceof RegExp) || expectedConflict.test(text(response?.body?.message))) : success(response)
|
||||
await check(label, expectedResult
|
||||
&& Boolean(response?.requestId) && Boolean(response?.traceId), response, JSON.stringify(trace(response)))
|
||||
return response.body?.data
|
||||
}
|
||||
const detail = await request('GET', '', undefined, '读取本轮车次与货物归属')
|
||||
await check('占位验收保留当前车次及分配资源', detail?.trip?.status === 'PLANNED'
|
||||
&& text(detail.trip.vehicleId) === text(tripProof.vehicleId) && text(detail.trip.driverId) === text(tripProof.driverId)
|
||||
&& detail.dispatches?.some(row => row.dispatchNo === tripProof.dispatchNo))
|
||||
const initial = await request('GET', '/capacity-plan', undefined, '读取拣货前真实自有货容量依据')
|
||||
await check('自有货容量来源完整', initial?.cargo?.length > 0 && !initial.blockReason
|
||||
&& initial.cargo.every(row => row.customerId && row.warehousePalletId && row.destinationKey)
|
||||
&& version(initial.recordVersion) && /^[0-9a-f]{64}$/.test(initial.manifestHash || ''))
|
||||
const pallets = measurements(initial.cargo, tripProof.stops)
|
||||
const plan = await request('POST', '/capacity-plan', {
|
||||
recordVersion: initial.recordVersion, manifestHash: initial.manifestHash, pallets,
|
||||
reason: '受控测试计划:按本轮真实实托确认预计占用,非生产测量',
|
||||
}, '确认本轮自有货计划容量')
|
||||
await check('保存容量计划不冒充实物装载', plan?.planValid === true
|
||||
&& plan.capacityPlan?.manifestHash === initial.manifestHash && plan.availableSegments?.length === tripProof.stops.length - 1)
|
||||
const loading = await request('GET', '/loading', undefined, '回查计划容量没有生成实载确认')
|
||||
await check('实载确认仍待执行', loading?.confirmedLoad == null)
|
||||
proof.initialSegments = plan.availableSegments
|
||||
const destinationKey = initial.cargo[0].destinationKey
|
||||
const destinationIndex = tripProof.stops.findIndex(stop => stop.destinationKey === destinationKey)
|
||||
const affected = plan.availableSegments.filter(row => row.fromIndex < destinationIndex)
|
||||
await check('本轮测试车次确有可销售余位', destinationIndex > 0 && affected.length === destinationIndex
|
||||
&& affected.every(row => Number(row.remaining?.palletSpaces) >= 1 && Number(row.remaining?.weightKg) >= 1
|
||||
&& Number(row.remaining?.volumeCbm) >= 0.01))
|
||||
let tripVersion = plan.recordVersion
|
||||
const draftBody = { tripRecordVersion: tripVersion, customerId: text(initial.cargo[0].customerId),
|
||||
idempotencyKey: key(), cargo: { destinationKey, cargoName: `${context.marker} 外接占位验收`,
|
||||
packageCount: 1, palletSpaces: 1, weightKg: 1, volumeCbm: 0.01,
|
||||
priceBasis: 'PALLET', unitPrice: 25, currency: 'CNY', remark: '仅验证询价及承运占位;不创建来货、不写库存或财务' } }
|
||||
proof.createRequest = draftBody
|
||||
const created = await request('POST', '/bookings', draftBody, '登记本轮外接询价')
|
||||
await check('外接询价正确归属本车客户及目的地', created?.booking?.status === 'DRAFT'
|
||||
&& created.booking.tripNo === tripProof.tripNo && created.booking.customerId === draftBody.customerId
|
||||
&& created.booking.cargo?.destinationKey === destinationKey && created.booking.bookingNo)
|
||||
proof.bookings.push(created.booking.bookingNo)
|
||||
const number = encodeURIComponent(created.booking.bookingNo)
|
||||
tripVersion = created.tripRecordVersion
|
||||
const replay = await request('POST', '/bookings', draftBody, '重放相同外接登记请求')
|
||||
await check('登记重放没有新预订或版本推进', replay?.replayed === true && replay.booking?.bookingNo === created.booking.bookingNo
|
||||
&& text(replay.tripRecordVersion) === text(tripVersion) && text(replay.booking?.recordVersion) === text(created.booking.recordVersion))
|
||||
await request('POST', '/bookings', { ...draftBody, cargo: { ...draftBody.cargo, packageCount: 2 } }, '相同请求键改变货量被拒绝', true)
|
||||
const draftCapacity = await request('GET', '/capacity-plan', undefined, '回查询价与错误重试后的余位')
|
||||
await check('询价及错误重试没有占位', stable(draftCapacity.availableSegments) === stable(proof.initialSegments)
|
||||
&& text(draftCapacity.recordVersion) === text(tripVersion))
|
||||
const confirmBody = { tripRecordVersion: tripVersion, recordVersion: created.booking.recordVersion, idempotencyKey: key() }
|
||||
proof.confirmRequest = confirmBody
|
||||
const confirmed = await request('POST', `/bookings/${number}/confirm`, confirmBody, '确认本轮外接承运')
|
||||
await check('承运确认保持约定报价且不假收货', confirmed?.booking?.status === 'CONFIRMED'
|
||||
&& Number(confirmed.booking.agreedAmount) === 25 && !confirmed.booking.incomingNo && !confirmed.booking.dispatchNo)
|
||||
tripVersion = confirmed.tripRecordVersion
|
||||
const reserved = await request('GET', '/capacity-plan', undefined, '读取确认承运后的分段余位')
|
||||
const expected = proof.initialSegments.map(segment => {
|
||||
if (segment.fromIndex >= destinationIndex) return segment
|
||||
const sum = (amount, delta) => Math.round((Number(amount) + delta) * 1000) / 1000
|
||||
return { ...segment,
|
||||
occupied: { palletSpaces: Number(segment.occupied.palletSpaces) + 1,
|
||||
weightKg: sum(segment.occupied.weightKg, 1), volumeCbm: sum(segment.occupied.volumeCbm, 0.01) },
|
||||
remaining: { palletSpaces: Number(segment.remaining.palletSpaces) - 1,
|
||||
weightKg: sum(segment.remaining.weightKg, -1), volumeCbm: sum(segment.remaining.volumeCbm, -0.01) } }
|
||||
})
|
||||
await check('只在起运至约定卸货站逐段扣减一次', stable(reserved.availableSegments) === stable(expected)
|
||||
&& reserved.planValid === true && text(reserved.recordVersion) === text(tripVersion))
|
||||
proof.reservedSegments = reserved.availableSegments
|
||||
const confirmReplay = await request('POST', `/bookings/${number}/confirm`, confirmBody, '重放承运确认')
|
||||
const afterReplay = await request('GET', '/capacity-plan', undefined, '核对承运重放后的容量')
|
||||
await check('重复承运确认不二次占位', confirmReplay?.replayed === true
|
||||
&& text(confirmReplay.tripRecordVersion) === text(tripVersion)
|
||||
&& text(afterReplay.recordVersion) === text(tripVersion) && stable(afterReplay.availableSegments) === stable(expected))
|
||||
const availableSpaces = Math.min(...reserved.availableSegments.filter(row => row.fromIndex < destinationIndex).map(row => Number(row.remaining.palletSpaces)))
|
||||
const excess = await request('POST', '/bookings', { ...draftBody, tripRecordVersion: tripVersion, idempotencyKey: key(),
|
||||
cargo: { ...draftBody.cargo, palletSpaces: availableSpaces + 1 } }, '登记超出余位但未确认的第二条询价')
|
||||
await check('第二条仍为询价', excess?.booking?.status === 'DRAFT' && excess.booking.bookingNo !== created.booking.bookingNo)
|
||||
proof.bookings.push(excess.booking.bookingNo)
|
||||
tripVersion = excess.tripRecordVersion
|
||||
await request('POST', `/bookings/${encodeURIComponent(excess.booking.bookingNo)}/confirm`, {
|
||||
tripRecordVersion: tripVersion, recordVersion: excess.booking.recordVersion, idempotencyKey: key(),
|
||||
}, '超卖确认被拒绝', /超过车辆托位、重量或容积限制/)
|
||||
const rejected = await request('GET', '/bookings', undefined, '回查超卖拒绝后的预订状态')
|
||||
const rejectedCapacity = await request('GET', '/capacity-plan', undefined, '回查超卖拒绝后的分段余位')
|
||||
await check('超卖失败保持询价和已承诺余位', rejected.records?.find(row => row.bookingNo === excess.booking.bookingNo)?.status === 'DRAFT'
|
||||
&& text(rejected.tripRecordVersion) === text(tripVersion) && text(rejectedCapacity.recordVersion) === text(tripVersion)
|
||||
&& stable(rejectedCapacity.availableSegments) === stable(expected))
|
||||
const excessCancelled = await request('POST', `/bookings/${encodeURIComponent(excess.booking.bookingNo)}/cancel`, {
|
||||
tripRecordVersion: tripVersion, recordVersion: excess.booking.recordVersion, idempotencyKey: key(), reason: '结束未承运的超容量测试询价',
|
||||
}, '正式取消未承运测试询价')
|
||||
tripVersion = excessCancelled.tripRecordVersion
|
||||
await check('未承运询价取消成功', excessCancelled.booking?.status === 'CANCELLED')
|
||||
const cancelBody = { tripRecordVersion: tripVersion, recordVersion: confirmed.booking.recordVersion,
|
||||
idempotencyKey: key(), reason: '结束本轮未执行外接预订并验证余位释放' }
|
||||
proof.cancelRequest = cancelBody
|
||||
const cancelled = await request('POST', `/bookings/${number}/cancel`, cancelBody, '正式取消已承运但未执行预订')
|
||||
tripVersion = cancelled.tripRecordVersion
|
||||
await check('已承运预订正式取消', cancelled.booking?.status === 'CANCELLED')
|
||||
const cancelReplay = await request('POST', `/bookings/${number}/cancel`, cancelBody, '重放取消承运请求')
|
||||
const released = await request('GET', '/capacity-plan', undefined, '回查取消与重放后的全部余位')
|
||||
await check('取消仅释放一次并恢复原自有货余位', cancelReplay?.replayed === true
|
||||
&& text(cancelReplay.tripRecordVersion) === text(tripVersion) && text(released.recordVersion) === text(tripVersion)
|
||||
&& stable(released.availableSegments) === stable(proof.initialSegments) && released.planValid === true)
|
||||
proof.finalSegments = released.availableSegments
|
||||
proof.finalTripVersion = tripVersion
|
||||
proof.status = 'VERIFIED'
|
||||
return proof
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { verifyFleetExternalDispatch } from './fleet-external-dispatch-acceptance.mjs'
|
||||
|
||||
function fixture(fault = '') {
|
||||
const segment = { fromIndex: 0, toIndex: 1, occupied: { palletSpaces: 2, weightKg: 200, volumeCbm: 2 },
|
||||
remaining: { palletSpaces: 28, weightKg: 17800, volumeCbm: 88 } }
|
||||
const tripProof = { tripNo: 'TR-CURRENT', warehouseId: '92', dispatchNo: 'DP-OWN',
|
||||
stops: [{ name: '起点' }, { name: 'ONT8', destinationKey: 'FBA:ONT8' }], externalReceipt: {
|
||||
status: 'VERIFIED', createRequest: { customerId: '900719925474099301' }, incomingNo: 'WT-CURRENT', bookingNo: 'FB-CURRENT',
|
||||
warehousePalletId: '900719925474099311', palletNo: 'PALLET-EXT', finalSegments: [segment], finalBooking: {
|
||||
cargo: { destinationKey: 'FBA:ONT8' }, receipt: { source: { pallets: [{ weightKg: 100, volumeCbm: 1 }] } },
|
||||
},
|
||||
} }
|
||||
let version = 10, linked = false, replanned = false, transferred = false
|
||||
const calls = [], assertions = []
|
||||
const customerId = tripProof.externalReceipt.createRequest.customerId
|
||||
const item = { warehousePalletId: tripProof.externalReceipt.warehousePalletId, packageCount: 1 }
|
||||
const booking = () => ({ bookingNo: 'FB-CURRENT', status: linked ? 'BOUND' : 'RECEIVED', incomingNo: 'WT-CURRENT', recordVersion: linked ? 4 : 3,
|
||||
dispatchNo: linked ? 'DP-EXT' : null })
|
||||
const binding = () => ({ tripRecordVersion: version, booking: booking(), replayed: false })
|
||||
const dispatches = [{ dispatchNo: 'DP-OWN', review: { status: 'PENDING' } }]
|
||||
const detail = () => ({ trip: { recordVersion: version, maxPalletSpaces: 30, vehiclePlate: 'TEST-TRUCK', driverName: 'TEST-DRIVER', driverPhone: '19900000000' }, dispatches })
|
||||
const response = (data, message = '') => ({ http: 200, body: { code: message ? 'A0409' : '200', message, data: structuredClone(data) }, requestId: 'req', traceId: 'trace' })
|
||||
const context = { marker: 'OFFLINE', createdResources: { fleetTrips: [tripProof] },
|
||||
assert(module, name, res, passed, evidence, options) { assertions.push({ name, passed, options }) },
|
||||
async request(method, path, body) {
|
||||
calls.push({ method, path, body })
|
||||
if (path.endsWith('/capacity-plan')) {
|
||||
if (method === 'POST') { replanned = true; version++ }
|
||||
return response({ recordVersion: version, manifestHash: 'a'.repeat(64), planValid: !linked || replanned || fault === 'stale-plan',
|
||||
capacityPlan: { pallets: [{ warehousePalletId: 'OWN-PALLET', palletSpaces: 1, weightKg: 100, volumeCbm: 1, deliveryIndex: 1 }] },
|
||||
cargo: [{ dispatchNo: 'DP-OWN', warehousePalletId: 'OWN-PALLET', packages: 1 }, ...(linked ? [{ dispatchNo: 'DP-EXT', warehousePalletId: tripProof.externalReceipt.warehousePalletId, packages: 1 }] : [])],
|
||||
availableSegments: replanned && fault === 'double-capacity' ? [{ ...segment, remaining: { ...segment.remaining, palletSpaces: 27 } }] : [segment] })
|
||||
}
|
||||
if (path.includes('/candidates?')) return response([{ customerId: fault === 'foreign-customer' ? 'wrong' : customerId, overseasWarehouseId: '92',
|
||||
inboundCode: 'WT-CURRENT', warehousePalletId: tripProof.externalReceipt.warehousePalletId, palletNo: 'PALLET-EXT', packageCount: 1,
|
||||
sourceStage: 'RECEIVED', planPalletId: 'PLAN-PALLET' }])
|
||||
if (path.endsWith('/drafts/containers')) return response({ id: 'CONTAINER' })
|
||||
if (path.endsWith('/draft-locks/lock')) return response(null)
|
||||
if (path.endsWith('/pre-arrange')) return response({ dispatchNo: 'DP-EXT', packageCount: 1, pickedPackageCount: 0, shippedPackageCount: 0, items: [item] })
|
||||
if (path.endsWith('/bookings')) return response({ tripRecordVersion: version, records: [booking()] })
|
||||
if (path.endsWith('/bookings/FB-CURRENT/dispatch')) {
|
||||
if (linked) return response({ ...binding(), replayed: true })
|
||||
linked = true; version++; dispatches.push({ dispatchNo: 'DP-EXT', review: { status: 'PENDING' } }); return response(binding())
|
||||
}
|
||||
if (path.endsWith('/review')) {
|
||||
assert.equal(body.recordVersion, version); version++
|
||||
const number = path.split('/').at(-2)
|
||||
if (fault !== 'review-not-applied') dispatches.find(row => row.dispatchNo === number).review.status = 'APPROVED'
|
||||
return response(detail())
|
||||
}
|
||||
if (path.endsWith('/trips/TR-CURRENT')) return response(detail())
|
||||
if (path.endsWith('/external-freight')) return response({ requiresDeliveryFee: false, snapshotHash: 'b'.repeat(64), quotes: [
|
||||
{ bookingNo: 'FB-CURRENT', incomingNo: 'WT-CURRENT', currency: 'CNY', amount: fault === 'stale-price' ? 50 : 30, quantity: 1, unitPrice: 30 },
|
||||
] })
|
||||
if (path.endsWith('/transfer-delivery')) {
|
||||
if (body.fees.length) return response(null, '本排车全部货物已带入外接约定运费,请勿重复填写派送应收')
|
||||
const replayed = transferred; transferred = true
|
||||
return response({ dispatchNo: 'DP-EXT', outboundNo: 'OUT-EXT', packageCount: 1, deliveries: [{ deliveryNo: 'DEL-EXT' }], replayed })
|
||||
}
|
||||
assert.match(path, /\/delivery-orders\/DEL-EXT\?customerId=900719925474099301$/)
|
||||
const rows = [{ feeDirection: 'RECEIVABLE', feeType: 'DELIVERY_FEE', currency: 'CNY', amount: 30, sourceStatus: 'ACTIVE', reviewStatus: 'APPROVED' }]
|
||||
if (fault === 'duplicate-fee' && calls.filter(row => row.path === path).length > 1) rows.push({ ...rows[0] })
|
||||
return response({ order: { dispatchNo: 'DP-EXT' }, fees: rows })
|
||||
},
|
||||
}
|
||||
return { context, tripProof, calls, assertions }
|
||||
}
|
||||
|
||||
test('external dispatch helper preserves reservation when binding physical cargo and publishes only agreed source fee', async () => {
|
||||
const f = fixture()
|
||||
const proof = await verifyFleetExternalDispatch(f.context, 'fleet-dispatch', f.tripProof)
|
||||
assert.equal(proof.status, 'VERIFIED')
|
||||
assert.equal(proof.dispatchNo, 'DP-EXT')
|
||||
assert.equal(proof.sourceFees.length, 1)
|
||||
assert.equal(proof.financeAcknowledgement, 'NOT_VERIFIED')
|
||||
assert.ok(f.assertions.every(row => row.passed && row.options.deploymentBlocker))
|
||||
assert.ok(f.calls.every(row => !/\/pick|\/depart|\/cancel/.test(row.path)))
|
||||
await assert.rejects(() => verifyFleetExternalDispatch(f.context, 'fleet-dispatch', f.tripProof), /尚未执行关联/)
|
||||
})
|
||||
|
||||
for (const fault of ['foreign-customer', 'stale-plan', 'double-capacity', 'review-not-applied', 'stale-price', 'duplicate-fee']) {
|
||||
test(`external dispatch proof blocks ${fault}`, async () => {
|
||||
const f = fixture(fault)
|
||||
await assert.rejects(() => verifyFleetExternalDispatch(f.context, 'fleet-dispatch', f.tripProof))
|
||||
assert.equal(f.tripProof.externalDispatch.status, 'CHECKING')
|
||||
assert.ok(f.assertions.some(row => !row.passed))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
const OPTIONS = { scenario: 'fleet-external-dispatch-price', deploymentBlocker: true }
|
||||
const id = value => value == null ? '' : String(value)
|
||||
const key = () => `fleet-dispatch-${randomUUID()}`
|
||||
const stable = rows => JSON.stringify(rows)
|
||||
|
||||
/** Continues a current-run VERIFIED external receipt. Leaves both dispatches ready for the caller's real PDA picking. */
|
||||
export async function verifyFleetExternalDispatch(context, module, tripProof) {
|
||||
const receipt = tripProof.externalReceipt
|
||||
if (!context.createdResources?.fleetTrips?.includes(tripProof) || receipt?.status !== 'VERIFIED' || tripProof.externalDispatch)
|
||||
throw new Error('外接关联只接受本轮已验证实收且尚未执行关联的车次')
|
||||
const proof = tripProof.externalDispatch = { status: 'CHECKING', marker: context.marker, requests: [] }
|
||||
const customerId = id(receipt.createRequest.customerId)
|
||||
const base = `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(tripProof.tripNo)}`
|
||||
const dispatchBase = '/api/platform/v1/last-leg/dispatches'
|
||||
async function check(name, passed, response) {
|
||||
context.assert(module, name, response, Boolean(passed), '', OPTIONS)
|
||||
if (!passed) throw new Error(`${module}: ${name}`)
|
||||
}
|
||||
async function request(method, path, body, label, expectedMessage) {
|
||||
const response = await context.request(method, path, body)
|
||||
proof.requests.push({ method, path, label, http: response?.http, code: response?.body?.code,
|
||||
requestId: response?.requestId, traceId: response?.traceId })
|
||||
const conflict = response?.http === 409 || (response?.http === 200 && id(response?.body?.code) === 'A0409')
|
||||
await check(label, response?.requestId && response?.traceId && (expectedMessage
|
||||
? conflict && expectedMessage.test(id(response?.body?.message)) : response?.http === 200 && id(response?.body?.code) === '200'), response)
|
||||
return response.body?.data
|
||||
}
|
||||
const before = await request('GET', `${base}/capacity-plan`, undefined, '关联外接前冻结有效计划与预留')
|
||||
await check('实际外接仍计入承诺容量', before?.planValid && before.capacityPlan?.pallets?.length > 0
|
||||
&& stable(before.availableSegments) === stable(receipt.finalSegments))
|
||||
const candidates = await request('GET', `${dispatchBase}/candidates?${new URLSearchParams({ sourceStage: 'RECEIVED', orderNo: receipt.incomingNo })}`,
|
||||
undefined, '查询本轮真实外接来货的可排车实托')
|
||||
await check('候选精确匹配本轮客户起运仓来货及实托', Array.isArray(candidates) && candidates.length === 1
|
||||
&& id(candidates[0].customerId) === customerId && id(candidates[0].overseasWarehouseId) === id(tripProof.warehouseId)
|
||||
&& candidates[0].inboundCode === receipt.incomingNo && id(candidates[0].warehousePalletId) === receipt.warehousePalletId
|
||||
&& candidates[0].palletNo === receipt.palletNo && Number(candidates[0].packageCount) === 1
|
||||
&& candidates[0].sourceStage === 'RECEIVED' && candidates[0].planPalletId)
|
||||
const candidate = candidates[0]
|
||||
proof.draftToken = key()
|
||||
const container = await request('POST', `${dispatchBase}/drafts/containers`, { draftToken: proof.draftToken, capacity: 30 }, '建立本轮外接排车草稿')
|
||||
await check('草稿返回明确容器引用', container?.id)
|
||||
proof.draftContainerId = id(container.id)
|
||||
await request('POST', `${dispatchBase}/draft-locks/lock`, { planPalletId: id(candidate.planPalletId),
|
||||
draftToken: proof.draftToken, draftContainerId: proof.draftContainerId }, '正式锁定本轮外接实托')
|
||||
proof.preArrangeRequest = { planPalletIds: [id(candidate.planPalletId)], draftToken: proof.draftToken }
|
||||
const arranged = await request('POST', `${dispatchBase}/pre-arrange`, proof.preArrangeRequest, '从真实已收货来源生成外接客户排车')
|
||||
await check('外接排车只包含本票实收货物', arranged?.dispatchNo && arranged.dispatchNo !== tripProof.dispatchNo
|
||||
&& arranged.items?.length === 1 && id(arranged.items[0].warehousePalletId) === receipt.warehousePalletId
|
||||
&& Number(arranged.packageCount) === 1 && Number(arranged.pickedPackageCount) === 0 && Number(arranged.shippedPackageCount) === 0)
|
||||
proof.dispatchNo = arranged.dispatchNo
|
||||
const replay = await request('POST', `${dispatchBase}/pre-arrange`, proof.preArrangeRequest, '重放外接预排不生成第二张排车')
|
||||
await check('预排重放保持排车与实托', replay?.dispatchNo === proof.dispatchNo && stable(replay.items) === stable(arranged.items))
|
||||
const bookingPath = `${base}/bookings/${encodeURIComponent(receipt.bookingNo)}`
|
||||
const bookingList = await request('GET', `${base}/bookings`, undefined, '读取关联前最新预订及车次版本')
|
||||
const booking = bookingList?.records?.find(row => row.bookingNo === receipt.bookingNo)
|
||||
await check('本票仍为实收完成且未关联', booking?.status === 'RECEIVED' && booking.incomingNo === receipt.incomingNo && !booking.dispatchNo)
|
||||
proof.bindRequest = { tripRecordVersion: bookingList.tripRecordVersion, recordVersion: booking.recordVersion,
|
||||
dispatchNo: proof.dispatchNo, idempotencyKey: key() }
|
||||
const linked = await request('POST', `${bookingPath}/dispatch`, proof.bindRequest, '将本票外接真实排车关联到承诺车次')
|
||||
await check('关联后预订绑定同一排车', linked?.booking?.status === 'BOUND' && linked.booking.dispatchNo === proof.dispatchNo)
|
||||
const linkedReplay = await request('POST', `${bookingPath}/dispatch`, proof.bindRequest, '重放外接排车关联')
|
||||
await check('关联重放不增加版本或第二次占位', linkedReplay?.replayed === true
|
||||
&& id(linkedReplay.tripRecordVersion) === id(linked.tripRecordVersion) && stable(linkedReplay.booking) === stable(linked.booking))
|
||||
const after = await request('GET', `${base}/capacity-plan`, undefined, '读取新增真实货物后的计划容量')
|
||||
await check('新增外接货物使旧容量确认待重核', after?.planValid === false && after.cargo?.some(row => row.dispatchNo === proof.dispatchNo
|
||||
&& id(row.warehousePalletId) === receipt.warehousePalletId && Number(row.packages) === 1))
|
||||
const pallets = new Map(before.capacityPlan.pallets.map(row => [id(row.warehousePalletId), row]))
|
||||
const actual = receipt.finalBooking.receipt.source.pallets[0]
|
||||
pallets.set(receipt.warehousePalletId, { warehousePalletId: receipt.warehousePalletId, palletSpaces: 1,
|
||||
weightKg: Number(actual.weightKg), volumeCbm: Number(actual.volumeCbm),
|
||||
deliveryIndex: tripProof.stops.findIndex(stop => stop.destinationKey === receipt.finalBooking.cargo.destinationKey) })
|
||||
await check('新容量只引用原计划和已核实外接实测', after.cargo.every(row => pallets.has(id(row.warehousePalletId)))
|
||||
&& new Set(after.cargo.map(row => id(row.warehousePalletId))).size === pallets.size)
|
||||
const replanned = await request('POST', `${base}/capacity-plan`, { recordVersion: after.recordVersion, manifestHash: after.manifestHash,
|
||||
pallets: [...pallets.values()], reason: '外接预订转实际发运后按本轮实测重新确认,不重复计算原预留' }, '重新核对包含外接实物的全部货物容量')
|
||||
await check('从预留转实际关联后分段总占用守恒', replanned?.planValid === true && stable(replanned.availableSegments) === stable(before.availableSegments))
|
||||
let detail = await request('GET', base, undefined, '读取本车所有客户排车待审核情况')
|
||||
await check('自有货与外接客户排车均保留', detail?.dispatches?.some(row => row.dispatchNo === tripProof.dispatchNo)
|
||||
&& detail.dispatches.some(row => row.dispatchNo === proof.dispatchNo))
|
||||
for (const binding of detail.dispatches) detail = await request('POST', `${base}/dispatches/${encodeURIComponent(binding.dispatchNo)}/review`,
|
||||
{ recordVersion: detail.trip.recordVersion, decision: 'APPROVED' }, '关联变化后逐客户通过调度审核')
|
||||
await check('每张客户执行单都有通过事实', detail.dispatches.every(row => row.review?.status === 'APPROVED'))
|
||||
const dispatchPath = `${dispatchBase}/${encodeURIComponent(proof.dispatchNo)}`
|
||||
const freight = await request('GET', `${dispatchPath}/external-freight`, undefined, '读取本票实收约定运费')
|
||||
await check('外接报价精确对应本票实收和确认单价', freight?.requiresDeliveryFee === false && freight.quotes?.length === 1
|
||||
&& freight.quotes[0].bookingNo === receipt.bookingNo && freight.quotes[0].incomingNo === receipt.incomingNo
|
||||
&& freight.quotes[0].currency === 'CNY' && Number(freight.quotes[0].amount) === 30
|
||||
&& Number(freight.quotes[0].quantity) === 1 && Number(freight.quotes[0].unitPrice) === 30)
|
||||
proof.transferRequest = { idempotencyKey: key(), plannedDeliveryAt: new Date(Date.now() + 21600000).toISOString().slice(0, 19),
|
||||
deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: 'TRUCK',
|
||||
vehicleSize: Number(detail.trip.maxPalletSpaces) <= 15 ? '15托' : '30托', vehiclePlate: detail.trip.vehiclePlate,
|
||||
driverName: detail.trip.driverName, driverPhone: detail.trip.driverPhone, appointmentAccount: 'SELF',
|
||||
remark: `${context.marker} 外接实收报价验收`, externalFreightHash: freight.snapshotHash, fees: [] }
|
||||
await request('POST', `${dispatchPath}/transfer-delivery`, { ...proof.transferRequest,
|
||||
idempotencyKey: key(), fees: [{ feeType: 'DELIVERY_FEE', feeName: '重复派送费测试', direction: 'RECEIVABLE', currency: 'CNY', amount: 30 }] },
|
||||
'全部外接已有约定运费时拒绝重复派送应收', /重复|再次/)
|
||||
const transferred = await request('POST', `${dispatchPath}/transfer-delivery`, proof.transferRequest, '按实收报价建立本票派送及外接应收来源')
|
||||
await check('外接只生成本目的地一张派送', transferred?.dispatchNo === proof.dispatchNo && transferred.outboundNo
|
||||
&& transferred.deliveries?.length === 1 && Number(transferred.packageCount) === 1)
|
||||
proof.outboundNo = transferred.outboundNo; proof.deliveryNo = transferred.deliveries[0].deliveryNo
|
||||
const deliveryPath = `/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(proof.deliveryNo)}?customerId=${encodeURIComponent(customerId)}`
|
||||
const delivery = await request('GET', deliveryPath, undefined, '回查本票派送费用来源')
|
||||
const fees = delivery?.fees?.filter(row => row.feeDirection === 'RECEIVABLE' && row.feeType === 'DELIVERY_FEE')
|
||||
await check('本票只登记约定30元应收来源且没有伪零费用', fees?.length === 1 && Number(fees[0].amount) === 30 && fees[0].currency === 'CNY'
|
||||
&& fees[0].sourceStatus === 'ACTIVE' && fees[0].reviewStatus === 'APPROVED' && delivery.order?.dispatchNo === proof.dispatchNo)
|
||||
const transferReplay = await request('POST', `${dispatchPath}/transfer-delivery`, proof.transferRequest, '外接转派送原请求重放')
|
||||
const replayedDelivery = await request('GET', deliveryPath, undefined, '重放后回查外接应收未重复')
|
||||
await check('外接重放保持派送及费用来源', transferReplay?.replayed === true && transferReplay.outboundNo === proof.outboundNo
|
||||
&& stable(replayedDelivery.fees) === stable(delivery.fees))
|
||||
proof.customerId = customerId; proof.finalTripVersion = detail.trip.recordVersion
|
||||
proof.sourceFees = fees; proof.financeAcknowledgement = 'NOT_VERIFIED'
|
||||
proof.status = 'VERIFIED'
|
||||
return proof
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { verifyFleetExternalReceipt } from './fleet-external-receipt-acceptance.mjs'
|
||||
|
||||
function fixture(fault = '') {
|
||||
const token = 'offline-secret-pda-token'
|
||||
const customerId = '900719925474099301'
|
||||
const tripProof = { tripNo: 'T-RECEIPT', warehouseId: '92', stops: [{ name: '起点' }, { name: 'ONT8', destinationKey: 'FBA:ONT8' }] }
|
||||
const baseline = [{ fromIndex: 0, toIndex: 1, occupied: { palletSpaces: 1, weightKg: 100, volumeCbm: 1 },
|
||||
remaining: { palletSpaces: 29, weightKg: 17900, volumeCbm: 89 } }]
|
||||
const finalSegments = [{ fromIndex: 0, toIndex: 1, occupied: { palletSpaces: 2, weightKg: 200, volumeCbm: 2 },
|
||||
remaining: { palletSpaces: 28, weightKg: 17800, volumeCbm: 88 } }]
|
||||
const reservedSegments = [{ fromIndex: 0, toIndex: 1, occupied: { palletSpaces: 3, weightKg: 150, volumeCbm: 1.5 },
|
||||
remaining: { palletSpaces: 27, weightKg: 17850, volumeCbm: 88.5 } }]
|
||||
const calls = [], assertions = [], commands = new Map()
|
||||
let tripVersion = 10, booking, pallet, taskStatus = 'WAIT_PDA_RECEIVING', stock = 0, ledgers = []
|
||||
const ok = (data, message = '') => ({ http: 200, body: { code: message ? 'A0409' : '200', message, data: structuredClone(data) }, requestId: 'req', traceId: 'trace' })
|
||||
const result = replayed => ok({ tripRecordVersion: tripVersion, booking: { ...booking,
|
||||
updatedAt: replayed ? '2026-10-02T21:55:52.334Z' : '2026-10-02T21:55:52.334272005Z' }, replayed })
|
||||
const task = () => ({ transferNo: 'WT-NEW', inboundCode: 'WT-NEW', operatingWarehouseId: fault === 'foreign-warehouse' ? '93' : '92',
|
||||
status: taskStatus, pallets: pallet ? [pallet] : [] })
|
||||
const evidence = () => ({ status: taskStatus, warehouseId: '92', destinationKey: 'FBA:ONT8', incomingNo: 'WT-NEW', inboundCode: 'WT-NEW',
|
||||
receiptHash: 'a'.repeat(64), receivedPackages: pallet?.status === 'COMPLETED' ? 1 : 0,
|
||||
pallets: pallet?.status === 'COMPLETED' ? [{ warehousePalletId: pallet.warehousePalletId, palletNo: pallet.palletNo, packages: 1, weightKg: 100, volumeCbm: 1 }] : [] })
|
||||
const ledger = () => ({ id: 'ledger-1', customerId, overseasWarehouseId: '92', inboundCode: 'WT-NEW',
|
||||
palletNo: 'PLT-NEW', businessOrderNo: 'WT-NEW', beforePackages: 0, deltaPackages: 1, afterPackages: 1 })
|
||||
const context = { marker: 'TEST-RECEIPT', createdResources: { fleetTrips: [tripProof] },
|
||||
assert(module, name, response, passed, details, options) { assertions.push({ name, passed, options }) },
|
||||
async requestMultipart(method, path, file, auth) {
|
||||
assert.equal(auth, token); assert.equal(file.type, 'image/png'); assert.ok(file.name.includes('TEST-MEASUREMENT'))
|
||||
calls.push({ method, path }); return ok({ fileId: fault === 'missing-file' ? null : '901', sizeBytes: file.content.length })
|
||||
},
|
||||
async request(method, path, body, auth) {
|
||||
calls.push({ method, path, body: structuredClone(body) })
|
||||
if (path.includes('/inventory/')) {
|
||||
const url = new URL(path, 'https://test.invalid')
|
||||
assert.equal(url.searchParams.get('customerId'), customerId)
|
||||
const balance = { id: '900719925474099900', customerId, overseasWarehouseId: '92', inboundCode: 'WT-NEW', availablePackages: stock,
|
||||
holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0, inTransitPackages: 0, procurementPackages: 0 }
|
||||
if (url.pathname.endsWith('/positions')) return ok([{ id: 'pos-1', balanceId: balance.id, customerId, overseasWarehouseId: '92',
|
||||
inboundCode: 'WT-NEW', palletId: '900719925474099311', palletNo: 'PLT-NEW', availablePackages: stock }])
|
||||
if (url.pathname.endsWith('/balances/900719925474099900')) return ok({ ...balance, inboundCode: fault === 'wrong-balance-source' ? 'WT-OTHER' : balance.inboundCode })
|
||||
assert.equal(url.searchParams.get('overseasWarehouseId'), '92')
|
||||
assert.equal(url.searchParams.get('inboundCode'), 'WT-NEW')
|
||||
const { inboundCode: omitted, ...group } = balance
|
||||
const rows = path.includes('/ledger?') ? ledgers : stock ? [group] : []
|
||||
return ok({ records: rows, total: rows.length })
|
||||
}
|
||||
if (path.endsWith('/capacity-plan')) return ok({ planValid: true, blockReason: null, recordVersion: tripVersion,
|
||||
cargo: [{ customerId, destinationKey: 'FBA:ONT8', warehousePalletId: '123' }],
|
||||
availableSegments: booking?.status === 'RECEIVED' && fault !== 'keeps-reservation' ? finalSegments : booking?.status === 'CONFIRMED' ? reservedSegments : baseline })
|
||||
if (path.startsWith('/api/pda/')) {
|
||||
assert.equal(auth, token)
|
||||
if (method === 'GET') return ok(task())
|
||||
if (commands.has(body.idempotencyKey)) {
|
||||
if (fault === 'pda-replay-stock' && path.endsWith('/palletize')) { stock++; ledgers.push({ ...ledger(), id: 'ledger-2' }) }
|
||||
return ok(task())
|
||||
}
|
||||
commands.set(body.idempotencyKey, true)
|
||||
if (path.endsWith('/pallets/drafts')) pallet = { id: 'pallet-record', status: 'DRAFT', cartonCount: body.cartonCount }
|
||||
else if (path.endsWith('/generate')) Object.assign(pallet, { status: 'GENERATED', warehousePalletId: '900719925474099311', palletNo: 'PLT-NEW' })
|
||||
else if (path.endsWith('/bind')) {
|
||||
assert.equal(body.palletCode, pallet.palletNo); pallet.status = 'BOUND'
|
||||
} else if (path.endsWith('/palletize')) {
|
||||
assert.equal(pallet.status, 'BOUND'); assert.deepEqual(body.photoFileIds, ['901'])
|
||||
pallet.status = 'COMPLETED'; taskStatus = 'WAIT_CUSTOMER_CONFIRMATION'
|
||||
if (fault !== 'no-stock') { stock = 1; ledgers = [ledger()] }
|
||||
} else throw new Error(`unexpected PDA path ${path}`)
|
||||
return ok(task())
|
||||
}
|
||||
if (method === 'GET' && path.endsWith('/incoming')) return ok({ tripRecordVersion: tripVersion, recordVersion: booking.recordVersion, incoming: evidence() })
|
||||
if (method === 'GET' && path.endsWith('/bookings')) {
|
||||
if (fault === 'persisted-read') throw Error('read interrupted after successful confirmation')
|
||||
return ok({ tripRecordVersion: tripVersion, records: [{ ...booking, updatedAt: '2026-10-02T21:55:52.334Z' }] })
|
||||
}
|
||||
if (commands.has(body.idempotencyKey)) return result(true)
|
||||
assert.equal(body.tripRecordVersion, tripVersion)
|
||||
if (path.endsWith('/bookings')) booking = { bookingNo: 'FB-NEW', customerId, status: 'DRAFT', recordVersion: 0, cargo: body.cargo }
|
||||
else if (path.endsWith('/confirm')) { booking.status = 'CONFIRMED'; booking.agreedAmount = 50; booking.recordVersion++ }
|
||||
else if (path.endsWith('/incoming')) { booking.incomingNo = 'WT-NEW'; booking.recordVersion++ }
|
||||
else if (path.endsWith('/receipt-confirmation')) {
|
||||
if (taskStatus === 'WAIT_PDA_RECEIVING') return ok(null, '仓库尚未完成实收及测量')
|
||||
assert.equal(body.receiptHash, 'a'.repeat(64)); assert.equal(body.pallets[0].warehousePalletId, pallet.warehousePalletId)
|
||||
booking.status = 'RECEIVED'; booking.agreedAmount = fault === 'old-price' ? 50 : 30; booking.recordVersion++
|
||||
booking.cargo = { ...booking.cargo, palletSpaces: 1, weightKg: 100, volumeCbm: 1, unitPrice: 30 }
|
||||
taskStatus = 'READY_FOR_DISPATCH'; booking.receipt = { source: evidence() }
|
||||
if (fault === 'confirmation-stock') { stock++; ledgers.push({ ...ledger(), id: 'ledger-3' }) }
|
||||
} else throw new Error(`unexpected platform path ${path}`)
|
||||
tripVersion++; commands.set(body.idempotencyKey, true)
|
||||
return result(false)
|
||||
},
|
||||
}
|
||||
return { context, tripProof, token, calls, assertions, clearFault() { fault = '' } }
|
||||
}
|
||||
|
||||
test('external receipt proof drives real PDA command chain and checks quantity/price/reservation separately', async () => {
|
||||
const f = fixture()
|
||||
const proof = await verifyFleetExternalReceipt(f.context, 'fleet-receipt', f.tripProof, f.token)
|
||||
assert.equal(proof.status, 'VERIFIED')
|
||||
assert.equal(proof.finalBooking.status, 'RECEIVED')
|
||||
assert.equal(proof.finalBooking.agreedAmount, 30)
|
||||
assert.equal(proof.finalInventory.ledger.length, 1)
|
||||
assert.ok(f.assertions.every(row => row.passed && row.options.deploymentBlocker))
|
||||
assert.ok(!JSON.stringify(proof).includes(f.token))
|
||||
assert.ok(f.calls.every(row => !/transfer-delivery|departure|cancel/.test(row.path)))
|
||||
await assert.rejects(() => verifyFleetExternalReceipt(f.context, 'fleet-receipt', f.tripProof, f.token), /保留原证据/)
|
||||
})
|
||||
|
||||
test('reviewed palletized sample continues with the original PDA key and never recreates or rereceives cargo', async () => {
|
||||
const f = fixture('wrong-balance-source')
|
||||
await assert.rejects(() => verifyFleetExternalReceipt(f.context, 'fleet-receipt', f.tripProof, f.token), /余额与位置确属/)
|
||||
const original = structuredClone(f.tripProof.externalReceipt.palletizeRequest)
|
||||
const priorCalls = f.calls.length
|
||||
assert.equal(f.tripProof.externalReceipt.receiptRequest, undefined)
|
||||
f.clearFault()
|
||||
const proof = await verifyFleetExternalReceipt(f.context, 'fleet-receipt', f.tripProof, f.token, { reconcilePalletized: true })
|
||||
const writes = f.calls.slice(priorCalls).filter(row => row.method !== 'GET')
|
||||
assert.deepEqual(writes.map(row => row.path.split('/').at(-1)), ['palletize', 'receipt-confirmation', 'receipt-confirmation'])
|
||||
assert.deepEqual(writes[0].body, original)
|
||||
assert.equal(proof.status, 'VERIFIED')
|
||||
assert.equal(proof.finalInventory.balances[0].availablePackages, 1)
|
||||
assert.equal(proof.finalInventory.positions[0].availablePackages, 1)
|
||||
assert.equal(proof.finalInventory.ledger.length, 1)
|
||||
assert.equal(proof.finalBooking.status, 'RECEIVED')
|
||||
await assert.rejects(() => verifyFleetExternalReceipt(f.context, 'fleet-receipt', f.tripProof, f.token, { reconcilePalletized: true }), /仅核对/)
|
||||
})
|
||||
|
||||
test('confirmed receipt reconciliation replays only the saved confirmation and preserves persisted timestamp and stock', async () => {
|
||||
const f = fixture('persisted-read')
|
||||
await assert.rejects(() => verifyFleetExternalReceipt(f.context, 'fleet-receipt', f.tripProof, f.token), /read interrupted/)
|
||||
const request = structuredClone(f.tripProof.externalReceipt.receiptRequest), previous = f.calls.length
|
||||
f.clearFault()
|
||||
const proof = await verifyFleetExternalReceipt(f.context, 'fleet-receipt', f.tripProof, f.token, { reconcileConfirmed: true })
|
||||
const writes = f.calls.slice(previous).filter(row => row.method !== 'GET')
|
||||
assert.equal(writes.length, 2)
|
||||
assert.ok(writes.every(row => row.path.endsWith('/receipt-confirmation')))
|
||||
for (const row of writes) assert.deepEqual(row.body, request)
|
||||
assert.equal(proof.finalInventory.ledger.length, 1)
|
||||
assert.equal(proof.finalInventory.balances[0].availablePackages, 1)
|
||||
assert.equal(proof.status, 'VERIFIED')
|
||||
})
|
||||
|
||||
for (const fault of ['foreign-warehouse', 'missing-file', 'no-stock', 'pda-replay-stock', 'confirmation-stock', 'keeps-reservation', 'old-price']) {
|
||||
test(`external receipt helper blocks ${fault} instead of treating status as inventory proof`, async () => {
|
||||
const f = fixture(fault)
|
||||
await assert.rejects(() => verifyFleetExternalReceipt(f.context, 'fleet-receipt', f.tripProof, f.token))
|
||||
assert.equal(f.tripProof.externalReceipt.status, 'CHECKING')
|
||||
assert.ok(f.assertions.some(row => !row.passed))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
const OPTIONS = { scenario: 'fleet-external-actual-receipt', deploymentBlocker: true }
|
||||
const id = value => value == null ? '' : String(value)
|
||||
const key = () => `fleet-receipt-${randomUUID()}`
|
||||
const ok = value => value?.http === 200 && id(value?.body?.code) === '200'
|
||||
const conflict = value => value?.http === 409 || (value?.http === 200 && id(value?.body?.code) === 'A0409')
|
||||
const snapshot = rows => JSON.stringify(rows.map(row => JSON.stringify(row)).sort())
|
||||
|
||||
/** Dedicated external receipt case; retains RECEIVED goods for subsequent dispatch. Never runs SQL or bypasses PDA. */
|
||||
export async function verifyFleetExternalReceipt(context, module, tripProof, pdaToken, { reconcilePalletized = false, reconcileConfirmed = false } = {}) {
|
||||
const reconciling = reconcilePalletized || reconcileConfirmed
|
||||
if (!context.createdResources?.fleetTrips?.includes(tripProof) || (tripProof.externalReceipt && !reconciling) || !pdaToken)
|
||||
throw new Error('外接实收验证须使用本轮车次及已绑定起运仓的PDA账号,失败后保留原证据')
|
||||
if (reconciling && (tripProof.externalReceipt?.status !== 'CHECKING' || !tripProof.externalReceipt.palletizeRequest?.idempotencyKey
|
||||
|| !tripProof.externalReceipt.warehousePalletId || (reconcileConfirmed ? !tripProof.externalReceipt.receiptRequest?.idempotencyKey : tripProof.externalReceipt.receiptRequest))) throw new Error('仅核对已入库的原来货与原完成命令,确认阶段须与保存的请求一致')
|
||||
const proof = reconciling ? tripProof.externalReceipt : tripProof.externalReceipt = { status: 'CHECKING', marker: context.marker, requests: [] }
|
||||
const base = `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(tripProof.tripNo)}`
|
||||
async function check(name, passed, response) {
|
||||
context.assert(module, name, response, Boolean(passed), '', OPTIONS)
|
||||
if (!passed) throw new Error(`${module}: ${name}`)
|
||||
}
|
||||
async function record(method, path, response, label, expectedMessage) {
|
||||
proof.requests.push({ method, path, label, http: response?.http, code: response?.body?.code,
|
||||
requestId: response?.requestId, traceId: response?.traceId })
|
||||
await check(label, response?.requestId && response?.traceId
|
||||
&& (expectedMessage ? conflict(response) && expectedMessage.test(id(response?.body?.message)) : ok(response)), response)
|
||||
return response.body?.data
|
||||
}
|
||||
const request = async (method, path, body, label, token, expectedMessage) =>
|
||||
record(method, path, await context.request(method, path, body, token), label, expectedMessage)
|
||||
let plan = await request('GET', `${base}/capacity-plan`, undefined, '读取本轮真实自有货及可用容量')
|
||||
await check('外接实收前已有有效自有货计划', plan?.planValid === true && !plan.blockReason && plan.cargo?.length > 0)
|
||||
const customerId = id(plan.cargo[0].customerId), destinationKey = plan.cargo[0].destinationKey
|
||||
const destinationIndex = tripProof.stops.findIndex(stop => stop.destinationKey === destinationKey)
|
||||
if (reconciling) {
|
||||
const baseline = proof.initialSegments || tripProof.reservation?.finalSegments
|
||||
const original = reconcileConfirmed ? { ...proof.createRequest?.cargo, palletSpaces: 1, weightKg: 100, volumeCbm: 1 } : proof.createRequest?.cargo
|
||||
await check('原实收来源及原预留基线可追溯', baseline?.length > 0 && proof.createRequest.customerId === customerId && original?.destinationKey === destinationKey)
|
||||
const reserved = baseline.map(segment => segment.fromIndex >= destinationIndex ? segment : { ...segment,
|
||||
occupied: { palletSpaces: Number(segment.occupied.palletSpaces) + Number(original.palletSpaces), weightKg: Number(segment.occupied.weightKg) + Number(original.weightKg), volumeCbm: Number(segment.occupied.volumeCbm) + Number(original.volumeCbm) },
|
||||
remaining: { palletSpaces: Number(segment.remaining.palletSpaces) - Number(original.palletSpaces), weightKg: Number(segment.remaining.weightKg) - Number(original.weightKg), volumeCbm: Number(segment.remaining.volumeCbm) - Number(original.volumeCbm) } })
|
||||
await check('已收货未确认期间仍保留原承运预留', snapshot(plan.availableSegments) === snapshot(reserved))
|
||||
plan = { ...plan, availableSegments: baseline }
|
||||
} else proof.initialSegments = plan.availableSegments
|
||||
await check('使用本轮客户和已约定FBA卸货站且容量足够', /^[1-9]\d*$/.test(customerId)
|
||||
&& destinationKey.startsWith('FBA:') && destinationIndex > 0
|
||||
&& plan.availableSegments?.filter(row => row.fromIndex < destinationIndex).length === destinationIndex
|
||||
&& plan.availableSegments.filter(row => row.fromIndex < destinationIndex).every(row =>
|
||||
Number(row.remaining.palletSpaces) >= 2 && Number(row.remaining.weightKg) >= 100 && Number(row.remaining.volumeCbm) >= 1))
|
||||
let bookingPath
|
||||
if (!reconciling) {
|
||||
const draft = { tripRecordVersion: plan.recordVersion, customerId, idempotencyKey: key(),
|
||||
cargo: { destinationKey, cargoName: `${context.marker} 外接实收测试货`, packageCount: 1, palletSpaces: 2,
|
||||
weightKg: 50, volumeCbm: 0.5, priceBasis: 'PALLET', unitPrice: 25, currency: 'CNY',
|
||||
remark: '受控测试:计划2托位,PDA实测1托位100kg/1m³,运营重新确认30元/托' } }
|
||||
proof.createRequest = draft
|
||||
let result = await request('POST', `${base}/bookings`, draft, '登记本轮专用外接实收预订')
|
||||
await check('来货由本轮询价产生', result?.booking?.status === 'DRAFT' && result.booking.customerId === customerId && result.booking.bookingNo)
|
||||
proof.bookingNo = result.booking.bookingNo
|
||||
bookingPath = `${base}/bookings/${encodeURIComponent(proof.bookingNo)}`
|
||||
proof.confirmRequest = { tripRecordVersion: result.tripRecordVersion, recordVersion: result.booking.recordVersion, idempotencyKey: key() }
|
||||
result = await request('POST', `${bookingPath}/confirm`, proof.confirmRequest, '确认来货前承运预留')
|
||||
await check('预留报价为2托位乘25元', result?.booking?.status === 'CONFIRMED' && Number(result.booking.agreedAmount) === 50)
|
||||
proof.incomingRequest = { tripRecordVersion: result.tripRecordVersion, recordVersion: result.booking.recordVersion,
|
||||
estimatedArrivalAt: new Date(Date.now() + 1800000).toISOString(), deliveryInstructions: `${context.marker} 客户自送起运仓`, idempotencyKey: key() }
|
||||
result = await request('POST', `${bookingPath}/incoming`, proof.incomingRequest, '创建真实外接来货并进入PDA待收货')
|
||||
proof.incomingNo = result?.booking?.incomingNo
|
||||
await check('创建来货不提前标记实收', proof.incomingNo && result.booking.status === 'CONFIRMED' && !result.booking.receipt)
|
||||
const incomingReplay = await request('POST', `${bookingPath}/incoming`, proof.incomingRequest, '原请求重放来货登记')
|
||||
await check('来货重放复用同一单号和版本', incomingReplay?.replayed === true && incomingReplay.booking?.incomingNo === proof.incomingNo
|
||||
&& id(incomingReplay.tripRecordVersion) === id(result.tripRecordVersion) && id(incomingReplay.booking.recordVersion) === id(result.booking.recordVersion))
|
||||
}
|
||||
bookingPath ||= `${base}/bookings/${encodeURIComponent(proof.bookingNo)}`
|
||||
const incoming = await request('GET', `${bookingPath}/incoming`, undefined, '读取运营侧来货及实收证据')
|
||||
await check(reconciling ? '原来货实收阶段与检查点一致' : '实际来货为待PDA收货且没有完成托盘',
|
||||
incoming?.incoming?.status === (reconcileConfirmed ? 'READY_FOR_DISPATCH' : reconcilePalletized ? 'WAIT_CUSTOMER_CONFIRMATION' : 'WAIT_PDA_RECEIVING')
|
||||
&& incoming.incoming.warehouseId === id(tripProof.warehouseId) && incoming.incoming.destinationKey === destinationKey
|
||||
&& incoming.incoming.inboundCode && incoming.incoming.pallets?.length === (reconciling ? 1 : 0))
|
||||
proof.inboundCode = incoming.incoming.inboundCode
|
||||
const filter = new URLSearchParams({ customerId, overseasWarehouseId: id(tripProof.warehouseId), inboundCode: proof.inboundCode, page: '1', size: '200' })
|
||||
async function inventory(label) {
|
||||
const balances = await request('GET', `/api/platform/v1/last-leg/inventory/balances?${filter}`, undefined, `${label}读取本票库存`)
|
||||
const ledger = await request('GET', `/api/platform/v1/last-leg/inventory/ledger?${filter}&businessOrderNo=${encodeURIComponent(proof.incomingNo)}`, undefined, `${label}读取本票库存流水`)
|
||||
for (const [kind, page] of [['库存', balances], ['流水', ledger]]) await check(`${label}${kind}精确范围且无漏页`,
|
||||
Array.isArray(page?.records) && Number(page.total) === page.records.length && page.records.every(row =>
|
||||
id(row.customerId) === customerId && id(row.overseasWarehouseId) === id(tripProof.warehouseId)
|
||||
&& (kind === '库存' || row.inboundCode === proof.inboundCode)))
|
||||
const exactBalances = [], exactPositions = []
|
||||
for (const group of balances.records) {
|
||||
await check(`${label}分组有可回查的余额引用`, /^[1-9]\d*$/.test(id(group.id)))
|
||||
const balancePath = `/api/platform/v1/last-leg/inventory/balances/${encodeURIComponent(id(group.id))}`
|
||||
const balance = await request('GET', `${balancePath}?customerId=${encodeURIComponent(customerId)}`, undefined, `${label}回查实际余额来源`)
|
||||
const positions = await request('GET', `${balancePath}/positions?customerId=${encodeURIComponent(customerId)}`, undefined, `${label}回查实际余额的实物位置`)
|
||||
await check(`${label}余额与位置确属本票实际实托`, id(balance?.id) === id(group.id)
|
||||
&& id(balance.customerId) === customerId && id(balance.overseasWarehouseId) === id(tripProof.warehouseId)
|
||||
&& balance.inboundCode === proof.inboundCode && Array.isArray(positions)
|
||||
&& positions.every(row => id(row.balanceId) === id(balance.id) && id(row.customerId) === customerId
|
||||
&& id(row.overseasWarehouseId) === id(tripProof.warehouseId) && row.inboundCode === proof.inboundCode
|
||||
&& id(row.palletId) === proof.warehousePalletId && row.palletNo === proof.palletNo)
|
||||
&& ['availablePackages', 'holdPackages', 'allocatedPackages', 'pickedPackages', 'receivingPackages', 'inTransitPackages', 'procurementPackages']
|
||||
.every(field => Number(balance[field]) === Number(group[field])))
|
||||
exactBalances.push(balance); exactPositions.push(...positions)
|
||||
}
|
||||
return { balances: exactBalances, positions: exactPositions, ledger: ledger.records }
|
||||
}
|
||||
const before = reconciling ? proof.beforeInventory : await inventory('PDA完成前')
|
||||
await check('登记来货未伪造库存或入库流水', before.balances.every(row => Number(row.availablePackages) === 0)
|
||||
&& before.ledger.length === 0)
|
||||
proof.beforeInventory = before
|
||||
const pdaBase = `/api/pda/v1/last-leg/warehouse-transfers/${encodeURIComponent(proof.incomingNo)}`
|
||||
const task = await request('GET', pdaBase, undefined, 'PDA按绑定仓读取本轮来货', pdaToken)
|
||||
await check('PDA真实任务与来货、客户来源及仓库一致', task?.transferNo === proof.incomingNo && task.inboundCode === proof.inboundCode
|
||||
&& id(task.operatingWarehouseId) === id(tripProof.warehouseId) && task.status === (reconcileConfirmed ? 'READY_FOR_DISPATCH' : reconcilePalletized ? 'WAIT_CUSTOMER_CONFIRMATION' : 'WAIT_PDA_RECEIVING')
|
||||
&& task.pallets?.length === (reconciling ? 1 : 0))
|
||||
let completed
|
||||
if (reconciling) completed = task
|
||||
else {
|
||||
const early = { tripRecordVersion: incoming.tripRecordVersion, recordVersion: incoming.recordVersion,
|
||||
receiptHash: incoming.incoming.receiptHash, pallets: [{ warehousePalletId: id(plan.cargo[0].warehousePalletId), palletSpaces: 1 }], unitPrice: 30, idempotencyKey: key() }
|
||||
await request('POST', `${bookingPath}/receipt-confirmation`, early, '未完成PDA测量禁止运营提前确认实收', undefined, /仓库尚未完成实收及测量/)
|
||||
proof.palletDraftRequest = { idempotencyKey: key(), cartonCount: 1, lengthCm: 100, widthCm: 100, heightCm: 100, volumeCbm: 1, grossWeightKg: 100 }
|
||||
const measured = await request('POST', `${pdaBase}/pallets/drafts`, proof.palletDraftRequest, 'PDA登记本轮受控实物测量', pdaToken)
|
||||
await check('测量仅产生待生成实托草稿', measured?.pallets?.length === 1 && measured.pallets[0].status === 'DRAFT' && measured.pallets[0].id)
|
||||
const draftReplay = await request('POST', `${pdaBase}/pallets/drafts`, proof.palletDraftRequest, '测量草稿原键重放', pdaToken)
|
||||
await check('测量重放不增加托盘', snapshot(draftReplay.pallets) === snapshot(measured.pallets))
|
||||
const palletPath = `${pdaBase}/pallets/${encodeURIComponent(id(measured.pallets[0].id))}`
|
||||
const generated = await request('POST', `${palletPath}/generate`, { idempotencyKey: key() }, '生成仓库权威实物托盘', pdaToken)
|
||||
const pallet = generated?.pallets?.[0]
|
||||
await check('生成真实托盘且未入库', generated.pallets.length === 1 && pallet?.status === 'GENERATED' && pallet.warehousePalletId && pallet.palletNo)
|
||||
proof.warehousePalletId = id(pallet.warehousePalletId); proof.palletNo = pallet.palletNo
|
||||
const bound = await request('POST', `${palletPath}/bind`, { palletCode: pallet.palletNo, idempotencyKey: key() }, '按真实托盘码完成PDA绑定', pdaToken)
|
||||
await check('PDA绑定保留同一实托和实测数量', bound?.pallets?.length === 1 && bound.pallets[0].status === 'BOUND'
|
||||
&& id(bound.pallets[0].warehousePalletId) === proof.warehousePalletId && Number(bound.pallets[0].cartonCount) === 1)
|
||||
const photoPath = `${pdaBase}/measurement-photos/files`
|
||||
const uploaded = await record('POST', photoPath, await context.requestMultipart('POST', photoPath, {
|
||||
name: `${context.marker}-TEST-MEASUREMENT.png`, type: 'image/png',
|
||||
content: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64'),
|
||||
}, pdaToken), '上传明确标记为测试的测量凭证')
|
||||
await check('测量凭证有真实文件引用', uploaded?.fileId && Number(uploaded.sizeBytes) > 0)
|
||||
proof.photoFileId = id(uploaded.fileId)
|
||||
proof.palletizeRequest = { idempotencyKey: key(), photoFileIds: [proof.photoFileId] }
|
||||
completed = await request('POST', `${pdaBase}/palletize`, proof.palletizeRequest, 'PDA正式完成外接收货及库存入账', pdaToken)
|
||||
}
|
||||
await check('PDA已完成且保留原实托', completed?.status === (reconcileConfirmed ? 'READY_FOR_DISPATCH' : 'WAIT_CUSTOMER_CONFIRMATION')
|
||||
&& completed.pallets?.length === 1 && completed.pallets[0].status === 'COMPLETED'
|
||||
&& id(completed.pallets[0].warehousePalletId) === proof.warehousePalletId)
|
||||
const receivedStock = await inventory('首次完成收货后')
|
||||
await check('本票真实可用库存及不可变入库流水只增加1箱', receivedStock.balances.reduce((n, row) => n + Number(row.availablePackages), 0) === 1
|
||||
&& receivedStock.positions.length === 1 && Number(receivedStock.positions[0].availablePackages) === 1
|
||||
&& receivedStock.ledger.length > 0 && receivedStock.ledger.every(row => row.businessOrderNo === proof.incomingNo && row.palletNo === proof.palletNo
|
||||
&& Number(row.beforePackages) === 0 && Number(row.deltaPackages) === 1 && Number(row.afterPackages) === 1)
|
||||
&& receivedStock.ledger.reduce((n, row) => n + Number(row.deltaPackages), 0) === 1)
|
||||
proof.receivedInventory = receivedStock
|
||||
if (!reconcileConfirmed) {
|
||||
const completedReplay = await request('POST', `${pdaBase}/palletize`, proof.palletizeRequest, 'PDA完成原请求重放', pdaToken)
|
||||
const replayedStock = await inventory('PDA完成重放后')
|
||||
await check('PDA重放不重复写入库存与流水', snapshot(replayedStock.balances) === snapshot(receivedStock.balances)
|
||||
&& snapshot(replayedStock.positions) === snapshot(receivedStock.positions)
|
||||
&& snapshot(replayedStock.ledger) === snapshot(receivedStock.ledger) && snapshot(completedReplay.pallets) === snapshot(completed.pallets))
|
||||
}
|
||||
const measuredIncoming = await request('GET', `${bookingPath}/incoming`, undefined, '运营读取PDA完成后的实测摘要')
|
||||
await check('运营读取真实实收数量而非原预估', measuredIncoming?.incoming?.status === (reconcileConfirmed ? 'READY_FOR_DISPATCH' : 'WAIT_CUSTOMER_CONFIRMATION')
|
||||
&& measuredIncoming.incoming.pallets?.length === 1 && id(measuredIncoming.incoming.pallets[0].warehousePalletId) === proof.warehousePalletId
|
||||
&& Number(measuredIncoming.incoming.receivedPackages) === 1)
|
||||
if (!reconcileConfirmed) proof.receiptRequest = { tripRecordVersion: measuredIncoming.tripRecordVersion, recordVersion: measuredIncoming.recordVersion,
|
||||
receiptHash: measuredIncoming.incoming.receiptHash, pallets: [{ warehousePalletId: proof.warehousePalletId, palletSpaces: 1 }], unitPrice: 30, idempotencyKey: key() }
|
||||
const accepted = await request('POST', `${bookingPath}/receipt-confirmation`, proof.receiptRequest, '运营按实际1托位和新单价30确认实收')
|
||||
proof.acceptedReceipt = accepted
|
||||
await check('实收确认冻结新货量和价格且形成可排车来源', accepted?.booking?.status === 'RECEIVED'
|
||||
&& accepted.booking.receipt?.source?.status === 'READY_FOR_DISPATCH' && Number(accepted.booking.agreedAmount) === 30
|
||||
&& Number(accepted.booking.cargo?.palletSpaces) === 1 && Number(accepted.booking.cargo?.weightKg) === 100
|
||||
&& Number(accepted.booking.cargo?.volumeCbm) === 1 && Number(accepted.booking.cargo?.unitPrice) === 30)
|
||||
const persisted = await request('GET', `${base}/bookings`, undefined, '冻结已持久化实收记录以核对重放')
|
||||
const persistedBooking = persisted?.records?.find(row => row.bookingNo === proof.bookingNo)
|
||||
const { updatedAt: responseTime, ...acceptedFacts } = accepted.booking
|
||||
const { updatedAt: storedTime, ...persistedFacts } = persistedBooking || {}
|
||||
await check('持久化保留全部实收业务字段与版本', snapshot([acceptedFacts]) === snapshot([persistedFacts])
|
||||
&& id(persisted.tripRecordVersion) === id(accepted.tripRecordVersion))
|
||||
const acceptedReplay = await request('POST', `${bookingPath}/receipt-confirmation`, proof.receiptRequest, '运营实收确认原键重放')
|
||||
await check('实收重放不改价格或版本', acceptedReplay?.replayed === true && snapshot([acceptedReplay.booking]) === snapshot([persistedBooking])
|
||||
&& id(acceptedReplay.tripRecordVersion) === id(accepted.tripRecordVersion))
|
||||
const finalStock = await inventory('运营确认及重放后')
|
||||
await check('运营确认不会再次入库', snapshot(finalStock.balances) === snapshot(receivedStock.balances)
|
||||
&& snapshot(finalStock.positions) === snapshot(receivedStock.positions)
|
||||
&& snapshot(finalStock.ledger) === snapshot(receivedStock.ledger))
|
||||
const finalPlan = await request('GET', `${base}/capacity-plan`, undefined, '实收确认后回查分段容量')
|
||||
const expected = plan.availableSegments.map(segment => segment.fromIndex >= destinationIndex ? segment : { ...segment,
|
||||
occupied: { palletSpaces: Number(segment.occupied.palletSpaces) + 1, weightKg: Number(segment.occupied.weightKg) + 100, volumeCbm: Number(segment.occupied.volumeCbm) + 1 },
|
||||
remaining: { palletSpaces: Number(segment.remaining.palletSpaces) - 1, weightKg: Number(segment.remaining.weightKg) - 100, volumeCbm: Number(segment.remaining.volumeCbm) - 1 } })
|
||||
await check('实收替换原预留而非重复占用且仅影响约定站段', finalPlan?.planValid === true && snapshot(finalPlan.availableSegments) === snapshot(expected))
|
||||
proof.finalBooking = accepted.booking; proof.finalTripVersion = accepted.tripRecordVersion
|
||||
proof.finalInventory = finalStock; proof.finalSegments = finalPlan.availableSegments
|
||||
proof.status = 'VERIFIED'
|
||||
return proof
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { verifyFleetFeeCoverage } from './fleet-fee-coverage-acceptance.mjs'
|
||||
|
||||
function fixture(mode = 'FULL', fault = '') {
|
||||
const source = { marker: 'CURRENT-TEST', mode, dispatchNo: 'D-CURRENT', customerId: '900719925474099301',
|
||||
transferBody: { loadingType: 'PALLET', appointmentAccount: 'SELF', fees: [
|
||||
{ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', feeName: '派送费', amount: 17.5, currency: 'USD' },
|
||||
] } }
|
||||
const cargo = [1, 2].map(n => ({ dispatchItemId: `item-${n}`, warehousePalletId: `pallet-${n}`, orderId: `order-${n}`,
|
||||
sourceRegion: 'FIRSTLEG', sourceInboundId: `90071992547409940${n}`, inboundCode: `IN-${n}`, packages: 2 }))
|
||||
const fees = cargo.map((row, index) => ({ financeFeeId: `90071992547409950${index}`, sourceVersion: 1, sourceHash: `fee-hash-${index}`,
|
||||
sourceInboundId: row.sourceInboundId, customerId: source.customerId, sourceType: 'INBOUND_FEE', currency: 'CNY', amount: 200, status: 'AVAILABLE' }))
|
||||
if (fault === 'foreign-customer') fees[0].customerId = 'different-customer'
|
||||
if (fault === 'missing-source') fees.splice(1)
|
||||
const hash = 'a'.repeat(64), calls = [], assertions = []
|
||||
let savedBody, replayed = false, dirtyOutbound = false
|
||||
const response = (data, message = '') => ({ http: 200, body: { code: message ? 'A0409' : '200', message, data }, requestId: 'req-test', traceId: 'trace-test' })
|
||||
const context = { marker: source.marker, createdResources: { fleetFeeCoverageCases: [source] },
|
||||
assert(module, name, res, passed, evidence, options) { assertions.push({ name, passed, options }) },
|
||||
async request(method, path, body) {
|
||||
calls.push({ method, path, body })
|
||||
if (method === 'GET' && path === '/api/platform/v1/last-leg/dispatches/D-CURRENT')
|
||||
return response({ dispatchNo: source.dispatchNo, pickedPackageCount: 0, shippedPackageCount: 0,
|
||||
items: cargo.map((row, index) => ({ id: row.dispatchItemId, packageCount: row.packages,
|
||||
destinationWarehouseCode: `FBA-${index + 1}`, fbaWarehouseCode: `ORIGINAL-FBA-${index + 1}` })) })
|
||||
if (method === 'GET' && path.endsWith('/fee-coverage')) return response({ dispatchNo: source.dispatchNo, sourceComplete: true,
|
||||
cargoHash: hash, cargo, feeSources: fees.map(row => fault === 'mutated-original' && savedBody ? { ...row, amount: 201 } : row) })
|
||||
if (method === 'GET' && path.includes('/outbound-orders?')) {
|
||||
const params = new URL(path, 'https://test.invalid').searchParams
|
||||
assert.equal(params.get('customerId'), source.customerId)
|
||||
assert.equal(params.has('dispatchNo'), false, 'outbound list does not support dispatchNo filtering')
|
||||
const rows = dirtyOutbound ? [{ outboundNo: 'OUT-WRONG', dispatchNo: source.dispatchNo }] : [{ outboundNo: 'OUT-OTHER', dispatchNo: 'DS-OTHER' }]
|
||||
return response({ records: rows, total: rows.length, page: 1, size: 200 })
|
||||
}
|
||||
if (method === 'GET' && path === '/api/platform/v1/last-leg/outbound-orders/OUT-CURRENT') return response({
|
||||
outbound: { outboundNo: 'OUT-CURRENT', dispatchNo: source.dispatchNo, status: 'WAIT_OUTBOUND' },
|
||||
deliveries: [1, 2].map(n => ({ deliveryNo: `DEL-${n}`, status: 'WAIT_DELIVERY' })),
|
||||
})
|
||||
if (method === 'GET' && path.endsWith('/external-freight')) return response({ snapshotHash: 'b'.repeat(64), quotes: [], requiresDeliveryFee: true })
|
||||
if (method === 'POST' && path.endsWith('/transfer-delivery')) {
|
||||
if (body.feeCoverageCargoHash !== hash) {
|
||||
if (fault === 'failed-command-writes') dirtyOutbound = true
|
||||
return response(null, '原费用对应货物已变化,请重新核对覆盖范围')
|
||||
}
|
||||
if (body.feeCoverage[0].sourceVersion !== 1) return response(null, '原费用版本已变化,请重新确认服务覆盖范围')
|
||||
if (mode === 'FULL' && body.fees.length) return response(null, '本次货物的该服务已由原费用覆盖,请勿重复登记应收')
|
||||
if (mode === 'PARTIAL' && !body.fees.length) return response(null, '尚未有约定运费的货物须填写派送费')
|
||||
replayed = Boolean(savedBody)
|
||||
if (savedBody) assert.deepEqual(body, savedBody)
|
||||
savedBody = structuredClone(body)
|
||||
return response({ dispatchNo: source.dispatchNo, outboundNo: 'OUT-CURRENT', outboundStatus: 'WAIT_OUTBOUND', replayed,
|
||||
deliveries: [1, 2].map(n => ({ deliveryNo: `DEL-${n}`, deliveryStatus: 'WAIT_DELIVERY' })) })
|
||||
}
|
||||
const detail = path.match(/^\/api\/platform\/v1\/last-leg\/delivery-orders\/DEL-([12])\?customerId=900719925474099301$/)
|
||||
assert.ok(detail, `Unexpected ${method} ${path}`)
|
||||
const index = Number(detail[1]) - 1
|
||||
const selected = savedBody.feeCoverage.filter(selection => selection.financeFeeId === fees[index].financeFeeId)
|
||||
const references = selected.map(selection => ({ source: fees[index], services: selection.services }))
|
||||
const shouldCharge = mode === 'PARTIAL' && (fault === 'charge-covered-destination' ? index === 0 : index === 1)
|
||||
const newFee = { feeDirection: 'RECEIVABLE', feeType: 'DELIVERY_FEE', currency: 'USD', amount: 17.5, sourceStatus: 'ACTIVE' }
|
||||
return response({ order: { dispatchNo: source.dispatchNo, deliveryNo: `DEL-${index + 1}`, destinationWarehouseCode: `FBA-${index + 1}` },
|
||||
fees: shouldCharge || (fault === 'duplicate-new-fee' && index === 0) || (fault === 'replay-adds-fee' && replayed) ? [newFee] : [],
|
||||
feeCoverage: { cargoHash: hash, operatorId: 'op-test', confirmedAt: replayed && fault === 'rewrite-confirmation' ? '2026-10-03T02:00:00Z' : '2026-10-03T01:00:00Z',
|
||||
cargo: fault === 'wrong-cargo' ? [cargo[0]] : [cargo[index]], references: fault === 'missing-reference' ? [] : references } })
|
||||
},
|
||||
}
|
||||
return { source, context, calls, assertions, clearFault() { fault = '' } }
|
||||
}
|
||||
|
||||
test('persisted reconciliation reads exact original documents and only replays the original successful command', async () => {
|
||||
const f = fixture('FULL', 'wrong-cargo')
|
||||
await assert.rejects(() => verifyFleetFeeCoverage(f.context, 'coverage', f.source))
|
||||
assert.equal(f.source.proof.outboundNo, 'OUT-CURRENT')
|
||||
const originalKey = f.source.proof.confirmationRequest.idempotencyKey
|
||||
const before = f.calls.length
|
||||
f.clearFault()
|
||||
const proof = await verifyFleetFeeCoverage(f.context, 'coverage', f.source, { reconcilePersisted: true })
|
||||
assert.equal(proof.status, 'VERIFIED')
|
||||
const commands = f.calls.slice(before).filter(row => row.method === 'POST')
|
||||
assert.ok(commands.length > 0)
|
||||
assert.ok(commands.every(row => row.body.idempotencyKey === originalKey))
|
||||
assert.ok(f.calls.slice(before).some(row => row.method === 'GET' && row.path.endsWith('/outbound-orders/OUT-CURRENT')))
|
||||
})
|
||||
|
||||
for (const mode of ['FULL', 'PARTIAL']) {
|
||||
test(`${mode} fee coverage helper checks source scope, pre-write conflicts, each destination and replay`, async () => {
|
||||
const f = fixture(mode)
|
||||
const proof = await verifyFleetFeeCoverage(f.context, 'coverage', f.source)
|
||||
assert.equal(proof.status, 'VERIFIED')
|
||||
assert.equal(proof.newReceivable, mode === 'FULL' ? 0 : 17.5)
|
||||
assert.deepEqual(proof.deliveryNos, ['DEL-1', 'DEL-2'])
|
||||
assert.ok(f.assertions.every(row => row.passed && row.options.deploymentBlocker))
|
||||
assert.ok(f.calls.every(call => !/cancel|receipt|departure/.test(call.path)))
|
||||
await assert.rejects(() => verifyFleetFeeCoverage(f.context, 'coverage', f.source), /未执行/)
|
||||
})
|
||||
}
|
||||
|
||||
for (const fault of ['foreign-customer', 'missing-source', 'failed-command-writes', 'wrong-cargo', 'missing-reference',
|
||||
'duplicate-new-fee', 'rewrite-confirmation', 'mutated-original', 'charge-covered-destination', 'replay-adds-fee']) {
|
||||
test(`fee coverage HTTP proof cannot pass ${fault}`, async () => {
|
||||
const f = fixture(fault === 'charge-covered-destination' ? 'PARTIAL' : 'FULL', fault)
|
||||
await assert.rejects(() => verifyFleetFeeCoverage(f.context, 'coverage', f.source))
|
||||
assert.equal(f.source.proof.status, 'CHECKING')
|
||||
assert.ok(f.assertions.some(row => !row.passed))
|
||||
if (['foreign-customer', 'missing-source'].includes(fault)) assert.equal(f.calls.some(row => row.method === 'POST'), false)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
const OPTIONS = { scenario: 'fleet-original-fee-coverage', deploymentBlocker: true }
|
||||
const id = value => value == null ? '' : String(value)
|
||||
const ok = response => response?.http === 200 && id(response?.body?.code) === '200'
|
||||
const businessConflict = response => response?.http === 409 || (response?.http === 200 && id(response?.body?.code) === 'A0409')
|
||||
const canonical = rows => JSON.stringify(rows.map(row => JSON.stringify(row)).sort())
|
||||
const cargoIdentity = row => ({ dispatchItemId: id(row.dispatchItemId), warehousePalletId: id(row.warehousePalletId),
|
||||
orderId: id(row.orderId), sourceInboundId: id(row.sourceInboundId), packages: Number(row.packages) })
|
||||
const feeIdentity = row => ({ financeFeeId: id(row.financeFeeId), sourceVersion: Number(row.sourceVersion), sourceHash: row.sourceHash,
|
||||
sourceInboundId: id(row.sourceInboundId), customerId: id(row.customerId), currency: row.currency, amount: Number(row.amount), status: row.status })
|
||||
|
||||
/** Dedicated current-run dispatches only: one FULL case and a separate PARTIAL case with >=2 actual inbound sources. */
|
||||
export async function verifyFleetFeeCoverage(context, module, source, { reconcilePersisted = false } = {}) {
|
||||
if (!context.createdResources?.fleetFeeCoverageCases?.includes(source) || source.marker !== context.marker || (source.proof && !reconcilePersisted)
|
||||
|| !['FULL', 'PARTIAL'].includes(source.mode) || !source.dispatchNo || !source.customerId)
|
||||
throw new Error('原费用验收需要本轮专用、未执行的排车用例;失败后保留原请求,不重复创建')
|
||||
if (reconcilePersisted && (source.mode !== 'FULL' || source.proof?.status !== 'CHECKING'
|
||||
|| !source.proof.outboundNo || !source.proof.deliveryNos?.length || !source.proof.confirmationRequest?.idempotencyKey))
|
||||
throw new Error('已落单核对须有原成功请求、出库单及派送集合,不允许重造')
|
||||
const proof = reconcilePersisted ? source.proof : source.proof = { status: 'CHECKING', mode: source.mode, dispatchNo: source.dispatchNo, requests: [] }
|
||||
const dispatchPath = `/api/platform/v1/last-leg/dispatches/${encodeURIComponent(source.dispatchNo)}`
|
||||
const outboundPath = `/api/platform/v1/last-leg/outbound-orders?size=200&customerId=${encodeURIComponent(source.customerId)}`
|
||||
async function check(name, passed, response) {
|
||||
context.assert(module, name, response, Boolean(passed), '', OPTIONS)
|
||||
if (!passed) throw new Error(`${module}: ${name}`)
|
||||
}
|
||||
async function request(method, path, body, label, expectedMessage) {
|
||||
const response = await context.request(method, path, body)
|
||||
proof.requests.push({ label, method, path, http: response?.http, code: response?.body?.code,
|
||||
requestId: response?.requestId, traceId: response?.traceId })
|
||||
await check(label, Boolean(response?.requestId) && Boolean(response?.traceId)
|
||||
&& (expectedMessage ? businessConflict(response) && expectedMessage.test(id(response?.body?.message)) : ok(response)), response)
|
||||
return response.body?.data
|
||||
}
|
||||
async function noOutbound(label) {
|
||||
const rows = []
|
||||
let total
|
||||
for (let index = 1; ; index++) {
|
||||
const page = await request('GET', `${outboundPath}&page=${index}`, undefined, label)
|
||||
await check('客户出库列表分页元数据完整', Array.isArray(page?.records) && Number.isSafeInteger(Number(page.total))
|
||||
&& Number(page.total) >= 0 && Number(page.page) === index && Number(page.size) === 200)
|
||||
total ??= Number(page.total)
|
||||
await check('客户出库列表跨页范围稳定', total === Number(page.total) && (page.records.length > 0 || rows.length === total))
|
||||
rows.push(...page.records)
|
||||
if (rows.length >= total) break
|
||||
}
|
||||
await check('失败命令没有生成本排车出库事实', rows.length === total
|
||||
&& new Set(rows.map(row => row.outboundNo)).size === rows.length && rows.every(row => row.dispatchNo !== source.dispatchNo))
|
||||
}
|
||||
const dispatch = await request('GET', dispatchPath, undefined, '核对本轮费用验收排车来源')
|
||||
await check('只处理本轮尚未拣货及发车的排车', dispatch?.dispatchNo === source.dispatchNo
|
||||
&& Number(dispatch.pickedPackageCount) === 0
|
||||
&& Number(dispatch.shippedPackageCount) === 0 && dispatch.items?.length > 0)
|
||||
if (!reconcilePersisted) await noOutbound('确认专用排车尚无出库单')
|
||||
const preview = await request('GET', `${dispatchPath}/fee-coverage`, undefined, '读取本次实际货物及原 Finance 费用来源')
|
||||
await check('原费用范围完整且可确认', preview?.sourceComplete === true && !preview.blockReason
|
||||
&& preview.dispatchNo === source.dispatchNo && /^[0-9a-f]{64}$/.test(preview.cargoHash || '')
|
||||
&& preview.cargo?.length > 0 && preview.cargo.every(row => row.sourceRegion === 'FIRSTLEG' && id(row.sourceInboundId)))
|
||||
const itemTotals = new Map()
|
||||
for (const row of preview.cargo) itemTotals.set(id(row.dispatchItemId), (itemTotals.get(id(row.dispatchItemId)) || 0) + Number(row.packages))
|
||||
await check('费用预览逐条排车箱数核平', itemTotals.size === dispatch.items.length
|
||||
&& dispatch.items.every(row => itemTotals.get(id(row.id)) === Number(row.packageCount)))
|
||||
const inboundIds = [...new Set(preview.cargo.map(row => id(row.sourceInboundId)))].sort()
|
||||
await check('原费用均属于当前客户和实际原入仓', Array.isArray(preview.feeSources) && preview.feeSources.every(fee =>
|
||||
id(fee.customerId) === id(source.customerId) && inboundIds.includes(id(fee.sourceInboundId))
|
||||
&& ['AVAILABLE', 'SETTLED'].includes(fee.status) && ['INBOUND_FEE', 'CONTAINER_FEE'].includes(fee.sourceType)
|
||||
&& fee.currency === 'CNY' && Number(fee.sourceVersion) >= 1 && fee.sourceHash && id(fee.financeFeeId)))
|
||||
const sources = inboundIds.map(inboundId => preview.feeSources.find(fee => id(fee.sourceInboundId) === inboundId))
|
||||
await check('本轮每票已产生可引用原费用,不借其他票费用补齐', sources.every(Boolean)
|
||||
&& (source.mode === 'FULL' || inboundIds.length >= 2))
|
||||
const selectedFees = source.mode === 'FULL' ? sources : [sources[0]]
|
||||
const selections = selectedFees.map(fee => ({ financeFeeId: id(fee.financeFeeId), sourceVersion: Number(fee.sourceVersion),
|
||||
sourceHash: fee.sourceHash, services: ['DELIVERY_FEE'] }))
|
||||
const freight = await request('GET', `${dispatchPath}/external-freight`, undefined, '核对本专用排车没有外接报价混入覆盖用例')
|
||||
await check('本用例为原费用货物且使用托盘派送自有预约', Array.isArray(freight?.quotes) && freight.quotes.length === 0
|
||||
&& freight.requiresDeliveryFee === true && source.transferBody?.loadingType === 'PALLET'
|
||||
&& source.transferBody.appointmentAccount === 'SELF' && source.transferBody.fees?.length === 1
|
||||
&& source.transferBody.fees[0].direction === 'RECEIVABLE' && source.transferBody.fees[0].feeType === 'DELIVERY_FEE'
|
||||
&& Number(source.transferBody.fees[0].amount) > 0)
|
||||
const body = reconcilePersisted ? proof.confirmationRequest : { ...source.transferBody, idempotencyKey: `fleet-fee-${randomUUID()}`,
|
||||
externalFreightHash: freight.snapshotHash, feeCoverageCargoHash: preview.cargoHash, feeCoverage: selections,
|
||||
fees: source.mode === 'FULL' ? [] : source.transferBody.fees }
|
||||
proof.confirmationRequest = body
|
||||
if (!reconcilePersisted) proof.originalFees = preview.feeSources.map(feeIdentity)
|
||||
const transferPath = `${dispatchPath}/transfer-delivery`
|
||||
let transferred
|
||||
if (reconcilePersisted) {
|
||||
await check('原成功请求的货物与引用仍对应本次核对范围', body.feeCoverageCargoHash === preview.cargoHash
|
||||
&& canonical(body.feeCoverage) === canonical(selections))
|
||||
const stored = await request('GET', `/api/platform/v1/last-leg/outbound-orders/${encodeURIComponent(proof.outboundNo)}`, undefined, '先核对已持久化的原出库与派送集合')
|
||||
await check('原出库和派送存在且尚未执行', stored?.outbound?.dispatchNo === source.dispatchNo
|
||||
&& stored.outbound.outboundNo === proof.outboundNo && stored.outbound.status === 'WAIT_OUTBOUND'
|
||||
&& canonical((stored.deliveries || []).map(row => row.deliveryNo)) === canonical(proof.deliveryNos)
|
||||
&& stored.deliveries.every(row => row.status === 'WAIT_DELIVERY'))
|
||||
transferred = await request('POST', transferPath, body, '确认原持久化单据后仅重放同键同载荷成功命令')
|
||||
await check('核对重放未新建任何业务单据', transferred?.replayed === true && transferred.outboundNo === proof.outboundNo
|
||||
&& canonical((transferred.deliveries || []).map(row => row.deliveryNo)) === canonical(proof.deliveryNos))
|
||||
} else {
|
||||
const staleHash = `${preview.cargoHash[0] === '0' ? '1' : '0'}${preview.cargoHash.slice(1)}`
|
||||
await request('POST', transferPath, { ...body, idempotencyKey: `stale-cargo-${randomUUID()}`, feeCoverageCargoHash: staleHash },
|
||||
'货物摘要陈旧时拒绝转派送', /货物已变化/)
|
||||
await noOutbound('陈旧货物拒绝后回查无业务写入')
|
||||
await request('POST', transferPath, { ...body, idempotencyKey: `stale-fee-${randomUUID()}`,
|
||||
feeCoverage: selections.map((row, index) => index === 0 ? { ...row, sourceVersion: row.sourceVersion + 1 } : row) },
|
||||
'原费用版本陈旧时拒绝确认', /原费用版本已变化/)
|
||||
await noOutbound('陈旧原费用拒绝后回查无业务写入')
|
||||
await request('POST', transferPath, { ...body, idempotencyKey: `fee-scope-${randomUUID()}`,
|
||||
fees: source.mode === 'FULL' ? source.transferBody.fees : [] },
|
||||
source.mode === 'FULL' ? '全覆盖禁止再登记派送应收' : '部分覆盖仍须登记未覆盖派送费',
|
||||
source.mode === 'FULL' ? /已由原费用覆盖/ : /须填写派送费/)
|
||||
await noOutbound('覆盖金额校验拒绝后回查无业务写入')
|
||||
transferred = await request('POST', transferPath, body, '正式保存本次原费用服务覆盖并转派送')
|
||||
}
|
||||
await check('转派送生成本次真实出库和各目的地派送', transferred?.dispatchNo === source.dispatchNo && transferred.outboundNo
|
||||
&& transferred.outboundStatus === 'WAIT_OUTBOUND' && transferred.deliveries?.length > 0
|
||||
&& transferred.deliveries.every(row => row.deliveryNo && row.deliveryStatus === 'WAIT_DELIVERY'))
|
||||
proof.outboundNo = transferred.outboundNo
|
||||
proof.deliveryNos = transferred.deliveries.map(row => row.deliveryNo).sort()
|
||||
const evidenceByDelivery = {}
|
||||
const feesByDelivery = {}
|
||||
let newReceivable = 0
|
||||
const allCargo = []
|
||||
for (const number of proof.deliveryNos) {
|
||||
const detailPath = `/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(number)}?customerId=${encodeURIComponent(source.customerId)}`
|
||||
const detail = await request('GET', detailPath, undefined, '回查派送详情中的原费用确认快照')
|
||||
const evidence = detail?.feeCoverage
|
||||
const destination = id(detail?.order?.destinationWarehouseCode || detail?.order?.destinationWarehouseId)
|
||||
const destinationItems = new Set(dispatch.items.filter(row => id(row.destinationWarehouseCode || row.fbaWarehouseCode) === destination).map(row => id(row.id)))
|
||||
await check('费用确认货物只属于当前派送目的地', destination && destinationItems.size > 0
|
||||
&& evidence?.cargo?.every(row => destinationItems.has(id(row.dispatchItemId))))
|
||||
await check('确认快照属于本次派送及实际原费用版本', detail?.order?.dispatchNo === source.dispatchNo
|
||||
&& detail.order.deliveryNo === number && evidence?.cargoHash === preview.cargoHash
|
||||
&& evidence.operatorId && Number.isFinite(Date.parse(evidence.confirmedAt)) && evidence.cargo?.length > 0
|
||||
&& Array.isArray(evidence.references) && evidence.references.every(ref => evidence.cargo.some(row => id(row.sourceInboundId) === id(ref.source?.sourceInboundId)) && selectedFees.some(fee =>
|
||||
id(fee.financeFeeId) === id(ref.source?.financeFeeId) && Number(fee.sourceVersion) === Number(ref.source?.sourceVersion)
|
||||
&& fee.sourceHash === ref.source?.sourceHash && canonical(ref.services || []) === canonical(['DELIVERY_FEE']))))
|
||||
const selectedInbounds = new Set(selectedFees.map(fee => id(fee.sourceInboundId)))
|
||||
await check('快照保留本派送已选择的每票服务引用', evidence.cargo.every(row => !selectedInbounds.has(id(row.sourceInboundId))
|
||||
|| evidence.references.some(ref => id(ref.source?.sourceInboundId) === id(row.sourceInboundId) && ref.services.includes('DELIVERY_FEE'))))
|
||||
await check('费用明细为明确应收列表', Array.isArray(detail.fees) && detail.fees.every(row => row.feeDirection && row.feeType))
|
||||
const added = detail.fees.filter(row => row.feeDirection === 'RECEIVABLE' && row.feeType === 'DELIVERY_FEE' && row.sourceStatus !== 'VOIDED')
|
||||
await check('已全覆盖目的地不承担其他目的地的新增派送费', !evidence.cargo.every(row => selectedInbounds.has(id(row.sourceInboundId))) || added.length === 0)
|
||||
await check('新增派送费没有伪零值或混合币种', added.every(row => Number(row.amount) > 0 && row.currency === source.transferBody.fees[0].currency))
|
||||
newReceivable += added.reduce((sum, row) => sum + Number(row.amount), 0)
|
||||
allCargo.push(...evidence.cargo.map(cargoIdentity))
|
||||
evidenceByDelivery[number] = evidence
|
||||
feesByDelivery[number] = canonical(detail.fees)
|
||||
}
|
||||
await check('多目的地快照汇总与实际原货物一致且不重复', canonical(allCargo) === canonical(preview.cargo.map(cargoIdentity)))
|
||||
await check('仅未覆盖部分产生本次新增应收', Math.round(newReceivable * 100)
|
||||
=== (source.mode === 'FULL' ? 0 : Math.round(Number(source.transferBody.fees[0].amount) * 100)))
|
||||
const replay = await request('POST', transferPath, body, '原费用确认成功后同键重放')
|
||||
await check('重放保持同一出库和派送集合', replay?.replayed === true && replay.outboundNo === proof.outboundNo
|
||||
&& canonical((replay.deliveries || []).map(row => row.deliveryNo)) === canonical(proof.deliveryNos))
|
||||
for (const number of proof.deliveryNos) {
|
||||
const detail = await request('GET', `/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(number)}?customerId=${encodeURIComponent(source.customerId)}`, undefined, '重放后回查覆盖证据')
|
||||
await check('重放未改写确认人时间和原费用快照', JSON.stringify(detail.feeCoverage) === JSON.stringify(evidenceByDelivery[number]))
|
||||
await check('成功命令重放未追加费用', Array.isArray(detail.fees) && canonical(detail.fees) === feesByDelivery[number])
|
||||
}
|
||||
const after = await request('GET', `${dispatchPath}/fee-coverage`, undefined, '回查引用原费用未改变其来源事实')
|
||||
await check('原费用版本金额与状态保持不变', canonical(after.feeSources.map(feeIdentity)) === canonical(proof.originalFees))
|
||||
proof.snapshots = evidenceByDelivery
|
||||
proof.newReceivable = newReceivable
|
||||
proof.status = 'VERIFIED'
|
||||
return proof
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { prepareFleetTrip, confirmFleetLoading, controlledPalletMeasurements, finishFleetTrip } from './fleet-trip-fulfillment.mjs'
|
||||
|
||||
const stops = [{ name: 'Origin' }, { name: 'ONT8', destinationKey: 'FBA:ONT8' }]
|
||||
const cargo = (customerId = 'A') => ({ dispatchNo: `DP-${customerId}`, customerId, warehousePalletId: '9007199254740993', palletNo: 'PLT-1', destinationKey: 'FBA:ONT8', destinationLabel: 'ONT8' })
|
||||
|
||||
test('test loading measures physical pallet once across customers and rejects conflicting unloading points', () => {
|
||||
assert.deepEqual(controlledPalletMeasurements([cargo(), cargo('B')], stops), [
|
||||
{ warehousePalletId: '9007199254740993', palletSpaces: 1, weightKg: 100, volumeCbm: 1, deliveryIndex: 1 },
|
||||
])
|
||||
assert.throws(() => controlledPalletMeasurements([{ ...cargo(), warehousePalletId: null }], stops), /缺少真实托盘/)
|
||||
assert.throws(() => controlledPalletMeasurements([cargo()], [{ name: 'Origin' }]), /不在车次路线/)
|
||||
assert.throws(() => controlledPalletMeasurements([cargo(), { ...cargo('B'), destinationKey: 'FBA:LAX9' }], [...stops, { destinationKey: 'FBA:LAX9' }]), /须先拆托/)
|
||||
})
|
||||
|
||||
test('fixture prepares actual qualification upload and review before assignment and copies authoritative destination keys', async () => {
|
||||
const calls = []
|
||||
let version = 0
|
||||
const context = {
|
||||
marker: 'TEST-FLEET', createdResources: {},
|
||||
apiSuccess(_module, _label, response) { return response.body.data },
|
||||
async request(method, path, body) {
|
||||
calls.push({ method, path, body })
|
||||
let data
|
||||
if (path.endsWith('/master-data/vehicles')) {
|
||||
assert.equal(body.status, 'IN_SERVICE', 'VehicleCreate requires an explicit status and trip assignment requires an in-service vehicle')
|
||||
data = { id: 'vehicle-id' }
|
||||
}
|
||||
else if (path.endsWith('/drivers')) data = { id: 'driver-id', recordVersion: 0 }
|
||||
else if (path.includes('/dispatches/') && path.endsWith('/review')) data = { trip: { recordVersion: version++ }, dispatches: [{ dispatchNo: 'DP-A', review: { status: 'APPROVED' } }] }
|
||||
else if (path.endsWith('/review')) data = { reviewStatus: 'APPROVED' }
|
||||
else if (path.endsWith('/loading')) data = { cargo: [cargo()] }
|
||||
else { data = { trip: { tripNo: 'TR-FIXTURE', recordVersion: version++, stops: body.stops || stops } } }
|
||||
return { http: 200, body: { code: '200', data } }
|
||||
},
|
||||
async requestMultipart(method, path, file) {
|
||||
calls.push({ method, path, file })
|
||||
assert.equal(file.type, 'image/png'); assert.ok(file.content.byteLength > 0)
|
||||
return { http: 200, body: { code: '200', data: { id: 'driver-id', recordVersion: 1, licenseFileId: 'file-id' } } }
|
||||
},
|
||||
}
|
||||
const proof = await prepareFleetTrip(context, 'test', 'DP-A', '92')
|
||||
assert.equal(proof.tripNo, 'TR-FIXTURE')
|
||||
assert.equal(proof.reassignment.status, 'PENDING')
|
||||
assert.ok(calls.findIndex(call => call.path.includes('/license?')) < calls.findIndex(call => call.path.endsWith('/review')))
|
||||
assert.ok(calls.findIndex(call => call.path.endsWith('/review')) < calls.findIndex(call => call.path.endsWith('/assignment')))
|
||||
assert.equal(calls.find(call => call.path.endsWith('/plan')).body.stops[1].destinationKey, 'FBA:ONT8')
|
||||
assert.equal(context.createdResources.fleetTrips[0].licenseFileId, 'file-id')
|
||||
assert.ok(calls.every(call => !call.path.endsWith('/depart')))
|
||||
})
|
||||
|
||||
test('unready actual dispatch prevents load confirmation rather than bypassing it with test values', async () => {
|
||||
const calls = []
|
||||
const context = { request: async (method, path) => { calls.push({ method, path }); return { http: 200, body: { code: '200', data: { blockReason: '拣货未完成' } } } },
|
||||
apiSuccess(_module, _name, response) { return response.body.data } }
|
||||
await assert.rejects(confirmFleetLoading(context, 'test', { tripNo: 'TR-1', stops }), /整车未就绪/)
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(calls[0].method, 'GET')
|
||||
})
|
||||
|
||||
function reassignmentServer(fault = '') {
|
||||
const proof = { tripNo: 'TR-FRESH', dispatchNo: 'DP-A', vehicleId: '101', driverId: '201', stops,
|
||||
reassignment: { status: 'PENDING' } }
|
||||
const trip = { tripNo: proof.tripNo, recordVersion: 4, vehicleId: '101', driverId: '201', vehiclePlate: 'ORIGINAL',
|
||||
driverName: 'Original driver', driverPhone: '123', status: 'PLANNED', stops, maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90 }
|
||||
const dispatch = { dispatchNo: proof.dispatchNo, overseasWarehouseId: '92', status: 'READY_TO_DEPART',
|
||||
packageCount: 10, pickedPackageCount: 10, shippedPackageCount: 0, remainingPackageCount: 0,
|
||||
estimatedArrivalAt: '2026-10-03T12:00:00', items: [{ id: '9007199254740995', inboundCode: 'IN-TEST', packageCount: 10 }],
|
||||
vehiclePlate: trip.vehiclePlate, driverName: trip.driverName, driverPhone: trip.driverPhone }
|
||||
const calls = [], assertions = [], dispatches = [{ dispatchNo: proof.dispatchNo, customerId: 'A', review: { status: 'APPROVED' } }]
|
||||
let load = null, currentCargo = [cargo()], assigned = false
|
||||
const ok = data => ({ http: 200, body: { code: '200', data: fault === 'string-versions'
|
||||
? JSON.parse(JSON.stringify(data, (key, value) => key === 'recordVersion' ? String(value) : value)) : structuredClone(data) } })
|
||||
if (fault === 'string-versions') trip.recordVersion = 8
|
||||
const loading = () => ({ recordVersion: trip.recordVersion, manifestHash: 'a'.repeat(64), cargo: currentCargo, confirmedLoad: load, blockReason: null })
|
||||
const context = {
|
||||
marker: 'TEST-REASSIGN',
|
||||
assert(_module, label, _response, passed) { assertions.push({ label, passed }) },
|
||||
apiSuccess(_module, _label, response) { return response.body.code === '200' ? response.body.data : null },
|
||||
async requestMultipart(method, path, file) {
|
||||
calls.push({ method, path }); assert.equal(file.type, 'image/png')
|
||||
return ok({ licenseFileId: 'FILE-NEW', recordVersion: 1 })
|
||||
},
|
||||
async request(method, path, body) {
|
||||
calls.push({ method, path, body: structuredClone(body) })
|
||||
if (path.endsWith('/loading')) {
|
||||
if (method === 'GET') return ok(loading())
|
||||
if (String(body.recordVersion) !== String(trip.recordVersion) && fault !== 'accept-old-version') {
|
||||
if (fault === 'reject-but-write') load = { manifestHash: body.manifestHash, pallets: body.pallets }
|
||||
return { http: 200, body: { code: 'A0409', message: fault === 'unrelated-conflict' ? '权限未配置' : '车次已被修改,请刷新后重试' } }
|
||||
}
|
||||
load = { manifestHash: body.manifestHash, pallets: body.pallets, cargo: currentCargo }
|
||||
if (assigned && fault === 'wrong-final-measurement') load.pallets = [{ ...body.pallets[0], weightKg: 200 }]
|
||||
trip.recordVersion++; return ok(loading())
|
||||
}
|
||||
if (path.endsWith('/master-data/vehicles')) {
|
||||
assert.equal(body.status, 'IN_SERVICE'); assert.equal(body.maxLoadKg, trip.maxLoadKg)
|
||||
return ok({ id: '102' })
|
||||
}
|
||||
if (path.endsWith('/drivers')) return ok({ id: '202', recordVersion: 0 })
|
||||
if (path.includes('/dispatches/') && path.endsWith('/review')) {
|
||||
assert.equal(String(body.recordVersion), String(trip.recordVersion)); trip.recordVersion++
|
||||
dispatches[0].review = { status: 'APPROVED' }; return ok({ trip, dispatches })
|
||||
}
|
||||
if (path.endsWith('/loading-review')) {
|
||||
assert.equal(String(body.recordVersion), String(trip.recordVersion)); assert.ok(load); trip.recordVersion++
|
||||
return ok({ trip, dispatches, loadingReview: { status: 'APPROVED' } })
|
||||
}
|
||||
if (path.endsWith('/review')) return ok({ reviewStatus: 'APPROVED' })
|
||||
if (path.endsWith('/assignment')) {
|
||||
assert.equal(String(body.recordVersion), String(trip.recordVersion))
|
||||
Object.assign(trip, { vehicleId: body.vehicleId, driverId: body.driverId, vehiclePlate: 'REPLACEMENT', driverName: 'Replacement driver', driverPhone: '456' })
|
||||
trip.recordVersion++; assigned = true; dispatches[0].review = { status: 'PENDING' }
|
||||
if (fault !== 'keeps-old-load') load = null
|
||||
Object.assign(dispatch, { vehiclePlate: trip.vehiclePlate, driverName: trip.driverName, driverPhone: trip.driverPhone })
|
||||
if (fault === 'changes-picking') dispatch.pickedPackageCount = 0
|
||||
if (fault === 'changes-customer') currentCargo = [{ ...cargo(), customerId: 'B' }]
|
||||
if (fault === 'unknown-assignment-outcome') throw new Error('connection interrupted after commit')
|
||||
return ok({ trip, dispatches })
|
||||
}
|
||||
if (path.endsWith(`/dispatches/${proof.dispatchNo}`)) return ok(dispatch)
|
||||
if (path.endsWith(`/trips/${proof.tripNo}`)) return ok({ trip, dispatches })
|
||||
throw new Error(`unexpected request ${method} ${path}`)
|
||||
},
|
||||
}
|
||||
return { proof, calls, assertions, context, trip }
|
||||
}
|
||||
|
||||
test('fresh-run load checks reassignment, unchanged work, stale version rejection and fresh confirmation before returning departure version', async () => {
|
||||
const server = reassignmentServer()
|
||||
const version = await confirmFleetLoading(server.context, 'test', server.proof)
|
||||
assert.equal(server.proof.reassignment.status, 'VERIFIED')
|
||||
assert.equal(version, 9)
|
||||
assert.equal(server.proof.vehicleId, '102'); assert.equal(server.proof.driverId, '202')
|
||||
assert.equal(server.proof.reassignment.originalTrip.vehicleId, '101')
|
||||
assert.equal(server.proof.reassignment.dispatchAfter.pickedPackageCount, 10)
|
||||
assert.equal(server.proof.confirmedLoad.pallets[0].weightKg, 100)
|
||||
assert.ok(server.assertions.every(row => row.passed))
|
||||
assert.equal(server.calls.filter(row => row.method === 'POST' && row.path.endsWith('/loading')).length, 3)
|
||||
assert.ok(server.calls.every(row => !/\/depart|\/pick|\/complete$/.test(row.path)), 'reassignment does not repeat physical warehouse work')
|
||||
})
|
||||
|
||||
test('string-serialized versions compare numerically across 9 to 10 and remain unchanged in requests', async () => {
|
||||
const server = reassignmentServer('string-versions')
|
||||
assert.equal(await confirmFleetLoading(server.context, 'test', server.proof), '13')
|
||||
assert.equal(server.proof.reassignment.status, 'VERIFIED')
|
||||
assert.equal(server.proof.reassignment.assignmentRequest.recordVersion, '9')
|
||||
assert.equal(server.calls.filter(row => row.method === 'POST' && row.path.endsWith('/loading')).at(-1).body.recordVersion, '11')
|
||||
})
|
||||
|
||||
for (const [fault, message] of [
|
||||
['keeps-old-load', /原装载失效/], ['changes-picking', /保留拣货/], ['changes-customer', /客户归属/],
|
||||
['accept-old-version', /旧版本不能/], ['unrelated-conflict', /旧版本不能/],
|
||||
['reject-but-write', /拒绝后仍未确认/], ['wrong-final-measurement', /装载确认返回/],
|
||||
['unknown-assignment-outcome', /connection interrupted/],
|
||||
]) test(`reassignment stops on ${fault} and retains requests without silently retrying`, async () => {
|
||||
const server = reassignmentServer(fault)
|
||||
await assert.rejects(confirmFleetLoading(server.context, 'test', server.proof), message)
|
||||
assert.equal(server.proof.reassignment.status, 'CHECKING')
|
||||
assert.equal(server.proof.reassignment.assignmentRequest.vehicleId, '102')
|
||||
const callCount = server.calls.length
|
||||
await assert.rejects(confirmFleetLoading(server.context, 'test', server.proof), /已开始/)
|
||||
assert.equal(server.calls.length, callCount)
|
||||
})
|
||||
|
||||
test('retained dispatch loading does not create replacement resources or mutate assignment', async () => {
|
||||
const server = reassignmentServer()
|
||||
delete server.proof.reassignment
|
||||
const version = await confirmFleetLoading(server.context, 'test', server.proof)
|
||||
assert.equal(version, 6)
|
||||
assert.equal(server.calls.length, 3)
|
||||
assert.ok(server.calls.every(row => (row.path.endsWith('/loading') || row.path.endsWith('/loading-review'))))
|
||||
})
|
||||
|
||||
function closureServer(fault = '') {
|
||||
const proof = { tripNo: 'TR-ORIGINAL', vehicleId: '9007199254740993', driverId: '9007199254740995', warehouseId: '92', stops }
|
||||
const trips = new Map([[proof.tripNo, { tripNo: proof.tripNo, recordVersion: 5, vehicleId: proof.vehicleId, driverId: proof.driverId,
|
||||
status: 'IN_TRANSIT', completedAt: null, vehicleRelease: null, driverRelease: null }]])
|
||||
const claims = new Map([[`VEHICLE:${proof.vehicleId}`, proof.tripNo], [`DRIVER:${proof.driverId}`, proof.tripNo]])
|
||||
const remembered = new Map(), calls = [], assertions = []
|
||||
let sequence = 0
|
||||
const ok = data => ({ http: 200, body: { code: '200', data: structuredClone(data) } })
|
||||
const fail = message => ({ http: 200, body: { code: 'A0409', message } })
|
||||
const context = {
|
||||
marker: 'CONTROLLED-TEST',
|
||||
assert(_module, label, _response, passed) { assertions.push({ label, passed }) },
|
||||
apiSuccess(_module, _label, response) { return response.body.code === '200' ? response.body.data : null },
|
||||
async request(method, path, body) {
|
||||
calls.push({ method, path, body: structuredClone(body) })
|
||||
if (path.endsWith('/master-data/vehicles')) {
|
||||
assert.equal(body.status, 'IN_SERVICE', 'the independent driver occupancy probe needs a valid in-service vehicle')
|
||||
return ok({ id: 'SPARE-VEHICLE' })
|
||||
}
|
||||
if (method === 'POST' && path.endsWith('/fleet/trips')) {
|
||||
const tripNo = `TR-PROBE-${++sequence}`
|
||||
const trip = { tripNo, recordVersion: 0, status: 'PLANNED', vehicleId: null, driverId: null }
|
||||
trips.set(tripNo, trip); return ok({ trip })
|
||||
}
|
||||
const match = path.match(/\/trips\/([^/]+)(.*)$/), trip = trips.get(match?.[1]), suffix = match?.[2]
|
||||
assert.ok(trip, `unexpected request ${path}`)
|
||||
if (method === 'GET' && suffix === '/completion') return ok({ recordVersion: trip.recordVersion, canComplete: true, outcomes: [cargo()] })
|
||||
if (method === 'GET' && !suffix) return ok({ trip })
|
||||
if (suffix === '/complete' || suffix === '/resources/release') {
|
||||
const key = `${trip.tripNo}:${body.idempotencyKey}`
|
||||
if (remembered.has(key)) {
|
||||
assert.deepEqual(body, remembered.get(key), 'retry must preserve exact original body')
|
||||
if (fault === 'old-release-clears-new-claim' && suffix === '/resources/release') claims.delete(`${body.resourceKind}:${body.resourceKind === 'VEHICLE' ? trip.vehicleId : trip.driverId}`)
|
||||
return ok({ trip })
|
||||
}
|
||||
assert.equal(body.recordVersion, trip.recordVersion)
|
||||
remembered.set(key, structuredClone(body)); trip.recordVersion++
|
||||
if (suffix === '/complete') { trip.status = 'COMPLETED'; trip.completedAt = '2026-10-02T00:00:00Z' }
|
||||
else {
|
||||
const vehicle = body.resourceKind === 'VEHICLE'
|
||||
claims.delete(`${body.resourceKind}:${vehicle ? trip.vehicleId : trip.driverId}`)
|
||||
trip[vehicle ? 'vehicleRelease' : 'driverRelease'] = { actualEndAt: body.actualEndAt, endLocation: body.endLocation, vehicleStatus: body.vehicleStatus }
|
||||
if (fault === 'vehicle-also-frees-driver' && vehicle) claims.delete(`DRIVER:${trip.driverId}`)
|
||||
}
|
||||
return ok({ trip })
|
||||
}
|
||||
if (suffix === '/assignment') {
|
||||
if (fault === 'unrelated-conflict') return fail('车次版本已变化')
|
||||
if (claims.has(`VEHICLE:${body.vehicleId}`)) return fail('车辆已被其他车次占用')
|
||||
if (claims.has(`DRIVER:${body.driverId}`)) return fail('司机已被其他车次占用')
|
||||
assert.equal(body.recordVersion, trip.recordVersion)
|
||||
trip.vehicleId = body.vehicleId; trip.driverId = body.driverId; trip.recordVersion++
|
||||
claims.set(`VEHICLE:${body.vehicleId}`, trip.tripNo); claims.set(`DRIVER:${body.driverId}`, trip.tripNo)
|
||||
return ok({ trip })
|
||||
}
|
||||
if (suffix === '/cancel') {
|
||||
assert.equal(body.recordVersion, trip.recordVersion)
|
||||
for (const [key, owner] of claims) if (owner === trip.tripNo) claims.delete(key)
|
||||
trip.status = 'CANCELLED'; trip.recordVersion++; return ok({ trip })
|
||||
}
|
||||
throw new Error(`unhandled request ${method} ${path}`)
|
||||
},
|
||||
}
|
||||
return { proof, trips, claims, calls, assertions, context }
|
||||
}
|
||||
|
||||
test('closure proves independent resource occupancy through real command-shaped probes, then cancels empty plans', async () => {
|
||||
const server = closureServer()
|
||||
const result = await finishFleetTrip(server.context, 'test', server.proof)
|
||||
assert.equal(result.status, 'CARGO_COMPLETED_AND_RESOURCES_RELEASED')
|
||||
assert.equal(server.claims.size, 0)
|
||||
assert.ok(server.assertions.every(row => row.passed))
|
||||
assert.equal(result.probes.length, 2)
|
||||
assert.ok(result.probes.every(row => row.finalStatus === 'CANCELLED'))
|
||||
assert.equal(server.calls.filter(row => row.path.endsWith('/resources/release')).length, 4)
|
||||
assert.equal(server.calls.filter(row => row.path.endsWith('/assignment')).length, 5)
|
||||
assert.ok(server.calls.every(row => !/\/depart|\/items\/.*\/pick/.test(row.path)), 'closure never replays physical departure or inventory work')
|
||||
await assert.rejects(finishFleetTrip(server.context, 'test', server.proof), /已开始/)
|
||||
})
|
||||
|
||||
test('closure fails if ending only the vehicle silently releases the driver too', async () => {
|
||||
const server = closureServer('vehicle-also-frees-driver')
|
||||
await assert.rejects(finishFleetTrip(server.context, 'test', server.proof), /司机仍不能/)
|
||||
assert.equal(server.proof.closure.status, 'CHECKING')
|
||||
assert.ok(server.assertions.some(row => row.passed === false))
|
||||
})
|
||||
|
||||
test('closure detects an old release command deleting a subsequent trip claim', async () => {
|
||||
const server = closureServer('old-release-clears-new-claim')
|
||||
await assert.rejects(finishFleetTrip(server.context, 'test', server.proof), /旧结束重放不能释放/)
|
||||
assert.notEqual(server.proof.closure.status, 'CARGO_COMPLETED_AND_RESOURCES_RELEASED')
|
||||
})
|
||||
|
||||
test('an unrelated conflict cannot stand in for proof of occupied resources', async () => {
|
||||
const server = closureServer('unrelated-conflict')
|
||||
await assert.rejects(finishFleetTrip(server.context, 'test', server.proof), /未证明资源占用/)
|
||||
assert.equal(server.calls.some(row => row.path.endsWith('/resources/release')), false)
|
||||
})
|
||||
@@ -0,0 +1,296 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
|
||||
const BASE = '/api/platform/v1/last-leg/fleet'
|
||||
const OPTIONS = { scenario: 'fleet-physical-trip-departure', deploymentBlocker: true }
|
||||
const id = value => value == null ? '' : String(value)
|
||||
const version = value => {
|
||||
if ((typeof value === 'number' && !Number.isSafeInteger(value)) || !/^(0|[1-9]\d*)$/.test(String(value)))
|
||||
throw new Error('服务端车次版本无效')
|
||||
return BigInt(String(value))
|
||||
}
|
||||
|
||||
async function command(context, module, method, path, body, label) {
|
||||
const response = await context.request(method, path, body)
|
||||
const data = context.apiSuccess(module, label, response, '', OPTIONS)
|
||||
if (response?.http !== 200 || String(response?.body?.code) !== '200' || !data) throw new Error(`${module}: ${label}失败`)
|
||||
return data
|
||||
}
|
||||
|
||||
/** Test-only resources are created through real APIs and retained in the run report. No business SQL. */
|
||||
export async function prepareFleetTrip(context, module, dispatchNo, warehouseId) {
|
||||
if (!dispatchNo || !warehouseId) throw new Error('车次验收缺少本轮排车单或实际起运仓')
|
||||
const marker = `FT-${randomUUID().slice(0, 12)}`
|
||||
const proof = { dispatchNo, warehouseId: id(warehouseId), marker, reassignment: { status: 'PENDING' } }
|
||||
context.createdResources.fleetTrips ??= []
|
||||
context.createdResources.fleetTrips.push(proof)
|
||||
const vehicle = await command(context, module, 'POST', '/api/platform/v1/last-leg/master-data/vehicles', {
|
||||
plateNumber: marker, model: 'Box Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 100,
|
||||
region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90,
|
||||
remark: `${context.marker} controlled TEST vehicle`,
|
||||
}, '建立本轮真实车辆档案及容量')
|
||||
proof.vehicleId = id(vehicle.id)
|
||||
await prepareQualifiedDriver(context, module, marker, proof)
|
||||
const plannedDepartureAt = new Date(Date.now() + 3600000).toISOString()
|
||||
const expectedDriverEndAt = new Date(Date.now() + 48 * 3600000).toISOString()
|
||||
const createKey = createHash('sha256').update(`${context.marker}:${dispatchNo}`).digest('hex')
|
||||
let detail = await command(context, module, 'POST', `${BASE}/trips`, {
|
||||
warehouseId, plannedDepartureAt, expectedDriverEndAt, idempotencyKey: createKey,
|
||||
stops: [{ name: '测试起运仓' }, { name: '待匹配货物目的地' }], remark: context.marker,
|
||||
}, '创建本轮共享运输车次')
|
||||
proof.tripNo = detail.trip.tripNo
|
||||
const path = `${BASE}/trips/${encodeURIComponent(proof.tripNo)}`
|
||||
detail = await command(context, module, 'POST', `${path}/assignment`, {
|
||||
recordVersion: detail.trip.recordVersion, vehicleId: proof.vehicleId, driverId: proof.driverId, reason: '本轮测试派车',
|
||||
}, '正式分配车辆司机并核验证照')
|
||||
detail = await command(context, module, 'POST', `${path}/dispatches/bind`, {
|
||||
recordVersion: detail.trip.recordVersion, dispatchNo, reason: '关联本轮客户执行单据',
|
||||
}, '将客户排车单关联至车次')
|
||||
const loading = await command(context, module, 'GET', `${path}/loading`, undefined, '读取真实货物目的地匹配键')
|
||||
const destinations = new Map()
|
||||
for (const cargo of loading.cargo || []) {
|
||||
if (!cargo.destinationKey) throw new Error('实际排车货物缺少目的地,不能伪造装载站点')
|
||||
destinations.set(cargo.destinationKey, { name: cargo.destinationLabel, destinationKey: cargo.destinationKey })
|
||||
}
|
||||
if (!destinations.size) throw new Error('实际排车没有货物')
|
||||
detail = await command(context, module, 'PUT', `${path}/plan`, {
|
||||
recordVersion: detail.trip.recordVersion, plannedDepartureAt, expectedDriverEndAt,
|
||||
stops: [{ name: '测试起运仓' }, ...destinations.values()], reason: '按实际货物目的地冻结路线',
|
||||
}, '在拣货前匹配真实卸货站')
|
||||
proof.stops = detail.trip.stops
|
||||
detail = await command(context, module, 'POST', `${path}/dispatches/${encodeURIComponent(dispatchNo)}/review`, {
|
||||
recordVersion: detail.trip.recordVersion, decision: 'APPROVED',
|
||||
}, '拣货前通过本轮客户排车调度审核')
|
||||
proof.dispatchReview = detail.dispatches.find(row => row.dispatchNo === dispatchNo)?.review
|
||||
if (proof.dispatchReview?.status !== 'APPROVED') throw new Error('客户排车调度审核未通过,停止拣货准备')
|
||||
return proof
|
||||
}
|
||||
|
||||
async function prepareQualifiedDriver(context, module, marker, proof) {
|
||||
let driver = await command(context, module, 'POST', `${BASE}/drivers`, {
|
||||
name: `${marker}验收司机`, phone: '19900000000', ownership: 'OWNED', region: 'OVERSEAS',
|
||||
licenseNumber: marker, licenseType: 'Class A', licenseZone: 'UTC',
|
||||
licenseExpiresOn: new Date(Date.now() + 365 * 86400000).toISOString().slice(0, 10),
|
||||
remark: `${context.marker} TEST credential fixture; not a real driving licence`,
|
||||
}, '建立本轮司机证照档案')
|
||||
proof.driverId = id(driver.id)
|
||||
const uploaded = await context.requestMultipart('POST', `${BASE}/drivers/${proof.driverId}/license?recordVersion=${driver.recordVersion}`, {
|
||||
name: `${marker}-TEST-LICENSE.png`, type: 'image/png',
|
||||
content: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64'),
|
||||
})
|
||||
driver = context.apiSuccess(module, '上传本轮测试证照文件', uploaded, '', OPTIONS)
|
||||
if (!driver?.licenseFileId) throw new Error('测试证照上传失败,不允许伪造已审核资格')
|
||||
proof.licenseFileId = id(driver.licenseFileId)
|
||||
const reviewed = await command(context, module, 'POST', `${BASE}/drivers/${proof.driverId}/review`, {
|
||||
recordVersion: driver.recordVersion, decision: 'APPROVED', reason: '仅用于本轮受控测试司机',
|
||||
}, '通过正式命令审核测试司机')
|
||||
if (reviewed.reviewStatus !== 'APPROVED') throw new Error('测试司机未审核通过,停止派车验证')
|
||||
}
|
||||
|
||||
export function controlledPalletMeasurements(cargo, stops) {
|
||||
const pallets = new Map()
|
||||
for (const item of cargo) {
|
||||
if (!item.warehousePalletId || !item.palletNo || !item.destinationKey) throw new Error('装载货物缺少真实托盘或目的地')
|
||||
const deliveryIndex = stops.findIndex(stop => stop.destinationKey === item.destinationKey)
|
||||
if (deliveryIndex < 1) throw new Error('货物目的地不在车次路线')
|
||||
const key = id(item.warehousePalletId)
|
||||
if (pallets.has(key) && pallets.get(key).deliveryIndex !== deliveryIndex) throw new Error('混合目的地实物托盘须先拆托')
|
||||
// Controlled test measurement, deliberately separate from source billing/allocated mass.
|
||||
pallets.set(key, { warehousePalletId: key, palletSpaces: 1, weightKg: 100, volumeCbm: 1, deliveryIndex })
|
||||
}
|
||||
if (!pallets.size) throw new Error('没有实际装载托盘')
|
||||
return [...pallets.values()]
|
||||
}
|
||||
|
||||
export async function confirmFleetLoading(context, module, proof) {
|
||||
const state = proof.reassignment
|
||||
if (state && state.status !== 'PENDING') throw new Error('本轮改派检查已开始;保留原请求排查,不自动重复写入')
|
||||
if (state) state.status = 'CHECKING'
|
||||
const confirmed = await confirmActualLoad(context, module, proof)
|
||||
if (!state) return approveLoading(context, module, proof, confirmed.recordVersion)
|
||||
state.originalConfirmation = confirmed
|
||||
await verifyPreDepartureReassignment(context, module, proof, confirmed)
|
||||
const reconfirmed = await confirmActualLoad(context, module, proof)
|
||||
check(context, module, '重新确认仍覆盖改派前同批货物', reconfirmed.manifestHash === confirmed.manifestHash
|
||||
&& JSON.stringify(reconfirmed.cargo) === JSON.stringify(confirmed.cargo)
|
||||
&& version(reconfirmed.recordVersion) > version(state.assignedTrip.recordVersion))
|
||||
state.finalConfirmation = reconfirmed
|
||||
const reviewedVersion = await approveLoading(context, module, proof, reconfirmed.recordVersion)
|
||||
state.status = 'VERIFIED'
|
||||
return reviewedVersion
|
||||
}
|
||||
|
||||
async function approveLoading(context, module, proof, recordVersion) {
|
||||
const detail = await command(context, module, 'POST', `${BASE}/trips/${encodeURIComponent(proof.tripNo)}/loading-review`, {
|
||||
recordVersion, decision: 'APPROVED',
|
||||
}, '实际装载确认后通过整车理货审核')
|
||||
proof.loadingReview = detail.loadingReview
|
||||
if (detail.loadingReview?.status !== 'APPROVED') throw new Error('整车理货审核未通过,不允许发车')
|
||||
proof.recordVersion = detail.trip.recordVersion
|
||||
return proof.recordVersion
|
||||
}
|
||||
|
||||
async function confirmActualLoad(context, module, proof) {
|
||||
const path = `${BASE}/trips/${encodeURIComponent(proof.tripNo)}/loading`
|
||||
const loading = await command(context, module, 'GET', path, undefined, '拣货后读取整车实际装载依据')
|
||||
if (loading.blockReason) throw new Error(`整车未就绪:${loading.blockReason}`)
|
||||
const pallets = controlledPalletMeasurements(loading.cargo, proof.stops)
|
||||
const confirmed = await command(context, module, 'POST', path, {
|
||||
recordVersion: loading.recordVersion, manifestHash: loading.manifestHash, pallets,
|
||||
reason: '受控测试测量:每个实物托盘1托位、100kg、1m³;非生产测量',
|
||||
}, '通过正式命令确认受控测试装载')
|
||||
proof.confirmedLoad = confirmed.confirmedLoad
|
||||
proof.recordVersion = confirmed.recordVersion
|
||||
check(context, module, '装载确认返回当前明细与新版本', confirmed.confirmedLoad?.manifestHash === loading.manifestHash
|
||||
&& version(confirmed.recordVersion) > version(loading.recordVersion)
|
||||
&& JSON.stringify(confirmed.confirmedLoad?.pallets) === JSON.stringify([...pallets].sort((a, b) => a.warehousePalletId.localeCompare(b.warehousePalletId))))
|
||||
return confirmed
|
||||
}
|
||||
|
||||
function dispatchWorkSnapshot(row) {
|
||||
return JSON.stringify({ dispatchNo: row.dispatchNo, warehouseId: row.overseasWarehouseId,
|
||||
status: row.status, packageCount: row.packageCount, pickedPackageCount: row.pickedPackageCount,
|
||||
shippedPackageCount: row.shippedPackageCount, remainingPackageCount: row.remainingPackageCount,
|
||||
estimatedArrivalAt: row.estimatedArrivalAt, items: row.items })
|
||||
}
|
||||
|
||||
async function verifyPreDepartureReassignment(context, module, proof, confirmed) {
|
||||
const state = proof.reassignment
|
||||
const path = `${BASE}/trips/${encodeURIComponent(proof.tripNo)}`
|
||||
const dispatchPath = `/api/platform/v1/last-leg/dispatches/${encodeURIComponent(proof.dispatchNo)}`
|
||||
const original = await command(context, module, 'GET', path, undefined, '改派前读取车次资源和计划')
|
||||
const before = await command(context, module, 'GET', dispatchPath, undefined, '改派前保留实际拣货与货物明细')
|
||||
check(context, module, '改派仅针对本轮已拣完但未发车的车次', original.trip?.status === 'PLANNED'
|
||||
&& version(original.trip.recordVersion) === version(confirmed.recordVersion) && before.status === 'READY_TO_DEPART'
|
||||
&& before.dispatchNo === proof.dispatchNo && before.pickedPackageCount > 0 && before.shippedPackageCount === 0
|
||||
&& before.pickedPackageCount === before.packageCount && before.items?.length > 0
|
||||
&& id(original.trip.vehicleId) === proof.vehicleId && id(original.trip.driverId) === proof.driverId)
|
||||
state.originalTrip = original.trip; state.dispatchBefore = before
|
||||
const marker = `SWAP-${randomUUID().slice(0, 12)}`
|
||||
const vehicle = await command(context, module, 'POST', '/api/platform/v1/last-leg/master-data/vehicles', {
|
||||
plateNumber: marker, model: 'Box Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 0,
|
||||
region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', maxPalletSpaces: original.trip.maxPalletSpaces,
|
||||
maxLoadKg: original.trip.maxLoadKg, maxVolumeCbm: original.trip.maxVolumeCbm, remark: `${context.marker} 发车前改派测试`,
|
||||
}, '创建本轮等容量替换车辆')
|
||||
state.replacement = { vehicleId: id(vehicle.id) }
|
||||
await prepareQualifiedDriver(context, module, marker, state.replacement)
|
||||
state.assignmentRequest = { recordVersion: confirmed.recordVersion, vehicleId: state.replacement.vehicleId,
|
||||
driverId: state.replacement.driverId, reason: '已拣完发车前换车换司机并重新核验装载' }
|
||||
const assigned = await command(context, module, 'POST', `${path}/assignment`, state.assignmentRequest, '已拣货车次正式更换车辆和司机')
|
||||
state.assignedTrip = assigned.trip
|
||||
check(context, module, '改派完成且保持路线与客户关联', id(assigned.trip?.vehicleId) === state.replacement.vehicleId
|
||||
&& id(assigned.trip?.driverId) === state.replacement.driverId && assigned.trip.status === 'PLANNED'
|
||||
&& version(assigned.trip.recordVersion) > version(confirmed.recordVersion) && JSON.stringify(assigned.trip.stops) === JSON.stringify(original.trip.stops)
|
||||
&& JSON.stringify(assigned.dispatches.map(({ review, ...row }) => row)) === JSON.stringify(original.dispatches.map(({ review, ...row }) => row))
|
||||
&& assigned.dispatches.every(row => row.review?.status === 'PENDING'))
|
||||
const after = await command(context, module, 'GET', dispatchPath, undefined, '改派后回查实际拣货与货物明细')
|
||||
state.dispatchAfter = after
|
||||
check(context, module, '改派只更新车辆司机并保留拣货货量到达时间', dispatchWorkSnapshot(after) === dispatchWorkSnapshot(before)
|
||||
&& after.vehiclePlate === assigned.trip.vehiclePlate && after.driverName === assigned.trip.driverName && after.driverPhone === assigned.trip.driverPhone)
|
||||
const loading = await command(context, module, 'GET', `${path}/loading`, undefined, '回查改派后原装载确认失效')
|
||||
check(context, module, '原装载失效但货物和客户归属保持一致', loading.confirmedLoad == null
|
||||
&& version(loading.recordVersion) === version(assigned.trip.recordVersion) && loading.manifestHash === confirmed.manifestHash
|
||||
&& JSON.stringify(loading.cargo) === JSON.stringify(confirmed.cargo))
|
||||
state.staleLoadRequest = { recordVersion: confirmed.recordVersion, manifestHash: confirmed.manifestHash,
|
||||
pallets: confirmed.confirmedLoad.pallets, reason: '旧改派前页面提交必须被拒绝' }
|
||||
const stale = await context.request('POST', `${path}/loading`, state.staleLoadRequest)
|
||||
const conflict = stale?.http === 409 || (stale?.http === 200 && ['409', 'A0409'].includes(String(stale?.body?.code)))
|
||||
state.staleLoadResult = { http: stale?.http, code: stale?.body?.code, message: stale?.body?.message }
|
||||
check(context, module, '旧版本不能恢复旧装载确认', conflict && /已被修改/.test(String(stale?.body?.message || '')))
|
||||
const unchanged = await command(context, module, 'GET', `${path}/loading`, undefined, '拒绝旧版本后回查装载未发生写入')
|
||||
check(context, module, '旧版本拒绝后仍未确认且版本保持不变', version(unchanged.recordVersion) === version(assigned.trip.recordVersion)
|
||||
&& unchanged.confirmedLoad == null && unchanged.manifestHash === loading.manifestHash)
|
||||
let reviewed = assigned
|
||||
for (const binding of assigned.dispatches) {
|
||||
reviewed = await command(context, module, 'POST', `${path}/dispatches/${encodeURIComponent(binding.dispatchNo)}/review`, {
|
||||
recordVersion: reviewed.trip.recordVersion, decision: 'APPROVED',
|
||||
}, '换车换司机后重新通过调度审核')
|
||||
if (reviewed.dispatches.find(row => row.dispatchNo === binding.dispatchNo)?.review?.status !== 'APPROVED') throw new Error('改派后调度审核未通过')
|
||||
}
|
||||
state.reviewedDispatches = reviewed.dispatches
|
||||
proof.vehicleId = state.replacement.vehicleId; proof.driverId = state.replacement.driverId
|
||||
proof.licenseFileId = state.replacement.licenseFileId
|
||||
}
|
||||
|
||||
function check(context, module, label, condition, evidence = '') {
|
||||
context.assert(module, label, null, condition, evidence, OPTIONS)
|
||||
if (!condition) throw new Error(`${module}: ${label}失败`)
|
||||
}
|
||||
|
||||
async function expectOccupied(context, module, path, body, resource, label) {
|
||||
const response = await context.request('POST', `${path}/assignment`, body)
|
||||
const conflict = response?.http === 409 || (response?.http === 200 && ['409', 'A0409'].includes(String(response?.body?.code)))
|
||||
const message = String(response?.body?.message || '')
|
||||
const valid = conflict && message.includes(resource) && message.includes('占用')
|
||||
context.assert(module, label, response, valid, `expected occupied ${resource}`, OPTIONS)
|
||||
if (!valid) throw new Error(`${module}: ${label}未证明资源占用;停止并保留本轮资源`)
|
||||
}
|
||||
|
||||
/** Fresh-run closure proof only. Retain all commands on proof for diagnostics; do not repair old fixtures. */
|
||||
export async function finishFleetTrip(context, module, proof) {
|
||||
if (!proof?.tripNo || !proof.vehicleId || !proof.driverId || !proof.warehouseId) throw new Error('缺少本轮车次与资源证明')
|
||||
if (proof.closure) throw new Error('本轮车次结案检查已开始;保留原请求排查,不自动重造或改写请求')
|
||||
const state = proof.closure = { status: 'CHECKING', probes: [] }
|
||||
const path = `${BASE}/trips/${encodeURIComponent(proof.tripNo)}`
|
||||
const inspection = await command(context, module, 'GET', `${path}/completion`, undefined, '核对本车所有货物结案条件')
|
||||
check(context, module, '全部货物具有本车结案依据', inspection.canComplete === true && inspection.outcomes?.length > 0, inspection.blockReason || '')
|
||||
state.completeRequest = { recordVersion: inspection.recordVersion, idempotencyKey: `${proof.tripNo}:COMPLETE`, reason: '本轮实际签收及退回终态核对' }
|
||||
const completed = await command(context, module, 'POST', `${path}/complete`, state.completeRequest, '正式结案车次货物')
|
||||
check(context, module, '结案记录原运输完成时间且尚未声明资源释放', completed.trip?.status === 'COMPLETED'
|
||||
&& Boolean(completed.trip.completedAt) && completed.trip.vehicleRelease == null && completed.trip.driverRelease == null)
|
||||
const replay = await command(context, module, 'POST', `${path}/complete`, state.completeRequest, '同键重放货物结案')
|
||||
check(context, module, '结案重放没有新增版本或改变完成时间', replay.trip?.recordVersion === completed.trip.recordVersion && replay.trip.completedAt === completed.trip.completedAt)
|
||||
|
||||
async function probe(index) {
|
||||
const request = { warehouseId: proof.warehouseId, idempotencyKey: `${proof.tripNo}:RELEASE-PROBE:${index}`,
|
||||
plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), expectedDriverEndAt: new Date(Date.now() + 48 * 3600000).toISOString(),
|
||||
stops: proof.stops, remark: '本轮资源占用验收计划,不关联货物或执行发车' }
|
||||
const evidence = { createRequest: request }; state.probes.push(evidence)
|
||||
const created = await command(context, module, 'POST', `${BASE}/trips`, request, `建立资源占用核对计划${index}`)
|
||||
evidence.tripNo = created.trip.tripNo; evidence.recordVersion = created.trip.recordVersion
|
||||
return { evidence, path: `${BASE}/trips/${encodeURIComponent(evidence.tripNo)}` }
|
||||
}
|
||||
const first = await probe(1)
|
||||
const assign = { recordVersion: first.evidence.recordVersion, vehicleId: proof.vehicleId, driverId: proof.driverId, reason: '验证本轮资源占用' }
|
||||
await expectOccupied(context, module, first.path, assign, '车辆', '货物结案后车辆仍不能被另一车次占用')
|
||||
|
||||
const endLocation = proof.stops?.at(-1)?.name
|
||||
check(context, module, '测试任务具有明确结束地点', Boolean(endLocation))
|
||||
// These controlled fixtures end at the signed final stop and contain no return-driving task.
|
||||
state.vehicleReleaseRequest = { recordVersion: completed.trip.recordVersion, resourceKind: 'VEHICLE',
|
||||
actualEndAt: completed.trip.completedAt, endLocation, vehicleStatus: 'IN_SERVICE',
|
||||
idempotencyKey: `${proof.tripNo}:RELEASE-VEHICLE`, reason: '受控测试车辆在末站结束,无返程安排' }
|
||||
const vehicleReleased = await command(context, module, 'POST', `${path}/resources/release`, state.vehicleReleaseRequest, '单独结束测试车辆任务')
|
||||
check(context, module, '只释放车辆仍保留司机任务', Boolean(vehicleReleased.trip?.vehicleRelease) && vehicleReleased.trip.driverRelease == null)
|
||||
await expectOccupied(context, module, first.path, assign, '司机', '车辆已释放但司机仍不能被另一车次占用')
|
||||
|
||||
state.driverReleaseRequest = { recordVersion: vehicleReleased.trip.recordVersion, resourceKind: 'DRIVER',
|
||||
actualEndAt: completed.trip.completedAt, endLocation, vehicleStatus: null,
|
||||
idempotencyKey: `${proof.tripNo}:RELEASE-DRIVER`, reason: '受控测试司机在末站结束,无返程安排' }
|
||||
const driverReleased = await command(context, module, 'POST', `${path}/resources/release`, state.driverReleaseRequest, '单独结束测试司机任务')
|
||||
check(context, module, '车辆和司机各自保留真实任务结束记录', Boolean(driverReleased.trip?.vehicleRelease && driverReleased.trip.driverRelease))
|
||||
const assigned = await command(context, module, 'POST', `${first.path}/assignment`, assign, '释放后正式再次分配相同车辆司机')
|
||||
first.evidence.recordVersion = assigned.trip.recordVersion
|
||||
check(context, module, '释放后的两项资源可被下一计划使用', id(assigned.trip?.vehicleId) === id(proof.vehicleId) && id(assigned.trip?.driverId) === id(proof.driverId))
|
||||
const oldVehicleReplay = await command(context, module, 'POST', `${path}/resources/release`, state.vehicleReleaseRequest, '资源再分配后重放旧车辆结束请求')
|
||||
const oldDriverReplay = await command(context, module, 'POST', `${path}/resources/release`, state.driverReleaseRequest, '资源再分配后重放旧司机结束请求')
|
||||
check(context, module, '原结束请求重放不增加旧车次版本', oldVehicleReplay.trip.recordVersion === driverReleased.trip.recordVersion && oldDriverReplay.trip.recordVersion === driverReleased.trip.recordVersion)
|
||||
const second = await probe(2)
|
||||
await expectOccupied(context, module, second.path, { ...assign, recordVersion: second.evidence.recordVersion }, '车辆', '旧结束重放不能释放下一计划的车辆占用')
|
||||
// A different free fixture vehicle isolates the driver claim from the vehicle claim.
|
||||
const spare = await command(context, module, 'POST', '/api/platform/v1/last-leg/master-data/vehicles', {
|
||||
plateNumber: `PROBE-${randomUUID().slice(0,12)}`, model: 'Box Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 0,
|
||||
region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90, remark: `${context.marker} 仅用于本轮司机占用负向验证`,
|
||||
}, '建立司机占用隔离用测试车辆')
|
||||
state.spareVehicleId = id(spare.id)
|
||||
await expectOccupied(context, module, second.path, { ...assign, vehicleId: state.spareVehicleId, recordVersion: second.evidence.recordVersion }, '司机', '旧结束重放不能释放下一计划的司机占用')
|
||||
for (const item of [second, first]) {
|
||||
const cancelled = await command(context, module, 'POST', `${item.path}/cancel`, { recordVersion: item.evidence.recordVersion, reason: '资源占用核对完成,取消无货测试计划' }, '正式取消无货核对计划')
|
||||
check(context, module, '核对计划取消且保留历史', cancelled.trip?.status === 'CANCELLED')
|
||||
item.evidence.finalStatus = cancelled.trip.status
|
||||
}
|
||||
const final = await command(context, module, 'GET', path, undefined, '回查最终车次结案与资源结束记录')
|
||||
check(context, module, '车次结案及两项资源结束保持一致', final.trip?.status === 'COMPLETED' && Boolean(final.trip.vehicleRelease && final.trip.driverRelease))
|
||||
state.status = 'CARGO_COMPLETED_AND_RESOURCES_RELEASED'
|
||||
state.finalTrip = final.trip
|
||||
return state
|
||||
}
|
||||
@@ -1,13 +1,29 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { missingRequiredStateFixtures, normalizeStateRequirements } from './manual-state-matrix.mjs'
|
||||
import { attachDeliveredOrderFixture, missingRequiredStateFixtures, normalizeStateRequirements } from './manual-state-matrix.mjs'
|
||||
import { normalizeModuleOrder, parseArguments } from '../admin-test-env-acceptance.mjs'
|
||||
|
||||
const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')
|
||||
const source = readFileSync(new URL('./manual-state-matrix.mjs', import.meta.url), 'utf8')
|
||||
const loadingSource = readFileSync(new URL('./manual-state-loading-pda.mjs', import.meta.url), 'utf8')
|
||||
|
||||
test('DELIVERED 必须回查本轮已签收但根未完成的正式订单', async () => {
|
||||
const matrix = { pages: { unifiedOrders: { fixtures: {} } } }
|
||||
const context = { marker: 'run-1', createdResources: { phase5OverseasFulfillment: { marker: 'run-1',
|
||||
orderCompletionProof: { customerId: '7', rootOrderId: '99', rootDelivered: true, expectedCompleted: false } } },
|
||||
request: async (method, path) => {
|
||||
assert.equal(method, 'GET')
|
||||
assert.equal(path, '/api/platform/v1/orders/99?tenantId=7')
|
||||
return { body: { data: { summary: { orderId: '99', mainStatus: 'DELIVERED' } } } }
|
||||
}, apiSuccess: (_m, _n, response) => response.body.data,
|
||||
assert: (_m, _n, _r, valid) => assert.equal(valid, true) }
|
||||
await attachDeliveredOrderFixture(context, matrix)
|
||||
assert.equal(matrix.pages.unifiedOrders.fixtures.DELIVERED.id, '99')
|
||||
context.createdResources.phase5OverseasFulfillment.marker = 'old'
|
||||
await assert.rejects(attachDeliveredOrderFixture(context, matrix), /本轮/)
|
||||
})
|
||||
|
||||
test('default full acceptance includes the retained manual state matrix after lifecycle modules', () => {
|
||||
assert.match(entry, /'manual-state-matrix'/)
|
||||
assert.ok(entry.indexOf("'phase3-order-splitting'") < entry.indexOf("'manual-state-matrix'"))
|
||||
@@ -31,6 +47,13 @@ test('focused matrix execution injects real container lifecycle prerequisites an
|
||||
'service-center-flow',
|
||||
'phase3-outbound-dispatch',
|
||||
'phase3-container-documents',
|
||||
'phase3-carrier-schedule-queries',
|
||||
'phase4-carrier-pickup-ready',
|
||||
'phase4-tail-order-controls',
|
||||
'phase4-overseas-pda',
|
||||
'phase4-overseas-pda-operations',
|
||||
'phase4-overseas-dispatch',
|
||||
'phase5-overseas-fulfillment',
|
||||
'manual-state-matrix',
|
||||
]
|
||||
assert.deepEqual(normalizeModuleOrder(['order-inbound-status-fixtures', 'manual-state-matrix']), expected)
|
||||
@@ -57,6 +80,13 @@ test('focused splitting and manual groups resolve producers before consumers wit
|
||||
'service-center-flow',
|
||||
'phase3-outbound-dispatch',
|
||||
'phase3-container-documents',
|
||||
'phase3-carrier-schedule-queries',
|
||||
'phase4-carrier-pickup-ready',
|
||||
'phase4-tail-order-controls',
|
||||
'phase4-overseas-pda',
|
||||
'phase4-overseas-pda-operations',
|
||||
'phase4-overseas-dispatch',
|
||||
'phase5-overseas-fulfillment',
|
||||
'manual-state-matrix',
|
||||
]
|
||||
assert.deepEqual(normalizeModuleOrder([
|
||||
@@ -92,10 +122,10 @@ test('fixture-unavailable statuses stay supported without becoming hard requirem
|
||||
assert.deepEqual(matrix.pages.inbounds.supportedStatuses,
|
||||
['WAIT_MEASURE', 'MEASURE_REJECTED', 'WAIT_PRICING', 'FEE_CONFIRMING', 'INBOUNDING', 'VOIDED'])
|
||||
assert.deepEqual(matrix.pages.inbounds.requiredStatuses, ['WAIT_MEASURE'])
|
||||
assert.deepEqual(matrix.pages.unifiedOrders.requiredStatuses, ['WAIT_SPLIT', 'LOADING', 'LOADED'])
|
||||
assert.deepEqual(matrix.pages.unifiedOrders.requiredStatuses, ['WAIT_SPLIT', 'LOADING', 'LOADED', 'DELIVERED'])
|
||||
assert.equal(matrix.pages.inbounds.coverageGaps.WAIT_PRICING.category, 'fixture-unavailable')
|
||||
assert.equal(matrix.pages.unifiedOrders.coverageGaps.DELIVERED.category, 'fixture-unavailable')
|
||||
assert.deepEqual(missingRequiredStateFixtures(matrix), [])
|
||||
assert.equal(matrix.pages.unifiedOrders.coverageGaps, undefined)
|
||||
assert.deepEqual(missingRequiredStateFixtures(matrix), ['unifiedOrders:DELIVERED'])
|
||||
assert.match(source, /gap\.category, \{ marker: context\.marker, page, status, blocking: false \}/)
|
||||
})
|
||||
|
||||
|
||||
@@ -15,9 +15,6 @@ const FIXTURE_UNAVAILABLE = {
|
||||
INBOUNDING: '自动入库在同一调用内继续推进 WAIT_SPLIT,无可暂停 API',
|
||||
VOIDED: '当前 Controller/Service 没有作废入库单的公开 API',
|
||||
},
|
||||
unifiedOrders: {
|
||||
DELIVERED: '当前后端没有完成派送并写入 DELIVERED 的公开业务 API',
|
||||
},
|
||||
}
|
||||
|
||||
function rows(value) { return Array.isArray(value) ? value : [] }
|
||||
@@ -46,6 +43,25 @@ export function missingRequiredStateFixtures(matrix) {
|
||||
.map((status) => `${page}:${status}`))
|
||||
}
|
||||
|
||||
export async function attachDeliveredOrderFixture(context, matrix) {
|
||||
const fulfilled = context.createdResources?.phase5OverseasFulfillment
|
||||
const proof = fulfilled?.orderCompletionProof
|
||||
const customerId = id(proof?.customerId)
|
||||
const orderId = id(proof?.rootOrderId)
|
||||
if (fulfilled?.marker !== context.marker || !customerId || !orderId || !proof?.rootDelivered || proof.expectedCompleted !== false) {
|
||||
throw new Error('DELIVERED 样本须复用本轮已签收且仍有有效余量的履约根')
|
||||
}
|
||||
const response = await context.request('GET',
|
||||
`/api/platform/v1/orders/${encodeURIComponent(orderId)}?tenantId=${encodeURIComponent(customerId)}`)
|
||||
const detail = context.apiSuccess(MODULE, '回读真实签收产生的 DELIVERED 统一订单', response, '', OPTIONS)
|
||||
const valid = id(detail?.summary?.orderId) === orderId && detail?.summary?.mainStatus === 'DELIVERED'
|
||||
context.assert(MODULE, 'DELIVERED 样本客户与真实根签收状态正确', response, valid,
|
||||
`orderId=${orderId}, status=${detail?.summary?.mainStatus || 'missing'}`, OPTIONS)
|
||||
if (!valid) throw new Error('DELIVERED 履约根详情未收敛到真实签收状态')
|
||||
matrix.pages.unifiedOrders.fixtures.DELIVERED = { id: orderId, status: 'DELIVERED',
|
||||
customerId, detailPath: `/orders/${orderId}` }
|
||||
}
|
||||
|
||||
function registerFixtureUnavailableGaps(context, matrix) {
|
||||
for (const [page, value] of Object.entries(matrix?.pages || {})) {
|
||||
for (const [status, gap] of Object.entries(value?.coverageGaps || {})) {
|
||||
@@ -102,6 +118,7 @@ export async function run(context) {
|
||||
}
|
||||
|
||||
await attachUnifiedOrderFixture(context, matrix, 'phase3Loading', 'LOADED')
|
||||
await attachDeliveredOrderFixture(context, matrix)
|
||||
normalizeStateRequirements(matrix)
|
||||
registerFixtureUnavailableGaps(context, matrix)
|
||||
|
||||
|
||||
@@ -3,8 +3,7 @@ import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { transition } from './order-inbound-status-fixtures.mjs'
|
||||
import { createCompletedOrder, transition } from './order-inbound-status-fixtures.mjs'
|
||||
|
||||
test('dynamic Platform cancellation freezes version and key while reject body stays unchanged',async()=>{
|
||||
const calls=[]
|
||||
@@ -21,6 +20,26 @@ test('dynamic Platform cancellation freezes version and key while reject body st
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const source = fs.readFileSync(path.join(here, 'order-inbound-status-fixtures.mjs'), 'utf8')
|
||||
|
||||
test('完成样本来自真实履约编排且缺证据或客户漂移均阻断', async () => {
|
||||
const context = { context: { phase4ReceivingEvidenceReader() {} }, createdResources: { orders: { orderId: '11' } } }
|
||||
const prepare = async scope => { scope.context.merchantQualificationFixture = { tenantId: '7' } }
|
||||
const run = proof => async scope => {
|
||||
assert.equal(scope.context.f40SignatureOrder, 'ROOT_FIRST')
|
||||
assert.equal(scope.context.phase4ReceivingEvidenceReader, context.context.phase4ReceivingEvidenceReader)
|
||||
scope.createdResources.phase5RootFulfillment = proof
|
||||
}
|
||||
assert.deepEqual(await createCompletedOrder(context, run({ finalStatus: 'VERIFIED', customerId: '7', rootOrderId: '99' }), prepare),
|
||||
{ orderId: '99', tenantId: '7' })
|
||||
assert.equal(context.createdResources.orders.orderId, '11')
|
||||
assert.equal(context.createdResources.phase5RootFulfillment, undefined)
|
||||
for (const proof of [{ finalStatus: 'HTTP_VERIFIED_OUTBOX_PENDING', customerId: '7', rootOrderId: '99' },
|
||||
{ finalStatus: 'VERIFIED', customerId: '8', rootOrderId: '99' }]) {
|
||||
await assert.rejects(createCompletedOrder(context, run(proof), prepare), /真实签收/)
|
||||
}
|
||||
assert.doesNotMatch(source, /'start', 'complete'/)
|
||||
assert.match(source, /phase5-root-fulfillment\.mjs/)
|
||||
})
|
||||
|
||||
test('为头程订单列表八个真实状态分别创建独立样本', () => {
|
||||
for (const status of ['DRAFT', 'SUBMITTED', 'REJECTED', 'APPROVED', 'ACCEPTED', 'PROCESSING', 'COMPLETED', 'CANCELLED']) {
|
||||
assert.match(source, new RegExp(`\\['${status}', \\[`))
|
||||
|
||||
@@ -12,7 +12,7 @@ const ORDER_TARGETS = [
|
||||
['APPROVED', ['submit', 'approve']],
|
||||
['ACCEPTED', ['submit', 'approve', 'accept']],
|
||||
['PROCESSING', ['submit', 'approve', 'accept', 'start']],
|
||||
['COMPLETED', ['submit', 'approve', 'accept', 'start', 'complete']],
|
||||
['COMPLETED', []],
|
||||
['CANCELLED', ['cancel']],
|
||||
]
|
||||
const INBOUND_FILTER_STATUSES = [
|
||||
@@ -135,6 +135,27 @@ export async function transition(context, token, fixture, orderId, target, actio
|
||||
context.apiSuccess(MODULE, `${target} 样本执行 ${action}`, response)
|
||||
}
|
||||
|
||||
export async function createCompletedOrder(context, runFulfillment = async scope => {
|
||||
const { run } = await import('./phase5-root-fulfillment.mjs')
|
||||
return run(scope)
|
||||
}, prepareCustomer = async scope => {
|
||||
const { run } = await import('./merchant-qualification-fixture.mjs')
|
||||
return run(scope, { publishEnvironment: false })
|
||||
}) {
|
||||
const scope = Object.create(context)
|
||||
scope.marker = `${context.marker}-COMPLETED`
|
||||
scope.context = { ...context.context, f40SignatureOrder: 'ROOT_FIRST' }
|
||||
scope.createdResources = { ...context.createdResources }
|
||||
await prepareCustomer(scope)
|
||||
try { await runFulfillment(scope) }
|
||||
finally { context.createdResources.manualCompletedOrder = scope.createdResources.phase5RootFulfillment }
|
||||
const proof = scope.createdResources.phase5RootFulfillment
|
||||
if (proof?.finalStatus !== 'VERIFIED'
|
||||
|| id(proof.customerId) !== id(scope.context.merchantQualificationFixture?.tenantId)
|
||||
|| !id(proof.rootOrderId)) throw new Error('COMPLETED 样本必须由同客户真实签收及完成Outbox验证产生')
|
||||
return { orderId: id(proof.rootOrderId), tenantId: id(proof.customerId) }
|
||||
}
|
||||
|
||||
async function readOrder(context, fixture, orderId) {
|
||||
const response = await context.request('GET',
|
||||
`/api/platform/v1/orders/first-leg/${query(orderId)}?tenantId=${query(fixture.tenantId)}`)
|
||||
@@ -248,16 +269,18 @@ export async function run(context, matrix = context.createdResources.manualState
|
||||
matrix.pages.unifiedOrders = { requiredStatuses: UNIFIED_FILTER_STATUSES, fixtures: {} }
|
||||
|
||||
for (const [target, actions] of ORDER_TARGETS) {
|
||||
const orderId = await createOrder(context, token, fixture, target)
|
||||
for (const action of actions) await transition(context, token, fixture, orderId, target, action)
|
||||
const { response, detail } = await readOrder(context, fixture, orderId)
|
||||
const completed = target === 'COMPLETED' ? await createCompletedOrder(context) : null
|
||||
const activeFixture = completed ? { ...fixture, tenantId: completed.tenantId } : fixture
|
||||
const orderId = completed?.orderId || await createOrder(context, token, activeFixture, target)
|
||||
for (const action of actions) await transition(context, token, activeFixture, orderId, target, action)
|
||||
const { response, detail } = await readOrder(context, activeFixture, orderId)
|
||||
context.assert(MODULE, `${target} 订单状态样本可从详情打开`, response,
|
||||
id(detail?.order?.id) === orderId && detail?.order?.status === target,
|
||||
`orderId=${orderId}, status=${detail?.order?.status ?? 'missing'}`)
|
||||
result.orders[target] = { orderId, orderNo: detail?.order?.orderNo || null, status: target,
|
||||
result.orders[target] = { orderId, tenantId: activeFixture.tenantId, orderNo: detail?.order?.orderNo || null, status: target,
|
||||
listPath: `/admin/first-leg-orders?status=${target}` }
|
||||
matrix.pages.firstLegOrders.fixtures[target] = {
|
||||
id: orderId, status: target, detailPath: `/orders/${orderId}`,
|
||||
id: orderId, tenantId: activeFixture.tenantId, status: target, detailPath: `/orders/${orderId}`,
|
||||
}
|
||||
|
||||
if (actions.includes('approve')) {
|
||||
@@ -274,7 +297,7 @@ export async function run(context, matrix = context.createdResources.manualState
|
||||
|
||||
for (const [status, sample] of Object.entries(result.orders)) {
|
||||
const response = await context.request('GET',
|
||||
`/api/platform/v1/orders/first-leg?tenantId=${query(fixture.tenantId)}&status=${status}&keyword=${query(sample.orderNo)}&page=1&size=20`)
|
||||
`/api/platform/v1/orders/first-leg?tenantId=${query(sample.tenantId)}&status=${status}&keyword=${query(sample.orderNo)}&page=1&size=20`)
|
||||
const page = context.apiSuccess(MODULE, `${status} 订单可按列表状态筛选`, response)
|
||||
context.assert(MODULE, `${status} 筛选结果包含人工样本`, response,
|
||||
rows(page).some((item) => id(item.id) === sample.orderId), `orderId=${sample.orderId}, total=${page?.total ?? 'n/a'}`)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { run } from './orders.mjs'
|
||||
|
||||
async function loginOnly(fixture, options = {}) {
|
||||
const calls = []
|
||||
const context = { context: { merchantQualificationFixture: fixture }, options,
|
||||
request: async (...args) => { calls.push(args); return {} }, apiSuccess: () => null }
|
||||
await assert.rejects(run(context), /Client 登录未返回 accessToken/)
|
||||
return calls
|
||||
}
|
||||
test('one cached orders module uses each serial run customer, never the first customer', async () => {
|
||||
const first = await loginOnly({ username: 'customer-a', password: 'password-a' })
|
||||
const second = await loginOnly({ username: 'customer-b', password: 'password-b' },
|
||||
{ merchantUsername: 'customer-a', merchantPassword: 'password-a' })
|
||||
assert.deepEqual(first[0].slice(0,3), ['POST','/api/auth/login',{ loginId:'customer-a',password:'password-a' }])
|
||||
assert.deepEqual(second[0].slice(0,3), ['POST','/api/auth/login',{ loginId:'customer-b',password:'password-b' }])
|
||||
assert.equal(first.length, 1); assert.equal(second.length, 1)
|
||||
})
|
||||
test('partial current fixture never combines credentials with configured prior customer', async () => {
|
||||
let requests = 0
|
||||
await assert.rejects(run({ context:{merchantQualificationFixture:{username:'customer-b'}},
|
||||
options:{merchantUsername:'customer-a',merchantPassword:'password-a'},
|
||||
request:async()=>{requests++} }), /缺少完整登录凭据/)
|
||||
assert.equal(requests, 0)
|
||||
})
|
||||
test('explicit paired options are used when no fixture was created', async () => {
|
||||
const calls = await loginOnly(undefined,{merchantUsername:'configured',merchantPassword:'configured-password'})
|
||||
assert.equal(calls[0][2].loginId,'configured')
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { CARRIER_EXCEPTION_CODES, carrierExceptionShapeErrors, carrierExceptionTransitionErrors,
|
||||
captureCarrierExceptionEvidence } from './phase3-carrier-schedule-queries.mjs'
|
||||
|
||||
const fixture = () => ({ marker: 'current', queryId: '2105227585104384001', detail: {
|
||||
query: { query: { id: '2105227585104384001', customerId: '0', oceanBookingId: '2105227599885111297',
|
||||
containerBookingId: '2105227585104384002', latestStandardNode: 'IN_TRANSIT', latestActualAt: '2026-10-02T09:00:00' } },
|
||||
nodes: [{ id: 'actual', standardNode: 'IN_TRANSIT', actualAt: '2026-10-02T09:00:00' },
|
||||
{ id: 'estimated', standardNode: 'ARRIVED_DESTINATION_PORT', estimatedAt: '2026-10-20T09:00:00' }],
|
||||
exceptions: [], histories: [],
|
||||
} })
|
||||
const fact = (code, active = true, sequence = 1) => ({ id: `210522758510438401${sequence}`,
|
||||
providerCode: 'TEST_PROVIDER', providerEventId: `${code}-${active}`, providerStatus: active ? 'HOLD' : 'RELEASE',
|
||||
exceptionCode: code, active, observedAt: '2026-10-02T10:00:00', locationCode: 'USLAX', locationName: 'Los Angeles',
|
||||
sourceReference: 'controlled-provider', rawPayloadHash: (active ? 'a' : 'b').repeat(64), createdAt: '2026-10-02T10:00:01' })
|
||||
function append(before, value) {
|
||||
const after = structuredClone(before)
|
||||
after.detail.exceptions.push(value)
|
||||
after.detail.histories.push({ id: value.id, action: 'EXCEPTION_OBSERVED', reason: value.exceptionCode })
|
||||
return after
|
||||
}
|
||||
|
||||
for (const code of CARRIER_EXCEPTION_CODES) {
|
||||
test(`${code}: append and release retain original exception and both normal time facts`, () => {
|
||||
const before = fixture(), active = fact(code), released = fact(code, false, 2)
|
||||
const after = append(before, active)
|
||||
assert.deepEqual(carrierExceptionTransitionErrors(before, after, { kind: 'APPEND', expected: active }), [])
|
||||
assert.deepEqual(carrierExceptionTransitionErrors(after, append(after, released), { kind: 'RELEASE', expected: released }), [])
|
||||
assert.deepEqual(carrierExceptionTransitionErrors(after, structuredClone(after), { kind: 'REPLAY', expected: active }), [])
|
||||
assert.deepEqual(carrierExceptionTransitionErrors(after, structuredClone(after), { kind: 'CONFLICT', expected: { ...active, rawPayloadHash: 'c'.repeat(64) } }), [])
|
||||
})
|
||||
}
|
||||
|
||||
test('replay/conflict fail on changed fact, extra history, missing original event or wrong payload hash', () => {
|
||||
const original = fact('CUSTOMS_HOLD'), before = append(fixture(), original)
|
||||
for (const mutate of [
|
||||
value => { value.detail.exceptions[0].active = false },
|
||||
value => { value.detail.histories.push({ id: 'extra', action: 'EXCEPTION_OBSERVED' }) },
|
||||
value => { value.detail.nodes[1].estimatedAt = '2026-10-21T09:00:00' },
|
||||
value => { value.detail.query.query.latestStandardNode = 'PICKUP_READY' },
|
||||
value => { value.marker = 'other' },
|
||||
]) {
|
||||
const after = structuredClone(before); mutate(after)
|
||||
assert.ok(carrierExceptionTransitionErrors(before, after, { kind: 'REPLAY', expected: original }).length)
|
||||
}
|
||||
assert.ok(carrierExceptionTransitionErrors(fixture(), fixture(), { kind: 'REPLAY', expected: original }).length)
|
||||
assert.ok(carrierExceptionTransitionErrors(before, before, { kind: 'CONFLICT', expected: original }).length)
|
||||
})
|
||||
|
||||
test('release replacing the active row, deleting original history or absent estimated baseline is rejected', () => {
|
||||
const active = fact('TERMINAL_HOLD'), release = fact('TERMINAL_HOLD', false, 2)
|
||||
const before = append(fixture(), active)
|
||||
for (const mutate of [
|
||||
value => { value.detail.exceptions.shift() },
|
||||
value => { value.detail.histories.shift() },
|
||||
value => { value.detail.nodes.pop() },
|
||||
]) {
|
||||
const after = append(before, release); mutate(after)
|
||||
assert.ok(carrierExceptionTransitionErrors(before, after, { kind: 'RELEASE', expected: release }).length)
|
||||
}
|
||||
const empty = fixture(); empty.detail.nodes.pop()
|
||||
assert.ok(carrierExceptionTransitionErrors(empty, append(empty, active), { kind: 'APPEND', expected: active }).length)
|
||||
})
|
||||
|
||||
test('reject customs declaration codes, duplicate supplier event, malformed hash and absent source fields', () => {
|
||||
for (const mutate of [
|
||||
value => { value.detail.exceptions[0].exceptionCode = 'INSPECTION' },
|
||||
value => { value.detail.exceptions.push({ ...value.detail.exceptions[0], id: '2105227585104384019' }) },
|
||||
value => { value.detail.exceptions[0].rawPayloadHash = 'bad' },
|
||||
value => { value.detail.exceptions[0].providerCode = '' },
|
||||
value => { value.detail.nodes[0].standardNode = 'CUSTOMS_HOLD' },
|
||||
]) {
|
||||
const value = append(fixture(), fact('UNKNOWN_HOLD')); mutate(value)
|
||||
assert.ok(carrierExceptionShapeErrors(value.detail).length)
|
||||
}
|
||||
})
|
||||
|
||||
test('provider DTO permits absent location and source reference; supplied values still match exactly', () => {
|
||||
const active = { ...fact('UNKNOWN_HOLD'), locationCode: null, locationName: null, sourceReference: null }
|
||||
const before = fixture(), after = append(before, structuredClone(active))
|
||||
assert.deepEqual(carrierExceptionShapeErrors(after.detail), [])
|
||||
assert.deepEqual(carrierExceptionTransitionErrors(before, after, { kind: 'APPEND', expected: active }), [])
|
||||
after.detail.exceptions[0].sourceReference = 'changed'
|
||||
assert.ok(carrierExceptionTransitionErrors(before, after, { kind: 'APPEND', expected: active }).length)
|
||||
})
|
||||
|
||||
test('real API checkpoint uses only original detail GET and rejects another marker before any call', async () => {
|
||||
const before = fixture(), calls = [], original = globalThis.fetch
|
||||
const context = { marker: 'current', options: { gateway: 'https://offline.invalid' }, token: 'offline',
|
||||
apiSuccess: (_module, _name, response) => response.body.data, assert: (_module, _name, _response, passed) => assert.ok(passed) }
|
||||
const resource = { queryId: before.queryId, marker: 'current', ownedByModule: true, triggerSource: 'OUTBOUND_CONFIRM' }
|
||||
globalThis.fetch = async (url, options) => { calls.push({ url, method: options.method }); return new Response(JSON.stringify({ code: '200', data: before.detail })) }
|
||||
try {
|
||||
const captured = await captureCarrierExceptionEvidence(context, resource)
|
||||
assert.deepEqual(captured.detail, before.detail)
|
||||
assert.deepEqual(calls.map(item => item.method), ['GET'])
|
||||
assert.ok(calls[0].url.endsWith(`/api/platform/v1/carrier-schedule-queries/${before.queryId}`))
|
||||
await assert.rejects(captureCarrierExceptionEvidence(context, { ...resource, marker: 'other' }), /current-marker/)
|
||||
assert.equal(calls.length, 1)
|
||||
} finally { globalThis.fetch = original }
|
||||
})
|
||||
@@ -2,6 +2,91 @@ const MODULE = 'phase3-carrier-schedule-queries'
|
||||
const OPTIONS = { scenario: 'carrier-schedule-query-policy', deploymentBlocker: true }
|
||||
const BASE = '/api/platform/v1/carrier-schedule-queries'
|
||||
const AUTOMATED_CARRIERS = Object.freeze(['MATSON', 'SML', 'ZIM', 'EMC', 'HEDE'])
|
||||
export const CARRIER_EXCEPTION_CODES = Object.freeze(['CUSTOMS_HOLD', 'TERMINAL_HOLD', 'DOCUMENT_HOLD', 'UNKNOWN_HOLD'])
|
||||
|
||||
function stable(value) {
|
||||
if (Array.isArray(value)) return value.map(stable)
|
||||
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(key => [key, stable(value[key])]))
|
||||
return value
|
||||
}
|
||||
const equal = (a, b) => JSON.stringify(stable(a)) === JSON.stringify(stable(b))
|
||||
const rows = value => [...value].sort((a, b) => id(a.id).localeCompare(id(b.id)))
|
||||
|
||||
export function carrierExceptionShapeErrors(detail) {
|
||||
const errors = []
|
||||
if (!Array.isArray(detail?.nodes) || !Array.isArray(detail?.exceptions) || !Array.isArray(detail?.histories)) return ['missing detail arrays']
|
||||
const identities = new Set()
|
||||
for (const fact of detail.exceptions) {
|
||||
const identity = `${fact.providerCode}|${fact.providerEventId}`
|
||||
if (!validLongId(fact.id) || !CARRIER_EXCEPTION_CODES.includes(fact.exceptionCode)
|
||||
|| typeof fact.active !== 'boolean' || !fact.providerCode || !fact.providerEventId
|
||||
|| !fact.providerStatus || !fact.observedAt || !fact.createdAt
|
||||
|| !/^[a-f0-9]{64}$/.test(fact.rawPayloadHash || '')) errors.push(`invalid exception ${id(fact.id)}`)
|
||||
for (const [key, max] of [['locationCode', 32], ['locationName', 128], ['sourceReference', 500]]) {
|
||||
if (fact[key] != null && (typeof fact[key] !== 'string' || fact[key].length > max)) errors.push(`invalid optional ${key}`)
|
||||
}
|
||||
if (identities.has(identity)) errors.push(`duplicate provider event ${identity}`)
|
||||
identities.add(identity)
|
||||
}
|
||||
if (detail.nodes.some(node => CARRIER_EXCEPTION_CODES.includes(node.standardNode))) errors.push('exception stored as normal node')
|
||||
return errors
|
||||
}
|
||||
|
||||
// A GET is evidence only. Supplier writes/replays must be orchestrated separately
|
||||
// through Integration + the real scheduler; no synthetic platform write endpoint.
|
||||
export async function captureCarrierExceptionEvidence(context, resource = context.createdResources?.phase3CarrierScheduleQuery) {
|
||||
if (!ownsCarrierQuery(resource, context.marker)) throw new Error('requires current-marker owned carrier query')
|
||||
const response = await request(context, 'GET', `${BASE}/${encodeURIComponent(resource.queryId)}`)
|
||||
const detail = context.apiSuccess(MODULE, 'C66读取原船任务正常节点和独立异常事实', response, '', OPTIONS)
|
||||
if (!detail || id(detail.query?.query?.id) !== id(resource.queryId)) throw new Error('carrier detail identity mismatch')
|
||||
const errors = carrierExceptionShapeErrors(detail)
|
||||
context.assert(MODULE, 'C66异常编码来源地点摘要完整且不混入正常节点', response, errors.length === 0, errors.join('; '), OPTIONS)
|
||||
if (errors.length) throw new Error('invalid carrier exception facts')
|
||||
return { marker: context.marker, queryId: id(resource.queryId), capturedAt: new Date().toISOString(),
|
||||
requestId: response.requestId, traceId: response.traceId, detail }
|
||||
}
|
||||
|
||||
export function carrierExceptionTransitionErrors(before, after, { kind, expected }) {
|
||||
const errors = [...carrierExceptionShapeErrors(before?.detail), ...carrierExceptionShapeErrors(after?.detail)]
|
||||
if (!before?.marker || before.marker !== after?.marker || !before.queryId || before.queryId !== after.queryId
|
||||
|| id(before.detail?.query?.query?.id) !== before.queryId || id(after.detail?.query?.query?.id) !== before.queryId) errors.push('checkpoint identity mismatch')
|
||||
if (errors.length) return errors
|
||||
const a = before.detail, b = after.detail
|
||||
if (!a.nodes.some(node => node.actualAt) || !a.nodes.some(node => node.estimatedAt)) errors.push('requires actual and estimated normal baseline')
|
||||
if (!equal(rows(a.nodes), rows(b.nodes))) errors.push('normal actual/estimated facts changed')
|
||||
for (const key of ['id', 'customerId', 'oceanBookingId', 'containerBookingId', 'latestStandardNode', 'latestNodeObservedAt', 'stoppedAt', 'stopReason']) {
|
||||
if (!equal(a.query.query[key], b.query.query[key])) errors.push(`normal query scope/progress changed: ${key}`)
|
||||
}
|
||||
const histories = detail => rows(detail.histories.filter(item => item.action === 'EXCEPTION_OBSERVED'))
|
||||
if (kind === 'REPLAY' || kind === 'CONFLICT') {
|
||||
const original = expected && a.exceptions.find(item => item.providerCode === expected.providerCode && item.providerEventId === expected.providerEventId)
|
||||
if (!original) errors.push('original event absent before replay/conflict')
|
||||
if (!/^[a-f0-9]{64}$/.test(expected?.rawPayloadHash || '')
|
||||
|| (original && (kind === 'REPLAY' ? original.rawPayloadHash !== expected.rawPayloadHash : original.rawPayloadHash === expected.rawPayloadHash))) errors.push('replay/conflict payload hash does not match scenario')
|
||||
if (!equal(rows(a.exceptions), rows(b.exceptions)) || !equal(histories(a), histories(b))) errors.push('replay/conflict changed exception facts/history')
|
||||
} else if (kind === 'APPEND' || kind === 'RELEASE') {
|
||||
if (!expected || !CARRIER_EXCEPTION_CODES.includes(expected.exceptionCode) || expected.active !== (kind === 'APPEND')) errors.push('invalid expected supplier fact')
|
||||
const added = b.exceptions.filter(item => !a.exceptions.some(old => id(old.id) === id(item.id)))
|
||||
if (added.length !== 1 || !expected || !Object.entries(expected).every(([key, value]) => equal(added[0]?.[key], value))) errors.push('expected independent fact not appended exactly once')
|
||||
if (!a.exceptions.every(old => b.exceptions.some(item => equal(old, item)))) errors.push('original exception overwritten/deleted')
|
||||
if (kind === 'RELEASE' && !a.exceptions.some(item => item.providerCode === expected?.providerCode && item.exceptionCode === expected?.exceptionCode && item.active === true)) errors.push('release lacks original active fact')
|
||||
const oldHistory = histories(a), newHistory = histories(b)
|
||||
if (newHistory.length !== oldHistory.length + 1 || !oldHistory.every(old => newHistory.some(item => equal(old, item)))) errors.push('exception history not append-only')
|
||||
} else errors.push('unsupported transition')
|
||||
return errors
|
||||
}
|
||||
|
||||
export async function verifyCarrierExceptionTransition(context, before, transition) {
|
||||
const after = await captureCarrierExceptionEvidence(context)
|
||||
const errors = carrierExceptionTransitionErrors(before, after, transition)
|
||||
context.assert(MODULE, `C66 ${transition.kind}异常独立保存且正常实际预计节点零漂移`, null,
|
||||
errors.length === 0, errors.join('; '), OPTIONS)
|
||||
context.createdResources.carrierExceptionTransitions ??= []
|
||||
// Zero drift does not prove that an upstream replay/conflict was actually sent.
|
||||
context.createdResources.carrierExceptionTransitions.push({ before, after, transition, errors,
|
||||
supplierDeliveryProofRequired: true, completionClaim: false })
|
||||
return after
|
||||
}
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
||||
@@ -86,6 +171,10 @@ export async function run(context) {
|
||||
return
|
||||
}
|
||||
|
||||
const bookingResponse = await request(context, 'GET', `/api/platform/v1/ocean-bookings/${fixture.oceanBookingId}`)
|
||||
const booking = context.apiSuccess(MODULE, '读取船任务所属订舱权威客户范围', bookingResponse, '', OPTIONS)
|
||||
if (!booking?.oceanBooking || id(booking.oceanBooking.id) !== fixture.oceanBookingId) return
|
||||
fixture.customerId = booking.oceanBooking.customerId == null ? '0' : id(booking.oceanBooking.customerId)
|
||||
const selector = new URLSearchParams({
|
||||
customerId: fixture.customerId, oceanBookingId: fixture.oceanBookingId,
|
||||
containerBookingId: fixture.containerBookingId, keyword: fixture.trackingNo, page: '1', size: '20',
|
||||
@@ -131,4 +220,8 @@ export async function run(context) {
|
||||
carrierCode: task?.carrierCode, initialStatus: task?.status,
|
||||
version: Number(data(detailResponse)?.query?.query?.version),
|
||||
}
|
||||
context.createdResources.carrierExceptionBaseline = await captureCarrierExceptionEvidence(context)
|
||||
context.addCoverageGap(MODULE, 'carrier-exception-provider-lifecycle', 'C66供应商异常新增解除及真实重放冲突',
|
||||
'正式平台API仅支持正常manual-nodes/manual-results;异常来自Integration query结果经真实scheduler写入。需总控受控供应商投递APPEND/REPLAY/CONFLICT/RELEASE并保存执行证据后调用只读checkpoint核验,空数组或重复GET不证明幂等。',
|
||||
'external-third-party')
|
||||
}
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { bookingPayload } from './phase3-order-splitting.mjs'
|
||||
|
||||
const source = readFileSync(new URL('./phase3-order-splitting.mjs', import.meta.url), 'utf8')
|
||||
|
||||
test('fresh completed child runs retain distinct bill numbers despite identical long marker suffixes', () => {
|
||||
const sourceOrder = { tenantId: '12', sourceOrderId: '34', channelCode: 'MATSON_CLX', warehouseId: '56' }
|
||||
const agent = { id: '78', name: 'agent' }, carrier = { id: '90', name: 'carrier', carrierCode: 'MATSON' }
|
||||
const markers = ['ADMIN-API-20261002030454498-COMPLETED-F40-CHILD',
|
||||
'ADMIN-API-20261002034957394-COMPLETED-F40-CHILD']
|
||||
const draft = (marker, sequence = 1, order = sourceOrder) => bookingPayload(marker, sequence, order, agent, carrier)
|
||||
assert.equal(`${markers[0]}-1`.replace(/[^A-Za-z0-9]/g, '').slice(-18),
|
||||
`${markers[1]}-1`.replace(/[^A-Za-z0-9]/g, '').slice(-18))
|
||||
assert.notEqual(draft(markers[0]).billOfLadingNo, draft(markers[1]).billOfLadingNo)
|
||||
assert.equal(draft(markers[0]).billOfLadingNo, draft(markers[0]).billOfLadingNo)
|
||||
assert.notEqual(draft(markers[0]).billOfLadingNo, draft(markers[0], 2).billOfLadingNo)
|
||||
assert.notEqual(draft(markers[0]).billOfLadingNo,
|
||||
draft(markers[0], 1, { ...sourceOrder, sourceOrderId: '35' }).billOfLadingNo)
|
||||
assert.match(draft(markers[0]).billOfLadingNo, /^BL-[a-f0-9]{18}$/)
|
||||
})
|
||||
|
||||
test('each remainder uses a simulation linked to a released booking requirement', () => {
|
||||
assert.match(source, /POST', CONTAINERS/)
|
||||
assert.match(source, /POST', '\/api\/platform\/v1\/ocean-bookings'/)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { selectSessionPdaOperator } from './domestic-pda-operator.mjs'
|
||||
import { createHash } from 'node:crypto'
|
||||
const MODULE = 'phase3-order-splitting'
|
||||
const SCENARIO = 'source-loaded-single-remainder-chain'
|
||||
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
|
||||
@@ -47,6 +48,8 @@ function sameBookingTransport(left, right) {
|
||||
}
|
||||
|
||||
async function request(context, method, path, body, headers = {}, token = undefined) {
|
||||
// Shared reads retry transport failures; business rejections remain final evidence.
|
||||
if (method === 'GET') return context.request(method, path, body, token ?? context.token, headers)
|
||||
try {
|
||||
const response = await fetch(`${context.options.gateway}${path}`, {
|
||||
method,
|
||||
@@ -165,8 +168,9 @@ function snapshotMatches(tree, original, loaded, remaining, activeSuffix) {
|
||||
&& activeMatches && quantityConserved(tree)
|
||||
}
|
||||
|
||||
function bookingPayload(marker, sequence, source, agent, carrier) {
|
||||
const suffix = `${marker}-${sequence}`.replace(/[^A-Za-z0-9]/g, '').slice(-18)
|
||||
export function bookingPayload(marker, sequence, source, agent, carrier) {
|
||||
const suffix = createHash('sha256').update(JSON.stringify([marker, sequence, id(source.sourceOrderId)]))
|
||||
.digest('hex').slice(0, 18)
|
||||
return oceanBookingDraft({ marker }, suffix, {
|
||||
customerId: source.tenantId, customerName: source.customerName, channelCode: source.channelCode,
|
||||
warehouseId: source.warehouseId, warehouseName: source.warehouseName,
|
||||
|
||||
@@ -131,6 +131,8 @@ test('only PDA-operated variants traverse real outbound and carrier nodes before
|
||||
assert.match(entry, /runPhase4ProducerVariant\('FBA-STANDARD', 'FBA', 'STANDARD', true\)/)
|
||||
assert.match(entry, /runPhase4ProducerVariant\('FBA-SECOND', 'FBA', 'STANDARD', true\)/)
|
||||
assert.match(entry, /runPhase4ProducerVariant\('FBA-OPERATIONS', 'FBA', 'STANDARD', true\)/)
|
||||
assert.match(entry, /runPhase4ProducerVariant\('FBA-ROUTE', 'FBA', 'STANDARD', true\)/)
|
||||
assert.match(entry, /routeFbaReady\?\.shippingReady === true/)
|
||||
assert.match(entry, /secondStandardFbaReady\?\.shippingReady === true/)
|
||||
assert.match(entry, /runPhase4ProducerVariant\('SECONDARY', 'FBA', 'STANDARD', false\)/)
|
||||
assert.match(entry, /const hasSecondaryProducer =/)
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { customsRetentionDigest, verifyTaxCustomsAfterShipEdit } from './phase3-port-schedules.mjs'
|
||||
import { customsRetentionDigest, verifyTaxCustomsAfterShipEdit, portScheduleCustomerScope } from './phase3-port-schedules.mjs'
|
||||
|
||||
test('public booking null scope becomes platform zero; positive IDs retain exact precision', () => {
|
||||
assert.equal(portScheduleCustomerScope(null), '0')
|
||||
assert.equal(portScheduleCustomerScope(undefined), '0')
|
||||
assert.equal(portScheduleCustomerScope('2105227486329671682'), '2105227486329671682')
|
||||
assert.equal(portScheduleCustomerScope(123), '123')
|
||||
for (const invalid of ['', '0', 0, '-1', 'customer']) {
|
||||
assert.throws(() => portScheduleCustomerScope(invalid), /null or a positive ID/)
|
||||
}
|
||||
})
|
||||
|
||||
test('customs retention digest includes documents, receipt versions, confirmations and completion facts', () => {
|
||||
const before = { status: 'COMPLETED', documents: [{ fileId: '1', tenantId: 'A', version: 2 }],
|
||||
|
||||
@@ -131,6 +131,13 @@ function oceanDraft(context, suffix, master) {
|
||||
}
|
||||
}
|
||||
|
||||
export function portScheduleCustomerScope(bookingCustomerId) {
|
||||
if (bookingCustomerId == null) return '0'
|
||||
const value = String(bookingCustomerId)
|
||||
if (!/^[1-9][0-9]*$/.test(value)) throw new Error('ocean booking customer scope must be null or a positive ID')
|
||||
return value
|
||||
}
|
||||
|
||||
async function ensureOceanBooking(context, suffix, master, preferredOceanBookingId = null) {
|
||||
const existingId = id(preferredOceanBookingId || context.createdResources?.oceanBooking?.id)
|
||||
if (existingId) {
|
||||
@@ -139,7 +146,7 @@ async function ensureOceanBooking(context, suffix, master, preferredOceanBooking
|
||||
`oceanBookingId=${existingId}`, OPTIONS)
|
||||
return detail?.oceanBooking ? {
|
||||
id: existingId,
|
||||
customerId: id(detail.oceanBooking.customerId),
|
||||
customerId: portScheduleCustomerScope(detail.oceanBooking.customerId),
|
||||
allocatedContainerBookingIds: (detail.containers || [])
|
||||
.map((item) => id(item?.containerBookingId)).filter(Boolean),
|
||||
} : null
|
||||
@@ -152,7 +159,7 @@ async function ensureOceanBooking(context, suffix, master, preferredOceanBooking
|
||||
Boolean(oceanBookingId) && id(detail?.oceanBooking?.customerId) === id(master.customerId),
|
||||
`oceanBookingId=${oceanBookingId || 'missing'}, customerId=${id(detail?.oceanBooking?.customerId) || 'missing'}`, OPTIONS)
|
||||
return oceanBookingId ? {
|
||||
id: oceanBookingId, customerId: id(detail.oceanBooking.customerId),
|
||||
id: oceanBookingId, customerId: portScheduleCustomerScope(detail.oceanBooking.customerId),
|
||||
allocatedContainerBookingIds: (detail.containers || [])
|
||||
.map((item) => id(item?.containerBookingId)).filter(Boolean),
|
||||
} : null
|
||||
@@ -191,6 +198,12 @@ export async function run(context) {
|
||||
Array.isArray(page?.records) && Number.isFinite(Number(page?.total)),
|
||||
`records=${page?.records?.length ?? 'n/a'}, total=${page?.total ?? 'n/a'}`, OPTIONS)
|
||||
|
||||
const publicResponse = await request(context, 'GET', `${BASE}?customerId=0&page=1&size=5`)
|
||||
const publicPage = context.apiSuccess(MODULE, '平台公共船期支持customerId=0查询', publicResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '平台公共船期查询不混入其他客户', publicResponse,
|
||||
Array.isArray(publicPage?.records) && publicPage.records.every(row => id(row?.schedule?.customerId) === '0'),
|
||||
`records=${publicPage?.records?.length ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const master = await resolveOceanBookingMaster(context, MODULE)
|
||||
if (!master) return
|
||||
const flow = context.createdResources?.phase3Loading
|
||||
@@ -206,7 +219,8 @@ export async function run(context) {
|
||||
OPTIONS)
|
||||
return
|
||||
}
|
||||
const payload = { expectedVersion: 0, data: scheduleData(context, suffix, ocean.id, knownAllocation, ocean.customerId), reason: context.marker }
|
||||
const scheduleCustomerId = ocean.customerId
|
||||
const payload = { expectedVersion: 0, data: scheduleData(context, suffix, ocean.id, knownAllocation, scheduleCustomerId), reason: context.marker }
|
||||
const createKey = `${context.marker}-port-create`.slice(0, 128)
|
||||
const createResponse = await request(context, 'POST', BASE, payload, { 'Idempotency-Key': createKey })
|
||||
const created = context.apiSuccess(MODULE, '新增独立港务船期观测', createResponse, '', OPTIONS)
|
||||
@@ -245,7 +259,7 @@ export async function run(context) {
|
||||
}
|
||||
|
||||
const initialVersion = Number(created.portSchedule.schedule.version)
|
||||
const expiredData = scheduleData(context, suffix, ocean.id, knownAllocation, master.customerId, true)
|
||||
const expiredData = scheduleData(context, suffix, ocean.id, knownAllocation, scheduleCustomerId, true)
|
||||
expiredData.sourceRef = payload.data.sourceRef
|
||||
// 甩柜只修改原柜的船信息;柜次、订舱关联和船期任务身份保持不变。
|
||||
expiredData.vesselName = `甩柜改配船-${suffix}`
|
||||
@@ -305,7 +319,7 @@ export async function run(context) {
|
||||
`inbounds=${expiredGate?.inbounds?.length ?? 'missing'}, obsoleteScheduleGate=${gateItems.some((item) => item?.code === 'SAILING_SCHEDULE')}`, OPTIONS)
|
||||
}
|
||||
|
||||
const currentData = scheduleData(context, suffix, ocean.id, knownAllocation, master.customerId, false)
|
||||
const currentData = scheduleData(context, suffix, ocean.id, knownAllocation, scheduleCustomerId, false)
|
||||
currentData.sourceRef = payload.data.sourceRef
|
||||
currentData.sourceType = 'MANUAL'
|
||||
currentData.sourceName = `平台人工兜底-${suffix}`
|
||||
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { verifyDiscardApproval } from './phase4-overseas-client-services.mjs'
|
||||
import { parseArguments } from '../admin-test-env-acceptance.mjs'
|
||||
|
||||
const source = await readFile(fileURLToPath(new URL('./phase4-overseas-client-services.mjs', import.meta.url)), 'utf8')
|
||||
@@ -14,7 +15,7 @@ test('client services module uses only stable merchant self-service paths', () =
|
||||
'/api/merchant/v1/last-leg/cargo-discards',
|
||||
'inventory-candidates',
|
||||
]) assert.ok(source.includes(path), `missing ${path}`)
|
||||
for (const forbidden of ['/api/pda/', '/api/platform/v1/last-leg/dispatches', '/outbound', '/delivery', '/dispatch', '/depart']) {
|
||||
for (const forbidden of ['/api/platform/v1/last-leg/dispatches', '/outbound', '/delivery', '/dispatch', '/depart']) {
|
||||
assert.equal(source.includes(forbidden), false, `must exclude ${forbidden}`)
|
||||
}
|
||||
})
|
||||
@@ -62,7 +63,7 @@ test('module records marker-owned resources without forbidden fulfillment cleanu
|
||||
assert.match(source, /relabelNo = relabelNo/)
|
||||
assert.match(source, /transferNo = transferNo/)
|
||||
assert.match(source, /discardNo = discardNo/)
|
||||
assert.match(source, /no outbound, dispatch, PDA or delivery action is invoked/)
|
||||
assert.match(source, /no outbound, dispatch or delivery action is invoked/)
|
||||
})
|
||||
|
||||
test('focused client services keeps the marker-owned merchant fixture when reusable credentials exist', () => {
|
||||
@@ -75,3 +76,42 @@ test('focused client services keeps the marker-owned merchant fixture when reusa
|
||||
const clientServices = parsed.modules.indexOf('phase4-overseas-client-services')
|
||||
assert.ok(fixture >= 0 && fixture < clientServices)
|
||||
})
|
||||
|
||||
test('discard follows formal approval deduction and keeps physical ledger evidence pending', () => {
|
||||
assert.match(source, /正式废弃合同要求审批事务扣减一箱/)
|
||||
assert.match(source, /废弃拒绝释放原箱且全部库存数量流水不变/)
|
||||
assert.match(source, /APPROVAL_HTTP_VERIFIED_CARTON_SQL_PENDING/)
|
||||
assert.doesNotMatch(source, /INSERT INTO|UPDATE ll_|fleet-trip|\/api\/pda\//)
|
||||
})
|
||||
|
||||
for (const fault of ['', 'pending deducts', 'reject does not release', 'approve does not deduct', 'create conflict mutates']) {
|
||||
test(`discard approval stock boundaries: ${fault || 'formal approval contract'}`, async () => {
|
||||
let state = 'PENDING_REVIEW', approved = false, conflictSeen = false
|
||||
const baseline = { balances: [{customerId:'123',overseasWarehouseId:'42',availablePackages:'4'}], ledger:[],
|
||||
source:{orderId:'9',overseasWarehouseId:'42',inboundCode:'IP',availablePackages:'4'},detail:null }
|
||||
const proof = {sourceOrderId:'9',customerId:'123',warehouseId:'42',inboundCode:'IP',snapshots:{before:baseline}}
|
||||
const calls=[]
|
||||
const context={marker:'fixture',token:'admin',addStep(){},
|
||||
assert(_m,name,_r,passed){ assert.ok(passed,name); if(name==='重复批准不会提前扣库存或新增历史') throw new Error('CHECKPOINT_PASSED') },
|
||||
apiSuccess(_m,_n,r){ assert.equal(r.body.code,'200');return r.body.data },
|
||||
async request(method,raw,body){
|
||||
calls.push({method,raw,body})
|
||||
const path=new URL(raw,'https://offline.invalid').pathname
|
||||
let data
|
||||
if(method==='POST' && path.endsWith('/review')) {state=body.decision==='REJECT'?'REJECTED':'APPROVED';data={}}
|
||||
else if(method==='POST' && path.endsWith('/cargo-discards')) {
|
||||
if(body.remark.endsWith('-DIFFERENT')) {conflictSeen=true;return {http:200,body:{code:'A0409'},requestId:'req',traceId:'trace'}}
|
||||
approved=true;state='PENDING_REVIEW';data={summary:{discardNo:'NEW'}}
|
||||
} else if(path.endsWith('/balances')) {
|
||||
const changed=(state==='PENDING_REVIEW' && fault==='pending deducts') || (state==='APPROVED' && fault!=='approve does not deduct') || (conflictSeen && fault==='create conflict mutates')
|
||||
data={total:'1',records:[{...baseline.balances[0],availablePackages:changed?'3':'4'}]}
|
||||
} else if(path.endsWith('/ledger')) data={total:'0',records:[]}
|
||||
else if(path.endsWith('/inventory-candidates')) data=[{...baseline.source,availablePackages:state==='REJECTED' && fault!=='reject does not release'?'4':'3'}]
|
||||
else data={summary:{discardNo:approved?'NEW':'OLD',status:state}}
|
||||
return {http:200,body:{code:'200',data}}
|
||||
}}
|
||||
await assert.rejects(verifyDiscardApproval(context,'merchant',{idempotencyKey:'key',remark:'fixture'},'OLD',proof),
|
||||
fault ? /仅锁定|释放原箱|审批事务扣减|异载荷拒绝后/ : /CHECKPOINT_PASSED/)
|
||||
assert.ok(!calls.some(call=>call.raw.includes('/pda/')))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -229,7 +229,7 @@ async function verifyTransfer(context, token, orders) {
|
||||
return verifyWarehouseTransferReview(context, token, order.row.customerId, body, transferNo)
|
||||
}
|
||||
|
||||
async function verifyDiscard(context, token, markerOrders) {
|
||||
export async function verifyDiscard(context, token, markerOrders) {
|
||||
const response = await context.request('GET', `${DISCARD}/inventory-candidates`, undefined, token, {})
|
||||
const candidates = context.apiSuccess(MODULE, '客户查询货物废弃库存候选', response, trace(response), OPTIONS)
|
||||
const markerOrderIds = new Set(markerOrders.map((value) => id(value.row?.id)))
|
||||
@@ -245,6 +245,14 @@ async function verifyDiscard(context, token, markerOrders) {
|
||||
items: [{ orderId: id(candidate.orderId), skuCode: sku.skuCode, discardPackages: 1 }],
|
||||
idempotencyKey: `${context.marker}-CLIENT-DISCARD`.slice(0, 255), remark: context.marker,
|
||||
}
|
||||
const proof = context.createdResources.phase4OverseasClientServices.discardApproval = {
|
||||
sourceOrderId: id(candidate.orderId), customerId: id(context.context.merchantQualificationFixture.tenantId),
|
||||
warehouseId: candidate.overseasWarehouseId, inboundCode: candidate.inboundCode,
|
||||
skuCode: sku.skuCode, snapshots: {}, status: 'RUNNING',
|
||||
contract: 'Current stage-five and service AGENTS require approval-transaction deduction. Review has no idempotencyKey. An approval that only enables PDA must fail this contract.',
|
||||
}
|
||||
const before = await discardSnapshot(context, token, proof)
|
||||
proof.snapshots.before = before
|
||||
const created = await merchant(context, token, 'POST', '客户创建货物废弃申请', DISCARD, body)
|
||||
const discardNo = id(created.data?.summary?.discardNo)
|
||||
context.assert(MODULE, '废弃申请只锁定库存并进入合法待审核状态', created.response,
|
||||
@@ -267,14 +275,97 @@ async function verifyDiscard(context, token, markerOrders) {
|
||||
&& Array.isArray(detail.data?.histories) && detail.data.histories.some((item) => item?.toStatus === 'PENDING_REVIEW'),
|
||||
`items=${detail.data?.items?.length ?? 'missing'}, histories=${detail.data?.histories?.length ?? 'missing'}; ${trace(detail.response)}`, OPTIONS)
|
||||
context.createdResources.phase4OverseasClientServices.discardNo = discardNo
|
||||
try { await verifyDiscardApproval(context, token, body, discardNo, proof) }
|
||||
catch (error) { proof.status = 'FAILED'; proof.failure = error.message; throw error }
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
function stable(value) {
|
||||
if (Array.isArray(value)) return JSON.stringify(value.map(stable).sort())
|
||||
if (value && typeof value === 'object') return JSON.stringify(Object.fromEntries(Object.keys(value).sort().map(k => [k, stable(value[k])])))
|
||||
return value
|
||||
}
|
||||
function discardRequire(context, name, passed, evidence = '') {
|
||||
context.assert(MODULE, name, null, Boolean(passed), evidence, OPTIONS)
|
||||
if (!passed) throw new Error(name)
|
||||
}
|
||||
async function discardCommand(context, token, method, name, path, body) {
|
||||
const result = await merchant(context, token, method, name, path, body)
|
||||
if (!success(result.response)) throw new Error(`${name}: ${trace(result.response)}`)
|
||||
return result.data
|
||||
}
|
||||
async function discardSnapshot(context, token, proof, discardNo) {
|
||||
const all = async (kind) => {
|
||||
const rows = []
|
||||
let total
|
||||
for (let page = 1; page <= 100; page++) {
|
||||
const data = await discardCommand(context, context.token, 'GET', `废弃核对完整${kind}`,
|
||||
query(`/api/platform/v1/last-leg/inventory/${kind}`, { customerId: proof.customerId,
|
||||
overseasWarehouseId: proof.warehouseId, page, size: 200 }))
|
||||
discardRequire(context, '废弃库存分页作用域与总数稳定', Array.isArray(data?.records)
|
||||
&& Number.isSafeInteger(Number(data.total)) && Number(data.total) >= 0
|
||||
&& (total === undefined || total === Number(data.total))
|
||||
&& data.records.every(row => id(row.customerId) === proof.customerId && row.overseasWarehouseId === proof.warehouseId))
|
||||
total = Number(data.total); rows.push(...data.records)
|
||||
if (rows.length === total) return rows
|
||||
discardRequire(context, '废弃库存分页不能截断', rows.length < total && data.records.length > 0)
|
||||
}
|
||||
throw new Error('废弃库存分页超过上限')
|
||||
}
|
||||
const balances = await all('balances'), ledger = await all('ledger')
|
||||
const candidates = await discardCommand(context, token, 'GET', '废弃回查本轮客户真实候选', `${DISCARD}/inventory-candidates`)
|
||||
const source = candidates.find(row => id(row.orderId) === proof.sourceOrderId)
|
||||
const detail = discardNo ? await discardCommand(context, token, 'GET', '废弃回查申请真实状态', `${DISCARD}/${encodeURIComponent(discardNo)}`) : null
|
||||
return { balances, ledger, source: source || null, detail }
|
||||
}
|
||||
export async function verifyDiscardApproval(context, token, body, discardNo, proof) {
|
||||
const require = (name, passed) => discardRequire(context, name, passed, `discardNo=${discardNo}`)
|
||||
const sameStock = (a,b) => stable(a.balances) === stable(b.balances) && stable(a.ledger) === stable(b.ledger)
|
||||
const base = proof.snapshots.before
|
||||
require('废弃来源属于本轮客户及实际收货仓', base.source?.overseasWarehouseId === proof.warehouseId
|
||||
&& base.source?.inboundCode === proof.inboundCode && Number(base.source.availablePackages) >= 1)
|
||||
let pending = await discardSnapshot(context, token, proof, discardNo)
|
||||
proof.snapshots.pending = pending
|
||||
require('申请仅锁定一箱且余额流水未扣减', sameStock(base,pending)
|
||||
&& Number(pending.source?.availablePackages || 0) === Number(base.source.availablePackages) - 1)
|
||||
const conflictBody = { ...body, remark: `${body.remark}-DIFFERENT` }
|
||||
const response = await context.request('POST', DISCARD, conflictBody, token, {})
|
||||
context.addStep(MODULE, '废弃创建同键异载荷拒绝', response, conflict(response), trace(response), OPTIONS)
|
||||
require('废弃创建异载荷有明确冲突诊断', conflict(response) && nonBlank(response.requestId) && nonBlank(response.traceId))
|
||||
require('废弃创建异载荷拒绝后事实不变', stable(await discardSnapshot(context,token,proof,discardNo)) === stable(pending))
|
||||
const root = '/api/platform/v1/last-leg/cargo-discards'
|
||||
const review = (number,decision) => discardCommand(context, context.token, 'POST', `废弃审批${decision}`,
|
||||
query(`${root}/${encodeURIComponent(number)}/review`, { customerId: proof.customerId }), { decision, remark: context.marker })
|
||||
await review(discardNo,'REJECT')
|
||||
const rejected = await discardSnapshot(context,token,proof,discardNo); proof.snapshots.rejected = rejected
|
||||
require('废弃拒绝释放原箱且全部库存数量流水不变', rejected.detail?.summary?.status === 'REJECTED'
|
||||
&& sameStock(base,rejected) && stable(base.source) === stable(rejected.source))
|
||||
await review(discardNo,'REJECT')
|
||||
require('废弃重复拒绝不重复释放', stable(await discardSnapshot(context,token,proof,discardNo)) === stable(rejected))
|
||||
const approvedBody = { ...body, idempotencyKey: `${body.idempotencyKey}-APPROVE` }
|
||||
const created = await discardCommand(context,token,'POST','创建独立待批准废弃申请',DISCARD,approvedBody)
|
||||
const approvedNo = id(created?.summary?.discardNo); require('批准对照使用本轮新申请', nonBlank(approvedNo) && approvedNo !== discardNo)
|
||||
proof.approvedNo = approvedNo
|
||||
await review(approvedNo,'APPROVE')
|
||||
const approved = await discardSnapshot(context,token,proof,approvedNo); proof.snapshots.approved = approved
|
||||
const available = snapshot => snapshot.balances.reduce((sum,row)=>sum+Number(row.availablePackages),0)
|
||||
require('正式废弃合同要求审批事务扣减一箱', approved.detail?.summary?.status === 'APPROVED' && available(base)-available(approved) === 1)
|
||||
await review(approvedNo,'APPROVE')
|
||||
require('重复批准不会提前扣库存或新增历史', stable(await discardSnapshot(context,token,proof,approvedNo)) === stable(approved))
|
||||
const reverse = await context.request('POST',query(`${root}/${encodeURIComponent(approvedNo)}/review`,{customerId:proof.customerId}),{decision:'REJECT',remark:context.marker},context.token,{})
|
||||
require('批准后反向审核拒绝且事实不变', conflict(reverse) && nonBlank(reverse.requestId) && nonBlank(reverse.traceId)
|
||||
&& stable(await discardSnapshot(context,token,proof,approvedNo)) === stable(approved))
|
||||
proof.status = 'APPROVAL_HTTP_VERIFIED_CARTON_SQL_PENDING'
|
||||
proof.remainingEvidence = 'll_dropship_carton, ll_dropship_sku_inventory, ll_dropship_carton_ledger require independent read-only evidence; public ledger is not carton ledger'
|
||||
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
context.createdResources.phase4OverseasClientServices = {
|
||||
marker: context.marker, relabelNo: null, transferNo: null, discardNo: null,
|
||||
finalStatuses: { relabel: null, transfer: null, discard: null },
|
||||
cleanup: 'Relabel/discard remain pending; transfers are reviewed with separate incoming approval/rejection fixtures; no outbound, dispatch, PDA or delivery action is invoked',
|
||||
cleanup: 'Relabel remains pending; transfer review fixtures retained; discard rejection and approval-transaction deduction checked; no outbound, dispatch or delivery action is invoked',
|
||||
}
|
||||
const token = await loginMerchant(context)
|
||||
if (!token) return
|
||||
@@ -300,6 +391,6 @@ export async function run(context) {
|
||||
context.createdResources.phase4OverseasClientServices.finalStatuses = {
|
||||
relabel: relabelDone ? 'PENDING_REVIEW' : null,
|
||||
transfer: transferDone ? 'APPROVED' : null,
|
||||
discard: discardDone ? 'PENDING_REVIEW' : null,
|
||||
discard: discardDone ? 'APPROVED' : null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dispatchBusinessSnapshot } from './phase4-overseas-dispatch.mjs'
|
||||
import { dispatchBusinessSnapshot, dispatchRouteSnapshot, dispatchRouteMatches,
|
||||
selectDispatchRouteFixture, verifyDispatchRouteLifecycle } from './phase4-overseas-dispatch.mjs'
|
||||
|
||||
const moduleUrl = new URL('./phase4-overseas-dispatch.mjs', import.meta.url)
|
||||
const source = await readFile(fileURLToPath(moduleUrl), 'utf8')
|
||||
@@ -29,13 +30,117 @@ test('module uses marker-owned completed PDA pallets and never borrows historica
|
||||
})
|
||||
|
||||
test('ordinary dispatch selects one current-run pallet per destination for multi-FBA delivery proof', () => {
|
||||
assert.match(source, /const candidatesByDestination = new Map\(\)/)
|
||||
assert.match(source, /destinationWarehouseCode \|\| value\?\.fbaWarehouseCode/)
|
||||
assert.match(source, /const candidatesByDestination = new Map\(selectedCandidates.map/)
|
||||
assert.match(source, /row\?\.fbaWarehouseCode \|\| row\?\.destinationWarehouseCode/)
|
||||
assert.match(source, /selectedCandidates\.length >= 2/)
|
||||
assert.match(source, /planPalletIds, route: `\$\{context\.marker\}-ROUTE`/)
|
||||
assert.match(source, /planPalletIds, fbaAppointmentNo: null/)
|
||||
assert.match(source, /Number\(arranged\.data\?\.palletCount\) === planPalletIds\.length/)
|
||||
})
|
||||
|
||||
function routeCandidates() {
|
||||
return [['20', '120', 'ABE4', 2], ['30', '130', 'ABE8', 3], ['21', '121', 'ABE4', 4]].map(([planPalletId, warehousePalletId, fbaWarehouseCode, packageCount]) => ({
|
||||
planId: '10', planPalletId, warehousePalletId, fbaWarehouseCode, destinationWarehouseCode: fbaWarehouseCode,
|
||||
overseasWarehouseId: '900', customerId: '100', inboundCode: `IN-${planPalletId}`, sourceStage: 'RECEIVED', destinationType: 'FBA', packageCount,
|
||||
}))
|
||||
}
|
||||
function responseFor(candidates) {
|
||||
return { id: '1', dispatchNo: 'DS-TEST', overseasWarehouseId: '900', status: 'PENDING_DEPARTURE',
|
||||
route: [...new Set(candidates.map(row => row.fbaWarehouseCode))].join(','),
|
||||
items: candidates.map(row => ({ ...row, id: '8' + row.planPalletId })),
|
||||
itemCount: candidates.length, palletCount: candidates.length,
|
||||
packageCount: candidates.reduce((sum, row) => sum + row.packageCount, 0), pickedPackageCount: 0, shippedPackageCount: 0 }
|
||||
}
|
||||
|
||||
test('C52 fixture needs two destinations and two distinct physical pallets at one destination', () => {
|
||||
const rows = routeCandidates()
|
||||
assert.equal(selectDispatchRouteFixture(rows.slice(0, 2)), null)
|
||||
assert.equal(selectDispatchRouteFixture([rows[0], rows[2]]), null)
|
||||
assert.equal(selectDispatchRouteFixture([rows[0], rows[1], { ...rows[2], warehousePalletId: rows[0].warehousePalletId }]), null)
|
||||
const result = selectDispatchRouteFixture(rows)
|
||||
assert.deepEqual(result.selected.map(row => row.planPalletId), ['20', '30'])
|
||||
assert.equal(result.duplicate.planPalletId, '21')
|
||||
assert.match(source, /generatedPalletsByInbound/)
|
||||
assert.match(source, /id\(value\?\.customerId\) === id\(fixture.customerId\)/)
|
||||
assert.match(source, /value\?\.destinationType === 'FBA'/)
|
||||
})
|
||||
|
||||
test('C52 independently derives route from actual rows and rejects fabricated destinations, sources, totals and duplicate route codes', () => {
|
||||
const rows = routeCandidates(), value = responseFor(rows)
|
||||
assert.equal(dispatchRouteMatches(value, rows), true)
|
||||
for (const route of ['ABE4', 'ABE4,ABE8,ABE9', 'ABE4,ABE8,ABE4', 'ABE4,ABE8,', '']) {
|
||||
assert.equal(dispatchRouteMatches({ ...value, route }, rows), false)
|
||||
}
|
||||
for (const patch of [{ itemCount: 9 }, { packageCount: 10 }, { overseasWarehouseId: '901' }, { status: 'PICKING' }]) {
|
||||
assert.equal(dispatchRouteMatches({ ...value, ...patch }, rows), false)
|
||||
}
|
||||
for (const patch of [{ inboundCode: 'FOREIGN' }, { warehousePalletId: '999' }, { fbaWarehouseCode: 'ABE9' }, { planPalletId: '999' }]) {
|
||||
assert.equal(dispatchRouteMatches({ ...value, items: [{ ...value.items[0], ...patch }, ...value.items.slice(1)] }, rows), false)
|
||||
}
|
||||
assert.notDeepEqual(dispatchRouteSnapshot(value), dispatchRouteSnapshot({ ...value, items: [{ ...value.items[0], id: '999' }, ...value.items.slice(1)] }))
|
||||
assert.notEqual(dispatchBusinessSnapshot(value), dispatchBusinessSnapshot({ ...value, route: 'ABE9' }))
|
||||
})
|
||||
|
||||
function lifecycleHarness(fault = '') {
|
||||
const rows = routeCandidates(), selected = rows.slice(0, 2)
|
||||
let state = responseFor(selected), nextItemId = 900, preArrangeCount = 0
|
||||
const calls = [], checks = []
|
||||
const base = '/api/platform/v1/last-leg/dispatches'
|
||||
const ok = data => ({ http: 200, body: { code: '200', data: structuredClone(data) }, requestId: 'REQ', traceId: 'TRACE' })
|
||||
const error = code => ({ http: 200, body: { code }, requestId: 'REQ', traceId: 'TRACE' })
|
||||
const context = { marker: 'CURRENT', createdResources: { phase4OverseasDispatch: {} },
|
||||
apiSuccess: (_module, _name, response) => response.body.data,
|
||||
assert: (_module, name, _response, passed) => checks.push({ name, passed }),
|
||||
request: async (method, path, body) => {
|
||||
calls.push({ method, path, body })
|
||||
if (path.includes('/candidates')) return ok(rows.filter(row => !state.items.some(item => item.planPalletId === row.planPalletId)))
|
||||
if (path.endsWith('/draft-locks/lock')) return ok(null)
|
||||
if (method === 'GET') return ok(fault === 'wrong-route' ? { ...state, route: 'ABE9' } : state)
|
||||
if (method === 'DELETE') {
|
||||
const itemId = path.split('/').at(-1), remaining = state.items.filter(row => row.id !== itemId)
|
||||
if (remaining.length === state.items.length) {
|
||||
if (fault === 'rejected-delete-mutates') state.route = 'ABE9'
|
||||
return error('A0404')
|
||||
}
|
||||
state = { ...responseFor(remaining), items: remaining }
|
||||
return ok(state)
|
||||
}
|
||||
if (path === `${base}/pre-arrange`) {
|
||||
if (new Set(body.planPalletIds).size !== body.planPalletIds.length) return error('A0400')
|
||||
if (body.draftToken !== 'DRAFT') return error('A0409')
|
||||
preArrangeCount++
|
||||
const picked = [...body.planPalletIds].sort().map(palletId => rows.find(row => row.planPalletId === palletId))
|
||||
const items = picked.map(row => state.items.find(item => item.planPalletId === row.planPalletId) || { ...row, id: String(nextItemId++) })
|
||||
state = { ...responseFor(picked), items }
|
||||
if (fault === 'replay-id-drift' && preArrangeCount === 2) state.items[0].id = String(nextItemId++)
|
||||
return ok(state)
|
||||
}
|
||||
throw new Error(`unexpected request ${method} ${path}`)
|
||||
} }
|
||||
return { context, calls, checks, options: { dispatchNo: 'DS-TEST', candidates: selected, duplicate: rows[2], draftToken: 'DRAFT', draftContainerId: '50' } }
|
||||
}
|
||||
|
||||
test('C52 HTTP orchestration adds duplicate destination, removes last destination, restores and freezes exact replay/rejection facts', async () => {
|
||||
const h = lifecycleHarness()
|
||||
await verifyDispatchRouteLifecycle(h.context, h.options)
|
||||
assert.equal(h.context.createdResources.phase4OverseasDispatch.routeLifecycle.status, 'VERIFIED')
|
||||
assert.ok(h.checks.length >= 20 && h.checks.every(row => row.passed))
|
||||
for (const label of ['同目的地第二托增加箱托数但路线稳定去重', '最后一托移除后路线删除该目的地', '重新加入后恢复原目的地集合', '重复托盘ID拒绝A0400', '其他草稿抢占原排车拒绝A0409']) {
|
||||
assert.ok(h.checks.some(row => row.name.includes(label)))
|
||||
}
|
||||
assert.ok(h.calls.filter(row => row.path.endsWith('/pre-arrange')).every(row => !Object.hasOwn(row.body, 'route')))
|
||||
assert.equal(h.calls.filter(row => row.method === 'DELETE').length, 3)
|
||||
assert.deepEqual(h.context.createdResources.phase4OverseasDispatch.routeLifecycle.stages.at(-1).snapshot.items.map(row => row.planPalletId), ['20', '30'])
|
||||
})
|
||||
|
||||
test('C52 stops on wrong route, replay row identity drift or side effects after rejected removal', async () => {
|
||||
for (const fault of ['wrong-route', 'replay-id-drift', 'rejected-delete-mutates']) {
|
||||
const h = lifecycleHarness(fault)
|
||||
await assert.rejects(verifyDispatchRouteLifecycle(h.context, h.options), /C52/)
|
||||
assert.equal(h.context.createdResources.phase4OverseasDispatch.routeLifecycle.status, 'UNPROVEN')
|
||||
assert.ok(h.checks.some(row => !row.passed))
|
||||
}
|
||||
})
|
||||
|
||||
test('module proves draft idempotency, lock isolation, editable pre-arrange replay and readback', () => {
|
||||
assert.match(source, /相同草稿快照重复保存/)
|
||||
assert.match(source, /同草稿重复锁定托盘幂等成功/)
|
||||
|
||||
@@ -11,10 +11,11 @@ function codeOf(response) { return response?.body?.code == null ? '' : String(re
|
||||
function success(response) { return response?.http === 200 && codeOf(response) === '200' }
|
||||
export function dispatchBusinessSnapshot(value) {
|
||||
return JSON.stringify({ id: id(value?.id), dispatchNo: value?.dispatchNo, status: value?.status,
|
||||
packageCount: value?.packageCount, palletCount: value?.palletCount,
|
||||
route: value?.route, packageCount: value?.packageCount, palletCount: value?.palletCount,
|
||||
items: (value?.items || []).map(item => ({ planPalletId: id(item.planPalletId),
|
||||
warehousePalletId: id(item.warehousePalletId), sourceStage: item.sourceStage,
|
||||
packageCount: item.packageCount, inboundCode: item.inboundCode }))
|
||||
packageCount: item.packageCount, inboundCode: item.inboundCode,
|
||||
destinationWarehouseCode: item.destinationWarehouseCode, fbaWarehouseCode: item.fbaWarehouseCode }))
|
||||
.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))),
|
||||
})
|
||||
}
|
||||
@@ -45,11 +46,150 @@ function blocker(context, name, evidence, response = null) {
|
||||
function markerFixture(context) {
|
||||
const fixture = context.createdResources?.phase4OverseasPda
|
||||
if (fixture?.marker !== context.marker || fixture?.finalStatus !== 'COMPLETED') return null
|
||||
const palletIds = new Set((fixture.palletIds || []).map(id).filter(Boolean))
|
||||
const palletIds = new Set([...(fixture.palletIds || []),
|
||||
...Object.values(fixture.generatedPalletsByInbound || {}).flat().filter(row =>
|
||||
id(row.warehouseId) === id(fixture.overseasWarehouseId)).map(row => row.palletId)].map(id).filter(Boolean))
|
||||
if (!fixture.overseasWarehouseId || palletIds.size === 0) return null
|
||||
return { ...fixture, palletIds }
|
||||
}
|
||||
|
||||
const destination = row => String(row?.fbaWarehouseCode || row?.destinationWarehouseCode || '').trim()
|
||||
const sorted = values => [...values].sort()
|
||||
const same = (left, right) => JSON.stringify(left) === JSON.stringify(right)
|
||||
// Item IDs are included here: editable pre-arrange retains existing rows, and rejected
|
||||
// commands must preserve the exact assigned facts, not merely their counts.
|
||||
export function dispatchRouteSnapshot(value) {
|
||||
return { id: id(value?.id), dispatchNo: value?.dispatchNo, status: value?.status,
|
||||
route: value?.route ?? '', warehouseId: id(value?.overseasWarehouseId),
|
||||
itemCount: value?.itemCount, palletCount: value?.palletCount, packageCount: value?.packageCount,
|
||||
pickedPackageCount: value?.pickedPackageCount, shippedPackageCount: value?.shippedPackageCount,
|
||||
items: (value?.items || []).map(row => ({ id: id(row.id), planPalletId: id(row.planPalletId),
|
||||
warehousePalletId: id(row.warehousePalletId), palletNo: row.palletNo,
|
||||
warehouseId: id(row.overseasWarehouseId), inboundCode: row.inboundCode,
|
||||
orderNo: row.orderNo, orderRefs: row.orderRefs, sourceStage: row.sourceStage,
|
||||
destinationWarehouseCode: row.destinationWarehouseCode, fbaWarehouseCode: row.fbaWarehouseCode,
|
||||
packageCount: row.packageCount })).sort((a, b) => a.planPalletId.localeCompare(b.planPalletId)) }
|
||||
}
|
||||
|
||||
export function dispatchRouteMatches(value, expectedCandidates) {
|
||||
if (!Array.isArray(value?.items) || !Array.isArray(expectedCandidates)) return false
|
||||
const items = value.items, route = String(value.route || '').trim()
|
||||
const routeParts = route ? route.split(',').map(part => part.trim()) : []
|
||||
const actualDestinations = sorted(new Set(items.map(destination)))
|
||||
return value.status === 'PENDING_DEPARTURE'
|
||||
&& Number(value.itemCount) === items.length && Number(value.palletCount) === items.length
|
||||
&& Number(value.packageCount) === items.reduce((total, row) => total + Number(row.packageCount), 0)
|
||||
&& items.length === expectedCandidates.length
|
||||
&& new Set(items.map(row => id(row.planPalletId))).size === items.length
|
||||
&& new Set(items.map(row => id(row.warehousePalletId))).size === items.length
|
||||
&& items.every(row => /^[1-9][0-9]*$/.test(id(row.id)) && destination(row)
|
||||
&& expectedCandidates.some(expected => id(expected.planPalletId) === id(row.planPalletId)
|
||||
&& id(expected.warehousePalletId) === id(row.warehousePalletId)
|
||||
&& id(expected.overseasWarehouseId) === id(row.overseasWarehouseId)
|
||||
&& id(value.overseasWarehouseId) === id(row.overseasWarehouseId)
|
||||
&& expected.sourceStage === row.sourceStage && expected.inboundCode === row.inboundCode
|
||||
&& Number(expected.packageCount) === Number(row.packageCount) && Number(row.packageCount) > 0
|
||||
&& destination(expected) === destination(row)))
|
||||
&& routeParts.every(Boolean) && routeParts.length === new Set(routeParts).size
|
||||
&& same(sorted(routeParts), actualDestinations)
|
||||
}
|
||||
|
||||
export function selectDispatchRouteFixture(candidates) {
|
||||
const unique = [...new Map(candidates.map(row => [id(row.warehousePalletId), row])).values()]
|
||||
const groups = new Map()
|
||||
for (const row of unique) {
|
||||
const code = destination(row)
|
||||
if (code) groups.set(code, [...(groups.get(code) || []), row])
|
||||
}
|
||||
const repeated = [...groups.values()].find(rows => rows.length >= 2)
|
||||
if (groups.size < 2 || !repeated) return null
|
||||
return { selected: [...groups.values()].map(rows => rows[0]), duplicate: repeated[1] }
|
||||
}
|
||||
|
||||
export async function verifyDispatchRouteLifecycle(context, { dispatchNo, candidates, duplicate, draftToken, draftContainerId }) {
|
||||
const proof = context.createdResources.phase4OverseasDispatch.routeLifecycle = {
|
||||
marker: context.marker, dispatchNo, status: 'UNPROVEN', stages: [],
|
||||
}
|
||||
const detailPath = `${BASE}/${encodeURIComponent(dispatchNo)}`
|
||||
let identity = null
|
||||
const check = (name, response, passed, evidence) => {
|
||||
context.assert(MODULE, `C52 ${name}`, response, passed, evidence, OPTIONS)
|
||||
if (!passed) throw new Error(`C52 ${name}`)
|
||||
}
|
||||
const read = async (name, expected) => {
|
||||
const current = await api(context, 'GET', `C52 ${name}回查实际分配明细`, detailPath)
|
||||
const snapshot = dispatchRouteSnapshot(current.data)
|
||||
const passed = success(current.response) && current.data?.dispatchNo === dispatchNo
|
||||
&& (!identity || snapshot.id === identity) && dispatchRouteMatches(current.data, expected)
|
||||
proof.stages.push({ name, passed, snapshot, expectedPlanPalletIds: expected.map(row => id(row.planPalletId)),
|
||||
requestId: current.response?.requestId, traceId: current.response?.traceId })
|
||||
check(`${name}实际明细独立推导路线及本轮来源核平`, current.response, passed, JSON.stringify(snapshot))
|
||||
identity ||= snapshot.id
|
||||
return current.data
|
||||
}
|
||||
const lock = async row => {
|
||||
const result = await api(context, 'POST', 'C52 新增本轮托盘到原草稿锁', `${BASE}/draft-locks/lock`,
|
||||
{ planPalletId: id(row.planPalletId), draftToken, draftContainerId })
|
||||
check('新增草稿锁成功', result.response, success(result.response), trace(result.response))
|
||||
}
|
||||
const arrange = async (name, selected) => {
|
||||
const body = { planPalletIds: selected.map(row => id(row.planPalletId)), draftToken }
|
||||
const result = await api(context, 'POST', `C52 ${name}`, `${BASE}/pre-arrange`, body)
|
||||
check(`${name}复用原排车`, result.response, success(result.response) && result.data?.dispatchNo === dispatchNo, trace(result.response))
|
||||
const current = await read(name, selected)
|
||||
const replay = await api(context, 'POST', `C52 ${name}同草稿同载荷重放`, `${BASE}/pre-arrange`, body)
|
||||
const after = await read(`${name}重放后`, selected)
|
||||
check(`${name}重放路线及完整明细零漂移`, replay.response, success(replay.response)
|
||||
&& same(dispatchRouteSnapshot(current), dispatchRouteSnapshot(replay.data))
|
||||
&& same(dispatchRouteSnapshot(current), dispatchRouteSnapshot(after)), trace(replay.response))
|
||||
return after
|
||||
}
|
||||
const rejectUnchanged = async (name, method, path, body, expectedCode, current, selected) => {
|
||||
const result = await context.request(method, path, body)
|
||||
// These business errors use the current gateway envelope and must retain traces.
|
||||
check(name, result, result?.http === 200 && codeOf(result) === expectedCode
|
||||
&& Boolean(result.requestId) && Boolean(result.traceId), trace(result))
|
||||
const after = await read(`${name}后`, selected)
|
||||
check(`${name}路线数量及明细零漂移`, result,
|
||||
same(dispatchRouteSnapshot(current), dispatchRouteSnapshot(after)), trace(result))
|
||||
}
|
||||
const initial = await read('初始多目的地', candidates)
|
||||
const expanded = [...candidates, duplicate]
|
||||
await lock(duplicate)
|
||||
const added = await arrange('新增同目的地第二托', expanded)
|
||||
check('同目的地第二托增加箱托数但路线稳定去重', null,
|
||||
initial.route === added.route && added.palletCount === initial.palletCount + 1,
|
||||
`before=${initial.route}, after=${added.route}`)
|
||||
const extraItem = added.items.find(row => id(row.planPalletId) === id(duplicate.planPalletId))
|
||||
const removeExtra = await api(context, 'DELETE', 'C52 移除同目的地第二托', `${detailPath}/items/${id(extraItem.id)}`)
|
||||
const dedup = await read('移除同目的地第二托', candidates)
|
||||
check('同目的地仍有托盘时路线不消失', removeExtra.response, success(removeExtra.response) && dedup.route === initial.route, trace(removeExtra.response))
|
||||
const removedCandidate = candidates.find(row => destination(row) !== destination(duplicate))
|
||||
const removedItem = dedup.items.find(row => id(row.planPalletId) === id(removedCandidate.planPalletId))
|
||||
const reducedCandidates = candidates.filter(row => row !== removedCandidate)
|
||||
const removePath = `${detailPath}/items/${id(removedItem.id)}`
|
||||
const removed = await api(context, 'DELETE', 'C52 移除某目的地最后一托', removePath)
|
||||
check('移除最后一托请求成功', removed.response, success(removed.response), trace(removed.response))
|
||||
const reduced = await read('移除目的地最后一托', reducedCandidates)
|
||||
check('最后一托移除后路线删除该目的地', null,
|
||||
!reduced.route.split(',').map(part => part.trim()).includes(destination(removedCandidate)), `route=${reduced.route}`)
|
||||
await rejectUnchanged('重复移除已删除明细A0404', 'DELETE', removePath, undefined, 'A0404', reduced, reducedCandidates)
|
||||
await lock(removedCandidate)
|
||||
const restored = await arrange('重新加入已移除目的地', candidates)
|
||||
check('重新加入后恢复原目的地集合', null,
|
||||
same(sorted(restored.route.split(',')), sorted(initial.route.split(','))), `route=${restored.route}`)
|
||||
const body = { planPalletIds: candidates.map(row => id(row.planPalletId)), draftToken }
|
||||
await rejectUnchanged('重复托盘ID拒绝A0400', 'POST', `${BASE}/pre-arrange`,
|
||||
{ ...body, planPalletIds: [...body.planPalletIds, body.planPalletIds[0]] }, 'A0400', restored, candidates)
|
||||
await rejectUnchanged('其他草稿抢占原排车拒绝A0409', 'POST', `${BASE}/pre-arrange`,
|
||||
{ ...body, draftToken: `${context.marker}-C52-FOREIGN`.slice(0, 64) }, 'A0409', restored, candidates)
|
||||
const freed = await api(context, 'GET', 'C52 移除重复目的地托盘后候选恢复', query(`${BASE}/candidates`, { sourceStage: 'RECEIVED' }))
|
||||
check('额外托盘正式锁已释放', freed.response, success(freed.response) && Array.isArray(freed.data)
|
||||
&& freed.data.some(row => id(row.planPalletId) === id(duplicate.planPalletId)
|
||||
&& id(row.warehousePalletId) === id(duplicate.warehousePalletId)), trace(freed.response))
|
||||
proof.status = 'VERIFIED'
|
||||
}
|
||||
|
||||
async function runRelabelDispatch(context, relabel) {
|
||||
const candidateResult = await api(context, 'GET', '查询本轮换标托盘智能排车候选',
|
||||
query(`${BASE}/candidates`, { orderNo: relabel.relabelNo, sourceStage: 'RECEIVED' }))
|
||||
@@ -68,7 +208,7 @@ async function runRelabelDispatch(context, relabel) {
|
||||
|
||||
const arranged = await api(context, 'POST', '将本轮换标托盘创建待出库排车', `${BASE}/pre-arrange`, {
|
||||
planPalletIds: candidates.map((value) => id(value.warehousePalletId)),
|
||||
route: `${context.marker}-RELABEL-ROUTE`, fbaAppointmentNo: null,
|
||||
fbaAppointmentNo: null,
|
||||
fbaAppointmentStatus: null, vehicleAppointmentStatus: null, relabelNo: relabel.relabelNo,
|
||||
})
|
||||
const dispatchNo = id(arranged.data?.dispatchNo)
|
||||
@@ -124,14 +264,16 @@ export async function run(context) {
|
||||
const candidates = Array.isArray(candidateResult.data) ? candidateResult.data : []
|
||||
const markerCandidates = candidates.filter((value) => fixture.palletIds.has(id(value?.warehousePalletId))
|
||||
&& id(value?.overseasWarehouseId) === id(fixture.overseasWarehouseId)
|
||||
&& id(value?.customerId) === id(fixture.customerId) && value?.destinationType === 'FBA'
|
||||
&& id(value?.planId) && id(value?.planPalletId)
|
||||
&& value?.sourceStage === 'RECEIVED' && Number(value?.packageCount) > 0)
|
||||
const candidatesByDestination = new Map()
|
||||
for (const value of markerCandidates) {
|
||||
const destination = String(value?.destinationWarehouseCode || value?.fbaWarehouseCode || '').trim().toUpperCase()
|
||||
if (destination && !candidatesByDestination.has(destination)) candidatesByDestination.set(destination, value)
|
||||
const routeFixture = selectDispatchRouteFixture(markerCandidates)
|
||||
if (!routeFixture) {
|
||||
blocker(context, 'C52 多目的地及同目的地双托前置', '本轮同客户同仓实际收货候选须至少两个FBA目的地,且至少一个目的地有两个不同实物托盘;不借历史数据')
|
||||
return
|
||||
}
|
||||
const selectedCandidates = [...candidatesByDestination.values()]
|
||||
const selectedCandidates = routeFixture.selected
|
||||
const candidatesByDestination = new Map(selectedCandidates.map(row => [destination(row), row]))
|
||||
const candidate = selectedCandidates[0]
|
||||
context.assert(MODULE, '候选严格命中本轮PDA库存托盘且覆盖多个FBA仓', candidateResult.response,
|
||||
selectedCandidates.length >= 2,
|
||||
@@ -144,7 +286,7 @@ export async function run(context) {
|
||||
const planPalletIds = selectedCandidates.map(value => id(value.planPalletId))
|
||||
// Exercise removal on a disposable dispatch, then reuse the released current-run pallet.
|
||||
const removable = await api(context, 'POST', '创建本轮托盘移除对照排车', `${BASE}/pre-arrange`, {
|
||||
planPalletIds: [planPalletId], route: `${context.marker}-REMOVE`,
|
||||
planPalletIds: [planPalletId],
|
||||
})
|
||||
const removableNo = id(removable.data?.dispatchNo)
|
||||
const removableItem = removable.data?.items?.find(item => id(item.planPalletId) === planPalletId)
|
||||
@@ -241,7 +383,7 @@ export async function run(context) {
|
||||
context.addStep(MODULE, '其他草稿锁定同一托盘返回A0409', foreignLock, conflict(foreignLock), trace(foreignLock), OPTIONS)
|
||||
|
||||
const preArrangeBody = {
|
||||
planPalletIds, route: `${context.marker}-ROUTE`, fbaAppointmentNo: null,
|
||||
planPalletIds, fbaAppointmentNo: null,
|
||||
fbaAppointmentStatus: null, vehicleAppointmentStatus: null, draftToken,
|
||||
}
|
||||
const arranged = await api(context, 'POST', '将草稿锁原子转换为待出库预排', `${BASE}/pre-arrange`, preArrangeBody)
|
||||
@@ -278,6 +420,9 @@ export async function run(context) {
|
||||
&& detailResult.data.items.some((item) => id(item?.planPalletId) === planPalletId),
|
||||
`dispatchNo=${id(detailResult.data?.dispatchNo) || 'missing'}, items=${detailResult.data?.items?.length ?? 'missing'}; ${trace(detailResult.response)}`, OPTIONS)
|
||||
|
||||
await verifyDispatchRouteLifecycle(context, { dispatchNo, candidates: selectedCandidates,
|
||||
duplicate: routeFixture.duplicate, draftToken, draftContainerId: id(finalContainer.data.id) })
|
||||
|
||||
const released = await api(context, 'POST', '释放已转换后的草稿资源', `${BASE}/draft-locks/release`, { draftToken })
|
||||
if (success(released.response)) {
|
||||
const releaseReplay = await api(context, 'POST', '重复释放草稿幂等成功', `${BASE}/draft-locks/release`, { draftToken })
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { run } from './phase4-overseas-master-data.mjs'
|
||||
import { run, vehicleCapacityMatches } from './phase4-overseas-master-data.mjs'
|
||||
|
||||
test('vehicle capacity evidence rejects absent, zero and guessed dimensions', () => {
|
||||
const expected = { maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90 }
|
||||
assert.equal(vehicleCapacityMatches(expected, expected), true)
|
||||
assert.equal(vehicleCapacityMatches({ maxPalletSpaces: '30', maxLoadKg: '18000.00', maxVolumeCbm: '90.0' }, expected), true)
|
||||
for (const key of Object.keys(expected)) for (const value of [undefined, null, '', 0, Infinity, 'unknown', 123]) {
|
||||
assert.equal(vehicleCapacityMatches({ ...expected, [key]: value }, expected), false, `${key}: ${value}`)
|
||||
}
|
||||
})
|
||||
|
||||
const source = readFileSync(new URL('./phase4-overseas-master-data.mjs', import.meta.url), 'utf8')
|
||||
const controllerSource = [
|
||||
@@ -28,12 +37,13 @@ function vehicleWire(state) {
|
||||
return { ...state.vehicle, mileageKm: String(state.vehicle.mileageKm), recordVersion: String(state.vehicle.recordVersion) }
|
||||
}
|
||||
|
||||
function fakeContext({ softAssertions = false, refreshEmpty = false } = {}) {
|
||||
function fakeContext({ softAssertions = false, refreshEmpty = false, careLeak = false, processingMarksPaid = false } = {}) {
|
||||
const calls = []
|
||||
const coverageGaps = []
|
||||
const steps = []
|
||||
const marker = 'ADMIN-API-20260821123456789'
|
||||
const state = { warehouse: null, quote: null, exchange: null, vehicle: null, optionReads: 0, role: null, user: null }
|
||||
const careRows = new Map(), careHistory = new Map(), uploadKeys = new Map()
|
||||
const context = {
|
||||
marker, calls, coverageGaps, steps, createdResources: {},
|
||||
async findAvailablePhone() { return '13800138000' },
|
||||
@@ -62,6 +72,37 @@ function fakeContext({ softAssertions = false, refreshEmpty = false } = {}) {
|
||||
calls.push({ gateway: 'overseas', method, path, body, extraHeaders, tokenKind: token === 'deny-overseas-token' ? 'denied' : 'admin' })
|
||||
assert.notEqual(method, 'DELETE')
|
||||
if (token === 'deny-overseas-token') return forbidden()
|
||||
if (path.includes('/fleet/vehicles/')) {
|
||||
const url = new URL(path, 'https://fixture.invalid'), parts = url.pathname.split('/records'), owner = parts[0].split('/').at(-1)
|
||||
const suffix = parts[1], record = careRows.get(suffix.split('/')[1])
|
||||
if (record && owner !== record.vehicleId && !careLeak) return { http: 200, body: { code: 'A0404' } }
|
||||
if (method === 'POST' && !suffix) {
|
||||
const prior = [...careRows.values()].find(value => value.key === body.idempotencyKey && value.vehicleId === owner)
|
||||
if (prior) return ok(structuredClone(prior))
|
||||
const created = { recordNo: `VR-${careRows.size + 1}`, vehicleId: owner, details: structuredClone(body.details), files: [], recordVersion: 0, key: body.idempotencyKey,
|
||||
paymentStatus: ['REPAIR', 'RESCUE'].includes(body.details.recordType) ? 'UNLINKED' : null }
|
||||
careRows.set(created.recordNo, created); careHistory.set(created.recordNo, [{ snapshot: structuredClone(created) }]); return ok(structuredClone(created))
|
||||
}
|
||||
if (method === 'GET' && !suffix) {
|
||||
const rows = [...careRows.values()].filter(value => value.vehicleId === owner && (!url.searchParams.get('recordType') || value.details.recordType === url.searchParams.get('recordType')))
|
||||
return ok({ records: structuredClone(rows), total: rows.length })
|
||||
}
|
||||
assert.ok(record)
|
||||
if (method === 'GET' && suffix.endsWith('/history')) return ok({ records: structuredClone(careHistory.get(record.recordNo)) })
|
||||
if (method === 'GET') return ok(structuredClone(record))
|
||||
if (method === 'PUT') {
|
||||
if (Number(body.recordVersion) !== record.recordVersion) return conflict()
|
||||
record.details = structuredClone(body.details); record.recordVersion++
|
||||
if (processingMarksPaid && record.details.incident?.processingStatus === 'COMPLETED') record.paymentStatus = 'PAID'
|
||||
careHistory.get(record.recordNo).unshift({ reason: body.reason, snapshot: structuredClone(record) }); return ok(structuredClone(record))
|
||||
}
|
||||
}
|
||||
if (method === 'POST' && path === `${OVERSEAS_BASE}/vehicles` && body.plateNumber.startsWith('CARE-')) {
|
||||
state.spareVehicle = { ...body, id: '930000000000000002', recordVersion: 0 }; return ok({ ...state.spareVehicle })
|
||||
}
|
||||
if (method === 'PATCH' && path === `${OVERSEAS_BASE}/vehicles/930000000000000002/status`) {
|
||||
state.spareVehicle.status = body.status; state.spareVehicle.recordVersion++; return ok({ ...state.spareVehicle })
|
||||
}
|
||||
if (method === 'GET' && path === `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`) {
|
||||
state.optionReads += 1
|
||||
if (!state.warehouse) return ok([])
|
||||
@@ -194,14 +235,25 @@ function fakeContext({ softAssertions = false, refreshEmpty = false } = {}) {
|
||||
return handler.apply(this,args);
|
||||
},
|
||||
|
||||
async requestMultipart(method, path, file) {
|
||||
calls.push({ gateway: 'overseas', method, path })
|
||||
const url = new URL(path, 'https://fixture.invalid'), number = url.pathname.split('/records/')[1].split('/')[0]
|
||||
const record = careRows.get(number), key = url.searchParams.get('idempotencyKey')
|
||||
assert.equal(file.type, 'image/png')
|
||||
if (uploadKeys.has(key)) return ok(structuredClone(record))
|
||||
assert.equal(Number(url.searchParams.get('recordVersion')), record.recordVersion)
|
||||
record.recordVersion++; record.files.push({ fileId: '990000000000000001' }); uploadKeys.set(key, true)
|
||||
return ok(structuredClone(record))
|
||||
},
|
||||
async requestBinary(method, path) {
|
||||
calls.push({ gateway: 'overseas-binary', method, path })
|
||||
assert.equal(method, 'GET')
|
||||
if (path.includes('/fleet/vehicles/')) return { http: 200, contentType: 'image/png', bytes: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64') }
|
||||
assert.match(path, /\/(?:overseas-quotes|exchange-rates|vehicles)\/export\?/)
|
||||
let csv = ''
|
||||
if (path.includes('/overseas-quotes/')) csv = `报价ID,报价名称,合作仓编码,合作仓名称,报价方,币种,生效开始,生效结束,版本号,状态,更新时间\r\n${state.quote.summary.id},${state.quote.summary.name},${state.quote.summary.warehouseCode},${state.quote.summary.warehouseName},${state.quote.summary.providerName},USD,2098-01-01,2098-12-31,V1,ACTIVE,2026-08-21\r\n`
|
||||
else if (path.includes('/exchange-rates/')) csv = `月份,基准币种,报价币种,月平均汇率,状态,更新人,更新时间\r\n${state.exchange.month},USD,CNY,${state.exchange.usdToCnyAverage},${state.exchange.status},admin,2026-08-21\r\n`
|
||||
else csv = `车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间\r\n${state.vehicle.id},${state.vehicle.plateNumber},${state.vehicle.model},18.75,101,Carrier,123,OVERSEAS,OWNED,${state.vehicle.status},2026-08-21\r\n`
|
||||
else csv = `车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,最大托位,最大载重(kg),可用容积(m³),更新时间\r\n${state.vehicle.plateNumber},${state.vehicle.model},18.75,101,Carrier,123,OVERSEAS,OWNED,${state.vehicle.status},${state.vehicle.maxPalletSpaces},${state.vehicle.maxLoadKg},${state.vehicle.maxVolumeCbm},2026-08-21\r\n`
|
||||
return { method, path, http: 200, body: null, bytes: new TextEncoder().encode(`\uFEFF${csv}`),
|
||||
contentType: 'text/csv;charset=UTF-8', requestId: 'req-csv' }
|
||||
},
|
||||
@@ -209,7 +261,7 @@ function fakeContext({ softAssertions = false, refreshEmpty = false } = {}) {
|
||||
return { context, state }
|
||||
}
|
||||
|
||||
test('静态锁定真实双Gateway生命周期、CSV与安全边界', () => {
|
||||
test('静态锁定统一Gateway主数据生命周期、CSV与安全边界', () => {
|
||||
assert.match(source, /context\.request\('POST', DOMESTIC_BASE/)
|
||||
assert.match(source, /warehouseType: 'OVERSEAS_PARTNER'/)
|
||||
assert.match(source, /context\.request\('POST'/)
|
||||
@@ -233,6 +285,22 @@ test('静态锁定真实双Gateway生命周期、CSV与安全边界', () => {
|
||||
assert.doesNotMatch(source, /Idempotency-Key/)
|
||||
})
|
||||
|
||||
test('API dropping updated vehicle capacity fails even with a correct CSV and cleans up the actor', async () => {
|
||||
const { context } = fakeContext({ softAssertions: true })
|
||||
const request = context.request.bind(context)
|
||||
context.request = async (...args) => {
|
||||
const result = await request(...args)
|
||||
if (args[0] === 'PUT' && /\/vehicles\/\d+$/.test(args[1]) && result.body?.code === '200') {
|
||||
return { ...result, body: { ...result.body, data: { ...result.body.data, maxLoadKg: 18000 } } }
|
||||
}
|
||||
return result
|
||||
}
|
||||
await run(context)
|
||||
assert.equal(context.steps.find(step => step.name === '车辆编辑保持三项实际容量')?.passed, false)
|
||||
assert.equal(context.steps.find(step => step.name === '导出marker车辆CSV表头与marker内容')?.passed, true)
|
||||
assert.equal(context.createdResources.phase4OverseasMasterData.vehicle.finalStatus, 'INACTIVE')
|
||||
})
|
||||
|
||||
test('marker资源完成创建、详情、编辑、冲突、状态、CSV并全部停用', async () => {
|
||||
const { context, state } = fakeContext()
|
||||
await run(context)
|
||||
@@ -253,9 +321,11 @@ test('marker资源完成创建、详情、编辑、冲突、状态、CSV并全
|
||||
assert.equal(state.role.status, 'DISABLED')
|
||||
assert.equal(state.user.status, 'DISABLED')
|
||||
assert.equal(state.optionReads, 4)
|
||||
assert.equal(context.calls.filter((call) => call.gateway === 'overseas-binary').length, 3)
|
||||
assert.equal(context.calls.filter((call) => call.gateway === 'overseas-binary' && call.path.includes('/export?')).length, 3)
|
||||
assert.equal(resource.vehicle.care.status, 'VERIFIED')
|
||||
assert.equal(state.spareVehicle.status, 'INACTIVE')
|
||||
assert.equal(context.calls.filter((call) => call.method === 'DELETE').length, 0)
|
||||
assert.equal(context.calls.filter((call) => call.method === 'PUT').length, 9)
|
||||
assert.equal(context.calls.filter((call) => call.method === 'PUT' && !call.path.includes('/fleet/vehicles/')).length, 9)
|
||||
assert.equal(context.calls.filter((call) => call.tokenKind === 'denied').length, 1)
|
||||
assert.equal(context.coverageGaps.length, 0)
|
||||
})
|
||||
@@ -273,6 +343,24 @@ test('创建前空候选不形成假PASS,刷新后本轮合作仓进入报价
|
||||
assert.equal(context.coverageGaps.length, 0)
|
||||
})
|
||||
|
||||
test('运维记录跨车泄露会阻断并保留证据,仍停用隔离用测试车辆', async () => {
|
||||
const { context, state } = fakeContext({ softAssertions: true, careLeak: true })
|
||||
await run(context)
|
||||
const care = context.createdResources.phase4OverseasMasterData.vehicle.care
|
||||
assert.equal(care.status, 'CHECKING')
|
||||
assert.equal(care.records.length, 6)
|
||||
assert.equal(care.otherVehicleStatus, 'INACTIVE')
|
||||
assert.equal(state.spareVehicle.status, 'INACTIVE')
|
||||
assert.ok(context.steps.some(step => step.name === '其他车辆不能读取或修改本车记录和照片' && !step.passed && step.deploymentBlocker))
|
||||
})
|
||||
|
||||
test('事件处理完成被错误映射为已付款时,真实验收编排必须阻断', async () => {
|
||||
const { context } = fakeContext({ softAssertions: true, processingMarksPaid: true })
|
||||
await run(context)
|
||||
assert.equal(context.createdResources.phase4OverseasMasterData.vehicle.care.status, 'CHECKING')
|
||||
assert.ok(context.steps.some(step => step.name === '处理完成保持独立的费用及付款事实' && !step.passed && step.deploymentBlocker))
|
||||
})
|
||||
|
||||
test('refresh后仍为空时报价前置明确deployment blocker且不产生coverageGap假PASS', async () => {
|
||||
const { context, state } = fakeContext({ softAssertions: true, refreshEmpty: true })
|
||||
await run(context)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { verifyVehicleCare } from './vehicle-care.mjs'
|
||||
|
||||
const MODULE = 'phase4-overseas-master-data'
|
||||
const DOMESTIC_BASE = '/api/platform/v1/warehouse-admin'
|
||||
const OVERSEAS_BASE = '/api/platform/v1/last-leg/master-data'
|
||||
@@ -298,6 +300,7 @@ function vehiclePayload(context, edited = false) {
|
||||
plateNumber: `QA-${suffix}`.slice(0, 64),
|
||||
model: edited ? 'Phase4 QA Truck Edited' : 'Phase4 QA Truck',
|
||||
fuelConsumptionPer100Km: edited ? 18.75 : 18.5,
|
||||
maxPalletSpaces: edited ? 28 : 30, maxLoadKg: edited ? 17500 : 18000, maxVolumeCbm: edited ? 88 : 90,
|
||||
mileageKm: edited ? 101 : 100,
|
||||
driverOrDispatchCompany: `${context.marker} Carrier`, phone: '12135550188',
|
||||
region: 'OVERSEAS', ownership: 'OWNED',
|
||||
@@ -306,6 +309,19 @@ function vehiclePayload(context, edited = false) {
|
||||
}
|
||||
}
|
||||
|
||||
export function vehicleCapacityMatches(actual, expected) {
|
||||
return ['maxPalletSpaces', 'maxLoadKg', 'maxVolumeCbm'].every(key =>
|
||||
['number', 'string'].includes(typeof actual?.[key]) && String(actual[key]).trim() !== ''
|
||||
&& Number.isFinite(Number(actual[key])) && Number(actual[key]) > 0
|
||||
&& Number(actual[key]) === Number(expected[key]))
|
||||
}
|
||||
|
||||
function assertVehicleCapacity(context, name, response, actual, expected) {
|
||||
context.assert(MODULE, name, response, vehicleCapacityMatches(actual, expected),
|
||||
`palletSpaces=${actual?.maxPalletSpaces ?? 'missing'}, loadKg=${actual?.maxLoadKg ?? 'missing'}, volumeCbm=${actual?.maxVolumeCbm ?? 'missing'}`,
|
||||
BLOCKER)
|
||||
}
|
||||
|
||||
async function vehicleLifecycle(context, resource) {
|
||||
const createPayload = vehiclePayload(context)
|
||||
const createResponse = await context.request('POST', `${OVERSEAS_BASE}/vehicles`, createPayload)
|
||||
@@ -313,10 +329,12 @@ async function vehicleLifecycle(context, resource) {
|
||||
const id = asId(vehicle.id)
|
||||
if (!id || !recordVersion(vehicle.recordVersion)) throw new Error('车辆创建响应缺少字符串ID或recordVersion')
|
||||
resource.vehicle = { id, plateNumber: createPayload.plateNumber, recordVersion: Number(vehicle.recordVersion), finalStatus: vehicle.status }
|
||||
assertVehicleCapacity(context, '新建车辆保存三项实际容量', createResponse, vehicle, createPayload)
|
||||
const path = `${OVERSEAS_BASE}/vehicles/${encodeURIComponent(id)}`
|
||||
|
||||
const detailResponse = await context.request('GET', path)
|
||||
vehicle = requireData(context, '查询marker车辆详情', detailResponse)
|
||||
assertVehicleCapacity(context, '车辆详情回显三项实际容量', detailResponse, vehicle, createPayload)
|
||||
context.assert(MODULE, '车辆详情长ID、五态字段与版本', detailResponse,
|
||||
asId(vehicle.id) === id && ['IN_SERVICE', 'DISPATCHED', 'MAINTENANCE', 'INACTIVE', 'RETURNED'].includes(vehicle.status)
|
||||
&& recordVersion(vehicle.recordVersion),
|
||||
@@ -329,6 +347,7 @@ async function vehicleLifecycle(context, resource) {
|
||||
const updatedVersion = Number(vehicle.recordVersion)
|
||||
const updatedMileage = Number(vehicle.mileageKm)
|
||||
resource.vehicle.recordVersion = updatedVersion
|
||||
assertVehicleCapacity(context, '车辆编辑保持三项实际容量', updateResponse, vehicle, updatePayload)
|
||||
context.assert(MODULE, '车辆编辑版本递增', updateResponse,
|
||||
vehicle.model === updatePayload.model && updatedMileage === Number(updatePayload.mileageKm)
|
||||
&& updatedVersion === Number(staleVersion) + 1,
|
||||
@@ -353,14 +372,18 @@ async function vehicleLifecycle(context, resource) {
|
||||
|
||||
const listResponse = await context.request('GET', `${OVERSEAS_BASE}/vehicles?page=1&size=20&keyword=${encodeURIComponent(createPayload.plateNumber)}`)
|
||||
const page = requireData(context, '分页查询marker车辆', listResponse)
|
||||
assertVehicleCapacity(context, '车辆列表回显三项实际容量', listResponse,
|
||||
page?.records?.find(item => asId(item.id) === id), updatePayload)
|
||||
context.assert(MODULE, '车辆列表包含本轮长ID与recordVersion', listResponse,
|
||||
Array.isArray(page?.records) && page.records.some((item) => asId(item.id) === id && recordVersion(item.recordVersion)),
|
||||
`id=${id}, total=${page?.total ?? 'missing'}`, BLOCKER)
|
||||
|
||||
const csv = await context.requestBinary('GET', `${OVERSEAS_BASE}/vehicles/export?keyword=${encodeURIComponent(createPayload.plateNumber)}`)
|
||||
assertBinaryCsv(context, '导出marker车辆CSV', csv,
|
||||
'车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间',
|
||||
[id, createPayload.plateNumber, 'MAINTENANCE'])
|
||||
'车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,最大托位,最大载重(kg),可用容积(m³),更新时间',
|
||||
[createPayload.plateNumber, 'MAINTENANCE', String(updatePayload.maxLoadKg)])
|
||||
resource.vehicle.care = {}
|
||||
await verifyVehicleCare(context, MODULE, vehicle, resource.vehicle.care)
|
||||
}
|
||||
|
||||
async function verifyCanonicalPermissionDenial(context, resource) {
|
||||
|
||||
@@ -125,7 +125,8 @@ test('fixture failure still disables both picker accounts and removes all transi
|
||||
const steps = []
|
||||
const handoff = {
|
||||
marker: 'PDAOPS-CONTRACT',
|
||||
primary: { membershipId: '1', username: 'primary', token: 'primary-secret-token' },
|
||||
primary: { membershipId: '1', username: 'primary', token: 'primary-secret-token',
|
||||
refreshToken: 'primary-secret-refresh', password: 'primary-secret-password', userUpdate: { phone: '13900000001' } },
|
||||
secondary: { membershipId: '2', username: 'secondary', token: 'secondary-secret-token' },
|
||||
merchantToken: 'merchant-secret-token',
|
||||
completedFixtures: [],
|
||||
@@ -144,6 +145,9 @@ test('fixture failure still disables both picker accounts and removes all transi
|
||||
|
||||
assert.deepEqual(cleanups, ['secondary', 'primary'])
|
||||
assert.equal(handoff.primary.token, undefined)
|
||||
assert.equal(handoff.primary.refreshToken, undefined)
|
||||
assert.equal(handoff.primary.password, undefined)
|
||||
assert.equal(handoff.primary.userUpdate, undefined)
|
||||
assert.equal(handoff.secondary.token, undefined)
|
||||
assert.equal(handoff.merchantToken, undefined)
|
||||
assert.equal(context.context.phase4OverseasPdaOperations, undefined)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { verifyReceivedOrderHold } from './phase4-tail-order-controls.mjs'
|
||||
import { verifyPdaWarehouseRevocation } from './unified-pda-session-revocation.mjs'
|
||||
|
||||
const MODULE = 'phase4-overseas-pda-operations'
|
||||
const SCENARIO = 'phase4-overseas-pda-p2-p5'
|
||||
@@ -802,12 +803,17 @@ export async function run(context) {
|
||||
await verifyInventoryMove(context, token, fixture)
|
||||
await verifyPalletOperations(context, token, fixture)
|
||||
await verifyRelabel(context, token, merchantToken, fixture)
|
||||
await verifyPdaWarehouseRevocation(context, handoff.primary)
|
||||
} finally {
|
||||
try {
|
||||
if (typeof handoff?.cleanup === 'function') await handoff.cleanup()
|
||||
} finally {
|
||||
if (handoff?.primary) delete handoff.primary.token
|
||||
if (handoff?.secondary) delete handoff.secondary.token
|
||||
for (const picker of [handoff?.primary, handoff?.secondary]) if (picker) {
|
||||
delete picker.token
|
||||
delete picker.refreshToken
|
||||
delete picker.password
|
||||
delete picker.userUpdate
|
||||
}
|
||||
if (handoff) delete handoff.merchantToken
|
||||
delete context.context.phase4OverseasPdaOperations
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overseas-pda.mjs'
|
||||
import { run as createMerchant } from './merchant-qualification-fixture.mjs'
|
||||
import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip } from './fleet-trip-fulfillment.mjs'
|
||||
|
||||
const MODULE = 'phase5-overseas-fulfillment'
|
||||
const SCENARIO = 'phase5-dispatch-outbound-delivery-exception-visit'
|
||||
@@ -378,7 +379,7 @@ export async function verifyFulfillmentRootStatus(context, { sourceOrderId, cust
|
||||
const rootDelivered = root.data.statusHistory?.some(row => row.action === 'OVERSEAS_DELIVERED') === true
|
||||
const expectedCompleted = rootDelivered && pendingChildren.length === 0
|
||||
const owned = context.createdResources.phase5OverseasFulfillment
|
||||
if (owned) owned.orderCompletionProof = { sourceOrderId, rootOrderId,
|
||||
if (owned) owned.orderCompletionProof = { customerId, sourceOrderId, rootOrderId,
|
||||
tree: { parentOrderId: rootOrderId, parentOrderNo: tree.parentOrderNo, originalPackageCount: Number(tree.originalPackageCount),
|
||||
cumulativeLoadedPackageCount: loaded, currentRemainderPackageCount: remaining,
|
||||
children: rows.map(row => ({ orderId: id(row.orderId), orderNo: row.orderNo, sourceOrderId: id(row.sourceOrderId),
|
||||
@@ -998,6 +999,7 @@ export async function run(context) {
|
||||
const access = await resolvePickerAccess(context)
|
||||
const picker = access ? await createPicker(context, 5, warehouseId, access) : null
|
||||
const dispatchInboundCodes = [...new Set((updated.data?.items || []).map(item => item.inboundCode).filter(nonBlank))]
|
||||
let tripProof
|
||||
if (dispatchInboundCodes.length === 0) {
|
||||
blocker(context, '排车库存数量守恒', '实际排车详情未提供来源入仓码')
|
||||
await disablePicker(context, picker, 5)
|
||||
@@ -1022,6 +1024,7 @@ export async function run(context) {
|
||||
const sourcesBeforeValid = inventorySourcesPresent(stockBeforePick, sourcePositionBalances)
|
||||
context.assert(MODULE, '扫码前库存完整覆盖任务冻结的每个原位置关联', null, sourcesBeforeValid, `positions=${sourcePositionIds.length}`, OPTIONS)
|
||||
if (!sourcesBeforeValid || !ledgerBeforePick) return
|
||||
tripProof = await prepareFleetTrip(context, MODULE, dispatchNo, warehouseId)
|
||||
const pickedSuccessfully = await pickAll(context, picker.token, dispatchNo)
|
||||
const stockAfterPick = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '扫码及重放后库存', sourceBalanceIds)
|
||||
const ledgerAfterPick = await shipmentLedger(context, customerId, dispatchNo, '扫码及重放后SHIP流水', 'SHIP', dispatchNo)
|
||||
@@ -1031,13 +1034,15 @@ export async function run(context) {
|
||||
ledgerBefore: ledgerBeforePick, ledgerAfter: ledgerAfterPick })
|
||||
const shippedRows = conservation.newLedger
|
||||
if (!pickedSuccessfully) return
|
||||
const tripVersion = await confirmFleetLoading(context, MODULE, tripProof)
|
||||
const ready = await verifyDepartureReadiness(context, picker.token, dispatchNo)
|
||||
if (!ready) return
|
||||
const departBody = { idempotencyKey: key(context, 'DISPATCH-DEPART'), remark: `${context.marker}-depart` }
|
||||
const departBody = { recordVersion: tripVersion, idempotencyKey: key(context, 'DISPATCH-DEPART').slice(0, 128), remark: `${context.marker}-depart` }
|
||||
const departed = await pda(context, picker.token, 'POST', 'PDA确认车辆发车并完成真实出库',
|
||||
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/depart`, departBody)
|
||||
context.assert(MODULE, '发车推进排车DEPARTED并返回实发数量', departed.response,
|
||||
departed.data?.status === 'DEPARTED' && Number(departed.data?.shippedPackages) > 0,
|
||||
departed.data?.status === 'IN_TRANSIT' && departed.data?.tripNo === tripProof.tripNo
|
||||
&& departed.data?.dispatches?.some(item => item.dispatchNo === dispatchNo) && Number(departed.data?.shippedPackages) > 0,
|
||||
trace(departed.response), OPTIONS)
|
||||
if (!success(departed.response)) return
|
||||
const departReplay = await pda(context, picker.token, 'POST', 'PDA发车同键重放',
|
||||
@@ -1226,6 +1231,7 @@ export async function run(context) {
|
||||
const returnReceipt = await completeReturnedException(context, { customerId, warehouseId,
|
||||
exceptionNo: returnExceptionNo, outboundNo })
|
||||
context.createdResources.phase5OverseasFulfillment.returnReceipt = returnReceipt
|
||||
context.createdResources.phase5OverseasFulfillment.tripClosure = await finishFleetTrip(context, MODULE, tripProof)
|
||||
context.createdResources.phase5OverseasFulfillment.finalStatus = returnReceipt
|
||||
? 'RETURNED_WITH_COMPLETED_VISIT' : 'RETURN_PENDING_WITH_COMPLETED_VISIT'
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { fulfillmentHistorySnapshot, validTwoFragmentTree, validSignatureGate,
|
||||
completionCounts, exactCompletionOutboxProof, resolveF40MerchantCredentials,
|
||||
f40TransferBody, f40LocalDateTime, validFragmentCabinetIdentity, validUnloadedRemainder,
|
||||
validRemainderCancellationGate, exactCancelledRemainderOutboxProof, cancelUnloadedRemainder,
|
||||
verifyRootProjections, validFreshReceiptSave, preserveReceivingEvidence, run } from './phase5-root-fulfillment.mjs'
|
||||
verifyRootProjections, verifyRemainderTaxIdentity, validFreshReceiptSave, preserveReceivingEvidence, run } from './phase5-root-fulfillment.mjs'
|
||||
import { loadOneFragment } from './phase3-order-splitting.mjs'
|
||||
import { verifyPalletLabels } from './phase3-loading-finalize.mjs'
|
||||
import { awaitProducerConsumption, convertReceivingContainers, runReceiving,
|
||||
@@ -32,6 +32,50 @@ const fixture = () => ({steps:[],token:'offline',marker:'offline-only',createdRe
|
||||
addCoverageGap(){},apiSuccess(_module,_label,response){return response?.body?.data},
|
||||
})
|
||||
|
||||
function remainderTaxFixture(taxMode) {
|
||||
const context=fixture(),calls=[]
|
||||
const evidence={taxMode,customerId:'90',rootOrderId:'100',childOrderId:'101',rootOrderNo:'ORDER-ROOT',
|
||||
rawChild:{id:'101',tenantId:'90',taxMode},rootInbound:{orderId:'100',taxMode},
|
||||
childInbound:{orderId:'101',tenantId:'90',taxMode}}
|
||||
const rows=[{orderId:'100',tenantId:'90',taxMode,relation:{role:'PARENT',rootOrderId:'100'}},
|
||||
{orderId:'101',tenantId:'90',taxMode,relation:{role:'CHILD',rootOrderId:'100',parentOrderId:'100'}}]
|
||||
context.request=async(method,path,body,token)=>{
|
||||
calls.push({method,path,body,token})
|
||||
const filter=new URL(path,'https://offline.invalid').searchParams.get('taxMode')
|
||||
const records=filter&&filter!==taxMode?[]:structuredClone(rows)
|
||||
return {http:200,body:{code:'200',data:{records,total:records.length}}}
|
||||
}
|
||||
return {context,evidence,calls,rows}
|
||||
}
|
||||
test('raw remainder and actual inbound summary taxMode agree for BUY and TAX and customer filters retain exact parent/child',async()=>{
|
||||
for(const mode of ['BUY_DECLARATION','TAX_REFUND']){
|
||||
const f=remainderTaxFixture(mode)
|
||||
await verifyRemainderTaxIdentity(f.context,f.evidence,'own-customer')
|
||||
assert.equal(f.calls.length,3)
|
||||
assert.ok(f.calls.every(c=>c.method==='GET'&&c.token==='own-customer'&&!c.path.includes('/customs')))
|
||||
assert.deepEqual(f.evidence.matchingTax.records.map(r=>r.orderId),['100','101'])
|
||||
assert.equal(f.evidence.otherTax.total,0)
|
||||
assert.match(f.calls[1].path,new RegExp('taxMode='+mode))
|
||||
}
|
||||
})
|
||||
test('missing or wrong raw remainder taxMode is rejected before customer/customs calls despite valid inbound projection',async()=>{
|
||||
for(const mode of ['BUY_DECLARATION','TAX_REFUND'])for(const actual of [null,undefined,mode==='BUY_DECLARATION'?'TAX_REFUND':'BUY_DECLARATION']){
|
||||
const f=remainderTaxFixture(mode);f.evidence.rawChild.taxMode=actual
|
||||
await assert.rejects(verifyRemainderTaxIdentity(f.context,f.evidence,'own-customer'),/原始余量子订单继承/)
|
||||
assert.equal(f.calls.length,0)
|
||||
}
|
||||
})
|
||||
test('wrong inbound tax, missing filtered child, foreign customer or wrong parent identity cannot pass',async()=>{
|
||||
for(const mutate of [f=>f.evidence.childInbound.taxMode=null,f=>f.rows.pop(),f=>f.rows[1].tenantId='91',
|
||||
f=>f.rows[1].relation.parentOrderId='999',f=>f.rows[1].taxMode='BUY_DECLARATION']) {
|
||||
const f=remainderTaxFixture('TAX_REFUND');mutate(f)
|
||||
await assert.rejects(verifyRemainderTaxIdentity(f.context,f.evidence,'own-customer'))
|
||||
}
|
||||
const f=remainderTaxFixture('TAX_REFUND'),original=f.context.request
|
||||
f.context.request=async(...args)=>{const r=await original(...args);if(args[1].includes('taxMode=BUY_DECLARATION'))r.body.data={records:[f.rows[1]],total:1};return r}
|
||||
await assert.rejects(verifyRemainderTaxIdentity(f.context,f.evidence,'own-customer'),/otherTax/)
|
||||
})
|
||||
|
||||
test('F40 unified projections require exact root and customer session with final COMPLETED or unfinished DELIVERED',async()=>{
|
||||
for (const completed of [true,false]) {
|
||||
const context=fixture(),calls=[],snapshot={root:{status:completed?'COMPLETED':'TRANSPORTING',histories:history('OVERSEAS_DELIVERED')}}
|
||||
@@ -232,7 +276,7 @@ test('exported consumer polling and conversion fail closed when real producer co
|
||||
test('exported receiving rejects unmatched current producer instead of borrowing any history container',async() => {
|
||||
const context=fixture();context.request=async(_method,path) => ({http:200,body:{code:200,data:
|
||||
path.endsWith('/candidates')?[{containerNo:'ANOTHER',sourceContainerBookingId:'999',totalPackages:1}]:null}})
|
||||
await runReceiving(context,'offline-pda',null,null,{containerNo:'EXPECTED',containerBookingId:'100'},false)
|
||||
await runReceiving(context,'offline-pda',null,null,{containerNo:'EXPECTED',containerBookingId:'100'},false,{attempts:1})
|
||||
assert.equal(context.steps.some(step => step.passed === false),true)
|
||||
})
|
||||
test('exported receiving warehouse resolver continues to choose only configured active official warehouse',async() => {
|
||||
|
||||
@@ -6,6 +6,8 @@ import { awaitProducerConsumption, convertReceivingContainers, runReceiving, res
|
||||
resolvePickerAccess, createPicker, disablePicker } from './phase4-overseas-pda.mjs'
|
||||
import { pickAll, verifyDepartureReadiness, saveReceiptAndComplete } from './phase5-overseas-fulfillment.mjs'
|
||||
import { prepareTerminalDrayage } from './phase5-terminal-drayage.mjs'
|
||||
import { prepareTaxCustoms, verifyTaxDocumentsAfterCompletion } from './phase5-root-tax-customs.mjs'
|
||||
import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip } from './fleet-trip-fulfillment.mjs'
|
||||
|
||||
const MODULE = 'phase5-root-fulfillment'
|
||||
const OPTIONS = { scenario: 'f40-real-root-and-remainder-signatures', deploymentBlocker: true }
|
||||
@@ -18,6 +20,7 @@ const query = (path, values) => `${path}?${new URLSearchParams(values)}`
|
||||
export function f40LocalDateTime(value) { return value.toISOString().slice(0,19) }
|
||||
export function preserveReceivingEvidence(proof, scope) {
|
||||
proof.receivingCompletionEvidence = scope.createdResources?.phase4OverseasPda?.receivingCompletionEvidence || {}
|
||||
proof.receivingCandidateEvidence = scope.createdResources?.phase4OverseasPda?.candidateWaitEvidence || []
|
||||
}
|
||||
export function validFreshReceiptSave(before, after) {
|
||||
const previousVersion = Number(before?.documents?.version)
|
||||
@@ -234,6 +237,36 @@ export async function verifyRootProjections(context, customerId, rootOrderId, me
|
||||
&& customer.summary.mainStatus === platform.summary.mainStatus,'exact-root-platform-customer-status')
|
||||
return {platform:platform.summary,customer:customer.summary}
|
||||
}
|
||||
export async function verifyRemainderTaxIdentity(context, evidence, merchantToken) {
|
||||
const {taxMode,customerId,rootOrderId,childOrderId,rootOrderNo,rawChild,rootInbound,childInbound}=evidence
|
||||
check(context,'F40 余量税制核对范围完整',['BUY_DECLARATION','TAX_REFUND'].includes(taxMode)
|
||||
&& Boolean(rootOrderNo && customerId && rootOrderId && childOrderId),'exact-current-remainder-scope')
|
||||
check(context,'F40 原始余量子订单继承本轮税制',rawChild?.taxMode===taxMode
|
||||
&& id(rawChild.id)===childOrderId && id(rawChild.tenantId)===customerId,
|
||||
`expected=${taxMode}, actual=${rawChild?.taxMode || 'missing'}`)
|
||||
check(context,'F40 根与余量入仓摘要税制和来源身份一致',rootInbound?.taxMode===taxMode
|
||||
&& childInbound?.taxMode===taxMode && id(rootInbound.orderId)===rootOrderId
|
||||
&& id(childInbound.orderId)===childOrderId && id(childInbound.tenantId)===customerId,
|
||||
`rootTax=${rootInbound?.taxMode || 'missing'}, childTax=${childInbound?.taxMode || 'missing'}`)
|
||||
const exactRows = page => Array.isArray(page?.records) && page.records.length===2 && Number(page.total)===2
|
||||
&& new Set(page.records.map(row=>id(row.orderId))).size===2
|
||||
&& page.records.every(row=>[rootOrderId,childOrderId].includes(id(row.orderId))
|
||||
&& id(row.tenantId)===customerId && row.taxMode===taxMode
|
||||
&& id(row.relation?.rootOrderId)===rootOrderId
|
||||
&& (id(row.orderId)===rootOrderId?row.relation?.role==='PARENT'
|
||||
:row.relation?.role==='CHILD'&&id(row.relation?.parentOrderId)===rootOrderId))
|
||||
for(const [name,filter] of [['unfiltered',null],['matchingTax',taxMode],
|
||||
['otherTax',taxMode==='TAX_REFUND'?'BUY_DECLARATION':'TAX_REFUND']]) {
|
||||
const values={orderNo:rootOrderNo,page:'1',size:'200'}
|
||||
if(filter)values.taxMode=filter
|
||||
evidence[name]=await api(context,'GET',`F40 客户统一订单税制筛选对照${name}`,
|
||||
query('/api/merchant/v1/orders',values),undefined,merchantToken)
|
||||
check(context,`F40 客户筛选${name}精确覆盖本轮父子身份`,name==='otherTax'
|
||||
? Array.isArray(evidence[name]?.records)&&evidence[name].records.length===0&&Number(evidence[name].total)===0
|
||||
: exactRows(evidence[name]),`taxMode=${filter || 'unfiltered'}`)
|
||||
}
|
||||
return evidence
|
||||
}
|
||||
export async function cancelUnloadedRemainder(context, proof, customerId, rootOrderId, childOrderId,
|
||||
rootInboundId, childInboundId) {
|
||||
proof.currentStage = 'REMAINDER_CANCEL_PRECONDITION'
|
||||
@@ -337,10 +370,12 @@ async function fragment(context, source, rootOrderId, sequence) {
|
||||
}
|
||||
context.createdResources.phase3Loading = loading
|
||||
context.createdResources.orders = { ...(context.createdResources.orders || {}), ...loading,
|
||||
taxMode: 'BUY_DECLARATION' }
|
||||
taxMode: context.context.f40TaxMode }
|
||||
const palletLabelManifest = await verifyPalletLabels(context, loading, result.planId)
|
||||
check(context, 'F40 来源柜散货托盘码来自真实准备接口', Boolean(palletLabelManifest?.totalCount), `sequence=${sequence}`)
|
||||
for (const name of ['phase3-customs', 'service-center-flow', 'phase3-port-schedules',
|
||||
if(context.context.f40TaxMode==='TAX_REFUND') await prepareTaxCustoms(context,loading)
|
||||
else await stage(context,'phase3-customs')
|
||||
for (const name of ['service-center-flow', 'phase3-port-schedules',
|
||||
'phase3-outbound-dispatch', 'phase3-container-documents', 'phase3-carrier-schedule-queries']) await stage(context, name)
|
||||
const nodes = await import('./phase4-carrier-nodes.mjs')
|
||||
await nodes.runUntilPickupReady(context)
|
||||
@@ -379,11 +414,14 @@ async function sendFragment(context, producer, picker, merchantToken, index) {
|
||||
&& detail.skus?.length > 0 && detail.skus.every(row => row.inboundCode === producer.inboundCode)
|
||||
&& detail.skus.reduce((sum,row) => sum + Number(row.packageCount),0) === 1, `source=${producer.inboundCode}`)
|
||||
context.marker = `${context.context.f40BaseMarker}-SEND-${index}`
|
||||
const tripProof = await prepareFleetTrip(context, MODULE, dispatchNo, picker.warehouseId)
|
||||
check(context, 'F40 正式 PDA 拣货完成', await pickAll(context, picker.token, dispatchNo), `dispatch=${dispatchNo}`)
|
||||
const tripVersion = await confirmFleetLoading(context, MODULE, tripProof)
|
||||
check(context, 'F40 发车前置就绪', await verifyDepartureReadiness(context, picker.token, dispatchNo), `dispatch=${dispatchNo}`)
|
||||
const departed = await api(context, 'POST', 'F40 正式 PDA 发车', `${PDA}/${dispatchNo}/depart`,
|
||||
{ idempotencyKey: `${context.marker}-DEPART`, remark: context.marker }, picker.token)
|
||||
check(context, 'F40 正式发车已 DEPARTED', departed.status === 'DEPARTED', `dispatch=${dispatchNo}`)
|
||||
{ recordVersion: tripVersion, idempotencyKey: `${context.marker}-DEPART`.slice(0,128), remark: context.marker }, picker.token)
|
||||
check(context, 'F40 整车发车包含本片排车', departed.status === 'IN_TRANSIT' && departed.tripNo === tripProof.tripNo
|
||||
&& departed.dispatches?.some(item => item.dispatchNo === dispatchNo), `dispatch=${dispatchNo}`)
|
||||
const outbound = await api(context, 'GET', '回查实际来源出库完成',
|
||||
query(`/api/platform/v1/last-leg/outbound-orders/${transfer.outboundNo}`, { page:'1',size:'20' }))
|
||||
check(context,'F40 实际出库单 OUTBOUNDED 且来源入仓码匹配', outbound.outbound?.status === 'OUTBOUNDED'
|
||||
@@ -397,8 +435,9 @@ async function sendFragment(context, producer, picker, merchantToken, index) {
|
||||
const completed = await saveReceiptAndComplete(context, deliveries[0], customerId, index)
|
||||
check(context, 'F40 实际签收资料驱动派送完成', completed?.order?.status === 'DELIVERED', `delivery=${deliveries[0].deliveryNo}`)
|
||||
check(context,'F40 签收完成记录真实签收时间', Boolean(completed.order.signedAt), `delivery=${deliveries[0].deliveryNo}`)
|
||||
const tripClosure = await finishFleetTrip(context, MODULE, tripProof)
|
||||
return { producer, dispatchNo, outboundNo: transfer.outboundNo, deliveryNo: deliveries[0].deliveryNo,
|
||||
receiptVersion: completed.order.version, signedAt: completed.order.signedAt, receiptSnapshot:completed }
|
||||
receiptVersion: completed.order.version, signedAt: completed.order.signedAt, receiptSnapshot:completed, tripNo: tripProof.tripNo, tripClosure }
|
||||
}
|
||||
|
||||
/** Explicit TEST orchestrator, called by the parent acceptance context; never synthesizes an event. */
|
||||
@@ -426,11 +465,14 @@ async function runRootFulfillment(context) {
|
||||
['ROOT_FIRST','CHILD_FIRST','ROOT_SIGNED_REMAINDER_CANCELLED'].includes(signatureOrder), signatureOrder)
|
||||
const cancelRemainder = signatureOrder === 'ROOT_SIGNED_REMAINDER_CANCELLED'
|
||||
proof.signatureOrder = signatureOrder
|
||||
const taxMode=context.context?.f40TaxMode||'BUY_DECLARATION'
|
||||
check(context,'F40 税制必须为显式支持值',['BUY_DECLARATION','TAX_REFUND'].includes(taxMode),taxMode)
|
||||
proof.taxMode=taxMode;proof.taxCustoms=[]
|
||||
const scope = Object.create(context)
|
||||
scope.marker = `${context.marker}-F40`
|
||||
scope.context = { ...context.context, orderDestinationType: 'FBA', orderFulfillmentType: 'STANDARD',
|
||||
orderStopAtApproved:cancelRemainder,
|
||||
orderProfile: { taxMode: 'BUY_DECLARATION', fulfillmentType: 'STANDARD' },
|
||||
orderProfile: { taxMode, fulfillmentType: 'STANDARD' },f40TaxMode:taxMode,f40TaxEvidence:proof.taxCustoms,
|
||||
waitSplitVariant: { skuCount: 1, packageCountPerSku: 2, packageCounts: [2] },
|
||||
phase4OverseasPdaOperations: { completedFixtures: [] },
|
||||
allowAnyExistingPallet: true, f40BaseMarker: `${context.marker}-F40` }
|
||||
@@ -443,6 +485,7 @@ async function runRootFulfillment(context) {
|
||||
const profile = await api(scope,'GET','F40 本轮客户会话归属核验',
|
||||
'/api/merchant/v1/iam/tenant/profile',undefined,login.accessToken)
|
||||
const expectedCustomerId = id(profile.id)
|
||||
if(taxMode==='TAX_REFUND')scope.context.f40TaxMerchantToken=login.accessToken
|
||||
check(scope,'F40 客户会话和本轮客户 fixture 一致', Boolean(expectedCustomerId)
|
||||
&& (!credentials.customerId || credentials.customerId === expectedCustomerId),'exact-fixture-customer')
|
||||
await domesticLogin(scope)
|
||||
@@ -490,6 +533,9 @@ async function runRootFulfillment(context) {
|
||||
check(scope, 'F40 子单有唯一独立真实入仓来源', childRows.length === 1, `sources=${childRows.length}`)
|
||||
const childInbound = await api(scope, 'GET', '读取子单可装柜来源',
|
||||
query(`/api/platform/inbounds/${childRows[0].id}`, { tenantId: customerId }))
|
||||
proof.remainderTaxIdentity = {taxMode,customerId,rootOrderId,childOrderId,rootOrderNo:orderFixture.orderNo,
|
||||
rawChild:childDetail.order,rootInbound:rootInbound.summary,childInbound:childInbound.summary}
|
||||
await verifyRemainderTaxIdentity(scope,proof.remainderTaxIdentity,login.accessToken)
|
||||
let second = null
|
||||
if (cancelRemainder) {
|
||||
check(scope,'F40 取消专项真实余量未装且原始 APPROVED',
|
||||
@@ -628,6 +674,7 @@ async function runRootFulfillment(context) {
|
||||
'exact-root/source/customer/readOnly-proof')
|
||||
check(scope,'F40 新键重复签收完成Outbox数量与发布状态零漂移',
|
||||
outboxCounts(proof.beforeReceiptReplayOutbox) === outboxCounts(proof.outboxProof),'before-after-receipt-replay-outbox')
|
||||
if(taxMode==='TAX_REFUND')await verifyTaxDocumentsAfterCompletion(scope,proof.taxCustoms,login.accessToken)
|
||||
proof.finalStatus='VERIFIED'
|
||||
} else {
|
||||
proof.finalStatus='HTTP_VERIFIED_OUTBOX_PENDING'
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {createHash} from 'node:crypto'
|
||||
import {readFileSync} from 'node:fs'
|
||||
import {prepareTaxCustoms,verifyTaxDocumentsAfterCompletion,taxDocumentMatches} from './phase5-root-tax-customs.mjs'
|
||||
const loading={tenantId:'90',orderId:'100',inboundId:'200',loadingPlanId:'300',containerBookingId:'400',inboundCode:'IN-TAX',containerNo:'TAX-BOX'}
|
||||
function fixture(){
|
||||
const calls=[],files={},evidence=[],view={version:0,status:'READY_TO_START',loadingPlanId:'300',containerBookingId:'400',
|
||||
inbounds:[{inboundId:'200',taxMode:'TAX_REFUND'}],documents:[]}
|
||||
const wrap=data=>({http:200,body:{code:'200',data:structuredClone(data)}})
|
||||
const context={marker:'OFFLINE-TAX',token:'platform',createdResources:{},context:{f40TaxMerchantToken:'customer',f40TaxEvidence:evidence},
|
||||
assert:(_m,name,_r,passed)=>{assert.equal(Boolean(passed),true,name)},apiSuccess:(_m,_n,r)=>r.body?.data,
|
||||
request:async(method,path,body,token)=>{
|
||||
calls.push({method,path,body,token})
|
||||
if(path==='/api/merchant/v1/orders/100')return wrap({summary:{orderId:'100',mainStatus:'COMPLETED',taxMode:'TAX_REFUND'}})
|
||||
if(method==='GET')return wrap(view)
|
||||
if(path.endsWith('/start')){view.status='DECLARING';view.documents[0].frozen=true}
|
||||
if(path.endsWith('/receipt/confirm'))view.documents[0].receiptStatus='CONFIRMED'
|
||||
if(path.endsWith('/declaration-batches'))return wrap({id:'500',version:0,status:'READY'})
|
||||
if(path.endsWith('/500/submit'))return wrap({version:1,status:'SUBMITTED'})
|
||||
if(path.endsWith('/500/results'))return wrap({resultType:'RELEASED',resultStatus:'RESOLVED'})
|
||||
view.version++;return wrap(view)
|
||||
},
|
||||
requestMultipart:async(_method,path,file,token)=>{
|
||||
calls.push({method:'UPLOAD',path,token})
|
||||
const kind=path.includes('/receipt/')?'receipt':path.includes('/final/')?'final':'source';files[kind]=file.content
|
||||
const metadata={fileHash:createHash('sha256').update(file.content).digest('hex'),version:1}
|
||||
if(kind==='source')view.documents.push({documentId:'600',tenantId:'90',inboundIds:['200'],source:metadata,frozen:false})
|
||||
else view.documents[0][kind==='final'?'finalDocument':kind]=metadata
|
||||
if(kind==='final')view.status='COMPLETED'
|
||||
return wrap(view.documents[0])
|
||||
},
|
||||
async requestBinary(_method,path){assert.equal(this.token,'customer');const kind=path.includes('/receipt/')?'receipt':path.includes('/final/')?'final':'source';return {http:200,bytes:files[kind]}},
|
||||
}
|
||||
return {context,calls,files,evidence,view}
|
||||
}
|
||||
test('tax F40 uses actual upload confirm declaration release and final before post-COMPLETED customer downloads',async()=>{
|
||||
const f=fixture();const proof=await prepareTaxCustoms(f.context,loading)
|
||||
assert.equal(proof.status,'CUSTOMS_COMPLETED')
|
||||
await verifyTaxDocumentsAfterCompletion(f.context,f.evidence,'customer')
|
||||
assert.equal(proof.status,'ORDER_COMPLETED_CUSTOMS_READ_DOWNLOAD_VERIFIED')
|
||||
assert.deepEqual(proof.downloads.map(x=>x.kind),['source','receipt','final'])
|
||||
const paths=f.calls.filter(c=>c.method!=='GET').map(c=>c.path)
|
||||
let last=-1
|
||||
for(const suffix of ['/documents/upload?inboundIds=200','/start','/send-to-broker','/receipt/upload?expectedVersion=0',
|
||||
'/receipt/confirm','/submit-to-broker','/declaration-batches','/500/submit','/500/results','/final/upload?expectedVersion=0']){
|
||||
const index=paths.findIndex((p,i)=>i>last&&p.endsWith(suffix));assert.ok(index>last,suffix);last=index
|
||||
}
|
||||
assert.equal(f.calls.find(c=>c.path.endsWith('/receipt/confirm')).token,'customer')
|
||||
assert.equal(JSON.stringify(proof).includes('customer'),true) // customerId evidence, never session token field
|
||||
assert.equal(Object.hasOwn(proof,'token'),false)
|
||||
assert.doesNotMatch(JSON.stringify(proof),/"(?:bytes|password|accessToken|refreshToken|authorization)"\s*:/i)
|
||||
})
|
||||
test('customer tax evidence rejects changed customer, source, plan, file bytes and still-uncompleted order',async()=>{
|
||||
const f=fixture(),proof=await prepareTaxCustoms(f.context,loading)
|
||||
for(const mutate of [v=>v.status='DECLARING',v=>v.loadingPlanId='999',v=>v.documents[0].tenantId='91',
|
||||
v=>v.documents[0].inboundIds=['201'],v=>v.documents[0].finalDocument.fileHash='changed']){
|
||||
const v=structuredClone(f.view);mutate(v);assert.equal(taxDocumentMatches(v,proof),false)
|
||||
}
|
||||
f.files.final=new TextEncoder().encode('incorrect bytes')
|
||||
await assert.rejects(verifyTaxDocumentsAfterCompletion(f.context,[proof],'customer'),/final字节/)
|
||||
})
|
||||
test('buy remains the default; tax path is explicit and final verification occurs after Outbox proof',()=>{
|
||||
const source=readFileSync(new URL('./phase5-root-fulfillment.mjs',import.meta.url),'utf8')
|
||||
assert.match(source,/f40TaxMode\|\|'BUY_DECLARATION'/)
|
||||
assert.match(source,/f40TaxMode==='TAX_REFUND'\) await prepareTaxCustoms/)
|
||||
assert.match(source,/else await stage\(context,'phase3-customs'\)/)
|
||||
assert.ok(source.indexOf('await verifyTaxDocumentsAfterCompletion')>source.indexOf('proof.outboxProof ='))
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
const MODULE='phase5-root-fulfillment',OPTIONS={scenario:'f40-tax-customs-after-completion',deploymentBlocker:true}
|
||||
const PLATFORM='/api/platform/v1/customs/loading-plans',MERCHANT='/api/merchant/v1/customs'
|
||||
const id=value=>String(value??'')
|
||||
const sha=bytes=>createHash('sha256').update(bytes).digest('hex')
|
||||
function check(context,name,passed,response=null){
|
||||
context.assert(MODULE,name,response,Boolean(passed),'exact-current-tax-source',OPTIONS)
|
||||
if(!passed)throw Error(name)
|
||||
}
|
||||
async function api(context,method,name,path,body,token=context.token,key){
|
||||
const response=await context.request(method,path,body,token,key?{'Idempotency-Key':key}:{})
|
||||
const data=context.apiSuccess(MODULE,name,response,'',OPTIONS)
|
||||
check(context,name,response?.http===200&&String(response.body?.code)==='200',response)
|
||||
return data
|
||||
}
|
||||
export function taxDocumentMatches(view,proof){
|
||||
const documents=view?.documents||[]
|
||||
const document=documents.find(row=>id(row.documentId)===proof.documentId)
|
||||
return view?.status==='COMPLETED'&&id(view.loadingPlanId)===proof.loadingPlanId
|
||||
&&id(view.containerBookingId)===proof.containerBookingId&&document?.frozen===true
|
||||
&&id(document.tenantId)===proof.customerId&&document.inboundIds?.map(id).includes(proof.inboundId)
|
||||
&&document.source?.fileHash===proof.files.source.sha256&&document.receiptStatus==='CONFIRMED'
|
||||
&&document.receipt?.fileHash===proof.files.receipt.sha256&&document.finalDocument?.fileHash===proof.files.final.sha256
|
||||
}
|
||||
export async function prepareTaxCustoms(context,loading){
|
||||
const proof={customerId:id(loading.tenantId),orderId:id(loading.orderId),inboundId:id(loading.inboundId),
|
||||
inboundCode:loading.inboundCode,containerBookingId:id(loading.containerBookingId),loadingPlanId:id(loading.loadingPlanId),
|
||||
containerNo:loading.containerNo,files:{},stages:[],status:'PREPARING'}
|
||||
context.context.f40TaxEvidence.push(proof)
|
||||
const token=context.context.f40TaxMerchantToken
|
||||
check(context,'F40 退税报关使用本轮客户会话',Boolean(token))
|
||||
const base=`${PLATFORM}/${proof.loadingPlanId}`
|
||||
const key=action=>`${context.marker}-F40-TAX-${action}`.slice(0,128)
|
||||
const detail=()=>api(context,'GET','F40 回查真实退税报关聚合',base)
|
||||
const upload=async(kind,path,token)=>{
|
||||
const content=new TextEncoder().encode(`${context.marker} TAX_REFUND ${proof.inboundCode} ${kind}`)
|
||||
proof.files[kind]={sha256:sha(content),byteCount:content.length}
|
||||
const response=await context.requestMultipart('POST',path,{name:`${context.marker}-${kind}.txt`,type:'text/plain',content},token,{'Idempotency-Key':key(kind)})
|
||||
const data=context.apiSuccess(MODULE,`F40 真实上传退税${kind}文件`,response,'',OPTIONS)
|
||||
check(context,`F40 退税${kind}上传成功`,response.http===200&&String(response.body?.code)==='200',response)
|
||||
return data
|
||||
}
|
||||
let current=await detail()
|
||||
check(context,'F40 实装来源确为本客户退税入仓码',current.inbounds?.length===1&¤t.inbounds.every(row=>
|
||||
row.taxMode==='TAX_REFUND'&&id(row.inboundId)===proof.inboundId))
|
||||
const source=await upload('source',`${MERCHANT}/loading-plans/${proof.loadingPlanId}/documents/upload?inboundIds=${proof.inboundId}`,token)
|
||||
proof.documentId=id(source.documentId)
|
||||
check(context,'F40 客户原始报关文件精确绑定本票',Boolean(proof.documentId)&&source.source?.fileHash===proof.files.source.sha256
|
||||
&&id(source.tenantId)===proof.customerId&&source.inboundIds?.map(id).includes(proof.inboundId))
|
||||
for(const action of ['start','send-to-broker']){
|
||||
current=await detail()
|
||||
const next=await api(context,'POST',`F40 退税真实${action}`,`${base}/${action}`,
|
||||
{expectedVersion:Number(current.version),confirm:true,remark:context.marker},context.token,key(action))
|
||||
proof.stages.push({action,status:next.status,version:next.version})
|
||||
}
|
||||
const receipt=await upload('receipt',`${PLATFORM}/documents/${proof.documentId}/receipt/upload?expectedVersion=0`,context.token)
|
||||
await api(context,'POST','F40 客户确认本票报关回执',`${MERCHANT}/documents/${proof.documentId}/receipt/confirm`,
|
||||
{expectedVersion:Number(receipt.receipt.version),confirm:true,remark:context.marker},token,key('confirm'))
|
||||
current=await detail()
|
||||
await api(context,'POST','F40 平台发送已确认回执执行报关',`${base}/submit-to-broker`,
|
||||
{expectedVersion:Number(current.version),confirm:true,remark:context.marker},context.token,key('execute'))
|
||||
current=await detail()
|
||||
const batch=await api(context,'POST','F40 人工创建真实正式申报批次',`${base}/declaration-batches`,
|
||||
{expectedVersion:Number(current.version),batchNo:`${context.marker}-DECL`.slice(-64),remark:context.marker},context.token,key('declaration'))
|
||||
proof.declarationBatchId=id(batch.id)
|
||||
const submitted=await api(context,'POST','F40 人工提交正式申报',`${PLATFORM}/declaration-batches/${batch.id}/submit`,
|
||||
{expectedVersion:Number(batch.version),customsEntryNo:`F40TAX-${proof.loadingPlanId}`,remark:context.marker},context.token,key('submit-declaration'))
|
||||
const now=new Date(),local=new Date(now.getTime()-now.getTimezoneOffset()*60000).toISOString().slice(0,19)
|
||||
const release=await api(context,'POST','F40 人工记录海关放行',`${PLATFORM}/declaration-batches/${batch.id}/results`,
|
||||
{expectedVersion:Number(submitted.version),resultType:'RELEASED',occurredAt:local,resolution:'本轮退税验收人工放行',resolved:true},context.token,key('release'))
|
||||
check(context,'F40 真实申报已提交且放行已处理',submitted.status==='SUBMITTED'&&release.resultType==='RELEASED'&&release.resultStatus==='RESOLVED')
|
||||
await upload('final',`${PLATFORM}/documents/${proof.documentId}/final/upload?expectedVersion=0`,context.token)
|
||||
proof.completedCustoms=await detail()
|
||||
check(context,'F40 退税完成文件齐全才完成报关',taxDocumentMatches(proof.completedCustoms,proof))
|
||||
proof.status='CUSTOMS_COMPLETED'
|
||||
context.createdResources.phase3Customs={...loading,parentOrderId:loading.orderId,finalStatus:'CLEARANCE_COMPLETED'}
|
||||
return proof
|
||||
}
|
||||
export async function verifyTaxDocumentsAfterCompletion(context,proofs,merchantToken){
|
||||
check(context,'F40 完成后验收具有真实退税报关证据',proofs.length>0)
|
||||
for(const proof of proofs){
|
||||
const order=await api(context,'GET','F40 客户回查退税原订单最终完成',`/api/merchant/v1/orders/${proof.orderId}`,undefined,merchantToken)
|
||||
check(context,'F40 退税订单最终COMPLETED且税制未变',id(order.summary?.orderId)===proof.orderId&&order.summary.mainStatus==='COMPLETED'&&order.summary.taxMode==='TAX_REFUND')
|
||||
proof.afterCompletedView=await api(context,'GET','F40 COMPLETED后客户仍可读取本票报关资料',`${MERCHANT}/orders/${proof.orderId}`,undefined,merchantToken)
|
||||
check(context,'F40 完成后报关资料客户范围及文件哈希保持一致',taxDocumentMatches(proof.afterCompletedView,proof))
|
||||
proof.downloads=[]
|
||||
const customer=Object.create(context);customer.token=merchantToken
|
||||
for(const [kind,suffix] of [['source','download'],['receipt','receipt/download'],['final','final/download']]){
|
||||
const response=await customer.requestBinary('GET',`${MERCHANT}/documents/${proof.documentId}/${suffix}`)
|
||||
const fact={kind,http:response.http,byteCount:response.bytes?.length||0,sha256:sha(response.bytes||new Uint8Array())}
|
||||
proof.downloads.push(fact)
|
||||
check(context,`F40 COMPLETED后客户真实下载${kind}字节保持一致`,fact.http===200&&fact.byteCount===proof.files[kind].byteCount&&fact.sha256===proof.files[kind].sha256,
|
||||
{...response,bytes:undefined})
|
||||
}
|
||||
proof.status='ORDER_COMPLETED_CUSTOMS_READ_DOWNLOAD_VERIFIED'
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,23 @@
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
|
||||
export async function verifyRevokedSessions(context, reason, sessions, accessPath = '/api/platform/v1/orders/status') {
|
||||
const options = { scenario: 'unified-session-revocation', deploymentBlocker: true }
|
||||
for (const [index, session] of sessions.entries()) {
|
||||
const complete = Boolean(session?.accessToken && session?.refreshToken)
|
||||
context.assert('system', `${reason}第${index + 1}组会话凭据完整`, null, complete,
|
||||
`session=${index + 1}`, options)
|
||||
if (!complete) throw new Error('会话撤销检查缺少完整 Access/Refresh 对')
|
||||
const access = await context.request('GET', accessPath, undefined, session.accessToken)
|
||||
context.assert('system', `${reason}旧Access失效`, access,
|
||||
access.http === 401 && String(access.body?.code) === 'A0401',
|
||||
`session=${index + 1}, HTTP=${access.http}, code=${access.body?.code || 'missing'}`, options)
|
||||
const refresh = await context.request('POST', '/api/auth/refresh', { refreshToken: session.refreshToken }, '')
|
||||
context.assert('system', `${reason}旧Refresh不能恢复会话`, refresh,
|
||||
[200, 401].includes(refresh.http) && String(refresh.body?.code) === 'A0401',
|
||||
`session=${index + 1}, HTTP=${refresh.http}, code=${refresh.body?.code || 'missing'}`, options)
|
||||
}
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const module = 'system'
|
||||
const suffix = context.marker.replace(/\D/g, '').slice(-12)
|
||||
@@ -140,9 +158,12 @@ export async function run(context) {
|
||||
const loginResponse = await context.request('POST', '/api/auth/login', { loginId: username, password: initialPassword }, '')
|
||||
const loginPassed = loginResponse.http === 200 && String(loginResponse.body?.code) === '200' && Boolean(loginResponse.body?.data?.accessToken)
|
||||
context.addStep(module, '新建内部用户可使用初始密码登录', loginResponse, loginPassed, `username=${username}`)
|
||||
const secondLoginResponse = await context.request('POST', '/api/auth/login', { loginId: username, password: initialPassword }, '')
|
||||
const secondLogin = context.apiSuccess(module, '建立同账号第二个独立会话', secondLoginResponse)
|
||||
|
||||
const resetResponse = await context.request('PUT', `/api/platform/v1/iam/users/${membershipId}/password`, { newPassword: resetPassword })
|
||||
context.apiSuccess(module, '重置平台内部用户密码', resetResponse)
|
||||
await verifyRevokedSessions(context, '密码重置撤销多端会话', [loginResponse.body?.data, secondLogin])
|
||||
const resetLoginResponse = await context.request('POST', '/api/auth/login', { loginId: username, password: resetPassword }, '')
|
||||
const resetLoginPassed = resetLoginResponse.http === 200 && String(resetLoginResponse.body?.code) === '200' && Boolean(resetLoginResponse.body?.data?.accessToken)
|
||||
context.addStep(module, '内部用户使用重置密码登录', resetLoginResponse, resetLoginPassed, `username=${username}`)
|
||||
@@ -161,16 +182,20 @@ export async function run(context) {
|
||||
}
|
||||
}
|
||||
|
||||
const clearPermissionResponse = await context.request('PUT', '/api/platform/v1/iam/roles/permissions', { roleId, permissionIds: [] })
|
||||
context.apiSuccess(module, '清空测试角色权限', clearPermissionResponse)
|
||||
await verifyRevokedSessions(context, '角色权限清空撤销已登录会话', [resetLoginResponse.body?.data])
|
||||
const activeLoginResponse = await context.request('POST', '/api/auth/login', { loginId: username, password: resetPassword }, '')
|
||||
const activeLogin = context.apiSuccess(module, '按更新后的权限重新登录', activeLoginResponse)
|
||||
const disableUserResponse = await context.request('PUT', `/api/platform/v1/iam/users/${membershipId}/status`, { status: 'DISABLED' })
|
||||
const disabledUser = context.apiSuccess(module, '停用平台内部用户', disableUserResponse)
|
||||
context.assert(module, '内部用户停用状态正确', disableUserResponse, disabledUser?.status === 'DISABLED', `status=${disabledUser?.status ?? 'missing'}`)
|
||||
await verifyRevokedSessions(context, '账号停用撤销会话', [activeLogin])
|
||||
const disabledLoginResponse = await context.request('POST', '/api/auth/login', { loginId: username, password: resetPassword }, '')
|
||||
context.addStep(module, '停用用户禁止登录', disabledLoginResponse,
|
||||
disabledLoginResponse.http === 200 && String(disabledLoginResponse.body?.code) !== '200',
|
||||
`HTTP=${disabledLoginResponse.http}, code=${disabledLoginResponse.body?.code ?? 'missing'}`)
|
||||
|
||||
const clearPermissionResponse = await context.request('PUT', '/api/platform/v1/iam/roles/permissions', { roleId, permissionIds: [] })
|
||||
context.apiSuccess(module, '清空测试角色权限', clearPermissionResponse)
|
||||
const deleteRoleResponse = await context.request('DELETE', `/api/platform/v1/iam/roles/${roleId}`)
|
||||
context.expectConflict(module, '已分配成员的角色禁止删除', deleteRoleResponse, 'system-role-protection')
|
||||
const finalDisableRoleResponse = await context.request('PUT', `/api/platform/v1/iam/roles/${roleId}/status`, { status: 'DISABLED' })
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { verifyPdaWarehouseRevocation, warehouseClaim } from './unified-pda-session-revocation.mjs'
|
||||
import { pickerSessionHandoff } from './phase4-overseas-pda.mjs'
|
||||
|
||||
const token = warehouse => 'header.' + Buffer.from(JSON.stringify({ overseas_warehouse_id: warehouse })).toString('base64url') + '.signature'
|
||||
test('signed binding identity retains 64-bit decimal precision for numeric and string claims', () => {
|
||||
const make = raw => 'header.' + Buffer.from(raw).toString('base64url') + '.signature'
|
||||
assert.equal(warehouseClaim(make('{"overseas_warehouse_id":193000000000200004}')), '193000000000200004')
|
||||
assert.equal(warehouseClaim(token('193000000000200004')), '193000000000200004')
|
||||
assert.equal(warehouseClaim(make('{"overseas_warehouse_id":null}')), '')
|
||||
assert.equal(warehouseClaim(make('{"overseas_warehouse_id":22,"overseas_warehouse_id":23}')), '')
|
||||
})
|
||||
function fixture(wrongWarehouse = false) {
|
||||
const checks = [], calls = []
|
||||
let cleared = false
|
||||
let isolatedRole
|
||||
const picker = { membershipId: '7', username: 'offline-user', password: 'offline-password',
|
||||
token: 'old-access', refreshToken: 'old-refresh', userUpdate: { displayName: 'offline', phone: '13900000001',
|
||||
email: 'offline@example.invalid', tenantId: '0', roleIds: ['9'], overseasWarehouseId: '11', domesticWarehouseId: null } }
|
||||
const response = (data, code = '200', http = 200) => ({ http, body: { code, data } })
|
||||
const context = { assert: (_m, name, _r, passed, evidence) => checks.push({ name, passed, evidence }),
|
||||
apiSuccess: (_m, _n, r) => r.body.code === '200' ? r.body.data : null,
|
||||
request: async (method, path, body, bearer) => {
|
||||
calls.push({ method, path, body, bearer })
|
||||
if (path.includes('receiving-options')) return response({ records: [{ id: '11' }, { id: '22' }] })
|
||||
if (path === '/api/platform/v1/iam/roles' && method === 'POST') {
|
||||
isolatedRole = { ...body, id: '99', status: 'ACTIVE', permissionIds: [] }
|
||||
return response(isolatedRole)
|
||||
}
|
||||
if (path === '/api/platform/v1/iam/roles') return response([isolatedRole])
|
||||
if (path === '/api/platform/v1/iam/roles/99/status') return response({ status: 'DISABLED' })
|
||||
if (method === 'PUT') {
|
||||
assert.equal(body.phone, picker.userUpdate.phone)
|
||||
assert.equal(body.email, picker.userUpdate.email)
|
||||
assert.ok(body.roleIds.length > 0, 'IAM user edits require a role')
|
||||
cleared = body.roleIds.includes('99')
|
||||
return response({ ...body })
|
||||
}
|
||||
if (path === '/api/auth/login') return response({ accessToken: token(cleared ? null : wrongWarehouse ? '11' : '22'), refreshToken: 'new-refresh' })
|
||||
if (path === '/api/auth/refresh') {
|
||||
assert.equal(bearer, '')
|
||||
return response(null, 'A0401')
|
||||
}
|
||||
if (bearer === 'old-access' || (cleared && bearer === token('22'))) return response(null, 'A0401', 401)
|
||||
return cleared ? response(null, 'A0403', 403) : response(null, 'A0404')
|
||||
} }
|
||||
return { context, picker: pickerSessionHandoff(picker), checks, calls }
|
||||
}
|
||||
test('binding changes revoke both tokens and role/binding clearing prevents fresh PDA access', async () => {
|
||||
const { context, picker, checks, calls } = fixture()
|
||||
await verifyPdaWarehouseRevocation(context, picker)
|
||||
for (const name of ['PDA仓绑定变更旧Access失效', 'PDA仓绑定变更旧Refresh不能恢复会话',
|
||||
'新PDA会话只携带服务器签发的新仓绑定', 'PDA角色与仓绑定清空旧Access失效',
|
||||
'PDA角色与仓绑定清空旧Refresh不能恢复会话', '无作业仓绑定的新会话不得进入PDA']) {
|
||||
assert.equal(checks.find(row => row.name === name)?.passed, true, name)
|
||||
}
|
||||
assert.ok(checks.every(row => row.passed))
|
||||
assert.ok(checks.every(row => !/offline-password|old-refresh|new-refresh/.test(row.evidence || '')))
|
||||
assert.equal(calls.filter(row => row.method === 'PUT' && row.path.includes('/iam/users/')).length, 2)
|
||||
assert.ok(calls.some(row => row.path === '/api/platform/v1/iam/roles/99/status'))
|
||||
assert.ok(calls.some(row => row.path === '/api/auth/refresh' && row.body.refreshToken === 'old-refresh'))
|
||||
})
|
||||
test('fresh login retaining the original warehouse blocks instead of accepting token presence', async () => {
|
||||
const { context, picker, checks } = fixture(true)
|
||||
await assert.rejects(verifyPdaWarehouseRevocation(context, picker), /绑定漂移/)
|
||||
assert.ok(checks.some(row => !row.passed))
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { verifyRevokedSessions } from './system.mjs'
|
||||
|
||||
const MODULE = 'phase4-overseas-pda-operations'
|
||||
const OPTIONS = { scenario: 'unified-pda-session-revocation', deploymentBlocker: true }
|
||||
const PATH = '/api/pda/v1/last-leg/receiving-containers/current'
|
||||
const id = value => value == null ? '' : String(value)
|
||||
const success = value => value?.http === 200 && String(value?.body?.code) === '200'
|
||||
export function warehouseClaim(token) {
|
||||
try {
|
||||
const raw = Buffer.from(token.split('.')[1], 'base64url').toString('utf8')
|
||||
JSON.parse(raw) // Check JSON shape; obtain the ID from its original decimal lexeme.
|
||||
const matches = [...raw.matchAll(/"overseas_warehouse_id"\s*:\s*(?:"([1-9][0-9]*)"|([1-9][0-9]*)(?=\s*[,}]))/g)]
|
||||
return matches.length === 1 ? matches[0][1] || matches[0][2] : ''
|
||||
}
|
||||
catch { return '' }
|
||||
}
|
||||
|
||||
// Called only after the owned actor's inventory operations finish. Credentials
|
||||
// stay in the private handoff context and are never added to createdResources.
|
||||
export async function verifyPdaWarehouseRevocation(context, picker) {
|
||||
if (!picker?.userUpdate || !picker.password || !picker.refreshToken) throw Error('PDA撤销检查缺少本轮完整会话和资料')
|
||||
const optionsResponse = await context.request('GET', '/api/platform/v1/warehouse-admin/overseas-receiving-options?page=1&size=200')
|
||||
const page = context.apiSuccess(MODULE, '查询PDA仓绑定变更可用的权威收货仓', optionsResponse, '', OPTIONS)
|
||||
const next = page?.records?.find(row => id(row.id) !== id(picker.userUpdate.overseasWarehouseId))
|
||||
if (!next?.id) throw Error('PDA仓绑定变更缺少第二个正式收货仓')
|
||||
const updatePath = `/api/platform/v1/iam/users/${picker.membershipId}`
|
||||
const updated = await context.request('PUT', updatePath, { ...picker.userUpdate, overseasWarehouseId: id(next.id) })
|
||||
context.assert(MODULE, '仅变更本轮PDA账号的海外仓绑定', updated,
|
||||
success(updated) && id(updated.body.data.overseasWarehouseId) === id(next.id)
|
||||
&& id(updated.body.data.phone) === id(picker.userUpdate.phone), `warehouseId=${id(next.id)}`, OPTIONS)
|
||||
if (!success(updated)) throw Error('PDA仓绑定更新失败')
|
||||
await verifyRevokedSessions(context, 'PDA仓绑定变更', [{ accessToken: picker.token, refreshToken: picker.refreshToken }], PATH)
|
||||
const loggedIn = await context.request('POST', '/api/auth/login', { loginId: picker.username, password: picker.password }, '')
|
||||
const session = context.apiSuccess(MODULE, 'PDA按新仓绑定重新登录', loggedIn, '', OPTIONS)
|
||||
const valid = session?.accessToken && session?.refreshToken && warehouseClaim(session.accessToken) === id(next.id)
|
||||
context.assert(MODULE, '新PDA会话只携带服务器签发的新仓绑定', loggedIn, Boolean(valid), `warehouseId=${id(next.id)}`, OPTIONS)
|
||||
if (!valid) throw Error('PDA新仓会话身份不完整或绑定漂移')
|
||||
const allowed = await context.request('GET', PATH, undefined, session.accessToken)
|
||||
context.assert(MODULE, '新仓绑定会话可进入PDA且不借旧仓会话', allowed,
|
||||
success(allowed) || (allowed.http === 200 && String(allowed.body?.code) === 'A0404'),
|
||||
`HTTP=${allowed.http}, code=${allowed.body?.code || 'missing'}`, OPTIONS)
|
||||
const roleCode = `PDA_UNBOUND_${picker.membershipId}`
|
||||
const roleResponse = await context.request('POST', '/api/platform/v1/iam/roles',
|
||||
{ roleCode, roleName: `无PDA权限验收-${picker.membershipId}`, description: context.marker })
|
||||
const role = context.apiSuccess(MODULE, '创建本轮无PDA权限的隔离角色', roleResponse, '', OPTIONS)
|
||||
if (!role?.id) throw Error('无PDA权限验收角色创建失败')
|
||||
try {
|
||||
const rolesResponse = await context.request('GET', '/api/platform/v1/iam/roles')
|
||||
const roles = context.apiSuccess(MODULE, '核对隔离角色未获任何权限', rolesResponse, '', OPTIONS)
|
||||
const isolated = roles?.find(row => id(row.id) === id(role.id))
|
||||
const emptyPermissions = isolated?.roleCode === roleCode && isolated?.status === 'ACTIVE'
|
||||
&& Array.isArray(isolated.permissionIds) && isolated.permissionIds.length === 0
|
||||
context.assert(MODULE, '清空仓绑定使用本轮无权限角色', rolesResponse, emptyPermissions, `roleId=${id(role.id)}`, OPTIONS)
|
||||
if (!emptyPermissions) throw Error('无PDA权限验收角色身份或权限不符合要求')
|
||||
const cleared = await context.request('PUT', updatePath,
|
||||
{ ...picker.userUpdate, roleIds: [id(role.id)], overseasWarehouseId: null, domesticWarehouseId: null })
|
||||
context.assert(MODULE, '取消本轮PDA角色同时清空作业仓绑定', cleared,
|
||||
success(cleared) && cleared.body.data.overseasWarehouseId == null && cleared.body.data.domesticWarehouseId == null
|
||||
&& cleared.body.data.roleIds?.length === 1 && id(cleared.body.data.roleIds[0]) === id(role.id), '', OPTIONS)
|
||||
if (!success(cleared)) throw Error('PDA角色及仓绑定清空失败')
|
||||
await verifyRevokedSessions(context, 'PDA角色与仓绑定清空', [session], PATH)
|
||||
const noBindingLogin = await context.request('POST', '/api/auth/login', { loginId: picker.username, password: picker.password }, '')
|
||||
const token = success(noBindingLogin) ? noBindingLogin.body.data?.accessToken : null
|
||||
if (token) {
|
||||
const denied = await context.request('GET', PATH, undefined, token)
|
||||
context.assert(MODULE, '无作业仓绑定的新会话不得进入PDA', denied,
|
||||
denied.http === 403 || (denied.http === 200 && String(denied.body?.code) === 'A0403'), '', OPTIONS)
|
||||
} else {
|
||||
context.assert(MODULE, '无PDA身份不能重新取得作业会话', noBindingLogin,
|
||||
[200, 401, 403].includes(noBindingLogin.http) && ['A0401', 'A0403'].includes(String(noBindingLogin.body?.code)), '', OPTIONS)
|
||||
}
|
||||
} finally {
|
||||
const disabled = await context.request('PUT', `/api/platform/v1/iam/roles/${role.id}/status`, { status: 'DISABLED' })
|
||||
context.assert(MODULE, 'finally停用本轮无权限验收角色', disabled,
|
||||
success(disabled) && disabled.body.data.status === 'DISABLED', `roleId=${id(role.id)}`, OPTIONS)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
const BASE = '/api/platform/v1/last-leg/fleet/vehicles'
|
||||
const VEHICLES = '/api/platform/v1/last-leg/master-data/vehicles'
|
||||
const OPTIONS = { scenario: 'vehicle-care-lifecycle', deploymentBlocker: true }
|
||||
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64')
|
||||
|
||||
/** Run only from an authorized fresh TEST lifecycle. Keep every created record and its evidence. */
|
||||
export async function verifyVehicleCare(context, module, vehicle, proof) {
|
||||
const id = String(vehicle.id), base = `${BASE}/${encodeURIComponent(id)}/records`
|
||||
proof.status = 'CHECKING'; proof.records = []
|
||||
function check(label, response, condition) {
|
||||
context.assert(module, label, response, Boolean(condition), '', OPTIONS)
|
||||
if (!condition) throw new Error(label)
|
||||
}
|
||||
async function command(method, path, body, label) {
|
||||
const response = await context.request(method, path, body)
|
||||
const data = context.apiSuccess(module, label, response, '', OPTIONS)
|
||||
if (response?.http !== 200 || String(response?.body?.code) !== '200' || data == null) throw new Error(label)
|
||||
return data
|
||||
}
|
||||
const occurredAt = new Date(Date.now() - 86400000).toISOString()
|
||||
const maintenance = { recordType: 'MAINTENANCE', occurredAt, distanceUnit: 'KM', odometer: 80,
|
||||
lastServiceOdometer: 20, nextServiceOdometer: 100, fuelQuantity: null, fuelUnit: null, verificationStatus: 'PENDING', remark: context.marker }
|
||||
const createRequest = { idempotencyKey: `${context.marker}:CARE`, details: maintenance }
|
||||
let record = await command('POST', base, createRequest, '建立本轮车辆保养记录')
|
||||
proof.records.push({ recordNo: record.recordNo, createRequest })
|
||||
check('保养记录属于本车且原始单位不变', null, record.vehicleId === id && record.details?.distanceUnit === 'KM' && Number(record.details.odometer) === 80)
|
||||
const replay = await command('POST', base, createRequest, '重放同一保养创建请求')
|
||||
check('保养创建重放没有重复记录', null, replay.recordNo === record.recordNo && Number(replay.recordVersion) === Number(record.recordVersion))
|
||||
const originalVersion = Number(record.recordVersion)
|
||||
const correction = { recordVersion: originalVersion, reason: '受控验收更正', details: { ...maintenance, odometer: 85 } }
|
||||
record = await command('PUT', `${base}/${record.recordNo}`, correction, '更正本轮保养里程')
|
||||
check('更正后业务值与版本均生效', null, Number(record.details.odometer) === 85 && Number(record.recordVersion) === originalVersion + 1)
|
||||
const stale = await context.request('PUT', `${base}/${record.recordNo}`, correction)
|
||||
check('旧版本不得覆盖运维记录', stale, stale?.http === 409 || (stale?.http === 200 && ['A0409', '409'].includes(String(stale.body?.code))))
|
||||
const history = await command('GET', `${base}/${record.recordNo}/history?page=1&size=20`, undefined, '读取保养更正历史')
|
||||
check('保养历史保留旧里程与更正原因', null, history.records?.length === 2 && history.records[0].reason === correction.reason
|
||||
&& Number(history.records[1].snapshot?.details?.odometer) === 80)
|
||||
const fuelRequest = { idempotencyKey: `${context.marker}:FUEL`, details: { ...maintenance, recordType: 'FUEL', distanceUnit: 'MI',
|
||||
lastServiceOdometer: null, nextServiceOdometer: null, fuelQuantity: 28.6, fuelUnit: 'US_GAL' } }
|
||||
let fuel = await command('POST', base, fuelRequest, '建立本轮加油记录')
|
||||
proof.records.push({ recordNo: fuel.recordNo, createRequest: fuelRequest })
|
||||
const uploadPath = `${base}/${fuel.recordNo}/files?recordVersion=${fuel.recordVersion}&idempotencyKey=${encodeURIComponent(`${context.marker}:PHOTO`)}`
|
||||
const upload = () => context.requestMultipart('POST', uploadPath, { name: 'controlled-meter.png', type: 'image/png', content: PNG })
|
||||
const first = await upload(); fuel = context.apiSuccess(module, '上传本轮受控运维照片', first, '', OPTIONS)
|
||||
check('运维照片正式关联本记录', first, Boolean(fuel?.files?.length === 1 && fuel.files[0].fileId))
|
||||
const repeated = await upload(), repeatedFuel = context.apiSuccess(module, '同键重放运维照片', repeated, '', OPTIONS)
|
||||
check('照片重放不增加附件或版本', repeated, repeatedFuel?.files?.length === 1 && repeatedFuel.files[0].fileId === fuel.files[0].fileId
|
||||
&& Number(repeatedFuel.recordVersion) === Number(fuel.recordVersion))
|
||||
proof.photoId = fuel.files[0].fileId
|
||||
const photo = await context.requestBinary('GET', `${base}/${fuel.recordNo}/files/${encodeURIComponent(proof.photoId)}`)
|
||||
check('运维图片下载真实字节一致', photo, photo?.http === 200 && String(photo.contentType).startsWith('image/png')
|
||||
&& photo.bytes instanceof Uint8Array && Buffer.from(photo.bytes).equals(PNG))
|
||||
for (const type of ['REPAIR', 'BREAKDOWN', 'RESCUE', 'EXCEPTION']) {
|
||||
const priced = ['REPAIR', 'RESCUE'].includes(type)
|
||||
const details = { recordType: type, occurredAt, distanceUnit: null, odometer: null, lastServiceOdometer: null, nextServiceOdometer: null,
|
||||
fuelQuantity: null, fuelUnit: null, verificationStatus: null, remark: context.marker,
|
||||
incident: { description: `${context.marker} 受控故障处置记录`, item: priced ? '更换轮胎' : null, place: priced ? '本轮测试作业点' : null,
|
||||
handler: null, processingStatus: 'PENDING', exceptionType: type === 'EXCEPTION' ? 'VEHICLE_BREAKDOWN' : null,
|
||||
reportedCost: priced ? 0 : null, currency: priced ? 'USD' : null } }
|
||||
const initial = { idempotencyKey: `${context.marker}:${type}`, details }
|
||||
let incident = await command('POST', base, initial, `创建本轮${type}记录`)
|
||||
proof.records.push({ recordNo: incident.recordNo, createRequest: initial })
|
||||
check('事件项目地点和分类完整回显', null, incident.vehicleId === id && incident.details?.incident?.item === details.incident.item
|
||||
&& incident.details.incident.place === details.incident.place && incident.details.incident.exceptionType === details.incident.exceptionType)
|
||||
incident = await command('PUT', `${base}/${incident.recordNo}`, { recordVersion: Number(incident.recordVersion), reason: '受控任务实际处理完成',
|
||||
details: { ...details, incident: { ...details.incident, handler: '本轮测试处理人', processingStatus: 'COMPLETED' } } }, `完成本轮${type}处理记录`)
|
||||
check('处理完成保持独立的费用及付款事实', null, incident.details.incident.processingStatus === 'COMPLETED'
|
||||
&& (!priced || (Number(incident.details.incident.reportedCost) === 0 && incident.paymentStatus === 'UNLINKED')))
|
||||
const refreshed = await command('GET', `${base}/${incident.recordNo}`, undefined, `重新读取本轮${type}记录`)
|
||||
check('处理记录重新读取保持全部现场字段', null, refreshed.details.incident.item === details.incident.item
|
||||
&& refreshed.details.incident.place === details.incident.place && refreshed.details.incident.handler === '本轮测试处理人')
|
||||
}
|
||||
let other
|
||||
try {
|
||||
other = await command('POST', VEHICLES, { plateNumber: `CARE-${context.marker}`.slice(0, 64), model: 'Care isolation fixture',
|
||||
fuelConsumptionPer100Km: 10, mileageKm: 0, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: context.marker }, '建立运维归属隔离测试车辆')
|
||||
proof.otherVehicleId = String(other.id)
|
||||
const foreignBase = `${BASE}/${encodeURIComponent(proof.otherVehicleId)}/records`
|
||||
for (const [method, suffix, body] of [['GET', `/${record.recordNo}`, undefined], ['PUT', `/${record.recordNo}`, { ...correction, recordVersion: record.recordVersion }],
|
||||
['GET', `/${fuel.recordNo}/files/${encodeURIComponent(proof.photoId)}`, undefined]]) {
|
||||
const response = await context.request(method, foreignBase + suffix, body)
|
||||
check('其他车辆不能读取或修改本车记录和照片', response, response?.http === 404 || (response?.http === 200 && ['404', 'A0404'].includes(String(response.body?.code))))
|
||||
}
|
||||
const foreign = await command('GET', `${foreignBase}?page=1&size=10`, undefined, '核对其他车辆记录列表')
|
||||
check('其他车辆列表没有串入本车记录', null, Number(foreign.total) === 0 && foreign.records?.length === 0)
|
||||
for (const type of ['MAINTENANCE', 'FUEL', 'REPAIR', 'BREAKDOWN', 'RESCUE', 'EXCEPTION']) {
|
||||
const page = await command('GET', `${base}?recordType=${type}&page=1&size=10`, undefined, '按车辆和类型查询运维记录')
|
||||
check('每类运维记录唯一且保留单位', null, Number(page.total) === 1 && page.records?.length === 1 && page.records[0].details.recordType === type
|
||||
&& page.records[0].vehicleId === id && (type !== 'FUEL' || page.records[0].details.fuelUnit === 'US_GAL'))
|
||||
}
|
||||
const after = await command('GET', `${VEHICLES}/${encodeURIComponent(id)}`, undefined, '回查车辆主档未被运维记录改写')
|
||||
check('运维保存不改车辆里程状态或主档版本', null, Number(after.mileageKm) === Number(vehicle.mileageKm)
|
||||
&& after.status === vehicle.status && Number(after.recordVersion) === Number(vehicle.recordVersion))
|
||||
} finally {
|
||||
if (other?.id) {
|
||||
const ended = await command('PATCH', `${VEHICLES}/${encodeURIComponent(String(other.id))}/status`, { recordVersion: Number(other.recordVersion), status: 'INACTIVE' }, '停用本轮隔离用车辆并保留记录')
|
||||
check('隔离用车辆已停用', null, ended.status === 'INACTIVE'); proof.otherVehicleStatus = ended.status
|
||||
}
|
||||
}
|
||||
proof.status = 'VERIFIED'
|
||||
return proof
|
||||
}
|
||||
Reference in New Issue
Block a user