Merge remote-tracking branch 'origin/test' into dev

# Conflicts:
#	firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java
This commit is contained in:
2026-09-08 18:37:23 +08:00
5 changed files with 316 additions and 7 deletions
@@ -74,6 +74,8 @@ public class LoadingListService {
@Resource @Resource
private LoadingCompletedOutboxService loadingCompletedOutboxService; private LoadingCompletedOutboxService loadingCompletedOutboxService;
@Resource @Resource
private FbaPricingService fbaPricingService;
@Resource
private ObjectMapper objectMapper; private ObjectMapper objectMapper;
@Resource @Resource
private LoadingDocumentFileClient documentFiles; private LoadingDocumentFileClient documentFiles;
@@ -501,6 +503,7 @@ public class LoadingListService {
AggregateRow before = requirePlan(planId); AggregateRow before = requirePlan(planId);
List<Link> links = loadingPickService.requirePreloadConfirmationReady(planId); List<Link> links = loadingPickService.requirePreloadConfirmationReady(planId);
requireContainerFeesReadyForPreloadConfirmation(links, operationId);
if (repository.touchForPreloadConfirmation(planId, request.expectedVersion(), if (repository.touchForPreloadConfirmation(planId, request.expectedVersion(),
operatorId, trim(operatorName)) != 1) { operatorId, trim(operatorName)) != 1) {
throw conflict("预装计划已被其他操作修改,请刷新后重试"); 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) { private static void validateRange(java.time.LocalDateTime from, java.time.LocalDateTime to) {
if (from != null && to != null && from.isAfter(to)) { if (from != null && to != null && from.isAfter(to)) {
throw badRequest("创建开始时间不能晚于结束时间"); throw badRequest("创建开始时间不能晚于结束时间");
@@ -63,6 +63,7 @@ public class SmartLoadingService {
@Resource private LoadingModelBridgeService loadingModelBridge; @Resource private LoadingModelBridgeService loadingModelBridge;
@Resource private SplitPreloadService splitPreloadService; @Resource private SplitPreloadService splitPreloadService;
@Resource private ColoadGroupAssignmentPort coloadGroups; @Resource private ColoadGroupAssignmentPort coloadGroups;
@Resource private FbaPricingService fbaPricingService;
@Resource private PriceBookQueryUseCase priceBooks; @Resource private PriceBookQueryUseCase priceBooks;
@Resource private FirstlegDocumentNumberGenerator documentNumbers; @Resource private FirstlegDocumentNumberGenerator documentNumbers;
@Resource private ObjectMapper objectMapper; @Resource private ObjectMapper objectMapper;
@@ -430,7 +431,13 @@ public class SmartLoadingService {
@BusinessAudit(event = "SMART_LOADING_ITEM_ASSIGN", resourceType = "LOADING_PLAN", @BusinessAudit(event = "SMART_LOADING_ITEM_ASSIGN", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "分配智能装柜明细") resourceId = "#planId", description = "分配智能装柜明细")
public Plan assign(long planId, AssignRequest request, long operatorId, String operatorName) { public Plan assign(long planId, AssignRequest request, long operatorId, String operatorName) {
return assign(planId, request, operatorId, operatorName, false, null, null, null, false, null); return assign(planId, request, operatorId, operatorName, false, null, null, null, false, null, true);
}
private Plan assign(long planId, AssignRequest request, long operatorId, String operatorName,
boolean wholeGroupAssignment) {
return assign(planId, request, operatorId, operatorName, wholeGroupAssignment,
null, null, null, false, null, false);
} }
@Transactional @Transactional
@@ -463,19 +470,21 @@ public class SmartLoadingService {
String childOperation = "PAL-" + sha256(operation + '|' + line.getInboundDetailId()).substring(0, 48); String childOperation = "PAL-" + sha256(operation + '|' + line.getInboundDetailId()).substring(0, 48);
assign(planId, new AssignRequest(line.getInboundId(), line.getInboundDetailId(), line.getCartonCount(), assign(planId, new AssignRequest(line.getInboundId(), line.getInboundDetailId(), line.getCartonCount(),
current.getVersion(), childOperation), operatorId, operatorName, true, request.palletId(), current.getVersion(), childOperation), operatorId, operatorName, true, request.palletId(),
line.getPalletNo(), line.getActualSkuQuantity(), sequence++ == 0, candidate); line.getPalletNo(), line.getActualSkuQuantity(), sequence++ == 0, candidate, false);
} }
LoadingPlanEntity assigned = repository.plan(planId); LoadingPlanEntity assigned = repository.plan(planId);
history(planId, null, "ADD_PALLET", original.getPlanStatus(), assigned.getPlanStatus(), operation, history(planId, null, "ADD_PALLET", original.getPlanStatus(), assigned.getPlanStatus(), operation,
json(values("palletId", request.palletId(), "version", request.expectedVersion())), json(values("palletId", request.palletId(), "version", request.expectedVersion())),
json(values("requestHash", requestHash, "palletNo", first.getPalletNo(), "lineCount", lines.size())), json(values("requestHash", requestHash, "palletNo", first.getPalletNo(), "lineCount", lines.size())),
operatorId, operatorName); operatorId, operatorName);
recalculateAfterRelationshipChange(assigned.getContainerBookingId(), Set.of(first.getAccountId()), operation);
return view(assigned, requireBooking(assigned.getContainerBookingId())); return view(assigned, requireBooking(assigned.getContainerBookingId()));
} }
private Plan assign(long planId, AssignRequest request, long operatorId, String operatorName, private Plan assign(long planId, AssignRequest request, long operatorId, String operatorName,
boolean wholeGroupAssignment, Long palletId, String palletNo, boolean wholeGroupAssignment, Long palletId, String palletNo,
Integer actualSkuQuantity, boolean primaryPalletLine, Candidate frozenCandidate) { Integer actualSkuQuantity, boolean primaryPalletLine, Candidate frozenCandidate,
boolean recalculateFees) {
String operation = operation(request.operationId()); String operation = operation(request.operationId());
String requestHash = sha256("ASSIGN|" + planId + '|' String requestHash = sha256("ASSIGN|" + planId + '|'
+ request.inboundId() + '|' + request.inboundDetailId() + '|' + request.packageCount() + request.inboundId() + '|' + request.inboundDetailId() + '|' + request.packageCount()
@@ -544,6 +553,9 @@ public class SmartLoadingService {
loadingModelBridge.bindIncrementalAllocation(planId, candidate.accountId(), candidate.inboundId(), loadingModelBridge.bindIncrementalAllocation(planId, candidate.accountId(), candidate.inboundId(),
operatorId, operatorName, operation); operatorId, operatorName, operation);
loadingPickService.sync(planId, changed); loadingPickService.sync(planId, changed);
if (recalculateFees) {
recalculateAfterRelationshipChange(booking.getId(), Set.of(candidate.accountId()), operation);
}
return view(repository.plan(planId), booking); return view(repository.plan(planId), booking);
} }
@@ -557,7 +569,7 @@ public class SmartLoadingService {
resourceId = "#planId", description = "按入仓码分配智能装柜箱数") resourceId = "#planId", description = "按入仓码分配智能装柜箱数")
public Plan assignInbound(long planId, AssignInboundRequest request, public Plan assignInbound(long planId, AssignInboundRequest request,
long operatorId, String operatorName) { long operatorId, String operatorName) {
return assignInbound(planId, request, operatorId, operatorName, false); return assignInbound(planId, request, operatorId, operatorName, false, null, true);
} }
/** /**
@@ -637,17 +649,24 @@ public class SmartLoadingService {
loadingModelBridge.bindIncrementalAllocation(planId, request.customerId(), inboundId, loadingModelBridge.bindIncrementalAllocation(planId, request.customerId(), inboundId,
operatorId, operatorName, operation); operatorId, operatorName, operation);
created.forEach(item -> loadingPickService.sync(planId, item)); created.forEach(item -> loadingPickService.sync(planId, item));
recalculateAfterRelationshipChange(booking.getId(), Set.of(request.customerId()), operation);
return view(repository.plan(planId), booking); return view(repository.plan(planId), booking);
} }
private Plan assignInbound(long planId, AssignInboundRequest request, private Plan assignInbound(long planId, AssignInboundRequest request,
long operatorId, String operatorName, boolean wholeGroupAssignment) { 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, private Plan assignInbound(long planId, AssignInboundRequest request,
long operatorId, String operatorName, boolean wholeGroupAssignment, long operatorId, String operatorName, boolean wholeGroupAssignment,
List<Candidate> frozenDetails) { 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 operation = operation(request.operationId());
String requestHash = sha256("ASSIGN_INBOUND|" + planId + '|' + request.inboundId() + '|' String requestHash = sha256("ASSIGN_INBOUND|" + planId + '|' + request.inboundId() + '|'
+ request.packageCount() + '|' + request.expectedVersion()); + request.packageCount() + '|' + request.expectedVersion());
@@ -661,8 +680,13 @@ public class SmartLoadingService {
if (!wholeGroupAssignment && projection != null && Set.of("PREPARING", "WAIT_MEASURE", if (!wholeGroupAssignment && projection != null && Set.of("PREPARING", "WAIT_MEASURE",
"MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", "WAIT_PRICING", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", "WAIT_PRICING",
"FEE_CONFIRMING", "INBOUNDING").contains(projection.getProcessStatus())) { "FEE_CONFIRMING", "INBOUNDING").contains(projection.getProcessStatus())) {
return assignDeclaredInbound(planId, request, projection, operation, requestHash, Plan assigned = assignDeclaredInbound(planId, request, projection, operation, requestHash,
operatorId, operatorName); 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); LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName);
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
@@ -686,7 +710,7 @@ public class SmartLoadingService {
.substring(0, 48); .substring(0, 48);
assign(planId, new AssignRequest(request.inboundId(), allocation.candidate().inboundDetailId(), assign(planId, new AssignRequest(request.inboundId(), allocation.candidate().inboundDetailId(),
allocation.packageCount(), current.getVersion(), childOperation), operatorId, operatorName, allocation.packageCount(), current.getVersion(), childOperation), operatorId, operatorName,
wholeGroupAssignment, null, null, null, false, null); wholeGroupAssignment, null, null, null, false, null, false);
sequence++; sequence++;
} }
LoadingPlanEntity assigned = repository.plan(planId); LoadingPlanEntity assigned = repository.plan(planId);
@@ -695,6 +719,9 @@ public class SmartLoadingService {
json(values("requestHash", requestHash, "packageCount", request.packageCount(), json(values("requestHash", requestHash, "packageCount", request.packageCount(),
"specificationLineCount", sequence, "cartonIdentityStatus", "UNVERIFIED")), "specificationLineCount", sequence, "cartonIdentityStatus", "UNVERIFIED")),
operatorId, operatorName); operatorId, operatorName);
if (recalculateFees) {
recalculateAfterRelationshipChange(booking.getId(), Set.of(first.accountId()), operation);
}
return view(assigned, booking); return view(assigned, booking);
} }
@@ -825,6 +852,8 @@ public class SmartLoadingService {
json(values("requestHash", requestHash, "groupId", locked.groupId(), json(values("requestHash", requestHash, "groupId", locked.groupId(),
"memberCount", locked.members().size(), "groupTotalBoxes", locked.fullPackageCount(), "memberCount", locked.members().size(), "groupTotalBoxes", locked.fullPackageCount(),
"candidateInboundCount", memberCandidates.size())), operatorId, operatorName); "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); return view(assigned, booking);
} }
@@ -864,6 +893,8 @@ public class SmartLoadingService {
json(values("version", request.expectedPlanVersion(), "groupId", groupId)), json(values("version", request.expectedPlanVersion(), "groupId", groupId)),
json(values("requestHash", requestHash, "groupId", groupId, "deleted", true)), json(values("requestHash", requestHash, "groupId", groupId, "deleted", true)),
operatorId, operatorName); 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); return view(repository.plan(planId), booking);
} }
@@ -1054,6 +1085,7 @@ public class SmartLoadingService {
history(planId, itemId, "REMOVE_ITEM", plan.getPlanStatus(), plan.getPlanStatus(), operation, history(planId, itemId, "REMOVE_ITEM", plan.getPlanStatus(), plan.getPlanStatus(), operation,
json(values("version", request.expectedPlanVersion(), "packageCount", item.getPackageCount())), json(values("version", request.expectedPlanVersion(), "packageCount", item.getPackageCount())),
json(values("requestHash", requestHash, "deleted", true)), operatorId, operatorName); json(values("requestHash", requestHash, "deleted", true)), operatorId, operatorName);
recalculateAfterRelationshipChange(plan.getContainerBookingId(), Set.of(item.getAccountId()), operation);
return view(repository.plan(planId), requireBooking(plan.getContainerBookingId())); return view(repository.plan(planId), requireBooking(plan.getContainerBookingId()));
} }
@@ -1160,6 +1192,10 @@ public class SmartLoadingService {
json(values("version", request.expectedPlanVersion(), "itemCount", items.size())), json(values("version", request.expectedPlanVersion(), "itemCount", items.size())),
json(values("requestHash", requestHash, "removedItemCount", removable.size(), json(values("requestHash", requestHash, "removedItemCount", removable.size(),
"removedGroupCount", removableGroupIds.size())), operatorId, operatorName); "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); return view(repository.plan(planId), booking);
} }
@@ -1662,6 +1698,17 @@ public class SmartLoadingService {
private static void requireSameCustomerScope(ContainerBookingEntity booking, Candidate candidate) { private static void requireSameCustomerScope(ContainerBookingEntity booking, Candidate candidate) {
if (!sameCustomerScope(booking, candidate)) throw forbidden(); 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 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) { private Plan replay(String operation, long planId, String action, Long itemId, String requestHash) {
LoadingPlanHistoryEntity history = repository.operation(operation); LoadingPlanHistoryEntity history = repository.operation(operation);
@@ -64,6 +64,8 @@ class LoadingListServiceTest {
@MockBean @MockBean
private LoadingCompletedOutboxService loadingCompletedOutboxService; private LoadingCompletedOutboxService loadingCompletedOutboxService;
@MockBean @MockBean
private FbaPricingService fbaPricingService;
@MockBean
private InboundHoldGuard inboundHoldGuard; private InboundHoldGuard inboundHoldGuard;
@Resource @Resource
private LoadingListService service; private LoadingListService service;
@@ -778,6 +780,9 @@ class LoadingListServiceTest {
when(repository.history(anyString())).thenReturn(null); when(repository.history(anyString())).thenReturn(null);
when(repository.aggregate(10L)).thenReturn(before, after); when(repository.aggregate(10L)).thenReturn(before, after);
when(loadingPickService.requirePreloadConfirmationReady(10L)).thenReturn(links); 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.touchForPreloadConfirmation(10L, 3, 9L, "operator")).thenReturn(1);
when(repository.confirmLinkedPreload(301L, "PENDING", 2, 9L, "operator")).thenReturn(1); when(repository.confirmLinkedPreload(301L, "PENDING", 2, 9L, "operator")).thenReturn(1);
when(repository.confirmLinkedPreload(302L, "CONFIRMING", 5, 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); assertThat(response.readiness().pdaTaskId()).isEqualTo(80L);
verify(repository).confirmLinkedPreload(301L, "PENDING", 2, 9L, "operator"); verify(repository).confirmLinkedPreload(301L, "PENDING", 2, 9L, "operator");
verify(repository).confirmLinkedPreload(302L, "CONFIRMING", 5, 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(loadingPickService, times(1)).beginIfReady(30L, 9L, "operator");
verify(repository).appendHistory(eq(10L), anyString(), eq("CONFIRM_PRELOAD"), verify(repository).appendHistory(eq(10L), anyString(), eq("CONFIRM_PRELOAD"),
eq("EDITING"), eq("EDITING"), anyString(), anyString(), eq(9L), eq("EDITING"), eq("EDITING"), anyString(), anyString(), eq(9L),
eq("operator"), org.mockito.ArgumentMatchers.isNull()); 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 @Test
void exactConfirmationReplayReturnsFrozenResponseWithoutTouchingPlan() throws Exception { void exactConfirmationReplayReturnsFrozenResponseWithoutTouchingPlan() throws Exception {
PreloadConfirmationResponse expected = new PreloadConfirmationResponse( PreloadConfirmationResponse expected = new PreloadConfirmationResponse(
@@ -94,6 +94,7 @@ class SmartLoadingServiceTest {
@MockBean private LoadingModelBridgeService loadingModelBridge; @MockBean private LoadingModelBridgeService loadingModelBridge;
@MockBean private SplitPreloadService splitPreloadService; @MockBean private SplitPreloadService splitPreloadService;
@MockBean private ColoadGroupAssignmentPort coloadGroups; @MockBean private ColoadGroupAssignmentPort coloadGroups;
@MockBean private FbaPricingService fbaPricingService;
@MockBean private PriceBookQueryUseCase priceBooks; @MockBean private PriceBookQueryUseCase priceBooks;
@MockBean private FirstlegDocumentNumberGenerator documentNumbers; @MockBean private FirstlegDocumentNumberGenerator documentNumbers;
@MockBean private DropshipPalletizationMapper dropshipPallets; @MockBean private DropshipPalletizationMapper dropshipPallets;
@@ -114,6 +115,9 @@ class SmartLoadingServiceTest {
when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn( when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn(
ApiResponse.success(new OrderCargoClient.CargoBinding(20L, 30L, 1, "货物", 5, ApiResponse.success(new OrderCargoClient.CargoBinding(20L, 30L, 1, "货物", 5,
new BigDecimal("10"), new BigDecimal("2")))); new BigDecimal("10"), new BigDecimal("2"))));
when(fbaPricingService.recalculateUnlockedContainerCustomerFees(anyLong(), anyLong(), anyString()))
.thenReturn(new FbaPricingService.ContainerFeeRecalculationResult(
FbaPricingService.ContainerFeeRecalculationStatus.NOT_READY, 0));
} }
@Test @Test
@@ -392,6 +396,7 @@ class SmartLoadingServiceTest {
assertThat(item.getValue().getGrossWeightKg()).isEqualByComparingTo("50"); assertThat(item.getValue().getGrossWeightKg()).isEqualByComparingTo("50");
verify(splitPreloadService).ensurePendingForPreAllocation(customerId, 10L, 7L, "u"); verify(splitPreloadService).ensurePendingForPreAllocation(customerId, 10L, 7L, "u");
verify(loadingModelBridge).bindIncrementalAllocation(1L, customerId, 10L, 7L, "u", "pending-1"); verify(loadingModelBridge).bindIncrementalAllocation(1L, customerId, 10L, 7L, "u", "pending-1");
verify(fbaPricingService).recalculateUnlockedContainerCustomerFees(eq(99L), eq(customerId), anyString());
} }
@Test @Test
@@ -866,6 +871,8 @@ class SmartLoadingServiceTest {
verify(repository, times(2)).remove(eq(1L), anyLong(), eq(0), eq(7L), eq("u")); verify(repository, times(2)).remove(eq(1L), anyLong(), eq(0), eq(7L), eq("u"));
verify(coloadGroups).unlockWholeGroupAfterRemoval(eq(700L), eq(1L), verify(coloadGroups).unlockWholeGroupAfterRemoval(eq(700L), eq(1L),
org.mockito.ArgumentMatchers.startsWith("UNLOCK-"), eq(7L), eq("u")); 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 @Test
@@ -3,6 +3,8 @@ package com.baiyue.lastleg.inventory.command.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreRequest;
import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreResponse;
import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper; import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper;
import com.baiyue.lastleg.inventory.entity.InventoryBalance; import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryLedger; import com.baiyue.lastleg.inventory.entity.InventoryLedger;
@@ -26,8 +28,11 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Objects; import java.util.Objects;
@Service @Service
@@ -245,6 +250,119 @@ public class InventoryCommandService {
}); });
} }
@Transactional
public ReturnedInventoryRestoreResponse restoreReturnedInventory(String customerId, String operatorId,
ReturnedInventoryRestoreRequest request) {
String exceptionNo = required(request.exceptionNo(), "Return exception number is required");
String outboundNo = required(request.outboundNo(), "Return outbound order number is required");
String warehouseId = required(request.overseasWarehouseId(), "Return warehouse is required");
String idempotencyKey = required(request.idempotencyKey(), "Idempotency key is required");
String requestHash = hash(request);
InventoryOperation existing = operations.findIdempotency(customerId, idempotencyKey);
if (existing != null) return replayReturned(existing, requestHash);
if (request.sources() == null || request.sources().isEmpty()) {
throw badRequest("Return inventory sources are required");
}
if (request.receipts() == null || request.receipts().isEmpty()) {
throw badRequest("Return inventory receipts are required");
}
Map<String, ReturnedInventoryRestoreRequest.SourceItem> sources = new LinkedHashMap<>();
for (ReturnedInventoryRestoreRequest.SourceItem source : request.sources()) {
if (source.outboundPalletItemId() == null || source.inventoryPositionId() == null
|| source.maxReturnPackages() <= 0) {
throw badRequest("Return inventory source is incomplete");
}
String key = returnSourceKey(source.outboundPalletItemId(), source.inventoryPositionId());
if (sources.putIfAbsent(key, source) != null) throw badRequest("Return inventory source is duplicated");
}
Map<String, Long> receiptTotals = new LinkedHashMap<>();
for (ReturnedInventoryRestoreRequest.Receipt receipt : request.receipts()) {
if (receipt.outboundPalletItemId() == null || receipt.inventoryPositionId() == null
|| receipt.receivedPackages() <= 0 || receipt.targetPalletId() == null
|| receipt.targetLocationId() == null || trim(receipt.targetPalletNo()) == null
|| trim(receipt.targetLocationCode()) == null) {
throw badRequest("Return inventory receipt is incomplete");
}
String key = returnSourceKey(receipt.outboundPalletItemId(), receipt.inventoryPositionId());
if (!sources.containsKey(key)) throw conflict("Return inventory receipt does not match an approved source");
receiptTotals.merge(key, receipt.receivedPackages(), Math::addExact);
}
InventoryOperation operation = new InventoryOperation();
operation.setId(IdWorker.getId());
operation.setCustomerId(customerId);
operation.setIdempotencyKey(idempotencyKey);
operation.setOperationType("RETURN_STOCK_IN");
operation.setRequestHash(requestHash);
operation.setStatus("PROCESSING");
operation.setCreatedAt(LocalDateTime.now());
operation.setUpdatedAt(operation.getCreatedAt());
try {
operations.insert(operation);
} catch (DuplicateKeyException exception) {
InventoryOperation concurrent = operations.findIdempotency(customerId, idempotencyKey);
if (concurrent != null) return replayReturned(concurrent, requestHash);
throw exception;
}
Map<String, Locked> lockedSources = new LinkedHashMap<>();
for (Map.Entry<String, ReturnedInventoryRestoreRequest.SourceItem> entry : sources.entrySet()) {
ReturnedInventoryRestoreRequest.SourceItem source = entry.getValue();
Locked locked = locked(customerId, source.inventoryPositionId());
if (!Objects.equals(locked.balance().getInboundOrderId(), request.inboundOrderId())
|| !warehouseId.equals(trim(locked.balance().getOverseasWarehouseId()))) {
throw conflict("Return inventory source does not belong to the exception inbound order and warehouse");
}
String sourceIdentity = returnSourceIdentity(exceptionNo, source);
long shipped = commands.shippedPackages(customerId, source.inventoryPositionId(), outboundNo,
sku(source.skuCode()));
long restored = commands.restoredPackages(customerId, sourceIdentity, sku(source.skuCode()));
long allowed = Math.min(source.maxReturnPackages(), shipped) - restored;
long requested = receiptTotals.getOrDefault(entry.getKey(), 0L);
if (requested > allowed) throw conflict("Return inventory receipt exceeds the remaining shipped packages");
lockedSources.put(entry.getKey(), locked);
}
List<Long> ledgerIds = new ArrayList<>();
long beforePackages = 0;
long restoredPackages = 0;
int sequence = 0;
String batchNo = ("RETURN-" + exceptionNo).substring(0, Math.min(128, 7 + exceptionNo.length()));
for (ReturnedInventoryRestoreRequest.Receipt receipt : request.receipts()) {
String key = returnSourceKey(receipt.outboundPalletItemId(), receipt.inventoryPositionId());
ReturnedInventoryRestoreRequest.SourceItem source = sources.get(key);
Locked locked = lockedSources.get(key);
InventoryBalance balance = locked.balance();
InventoryPosition target = returnPosition(customerId, balance, receipt, batchNo);
long before = value(target.getAvailablePackages());
beforePackages = Math.addExact(beforePackages, before);
target.setAvailablePackages(Math.addExact(before, receipt.receivedPackages()));
target.setStatus("OCCUPIED");
target.setVersion(value(target.getVersion()) + 1);
target.setUpdatedAt(LocalDateTime.now());
positions.updateById(target);
balance.setAvailablePackages(Math.addExact(value(balance.getAvailablePackages()), receipt.receivedPackages()));
touch(balance);
balances.updateById(balance);
restoredPackages = Math.addExact(restoredPackages, receipt.receivedPackages());
InventoryLedger ledger = returnLedger(customerId, operatorId, operation.getId(), balance, target,
source, exceptionNo, outboundNo, returnSourceIdentity(exceptionNo, source), idempotencyKey,
sequence++, before, receipt.receivedPackages(), request.evidenceFileIds());
ledgers.insert(ledger);
ledgerIds.add(ledger.getId());
}
LocalDateTime completedAt = LocalDateTime.now();
ReturnedInventoryRestoreResponse response = new ReturnedInventoryRestoreResponse(operation.getId(), ledgerIds,
beforePackages, restoredPackages, Math.addExact(beforePackages, restoredPackages), false, completedAt);
operation.setStatus("COMPLETED");
operation.setResultJson(write(response));
operation.setUpdatedAt(completedAt);
operations.updateById(operation);
return response;
}
@Transactional @Transactional
public InventoryCommandResponse adjust(String customerId, String operatorId, public InventoryCommandResponse adjust(String customerId, String operatorId,
InventoryCommandRequests.Adjustment request) { InventoryCommandRequests.Adjustment request) {
@@ -423,6 +541,42 @@ public class InventoryCommandService {
} }
} }
private InventoryPosition returnPosition(String customerId, InventoryBalance balance,
ReturnedInventoryRestoreRequest.Receipt receipt, String batchNo) {
InventoryPosition position = positions.lockPosition(customerId, balance.getId(), receipt.targetPalletId(), batchNo);
if (position != null) {
if (!required(receipt.targetPalletNo(), "Return pallet number is required").equals(position.getPalletNo())
|| !Objects.equals(receipt.targetLocationId(), position.getWarehouseLocationId())
|| !required(receipt.targetLocationCode(), "Return location code is required")
.equals(position.getWarehouseLocationCode())) {
throw conflict("Return target pallet or location changed for the same exception");
}
return position;
}
position = new InventoryPosition();
position.setId(IdWorker.getId());
position.setCustomerId(customerId);
position.setBalanceId(balance.getId());
position.setOverseasWarehouseId(balance.getOverseasWarehouseId());
position.setDestinationWarehouseCode(balance.getDestinationWarehouseCode());
position.setPalletId(receipt.targetPalletId());
position.setPalletNo(required(receipt.targetPalletNo(), "Return pallet number is required"));
position.setBatchNo(batchNo);
position.setWarehouseLocationId(receipt.targetLocationId());
position.setWarehouseLocationCode(required(receipt.targetLocationCode(), "Return location code is required"));
position.setAvailablePackages(0L); position.setHoldPackages(0L); position.setAllocatedPackages(0L);
position.setPickedPackages(0L); position.setReceivingPackages(0L); position.setStatus("OCCUPIED");
position.setVersion(0L); position.setCreatedAt(LocalDateTime.now()); position.setUpdatedAt(position.getCreatedAt());
try {
positions.insert(position);
return position;
} catch (DuplicateKeyException exception) {
InventoryPosition concurrent = positions.lockPosition(customerId, balance.getId(), receipt.targetPalletId(), batchNo);
if (concurrent != null) return concurrent;
throw exception;
}
}
private Locked locked(String customerId, Long positionId) { private Locked locked(String customerId, Long positionId) {
InventoryPosition position = commands.lockPosition(customerId, positionId); InventoryPosition position = commands.lockPosition(customerId, positionId);
if (position == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inventory position not found"); if (position == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inventory position not found");
@@ -483,6 +637,52 @@ public class InventoryCommandService {
} }
} }
private ReturnedInventoryRestoreResponse replayReturned(InventoryOperation operation, String requestHash) {
if (!"RETURN_STOCK_IN".equals(operation.getOperationType()) || !requestHash.equals(operation.getRequestHash())) {
throw conflict("Idempotency key was already used with a different inventory request");
}
if (!"COMPLETED".equals(operation.getStatus()) || trim(operation.getResultJson()) == null) {
throw conflict("Inventory operation is still processing");
}
try {
return objectMapper.readValue(operation.getResultJson(), ReturnedInventoryRestoreResponse.class).asReplay();
} catch (JsonProcessingException exception) {
throw new IllegalStateException("Cannot read return inventory idempotency result", exception);
}
}
private InventoryLedger returnLedger(String customerId, String operatorId, Long operationId,
InventoryBalance balance, InventoryPosition position,
ReturnedInventoryRestoreRequest.SourceItem source,
String exceptionNo, String outboundNo, String sourceIdentity,
String idempotencyKey, int sequence, long before, long delta,
List<String> evidenceFileIds) {
InventoryLedger ledger = new InventoryLedger();
ledger.setId(IdWorker.getId()); ledger.setCustomerId(customerId); ledger.setCustomerName(balance.getCustomerName());
ledger.setBalanceId(balance.getId()); ledger.setPositionId(position.getId());
ledger.setOverseasWarehouseId(balance.getOverseasWarehouseId());
ledger.setDestinationWarehouseCode(balance.getDestinationWarehouseCode()); ledger.setInboundOrderId(balance.getInboundOrderId());
ledger.setPalletId(position.getPalletId()); ledger.setPalletNo(position.getPalletNo()); ledger.setBatchNo(position.getBatchNo());
ledger.setWarehouseLocationCode(position.getWarehouseLocationCode()); ledger.setSkuCode(sku(source.skuCode()));
ledger.setBusinessType("RETURN_STOCK_IN"); ledger.setBusinessOrderId(balance.getInboundOrderId());
ledger.setBusinessOrderNo(exceptionNo); ledger.setInboundCode(balance.getInboundCode());
ledger.setInboundOrderNo(balance.getInboundOrderNo()); ledger.setOutboundOrderNo(outboundNo);
ledger.setSourceEventId(sourceIdentity); ledger.setIdempotencyKey(idempotencyKey + ":RETURN:" + sequence + ":" + operationId);
ledger.setStatusFrom("RETURNED"); ledger.setStatusTo("AVAILABLE"); ledger.setBeforePackages(before);
ledger.setDeltaPackages(delta); ledger.setAfterPackages(Math.addExact(before, delta)); ledger.setOperatorId(trim(operatorId));
ledger.setRemark("Exception return received; evidenceCount=" + (evidenceFileIds == null ? 0 : evidenceFileIds.size()));
ledger.setOccurredAt(LocalDateTime.now()); ledger.setCreatedAt(ledger.getOccurredAt());
return ledger;
}
private static String returnSourceKey(Long outboundPalletItemId, Long inventoryPositionId) {
return outboundPalletItemId + ":" + inventoryPositionId;
}
private static String returnSourceIdentity(String exceptionNo, ReturnedInventoryRestoreRequest.SourceItem source) {
return "RETURN:" + exceptionNo + ":" + source.outboundPalletItemId() + ":" + source.inventoryPositionId();
}
private void ledger(String customerId, String operatorId, Long operationId, InventoryBalance balance, private void ledger(String customerId, String operatorId, Long operationId, InventoryBalance balance,
InventoryPosition position, String skuCode, String businessType, String from, String to, InventoryPosition position, String skuCode, String businessType, String from, String to,
long beforePackages, long deltaPackages, long afterPackages, long beforePackages, long deltaPackages, long afterPackages,