Merge test into dev and resolve conflicts

This commit is contained in:
2026-09-14 11:02:37 +08:00
342 changed files with 8454 additions and 1591 deletions
+7 -6
View File
@@ -39,7 +39,7 @@ export async function run(context) {
货柜查询使用 `/api/platform/v1/containers/domestic` 列表和 `/domestic/{id}` 详情,响应为 `WorkbenchItem`(`container`、`workbenchStage`、`statusSummary`、`allowedActions`),不接受旧扁平实体。默认 `phase3-container-loading` 同时验证页面的 ACTIVE、状态、关键字组合查询及详情结构;`container-detail-route-contract.test.mjs` 离线核对 Admin、Controller 和人工分段/同柜脚本读路径,并反证旧 DTO。定向真实 HTTP 使用总入口 `--modules phase3-container-loading`,仍自动展开本轮前置并创建测试资源;不借历史柜。
一件代发的 `inbound-measurement` 前置通过 PDA 正式 `pallets/create-and-bind` 创建系统 DP 码并绑定待清点箱,不再按测试 marker 拼托盘号;默认一件代发后续链复用该前置。验证清点前及绑定后无待绑箱请求均拒绝且无空托、系统发码同操作重放保持托盘/计数不变、封托完成后原操作可重放;离线场景反向注入重复发码和空托,保证回归确实识别这两类故障。
一件代发的 `inbound-measurement` 前置在测量草稿录入预计托数;审核通过时系统一次性预生成全部 DP 托盘码,允许空托先打印。PDA 既支持先清点箱再扫预生成托盘码批量归托,也支持先扫托盘后逐箱直接归托;预计托数不得超过实测箱数。验收逐托封托并确认全部箱数已绑定、全部预生成托盘已封托后任务才完成,默认一件代发后续链复用该前置。人工验收造数可传 `--prepare-dropship-manual-count true`,脚本会停在任务未领取、箱未清点、托盘未绑定的状态。
该前置保存成功解析且清点的原始 `boxCode`,随装柜完成生产 fixture 传入默认 `phase4-overseas-pda`。默认矩阵同时创建 FBA 和 OVERSEAS 两种一件代发,必须先将全部原箱码经海外 `current/cartons/resolve` 精确解析到同一入仓码及冻结目的类型,再进入实收及库存验证;OVERSEAS 逐箱实收,A0404、错误箱码/归属或数量缺失均阻断,不扩展推算箱号或排除失败候选。现行 V2 托盘 SKU 分组必须携带 `sourceInboundDetailId`,离线合同同时锁定两个生产分支来源明细和上述真实 HTTP 调用链。
@@ -91,7 +91,8 @@ export async function run(context) {
| Finance 扩展隔离与跨区费用 | `firstleg-container-fee-allocation.mjs`(柜次跨客户分摊)、`finance-direct-paid-isolation.mjs`(海外仓服务直接已付)、`phase4-overseas-finance-cross-region.mjs`(五类海外费用跨区进入 Finance) | 对应同名 `*-contract.test.mjs`;跨区事件持久化链在本轮获授权时另以只读数据库证据核对 |
| 客户钱包与拆柜打托费自动闭环 | `phase4-overseas-wallet-and-pallet-cost.mjs`(授信初始化、平台充值、采购凭证/费用冻结结算、换标冻结结算/释放、Client 余额账本、国内/海外托盘汇总、按体积分摊及跨区金额守恒) | `phase4-overseas-wallet-and-pallet-cost-contract.test.mjs`;默认全量已启用,定向入口自动展开已审核客户与海外 PDA 实收链 |
| 海外仓 HTTP 生命周期 | `phase4-overseas-warehouse.mjs` | `phase4-overseas-warehouse-contract.test.mjs` |
| 海外 PDA 柜号收货与库内操作 HTTP 生命周期 | `phase4-overseas-pda.mjs`(按柜完成签收和实收,成功后仅以内存上下文交接操作员会话和已完成库存事实)、`phase4-overseas-pda-operations.mjs`(紧随收货执行盘点、整托移库、托盘拆装换绑和换标复核;finally 停用双操作员并清空临时 Token;任何真实 fixture 缺失均 fail-closed) | `phase4-overseas-pda-contract.test.mjs`、`phase4-overseas-pda-operations-contract.test.mjs` |
| 海外 PDA 柜号收货与库内操作 HTTP 生命周期 | `phase4-overseas-pda.mjs`(按柜完成签收和实收,成功后仅以内存上下文交接操作员会话和已完成库存事实)、`phase4-overseas-pda-operations.mjs`(紧随收货执行盘点、公共位核对、托盘拆装换绑和换标复核;只复用本轮入仓计划已有空托盘及公共位,不新增托盘或库位;finally 停用双操作员并清空临时 Token;任何真实 fixture 缺失均 fail-closed) | `phase4-overseas-pda-contract.test.mjs`、`phase4-overseas-pda-operations-contract.test.mjs` |
| 海外 PDA 退货入库、计费批次与退货合箱 | `phase4-overseas-return-receipt.mjs`(自包含创建本轮客户退货预报和绑定仓临时 PDA 操作员;覆盖退货单号/SKU 查询、正常空结果、分批签收、核平、超收、幂等、退货箱/SKU 库存、每次成功签收唯一仓储计费批次及合箱;finally 停用操作员) | `phase4-overseas-return-receipt-contract.test.mjs`;`ReturnReceiptServiceTest` 精确核对计费批次客户、仓库、签收单、起算时间和 `PENDING_PRICING` 初态 |
| 尾程单公网控制与人工计划 | `phase4-tail-order-controls.mjs`(PDA 收货前对本轮柜执行整柜接柜仓变更/恢复与幂等冲突、单/批 FBA 目的仓变更、单/批合托标记并取消、CSV 导出、订单费用回查、单票暂扣/解除、八个人工节点和逐票人工计划) | `phase4-tail-order-controls-contract.test.mjs`;只复用现有公共接柜仓/FBA 主档,不新增仓库、托盘或库位;为目标柜全部入仓码创建 `MANUAL` 计划后交给 PDA 继续消费,不借历史订单 |
| 海外入仓扩展、客户服务与排车 | `phase4-overseas-inbound-operations.mjs`、`phase4-overseas-client-services.mjs`、`phase4-overseas-dispatch.mjs` | 对应同名 `*-contract.test.mjs`;只消费本轮前置资源,缺失时 fail-closed |
| 海外仓业务跨区事件与安全门禁 | `phase4-cross-region-events.mjs`(双 Token Audience 互斥、canonical 权限 403、国内装柜完成到 Lastleg 建单、海外柜完成到 Firstleg 回写、轮询重试/幂等/诊断证据;缺本轮 marker 前置时阻断) | `phase4-cross-region-events-contract.test.mjs` |
@@ -100,17 +101,17 @@ 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` |
| 目的港码头拖车与船节点终态 | `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`;不再由船节点脚本直接伪造提柜和还柜 |
| 目的港码头拖车与船节点终态 | `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` |
表中的 `*-fixture.mjs` 是被业务模块复用的测试数据前置,不是新增的独立全量入口。执行 HTTP 时仍从 `../admin-test-env-acceptance.mjs` 进入;`*contract.test.mjs` 只做离线脚本结构和数据归属回归。
海外仓业务 HTTP 验收使用国内登录响应同时签发的短时海外 Token。总入口通过 `--gateway` 访问国内 Gateway,通过 `--overseas-gateway` 访问海外 Gateway。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;PDA 收货模块使用本轮临时 OVERSEAS_WAREHOUSE_PDA_OPERATOR 海外 Token,只调用 `/api/pda/v1/last-leg/receiving-containers/**` 柜号收货入口,不接受 `customerId`、`planNo` 或分配 ID。收货成功后由相邻的 `phase4-overseas-pda-operations` 消费仅存在于进程内存、不会进入 JSON 报告的 Token 与库存事实,执行盘点、整托移库、托盘操作和换标;新模块无论成功失败都在 finally 停用两个 picker 并清空临时凭据。两模块均不调用出库、退货出库、派送或内部接口;任何真实柜、箱唛、托盘、库存或换标前置缺失都必须形成 deployment blocker,不能按空场景通过;船节点禁止手工创建任务。
海外仓业务 HTTP 验收使用国内登录响应同时签发的短时海外 Token。总入口通过 `--gateway` 访问国内 Gateway,通过 `--overseas-gateway` 访问海外 Gateway。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;PDA 收货模块使用本轮临时 OVERSEAS_WAREHOUSE_PDA_OPERATOR 海外 Token,只调用 `/api/pda/v1/last-leg/receiving-containers/**` 柜号收货入口,不接受 `customerId`、`planNo` 或分配 ID。收货成功后由相邻的 `phase4-overseas-pda-operations` 消费仅存在于进程内存、不会进入 JSON 报告的 Token 与库存事实,执行盘点、公共位核对、托盘操作和换标;库内操作只复用收货计划产生的已有空托盘及当前仓 `VIRTUAL + ACTIVE` 公共位,不调用托盘或库位新增接口。新模块无论成功失败都在 finally 停用两个 picker 并清空临时凭据。两模块均不调用出库、退货出库、派送或内部接口;任何真实柜、箱唛、托盘、库存或换标前置缺失都必须形成 deployment blocker,不能按空场景通过;船节点禁止手工创建任务。
节点全链定向入口为 `--modules phase4-overseas-pda`。依赖展开固定先为本轮 `LOADED` 柜次完成国内 `CUSTOMS_DECLARATION` 与 PDA 装柜证据,再确认国内出仓;离仓后才维护 `DESTINATION_CLEARANCE`、`DELIVERY_ORDER` 两类目的港文件,PU 使用柜级数字码而不是文件,随后核对自动创建的 `OUTBOUND_CONFIRM` 船节点任务、人工补录至实际 `PICKUP_READY`,最后验证海外 PDA 候选可见。资料模块只写本轮 marker 柜次,覆盖上传/完成重放、同键异载荷 `A0409`、冲突后零漂移和聚合回读;任一 fixture 或 Trace 缺失均为 deployment blocker。
尾程单公网控制与目的港码头拖车定向入口为 `--modules phase4-carrier-nodes`。依赖会在海外 PDA 前插入 `phase4-tail-order-controls`,先对本轮柜验证接柜仓变更/恢复、目的仓变更、合托标记、CSV 导出、订单费用、暂扣阻断、解除和八个人工节点,再通过柜级预览与确认自动生成逐票计划和实体托盘;PDA 必须消费这些计划托盘,不得临时另建脱离计划的托盘。卸柜完成后再运行 `phase5-terminal-drayage`,只复用公共 `ACTIVE + TRUCKING` 供应商,依次推进选车队、预约、提柜和还柜。最后的船节点模块仅回读两次业务动作同步生成的 `PICKED_UP/EMPTY_RETURNED`,不得直接补录这两个节点。
尾程单公网控制与目的港码头拖车定向入口为 `--modules phase4-carrier-nodes`。依赖会在海外 PDA 前插入 `phase4-tail-order-controls`,先对本轮柜验证接柜仓变更/恢复、目的仓变更、合托标记、CSV 导出、订单费用、暂扣阻断、解除和八个人工节点,再通过柜级预览与确认自动生成逐票计划和实体托盘;PDA 必须消费这些计划托盘,不得临时另建脱离计划的托盘。货柜到达 `PICKUP_READY` 后先复用公共 `ACTIVE + TRUCKING` 供应商完成选车队、预约和实际提柜;PDA 卸柜完成后,有界等待跨区卸柜事件回写国内聚合为 `UNLOADED`,再确认实际还柜。最后的船节点模块仅回读两次业务动作同步生成的 `PICKED_UP/EMPTY_RETURNED`,不得直接补录这两个节点。
```powershell
node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/admin-test-env-acceptance.mjs --modules phase4-carrier-nodes
@@ -140,7 +141,7 @@ node --env-file="$env:USERPROFILE\.codex\chongchong-global-dev-acceptance.env" s
其中,无号 push 只冻结装柜计划、建立 bridge 并使预装可见,不得创建 PDA task;订舱放舱也不创建真实货柜或 allocation。港口取得真实柜号后,四字段 link 直接补全原模拟柜并创建 allocation,原 `containerBookingId/cabinetNo/loadingPlan/items` 不变;未关联、订舱未 `RELEASED` 或整份计划未确认时,PDA 查询必须返回业务码 `A0404`。唯一确认入口为 `POST /api/platform/v1/first-leg/loading-plans/{loadingPlanId}/confirm`,请求体只含当前计划 `expectedVersion` 并携带 `Idempotency-Key`;成功必须返回 `confirmationStatus=CONFIRMED`、`readiness.readyForPda=true` 和唯一 `readiness.pdaTaskId`。验收脚本禁止调用旧入库单级 `/preload/confirming`、`/preload/confirm`。
计划在 `EDITING/LOADING` 可通过正式 assignments/PATCH/DELETE 端点增量;计划、任务和明细版本必须递增,PDA 四元身份不变,旧 `inventorySnapshotToken` 必须返回 `A0409 库存已变化,请刷新后重试`。完整国内货柜履约装柜 fixture 通过平台托盘 API 创建本轮专用称重托盘,再交给通用测量模块;当普通位不足时由 Warehouse 自然分配到 A0,随后仅使用公开 A0 换绑接口制造库存版本变化,不直连数据库或调用内部 Warehouse 接口。主国内货柜履约链强制完成该专项,海外仓业务生产矩阵的附加柜只负责跨区生产,不重复要求 A0;附加柜若自然落入 A0 仍执行同一验证。批量待分柜入口仍只复用 `TP-001`/`YW-TP-002`,不创建托盘。已拣项不可删或减量,未拣项可删;增量会把全拣的 `AWAITING_REVIEW` 撤回为 `IN_PROGRESS`,刷新 task detail 与 inventory 后续拣才可再次等待最终确认。最终唯一完成入口仍为 `POST /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/complete`:保留同柜 `RELEASED` 订舱、bridge 与五类 ACTIVE 证据门禁;拖车状态和数量不是前置门禁。主事务提交后,零条有效拖车直接跳过,恰有一条时在独立事务尝试推进 `COMPLETED`,多条或并发冲突只记录拖车同步失败,不回滚已完成的 task/plan/inbound。PDA 结算可能先递增来源入库单版本或创建实装分片,完成动作必须在结算后重新锁定实际完成行并使用当前版本;HTTP finalizer 会回查 full/partial 履约分片,失败时保存 `requestId/traceId` 并断言 task/plan/inbound/operation 零漂移。后续模块必须复用 `phase3Loading` 发布的本轮 `taskId + inboundId + loadingPlanId + containerBookingId`,不得回退历史资源。
计划在 `EDITING/LOADING` 可通过正式 assignments/PATCH/DELETE 端点增量;计划、任务和明细版本必须递增,PDA 四元身份不变,旧 `inventorySnapshotToken` 必须返回 `A0409 库存已变化,请刷新后重试`。完整国内货柜履约装柜 fixture 与批量待分柜入口都只随机复用初始化的 `TP-001`/`YW-TP-002`,任一缺失即报告部署阻断,不调用托盘新增接口;当普通位不足时由 Warehouse 自然分配到 A0,随后仅使用公开 A0 换绑接口制造库存版本变化,不直连数据库、创建库位或调用内部 Warehouse 接口。模拟柜的 `channelGroupCode` 统一由 `smart-loading-channel-group.mjs` 根据订单渠道映射。主国内货柜履约链强制完成该专项,海外仓业务生产矩阵的附加柜只负责跨区生产,不重复要求 A0;附加柜若自然落入 A0 仍执行同一验证。已拣项不可删或减量,未拣项可删;增量会把全拣的 `AWAITING_REVIEW` 撤回为 `IN_PROGRESS`,刷新 task detail 与 inventory 后续拣才可再次等待最终确认。最终唯一完成入口仍为 `POST /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/complete`:保留同柜 `RELEASED` 订舱、bridge 与五类 ACTIVE 证据门禁;拖车状态和数量不是前置门禁。主事务提交后,零条有效拖车直接跳过,恰有一条时在独立事务尝试推进 `COMPLETED`,多条或并发冲突只记录拖车同步失败,不回滚已完成的 task/plan/inbound。PDA 结算可能先递增来源入库单版本或创建实装分片,完成动作必须在结算后重新锁定实际完成行并使用当前版本;HTTP finalizer 会回查 full/partial 履约分片,失败时保存 `requestId/traceId` 并断言 task/plan/inbound/operation 零漂移。后续模块必须复用 `phase3Loading` 发布的本轮 `taskId + inboundId + loadingPlanId + containerBookingId`,不得回退历史资源。
动态增量还会读取计划明细中的服务端 `totalPackageCount/remainingPackageCount`,断言
`remaining = total - assigned`;随后尝试把箱数调整为权威总数加一,必须得到 `A0400`,并回查
@@ -43,4 +43,6 @@ test('同柜不可拆组HTTP验收锁定公开接口与防绕过矩阵', async (
assert.doesNotMatch(source, /跨仓库渠道或目的仓港创建整组被拒绝/)
assert.doesNotMatch(source, /已有父子拆分订单创建同柜组被拒绝/)
assert.doesNotMatch(source, /push-preload/)
assert.match(source, /import \{ channelGroup \} from '\.\/smart-loading-channel-group\.mjs'/)
assert.equal((source.match(/channelGroupCode:\s*channelGroup\(/g) || []).length, 6)
})
@@ -1,6 +1,7 @@
const MODULE = 'coload-groups'
const OPTIONS = { scenario: 'indivisible-coload-group', deploymentBlocker: true }
const CONTAINERS = '/api/platform/v1/containers'
import { channelGroup } from './smart-loading-channel-group.mjs'
const SMART = '/api/platform/v1/first-leg/smart-loading'
const FIRST_LEG = '/api/platform/v1/first-leg'
const PDA = '/api/platform/v1/first-leg/pda-loading'
@@ -10,19 +11,6 @@ import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs'
import { iso6346ContainerNumber } from './iso6346-container-number.mjs'
function id(value) { return value == null ? '' : String(value) }
function channelGroup(channelCode) {
const value = String(channelCode || '').toUpperCase()
if (value.startsWith('EXX_')) return 'EXX'
if (value.startsWith('MATSON_CLX_')) return 'MATSON_CLX'
if (value.startsWith('MATSON_MAX_')) return 'MATSON_MAX'
if (value.startsWith('HEDE_')) return 'HEDE'
if (value.startsWith('OA_LA_') || value === 'OA_PARCEL') return 'OA_LA'
if (value.startsWith('OA_CHICAGO_')) return 'OA_CHICAGO'
if (value.startsWith('OA_HOUSTON_')) return 'OA_HOUSTON'
if (value.startsWith('OA_SAVANNAH_')) return 'OA_SAVANNAH'
if (value === 'OA_NEW_YORK_TRUCK') return value
throw new Error(`unsupported smart-loading channel group: ${channelCode}`)
}
function code(response) { return id(response?.body?.code) || 'missing' }
function evidence(response, extra = '') {
return `HTTP=${response?.http ?? 'missing'}, code=${code(response)}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}${extra ? `, ${extra}` : ''}`
@@ -105,7 +93,7 @@ async function createSecondGroup(context, third, fourth) {
async function createAssignedPushedPlan(context, fixture, groupId, suffix) {
const simulationResponse = await context.request('POST', CONTAINERS, {
channelCode: fixture.channelCode, warehouseId: fixture.warehouseId, containerType: '40HC',
channelGroupCode: channelGroup(fixture.channelCode), warehouseId: fixture.warehouseId, containerType: '40HC',
capacityCbm: 76, maxWeightKg: 28000,
})
const simulation = context.apiSuccess(MODULE, `${suffix} 创建组装柜模拟柜`, simulationResponse,
@@ -131,7 +119,7 @@ async function createAssignedPushedPlan(context, fixture, groupId, suffix) {
async function createNormalPushedPlan(context, fixture, suffix) {
const simulationResponse = await context.request('POST', CONTAINERS, {
channelCode: fixture.channelCode, warehouseId: fixture.warehouseId, containerType: '40HC',
channelGroupCode: channelGroup(fixture.channelCode), warehouseId: fixture.warehouseId, containerType: '40HC',
capacityCbm: 76, maxWeightKg: 28000,
})
const simulation = context.apiSuccess(MODULE, `${suffix} 创建普通单模拟柜`, simulationResponse,
@@ -522,7 +510,7 @@ export async function run(context) {
`status=${queried.data?.status || 'missing'}, members=${summaryInboundCodes(queried.data).join('|')}`, OPTIONS)
const simulationResponse = await context.request('POST', '/api/platform/v1/containers', {
channelCode: first.channelCode, warehouseId: first.warehouseId, containerType: '20GP',
channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '20GP',
capacityCbm: 33, maxWeightKg: 28000,
})
const simulation = context.apiSuccess(MODULE, '创建容量拒绝专用20GP模拟柜', simulationResponse,
@@ -613,7 +601,7 @@ export async function run(context) {
`targetGroupId=${groupId}, assignedMembers=${capacityGroupItems.map((item) => id(item?.inboundId)).join('|') || 'none'}, before=${planSignature(beforeRecommend)}, after=${planSignature(capacityRecommendation)}`, OPTIONS)
const recommendSimulationResponse = await context.request('POST', '/api/platform/v1/containers', {
channelCode: first.channelCode, warehouseId: first.warehouseId, containerType: '40HC',
channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '40HC',
capacityCbm: 76, maxWeightKg: 28000,
})
const recommendSimulation = context.apiSuccess(MODULE, '创建智能推荐整组成功专用40HC模拟柜',
@@ -659,7 +647,7 @@ export async function run(context) {
}
const workingSimulationResponse = await context.request('POST', '/api/platform/v1/containers', {
channelCode: first.channelCode, warehouseId: first.warehouseId, containerType: '40HC',
channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '40HC',
capacityCbm: 76, maxWeightKg: 28000,
})
const workingSimulation = context.apiSuccess(MODULE, '创建PDA主链40HC模拟柜', workingSimulationResponse,
@@ -978,7 +966,7 @@ export async function run(context) {
const uiGroup = context.apiSuccess(MODULE, '为Admin页面保留ACTIVE未分配跨目的仓组', uiGroupResponse,
evidence(uiGroupResponse), OPTIONS)
const uiSimulationResponse = await context.request('POST', CONTAINERS, {
channelCode: fifth.channelCode, warehouseId: fifth.warehouseId, containerType: '40HC',
channelGroupCode: channelGroup(fifth.channelCode), warehouseId: fifth.warehouseId, containerType: '40HC',
capacityCbm: 76, maxWeightKg: 28000,
})
const uiSimulation = context.apiSuccess(MODULE, '为Admin页面保留可编辑智能装柜柜次', uiSimulationResponse,
@@ -98,7 +98,7 @@ function fakeContext(withFixture = true, permissions = financePermissions, denyF
return ok({ accessToken: 'client-token' })
}
if (method === 'GET' && requestPath === '/api/platform/finance/v1/accounts?page=1&pageSize=20&customerId=11') {
return ok({ records: [{ customerId: '11', customerCode: 'TEN11', customerName: 'Fixture Account',
return ok({ records: [{ customerId: '11', customerCode: '00011', customerName: 'Fixture Account',
contactName: 'Fixture Contact', contactPhone: '13800000000', outstandingReceivable: 100,
pendingBilling: 20, receivedInPeriod: 30, outstandingPayable: 40, status: 'ACTIVE',
lastChangedAt: '2026-08-10T09:00:00' }] })
@@ -18,6 +18,7 @@ test('candidate plans only come from resources created by the current acceptance
financeFullFixtures: { feeSources: [{ loadingPlanId: '104' }] },
}), ['101', '102', '103', '104'])
assert.doesNotMatch(source, /pageSize=\d+.*smart-loading|containers\?page|历史柜/)
assert.match(source, /channelGroupCode:\s*channelGroup\(first\.channelCode\)/)
})
test('allocation builder selects one real order per distinct customer and preserves exact cents', () => {
@@ -2,6 +2,7 @@ const MODULE = 'firstleg-container-fee-allocation'
const OPTIONS = { scenario: 'firstleg-container-fee-allocation-http', deploymentBlocker: true }
import { run as createMerchant } from './merchant-qualification-fixture.mjs'
import { channelGroup } from './smart-loading-channel-group.mjs'
function id(value) { return value == null ? '' : String(value) }
function rows(value) { return Array.isArray(value) ? value : Array.isArray(value?.records) ? value.records : [] }
@@ -140,7 +141,7 @@ async function provisionCrossCustomerPlan(context) {
|| id(first.receivingWarehouseId) !== id(second.receivingWarehouseId)
|| first.channelCode !== second.channelCode) return null
const containerResponse = await context.request('POST', '/api/platform/v1/containers', {
channelCode: first.channelCode, warehouseId: id(first.receivingWarehouseId),
channelGroupCode: channelGroup(first.channelCode), warehouseId: id(first.receivingWarehouseId),
containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000,
})
const container = context.apiSuccess(MODULE, '创建跨客户费用分摊专用模拟柜', containerResponse,
@@ -48,8 +48,8 @@ test('customer confirms chargeable quantity and manual unit price in one locked
const source = await readFile(moduleFile, 'utf8')
assert.match(source, /const orderId = id\(fixture\?\.orderId\)/)
assert.match(source, /context\.context\?\.merchantQualificationFixture/)
assert.match(source, /merchantFixture\?\.username/)
assert.match(source, /merchantFixture\?\.password/)
assert.match(source, /merchantFixture\?\.username \|\| MERCHANT_USERNAME/)
assert.match(source, /merchantFixture\?\.password \|\| MERCHANT_PASSWORD/)
assert.doesNotMatch(source, /const orderId = id\(context\.createdResources\.orders\?\.orderId\)/)
assert.match(source, /Client 读取已推送计费量、对客单价和费用合计/)
assert.match(source, /Client 一次确认计费量、对客单价和费用/)
@@ -1,5 +1,7 @@
const MODULE = 'inbound-fees'
const OPTIONS = { scenario: 'inbound-fee-lifecycle', deploymentBlocker: true }
const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || ''
const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || ''
function id(value) { return value === null || value === undefined ? '' : String(value) }
function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' }
@@ -134,7 +136,8 @@ export async function run(context) {
const orderId = id(fixture?.orderId)
const merchantFixture = context.context?.merchantQualificationFixture
const merchantLoginResponse = await context.request('POST', '/api/auth/login', {
loginId: merchantFixture?.username, password: merchantFixture?.password,
loginId: merchantFixture?.username || MERCHANT_USERNAME,
password: merchantFixture?.password || MERCHANT_PASSWORD,
}, '')
const merchantLogin = context.apiSuccess(MODULE, 'Client 登录并准备确认计费量和费用', merchantLoginResponse,
`orderId=${orderId || 'missing'}`, OPTIONS)
@@ -30,13 +30,6 @@ function validPdf(response) {
async function reusablePallets(context, warehouseId) {
const cacheKey = asId(warehouseId)
const override = context.context?.measurementPalletOverride
if (asId(override?.warehouseId) === cacheKey && asId(override?.id)
&& String(override?.code || '').trim()) {
context.assert(MODULE, '使用编排层提供的本轮专用称重托盘', null, true,
`warehouseId=${warehouseId}, palletId=${asId(override.id)}`, OPTIONS)
return [{ id: asId(override.id), code: String(override.code) }]
}
const allowAnyExistingPallet = context.context?.allowAnyExistingPallet === true
const scopedCacheKey = `${cacheKey}:${allowAnyExistingPallet ? 'any' : 'fixed'}`
if (reusablePalletsByWarehouse.has(scopedCacheKey)) return reusablePalletsByWarehouse.get(scopedCacheKey)
@@ -64,29 +57,11 @@ async function reusablePallets(context, warehouseId) {
const exact = (page?.records || []).find((item) => asId(item?.warehouseId) === cacheKey
&& String(item?.palletCode || '') === palletCode && asId(item?.id))
context.assert(MODULE, `查询原有称重托盘 ${palletCode}`, response, true,
`warehouseId=${warehouseId}, matched=${exact?.palletCode || 'missing'}, fallback=${exact ? 'not-needed' : 'create-marker-owned'}`, OPTIONS)
`warehouseId=${warehouseId}, matched=${exact?.palletCode || 'missing'}, fallback=disabled`, OPTIONS)
if (!exact) {
// A newly selected development warehouse may not have the historical fixture pallets.
// Create one marker-owned pallet through the public platform API rather than coupling
// measurement acceptance to another warehouse's seed data.
const createResponse = await context.request('POST', '/api/platform/v1/pallets?tenantId=0', {
warehouseId, palletType: 'STANDARD', capacityCbm: 1.5, weightKg: 25,
color: 'ORANGE', remark: `${context.marker}-measurement-fixture`,
})
const createdId = asId(context.apiSuccess(MODULE, '创建本轮称重托盘', createResponse,
`warehouseId=${warehouseId}`, OPTIONS))
if (!createdId) return null
const detailResponse = await context.request('GET',
`/api/platform/v1/pallets/${encodeURIComponent(createdId)}?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}`)
const created = context.apiSuccess(MODULE, '回读本轮称重托盘', detailResponse,
`warehouseId=${warehouseId}, palletId=${createdId}`, OPTIONS)
const valid = asId(created?.id) === createdId && asId(created?.warehouseId) === cacheKey
&& String(created?.palletCode || '').trim()
context.assert(MODULE, '新建称重托盘归属当前仓库且编码完整', detailResponse, valid,
`warehouseId=${warehouseId}, palletId=${createdId}, palletCode=${created?.palletCode || 'missing'}`, OPTIONS)
if (!valid) return null
pallets.push({ id: createdId, code: String(created.palletCode) })
break
context.assert(MODULE, '固定称重托盘主数据完整', response, false,
`warehouseId=${warehouseId}, missing=${palletCode}`, OPTIONS)
return null
}
pallets.push({ id: asId(exact.id), code: palletCode })
}
@@ -115,7 +90,7 @@ async function waitForCompletedMeasurement(context, measurementPath, expectedSta
export async function completeDropshipPalletization(context, { inboundId, inboundCode, warehouseId, measurement }) {
const pendingResponse = await context.request('GET',
`/api/platform/v1/first-leg/pda-dropship/tasks/pending?keyword=${encodeURIComponent(inboundCode)}`)
`/api/platform/v1/first-leg/pda-dropship/tasks/pending?warehouseId=${warehouseId}&keyword=${encodeURIComponent(inboundCode)}`)
const pending = context.apiSuccess(MODULE, '查询一件代发待合托任务', pendingResponse,
`inboundId=${inboundId}`, OPTIONS)
const task = (pending || []).find((item) => asId(item?.inboundId) === inboundId
@@ -135,31 +110,17 @@ export async function completeDropshipPalletization(context, { inboundId, inboun
`taskStatus=${claimed?.taskStatus ?? 'missing'}`, OPTIONS)
const totalCartons = Number(measurement?.actualPackageCount)
const createPath = `/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/pallets/create-and-bind`
const expectedPalletCount = Number(measurement?.expectedPalletCount)
const taskPath = `/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}?warehouseId=${warehouseId}`
const createPayload = { warehouseId, operationId: `${context.marker}-dropship-create-bind`.slice(0, 128) }
let palletNo = ''
const generatedPallets = Array.isArray(claimed?.pallets) ? claimed.pallets : []
context.assert(MODULE, '审核通过时已按预计托数预生成全部DP托盘码', claimResponse,
generatedPallets.length === expectedPalletCount && generatedPallets.every((item) =>
item?.status === 'OPEN' && Number(item?.cartonCount) === 0 && /^DP-\d{6}-\d{4}$/.test(item?.palletNo || '')),
`expected=${expectedPalletCount}, generated=${generatedPallets.length}`, OPTIONS)
if (!generatedPallets.length) return null
let countedCartons = 0
const sealedCartonMarks = []
let pallet = null
const palletSnapshot = (value) => JSON.stringify({
counted: value?.countedCartons, bound: value?.boundCartons, unbound: value?.unboundCartons,
pallets: value?.pallets?.map((item) => ({ id: asId(item.palletId), no: item.palletNo,
count: item.cartonCount, status: item.status, version: item.version })),
})
async function rejectEmptyCreation(before, suffix) {
const response = await context.request('POST', createPath,
{ ...createPayload, operationId: `${context.marker}-dropship-empty-${suffix}`.slice(0, 128) })
context.assert(MODULE, `无待绑箱创建托盘被拒绝-${suffix}`, response,
response.http === 200 && String(response.body?.code) === 'A0409',
`HTTP=${response.http}, code=${response.body?.code || 'missing'}`, OPTIONS)
const afterResponse = await context.request('GET', taskPath)
const after = context.apiSuccess(MODULE, `拒绝创建后回查任务-${suffix}`, afterResponse, '', OPTIONS)
context.assert(MODULE, `拒绝创建没有空托或计数漂移-${suffix}`, afterResponse,
palletSnapshot(after) === palletSnapshot(before), `pallets=${after?.pallets?.length ?? 'missing'}`, OPTIONS)
}
await rejectEmptyCreation(claimed, 'before-count')
for (let index = 0; index < totalCartons; index += 1) {
const boxCode = `${inboundCode}#${totalCartons}-${index + 1}`
@@ -167,18 +128,25 @@ export async function completeDropshipPalletization(context, { inboundId, inboun
`/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/cartons/resolve?warehouseId=${warehouseId}&boxCode=${encodeURIComponent(boxCode)}`)
const resolved = context.apiSuccess(MODULE, `解析第${index + 1}箱的冻结箱唛`, resolveResponse,
`boxCode=${boxCode}`, OPTIONS)
context.assert(MODULE, `第${index + 1}箱允许清点且每箱SKU数完整`, resolveResponse,
resolved?.canCount === true && Number(resolved?.expectedSkuQuantity) > 0,
`canCount=${resolved?.canCount ?? 'missing'}, expectedSkuQuantity=${resolved?.expectedSkuQuantity ?? 'missing'}`, OPTIONS)
if (resolved?.canCount !== true || Number(resolved?.expectedSkuQuantity) < 1) continue
const resolvedSkuLines = Array.isArray(resolved?.skuLines) ? resolved.skuLines : []
context.assert(MODULE, `第${index + 1}箱允许清点且每箱SKU明细完整`, resolveResponse,
resolved?.canCount === true && resolvedSkuLines.length > 0
&& resolvedSkuLines.every((line) => asId(line?.inboundDetailId) && line?.skuCode
&& Number(line?.expectedQuantity) > 0),
`canCount=${resolved?.canCount ?? 'missing'}, skuLines=${resolvedSkuLines.length}`, OPTIONS)
if (resolved?.canCount !== true || resolvedSkuLines.length < 1) continue
const countPayload = {
warehouseId,
boxCode,
actualSkuQuantity: Number(resolved.expectedSkuQuantity),
discrepancyAction: 'MATCH',
discrepancyRemark: null,
palletNo: index === 0 ? null : palletNo,
skuLines: resolvedSkuLines.map((line) => ({
inboundDetailId: asId(line.inboundDetailId),
skuCode: line.skuCode,
actualQuantity: Number(line.expectedQuantity),
discrepancyAction: 'MATCH',
discrepancyRemark: null,
})),
palletNo: index === 0 ? null : generatedPallets[Math.min(index, generatedPallets.length - 1)].palletNo,
operationId: `${context.marker}-dropship-count-${index + 1}`.slice(0, 128),
}
const countResponse = await context.request('POST',
@@ -187,10 +155,11 @@ export async function completeDropshipPalletization(context, { inboundId, inboun
`boxCode=${boxCode}`, OPTIONS)
context.assert(MODULE, `第${index + 1}箱清点结果与冻结箱唛一致`, countResponse,
counted?.carton?.boxCode === resolved?.boxCode
&& Number(counted?.carton?.expectedSkuQuantity) === Number(resolved?.expectedSkuQuantity)
&& Number(counted?.carton?.actualSkuQuantity) === Number(resolved?.expectedSkuQuantity)
&& counted?.carton?.discrepancyStatus === 'MATCH',
`cartonId=${asId(counted?.carton?.cartonId) || 'missing'}, discrepancy=${counted?.carton?.discrepancyStatus ?? 'missing'}`, OPTIONS)
&& Array.isArray(counted?.carton?.skuLines)
&& counted.carton.skuLines.length === resolvedSkuLines.length
&& counted.carton.skuLines.every((line) => Number(line?.actualQuantity) === Number(line?.expectedQuantity)
&& line?.discrepancyStatus === 'MATCH'),
`cartonId=${asId(counted?.carton?.cartonId) || 'missing'}, skuLines=${counted?.carton?.skuLines?.length ?? 'missing'}`, OPTIONS)
if (counted?.carton?.boxCode === resolved?.boxCode) {
countedCartons += 1
sealedCartonMarks.push(resolved.boxCode)
@@ -209,55 +178,57 @@ export async function completeDropshipPalletization(context, { inboundId, inboun
&& Number(replayed?.unboundCartons) === Number(counted?.unboundCartons),
`counted=${replayed?.countedCartons ?? 'missing'}, unbound=${replayed?.unboundCartons ?? 'missing'}`, OPTIONS)
const bindResponse = await context.request('POST', createPath, createPayload)
const bound = context.apiSuccess(MODULE, '系统发码创建托盘并绑定全部待组托箱', bindResponse,
const firstPallet = generatedPallets[0]
const bindPayload = { warehouseId, palletNo: firstPallet.palletNo,
operationId: `${context.marker}-dropship-bind-first`.slice(0, 128) }
const bindResponse = await context.request('POST',
`/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/pallets/bind-pending`, bindPayload)
const bound = context.apiSuccess(MODULE, '后扫预生成托盘码并绑定全部待组托箱', bindResponse,
`taskId=${taskId}`, OPTIONS)
pallet = bound?.pallets?.find((item) => Number(item.cartonCount) === 1) || null
palletNo = pallet?.palletNo || ''
const pallet = bound?.pallets?.find((item) => asId(item.palletId) === asId(firstPallet.palletId)) || null
context.assert(MODULE, '待组托箱绑定到OPEN托盘且不再游离', bindResponse,
Number(bound?.unboundCartons) === 0 && Number(pallet?.cartonCount) === 1
&& pallet?.status === 'OPEN' && /^DP-\d{6}-\d{4}$/.test(palletNo)
&& bound?.pallets?.length === 1,
&& pallet?.status === 'OPEN' && pallet?.palletNo === firstPallet.palletNo
&& bound?.pallets?.length === expectedPalletCount,
`unbound=${bound?.unboundCartons ?? 'missing'}, palletStatus=${pallet?.status ?? 'missing'}`, OPTIONS)
const createReplayResponse = await context.request('POST', createPath, createPayload)
const createReplay = context.apiSuccess(MODULE, '系统发码同operationId重放', createReplayResponse, '', OPTIONS)
context.assert(MODULE, '系统发码重放不新增托盘且业务码和计数稳定', createReplayResponse,
palletSnapshot(createReplay) === palletSnapshot(bound), `palletNo=${palletNo}`, OPTIONS)
await rejectEmptyCreation(bound, 'after-bind')
} else {
pallet = counted?.pallet || pallet
const expectedPallet = generatedPallets[Math.min(index, generatedPallets.length - 1)]
context.assert(MODULE, `第${index + 1}箱直接进入当前OPEN托盘`, countResponse,
counted?.pallet?.palletNo === palletNo && counted?.pallet?.status === 'OPEN',
counted?.pallet?.palletNo === expectedPallet.palletNo && counted?.pallet?.status === 'OPEN',
`palletNo=${counted?.pallet?.palletNo ?? 'missing'}, status=${counted?.pallet?.status ?? 'missing'}`, OPTIONS)
}
}
context.assert(MODULE, '一件代发工作托盘覆盖测量冻结的全部箱数', null,
totalCartons > 0 && countedCartons === totalCartons && Number(pallet?.cartonCount) === totalCartons,
`counted=${countedCartons}, palletCartons=${pallet?.cartonCount ?? 'missing'}, expected=${totalCartons || 'missing'}`, OPTIONS)
const countedTaskResponse = await context.request('GET', taskPath)
let sealed = context.apiSuccess(MODULE, '清点完成后回查预生成托盘分配', countedTaskResponse, '', OPTIONS)
context.assert(MODULE, '预生成托盘覆盖测量冻结的全部箱数', countedTaskResponse,
totalCartons > 0 && countedCartons === totalCartons
&& Number(sealed?.boundCartons) === totalCartons
&& sealed?.pallets?.length === expectedPalletCount
&& sealed?.pallets?.every((item) => Number(item?.cartonCount) > 0),
`counted=${countedCartons}, bound=${sealed?.boundCartons ?? 'missing'}, expected=${totalCartons || 'missing'}`, OPTIONS)
const palletId = asId(pallet?.palletId)
if (!palletId) return null
const sealResponse = await context.request('POST',
`/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/pallets/${palletId}/seal`, {
warehouseId, expectedVersion: Number(pallet?.version),
operationId: `${context.marker}-dropship-seal`.slice(0, 128),
})
const sealed = context.apiSuccess(MODULE, '封托并完成一件代发合托任务', sealResponse,
`taskId=${taskId}, palletId=${palletId}`, OPTIONS)
context.assert(MODULE, '全部箱数封托后入仓单允许进入WAIT_SPLIT', sealResponse,
let lastSealResponse = countedTaskResponse
for (let index = 0; index < (sealed?.pallets || []).length; index += 1) {
const pallet = sealed.pallets.find((item) => item.status === 'OPEN')
if (!pallet) break
const sealResponse = await context.request('POST',
`/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/pallets/${asId(pallet.palletId)}/seal`, {
warehouseId, expectedVersion: Number(pallet.version),
operationId: `${context.marker}-dropship-seal-${index + 1}`.slice(0, 128),
})
lastSealResponse = sealResponse
sealed = context.apiSuccess(MODULE, `封托第${index + 1}个预生成托盘`, sealResponse,
`taskId=${taskId}, palletNo=${pallet.palletNo}`, OPTIONS)
}
context.assert(MODULE, '全部箱数封托后入仓单允许进入WAIT_SPLIT', lastSealResponse,
sealed?.taskStatus === 'COMPLETED'
&& Number(sealed?.countedCartons) === Number(sealed?.totalCartons)
&& Number(sealed?.boundCartons) === Number(sealed?.totalCartons)
&& Number(sealed?.unboundCartons) === 0
&& sealed?.pallets?.every((item) => item?.status === 'SEALED'),
`taskStatus=${sealed?.taskStatus ?? 'missing'}, counted=${sealed?.countedCartons ?? 'missing'}, bound=${sealed?.boundCartons ?? 'missing'}`, OPTIONS)
const sealedReplayResponse = await context.request('POST', createPath, createPayload)
const sealedReplay = context.apiSuccess(MODULE, '封托完成后系统发码原操作仍可重放', sealedReplayResponse, '', OPTIONS)
context.assert(MODULE, '完成后重放不重开任务或新建空托', sealedReplayResponse,
sealedReplay?.taskStatus === 'COMPLETED' && palletSnapshot(sealedReplay) === palletSnapshot(sealed),
`taskStatus=${sealedReplay?.taskStatus || 'missing'}, palletNo=${palletNo}`, OPTIONS)
return { ...sealed, sealedCartonMarks, sealedSourcePalletNos: [palletNo] }
return { ...sealed, sealedCartonMarks,
sealedSourcePalletNos: (sealed?.pallets || []).map((item) => item.palletNo).filter(Boolean) }
}
async function provisionWarehouseResources(context, { inboundId, tenantId, warehouseId, palletCount }) {
@@ -813,8 +784,10 @@ export async function run(context) {
measured = context.apiSuccess(MODULE, '回查增量 fixture 动态测量明细', measuredResponse, '', OPTIONS)
measuredItem = measured?.items?.find((item) => asId(item.id) === asId(firstScan.itemId))
const detailIds = new Set((measured?.items || []).map((item) => asId(item?.inboundDetailId)).filter(Boolean))
context.assert(MODULE, '增量 fixture 形成动态真实独立货物测量明细', measuredResponse,
detailIds.size >= incrementalDetailCount
const mixedDropship = context.createdResources?.orders?.fulfillmentType === 'ONE_CLICK_DROPSHIP'
context.assert(MODULE, mixedDropship
? '混箱一件代发复用物理箱测量明细' : '增量 fixture 形成动态真实独立货物测量明细', measuredResponse,
mixedDropship ? detailIds.size === 1 : detailIds.size >= incrementalDetailCount
&& measured.items.filter((item) => detailIds.has(asId(item?.inboundDetailId))).length >= incrementalDetailCount,
`measurementItems=${measured?.items?.length ?? 'missing'}, distinctInboundDetails=${detailIds.size}`, OPTIONS)
}
@@ -871,9 +844,13 @@ export async function run(context) {
? 'ADD' : measuredItem.coefficientMode ?? null,
remark: context.marker,
}]
const palletCount = Math.max(1, Math.min(originalPackageCount,
Number(context.context?.waitSplitVariant?.palletCount || 1)))
const draftPayload = {
measurementId: measured.measurementId, version: Number(measured.version),
actualGoodsName: `墨家测量验收-${context.marker.slice(-12)}`,
expectedPalletCount: context.createdResources?.orders?.fulfillmentType === 'ONE_CLICK_DROPSHIP'
? palletCount : null,
items: draftItems,
}
const draftResponse = await context.request('PUT',
@@ -889,7 +866,6 @@ export async function run(context) {
return
}
const palletCount = Math.max(1, Number(context.context?.waitSplitVariant?.palletCount || 1))
const warehouseResourcesReady = await provisionWarehouseResources(context, {
inboundId, tenantId, warehouseId: asId(summary?.warehouseId), palletCount,
})
@@ -1069,21 +1045,40 @@ export async function run(context) {
let sealedCartonMarks = []
let sealedSourcePalletNos = []
if (dropship && finalMeasurement?.processStatus === 'WAIT_PALLETIZE') {
const sealed = await completeDropshipPalletization(context, {
inboundId, inboundCode, warehouseId: asId(summary?.warehouseId), measurement: finalMeasurement,
})
sealedCartonMarks = sealed?.taskStatus === 'COMPLETED' ? sealed.sealedCartonMarks : []
sealedSourcePalletNos = sealed?.taskStatus === 'COMPLETED'
? (sealed.sealedSourcePalletNos || []).filter(Boolean) : []
completed = await waitForCompletedMeasurement(context, measurementPath, ['WAIT_SPLIT'], 10)
finalResponse = completed.response
finalMeasurement = context.apiSuccess(MODULE, '合托后回查一件代发入仓单', finalResponse,
`pollAttempts=${completed.attempts}`, OPTIONS)
context.assert(MODULE, '一件代发合托完成后进入智能分柜门禁', finalResponse,
finalMeasurement?.processStatus === 'WAIT_SPLIT'
&& finalMeasurement?.measureReviewStatus === 'APPROVED'
&& finalMeasurement?.measurementStatus === 'APPROVED',
`processStatus=${finalMeasurement?.processStatus ?? 'missing'}`, OPTIONS)
if (context.options?.prepareDropshipManualCount) {
const warehouseId = asId(summary?.warehouseId)
const pendingResponse = await context.request('GET',
`/api/platform/v1/first-leg/pda-dropship/tasks/pending?warehouseId=${warehouseId}&keyword=${encodeURIComponent(inboundCode)}`)
const pending = context.apiSuccess(MODULE, '准备PDA人工清点任务', pendingResponse,
`inboundId=${inboundId}`, OPTIONS)
const task = (pending || []).find((item) => asId(item?.inboundId) === inboundId)
context.assert(MODULE, '任务停在未领取、未清点、未归托状态', pendingResponse,
Boolean(asId(task?.taskId)) && task?.taskStatus === 'PENDING'
&& Number(task?.countedCartons) === 0 && Number(task?.boundCartons) === 0
&& Number(task?.totalCartons) === Number(finalMeasurement?.actualPackageCount),
`taskId=${asId(task?.taskId) || 'missing'}, status=${task?.taskStatus ?? 'missing'}, counted=${task?.countedCartons ?? 'missing'}`, OPTIONS)
context.createdResources.dropshipManualCount = {
taskId: asId(task?.taskId), inboundId, inboundCode, warehouseId,
warehouseName: task?.warehouseName || null, totalCartons: Number(task?.totalCartons || 0),
taskStatus: task?.taskStatus || null,
}
} else {
const sealed = await completeDropshipPalletization(context, {
inboundId, inboundCode, warehouseId: asId(summary?.warehouseId), measurement: finalMeasurement,
})
sealedCartonMarks = sealed?.taskStatus === 'COMPLETED' ? sealed.sealedCartonMarks : []
sealedSourcePalletNos = sealed?.taskStatus === 'COMPLETED'
? (sealed.sealedSourcePalletNos || []).filter(Boolean) : []
completed = await waitForCompletedMeasurement(context, measurementPath, ['WAIT_SPLIT'], 10)
finalResponse = completed.response
finalMeasurement = context.apiSuccess(MODULE, '合托后回查一件代发入仓单', finalResponse,
`pollAttempts=${completed.attempts}`, OPTIONS)
context.assert(MODULE, '一件代发合托完成后进入智能分柜门禁', finalResponse,
finalMeasurement?.processStatus === 'WAIT_SPLIT'
&& finalMeasurement?.measureReviewStatus === 'APPROVED'
&& finalMeasurement?.measurementStatus === 'APPROVED',
`processStatus=${finalMeasurement?.processStatus ?? 'missing'}`, OPTIONS)
}
}
const approvedScanResponse = await multipartRequest(context, scanPath, {
@@ -1103,6 +1098,7 @@ export async function run(context) {
boxCode, finalStatus: finalMeasurement?.measurementStatus || null,
processStatus: finalMeasurement?.processStatus || null,
palletCount: weightEvidence.count, actualGrossWeightKg: weightEvidence.grossWeightKg,
actualPackageCount: Number(finalMeasurement?.actualPackageCount || 0),
destinationType: context.createdResources?.orders?.destinationType || null,
destinationWarehouseId: context.createdResources?.orders?.destinationWarehouseId || null,
destinationWarehouseCode: context.createdResources?.orders?.destinationWarehouseCode || null,
@@ -43,7 +43,7 @@ test('统一初始化 SQL 提供人工验收所需公共仓储主数据', () =>
assert.match(sql, /193000000000400004,0,193000000000200004,'RECEIVING','海外收货',1,'16:00:00',[\s\S]*?'纽约仓海外接柜入仓收货能力'/)
assert.match(sql, /固定平台作业仓收货能力写入校验失败/)
const palletSeed = sql.match(/INSERT INTO wh_pallet \([\s\S]*?remark = VALUES\(remark\);/)?.[0]
const palletSeed = sql.match(/INSERT INTO wh_pallet \([\s\S]*?deleted = 0;/)?.[0]
assert.ok(palletSeed, 'missing pallet initialization seed')
assert.doesNotMatch(palletSeed, /\bstatus\b|'AVAILABLE'/)
})
@@ -67,6 +67,19 @@ test('国内统一初始化在注释门禁前创建订单申报快照表', () =>
}
})
test('国内统一增量在 Firstleg 补列前重建已由 Warehouse 段删除的过程', () => {
const warehouseDrop = sql.indexOf('DROP PROCEDURE IF EXISTS add_column_if_missing;',
sql.indexOf('CALL assert_platform_public_resource_scope();'))
const firstlegCall = sql.indexOf("CALL add_column_if_missing('fl_price_book_version', 'voided_by'")
const firstlegCreate = sql.lastIndexOf('CREATE PROCEDURE add_column_if_missing(', firstlegCall)
const firstlegDrop = sql.indexOf('DROP PROCEDURE IF EXISTS add_column_if_missing;', firstlegCall)
assert.ok(warehouseDrop >= 0, 'Warehouse migration must clean up its helper procedure')
assert.ok(firstlegCreate > warehouseDrop, 'Firstleg must recreate the helper after the Warehouse cleanup')
assert.ok(firstlegCreate < firstlegCall, 'Firstleg helper must exist before the price-version ALTER calls')
assert.ok(firstlegDrop > firstlegCall, 'Firstleg must clean up the helper after its ALTER calls')
})
test('海外统一初始化内联空库基线且不依赖模块 SQL', () => {
assert.match(overseasSql, /BEGIN INLINE EMPTY-DATABASE BASELINE: LASTLEG/)
assert.match(overseasSql, /BEGIN INLINE EMPTY-DATABASE BASELINE: OVERSEAS WAREHOUSE/)
@@ -28,14 +28,14 @@ test('随机线路只固定义乌仓并从客户真实可计价候选选择', ()
assert.doesNotMatch(runner, /channel-name|channel-code/)
})
test('称重优先复用固定托盘并允许为无基线仓创建本轮托盘', () => {
test('称重只随机复用固定托盘且不创建新托盘', () => {
assert.match(measurement, /REUSABLE_PALLET_CODES = Object\.freeze\(\['TP-001', 'YW-TP-002'\]\)/)
assert.match(measurement, /context\.request\('GET',[\s\S]*\/api\/platform\/v1\/pallets\?page=1&size=20/)
assert.match(measurement, /randomlyReusePalletIds\(candidates, palletCount/)
assert.match(measurement, /随机复用原有称重托盘/)
assert.match(measurement, /context\.request\('POST', '\/api\/platform\/v1\/pallets\?tenantId=0'/)
assert.match(measurement, /创建本轮称重托盘/)
assert.match(measurement, /measurement-fixture/)
assert.match(measurement, /固定称重托盘主数据完整/)
assert.doesNotMatch(measurement, /context\.request\('POST', '\/api\/platform\/v1\/pallets/)
assert.doesNotMatch(acceptance, /createPhase3FreshMeasurementPallet/)
})
test('称重脚本不新增库位且容量不足由仓库服务复用 A0 公共位', () => {
@@ -17,6 +17,7 @@ test('retains all clickable container, loading-list and PDA workflow states thro
assert.match(source, /\.\/orders\.mjs/)
assert.match(source, /\.\/inbound-measurement\.mjs/)
assert.match(source, /context\.request\('POST', CONTAINERS/)
assert.match(source, /channelGroupCode:\s*channelGroup\(summary\.channelCode\)/)
assert.match(source, /summary\?\.processStatus === 'WAIT_SPLIT'/)
const pushIndex = source.indexOf('`${SMART}/plans/${plan.id}`')
const transportIndex = source.indexOf('ocean-booking-link')
@@ -9,6 +9,7 @@ const PRELOAD_STATUSES = ['EDITING', 'LOADING']
const PDA_STATUSES = ['UNASSIGNED', 'IN_PROGRESS', 'AWAITING_REVIEW', 'COMPLETED']
import { resolveOceanBookingAgent } from './ocean-booking-fixture.mjs'
import { channelGroup } from './smart-loading-channel-group.mjs'
function id(value) { return value == null ? '' : String(value) }
function records(value) { return Array.isArray(value?.records) ? value.records : [] }
@@ -127,7 +128,7 @@ async function createConfirmedContainer(context, source, label) {
maxWeightKg: 28000 }],
}
const simulationResponse = await context.request('POST', CONTAINERS, {
channelCode: summary.channelCode, warehouseId: source.warehouseId, containerType: '40HC',
channelGroupCode: channelGroup(summary.channelCode), warehouseId: source.warehouseId, containerType: '40HC',
capacityCbm: 76, maxWeightKg: 28000,
})
const simulation = context.apiSuccess(MODULE, `${label} 新增内部模拟柜`, simulationResponse, '', OPTIONS)
@@ -15,6 +15,8 @@ test('默认全量验收先准备已审核商户再运行订单链路', () => {
test('商户夹具通过正式 File 和 IAM 审核流程建立下单资格', () => {
assert.match(fixture, /files\/qualifications\/\$\{encodeURIComponent\(tenantId\)\}\/required-package/)
assert.match(fixture, /required-package\?unifiedSocialCreditCode=\$\{encodeURIComponent\(unifiedSocialCreditCode\)\}/)
assert.doesNotMatch(fixture, /packageForm\.append\('unifiedSocialCreditCode'/)
assert.match(fixture, /iam\/tenants\/\$\{encodeURIComponent\(tenantId\)\}\/submit-review/)
assert.match(fixture, /iam\/tenants\/\$\{encodeURIComponent\(tenantId\)\}\/dimension-initialization/)
assert.match(fixture, /status === 'READY'/)
@@ -62,9 +62,10 @@ export async function run(context) {
const packageForm = new FormData()
packageForm.append('businessLicense', new Blob([QUALIFICATION_IMAGE], { type: 'image/png' }), `business-${suffix}.png`)
packageForm.append('legalPersonId', new Blob([QUALIFICATION_IMAGE], { type: 'image/png' }), `legal-person-${suffix}.png`)
packageForm.append('unifiedSocialCreditCode', `91440300${suffix}`)
const unifiedSocialCreditCode = `91440300${suffix}`
const packageResponse = await context.requestWithBody('POST',
`/api/platform/v1/files/qualifications/${encodeURIComponent(tenantId)}/required-package`, packageForm)
`/api/platform/v1/files/qualifications/${encodeURIComponent(tenantId)}/required-package?unifiedSocialCreditCode=${encodeURIComponent(unifiedSocialCreditCode)}`,
packageForm)
context.apiSuccess(MODULE, '通过 File 上传真实必填资质包', packageResponse,
`tenantId=${tenantId}`, { deploymentBlocker: true })
@@ -37,12 +37,15 @@ test('backend OpenAPI freezes a platform channel query scoped by originWarehouse
'BLOCKED_CONTRACT: platform logistics-channel query by originWarehouseId is not frozen in OpenAPI')
})
test('released requirement association locks the selected channel onto the simulation', () => {
test('released requirement association locks the selected channel group onto the simulation', () => {
const fixture = source(new URL('./phase3-container-loading.mjs', import.meta.url))
assert.match(fixture, /ocean-booking-link/)
assert.match(fixture, /订舱放舱为 RELEASED 且不预生成真实货柜/)
assert.match(fixture, /oceanBookingId: bookingId, requirementId: id\(requirement\.id\), containerNo: realContainerNo/)
assert.match(fixture, /containerDetail\?\.channelCode === channelCode/)
assert.match(fixture, /simulation\?\.channelGroupCode === channelGroup\(channelCode\)/)
assert.match(fixture, /simulation\?\.channelCode == null/)
assert.match(fixture, /containerDetail\?\.channelGroupCode === channelGroup\(channelCode\)/)
assert.match(fixture, /containerDetail\?\.channelCode == null/)
assert.match(fixture, /selectPlatformChannelIsolationPair/)
assert.doesNotMatch(fixture, /\$\{channelCode\}-OTHER|foreignChannelCode/)
})
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { findPricedOrderRoute, findPricedOrderRouteAcrossOrigins } from './orders.mjs'
import { eligibleOrderDestinations, findPricedOrderRoute, findPricedOrderRouteAcrossOrigins } from './orders.mjs'
const source = readFileSync(new URL('orders.mjs', import.meta.url), 'utf8')
@@ -18,6 +18,16 @@ test('orders fixture verifies the complete pricing tuple before selecting an FBA
assert.match(source, /warehouse\?\.status === 'ACTIVE'/)
})
test('FBA order fixture excludes warehouses without the internal code required by pallet labels', () => {
const warehouses = [
{ id: 'abe3', warehouseCode: 'ABE3', status: 'ACTIVE', internalCode: null },
{ id: 'abe4', warehouseCode: 'ABE4', status: 'ACTIVE', internalCode: '126' },
{ id: 'abe8', warehouseCode: 'ABE8', status: 'INACTIVE', internalCode: '39' },
]
assert.deepEqual(eligibleOrderDestinations(warehouses, 'FBA').map((item) => item.warehouseCode), ['ABE4'])
assert.deepEqual(eligibleOrderDestinations(warehouses, 'OVERSEAS').map((item) => item.warehouseCode), ['ABE3', 'ABE4'])
})
test('pricing selection skips an ACTIVE domestic warehouse with an empty channel catalog', async () => {
const channelCalls = []
const result = await findPricedOrderRouteAcrossOrigins({
+22 -9
View File
@@ -55,6 +55,12 @@ export function requestedOrderDestinationType(context) {
const value = String(context?.context?.orderDestinationType || 'FBA').trim().toUpperCase()
return ['FBA', 'OVERSEAS', 'PRIVATE'].includes(value) ? value : 'FBA'
}
export function eligibleOrderDestinations(values, destinationType) {
const active = (Array.isArray(values) ? values : []).filter((warehouse) => warehouse?.status === 'ACTIVE')
return destinationType === 'FBA'
? active.filter((warehouse) => String(warehouse?.internalCode || '').trim() !== '')
: active
}
export function approvedInboundReady(orderDetail, taskResponse, tenantId, orderId) {
const summary = orderDetail?.summary
const task = success(taskResponse) ? taskResponse.body?.data : null
@@ -251,13 +257,13 @@ async function loadMasterData(context, merchantToken) {
records(destinationPage).length > 0, `count=${records(destinationPage).length}`)
}
const receivings = records(domesticPage).filter((warehouse) => warehouse?.status === 'ACTIVE')
let destinations = records(destinationPage).filter((warehouse) => warehouse?.status === 'ACTIVE')
let destinations = eligibleOrderDestinations(records(destinationPage), destinationType)
if (Number(destinationPage?.total) > destinations.length) {
const nextResponse = await request(context, 'GET',
`/api/merchant/v1/warehouses?page=2&size=200&status=ACTIVE&warehouseType=${encodeURIComponent(destinationType)}`,
undefined, merchantToken)
const nextPage = context.apiSuccess(MODULE, `Client 查询 ${destinationType} 目的仓第二页`, nextResponse)
destinations = [...destinations, ...records(nextPage).filter((warehouse) => warehouse?.status === 'ACTIVE')]
destinations = [...destinations, ...eligibleOrderDestinations(records(nextPage), destinationType)]
}
if (receivings.length === 0 || (destinationType !== 'PRIVATE' && destinations.length === 0)) {
throw new Error('缺少创建测试订单所需的收货仓或目的仓')
@@ -396,6 +402,11 @@ function buildPayload(context, profile, master, productImageFileId, customerAddr
? context.context.waitSplitVariant.packageCounts : []
const selectedBoxes = BOXES.slice(0, skuCount)
const orderProfile = context.context?.orderProfile || {}
const dropship = String(context.context?.orderFulfillmentType || 'STANDARD').trim().toUpperCase()
=== 'ONE_CLICK_DROPSHIP'
const mixedPackageCount = Math.max(1,
Number(context.context?.waitSplitVariant?.totalPackageCount || packageCountPerSku))
const mixedPackingGroupNo = `MIX-${context.marker}`.slice(0, 32)
const isPrivate = master.destinationType === 'PRIVATE'
const pickupMethod = orderProfile.pickupMethod || 'SELF_EXPRESS'
const receiver = isPrivate ? {
@@ -405,7 +416,7 @@ function buildPayload(context, profile, master, productImageFileId, customerAddr
addressLine: `${context.marker} 88 Randomized Test Ave`, postalCode: '11354',
} : null
return {
fulfillmentType: String(context.context?.orderFulfillmentType || 'STANDARD').trim().toUpperCase(),
fulfillmentType: dropship ? 'ONE_CLICK_DROPSHIP' : 'STANDARD',
customerId: profile.id,
customerName: companyName,
transportMode: 'SEA', tradeTerm: 'FOB',
@@ -433,17 +444,19 @@ function buildPayload(context, profile, master, productImageFileId, customerAddr
},
receiver,
cargoes: selectedBoxes.map((box, index) => {
const volumeCbm = box.lengthCm * box.widthCm * box.heightCm / 1_000_000
const unitsPerPackage = 10 + index
const packageCount = Number(packageCounts[index] || packageCountPerSku)
const packageCount = dropship ? mixedPackageCount : Number(packageCounts[index] || packageCountPerSku)
const carton = dropship ? selectedBoxes[0] : box
const cartonVolumeCbm = carton.lengthCm * carton.widthCm * carton.heightCm / 1_000_000
return {
skuNo: `SKU-${box.boxType}-${context.marker}`.slice(0, 64),
cargoName: `${box.boxType} 测试产品`, cargoNameEn: `${box.boxType} test product`,
hsCode: '8518210000', customsHsCode: '8518210000', packageType: 'CARTON',
lengthCm: box.lengthCm, widthCm: box.widthCm, heightCm: box.heightCm,
unitsPerPackage, packageCount, grossWeightKg: (10 + index) * packageCount,
volumeCbm: volumeCbm * packageCount, totalQuantity: unitsPerPackage * packageCount,
netWeightKg: (9 + index) * packageCount,
packingGroupNo: dropship ? mixedPackingGroupNo : null,
lengthCm: carton.lengthCm, widthCm: carton.widthCm, heightCm: carton.heightCm,
unitsPerPackage, packageCount, grossWeightKg: (dropship ? 10 : 10 + index) * packageCount,
volumeCbm: cartonVolumeCbm * packageCount, totalQuantity: unitsPerPackage * packageCount,
netWeightKg: (dropship ? 9 : 9 + index) * packageCount,
unitDeclaredValue: 8.5, declaredValue: unitsPerPackage * packageCount * 8.5, currency: 'USD',
material: '塑料 / Plastic', usage: '接口验收 / API acceptance',
model: `MODEL-${box.boxType}`, brand: 'TEST-NO-LOGO',
@@ -3,6 +3,7 @@ import { diffPushPreloadStability, projectPushPreloadStability } from './push-pr
import { containerFieldGaps, loadingListFieldGaps, preloadFieldGaps } from './fulfillment-list-field-contract.mjs'
import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs'
import { iso6346ContainerNumber } from './iso6346-container-number.mjs'
import { channelGroup } from './smart-loading-channel-group.mjs'
const MODULE = 'phase3-container-loading'
const OPTIONS = { scenario: 'phase3-container-smart-loading-pda', deploymentBlocker: true }
@@ -208,7 +209,7 @@ export async function run(context) {
context.assert(MODULE, '订舱渠道与仓库快照完整', inboundResponse, Boolean(channelCode && channelName && warehouseName),
`channelCode=${channelCode || 'missing'}, warehouseId=${warehouseId}, warehouseName=${warehouseName || 'missing'}`, OPTIONS)
const simulationResponse = await request(context, 'POST', CONTAINERS, {
channelCode, warehouseId, containerType: primaryRequirement.containerType,
channelGroupCode: channelGroup(channelCode), warehouseId, containerType: primaryRequirement.containerType,
capacityCbm: primaryRequirement.capacityCbm, maxWeightKg: primaryRequirement.maxWeightKg,
})
const simulation = context.apiSuccess(MODULE, '新增内部模拟柜', simulationResponse,
@@ -216,7 +217,8 @@ export async function run(context) {
const containerId = id(simulation?.id)
context.assert(MODULE, '模拟柜由服务端生成稳定身份且不伪造真实柜号或订舱', simulationResponse,
Boolean(containerId) && Boolean(simulation?.cabinetNo) && simulation?.containerNo == null
&& simulation?.oceanBookingId == null && simulation?.channelCode === channelCode
&& simulation?.oceanBookingId == null && simulation?.channelGroupCode === channelGroup(channelCode)
&& simulation?.channelCode == null
&& id(simulation?.warehouseId) === warehouseId,
`containerBookingId=${containerId || 'missing'}, cabinetNo=${simulation?.cabinetNo || 'missing'}, oceanBookingId=${id(simulation?.oceanBookingId) || 'null'}`, OPTIONS)
if (!containerId) return
@@ -240,7 +242,8 @@ export async function run(context) {
} else {
const foreignChannel = channelIsolation.pair.foreign
const foreignContainerResponse = await request(context, 'POST', CONTAINERS, {
channelCode: foreignChannel.channelCode, warehouseId, containerType: primaryRequirement.containerType,
channelGroupCode: channelGroup(foreignChannel.channelCode), warehouseId,
containerType: primaryRequirement.containerType,
capacityCbm: primaryRequirement.capacityCbm, maxWeightKg: primaryRequirement.maxWeightKg,
})
const foreignContainer = context.apiSuccess(MODULE, '新增真实异渠道模拟柜', foreignContainerResponse, '', OPTIONS)
@@ -288,9 +291,10 @@ export async function run(context) {
context.assert(MODULE, '货柜详情保留候选货物路线约束', containerDetailResponse,
(!containerDetail?.destinationWarehouseCode || !candidate.destinationWarehouseCode
|| containerDetail.destinationWarehouseCode === candidate.destinationWarehouseCode)
&& containerDetail?.channelCode === channelCode
&& containerDetail?.channelGroupCode === channelGroup(channelCode)
&& containerDetail?.channelCode == null
&& ['EDITING', 'WAIT_LOADING'].includes(containerDetail?.planStatus),
`planStatus=${containerDetail?.planStatus || 'missing'}, channel=${containerDetail?.channelCode || 'missing'}`, OPTIONS)
`planStatus=${containerDetail?.planStatus || 'missing'}, channelGroup=${containerDetail?.channelGroupCode || 'missing'}`, OPTIONS)
const containerNo = id(containerDetail?.containerNo || containerDetail?.cabinetNo)
const planResponse = await request(context, 'GET', `${SMART}/plans/by-booking/${encodeURIComponent(containerId)}`)
@@ -517,7 +521,7 @@ export async function run(context) {
`containerBookingId=${id(mergedContainer?.containerBookingId) || 'missing'}, requirementId=${id(mergedContainer?.requirementId) || 'missing'}, allocationId=${id(mergedContainer?.allocationId) || 'missing'}`, OPTIONS)
const duplicateSimulationResponse = await request(context, 'POST', CONTAINERS, {
channelCode, warehouseId, containerType: primaryRequirement.containerType,
channelGroupCode: channelGroup(channelCode), warehouseId, containerType: primaryRequirement.containerType,
capacityCbm: primaryRequirement.capacityCbm, maxWeightKg: primaryRequirement.maxWeightKg,
})
const duplicateSimulation = context.apiSuccess(MODULE, '新增重复容量占用负向模拟柜', duplicateSimulationResponse, '', OPTIONS)
@@ -1,6 +1,7 @@
import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs'
import { isPureStatusNoop, visibleLoadingHistories } from './loading-history-noop.mjs'
import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs'
import { channelGroup } from './smart-loading-channel-group.mjs'
import { createHash } from 'node:crypto'
const MODULE = 'phase3-loading'
@@ -135,20 +136,6 @@ function inventorySignature(snapshot) {
})))
}
function channelGroup(channelCode) {
const value = String(channelCode || '').toUpperCase()
if (value.startsWith('EXX_')) return 'EXX'
if (value.startsWith('MATSON_CLX_')) return 'MATSON_CLX'
if (value.startsWith('MATSON_MAX_')) return 'MATSON_MAX'
if (value.startsWith('HEDE_')) return 'HEDE'
if (value.startsWith('OA_LA_') || value === 'OA_PARCEL') return 'OA_LA'
if (value.startsWith('OA_CHICAGO_')) return 'OA_CHICAGO'
if (value.startsWith('OA_HOUSTON_')) return 'OA_HOUSTON'
if (value.startsWith('OA_SAVANNAH_')) return 'OA_SAVANNAH'
if (value === 'OA_NEW_YORK_TRUCK') return value
throw new Error(`unsupported smart-loading channel group: ${channelCode}`)
}
function operationsSignature(operations) {
return JSON.stringify((operations || []).map((operation) => ({
id: id(operation?.id), operationId: operation?.operationId || null,
@@ -226,7 +213,7 @@ export async function run(context) {
maxWeightKg: 28000 }],
}
const simulationResponse = await request(context, 'POST', '/api/platform/v1/containers', {
channelCode: initial?.summary?.channelCode, warehouseId, containerType: '40HC',
channelGroupCode: channelGroup(initial?.summary?.channelCode), warehouseId, containerType: '40HC',
capacityCbm: 76, maxWeightKg: 28000,
})
const simulation = context.apiSuccess(MODULE, '新增三期内部模拟柜', simulationResponse, '', OPTIONS)
@@ -106,6 +106,14 @@ test('booking freezes the source customer channel and warehouse scope', () => {
assert.match(source, /containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000/)
})
test('split chain starts from the partial inbound and derives quantities from the real tree', () => {
assert.match(source, /fixture\?\.incrementalInboundId \|\| fixture\?\.inboundId/)
assert.match(source, /const originalPackageCount = Number\(firstTree\?\.originalPackageCount\)/)
assert.match(source, /while \(activeRemainders\(tree\)\.length > 0\)/)
assert.match(source, /sourcePackageCount = Number\(source\?\.packageCount\)/)
assert.doesNotMatch(source, /snapshotMatches\(firstTree, 4, 1, 3, 1\)/)
})
test('booking fixture never invents supplier names without formal IDs', () => {
assert.match(source, /oceanBookingDraft/)
assert.doesNotMatch(source, /SPLIT-SHIPPING|SPLIT-AGENT/)
@@ -10,6 +10,7 @@ const PDA = '/api/platform/v1/first-leg/pda-loading'
import { oceanBookingDraft } from './ocean-booking-fixture.mjs'
import { iso6346ContainerNumber } from './iso6346-container-number.mjs'
import { ensureInitialCalculated } from './inbound-fees.mjs'
import { channelGroup } from './smart-loading-channel-group.mjs'
function id(value) { return value == null ? '' : String(value) }
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
@@ -186,7 +187,8 @@ async function loadOneFragment(context, state) {
const carrier = await ensureMatsonCarrierPartner(context, MODULE, OPTIONS)
if (!carrier?.id) return null
const simulationResponse = await request(context, 'POST', CONTAINERS, {
channelCode: state.channelCode, warehouseId, containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000,
channelGroupCode: channelGroup(state.channelCode), warehouseId,
containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000,
})
const simulation = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 新增内部模拟柜`, simulationResponse, '', OPTIONS)
const containerId = id(simulation?.id)
@@ -237,9 +239,9 @@ async function loadOneFragment(context, state) {
const candidate = Array.isArray(candidates)
? candidates.find((row) => id(row.orderId) === sourceOrderId && id(row.accountId) === tenantId)
: null
context.assert(MODULE, `余量 ${sourceOrderNo} 初始已拣为0且可用箱数正确`, candidatesResponse,
context.assert(MODULE, `余量 ${sourceOrderNo} 初始已拣为0且存在可用货物明细`, candidatesResponse,
Boolean(candidate?.inboundId) && Boolean(candidate?.inboundDetailId)
&& Number(candidate?.availablePackageCount) === sourcePackageCount,
&& Number(candidate?.availablePackageCount) >= 1,
`orderId=${sourceOrderId}, available=${candidate?.availablePackageCount ?? 'missing'}`, OPTIONS)
if (!candidate?.inboundId || !candidate?.inboundDetailId) return null
@@ -484,7 +486,7 @@ async function loadOneFragment(context, state) {
export async function run(context) {
const fixture = context.createdResources?.phase3Loading || context.createdResources?.phase3ContainerLoading
const tenantId = id(fixture?.tenantId)
const inboundId = id(fixture?.inboundId)
const inboundId = id(fixture?.incrementalInboundId || fixture?.inboundId)
const warehouseId = id(fixture?.warehouseId)
const containerBookingId = id(fixture?.containerBookingId)
if (!tenantId || !inboundId || !warehouseId || !containerBookingId || fixture?.marker !== context.marker) {
@@ -511,8 +513,20 @@ export async function run(context) {
const firstTreeResponse = await context.request('GET', treePath(orderId, tenantId))
const firstTree = context.apiSuccess(MODULE, '查询首次拆分后的根履约树', firstTreeResponse,
`orderId=${orderId}, tenantId=${tenantId}`, OPTIONS)
const originalPackageCount = Number(firstTree?.originalPackageCount)
const initialLoadedPackageCount = Number(firstTree?.cumulativeLoadedPackageCount)
const initialRemainingPackageCount = Number(firstTree?.currentRemainderPackageCount)
const initialActive = activeRemainders(firstTree)
const initialActiveSequence = Number(initialActive[0]?.splitSequence)
context.assert(MODULE, '新批次由来源单承接已装且只创建一个余量子单', firstTreeResponse,
treeShape(firstTree, orderId) && snapshotMatches(firstTree, 4, 1, 3, 1)
treeShape(firstTree, orderId)
&& Number.isInteger(originalPackageCount) && originalPackageCount > 1
&& Number.isInteger(initialLoadedPackageCount) && initialLoadedPackageCount > 0
&& Number.isInteger(initialRemainingPackageCount) && initialRemainingPackageCount > 0
&& originalPackageCount === initialLoadedPackageCount + initialRemainingPackageCount
&& initialActive.length === 1 && Number.isInteger(initialActiveSequence)
&& snapshotMatches(firstTree, originalPackageCount, initialLoadedPackageCount,
initialRemainingPackageCount, initialActiveSequence)
&& firstTree.children.filter((row) => row.splitRole === 'LOADED').length === 1
&& firstTree.children.filter((row) => row.splitRole === 'REMAINDER').length === 1,
`original=${firstTree?.originalPackageCount ?? 'missing'}, loaded=${firstTree?.cumulativeLoadedPackageCount ?? 'missing'}, remaining=${firstTree?.currentRemainderPackageCount ?? 'missing'}`, OPTIONS)
@@ -521,7 +535,8 @@ export async function run(context) {
const firstLoaded = firstTree.children.find((row) => row.splitRole === 'LOADED')
context.assert(MODULE, '新拆分未生成 LOADED 子单且完成目标保持来源单', firstTreeResponse,
id(firstLoaded?.orderId) === orderId && id(firstLoaded?.sourceOrderId) === orderId
&& firstLoaded?.orderNo === firstTree.parentOrderNo && Number(firstLoaded?.packageCount) === 1,
&& firstLoaded?.orderNo === firstTree.parentOrderNo
&& Number(firstLoaded?.packageCount) === initialLoadedPackageCount,
`loadedOrderId=${id(firstLoaded?.orderId) || 'missing'}, sourceOrderId=${id(firstLoaded?.sourceOrderId) || 'missing'}`, OPTIONS)
const merchantLogin = await context.request('POST', '/api/auth/login', {
@@ -534,7 +549,9 @@ export async function run(context) {
`/api/merchant/v1/orders/${encodeURIComponent(orderId)}/fulfillment-tree`, undefined, merchantToken)
const merchantTree = context.apiSuccess(MODULE, '客户查询本客户根履约树', merchantTreeResponse, '', OPTIONS)
context.assert(MODULE, '客户履约树与平台数量聚合一致', merchantTreeResponse,
treeShape(merchantTree, orderId) && snapshotMatches(merchantTree, 4, 1, 3, 1),
treeShape(merchantTree, orderId)
&& snapshotMatches(merchantTree, originalPackageCount, initialLoadedPackageCount,
initialRemainingPackageCount, initialActiveSequence),
`children=${merchantTree?.children?.length ?? 'missing'}`, OPTIONS)
const forbidden = await request(context, 'GET', treePath(orderId, tenantId), undefined, {}, merchantToken)
@@ -569,41 +586,54 @@ export async function run(context) {
cleanupNote: '公开 API 不允许删除订单/柜次/PDA审计记录;只有全部分片完成且无活动余量时标记业务清理完成。',
}
let tree = firstTree
for (const step of [
{ sourcePackageCount: 3, expectedLoaded: 2, expectedRemaining: 2, sourceSuffix: 1, activeSuffix: 2 },
{ sourcePackageCount: 2, expectedLoaded: 3, expectedRemaining: 1, sourceSuffix: 2, activeSuffix: 3 },
{ sourcePackageCount: 1, expectedLoaded: 4, expectedRemaining: 0, sourceSuffix: 3, activeSuffix: null },
]) {
let completedFragments = 0
while (activeRemainders(tree).length > 0) {
const active = activeRemainders(tree)
const source = active[0]
context.assert(MODULE, `链式拆分来源固定为根编号 -${step.sourceSuffix}`, null,
active.length === 1 && source?.orderNo === `${tree.parentOrderNo}-${step.sourceSuffix}`,
const sourceSuffix = Number(source?.splitSequence)
const sourcePackageCount = Number(source?.packageCount)
const beforeLoaded = Number(tree.cumulativeLoadedPackageCount)
const beforeRemaining = Number(tree.currentRemainderPackageCount)
context.assert(MODULE, `链式拆分来源固定为根编号 -${sourceSuffix}`, null,
active.length === 1 && Number.isInteger(sourceSuffix) && sourceSuffix > 0
&& source?.orderNo === `${tree.parentOrderNo}-${sourceSuffix}`
&& Number.isInteger(sourcePackageCount) && sourcePackageCount === beforeRemaining,
`active=${active.map((row) => row.orderNo).join(',') || 'none'}`, OPTIONS)
if (!source) return
const result = await loadOneFragment(context, {
tenantId, rootOrderId: orderId, sourceOrderId: id(source.orderId), sourceOrderNo: source.orderNo,
customerName, channelCode, channelName, warehouseId, warehouseName,
sourcePackageCount: step.sourcePackageCount, sequence: step.sourceSuffix,
sourcePackageCount, sequence: sourceSuffix,
})
if (!result?.tree) return
tree = result.tree
completedFragments += 1
const expectedLoaded = beforeLoaded + 1
const expectedRemaining = beforeRemaining - 1
const activeSuffix = expectedRemaining > 0 ? sourceSuffix + 1 : null
Object.assign(context.createdResources.phase3OrderSplitting, {
activeRemainderCount: activeRemainders(tree).length,
finalLoadedPackageCount: Number(tree.cumulativeLoadedPackageCount),
finalRemainingPackageCount: Number(tree.currentRemainderPackageCount),
})
context.assert(MODULE, `完成第 ${step.sourceSuffix + 1} 个分片后根数量守恒`, result.treeResponse,
context.assert(MODULE, `完成余量分片 -${sourceSuffix} 后根数量守恒`, result.treeResponse,
treeShape(tree, orderId)
&& snapshotMatches(tree, 4, step.expectedLoaded, step.expectedRemaining, step.activeSuffix),
&& snapshotMatches(tree, originalPackageCount, expectedLoaded, expectedRemaining, activeSuffix),
`original=${tree.originalPackageCount}, loaded=${tree.cumulativeLoadedPackageCount}, remaining=${tree.currentRemainderPackageCount}`, OPTIONS)
if (completedFragments > originalPackageCount) {
context.addStep(MODULE, '余量链在原始箱数边界内收敛', null, false,
`original=${originalPackageCount}, fragments=${completedFragments}`, OPTIONS)
return
}
}
const remainderCodes = tree.children.filter((row) => row.splitRole === 'REMAINDER').map((row) => row.orderNo)
const expectedRemainderCodes = Array.from({ length: completedFragments }, (_, index) =>
`${tree.parentOrderNo}-${initialActiveSequence + index}`)
context.assert(MODULE, '余量子单始终按根编号全局递增且禁止嵌套编号', null,
JSON.stringify(remainderCodes) === JSON.stringify([
`${tree.parentOrderNo}-1`, `${tree.parentOrderNo}-2`, `${tree.parentOrderNo}-3`,
]) && remainderCodes.every((code) => !/-[0-9]+-[0-9]+$/.test(code)),
JSON.stringify(remainderCodes) === JSON.stringify(expectedRemainderCodes)
&& remainderCodes.every((code) => !/-[0-9]+-[0-9]+$/.test(code)),
`codes=${remainderCodes.join(',')}`, OPTIONS)
context.assert(MODULE, '最后一次全量装完不再生成子单且根整体完成', null,
activeRemainders(tree).length === 0 && Number(tree.currentRemainderPackageCount) === 0
@@ -53,7 +53,7 @@ export async function run(context) {
`${taskPath}?warehouseId=${encodeURIComponent(warehouseId)}`)
const taskDetail = context.apiSuccess(MODULE, '按稳定 taskId 读取 PDA 任务详情', taskDetailResponse, '', OPTIONS)
const pendingResponse = await request(context, 'GET',
`/api/platform/v1/first-leg/pda-loading/tasks/pending?keyword=${encodeURIComponent(containerNo)}`)
`/api/platform/v1/first-leg/pda-loading/tasks/pending?warehouseId=${encodeURIComponent(warehouseId)}&keyword=${encodeURIComponent(containerNo)}`)
const pendingTasks = context.apiSuccess(MODULE, '读取当前操作员 PDA pending 列表', pendingResponse, '', OPTIONS)
const pendingTask = Array.isArray(pendingTasks)
? pendingTasks.find((item) => id(item?.taskId) === taskId)
@@ -434,7 +434,9 @@ test('container loading permits manual cross-channel allocation and keeps stable
'换柜号后按稳定计划身份回查 PDA 任务',
'换柜号不改变任务计划柜次柜次号和库存快照身份',
])
assert.match(value, /containerDetail\?\.channelCode === channelCode/)
assert.match(value, /simulation\?\.channelGroupCode === channelGroup\(channelCode\)/)
assert.match(value, /simulation\?\.channelCode == null/)
assert.match(value, /containerDetail\?\.channelGroupCode === channelGroup\(channelCode\)/)
assert.match(value, /id\(replacedTask\?\.cabinetNo\) === stableIdentity\.cabinetNo/)
assert.match(value, /afterReplacementSnapshot\?\.snapshotToken === beforeReplacementSnapshot\?\.snapshotToken/)
assert.match(value, /pdaIdentityStable: true/)
@@ -71,6 +71,8 @@ test('canonical states, ten ordered nodes, exception codes and complete payload
test('actual node timestamps advance from the frozen outbound-confirm fact instead of the runner timezone', () => {
assert.match(source, /Date\.parse\(`\$\{frozenLatestActual\}Z`\)/)
assert.match(source, /frozenLatestActualMs\) \? Date\.now\(\) : frozenLatestActualMs/)
assert.equal((source.match(/observedAt: localWallIso\(new Date\(\)\)/g) || []).length, 3)
assert.match(source, /const observedAt = localWallIso\(new Date\(\)\)/)
})
test('module exposes no scheduler, attempt, internal or arbitrary stop endpoint', () => {
@@ -185,7 +187,7 @@ function markerOwnedContext() {
if (body.actualAt != null
&& NODE_SEQUENCES[body.standardNode] <= NODE_SEQUENCES[task.latestActualNode]) {
return conflict(`req-monotonic-${key}`,
`人工实际节点必须严格晚于当前最新实际节点:${task.latestActualNode}`)
'实际船节点必须按业务顺序向后推进')
}
const providerEventId = `manual-${String(nextId)}`
nodes.push({
@@ -34,10 +34,15 @@ function codeOf(response) { return response?.body?.code == null ? 'missing' : St
function rejected(response) { return response?.http === 200 && ['A0409', '409'].includes(codeOf(response)) }
function monotonicRejected(response) {
return rejected(response)
&& String(response?.body?.message || '').includes('人工实际节点必须严格晚于当前最新实际节点')
&& String(response?.body?.message || '').includes('实际船节点必须按业务顺序向后推进')
&& Boolean(response?.requestId) && Boolean(response?.traceId)
}
function localIso(value) { return value.toISOString().slice(0, 19) }
function localWallIso(value) {
const pad = (part) => String(part).padStart(2, '0')
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}`
+ `T${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`
}
export function markerOwnedCarrierQuery(context) {
const resource = context?.createdResources?.phase3CarrierScheduleQuery
@@ -167,7 +172,7 @@ export async function runUntilPickupReady(context) {
ownershipMatches, `registered=${queryId}, actual=${id(initialTask?.id) || 'missing'}`, OPTIONS)
if (!ownershipMatches) return
const observedAt = localIso(new Date())
const observedAt = localWallIso(new Date())
const estimatedAt = localIso(new Date(Date.now() + 30 * 24 * 60 * 60_000))
const estimatedBody = manualNodePayload({ expectedVersion: Number(initialTask.version),
standardNode: 'ARRIVED_DESTINATION_PORT', observedAt, estimatedAt, marker: context.marker })
@@ -226,7 +231,7 @@ export async function runUntilPickupReady(context) {
const sameNodeSnapshot = actualProgressSnapshot(detail)
const sameNodeBody = manualNodePayload({ expectedVersion: version,
standardNode: detail.query.query.latestActualNode, observedAt: localIso(new Date()),
standardNode: detail.query.query.latestActualNode, observedAt: localWallIso(new Date()),
actualAt: localIso(new Date(baseActual)), marker: context.marker, index: frozenLatestNodeIndex + 1 })
const sameNodeResponse = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, sameNodeBody,
context.token, { 'Idempotency-Key': `${context.marker}-carrier-same-actual`.slice(0, 128) })
@@ -250,7 +255,7 @@ export async function runUntilPickupReady(context) {
const standardNode = STANDARD_NODES[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: index + 1 })
observedAt: localWallIso(new Date()), actualAt, marker: context.marker, index: index + 1 })
const key = `${context.marker}-carrier-actual-${String(index + 1).padStart(2, '0')}`.slice(0, 128)
const response = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, body,
context.token, { 'Idempotency-Key': key })
@@ -275,7 +280,7 @@ export async function runUntilPickupReady(context) {
const progressedSnapshot = actualProgressSnapshot(detail)
const earlierNode = STANDARD_NODES[index - 1]
const earlierBody = manualNodePayload({ expectedVersion: version, standardNode: earlierNode,
observedAt: localIso(new Date()), actualAt: localIso(new Date(baseActual + (index + 1) * 60_000)),
observedAt: localWallIso(new Date()), actualAt: localIso(new Date(baseActual + (index + 1) * 60_000)),
marker: context.marker, index })
const earlierResponse = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, earlierBody,
context.token, { 'Idempotency-Key': `${context.marker}-carrier-earlier-actual`.slice(0, 128) })
@@ -30,6 +30,7 @@ test('module never borrows historical order or inventory candidates', () => {
})
test('relabel transfer and discard cover create replay conflict list detail and legal initial states', () => {
assert.match(source, /deliveryMethod: 'TRUCK', printShippingLabel: false/)
assert.match(source, /chargeAmount: 0\.01/)
assert.match(source, /cartonMark:/)
assert.match(source, /fbaCartonMark:/)
@@ -108,6 +108,7 @@ async function verifyRelabel(context, token, order) {
operationWarehouseId: order.row.overseasWarehouseId,
labelDeadline: future(7), contactName: '客户贴标验收联系人', contactPhone: '13800000006',
chargeAmount: 0.01,
deliveryMethod: 'TRUCK', printShippingLabel: false,
remark: context.marker, idempotencyKey: `${context.marker}-CLIENT-RELABEL`.slice(0, 255),
items: [{
sourceInventoryBalanceId: id(source.inventoryBalanceId),
@@ -107,10 +107,12 @@ test('PDA module covers the container-number receiving contract and excludes out
assert.match(source, /安全投影已收敛/)
assert.match(source, /projectionReady = success\(projectionResponse\) \|\| notFound\(projectionResponse\)/)
assert.match(source, /setTimeout\(resolve, 500\)/)
assert.match(source, /超收被拒绝/)
assert.match(source, /重复累计不产生超收/)
assert.match(source, /重复箱新键被拒绝/)
assert.match(source, /key\(context, `fba-bulk-\$\{index\}`\)/)
assert.match(source, /packageCount = Number\(inbound\.pendingPackages \|\| 0\)/)
assert.match(source, /inbounds\.reduce\(\(total, inbound\) => total \+ Number\(inbound\.totalPackages \|\| 0\), 0\)/)
assert.match(source, /resolvedInboundCodes\.includes\(scannedInboundCode\)/)
assert.match(source, /duplicate\.data\?\.result === 'ALREADY_RECEIVED'/)
assert.match(source, /if \(packageCount <= 0\) return true/)
assert.match(source, /key\(context, `overseas-carton-\$\{index\}`\)/)
assert.match(source, /模式硬拒绝/)
@@ -5,7 +5,7 @@ import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
import { auxiliaryPalletFbaWarehouseCode, run } from './phase4-overseas-pda-operations.mjs'
import { run } from './phase4-overseas-pda-operations.mjs'
const here = path.dirname(fileURLToPath(import.meta.url))
const source = fs.readFileSync(path.join(here, 'phase4-overseas-pda-operations.mjs'), 'utf8')
@@ -29,6 +29,8 @@ test('receiving hands off only transient credentials and completed warehouse fac
assert.match(receivingSource, /context\.context\.phase4OverseasPdaOperations = \{/)
assert.match(receivingSource, /completedFixtures: \[\]/)
assert.match(receivingSource, /merchantToken,/)
assert.match(receivingSource, /const refreshedMerchantToken = await loginMerchantForNegativeProbe\(context\)/)
assert.match(receivingSource, /phase4OverseasPdaOperations\.merchantToken = refreshedMerchantToken/)
assert.match(receivingSource, /if \(!handoffReady\) \{[\s\S]*await cleanup\(\)/)
assert.doesNotMatch(receivingSource, /createdResources\.phase4OverseasPdaOperations/)
assert.doesNotMatch(receivingSource, /primary: primary \? \{[^}]*password/)
@@ -66,16 +68,25 @@ test('P2 to P5 cover all frozen endpoints, replays and conflict boundaries witho
assert.match(source, /traceId=/)
})
test('auxiliary pallets use the authoritative inbound FBA code and never the business destination fallback', () => {
assert.equal(auxiliaryPalletFbaWarehouseCode('FBA', ' ABE3 '), 'ABE3')
assert.equal(auxiliaryPalletFbaWarehouseCode('OVERSEAS', 'ABE3'), null)
assert.equal(auxiliaryPalletFbaWarehouseCode('FBA', ''), null)
test('PDA operations reuse received plan pallets and the public location without creating resources', () => {
assert.match(receivingSource,
/destinationType: inbound\.destinationType,[\s\S]*fbaWarehouseCode: inbound\.destinationType === 'FBA' \? inbound\.fbaWarehouseCode : null/)
assert.match(source, /fbaWarehouseCode,\s*\n\s*remark:/)
assert.match(source, /createLocation\(context, fixture\.overseasWarehouseId, `\$\{suffix\}-PALLET`\)/)
assert.match(source, /locationId: location\.locationId/)
assert.doesNotMatch(source, /createEmptyPallet\([^\n]*main\.destinationWarehouseCode/)
/auxiliaryPallets: generatedContainerPallets\.filter/)
assert.match(source, /resolvePublicLocation/)
assert.match(source, /resolveReusableAuxiliaryPallet/)
assert.match(source, /locationType === 'VIRTUAL'/)
assert.match(source, /P3托盘已在公共库位不重复移库/)
assert.doesNotMatch(source, /warehouse-admin[^'`]*\/locations`,\s*\{/)
assert.doesNotMatch(source, /warehouse-admin[^'`]*\/pallets`,\s*\{/)
assert.doesNotMatch(source, /createLocation|createEmptyPallet/)
})
test('P5 follows warehouse review, customer confirmation and fulfillment completion in order', () => {
assert.match(source, /applicationStatus === 'CUSTOMER_CONFIRM_PENDING'/)
assert.match(source, /const merchantRelabelBase = '\/api\/merchant\/v1\/last-leg\/relabel-applications'/)
assert.match(source, /`\$\{merchantRelabelBase\}\/\$\{encodeURIComponent\(relabelNo\)\}\/confirm-completion`/)
assert.match(source, /status === 'READY_FOR_OUTBOUND'/)
assert.match(source, /status: 'COMPLETED'/)
assert.match(source, /walletStatus === 'SETTLED'/)
})
test('fixture failure still disables both picker accounts and removes all transient tokens', async () => {
@@ -1,3 +1,5 @@
import { prepareTerminalDrayage } from './phase5-terminal-drayage.mjs'
const MODULE = 'phase4-overseas-pda'
const SCENARIO = 'phase4-overseas-pda-container-receiving'
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
@@ -91,8 +93,10 @@ async function captureCompletedOrder(context, containerNo, expectedCustomerId) {
async function loginMerchantForNegativeProbe(context) {
const fixture = context.context?.merchantQualificationFixture
if (!fixture?.username || !fixture?.password) return null
const response = await context.request('POST', '/api/auth/login', { loginId: fixture.username, password: fixture.password }, '')
const username = fixture?.username || context.options?.merchantUsername
const password = fixture?.password || context.options?.merchantPassword
if (!username || !password) return null
const response = await context.request('POST', '/api/auth/login', { loginId: username, password }, '')
const data = success(response) ? response.body?.data : null
context.addStep(MODULE, '本轮客户登录取得海外令牌用于PDA越权反证', response,
success(response) && nonBlank(data?.overseasAccessToken),
@@ -204,31 +208,38 @@ async function resolveGeneratedPallet(context, inbound) {
async function verifyWarehouseQueries(context, token, inboundCode, pallet, destinationType) {
const inventory = await pda(context, token, 'POST', '完成入库后按入仓码现场查询库存',
'/api/pda/v1/last-leg/inventory/resolve', { code: inboundCode })
const inventoryItems = Array.isArray(inventory.data?.items) ? inventory.data.items : []
context.assert(MODULE, '现场库存查询只返回Token绑定仓且包含库存位置', inventory.response,
success(inventory.response) && inventory.data?.queryCode === inboundCode
&& Array.isArray(inventory.data?.items) && inventory.data.items.length > 0
&& inventory.data.items.every((item) => id(item?.palletId) === pallet.palletId
&& id(item?.locationId) === pallet.locationId && Number(item?.availablePackages) > 0),
&& inventoryItems.length > 0
&& inventoryItems.every((item) => longId(id(item?.palletId))
&& longId(id(item?.locationId)) && nonBlank(item?.palletCode)
&& nonBlank(item?.locationCode) && Number(item?.availablePackages) > 0),
`items=${inventory.data?.items?.length ?? 'missing'}; ${trace(inventory.response)}`, OPTIONS)
const inventoryItems = Array.isArray(inventory.data?.items) ? inventory.data.items : []
context.assert(MODULE, `${destinationType}实收形成服务端SKU库存账`, inventory.response,
success(inventory.response) && inventoryItems.length > 0
&& inventoryItems.every((item) => nonBlank(item?.skuCode) && Number(item?.availablePackages) > 0),
`skuCodes=${[...new Set(inventoryItems.map((item) => item?.skuCode).filter(nonBlank))].join('|') || 'missing'}, availablePackages=${inventoryItems.reduce((total, item) => total + Number(item?.availablePackages || 0), 0)}; ${trace(inventory.response)}`, OPTIONS)
const occupied = [...inventoryItems].sort((left, right) =>
Number(right?.availablePackages || 0) - Number(left?.availablePackages || 0))[0]
const actualPalletCode = occupied?.palletCode || pallet.palletCode
const actualPalletId = id(occupied?.palletId || pallet.palletId)
const actualLocationCode = occupied?.locationCode || pallet.locationCode
const actualLocationId = id(occupied?.locationId || pallet.locationId)
const location = await pda(context, token, 'POST', '完成入库后扫码查询库位',
'/api/pda/v1/last-leg/locations/resolve', { code: pallet.locationCode })
'/api/pda/v1/last-leg/locations/resolve', { code: actualLocationCode })
context.assert(MODULE, '库位查询返回当前仓真实占用汇总', location.response,
success(location.response) && id(location.data?.locationId) === pallet.locationId
&& location.data?.locationCode === pallet.locationCode
success(location.response) && id(location.data?.locationId) === actualLocationId
&& location.data?.locationCode === actualLocationCode
&& Number(location.data?.occupiedPackages) > 0 && Number(location.data?.inventoryPositionCount) > 0,
`locationId=${id(location.data?.locationId) || 'missing'}; ${trace(location.response)}`, OPTIONS)
const resolvedPallet = await pda(context, token, 'POST', '完成入库后扫码查询托盘内容',
'/api/pda/v1/last-leg/pallets/resolve', { code: pallet.palletCode })
'/api/pda/v1/last-leg/pallets/resolve', { code: actualPalletCode })
context.assert(MODULE, '托盘查询返回当前位置与库存明细', resolvedPallet.response,
success(resolvedPallet.response) && id(resolvedPallet.data?.palletId) === pallet.palletId
&& id(resolvedPallet.data?.locationId) === pallet.locationId
success(resolvedPallet.response) && id(resolvedPallet.data?.palletId) === actualPalletId
&& id(resolvedPallet.data?.locationId) === actualLocationId
&& Array.isArray(resolvedPallet.data?.inventory) && resolvedPallet.data.inventory.length > 0
&& Number(resolvedPallet.data?.occupiedPackages) > 0,
`palletId=${id(resolvedPallet.data?.palletId) || 'missing'}; ${trace(resolvedPallet.response)}`, OPTIONS)
@@ -242,7 +253,8 @@ async function verifyWarehouseQueries(context, token, inboundCode, pallet, desti
&& Number(history.data?.page) === 1 && Number(history.data?.size) === 50,
`records=${historyRows.length}; ${trace(history.response)}`, OPTIONS)
return success(inventory.response) && success(location.response) && success(resolvedPallet.response)
? { inboundCode, pallet: resolvedPallet.data, location: location.data, inventory: inventory.data?.items || [] }
? { inboundCode, pallet: resolvedPallet.data, location: location.data,
inventory: inventoryItems.filter((item) => id(item?.palletId) === actualPalletId) }
: null
}
@@ -465,12 +477,17 @@ export async function resolveCartonFixtures(context, token, inbounds, producerCa
continue
}
const resolved = response.body?.data
const matches = inbounds.filter((item) => item.inboundCode === resolved?.inboundCode)
const delimiter = cartonMark.indexOf('#')
const scannedInboundCode = delimiter > 0 ? cartonMark.slice(0, delimiter).trim() : ''
const resolvedInboundCodes = Array.isArray(resolved?.inboundCodes)
? resolved.inboundCodes.map(id) : [id(resolved?.inboundCode)].filter(nonBlank)
const matches = inbounds.filter((item) => item.inboundCode === scannedInboundCode)
const destinations = new Set(matches.map((item) => item?.destinationType).filter(nonBlank))
const unique = success(response) && nonBlank(resolved?.inboundCode) && !Array.isArray(resolved?.inboundCode)
&& matches.length === 1 && destinations.size === 1
&& ['FBA', 'OVERSEAS'].includes(resolved?.destinationType)
&& destinations.has(resolved?.destinationType)
&& resolvedInboundCodes.includes(scannedInboundCode)
&& (!strictDropship || (resolved?.cartonMark === cartonMark
&& resolved?.inboundCode === dropshipProducer.inboundCode && resolved?.destinationType === dropshipProducer.destinationType))
context.addStep(MODULE, `统一解析${resolved?.destinationType || 'UNKNOWN'}箱码${index + 1}并返回唯一入仓码`, response,
@@ -521,29 +538,33 @@ async function probeCartonResolveNegatives(context, token, current) {
}
}
async function receiveFba(context, token, inbound, pallet, cartonFixtures, index) {
const resolvedCartons = cartonFixtures.filter((item) => item.inbound.inboundCode === inbound.inboundCode
async function receiveFba(context, token, inbounds, pallet, cartonFixtures, index) {
const inboundCodes = new Set(inbounds.map((item) => item.inboundCode))
const resolvedCartons = cartonFixtures.filter((item) => inboundCodes.has(item.inbound.inboundCode)
&& item.resolved.destinationType === 'FBA')
if (!resolvedCartons.length) {
blockerGap(context, `FBA入仓码${inbound.inboundCode}箱码解析`, '没有冻结箱码以200/code 200唯一解析到当前FBA入仓码')
if (resolvedCartons.length !== inbounds.length) {
blockerGap(context, `FBA组${[...inboundCodes].join('|')}箱码解析`, '没有为FBA组内每个入仓码完成唯一解析')
return false
}
const packageCount = Number(inbound.pendingPackages || 0)
const packageCount = inbounds.reduce((total, inbound) => total + Number(inbound.totalPackages || 0), 0)
if (packageCount <= 0) return true
const body = { inboundCode: inbound.inboundCode, palletCode: pallet.palletCode,
const body = { inboundCode: inbounds[0].inboundCode, palletCode: pallet.palletCode,
packageCount, idempotencyKey: key(context, `fba-bulk-${index}`) }
const first = await pda(context, token, 'POST', `FBA入仓码${inbound.inboundCode}按箱数实收`,
const first = await pda(context, token, 'POST', `FBA组${[...inboundCodes].join('|')}按累计箱数实收`,
'/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body)
if (!success(first.response)) return false
const replay = await pda(context, token, 'POST', `FBA入仓码${inbound.inboundCode}同键同载荷重放`,
const replay = await pda(context, token, 'POST', `FBA组${[...inboundCodes].join('|')}同键同载荷重放`,
'/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body)
context.assert(MODULE, `FBA入仓码${inbound.inboundCode}幂等结果一致`, replay.response,
context.assert(MODULE, `FBA组${[...inboundCodes].join('|')}幂等结果一致`, replay.response,
success(replay.response) && JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS)
await expected(context, token, 'POST', `FBA入仓码${inbound.inboundCode}同键异载荷冲突`,
await expected(context, token, 'POST', `FBA组${[...inboundCodes].join('|')}同键异载荷冲突`,
'/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', { ...body, packageCount: packageCount + 1 }, conflict)
await expected(context, token, 'POST', `FBA入仓码${inbound.inboundCode}超收被拒绝`,
const already = await pda(context, token, 'POST', `FBA组${[...inboundCodes].join('|')}新键重复累计为零增量`,
'/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk',
{ ...body, packageCount: Number(inbound.totalPackages) + 1, idempotencyKey: key(context, `fba-over-${index}`) }, conflict)
{ ...body, idempotencyKey: key(context, `fba-already-${index}`) })
context.assert(MODULE, `FBA组${[...inboundCodes].join('|')}重复累计不产生超收`, already.response,
success(already.response) && already.data?.result === 'ALREADY_RECEIVED'
&& Number(already.data?.acceptedPackages) === 0, trace(already.response), OPTIONS)
return true
}
@@ -604,9 +625,12 @@ async function receiveDropship(context, token, producer, inbounds) {
await expected(context, token, 'POST', `一件代发整托${index + 1}同键异载荷冲突`,
'/api/pda/v1/last-leg/receiving-containers/current/receipts/pallet',
{ ...body, sourcePalletNo: `${sourcePalletNo}-MUTATED` }, conflict)
await expected(context, token, 'POST', `一件代发来源托盘重复收货被拒绝${index + 1}`,
const duplicate = await pda(context, token, 'POST', `一件代发来源托盘新键重复收货${index + 1}`,
'/api/pda/v1/last-leg/receiving-containers/current/receipts/pallet',
{ ...body, idempotencyKey: key(context, `dropship-pallet-duplicate-${index}`) }, conflict)
{ ...body, idempotencyKey: key(context, `dropship-pallet-duplicate-${index}`) })
context.assert(MODULE, `一件代发来源托盘重复收货不重复入账${index + 1}`, duplicate.response,
success(duplicate.response) && duplicate.data?.result === 'ALREADY_RECEIVED'
&& Number(duplicate.data?.acceptedPackages) === 0, trace(duplicate.response), OPTIONS)
}
context.assert(MODULE, '一件代发整托实收箱数与待收箱数守恒', null,
accepted === expectedPending, `accepted=${accepted}, expected=${expectedPending}`, OPTIONS)
@@ -728,8 +752,15 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe
cartonMark: fbaCarton?.cartonMark,
palletCode: pallets.get(firstFba.inboundCode).palletCode, idempotencyKey: key(context, 'fba-carton-forbidden'),
}, (response) => a0409Message(response, /^FBA destination must use bulk count$/))
for (const [index, inbound] of fbaInbounds.entries()) {
await receiveFba(context, token, inbound, pallets.get(inbound.inboundCode), cartonFixtures, index)
const groups = new Map()
for (const inbound of fbaInbounds) {
const fixture = cartonFixtures.find((item) => item.inbound.inboundCode === inbound.inboundCode)
const groupKey = fixture?.resolved?.groupKey || `INBOUND:${inbound.inboundCode}`
if (!groups.has(groupKey)) groups.set(groupKey, [])
groups.get(groupKey).push(inbound)
}
for (const [index, group] of [...groups.values()].entries()) {
await receiveFba(context, token, group, pallets.get(group[0].inboundCode), cartonFixtures, index)
}
} else if (!strictDropship && preferredProducer?.destinationType !== 'OVERSEAS') {
blockerGap(context, 'FBA按箱数链', '当前FBA柜没有带真实托盘的 FBA 入仓码')
@@ -785,24 +816,36 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe
if (context.createdResources.phase4OverseasPda.finalStatus === 'COMPLETED') {
const physicalPallets = [...new Map([...pallets.values()]
.map((item) => [id(item.palletId), item])).values()]
context.createdResources.phase4OverseasPda.completedContainerFacts.push({
containerNo,
containerBookingId: id(preferredProducer?.containerBookingId),
expectedPalletCost: physicalPallets.reduce((sum, item) => sum + Number(item.unitPrice || 0), 0),
palletIds: physicalPallets.map((item) => item.palletId),
})
const generatedContainerPallets = current.data.inbounds.flatMap((item) =>
context.createdResources.phase4OverseasPda.generatedPalletsByInbound[item.inboundCode] || [])
const completedFixtures = []
const order = await captureCompletedOrder(context, containerNo, preferredProducer?.tenantId)
for (const inbound of current.data.inbounds.filter((item) => pallets.has(item.inboundCode))) {
const warehouseFacts = await verifyWarehouseQueries(context, token, inbound.inboundCode,
pallets.get(inbound.inboundCode), inbound.destinationType)
if (warehouseFacts && context.context?.phase4OverseasPdaOperations) {
context.context.phase4OverseasPdaOperations.completedFixtures.push({
const fixture = {
containerNo, order, destinationType: inbound.destinationType,
fbaWarehouseCode: inbound.destinationType === 'FBA' ? inbound.fbaWarehouseCode : null,
auxiliaryPallets: generatedContainerPallets.filter((candidate) =>
id(candidate?.palletId) !== id(warehouseFacts.pallet?.palletId)),
...warehouseFacts,
})
}
context.context.phase4OverseasPdaOperations.completedFixtures.push(fixture)
completedFixtures.push(fixture)
}
}
const receivedPallets = [...new Map(completedFixtures.map((fixture) => {
const palletId = id(fixture.pallet?.palletId)
const source = physicalPallets.find((candidate) => id(candidate?.palletId) === palletId)
return [palletId, { palletId, unitPrice: Number(source?.unitPrice || 0) }]
}).filter(([palletId]) => longId(palletId))).values()]
context.createdResources.phase4OverseasPda.completedContainerFacts.push({
containerNo,
containerBookingId: id(preferredProducer?.containerBookingId),
expectedPalletCost: receivedPallets.reduce((sum, item) => sum + item.unitPrice, 0),
palletIds: receivedPallets.map((item) => item.palletId),
})
}
}
@@ -869,6 +912,12 @@ export async function run(context) {
'Dropship/FBA与OVERSEAS实际处理柜尚未全部达到PICKUP_READY;跨区消费已独立验证,不回退处理历史柜')
return
}
if (producers.length && context.options?.modules?.includes('phase5-terminal-drayage')
&& !(await prepareTerminalDrayage(context, producers[0]))) {
blockerGap(context, '目的港码头拖车提柜前置',
'本轮首个PICKUP_READY货柜未能在卸柜前完成选车队、预约和实际提柜')
return
}
for (const [index, producer] of (producers.length ? producers : [null]).entries()) {
context.pdaContainerSuffix = producer?.containerNo || `CONTAINER-${index + 1}`
context.createdResources.phase4OverseasPda.finalStatus = null
@@ -878,6 +927,10 @@ export async function run(context) {
context.createdResources.phase4OverseasPda.completedContainers.push(producer?.containerNo || null)
}
}
const refreshedMerchantToken = await loginMerchantForNegativeProbe(context)
if (nonBlank(refreshedMerchantToken)) {
context.context.phase4OverseasPdaOperations.merchantToken = refreshedMerchantToken
}
handoffReady = context.context.phase4OverseasPdaOperations.completedFixtures.length > 0
} finally {
delete context.pdaContainerSuffix
@@ -0,0 +1,48 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
const moduleUrl = new URL('./phase4-overseas-return-receipt.mjs', import.meta.url)
const acceptanceUrl = new URL('../admin-test-env-acceptance.mjs', import.meta.url)
test('full acceptance registers return receipt as a configured overseas module with an isolated merchant fixture', async () => {
const source = await readFile(acceptanceUrl, 'utf8')
assert.match(source, /CONFIGURED_MODULES[^\n]+phase4-overseas-return-receipt/)
assert.match(source, /\['phase4-overseas-return-receipt', \['merchant-qualification-fixture'\]\]/)
assert.match(source, /requiresMarkerOwnedMerchantFixture[\s\S]+phase4-overseas-return-receipt/)
})
test('return receipt acceptance covers both lookup paths, partial/final receipt and normal empty results', async () => {
const source = await readFile(moduleUrl, 'utf8')
assert.match(source, /PDA_RECEIPTS = '\/api\/pda\/v1\/last-leg\/return-receipts'/)
assert.match(source, /\$\{PDA_RECEIPTS\}\/start/)
assert.match(source, /\$\{PDA_RECEIPTS\}\/sku-candidates/)
assert.match(source, /PDA首次分批签收2件/)
assert.match(source, /PDA按SKU完成剩余3件签收/)
assert.match(source, /不存在SKU不是业务错误/)
assert.match(source, /已完成退货单不再进入待签收流程/)
})
test('return receipt acceptance proves idempotency, over-receipt isolation and exact inventory', async () => {
const source = await readFile(moduleUrl, 'utf8')
assert.match(source, /预报幂等重放复用同一退货单/)
assert.match(source, /签收重放不重复创建签收单、箱或计费批次/)
assert.match(source, /超收被拒绝且不得生成费用批次/)
assert.match(source, /订单5件核平且恰有两个唯一仓储计费批次/)
assert.match(source, /两个实收箱逐一引用对应计费批次且数量守恒/)
})
test('return receipt acceptance includes fee-batch evidence and return packing lifecycle', async () => {
const source = await readFile(moduleUrl, 'utf8')
assert.match(source, /billingExpectation: 'One PENDING_PRICING storage billing batch per successful non-replayed receipt'/)
assert.match(source, /receiptBatchNos/)
assert.match(source, /sourceBatchNo/)
assert.match(source, /PDA查询完成退货单的可合箱库存/)
assert.match(source, /PDA将同客户退货库存合为新箱/)
assert.match(source, /合箱重放复用同一新箱且不重复扣减/)
})
test('HTTP acceptance uses injected clients and never bypasses APIs with database access', async () => {
const source = await readFile(moduleUrl, 'utf8')
assert.doesNotMatch(source, /process\.env|https?:\/\/|\bfetch\s*\(|\bmysql\b|\bSELECT\s|\bINSERT\s|\bUPDATE\s/i)
})
@@ -0,0 +1,310 @@
import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overseas-pda.mjs'
const MODULE = 'phase4-overseas-return-receipt'
const SCENARIO = 'phase4-overseas-return-receipt-and-billing'
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
const RETURN_ORDERS = '/api/merchant/v1/last-leg/return-orders'
const PDA_RECEIPTS = '/api/pda/v1/last-leg/return-receipts'
const RETURN_INVENTORY = '/api/merchant/v1/last-leg/return-inventory'
function id(value) { return value == null ? '' : String(value) }
function nonBlank(value) { return typeof value === 'string' && value.trim() !== '' }
function code(response) { return String(response?.body?.code ?? '') }
function success(response) { return response?.http === 200 && code(response) === '200' }
function conflict(response) {
return (response?.http === 409 || (response?.http === 200 && ['409', 'A0409'].includes(code(response))))
&& nonBlank(response?.requestId) && nonBlank(response?.traceId)
}
function notFound(response) {
return (response?.http === 404 || (response?.http === 200 && ['404', 'A0404'].includes(code(response))))
&& nonBlank(response?.requestId) && nonBlank(response?.traceId)
}
function records(value) { return Array.isArray(value?.records) ? value.records : Array.isArray(value) ? value : [] }
function trace(response) {
return `HTTP=${response?.http ?? 'missing'}, code=${code(response) || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`
}
function query(path, values) {
const params = new URLSearchParams()
for (const [name, value] of Object.entries(values || {})) {
if (value !== undefined && value !== null && String(value) !== '') params.set(name, String(value))
}
return params.size ? `${path}?${params}` : path
}
function key(context, action) {
return `${context.marker}-RETURN-${action}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 255)
}
function markerSuffix(context) {
return String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-18).toUpperCase()
}
function add(context, name, response, passed, evidence = '') {
context.addStep(MODULE, name, response, passed, `${evidence}${evidence ? '; ' : ''}${trace(response)}`, OPTIONS)
}
async function overseas(context, token, method, name, path, body = undefined) {
const response = await context.requestOverseas(method, path, body, {}, token)
add(context, name, response, success(response))
return { response, data: success(response) ? response.body?.data : null }
}
async function resolveWarehouse(context) {
const existing = id(context.createdResources?.phase4OverseasPda?.overseasWarehouseId)
const response = await context.request('GET',
'/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE')
const rows = records(response?.body?.data)
const fixedIds = new Set(['193000000000200003', '193000000000200004'])
const warehouse = rows.find((item) => id(item?.id) === existing)
|| rows.find((item) => fixedIds.has(id(item?.id)))
|| rows[0]
add(context, '读取可执行退货签收的海外仓', response,
success(response) && nonBlank(id(warehouse?.id)), `warehouseId=${id(warehouse?.id) || 'missing'}`)
return success(response) && warehouse ? { id: id(warehouse.id), name: warehouse.warehouseName || warehouse.name } : null
}
async function loginMerchant(context) {
const fixture = context.context?.merchantQualificationFixture
if (fixture?.marker !== context.marker || !fixture?.username || !fixture?.password) {
add(context, '取得本轮客户账号', null, false, `marker=${fixture?.marker || 'missing'}`)
return null
}
const response = await context.request('POST', '/api/auth/login', {
loginId: fixture.username, password: fixture.password,
}, '')
const token = success(response) ? response.body?.data?.overseasAccessToken : null
add(context, '客户登录并取得海外访问令牌', response, nonBlank(token), `tokenPresent=${nonBlank(token)}`)
return nonBlank(token) ? token : null
}
function itemMap(items) {
return new Map((Array.isArray(items) ? items : []).map((item) => [item.skuCode, item]))
}
export async function run(context) {
context.createdResources.phase4OverseasReturnReceipt = {
marker: context.marker, returnNo: null, receiptNos: [], receiptBatchNos: [], cartonMarks: [],
billingExpectation: 'One PENDING_PRICING storage billing batch per successful non-replayed receipt',
}
const merchantToken = await loginMerchant(context)
const warehouse = await resolveWarehouse(context)
const access = await resolvePickerAccess(context)
if (!merchantToken || !warehouse || !access) return
const picker = await createPicker(context, 91, warehouse.id, access)
if (!picker?.token) {
add(context, '创建退货签收PDA操作员', null, false, '未取得绑定仓PDA令牌')
if (picker?.membershipId) await disablePicker(context, picker, 91)
return
}
const suffix = markerSuffix(context)
const skuA = `RET-A-${suffix}`.slice(0, 128)
const skuB = `RET-B-${suffix}`.slice(0, 128)
const createBody = {
overseasWarehouseId: warehouse.id,
warehouseContactName: '全量验收海外仓', warehouseContactPhone: '0000000000',
customerReferenceNo: `REF-${suffix}`, needRelabel: false, remark: context.marker,
idempotencyKey: key(context, 'CREATE'),
items: [
{ skuCode: skuA, productNameZh: '退货验收商品A', expectedQuantity: 3 },
{ skuCode: skuB, productNameZh: '退货验收商品B', expectedQuantity: 2 },
],
}
try {
const created = await overseas(context, merchantToken, 'POST', '客户创建双SKU退货预报', RETURN_ORDERS, createBody)
const returnNo = id(created.data?.order?.returnNo)
context.assert(MODULE, '退货预报初始数量和状态正确', created.response,
nonBlank(returnNo) && created.data?.order?.status === 'PENDING_RECEIPT'
&& Number(created.data?.order?.expectedQuantity) === 5
&& Number(created.data?.order?.receivedQuantity) === 0
&& itemMap(created.data?.items).size === 2,
`returnNo=${returnNo || 'missing'}`, OPTIONS)
if (!returnNo) return
context.createdResources.phase4OverseasReturnReceipt.returnNo = returnNo
const createReplay = await overseas(context, merchantToken, 'POST', '退货预报同键同载荷重放', RETURN_ORDERS, createBody)
context.assert(MODULE, '预报幂等重放复用同一退货单', createReplay.response,
id(createReplay.data?.order?.returnNo) === returnNo, trace(createReplay.response), OPTIONS)
const createConflict = await context.requestOverseas('POST', RETURN_ORDERS,
{ ...createBody, remark: `${context.marker}-CHANGED` }, {}, merchantToken)
add(context, '退货预报同键异载荷返回冲突', createConflict, conflict(createConflict))
const emptySku = await overseas(context, picker.token, 'POST', 'PDA查询不存在SKU返回正常空候选',
`${PDA_RECEIPTS}/sku-candidates`, { skuCode: `MISSING-${suffix}` })
context.assert(MODULE, '不存在SKU不是业务错误', emptySku.response,
success(emptySku.response) && Array.isArray(emptySku.data?.candidates) && emptySku.data.candidates.length === 0,
trace(emptySku.response), OPTIONS)
const started = await overseas(context, picker.token, 'POST', 'PDA按退货单号开始签收',
`${PDA_RECEIPTS}/start`, { returnNo })
context.assert(MODULE, '首次开始返回全部未收SKU', started.response,
started.data?.status === 'PENDING_RECEIPT' && itemMap(started.data?.expectedItems).size === 2,
trace(started.response), OPTIONS)
const skuCandidates = await overseas(context, picker.token, 'POST', 'PDA按SKU查询当前仓候选退货单',
`${PDA_RECEIPTS}/sku-candidates`, { skuCode: skuB })
context.assert(MODULE, 'SKU候选包含本轮退货单及准确数量', skuCandidates.response,
skuCandidates.data?.candidates?.some?.((candidate) => candidate.returnNo === returnNo
&& Number(candidate.skuExpectedQuantity) === 2 && Number(candidate.skuReceivedQuantity) === 0),
trace(skuCandidates.response), OPTIONS)
const cartonA = `RC-${suffix}-A`.slice(0, 128)
const firstBody = {
returnNo, trackingNo: `TRACK-${suffix}`, startedBy: 'RETURN_NO', cartonMark: cartonA,
items: [{ skuCode: skuA, receivedQuantity: 2 }], idempotencyKey: key(context, 'RECEIPT-1'),
}
const first = await overseas(context, picker.token, 'POST', 'PDA首次分批签收2件', `${PDA_RECEIPTS}/confirm`, firstBody)
context.assert(MODULE, '首次签收形成唯一箱和仓储计费批次', first.response,
first.data?.orderStatus === 'PARTIALLY_RECEIVED' && first.data?.fullyMatched === false
&& Number(first.data?.receivedQuantity) === 2 && first.data?.cartonMark === cartonA
&& nonBlank(first.data?.receiptNo) && nonBlank(first.data?.receiptBatchNo),
`receiptNo=${id(first.data?.receiptNo) || 'missing'}, billingBatch=${id(first.data?.receiptBatchNo) || 'missing'}`, OPTIONS)
const firstReceiptNo = id(first.data?.receiptNo)
const firstBatchNo = id(first.data?.receiptBatchNo)
const firstReplay = await overseas(context, picker.token, 'POST', '首次签收同键同载荷重放', `${PDA_RECEIPTS}/confirm`, firstBody)
context.assert(MODULE, '签收重放不重复创建签收单、箱或计费批次', firstReplay.response,
id(firstReplay.data?.receiptNo) === firstReceiptNo
&& id(firstReplay.data?.receiptBatchNo) === firstBatchNo
&& id(firstReplay.data?.cartonMark) === cartonA,
trace(firstReplay.response), OPTIONS)
const firstConflict = await context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`, {
...firstBody, trackingNo: `TRACK-${suffix}-CHANGED`,
}, {}, picker.token)
add(context, '签收同键异载荷返回冲突', firstConflict, conflict(firstConflict))
const overReceipt = await context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`, {
returnNo, startedBy: 'SKU', items: [{ skuCode: skuA, receivedQuantity: 2 }],
idempotencyKey: key(context, 'OVER-RECEIPT'),
}, {}, picker.token)
add(context, '超收被拒绝且不得生成费用批次', overReceipt, conflict(overReceipt))
const afterPartial = await overseas(context, merchantToken, 'GET', '客户回查部分签收状态',
`${RETURN_ORDERS}/${encodeURIComponent(returnNo)}`)
context.assert(MODULE, '超收失败后订单、签收和费用批次数量均未变化', afterPartial.response,
afterPartial.data?.order?.status === 'PARTIALLY_RECEIVED'
&& Number(afterPartial.data?.order?.receivedQuantity) === 2
&& afterPartial.data?.receipts?.length === 1
&& id(afterPartial.data.receipts[0]?.receiptBatchNo) === firstBatchNo,
`receipts=${afterPartial.data?.receipts?.length ?? 'missing'}`, OPTIONS)
const secondBody = {
returnNo, startedBy: 'SKU',
items: [{ skuCode: skuA, receivedQuantity: 1 }, { skuCode: skuB, receivedQuantity: 2 }],
idempotencyKey: key(context, 'RECEIPT-2'),
}
const second = await overseas(context, picker.token, 'POST', 'PDA按SKU完成剩余3件签收', `${PDA_RECEIPTS}/confirm`, secondBody)
context.assert(MODULE, '最终签收自动核平并生成第二个费用批次', second.response,
second.data?.orderStatus === 'COMPLETED' && second.data?.fullyMatched === true
&& Number(second.data?.receivedQuantity) === 5 && nonBlank(second.data?.cartonMark)
&& nonBlank(second.data?.receiptNo) && nonBlank(second.data?.receiptBatchNo)
&& id(second.data?.receiptBatchNo) !== firstBatchNo,
`billingBatch=${id(second.data?.receiptBatchNo) || 'missing'}`, OPTIONS)
const secondReceiptNo = id(second.data?.receiptNo)
const secondBatchNo = id(second.data?.receiptBatchNo)
const cartonB = id(second.data?.cartonMark)
context.createdResources.phase4OverseasReturnReceipt.receiptNos.push(firstReceiptNo, secondReceiptNo)
context.createdResources.phase4OverseasReturnReceipt.receiptBatchNos.push(firstBatchNo, secondBatchNo)
context.createdResources.phase4OverseasReturnReceipt.cartonMarks.push(cartonA, cartonB)
const completedStart = await context.requestOverseas('POST', `${PDA_RECEIPTS}/start`, { returnNo }, {}, picker.token)
add(context, '已完成退货单不再进入待签收流程', completedStart, notFound(completedStart))
const completedCandidates = await overseas(context, picker.token, 'POST', '完成后SKU候选自动清空',
`${PDA_RECEIPTS}/sku-candidates`, { skuCode: skuB })
context.assert(MODULE, '完成单不再出现在SKU候选', completedCandidates.response,
!completedCandidates.data?.candidates?.some?.((candidate) => candidate.returnNo === returnNo),
trace(completedCandidates.response), OPTIONS)
const detail = await overseas(context, merchantToken, 'GET', '客户回查完成退货单与两次签收',
`${RETURN_ORDERS}/${encodeURIComponent(returnNo)}`)
const detailItems = itemMap(detail.data?.items)
const detailReceipts = Array.isArray(detail.data?.receipts) ? detail.data.receipts : []
const detailBatches = new Set(detailReceipts.map((receipt) => id(receipt.receiptBatchNo)))
context.assert(MODULE, '订单5件核平且恰有两个唯一仓储计费批次', detail.response,
detail.data?.order?.status === 'COMPLETED' && Number(detail.data?.order?.receivedQuantity) === 5
&& Number(detailItems.get(skuA)?.receivedQuantity) === 3
&& Number(detailItems.get(skuB)?.receivedQuantity) === 2
&& detailReceipts.length === 2 && detailBatches.size === 2
&& detailBatches.has(firstBatchNo) && detailBatches.has(secondBatchNo),
`receipts=${detailReceipts.length}, billingBatches=${detailBatches.size}`, OPTIONS)
const merchantInventory = await context.requestOverseas('GET',
query(RETURN_INVENTORY, { page: 1, size: 20, overseasWarehouseId: warehouse.id, returnNo }),
undefined, {}, merchantToken)
add(context, '客户权限可读取自身退货库存', merchantInventory, success(merchantInventory))
const customerId = id(context.context?.merchantQualificationFixture?.tenantId)
const inventory = await overseas(context, context.overseasToken, 'GET', '平台按客户和退货单回查全部退货箱库存',
query('/api/platform/v1/last-leg/return-inventory/cartons', {
page: 1, size: 20, customerId, overseasWarehouseId: warehouse.id, returnNo,
}))
const inventoryRows = records(inventory.data)
const sourceBatches = new Set(inventoryRows.map((row) => id(row.sourceBatchNo)))
context.assert(MODULE, '两个实收箱逐一引用对应计费批次且数量守恒', inventory.response,
inventoryRows.length === 2
&& inventoryRows.reduce((sum, row) => sum + Number(row.totalQuantity || 0), 0) === 5
&& sourceBatches.size === 2 && sourceBatches.has(firstBatchNo) && sourceBatches.has(secondBatchNo),
`cartons=${inventoryRows.length}, quantity=${inventoryRows.reduce((sum, row) => sum + Number(row.totalQuantity || 0), 0)}`, OPTIONS)
const summary = await overseas(context, context.overseasToken, 'GET', '平台回查退货SKU汇总库存',
query('/api/platform/v1/last-leg/return-inventory', {
page: 1, size: 20, customerId, overseasWarehouseId: warehouse.id,
}))
const summaries = new Map(records(summary.data).filter((row) => [skuA, skuB].includes(row.skuCode))
.map((row) => [row.skuCode, row]))
context.assert(MODULE, '退货SKU汇总数量与两个实收箱一致', summary.response,
Number(summaries.get(skuA)?.totalQuantity) === 3
&& Number(summaries.get(skuB)?.totalQuantity) === 2,
`skuA=${summaries.get(skuA)?.totalQuantity ?? 'missing'}, skuB=${summaries.get(skuB)?.totalQuantity ?? 'missing'}`, OPTIONS)
for (const [index, expected] of [{ cartonMark: cartonA, batchNo: firstBatchNo, quantity: 2 },
{ cartonMark: cartonB, batchNo: secondBatchNo, quantity: 3 }].entries()) {
const carton = await overseas(context, context.overseasToken, 'GET', `平台回查第${index + 1}个退货箱明细`,
`/api/platform/v1/last-leg/return-inventory/cartons/${encodeURIComponent(expected.cartonMark)}`)
context.assert(MODULE, `第${index + 1}个退货箱来源费用批次和数量正确`, carton.response,
carton.data?.sourceBatchNo === expected.batchNo
&& Number(carton.data?.totalQuantity) === expected.quantity
&& Array.isArray(carton.data?.items) && carton.data.items.length > 0,
`batch=${carton.data?.sourceBatchNo || 'missing'}, quantity=${carton.data?.totalQuantity ?? 'missing'}`, OPTIONS)
const label = await overseas(context, context.overseasToken, 'GET', `平台生成第${index + 1}个退货箱标签`,
`/api/platform/v1/last-leg/return-inventory/cartons/${encodeURIComponent(expected.cartonMark)}/label`)
context.assert(MODULE, `第${index + 1}个箱标签保留来源批次`, label.response,
label.data?.cartonMark === expected.cartonMark && label.data?.sourceBatchNo === expected.batchNo
&& Number(label.data?.totalQuantity) === expected.quantity && nonBlank(label.data?.printableHtml),
trace(label.response), OPTIONS)
}
const packing = await overseas(context, picker.token, 'GET', 'PDA查询完成退货单的可合箱库存',
query('/api/pda/v1/last-leg/return-inventory/packing/orders', { returnNo }))
const packingSkus = new Map((packing.data?.skus || []).map((row) => [row.skuCode, row]))
context.assert(MODULE, '退货合箱候选只含本轮完成单且库存准确', packing.response,
packing.data?.orders?.some?.((order) => order.returnNo === returnNo && order.status === 'COMPLETED')
&& Number(packingSkus.get(skuA)?.availableQuantity) === 3
&& Number(packingSkus.get(skuB)?.availableQuantity) === 2,
trace(packing.response), OPTIONS)
const mergeBody = {
returnNos: [returnNo], items: [{ skuCode: skuA, quantity: 1 }, { skuCode: skuB, quantity: 1 }],
length: 30, width: 20, height: 10, weight: 2, idempotencyKey: key(context, 'MERGE'),
}
const merged = await overseas(context, picker.token, 'POST', 'PDA将同客户退货库存合为新箱',
'/api/pda/v1/last-leg/return-inventory/cartons/merge', mergeBody)
if (success(merged.response)) {
context.assert(MODULE, '退货合箱生成含两SKU和箱规的可打印新箱', merged.response,
nonBlank(merged.data?.cartonMark) && Number(merged.data?.totalQuantity) === 2
&& merged.data?.label?.sourceBatchNo === 'MERGED'
&& Number(merged.data?.label?.items?.length) === 2
&& nonBlank(merged.data?.label?.printableHtml),
`cartonMark=${merged.data?.cartonMark || 'missing'}`, OPTIONS)
const mergeReplay = await overseas(context, picker.token, 'POST', '退货合箱同键同载荷重放',
'/api/pda/v1/last-leg/return-inventory/cartons/merge', mergeBody)
context.assert(MODULE, '合箱重放复用同一新箱且不重复扣减', mergeReplay.response,
success(mergeReplay.response) && nonBlank(merged.data?.cartonMark)
&& id(mergeReplay.data?.cartonMark) === id(merged.data?.cartonMark), trace(mergeReplay.response), OPTIONS)
const mergeConflict = await context.requestOverseas('POST',
'/api/pda/v1/last-leg/return-inventory/cartons/merge',
{ ...mergeBody, weight: 3 }, {}, picker.token)
add(context, '合箱同键异载荷返回冲突', mergeConflict, conflict(mergeConflict))
}
} finally {
await disablePicker(context, picker, 91)
}
}
@@ -18,6 +18,7 @@ test('采购单号按北京时间日期执行精确合同', () => {
test('钱包闭环覆盖充值、授信、采购冻结结算和换标结算释放', () => {
for (const token of [
'/accounts/${encodeURIComponent(customerId)}/credit-limit',
'creditExpiresOn',
'/accounts/${encodeURIComponent(customerId)}/adjustments',
"const CLIENT_FINANCE = '/api/client/finance/v1'",
"['VOUCHER', 'RECEIPT']",
@@ -28,6 +29,8 @@ test('钱包闭环覆盖充值、授信、采购冻结结算和换标结算释
'item.sourceNo === businessNo',
"'RELEASED'",
"terminalStatus === 'COMPLETED'",
"status: 'CUSTOMER_CONFIRM_PENDING'",
"status: 'READY_FOR_OUTBOUND'",
"'CANCELLED'",
'relabelPayload(context, warehouseId, source, suffix, 0.01)',
'/relabel-applications/inventory-candidates',
@@ -38,6 +41,7 @@ test('钱包闭环覆盖充值、授信、采购冻结结算和换标结算释
'destinationFbaWarehouseCode: source.destinationFbaWarehouseCode',
'cartonMark:',
'fbaCartonMark:',
"deliveryMethod: 'TRUCK'",
]) assert.ok(source.includes(token), token)
assert.match(source, /actualBalance[\s\S]*frozenBalance[\s\S]*availableBalance[\s\S]*creditLimit[\s\S]*creditUsed[\s\S]*availableCredit/)
assert.match(source, /voucherFileId: id\(files\.find\(\(item\) => item\.category === 'VOUCHER'\)\?\.fileId\)/)
@@ -67,8 +67,10 @@ async function prepareWallet(context, customerId) {
if (!before.account) return null
const creditLimit = 2.00
const creditExpiresOn = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)
const credit = await context.request('PUT', `${FINANCE}/accounts/${encodeURIComponent(customerId)}/credit-limit`,
{ creditLimit, remark: context.marker, expectedAccountVersion: Number(before.account.accountVersion) }, context.token,
{ creditLimit, creditExpiresOn, remark: context.marker,
expectedAccountVersion: Number(before.account.accountVersion) }, context.token,
{ 'Idempotency-Key': `${context.marker}-wallet-credit-limit`.slice(0, 128) })
assert(context, '授信额度可通过正式接口初始化', credit,
success(credit) && money(credit.body.data?.creditLimitAfter) === creditLimit,
@@ -175,6 +177,7 @@ function relabelPayload(context, warehouseId, source, suffix, chargeAmount) {
customerOrderNo: `${context.marker}-${suffix}`.slice(0, 128), operationWarehouseId: id(warehouseId),
labelDeadline: new Date(Date.now() + 86_400_000).toISOString().slice(0, 19),
contactName: '全量验收联系人', contactPhone: '+1-213-555-0199', chargeAmount,
deliveryMethod: 'TRUCK', printShippingLabel: false,
remark: context.marker, idempotencyKey: `${context.marker}-${suffix}`.slice(0, 255),
items: [{ sourceInventoryBalanceId: id(source.inventoryBalanceId),
sourceInventoryPositionId: id(source.position.positionId), sourceInboundOrderId: id(source.inboundOrderId),
@@ -223,6 +226,14 @@ async function relabelFlow(context, overseasToken, warehouseId, suffix, terminal
const processing = await context.requestOverseas('PUT', `${detailPath}/status`, { status: 'PROCESSING', remark: context.marker })
assert(context, '换标单冻结后才允许进入处理中', processing,
success(processing) && processing.body.data?.application?.status === 'PROCESSING')
const confirmPending = await context.requestOverseas('PUT', `${detailPath}/status`,
{ status: 'CUSTOMER_CONFIRM_PENDING', remark: context.marker })
assert(context, '换标处理完成后进入客户确认', confirmPending,
success(confirmPending) && confirmPending.body.data?.application?.status === 'CUSTOMER_CONFIRM_PENDING')
const readyForOutbound = await context.requestOverseas('PUT', `${detailPath}/status`,
{ status: 'READY_FOR_OUTBOUND', remark: context.marker })
assert(context, '换标客户确认后进入待出库', readyForOutbound,
success(readyForOutbound) && readyForOutbound.body.data?.application?.status === 'READY_FOR_OUTBOUND')
}
const terminal = await context.requestOverseas('PUT', `${detailPath}/status`, { status: terminalStatus, remark: context.marker })
assert(context, `${suffix}换标单进入${terminalStatus}`, terminal,
@@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
import { run } from './phase5-terminal-drayage.mjs'
import { prepareTerminalDrayage, run } from './phase5-terminal-drayage.mjs'
const source = await readFile(fileURLToPath(new URL('./phase5-terminal-drayage.mjs', import.meta.url)), 'utf8')
@@ -19,11 +19,12 @@ test('four real command payloads mutate contract fields and preserve remarks and
let conflicts = 0
let replays = 0
const response = (data, code = '200') => ({ http: 200, body: { code, data }, requestId: 'offline-request', traceId: 'offline-trace' })
const producer = { containerBookingId, containerNo, shippingReady: true,
carrierScheduleQueryId: '9223372036854775805', carrierLatestActualAt: '2026-09-09T01:00:00' }
const context = {
marker, token: 'offline-only',
createdResources: {
phase4InboundProducerFixtures: { marker, containers: [{ containerBookingId, containerNo,
shippingReady: true, carrierScheduleQueryId: '9223372036854775805', carrierLatestActualAt: '2026-09-09T01:00:00' }] },
phase4InboundProducerFixtures: { marker, containers: [producer] },
phase4OverseasPda: { marker, finalStatus: 'COMPLETED', completedContainerFacts: [{ containerBookingId, containerNo }] },
},
apiSuccess(_module, name, result) { assert.equal(result.body.code, '200', name); return result.body.data },
@@ -58,13 +59,15 @@ test('four real command payloads mutate contract fields and preserve remarks and
state = { ...state, version: state.version + 1 }
if (index === 0) Object.assign(state, { fleetSupplierId: body.fleetSupplierId, fleetSupplierName: body.fleetSupplierName, allowedActions: ['SCHEDULE_PICKUP'] })
if (index === 1) state.pickupAppointmentAt = body.pickupAppointmentAt
if (index === 2) Object.assign(state, { status: 'UNLOADED', actualPickupAt: body.actualAt, unloadingCompletedAt: '2026-09-09T04:00:00', allowedActions: ['RECORD_EMPTY_RETURN'] })
if (index === 2) Object.assign(state, { status: 'PICKED_UP', actualPickupAt: body.actualAt, allowedActions: [] })
if (index === 3) Object.assign(state, { status: 'EMPTY_RETURNED', actualEmptyReturnAt: body.actualAt, allowedActions: [], emptyReturnWarning: { status: 'RESOLVED' } })
history.push({ action: actions[index], fromVersion: index, toVersion: index + 1, reason: body[field] })
commands.set(key, { body: structuredClone(body), state: structuredClone(state) })
return response(state)
},
}
await prepareTerminalDrayage(context, producer)
state = { ...state, status: 'UNLOADED', unloadingCompletedAt: '2026-09-09T04:00:00', allowedActions: ['RECORD_EMPTY_RETURN'] }
await run(context)
assert.equal(commands.size, 4)
assert.equal(replays, 4)
@@ -73,7 +76,7 @@ test('four real command payloads mutate contract fields and preserve remarks and
assert.equal(context.createdResources.phase5TerminalDrayage.finalStatus, 'EMPTY_RETURNED')
})
test('terminal drayage runs after pickup-ready and overseas unloading but before final carrier validation', () => {
test('terminal drayage prepares pickup before unloading and finalizes return before carrier validation', () => {
const modules = normalizeModuleOrder(['phase4-carrier-nodes'])
const pickup = modules.indexOf('phase4-carrier-pickup-ready')
const pda = modules.indexOf('phase4-overseas-pda')
@@ -81,6 +84,8 @@ test('terminal drayage runs after pickup-ready and overseas unloading but before
const finalCarrier = modules.indexOf('phase4-carrier-nodes')
assert.ok(pickup >= 0 && pickup < pda)
assert.ok(pda < drayage && drayage < finalCarrier)
assert.match(source, /attempt <= 30/)
assert.match(source, /initialData\?\.status === 'UNLOADED'/)
})
test('module uses only public business endpoints and reuses public trucking master data', () => {
@@ -106,12 +111,10 @@ test('all four commands cover idempotent replay hash conflict and lifecycle sema
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, /prepared\?\.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, /producer\?\.shippingReady !== true/)
assert.match(source, /phase4CarrierPickupReady =/)
assert.match(source, /禁止借历史货柜/)
})
@@ -20,22 +20,15 @@ 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
function completedTarget(context) {
const prepared = context.createdResources?.phase5TerminalDrayage?.prepared
const pda = context.createdResources?.phase4OverseasPda
if (matrix?.marker !== context.marker || pda?.marker !== context.marker
if (prepared?.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
const completed = (pda.completedContainerFacts || []).find((item) =>
id(item?.containerBookingId) === id(prepared.containerBookingId)
&& id(item?.containerNo) === id(prepared.containerNo))
return completed ? { ...prepared, pda, completed } : null
}
async function command(context, target, action, path, body) {
const key = `${context.marker}-terminal-${path}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 128)
@@ -58,32 +51,37 @@ async function command(context, target, action, path, body) {
return first
}
export async function run(context) {
context.createdResources.phase5TerminalDrayage = {
export async function prepareTerminalDrayage(context, producer) {
const matrix = context.createdResources?.phase4InboundProducerFixtures
if (matrix?.marker !== context.marker || !producer || producer?.shippingReady !== true
|| !id(producer?.containerBookingId) || !id(producer?.containerNo)
|| !id(producer?.carrierScheduleQueryId) || !producer?.carrierLatestActualAt) {
blocker(context, '取得本轮PICKUP_READY货柜执行码头提柜前置',
`matrix=${matrix?.marker || 'missing'}, container=${id(producer?.containerNo) || 'missing'}; 禁止借历史货柜`)
return null
}
const resource = 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
}
context.createdResources.phase5TerminalDrayage = resource
const target = { marker: context.marker, producer, containerBookingId: id(producer.containerBookingId),
containerNo: id(producer.containerNo) }
const path = `${BASE}/${encodeURIComponent(target.containerBookingId)}`
const initial = await api(context, 'GET', '读取目的港码头拖车初始聚合', path)
context.assert(MODULE, '初始聚合绑定本轮货柜且允许选择车队', initial.response,
const initial = await api(context, 'GET', '卸柜前读取目的港码头拖车初始聚合', path)
context.assert(MODULE, 'PICKUP_READY货柜允许选择车队', initial.response,
id(initial.data?.containerBookingId) === target.containerBookingId
&& id(initial.data?.containerNo) === id(target.completed.containerNo)
&& id(initial.data?.containerNo) === target.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
if (!success(initial.response) || !initial.data?.allowedActions?.includes('SELECT_FLEET')) return null
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
return null
}
const selected = await command(context, target, '选择车队', 'fleet', {
expectedVersion: Number(initial.data.version), fleetSupplierId: id(fleet.id),
@@ -92,9 +90,9 @@ export async function run(context) {
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
if (!success(selected.response)) return null
const latestActual = Date.parse(`${target.producer.carrierLatestActualAt}Z`)
const latestActual = Date.parse(`${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,
@@ -103,22 +101,66 @@ export async function run(context) {
scheduled.data?.pickupAppointmentAt === appointmentAt
&& Number(scheduled.data?.version) === Number(selected.data.version) + 1,
trace(scheduled.response), OPTIONS)
if (!success(scheduled.response)) return
if (!success(scheduled.response)) return null
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, remark: 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
context.assert(MODULE, '实际提柜同步PICKED_UP并等待海外仓卸柜', picked.response,
picked.data?.status === 'PICKED_UP' && picked.data?.actualPickupAt === pickupAt,
trace(picked.response), OPTIONS)
if (!success(picked.response) || picked.data?.status !== 'PICKED_UP') return null
resource.prepared = { ...target, appointmentAt, pickupAt, pickedVersion: Number(picked.data.version) }
resource.containerBookingId = target.containerBookingId
resource.containerNo = target.containerNo
return resource.prepared
}
const unloadingAt = Date.parse(`${picked.data?.unloadingCompletedAt}Z`)
const returnBase = Math.max(Date.parse(`${pickupAt}Z`), Number.isNaN(unloadingAt) ? 0 : unloadingAt)
export async function run(context) {
const resource = 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',
}
context.createdResources.phase5TerminalDrayage = resource
const target = completedTarget(context)
if (!target) {
blocker(context, '取得本轮已提柜且已卸柜货柜',
`prepared=${resource.prepared?.marker || 'missing'}, pda=${context.createdResources?.phase4OverseasPda?.finalStatus || 'missing'}; 禁止借历史货柜`)
return
}
const path = `${BASE}/${encodeURIComponent(target.containerBookingId)}`
let initialResponse = null
let initialData = null
let pollRequests = 0
for (let attempt = 1; attempt <= 30; attempt += 1) {
pollRequests = attempt
initialResponse = await context.request('GET', path, undefined, context.token)
initialData = success(initialResponse) ? initialResponse.body?.data : null
if (initialData?.status === 'UNLOADED'
&& initialData?.allowedActions?.includes('RECORD_EMPTY_RETURN')) break
await new Promise((resolve) => setTimeout(resolve, 1000))
}
const initial = {
response: initialResponse,
data: context.apiSuccess(MODULE, '卸柜后等待跨区事件并读取目的港码头拖车聚合', initialResponse,
`pollRequests=${pollRequests}; ${trace(initialResponse)}`, OPTIONS),
}
context.assert(MODULE, '卸柜聚合保留提柜事实并开放还柜', initial.response,
id(initial.data?.containerBookingId) === target.containerBookingId
&& id(initial.data?.containerNo) === id(target.completed.containerNo)
&& Number(initial.data?.version) === Number(target.pickedVersion)
&& initial.data?.status === 'UNLOADED'
&& initial.data?.actualPickupAt === target.pickupAt
&& initial.data?.allowedActions?.includes('RECORD_EMPTY_RETURN'),
`pollRequests=${pollRequests}, status=${initial.data?.status || 'missing'}, actions=${initial.data?.allowedActions?.join(',') || 'missing'}; ${trace(initial.response)}`, OPTIONS)
if (!success(initial.response)) return
const unloadingAt = Date.parse(`${initial.data?.unloadingCompletedAt}Z`)
const returnBase = Math.max(Date.parse(`${target.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, remark: context.marker,
expectedVersion: Number(initial.data.version), actualAt: returnedAt, remark: context.marker,
})
context.assert(MODULE, '实际还柜同步EMPTY_RETURNED并关闭全部动作', returned.response,
returned.data?.status === 'EMPTY_RETURNED' && returned.data?.actualEmptyReturnAt === returnedAt
@@ -0,0 +1,14 @@
export function channelGroup(channelCode) {
const value = String(channelCode || '').trim().toUpperCase()
if (value.startsWith('EXX_') || ['EXXHK', 'EXXHWK', 'NYEXX'].includes(value)) return 'EXX'
if (value.startsWith('MATSON_CLX_') || ['ZBYHK', 'ZBYHWK', 'ZBYHP'].includes(value)) return 'MATSON_CLX'
if (value.startsWith('MATSON_MAX_') || ['BYHK', 'BYHWK', 'BYHP'].includes(value)) return 'MATSON_MAX'
if (value.startsWith('HEDE_') || ['HDHK', 'HDHWK', 'HDHP'].includes(value)) return 'HEDE'
if (value.startsWith('OA_LA_') || value === 'OA_PARCEL'
|| ['PCHK', 'PCHWK', 'PCKK', 'PCHP'].includes(value)) return 'OA_LA'
if (value.startsWith('OA_CHICAGO_') || value === 'CPHT') return 'OA_CHICAGO'
if (value.startsWith('OA_HOUSTON_') || value === 'HPHK') return 'OA_HOUSTON'
if (value.startsWith('OA_SAVANNAH_') || value === 'SPHK') return 'OA_SAVANNAH'
if (['OA_NEW_YORK_TRUCK', 'NPHK', 'NPHWK'].includes(value)) return 'OA_NEW_YORK_TRUCK'
throw new Error(`unsupported smart-loading channel group: ${channelCode}`)
}