From e281a8f20b9015e0d77ab579fbecabae0d5409ca Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 7 Sep 2026 12:16:55 +0800 Subject: [PATCH] fix: preserve hold binding replays --- .../service/LoadingModelBridgeService.java | 16 +++--- .../LoadingModelBridgeServiceTest.java | 51 ++++++++++++++++++- 2 files changed, 57 insertions(+), 10 deletions(-) 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 6c9a8ce28..9c41f4913 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 @@ -32,7 +32,6 @@ public class LoadingModelBridgeService { resourceId = "#loadingPlanId", description = "关联入库预装与柜次装柜计划") public Link bind(long tenantId, long inboundId, long loadingPlanId, long operatorId, String operatorName, String idempotencyKey, BindRequest request) { - inboundHoldGuard.requireInboundNotHeld(tenantId, inboundId); String key = validateKey(idempotencyKey); String requestHash = hash(tenantId + "|" + inboundId + "|" + loadingPlanId + "|" + request.expectedPreloadVersion() + "|" + request.expectedLoadingPlanVersion()); @@ -44,6 +43,7 @@ public class LoadingModelBridgeService { if (repository.byPair(tenantId, inboundId, loadingPlanId) != null) { return requireLink(tenantId, inboundId, loadingPlanId); } + inboundHoldGuard.requireInboundNotHeld(tenantId, inboundId); var preload = repository.preload(tenantId, inboundId); if (preload == null) throw new BizException(ResultCode.NOT_FOUND, "入库单预装计划不存在"); @@ -80,7 +80,6 @@ public class LoadingModelBridgeService { @Transactional public List bindAllocatedBeforeLoading(long loadingPlanId, long operatorId, String operatorName, String orchestrationKey) { - inboundHoldGuard.requirePlanNotHeld(loadingPlanId); var loadingPlan = repository.loadingPlan(loadingPlanId); if (loadingPlan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次装柜计划不存在"); List scopes = repository.allocatedScopes(loadingPlanId); @@ -89,14 +88,15 @@ public class LoadingModelBridgeService { && scopes.stream().anyMatch(scope -> !"WAIT_SPLIT".equals(scope.processStatus()))) { throw conflict("装柜清单仍有订单未完成审核和测量,不能开始PDA作业"); } + boolean allLinked = scopes.stream().allMatch(scope -> repository.byPair( + scope.tenantId(), scope.inboundId(), loadingPlanId) != null); + if (Set.of("EDITING", "LOADING", "COMPLETED").contains(loadingPlan.status()) && allLinked) { + return repository.byLoadingPlan(loadingPlanId); + } if (!"EDITING".equals(loadingPlan.status())) { - if (Set.of("LOADING", "COMPLETED").contains(loadingPlan.status()) - && scopes.stream().allMatch(scope -> repository.byPair( - scope.tenantId(), scope.inboundId(), loadingPlanId) != null)) { - return repository.byLoadingPlan(loadingPlanId); - } throw conflict("PDA开始前的预装关联不完整,禁止在LOADING后补关联"); } + inboundHoldGuard.requirePlanNotHeld(loadingPlanId); for (AllocationScope scope : scopes) { // Internal push replay is pair-idempotent: an existing association has // already advanced both versions, so calling public bind again would @@ -121,8 +121,8 @@ public class LoadingModelBridgeService { @Transactional public void bindIncrementalAllocation(long loadingPlanId, long tenantId, long inboundId, long operatorId, String operatorName, String operationId) { - inboundHoldGuard.requireInboundNotHeld(tenantId, inboundId); if (repository.byPair(tenantId, inboundId, loadingPlanId) != null) return; + inboundHoldGuard.requireInboundNotHeld(tenantId, inboundId); var plan = repository.loadingPlan(loadingPlanId); if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次装柜计划不存在"); if (!Set.of("EDITING", "LOADING").contains(plan.status())) { 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 6455be97f..5aeaa7465 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 @@ -31,7 +31,7 @@ class LoadingModelBridgeServiceTest { @InjectMocks LoadingModelBridgeService service; @Test - void heldInboundOrPlanBlocksEveryPreloadBindingWriteBoundary() { + void heldInboundOrPlanBlocksEveryNewPreloadBindingWriteBoundary() { doThrow(new BizException(ResultCode.CONFLICT, "入库单已扣货")) .when(inboundHoldGuard).requireInboundNotHeld(10L, 20L); doThrow(new BizException(ResultCode.CONFLICT, "入库单已扣货")) @@ -45,7 +45,41 @@ class LoadingModelBridgeServiceTest { 40L, 10L, 20L, 7L, "admin", "incremental-held")) .isInstanceOf(BizException.class).hasMessageContaining("扣货"); - verifyNoInteractions(repository); + verify(repository).byIdempotency(7L, "bind-held"); + verify(repository, times(2)).byPair(10L, 20L, 40L); + verify(repository).loadingPlan(40L); + verifyNoMoreInteractions(repository); + } + + @Test + void holdDoesNotBreakReadOnlyIdempotentBindingReplays() { + var replay = new PreloadLoadingPlanLinkEntity(); + replay.setRequestHash(hashFor(10L, 20L, 40L, 1, 1)); + when(repository.byIdempotency(7L, "bind-replay")).thenReturn(replay); + Link linked = link("PENDING", 2, "EDITING", 3, 8); + when(repository.byInbound(10L, 20L)).thenReturn(List.of(linked)); + + assertThat(service.bind(10L, 20L, 40L, 7L, "admin", "bind-replay", + new BindRequest(1, 1))).isEqualTo(linked); + + verifyNoInteractions(inboundHoldGuard); + verify(repository, never()).insert(any()); + } + + @Test + void holdDoesNotBreakReadOnlyPlanOrIncrementalReplays() { + when(repository.loadingPlan(40L)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "LOADING", 4)); + when(repository.allocatedScopes(40L)).thenReturn(List.of(new AllocationScope(10L, 20L, 8, "WAIT_SPLIT"))); + when(repository.byPair(10L, 20L, 40L)).thenReturn(new PreloadLoadingPlanLinkEntity()); + Link linked = link("CONFIRMED", 3, "LOADING", 4, 8); + when(repository.byLoadingPlan(40L)).thenReturn(List.of(linked)); + + assertThat(service.bindAllocatedBeforeLoading(40L, 7L, "admin", "push-replay")) + .containsExactly(linked); + service.bindIncrementalAllocation(40L, 10L, 20L, 7L, "admin", "incremental-replay"); + + verifyNoInteractions(inboundHoldGuard); + verify(repository, never()).insert(any()); } @Test @@ -244,4 +278,17 @@ class LoadingModelBridgeServiceTest { return new Link(1L, 10L, 20L, 30L, preloadStatus, preloadVersion, 40L, 50L, "C1", loadingStatus, loadingVersion, packages, LocalDateTime.now()); } + + private static String hashFor(long tenantId, long inboundId, long loadingPlanId, + int preloadVersion, int loadingPlanVersion) { + try { + var digest = java.security.MessageDigest.getInstance("SHA-256"); + String value = tenantId + "|" + inboundId + "|" + loadingPlanId + "|" + + preloadVersion + "|" + loadingPlanVersion; + return java.util.HexFormat.of().formatHex(digest.digest( + value.getBytes(java.nio.charset.StandardCharsets.UTF_8))); + } catch (java.security.NoSuchAlgorithmException exception) { + throw new AssertionError(exception); + } + } }