Merge branch 'test' into dev
This commit is contained in:
+14
-1
@@ -106,7 +106,7 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜
|
||||
| 验收目标 | 唯一入口 | 数据副作用 | 覆盖范围 |
|
||||
|---|---|---|---|
|
||||
| 平台后台全模块 HTTP 验收 | `scripts/admin-test-env-acceptance.mjs` | 会创建、更新或最终停用测试数据 | `--modules` 默认列表中的平台客户、仓储、订单、入库、分柜、装柜、船期、拖车、报关、海外排车/出库/派送/异常/回访、财务、通知、统计等模块 |
|
||||
| 客户工作台完整 HTTP 链路验收 | `scripts/client-test-env-acceptance.mjs` | 会创建测试企业、订单、入库、权限和站内消息数据 | 注册/审核、客户隔离、订单、入库计费、文件、财务、客户侧权限,以及我的消息未读/已读/全部已读链路 |
|
||||
| 客户工作台完整 HTTP 链路验收 | `scripts/client-test-env-acceptance.mjs` | 会创建测试企业、订单、权限和站内消息数据 | 注册/审核、企业与个人资料、客户隔离、订单、文件、客户侧权限、消息已读与账号安全;未完成注册的同凭据重放须复用账号/客户/成员身份,不同凭据重复用户名须冲突;测量/计费继续使用下列专项入口 |
|
||||
| Client 快速下单定向 HTTP 验收 | `scripts/client-quick-order-test-env-acceptance.mjs` | 使用 `MERCHANT_TEST_USERNAME/PASSWORD` 对应客户创建快速下单草稿 | 真实调用 `/api/merchant/v1/orders/first-leg/batch`,覆盖 FBA、多产品、海外仓、多个私人地址、地址归属失败、渠道失败、重复行、逐行隔离、失败重试、详情归属、幂等重放与冲突;只创建草稿,不提交、不审核 |
|
||||
| 固定客户批量创建待审核订单 | `scripts/create-pending-review-orders.mjs` | 只使用 `MERCHANT_TEST_USERNAME/PASSWORD` 配置的客户账号创建指定数量订单 | 使用 `ADMIN_TEST_GATEWAY`,选择真实可计价仓库/渠道,创建并提交订单,停在待审核,不创建客户、不审核订单 |
|
||||
| Client 入库测量与计费专项 HTTP 验收 | `scripts/client-phase2-test-env-acceptance.mjs` | 会写入测试环境业务数据 | 入库测量与计费客户入库、测量、费用和权限专项场景;不是替代 Client 全量入口 |
|
||||
@@ -150,6 +150,14 @@ node scripts/create-pending-review-orders.mjs
|
||||
|
||||
## 本地质量检查(非全量验收)
|
||||
|
||||
合并后海外新增能力的覆盖清单、fixture 依赖和未实现/未覆盖项见
|
||||
`admin-test-env-modules/README.md` 的“合并后的海外增量覆盖”。
|
||||
转运审核、草稿柜及托盘移除、BOL 与后台转派送复用默认注册的
|
||||
`phase4-overseas-client-services`、`phase4-overseas-dispatch`、`phase5-overseas-fulfillment`,
|
||||
不是独立造数脚本;新增离线检查为 `admin-test-env-modules/merged-overseas-coverage-contract.test.mjs`。
|
||||
这些模块真实运行会创建和推进本轮测试业务单据,只有取得对应环境授权后才执行;
|
||||
空驳回原因当前后端未拒绝,必须保留失败,不以离线合同通过声称全量完成。
|
||||
|
||||
Gateway 路由防漏:`node --test scripts/gateway-route-contract.test.mjs`。只读 Maven 模块的 `src/main/java` Controller 与国内/海外源码路由,自动组合类/方法路径并校验目标服务、路由优先顺序和内部路径隔离;不需要凭据、不写数据或报告,失败返回非零,修复后原命令重跑。包含删除船节点路由、错误目标、内部暴露的反向用例。支持当前字面量/类内字符串常量、短格式 Path 配置;复杂映射需同步扩展解析器,不得忽略失败。它不验证 HTTP 方法、权限、外部 Nacos 实际值或运行时可达性。
|
||||
|
||||
`backend-full-regression.ps1` 在 Maven 前执行该合同;根 `Jenkinsfile` 在服务打包调度前独立执行,不受 `SKIP_TESTS` 跳过 Maven 的参数影响。CI 的 `java-builder` 必须提供支持 `node --test` 的 Node.js(建议 22);缺失时门禁失败,不自动安装或静默跳过。直接单服务 Job、手工 Git 提交不会自动触发此脚本,仍须遵守提交前检查规则。
|
||||
@@ -262,3 +270,8 @@ Dry-run 只校验两个统一 SQL 的入口标记,打印当前 SHA-256、行
|
||||
- 其他非零值:原样透传 Maven 失败退出码。
|
||||
|
||||
脚本不设置 `MAVEN_OPTS`、数据库 URL 或测试账号;环境参数仅为可选的 `$MavenCommand`(默认 `mvn`),用于指定本机 Maven 可执行文件。
|
||||
|
||||
## 海外换标定向验收补充
|
||||
|
||||
- `scripts/overseas-relabel-real-acceptance.mjs`:定向真实 HTTP 换标作业,会创建换标、库存及排车记录;从显式环境文件读取平台/PDA账号、网关及客户参数,输出脚本配置的报告,不自动清理。失败保留证据与已创建资源,重跑前核对报告,必须另获指定环境授权。
|
||||
- `scripts/overseas-relabel-fulfillment-acceptance.mjs`:读取 `OVERSEAS_ACCEPTANCE_SOURCE` 或指定排车/换标编号,执行 PDA 拣货、出库、派送后续真实 HTTP,会推进业务状态;凭据来自显式环境文件,报告默认 `reports/overseas-relabel-fulfillment-acceptance.json`(`OVERSEAS_FULFILLMENT_OUTPUT` 可覆盖)。失败保留资源,重跑前核对业务状态,不自动清理。两个定向脚本不替代 Admin 全量入口,本次合并仅做离线合同。
|
||||
|
||||
@@ -37,6 +37,13 @@ export async function run(context) {
|
||||
|
||||
## 可用上下文
|
||||
|
||||
- 一件代发任务 `Task.pallets` 只列实际参与绑箱的托盘;未使用的预生成托盘通过 `/api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets?accountId=...` 读取,客户范围取本轮任务。脚本同时核对 `generatedPalletCount/palletSetupReady`,不因空的任务托盘列表新增资源。
|
||||
- `phase3-container-documents` 从本轮货柜详情读取渠道:海铁验证 DO 上传被明确拒绝且聚合中没有 DO,其他渠道继续验证 DO 上传、重放及零漂移;缺渠道不猜测适用性。
|
||||
- `phase4-overseas-pda-operations` 用三个独立逐箱任务分别验证正常换标、跳过和异常,异常不得重复使用前两箱。前一步失败时保留失败并停止该链,不继续使用空响应版本。
|
||||
- 海外 PDA 按真实柜号去重执行签收,缺少一件代发生产者不得用 FBA 生产者替代;原生产矩阵失败仍保留。P4 解绑样本保留为 `UNBOUND`,P5 只从本轮已完成同柜、同目的仓的入仓码中选择三箱实物托盘库存,排除 `UNBOUND` 和重复仓位,不借用历史订单。
|
||||
- 排车详情按 `DispatchResponse.Item` 核对托盘与箱数,不读取 DTO 未提供的箱唛字段;`phase5-overseas-fulfillment` 将同一换标排车转为待出库单,通过 `OutboundDetail.pallets.cartonMarks` 验证全部新箱唛、排除原箱唛和重复箱。该测试生成的待出库单及派送单保留为本轮证据,不推进人工退货样本。
|
||||
- 出库前同操作员、同草稿允许重复预排并更新原排车;脚本核对排车身份与业务数量不漂移,不要求重新生成的明细行 ID 不变。其他草稿抢锁仍必须冲突,生成出库单后仍验证托盘编辑被拒绝。
|
||||
|
||||
货柜查询使用 `/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` 前置在测量草稿录入预计托数;审核通过时系统一次性预生成全部 DP 托盘码,允许空托先打印。PDA 既支持先清点箱再扫预生成托盘码批量归托,也支持先扫托盘后逐箱直接归托;预计托数不得超过实测箱数。验收逐托封托并确认全部箱数已绑定、全部预生成托盘已封托后任务才完成,默认一件代发后续链复用该前置。人工验收造数可传 `--prepare-dropship-manual-count true`,脚本会停在任务未领取、箱未清点、托盘未绑定的状态。
|
||||
@@ -62,6 +69,33 @@ export async function run(context) {
|
||||
|
||||
## 近期业务模块与静态合同快速索引
|
||||
|
||||
### 合并后的海外增量覆盖
|
||||
|
||||
本节核对 `d4fec829` 首父至 `fb66d920` 的海外后端差异及当前 Admin 调用端。
|
||||
复用已注册默认模块,不新增独立造数入口;定向执行仍由总入口展开本轮客户、国内履约和海外 PDA 前置。
|
||||
离线合同 `merged-overseas-coverage-contract.test.mjs` 锁定默认注册、定向依赖、陈旧 fixture 拒绝及新增断言。
|
||||
离线通过只证明脚本合同,不证明部署环境已通过。
|
||||
|
||||
| 新增功能 | 全量模块 | 本轮 fixture / 依赖 | 正常与失败断言 |
|
||||
|---|---|---|---|
|
||||
| 仓储转运审核 | `phase4-overseas-client-services` 调用内部 `warehouse-transfer-review.mjs` | 本轮资质客户 Token、PDA 完成柜来源订单;另通过客户 API 创建独立新到仓转运对照票 | 平台显式 customerId;客户 Token 无审核权限;缺作用域/非法决定拒绝且状态不漂移;已有库存 APPROVED/PENDING_ASSIGNMENT;新到仓 APPROVE→WAIT_PDA_RECEIVING/WAIT_DELIVERY,REJECT→REJECTED/CANCELLED;同决定重放、反向审核冲突、客户详情回查;空驳回原因应 A0400 并保持待审核 |
|
||||
| 智能预排真实草稿柜 | `phase4-overseas-dispatch` | `phase4-overseas-pda-operations` 后本轮实际收货托盘 | 草稿柜创建、明确 draftContainerId 绑定、结构回查、unlock 恢复候选、删除柜释放锁、重建柜预排;保留同草稿重锁、其他草稿冲突、旧载荷预排冲突与 release 合同 |
|
||||
| 排车托盘移除 | `phase4-overseas-dispatch` | 同一本轮托盘先创建独立移除对照排车,移除后才进入主链预排 | DELETE 明细后箱数/托数/明细归零;重复删除 A0404;正式锁释放后候选恢复。空对照排车保留,不借历史排车 |
|
||||
| 排车编辑、BOL、后台转派送 | `phase5-overseas-fulfillment` | `phase4-overseas-dispatch` 生成的主链排车 | 复用车辆编辑和客户转派送;新增 BOL content-type 与 %PDF- 签名检查;后台同载荷转派送复用同一出库/派送业务号;生成出库后移除托盘 A0409 且数量和身份不漂移;原 PDA 拣货/发车、派送、异常和回访继续执行 |
|
||||
| 退货签收与合箱 | `phase4-overseas-return-receipt` | 本轮退货预报及临时 PDA,不使用历史100单 | 已有分批签收、超收、幂等、数量守恒、库存与计费批次、合箱;不重复编排 |
|
||||
| PDA 收货、换标托盘及采购资金 | `phase4-overseas-pda`、`phase4-overseas-pda-operations`、`phase4-overseas-wallet-and-pallet-cost` | 原本轮收货与钱包前置 | 复用已有收货、换标拆托、出库衔接和采购冻结/结算/释放,不新增旁路写库 fixture |
|
||||
|
||||
明确缺口及待验收(不能计作已覆盖通过):
|
||||
|
||||
- 转运 `Review.remark` 仅有长度限制,`WarehouseTransferService.review` 未校验空驳回原因,也未将审核原因保存到详情或历史。新增空原因负向 HTTP 断言会在当前实现失败,须业务实现专项修复;原 `Detail.remark` 是申请备注,不能冒充审核原因。
|
||||
- 已核对专用 `WAREHOUSE_TRANSFER_APPROVE` Controller 注解;本轮 HTTP 增量覆盖客户 Token 拒绝,但“平台仅列表/仅管理无审核权限”的独立角色矩阵仍缺 fixture。不能用管理员成功替代最小权限验证。
|
||||
- 新到仓转运本轮只验证审核落到 PDA 前置状态;`PdaWarehouseTransferController` 托盘化、客户确认、可选换标与后续预排完整新链尚未编排。不得声称 WAIT_PDA_RECEIVING 就是 PDA 已收货。
|
||||
- 合并新增退货单换标候选及创建换标接口、换标申请编辑能力,尚需各自独立回归。退货出库需另补当前退货箱 fixture 到 RETURN_OUTBOUND 的完整链,不由普通派送退回覆盖。退货模块本轮已将旧 `sourceBatchNo=MERGED` 断言改为实际扣减来源批次去重、真实来源退货单,并补客户箱号分页与标签 SVG 条码;条码实际扫码可读性仍由人工验收。
|
||||
- 智能预排跨客户/跨仓候选混选、不同平台操作员锁隔离与容量边界尚缺独立反向 fixture;本次同操作员不同草稿锁冲突不能替代它们。
|
||||
- UI 专项由人工验收:审核弹窗驳回原因必填、按钮权限、列表状态刷新;排车编辑和移除交互、智能预排拖放/草稿恢复、BOL 排版与打印、请求数量。PDF 签名验证不证明打印样式正确。
|
||||
|
||||
所有真实 HTTP 场景待当前轮另行授权后运行;不读取真实客户 Excel、不造历史样本、不执行 SQL 或部署。
|
||||
|
||||
| 业务链路 | HTTP 模块/共享前置 | 离线静态合同 |
|
||||
|---|---|---|
|
||||
| 商户资质与下单身份 | `merchant-qualification-fixture.mjs` | `merchant-qualification-fixture-contract.test.mjs` |
|
||||
|
||||
@@ -103,11 +103,11 @@ test('dropship measurement completes the formal palletization workflow before sm
|
||||
assert.match(source, /pda-dropship\/tasks\/\$\{taskId\}\/claim/)
|
||||
assert.match(source, /cartons\/resolve/)
|
||||
assert.match(source, /cartons\/count/)
|
||||
assert.match(source, /pallets\/create-and-bind/)
|
||||
assert.match(source, /系统发码重放不新增托盘且业务码和计数稳定/)
|
||||
assert.match(source, /拒绝创建没有空托或计数漂移/)
|
||||
assert.match(source, /pallets\/bind-pending/)
|
||||
assert.match(source, /预生成托盘绑定重放不新增托盘且计数稳定/)
|
||||
assert.match(source, /预生成托盘覆盖测量冻结的全部箱数/)
|
||||
assert.doesNotMatch(source, /palletHash|palletDate/)
|
||||
assert.match(source, /pallets\/\$\{palletId\}\/seal/)
|
||||
assert.match(source, /pallets\/\$\{asId\(pallet.palletId\)\}\/seal/)
|
||||
assert.match(source, /重复清点操作按operationId幂等返回/)
|
||||
assert.doesNotMatch(source, /pallets\/open/)
|
||||
assert.doesNotMatch(source, /pallets\/\$\{palletId\}\/scan/)
|
||||
@@ -123,8 +123,8 @@ test('confirmed package count reconciles an uncertain transport result before re
|
||||
})
|
||||
|
||||
async function exerciseSystemPalletScenario(fault = '') {
|
||||
const task = { taskId: '81', inboundId: '82', warehouseId: '83', taskStatus: 'CLAIMED',
|
||||
totalCartons: 2, countedCartons: 0, boundCartons: 0, unboundCartons: 0, pallets: [] }
|
||||
const task = { taskId: '81', inboundId: '82', warehouseId: '83', tenantId: '85', generatedPalletCount: 1, palletSetupReady: true, taskStatus: 'CLAIMED',
|
||||
totalCartons: 2, countedCartons: 0, boundCartons: 0, unboundCartons: 0, pallets: [{ palletId: '84', palletNo: 'DP-260909-0001', cartonCount: 0, status: 'OPEN', version: 1 }] }
|
||||
const seenCounts = new Map()
|
||||
const seenCreates = new Set()
|
||||
const checks = []
|
||||
@@ -138,33 +138,31 @@ async function exerciseSystemPalletScenario(fault = '') {
|
||||
assert(_module, name, _response, condition) { checks.push({ name, passed: Boolean(condition) }) },
|
||||
async request(method, path, body) {
|
||||
if (path.includes('/pending?')) return response([task])
|
||||
if (path.endsWith('/claim') || (method === 'GET' && path.includes('/tasks/81?'))) return response(task)
|
||||
if (path.includes('/cartons/resolve?')) return response({ boxCode: new URL(path, 'http://fixture').searchParams.get('boxCode'), canCount: true, expectedSkuQuantity: 10 })
|
||||
if (path.endsWith('/claim')) return response({ ...task, pallets: [] })
|
||||
if (path === '/api/platform/v1/first-leg/pda-dropship/inbound-orders/82/pallets?accountId=85') return response(task.pallets)
|
||||
if (method === 'GET' && path.includes('/tasks/81?')) return response(task)
|
||||
if (path.includes('/cartons/resolve?')) return response({ boxCode: new URL(path, 'http://fixture').searchParams.get('boxCode'), canCount: true, skuLines: [{ inboundDetailId: '91', skuCode: 'SKU-1', expectedQuantity: 10 }] })
|
||||
if (path.endsWith('/cartons/count')) {
|
||||
if (seenCounts.has(body.operationId)) return seenCounts.get(body.operationId)
|
||||
task.countedCartons += 1
|
||||
const targetPallet = task.pallets.find(pallet => pallet.palletNo === body.palletNo)
|
||||
if (body.palletNo) { task.boundCartons += 1; targetPallet.cartonCount += 1; targetPallet.version += 1 }
|
||||
else task.unboundCartons += 1
|
||||
const result = response({ ...task, carton: { boxCode: body.boxCode, expectedSkuQuantity: 10, actualSkuQuantity: 10,
|
||||
discrepancyStatus: 'MATCH' }, pallet: body.palletNo ? targetPallet : null })
|
||||
const result = response({ ...task, carton: { boxCode: body.boxCode, skuLines: [{ inboundDetailId: '91', skuCode: 'SKU-1', expectedQuantity: 10, actualQuantity: 10, discrepancyStatus: 'MATCH' }] }, pallet: body.palletNo ? targetPallet : null })
|
||||
seenCounts.set(body.operationId, result)
|
||||
return result
|
||||
}
|
||||
if (path.endsWith('/create-and-bind')) {
|
||||
assert.deepEqual(Object.keys(body).sort(), ['operationId', 'warehouseId'])
|
||||
if (path.endsWith('/bind-pending')) {
|
||||
assert.deepEqual(Object.keys(body).sort(), ['operationId', 'palletNo', 'warehouseId'])
|
||||
if (seenCreates.has(body.operationId)) {
|
||||
if (fault === 'duplicate-replay') task.pallets.push({ ...task.pallets[0], palletId: '999', palletNo: 'DP-260909-0999' })
|
||||
return response(task)
|
||||
}
|
||||
if (!task.unboundCartons) {
|
||||
if (fault === 'empty-pallet') task.pallets.push({ palletId: '998', palletNo: 'DP-260909-0998', cartonCount: 0, status: 'OPEN', version: 0 })
|
||||
return response(null, 'A0409')
|
||||
}
|
||||
seenCreates.add(body.operationId)
|
||||
task.pallets.push({ palletId: '84', palletNo: 'DP-260909-0001', cartonCount: task.unboundCartons, status: 'OPEN', version: 0 })
|
||||
task.pallets[0].cartonCount += task.unboundCartons
|
||||
task.boundCartons += task.unboundCartons
|
||||
task.unboundCartons = 0
|
||||
if (fault === 'empty-pallet') task.pallets.push({ palletId: '998', palletNo: 'DP-260909-0998', cartonCount: 0, status: 'SEALED', version: 1 })
|
||||
return response(task)
|
||||
}
|
||||
if (path.endsWith('/84/seal')) {
|
||||
@@ -175,21 +173,21 @@ async function exerciseSystemPalletScenario(fault = '') {
|
||||
throw new Error(`Unexpected scenario request: ${method} ${path}`)
|
||||
},
|
||||
}
|
||||
const sealed = await completeDropshipPalletization(context, { inboundId: '82', inboundCode: 'CODE', warehouseId: '83', measurement: { actualPackageCount: 2 } })
|
||||
const sealed = await completeDropshipPalletization(context, { inboundId: '82', inboundCode: 'CODE', warehouseId: '83', measurement: { actualPackageCount: 2, expectedPalletCount: 1 } })
|
||||
assert.deepEqual(sealed.sealedCartonMarks, ['CODE#2-1', 'CODE#2-2'])
|
||||
assert.deepEqual(sealed.sealedSourcePalletNos, ['DP-260909-0001'])
|
||||
if (!fault) assert.deepEqual(sealed.sealedSourcePalletNos, ['DP-260909-0001'])
|
||||
return checks
|
||||
}
|
||||
|
||||
test('system-issued pallet HTTP scenario covers count, replay, empty rejection and completed replay', async () => {
|
||||
test('pregenerated pallet scenario covers multi-SKU count, bind replay and sealing', async () => {
|
||||
const checks = await exerciseSystemPalletScenario()
|
||||
assert.ok(checks.length >= 15)
|
||||
for (const name of ['审核通过时已按预计托数预生成全部DP托盘码', '重复清点没有重复累计箱数', '待组托箱绑定到OPEN托盘且不再游离', '预生成托盘绑定重放不新增托盘且计数稳定', '预生成托盘覆盖测量冻结的全部箱数', '全部箱数封托后入仓单允许进入WAIT_SPLIT']) assert.ok(checks.some(check => check.name === name), name)
|
||||
assert.deepEqual(checks.filter(check => !check.passed), [])
|
||||
})
|
||||
|
||||
test('system-issued pallet HTTP regression detects duplicate replay and orphan empty pallets', async () => {
|
||||
test('pregenerated pallet regression detects duplicate replay and orphan empty pallets', async () => {
|
||||
const replayChecks = await exerciseSystemPalletScenario('duplicate-replay')
|
||||
assert.ok(replayChecks.some(check => check.name.includes('系统发码重放不新增托盘') && !check.passed))
|
||||
assert.ok(replayChecks.some(check => check.name.includes('预生成托盘绑定重放不新增托盘') && !check.passed))
|
||||
const emptyChecks = await exerciseSystemPalletScenario('empty-pallet')
|
||||
assert.ok(emptyChecks.some(check => check.name.includes('拒绝创建没有空托或计数漂移') && !check.passed))
|
||||
assert.ok(emptyChecks.some(check => check.name.includes('预生成托盘覆盖测量冻结的全部箱数') && !check.passed))
|
||||
})
|
||||
|
||||
@@ -112,12 +112,20 @@ export async function completeDropshipPalletization(context, { inboundId, inboun
|
||||
const totalCartons = Number(measurement?.actualPackageCount)
|
||||
const expectedPalletCount = Number(measurement?.expectedPalletCount)
|
||||
const taskPath = `/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}?warehouseId=${warehouseId}`
|
||||
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 || '')),
|
||||
if (!claimed?.tenantId || claimed.taskStatus !== 'CLAIMED') return null
|
||||
// Task.pallets contains pallets with actual bound cartons, not unused pre-generated pallets.
|
||||
const palletResponse = await context.request('GET',
|
||||
`/api/platform/v1/first-leg/pda-dropship/inbound-orders/${inboundId}/pallets?accountId=${asId(claimed.tenantId)}`)
|
||||
const palletData = context.apiSuccess(MODULE, '读取本轮入仓单预生成托盘', palletResponse, '', OPTIONS)
|
||||
const generatedPallets = Array.isArray(palletData) ? palletData : []
|
||||
const palletSetupValid = Number(claimed.generatedPalletCount) === expectedPalletCount
|
||||
&& claimed.palletSetupReady === true && generatedPallets.length === expectedPalletCount
|
||||
&& generatedPallets.every((item) =>
|
||||
item?.status === 'OPEN' && Number(item?.cartonCount) === 0 && /^DP-\d{6}-\d{4}$/.test(item?.palletNo || ''))
|
||||
context.assert(MODULE, '审核通过时已按预计托数预生成全部DP托盘码', palletResponse,
|
||||
palletSetupValid,
|
||||
`expected=${expectedPalletCount}, generated=${generatedPallets.length}`, OPTIONS)
|
||||
if (!generatedPallets.length) return null
|
||||
if (!palletSetupValid) return null
|
||||
|
||||
let countedCartons = 0
|
||||
const sealedCartonMarks = []
|
||||
@@ -189,8 +197,15 @@ export async function completeDropshipPalletization(context, { inboundId, inboun
|
||||
context.assert(MODULE, '待组托箱绑定到OPEN托盘且不再游离', bindResponse,
|
||||
Number(bound?.unboundCartons) === 0 && Number(pallet?.cartonCount) === 1
|
||||
&& pallet?.status === 'OPEN' && pallet?.palletNo === firstPallet.palletNo
|
||||
&& bound?.pallets?.length === expectedPalletCount,
|
||||
&& bound?.pallets?.length === 1,
|
||||
`unbound=${bound?.unboundCartons ?? 'missing'}, palletStatus=${pallet?.status ?? 'missing'}`, OPTIONS)
|
||||
const replayBindResponse = await context.request('POST',
|
||||
`/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/pallets/bind-pending`, bindPayload)
|
||||
const replayBind = context.apiSuccess(MODULE, '预生成托盘绑定同键重放', replayBindResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '预生成托盘绑定重放不新增托盘且计数稳定', replayBindResponse,
|
||||
replayBind?.pallets?.length === bound?.pallets?.length
|
||||
&& Number(replayBind?.boundCartons) === Number(bound?.boundCartons)
|
||||
&& Number(replayBind?.unboundCartons) === 0, '', OPTIONS)
|
||||
} else {
|
||||
const expectedPallet = generatedPallets[Math.min(index, generatedPallets.length - 1)]
|
||||
context.assert(MODULE, `第${index + 1}箱直接进入当前OPEN托盘`, countResponse,
|
||||
|
||||
@@ -113,12 +113,14 @@ test('海外空库基线为固定平台管理员提供完整version0授权投影
|
||||
'platform:overseas-warehouse:product:create',
|
||||
'platform:overseas-warehouse:product:edit',
|
||||
])
|
||||
assert.equal(authorities.length, 105)
|
||||
assert.equal(authorities.length, 106)
|
||||
assert.ok(overseasSql.includes(`JSON_LENGTH(authorities_json) <> ${authorities.length}`),
|
||||
'fixed-admin SQL assertion must match the complete authority snapshot')
|
||||
assert.equal(new Set(authorities).size, authorities.length, 'baseline authorities must not contain duplicates')
|
||||
assert.equal(authorities.filter((item) => item.startsWith('platform:finance:')).length, 34)
|
||||
assert.equal(authorities.filter((item) => item.startsWith('platform:overseas-business:')).length, 11)
|
||||
assert.equal(authorities.filter((item) => item.startsWith('platform:overseas-master-data:')).length, 15)
|
||||
assert.equal(authorities.filter((item) => item.startsWith('platform:overseas-warehouse:')).length, 45)
|
||||
assert.equal(authorities.filter((item) => item.startsWith('platform:overseas-warehouse:')).length, 46)
|
||||
for (const authority of authorities) {
|
||||
assert.ok(sql.includes(`'${authority}'`), `authority is absent from the domestic IAM baseline: ${authority}`)
|
||||
}
|
||||
@@ -186,3 +188,10 @@ test('三期权限迁移删除退役权限并只保留 canonical 权限', () =>
|
||||
assert.match(sql, /status='ACTIVE' AND deleted=0[\s\S]*?id BETWEEN 5102140 AND 5102149/)
|
||||
assert.match(sql, /status='ACTIVE' AND deleted=0[\s\S]*?id BETWEEN 5102150 AND 5102154/)
|
||||
})
|
||||
|
||||
test('海外仓物理托盘初始化与模块基线一致', () => {
|
||||
const baseline = readFileSync(new URL('../../warehouse-overseas-service/src/main/resources/db/warehouse.sql', import.meta.url), 'utf8')
|
||||
const inline = overseasSql.match(/-- BEGIN INLINE EMPTY-DATABASE BASELINE: OVERSEAS WAREHOUSE\r?\n([\s\S]*?)\r?\n\r?\n-- END INLINE EMPTY-DATABASE BASELINE: OVERSEAS WAREHOUSE/)?.[1]
|
||||
assert.ok(inline)
|
||||
assert.equal(inline.slice(inline.indexOf('-- This script is run')).replace(/\r\n/g, '\n').trim(), baseline.replace(/\r\n/g, '\n').trim())
|
||||
})
|
||||
|
||||
@@ -50,7 +50,8 @@ test('批量待分柜参数固定同一客户并限制为订单测量链', () =>
|
||||
assert.match(acceptance, /orderBlockingFailure[\s\S]*step\.deploymentBlocker/)
|
||||
assert.match(acceptance, /measurementBlockingFailure[\s\S]*step\.deploymentBlocker/)
|
||||
assert.doesNotMatch(acceptance, /this\.modules\.at\(-1\)\?\.success !== true/)
|
||||
assert.match(acceptance, /measurement\.processStatus === 'WAIT_SPLIT'/)
|
||||
assert.match(acceptance, /measurement\.processStatus === expectedProcessStatus/)
|
||||
assert.match(acceptance, /prepareDropshipManualCount \? 'WAIT_PALLETIZE' : 'WAIT_SPLIT'/)
|
||||
assert.match(acceptance, /measurement\.finalStatus === 'APPROVED'/)
|
||||
assert.match(acceptance, /waitSplitVariant\(sequence\)/)
|
||||
assert.match(acceptance, /packageCountPerSku/)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { parseArguments } from '../admin-test-env-acceptance.mjs'
|
||||
import { verifyWarehouseTransferReview } from './warehouse-transfer-review.mjs'
|
||||
|
||||
const read = name => readFile(new URL(name, import.meta.url), 'utf8')
|
||||
test('merged scenarios remain in default and focused dependency chains', () => {
|
||||
const defaults = parseArguments([]).modules
|
||||
for (const module of ['phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment']) {
|
||||
assert.ok(defaults.includes(module))
|
||||
const focused = parseArguments(['--modules', module]).modules
|
||||
assert.ok(focused.indexOf('merchant-qualification-fixture') >= 0)
|
||||
assert.ok(focused.indexOf('phase4-overseas-pda-operations') < focused.indexOf(module))
|
||||
}
|
||||
})
|
||||
|
||||
test('review helper refuses stale fixture before any request', async () => {
|
||||
const steps = []
|
||||
await verifyWarehouseTransferReview({ marker: 'current', context: { merchantQualificationFixture: { marker: 'old' } },
|
||||
addStep: (...args) => steps.push(args), requestOverseas: () => { throw Error('must not request') } }, 'token', '123', {}, 'T1')
|
||||
assert.equal(steps.length, 1)
|
||||
assert.equal(steps[0][3], false)
|
||||
assert.equal(steps[0][5].deploymentBlocker, true)
|
||||
})
|
||||
|
||||
test('transfer review integration covers both source types and known blank-reason failure', async () => {
|
||||
const source = await read('warehouse-transfer-review.mjs')
|
||||
const caller = await read('phase4-overseas-client-services.mjs')
|
||||
assert.match(caller, /return verifyWarehouseTransferReview\(context, token, order.row.customerId, body, transferNo\)/)
|
||||
for (const value of ['WAIT_PDA_RECEIVING', 'PENDING_ASSIGNMENT', 'WAIT_DELIVERY', 'CANCELLED',
|
||||
'REJECTED', 'APPROVED', 'customerId=', 'rejected(denied, 403)', 'rejected(missingScope, 400)',
|
||||
'rejected(reverse, 409)', 'rejected(emptyReason, 400)', "sourceLastlegOrderIds: []"])
|
||||
assert.ok(source.includes(value), value)
|
||||
assert.doesNotMatch(source, /fetch\(|mysql|process\.env|\/internal\//)
|
||||
const controller = await read('../../lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferController.java')
|
||||
assert.match(controller, /WAREHOUSE_TRANSFER_APPROVE/)
|
||||
})
|
||||
|
||||
test('draft and removal checks use actual IDs plus readback rather than snapshot-only success', async () => {
|
||||
const source = await read('phase4-overseas-dispatch.mjs')
|
||||
for (const value of ['drafts/containers', 'draftContainerId: containerId', 'container.palletIds',
|
||||
'draft-locks/unlock', 'deletedDrafts', 'removableItem.id', 'removedDetail.data?.packageCount', 'freed.data.some'])
|
||||
assert.ok(source.includes(value), value)
|
||||
})
|
||||
|
||||
test('fulfillment checks PDF signature, platform replay and immutable pallets after transfer', async () => {
|
||||
const source = await read('phase5-overseas-fulfillment.mjs')
|
||||
for (const value of ['requestOverseasBinary', '/prints/bol', "=== '%PDF-'", '/transfer-delivery',
|
||||
'adminReplay.data?.replayed', 'conflict(deniedRemove)', 'stable.data?.items?.map'])
|
||||
assert.ok(source.includes(value), value)
|
||||
})
|
||||
|
||||
test('return merge sources are actual consumed batches, not a synthetic MERGED identifier', async () => {
|
||||
const source = await read('phase4-overseas-return-receipt.mjs')
|
||||
assert.doesNotMatch(source, /sourceBatchNo === 'MERGED'/)
|
||||
for (const value of ['consumedBatches', 'new Set(actualBatches)', 'sourceReturnNo === returnNo',
|
||||
'客户箱号管理按实物箱分页', '标签包含SVG条码和当前箱号']) assert.ok(source.includes(value), value)
|
||||
})
|
||||
@@ -2,7 +2,15 @@ import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
import { DOCUMENT_TYPES } from './phase3-container-documents.mjs'
|
||||
import { DOCUMENT_TYPES, applicableDocumentTypes } from './phase3-container-documents.mjs'
|
||||
|
||||
test('rail excludes DO but truck still requires it; missing channel fails closed', () => {
|
||||
for (const code of ['EXX_RAIL_NY', 'NYEXX', 'OA_CHICAGO_RAIL', 'CPHT']) {
|
||||
assert.deepEqual(applicableDocumentTypes(code), ['DESTINATION_CLEARANCE'])
|
||||
}
|
||||
assert.deepEqual(applicableDocumentTypes('EXX_TRUCK'), DOCUMENT_TYPES)
|
||||
assert.throws(() => applicableDocumentTypes(null), /权威渠道/)
|
||||
})
|
||||
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
|
||||
|
||||
const source = readFileSync(new URL('./phase3-container-documents.mjs', import.meta.url), 'utf8')
|
||||
|
||||
@@ -6,6 +6,13 @@ export const DOCUMENT_TYPES = Object.freeze([
|
||||
'DESTINATION_CLEARANCE', 'DELIVERY_ORDER',
|
||||
])
|
||||
|
||||
export function applicableDocumentTypes(channelCode) {
|
||||
if (!String(channelCode || '').trim()) throw new Error('货柜缺少权威渠道,不能判断DO适用性')
|
||||
const seaRail = ['EXX_RAIL_NY', 'NYEXX', 'OA_CHICAGO_RAIL', 'CPHT']
|
||||
.includes(String(channelCode).trim().toUpperCase())
|
||||
return seaRail ? ['DESTINATION_CLEARANCE'] : [...DOCUMENT_TYPES]
|
||||
}
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
||||
function success(response) { return response?.http === 200 && codeOf(response) === '200' }
|
||||
@@ -115,6 +122,13 @@ export async function run(context) {
|
||||
return
|
||||
}
|
||||
|
||||
const detailResponse = await context.request('GET', `${CONTAINERS}/domestic/${containerId}`)
|
||||
const detail = context.apiSuccess(MODULE, '读取本轮货柜权威渠道', detailResponse, '', OPTIONS)
|
||||
if (id(detail?.container?.id) !== containerId || !detail?.container?.channelCode) {
|
||||
context.addStep(MODULE, '货柜渠道适用性前置', detailResponse, false, '缺少同柜权威渠道', OPTIONS)
|
||||
return
|
||||
}
|
||||
const expectedTypes = applicableDocumentTypes(detail.container.channelCode)
|
||||
const specs = [
|
||||
{
|
||||
type: 'DESTINATION_CLEARANCE',
|
||||
@@ -122,7 +136,7 @@ export async function run(context) {
|
||||
readPath: `${CONTAINERS}/${containerId}/clearance`,
|
||||
completePath: `${CONTAINERS}/${containerId}/clearance/complete`,
|
||||
},
|
||||
...['DELIVERY_ORDER'].map((type) => ({
|
||||
...expectedTypes.filter((type) => type === 'DELIVERY_ORDER').map((type) => ({
|
||||
type,
|
||||
uploadPath: `${CONTAINERS}/${containerId}/documents/${type}/upload`,
|
||||
readPath: `${CONTAINERS}/${containerId}/documents/${type}`,
|
||||
@@ -131,17 +145,28 @@ export async function run(context) {
|
||||
|
||||
const documents = []
|
||||
for (const spec of specs) documents.push(await uploadAndVerify(context, spec))
|
||||
let applicabilityPassed = true
|
||||
if (!expectedTypes.includes('DELIVERY_ORDER')) {
|
||||
const rejected = await upload(context, `${CONTAINERS}/${containerId}/documents/DELIVERY_ORDER/upload`,
|
||||
'DELIVERY_ORDER', 0, `${context.marker} 海铁DO负向验收`, key(context, 'rail-do-rejected'))
|
||||
applicabilityPassed = conflict(rejected) && traced(rejected)
|
||||
&& rejected.body?.message === '海铁渠道不适用DO资料'
|
||||
context.addStep(MODULE, '海铁DO上传按渠道规则拒绝', rejected, applicabilityPassed,
|
||||
`code=${codeOf(rejected)}, requestId=${rejected.requestId}, traceId=${rejected.traceId}`, OPTIONS)
|
||||
}
|
||||
const overviewResponse = await context.request('GET', `${CONTAINERS}/${containerId}/overview`)
|
||||
const overview = context.apiSuccess(MODULE, '回查货柜作业资料聚合', overviewResponse, '', OPTIONS)
|
||||
const storedTypes = new Set((overview?.documents || []).map((item) => item?.documentType))
|
||||
const typesValid = expectedTypes.every((type) => storedTypes.has(type))
|
||||
&& (expectedTypes.includes('DELIVERY_ORDER') || !storedTypes.has('DELIVERY_ORDER'))
|
||||
context.assert(MODULE, '同一LOADED柜次聚合返回作业资料且身份未漂移', overviewResponse,
|
||||
id(overview?.container?.id) === containerId && documents.every(Boolean)
|
||||
&& DOCUMENT_TYPES.every((type) => storedTypes.has(type)),
|
||||
&& typesValid && applicabilityPassed,
|
||||
`containerBookingId=${containerId}, documentTypes=${[...storedTypes].join('|') || 'none'}`, OPTIONS)
|
||||
if (documents.every(Boolean) && DOCUMENT_TYPES.every((type) => storedTypes.has(type))) {
|
||||
if (id(overview?.container?.id) === containerId && documents.every(Boolean) && typesValid && applicabilityPassed) {
|
||||
context.createdResources.phase3ContainerDocuments = {
|
||||
marker: context.marker, containerBookingId: containerId, loadingPlanId: planId,
|
||||
warehouseId, finalStatus: 'COMPLETED', documentTypes: [...DOCUMENT_TYPES],
|
||||
warehouseId, finalStatus: 'COMPLETED', documentTypes: expectedTypes,
|
||||
documentIds: Object.fromEntries(documents.map((item) => [item.documentType, id(item.id)])),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ test('order fixture defaults to FBA and preserves explicit OVERSEAS or PRIVATE r
|
||||
const orders = read('orders.mjs')
|
||||
assert.match(orders, /warehouseType=\$\{encodeURIComponent\(destinationType\)\}/)
|
||||
assert.match(orders, /destinationType: master\.destinationType/)
|
||||
assert.match(orders, /fulfillmentType: String\(context\.context\?\.orderFulfillmentType \|\| 'STANDARD'\)/)
|
||||
assert.match(orders, /fulfillmentType: dropship \? 'ONE_CLICK_DROPSHIP' : 'STANDARD'/)
|
||||
assert.match(orders, /skuCodes: payload\.cargoes\.map\(\(cargo\) => cargo\.skuNo\)/)
|
||||
assert.match(orders, /skuLines: payload\.cargoes\.map\(\(cargo\) => \(\{/)
|
||||
assert.match(orders, /destinationType,\s*\n\s*\}\)/)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { verifyWarehouseTransferReview } from './warehouse-transfer-review.mjs'
|
||||
|
||||
const MODULE = 'phase4-overseas-client-services'
|
||||
const SCENARIO = 'phase4-overseas-client-services-lifecycle'
|
||||
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
|
||||
@@ -196,7 +198,7 @@ async function verifyTransfer(context, token, orders) {
|
||||
&& detail.data?.outboundAt == null && Array.isArray(detail.data?.allocations) && detail.data.allocations.length === 0,
|
||||
`status=${detail.data?.status || 'missing'}, allocations=${detail.data?.allocations?.length ?? 'missing'}; ${trace(detail.response)}`, OPTIONS)
|
||||
context.createdResources.phase4OverseasClientServices.transferNo = transferNo
|
||||
return true
|
||||
return verifyWarehouseTransferReview(context, token, order.row.customerId, body, transferNo)
|
||||
}
|
||||
|
||||
async function verifyDiscard(context, token, markerOrders) {
|
||||
@@ -244,7 +246,7 @@ export async function run(context) {
|
||||
context.createdResources.phase4OverseasClientServices = {
|
||||
marker: context.marker, relabelNo: null, transferNo: null, discardNo: null,
|
||||
finalStatuses: { relabel: null, transfer: null, discard: null },
|
||||
cleanup: 'Merchant applications remain in their legal initial review/processing states; no outbound, dispatch, PDA or delivery action is invoked',
|
||||
cleanup: 'Relabel/discard remain pending; transfers are reviewed with separate incoming approval/rejection fixtures; no outbound, dispatch, PDA or delivery action is invoked',
|
||||
}
|
||||
const token = await loginMerchant(context)
|
||||
if (!token) return
|
||||
@@ -269,7 +271,7 @@ export async function run(context) {
|
||||
const discardDone = await verifyDiscard(context, token, orders)
|
||||
context.createdResources.phase4OverseasClientServices.finalStatuses = {
|
||||
relabel: relabelDone ? 'PENDING_PROCESSING' : null,
|
||||
transfer: transferDone ? 'PENDING_REVIEW' : null,
|
||||
transfer: transferDone ? 'APPROVED' : null,
|
||||
discard: discardDone ? 'PENDING_REVIEW' : null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dispatchBusinessSnapshot } from './phase4-overseas-dispatch.mjs'
|
||||
|
||||
const moduleUrl = new URL('./phase4-overseas-dispatch.mjs', import.meta.url)
|
||||
const source = await readFile(fileURLToPath(moduleUrl), 'utf8')
|
||||
@@ -27,11 +28,11 @@ test('module uses marker-owned completed PDA pallets and never borrows historica
|
||||
assert.doesNotMatch(source, /candidates\[0\]/)
|
||||
})
|
||||
|
||||
test('module proves draft idempotency, lock isolation, stale replay conflict and readback', () => {
|
||||
test('module proves draft idempotency, lock isolation, editable pre-arrange replay and readback', () => {
|
||||
assert.match(source, /相同草稿快照重复保存/)
|
||||
assert.match(source, /同草稿重复锁定托盘幂等成功/)
|
||||
assert.match(source, /其他草稿锁定同一托盘返回A0409/)
|
||||
assert.match(source, /已转换正式锁的旧草稿载荷再次预排返回A0409/)
|
||||
assert.match(source, /出库前同草稿重复预排复用原排车且业务快照不漂移/)
|
||||
assert.match(source, /重复释放草稿幂等成功/)
|
||||
assert.match(source, /排车详情按记录自身客户范围加载托盘明细/)
|
||||
assert.match(source, /response\?\.http === 409.*codeOf\(response\) === 'A0409'/s)
|
||||
@@ -58,3 +59,22 @@ test('module prefers marker-owned relabel pallets and proves only new FBA carton
|
||||
assert.match(source, /!String\(value\?\.cartonMarks \|\| ''\)\.trim\(\)/)
|
||||
assert.match(source, /relabelNo: relabel\.relabelNo/)
|
||||
})
|
||||
|
||||
test('relabel settlement follows pre-arrange and does not replace the ordinary dispatch lifecycle', () => {
|
||||
assert.match(source, /await runRelabelDispatch\(context, relabel\)[\s\S]*if \(relabel.dispatchNo\) await settleRelabelFulfillment\(context, relabel\)/)
|
||||
const branch = source.slice(source.indexOf('const relabel = context.createdResources'), source.indexOf('const fixture = markerFixture(context)', source.indexOf('const relabel = context.createdResources')))
|
||||
assert.doesNotMatch(branch, /\breturn\b/)
|
||||
assert.match(source, /Object.assign\(relabel, \{/)
|
||||
})
|
||||
|
||||
test('dispatch replay snapshot ignores regenerated item ids but detects business drift', () => {
|
||||
const a = { id: '1', dispatchNo: 'DS-1', status: 'PENDING_DEPARTURE', packageCount: 2,
|
||||
palletCount: 1, items: [{ id: '10', planPalletId: '20', warehousePalletId: '30', packageCount: 2 }] }
|
||||
const replay = { ...a, items: [{ ...a.items[0], id: '11' }] }
|
||||
assert.equal(dispatchBusinessSnapshot(a), dispatchBusinessSnapshot(replay))
|
||||
for (const changed of [{ ...replay, id: '2' }, { ...replay, packageCount: 4 },
|
||||
{ ...replay, items: [{ ...a.items[0], warehousePalletId: '31' }] }]) {
|
||||
assert.notEqual(dispatchBusinessSnapshot(a), dispatchBusinessSnapshot(changed))
|
||||
}
|
||||
assert.doesNotMatch(source, /item\?\.cartonMarks/)
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { settleRelabelFulfillment } from './phase4-overseas-pda-operations.mjs'
|
||||
|
||||
const MODULE = 'phase4-overseas-dispatch'
|
||||
const SCENARIO = 'phase4-overseas-dispatch-lifecycle'
|
||||
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
|
||||
@@ -7,6 +9,15 @@ function id(value) { return value == null ? '' : String(value) }
|
||||
function records(value) { return Array.isArray(value?.records) ? value.records : [] }
|
||||
function codeOf(response) { return response?.body?.code == null ? '' : String(response.body.code) }
|
||||
function success(response) { return response?.http === 200 && codeOf(response) === '200' }
|
||||
export function dispatchBusinessSnapshot(value) {
|
||||
return JSON.stringify({ id: id(value?.id), dispatchNo: value?.dispatchNo, status: value?.status,
|
||||
packageCount: value?.packageCount, palletCount: value?.palletCount,
|
||||
items: (value?.items || []).map(item => ({ planPalletId: id(item.planPalletId),
|
||||
warehousePalletId: id(item.warehousePalletId), sourceStage: item.sourceStage,
|
||||
packageCount: item.packageCount, inboundCode: item.inboundCode }))
|
||||
.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))),
|
||||
})
|
||||
}
|
||||
function conflict(response) { return response?.http === 409 || (response?.http === 200 && codeOf(response) === 'A0409') }
|
||||
function trace(response) {
|
||||
return `HTTP=${response?.http ?? 'missing'}, code=${codeOf(response) || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`
|
||||
@@ -69,17 +80,16 @@ async function runRelabelDispatch(context, relabel) {
|
||||
`dispatchNo=${dispatchNo || 'missing'}, pallets=${arranged.data?.palletCount ?? 'missing'}; ${trace(arranged.response)}`,
|
||||
OPTIONS)
|
||||
if (!dispatchNo) return
|
||||
Object.assign(context.createdResources.phase4OverseasDispatch, {
|
||||
Object.assign(relabel, {
|
||||
dispatchNo, dispatchId: id(arranged.data?.id), finalStatus: arranged.data?.status || null,
|
||||
relabelNo: relabel.relabelNo, planPalletId: null,
|
||||
})
|
||||
const detail = await api(context, 'GET', '回查本轮换标排车详情', `${BASE}/${encodeURIComponent(dispatchNo)}`)
|
||||
const detailMarks = (detail.data?.items || []).flatMap((item) => String(item?.cartonMarks || '')
|
||||
.split(',').map((mark) => mark.trim()).filter(Boolean))
|
||||
context.assert(MODULE, '排车详情冻结全部换标后箱唛且不混入原箱唛', detail.response,
|
||||
relabel.relabelCartonMarks.every((mark) => detailMarks.includes(mark))
|
||||
&& relabel.sourceCartonMarks.every((mark) => !detailMarks.includes(mark)),
|
||||
`frozenMarks=${detailMarks.join(',')}; ${trace(detail.response)}`, OPTIONS)
|
||||
// DispatchResponse.Item omits cartonMarks. Phase5 verifies the persisted marks
|
||||
// through OutboundDetail.pallets after transferring this exact dispatch.
|
||||
context.assert(MODULE, '排车详情保留本轮换标托盘与箱数快照', detail.response,
|
||||
success(detail.response) && dispatchBusinessSnapshot(detail.data) === dispatchBusinessSnapshot(arranged.data),
|
||||
trace(detail.response), OPTIONS)
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
@@ -99,7 +109,7 @@ export async function run(context) {
|
||||
const relabel = context.createdResources?.phase4OverseasRelabel
|
||||
if (relabel?.marker === context.marker && relabel?.status === 'READY_FOR_OUTBOUND') {
|
||||
await runRelabelDispatch(context, relabel)
|
||||
return
|
||||
if (relabel.dispatchNo) await settleRelabelFulfillment(context, relabel)
|
||||
}
|
||||
|
||||
const fixture = markerFixture(context)
|
||||
@@ -122,6 +132,30 @@ export async function run(context) {
|
||||
|
||||
const planId = id(candidate.planId)
|
||||
const planPalletId = id(candidate.planPalletId)
|
||||
// Exercise removal on a disposable dispatch, then reuse the released current-run pallet.
|
||||
const removable = await api(context, 'POST', '创建本轮托盘移除对照排车', `${BASE}/pre-arrange`, {
|
||||
planPalletIds: [planPalletId], route: `${context.marker}-REMOVE`,
|
||||
})
|
||||
const removableNo = id(removable.data?.dispatchNo)
|
||||
const removableItem = removable.data?.items?.find(item => id(item.planPalletId) === planPalletId)
|
||||
if (!success(removable.response) || !removableNo || !removableItem?.id) {
|
||||
blocker(context, '移除对照排车前置', '本轮托盘未生成排车明细', removable.response)
|
||||
return
|
||||
}
|
||||
const removePath = `${BASE}/${encodeURIComponent(removableNo)}/items/${encodeURIComponent(id(removableItem.id))}`
|
||||
const removed = await api(context, 'DELETE', '排车移除未拣托盘并释放正式锁', removePath)
|
||||
const removedDetail = await api(context, 'GET', '回查移除后排车数量', `${BASE}/${encodeURIComponent(removableNo)}`)
|
||||
context.assert(MODULE, '移除最后托盘后箱数托数明细归零', removedDetail.response,
|
||||
success(removed.response) && success(removedDetail.response)
|
||||
&& Number(removedDetail.data?.packageCount) === 0 && Number(removedDetail.data?.palletCount) === 0
|
||||
&& removedDetail.data?.items?.length === 0, trace(removedDetail.response), OPTIONS)
|
||||
const removedAgain = await context.requestOverseas('DELETE', removePath)
|
||||
context.addStep(MODULE, '重复删除已不存在排车明细返回A0404', removedAgain,
|
||||
removedAgain?.http === 404 || (removedAgain?.http === 200 && codeOf(removedAgain) === 'A0404'), trace(removedAgain), OPTIONS)
|
||||
const freed = await api(context, 'GET', '移除后本轮托盘重新可预排', query(`${BASE}/candidates`, { sourceStage: 'RECEIVED' }))
|
||||
context.assert(MODULE, '正式锁释放后候选恢复', freed.response,
|
||||
Array.isArray(freed.data) && freed.data.some(item => id(item.planPalletId) === planPalletId), trace(freed.response), OPTIONS)
|
||||
context.createdResources.phase4OverseasDispatch.removalDispatchNo = removableNo
|
||||
const draftToken = `${context.marker}-DISPATCH-DRAFT`.slice(0, 64)
|
||||
const containersJson = JSON.stringify([{ key: 'C1', capacity: Math.max(1, Number(candidate.packageCount)) }])
|
||||
const snapshotBody = { draftToken, planIds: [planId], containersJson }
|
||||
@@ -143,7 +177,28 @@ export async function run(context) {
|
||||
&& matchingDrafts[0].containersJson === containersJson,
|
||||
`matches=${matchingDrafts.length}, planIds=${matchingDrafts[0]?.planIdsJson || 'missing'}; ${trace(draftsResult.response)}`, OPTIONS)
|
||||
|
||||
const lockBody = { planPalletId, draftToken }
|
||||
const disposableContainer = await api(context, 'POST', '创建真实智能预排草稿柜', `${BASE}/drafts/containers`, { draftToken, capacity: 500 })
|
||||
const containerId = id(disposableContainer.data?.id)
|
||||
if (!success(disposableContainer.response) || !containerId) return
|
||||
const boundBody = { planPalletId, draftToken, draftContainerId: containerId }
|
||||
const bound = await api(context, 'POST', '托盘绑定真实草稿柜', `${BASE}/draft-locks/lock`, boundBody)
|
||||
if (!success(bound.response)) return
|
||||
const boundDrafts = await api(context, 'GET', '回查真实草稿柜及托盘绑定', `${BASE}/drafts`)
|
||||
context.assert(MODULE, '草稿结构回显服务端柜ID和托盘', boundDrafts.response,
|
||||
boundDrafts.data?.find(draft => draft.draftToken === draftToken)?.containers?.some(container =>
|
||||
id(container.id) === containerId && container.palletIds?.map(id).includes(planPalletId)), trace(boundDrafts.response), OPTIONS)
|
||||
await api(context, 'POST', '移出草稿托盘解锁', `${BASE}/draft-locks/unlock`, boundBody)
|
||||
const unlocked = await api(context, 'GET', '解锁后回查候选恢复', query(`${BASE}/candidates`, { sourceStage: 'RECEIVED' }))
|
||||
context.assert(MODULE, '草稿解锁恢复候选', unlocked.response,
|
||||
unlocked.data?.some(item => id(item.planPalletId) === planPalletId), trace(unlocked.response), OPTIONS)
|
||||
await api(context, 'POST', '重新锁定以验证删除柜释放锁', `${BASE}/draft-locks/lock`, boundBody)
|
||||
await api(context, 'DELETE', '删除本轮草稿柜并释放绑定托盘', `${BASE}/drafts/${encodeURIComponent(draftToken)}/containers/${encodeURIComponent(containerId)}`)
|
||||
const deletedDrafts = await api(context, 'GET', '删除后回查草稿柜消失', `${BASE}/drafts`)
|
||||
context.assert(MODULE, '删除草稿柜不再出现在结构响应', deletedDrafts.response,
|
||||
Array.isArray(deletedDrafts.data) && !deletedDrafts.data.find(draft => draft.draftToken === draftToken)?.containers?.some(container => id(container.id) === containerId), trace(deletedDrafts.response), OPTIONS)
|
||||
const finalContainer = await api(context, 'POST', '创建正式预排使用的草稿柜', `${BASE}/drafts/containers`, { draftToken, capacity: 500 })
|
||||
if (!success(finalContainer.response) || !finalContainer.data?.id) return
|
||||
const lockBody = { planPalletId, draftToken, draftContainerId: id(finalContainer.data.id) }
|
||||
const locked = await api(context, 'POST', '锁定本轮预排托盘', `${BASE}/draft-locks/lock`, lockBody)
|
||||
if (!success(locked.response)) return
|
||||
const lockReplay = await api(context, 'POST', '同草稿重复锁定托盘幂等成功', `${BASE}/draft-locks/lock`, lockBody)
|
||||
@@ -186,8 +241,10 @@ export async function run(context) {
|
||||
context.createdResources.phase4OverseasDispatch.finalStatus = arranged.data?.status || null
|
||||
|
||||
const duplicateArrange = await context.requestOverseas('POST', `${BASE}/pre-arrange`, preArrangeBody)
|
||||
context.addStep(MODULE, '已转换正式锁的旧草稿载荷再次预排返回A0409', duplicateArrange,
|
||||
conflict(duplicateArrange), trace(duplicateArrange), OPTIONS)
|
||||
context.addStep(MODULE, '出库前同草稿重复预排复用原排车且业务快照不漂移', duplicateArrange,
|
||||
success(duplicateArrange)
|
||||
&& dispatchBusinessSnapshot(duplicateArrange.body?.data) === dispatchBusinessSnapshot(arranged.data),
|
||||
trace(duplicateArrange), OPTIONS)
|
||||
|
||||
const pageResult = await api(context, 'GET', '按排车单号回查跨客户分页', query(BASE, {
|
||||
page: 1, size: 20, dispatchNo,
|
||||
|
||||
@@ -4,7 +4,14 @@ import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { expandOverseasCartonCandidates, resolveCartonFixtures, run } from './phase4-overseas-pda.mjs'
|
||||
import { expandOverseasCartonCandidates, resolveCartonFixtures, run, uniqueContainerProducer } from './phase4-overseas-pda.mjs'
|
||||
|
||||
test('one physical container is received once even when matrix roles overlap', () => {
|
||||
const fba = { containerNo: 'TEST-C1', fulfillmentType: 'STANDARD' }
|
||||
const overseas = { containerNo: 'TEST-C2' }
|
||||
assert.deepEqual([null, fba, { ...fba }, overseas].filter(uniqueContainerProducer), [fba, overseas])
|
||||
assert.doesNotMatch(source, /dropship \|\| matrix/)
|
||||
})
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const source = fs.readFileSync(path.join(here, 'phase4-overseas-pda.mjs'), 'utf8')
|
||||
|
||||
@@ -5,7 +5,26 @@ import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
|
||||
import { run } from './phase4-overseas-pda-operations.mjs'
|
||||
import { run, remainingRelabelItem, selectPhysicalRelabelSources } from './phase4-overseas-pda-operations.mjs'
|
||||
|
||||
test('relabel uses three physical cartons across current inbounds, never UNBOUND or duplicate positions', () => {
|
||||
const a = { inventoryBalanceId: 'A', inboundOrderId: '1', positions: [
|
||||
{ positionId: '10', palletNo: 'REAL-A', availablePackages: 2 },
|
||||
{ positionId: '11', palletNo: 'UNBOUND', availablePackages: 1 }] }
|
||||
const b = { inventoryBalanceId: 'B', inboundOrderId: '2', positions: [
|
||||
{ positionId: '20', palletNo: 'REAL-B', availablePackages: 1 }] }
|
||||
const selected = selectPhysicalRelabelSources([b, a, a], 'A')
|
||||
assert.deepEqual(selected.map(line => line.position.palletNo), ['REAL-A', 'REAL-A', 'REAL-B'])
|
||||
assert.equal(selectPhysicalRelabelSources([a, a], 'A').length, 2)
|
||||
})
|
||||
|
||||
test('exception uses the untouched third carton, independent of response ordering', () => {
|
||||
const third = { itemId: '3', expectedPackages: 1 }
|
||||
const detail = { items: [third, { itemId: '2' }, { itemId: '1' }] }
|
||||
assert.equal(remainingRelabelItem(detail, [1, '2']), third)
|
||||
assert.equal(remainingRelabelItem(detail, ['1', '2', '3']), null)
|
||||
assert.equal(remainingRelabelItem(null, []), null)
|
||||
})
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const source = fs.readFileSync(path.join(here, 'phase4-overseas-pda-operations.mjs'), 'utf8')
|
||||
@@ -56,7 +75,8 @@ test('P2 to P5 cover all frozen endpoints, replays and conflict boundaries witho
|
||||
'id(candidate.itemId) !== id(item.itemId)', 'oldLabel: null',
|
||||
'newLabel: item.fbaCartonMark || item.newSkuCode',
|
||||
"operationType: changesCartonLabel ? 'CARTON_AND_SKU' : 'SKU_LABEL'", 'skuItems:',
|
||||
'P5换标费用冻结后开放PDA作业', 'CUSTOMER_CONFIRM_PENDING', 'confirm-completion',
|
||||
"deliveryMethod: 'TRUCK'", 'printShippingLabel: false',
|
||||
'P5换标费用冻结后开放PDA作业', 'P5换标完成后钱包结算收敛', 'CUSTOMER_CONFIRM_PENDING', 'confirm-completion',
|
||||
'P5库存流水记录换标拆托原因和正负变动', 'phase4OverseasRelabel']) {
|
||||
assert.ok(source.includes(marker), marker)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
|
||||
const PNG = Uint8Array.from(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64'))
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function longId(value) { return /^\d+$/.test(String(value || '')) && String(value) !== '0' }
|
||||
function nonBlank(value) { return typeof value === 'string' && value.trim() !== '' }
|
||||
function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' }
|
||||
function conflict(response) {
|
||||
@@ -86,51 +87,38 @@ async function expectConflict(context, token, method, name, path, body) {
|
||||
}
|
||||
|
||||
async function resolvePallet(context, token, palletCode, name = '扫码解析托盘') {
|
||||
return request(context, token, 'POST', name,
|
||||
return request(context, token, 'POST', `${name}(${palletCode})`,
|
||||
'/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: palletCode })
|
||||
}
|
||||
|
||||
async function createLocation(context, warehouseId, suffix) {
|
||||
const locationCode = `PDAOPS-${context.marker}-${suffix}`.replace(/[^A-Za-z0-9_-]/g, '').slice(-64)
|
||||
const response = await context.requestOverseas('POST',
|
||||
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, {
|
||||
zoneCode: 'PDAOPS', locationCode, locationType: 'STORAGE', maxLoadKg: 5000,
|
||||
status: 'IDLE', remark: `${context.marker}-pda-operations-${suffix}`,
|
||||
})
|
||||
const locationId = id(response?.body?.data)
|
||||
context.addStep(MODULE, `创建${suffix}目标库位`, response,
|
||||
success(response) && /^\d+$/.test(locationId) && locationId !== '0',
|
||||
`locationId=${locationId || 'missing'}, locationCode=${locationCode}; ${trace(response)}`, OPTIONS)
|
||||
return success(response) ? { locationId, locationCode } : null
|
||||
async function resolvePublicLocation(context, fixture) {
|
||||
const response = await context.requestOverseas('GET',
|
||||
`/api/platform/v1/warehouse-admin/${encodeURIComponent(fixture.overseasWarehouseId)}/locations?page=1&size=200`)
|
||||
const location = records(response?.body?.data).find((item) => item?.locationType === 'VIRTUAL'
|
||||
&& item?.status === 'ACTIVE')
|
||||
context.addStep(MODULE, '复用当前海外仓公共库位', response,
|
||||
success(response) && longId(id(location?.id)) && nonBlank(location?.locationCode),
|
||||
`locationId=${id(location?.id) || 'missing'}, locationCode=${location?.locationCode || 'missing'}; ${trace(response)}`, OPTIONS)
|
||||
return success(response) && location
|
||||
? { locationId: id(location.id), locationCode: location.locationCode } : null
|
||||
}
|
||||
|
||||
export function auxiliaryPalletFbaWarehouseCode(destinationType, fbaWarehouseCode) {
|
||||
if (destinationType === 'OVERSEAS') return null
|
||||
if (destinationType === 'FBA' && nonBlank(fbaWarehouseCode)) return fbaWarehouseCode.trim()
|
||||
return null
|
||||
export function remainingRelabelItem(detail, usedItemIds) {
|
||||
const used = new Set(usedItemIds.map(id))
|
||||
return (detail?.items || []).find((item) => id(item?.itemId) && !used.has(id(item.itemId))) || null
|
||||
}
|
||||
|
||||
async function createEmptyPallet(context, token, fixture, suffix) {
|
||||
const fbaWarehouseCode = auxiliaryPalletFbaWarehouseCode(
|
||||
fixture.destinationType, fixture.fbaWarehouseCode)
|
||||
if (fixture.destinationType === 'FBA' && !fbaWarehouseCode) {
|
||||
blocker(context, `${suffix}辅助空托盘`,
|
||||
`当前FBA入仓码 ${fixture.inboundCode || 'missing'} 缺少权威fbaWarehouseCode`)
|
||||
return null
|
||||
async function resolveReusableAuxiliaryPallet(context, token, fixture) {
|
||||
for (const candidate of fixture.auxiliaryPallets || []) {
|
||||
if (!nonBlank(candidate?.palletCode) || id(candidate?.palletId) === id(fixture.pallet?.palletId)) continue
|
||||
const resolved = await resolvePallet(context, token, candidate.palletCode, '解析入仓计划已有空托盘')
|
||||
if (resolved.data && Array.isArray(resolved.data.inventory) && resolved.data.inventory.length === 0) {
|
||||
context.assert(MODULE, '辅助托盘只复用本轮入仓计划已有空托盘', resolved.response, true,
|
||||
`palletCode=${resolved.data.palletCode}; ${trace(resolved.response)}`, OPTIONS)
|
||||
return resolved.data
|
||||
}
|
||||
}
|
||||
const location = await createLocation(context, fixture.overseasWarehouseId, `${suffix}-PALLET`)
|
||||
if (!location) return null
|
||||
const palletCode = `PDAOPS-${context.marker}-${suffix}`.replace(/[^A-Za-z0-9_-]/g, '').slice(-120)
|
||||
const response = await context.requestOverseas('POST',
|
||||
`/api/platform/v1/warehouse-admin/${encodeURIComponent(fixture.overseasWarehouseId)}/pallets`, {
|
||||
palletCode, palletType: '木托盘', capacityCbm: 10, unitPrice: 0, status: 'IDLE',
|
||||
locationId: location.locationId,
|
||||
fbaWarehouseCode,
|
||||
remark: `${context.marker}-pda-operations-${suffix}`,
|
||||
})
|
||||
context.addStep(MODULE, `创建${suffix}辅助空托盘`, response, success(response), trace(response), OPTIONS)
|
||||
if (!success(response)) return null
|
||||
return (await resolvePallet(context, token, palletCode, `解析${suffix}辅助空托盘`)).data
|
||||
return null
|
||||
}
|
||||
|
||||
async function verifyStocktake(context, token, fixture) {
|
||||
@@ -201,7 +189,7 @@ async function verifyStocktake(context, token, fixture) {
|
||||
}
|
||||
|
||||
async function verifyInventoryMove(context, token, fixture) {
|
||||
const target = await createLocation(context, fixture.overseasWarehouseId, 'MOVE')
|
||||
const target = await resolvePublicLocation(context, fixture)
|
||||
if (!target) return false
|
||||
let pallet = (await resolvePallet(context, token, fixture.pallet.palletCode, 'P3移库前解析整托')).data
|
||||
if (!pallet || !nonBlank(pallet.locationCode)) {
|
||||
@@ -209,6 +197,14 @@ async function verifyInventoryMove(context, token, fixture) {
|
||||
return false
|
||||
}
|
||||
const originalLocationCode = pallet.locationCode
|
||||
if (originalLocationCode === target.locationCode) {
|
||||
const availablePackages = (pallet.inventory || []).reduce((sum, item) =>
|
||||
sum + Number(item?.availablePackages || 0), 0)
|
||||
context.assert(MODULE, 'P3托盘已在公共库位不重复移库', null,
|
||||
availablePackages > 0,
|
||||
`palletCode=${pallet.palletCode}, locationCode=${target.locationCode}, availablePackages=${availablePackages}`, OPTIONS)
|
||||
return availablePackages > 0
|
||||
}
|
||||
const path = '/api/pda/v1/last-leg/inventory-moves'
|
||||
const body = { sourcePalletCode: pallet.palletCode, targetLocationCode: target.locationCode,
|
||||
expectedPalletVersion: Number(pallet.palletVersion), idempotencyKey: key(context, 'MOVE-FORWARD'),
|
||||
@@ -258,23 +254,21 @@ async function verifyPalletOperations(context, token, fixture) {
|
||||
'主托盘只有一箱可用库存,解绑会清空唯一业务托盘;必须由本轮生产矩阵提供至少两箱')
|
||||
return false
|
||||
}
|
||||
const swapTarget = await createEmptyPallet(context, token, fixture, 'SWAP')
|
||||
const splitTarget = await createEmptyPallet(context, token, fixture, 'SPLIT')
|
||||
const unbindTarget = await createEmptyPallet(context, token, fixture, 'UNBIND')
|
||||
if (!swapTarget || !splitTarget || !unbindTarget) {
|
||||
blocker(context, 'P4辅助实物托盘', '无法创建换托、拆托和解绑所需辅助托盘')
|
||||
let auxiliary = await resolveReusableAuxiliaryPallet(context, token, fixture)
|
||||
if (!auxiliary) {
|
||||
blocker(context, 'P4辅助实物托盘', '本轮入仓计划没有可复用的已有空托盘')
|
||||
return false
|
||||
}
|
||||
|
||||
const swapBody = { idempotencyKey: key(context, 'PALLET-SWAP'),
|
||||
source: { palletCode: main.palletCode, expectedVersion: Number(main.palletVersion) },
|
||||
target: { palletCode: swapTarget.palletCode, expectedVersion: Number(swapTarget.palletVersion) },
|
||||
target: { palletCode: auxiliary.palletCode, expectedVersion: Number(auxiliary.palletVersion) },
|
||||
reason: `${context.marker}-swap` }
|
||||
const swapped = await replayOperation(context, token, 'P4整托换托', `${base}/swap`, swapBody, 'PALLET_SWAP')
|
||||
await expectConflict(context, token, 'POST', 'P4换托同键异载荷A0409', `${base}/swap`,
|
||||
{ ...swapBody, reason: `${swapBody.reason}-DIFFERENT` })
|
||||
let occupiedSwap = operationPallet(swapped.data, swapTarget.palletCode)
|
||||
|| (await resolvePallet(context, token, swapTarget.palletCode, 'P4换托后解析目标托盘')).data
|
||||
let occupiedSwap = operationPallet(swapped.data, auxiliary.palletCode)
|
||||
|| (await resolvePallet(context, token, auxiliary.palletCode, 'P4换托后解析目标托盘')).data
|
||||
let emptyMain = operationPallet(swapped.data, main.palletCode)
|
||||
|| (await resolvePallet(context, token, main.palletCode, 'P4换托后解析原托盘')).data
|
||||
await expectConflict(context, token, 'POST', 'P4换托旧托盘版本A0409', `${base}/swap`, {
|
||||
@@ -288,19 +282,20 @@ async function verifyPalletOperations(context, token, fixture) {
|
||||
await replayOperation(context, token, 'P4换托恢复主托盘', `${base}/swap`, swapBackBody, 'PALLET_SWAP')
|
||||
|
||||
main = (await resolvePallet(context, token, main.palletCode, 'P4拆托前刷新主托盘')).data
|
||||
auxiliary = (await resolvePallet(context, token, auxiliary.palletCode, 'P4拆托前刷新已有空托盘')).data
|
||||
const sourceLine = main.inventory.find((line) => Number(line.availablePackages) > 1)
|
||||
|| main.inventory.find((line) => Number(line.availablePackages) > 0)
|
||||
const onePackage = 1
|
||||
const disassembleBody = { idempotencyKey: key(context, 'PALLET-DISASSEMBLE'),
|
||||
source: { palletCode: main.palletCode, expectedVersion: Number(main.palletVersion) },
|
||||
targets: [{ target: { palletCode: splitTarget.palletCode, expectedVersion: Number(splitTarget.palletVersion) },
|
||||
targets: [{ target: { palletCode: auxiliary.palletCode, expectedVersion: Number(auxiliary.palletVersion) },
|
||||
lines: [{ positionId: id(sourceLine.positionId), packages: onePackage,
|
||||
expectedPositionVersion: Number(sourceLine.positionVersion) }] }],
|
||||
reason: `${context.marker}-disassemble` }
|
||||
const disassembled = await replayOperation(context, token, 'P4拆托一箱到辅助托盘',
|
||||
`${base}/disassemble`, disassembleBody, 'PALLET_DISASSEMBLE')
|
||||
const splitOccupied = operationPallet(disassembled.data, splitTarget.palletCode)
|
||||
|| (await resolvePallet(context, token, splitTarget.palletCode, 'P4拆托后解析辅助托盘')).data
|
||||
const splitOccupied = operationPallet(disassembled.data, auxiliary.palletCode)
|
||||
|| (await resolvePallet(context, token, auxiliary.palletCode, 'P4拆托后解析辅助托盘')).data
|
||||
const mainAfterSplit = operationPallet(disassembled.data, main.palletCode)
|
||||
|| (await resolvePallet(context, token, main.palletCode, 'P4拆托后解析主托盘')).data
|
||||
const splitLine = splitOccupied.inventory.find((line) => Number(line.availablePackages) > 0)
|
||||
@@ -313,18 +308,19 @@ async function verifyPalletOperations(context, token, fixture) {
|
||||
await replayOperation(context, token, 'P4合托恢复主托盘', `${base}/assemble`, assembleBody, 'PALLET_ASSEMBLE')
|
||||
|
||||
main = (await resolvePallet(context, token, main.palletCode, 'P4解绑前刷新主托盘')).data
|
||||
auxiliary = (await resolvePallet(context, token, auxiliary.palletCode, 'P4解绑前刷新已有空托盘')).data
|
||||
const unbindSource = main.inventory.find((line) => Number(line.availablePackages) > 1)
|
||||
|| main.inventory.find((line) => Number(line.availablePackages) > 0)
|
||||
const unbindPreparedBody = { idempotencyKey: key(context, 'PALLET-UNBIND-PREPARE'),
|
||||
source: { palletCode: main.palletCode, expectedVersion: Number(main.palletVersion) },
|
||||
targets: [{ target: { palletCode: unbindTarget.palletCode, expectedVersion: Number(unbindTarget.palletVersion) },
|
||||
targets: [{ target: { palletCode: auxiliary.palletCode, expectedVersion: Number(auxiliary.palletVersion) },
|
||||
lines: [{ positionId: id(unbindSource.positionId), packages: 1,
|
||||
expectedPositionVersion: Number(unbindSource.positionVersion) }] }],
|
||||
reason: `${context.marker}-unbind-prepare` }
|
||||
const prepared = await replayOperation(context, token, 'P4拆出一箱作为解绑样本',
|
||||
`${base}/disassemble`, unbindPreparedBody, 'PALLET_DISASSEMBLE')
|
||||
const boundAuxiliary = operationPallet(prepared.data, unbindTarget.palletCode)
|
||||
|| (await resolvePallet(context, token, unbindTarget.palletCode, 'P4解析解绑样本托盘')).data
|
||||
const boundAuxiliary = operationPallet(prepared.data, auxiliary.palletCode)
|
||||
|| (await resolvePallet(context, token, auxiliary.palletCode, 'P4解析解绑样本托盘')).data
|
||||
const unbindBody = { idempotencyKey: key(context, 'PALLET-UNBIND'),
|
||||
source: { palletCode: boundAuxiliary.palletCode, expectedVersion: Number(boundAuxiliary.palletVersion) },
|
||||
reason: `${context.marker}-unbind` }
|
||||
@@ -337,6 +333,27 @@ async function verifyPalletOperations(context, token, fixture) {
|
||||
return success(unbound.response)
|
||||
}
|
||||
|
||||
export function selectPhysicalRelabelSources(candidates, preferredBalanceId) {
|
||||
const ordered = [...candidates].sort((a, b) =>
|
||||
Number(id(b.inventoryBalanceId) === id(preferredBalanceId)) - Number(id(a.inventoryBalanceId) === id(preferredBalanceId)))
|
||||
const sourceLines = []
|
||||
const seen = new Set()
|
||||
for (const source of ordered) {
|
||||
if (!id(source?.inventoryBalanceId) || !id(source?.inboundOrderId)) continue
|
||||
for (const position of source.positions || []) {
|
||||
const positionId = id(position?.positionId)
|
||||
if (!positionId || seen.has(positionId) || !nonBlank(position?.palletNo)
|
||||
|| position.palletNo.toUpperCase() === 'UNBOUND') continue
|
||||
seen.add(positionId)
|
||||
const selected = Math.min(3 - sourceLines.length, Number(position.availablePackages))
|
||||
if (!Number.isInteger(selected) || selected <= 0) continue
|
||||
for (let index = 0; index < selected; index++) sourceLines.push({ source, position, packageCount: 1 })
|
||||
if (sourceLines.length === 3) return sourceLines
|
||||
}
|
||||
}
|
||||
return sourceLines
|
||||
}
|
||||
|
||||
async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
const customerId = id(fixture.order?.order?.customerId || context.createdResources?.phase4OverseasPda?.customerId)
|
||||
const customerOrderNo = fixture.order?.order?.orderNo || fixture.order?.order?.sourceOrderNo
|
||||
@@ -347,33 +364,27 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
return false
|
||||
}
|
||||
if (!await fundRelabelWallet(context, customerId)) return false
|
||||
const inventoryCandidatesResponse = await context.requestOverseas('GET', query(
|
||||
'/api/merchant/v1/last-leg/relabel-applications/inventory-candidates', {
|
||||
operationWarehouseId: fixture.overseasWarehouseId, inboundCode: fixture.inboundCode,
|
||||
}), undefined, {}, merchantToken)
|
||||
const inventoryCandidates = success(inventoryCandidatesResponse)
|
||||
&& Array.isArray(inventoryCandidatesResponse.body?.data) ? inventoryCandidatesResponse.body.data : []
|
||||
const orderedCandidates = [...inventoryCandidates].sort((left, right) => {
|
||||
const leftPreferred = id(left?.inventoryBalanceId) === id(inventory?.balanceId) ? 1 : 0
|
||||
const rightPreferred = id(right?.inventoryBalanceId) === id(inventory?.balanceId) ? 1 : 0
|
||||
return rightPreferred - leftPreferred
|
||||
})
|
||||
const sourceLines = []
|
||||
let requiredPackages = 3
|
||||
for (const source of orderedCandidates) {
|
||||
if (!id(source?.inventoryBalanceId) || !id(source?.inboundOrderId)) continue
|
||||
for (const position of source?.positions || []) {
|
||||
const available = Number(position?.availablePackages)
|
||||
if (!id(position?.positionId) || available <= 0) continue
|
||||
const selected = Math.min(requiredPackages, available)
|
||||
for (let packageIndex = 0; packageIndex < selected; packageIndex += 1) {
|
||||
sourceLines.push({ source, position, packageCount: 1 })
|
||||
}
|
||||
requiredPackages -= selected
|
||||
if (requiredPackages === 0) break
|
||||
}
|
||||
if (requiredPackages === 0) break
|
||||
// P4 intentionally leaves one carton UNBOUND. P5 requires physical source pallets,
|
||||
// and may combine only the current completed container's same-destination inbounds.
|
||||
const inboundCodes = [...new Set((context.context.phase4OverseasPdaOperations?.completedFixtures || [])
|
||||
.filter((item) => item.containerNo === fixture.containerNo && item.destinationType === fixture.destinationType
|
||||
&& item.fbaWarehouseCode === fixture.fbaWarehouseCode)
|
||||
.map((item) => item.inboundCode).filter(nonBlank))]
|
||||
const inventoryCandidates = []
|
||||
let inventoryCandidatesResponse = null
|
||||
for (const inboundCode of inboundCodes) {
|
||||
inventoryCandidatesResponse = await context.requestOverseas('GET', query(
|
||||
'/api/merchant/v1/last-leg/relabel-applications/inventory-candidates', {
|
||||
operationWarehouseId: fixture.overseasWarehouseId, inboundCode,
|
||||
}), undefined, {}, merchantToken)
|
||||
context.addStep(MODULE, 'P5读取本轮同柜同目的仓换标候选', inventoryCandidatesResponse,
|
||||
success(inventoryCandidatesResponse), trace(inventoryCandidatesResponse), OPTIONS)
|
||||
if (!success(inventoryCandidatesResponse)) return false
|
||||
inventoryCandidates.push(...records(inventoryCandidatesResponse.body?.data).filter((item) =>
|
||||
item.inboundCode === inboundCode && id(item.overseasWarehouseId) === id(fixture.overseasWarehouseId)))
|
||||
}
|
||||
const sourceLines = selectPhysicalRelabelSources(inventoryCandidates, inventory.balanceId)
|
||||
const requiredPackages = 3 - sourceLines.length
|
||||
context.addStep(MODULE, 'P5客户库存候选返回换标权威来源', inventoryCandidatesResponse,
|
||||
requiredPackages === 0,
|
||||
`sourceLines=${sourceLines.length}, selectedPackages=${3 - requiredPackages}; ${trace(inventoryCandidatesResponse)}`, OPTIONS)
|
||||
@@ -382,6 +393,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
return false
|
||||
}
|
||||
const relabelBase = '/api/platform/v1/last-leg/relabel-applications'
|
||||
const merchantRelabelBase = '/api/merchant/v1/last-leg/relabel-applications'
|
||||
const pdaBase = '/api/pda/v1/last-leg/relabel-tasks'
|
||||
const markerSuffix = String(context.marker).slice(-8)
|
||||
const createBody = {
|
||||
@@ -460,8 +472,13 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
await expectConflict(context, token, 'POST', 'P5正常换标旧版本A0409', unitPath,
|
||||
{ ...scanBody, oldLabel: `${context.marker}-OLD-STALE`, idempotencyKey: key(context, 'RELABEL-STALE') })
|
||||
detail = scanned.data
|
||||
if (!detail) return false
|
||||
const skipItem = Array.isArray(detail?.items)
|
||||
? detail.items.find((candidate) => id(candidate?.itemId) && id(candidate.itemId) !== id(item.itemId)) : null
|
||||
if (!skipItem) {
|
||||
blocker(context, 'P5跳过换标明细', '缺少独立的第二箱换标任务', scanned.response)
|
||||
return false
|
||||
}
|
||||
const skipBody = { itemId: id(skipItem?.itemId), oldLabel: skipItem?.cartonMark,
|
||||
reason: `${context.marker}-skip`, expectedVersion: Number(detail.version),
|
||||
idempotencyKey: key(context, 'RELABEL-SKIP') }
|
||||
@@ -470,7 +487,12 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
context.assert(MODULE, 'P5跳过换标重放不重复计数', skipReplay.response,
|
||||
Number(skipReplay.data?.skippedPackages) === 1, trace(skipReplay.response), OPTIONS)
|
||||
detail = skipped.data
|
||||
const exceptionItem = item
|
||||
if (!detail) return false
|
||||
const exceptionItem = remainingRelabelItem(detail, [item.itemId, skipItem.itemId])
|
||||
if (!exceptionItem) {
|
||||
blocker(context, 'P5异常换标明细', '缺少未被正常换标或跳过的第三箱任务', skipped.response)
|
||||
return false
|
||||
}
|
||||
const exceptionBody = { itemId: id(exceptionItem?.itemId), oldLabel: null,
|
||||
exceptionCode: 'DAMAGED', reason: `${context.marker}-damaged`, expectedVersion: Number(detail.version),
|
||||
idempotencyKey: key(context, 'RELABEL-EXCEPTION') }
|
||||
@@ -481,6 +503,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
context.assert(MODULE, 'P5换标异常重放不重复计数', exceptionReplay.response,
|
||||
Number(exceptionReplay.data?.exceptionPackages) === 1, trace(exceptionReplay.response), OPTIONS)
|
||||
detail = exception.data
|
||||
if (!detail) return false
|
||||
|
||||
const photo = await context.requestOverseasMultipart('POST',
|
||||
`${pdaBase}/${encodeURIComponent(relabelNo)}/photos/files`, {
|
||||
@@ -524,7 +547,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
const confirmBody = { idempotencyKey: key(context, 'RELABEL-CUSTOMER-CONFIRM'),
|
||||
remark: `${context.marker}-customer-confirmed` }
|
||||
const confirmedResponse = await context.requestOverseas('POST',
|
||||
`${relabelBase}/${encodeURIComponent(relabelNo)}/confirm-completion`, confirmBody, {}, merchantToken)
|
||||
`${merchantRelabelBase}/${encodeURIComponent(relabelNo)}/confirm-completion`, confirmBody, {}, merchantToken)
|
||||
const confirmed = success(confirmedResponse) ? confirmedResponse.body?.data : null
|
||||
context.addStep(MODULE, 'P5客户确认换标完成进入待排车', confirmedResponse,
|
||||
confirmed?.application?.status === 'READY_FOR_OUTBOUND'
|
||||
@@ -644,7 +667,25 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
|
||||
relabelCartonMarks: movedFacts.map((fact) => fact.fbaCartonMark).filter(nonBlank),
|
||||
sourceCartonMarks: movedFacts.map((fact) => fact.cartonMark), status: 'READY_FOR_OUTBOUND',
|
||||
}
|
||||
return true
|
||||
return success(approved.response)
|
||||
}
|
||||
|
||||
export async function settleRelabelFulfillment(context, relabel) {
|
||||
const relabelNo = relabel.relabelNo
|
||||
const relabelBase = '/api/platform/v1/last-leg/relabel-applications'
|
||||
const terminal = await context.requestOverseas('PUT',
|
||||
`${relabelBase}/${encodeURIComponent(relabelNo)}/status`, {
|
||||
status: 'COMPLETED', remark: `${context.marker}-fulfillment-complete`,
|
||||
})
|
||||
context.addStep(MODULE, 'P5待出库换标单完成履约', terminal,
|
||||
success(terminal) && terminal.body?.data?.application?.status === 'COMPLETED', trace(terminal), OPTIONS)
|
||||
const settled = await poll(async () => context.requestOverseas('GET', `${relabelBase}/${encodeURIComponent(relabelNo)}`),
|
||||
(response) => success(response) && response.body?.data?.application?.walletStatus === 'SETTLED')
|
||||
context.addStep(MODULE, 'P5换标完成后钱包结算收敛', settled.result,
|
||||
settled.result?.body?.data?.application?.walletStatus === 'SETTLED',
|
||||
`pollRequests=${settled.attempts}, walletStatus=${settled.result?.body?.data?.application?.walletStatus || 'missing'}; ${trace(settled.result)}`, OPTIONS)
|
||||
relabel.status = terminal.body?.data?.application?.status || relabel.status
|
||||
return success(terminal) && settled.result?.body?.data?.application?.walletStatus === 'SETTLED'
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
|
||||
@@ -10,6 +10,10 @@ const PNG = Uint8Array.from(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function longId(value) { return /^\d+$/.test(id(value)) && id(value) !== '0' }
|
||||
function nonBlank(value) { return typeof value === 'string' && value.trim().length > 0 }
|
||||
export function uniqueContainerProducer(item, index, all) {
|
||||
return nonBlank(item?.containerNo)
|
||||
&& all.findIndex((candidate) => candidate?.containerNo === item.containerNo) === index
|
||||
}
|
||||
function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' }
|
||||
function code(response) { return String(response?.body?.code ?? '') }
|
||||
function traced(response) { return nonBlank(response?.requestId) && nonBlank(response?.traceId) }
|
||||
@@ -897,9 +901,9 @@ export async function run(context) {
|
||||
const standardFba = matrix?.byDestinationType?.FBA?.find((item) => item?.fulfillmentType !== 'ONE_CLICK_DROPSHIP')
|
||||
// The sealed dropship pallet proves the indivisible handover. A separate standard FBA
|
||||
// fixture supplies multiple cartons for reversible split/merge/unbind and relabel checks.
|
||||
const producers = [dropship || matrix?.byDestinationType?.FBA?.[0], standardFba,
|
||||
const producers = [dropship, standardFba,
|
||||
matrix?.byDestinationType?.OVERSEAS?.[0]]
|
||||
.filter((item) => nonBlank(item?.containerNo))
|
||||
.filter(uniqueContainerProducer)
|
||||
const allProducers = Array.isArray(matrix?.containers) ? matrix.containers : producers
|
||||
if (allProducers.length) await awaitProducerConsumption(context, allProducers)
|
||||
if (producers.length && !(await convertReceivingContainers(context, producers))) {
|
||||
|
||||
@@ -290,10 +290,33 @@ export async function run(context) {
|
||||
if (success(merged.response)) {
|
||||
context.assert(MODULE, '退货合箱生成含两SKU和箱规的可打印新箱', merged.response,
|
||||
nonBlank(merged.data?.cartonMark) && Number(merged.data?.totalQuantity) === 2
|
||||
&& merged.data?.label?.sourceBatchNo === 'MERGED'
|
||||
&& nonBlank(merged.data?.label?.sourceBatchNo) && merged.data?.label?.sourceBatchNo !== 'MERGED'
|
||||
&& merged.data?.label?.sourceReturnNo === returnNo
|
||||
&& Number(merged.data?.label?.items?.length) === 2
|
||||
&& nonBlank(merged.data?.label?.printableHtml),
|
||||
`cartonMark=${merged.data?.cartonMark || 'missing'}`, OPTIONS)
|
||||
const consumedBatches = []
|
||||
for (const [mark, batch, before] of [[cartonA, firstBatchNo, 2], [cartonB, secondBatchNo, 3]]) {
|
||||
const source = await overseas(context, merchantToken, 'GET', '合箱后回查来源箱实际扣减',
|
||||
`${RETURN_INVENTORY}/cartons/${encodeURIComponent(mark)}`)
|
||||
if (success(source.response) && Number(source.data?.totalQuantity) < before) consumedBatches.push(batch)
|
||||
}
|
||||
const actualBatches = String(merged.data?.label?.sourceBatchNo || '').split(',')
|
||||
context.assert(MODULE, '合箱来源批次精确等于实际消耗来源且去重', merged.response,
|
||||
consumedBatches.length > 0 && new Set(actualBatches).size === actualBatches.length
|
||||
&& JSON.stringify([...consumedBatches].sort()) === JSON.stringify([...actualBatches].sort()),
|
||||
`expected=${consumedBatches.join(',')}, actual=${actualBatches.join(',')}`, OPTIONS)
|
||||
const cartonPage = await overseas(context, merchantToken, 'GET', '客户箱号管理按实物箱分页',
|
||||
query(`${RETURN_INVENTORY}/cartons`, { page: 1, size: 20, returnNo }))
|
||||
context.assert(MODULE, '箱号分页包含本轮合箱且来源退货单保持真实业务号', cartonPage.response,
|
||||
records(cartonPage.data).some(row => row.cartonMark === merged.data.cartonMark
|
||||
&& row.sourceReturnNo === returnNo && Number(row.totalQuantity) === 2), trace(cartonPage.response), OPTIONS)
|
||||
const label = await overseas(context, merchantToken, 'GET', '客户读取本轮合箱条码标签',
|
||||
`${RETURN_INVENTORY}/cartons/${encodeURIComponent(merged.data.cartonMark)}/label`)
|
||||
context.assert(MODULE, '标签包含SVG条码和当前箱号', label.response,
|
||||
label.data?.cartonMark === merged.data.cartonMark
|
||||
&& /<svg\b/.test(label.data?.printableHtml || '')
|
||||
&& label.data?.printableHtml?.includes(merged.data.cartonMark), trace(label.response), OPTIONS)
|
||||
const mergeReplay = await overseas(context, picker.token, 'POST', '退货合箱同键同载荷重放',
|
||||
'/api/pda/v1/last-leg/return-inventory/cartons/merge', mergeBody)
|
||||
context.assert(MODULE, '合箱重放复用同一新箱且不重复扣减', mergeReplay.response,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import test from 'node:test'
|
||||
import { relabelOutboundMarksMatch } from './phase5-overseas-fulfillment.mjs'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
@@ -84,3 +85,14 @@ test('phase-five writes carry idempotency and record deployment blockers instead
|
||||
assert.match(source, /addCoverageGap\(MODULE, SCENARIO/)
|
||||
assert.match(source, /deploymentBlocker: true/)
|
||||
})
|
||||
test('relabel marks are checked on the authoritative outbound DTO without weakening identity checks', () => {
|
||||
const relabel = { relabelCartonMarks: ['NEW-1', 'NEW-2'], sourceCartonMarks: ['OLD-1', 'OLD-2'] }
|
||||
const detail = { outbound: { packageCount: 2 }, pallets: [{ cartonMarks: 'NEW-1,NEW-2' }] }
|
||||
assert.equal(relabelOutboundMarksMatch(detail, relabel), true)
|
||||
for (const cartonMarks of ['', 'NEW-1', 'NEW-1,NEW-1', 'NEW-1,OLD-2', 'NEW-1,NEW-2,OTHER']) {
|
||||
assert.equal(relabelOutboundMarksMatch({ ...detail, pallets: [{ cartonMarks }] }, relabel), false)
|
||||
}
|
||||
assert.equal(relabelOutboundMarksMatch({ ...detail, outbound: { packageCount: 3 } }, relabel), false)
|
||||
assert.match(source, /detail\.data\?\.outbound\?\.dispatchNo === relabel\.dispatchNo/)
|
||||
assert.match(source, /relabel\?\.marker !== context\.marker/)
|
||||
})
|
||||
|
||||
@@ -48,6 +48,35 @@ async function merchant(context, token, method, name, path, body = undefined) {
|
||||
return { response, data }
|
||||
}
|
||||
|
||||
export function relabelOutboundMarksMatch(detail, relabel) {
|
||||
const marks = (detail?.pallets || []).flatMap(item => String(item.cartonMarks || '')
|
||||
.split(',').map(mark => mark.trim()).filter(Boolean))
|
||||
const expected = relabel?.relabelCartonMarks || []
|
||||
return expected.length > 0 && marks.length === expected.length
|
||||
&& new Set(marks).size === marks.length && expected.every(mark => marks.includes(mark))
|
||||
&& (relabel.sourceCartonMarks || []).every(mark => !marks.includes(mark))
|
||||
&& Number(detail?.outbound?.packageCount) === expected.length
|
||||
}
|
||||
|
||||
async function verifyRelabelDispatchOutbound(context, relabel, transferBody) {
|
||||
if (relabel?.marker !== context.marker || !nonBlank(relabel?.dispatchNo)) {
|
||||
blocker(context, '换标排车箱唛传递前置', '缺少本轮换标排车,不借用历史数据')
|
||||
return
|
||||
}
|
||||
const transferred = await platform(context, 'POST', '本轮换标排车转派送并生成出库快照',
|
||||
`${DISPATCH_BASE}/${encodeURIComponent(relabel.dispatchNo)}/transfer-delivery`,
|
||||
{ ...transferBody, idempotencyKey: key(context, 'RELABEL-TRANSFER') })
|
||||
const outboundNo = transferred.data?.outboundNo
|
||||
if (!success(transferred.response) || !nonBlank(outboundNo)) return
|
||||
const detail = await platform(context, 'GET', '回查换标排车的真实出库箱唛',
|
||||
query(`${LASTLEG_BASE}/outbound-orders/${encodeURIComponent(outboundNo)}`, { page: 1, size: 200 }))
|
||||
context.assert(MODULE, '排车出库快照精确保留全部新箱唛且无原箱唛或重复箱', detail.response,
|
||||
success(detail.response) && detail.data?.outbound?.outboundNo === outboundNo
|
||||
&& detail.data?.outbound?.dispatchNo === relabel.dispatchNo
|
||||
&& relabelOutboundMarksMatch(detail.data, relabel), trace(detail.response), OPTIONS)
|
||||
Object.assign(relabel, { outboundNo, deliveryNo: transferred.data?.deliveryNo })
|
||||
}
|
||||
|
||||
async function loginMerchant(context, customerId) {
|
||||
const merchantFixture = context.context?.merchantQualificationFixture
|
||||
const credentials = nonBlank(merchantFixture?.username) && nonBlank(merchantFixture?.password)
|
||||
@@ -172,6 +201,11 @@ export async function run(context) {
|
||||
trace(updated.response), OPTIONS)
|
||||
if (!success(updated.response)) return
|
||||
|
||||
const bol = await context.requestOverseasBinary('GET', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/prints/bol`)
|
||||
context.addStep(MODULE, '排车BOL下载为真实PDF而非JSON错误', bol,
|
||||
bol.http === 200 && /application\/pdf/i.test(bol.contentType || '')
|
||||
&& new TextDecoder().decode(bol.bytes?.slice(0, 5)) === '%PDF-', trace(bol), OPTIONS)
|
||||
|
||||
const transferBody = {
|
||||
idempotencyKey: key(context, 'TRANSFER'), plannedDeliveryAt: new Date(Date.now() + 172_800_000).toISOString(),
|
||||
deliveryMethod: 'TRUCK_PALLET', waybillNo: `${context.marker}-WAYBILL`.slice(0, 64),
|
||||
@@ -182,6 +216,7 @@ export async function run(context) {
|
||||
fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费', direction: 'RECEIVABLE',
|
||||
currency: 'USD', amount: 10, remark: `${context.marker}-acceptance` }],
|
||||
}
|
||||
await verifyRelabelDispatchOutbound(context, context.createdResources?.phase4OverseasRelabel, transferBody)
|
||||
const transferred = await merchant(context, merchantToken, 'POST', '客户创建派送并原子转出库单',
|
||||
query(MERCHANT_DELIVERY_BASE, { dispatchNo }), transferBody)
|
||||
const outboundNo = id(transferred.data?.outboundNo)
|
||||
@@ -192,6 +227,21 @@ export async function run(context) {
|
||||
`outboundNo=${outboundNo || 'missing'}, deliveryNo=${deliveryNo || 'missing'}; ${trace(transferred.response)}`, OPTIONS)
|
||||
if (!success(transferred.response) || !outboundNo || !deliveryNo) return
|
||||
Object.assign(context.createdResources.phase5OverseasFulfillment, { outboundNo, deliveryNo })
|
||||
const adminReplay = await platform(context, 'POST', '后台转派送复用客户已创建的同一出库派送单',
|
||||
`${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/transfer-delivery`, transferBody)
|
||||
context.assert(MODULE, '后台与客户转派送共享同一业务事实', adminReplay.response,
|
||||
adminReplay.data?.replayed === true && id(adminReplay.data?.outboundNo) === outboundNo
|
||||
&& id(adminReplay.data?.deliveryNo) === deliveryNo, trace(adminReplay.response), OPTIONS)
|
||||
const lockedItem = updated.data?.items?.[0]
|
||||
if (lockedItem?.id) {
|
||||
const deniedRemove = await context.requestOverseas('DELETE',
|
||||
`${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(lockedItem.id))}`)
|
||||
context.addStep(MODULE, '已生成出库单禁止移除排车托盘', deniedRemove, conflict(deniedRemove), trace(deniedRemove), OPTIONS)
|
||||
const stable = await platform(context, 'GET', '移除拒绝后回查排车托盘零漂移', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
|
||||
context.assert(MODULE, '移除失败不改变箱数和托盘身份', stable.response,
|
||||
Number(stable.data?.packageCount) === Number(updated.data?.packageCount)
|
||||
&& JSON.stringify(stable.data?.items?.map(item => id(item.id))) === JSON.stringify(updated.data?.items?.map(item => id(item.id))), trace(stable.response), OPTIONS)
|
||||
} else blocker(context, '转派送后移除门禁前置', '本轮排车缺少真实明细ID')
|
||||
const transferReplay = await merchant(context, merchantToken, 'POST', '客户创建派送同键重放',
|
||||
query(MERCHANT_DELIVERY_BASE, { dispatchNo }), transferBody)
|
||||
context.assert(MODULE, '转派送重放复用同一业务单据', transferReplay.response,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
const PLATFORM = '/api/platform/v1/last-leg/warehouse-transfers'
|
||||
const MERCHANT = '/api/merchant/v1/last-leg/warehouse-transfers'
|
||||
const MODULE = 'phase4-overseas-client-services'
|
||||
const OPTIONS = { scenario: 'warehouse-transfer-review', deploymentBlocker: true }
|
||||
const ok = r => r?.http === 200 && String(r.body?.code) === '200'
|
||||
const rejected = (r, status) => r?.http === status || (r?.http === 200 && String(r.body?.code) === `A0${status}`)
|
||||
const evidence = r => `HTTP=${r?.http}, code=${r?.body?.code}, requestId=${r?.requestId}, traceId=${r?.traceId}`
|
||||
|
||||
// Called by the registered client-services module, never a standalone fixture runner.
|
||||
export async function verifyWarehouseTransferReview(context, token, customerId, sourceBody, transferNo) {
|
||||
if (!customerId || String(customerId) === '0'
|
||||
|| context.context?.merchantQualificationFixture?.marker !== context.marker) {
|
||||
context.addStep(MODULE, '转运审核缺少本轮客户作用域', null, false, 'No historical fallback', OPTIONS)
|
||||
return false
|
||||
}
|
||||
const scoped = no => `${PLATFORM}/${encodeURIComponent(no)}?customerId=${encodeURIComponent(customerId)}`
|
||||
const reviewPath = no => `${PLATFORM}/${encodeURIComponent(no)}/review?customerId=${encodeURIComponent(customerId)}`
|
||||
async function call(method, path, body, merchantToken) {
|
||||
return context.requestOverseas(method, path, body, {}, merchantToken)
|
||||
}
|
||||
const initial = await call('GET', scoped(transferNo))
|
||||
context.addStep(MODULE, '平台转运详情按客户读取待审核单', initial,
|
||||
ok(initial) && initial.body.data?.status === 'PENDING_REVIEW', evidence(initial), OPTIONS)
|
||||
if (!ok(initial)) return false
|
||||
const denied = await call('POST', reviewPath(transferNo), { decision: 'APPROVE' }, token)
|
||||
context.addStep(MODULE, '客户Token没有平台专用转运审核权限', denied, rejected(denied, 403), evidence(denied), OPTIONS)
|
||||
const missingScope = await call('POST', `${PLATFORM}/${encodeURIComponent(transferNo)}/review`, { decision: 'APPROVE' })
|
||||
context.addStep(MODULE, '平台转运审核缺客户作用域拒绝', missingScope, rejected(missingScope, 400), evidence(missingScope), OPTIONS)
|
||||
const invalid = await call('POST', reviewPath(transferNo), { decision: 'INVALID' })
|
||||
context.addStep(MODULE, '非法审核决定拒绝', invalid, rejected(invalid, 400), evidence(invalid), OPTIONS)
|
||||
const unchanged = await call('GET', scoped(transferNo))
|
||||
context.addStep(MODULE, '审核负向后状态和分配零漂移', unchanged,
|
||||
ok(unchanged) && unchanged.body.data?.status === 'PENDING_REVIEW'
|
||||
&& JSON.stringify(unchanged.body.data?.allocations) === JSON.stringify(initial.body.data?.allocations), evidence(unchanged), OPTIONS)
|
||||
|
||||
// Existing-stock approval must not be confused with inbound-transit PDA receipt.
|
||||
const approved = await call('POST', reviewPath(transferNo), { decision: 'APPROVE', remark: context.marker })
|
||||
context.addStep(MODULE, '已有库存转运通过为APPROVED且主单待分配', approved,
|
||||
ok(approved) && approved.body.data?.status === 'APPROVED'
|
||||
&& approved.body.data?.orderStatus === 'PENDING_ASSIGNMENT', evidence(approved), OPTIONS)
|
||||
if (!ok(approved)) return false
|
||||
const replay = await call('POST', reviewPath(transferNo), { decision: 'APPROVE', remark: context.marker })
|
||||
context.addStep(MODULE, '同决定重放不推进状态', replay,
|
||||
ok(replay) && replay.body.data?.status === 'APPROVED' && replay.body.data?.transferNo === transferNo, evidence(replay), OPTIONS)
|
||||
const reverse = await call('POST', reviewPath(transferNo), { decision: 'REJECT', remark: '反向审核应拒绝' })
|
||||
context.addStep(MODULE, '已通过转运不能反向驳回', reverse, rejected(reverse, 409), evidence(reverse), OPTIONS)
|
||||
const readback = await call('GET', `${MERCHANT}/${encodeURIComponent(transferNo)}`, undefined, token)
|
||||
context.addStep(MODULE, '客户回查审核通过且未产生出库', readback,
|
||||
ok(readback) && readback.body.data?.status === 'APPROVED' && readback.body.data?.actualShipAt == null
|
||||
&& Array.isArray(readback.body.data?.allocations) && readback.body.data.allocations.length === 0, evidence(readback), OPTIONS)
|
||||
|
||||
for (const decision of ['APPROVE', 'REJECT']) {
|
||||
const body = { ...sourceBody, inboundCode: null, sourceLastlegOrderIds: [],
|
||||
idempotencyKey: `${context.marker}-TRANSIT-${decision}`.slice(0, 255) }
|
||||
const created = await call('POST', MERCHANT, body, token)
|
||||
const no = created.body?.data?.transferNo
|
||||
context.addStep(MODULE, `创建本轮新到仓转运${decision}对照`, created,
|
||||
ok(created) && Boolean(no) && created.body.data?.status === 'PENDING_REVIEW', evidence(created), OPTIONS)
|
||||
if (!ok(created) || !no) return false
|
||||
const result = await call('POST', reviewPath(no), { decision, remark: `${context.marker}-审核原因` })
|
||||
const expected = decision === 'APPROVE' ? 'WAIT_PDA_RECEIVING' : 'REJECTED'
|
||||
const expectedOrder = decision === 'APPROVE' ? 'WAIT_DELIVERY' : 'CANCELLED'
|
||||
const detail = await call('GET', `${MERCHANT}/${encodeURIComponent(no)}`, undefined, token)
|
||||
context.addStep(MODULE, `新到仓转运${decision}回查${expected}`, detail,
|
||||
ok(result) && ok(detail) && detail.body.data?.status === expected
|
||||
&& detail.body.data?.orderType === 'WAREHOUSE_TRANSFER'
|
||||
&& detail.body.data?.orderStatus === expectedOrder
|
||||
&& (detail.body.data?.pallets || []).length === 0, evidence(detail), OPTIONS)
|
||||
context.createdResources.phase4OverseasClientServices[`transit${decision}`] = { transferNo: no, status: detail.body?.data?.status }
|
||||
}
|
||||
const reasonFixture = await call('POST', MERCHANT, { ...sourceBody, inboundCode: null, sourceLastlegOrderIds: [],
|
||||
idempotencyKey: `${context.marker}-TRANSIT-REASON`.slice(0, 255) }, token)
|
||||
const reasonNo = reasonFixture.body?.data?.transferNo
|
||||
context.addStep(MODULE, '创建空驳回原因独立反向票', reasonFixture, ok(reasonFixture) && Boolean(reasonNo), evidence(reasonFixture), OPTIONS)
|
||||
if (!ok(reasonFixture) || !reasonNo) return false
|
||||
const emptyReason = await call('POST', reviewPath(reasonNo), { decision: 'REJECT', remark: '' })
|
||||
context.addStep(MODULE, '空驳回原因应拒绝', emptyReason, rejected(emptyReason, 400), evidence(emptyReason), OPTIONS)
|
||||
const reasonReadback = await call('GET', scoped(reasonNo))
|
||||
context.addStep(MODULE, '空原因拒绝后仍待审核', reasonReadback,
|
||||
ok(reasonReadback) && reasonReadback.body.data?.status === 'PENDING_REVIEW', evidence(reasonReadback), OPTIONS)
|
||||
context.createdResources.phase4OverseasClientServices.reasonValidationTransferNo = reasonNo
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
|
||||
const source = await readFile(new URL('./client-test-env-acceptance.mjs', import.meta.url), 'utf8')
|
||||
|
||||
test('client registration distinguishes incomplete-registration replay from credential conflicts', () => {
|
||||
assert.match(source, /same credentials replay the incomplete registration/)
|
||||
assert.match(source, /\['accountId', 'tenantId', 'membershipId'\]/)
|
||||
assert.match(source, /String\(json\.data\[field\]\), String\(registration\.json\.data\[field\]\)/)
|
||||
assert.match(source, /duplicate username with different credentials is rejected/)
|
||||
assert.match(source, /body: \{ \.\.\.registrationBody, password: `Different/)
|
||||
})
|
||||
@@ -142,8 +142,20 @@ async function registrationModule() {
|
||||
report.resources.tenantId = registration.json.data.tenantId
|
||||
report.resources.membershipId = registration.json.data.membershipId
|
||||
|
||||
await apiStep('registration', 'duplicate username is rejected', {
|
||||
await apiStep('registration', 'same credentials replay the incomplete registration without creating another customer', {
|
||||
method: 'POST', path: '/api/auth/register', body: registrationBody,
|
||||
}, ({ json }) => {
|
||||
requireSuccess(json)
|
||||
for (const field of ['accountId', 'tenantId', 'membershipId']) {
|
||||
assertPresent(json.data?.[field], field)
|
||||
assertEqual(String(json.data[field]), String(registration.json.data[field]), `replayed ${field}`)
|
||||
}
|
||||
assertEqual(json.data?.tenantStatus, 'PROFILE_REQUIRED', 'replayed tenantStatus')
|
||||
})
|
||||
|
||||
await apiStep('registration', 'duplicate username with different credentials is rejected', {
|
||||
method: 'POST', path: '/api/auth/register',
|
||||
body: { ...registrationBody, password: `Different${randomUUID()}9!` },
|
||||
}, ({ json, status }) => {
|
||||
assertEqual(status, 200, 'HTTP status')
|
||||
assertEqual(String(json?.code), 'A0409', 'business code')
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"services": 15
|
||||
},
|
||||
"openapi": {
|
||||
"documents": 46,
|
||||
"documents": 48,
|
||||
"coveredServices": 10,
|
||||
"codeFirstServices": 5
|
||||
},
|
||||
|
||||
@@ -65,7 +65,7 @@ test('repository freezes the current topology and non-blocking aggregate summary
|
||||
const summary = await buildQualitySummary(repositoryRoot)
|
||||
const baseline = JSON.parse(await readFile(path.join(repositoryRoot, 'scripts', 'java-quality-baseline.json'), 'utf8'))
|
||||
assert.deepEqual(summary.topology, { modules: 22, codeModules: 21, services: 15 })
|
||||
assert.equal(summary.openapi.documents, 46)
|
||||
assert.equal(summary.openapi.documents, 48)
|
||||
assert.ok(summary.openapi.files.includes('file/src/main/resources/openapi/overseas-relabel-document-v1.yaml'))
|
||||
assert.ok(summary.openapi.files.includes('finance/src/main/resources/openapi/finance-container-settlement-v1.yaml'))
|
||||
assert.ok(summary.openapi.files.includes('warehouse-overseas-service/src/main/resources/openapi/warehouse-pallet-batch-internal-v1.yaml'))
|
||||
|
||||
@@ -15,6 +15,8 @@ const http = (path, source, target, contexts, access, idempotency, note) => ({
|
||||
})
|
||||
|
||||
export const SYNCHRONOUS_CLIENTS = Object.freeze([
|
||||
feign('lastleg-service/src/main/java/com/baiyue/lastleg/client/PrintPdfClient.java',
|
||||
'lastleg-service', 'print-service', ['REQUEST'], 'READ', 'SAFE_READ', '根据已授权排车快照生成 BOL PDF,内部令牌保护,无业务状态写入'),
|
||||
feign('auth/src/main/java/com/baiyue/auth/client/IamAuthorizationClient.java',
|
||||
'auth-service', 'iam-service', ['REQUEST'], 'READ', 'SAFE_READ', 'POST 读取授权快照,不写 IAM'),
|
||||
feign('auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java',
|
||||
|
||||
@@ -34,6 +34,7 @@ const expectedOnlineEdges = [
|
||||
'iam-service->auth-service',
|
||||
'iam-service->warehouse-service',
|
||||
'lastleg-service->file-service',
|
||||
'lastleg-service->print-service',
|
||||
'lastleg-service->warehouse-overseas-service',
|
||||
'lastleg-service->warehouse-service',
|
||||
'order-service->file-service',
|
||||
@@ -99,11 +100,11 @@ async function discoverFeignEdges() {
|
||||
return clients.sort((left, right) => left.path.localeCompare(right.path))
|
||||
}
|
||||
|
||||
test('all 57 Feign clients have reviewed context, access and idempotency metadata', async () => {
|
||||
test('all 58 Feign clients have reviewed context, access and idempotency metadata', async () => {
|
||||
const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'FEIGN')
|
||||
.map(({ path, source, target }) => ({ path, source, target }))
|
||||
.sort((left, right) => left.path.localeCompare(right.path))
|
||||
assert.equal(reviewed.length, 57)
|
||||
assert.equal(reviewed.length, 58)
|
||||
assert.deepEqual(await discoverFeignEdges(), reviewed)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user