fix(firstleg): reprice container fee on loading changes
This commit is contained in:
@@ -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<Link> 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<Link> links, String operationId) {
|
||||
Map<Long, Set<Long>> 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<Long, Set<Long>> 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("创建开始时间不能晚于结束时间");
|
||||
|
||||
@@ -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<Candidate> frozenDetails) {
|
||||
return assignInbound(planId, request, operatorId, operatorName, wholeGroupAssignment, frozenDetails, false);
|
||||
}
|
||||
|
||||
private Plan assignInbound(long planId, AssignInboundRequest request,
|
||||
long operatorId, String operatorName, boolean wholeGroupAssignment,
|
||||
List<Candidate> 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<Long> 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);
|
||||
|
||||
@@ -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<Link> 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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user