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 75fd69856..5806840b8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -63,6 +63,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; @@ -430,7 +431,13 @@ 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, 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 @@ -463,19 +470,21 @@ public class SmartLoadingService { String childOperation = "PAL-" + sha256(operation + '|' + line.getInboundDetailId()).substring(0, 48); assign(planId, new AssignRequest(line.getInboundId(), line.getInboundDetailId(), line.getCartonCount(), 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); history(planId, null, "ADD_PALLET", original.getPlanStatus(), assigned.getPlanStatus(), operation, json(values("palletId", request.palletId(), "version", request.expectedVersion())), json(values("requestHash", requestHash, "palletNo", first.getPalletNo(), "lineCount", lines.size())), operatorId, operatorName); + recalculateAfterRelationshipChange(assigned.getContainerBookingId(), Set.of(first.getAccountId()), operation); return view(assigned, requireBooking(assigned.getContainerBookingId())); } private Plan assign(long planId, AssignRequest request, long operatorId, String operatorName, 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 requestHash = sha256("ASSIGN|" + planId + '|' + request.inboundId() + '|' + request.inboundDetailId() + '|' + request.packageCount() @@ -544,6 +553,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); } @@ -557,7 +569,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); } /** @@ -637,17 +649,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()); @@ -661,8 +680,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()); @@ -686,7 +710,7 @@ public class SmartLoadingService { .substring(0, 48); assign(planId, new AssignRequest(request.inboundId(), allocation.candidate().inboundDetailId(), allocation.packageCount(), current.getVersion(), childOperation), operatorId, operatorName, - wholeGroupAssignment, null, null, null, false, null); + wholeGroupAssignment, null, null, null, false, null, false); sequence++; } LoadingPlanEntity assigned = repository.plan(planId); @@ -695,6 +719,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); } @@ -825,6 +852,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); } @@ -864,6 +893,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); } @@ -1054,6 +1085,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())); } @@ -1160,6 +1192,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); } @@ -1662,6 +1698,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 dcfc5b417..fbae44ef2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -94,6 +94,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; @MockBean private DropshipPalletizationMapper dropshipPallets; @@ -114,6 +115,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 @@ -392,6 +396,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 @@ -866,6 +871,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 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java index f36082193..3f654b024 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java @@ -3,6 +3,8 @@ package com.baiyue.lastleg.inventory.command.service; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; 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.entity.InventoryBalance; import com.baiyue.lastleg.inventory.entity.InventoryLedger; @@ -26,8 +28,11 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.HexFormat; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; @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 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 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 lockedSources = new LinkedHashMap<>(); + for (Map.Entry 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 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 public InventoryCommandResponse adjust(String customerId, String operatorId, 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) { InventoryPosition position = commands.lockPosition(customerId, positionId); 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 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, InventoryPosition position, String skuCode, String businessType, String from, String to, long beforePackages, long deltaPackages, long afterPackages,