Merge test into dev and resolve conflicts
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+4
@@ -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}`)
|
||||
}
|
||||
Reference in New Issue
Block a user