This commit is contained in:
2026-09-08 14:07:31 +08:00
parent d318f4a450
commit 07688005a1
15 changed files with 695 additions and 87 deletions
+4 -2
View File
@@ -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 <name>`,模块名称和静态合同先查 `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/` 时不得作为业务事实或提交物。
+3 -2
View File
@@ -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` |
+1
View File
@@ -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/` | 靠近部署组件管理,不移动到根脚本目录;部署、初始化数据库或发布配置必须获得当前轮明确授权 |
+12 -6
View File
@@ -5,7 +5,7 @@ import { dirname, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import process from 'node:process'
const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'inbound-hold', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-container-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-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}`,
+8 -1
View File
@@ -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 入仓码前缀解析落盘及重新部署后的回归反证,不据此放宽成功合同。
@@ -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/)
@@ -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)
@@ -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)
@@ -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/)
})
@@ -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',
}
}
@@ -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, /禁止借历史货柜/)
})
@@ -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,
}
}
@@ -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/)
+38 -6
View File
@@ -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 柜级计划待处理筛选'
+1 -1
View File
@@ -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')