diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 7187ceaa9..984c54f17 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -86,7 +86,7 @@ ## 国内货柜履约装柜与 PDA 资源结算 - 新增模拟柜只保存智能装柜的 9 类计划渠道 channel_group_code;未关联已放舱订舱时 channel_code/channel_name 为空,关联后才冻结订舱的实际渠道。智能装柜全局工作台和订舱关联候选均按计划渠道组匹配;服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `WAIT_LOADING/EDITING/LOADING` 柜次;候选支持按创建日期和预计到货日期闭区间筛选,状态筛选使用最多四项的 `orderStatuses`,未传表示 `PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING` 全部未完成状态。状态只用于搜索和展示,能否加入以响应 `canAssign` 为准,由有效柜次占用、剩余箱数、容量、组锁和版本等事实决定;人工加入允许订单国内仓及渠道与柜次不同,智能推荐允许跨国内仓但仍只选择柜次渠道。容积和重量均允许达到柜型额定值的 150%,超过即拒绝。待审核事实必须经 `PendingReviewOrderClient` 重新核验 Order 权威 `SUBMITTED` 订单,待审核或待测量可先占位,但确认预装和启动 PDA 前仍必须进入 `WAIT_SPLIT`。计划明细通过 `totalPackageCount/remainingPackageCount` 返回可调总量和余量:测量前取 Order 原始申报箱数,进入 `WAIT_SPLIT/LOADING` 后取最新已审核测量箱数;只读补充失败时保守回退当前已分配数,调整写入仍重新查询并校验权威数量。同柜不可拆组只有全部成员命中状态集合时才返回,且必须成员完整、满箱并作为单个分页与分配单元,不能跨页或拆分。一次性移除未拣明细时,普通未拣项直接移除;同柜组仅全组未拣时整组移除,任一成员已拣则整组保留,单事务只递增一次计划版本。推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。 -- 预装进入 `CONFIRMING/CONFIRMED` 只固化确认快照,不得锁死尚未完成柜次的智能装柜分配;装柜计划仍处于 `EDITING/LOADING` 时可继续新增,未产生实物拣货的明细可调整或移除,已拣明细保持锁定。一件代发智能装柜候选以每个未被有效柜次占用的已封托托盘为独立分页、展示和分配单位;同一入仓单有多托时必须逐托返回,其中一托已分配不得隐藏其余未分配托盘。 +- 预装进入 `CONFIRMING/CONFIRMED` 只固化确认快照,不得锁死尚未完成柜次的智能装柜分配;装柜计划仍处于 `EDITING/LOADING` 时可继续新增,未产生实物拣货的明细可调整或移除,已拣明细保持锁定。一件代发智能装柜候选以每个未被有效柜次占用的已封托托盘为独立分页、展示和分配单位;同一入仓单有多托时必须逐托返回,其中一托已分配不得隐藏其余未分配托盘。装柜完成事件必须按计划项 `palletId` 精确冻结每托箱唛与 SKU 清单,不得仅按入仓单或货物明细关联而造成多托交叉重复。 - 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间;客户未确认或该票尚未装柜完成时,平台仍可修改箱规规则、生成新费用版本并重新发送,新版本必须清空旧版客户确认快照。仅当“客户已确认且该票已装柜完成”同时成立时最终冻结;装柜完成不得代替客户自动确认。旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。 - PDA 普通散箱拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数、入库单号或页面展示字段替代箱码。一件代发已封托的 `SEALED_PALLET` 是不可拆整托单元,PDA 可扫描计划快照中的 `palletNo`、用该托任一成员 `boxCode` 反查所属整托,也可在明细卡片手工确认整托;三种入口最终都必须携带权威 `palletNo` 以 `PICK_PALLET` 一次性提交,不能按成员箱逐箱累计、部分拣货或跳拣。PDA 首页从 Warehouse 主档选择启用国内仓,一件代发与装柜待办列表必须按所选 `warehouseId` 服务端过滤;装柜任务内部仍允许跨国内仓,仓库与渠道限制统一由上游装柜编排负责,PDA 只按已生成任务读取各入仓单自身的 `CONFIRMED` 库位资源,不得再用柜次仓库过滤任务明细、库存快照或拣货写入。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。 - PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java index 947107039..d8bbabdda 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java @@ -91,7 +91,8 @@ public interface LoadingCompletedEventMapper { JOIN fl_dropship_pallet_carton carton ON carton.pallet_id=pallet.id JOIN fl_loading_plan_item item ON item.loading_plan_id=#{planId} AND item.account_id=carton.tenant_id AND item.inbound_id=carton.inbound_id - AND item.inbound_detail_id=carton.inbound_detail_id AND item.deleted=0 + AND item.inbound_detail_id=carton.inbound_detail_id + AND item.pallet_id=pallet.id AND item.deleted=0 WHERE pallet.pallet_status='SEALED' ORDER BY carton.tenant_id,carton.inbound_id,pallet.id,item.inbound_detail_id,carton.id """) @@ -101,7 +102,8 @@ public interface LoadingCompletedEventMapper { SELECT COUNT(DISTINCT p.id) FROM fl_dropship_pallet p JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id JOIN fl_loading_plan_item i ON i.loading_plan_id=#{planId} AND i.account_id=c.tenant_id - AND i.inbound_id=c.inbound_id AND i.deleted=0 WHERE p.pallet_status='SEALED' + AND i.inbound_id=c.inbound_id AND i.pallet_id=p.id AND i.deleted=0 + WHERE p.pallet_status='SEALED' """) int workPalletCount(@Param("planId") long planId); @@ -109,7 +111,8 @@ public interface LoadingCompletedEventMapper { SELECT COUNT(DISTINCT p.id) FROM fl_dropship_pallet p JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id JOIN fl_loading_plan_item i ON i.loading_plan_id=#{planId} AND i.account_id=c.tenant_id - AND i.inbound_id=c.inbound_id AND i.deleted=0 WHERE p.pallet_status='OPEN' + AND i.inbound_id=c.inbound_id AND i.pallet_id=p.id AND i.deleted=0 + WHERE p.pallet_status='OPEN' """) int openWorkPalletCount(@Param("planId") long planId); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java index 09ca1e1d2..b8b30d798 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -248,6 +248,12 @@ class LoadingCompletedOutboxServiceTest { String actualPalletsSql = String.join(" ", LoadingCompletedEventMapper.class .getMethod("actualPalletRows", long.class).getAnnotation(Select.class).value()) .replaceAll("\\s+", " "); + String workPalletCountSql = String.join(" ", LoadingCompletedEventMapper.class + .getMethod("workPalletCount", long.class).getAnnotation(Select.class).value()) + .replaceAll("\\s+", " "); + String openWorkPalletCountSql = String.join(" ", LoadingCompletedEventMapper.class + .getMethod("openWorkPalletCount", long.class).getAnnotation(Select.class).value()) + .replaceAll("\\s+", " "); assertThat(rowsSql).doesNotContain("p.plan_status = 'COMPLETED'") .doesNotContain("o.process_status = 'LOADED'") @@ -276,7 +282,10 @@ class LoadingCompletedOutboxServiceTest { .doesNotContain("label.inbound_id = i.inbound_id"); assertThat(actualPalletsSql) .contains("pallet.pallet_no AS pallet_code") + .contains("item.pallet_id=pallet.id") .doesNotContain("pallet.pallet_code"); + assertThat(workPalletCountSql).contains("i.pallet_id=p.id"); + assertThat(openWorkPalletCountSql).contains("i.pallet_id=p.id"); } @org.junit.jupiter.params.ParameterizedTest diff --git a/scripts/README.md b/scripts/README.md index 874d79d8f..85cbb5e9d 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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 提交不会自动触发此脚本,仍须遵守提交前检查规则。 diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 48f054289..c885a80c7 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -62,6 +62,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` | diff --git a/scripts/admin-test-env-modules/merged-overseas-coverage-contract.test.mjs b/scripts/admin-test-env-modules/merged-overseas-coverage-contract.test.mjs new file mode 100644 index 000000000..b271ee44a --- /dev/null +++ b/scripts/admin-test-env-modules/merged-overseas-coverage-contract.test.mjs @@ -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) +}) diff --git a/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs b/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs index 278a686a6..f0f864410 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs @@ -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, } } diff --git a/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs b/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs index 9c7b188b0..69b60de5c 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs @@ -124,6 +124,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 } @@ -145,7 +169,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) diff --git a/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs b/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs index 3507217bf..a975613e9 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs @@ -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 + && / 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, diff --git a/scripts/admin-test-env-modules/warehouse-transfer-review.mjs b/scripts/admin-test-env-modules/warehouse-transfer-review.mjs new file mode 100644 index 000000000..0e503e9c7 --- /dev/null +++ b/scripts/admin-test-env-modules/warehouse-transfer-review.mjs @@ -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 +}