From 7fd2e700247924c93f9313a191f452a3e8565752 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 11 Sep 2026 11:15:01 +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 --- firstleg/AGENTS.md | 1 + .../service/LoadingModelBridgeService.java | 3 +-- .../split/service/SplitPreloadService.java | 25 +++++++++---------- .../LoadingModelBridgeServiceTest.java | 14 +++++++++++ .../service/SplitPreloadServiceTest.java | 14 ++++++----- 5 files changed, 36 insertions(+), 21 deletions(-) diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index a301acdd1..841f6439b 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -86,6 +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` 时可继续新增,未产生实物拣货的明细可调整或移除,已拣明细保持锁定。 - 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间;客户未确认或该票尚未装柜完成时,平台仍可修改箱规规则、生成新费用版本并重新发送,新版本必须清空旧版客户确认快照。仅当“客户已确认且该票已装柜完成”同时成立时最终冻结;装柜完成不得代替客户自动确认。旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。 - PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。装柜允许跨国内仓,仓库与渠道限制统一由上游装柜编排负责;PDA 只按已生成任务读取各入仓单自身的 `CONFIRMED` 库位资源,不得再用柜次仓库过滤任务明细、库存快照或拣货写入。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。 - PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeService.java index 9c41f4913..f0989d6d7 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeService.java @@ -134,8 +134,7 @@ public class LoadingModelBridgeService { var preload = repository.preload(tenantId, inboundId); if (preload == null) throw new BizException(ResultCode.NOT_FOUND, "入库单预装计划不存在"); boolean running = "LOADING".equals(plan.status()); - if (!Set.of("PENDING", "CONFIRMED").contains(preload.status()) - && !(running && "CONFIRMING".equals(preload.status()))) { + if (!Set.of("PENDING", "CONFIRMING", "CONFIRMED").contains(preload.status())) { throw conflict("新增入库单预装状态不允许进入当前柜次"); } PreloadLoadingPlanLinkEntity link = new PreloadLoadingPlanLinkEntity(); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java b/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java index 14c5fdf5b..3d9fb3f29 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java @@ -82,8 +82,8 @@ public class SplitPreloadService { } PreloadPlanEntity plan = repository.findPlan(tenantId, inboundId); if (plan != null) { - if (!PreloadStatus.PENDING.equals(plan.getStatus())) { - throw new BizException(ResultCode.CONFLICT, "预装计划已进入确认流程,不能修改智能装柜分配"); + if (!reusableForLoadingAllocation(plan.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "预装计划当前状态不能修改智能装柜分配"); } return; } @@ -96,17 +96,13 @@ public class SplitPreloadService { repository.createPlan(plan); } catch (DuplicateKeyException exception) { PreloadPlanEntity concurrent = repository.findPlan(tenantId, inboundId); - if (concurrent == null || !PreloadStatus.PENDING.equals(concurrent.getStatus())) { + if (concurrent == null || !reusableForLoadingAllocation(concurrent.getStatus())) { throw new BizException(ResultCode.CONFLICT, "预装计划已被并发修改,请刷新后重试"); } } } - /** - * A running PDA task may restore or add an unpicked allocation after the plan-level preload - * confirmation. A running PDA task owns the mutation lock, so CONFIRMING and CONFIRMED - * lifecycles may be reused while the restored loading item is synchronised atomically. - */ + /** Reuses the confirmed preload lifecycle while a running PDA task owns the mutation lock. */ @Transactional public void ensureForRunningLoadingAllocation(long tenantId, long inboundId, long operatorId, String operatorName) { @@ -126,10 +122,8 @@ public class SplitPreloadService { throw new BizException(ResultCode.CONFLICT, "装柜中的订单缺少既有预装计划,不能新增分配"); } if (plan != null) { - if (!PreloadStatus.PENDING.equals(plan.getStatus()) - && !(running && Set.of(PreloadStatus.CONFIRMING, PreloadStatus.CONFIRMED) - .contains(plan.getStatus()))) { - throw new BizException(ResultCode.CONFLICT, "预装计划已进入确认流程,不能修改智能装柜分配"); + if (!reusableForLoadingAllocation(plan.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "预装计划当前状态不能修改智能装柜分配"); } return; } @@ -142,12 +136,17 @@ public class SplitPreloadService { repository.createPlan(plan); } catch (DuplicateKeyException exception) { PreloadPlanEntity concurrent = repository.findPlan(tenantId, inboundId); - if (concurrent == null || !PreloadStatus.PENDING.equals(concurrent.getStatus())) { + if (concurrent == null || !reusableForLoadingAllocation(concurrent.getStatus())) { throw new BizException(ResultCode.CONFLICT, "预装计划已被并发修改,请刷新后重试"); } } } + private static boolean reusableForLoadingAllocation(String status) { + return Set.of(PreloadStatus.PENDING, PreloadStatus.CONFIRMING, PreloadStatus.CONFIRMED) + .contains(status); + } + public PreloadPlanResponse detail(long tenantId, long inboundId) { requireInbound(tenantId, inboundId); return response(requirePlan(tenantId, inboundId)); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeServiceTest.java index 67d4b2ed2..9059491b7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeServiceTest.java @@ -278,6 +278,20 @@ class LoadingModelBridgeServiceTest { verify(repository).confirmIncrementalPreload(30L, 10L, 20L, 4, 7L, "admin"); } + @Test + void bindsConfirmingPreloadToEditingPlanWithoutFinalizingIt() { + when(repository.byPair(10, 20, 40)).thenReturn(null); + when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "EDITING", 3)); + when(repository.allocatedPackages(10, 20, 40)).thenReturn(2); + when(repository.preload(10, 20)).thenReturn(new PreloadSnapshot(30L, 10L, 20L, "CONFIRMING", 4)); + + service.bindIncrementalAllocation(40L, 10L, 20L, 7L, "admin", "assign-confirming-editing"); + + verify(repository).insert(any()); + verify(repository, never()).confirmIncrementalPreload(anyLong(), anyLong(), anyLong(), + anyInt(), anyLong(), anyString()); + } + private static Link link(String preloadStatus, int preloadVersion, String loadingStatus, int loadingVersion, int packages) { return new Link(1L, 10L, 20L, 30L, preloadStatus, preloadVersion, 40L, 50L, "C1", diff --git a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java index ceca203bf..0f52cade2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java @@ -84,14 +84,16 @@ class SplitPreloadServiceTest { } @Test - void loadingAllocationDoesNotOverwriteConfirmedPreload() { + void loadingAllocationMayReuseConfirmingAndConfirmedPreloads() { when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 3)); - PreloadPlanEntity plan = new PreloadPlanEntity(); - plan.setStatus("CONFIRMING"); - when(repository.findPlan(10, 20)).thenReturn(plan); + PreloadPlanEntity confirming = new PreloadPlanEntity(); + confirming.setStatus("CONFIRMING"); + PreloadPlanEntity confirmed = new PreloadPlanEntity(); + confirmed.setStatus("CONFIRMED"); + when(repository.findPlan(10, 20)).thenReturn(confirming, confirmed); - assertThatThrownBy(() -> service.ensurePendingForLoadingAllocation(10, 20, 7, "admin")) - .hasMessageContaining("已进入确认流程"); + service.ensurePendingForLoadingAllocation(10, 20, 7, "admin"); + service.ensurePendingForLoadingAllocation(10, 20, 7, "admin"); verify(repository, never()).createPlan(any()); }