From 3b88e5f6ab6c9aceb226708beb61e85ca1909762 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 18:24:04 +0800 Subject: [PATCH] fix(firstleg): reprice container fee on loading changes --- .../firstleg/service/LoadingListService.java | 27 ++++++++++ .../firstleg/service/SmartLoadingService.java | 52 +++++++++++++++++-- .../service/LoadingListServiceTest.java | 28 ++++++++++ .../service/SmartLoadingServiceTest.java | 7 +++ 4 files changed, 110 insertions(+), 4 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java index 8875925df..db4f97006 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -74,6 +74,8 @@ public class LoadingListService { @Resource private LoadingCompletedOutboxService loadingCompletedOutboxService; @Resource + private FbaPricingService fbaPricingService; + @Resource private ObjectMapper objectMapper; @Resource private LoadingDocumentFileClient documentFiles; @@ -501,6 +503,7 @@ public class LoadingListService { AggregateRow before = requirePlan(planId); List links = loadingPickService.requirePreloadConfirmationReady(planId); + requireContainerFeesReadyForPreloadConfirmation(links, operationId); if (repository.touchForPreloadConfirmation(planId, request.expectedVersion(), operatorId, trim(operatorName)) != 1) { throw conflict("预装计划已被其他操作修改,请刷新后重试"); @@ -610,6 +613,30 @@ public class LoadingListService { } } + /** + * Pre-review/pre-measurement members may be reserved in an editing plan. Confirmation is the + * hard boundary: every customer group in the cabinet must have a ready, freshly recomputed fee. + */ + private void requireContainerFeesReadyForPreloadConfirmation(List links, String operationId) { + Map> tenantsByContainer = new LinkedHashMap<>(); + for (Link link : links) { + if (link.containerBookingId() == null || link.tenantId() == null) { + throw conflict("预装关联缺少柜次或客户归属,不能确认预装"); + } + tenantsByContainer.computeIfAbsent(link.containerBookingId(), ignored -> new java.util.LinkedHashSet<>()) + .add(link.tenantId()); + } + for (Map.Entry> entry : tenantsByContainer.entrySet()) { + for (Long tenantId : entry.getValue()) { + var result = fbaPricingService.recalculateUnlockedContainerCustomerFees(entry.getKey(), tenantId, + sha256("CONFIRM_PRELOAD_FEE|" + operationId + '|' + entry.getKey() + '|' + tenantId)); + if (result.status() == FbaPricingService.ContainerFeeRecalculationStatus.NOT_READY) { + throw conflict("柜内存在尚未完成初始费用的入仓单,不能确认预装"); + } + } + } + } + private static void validateRange(java.time.LocalDateTime from, java.time.LocalDateTime to) { if (from != null && to != null && from.isAfter(to)) { throw badRequest("创建开始时间不能晚于结束时间"); 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 3fea4c3c1..f9d416997 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -62,6 +62,7 @@ public class SmartLoadingService { @Resource private LoadingModelBridgeService loadingModelBridge; @Resource private SplitPreloadService splitPreloadService; @Resource private ColoadGroupAssignmentPort coloadGroups; + @Resource private FbaPricingService fbaPricingService; @Resource private PriceBookQueryUseCase priceBooks; @Resource private FirstlegDocumentNumberGenerator documentNumbers; @Resource private ObjectMapper objectMapper; @@ -418,11 +419,16 @@ public class SmartLoadingService { @BusinessAudit(event = "SMART_LOADING_ITEM_ASSIGN", resourceType = "LOADING_PLAN", resourceId = "#planId", description = "分配智能装柜明细") public Plan assign(long planId, AssignRequest request, long operatorId, String operatorName) { - return assign(planId, request, operatorId, operatorName, false); + return assign(planId, request, operatorId, operatorName, false, true); } private Plan assign(long planId, AssignRequest request, long operatorId, String operatorName, boolean wholeGroupAssignment) { + return assign(planId, request, operatorId, operatorName, wholeGroupAssignment, false); + } + + private Plan assign(long planId, AssignRequest request, long operatorId, String operatorName, + boolean wholeGroupAssignment, boolean recalculateFees) { String operation = operation(request.operationId()); String requestHash = sha256("ASSIGN|" + planId + '|' + request.inboundId() + '|' + request.inboundDetailId() + '|' + request.packageCount() @@ -475,6 +481,9 @@ public class SmartLoadingService { loadingModelBridge.bindIncrementalAllocation(planId, candidate.accountId(), candidate.inboundId(), operatorId, operatorName, operation); loadingPickService.sync(planId, changed); + if (recalculateFees) { + recalculateAfterRelationshipChange(booking.getId(), Set.of(candidate.accountId()), operation); + } return view(repository.plan(planId), booking); } @@ -488,7 +497,7 @@ public class SmartLoadingService { resourceId = "#planId", description = "按入仓码分配智能装柜箱数") public Plan assignInbound(long planId, AssignInboundRequest request, long operatorId, String operatorName) { - return assignInbound(planId, request, operatorId, operatorName, false); + return assignInbound(planId, request, operatorId, operatorName, false, null, true); } /** @@ -568,17 +577,24 @@ public class SmartLoadingService { loadingModelBridge.bindIncrementalAllocation(planId, request.customerId(), inboundId, operatorId, operatorName, operation); created.forEach(item -> loadingPickService.sync(planId, item)); + recalculateAfterRelationshipChange(booking.getId(), Set.of(request.customerId()), operation); return view(repository.plan(planId), booking); } private Plan assignInbound(long planId, AssignInboundRequest request, long operatorId, String operatorName, boolean wholeGroupAssignment) { - return assignInbound(planId, request, operatorId, operatorName, wholeGroupAssignment, null); + return assignInbound(planId, request, operatorId, operatorName, wholeGroupAssignment, null, false); } private Plan assignInbound(long planId, AssignInboundRequest request, long operatorId, String operatorName, boolean wholeGroupAssignment, List frozenDetails) { + return assignInbound(planId, request, operatorId, operatorName, wholeGroupAssignment, frozenDetails, false); + } + + private Plan assignInbound(long planId, AssignInboundRequest request, + long operatorId, String operatorName, boolean wholeGroupAssignment, + List frozenDetails, boolean recalculateFees) { String operation = operation(request.operationId()); String requestHash = sha256("ASSIGN_INBOUND|" + planId + '|' + request.inboundId() + '|' + request.packageCount() + '|' + request.expectedVersion()); @@ -592,8 +608,13 @@ public class SmartLoadingService { if (!wholeGroupAssignment && projection != null && Set.of("PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING").contains(projection.getProcessStatus())) { - return assignDeclaredInbound(planId, request, projection, operation, requestHash, + Plan assigned = assignDeclaredInbound(planId, request, projection, operation, requestHash, operatorId, operatorName); + if (recalculateFees) { + recalculateAfterRelationshipChange(requireBooking(repository.plan(planId).getContainerBookingId()).getId(), + Set.of(projection.getTenantId()), operation); + } + return assigned; } LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName); ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); @@ -626,6 +647,9 @@ public class SmartLoadingService { json(values("requestHash", requestHash, "packageCount", request.packageCount(), "specificationLineCount", sequence, "cartonIdentityStatus", "UNVERIFIED")), operatorId, operatorName); + if (recalculateFees) { + recalculateAfterRelationshipChange(booking.getId(), Set.of(first.accountId()), operation); + } return view(assigned, booking); } @@ -756,6 +780,8 @@ public class SmartLoadingService { json(values("requestHash", requestHash, "groupId", locked.groupId(), "memberCount", locked.members().size(), "groupTotalBoxes", locked.fullPackageCount(), "candidateInboundCount", memberCandidates.size())), operatorId, operatorName); + recalculateAfterRelationshipChange(booking.getId(), locked.members().stream() + .map(Member::tenantId).collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)), operation); return view(assigned, booking); } @@ -795,6 +821,8 @@ public class SmartLoadingService { json(values("version", request.expectedPlanVersion(), "groupId", groupId)), json(values("requestHash", requestHash, "groupId", groupId, "deleted", true)), operatorId, operatorName); + recalculateAfterRelationshipChange(booking.getId(), group.members().stream() + .map(Member::tenantId).collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)), operation); return view(repository.plan(planId), booking); } @@ -979,6 +1007,7 @@ public class SmartLoadingService { history(planId, itemId, "REMOVE_ITEM", plan.getPlanStatus(), plan.getPlanStatus(), operation, json(values("version", request.expectedPlanVersion(), "packageCount", item.getPackageCount())), json(values("requestHash", requestHash, "deleted", true)), operatorId, operatorName); + recalculateAfterRelationshipChange(plan.getContainerBookingId(), Set.of(item.getAccountId()), operation); return view(repository.plan(planId), requireBooking(plan.getContainerBookingId())); } @@ -1057,6 +1086,10 @@ public class SmartLoadingService { json(values("version", request.expectedPlanVersion(), "itemCount", items.size())), json(values("requestHash", requestHash, "removedItemCount", removable.size(), "removedGroupCount", removableGroupIds.size())), operatorId, operatorName); + if (!removable.isEmpty()) { + recalculateAfterRelationshipChange(booking.getId(), removable.stream().map(LoadingPlanItemEntity::getAccountId) + .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)), operation); + } return view(repository.plan(planId), booking); } @@ -1526,6 +1559,17 @@ public class SmartLoadingService { private static void requireSameCustomerScope(ContainerBookingEntity booking, Candidate candidate) { if (!sameCustomerScope(booking, candidate)) throw forbidden(); } + /** + * A draft may legitimately contain pre-review or pre-measurement members without a fee snapshot. + * The pricing port reports that as NOT_READY, while any sent/customer-confirmed member fails and + * rolls this relation mutation back with the surrounding transaction. + */ + private void recalculateAfterRelationshipChange(long containerBookingId, Set tenantIds, + String operation) { + tenantIds.stream().filter(Objects::nonNull).filter(tenantId -> tenantId > 0).sorted() + .forEach(tenantId -> fbaPricingService.recalculateUnlockedContainerCustomerFees( + containerBookingId, tenantId, sha256("SMART_LOADING_RELATION|" + operation + '|' + tenantId))); + } private void touch(LoadingPlanEntity p,long op,String name){if(repository.touch(p.getId(),p.getVersion(),op,name)!=1)throw conflict("计划已被并发修改");} private Plan replay(String operation, long planId, String action, Long itemId, String requestHash) { LoadingPlanHistoryEntity history = repository.operation(operation); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java index 28c6ecf41..1467f7e94 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -64,6 +64,8 @@ class LoadingListServiceTest { @MockBean private LoadingCompletedOutboxService loadingCompletedOutboxService; @MockBean + private FbaPricingService fbaPricingService; + @MockBean private InboundHoldGuard inboundHoldGuard; @Resource private LoadingListService service; @@ -778,6 +780,9 @@ class LoadingListServiceTest { when(repository.history(anyString())).thenReturn(null); when(repository.aggregate(10L)).thenReturn(before, after); when(loadingPickService.requirePreloadConfirmationReady(10L)).thenReturn(links); + when(fbaPricingService.recalculateUnlockedContainerCustomerFees(eq(30L), anyLong(), anyString())) + .thenReturn(new FbaPricingService.ContainerFeeRecalculationResult( + FbaPricingService.ContainerFeeRecalculationStatus.RECALCULATED, 2)); when(repository.touchForPreloadConfirmation(10L, 3, 9L, "operator")).thenReturn(1); when(repository.confirmLinkedPreload(301L, "PENDING", 2, 9L, "operator")).thenReturn(1); when(repository.confirmLinkedPreload(302L, "CONFIRMING", 5, 9L, "operator")).thenReturn(1); @@ -791,12 +796,35 @@ class LoadingListServiceTest { assertThat(response.readiness().pdaTaskId()).isEqualTo(80L); verify(repository).confirmLinkedPreload(301L, "PENDING", 2, 9L, "operator"); verify(repository).confirmLinkedPreload(302L, "CONFIRMING", 5, 9L, "operator"); + verify(fbaPricingService).recalculateUnlockedContainerCustomerFees(eq(30L), eq(101L), anyString()); + verify(fbaPricingService).recalculateUnlockedContainerCustomerFees(eq(30L), eq(102L), anyString()); verify(loadingPickService, times(1)).beginIfReady(30L, 9L, "operator"); verify(repository).appendHistory(eq(10L), anyString(), eq("CONFIRM_PRELOAD"), eq("EDITING"), eq("EDITING"), anyString(), anyString(), eq(9L), eq("operator"), org.mockito.ArgumentMatchers.isNull()); } + @Test + void refusesConfirmationWhenAContainerCustomerFeeIsNotReadyWithoutTouchingPlan() { + AggregateRow before = row("EDITING", 3); + List links = List.of(new Link(1L, 101L, 201L, 301L, "PENDING", 2, 10L, 30L, + "MSCU-001", "EDITING", 3, 4, null)); + when(repository.history(anyString())).thenReturn(null); + when(repository.aggregate(10L)).thenReturn(before); + when(loadingPickService.requirePreloadConfirmationReady(10L)).thenReturn(links); + when(fbaPricingService.recalculateUnlockedContainerCustomerFees(eq(30L), eq(101L), anyString())) + .thenReturn(new FbaPricingService.ContainerFeeRecalculationResult( + FbaPricingService.ContainerFeeRecalculationStatus.NOT_READY, 0)); + + assertThatThrownBy(() -> service.confirmPreload( + 10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(3))) + .isInstanceOf(BizException.class).hasMessageContaining("初始费用"); + + verify(repository, never()).touchForPreloadConfirmation(anyLong(), anyInt(), anyLong(), anyString()); + verify(repository, never()).confirmLinkedPreload(anyLong(), anyString(), anyInt(), anyLong(), anyString()); + verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString()); + } + @Test void exactConfirmationReplayReturnsFrozenResponseWithoutTouchingPlan() throws Exception { PreloadConfirmationResponse expected = new PreloadConfirmationResponse( 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 cdbed4b46..a0b0a90b7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -93,6 +93,7 @@ class SmartLoadingServiceTest { @MockBean private LoadingModelBridgeService loadingModelBridge; @MockBean private SplitPreloadService splitPreloadService; @MockBean private ColoadGroupAssignmentPort coloadGroups; + @MockBean private FbaPricingService fbaPricingService; @MockBean private PriceBookQueryUseCase priceBooks; @MockBean private FirstlegDocumentNumberGenerator documentNumbers; @Resource private SmartLoadingService service; @@ -112,6 +113,9 @@ class SmartLoadingServiceTest { when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn( ApiResponse.success(new OrderCargoClient.CargoBinding(20L, 30L, 1, "货物", 5, new BigDecimal("10"), new BigDecimal("2")))); + when(fbaPricingService.recalculateUnlockedContainerCustomerFees(anyLong(), anyLong(), anyString())) + .thenReturn(new FbaPricingService.ContainerFeeRecalculationResult( + FbaPricingService.ContainerFeeRecalculationStatus.NOT_READY, 0)); } @Test @@ -390,6 +394,7 @@ class SmartLoadingServiceTest { assertThat(item.getValue().getGrossWeightKg()).isEqualByComparingTo("50"); verify(splitPreloadService).ensurePendingForPreAllocation(customerId, 10L, 7L, "u"); verify(loadingModelBridge).bindIncrementalAllocation(1L, customerId, 10L, 7L, "u", "pending-1"); + verify(fbaPricingService).recalculateUnlockedContainerCustomerFees(eq(99L), eq(customerId), anyString()); } @Test @@ -864,6 +869,8 @@ class SmartLoadingServiceTest { 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")); + verify(fbaPricingService).recalculateUnlockedContainerCustomerFees(eq(99L), eq(9L), anyString()); + verify(fbaPricingService).recalculateUnlockedContainerCustomerFees(eq(99L), eq(19L), anyString()); } @Test