diff --git a/AGENTS.md b/AGENTS.md index bd585b504..6e0afca0b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,7 @@ | 全局智能装柜候选、柜次、占位与移除 | `firstleg/`、`order/` | Firstleg `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/containers`、`/plans/{planId}/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`,业务编排 `service/SmartLoadingService.java`;待审核事实由 `client/PendingReviewOrderClient.java` 经内部令牌查询 Order `/internal/order/v1/pending-review-orders` 与 `PendingReviewOrderQueryService.java` 的权威 `SUBMITTED/PENDING_REVIEW` 行。候选四态只用于筛选展示,资格以 `canAssign` 为准;待审核或待测量可先占位,确认预装和启动 PDA 前仍须进入 `WAIT_SPLIT`;计划明细总量/余量由 Order 原始申报或最新已审核测量补齐,调整时重验权威数量;人工加入允许跨渠道,智能推荐仍按柜次渠道;同柜组整组分页、分配和移除 | | 客户入库结果与头程费用确认 | `firstleg/` | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java`、`FbaPricingService.java`,接口 `/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing` 与 `/confirm`;客户按登录态范围读取并以费用版本和幂等键确认当前 `SENT` 费用,确认后锁定 | | 订舱资料/打印、船期、订舱级拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`TruckAppointmentService.java`、`outbound/dispatch/`、`customs/`;拖车只绑定 `RELEASED` 订舱,不携带客户、柜次、装柜计划或真实柜号,每订舱最多一张非终态预约,订舱文件联查 `file/AGENTS.md` | +| 目的港码头拖车与还空预警 | `firstleg/` | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`src/main/resources/openapi/terminal-drayage-v1.yaml`;按 `containerBookingId` 聚合并与订舱级拖车分离,引用 Warehouse `ACTIVE + TRUCKING` 供应商,提柜/还柜复用船节点,服务端按海外卸柜完成时间加 72 小时计算还空预警 | | 客户账期、费用池、账单、入仓码联查、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;客户账单行 `inboundCode` 由 `FinanceQueryService` 从费用来源快照提供,Client 复用头程入仓关系详情;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` | | 客户资金账户、充值、授信、冻结与结算 | `finance/` | `FinanceWalletService.java`、`ClientFinanceWalletController.java`、`PlatformFinanceWalletController.java`、`InternalFinanceWalletController.java`、`finance-wallet-v1.yaml`;Finance 持有 CNY 主账和不可变流水,业务服务只能经内部命令或跨区 Wallet 事件冻结、结算、释放 | | 柜级公共费用跨客户分摊 | `firstleg/`、`finance/` | Firstleg `PlatformContainerFeeDetailController.java`、`ContainerFeeDetailService.java`、`container-fees-v1.yaml` 按柜内真实客户与订单原子创建 `allocation-groups`;金额合计必须等于总额,分组审核、驳回、调整、作废均整组处理,Finance 只消费各客户不可变来源快照 | @@ -50,8 +51,8 @@ | 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` | | 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` | | 海外 IAM 状态与权限版本投影 | `lastleg-service/security/` | `lastleg-service/AGENTS.md`;内部入口 `/internal/lastleg/security/v1/events`、`/validate`;消费三类 IAM 安全事件并为海外 Gateway 提供 fail-closed 校验,投影表归 `baiyue_lastleg` | -| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 | -| 柜级入仓计划进度与整柜转换 | `lastleg-service/` | `orders/container-summaries` 按 `sourceContainerBookingId` 跨客户聚合,分页前排除全部入仓码均已生成有效独立计划的柜,只分页返回仍有入仓码待生成有效独立计划的柜;详情返回柜内全部订单/入仓码/计划且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结事实并升序、空值最后;`inbound-plans/convert-container` 锁定同柜订单,每票生成独立计划,整批原子且幂等 | +| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,尾程订单控制从 `LastlegOrderController.java`、`LastlegOrderCommandService.java` 进入,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 | +| 尾程订单控制与柜级入仓计划转换 | `lastleg-service/` | `orders/container-summaries` 按 `sourceContainerBookingId` 跨客户聚合,分页前排除全部入仓码均已生成有效独立计划的柜,详情返回全柜数据;`inbound-plans/convert-container` 锁定同柜订单,每票生成独立计划,整批原子且幂等。单票完整入库计划进入 `InboundPlanService.java`,暂扣/解除和固定八行人工节点进入 `LastlegOrderCommandService.java`;命令按订单版本、客户范围和幂等键执行,暂扣中的订单不得进入排车或出库 | | 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 | | 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 | | 海外排车执行、PDA 出库、派送与一件代发 | `lastleg-service/` | `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`OutboundPdaController.java`、`MerchantDeliveryController.java`,一件代发库存归 `dropship/`;统一经 `gateway-overseas/`,设备端正式入口继续查 `../apps/pda-overseas-uniapp/AGENTS.md` | @@ -112,6 +113,7 @@ - 固定客户待审核订单使用 `scripts/create-pending-review-orders.mjs`;固定“IT测试”与“义乌仓”、随机真实线路并推进至 `WAIT_SPLIT` 使用 `scripts/it-test-wait-split-bulk.mjs`。二者都是造数入口,不属于全量验证;会写目标环境且不自动清理成功数据。 - Admin 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--modules `,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。 - 订舱与拖车跨服务完整状态链由 Admin 注册模块 `service-center-flow` 验证,必须复用本轮已放舱订舱并由总入口展开依赖,不得直接执行模块文件或借历史数据。 +- 尾程公网控制由 `phase4-tail-order-controls` 在海外 PDA 前验证整柜接柜仓变更与恢复、单/批目的仓变更、单/批合托标记、CSV 导出、订单费用、暂扣/解除、八个人工节点和逐票人工计划;目的港码头拖车由 `phase5-terminal-drayage` 在卸柜后验证选车队、预约提柜、实际提柜和实际还柜,`phase4-carrier-nodes` 只读核验最终船节点终态。 - 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。 - 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。 - Java 模块、OpenAPI 覆盖、JaCoCo 与 SpotBugs 聚合摘要使用 `node scripts/java-quality-summary.mjs`;默认仅报告存量缺口,是否阻断由显式 `--require-*` 参数决定,输出位于 `target/` 时不得作为业务事实或提交物。 diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index a8e63a666..175f3f47b 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -3,8 +3,8 @@ ## 职责与边界 - Mapper 统一显式使用 `@Mapper`,仅由 `BaiyueLastlegApplication.java` 对 `com.baiyue.lastleg` 根包按 `annotationClass = Mapper.class` 扫描;各业务切片不得新增 `*MapperConfiguration` 或维护包白名单。静态门禁见根 `scripts/mapper-scan-contract.mjs`,模块内门禁见 `MapperRegistrationArchitectureTest.java`。 -- 负责消费头程装柜完成事件,保存业务 Inbox,并按最终实装入仓单建立待分配尾程订单、明细冻结快照和初始状态历史;事件不得自动创建入仓计划,平台人工调用 `/api/platform/v1/last-leg/inbound-plans/convert` 后才生成计划并推进订单。 -- 负责尾程订单列表、详情、导出和目的仓调整,以及海外入仓计划、托盘分配与 PDA 入仓作业;PDA 卸柜完成后通过本地 Outbox 向国内头程回传货柜报空事实。 +- 负责消费头程装柜完成事件,保存业务 Inbox,并按最终实装入仓单建立待分配尾程订单、明细冻结快照和初始状态历史;事件不得自动创建入仓计划,平台人工调用 `/api/platform/v1/last-leg/inbound-plans/convert-container` 后才按柜内每票生成独立计划并推进订单。 +- 负责尾程订单列表、详情、导出、完整入库计划、暂扣/解除、人工节点和目的仓调整,以及海外入仓计划、托盘分配与 PDA 入仓作业;PDA 卸柜完成后通过本地 Outbox 向国内头程回传货柜报空事实。 - 不查询头程数据库,不调用头程接口补齐建单字段;事件快照缺失时应拒绝消费并保留可诊断错误。 ## 快速索引 @@ -13,6 +13,7 @@ |---|---| | 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 | | 事件接收、平台订单列表/详情/导出、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码和有效计划,分页前排除全部入仓码均已生成有效独立计划的柜,`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 | +| 尾程订单完整计划、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`,已拣货、已发运或已有出库单时禁止新增暂扣;暂扣期间不得进入排车或出库。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 | | 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` | | 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V2,消费端冻结 `destinationType`:FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;历史缺字段事件仅按明确 FBA 单号或目的仓与接柜仓一致性安全推断,歧义直接拒绝 | | 尾程单号生成 | `service/LastlegOrderNoGenerator.java` | diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 4699ddf4b..2720ace8c 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -19,6 +19,7 @@ | 入仓测量与整票库位块专项 | `admin-test-env-modules/inbound-measurement.mjs`、`admin-test-env-modules/README.md` | 仍从 Admin 总入口按 `orders,inbound-measurement` 依赖链执行;开启 `--verify-pallet-location-guide true` 后逐张确认回查块扩容只能追加,既有槽身份不得漂移,并验证删除图片后空槽保留与替代图片复用;会写目标环境,必须有对应环境授权 | | Finance 扩展 HTTP 模块 | `admin-test-env-modules/firstleg-container-fee-allocation.mjs`、`finance-direct-paid-isolation.mjs`、`phase4-overseas-finance-cross-region.mjs` | 分别验证柜费跨客户分摊、海外仓服务直接已付隔离、五类海外费用跨区入池;必须由 Admin 总入口按已登记依赖运行,使用本轮资源且禁止借历史数据,真实 HTTP 仍须当前轮授权 | | 订舱与拖车服务中心链路 | `admin-test-env-modules/service-center-flow.mjs` | 复用本轮 `RELEASED` 订舱,创建订舱级拖车并验证附件、完整状态链、历史和稳定关联;由 Admin 总入口按依赖运行,会写目标环境 | +| 尾程单控制与目的港码头拖车 | `admin-test-env-modules/phase4-tail-order-controls.mjs`、`admin-test-env-modules/phase5-terminal-drayage.mjs`、`admin-test-env-modules/phase4-carrier-nodes.mjs` | PDA 前覆盖整柜接柜仓变更与恢复、单/批目的仓变更、单/批合托标记、CSV 导出、订单费用、单票暂扣/解除、八个人工节点和逐票人工计划;接柜仓仅复用公共主档且恢复原值。PDA 卸柜后经正式码头拖车接口完成选车队、预约、提柜、还柜,船节点模块只读核对最终回写,不直接伪造最后两个节点 | | 定向 HTTP/诊断 | 以 `README.md` 的“仓库级入口清单”为准 | 必须明确是只读诊断还是会补证据、创建资源或推进状态,不能用诊断成功冒充全量通过 | | 本地质量与静态合同 | `backend-full-regression.ps1`、`frontend-full-regression.ps1`、`mapper-scan-contract.mjs`、`script-documentation-contract.test.mjs` 等 | 默认不访问开发或测试环境;数据库非 dry-run、Nacos 发布等有写入能力的入口必须按 README 单独识别 | | 部署与基础设施 | `../deploy/scripts/`、`../deploy/rocketmq/` | 靠近部署组件管理,不移动到根脚本目录;部署、初始化数据库或发布配置必须获得当前轮明确授权 | diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 1f4419fcb..4e05e0ffe 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -5,7 +5,7 @@ import { dirname, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import process from 'node:process' -const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'inbound-hold', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-container-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-direct-paid-isolation', 'finance-downloads', 'firstleg-container-fee-allocation', 'phase4-overseas-finance-cross-region', 'phase4-overseas-wallet-and-pallet-cost', 'customer-operating-statistics'] +const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'inbound-hold', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-container-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-tail-order-controls', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment', 'phase5-terminal-drayage', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-direct-paid-isolation', 'finance-downloads', 'firstleg-container-fee-allocation', 'phase4-overseas-finance-cross-region', 'phase4-overseas-wallet-and-pallet-cost', 'customer-operating-statistics'] const OPTIONAL_MODULES = ['manual-order-splitting-fixture', 'manual-order-splitting-verify', 'manual-preload-push-fixture', 'manual-preload-push-verify', 'manual-pda-ready-fixture', 'manual-pda-ready-verify', @@ -71,14 +71,16 @@ const MODULE_DEPENDENCIES = new Map([ ['phase3-order-splitting', ['phase3-loading-finalize']], ['manual-state-matrix', ['phase3-loading-finalize', 'phase3-customs', 'ocean-bookings']], ['phase4-carrier-pickup-ready', ['phase3-carrier-schedule-queries']], - ['phase4-overseas-pda', ['phase4-carrier-pickup-ready']], + ['phase4-tail-order-controls', ['phase4-carrier-pickup-ready']], + ['phase4-overseas-pda', ['phase4-tail-order-controls']], ['phase4-overseas-pda-operations', ['phase4-overseas-pda']], ['phase4-overseas-inbound-operations', ['phase4-overseas-pda-operations']], ['phase4-overseas-client-services', ['phase4-overseas-pda-operations']], ['phase4-overseas-dispatch', ['phase4-overseas-pda-operations']], ['phase5-overseas-fulfillment', ['phase4-overseas-dispatch']], + ['phase5-terminal-drayage', ['phase4-overseas-pda']], ['phase4-cross-region-events', ['system', 'phase3-customs', 'phase4-overseas-pda-operations']], - ['phase4-carrier-nodes', ['phase4-overseas-pda-operations']], + ['phase4-carrier-nodes', ['phase4-overseas-pda-operations', 'phase5-terminal-drayage']], ['phase4-overseas-business', ['merchant-qualification-fixture']], ['firstleg-container-fee-allocation', []], ['finance-direct-paid-isolation', ['finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full']], @@ -1498,13 +1500,16 @@ export class AcceptanceRun { const documents = await import(new URL('./admin-test-env-modules/phase3-container-documents.mjs', import.meta.url)) await this.executeModule(`phase3-container-documents-phase4-${label}`, () => documents.run(this)) const producerKey = this.createdResources.phase3LoadingCompletedProducer?.resourceKey - const writeShippingEvidence = (shippingReady, carrierScheduleQueryId = null) => { + const writeShippingEvidence = (shippingReady, carrierScheduleQueryId = null, + carrierLatestActualAt = null) => { const matrix = this.createdResources.phase4InboundProducerFixtures const records = [matrix?.containers, matrix?.byDestinationType?.FBA, matrix?.byDestinationType?.OVERSEAS].filter(Array.isArray) for (const values of records) { const target = values.find((item) => item?.resourceKey === producerKey) - if (target) Object.assign(target, { shippingReady, carrierScheduleQueryId }) + if (target) Object.assign(target, { + shippingReady, carrierScheduleQueryId, carrierLatestActualAt, + }) } } if (producerKey) writeShippingEvidence(false) @@ -1530,7 +1535,8 @@ export class AcceptanceRun { const pickup = this.createdResources.phase4CarrierPickupReady const shippingReady = pickup?.marker === variantMarker && pickup?.ready === true && Boolean(pickup?.queryId) - writeShippingEvidence(shippingReady, shippingReady ? String(pickup.queryId) : null) + writeShippingEvidence(shippingReady, shippingReady ? String(pickup.queryId) : null, + shippingReady ? pickup.latestActualAt : null) this.assert('phase4-overseas-pda-fixture', `${label}通过真实出仓与船节点进入PICKUP_READY`, null, shippingReady, `resourceKey=${producerKey}, queryId=${pickup?.queryId || 'missing'}, ready=${pickup?.ready === true}`, diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index e5f75afee..385eaa211 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -86,6 +86,7 @@ export async function run(context) { | 客户钱包与拆柜打托费自动闭环 | `phase4-overseas-wallet-and-pallet-cost.mjs`(授信初始化、平台充值、采购凭证/费用冻结结算、换标冻结结算/释放、Client 余额账本、国内/海外托盘汇总、按体积分摊及跨区金额守恒) | `phase4-overseas-wallet-and-pallet-cost-contract.test.mjs`;默认全量已启用,定向入口自动展开已审核客户与海外 PDA 实收链 | | 海外仓 HTTP 生命周期 | `phase4-overseas-warehouse.mjs` | `phase4-overseas-warehouse-contract.test.mjs` | | 海外 PDA 柜号收货与库内操作 HTTP 生命周期 | `phase4-overseas-pda.mjs`(按柜完成签收和实收,成功后仅以内存上下文交接操作员会话和已完成库存事实)、`phase4-overseas-pda-operations.mjs`(紧随收货执行盘点、整托移库、托盘拆装换绑和换标复核;finally 停用双操作员并清空临时 Token;任何真实 fixture 缺失均 fail-closed) | `phase4-overseas-pda-contract.test.mjs`、`phase4-overseas-pda-operations-contract.test.mjs` | +| 尾程单公网控制与人工计划 | `phase4-tail-order-controls.mjs`(PDA 收货前对本轮柜执行整柜接柜仓变更/恢复与幂等冲突、单/批 FBA 目的仓变更、单/批合托标记并取消、CSV 导出、订单费用回查、单票暂扣/解除、八个人工节点和逐票人工计划) | `phase4-tail-order-controls-contract.test.mjs`;只复用现有公共接柜仓/FBA 主档,不新增仓库、托盘或库位;为目标柜全部入仓码创建 `MANUAL` 计划后交给 PDA 继续消费,不借历史订单 | | 海外入仓扩展、客户服务与排车 | `phase4-overseas-inbound-operations.mjs`、`phase4-overseas-client-services.mjs`、`phase4-overseas-dispatch.mjs` | 对应同名 `*-contract.test.mjs`;只消费本轮前置资源,缺失时 fail-closed | | 海外仓业务跨区事件与安全门禁 | `phase4-cross-region-events.mjs`(双 Token Audience 互斥、canonical 权限 403、国内装柜完成到 Lastleg 建单、海外柜完成到 Firstleg 回写、轮询重试/幂等/诊断证据;缺本轮 marker 前置时阻断) | `phase4-cross-region-events-contract.test.mjs` | | 国内离仓至海外 PDA 可见人工验收节点 | `phase3-customs.mjs`(四类柜资料与整柜照片)、`phase3-outbound-dispatch.mjs`(柜级离仓)、`phase3-carrier-schedule-queries.mjs`、`phase4-carrier-pickup-ready.mjs`、`phase4-overseas-pda.mjs`(同柜候选) | `phase3-phase4-departure-pickup-chain-contract.test.mjs` | @@ -93,7 +94,7 @@ export async function run(context) { | 人工状态矩阵 | `manual-state-matrix.mjs` | `manual-state-matrix-contract.test.mjs`;验证装柜完成、报关和订舱相关状态组合 | | 海外业务 HTTP 生命周期 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` | | 海外主数据 HTTP 生命周期 | `phase4-overseas-master-data.mjs` | `phase4-overseas-master-data-contract.test.mjs` | -| 海外仓业务船节点安全冒烟 | `phase4-carrier-nodes.mjs`(standalone 对历史任务只读;仅复用当前 marker 的 `phase3CarrierScheduleQuery` 时补录预计事实) | `phase4-carrier-nodes-contract.test.mjs` | +| 目的港码头拖车与船节点终态 | `phase5-terminal-drayage.mjs`(PDA 卸柜后复用 `ACTIVE + TRUCKING` 主数据,覆盖选车队、预约提柜、实际提柜、实际还柜及四命令幂等/历史)、`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` 停用;覆盖排车编辑、转出库/派送、PDA 全量拣货/发车、派送完成、客户回访、人工异常及派送退回) | `phase5-overseas-fulfillment-contract.test.mjs` | | 初始化基线(仅离线) | — | `initialization-baseline-contract.test.mjs` | @@ -103,6 +104,12 @@ export async function run(context) { 节点全链定向入口为 `--modules phase4-overseas-pda`。依赖展开固定经过本轮 `LOADED` 柜次的 `DESTINATION_CLEARANCE`、`CUSTOMS_DECLARATION`、`DELIVERY_ORDER`、`PICKUP_ORDER` 四类资料生命周期,再确认国内出仓、核对自动创建的 `OUTBOUND_CONFIRM` 船节点任务、人工补录至实际 `PICKUP_READY`,最后验证海外 PDA 候选可见。四类资料模块只写本轮 marker 柜次,覆盖上传/完成重放、同键异载荷 `A0409`、冲突后零漂移和聚合回读;任一 fixture 或 Trace 缺失均为 deployment blocker。 +尾程单公网控制与目的港码头拖车定向入口为 `--modules phase4-carrier-nodes`。依赖会在海外 PDA 前插入 `phase4-tail-order-controls`,先对本轮柜验证接柜仓变更/恢复、目的仓变更、合托标记、CSV 导出、订单费用、暂扣阻断、解除和八个人工节点,再对目标柜全部尾程订单生成人工计划;PDA 卸柜完成后再运行 `phase5-terminal-drayage`,只复用公共 `ACTIVE + TRUCKING` 供应商,依次推进选车队、预约、提柜和还柜。最后的船节点模块仅回读两次业务动作同步生成的 `PICKED_UP/EMPTY_RETURNED`,不得直接补录这两个节点。 + +```powershell +node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/admin-test-env-acceptance.mjs --modules phase4-carrier-nodes +``` + 海外 PDA 收货统一扫描冻结箱码:`FBA` 与 `OVERSEAS` 都必须由 `POST /api/pda/v1/last-leg/receiving-containers/current/cartons/resolve` 返回 HTTP 200、业务码 `200` 和当前柜唯一 `inboundCode`,不展示、扫描或提交 SKU。仅实收阶段分流:FBA 使用不含 `skuCode` 的 `/current/receipts/bulk`,逐箱接口必须精确返回 `A0409 FBA destination must use bulk count`;OVERSEAS 使用 `/current/receipts/carton`,bulk 必须精确返回 `A0409 OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden`。实机旧行为反证为 requestId `5e925a3d-8184-41e1-b0d2-f4e1aae86639`、traceId `62fdf603b65fd7f43ec452bb8a6ca185`。跨区广播消费只保留观察证据,不再单独形成 deployment blocker;实际 `PICKUP_READY` 候选门禁仍必须通过。 FBA 无 `ll_inbound_plan_carton` manifest 时,服务端只取完整扫描值的第一个 `#` 前缀,并在当前锁定柜中精确匹配唯一 `inboundCode`;不校验 SKU、`#` 后缀或箱序号,也不能要求前端拆分或自行决定归属。无 `#`、空前缀、未命中、跨柜或同柜多匹配必须拒绝。OVERSEAS 仍按完整箱码精确匹配真实 manifest 行。HTTP 合同使用任意 FBA 后缀验证前缀解析,覆盖上述负向与 OVERSEAS 缺 manifest,并在正负向 resolve 后回查 current 的状态、版本与收货数量零漂移。2026-08-26 最新已部署实机尚返回 `A0404 Carton mark was not found`,requestId `a716e2c2-dc10-4d07-8014-7438afb63090`、traceId `6ab8fcfcd812056ad6570d3b1652157b`;该证据保留为后端 FBA 入仓码前缀解析落盘及重新部署后的回归反证,不据此放宽成功合同。 diff --git a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs index f893a0667..03d5dba66 100644 --- a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs @@ -123,6 +123,8 @@ test('only PDA-operated variants traverse real outbound and carrier nodes before assert.match(entry, /for \(const key of createdKeys\) delete this\.createdResources\[key\]/) assert.match(pickupChain, /shippingReady/) assert.match(entry, /carrierScheduleQueryId/) + assert.match(entry, /carrierLatestActualAt/) + assert.match(pickupChain, /pickup\.latestActualAt/) assert.match(pickupChain, /pickup\?\.marker === variantMarker/) assert.doesNotMatch(pickupChain, /internal\/event-bridge|ll_container_shipping_gate|requestOverseas/) assert.match(entry, /dropshipReady\?\.shippingReady === true/) diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs index b8aba6357..a4a33b8a2 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs @@ -5,7 +5,7 @@ import test from 'node:test' import { fileURLToPath } from 'node:url' import { EXCEPTION_CODES, NODE_SEQUENCES, QUERY_STATUSES, STANDARD_NODES, - manualNodePayload, markerOwnedCarrierQuery, run } from './phase4-carrier-nodes.mjs' + manualNodePayload, markerOwnedCarrierQuery, run, runUntilPickupReady } from './phase4-carrier-nodes.mjs' import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' const here = path.dirname(fileURLToPath(import.meta.url)) @@ -32,22 +32,25 @@ test('focused carrier node execution injects the complete outbound-confirm produ 'phase3-loading-finalize', 'phase3-customs', 'ocean-bookings', 'phase3-port-schedules', 'truck-appointments', 'service-center-flow', 'phase3-outbound-dispatch', 'phase3-carrier-schedule-queries', 'phase4-carrier-pickup-ready', - 'phase4-overseas-pda', 'phase4-carrier-nodes'] + 'phase4-tail-order-controls', 'phase4-overseas-pda', 'phase5-terminal-drayage', + 'phase4-carrier-nodes'] for (const module of required) assert.ok(modules.includes(module), module) for (let index = 1; index < required.length; index += 1) { assert.ok(modules.indexOf(required[index - 1]) < modules.indexOf(required[index]), required[index]) } }) -test('real lifecycle opens the shipping gate before PDA and records pickup and empty return afterwards', () => { +test('real lifecycle opens the shipping gate before PDA and delegates pickup and empty return to terminal drayage', () => { const modules = normalizeModuleOrder(['phase4-overseas-pda', 'phase4-carrier-nodes']) const pickup = modules.indexOf('phase4-carrier-pickup-ready') const pda = modules.indexOf('phase4-overseas-pda') + const drayage = modules.indexOf('phase5-terminal-drayage') const completion = modules.indexOf('phase4-carrier-nodes') assert.ok(pickup >= 0 && pickup < pda) - assert.ok(pda < completion) + assert.ok(pda < drayage && drayage < completion) assert.match(source, /phase4CarrierPickupReady/) - assert.match(source, /STANDARD_NODES\.slice\(STANDARD_NODES\.indexOf\('PICKED_UP'\)\)/) + assert.match(source, /phase5TerminalDrayage/) + assert.doesNotMatch(source, /PDA完成后补录实际节点/) }) test('canonical states, ten ordered nodes, exception codes and complete payload are frozen', () => { @@ -100,16 +103,11 @@ test('ownership requires current marker, long query id and outbound-confirm sour } }), null) }) -test('missing phase3 outbound query is an explicit deployment blocker and performs no write', async () => { +test('missing terminal drayage completion is an explicit deployment blocker and performs no write', async () => { const calls = []; const steps = [] const context = { marker: MARKER, token: 'token', createdResources: {}, calls, - request: async (method, requestPath) => { - calls.push({ method, path: requestPath }) - if (requestPath === `${BASE}/carriers`) return ok([]) - if (requestPath === `${BASE}?page=1&size=20`) return ok({ records: [], page: 1, size: 20, total: 0, pages: 0 }) - throw new Error(`unexpected path ${requestPath}`) - }, + request: async (method, requestPath) => { calls.push({ method, path: requestPath }); throw new Error('unexpected request') }, apiSuccess: (_module, _name, response) => response.body.data, assert: (_module, name, _response, condition, evidence) => assert.ok(condition, `${name}: ${evidence}`), addStep: (_module, name, _response, passed, evidence, options) => steps.push({ name, passed, evidence, options }), @@ -117,11 +115,11 @@ test('missing phase3 outbound query is an explicit deployment blocker and perfor await run(context) - assert.deepEqual(calls.map((item) => item.method), ['GET', 'GET']) + assert.deepEqual(calls, []) assert.equal(steps.length, 1) assert.equal(steps[0].passed, false) assert.equal(steps[0].options.deploymentBlocker, true) - assert.match(steps[0].name, /禁止借历史任务写节点/) + assert.match(steps[0].name, /取得本轮码头拖车还柜终态/) }) function markerOwnedContext() { @@ -225,13 +223,13 @@ function markerOwnedContext() { return { context, task, nodes, exceptions, histories, steps, assertions } } -test('marker-owned chain covers idempotency conflicts, all actual nodes, exception DTO and empty-return stop', async () => { +test('marker-owned pre-PDA chain covers idempotency conflicts through pickup-ready', async () => { const fixture = markerOwnedContext() - await run(fixture.context) + await runUntilPickupReady(fixture.context) const posts = fixture.context.calls.filter((item) => item.method === 'POST') - assert.equal(posts.length, 17) + assert.equal(posts.length, 15) assert.ok(posts.every((item) => item.path === `${BASE}/${QUERY_ID}/manual-nodes`)) assert.ok(posts.every((item) => item.headers['Idempotency-Key'])) assert.ok(posts.every((item) => Object.keys(item.body).length === 8)) @@ -240,11 +238,11 @@ test('marker-owned chain covers idempotency conflicts, all actual nodes, excepti assert.ok(fixture.assertions.some((item) => /更早实际节点A0409后task、node、history零漂移/.test(item.name))) assert.ok(fixture.steps.some((item) => /同级actual失败请求同键重试.*operation claim零残留/.test(item.name))) assert.ok(fixture.steps.some((item) => /更早actual失败请求同键重试.*operation claim零残留/.test(item.name))) - assert.equal(fixture.task.status, 'STOPPED') - assert.equal(fixture.task.latestActualNode, 'EMPTY_RETURNED') - assert.equal(fixture.task.stopReason, '已还柜') + assert.equal(fixture.task.status, 'READY') + assert.equal(fixture.task.latestActualNode, 'PICKUP_READY') + assert.equal(fixture.task.stopReason, null) const actualNodes = new Set(fixture.nodes.filter((item) => item.actualAt != null).map((item) => item.standardNode)) - assert.deepEqual([...actualNodes].sort(), [...STANDARD_NODES].sort()) + assert.deepEqual([...actualNodes].sort(), [...STANDARD_NODES.slice(0, 8)].sort()) assert.equal(fixture.exceptions[0].exceptionCode, 'CUSTOMS_HOLD') assert.ok(fixture.nodes.every((item) => /^\d{16,}$/.test(item.id))) assert.equal(fixture.context.calls.some((item) => /attempts|scheduler|internal|\/stop/.test(item.path)), false) diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs index 99675ccae..21a1e2586 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs @@ -312,63 +312,32 @@ export async function runUntilPickupReady(context) { } export async function run(context) { - let pickup = context.createdResources?.phase4CarrierPickupReady - if (pickup?.marker !== context.marker || pickup?.ready !== true) { - await runUntilPickupReady(context) - pickup = context.createdResources?.phase4CarrierPickupReady - } - if (!pickup?.queryId) return - - const queryId = id(pickup.queryId) - const detailResponse = await context.request('GET', `${BASE}/${encodeURIComponent(queryId)}`) - let detail = context.apiSuccess(MODULE, 'PDA收货完成后恢复本轮船节点任务', detailResponse, - `queryId=${queryId}`, OPTIONS) - assertDetail(context, detailResponse, detail, '恢复任务详情DTO完整') - const pickupTask = detail?.query?.query - if (pickupTask?.latestActualNode !== 'PICKUP_READY' || pickupTask?.status === 'STOPPED') { - blocker(context, 'PDA收货后船节点未停留在PICKUP_READY,禁止伪造后续节点', - `latest=${pickupTask?.latestActualNode || 'missing'}, status=${pickupTask?.status || 'missing'}`, detailResponse) + const pickup = context.createdResources?.phase4CarrierPickupReady + const drayage = context.createdResources?.phase5TerminalDrayage + if (pickup?.marker !== context.marker || pickup?.ready !== true || !pickup?.queryId + || drayage?.marker !== context.marker || drayage?.finalStatus !== 'EMPTY_RETURNED') { + blocker(context, '取得本轮码头拖车还柜终态', + `pickup=${pickup?.ready === true}, drayageMarker=${drayage?.marker || 'missing'}, drayageStatus=${drayage?.finalStatus || 'missing'}`) return } - let version = Number(pickupTask.version) - const frozenLatestActualMs = Date.parse(`${pickupTask.latestActualAt}Z`) - const baseActual = (Number.isNaN(frozenLatestActualMs) ? Date.now() : frozenLatestActualMs) + 60 * 60_000 - const remainingNodes = STANDARD_NODES.slice(STANDARD_NODES.indexOf('PICKED_UP')) - for (let index = 0; index < remainingNodes.length; index += 1) { - const standardNode = remainingNodes[index] - const actualAt = localIso(new Date(baseActual + index * 60_000)) - const body = manualNodePayload({ expectedVersion: version, standardNode, - observedAt: localIso(new Date()), actualAt, marker: context.marker, - index: STANDARD_NODES.indexOf(standardNode) + 1 }) - const key = `${context.marker}-carrier-actual-${String(STANDARD_NODES.indexOf(standardNode) + 1).padStart(2, '0')}`.slice(0, 128) - const response = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, body, - context.token, { 'Idempotency-Key': key }) - const updated = context.apiSuccess(MODULE, `PDA完成后补录实际节点${standardNode}`, response, - `queryId=${queryId}`, OPTIONS) - assertDetail(context, response, updated, `实际节点${standardNode}响应DTO完整`) - const updatedTask = updated?.query?.query - const isEmptyReturned = standardNode === 'EMPTY_RETURNED' - context.assert(MODULE, `${standardNode}实际事实单调推进且停止语义正确`, response, - updatedTask?.latestActualNode === standardNode && updatedTask?.latestActualAt === actualAt - && (isEmptyReturned - ? updatedTask.status === 'STOPPED' && updatedTask.stopReason === '已还柜' && updatedTask.stoppedAt != null - : updatedTask.status !== 'STOPPED'), - `status=${updatedTask?.status || 'missing'}, latest=${updatedTask?.latestActualNode || 'none'}`, OPTIONS) - version = Number(updatedTask.version) - detail = updated - } + const queryId = id(pickup.queryId) + const detailResponse = await context.request('GET', `${BASE}/${encodeURIComponent(queryId)}`) + const detail = context.apiSuccess(MODULE, '码头拖车完成后回查本轮船节点任务', detailResponse, + `queryId=${queryId}`, OPTIONS) + assertDetail(context, detailResponse, detail, '码头拖车回写后任务详情DTO完整') const finalTask = detail.query.query const actualNodes = new Set(detail.nodes.filter((item) => item.actualAt != null).map((item) => item.standardNode)) - context.assert(MODULE, '最终详情覆盖十标准实际节点且只有EMPTY_RETURNED触发停止', detailResponse, + context.assert(MODULE, '拖车业务回写后覆盖十标准实际节点且EMPTY_RETURNED停止', detailResponse, STANDARD_NODES.every((node) => actualNodes.has(node)) && finalTask.status === 'STOPPED' - && finalTask.latestActualNode === 'EMPTY_RETURNED' && finalTask.stopReason === '已还柜', + && finalTask.latestActualNode === 'EMPTY_RETURNED' && finalTask.stopReason === '已还柜' + && id(finalTask.containerBookingId) === id(drayage.containerBookingId), `actualNodes=${[...actualNodes].join(',')}, status=${finalTask.status}`, OPTIONS) const historyResponse = await context.request('GET', `${BASE}/${queryId}/history`) const history = context.apiSuccess(MODULE, '查询本轮任务不可变历史', historyResponse, `queryId=${queryId}`, OPTIONS) - context.assert(MODULE, '历史只含业务动作且人工节点完整留痕', historyResponse, + context.assert(MODULE, '历史包含码头拖车回写节点且结构完整', historyResponse, Array.isArray(history) && history.every(validHistory) && history.filter((item) => item.action === 'MANUAL_NODE').length >= STANDARD_NODES.length, `history=${Array.isArray(history) ? history.length : 'missing'}`, OPTIONS) diff --git a/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs b/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs new file mode 100644 index 000000000..9dd87cda2 --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' +import { fileURLToPath } from 'node:url' +import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' + +const source = await readFile(fileURLToPath(new URL('./phase4-tail-order-controls.mjs', import.meta.url)), 'utf8') + +test('tail order controls execute after current producer and before overseas PDA', () => { + const modules = normalizeModuleOrder(['phase4-overseas-pda']) + const controls = modules.indexOf('phase4-tail-order-controls') + assert.ok(controls > modules.indexOf('phase4-carrier-pickup-ready')) + assert.ok(controls < modules.indexOf('phase4-overseas-pda')) +}) + +test('module covers the public tail-order write surface before PDA receiving', () => { + for (const path of ['/receiving-warehouse', '/destination', '/pallet-merge-required', '/fees', '/export', + '/hold', '/manual-nodes', '/api/platform/v1/last-leg/inbound-plans', '/manual']) { + assert.ok(source.includes(path), `missing ${path}`) + } + for (const evidence of [ + '接柜仓变更同键同载荷重放', '接柜仓变更同键异载荷返回A0409', '接柜仓变更旧版本返回A0409', + '整柜恢复原公共接柜仓', '单票变更FBA目的仓', '批量变更FBA目的仓', + '单票开启PDA合托提示', '批量开启PDA合托分组', '批量取消PDA合托分组', + '尾程费用详情可追溯', '导出本轮尾程订单CSV', + '暂扣命令同键同载荷重放', '暂扣同键异载荷返回A0409', '暂扣尾程订单不能生成人工计划', + '解除本轮单票尾程暂扣', '保存尾程八个人工节点快照', '人工节点同键同载荷重放', + '人工节点同键异载荷返回A0409', '人工节点读取固定为MANUAL且不回退柜级节点', + '人工计划按订单稳定接柜仓回读当前主档并冻结入仓码、托数和操作标志', '人工计划同键异载荷返回A0409', + ]) assert.ok(source.includes(evidence), `missing ${evidence}`) + assert.match(source, /nodes\.data\?\.nodes\?\.length === 8/) + assert.match(source, /creationMode === 'MANUAL'/) + assert.match(source, /requestOverseasBinary\('GET'/) + assert.match(source, /palletMergeBatchId/) +}) + +test('receiving warehouse verification reuses public master data and restores the original assignment', () => { + assert.match(source, /tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE/) + assert.match(source, /id\(row\.id\) !== originalWarehouseId/) + assert.match(source, /overseasWarehouseId: originalWarehouseId, expectedVersion: Number\(changed\.data\.assignmentVersion\)/) + assert.doesNotMatch(source, /request\('POST', '\/api\/platform\/v1\/warehouse-admin'/) +}) + +test('module is marker-owned and never borrows historical tail orders', () => { + assert.match(source, /matrix\?\.marker !== context\.marker/) + assert.match(source, /containerNo: producer\.containerNo/) + assert.match(source, /customerId\) === id\(producer\.tenantId\)/) + assert.match(source, /禁止借历史尾程订单/) + assert.doesNotMatch(source, /records\([^)]*\)\[0\]/) +}) + +test('manual plans preserve long identifiers and are retained for PDA consumption', () => { + assert.match(source, /orderId: id\(order\.id\)/) + assert.doesNotMatch(source, /plannedWarehouse(?:Id|Code|Name):/) + assert.match(source, /!order\.overseasWarehouseId/) + assert.match(source, /planNos: plans/) + assert.match(source, /MANUAL_PLANS_READY/) + assert.match(source, /remain as the marker-owned prerequisites consumed by overseas PDA receiving/) +}) diff --git a/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs b/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs new file mode 100644 index 000000000..7909af6ba --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs @@ -0,0 +1,336 @@ +const MODULE = 'phase4-tail-order-controls' +const SCENARIO = 'phase4-tail-order-controls-lifecycle' +const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } +const ORDERS = '/api/platform/v1/last-leg/orders' +const PLANS = '/api/platform/v1/last-leg/inbound-plans' +const NODE_CODES = Object.freeze([ + 'DOMESTIC_CUSTOMS_COMPLETED', 'ESTIMATED_DEPARTURE', 'ACTUAL_DEPARTURE', 'ESTIMATED_ARRIVAL', + 'OVERSEAS_CUSTOMS_COMPLETED', 'ACTUAL_ARRIVAL_WAITING_UNLOAD', 'PICKUP_APPOINTED', 'UNLOADED', +]) + +function id(value) { return value == null ? '' : String(value) } +function codeOf(response) { return response?.body?.code == null ? '' : String(response.body.code) } +function success(response) { return response?.http === 200 && codeOf(response) === '200' } +function conflict(response) { return response?.http === 409 || (response?.http === 200 && codeOf(response) === 'A0409') } +function records(value) { return Array.isArray(value?.records) ? value.records : [] } +function nonBlank(value) { return typeof value === 'string' && value.trim().length > 0 } +function localIso(value) { return value.toISOString().slice(0, 19) } +function trace(response) { + return `HTTP=${response?.http ?? 'missing'}, code=${codeOf(response) || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` +} +function query(path, values) { + const params = new URLSearchParams() + for (const [key, value] of Object.entries(values || {})) { + if (value !== undefined && value !== null && String(value) !== '') params.set(key, String(value)) + } + return params.size ? `${path}?${params}` : path +} +async function api(context, method, name, path, body, headers = {}) { + const response = await context.requestOverseas(method, path, body, headers, context.overseasToken) + const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) + return { response, data } +} +async function domesticApi(context, name, path) { + const response = await context.request('GET', path) + const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) + return { response, data } +} +function blocker(context, name, evidence, response = null) { + context.addStep(MODULE, name, response, false, `${evidence}; ${trace(response)}`, OPTIONS) + context.addCoverageGap(MODULE, SCENARIO, name, evidence, 'fixture', { deploymentBlocker: true }) +} +function targetProducer(context) { + const matrix = context.createdResources?.phase4InboundProducerFixtures + if (matrix?.marker !== context.marker) return null + return matrix.containers?.find((item) => item?.fulfillmentType === 'ONE_CLICK_DROPSHIP') + || matrix.byDestinationType?.FBA?.[0] + || matrix.byDestinationType?.OVERSEAS?.[0] + || null +} +async function markerOrders(context, producer) { + let last = null + for (let attempt = 1; attempt <= 60; attempt += 1) { + last = await api(context, 'GET', `等待本轮尾程订单落地(${attempt}/60)`, + query(ORDERS, { page: 1, size: 200, containerNo: producer.containerNo })) + const matched = records(last.data).filter((row) => id(row?.containerNo) === id(producer.containerNo) + && id(row?.customerId) === id(producer.tenantId) && id(row?.id)) + if (matched.length > 0) return { rows: matched, response: last.response } + if (attempt < 60) await new Promise((resolve) => setTimeout(resolve, 1_000)) + } + return { rows: [], response: last?.response || null } +} +async function detail(context, row, name = '读取尾程订单即时详情') { + return api(context, 'GET', name, + query(`${ORDERS}/${encodeURIComponent(id(row.id))}`, { customerId: id(row.customerId) })) +} +async function verifyReceivingWarehouseChange(context, producer, details) { + const bookingId = id(producer.containerBookingId) + if (!bookingId) { + blocker(context, '取得本轮来源柜预订ID', `containerNo=${producer.containerNo}`) + return false + } + const progressPath = `${ORDERS}/container-summaries/${encodeURIComponent(bookingId)}` + const current = await api(context, 'GET', '读取本轮柜级入仓计划进度', progressPath) + const summary = current.data?.summary + const originalWarehouseId = id(summary?.overseasWarehouseId) + if (!success(current.response) || !originalWarehouseId || summary?.canChangeReceivingWarehouse !== true) { + blocker(context, '柜级接柜仓可变更前置', + `bookingId=${bookingId}, warehouseId=${originalWarehouseId || 'missing'}, canChange=${summary?.canChangeReceivingWarehouse}`, current.response) + return false + } + const warehouses = await domesticApi(context, '查询可用公共海外接柜仓', + '/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE') + const alternate = records(warehouses.data).find((row) => id(row?.id) && id(row.id) !== originalWarehouseId + && row?.warehouseType === 'OVERSEAS' && row?.status === 'ACTIVE') + if (!alternate) { + blocker(context, '柜级接柜仓真实切换候选', + `originalWarehouseId=${originalWarehouseId}; 至少需要2个ACTIVE公共OVERSEAS接柜仓`, warehouses.response) + return false + } + const changePath = `${progressPath}/receiving-warehouse` + const changeKey = `${context.marker}-receiving-warehouse-change`.slice(0, 128) + const changeBody = { overseasWarehouseId: id(alternate.id), expectedVersion: Number(summary.assignmentVersion), remark: context.marker } + const changed = await api(context, 'PUT', '整柜切换到另一公共接柜仓', changePath, changeBody, + { 'Idempotency-Key': changeKey }) + context.assert(MODULE, '接柜仓变更原子推进版本并保持柜内订单数', changed.response, + id(changed.data?.overseasWarehouseId) === id(alternate.id) + && Number(changed.data?.assignmentVersion) === Number(summary.assignmentVersion) + 1 + && Number(changed.data?.totalOrderCount) === details.length, + trace(changed.response), OPTIONS) + if (!success(changed.response)) return false + const replay = await api(context, 'PUT', '接柜仓变更同键同载荷重放', changePath, changeBody, + { 'Idempotency-Key': changeKey }) + context.assert(MODULE, '接柜仓变更重放版本零漂移', replay.response, + Number(replay.data?.assignmentVersion) === Number(changed.data?.assignmentVersion) + && id(replay.data?.overseasWarehouseId) === id(alternate.id), trace(replay.response), OPTIONS) + const hashConflict = await context.requestOverseas('PUT', changePath, { ...changeBody, remark: `${context.marker}-DIFFERENT` }, + { 'Idempotency-Key': changeKey }, context.overseasToken) + context.addStep(MODULE, '接柜仓变更同键异载荷返回A0409', hashConflict, conflict(hashConflict), trace(hashConflict), OPTIONS) + const stale = await context.requestOverseas('PUT', changePath, + { overseasWarehouseId: originalWarehouseId, expectedVersion: Number(summary.assignmentVersion), remark: context.marker }, + { 'Idempotency-Key': `${context.marker}-receiving-warehouse-stale`.slice(0, 128) }, context.overseasToken) + context.addStep(MODULE, '接柜仓变更旧版本返回A0409', stale, conflict(stale), trace(stale), OPTIONS) + const restored = await api(context, 'PUT', '整柜恢复原公共接柜仓', changePath, + { overseasWarehouseId: originalWarehouseId, expectedVersion: Number(changed.data.assignmentVersion), remark: `${context.marker}-restore` }, + { 'Idempotency-Key': `${context.marker}-receiving-warehouse-restore`.slice(0, 128) }) + context.assert(MODULE, '接柜仓恢复后供后续PDA消费', restored.response, + id(restored.data?.overseasWarehouseId) === originalWarehouseId + && Number(restored.data?.assignmentVersion) === Number(changed.data.assignmentVersion) + 1, + trace(restored.response), OPTIONS) + return success(restored.response) +} + +async function verifyDestinationMergeFeeAndExport(context, details, producer) { + const fbaResult = await domesticApi(context, '查询公共ACTIVE FBA目的仓', + '/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&warehouseType=FBA&status=ACTIVE') + const fba = records(fbaResult.data).find((row) => id(row?.id) && nonBlank(row?.warehouseCode) + && nonBlank(row?.warehouseName) && row?.warehouseType === 'FBA' && row?.status === 'ACTIVE') + if (!fba) { + blocker(context, '尾程目的仓变更权威主档', '国内Warehouse未返回公共ACTIVE FBA仓', fbaResult.response) + return false + } + const destination = { warehouseId: id(fba.id), warehouseCode: fba.warehouseCode, + warehouseName: fba.warehouseName, remark: context.marker } + const representative = details[0] + const one = await api(context, 'PUT', '单票变更FBA目的仓', + query(`${ORDERS}/${encodeURIComponent(id(representative.id))}/destination`, { customerId: id(representative.customerId) }), destination) + context.assert(MODULE, '单票目的仓变更返回1', one.response, Number(one.data) === 1, trace(one.response), OPTIONS) + if (!success(one.response)) return false + const batchOrders = details.slice(1).length > 0 ? details.slice(1) : [representative] + const batch = await api(context, 'PUT', '批量变更FBA目的仓', + query(`${ORDERS}/destination`, { customerId: id(representative.customerId) }), + { ...destination, orderIds: batchOrders.map((row) => id(row.id)) }) + context.assert(MODULE, '批量目的仓变更数量精确', batch.response, + Number(batch.data) === batchOrders.length, trace(batch.response), OPTIONS) + if (!success(batch.response)) return false + + const mergePath = query(`${ORDERS}/${encodeURIComponent(id(representative.id))}/pallet-merge-required`, { + customerId: id(representative.customerId), + }) + const marked = await api(context, 'PUT', '单票开启PDA合托提示', mergePath, { palletMergeRequired: true }) + context.assert(MODULE, '单票合托标记写入成功', marked.response, marked.data == null, trace(marked.response), OPTIONS) + const markedRead = await detail(context, representative, '回查单票合托标记') + context.assert(MODULE, '单票合托标记为REQUIRED', markedRead.response, + markedRead.data?.order?.palletMergeFlag === 'REQUIRED', trace(markedRead.response), OPTIONS) + const unmarked = await api(context, 'PUT', '单票取消PDA合托提示', mergePath, { palletMergeRequired: false }) + context.assert(MODULE, '单票合托标记可取消', unmarked.response, unmarked.data == null, trace(unmarked.response), OPTIONS) + + const allIds = details.map((row) => id(row.id)) + const batchMergePath = query(`${ORDERS}/pallet-merge-required/batch`, { customerId: id(representative.customerId) }) + const batchMarked = await api(context, 'PUT', '批量开启PDA合托分组', batchMergePath, + { orderIds: allIds, palletMergeRequired: true }) + context.assert(MODULE, '批量合托分组写入成功', batchMarked.response, batchMarked.data == null, trace(batchMarked.response), OPTIONS) + const groupRead = await detail(context, representative, '回查批量合托关系') + context.assert(MODULE, '批量合托关系包含本轮订单', groupRead.response, + groupRead.data?.order?.palletMergeFlag === 'REQUIRED' + && nonBlank(groupRead.data?.order?.palletMergeBatchId), trace(groupRead.response), OPTIONS) + const batchCleared = await api(context, 'PUT', '批量取消PDA合托分组', batchMergePath, + { orderIds: allIds, palletMergeRequired: false }) + context.assert(MODULE, '批量合托分组取消成功', batchCleared.response, batchCleared.data == null, trace(batchCleared.response), OPTIONS) + + const feeItem = `${context.marker}-TAIL-FEE`.slice(0, 128) + const fee = await api(context, 'POST', '新增本轮尾程订单费用', + query(`${ORDERS}/${encodeURIComponent(id(representative.id))}/fees`, { customerId: id(representative.customerId) }), + { feeItem, currency: 'USD', amount: 0.01, status: 'PENDING_REVIEW', supplierDescription: context.marker }) + context.assert(MODULE, '尾程费用冻结币种金额和审核状态', fee.response, + id(fee.data?.orderId) === id(representative.id) && fee.data?.feeItem === feeItem + && fee.data?.currency === 'USD' && Number(fee.data?.amount) === 0.01 && fee.data?.status === 'PENDING_REVIEW', + trace(fee.response), OPTIONS) + const feeRead = await detail(context, representative, '回查尾程订单费用明细') + context.assert(MODULE, '尾程费用详情可追溯', feeRead.response, + feeRead.data?.fees?.some((row) => row?.feeItem === feeItem && Number(row?.amount) === 0.01), trace(feeRead.response), OPTIONS) + + const exported = await context.requestOverseasBinary('GET', query(`${ORDERS}/export`, { + customerId: id(representative.customerId), containerNo: producer.containerNo, + })) + const csv = new TextDecoder().decode(exported.bytes || new Uint8Array()) + context.addStep(MODULE, '导出本轮尾程订单CSV', exported, + exported?.http === 200 && String(exported?.contentType || '').toLowerCase().includes('csv') + && csv.includes(producer.containerNo) && csv.includes('尾程单号'), + `bytes=${exported?.bytes?.length ?? 0}, containerNo=${producer.containerNo}; ${trace(exported)}`, OPTIONS) + return success(batchCleared.response) && success(fee.response) && exported?.http === 200 +} +function manualPlanBody(order, version, marker) { + return { + customerId: id(order.customerId), orderId: id(order.id), expectedOrderVersion: Number(version), + plannedArrivalDate: new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 10), + inboundCode: order.inboundCode, + palletCount: Math.min(999, Math.max(1, Number(order.palletCount) || 1)), + palletType: 'STANDARD', assigned: false, mergeRequired: false, urgent: true, + remark: marker, + } +} +function completeManualNodes(marker) { + const base = Date.now() - 8 * 60 * 60_000 + return NODE_CODES.map((nodeCode, index) => ({ + nodeCode, nodeAt: localIso(new Date(base + index * 60 * 60_000)), + })).map((node, index) => index === 3 ? { ...node, nodeAt: null } : node) +} + +export async function run(context) { + context.createdResources.phase4TailOrderControls = { + marker: context.marker, containerNo: null, orderIds: [], planNos: [], representativeOrderId: null, + finalStatus: null, + cleanup: 'Manual inbound plans remain as the marker-owned prerequisites consumed by overseas PDA receiving', + } + const producer = targetProducer(context) + if (!producer?.containerNo || !producer?.tenantId) { + blocker(context, '取得本轮尾程控制目标柜', '缺少本轮phase4入仓生产者,禁止借历史尾程订单') + return + } + const listed = await markerOrders(context, producer) + if (listed.rows.length === 0) { + blocker(context, '取得本轮尾程订单', `containerNo=${producer.containerNo}, customerId=${producer.tenantId}`, listed.response) + return + } + const details = [] + for (const row of listed.rows) { + const current = await detail(context, row) + if (success(current.response) && id(current.data?.order?.id) === id(row.id)) details.push(current.data.order) + } + if (details.length !== listed.rows.length || details.some((order) => !order.inboundCode + || !order.overseasWarehouseId)) { + blocker(context, '尾程订单人工计划冻结事实完整', `listed=${listed.rows.length}, detailed=${details.length}`) + return + } + if (!await verifyReceivingWarehouseChange(context, producer, details)) return + if (!await verifyDestinationMergeFeeAndExport(context, details, producer)) return + const refreshedDetails = [] + for (const original of details) { + const refreshed = await detail(context, original, `管理命令后刷新订单${id(original.id)}版本`) + if (!success(refreshed.response) || !refreshed.data?.order) return + refreshedDetails.push(refreshed.data.order) + } + const representative = refreshedDetails[0] + const orderPath = query(`${ORDERS}/${encodeURIComponent(id(representative.id))}/hold`, { + customerId: id(representative.customerId), + }) + const holdKey = `${context.marker}-tail-hold`.slice(0, 128) + const holdBody = { held: true, chargeable: true, reason: `${context.marker} 暂扣验收`, expectedVersion: Number(representative.version) } + const held = await api(context, 'PUT', '暂扣本轮单票尾程订单', orderPath, holdBody, { 'Idempotency-Key': holdKey }) + context.assert(MODULE, '暂扣只推进单票版本并保留计费原因', held.response, + held.data?.held === true && held.data?.chargeable === true + && id(held.data?.orderId) === id(representative.id) && Number(held.data?.version) === Number(representative.version) + 1, + trace(held.response), OPTIONS) + if (!success(held.response)) return + const heldReplay = await api(context, 'PUT', '暂扣命令同键同载荷重放', orderPath, holdBody, { 'Idempotency-Key': holdKey }) + context.assert(MODULE, '暂扣重放版本零漂移', heldReplay.response, + JSON.stringify(heldReplay.data) === JSON.stringify(held.data), trace(heldReplay.response), OPTIONS) + const heldConflict = await context.requestOverseas('PUT', orderPath, + { ...holdBody, reason: `${holdBody.reason}-DIFFERENT` }, { 'Idempotency-Key': holdKey }, context.overseasToken) + context.addStep(MODULE, '暂扣同键异载荷返回A0409', heldConflict, conflict(heldConflict), trace(heldConflict), OPTIONS) + + const blockedPlanBody = manualPlanBody(representative, held.data.version, context.marker) + const blockedPlan = await context.requestOverseas('POST', `${PLANS}/manual`, blockedPlanBody, + { 'Idempotency-Key': `${context.marker}-held-plan`.slice(0, 128) }, context.overseasToken) + context.addStep(MODULE, '暂扣尾程订单不能生成人工计划', blockedPlan, conflict(blockedPlan), trace(blockedPlan), OPTIONS) + + const releaseKey = `${context.marker}-tail-release`.slice(0, 128) + const releaseBody = { held: false, chargeable: false, reason: `${context.marker} 解除暂扣`, expectedVersion: Number(held.data.version) } + const released = await api(context, 'PUT', '解除本轮单票尾程暂扣', orderPath, releaseBody, { 'Idempotency-Key': releaseKey }) + context.assert(MODULE, '解除暂扣清空计费与原因并推进版本', released.response, + released.data?.held === false && released.data?.chargeable === false && released.data?.reason == null + && Number(released.data?.version) === Number(held.data.version) + 1, trace(released.response), OPTIONS) + if (!success(released.response)) return + + const nodesPath = query(`${ORDERS}/${encodeURIComponent(id(representative.id))}/manual-nodes`, { + customerId: id(representative.customerId), + }) + const nodesKey = `${context.marker}-tail-manual-nodes`.slice(0, 128) + const nodesBody = { expectedVersion: Number(released.data.version), nodes: completeManualNodes(context.marker) } + const nodes = await api(context, 'PUT', '保存尾程八个人工节点快照', nodesPath, nodesBody, + { 'Idempotency-Key': nodesKey }) + context.assert(MODULE, '人工节点永久切换MANUAL且完整返回八行', nodes.response, + nodes.data?.manualOverrideEnabled === true && nodes.data?.nodes?.length === 8 + && nodes.data.nodes.every((node) => node?.source === 'MANUAL') + && Number(nodes.data?.version) === Number(released.data.version) + 1, + `nodes=${nodes.data?.nodes?.length ?? 'missing'}; ${trace(nodes.response)}`, OPTIONS) + if (!success(nodes.response)) return + const nodesReplay = await api(context, 'PUT', '人工节点同键同载荷重放', nodesPath, nodesBody, + { 'Idempotency-Key': nodesKey }) + context.assert(MODULE, '人工节点重放版本与八行快照零漂移', nodesReplay.response, + JSON.stringify(nodesReplay.data) === JSON.stringify(nodes.data), trace(nodesReplay.response), OPTIONS) + const nodesConflict = await context.requestOverseas('PUT', nodesPath, + { ...nodesBody, nodes: nodesBody.nodes.map((node, index) => index === 0 ? { ...node, nodeAt: localIso(new Date()) } : node) }, + { 'Idempotency-Key': nodesKey }, context.overseasToken) + context.addStep(MODULE, '人工节点同键异载荷返回A0409', nodesConflict, conflict(nodesConflict), trace(nodesConflict), OPTIONS) + const nodesRead = await api(context, 'GET', '回查尾程人工节点来源', nodesPath) + context.assert(MODULE, '人工节点读取固定为MANUAL且不回退柜级节点', nodesRead.response, + nodesRead.data?.manualOverrideEnabled === true && nodesRead.data?.nodes?.length === 8 + && nodesRead.data.nodes.every((node) => node?.source === 'MANUAL'), trace(nodesRead.response), OPTIONS) + + const plans = [] + for (const original of refreshedDetails) { + const refreshed = await detail(context, original, `创建人工计划前刷新订单${id(original.id)}版本`) + const order = refreshed.data?.order + if (!success(refreshed.response) || !order) return + const body = manualPlanBody(order, order.version, context.marker) + const key = `${context.marker}-manual-plan-${id(order.id)}`.slice(0, 128) + const created = await api(context, 'POST', `为本轮尾程订单${id(order.id)}创建人工计划`, `${PLANS}/manual`, body, + { 'Idempotency-Key': key }) + context.assert(MODULE, '人工计划按订单稳定接柜仓回读当前主档并冻结入仓码、托数和操作标志', created.response, + Boolean(created.data?.planNo) && created.data?.creationMode === 'MANUAL' + && Boolean(created.data?.overseasWarehouseCode) && Boolean(created.data?.overseasWarehouseName) + && created.data?.plannedArrivalDate === body.plannedArrivalDate + && created.data?.palletType === body.palletType && created.data?.urgent === true + && Number(created.data?.palletCount) === body.palletCount + && created.data?.lines?.some((line) => id(line?.orderId) === id(order.id) && line?.inboundCode === order.inboundCode), + `orderId=${id(order.id)}, planNo=${created.data?.planNo || 'missing'}; ${trace(created.response)}`, OPTIONS) + if (!success(created.response) || !created.data?.planNo) return + const replay = await api(context, 'POST', `人工计划${created.data.planNo}幂等重放`, `${PLANS}/manual`, body, + { 'Idempotency-Key': key }) + context.assert(MODULE, '人工计划重放复用同一计划且零新增', replay.response, + replay.data?.planNo === created.data.planNo, trace(replay.response), OPTIONS) + if (plans.length === 0) { + const hashConflict = await context.requestOverseas('POST', `${PLANS}/manual`, + { ...body, remark: `${context.marker}-DIFFERENT` }, { 'Idempotency-Key': key }, context.overseasToken) + context.addStep(MODULE, '人工计划同键异载荷返回A0409', hashConflict, conflict(hashConflict), trace(hashConflict), OPTIONS) + } + plans.push(created.data.planNo) + } + context.createdResources.phase4TailOrderControls = { + ...context.createdResources.phase4TailOrderControls, + containerNo: producer.containerNo, orderIds: refreshedDetails.map((order) => id(order.id)), + planNos: plans, representativeOrderId: id(representative.id), finalStatus: 'MANUAL_PLANS_READY', + } +} diff --git a/scripts/admin-test-env-modules/phase5-terminal-drayage-contract.test.mjs b/scripts/admin-test-env-modules/phase5-terminal-drayage-contract.test.mjs new file mode 100644 index 000000000..04935c24b --- /dev/null +++ b/scripts/admin-test-env-modules/phase5-terminal-drayage-contract.test.mjs @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' +import { fileURLToPath } from 'node:url' +import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' + +const source = await readFile(fileURLToPath(new URL('./phase5-terminal-drayage.mjs', import.meta.url)), 'utf8') + +test('terminal drayage runs after pickup-ready and overseas unloading but before final carrier validation', () => { + const modules = normalizeModuleOrder(['phase4-carrier-nodes']) + const pickup = modules.indexOf('phase4-carrier-pickup-ready') + const pda = modules.indexOf('phase4-overseas-pda') + const drayage = modules.indexOf('phase5-terminal-drayage') + const finalCarrier = modules.indexOf('phase4-carrier-nodes') + assert.ok(pickup >= 0 && pickup < pda) + assert.ok(pda < drayage && drayage < finalCarrier) +}) + +test('module uses only public business endpoints and reuses public trucking master data', () => { + for (const path of [ + '/api/platform/v1/terminal-drayages', "'fleet'", "'pickup-appointment'", "'pickup'", "'empty-return'", '/history', + 'category=TRUCKING', + ]) assert.ok(source.includes(path), `missing ${path}`) + assert.doesNotMatch(source, /\/internal\//) + assert.doesNotMatch(source, /POST[^\n]*\/api\/platform\/v1\/suppliers/) + assert.match(source, /脚本不临时创建主数据/) +}) + +test('all four commands cover idempotent replay hash conflict and lifecycle semantics', () => { + for (const token of [ + '选择车队', '预约提柜', '确认实际提柜', '确认实际还柜', + '同键重放', '同键异载荷返回A0409', 'PICKED_UP', 'UNLOADED', 'EMPTY_RETURNED', + 'SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_PICKUP', 'RECORD_EMPTY_RETURN', 'RESOLVED', + ]) assert.ok(source.includes(token), `missing ${token}`) + assert.match(source, /JSON\.stringify\(replay\.data\) === JSON\.stringify\(first\.data\)/) +}) + +test('target is strictly current-marker pickup and completed-PDA container', () => { + assert.match(source, /matrix\?\.marker !== context\.marker/) + assert.match(source, /pda\?\.marker !== context\.marker/) + assert.match(source, /pda\?\.finalStatus !== 'COMPLETED'/) + assert.match(source, /completedContainerFacts/) + assert.match(source, /item\?\.shippingReady === true/) + assert.match(source, /carrierScheduleQueryId/) + assert.match(source, /carrierLatestActualAt/) + assert.match(source, /phase4CarrierPickupReady =/) + assert.match(source, /禁止借历史货柜/) +}) diff --git a/scripts/admin-test-env-modules/phase5-terminal-drayage.mjs b/scripts/admin-test-env-modules/phase5-terminal-drayage.mjs new file mode 100644 index 000000000..b0ffb1391 --- /dev/null +++ b/scripts/admin-test-env-modules/phase5-terminal-drayage.mjs @@ -0,0 +1,147 @@ +const MODULE = 'phase5-terminal-drayage' +const SCENARIO = 'phase5-terminal-drayage-lifecycle' +const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } +const BASE = '/api/platform/v1/terminal-drayages' + +function id(value) { return value == null ? '' : String(value) } +function codeOf(response) { return response?.body?.code == null ? '' : String(response.body.code) } +function success(response) { return response?.http === 200 && codeOf(response) === '200' } +function conflict(response) { return response?.http === 409 || (response?.http === 200 && codeOf(response) === 'A0409') } +function localIso(value) { return value.toISOString().slice(0, 19) } +function trace(response) { + return `HTTP=${response?.http ?? 'missing'}, code=${codeOf(response) || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` +} +async function api(context, method, name, path, body, headers = {}) { + const response = await context.request(method, path, body, context.token, headers) + const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) + return { response, data } +} +function blocker(context, name, evidence, response = null) { + context.addStep(MODULE, name, response, false, `${evidence}; ${trace(response)}`, OPTIONS) + context.addCoverageGap(MODULE, SCENARIO, name, evidence, 'fixture', { deploymentBlocker: true }) +} +function fixture(context) { + const matrix = context.createdResources?.phase4InboundProducerFixtures + const pda = context.createdResources?.phase4OverseasPda + if (matrix?.marker !== context.marker || pda?.marker !== context.marker + || pda?.finalStatus !== 'COMPLETED') return null + const producers = Array.isArray(matrix.containers) ? matrix.containers : [] + for (const completed of pda.completedContainerFacts || []) { + const producer = producers.find((item) => id(item?.containerBookingId) === id(completed?.containerBookingId) + && id(item?.containerNo) === id(completed?.containerNo) + && item?.shippingReady === true && id(item?.carrierScheduleQueryId) + && item?.carrierLatestActualAt) + if (producer) return { + matrix, producer, pda, completed, containerBookingId: id(producer.containerBookingId), + } + } + return null +} +async function command(context, target, action, path, body) { + const key = `${context.marker}-terminal-${action}`.slice(0, 128) + const first = await api(context, 'POST', `目的港码头拖车${action}`, `${BASE}/${target.containerBookingId}/${path}`, + body, { 'Idempotency-Key': key }) + if (!success(first.response)) return first + const replay = await api(context, 'POST', `目的港码头拖车${action}同键重放`, + `${BASE}/${target.containerBookingId}/${path}`, body, { 'Idempotency-Key': key }) + context.assert(MODULE, `${action}重放状态和版本零漂移`, replay.response, + JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS) + const changed = path === 'fleet' ? { ...body, reason: `${body.reason}-DIFFERENT` } + : { ...body, reason: `${body.reason}-DIFFERENT` } + const hashConflict = await context.request('POST', `${BASE}/${target.containerBookingId}/${path}`, + changed, context.token, { 'Idempotency-Key': key }) + context.addStep(MODULE, `${action}同键异载荷返回A0409`, hashConflict, + conflict(hashConflict), trace(hashConflict), OPTIONS) + return first +} + +export async function run(context) { + context.createdResources.phase5TerminalDrayage = { + marker: context.marker, containerBookingId: null, containerNo: null, finalStatus: null, + cleanup: 'Terminal drayage ends at immutable EMPTY_RETURNED and is retained as acceptance evidence', + } + const target = fixture(context) + if (!target) { + blocker(context, '取得本轮PICKUP_READY且已卸柜货柜', + `matrix=${context.createdResources?.phase4InboundProducerFixtures?.marker || 'missing'}, shippingReady=${context.createdResources?.phase4InboundProducerFixtures?.containers?.filter((item) => item?.shippingReady === true).length ?? 0}, pda=${context.createdResources?.phase4OverseasPda?.finalStatus || 'missing'}; 禁止借历史货柜`) + return + } + const path = `${BASE}/${encodeURIComponent(target.containerBookingId)}` + const initial = await api(context, 'GET', '读取目的港码头拖车初始聚合', path) + context.assert(MODULE, '初始聚合绑定本轮货柜且允许选择车队', initial.response, + id(initial.data?.containerBookingId) === target.containerBookingId + && id(initial.data?.containerNo) === id(target.completed.containerNo) + && Number(initial.data?.version) === 0 && initial.data?.allowedActions?.includes('SELECT_FLEET'), + `status=${initial.data?.status || 'missing'}, actions=${initial.data?.allowedActions?.join(',') || 'missing'}; ${trace(initial.response)}`, OPTIONS) + if (!success(initial.response)) return + + const suppliers = await api(context, 'GET', '查询ACTIVE码头拖车供应商', + '/api/platform/v1/suppliers?tenantId=0&category=TRUCKING&status=ACTIVE&page=1&size=100') + const fleet = suppliers.data?.records?.find((item) => id(item?.id) && item?.status === 'ACTIVE') + if (!fleet) { + blocker(context, '取得有效码头拖车供应商', '没有可复用的ACTIVE + TRUCKING公共供应商;脚本不临时创建主数据', suppliers.response) + return + } + const selected = await command(context, target, '选择车队', 'fleet', { + expectedVersion: Number(initial.data.version), fleetSupplierId: id(fleet.id), + fleetSupplierName: fleet.name, reason: context.marker, + }) + context.assert(MODULE, '选车队后只绑定公共供应商并开放预约', selected.response, + id(selected.data?.fleetSupplierId) === id(fleet.id) && selected.data?.fleetSupplierName === fleet.name + && selected.data?.allowedActions?.includes('SCHEDULE_PICKUP'), trace(selected.response), OPTIONS) + if (!success(selected.response)) return + + const latestActual = Date.parse(`${target.producer.carrierLatestActualAt}Z`) + const appointmentAt = localIso(new Date((Number.isNaN(latestActual) ? Date.now() : latestActual) + 60 * 60_000)) + const scheduled = await command(context, target, '预约提柜', 'pickup-appointment', { + expectedVersion: Number(selected.data.version), pickupAppointmentAt: appointmentAt, reason: context.marker, + }) + context.assert(MODULE, '预约提柜时间和版本持久化', scheduled.response, + scheduled.data?.pickupAppointmentAt === appointmentAt + && Number(scheduled.data?.version) === Number(selected.data.version) + 1, + trace(scheduled.response), OPTIONS) + if (!success(scheduled.response)) return + + const pickupAt = localIso(new Date(Date.parse(`${appointmentAt}Z`) + 60 * 60_000)) + const picked = await command(context, target, '确认实际提柜', 'pickup', { + expectedVersion: Number(scheduled.data.version), actualAt: pickupAt, reason: context.marker, + }) + context.assert(MODULE, '实际提柜同步PICKED_UP事实并在已卸柜聚合开放还柜', picked.response, + picked.data?.status === 'UNLOADED' && picked.data?.actualPickupAt === pickupAt + && picked.data?.allowedActions?.includes('RECORD_EMPTY_RETURN'), trace(picked.response), OPTIONS) + if (!success(picked.response)) return + + const unloadingAt = Date.parse(`${picked.data?.unloadingCompletedAt}Z`) + const returnBase = Math.max(Date.parse(`${pickupAt}Z`), Number.isNaN(unloadingAt) ? 0 : unloadingAt) + const returnedAt = localIso(new Date(returnBase + 60 * 60_000)) + const returned = await command(context, target, '确认实际还柜', 'empty-return', { + expectedVersion: Number(picked.data.version), actualAt: returnedAt, reason: context.marker, + }) + context.assert(MODULE, '实际还柜同步EMPTY_RETURNED并关闭全部动作', returned.response, + returned.data?.status === 'EMPTY_RETURNED' && returned.data?.actualEmptyReturnAt === returnedAt + && Array.isArray(returned.data?.allowedActions) && returned.data.allowedActions.length === 0 + && returned.data?.emptyReturnWarning?.status === 'RESOLVED', trace(returned.response), OPTIONS) + if (!success(returned.response)) return + + const list = await api(context, 'GET', '批量回读本轮码头拖车聚合', + `${BASE}?containerBookingIds=${encodeURIComponent(target.containerBookingId)}`) + context.assert(MODULE, '批量读取只命中本轮货柜终态', list.response, + Array.isArray(list.data) && list.data.length === 1 + && id(list.data[0]?.containerBookingId) === target.containerBookingId + && list.data[0]?.status === 'EMPTY_RETURNED', trace(list.response), OPTIONS) + const history = await api(context, 'GET', '读取目的港码头拖车不可变历史', `${path}/history`) + const actions = new Set((Array.isArray(history.data) ? history.data : []).map((item) => item?.action)) + context.assert(MODULE, '历史完整记录四个业务动作和连续版本', history.response, + ['SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_PICKUP', 'RECORD_EMPTY_RETURN'].every((action) => actions.has(action)) + && history.data.every((item) => Number(item?.toVersion) === Number(item?.fromVersion) + 1), + `actions=${[...actions].join(',')}; ${trace(history.response)}`, OPTIONS) + context.createdResources.phase5TerminalDrayage = { + ...context.createdResources.phase5TerminalDrayage, + containerBookingId: target.containerBookingId, containerNo: target.completed.containerNo, + finalStatus: 'EMPTY_RETURNED', version: Number(returned.data.version), + } + context.createdResources.phase4CarrierPickupReady = { + marker: context.marker, queryId: id(target.producer.carrierScheduleQueryId), + latestActualAt: target.producer.carrierLatestActualAt, ready: true, + } +} diff --git a/scripts/admin-test-env-modules/test-container-inbound-plan-batch-contract.test.mjs b/scripts/admin-test-env-modules/test-container-inbound-plan-batch-contract.test.mjs index 472e02f9b..8b82ad51c 100644 --- a/scripts/admin-test-env-modules/test-container-inbound-plan-batch-contract.test.mjs +++ b/scripts/admin-test-env-modules/test-container-inbound-plan-batch-contract.test.mjs @@ -96,8 +96,8 @@ test('Admin consumes a server-paged container summary and never derives 15 from assert.match(adminApi, /listContainerPlanProgress/) assert.match(adminApi, /container-summaries/) assert.match(adminView, /listContainerPlanProgress\(/) - assert.match(adminView, /const pendingRecords\s*=\s*result\.records\.filter/) - assert.match(adminView, /containerRows\.value\s*=\s*pendingRecords/) + assert.match(adminView, /containerRows\.value\s*=\s*result\.records/) + assert.match(adminView, /containerTotal\.value\s*=\s*result\.total/) assert.match(adminView, /containerOptions\.value\s*=\s*result\.records/) assert.match(adminView, /if \(request !== containerOptionRequest\) return/) assert.doesNotMatch(adminView, /(?:rows|result\.records)\.value?\.(?:reduce|filter|forEach)[\s\S]{0,300}containerNo/) diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 07a8f930f..298e953aa 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -127,13 +127,13 @@ Assert-Contains $docsIndex '[开发环境独立任务协作登记](开发环境 Assert-Contains $testTaskRegistry 'projectId `c64729ff-6710-43c6-8ef2-d84e9666cc1e`' '测试登记保存项目' Assert-Contains $testTaskRegistry 'C:\Users\BY-OY\Desktop\code\chongchong-global`' '测试登记工作目录' Assert-Contains $testTaskRegistry '已重新启用测试环境总控调度' '测试登记启用状态' +Assert-Contains $testTaskRegistry '`01a05b10-a5fc-7d51-8203-c671f3a823ad`' '测试环境全流程总控' Assert-Contains $testTaskRegistry '`01a046ea-cfc4-73a1-85f0-c69c714bff22`' '测试环境海外接柜仓总控' -Assert-Contains $testTaskRegistry '【测试专项】海外接柜后端' '测试环境海外接柜后端专项' -Assert-Contains $testTaskRegistry '【测试专项】海外接柜Admin' '测试环境海外接柜 Admin 专项' -Assert-Contains $testTaskRegistry '【测试专项】海外接柜Client' '测试环境海外接柜 Client 专项' -Assert-Contains $testTaskRegistry '【测试专项】海外接柜PDA' '测试环境海外接柜 PDA 专项' +Assert-Contains $testTaskRegistry '| 海外接柜后端 |' '测试环境海外接柜后端专项' +Assert-Contains $testTaskRegistry '| 海外接柜Admin前端 |' '测试环境海外接柜 Admin 专项' +Assert-Contains $testTaskRegistry '| 海外接柜Client前端 |' '测试环境海外接柜 Client 专项' +Assert-Contains $testTaskRegistry '| 海外接柜PDA前端 |' '测试环境海外接柜 PDA 专项' Assert-NotContains $testTaskRegistry '01a0194a-259c-7171-8967-074731a27618' '测试登记历史总控' -Assert-NotContains $testTaskRegistry '01a05b10-a5fc-7d51-8203-c671f3a823ad' '测试登记开发总控' Assert-NotContains $testTaskRegistry '01a06c0b-0515-7c60-b329-77342b54e666' '测试登记当前开发总控' Assert-NotContains $testTaskRegistry '6f874ec3-d3b2-49bd-8625-327f4d685f98' '测试登记开发 projectId' Assert-Contains $devTaskRegistry 'projectId `6f874ec3-d3b2-49bd-8625-327f4d685f98`' '开发登记保存项目' @@ -166,6 +166,13 @@ Assert-Contains $clientAgents '客户资金账户、授信与充值申请' 'Clie Assert-Contains $backendAgents '客户资金账户、充值、授信、冻结与结算' '后端客户资金账户路由' Assert-Contains $rootAgents '顶层授权快照' '根海外顶层授权快照' Assert-Contains $backendAgents '顶层授权快照' '后端海外顶层授权快照' +Assert-Contains $rootAgents '目的港码头拖车与还空预警' '根目的港码头拖车路由' +Assert-Contains $rootAgents '与订舱级拖车预约是不同生命周期' '根两类拖车边界' +Assert-Contains $rootAgents '尾程订单控制与柜级入仓计划转换' '根尾程订单控制路由' +Assert-Contains $adminAgents '目的港码头拖车与还空预警' 'Admin 目的港码头拖车路由' +Assert-Contains $adminAgents '尾程订单控制与柜级入仓计划转换' 'Admin 尾程订单控制路由' +Assert-Contains $backendAgents '目的港码头拖车与还空预警' '后端目的港码头拖车路由' +Assert-Contains $backendAgents '尾程订单控制与柜级入仓计划转换' '后端尾程订单控制路由' $testTaskIds = @([regex]::Matches($testTaskRegistry, '(?m)^\|\s*`([0-9a-f-]{36})`\s*\|') | ForEach-Object { $_.Groups[1].Value }) $devTaskIds = @([regex]::Matches($devTaskRegistry, '(?m)^\|\s*`([0-9a-f-]{36})`\s*\|') | ForEach-Object { $_.Groups[1].Value }) foreach ($sharedTaskId in @($testTaskIds | Where-Object { $devTaskIds -contains $_ } | Select-Object -Unique)) { @@ -174,6 +181,8 @@ foreach ($sharedTaskId in @($testTaskIds | Where-Object { $devTaskIds -contains } Assert-Contains $scriptsAgents 'client-quick-order-test-env-acceptance.mjs' '脚本指引 Client 快速下单入口' Assert-Contains $scriptsAgents 'admin-test-env-modules/service-center-flow.mjs' '脚本指引服务中心验收模块' +Assert-Contains $scriptsAgents 'admin-test-env-modules/phase4-tail-order-controls.mjs' '脚本指引尾程单控制模块' +Assert-Contains $scriptsAgents 'admin-test-env-modules/phase5-terminal-drayage.mjs' '脚本指引目的港码头拖车模块' Assert-Contains $scriptsAgents 'script-documentation-contract.test.mjs' '脚本说明防漏门禁' Assert-Contains $scriptsAgents '不得散落到 `src/test/scripts/`' '服务局部脚本目录边界' Assert-Path 'baiyue-firstleg/scripts/AGENTS.md' @@ -182,6 +191,7 @@ Assert-Path 'baiyue-firstleg/scripts/script-documentation-contract.test.mjs' Assert-Contains $mapperScanContract 'annotationClass = Mapper.class' 'Mapper 根包注解过滤合同' Assert-Contains $mapperScanContract '禁止分散维护 MapperScan' 'Mapper 单一扫描入口合同' $firstlegAgents = Read-RequiredText (Join-Path $backendRoot 'firstleg/AGENTS.md') +$lastlegAgents = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/AGENTS.md') $warehouseAgents = Read-RequiredText (Join-Path $backendRoot 'warehouse/AGENTS.md') $coloadOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/coload-groups-v1.yaml') Assert-Contains $rootAgents 'modules/unified-orders/api/coLoadGroups.ts' '根同柜不可拆组入口' @@ -201,6 +211,9 @@ $orderAgents = Read-RequiredText (Join-Path $backendRoot 'order/AGENTS.md') Assert-Contains $orderAgents '`channelCode` 直接来自 Order/Firstleg 权威快照' 'Order 稳定渠道编码' Assert-Contains $orderAgents 'InboundCodeAssociationService.java' 'Order 入仓码业务关系入口' Assert-Contains $firstlegAgents '/loading-plans/{planId}/lifecycle' 'Firstleg 装柜生命周期入口' +Assert-Contains $lastlegAgents 'LastlegOrderCommandService.java' 'Lastleg 尾程订单命令入口' +Assert-Contains $lastlegAgents '固定八行完整快照' 'Lastleg 人工节点快照边界' +Assert-Contains $lastlegAgents '/api/platform/v1/last-leg/inbound-plans/convert-container' 'Lastleg 整柜转换正式入口' # 本轮新增的批量下单、测量计费、智能装柜、账单联查与导出重试必须从最近指引直达。 $fileAgents = Read-RequiredText (Join-Path $backendRoot 'file/AGENTS.md') @@ -349,6 +362,11 @@ Assert-Contains $qualificationOpenApi 'BUSINESS_LICENSE 不允许走此入口' ' Assert-Contains $clientCompanyProfile '法人身份证已保存,尚未提交审核' 'Client 法人身份证单独上传反馈' Assert-NotContains $clientCompanyProfile '当前文件服务尚未开放法人身份证单独上传或替换' 'Client 已移除法人身份证旧阻断' $truckOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/truck-appointments-v1.yaml') +$terminalDrayageOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml') +$terminalDrayageService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java') +$lastlegOrderCommandService = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderCommandService.java') +$adminTerminalDrayages = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/container-loading/api/terminalDrayages.ts') +$adminLastMileOrders = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/api/lastMileOrders.ts') $loadingListsOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/loading-lists-v1.yaml') $pdaLoadingApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/api/pda-loading.js') $cabinetEvidenceService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceService.java') @@ -368,6 +386,20 @@ Assert-NotContains $truckOpenApi 'loadingPlanId:' '拖车 OpenAPI 装柜计划 Assert-Contains $truckOpenApi 'Each booking may have at most one non-terminal appointment' '拖车订舱级唯一性' Assert-Contains $truckOpenApi 'PDA loading completion does not change a booking-level truck appointment' '拖车与 PDA 生命周期解耦' Assert-Contains $truckOpenApi 'excluding bookings that already have a non-terminal truck appointment' '拖车创建候选过滤' +Assert-Contains $firstlegAgents '目的港码头拖车与还空预警' 'Firstleg 目的港码头拖车入口' +Assert-Contains $firstlegAgents '按卸柜完成时间加 72 小时' 'Firstleg 还空预警口径' +Assert-Contains $terminalDrayageOpenApi '/api/platform/v1/terminal-drayages/{containerBookingId}/fleet:' '码头拖车车队命令 OpenAPI' +Assert-Contains $terminalDrayageOpenApi '/api/platform/v1/terminal-drayages/{containerBookingId}/empty-return:' '码头拖车还柜命令 OpenAPI' +Assert-Contains $terminalDrayageService 'EMPTY_RETURN_WARNING_HOURS = 72' '码头拖车 72 小时预警实现' +Assert-Contains $terminalDrayageService 'context.unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS)' '码头拖车权威截止时间实现' +Assert-Contains $adminTerminalDrayages 'allowedActions' 'Admin 码头拖车服务端动作消费' +Assert-Contains $lastlegOrderCommandService '必须提交完整8行人工时间快照' '尾程人工节点完整快照实现' +Assert-Contains $lastlegOrderCommandService '尾程订单已进入出库流程,不能新增暂扣' '尾程暂扣出库门禁实现' +Assert-Contains $adminLastMileOrders '/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/hold' 'Admin 尾程暂扣入口' +Assert-Contains $adminLastMileOrders '/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/manual-nodes' 'Admin 尾程人工节点入口' +Assert-Contains $adminAgents '入仓码只读展示并提交订单冻结值' 'Admin 尾程计划冻结入仓码边界' +Assert-Contains $lastlegAgents '接柜仓编码/名称以该 ID 当前目录快照为准' 'Lastleg 稳定接柜仓身份边界' +Assert-Contains $lastlegOrderCommandService '同一幂等键不能用于不同请求' '尾程命令请求哈希重放边界' Assert-Contains $loadingListsOpenApi '真实集装箱号可在PDA确认装柜完成前补录' '预装真实柜号晚录门禁' Assert-Contains $loadingListsOpenApi '可在 EDITING 或 LOADING 阶段首次补录真实集装箱号' '预装真实柜号首次补录阶段' Assert-Contains $adminAgents '创建柜次时自动生成唯一预装计划' 'Admin 自动预装计划流程' @@ -651,7 +683,7 @@ Assert-Contains $lastlegBusinessOpenApi '/api/platform/v1/last-leg/orders/contai Assert-Contains $lastlegBusinessOpenApi '/api/platform/v1/last-leg/inbound-plans/convert-container:' 'Lastleg 整柜计划转换契约' Assert-Contains $lastlegBusinessOpenApi 'sourceContainerBookingId' 'Lastleg 稳定来源柜身份' Assert-Contains $adminAgents '/orders/container-summaries' 'Admin 柜级摘要目标入口' -Assert-Contains $rootAgents '柜级入仓计划进度与转换' '根柜级计划入口' +Assert-Contains $rootAgents '尾程订单控制与柜级入仓计划转换' '根柜级计划入口' Assert-Contains $backendAgents '分页前排除全部入仓码均已生成有效独立计划的柜' '后端柜级计划待处理筛选' Assert-Contains $adminAgents '返回仍有待生成计划入仓码的柜' 'Admin 柜级计划待处理筛选' Assert-Contains $lastlegAgents '分页前排除全部入仓码均已生成有效独立计划的柜' 'Lastleg 柜级计划待处理筛选' diff --git a/scripts/java-quality-summary.test.mjs b/scripts/java-quality-summary.test.mjs index a9cb41cec..bf77a3e19 100644 --- a/scripts/java-quality-summary.test.mjs +++ b/scripts/java-quality-summary.test.mjs @@ -65,7 +65,7 @@ test('repository freezes the current topology and non-blocking aggregate summary const summary = await buildQualitySummary(repositoryRoot) const baseline = JSON.parse(await readFile(path.join(repositoryRoot, 'scripts', 'java-quality-baseline.json'), 'utf8')) assert.deepEqual(summary.topology, { modules: 22, codeModules: 21, services: 15 }) - assert.equal(summary.openapi.documents, 39) + assert.equal(summary.openapi.documents, 40) assert.equal(summary.openapi.coveredServices.length, 8) assert.equal(summary.openapi.codeFirstServices.length, 7) assert.equal(baseline.mode, 'report-only')