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 fd3d7bf6b..68a5f7c5e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -368,6 +368,13 @@ public class SmartLoadingService { if (!visitedGroups.add(candidate.coLoadGroupId()) || StringUtils.hasText(candidate.blockReason())) { continue; } + GroupSnapshot currentGroup = group(candidate.accountId(), candidate.inboundId()); + if (currentGroup == null || !"ACTIVE".equals(currentGroup.status()) + || currentGroup.lockedPlanId() != null + || !groupFits(booking, repository.items(planId), currentGroup, + availableCandidates(booking))) { + continue; + } LoadingPlanEntity current = repository.plan(planId); assignGroup(planId, new AssignGroupRequest(candidate.inboundId(), current.getVersion(), "REC-GRP-" + sha256(operation + '|' + candidate.coLoadGroupId()).substring(0, 48)), @@ -640,8 +647,7 @@ public class SmartLoadingService { if (!"ACTIVE".equals(group.status()) || group.lockedPlanId() != null) { throw conflict("同柜不可拆组已锁定或已消费"); } - List available = repository.candidates(booking.getWarehouseId()).stream() - .filter(candidate -> sameChannel(booking, candidate) && sameCustomerScope(booking, candidate)).toList(); + List available = availableCandidates(booking); Map> result = new LinkedHashMap<>(); for (Member member : group.members()) { if (!repository.activeItemsByInbound(member.tenantId(), member.inboundId()).isEmpty()) { @@ -663,6 +669,11 @@ public class SmartLoadingService { return result; } + private List availableCandidates(ContainerBookingEntity booking) { + return repository.candidates(booking.getWarehouseId()).stream() + .filter(candidate -> sameChannel(booking, 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; 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 f9800a076..cf79048b9 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -262,6 +262,87 @@ class SmartLoadingServiceTest { verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), anyString()); } + @Test + void recommendRechecksCurrentCapacitySkipsStaleGroupAndContinuesWithLaterCandidate() { + Candidate ordinary = candidate(9L, 10L, 20L, 30L, 4); + Candidate groupFirst = candidate(9L, 11L, 21L, 31L, 4); + Candidate groupSecond = candidate(9L, 12L, 22L, 32L, 4); + Candidate later = candidate(9L, 13L, 23L, 33L, 1); + GroupSnapshot group = group("ACTIVE", null, + new Member(9L, 31L, 11L, 4), new Member(9L, 32L, 12L, 4)); + ContainerBookingEntity small = booking(); + small.setCapacityCbm(new BigDecimal("5")); + AtomicInteger version = new AtomicInteger(); + List saved = new ArrayList<>(); + when(repository.plan(1L)).thenAnswer(invocation -> plan("EDITING", version.get())); + when(repository.planByBooking(99L)).thenAnswer(invocation -> plan("EDITING", version.get())); + when(repository.booking(99L)).thenReturn(small); + when(repository.items(1L)).thenAnswer(invocation -> List.copyOf(saved)); + when(repository.candidates(100L)).thenReturn(List.of(ordinary, groupFirst, groupSecond, later)); + when(repository.candidate(1L, 100L, 10L, 20L)).thenReturn(ordinary); + when(repository.candidate(1L, 100L, 13L, 23L)).thenReturn(later); + when(repository.activeItemsByInbound(anyLong(), anyLong(), anyLong())).thenReturn(List.of()); + when(repository.activeItem(anyLong(), anyLong(), anyLong())).thenReturn(null); + when(repository.touch(eq(1L), anyInt(), eq(7L), eq("u"))).thenAnswer(invocation -> { + int expected = invocation.getArgument(1); + if (expected != version.get()) return 0; + version.incrementAndGet(); + return 1; + }); + doAnswer(invocation -> { + LoadingPlanItemEntity item = invocation.getArgument(0); + item.setId(800L + saved.size()); + saved.add(item); + return null; + }).when(repository).insert(any(LoadingPlanItemEntity.class)); + when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenAnswer(invocation -> { + long orderId = invocation.getArgument(2); + long detailId = invocation.getArgument(3); + return ApiResponse.success(new OrderCargoClient.CargoBinding(detailId, orderId, 1, + "货物", detailId == 23L ? 1 : 4, BigDecimal.TEN, BigDecimal.ONE)); + }); + when(coloadGroups.findByInbound(9L, 11L)).thenReturn(group); + when(coloadGroups.findByInbound(9L, 12L)).thenReturn(group); + + Plan result = service.recommend(1L, + new RecommendRequest(0, "recommend-refresh-group-capacity"), 7L, "u"); + + assertThat(result.version()).isEqualTo(2); + assertThat(saved).extracting(LoadingPlanItemEntity::getInboundId, + LoadingPlanItemEntity::getPackageCount).containsExactly( + org.assertj.core.groups.Tuple.tuple(10L, 4), + org.assertj.core.groups.Tuple.tuple(13L, 1)); + verify(coloadGroups, never()).lockWholeGroupForAssignment(anyLong(), anyLong(), anyLong(), + anyString(), anyLong(), anyString()); + } + + @Test + void manualGroupAssignmentOverCurrentCapacityStillReturnsA0422() { + Candidate first = candidate(9L, 11L, 21L, 31L, 4); + Candidate second = candidate(9L, 12L, 22L, 32L, 4); + GroupSnapshot group = group("ACTIVE", null, + new Member(9L, 31L, 11L, 4), new Member(9L, 32L, 12L, 4)); + ContainerBookingEntity small = booking(); + small.setCapacityCbm(new BigDecimal("3")); + when(repository.plan(1L)).thenReturn(plan("EDITING", 0)); + when(repository.planByBooking(99L)).thenReturn(plan("EDITING", 0)); + when(repository.booking(99L)).thenReturn(small); + when(repository.items(1L)).thenReturn(List.of()); + when(repository.candidates(100L)).thenReturn(List.of(first, second)); + when(repository.activeItemsByInbound(anyLong(), anyLong())).thenReturn(List.of()); + when(coloadGroups.findByInbound(9L, 11L)).thenReturn(group); + when(coloadGroups.findByInbound(9L, 12L)).thenReturn(group); + + BizException error = catchThrowableOfType(() -> service.assignGroup(1L, + new com.baiyue.firstleg.dto.SmartLoadingDtos.AssignGroupRequest( + 11L, 0, "manual-group-too-large"), 7L, "u"), BizException.class); + + assertThat(error.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED); + assertThat(error).hasMessageContaining("110%上限"); + verify(coloadGroups, never()).lockWholeGroupForAssignment(anyLong(), anyLong(), anyLong(), + anyString(), anyLong(), anyString()); + } + @Test void adjustAndIndividualDeleteRejectColoadMembersBeforeAnyWrite() { LoadingPlanEntity plan = plan("EDITING", 0);