From 7a0e158b51cdbb239c4d04f8212356bab7b3f8e8 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 4 Sep 2026 10:51:25 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 3 +- firstleg/AGENTS.md | 6 +- .../PlatformSmartLoadingController.java | 2 + .../baiyue/firstleg/dto/SmartLoadingDtos.java | 2 + .../firstleg/service/SmartLoadingService.java | 98 ++++++- .../main/resources/openapi/firstleg-v1.yaml | 32 ++- ...ormSmartLoadingControllerContractTest.java | 4 + .../service/SmartLoadingServiceTest.java | 104 ++++++- order/AGENTS.md | 2 + scripts/README.md | 12 + scripts/agents-index-contract.ps1 | 33 ++- ...rder-test-env-acceptance.contract.test.mjs | 23 ++ ...client-quick-order-test-env-acceptance.mjs | 270 ++++++++++++++++++ 13 files changed, 561 insertions(+), 30 deletions(-) create mode 100644 scripts/client-quick-order-test-env-acceptance.contract.test.mjs create mode 100644 scripts/client-quick-order-test-env-acceptance.mjs diff --git a/AGENTS.md b/AGENTS.md index b96134c1b..b59c0b849 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,8 @@ | 脚本管理、全量验证、测试造数与诊断 | 仓库根 `scripts/`;服务专属脚本留在 `/scripts/` | 先读 `scripts/AGENTS.md` 和 `scripts/README.md`;Admin HTTP 内部模块继续读 `scripts/admin-test-env-modules/README.md`。新增入口必须登记用途、目标环境、数据副作用和报告位置,并通过 `scripts/script-documentation-contract.test.mjs` | | 分柜、柜次、智能排柜、预装/装柜清单、真实柜号后补、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位;创建柜次时原子生成唯一预装计划,加入入仓码时同步建立关联,不设推送步骤;预装/正式详情由 `/loading-plans/{planId}/lifecycle` 解析。真实集装箱号不阻塞订舱关联、计划确认或 PDA task 创建,可在 `EDITING/LOADING` 首次补录,但必须在 PDA 最终完成前存在。装柜证据上传重试按幂等键与包含文件内容的请求哈希识别,完全重放不得再次调用 File | | 合柜报关 / 同柜不可拆组、跨客户合柜、整组分配/移除 | `firstleg/` | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`、`src/main/resources/openapi/coload-groups-v1.yaml`;按订单建组并冻结全部可用入仓片段,仅要求同国内仓、同渠道 | -| 全局智能装柜候选、柜次与箱数分配 | `firstleg/` | `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/containers`、`/plans/{planId}/inbound-assignments`、`/group-assignments`,业务编排 `service/SmartLoadingService.java`;渠道分组由服务端映射,同柜不可拆组作为一个分页与分配单元 | +| 全局智能装柜候选、柜次、占位与移除 | `firstleg/`、`order/` | `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/containers`、`/plans/{planId}/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`,业务编排 `service/SmartLoadingService.java`;待审核事实由 `client/PendingReviewOrderClient.java` 查询 Order `PendingReviewOrderQueryService.java`。候选四态只用于筛选展示,资格以 `canAssign` 为准;人工加入允许跨渠道,智能推荐仍按柜次渠道;同柜组整组分页、分配和移除 | +| 客户入库结果与头程费用确认 | `firstleg/` | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java`、`FbaPricingService.java`,接口 `/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing` 与 `/confirm`;客户按登录态范围读取并以费用版本和幂等键确认当前 `SENT` 费用,确认后锁定 | | 订舱资料/打印、船期、订舱级拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`TruckAppointmentService.java`、`outbound/dispatch/`、`customs/`;拖车只绑定 `RELEASED` 订舱,不携带客户、柜次、装柜计划或真实柜号,每订舱最多一张非终态预约,订舱文件联查 `file/AGENTS.md` | | 客户账期、费用池、账单、入仓码联查、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;客户账单行 `inboundCode` 由 `FinanceQueryService` 从费用来源快照提供,Client 复用头程入仓关系详情;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` | | 柜级公共费用跨客户分摊 | `firstleg/`、`finance/` | Firstleg `PlatformContainerFeeDetailController.java`、`ContainerFeeDetailService.java`、`container-fees-v1.yaml` 按柜内真实客户与订单原子创建 `allocation-groups`;金额合计必须等于总额,分组审核、驳回、调整、作废均整组处理,Finance 只消费各客户不可变来源快照 | diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 3f539cc14..d5e02d165 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -26,13 +26,14 @@ | 客户费用查询 | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java` | | 客户运营统计/在途柜量 | `customerstatistics/controller/PlatformCustomerTransitStatisticsController.java`、`customerstatistics/service/CustomerTransitStatisticsService.java`;契约见 `src/main/resources/openapi/customer-statistics-v1.yaml` | | 费用项目与价格簿 | `controller/PlatformFeeItemController.java`、`PlatformPriceBookController.java`、`MerchantPriceBookController.java` | +| 客户入库结果与头程费用确认 | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java`、`FbaPricingService.java`;查询与确认接口为 `/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing`、`/confirm`,契约 `src/main/resources/openapi/firstleg-v1.yaml` | | 价格簿查询内部切片 | `pricebookquery/application/port/in/PriceBookQueryUseCase.java`、`application/PriceBookQueryService.java`、`application/port/out/PriceBookQueryReader.java`、`adapter/out/persistence/PriceBookQueryMapper.java`;查询行模型位于 `application/model/PriceBookQueryRows.java` | | 财务费用来源快照 | `controller/InternalFinanceFeeSourceController.java`;入库费用与柜次费用分别按来源 ID、版本向 Finance 提供不可变快照 | | 柜次费用明细与跨客户分摊组 | `controller/PlatformContainerFeeDetailController.java`、`service/ContainerFeeDetailService.java`、`dto/ContainerFeeDetailDtos.java`;跨客户公共柜费使用 `allocation-groups` 按柜内真实客户和订单原子分摊,入仓 ID/入仓码同时填写或同时留空;各行金额合计必须等于原始柜费,审核、驳回、调整、作废均整组处理,单行入口拒绝分摊组;契约 `src/main/resources/openapi/container-fees-v1.yaml`;Admin 消费端见 `../../apps/admin-web/src/modules/loading-lists/api/containerFees.ts` | | 财务整柜成本分摊快照 | `loading/controller/InternalFinanceCostSnapshotController.java`、`loading/service/FinanceCostSnapshotService.java` | | 分柜、预装、真实柜号后补与正式装柜清单生命周期 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java`、`service/LoadingListService.java`;契约 `src/main/resources/openapi/loading-lists-v1.yaml`,详情阶段先查 `/loading-plans/{planId}/lifecycle`。创建柜次时原子生成唯一预装计划,加入入仓码明细时同步创建入库预装聚合与关联,不设推送步骤;同一 `planId` 在 `EDITING/LOADING` 返回 `PRELOAD`,`COMPLETED` 返回 `LOADING_LIST`。订舱关联、预装确认和 PDA task 创建不依赖真实集装箱号;可在 `EDITING/LOADING` 首次补录,最终 PDA 完成前必须存在;已有值纠正仅限 `EDITING` 且任务未领取、无物理作业事实 | | 同柜不可拆组 | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`;契约 `src/main/resources/openapi/coload-groups-v1.yaml`。请求按订单 ID 建组,服务端冻结每票全部 `WAIT_SPLIT` 可用入仓片段与满箱数;仅要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均保持整组原子 | -| 货柜与全局智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`loading/`;全局候选、工作台柜次和箱数分配分别从 `/inbound-candidates`、`/containers`、`/plans/{planId}/inbound-assignments` 进入,同柜组从 `/group-assignments` 整组处理,明细规格拆分留在服务端 | +| 货柜与全局智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`client/PendingReviewOrderClient.java`、`loading/`;全局候选与工作台柜次从 `/inbound-candidates`、`/containers` 进入,普通箱数、待审核占位、同柜组和全部未拣移除分别走 `/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`,明细规格拆分留在服务端 | | 货柜资料、PDA 证据与业务总览 | `controller/PlatformContainerController.java` 的 `/{id}/overview`、`/{id}/documents/{documentType}`,聚合 `mapper/ContainerOverviewMapper.java`;契约 `src/main/resources/openapi/phase3-container-overview-v1.yaml`,四类资料固定为目的港清关、国内出口报关、DO、PU 的当前唯一版本 | | PDA 拣货与装柜、证据幂等上传 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java`、`loading/service/CabinetLoadingEvidenceService.java`;证据写入和 multipart 上传均先以任务内幂等键匹配请求哈希,上传哈希覆盖仓库、版本、证据类型、锁码、备注、文件名、媒体类型、大小与文件内容;完全重放返回当前视图且不得再次上传 File,相同键不同内容返回冲突 | | 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`client/EventBridgeClient.java`;从 `fl_inbound_order.destination_type` 冻结 V2 `orders[].destinationType`(`FBA` 或 `OVERSEAS`),OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` | @@ -76,7 +77,8 @@ ## 三期装柜与 PDA 资源结算 -- 智能装柜全局工作台由服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `WAIT_LOADING/EDITING/LOADING` 柜次;候选状态筛选使用最多四项的 `orderStatuses`,未传表示 `PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING` 全部未完成状态。状态只用于搜索和展示,能否加入由有效柜次占用、剩余箱数、国内中转仓、实际渠道、容量、组锁和版本等事实决定;容积和重量均允许达到柜型额定值的 150%,超过即拒绝。待审核或待测量可先占位,但确认预装和启动 PDA 前仍必须进入 `WAIT_SPLIT`。同柜不可拆组必须成员完整、满箱且作为单个分页与分配单元,不能跨页或拆分。推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。 +- 智能装柜全局工作台由服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `WAIT_LOADING/EDITING/LOADING` 柜次;候选状态筛选使用最多四项的 `orderStatuses`,未传表示 `PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING` 全部未完成状态。状态只用于搜索和展示,能否加入以响应 `canAssign` 为准,由有效柜次占用、剩余箱数、国内中转仓、容量、组锁和版本等事实决定;人工加入允许订单渠道与柜次渠道不同,智能推荐仍只选择柜次渠道。容积和重量均允许达到柜型额定值的 150%,超过即拒绝。待审核事实必须经 `PendingReviewOrderClient` 重新核验 Order 权威 `SUBMITTED` 订单,待审核或待测量可先占位,但确认预装和启动 PDA 前仍必须进入 `WAIT_SPLIT`。同柜不可拆组只有全部成员命中状态集合时才返回,且必须成员完整、满箱并作为单个分页与分配单元,不能跨页或拆分。一次性移除未拣明细时,普通未拣项直接移除;同柜组仅全组未拣时整组移除,任一成员已拣则整组保留,单事务只递增一次计划版本。推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。 +- 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间并将该费用版本永久锁定,平台不得再调整;旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。 - PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。 - PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。 - `releaseMode=AUTO` 时,释放资源数为 `ceil(实际拣货箱数 × 原资源数 / 原可用箱数)`;`MANUAL` 按 `locationIds` 选择,空数组表示明确释放 0 个。当前契约中未传模式且 `locationIds=null` 时按 AUTO 处理。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java index c859de9e7..0772819e6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java @@ -38,4 +38,6 @@ public class PlatformSmartLoadingController { public ApiResponse warehouseRemark(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody WarehouseRemarkRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.updateWarehouseRemark(planId,itemId,r,u.userId(),u.username()));} @DeleteMapping("/plans/{planId}/items/{itemId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:delete')") public ApiResponse delete(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody DeleteRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.delete(planId,itemId,r,u.userId(),u.username()));} + @DeleteMapping("/plans/{planId}/unpicked-items") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:delete')") + public ApiResponse removeUnpicked(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody RemoveUnpickedRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.removeUnpicked(planId,r,u.userId(),u.username()));} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java index 39ea213a1..351b6007b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java @@ -73,6 +73,8 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){} @NotNull @Min(0) Integer expectedItemVersion,@NotBlank @Size(max=128) String operationId) {} public record DeleteRequest(@NotNull @Min(0) Integer expectedPlanVersion, @NotNull @Min(0) Integer expectedItemVersion,@NotBlank @Size(max=128) String operationId) {} + public record RemoveUnpickedRequest(@NotNull @Min(0) Integer expectedPlanVersion, + @NotBlank @Size(max=128) String operationId) {} public record WarehouseRemarkRequest(@Size(max=1000) String warehouseRemark, @NotNull @Min(0) Integer expectedPlanVersion, @NotNull @Min(0) Integer expectedItemVersion, @NotBlank @Size(max=128) String operationId) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index 40850bcb8..d80f70ffe 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -420,7 +420,6 @@ public class SmartLoadingService { request.inboundDetailId()); if (candidate == null) throw conflict("入库货物不存在或尚未完成入库"); if (!wholeGroupAssignment) requireStandalone(candidate.accountId(), candidate.inboundId()); - requireSameChannel(booking, candidate); requireSameCustomerScope(booking, candidate); var task = loadingPickService.mutationSnapshot(planId); if (task.taskId() != null && "LOADING".equals(task.taskStatus())) { @@ -525,7 +524,6 @@ public class SmartLoadingService { for (InboundAllocation allocation : allocations) { if (allocation.packageCount() == 0) continue; Candidate candidate = allocation.candidate(); - requireSameChannel(booking, candidate); requireSameCustomerScope(booking, candidate); checkRouteAndCapacity(booking, current, candidate, allocation.packageCount(), true); OrderCargoClient.CargoBinding cargo = declared.stream() @@ -581,7 +579,7 @@ public class SmartLoadingService { LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName); ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); List details = frozenDetails == null - ? candidates(plan.getContainerBookingId(), booking.getWarehouseId()).stream() + ? manualCandidates(booking).stream() .filter(candidate -> Objects.equals(candidate.inboundId(), request.inboundId())).toList() : List.copyOf(frozenDetails); if (details.isEmpty()) throw conflict("入仓码不存在、已分配完或尚未完成入库"); @@ -641,7 +639,6 @@ public class SmartLoadingService { for (InboundAllocation allocation : allocations) { if (allocation.packageCount() == 0) continue; Candidate candidate = allocation.candidate(); - requireSameChannel(booking, candidate); requireSameCustomerScope(booking, candidate); checkRouteAndCapacity(booking, current, candidate, allocation.packageCount(), true); OrderCargoClient.CargoBinding cargo = declared.stream().map(DeclaredCargo::cargo) @@ -853,7 +850,6 @@ public class SmartLoadingService { Candidate candidate = repository.candidate(planId, booking.getWarehouseId(), item.getInboundId(), item.getInboundDetailId()); if (candidate == null || request.packageCount() > candidate.availablePackageCount() + item.getPackageCount()) throw validation("调整箱数超过可用箱数"); - requireSameChannel(booking, candidate); requireSameCustomerScope(booking, candidate); OrderCargoClient.CargoBinding cargo = loadCargo(candidate); List others = new ArrayList<>(repository.items(planId)); @@ -945,6 +941,84 @@ public class SmartLoadingService { return view(repository.plan(planId), requireBooking(plan.getContainerBookingId())); } + @Transactional + @BusinessAudit(event = "SMART_LOADING_UNPICKED_REMOVE", resourceType = "LOADING_PLAN", + resourceId = "#planId", description = "批量移除智能装柜未拣明细") + public Plan removeUnpicked(long planId, RemoveUnpickedRequest request, + long operatorId, String operatorName) { + String operation = operation(request.operationId()); + String requestHash = sha256("REMOVE_UNPICKED|" + planId + '|' + request.expectedPlanVersion()); + Plan replay = replay(operation, planId, "REMOVE_UNPICKED", null, requestHash); + if (replay != null) return replay; + LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion(), operatorId, operatorName); + ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); + List items = repository.items(planId); + LoadingPickService.MutationSnapshot pickSnapshot = loadingPickService.mutationSnapshot(planId); + Map picked = pickSnapshot == null ? Map.of() : pickSnapshot.pickedByPlanItem(); + + Map groupByMember = new HashMap<>(); + Map groups = new LinkedHashMap<>(); + for (LoadingPlanItemEntity item : items) { + String memberKey = item.getAccountId() + "|" + item.getInboundId(); + if (!groupByMember.containsKey(memberKey)) { + groupByMember.put(memberKey, group(item.getAccountId(), item.getInboundId())); + } + GroupSnapshot group = groupByMember.get(memberKey); + if (group != null) groups.putIfAbsent(group.groupId(), group); + } + Set groupsWithPickedItems = new HashSet<>(); + for (LoadingPlanItemEntity item : items) { + if (picked.getOrDefault(item.getId(), 0) <= 0) continue; + GroupSnapshot group = groupByMember.get(item.getAccountId() + "|" + item.getInboundId()); + if (group != null) groupsWithPickedItems.add(group.groupId()); + } + + List removable = items.stream().filter(item -> { + if (picked.getOrDefault(item.getId(), 0) > 0) return false; + GroupSnapshot group = groupByMember.get(item.getAccountId() + "|" + item.getInboundId()); + return group == null || !groupsWithPickedItems.contains(group.groupId()); + }).toList(); + Set removableIds = removable.stream().map(LoadingPlanItemEntity::getId) + .collect(java.util.stream.Collectors.toSet()); + Set removableGroupIds = groups.values().stream() + .filter(group -> !groupsWithPickedItems.contains(group.groupId())) + .filter(group -> items.stream().anyMatch(item -> removableIds.contains(item.getId()) + && group.members().stream().anyMatch(member -> member.tenantId() == item.getAccountId() + && member.inboundId() == item.getInboundId()))) + .map(GroupSnapshot::groupId).collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); + for (Long groupId : removableGroupIds) { + requireCompleteGroupAllocation(planId, groups.get(groupId), items); + } + + if (!removable.isEmpty()) { + touch(plan, operatorId, operatorName); + for (LoadingPlanItemEntity item : removable) { + loadingPickService.remove(item.getId()); + if (repository.remove(planId, item.getId(), item.getVersion(), operatorId, operatorName) != 1) { + throw conflict("装柜未拣明细已被并发修改"); + } + } + Set releasedInbounds = new HashSet<>(); + for (LoadingPlanItemEntity item : removable) { + String key = item.getAccountId() + "|" + item.getInboundId(); + if (releasedInbounds.add(key)) { + loadingModelBridge.removeIfUnallocated(planId, item.getAccountId(), item.getInboundId()); + } + } + for (Long groupId : removableGroupIds) { + GroupSnapshot group = groups.get(groupId); + coloadGroups.unlockWholeGroupAfterRemoval(groupId, planId, + "UNLOCK-" + sha256(operation + '|' + groupId).substring(0, 48), + operatorId, operatorName); + } + } + history(planId, null, "REMOVE_UNPICKED", plan.getPlanStatus(), plan.getPlanStatus(), operation, + json(values("version", request.expectedPlanVersion(), "itemCount", items.size())), + json(values("requestHash", requestHash, "removedItemCount", removable.size(), + "removedGroupCount", removableGroupIds.size())), operatorId, operatorName); + return view(repository.plan(planId), booking); + } + private List enrichColoadCandidates(ContainerBookingEntity booking, LoadingPlanEntity plan, List values) { Map snapshots = new HashMap<>(); @@ -1013,7 +1087,7 @@ public class SmartLoadingService { if (!"ACTIVE".equals(group.status()) || group.lockedPlanId() != null) { throw conflict("同柜不可拆组已锁定或已消费"); } - List available = availableCandidates(booking); + List available = manualCandidates(booking); Map> result = new LinkedHashMap<>(); for (Member member : group.members()) { if (!repository.activeItemsByInbound(member.tenantId(), member.inboundId()).isEmpty()) { @@ -1040,6 +1114,11 @@ public class SmartLoadingService { .filter(candidate -> sameChannel(booking, candidate) && sameCustomerScope(booking, candidate)).toList(); } + private List manualCandidates(ContainerBookingEntity booking) { + return repository.candidates(booking.getWarehouseId()).stream() + .filter(candidate -> sameCustomerScope(booking, candidate)).toList(); + } + private boolean groupFits(ContainerBookingEntity booking, List planItems, GroupSnapshot group, List available) { if (booking.getCapacityCbm() == null || booking.getMaxWeightKg() == null) return false; @@ -1272,7 +1351,6 @@ public class SmartLoadingService { private record DeclaredCargo(OrderCargoClient.CargoBinding cargo) {} private void checkRouteAndCapacity(ContainerBookingEntity b,List lines,Candidate c, int count, boolean requireSameDestination){ - requireSameChannel(b, c); if (!Objects.equals(b.getWarehouseId(), c.warehouseId())) throw validation("装柜仓库不一致"); if (requireSameDestination && StringUtils.hasText(b.getDestinationWarehouseCode()) && !Objects.equals(b.getDestinationWarehouseCode(), c.destinationWarehouseCode())) { @@ -1376,12 +1454,6 @@ public class SmartLoadingService { private static boolean sameCustomerScope(ContainerBookingEntity booking, Candidate candidate) { return booking.getCustomerId() == null || Objects.equals(booking.getCustomerId(), candidate.accountId()); } - private static void requireSameChannel(ContainerBookingEntity booking, Candidate candidate) { - if (!StringUtils.hasText(booking.getChannelCode()) || !StringUtils.hasText(candidate.channelCode())) { - throw validation("柜次或入库货物缺少渠道快照,请先完成存量数据回填"); - } - if (!sameChannel(booking, candidate)) throw validation("禁止跨渠道混装"); - } private static void requireSameCustomerScope(ContainerBookingEntity booking, Candidate candidate) { if (!sameCustomerScope(booking, candidate)) throw forbidden(); } diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 9dece35b3..8c7a2e443 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -466,7 +466,8 @@ paths: description: >- 需要 platform:firstleg:smart-loading:list 权限。渠道目录只来自平台当前唯一 ACTIVE 价格版本中 对该国内仓存在 ACTIVE 价格行的渠道;候选入仓码数、可用箱数及可编排柜次数由 Firstleg 当前事实聚合。 - 零候选或零可用柜次的有效渠道仍返回,Admin 可展示空态;实际分配仍由服务端重新校验仓库、渠道和资格。 + 零候选或零可用柜次的有效渠道仍返回,Admin 可展示空态。渠道只约束智能推荐;人工加入允许跨渠道, + 仍由服务端重新校验国内仓、客户范围、占用、同柜组、箱数和容量资格。 parameters: - {name: warehouseId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} responses: @@ -493,7 +494,7 @@ paths: LOADING 全部未完成状态,已完成装柜永不返回。状态映射为:Order SUBMITTED=PENDING_REVIEW; Firstleg WAIT_MEASURE/MEASURING/MEASURE_PENDING/MEASURE_REJECTED/WAIT_PRICING/FEE_CONFIRMING/INBOUNDING =WAIT_MEASURE;WAIT_SPLIT=WAIT_SPLIT;LOADING=LOADING。状态只用于搜索和展示,canAssign 由有效柜次占用、 - 剩余箱数、同柜组、仓库、渠道和容量等事实决定;容积和重量均允许达到柜型额定值的150%,超过即拒绝。 + 剩余箱数、同柜组、仓库和容量等事实决定;容积和重量均允许达到柜型额定值的150%,超过即拒绝。 待审核及待测量可以先加入清单;确认预装/PDA 前 仍必须完成审核与测量并进入 WAIT_SPLIT。同柜不可拆组仅在全部成员命中当前状态集合时作为一个分页单位返回。 parameters: @@ -597,7 +598,8 @@ paths: summary: 向预装计划新增入库单明细 description: >- EDITING 或 LOADING 且 PDA task 尚未完成时可用。服务端按 planId 锁定同一 PDA task, - 校验仓库、渠道、客户范围、可用箱数、容积和限重后原子新增/累加明细;LOADING 时同步到原 taskId, + 校验仓库、客户范围、可用箱数、容积和限重后原子新增/累加明细;人工加入允许订单渠道与柜次渠道不同, + 智能推荐仍只选择柜次渠道。LOADING 时同步到原 taskId, 递增 planVersion、taskVersion,原 inventorySnapshotToken 随即失效。与 PDA batch/final complete 并发时, 锁顺序为 PDA task 行锁 -> plan 乐观锁;仅先取得任务锁且持有匹配版本的一方成功,另一方返回 A0409。 PDA 应重新 GET task detail 与 inventory snapshot,Admin 应重新 GET SmartLoadingPlan。新增项沿用原 taskId、 @@ -616,6 +618,7 @@ paths: description: >- 需要 platform:firstleg:smart-loading:assign 权限。操作员只选择某个入仓码的本次计划箱数; 服务端在单一事务内按剩余规格箱数比例生成内部规格快照,用于体积估算及兼容 PDA/拆单; + 人工分配不限制订单渠道与柜次渠道一致,渠道只约束智能推荐;国内仓、占用、同柜组和容量门禁不变。 柜内总容积和总重量分别允许达到柜型额定值的150%,超过即拒绝。 只记录数量计划,具体实装箱号归属为 UNVERIFIED,不得将内部比例分摊宣称为现场箱号事实。 parameters: @@ -631,7 +634,8 @@ paths: description: >- 需要 platform:firstleg:smart-loading:assign 权限。订单审核状态不作为清单占位门禁;服务端以 customerId + orderId + inboundCode 重新核验 Order 权威待审核事实,幂等创建或复用 PREPARING - 入仓投影,并按订单申报箱数、重量和体积建立柜次占位。已被其他有效柜次占用、仓库/渠道不一致、 + 入仓投影,并按订单申报箱数、重量和体积建立柜次占位。人工分配允许订单渠道与柜次渠道不同; + 已被其他有效柜次占用、国内仓不一致、 超容量或订单事实变化返回业务冲突且事务零漂移。清单中存在 PREPARING/WAIT_MEASURE 订单时不得 确认预装或启动 PDA,审核测量完成并进入 WAIT_SPLIT 后才可继续物理装柜。 parameters: @@ -670,6 +674,20 @@ paths: content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingDeleteRequest'}}} responses: '200': {description: 成功或业务失败,data 为最新 SmartLoadingPlan} + /api/platform/v1/first-leg/smart-loading/plans/{planId}/unpicked-items: + delete: + summary: 一次性移除当前计划全部未拣明细 + description: >- + 需要 platform:firstleg:smart-loading:delete 权限。单次请求、单事务锁定计划与 PDA task,移除全部 + pickedPackageCount=0 的普通入仓码;同柜不可拆组只在组内所有明细均未拣时整组移除,任一成员已拣则整组保留。 + planVersion 只递增一次;并发版本变化或任一步失败均整体回滚。同 operationId 重放返回既有结果。 + parameters: + - {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingRemoveUnpickedRequest'}}} + responses: + '200': {description: 成功或业务失败,data 为最新 SmartLoadingPlan} /api/platform/v1/first-leg/smart-loading/plans/{planId}/items/{itemId}/warehouse-remark: patch: summary: 修改装柜明细仓库备注 @@ -1956,6 +1974,12 @@ components: expectedPlanVersion: {type: integer, minimum: 0} expectedItemVersion: {type: integer, minimum: 0} operationId: {type: string, minLength: 1, maxLength: 128} + SmartLoadingRemoveUnpickedRequest: + type: object + required: [expectedPlanVersion, operationId] + properties: + expectedPlanVersion: {type: integer, minimum: 0} + operationId: {type: string, minLength: 1, maxLength: 128} SmartLoadingWarehouseRemarkRequest: type: object required: [expectedPlanVersion, expectedItemVersion, operationId] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java index 345606257..733b2c9d8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java @@ -56,6 +56,7 @@ class PlatformSmartLoadingControllerContractTest { authority("adjust","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,long.class,AdjustRequest.class); authority("warehouseRemark","platform:firstleg:preload:edit",AuthenticatedUser.class,long.class,long.class,WarehouseRemarkRequest.class); authority("delete","platform:firstleg:smart-loading:delete",AuthenticatedUser.class,long.class,long.class,DeleteRequest.class); + authority("removeUnpicked","platform:firstleg:smart-loading:delete",AuthenticatedUser.class,long.class,RemoveUnpickedRequest.class); } @Test void openApiHasNoManualCreateOrPushStep() throws Exception { String yaml=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/openapi/firstleg-v1.yaml")); @@ -75,6 +76,7 @@ class PlatformSmartLoadingControllerContractTest { .contains("SmartLoadingAssignRequest:") .contains("SmartLoadingAdjustRequest:") .contains("SmartLoadingDeleteRequest:") + .contains("SmartLoadingRemoveUnpickedRequest:") .contains("SmartLoadingWarehouseRemarkRequest:") .contains("/items/{itemId}/warehouse-remark:") .contains("需要 platform:firstleg:preload:edit 权限") @@ -83,6 +85,8 @@ class PlatformSmartLoadingControllerContractTest { .contains("items[].version 是递增后的 itemVersion") .contains("数量调整或动态增量不会覆盖已有 warehouseRemark") .contains("柜内总容积和总重量分别允许达到柜型额定值的150%") + .contains("人工分配不限制订单渠道与柜次渠道一致") + .contains("渠道只约束智能推荐") .contains("canAssign: {type: boolean}") .contains("canEdit: {type: boolean}") .contains("pickedPackageCount: {type: integer, minimum: 0}") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index 555790476..45a136ee9 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -332,16 +332,16 @@ class SmartLoadingServiceTest { } @Test - void assignsPendingReviewOrderAsAProvisionalContainerAllocation() { + void manuallyAssignsCrossChannelPendingReviewOrderAsAProvisionalContainerAllocation() { long customerId = 2092797167699816450L; long orderId = 2095052002489335810L; String inboundCode = "EXXHK2609020361"; var pending = new PendingReviewOrderClient.Row(orderId, "FL-361", inboundCode, - customerId, "客户", 100L, "FBA", "FBA渠道", 5, + customerId, "客户", 100L, "SLOW", "慢船", 5, LocalDateTime.parse("2026-09-02T15:31:36"), "PENDING_REVIEW"); var snapshot = new com.baiyue.firstleg.dto.OrderApprovedInboundCommand( customerId, orderId, "FL-361", inboundCode, customerId, "客户", 100L, "义乌仓", - null, null, null, "FIRST_LEG", "FBA", "FBA渠道", null, null, null, + null, null, null, "FIRST_LEG", "SLOW", "慢船", null, null, null, null, null, false, "FBA", 200L, "FBA1", "FBA仓", null, null, null, 5); var label = new OrderCargoClient.CargoLabelBinding(20L, orderId, 1, "货物", 5, null, null, null); @@ -381,7 +381,7 @@ class SmartLoadingServiceTest { } @Test - void assignsWaitMeasureInboundUsingDeclaredFactsWithoutTreatingStatusAsAGate() { + void manuallyAssignsCrossChannelWaitMeasureInboundUsingDeclaredFacts() { long customerId = 9L; long inboundId = 10L; long orderId = 30L; @@ -391,11 +391,11 @@ class SmartLoadingServiceTest { projection.setOrderId(orderId); projection.setInboundCode("I10"); projection.setWarehouseId(100L); - projection.setChannelCode("FBA"); + projection.setChannelCode("SLOW"); projection.setProcessStatus("WAIT_MEASURE"); var snapshot = new com.baiyue.firstleg.dto.OrderApprovedInboundCommand( customerId, orderId, "O30", "I10", customerId, "客户", 100L, "义乌仓", - null, null, null, "FIRST_LEG", "FBA", "FBA渠道", null, null, null, + null, null, null, "FIRST_LEG", "SLOW", "慢船", null, null, null, null, null, false, "FBA", 200L, "FBA1", "FBA仓", null, null, null, 5); var label = new OrderCargoClient.CargoLabelBinding(20L, orderId, 1, "货物", 5, null, null, null); @@ -575,8 +575,8 @@ class SmartLoadingServiceTest { @Test void atomicallyAssignsEveryInboundFragmentOfSameOrderAcrossDifferentDestinations() { - Candidate first = candidate(9L, 10L, 20L, 30L, 4); - Candidate second = candidate(9L, 11L, 21L, 30L, 6, "FBA2"); + Candidate first = withChannel(candidate(9L, 10L, 20L, 30L, 4), "SLOW", "慢船"); + Candidate second = withChannel(candidate(9L, 11L, 21L, 30L, 6, "FBA2"), "SLOW", "慢船"); GroupSnapshot active = group("ACTIVE", null, new Member(9L, 30L, 10L, "IN-10", 4), new Member(9L, 30L, 11L, "IN-11", 6)); GroupSnapshot locked = group("LOCKED", 1L, @@ -796,6 +796,94 @@ class SmartLoadingServiceTest { org.mockito.ArgumentMatchers.startsWith("UNLOCK-"), eq(7L), eq("u")); } + @Test + void removesAllUnpickedItemsWithOnePlanVersionChange() { + LoadingPlanEntity initial = plan("EDITING", 2); + LoadingPlanEntity after = plan("EDITING", 3); + LoadingPlanItemEntity first = item(8L, 9L, 20L, 4); + LoadingPlanItemEntity second = item(9L, 19L, 21L, 6); + second.setInboundId(11L); + when(repository.plan(1L)).thenReturn(initial, after); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.items(1L)).thenReturn(List.of(first, second), List.of()); + when(repository.touch(1L, 2, 7L, "u")).thenReturn(1); + when(repository.remove(eq(1L), anyLong(), eq(0), eq(7L), eq("u"))).thenReturn(1); + + Plan result = service.removeUnpicked(1L, + new com.baiyue.firstleg.dto.SmartLoadingDtos.RemoveUnpickedRequest(2, "remove-unpicked"), + 7L, "u"); + + assertThat(result.items()).isEmpty(); + verify(repository).touch(1L, 2, 7L, "u"); + verify(repository, times(2)).remove(eq(1L), anyLong(), eq(0), eq(7L), eq("u")); + verify(loadingPickService, times(2)).remove(anyLong()); + } + + @Test + void bulkRemovalKeepsWholeGroupWhenAnyMemberWasPicked() { + LoadingPlanEntity initial = plan("LOADING", 2); + LoadingPlanEntity after = plan("LOADING", 3); + LoadingPlanItemEntity first = item(8L, 9L, 20L, 4); + LoadingPlanItemEntity second = item(9L, 19L, 21L, 6); + second.setInboundId(11L); + LoadingPlanItemEntity standalone = item(10L, 29L, 22L, 3); + standalone.setInboundId(12L); + GroupSnapshot group = group("LOCKED", 1L, + new Member(9L, 30L, 10L, "IN-10", 4), new Member(19L, 31L, 11L, "IN-11", 6)); + when(repository.plan(1L)).thenReturn(initial, after); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.items(1L)).thenReturn(List.of(first, second, standalone), List.of(first, second)); + when(repository.touch(1L, 2, 7L, "u")).thenReturn(1); + when(repository.remove(1L, 10L, 0, 7L, "u")).thenReturn(1); + when(coloadGroups.findByInbound(9L, 10L)).thenReturn(group); + when(coloadGroups.findByInbound(19L, 11L)).thenReturn(group); + when(loadingPickService.mutationSnapshot(1L)).thenReturn( + new LoadingPickService.MutationSnapshot(77L, "LOADING", 5, Map.of(8L, 1))); + + Plan result = service.removeUnpicked(1L, + new com.baiyue.firstleg.dto.SmartLoadingDtos.RemoveUnpickedRequest(2, "keep-picked-group"), + 7L, "u"); + + assertThat(result.items()).extracting(LoadingPlanItemEntity::getId).containsExactly(8L, 9L); + verify(repository).remove(1L, 10L, 0, 7L, "u"); + verify(repository, never()).remove(eq(1L), eq(8L), anyInt(), anyLong(), anyString()); + verify(repository, never()).remove(eq(1L), eq(9L), anyInt(), anyLong(), anyString()); + verify(coloadGroups, never()).unlockWholeGroupAfterRemoval(anyLong(), anyLong(), anyString(), anyLong(), anyString()); + } + + @Test + void bulkRemovalRemovesAndUnlocksACompletelyUnpickedGroupOnce() { + LoadingPlanEntity initial = plan("EDITING", 2); + LoadingPlanEntity after = plan("EDITING", 3); + LoadingPlanItemEntity first = item(8L, 9L, 20L, 4); + LoadingPlanItemEntity second = item(9L, 19L, 21L, 6); + second.setInboundId(11L); + GroupSnapshot group = group("LOCKED", 1L, + new Member(9L, 30L, 10L, "IN-10", 4), new Member(19L, 31L, 11L, "IN-11", 6)); + when(repository.plan(1L)).thenReturn(initial, after); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.items(1L)).thenReturn(List.of(first, second), List.of()); + when(repository.activeItemsByInbound(9L, 10L)).thenReturn(List.of(first)); + when(repository.activeItemsByInbound(19L, 11L)).thenReturn(List.of(second)); + when(repository.touch(1L, 2, 7L, "u")).thenReturn(1); + when(repository.remove(eq(1L), anyLong(), eq(0), eq(7L), eq("u"))).thenReturn(1); + when(coloadGroups.findByInbound(9L, 10L)).thenReturn(group); + when(coloadGroups.findByInbound(19L, 11L)).thenReturn(group); + GroupSnapshot unlocked = group("ACTIVE", null, + new Member(9L, 30L, 10L, "IN-10", 4), new Member(19L, 31L, 11L, "IN-11", 6)); + when(coloadGroups.unlockWholeGroupAfterRemoval(eq(700L), eq(1L), anyString(), eq(7L), eq("u"))) + .thenReturn(unlocked); + + Plan result = service.removeUnpicked(1L, + new com.baiyue.firstleg.dto.SmartLoadingDtos.RemoveUnpickedRequest(2, "remove-unpicked-group"), + 7L, "u"); + + assertThat(result.items()).isEmpty(); + verify(repository, times(2)).remove(eq(1L), anyLong(), eq(0), eq(7L), eq("u")); + verify(coloadGroups).unlockWholeGroupAfterRemoval(eq(700L), eq(1L), + org.mockito.ArgumentMatchers.startsWith("UNLOCK-"), eq(7L), eq("u")); + } + @Test void proportionallyDistributesInboundQuantityWithoutLosingCartons() { Candidate first = new Candidate(9L, 10L, 20L, 30L, "O", "I", 100L, diff --git a/order/AGENTS.md b/order/AGENTS.md index 60f170340..637fe07b4 100644 --- a/order/AGENTS.md +++ b/order/AGENTS.md @@ -23,6 +23,7 @@ | 下单仓库主数据守卫 | `service/OrderMasterDataGuard.java`、`client/WarehouseClient.java`;校验公共收货仓、目的仓类型、状态、地址与收货能力,并保存服务端快照 | | 统一订单七状态与操作映射 | `../../docs/统一订单主状态与操作契约.md`;`LOADED` 硬门禁和 `DELIVERED` 可信回传同时核对三期需求确认表与开工门禁 | | 货物内部查询与箱唛绑定来源 | `controller/InternalOrderCargoController.java`;Firstleg 消费端为 `../firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java` | +| 智能装柜待审核候选内部查询 | `controller/InternalPendingReviewOrderController.java`、`service/PendingReviewOrderQueryService.java`、`dto/PendingReviewOrderDtos.java`;Firstleg 消费端为 `../firstleg/src/main/java/com/baiyue/firstleg/client/PendingReviewOrderClient.java`,契约 `src/main/resources/openapi/order-v1.yaml` | | 快照/Outbox | `service/`、`repository/`、`event/` | | DTO/持久化 | `dto/`、`entity/`、`mapper/`、`repository/` | | SQL/OpenAPI | `src/main/resources/db/`、`src/main/resources/openapi/` | @@ -34,6 +35,7 @@ - `inbound_code` 是跨客户全局唯一业务码;生成模式由 `InboundCodeModeProvider` 控制,序列与唯一约束不得退化为客户内唯一。修改格式、长度或生成时机时同步检查 Order SQL、Firstleg 入库快照、箱唛箱码、前端展示和全局唯一性契约测试。 - 下单和后续关键状态操作不得信任客户端仓库名称、类型或启用状态;公共仓库必须通过 Warehouse 内部接口按 ID 实时校验。私人地址不得伪装为公共目的仓,非私人目的地必须引用类型匹配的公共仓库;Warehouse 不可用时失败关闭,不回退到旧快照或模拟数据。 - Warehouse 往来单位、Firstleg 入库协作只通过客户端 DTO/内部令牌;修改契约需同步消费者。 +- 智能装柜待审核候选只返回 Order 权威 `SUBMITTED` 订单,并按服务身份、客户/仓库/渠道、入仓码、创建日期和分页条件查询;Firstleg 可据此提前占位,但不得把该内部查询或申报数量当作审核、测量或可物理装柜完成的事实。 - 中文批量模板按登录客户生成取货/私人地址、国内收货仓、目的仓和渠道下拉;仓库目录必须翻页读取全部候选,不得只取第一页。导入时重新读取并校验动态目录、客户归属和启用状态,不信任模板隐藏页;文件上限 5MB、有效数据最多 50 行,仅创建草稿,`PARTIAL` 允许单行失败、`ATOMIC` 任一行失败不得落单。 - 平台只能在 `DRAFT/REJECTED/SUBMITTED/APPROVED` 版本化修改客户原始申报;不得修改客户、国内收货仓、订单号、入仓码、状态或仓库/PDA/设备实测、测量审核和计费事实,不得增删或重排货物行。国内收货仓或中转仓变化必须重新下单。 - 只有渠道发生变化才同时调用 Firstleg 履约可逆性与重新计价门禁,任一内部校验不可用或失败均不写入;成功后 Outbox 必须携带完整申报快照和字段变化。Firstleg 消费按 `eventId + payload hash` 幂等、按订单版本单调推进,渠道变化才使未确认价格预估失效。 diff --git a/scripts/README.md b/scripts/README.md index ecb55e169..ee21e221b 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -64,6 +64,7 @@ Client 全量、Client 二期、待审核订单和批量待分柜均复用这套 |---|---|---| | 测试环境 HTTP | `admin-test-env-acceptance.mjs` | Admin 全模块真实 HTTP 验收;按模块创建、更新、停用测试数据 | | 测试环境 HTTP | `client-test-env-acceptance.mjs` | Client 完整真实 HTTP 链路;写入客户、订单、入库、权限和消息数据 | +| 测试环境 HTTP | `client-quick-order-test-env-acceptance.mjs` | Client 快速下单定向真实 HTTP 验收;使用既有测试客户创建 FBA、海外仓和私人地址草稿,验证逐行失败与幂等重放,不提交订单 | | 测试环境 HTTP | `client-phase2-test-env-acceptance.mjs` | Client 二期入库、测量、费用和权限专项;写测试数据 | | 测试环境 HTTP | `create-pending-review-orders.mjs` | 固定客户批量创建并提交待审核订单,不做平台审核和后续履约 | | 测试环境 HTTP | `it-test-wait-split-bulk.mjs` | 固定“IT测试”与“义乌仓”,渠道等随机,逐票推进到 `WAIT_SPLIT`;失败继续并落报告。只复用 `TP-001`/`YW-TP-002`,不新增托盘或库位,库位不足使用公共位 `A0` | @@ -101,6 +102,7 @@ Client 全量、Client 二期、待审核订单和批量待分柜均复用这套 |---|---|---|---| | 平台后台全模块 HTTP 验收 | `scripts/admin-test-env-acceptance.mjs` | 会创建、更新或最终停用测试数据 | `--modules` 默认列表中的平台客户、仓储、订单、入库、分柜、装柜、船期、拖车、报关、财务、通知、统计等模块 | | 客户工作台完整 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 全量入口 | | 人工分柜至 LOADED 分段验收 | `scripts/admin-test-env-acceptance.mjs` 的 `manual-*-fixture/verify` 可选模块;步骤和参数见 `scripts/admin-test-env-modules/README.md` | fixture 通过正式 HTTP 准备本轮数据,verify 只读核验;不进入默认全量 | `WAIT_SPLIT → 人工分柜 → 推送预装 → 订舱关联/计划确认 → PDA 领取与原子 batch → 五类证据/完成 → LOADED` | @@ -109,6 +111,16 @@ Client 全量、Client 二期、待审核订单和批量待分柜均复用这套 推荐顺序:部署服务 → Admin 全模块 HTTP 验收 → Client 完整 HTTP 链路验收 → 前端真实页面调用和样式验收 → PDA 页面/设备验收。任何本地单测通过都不能替代 HTTP 全量验收。 +## Client 快速下单定向验收 + +快速下单页面使用独立批量草稿接口,不等同于 Excel 批量导入。脚本按 Client 页面当前真实请求构造最小草稿,包含页面只要求 SKU 和箱数、其余货物字段为空值或零值的行为,用于及时发现前后端契约漂移。它会使用测试客户创建 FBA、海外仓和私人地址草稿,并验证无权限请求、单行失败隔离、详情客户归属与同幂等键重放。 + +```powershell +node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/client-quick-order-test-env-acceptance.mjs +``` + +报告写入 `reports/client-quick-order-test-env-*.json`。脚本不提交、不审核、不取消订单,成功草稿保留为可丢弃测试数据;真实 HTTP 仅在当前轮明确授权测试环境验收后执行。 + ## 固定客户待审核订单 需要为人工审核准备固定数量的订单时,使用 `create-pending-review-orders.mjs`。Gateway 统一读取 `ADMIN_TEST_GATEWAY`,客户账号统一读取 `MERCHANT_TEST_USERNAME/PASSWORD`;数量可通过 `--count` 或 `PENDING_REVIEW_ORDER_COUNT` 设置,默认 50。脚本不会创建客户,也不会调用平台审核、入库或后续履约接口。数量支持 `1-100`,每张订单创建后立即提交并回查客户端状态为 `SUBMITTED`,报告统一记录为 `PENDING_REVIEW`。 diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 1978cd4e1..7e4d192d6 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -151,8 +151,8 @@ Assert-Contains $clientAgents '中文批量导入' 'Client 批量导入入口' Assert-Contains $firstlegAgents '客户四档箱规与默认算法' 'Firstleg 客户箱规入口' Assert-Contains $firstlegAgents '`ADD/MULTIPLY`' 'Firstleg 箱规算法约束' Assert-Contains $adminAgents '客户箱规策略与计价主数据' 'Admin 箱规计费入口' -Assert-Contains $backendAgents '全局智能装柜候选、柜次与箱数分配' '后端全局智能装柜路由' -Assert-Contains $firstlegAgents '同柜不可拆组必须成员完整、满箱且作为单个分页与分配单元' 'Firstleg 智能装柜组分页边界' +Assert-Contains $backendAgents '全局智能装柜候选、柜次、占位与移除' '后端全局智能装柜路由' +Assert-Contains $firstlegAgents '必须成员完整、满箱并作为单个分页与分配单元' 'Firstleg 智能装柜组分页边界' Assert-Contains $adminAgents 'GlobalSmartLoadingView.vue' 'Admin 全局智能装柜入口' Assert-Contains $financeAgents 'StatementLine.inboundCode' 'Finance 账单入仓码字段' Assert-Contains $clientAgents '账单行只显示服务端 `inboundCode`' 'Client 账单入仓码联查边界' @@ -189,6 +189,35 @@ Assert-Contains $unifiedOrderQueryService 'order.getStatus() == OrderStatus.SUBM Assert-Contains $unifiedOrderQueryService 'pendingReview ? "PENDING_REVIEW" : summary.mainStatus()' 'Order 待审核主状态优先' Assert-Contains $unifiedOrderQueryService 'pendingReview ? "APPROVE_ORDER" : summary.nextAction()' 'Order 待审核动作优先' +# 智能装柜已扩展到四态候选、Order 待审核补齐、提前占位与未拣整批移除;费用确认和会话恢复也必须可定向命中。 +$pendingReviewOrderService = Read-RequiredText (Join-Path $backendRoot 'order/src/main/java/com/baiyue/order/service/PendingReviewOrderQueryService.java') +$merchantInboundBillingService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java') +$clientSession = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/api/session.ts') +$clientHttp = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/api/http.ts') +Assert-Contains $rootAgents '`PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING`' '根智能装柜四态候选' +Assert-Contains $rootAgents '能否加入以服务端 `canAssign` 为准' '根智能装柜资格边界' +Assert-Contains $rootAgents '人工加入允许跨渠道,智能推荐仍按柜次渠道' '根智能装柜渠道边界' +Assert-Contains $backendAgents '/pending-review-assignments' '后端待审核装柜占位入口' +Assert-Contains $backendAgents '/unpicked-items' '后端未拣整批移除入口' +Assert-Contains $firstlegAgents '任一成员已拣则整组保留' 'Firstleg 同柜组整批移除边界' +Assert-Contains $orderAgents 'PendingReviewOrderQueryService.java' 'Order 待审核装柜候选入口' +Assert-Contains $adminAgents '按钮资格以服务端 `canAssign` 为准' 'Admin 智能装柜资格边界' +Assert-Contains $firstlegOpenApi '未传或空数组表示 PENDING_REVIEW、WAIT_MEASURE、WAIT_SPLIT、' '智能装柜四态 OpenAPI' +Assert-Contains $firstlegOpenApi '/api/platform/v1/first-leg/smart-loading/plans/{planId}/pending-review-assignments:' '待审核占位 OpenAPI' +Assert-Contains $firstlegOpenApi '/api/platform/v1/first-leg/smart-loading/plans/{planId}/unpicked-items:' '未拣整批移除 OpenAPI' +Assert-Contains $pendingReviewOrderService 'OrderStatus.SUBMITTED' 'Order 待审核候选状态事实' +Assert-Contains $rootAgents '客户入库结果与头程费用确认' '根客户入库费用确认入口' +Assert-Contains $firstlegAgents 'expectedFeeVersion' 'Firstleg 客户费用版本确认' +Assert-Contains $clientAgents 'components/InboundBillingPanel.vue' 'Client 入库费用确认入口' +Assert-Contains $firstlegOpenApi '/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing/confirm:' '客户费用确认 OpenAPI' +Assert-Contains $merchantInboundBillingService 'pricing.confirmByCustomer' 'Firstleg 客户费用确认服务' +Assert-Contains $clientAgents '“记住账号”只记登录标识' 'Client 记住账号边界' +Assert-Contains $clientSession 'localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken)' 'Client 刷新令牌持久化' +Assert-Contains $clientHttp "'/auth/refresh'" 'Client 公开刷新入口' +Assert-Path 'baiyue-firstleg/order/src/main/java/com/baiyue/order/controller/InternalPendingReviewOrderController.java' +Assert-Path 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/controller/MerchantInboundBillingController.java' +Assert-Path 'apps/client-web/src/modules/first-leg/components/InboundBillingPanel.vue' + # 企业联系信息与账号登录标识已经解耦,资质审核不得重新把法人身份证设为必填。 $authAgents = Read-RequiredText (Join-Path $backendRoot 'auth/AGENTS.md') $iamAgents = Read-RequiredText (Join-Path $backendRoot 'iam/AGENTS.md') diff --git a/scripts/client-quick-order-test-env-acceptance.contract.test.mjs b/scripts/client-quick-order-test-env-acceptance.contract.test.mjs new file mode 100644 index 000000000..3e4ccfb3e --- /dev/null +++ b/scripts/client-quick-order-test-env-acceptance.contract.test.mjs @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +const source = await readFile(new URL('./client-quick-order-test-env-acceptance.mjs', import.meta.url), 'utf8') + +test('quick-order HTTP acceptance uses the same endpoint and skeletal cargo payload as Client', () => { + assert.match(source, /\/api\/merchant\/v1\/orders\/first-leg\/batch/) + assert.match(source, /cargoName: '', packageType: 'CARTON', packageCount: 2, grossWeightKg: 0, volumeCbm: 0/) + assert.match(source, /destinationType: 'FBA'/) + assert.match(source, /destinationType: 'OVERSEAS'/) + assert.match(source, /destinationType: 'PRIVATE'/) + assert.match(source, /addresses\?addressType=PRIVATE_DESTINATION/) + assert.match(source, /privateDestinationAddressId: String\(privateAddress\.id\)/) +}) + +test('quick-order HTTP acceptance verifies partial failure, detail ownership, and idempotent replay', () => { + assert.match(source, /expected succeeded=3/) + assert.match(source, /invalid channel row must fail independently/) + assert.match(source, /draft must belong to the authenticated enterprise/) + assert.match(source, /same idempotency key replays the same quick-order drafts/) + assert.match(source, /idempotent replay returned different order IDs/) +}) diff --git a/scripts/client-quick-order-test-env-acceptance.mjs b/scripts/client-quick-order-test-env-acceptance.mjs new file mode 100644 index 000000000..77923366b --- /dev/null +++ b/scripts/client-quick-order-test-env-acceptance.mjs @@ -0,0 +1,270 @@ +#!/usr/bin/env node + +import { randomUUID } from 'node:crypto' +import { mkdir, writeFile } from 'node:fs/promises' +import { resolve } from 'node:path' + +const args = parseArgs(process.argv.slice(2)) +if (args.help) { + console.log('Usage: node scripts/client-quick-order-test-env-acceptance.mjs [--report-dir ] [--timeout-ms ]') + process.exit(0) +} + +const gateway = trimSlash(process.env.ADMIN_TEST_GATEWAY) +const username = process.env.MERCHANT_TEST_USERNAME +const password = process.env.MERCHANT_TEST_PASSWORD +const timeoutMs = Number(args['timeout-ms'] ?? process.env.CLIENT_TEST_TIMEOUT_MS ?? 30_000) +const reportDir = resolve(args['report-dir'] ?? process.env.CLIENT_TEST_REPORT_DIR ?? 'reports') +const marker = `${compactTimestamp()}-${randomUUID().slice(0, 8)}` +const reportPath = resolve(reportDir, `client-quick-order-test-env-${marker}.json`) +const report = { + marker, + generatedAt: new Date().toISOString(), + completedAt: null, + success: false, + fatal: null, + resources: { batchKey: null, orderNos: [] }, + steps: [], +} + +if (!gateway || !username || !password) { + console.error('ADMIN_TEST_GATEWAY, MERCHANT_TEST_USERNAME and MERCHANT_TEST_PASSWORD are required') + process.exit(2) +} +if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) { + console.error('--timeout-ms must be at least 1000') + process.exit(2) +} + +let token +try { + await step('unauthenticated batch request is rejected', async () => { + const response = await rawRequest('/api/merchant/v1/orders/first-leg/batch', { + method: 'POST', body: [], headers: { 'Idempotency-Key': `quick-order-anonymous-${marker}` }, + }) + assert([401, 403].includes(response.status), `expected HTTP 401/403, got ${response.status}`) + return { httpStatus: response.status } + }) + + const login = await step('merchant login', async () => { + const response = await jsonRequest('/api/auth/login', { + method: 'POST', body: { loginId: username, password }, authenticated: false, + }) + requireSuccess(response) + assert(response.data?.accessToken, 'accessToken is required') + token = response.data.accessToken + return { authenticated: true } + }) + assert(login.authenticated, 'login did not complete') + + const profile = await step('load authoritative enterprise profile', async () => { + const response = await jsonRequest('/api/merchant/v1/iam/tenant/profile') + requireSuccess(response) + assert(response.data?.tenantName?.trim(), 'tenantName is required') + return { tenantId: String(response.data.id), tenantName: response.data.tenantName.trim() } + }) + const policy = await step('load inbound-code policy', async () => { + const response = await jsonRequest('/api/merchant/v1/orders/first-leg/inbound-code-policy') + requireSuccess(response) + assert(['AUTO', 'LEGACY_MANUAL'].includes(response.data?.mode), `unexpected policy ${response.data?.mode}`) + return response.data + }) + const privateAddresses = await step('load customer-owned private destination addresses', async () => { + const response = await jsonRequest('/api/merchant/v1/orders/first-leg/addresses?addressType=PRIVATE_DESTINATION') + requireSuccess(response) + const addresses = arrayData(response.data) + assert(addresses.length > 0, 'at least one customer-owned private destination address is required') + return addresses + }) + const domestic = await loadWarehouses('DOMESTIC') + const fba = await loadWarehouses('FBA') + const overseas = await loadWarehouses('OVERSEAS') + assert(domestic.length > 0, 'at least one ACTIVE domestic warehouse is required') + assert(fba.length > 0, 'at least one ACTIVE FBA warehouse is required') + assert(overseas.length > 0, 'at least one ACTIVE overseas warehouse is required') + + let receiving + let channel + for (const warehouse of domestic) { + const response = await jsonRequest(`/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${warehouse.id}`) + requireSuccess(response) + const candidate = arrayData(response.data).find((item) => arrayData(item.pricingTaxModes).length > 0) + if (candidate) { receiving = warehouse; channel = candidate; break } + } + assert(receiving && channel, 'no domestic warehouse has an active pricing channel') + + const privateAddress = privateAddresses[0] + const privateDestination = { + destinationType: 'PRIVATE', destinationCountryCode: privateAddress.countryCode, + destinationWarehouseName: privateAddress.addressName, privateDestinationAddressId: String(privateAddress.id), + } + const rows = [ + { clientRowId: `quick-order-fba-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel, { + destinationType: 'FBA', destinationCountryCode: fba[0].countryCode, destinationWarehouseId: fba[0].id, + destinationWarehouseName: fba[0].warehouseName, amazonWarehouseCode: fba[0].warehouseCode, + }, 'FBA') }, + { clientRowId: `quick-order-overseas-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel, { + destinationType: 'OVERSEAS', destinationCountryCode: overseas[0].countryCode, + destinationWarehouseId: overseas[0].id, destinationWarehouseName: overseas[0].warehouseName, + }, 'OVERSEAS') }, + { clientRowId: `quick-order-private-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel, + privateDestination, 'PRIVATE') }, + { clientRowId: `quick-order-invalid-channel-${marker}`, draft: { + ...quickDraft(profile.tenantName, policy, receiving, channel, { + destinationType: 'FBA', destinationCountryCode: fba[0].countryCode, destinationWarehouseId: fba[0].id, + destinationWarehouseName: fba[0].warehouseName, amazonWarehouseCode: fba[0].warehouseCode, + }, 'INVALID'), channelCode: `INVALID-${marker}`, channelName: '不存在的渠道', + } }, + ] + + const batchKey = `quick-order-http-${randomUUID()}` + report.resources.batchKey = batchKey + const created = await step('create FBA, overseas and private quick-order drafts with one invalid row', async () => { + const response = await jsonRequest('/api/merchant/v1/orders/first-leg/batch', { + method: 'POST', body: rows, headers: { 'Idempotency-Key': batchKey }, + }) + requireSuccess(response) + assert(response.data?.total === 4, `expected total=4, got ${response.data?.total}`) + const rowEvidence = arrayData(response.data?.items).map((item) => ({ + clientRowId: item.clientRowId, success: item.success, orderId: item.orderId, orderNo: item.orderNo, + errorCode: item.errorCode, errorMessage: item.errorMessage, + })) + assert(response.data?.succeeded === 3, + `expected succeeded=3, got ${response.data?.succeeded}; rows=${JSON.stringify(rowEvidence)}`) + assert(response.data?.failed === 1, + `expected failed=1, got ${response.data?.failed}; rows=${JSON.stringify(rowEvidence)}`) + const failed = response.data.items.find((item) => item.clientRowId === rows[3].clientRowId) + assert(failed && !failed.success && failed.errorCode, 'invalid channel row must fail independently') + return { ...response.data, items: rowEvidence } + }) + + const successfulItems = created.items.filter((item) => item.success) + for (const item of successfulItems) { + await step(`verify created draft ${item.clientRowId}`, async () => { + assert(item.orderId != null, 'successful row must include orderId') + assert(item.orderNo, 'successful row must include orderNo') + const response = await jsonRequest(`/api/merchant/v1/orders/first-leg/${item.orderId}`) + requireSuccess(response) + assert(response.data?.order?.status === 'DRAFT', `expected DRAFT, got ${response.data?.order?.status}`) + assert(response.data?.order?.customerName === profile.tenantName, 'draft must belong to the authenticated enterprise') + report.resources.orderNos.push(item.orderNo) + return { orderNo: item.orderNo, status: response.data.order.status, customerName: response.data.order.customerName } + }) + } + + await step('same idempotency key replays the same quick-order drafts', async () => { + const response = await jsonRequest('/api/merchant/v1/orders/first-leg/batch', { + method: 'POST', body: rows, headers: { 'Idempotency-Key': batchKey }, + }) + requireSuccess(response) + const originalIds = successfulItems.map((item) => String(item.orderId)) + const replayIds = response.data.items.filter((item) => item.success).map((item) => String(item.orderId)) + assert(JSON.stringify(replayIds) === JSON.stringify(originalIds), 'idempotent replay returned different order IDs') + return { replayedOrderNos: response.data.items.filter((item) => item.success).map((item) => item.orderNo) } + }) + + report.success = report.steps.every((item) => item.passed) +} catch (error) { + report.fatal = sanitizeError(error) + report.success = false +} finally { + report.completedAt = new Date().toISOString() + await mkdir(reportDir, { recursive: true }) + await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8') + console.log(JSON.stringify({ success: report.success, reportPath, orderNos: report.resources.orderNos, fatal: report.fatal })) +} + +process.exitCode = report.success ? 0 : 1 + +function quickDraft(customerName, policy, receiving, channel, destination, suffix) { + const inboundCode = policy.mode === 'LEGACY_MANUAL' ? `QO${compactTimestamp()}${suffix.slice(0, 3)}` : undefined + return { + customerName, + transportMode: 'SEA', tradeTerm: 'FOB', expectedShipDate: undefined, customerRemark: `快速下单 HTTP 验收 ${marker} ${suffix}`, + pickupMethod: 'SELF_EXPRESS', receivingWarehouseId: receiving.id, receivingWarehouseName: receiving.warehouseName, + channelCode: channel.channelCode, channelName: channel.channelName, + pricingTaxMode: channel.pricingTaxModes[0], taxMode: 'BUY_DECLARATION', containerMode: 'FULL_CONTAINER', + customsInspection: null, inboundCode, ...destination, + cargoes: [{ + cargoName: '', packageType: 'CARTON', packageCount: 2, grossWeightKg: 0, volumeCbm: 0, + currency: 'USD', dangerousGoods: false, woodenProduct: false, skuNo: `SKU-${suffix}-${marker}`, + totalQuantity: 2, unitsPerPackage: 1, + }], + } +} + +async function loadWarehouses(type) { + const all = [] + for (let page = 1; ; page += 1) { + const response = await jsonRequest(`/api/merchant/v1/warehouses?page=${page}&size=200&status=ACTIVE&warehouseType=${type}`) + requireSuccess(response) + all.push(...pageRecords(response.data)) + if (page >= Number(response.data?.pages ?? 1)) return all + } +} + +async function step(name, action) { + const startedAt = new Date().toISOString() + try { + const evidence = await action() + report.steps.push({ name, passed: true, startedAt, completedAt: new Date().toISOString(), evidence }) + return evidence + } catch (error) { + report.steps.push({ name, passed: false, startedAt, completedAt: new Date().toISOString(), error: sanitizeError(error) }) + throw error + } +} + +async function rawRequest(path, { method = 'GET', body, headers = {}, authenticated = true } = {}) { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + const requestHeaders = { ...headers } + if (authenticated && token) requestHeaders.Authorization = `Bearer ${token}` + let requestBody = body + if (body != null && !(body instanceof FormData)) { + requestHeaders['Content-Type'] = 'application/json' + requestBody = JSON.stringify(body) + } + try { + return await fetch(`${gateway}${path}`, { method, headers: requestHeaders, body: requestBody, signal: controller.signal }) + } finally { + clearTimeout(timer) + } +} + +async function jsonRequest(path, options) { + const response = await rawRequest(path, options) + const text = await response.text() + let json + try { json = text ? JSON.parse(text) : {} } + catch { throw new Error(`${path} returned non-JSON HTTP ${response.status}`) } + if (!response.ok) throw new Error(`${path} HTTP ${response.status} code=${json.code ?? '—'} requestId=${json.requestId ?? '—'} traceId=${json.traceId ?? '—'}`) + return json +} + +function requireSuccess(response) { + if (String(response?.code) !== '200') { + throw new Error(`business failure code=${response?.code ?? '—'} message=${response?.message ?? '—'} requestId=${response?.requestId ?? '—'} traceId=${response?.traceId ?? '—'}`) + } +} + +function parseArgs(values) { + const result = {} + for (let index = 0; index < values.length; index += 1) { + const value = values[index] + if (value === '--help') { result.help = true; continue } + if (!value.startsWith('--')) throw new Error(`Unknown argument: ${value}`) + const key = value.slice(2) + const next = values[++index] + if (!next || next.startsWith('--')) throw new Error(`--${key} requires a value`) + result[key] = next + } + return result +} + +function trimSlash(value) { return String(value ?? '').replace(/\/+$/, '') } +function compactTimestamp() { return new Date().toISOString().replace(/\D/g, '').slice(0, 14) } +function pageRecords(value) { return value?.records ?? value?.content ?? value?.items ?? [] } +function arrayData(value) { return Array.isArray(value) ? value : [] } +function assert(condition, message) { if (!condition) throw new Error(message) } +function sanitizeError(error) { return { name: error?.name ?? 'Error', message: String(error?.message ?? error) } }