diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 8ec8d2b43..6cae76174 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -2414,6 +2414,7 @@ CREATE TABLE ll_return_inventory_balance ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货库存按客户海外仓SKU汇总余额'; CREATE TABLE ll_return_inventory_carton ( + label_status varchar(32) DEFAULT NULL COMMENT 'WAIT_LABEL待贴标 CONFIRMED已扫码确认标签', new_carton_mark varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标后新箱唛', id bigint NOT NULL COMMENT '退货库存箱ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index c037053ad..6f68db6d1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -486,7 +486,6 @@ public class DeliveryTransferService { public List returnOutboundCandidates(String customerId, String warehouseId) { if (!StringUtils.hasText(warehouseId)) return List.of(); return returnCartons.findAvailable(required(customerId, "Customer is required"), warehouseId.trim()).stream() - .filter(carton -> !"PARCEL".equals(carton.getSourceUnitType())) .map(c -> new DeliveryResponses.ReturnOutboundCandidate(c.getSourceReturnNo(), c.getOverseasWarehouseId(), c.getCartonMark(), c.getPalletCode(), c.getFbaCartonMark(), c.getDestinationWarehouseCode(), c.getDestinationWarehouseName(), value(c.getTotalQuantity()), c.getStatus())).toList(); @@ -499,23 +498,16 @@ public class DeliveryTransferService { OutboundOrder existing = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim()); if (existing != null) return replayReturnOutbound(customerId, existing, requestHash); String warehouseId = request.overseasWarehouseId().trim(); - boolean itemMode = request.items() != null; - boolean skuMode = itemMode || StringUtils.hasText(request.skuCode()); - Map requestedItems = new LinkedHashMap<>(); - if (itemMode) { - if (request.items().isEmpty() || StringUtils.hasText(request.skuCode()) || request.quantity() != null || "PALLET".equals(request.unitType())) - throw conflict("散件明细出库不可同时提交统一SKU、数量或整托方式"); - for (var line : request.items()) { - if (line == null || !StringUtils.hasText(line.cartonMark()) || !StringUtils.hasText(line.skuCode()) || line.quantity() == null || line.quantity() <= 0 - || requestedItems.putIfAbsent(line.cartonMark().trim(), line) != null) throw conflict("散件出库明细不能为空、数量必须大于0且来源不可重复"); - } - } - if (request.quantity() != null && (!StringUtils.hasText(request.skuCode()) || request.quantity() <= 0 - || "PALLET".equals(request.unitType()))) throw conflict("按件数出库必须选择单SKU,不能同时整托出库"); + // A return outbound is always a physical package/carton operation. SKU and + // quantity are selection fields for the packing workflow, never a direct + // outbound command. Partial package/carton quantities must be materialized + // into a new labelled carton before they can be picked. + boolean itemMode = request.items() != null && !request.items().isEmpty(); + boolean skuMode = itemMode || StringUtils.hasText(request.skuCode()) || request.quantity() != null; + if (skuMode) + throw conflict("退货出库最小单位为包裹/箱;部分数量请先合箱并打印新箱标签后再出库"); List marks = request.cartonMarks().stream().map(String::trim).filter(StringUtils::hasText).distinct().toList(); if (marks.size() != request.cartonMarks().size()) throw conflict("箱唛不能为空且不可重复"); - if (itemMode && (marks.size() != requestedItems.size() || !requestedItems.keySet().containsAll(marks))) - throw conflict("散件出库明细与所选来源不一致"); LocalDateTime now = LocalDateTime.now(); List locked = new ArrayList<>(); for (String mark : marks.stream().sorted().toList()) { @@ -529,37 +521,18 @@ public class DeliveryTransferService { existing = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim()); if (existing != null) return replayReturnOutbound(customerId, existing, requestHash); for (ReturnInventoryCarton carton : locked) { - if (skuMode && !"PARCEL".equals(carton.getSourceUnitType())) - throw conflict("单SKU出库只允许散件库存;箱内库存请按箱或托盘出库"); - if ("PARCEL".equals(carton.getSourceUnitType()) && !skuMode) - throw conflict("包裹散件只能按 SKU 出库,不能按箱号或托盘出库"); + if ("PACKING_RESERVED".equals(carton.getStatus())) + throw conflict("该包裹/箱正在合箱,请先完成或取消合箱"); com.baiyue.lastleg.returnreceipt.service.ReturnCartonCountGuard.requireConfirmed(carton); if (!"AVAILABLE".equals(carton.getStatus())) throw conflict("退货箱已被其他作业占用:" + carton.getCartonMark()); var contents = returnCartonItems.lockCarton(carton.getId()); if (contents.isEmpty() || contents.stream().anyMatch(line -> value(line.getPackingReservedQuantity()) > 0)) throw conflict("退货箱缺少明细或已被合箱计划锁定:" + carton.getCartonMark()); - String selectedSku = itemMode ? requestedItems.get(carton.getCartonMark()).skuCode().trim() : trim(request.skuCode()); - if (skuMode && (contents.size() != 1 - || !selectedSku.equals(contents.get(0).getSkuCode()))) - throw conflict("所选散件来源必须仅包含指定SKU"); - if (itemMode && requestedItems.get(carton.getCartonMark()).quantity() > value(contents.get(0).getQuantity())) - throw conflict("出库件数超过所选散件来源可用数量:" + carton.getCartonMark()); if ("PALLET".equals(request.unitType()) && !StringUtils.hasText(carton.getPalletCode())) throw conflict("整托出库必须存在托盘绑定"); } - Map skuQuantities = new LinkedHashMap<>(); - if (itemMode) requestedItems.forEach((mark, line) -> skuQuantities.put(mark, line.quantity())); - else if (StringUtils.hasText(request.skuCode())) { - long available = locked.stream().mapToLong(c -> value(c.getTotalQuantity())).sum(); - long remaining = request.quantity() == null ? available : request.quantity(); - if (remaining > available) throw conflict("出库件数超过所选来源库存数量"); - for (ReturnInventoryCarton carton : locked) { - long selected = Math.min(remaining, value(carton.getTotalQuantity())); - if (selected > 0) skuQuantities.put(carton.getCartonMark(), selected); - remaining -= selected; - } - locked = locked.stream().filter(c -> skuQuantities.containsKey(c.getCartonMark())).toList(); - } + // No quantity allocation is performed here. The selected physical units + // are shipped whole; partials arrive only as newly-created merge cartons. if ("PALLET".equals(request.unitType())) { String pallet = locked.get(0).getPalletCode(); if (locked.stream().anyMatch(c -> !Objects.equals(pallet, c.getPalletCode()))) throw conflict("整托出库只能选择同一托盘"); @@ -580,9 +553,8 @@ public class DeliveryTransferService { OutboundPalletItem item = new OutboundPalletItem(); item.setId(IdWorker.getId()); item.setCustomerId(customerId); item.setOutboundOrderId(outbound.getId()); item.setPackageCount(1); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); item.setCartonMarks(carton.getCartonMark()); item.setInboundCode(carton.getSourceReturnNo()); - // Packed stock is still picked by its physical carton mark; loose parcels use SKU scanning. - item.setSkuCode("PARCEL".equals(carton.getSourceUnitType()) ? itemMode ? requestedItems.get(carton.getCartonMark()).skuCode().trim() : trim(request.skuCode()) : null); - item.setReturnSkuQuantity(skuQuantities.get(carton.getCartonMark())); + // Packages and cartons are picked by their physical barcode. + item.setSkuCode(null); item.setReturnSkuQuantity(null); item.setPalletNo("PALLET".equals(request.unitType()) ? carton.getPalletCode() : null); item.setCreatedAt(now); outboundPallets.insert(item); carton.setStatus("OUTBOUND_PENDING"); carton.setUpdatedAt(now); returnCartons.updateById(carton); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java index 9e8d44564..a3c2a424f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java @@ -38,7 +38,19 @@ public final class RelabelResponses { String destinationFbaWarehouseName, String skuCode, String productNameZh, long availablePackages, long version, List positions, String sourceType, Long returnCartonId, String cartonMark, - List skuItems, long relabelledPackages) {} + List skuItems, long relabelledPackages, String relabelStatus, + long relabelledQuantity, long relabelReservedQuantity) { + public InventoryCandidate(Long inventoryBalanceId, Long inboundOrderId, String inboundOrderNo, String inboundCode, + String overseasWarehouseId, String overseasWarehouseCode, String overseasWarehouseName, + String destinationFbaWarehouseCode, String destinationFbaWarehouseName, String skuCode, String productNameZh, + long availablePackages, long version, List positions, String sourceType, + Long returnCartonId, String cartonMark, List skuItems, long relabelledPackages) { + this(inventoryBalanceId, inboundOrderId, inboundOrderNo, inboundCode, overseasWarehouseId, + overseasWarehouseCode, overseasWarehouseName, destinationFbaWarehouseCode, destinationFbaWarehouseName, + skuCode, productNameZh, availablePackages, version, positions, sourceType, returnCartonId, cartonMark, + skuItems, relabelledPackages, null, relabelledPackages, 0); + } + } public record HistoricalOrder(Long id, String orderNo, String sourceOrderNo, String outboundOrderNo, String relabelOrderNo, String orderType, String status, Integer packageCount, LocalDateTime createdAt) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java index 871f82830..3ef755bed 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java @@ -6,6 +6,25 @@ import org.apache.ibatis.annotations.Mapper; @Mapper public interface RelabelItemMapper extends BaseMapper { + @org.apache.ibatis.annotations.Select("SELECT ri.* FROM ll_relabel_item ri JOIN ll_relabel_application ra ON ra.id=ri.application_id " + + "WHERE ri.customer_id=#{customerId} AND ri.return_carton_id=#{cartonId} AND ri.application_id<>#{currentApplicationId} " + + "AND ra.status NOT IN ('CANCELLED','REJECTED') AND ra.customer_confirmed_at IS NOT NULL " + + "AND EXISTS(SELECT 1 FROM ll_relabel_pda_unit_fact f WHERE f.item_id=ri.id AND f.result='RELABELLED') " + + "AND NOT EXISTS(SELECT 1 FROM ll_relabel_item newer JOIN ll_relabel_application na ON na.id=newer.application_id " + + "WHERE newer.return_carton_id=ri.return_carton_id AND newer.application_id<>#{currentApplicationId} AND na.status NOT IN ('CANCELLED','REJECTED') " + + "AND (COALESCE(newer.source_carton_mark,newer.carton_mark)=COALESCE(ri.source_carton_mark,ri.carton_mark) " + + "OR COALESCE(newer.source_carton_mark,newer.carton_mark)=(SELECT c.carton_mark FROM ll_return_inventory_carton c WHERE c.id=#{cartonId})) " + + "AND (na.created_at>ra.created_at OR (na.created_at=ra.created_at AND na.id>ra.id))) ORDER BY ri.id") + java.util.List priorCompletedReturnItems(@org.apache.ibatis.annotations.Param("customerId") String customerId, + @org.apache.ibatis.annotations.Param("cartonId") long cartonId, + @org.apache.ibatis.annotations.Param("currentApplicationId") long currentApplicationId); + @org.apache.ibatis.annotations.Update("UPDATE ll_relabel_item i JOIN ll_relabel_application a ON a.id=i.application_id " + + "SET i.return_carton_id=#{targetId},i.carton_mark=#{targetMark} WHERE i.customer_id=#{customerId} " + + "AND i.return_carton_id=#{sourceId} AND a.status NOT IN ('CANCELLED','REJECTED') AND a.customer_confirmed_at IS NOT NULL") + int moveCompletedReturnCarton(@org.apache.ibatis.annotations.Param("customerId") String customerId, + @org.apache.ibatis.annotations.Param("sourceId") long sourceId, + @org.apache.ibatis.annotations.Param("targetId") long targetId, + @org.apache.ibatis.annotations.Param("targetMark") String targetMark); @org.apache.ibatis.annotations.Select("SELECT id FROM ll_inventory_balance WHERE id=#{balanceId} AND customer_id=#{customerId} FOR UPDATE") Long lockSourceBalance(@org.apache.ibatis.annotations.Param("customerId") String customerId, @org.apache.ibatis.annotations.Param("balanceId") Long balanceId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java index 0e4405b67..a15335082 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java @@ -86,6 +86,13 @@ public class PdaRelabelController { identity.overseasWarehouseId(), relabelNo, request)); } + @PostMapping("/{relabelNo}/return-cartons/pack") + public ApiResponse packReturn(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @Valid @RequestBody com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests.BoxMerge request) { + Identity identity = identity(user); + return ApiResponse.success(service.packReturn(identity.operatorKey(), identity.operatorName(), identity.overseasWarehouseId(), relabelNo, request)); + } + @PostMapping("/{relabelNo}/return-skus/complete-total") public ApiResponse completeReturnSkuTotal(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.BulkSkuTotal request) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java index a44f212f0..b38d7d556 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java @@ -17,7 +17,13 @@ public final class PdaRelabelRequests { public record SkuScan(@NotNull @Positive Long itemId, @Min(0) int skuIndex, @NotBlank @Size(max = 255) String oldSku, @NotNull @Min(0) Integer expectedVersion, - @NotBlank @Size(max = 255) String idempotencyKey) { } + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 128) String cartonMark, + @Size(max = 128) String newSkuCode, @Positive Integer quantity) { + public SkuScan(Long itemId, int skuIndex, String oldSku, Integer expectedVersion, String idempotencyKey) { + this(itemId, skuIndex, oldSku, expectedVersion, idempotencyKey, null, null, null); + } + } public record CartonScan(@NotBlank @Size(max = 255) String cartonMark, @NotNull @Min(0) Integer expectedVersion, @@ -40,7 +46,13 @@ public final class PdaRelabelRequests { public record BulkSku(@NotNull @Positive Long itemId, @Positive int quantity, @NotNull @Min(0) Integer expectedVersion, - @NotBlank @Size(max = 255) String idempotencyKey) { } + @NotBlank @Size(max = 255) String idempotencyKey, + @NotBlank @Size(max = 128) String cartonMark, + @NotBlank @Size(max = 128) String skuCode) { + public BulkSku(Long itemId, int quantity, Integer expectedVersion, String idempotencyKey) { + this(itemId, quantity, expectedVersion, idempotencyKey, null, null); + } + } public record BulkSkuTotal(@NotBlank @Size(max = 255) String skuCode, @Positive int quantity, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java index 02afc5ff0..0b246255f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java @@ -20,7 +20,20 @@ public final class PdaRelabelResponses { String operatorName, String reviewerName, List completionPhotoFileIds, String completionRemark, String reviewDecision, String reviewRemark, LocalDateTime claimedAt, LocalDateTime submittedAt, LocalDateTime reviewedAt, - List items, List unitFacts, List history) { } + List items, List unitFacts, List history, String deliveryMethod, + boolean requiresPalletBinding) { + public Detail(String relabelNo, String customerOrderNo, LocalDateTime labelDeadline, String contactName, + String contactPhone, String applicationStatus, String operationType, String taskStatus, int expectedPackages, + int relabelledPackages, int skippedPackages, int exceptionPackages, int version, String operatorName, + String reviewerName, List completionPhotoFileIds, String completionRemark, String reviewDecision, + String reviewRemark, LocalDateTime claimedAt, LocalDateTime submittedAt, LocalDateTime reviewedAt, + List items, List unitFacts, List history) { + this(relabelNo, customerOrderNo, labelDeadline, contactName, contactPhone, applicationStatus, operationType, + taskStatus, expectedPackages, relabelledPackages, skippedPackages, exceptionPackages, version, + operatorName, reviewerName, completionPhotoFileIds, completionRemark, reviewDecision, reviewRemark, + claimedAt, submittedAt, reviewedAt, items, unitFacts, history, null, false); + } + } public record Item(Long itemId, String skuCode, String productNameZh, String shipmentNo, String trackingCode, String newSkuCode, String labelRequirement, @@ -29,7 +42,18 @@ public final class PdaRelabelResponses { List skuItems, int expectedPackages, int relabelledPackages, int skippedPackages, int exceptionPackages, int cartonProcessedPackages, int skuProcessedPackages, - String destinationFbaWarehouseCode) { } + String destinationFbaWarehouseCode, Long returnCartonId, long returnQuantity) { + public Item(Long itemId, String skuCode, String productNameZh, String shipmentNo, String trackingCode, + String newSkuCode, String labelRequirement, String relabelFileId, String relabelFileName, String relabelFileUrl, + String cartonMark, String fbaCartonMark, String operationType, List skuItems, int expectedPackages, + int relabelledPackages, int skippedPackages, int exceptionPackages, int cartonProcessedPackages, + int skuProcessedPackages, String destinationFbaWarehouseCode) { + this(itemId, skuCode, productNameZh, shipmentNo, trackingCode, newSkuCode, labelRequirement, + relabelFileId, relabelFileName, relabelFileUrl, cartonMark, fbaCartonMark, operationType, skuItems, + expectedPackages, relabelledPackages, skippedPackages, exceptionPackages, cartonProcessedPackages, + skuProcessedPackages, destinationFbaWarehouseCode, null, 0); + } + } public record SkuItem(String skuCode, String newSkuCode, int quantity, String remark) { } public record UnitFact(Long factId, Long itemId, String oldLabel, String newLabel, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java index 6e18f077a..9b1c186ba 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java @@ -52,6 +52,32 @@ import java.util.stream.Collectors; @Service public class PdaRelabelService { + @Resource private com.baiyue.lastleg.relabel.service.ReturnRelabelPackingService returnPacking; + + @Transactional + public PdaRelabelResponses.Detail packReturn(String operatorKey, String operatorName, long warehouseId, + String relabelNo, com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests.BoxMerge request) { + UnitCommand command = new UnitCommand(relabelNo, "RETURN_PACK", request); + var replay = replay(operatorKey, warehouseId, relabelNo, "RETURN_PACK", request.idempotencyKey(), command); + if (replay != null) return replay; + var app = requireApplication(relabelNo, warehouseId, true); + var task = requireOwnedTask(app, operatorKey, true); + var selected = itemList(app).stream().filter(i -> i.getReturnCartonId() != null + && Set.of("RETURN_BULK_SKU_LABEL", "SKU_LABEL").contains(operationType(i))).toList(); + if (selected.isEmpty() || selected.size() != itemList(app).size()) throw conflict("仅退货按件换标任务可使用此合箱操作"); + var facts = factList(task); + for (var item : selected) if (quantity(facts.stream().filter(f -> item.getId().equals(f.getItemId())).toList(), "RELABELLED") + != value(item.getNotifiedPackageCount())) throw conflict("请先完成每个来源包裹/箱的换标数量登记,再合箱"); + var sourceDeductions = returnPacking.deductions(selected); + var packed = returnPacking.pack(new com.baiyue.lastleg.pda.security.PdaWarehouseIdentity(operatorKey, operatorName, warehouseId), app, selected, request); + updateTask(task, task.getVersion()); + insertHistory(task, "RETURN_PACK", PROCESSING, PROCESSING, operatorKey, operatorName, + Map.of("cartonMark", packed.cartonMark(), "quantity", packed.totalQuantity(), "label", packed.label(), + "round", mixedRound(task), "sourceCartonMarks", request.sourceCartonMarks(), "sourceDeductions", sourceDeductions)); + var result = detail(app, task); + saveOperation(task, operatorKey, "RETURN_PACK", request.idempotencyKey(), command, result); + return result; + } @Resource private com.baiyue.lastleg.relabel.service.RelabelBindingGuard bindingGuard; public static final String AVAILABLE = "AVAILABLE"; public static final String PROCESSING = "PROCESSING"; @@ -230,11 +256,16 @@ public class PdaRelabelService { var task = requireOwnedTask(app, operatorKey, true); requireVersion(task, request.expectedVersion()); var item = requireItem(app, request.itemId()); + if (item.getReturnCartonId() != null && !Objects.equals(item.getCartonMark(), trim(request.cartonMark()))) + throw conflict("请扫描当前来源包裹/箱号后再扫描 SKU"); if (!Set.of("SKU_LABEL", "CARTON_AND_SKU").contains(operationType(item))) throw conflict("该箱没有SKU换标任务"); if (value(item.getNotifiedPackageCount()) != 1) throw conflict("SKU逐条作业要求每个仓库唛头独立一箱"); var skus = readSkuItems(item.getSkuRelabelItemsJson()); if (request.skuIndex() < 0 || request.skuIndex() >= skus.size()) throw conflict("SKU明细不存在"); var sku = skus.get(request.skuIndex()); + if (item.getReturnCartonId() != null && (!Objects.equals(sku.newSkuCode(), trim(request.newSkuCode())) + || !Objects.equals(sku.quantity(), request.quantity()))) + throw conflict("新 SKU 和件数必须与已批准的来源箱明细一致"); if (!Objects.equals(sku.skuCode(), request.oldSku().trim()) || !StringUtils.hasText(sku.newSkuCode()) || sku.quantity() <= 0) throw conflict("旧SKU或绑定的新标签、数量不正确"); if (factList(task).stream().anyMatch(fact -> item.getId().equals(fact.getItemId())) @@ -275,6 +306,9 @@ public class PdaRelabelService { requireVersion(task, request.expectedVersion()); RelabelItem item = requireItem(application, request.itemId()); if (!"RETURN_BULK_SKU_LABEL".equals(operationType(item))) throw conflict("当前明细不是退货按件贴标任务"); + if (!Objects.equals(item.getCartonMark(), trim(request.cartonMark())) + || !Objects.toString(item.getSkuCode(), "").equalsIgnoreCase(Objects.toString(trim(request.skuCode()), ""))) + throw conflict("请扫描当前来源包裹/箱号和对应 SKU,不能登记其他来源"); int completed = quantity(factList(task).stream().filter(fact -> item.getId().equals(fact.getItemId())).toList(), "RELABELLED"); int remaining = value(item.getNotifiedPackageCount()) - completed; if (request.quantity() > remaining) throw conflict("本次完成数量超过该 SKU 剩余贴标数量"); @@ -309,6 +343,8 @@ public class PdaRelabelService { .filter(item -> skuCode.equalsIgnoreCase(Objects.toString(item.getSkuCode(), "").trim())) .toList(); if (skuItems.isEmpty()) throw notFound("当前换标单没有该退货 SKU"); + if (skuItems.stream().map(RelabelItem::getCartonMark).distinct().count() != 1) + throw conflict("同 SKU 来自多个包裹/箱,请逐来源扫码登记,不能自动分配"); List existingFacts = factList(task); Map completedByItem = new HashMap<>(); for (RelabelItem item : skuItems) { @@ -574,6 +610,9 @@ public class PdaRelabelService { throw conflict("Relabel item already has all required package facts"); } String normalizedOldLabel = trim(oldLabel); + // A merged return carton can carry several independent SKU task items. + // Product-label facts are unique by item; old_label's global key is for physical carton-label scans. + if (item.getReturnCartonId() != null && "SKU_LABEL".equals(operationType(item))) normalizedOldLabel = null; if (normalizedOldLabel != null && unitFacts.findOldLabel(normalizedOldLabel) != null) { throw conflict("Old label was already processed by another relabel unit"); } @@ -746,7 +785,8 @@ public class PdaRelabelService { quantity(itemFacts, "EXCEPTION"), count(itemFacts, "RELABELLED") + (itemFacts.isEmpty() && mixedStageDone(task, item.getId(), "CARTON") ? 1 : 0), count(itemFacts, "RELABELLED") + (itemFacts.isEmpty() && mixedStageDone(task, item.getId(), "SKU") ? 1 : 0), - item.getDestinationFbaWarehouseCode()); + item.getDestinationFbaWarehouseCode(), item.getReturnCartonId(), + returnQuantity(item)); }).toList(); List factResponses = facts.stream().map(fact -> new PdaRelabelResponses.UnitFact(fact.getId(), fact.getItemId(), fact.getOldLabel(), fact.getNewLabel(), @@ -768,7 +808,7 @@ public class PdaRelabelService { task == null ? null : task.getCompletionRemark(), task == null ? null : task.getReviewDecision(), task == null ? null : task.getReviewRemark(), task == null ? null : task.getClaimedAt(), task == null ? null : task.getSubmittedAt(), task == null ? null : task.getReviewedAt(), - itemResponses, factResponses, historyResponses); + itemResponses, factResponses, historyResponses, application.getDeliveryMethod(), "TRUCK".equals(application.getDeliveryMethod())); } private String effectiveTaskStatus(RelabelApplication application, PdaRelabelTask task) { @@ -841,6 +881,10 @@ public class PdaRelabelService { List.class, PdaRelabelResponses.SkuItem.class)); } catch (JsonProcessingException exception) { throw new IllegalStateException("Cannot read relabel SKU details", exception); } } + private long returnQuantity(RelabelItem item) { + var carton = item.getReturnCartonId() == null ? null : returnCartons.selectById(item.getReturnCartonId()); + return carton == null || carton.getTotalQuantity() == null ? 0L : carton.getTotalQuantity(); + } private static String operationType(RelabelItem item) { return StringUtils.hasText(item.getOperationType()) ? item.getOperationType() : "CARTON_LABEL"; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java index 4ad45ee05..bbc868a6f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java @@ -264,7 +264,7 @@ public class RelabelApplicationService implements WalletResultHandler { balance.getProductNameZh(), available, value(balance.getVersion()), positions, "INBOUND", null, null, List.of(), 0); }).filter(candidate -> candidate.availablePackages() > 0).toList()); - for (ReturnInventoryCarton carton : returnCartons.findAvailable(customerId, operationWarehouseId.trim())) { + for (ReturnInventoryCarton carton : returnCartons.findRelabelCandidates(customerId, operationWarehouseId.trim())) { String keyword = trim(inboundCode); if (StringUtils.hasText(keyword) && !containsIgnoreCase(carton.getCartonMark(), keyword) @@ -272,22 +272,22 @@ public class RelabelApplicationService implements WalletResultHandler { List cartonSkus = returnCartonItems.listCarton(carton.getId()).stream() .filter(item -> value(item.getQuantity()) > 0) .map(item -> { - long used = returnCartons.relabelledQuantity(carton.getId(), item.getSkuCode()); - long remaining = value(item.getQuantity()) - used - value(item.getPackingReservedQuantity()); + long remaining = "AVAILABLE".equals(carton.getStatus()) + ? value(item.getQuantity()) - value(item.getPackingReservedQuantity()) : 0; return new RelabelResponses.SkuItem(item.getSkuCode(), null, Math.toIntExact(Math.max(remaining, 0)), null); - }).filter(item -> item.quantity() > 0).toList(); + }).toList(); if (cartonSkus.isEmpty()) continue; String skuCodes = cartonSkus.stream().map(RelabelResponses.SkuItem::skuCode) .collect(Collectors.joining(",")); long availablePackages = cartonSkus.stream().mapToLong(RelabelResponses.SkuItem::quantity).sum(); - if (availablePackages <= 0) continue; - long relabelledPackages = carton.getTotalQuantity() == null ? 0 - : Math.max(0, carton.getTotalQuantity() - availablePackages); + long relabelledPackages = returnCartons.totalRelabelledQuantity(carton.getId()); + long reserved = returnCartons.relabelReservedQuantity(carton.getId()); result.add(new RelabelResponses.InventoryCandidate(null, null, carton.getSourceReturnNo(), null, carton.getOverseasWarehouseId(), null, null, carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), skuCodes, "退货入库库存", availablePackages, 0, List.of(), - "PARCEL".equals(carton.getSourceUnitType()) ? "RETURN_PARCEL" : "RETURN_CARTON", carton.getId(), carton.getCartonMark(), cartonSkus, relabelledPackages)); + "PARCEL".equals(carton.getSourceUnitType()) ? "RETURN_PARCEL" : "RETURN_CARTON", carton.getId(), carton.getCartonMark(), cartonSkus, relabelledPackages, + com.baiyue.lastleg.returnreceipt.service.ReturnRelabelProgress.status(value(carton.getTotalQuantity()), relabelledPackages, reserved), relabelledPackages, reserved)); } return result; } @@ -370,6 +370,7 @@ public class RelabelApplicationService implements WalletResultHandler { Set selectedCartonMarks = new HashSet<>(); Map selectedReturnCartons = new LinkedHashMap<>(); Map selectedReturnPackages = new LinkedHashMap<>(); + Map> selectedReturnSkus = new LinkedHashMap<>(); for (RelabelRequests.Item item : request.items()) { validateOperation(item); if (item.notifiedPackageCount() > item.totalPackageCount()) { @@ -401,9 +402,8 @@ public class RelabelApplicationService implements WalletResultHandler { final long sourceReturnCartonId = carton.getId(); Map availableSku = returnCartonItems.lockCarton(sourceReturnCartonId).stream() .collect(Collectors.toMap(line -> line.getSkuCode().toUpperCase(java.util.Locale.ROOT), - line -> Math.max(0, value(line.getQuantity()) - value(line.getPackingReservedQuantity()) - - returnCartons.relabelledQuantity(sourceReturnCartonId, line.getSkuCode())), Long::sum)); - Map requestedSku = new java.util.HashMap<>(); + line -> Math.max(0, value(line.getQuantity()) - value(line.getPackingReservedQuantity())), Long::sum)); + Map requestedSku = selectedReturnSkus.computeIfAbsent(carton.getId(), ignored -> new java.util.HashMap<>()); if (item.skuItems() != null) for (var skuItem : item.skuItems()) { long quantity = requestedSku.merge(skuItem.skuCode().toUpperCase(java.util.Locale.ROOT), (long) skuItem.quantity(), Long::sum); if (quantity > availableSku.getOrDefault(skuItem.skuCode().toUpperCase(java.util.Locale.ROOT), 0L)) @@ -414,10 +414,9 @@ public class RelabelApplicationService implements WalletResultHandler { if (Set.of("CARTON_LABEL", "CARTON_AND_SKU").contains(item.operationType()) && (item.totalPackageCount() != 1 || item.notifiedPackageCount() != 1)) throw conflict("退货箱唛换标按真实箱计数,每个来源箱只能填写一箱"); - long previouslyRelabelled = returnCartons.relabelledQuantity(carton.getId(), item.skuCode()); long requestedPackages = selectedReturnPackages.merge(carton.getId(), (long) item.notifiedPackageCount(), Math::addExact); - if (requestedPackages + previouslyRelabelled > value(carton.getTotalQuantity())) { + if (requestedPackages > value(carton.getTotalQuantity())) { throw conflict("退货托盘 " + carton.getCartonMark() + " 可用箱数不足"); } sources.add(new SourceInventory(item, null, carton)); @@ -495,7 +494,8 @@ public class RelabelApplicationService implements WalletResultHandler { } } for (String returnNo : selectedReturnCartons.values().stream().map(ReturnInventoryCarton::getSourceReturnNo) - .filter(StringUtils::hasText).distinct().sorted().toList()) { + .filter(StringUtils::hasText).flatMap(value -> java.util.Arrays.stream(value.split(","))) + .map(String::trim).filter(StringUtils::hasText).distinct().sorted().toList()) { ReturnOrder returnOrder = returnOrders.lockCustomerNo(customerId, returnNo); if (returnOrder != null) { returnOrder.setRelabelApplicationId(applicationId); @@ -618,11 +618,15 @@ public class RelabelApplicationService implements WalletResultHandler { } if (next.equals(application.getStatus())) return detail(customerId, relabelNo); validateTransition(application.getStatus(), next); + if (Set.of("REJECTED", "CANCELLED").contains(next) && itemList(application.getId()).stream() + .filter(item -> item.getReturnCartonId() != null) + .anyMatch(item -> returnCartons.totalRelabelledQuantity(item.getReturnCartonId()) > 0)) + throw conflict("该退货任务已有实际商品换标事实,不能取消并恢复为未换标库存;请按异常完工或退回重做处理"); if (PENDING_REVIEW.equals(application.getStatus()) && PENDING_PROCESSING.equals(next) && itemList(application.getId()).stream().anyMatch(item -> "RETURN_BULK_SKU_LABEL".equals(operationType(item)))) { boolean hasCartonFile = StringUtils.hasText(application.getCartonTotalFileId()); boolean hasSkuFile = StringUtils.hasText(application.getSkuTotalFileId()); - if (hasCartonFile == hasSkuFile) throw conflict("退货贴标审核前必须且只能上传一个箱唛总文件或 SKU 总文件"); + if (!hasCartonFile && !hasSkuFile) throw conflict("退货贴标审核前必须上传箱唛总文件或 SKU 总文件,两类资料可分别上传"); } if ("PROCESSING".equals(next) && !Set.of("FROZEN", "NOT_REQUIRED").contains(application.getWalletStatus())) { @@ -686,11 +690,10 @@ public class RelabelApplicationService implements WalletResultHandler { insertOrderHistory(customerId, order.getId(), orderBefore, orderStatus, request.remark(), now); } } - ReturnOrder returnOrder = returnOrders.selectOne(new LambdaQueryWrapper() + List linkedReturnOrders = returnOrders.selectList(new LambdaQueryWrapper() .eq(ReturnOrder::getRelabelApplicationId, application.getId()) - .eq(ReturnOrder::getCustomerId, customerId) - .last("LIMIT 1")); - if (returnOrder != null) { + .eq(ReturnOrder::getCustomerId, customerId)); + for (ReturnOrder returnOrder : linkedReturnOrders) { returnOrder.setRelabelStatus(next); returnOrder.setUpdatedAt(now); returnOrders.updateById(returnOrder); @@ -938,11 +941,10 @@ public class RelabelApplicationService implements WalletResultHandler { request.remark(), now); } } - ReturnOrder returnOrder = returnOrders.selectOne(new LambdaQueryWrapper() + List completedReturnOrders = returnOrders.selectList(new LambdaQueryWrapper() .eq(ReturnOrder::getRelabelApplicationId, application.getId()) - .eq(ReturnOrder::getCustomerId, customerId) - .last("LIMIT 1")); - if (returnOrder != null) { + .eq(ReturnOrder::getCustomerId, customerId)); + for (ReturnOrder returnOrder : completedReturnOrders) { returnOrder.setRelabelStatus("COMPLETED"); returnOrder.setUpdatedAt(now); returnOrders.updateById(returnOrder); @@ -961,6 +963,7 @@ public class RelabelApplicationService implements WalletResultHandler { carton.setStatus(StringUtils.hasText(application.getOutboundOrderNo()) ? "OUTBOUND_PENDING" : awaitingTruck ? "RELABEL_PENDING" : "AVAILABLE"); carton.setUpdatedAt(now); returnCartons.updateById(carton); + if (carton.getRelabelApplicationId() == null) returnCartons.clearRelabel(carton.getId()); } insertHistory(customerId, application.getId(), before, application.getStatus(), operatorId, request.remark(), now); @@ -1295,6 +1298,7 @@ public class RelabelApplicationService implements WalletResultHandler { && "RELABEL_PENDING".equals(carton.getStatus())) { carton.setRelabelApplicationId(null); carton.setRelabelNo(null); carton.setRelabelStatus(null); carton.setStatus("AVAILABLE"); carton.setUpdatedAt(LocalDateTime.now()); returnCartons.updateById(carton); + returnCartons.clearRelabel(carton.getId()); } continue; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuard.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuard.java index 88cb5602f..e3de83b25 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuard.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuard.java @@ -22,12 +22,13 @@ public class RelabelBindingGuard { @Resource private PdaRelabelHistoryMapper histories; @Resource private InventoryPositionMapper positions; @Resource private ReturnInventoryCartonMapper returnCartons; + @Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper returnLines; @Resource private ObjectMapper objectMapper; public void requireBound(RelabelApplication app) { List cartons = items.selectList(new QueryWrapper() .eq("application_id", app.getId()).eq("customer_id", app.getCustomerId())) - .stream().filter(item -> Set.of("CARTON_LABEL", "CARTON_AND_SKU", "RETURN_BULK_SKU_LABEL") + .stream().filter(item -> (item.getReturnCartonId() != null && "SKU_LABEL".equals(item.getOperationType())) || Set.of("CARTON_LABEL", "CARTON_AND_SKU", "RETURN_BULK_SKU_LABEL") .contains(item.getOperationType())).toList(); if (cartons.isEmpty()) return; Map> bindings = new HashMap<>(); @@ -44,7 +45,7 @@ public class RelabelBindingGuard { } catch (Exception e) { throw conflict("组托记录无法读取,请核对后重试"); } } for (RelabelItem item : cartons) { - if (item.getReturnCartonId() != null && !"RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) { + if (item.getReturnCartonId() != null && !Set.of("RETURN_BULK_SKU_LABEL", "SKU_LABEL").contains(item.getOperationType())) { var carton = returnCartons.selectById(item.getReturnCartonId()); if (carton == null || !app.getCustomerId().equals(carton.getCustomerId())) throw conflict("退货箱来源缺失"); if (!"TRUCK".equals(app.getDeliveryMethod())) continue; @@ -52,7 +53,20 @@ public class RelabelBindingGuard { throw conflict("退货箱尚未完成排车托盘绑定"); continue; } - if ("RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) { + if (Set.of("RETURN_BULK_SKU_LABEL", "SKU_LABEL").contains(item.getOperationType()) && item.getReturnCartonId() != null) { + var sameCarton = cartons.stream().filter(i -> Objects.equals(i.getReturnCartonId(), item.getReturnCartonId())).toList(); + var stock = item.getReturnCartonId() == null ? List.of() : returnLines.lockCarton(item.getReturnCartonId()); + var selected = new HashMap(); + for (var i : sameCarton) { + if ("RETURN_BULK_SKU_LABEL".equals(i.getOperationType())) selected.merge(i.getSkuCode().toUpperCase(Locale.ROOT), (long)i.getNotifiedPackageCount(), Long::sum); + else try { + for (var sku : objectMapper.readTree(i.getSkuRelabelItemsJson())) selected.merge(sku.path("skuCode").asText().toUpperCase(Locale.ROOT), sku.path("quantity").asLong(), Long::sum); + } catch (Exception failure) { throw conflict("换标明细无法读取"); } + } + var actual = new HashMap(); + stock.forEach(i -> actual.merge(i.getSkuCode().toUpperCase(Locale.ROOT), i.getQuantity(), Long::sum)); + if (!selected.equals(actual)) throw conflict("部分换标货物必须先合箱生成独立新箱,再提交完工"); + if (!"TRUCK".equals(app.getDeliveryMethod())) continue; var carton = item.getReturnCartonId() == null ? null : returnCartons.selectById(item.getReturnCartonId()); if (carton == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(carton.getPalletCode()) || !"PROCESSING".equals(carton.getRelabelStatus())) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/ReturnRelabelPackingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/ReturnRelabelPackingService.java new file mode 100644 index 000000000..0d3d23925 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/ReturnRelabelPackingService.java @@ -0,0 +1,136 @@ +package com.baiyue.lastleg.relabel.service; + +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; +import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests; +import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; +import com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import java.util.*; + +/** Called inside the owned PDA task transaction; materializes only its completed pieces. */ +@Service +public class ReturnRelabelPackingService { + @Resource private ReturnInventoryService inventory; + @Resource private ReturnInventoryCartonMapper cartons; + @Resource private RelabelItemMapper items; + @Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper cartonItems; + @Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnRelabelCompletionLedgerMapper completionLedger; + @Resource private com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelUnitFactMapper facts; + @Resource private com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper histories; + @Resource private com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper tasks; + @Resource private com.fasterxml.jackson.databind.ObjectMapper json; + + @org.springframework.transaction.annotation.Transactional + public ReturnInventoryResponses.MergeResult pack(PdaWarehouseIdentity identity, RelabelApplication app, + List selected, ReturnInventoryRequests.BoxMerge request) { + if (request.targetCartonMark() != null || selected.isEmpty()) throw conflict("换标合箱必须生成独立新箱"); + var marks = selected.stream().map(RelabelItem::getCartonMark).distinct().sorted().toList(); + if (!new HashSet<>(marks).equals(new HashSet<>(request.sourceCartonMarks()))) throw conflict("请扫描本次换标全部来源包裹/箱"); + if (selected.stream().map(RelabelItem::getDestinationFbaWarehouseCode).distinct().count() != 1) + throw conflict("不同目的仓不能合箱"); + var deductions = deductions(selected); + if (request.sourceDeductions() != null && !totals(deductions).equals(totals(request.sourceDeductions()))) + throw conflict("合箱数量必须等于本次已完成换标的明细数量"); + var inheritedTransfers = new ArrayList>(); + for (String mark : marks) { + var source = cartons.lockMark(mark); + if (source == null || !app.getCustomerId().equals(source.getCustomerId()) + || !app.getId().equals(source.getRelabelApplicationId()) || !"RELABEL_PENDING".equals(source.getStatus())) + throw conflict("来源包裹/箱已变化,请刷新"); + var prior = items.priorCompletedReturnItems(app.getCustomerId(), source.getId(), app.getId()); + if (!prior.isEmpty()) { + var ids = prior.stream().map(i -> i.getId().toString()).toList(); + var raw = inheritedQuantities(prior); + var physical = new HashMap(); + cartonItems.lockCarton(source.getId()).forEach(i -> physical.merge(i.getSkuCode().toUpperCase(Locale.ROOT), i.getQuantity(), Math::addExact)); + var moved = new ArrayList>(); + for (var entry : totals(deductions).entrySet()) { + if (!mark.equals(entry.getKey().get(0))) continue; + String sku = entry.getKey().get(1).toUpperCase(Locale.ROOT); + long inherited = raw.getOrDefault(sku, 0L) - completionLedger.deductedQuantity(ids, sku); + long stock = physical.getOrDefault(sku, 0L); + if (inherited < 0 || inherited > stock) throw conflict("历史换标实物归属与来源库存不一致,请核对来源"); + if (inherited > 0 && inherited < stock && entry.getValue() < stock) + throw conflict("已换标与未换标货物来源不能自动猜测,请整箱隔离后重新换标"); + long transfer = Math.min(inherited, entry.getValue()); + if (transfer > 0) moved.add(Map.of("skuCode", sku, "quantity", transfer)); + } + if (!moved.isEmpty()) inheritedTransfers.add(Map.of("sourceItemIds", ids, "sourceCartonMark", mark, "items", moved)); + } + source.setStatus("AVAILABLE"); cartons.updateById(source); + } + var result = inventory.mergeBoxes(identity, new ReturnInventoryRequests.BoxMerge(marks, null, + "RELABEL-PACK:" + app.getId() + ":" + request.idempotencyKey(), deductions, + request.length(), request.width(), request.height(), request.weight())); + for (String mark : marks) { + var source = cartons.lockMark(mark); + cartons.clearRelabel(source.getId()); + } + var target = cartons.lockMark(result.cartonMark()); + target.setStatus("RELABEL_PENDING"); target.setRelabelApplicationId(app.getId()); + target.setRelabelNo(app.getRelabelNo()); target.setRelabelStatus("PROCESSING"); + target.setDestinationWarehouseCode(selected.get(0).getDestinationFbaWarehouseCode()); + cartons.updateById(target); + for (var item : selected) { + item.setReturnCartonId(target.getId()); item.setCartonMark(target.getCartonMark()); + items.updateById(item); + } + if (!inheritedTransfers.isEmpty()) { + var task = tasks.find(app.getCustomerId(), app.getId()); + for (var transfer : inheritedTransfers) { + var detail = new LinkedHashMap(transfer); + detail.put("targetCartonMark", result.cartonMark()); detail.put("targetApplicationId", app.getId().toString()); + var history = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory(); + history.setId(com.baomidou.mybatisplus.core.toolkit.IdWorker.getId()); history.setTaskId(task.getId()); + history.setApplicationId(app.getId()); history.setCustomerId(app.getCustomerId()); + history.setAction("RETURN_COMPLETION_TRANSFER"); history.setFromStatus("PROCESSING"); history.setToStatus("PROCESSING"); + history.setOperatorKey(identity.operatorKey()); history.setOperatorName(identity.operatorName()); + try { history.setDetailJson(json.writeValueAsString(detail)); } + catch (java.io.IOException failure) { throw new IllegalStateException(failure); } + history.setCreatedAt(java.time.LocalDateTime.now()); histories.insert(history); + } + } + return result; + } + + private Map inheritedQuantities(List prior) { + var completed = facts.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper() + .in("item_id", prior.stream().map(RelabelItem::getId).toList()).eq("result", "RELABELLED")); + var result = new HashMap(); + for (var item : prior) { + long count = completed.stream().filter(f -> item.getId().equals(f.getItemId())).mapToLong(f -> f.getQuantity() == null ? 1 : f.getQuantity()).sum(); + if ("RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) result.merge(item.getSkuCode().toUpperCase(Locale.ROOT), count, Math::addExact); + else for (var deduction : deductions(List.of(item))) + result.merge(deduction.skuCode().toUpperCase(Locale.ROOT), Math.multiplyExact(count, deduction.quantity()), Math::addExact); + } + return result; + } + + public List deductions(List selected) { + var deductions = new ArrayList(); + for (var i : selected) { + if ("RETURN_BULK_SKU_LABEL".equals(i.getOperationType())) deductions.add(new ReturnInventoryRequests.SourceDeduction(i.getCartonMark(), i.getSkuCode(), i.getNotifiedPackageCount())); + else try { + for (var sku : json.readTree(i.getSkuRelabelItemsJson())) deductions.add(new ReturnInventoryRequests.SourceDeduction(i.getCartonMark(), sku.path("skuCode").asText(), sku.path("quantity").asLong())); + } catch (java.io.IOException failure) { throw conflict("换标 SKU 明细无法读取"); } + } + return deductions; + } + private Map, Long> totals(List lines) { + var result = new LinkedHashMap, Long>(); + for (var line : lines) { + if (line.quantity() <= 0) throw conflict("合箱扣减数量必须为正数"); + result.merge(List.of(line.cartonMark(), line.skuCode()), line.quantity(), Math::addExact); + } + return result; + } + + private RuntimeException conflict(String reason) { + return new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, reason); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PdaReturnInventoryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PdaReturnInventoryController.java index 689ad53da..80cde6628 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PdaReturnInventoryController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PdaReturnInventoryController.java @@ -24,6 +24,11 @@ public class PdaReturnInventoryController { @Resource private ReturnInventoryService service; @Resource private com.baiyue.lastleg.returnreceipt.service.ReturnCartonCountService counting; + @PostMapping("/cartons/{cartonMark}/confirm-label") + public ApiResponse confirmLabel(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String cartonMark) { + return ApiResponse.success(service.confirmLabel(PdaWarehouseIdentity.require(user), cartonMark)); + } + @GetMapping("/counting/tasks") public ApiResponse> countingTasks( @AuthenticationPrincipal AuthenticatedUser user, @Valid com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.Query query) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryRequests.java index e3f4d37d5..8a3158e39 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryRequests.java @@ -45,9 +45,13 @@ public final class ReturnInventoryRequests { @NotEmpty @Size(max = 100) List<@Valid PlanBox> boxes, @Size(max = 1000) String remark, @NotBlank @Size(max = 255) String idempotencyKey, - @Size(max = 500) List<@NotBlank String> sourceCartonMarks) { + @Size(max = 500) List<@NotBlank String> sourceCartonMarks, + @Size(max = 1000) List<@Valid SourceDeduction> sourceDeductions) { + public PackingPlanCreate(List returnNos, String mode, List boxes, String remark, String idempotencyKey, List sourceCartonMarks) { + this(returnNos, mode, boxes, remark, idempotencyKey, sourceCartonMarks, null); + } public PackingPlanCreate(List returnNos, String mode, List boxes, String remark, String idempotencyKey) { - this(returnNos, mode, boxes, remark, idempotencyKey, List.of()); + this(returnNos, mode, boxes, remark, idempotencyKey, List.of(), null); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryResponses.java index 9da92ed40..610a1d433 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryResponses.java @@ -12,7 +12,8 @@ public final class ReturnInventoryResponses { public record CartonSummary(String cartonMark, String overseasWarehouseId, String sourceBatchNo, String sourceReturnNo, String status, int skuTypes, long totalQuantity, BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight, - LocalDateTime createdAt) { } + LocalDateTime createdAt, String relabelStatus, long relabelledQuantity, + long relabelReservedQuantity) { } public record InventoryItem(String overseasWarehouseId, String cartonMark, String sourceBatchNo, String status, String skuCode, String productNameZh, long quantity, int cartonSkuTypes, long cartonTotalQuantity, @@ -23,7 +24,8 @@ public final class ReturnInventoryResponses { String fbaCartonMark, String destinationWarehouseCode, String destinationWarehouseName, String relabelNo, String relabelStatus, int skuTypes, long totalQuantity, BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight, LocalDateTime receivedAt, List items, - String locationCode, String palletCode, LocalDateTime putawayAt, List mergeHistory) { } + String locationCode, String palletCode, LocalDateTime putawayAt, List mergeHistory, + long relabelledQuantity, long relabelReservedQuantity) { } public record MergeHistory(String planNo, String sourceCartonMark, String targetCartonMark, String returnNo, String skuCode, long quantity, LocalDateTime createdAt) { } public record CartonItem(String skuCode, String productNameZh, long quantity, long availableQuantity) { @@ -57,7 +59,8 @@ public final class ReturnInventoryResponses { String sourceUnitType, String sourceBatchNo, String status, int skuTypes, long totalQuantity, String fbaCartonMark, String destinationWarehouseCode, String destinationWarehouseName, String relabelNo, String relabelStatus, BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight, - LocalDateTime createdAt, String locationCode, String palletCode, LocalDateTime putawayAt) { } + LocalDateTime createdAt, String locationCode, String palletCode, LocalDateTime putawayAt, + long relabelledQuantity, long relabelReservedQuantity) { } public record PlatformMovement(String customerId, String overseasWarehouseId, String skuCode, String productNameZh, String movementType, String referenceNo, long totalDelta, long availableDelta, LocalDateTime occurredAt) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderRequests.java index f577a1e83..def6d9c3e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderRequests.java @@ -35,6 +35,8 @@ public final class ReturnOrderRequests { @NotBlank @Size(max = 255) String idempotencyKey, @Size(max = 1000) String cartonRemark) {} public record RelabelItem(@NotBlank @Size(max = 128) String skuCode, - @Positive long quantity) {} + @Positive long quantity, @Size(max = 128) String cartonMark) { + public RelabelItem(String skuCode, long quantity) { this(skuCode, quantity, null); } + } public record Review(@NotBlank String decision, @Size(max = 500) String remark) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnInventoryCarton.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnInventoryCarton.java index 89b865f58..cf80d89a0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnInventoryCarton.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnInventoryCarton.java @@ -31,6 +31,8 @@ public class ReturnInventoryCarton { private LocalDateTime relabelCompletedAt; private String status; private String countStatus = "CONFIRMED"; + /** WAIT_LABEL until the operator scans the affixed warehouse label; null for historical units. */ + private String labelStatus; private LocalDateTime countedAt; private String countedBy; private Integer skuTypes; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryBalanceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryBalanceMapper.java index 7dae31f47..e5cea5c03 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryBalanceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryBalanceMapper.java @@ -15,7 +15,9 @@ public interface ReturnInventoryBalanceMapper extends BaseMapper=#{quantity} AND available_quantity>=#{quantity} AND carton_quantity>=1") + // PARCEL is a labelled physical unit but is not counted as a box in + // carton_quantity. Piece shipment must therefore only guard quantities. + "AND total_quantity>=#{quantity} AND available_quantity>=#{quantity}") int deductOutboundPieces(@Param("id") long id, @Param("customerId") String customerId, @Param("warehouseId") String warehouseId, @Param("quantity") long quantity); String FREE_STOCK_COLUMNS = "b.id,b.customer_id,b.overseas_warehouse_id,b.sku_code,b.product_name_zh,b.total_quantity,b.carton_quantity,b.created_at,b.updated_at," + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonMapper.java index 12b4fdf3b..813ac52b3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonMapper.java @@ -10,6 +10,8 @@ import java.util.List; @Mapper public interface ReturnInventoryCartonMapper extends BaseMapper { + @Update("UPDATE ll_return_inventory_carton SET relabel_application_id=NULL,relabel_no=NULL,relabel_status=NULL WHERE id=#{id}") + int clearRelabel(@Param("id") long id); @Select("SELECT * FROM ll_return_inventory_carton WHERE source_receipt_id=#{receiptId} ORDER BY id FOR UPDATE") List lockReceipt(@Param("receiptId") long receiptId); @Select("SELECT * FROM ll_return_inventory_carton WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} AND status='AVAILABLE' AND count_status='CONFIRMED' ORDER BY carton_mark") @@ -37,6 +39,9 @@ public interface ReturnInventoryCartonMapper extends BaseMapperAND overseas_warehouse_id=#{warehouseId} " + "ORDER BY created_at DESC,id DESC LIMIT 500") List findAvailable(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId); + @Select("SELECT * FROM ll_return_inventory_carton WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} " + + "AND count_status='CONFIRMED' AND status IN ('AVAILABLE','RELABEL_PENDING','PACKING_RESERVED') ORDER BY created_at DESC,id DESC LIMIT 500") + List findRelabelCandidates(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId); @Select("SELECT * FROM ll_return_inventory_carton WHERE carton_mark=#{cartonMark} LIMIT 1") ReturnInventoryCarton findMark(@Param("cartonMark") String cartonMark); @@ -57,12 +62,46 @@ public interface ReturnInventoryCartonMapper extends BaseMapperra.created_at OR (na.created_at=ra.created_at AND na.id>ra.id)))))"; + String COMPLETION_DEDUCTION = "COALESCE((SELECT SUM(j.quantity) FROM ll_relabel_pda_history h " + + "JOIN JSON_TABLE(CASE WHEN h.action='RETURN_COMPLETION_TRANSFER' AND JSON_VALID(h.detail_json) THEN h.detail_json ELSE '{}' END,'$.items[*]' COLUMNS(sku_code VARCHAR(128) PATH '$.skuCode',quantity BIGINT PATH '$.quantity')) j " + + "WHERE h.action='RETURN_COMPLETION_TRANSFER' AND EXISTS(SELECT 1 FROM ll_relabel_item ri " + "JOIN ll_relabel_application ra ON ra.id=ri.application_id " + - "WHERE ri.return_carton_id=#{cartonId} AND ri.sku_code=#{skuCode} " + - "AND ra.status NOT IN ('CANCELLED','REJECTED')") + "WHERE ri.return_carton_id=#{cartonId} AND JSON_CONTAINS(CASE WHEN JSON_VALID(h.detail_json) THEN h.detail_json ELSE '{}' END," + + "JSON_QUOTE(CAST(ri.id AS CHAR)),'$.sourceItemIds') AND ra.status NOT IN ('CANCELLED','REJECTED') " + CURRENT_RELABEL_ROUND + ")"; + @Select("SELECT COALESCE(SUM(CASE WHEN ri.operation_type='RETURN_BULK_SKU_LABEL' THEN f.quantity " + + "ELSE f.quantity * COALESCE((SELECT SUM(j.quantity) FROM JSON_TABLE(CASE WHEN JSON_VALID(ri.sku_relabel_items_json) THEN ri.sku_relabel_items_json ELSE '[]' END, '$[*]' " + + "COLUMNS(sku_code VARCHAR(128) PATH '$.skuCode', quantity BIGINT PATH '$.quantity')) j WHERE UPPER(j.sku_code)=UPPER(#{skuCode})),0) END),0) - " + + COMPLETION_DEDUCTION + " AND UPPER(j.sku_code)=UPPER(#{skuCode})),0) FROM ll_relabel_item ri " + + "JOIN ll_relabel_application ra ON ra.id=ri.application_id " + + "JOIN ll_relabel_pda_unit_fact f ON f.item_id=ri.id AND f.result='RELABELLED' " + + "WHERE ri.return_carton_id=#{cartonId} AND (ri.operation_type<>'RETURN_BULK_SKU_LABEL' OR UPPER(ri.sku_code)=UPPER(#{skuCode})) " + + "AND ra.status NOT IN ('CANCELLED','REJECTED')" + CURRENT_RELABEL_ROUND) long relabelledQuantity(@Param("cartonId") long cartonId, @Param("skuCode") String skuCode); + String RELABEL_ITEM_QUANTITY = "CASE WHEN ri.operation_type='RETURN_BULK_SKU_LABEL' THEN ri.notified_package_count ELSE " + + "COALESCE((SELECT SUM(j.quantity) FROM JSON_TABLE(CASE WHEN JSON_VALID(ri.sku_relabel_items_json) THEN ri.sku_relabel_items_json ELSE '[]' END, '$[*]' " + + "COLUMNS(quantity BIGINT PATH '$.quantity')) j),0) END"; + @Select("SELECT COALESCE(SUM(CASE WHEN ri.operation_type='RETURN_BULK_SKU_LABEL' THEN f.quantity ELSE f.quantity * " + + "COALESCE((SELECT SUM(j.quantity) FROM JSON_TABLE(CASE WHEN JSON_VALID(ri.sku_relabel_items_json) THEN ri.sku_relabel_items_json ELSE '[]' END,'$[*]' COLUMNS(quantity BIGINT PATH '$.quantity')) j),0) END),0) - " + + COMPLETION_DEDUCTION + "),0) " + + "FROM ll_relabel_item ri JOIN ll_relabel_application ra ON ra.id=ri.application_id " + + "JOIN ll_relabel_pda_unit_fact f ON f.item_id=ri.id AND f.result='RELABELLED' " + + "WHERE ri.return_carton_id=#{cartonId} AND ra.status NOT IN ('CANCELLED','REJECTED')" + CURRENT_RELABEL_ROUND) + long totalRelabelledQuantity(@Param("cartonId") long cartonId); + @Select("SELECT COALESCE(SUM(GREATEST(0,(" + RELABEL_ITEM_QUANTITY + ") - " + + "COALESCE((SELECT SUM(CASE WHEN ri.operation_type='RETURN_BULK_SKU_LABEL' THEN f.quantity ELSE f.quantity * (" + + RELABEL_ITEM_QUANTITY + ") END) FROM ll_relabel_pda_unit_fact f WHERE f.item_id=ri.id AND f.result='RELABELLED'),0))),0) " + + "FROM ll_relabel_item ri JOIN ll_relabel_application ra ON ra.id=ri.application_id " + + "WHERE ri.return_carton_id=#{cartonId} AND ra.status NOT IN ('CANCELLED','REJECTED','COMPLETED')") + long relabelReservedQuantity(@Param("cartonId") long cartonId); + @Update("UPDATE ll_return_inventory_carton SET total_quantity=#{remaining},sku_types=#{skuTypes},status='AVAILABLE'," + "relabel_application_id=NULL,relabel_no=NULL,relabel_status=NULL,updated_at=NOW(3) " + "WHERE id=#{id} AND customer_id=#{customerId} AND relabel_application_id=#{applicationId} " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnRelabelCompletionLedgerMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnRelabelCompletionLedgerMapper.java new file mode 100644 index 000000000..3b171480d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnRelabelCompletionLedgerMapper.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.returnreceipt.mapper; + +import org.apache.ibatis.annotations.*; +import java.util.List; + +/** Immutable PDA history records exact completed physical pieces moved out of a historical item group. */ +@Mapper +public interface ReturnRelabelCompletionLedgerMapper { + @Select("") + long deductedQuantity(@Param("itemIds") List itemIds, @Param("skuCode") String skuCode); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java index e20cfe196..94d7adf43 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java @@ -56,6 +56,7 @@ public class ReturnInventoryService { public static final String MERGED = "MERGED"; @Resource private ReturnInventoryCartonMapper cartons; + @Resource private com.baiyue.lastleg.relabel.mapper.RelabelItemMapper relabelItems; @Resource private ReturnInventoryCartonItemMapper cartonItems; @Resource private ReturnInventoryBalanceMapper balances; @Resource private ReturnInventoryOperationMapper operations; @@ -129,13 +130,27 @@ public class ReturnInventoryService { if (!List.of("BOX", "SKU").contains(mode)) throw badRequest("合箱方式只能选择 BOX 或 SKU"); List marks = request.sourceCartonMarks() == null ? List.of() : request.sourceCartonMarks().stream().distinct().sorted().toList(); if ("BOX".equals(mode) && marks.isEmpty()) throw badRequest("按箱合箱必须选择来源箱唛"); - if ("SKU".equals(mode) && !marks.isEmpty()) throw badRequest("按 SKU 合箱不能同时选择来源箱"); + // SKU mode is the partial-packing mode. It may explicitly name one or + // more physical packages/boxes; the quantities in `boxes` are then + // deducted from those sources and materialized as a new labelled carton. Map requested = planQuantities(request.boxes()); + List exact = request.sourceDeductions(); + if (exact != null) { + if (!"SKU".equals(mode) || exact.isEmpty() || marks.isEmpty()) throw badRequest("部分合箱必须指定来源包裹/箱及数量"); + var seen = new java.util.HashSet(); + for (var line : exact) if (!marks.contains(line.cartonMark()) || line.quantity() <= 0 + || !seen.add(line.cartonMark() + ":" + line.skuCode())) throw badRequest("部分合箱来源重复或不属于所选包裹/箱"); + Map totals = new LinkedHashMap<>(); + exact.forEach(line -> totals.merge(line.skuCode(), line.quantity(), Math::addExact)); + if (!totals.equals(requested)) throw conflict("来源扣除数量必须与目标箱数量一致"); + } List allocation = new ArrayList<>(); List sources = "BOX".equals(mode) ? marks.stream().map(cartons::lockMark).toList() - : packingSourceCartons(selected).stream().filter(c -> "PARCEL".equals(c.getSourceUnitType())) - .sorted(Comparator.comparing(ReturnInventoryCarton::getCartonMark)).map(c -> cartons.lockMark(c.getCartonMark())).toList(); + : (marks.isEmpty() + ? packingSourceCartons(selected).stream() + .sorted(Comparator.comparing(ReturnInventoryCarton::getCartonMark)).map(c -> cartons.lockMark(c.getCartonMark())).toList() + : marks.stream().map(cartons::lockMark).toList()); Map remaining = new LinkedHashMap<>(requested); for (ReturnInventoryCarton carton : sources) { if (carton == null || !first.getCustomerId().equals(carton.getCustomerId()) @@ -143,10 +158,12 @@ public class ReturnInventoryService { || !AVAILABLE.equals(carton.getStatus()) || !sourceMatches(carton, returnNos)) throw conflict("来源箱不属于所选退货单或已被占用"); ReturnCartonCountGuard.requireConfirmed(carton); - if ("BOX".equals(mode) && "PARCEL".equals(carton.getSourceUnitType())) throw badRequest("包裹散 SKU 不能作为整箱选择"); for (ReturnInventoryCartonItem item : cartonItems.lockCarton(carton.getId())) { long free = Math.max(0, value(item.getQuantity()) - value(item.getPackingReservedQuantity())); - long take = "BOX".equals(mode) ? free : Math.min(free, value(remaining.get(item.getSkuCode()))); + if (value(item.getPackingReservedQuantity()) > 0) throw conflict("来源包裹/箱已有合箱预占"); + long take = "BOX".equals(mode) ? free : exact == null ? Math.min(free, value(remaining.get(item.getSkuCode()))) + : exact.stream().filter(line -> carton.getCartonMark().equals(line.cartonMark()) && item.getSkuCode().equals(line.skuCode())).mapToLong(ReturnInventoryRequests.SourceDeduction::quantity).sum(); + if (take > free) throw conflict("来源包裹/箱扣除数量超过可用库存"); if ("BOX".equals(mode) && value(item.getPackingReservedQuantity()) > 0) throw conflict("来源箱已有合箱预占"); if (take <= 0) continue; if ("SKU".equals(mode)) remaining.put(item.getSkuCode(), remaining.get(item.getSkuCode()) - take); @@ -154,7 +171,9 @@ public class ReturnInventoryService { cartonItems.updateById(item); allocation.add(new SourceItem(carton.getCartonMark(), item.getSkuCode(), take)); } - if ("BOX".equals(mode)) { carton.setStatus("PACKING_RESERVED"); cartons.updateById(carton); } + if (allocation.stream().anyMatch(line -> line.cartonMark().equals(carton.getCartonMark()))) { + carton.setStatus("PACKING_RESERVED"); cartons.updateById(carton); + } } if (allocation.isEmpty() || ("SKU".equals(mode) && remaining.values().stream().anyMatch(q -> q > 0))) throw conflict("来源库存不足,无法锁定合箱数量"); Map reserved = sourceQuantities(allocation); @@ -282,6 +301,8 @@ public class ReturnInventoryService { return new ReturnInventoryResponses.PackingPlanCompletion(plan.getPlanNo(), read(plan.getResultLabelsJson(), new TypeReference>() { })); } if (!"READY".equals(plan.getStatus())) throw conflict("合箱计划尚未审核或已取消"); + if ("SKU".equals(plan.getMode()) && request.boxes().stream().anyMatch(box -> text(box.targetCartonMark()) != null)) + throw conflict("部分包裹/箱必须合箱生成独立新箱"); if ("BOX".equals(plan.getMode()) && request.boxes().size() != 1) throw badRequest("箱与箱合并必须扫描一个已有目标箱,不支持拆分来源箱"); List allocation = read(plan.getSourceItemsJson(), new TypeReference<>() { }); if (allocation.isEmpty()) throw conflict("合箱计划缺少来源锁定明细,请重新创建"); @@ -289,7 +310,7 @@ public class ReturnInventoryService { ? text(request.boxes().get(0).targetCartonMark()) : null; List sourceAllocation = plannedTarget == null ? allocation : allocation.stream() .filter(source -> !plannedTarget.equals(source.cartonMark())).toList(); - if ("BOX".equals(plan.getMode()) && request.sourceDeductions() != null) { + if (request.sourceDeductions() != null) { Map seen = new LinkedHashMap<>(); List selected = new ArrayList<>(); for (var deduction : request.sourceDeductions()) { @@ -299,8 +320,13 @@ public class ReturnInventoryService { selected.add(new SourceItem(deduction.cartonMark(), deduction.skuCode(), deduction.quantity())); } sourceAllocation = selected; + if ("SKU".equals(plan.getMode()) && !new java.util.HashSet<>(sourceAllocation).equals(new java.util.HashSet<>(allocation))) + throw conflict("部分合箱实际来源与计划锁定来源数量不一致"); } final List selectedAllocation = sourceAllocation; + if (plannedTarget != null && allocation.stream().anyMatch(source -> !plannedTarget.equals(source.cartonMark()) + && selectedAllocation.stream().filter(s -> s.cartonMark().equals(source.cartonMark()) && s.skuCode().equals(source.skuCode())).mapToLong(SourceItem::quantity).sum() != source.quantity())) + throw conflict("部分包裹/箱必须合箱生成独立新箱,不能并入已有箱"); if (!sourceQuantities(sourceAllocation).equals(planQuantities(request.boxes()))) throw conflict("来源箱 SKU 数量合计必须与计划锁定数量完全一致"); List returnNos = read(plan.getReturnNosJson(), new TypeReference<>() { }); Map> limits = new LinkedHashMap<>(); @@ -464,8 +490,8 @@ public class ReturnInventoryService { required(lines.get(0).skuCode(), "SKU 不能为空"); String cartonMark = text(requestedMark); long cartonId = IdWorker.getId(); - // A parcel row tracks loose SKU provenance; it is never a physical carton or printable box label. - if ("PARCEL".equals(receipt.getUnitType())) cartonMark = "SP" + hash(List.of(receipt.getId(), lines.get(0).skuCode())).substring(0, 24).toUpperCase(java.util.Locale.ROOT); + // One durable barcode identifies a physical parcel, irrespective of SKU count. + if ("PARCEL".equals(receipt.getUnitType())) cartonMark = "RP" + hash(List.of(receipt.getId())).substring(0, 24).toUpperCase(java.util.Locale.ROOT); else if (cartonMark == null) cartonMark = numbers.next("RETURN_CARTON", order.getOverseasWarehouseId(), cartonId); if (cartons.findMark(cartonMark) != null) throw conflict("箱号已存在: " + cartonMark); LocalDateTime now = LocalDateTime.now(); @@ -476,10 +502,11 @@ public class ReturnInventoryService { carton.setSourceReturnNo(order.getReturnNo()); carton.setCartonMark(cartonMark); carton.setPalletCode(text(receipt.getPalletCode())); carton.setSourceUnitType(receipt.getUnitType()); + carton.setLabelStatus("PALLET".equals(receipt.getUnitType()) ? null : "WAIT_LABEL"); carton.setCountStatus("PALLET".equals(receipt.getUnitType()) ? "PENDING" : "CONFIRMED"); carton.setSourceUnitCode(normalizedUnitCode); // Pallet measurements describe the carrier, never each child carton. - if ("BOX".equals(receipt.getUnitType())) { + if ("BOX".equals(receipt.getUnitType()) || "PARCEL".equals(receipt.getUnitType())) { carton.setLength(receipt.getLength()); carton.setWidth(receipt.getWidth()); carton.setHeight(receipt.getHeight()); carton.setWeight(receipt.getWeight()); } carton.setStatus(AVAILABLE); carton.setSkuTypes(lines.size()); carton.setTotalQuantity(total); carton.setCreatedAt(now); carton.setUpdatedAt(now); @@ -492,7 +519,7 @@ public class ReturnInventoryService { balances.adjust(IdWorker.getId(), order.getCustomerId(), order.getOverseasWarehouseId(), line.skuCode(), text(line.productNameZh()), line.quantity(), line.quantity(), "PARCEL".equals(receipt.getUnitType()) ? 0 : 1); } - if ("BOX".equals(receipt.getUnitType())) { + if ("BOX".equals(receipt.getUnitType()) || "PARCEL".equals(receipt.getUnitType())) { var contents = lines.stream().map(line -> new com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.Sku(line.skuCode(), line.productNameZh(), line.quantity())).toList(); var snapshot = new com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.Task(receipt.getReceiptNo(), order.getReturnNo(), receipt.getPalletCode(), "CONFIRMED", contents, List.of(new com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.Box(cartonMark, contents))); @@ -551,7 +578,8 @@ public class ReturnInventoryService { .map(carton -> new ReturnInventoryResponses.CartonSummary(carton.getCartonMark(), carton.getOverseasWarehouseId(), carton.getSourceBatchNo(), carton.getSourceReturnNo(), displayStatus(carton), carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()), - carton.getLength(), carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt())).toList(); + carton.getLength(), carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt(), + relabelProgressStatus(carton), cartons.totalRelabelledQuantity(carton.getId()), cartons.relabelReservedQuantity(carton.getId()))).toList(); return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size()); } @@ -594,8 +622,9 @@ public class ReturnInventoryService { List records = total == 0 ? List.of() : cartons.platformPage(customerId, warehouseId, cartonMark, returnNo, status, sku, (request.page() - 1) * request.size(), request.size()).stream().map(carton -> new ReturnInventoryResponses.PlatformCarton( carton.getCustomerId(), carton.getOverseasWarehouseId(), carton.getCartonMark(), carton.getSourceReturnNo(), carton.getSourceUnitType(), carton.getSourceBatchNo(), - displayStatus(carton), carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()), carton.getFbaCartonMark(), carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), carton.getRelabelNo(), carton.getRelabelStatus(), carton.getLength(), - carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt(), carton.getLocationCode(), carton.getPalletCode(), carton.getPutawayAt())).toList(); + displayStatus(carton), carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()), carton.getFbaCartonMark(), carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), carton.getRelabelNo(), relabelProgressStatus(carton), carton.getLength(), + carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt(), carton.getLocationCode(), carton.getPalletCode(), carton.getPutawayAt(), + cartons.totalRelabelledQuantity(carton.getId()), cartons.relabelReservedQuantity(carton.getId()))).toList(); return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size()); } @@ -652,10 +681,20 @@ public class ReturnInventoryService { return label(carton); } + @Transactional + public ReturnInventoryResponses.Label confirmLabel(PdaWarehouseIdentity identity, String cartonMark) { + ReturnInventoryCarton carton = cartons.lockMark(required(cartonMark, "请扫描已贴好的包裹/箱标签")); + if (carton == null || !String.valueOf(identity.overseasWarehouseId()).equals(carton.getOverseasWarehouseId())) throw notFound("当前仓没有该包裹/箱"); + if (!AVAILABLE.equals(carton.getStatus())) throw conflict("已占用或已拆空的包裹/箱不能确认贴标"); + if (!"CONFIRMED".equals(carton.getLabelStatus())) { + carton.setLabelStatus("CONFIRMED"); carton.setUpdatedAt(LocalDateTime.now()); cartons.updateById(carton); + } + return label(carton); + } + /** Print only persisted physical cartons; a parcel's SKU rows are not cartons. */ @Transactional(readOnly = true) public List receiptContents(ReturnReceipt receipt) { - if ("PARCEL".equals(receipt.getUnitType())) return List.of(); var snapshot = operations.receiptContents(receipt.getCustomerId(), receipt.getId()); if (snapshot != null) { var task = read(snapshot.getResultJson(), new TypeReference() { }); @@ -673,7 +712,6 @@ public class ReturnInventoryService { List lines) { List existing = cartons.listReceipt(receipt.getId()).stream() .filter(carton -> !"MERGED".equals(carton.getStatus())).toList(); - if ("PARCEL".equals(receipt.getUnitType())) return List.of(); long expected = "BOX".equals(receipt.getUnitType()) ? Math.max(1, value(receipt.getTotalBoxCount())) : lines.stream().mapToLong(line -> Math.max(0, line.boxCount())).sum(); if (expected <= 0 || existing.size() >= expected) { @@ -831,7 +869,7 @@ public class ReturnInventoryService { public ReturnInventoryResponses.CartonDetail scanCarton(PdaWarehouseIdentity identity, String mark) { ReturnInventoryCarton carton = cartons.findMark(required(mark, "箱唛不能为空")); if (carton == null || !String.valueOf(identity.overseasWarehouseId()).equals(carton.getOverseasWarehouseId())) throw notFound("当前仓没有该退货箱"); - if (!AVAILABLE.equals(carton.getStatus()) || "PARCEL".equals(carton.getSourceUnitType())) throw conflict("该箱不是可用实物箱"); + if (!AVAILABLE.equals(carton.getStatus())) throw conflict("该包裹/箱不是可用实物单元"); ReturnCartonCountGuard.requireConfirmed(carton); return cartonDetail(carton); } @@ -851,7 +889,7 @@ public class ReturnInventoryService { for (String mark : request.sourceCartonMarks().stream().distinct().sorted().toList()) { if (mark.equals(request.targetCartonMark())) throw badRequest("来源箱不能与目标箱相同"); ReturnInventoryCarton carton = cartons.lockMark(mark); - if (carton == null || !AVAILABLE.equals(carton.getStatus()) || "PARCEL".equals(carton.getSourceUnitType()) + if (carton == null || !AVAILABLE.equals(carton.getStatus()) || !String.valueOf(identity.overseasWarehouseId()).equals(carton.getOverseasWarehouseId())) throw conflict("来源箱不存在或不可合并"); if (customer != null && !customer.equals(carton.getCustomerId())) throw conflict("不同客户的箱不能合并"); ReturnCartonCountGuard.requireConfirmed(carton); @@ -868,6 +906,10 @@ public class ReturnInventoryService { } if(request.sourceDeductions() != null && request.sourceDeductions().stream().anyMatch(d -> !limits.containsKey(d.cartonMark()) || !limits.get(d.cartonMark()).containsKey(d.skuCode()))) throw badRequest("扣除明细不属于已扫描来源箱"); String targetMark = text(request.targetCartonMark()); + if (targetMark != null && request.sourceCartonMarks().stream().anyMatch(mark -> + cartonItems.lockCarton(cartons.lockMark(mark).getId()).stream().anyMatch(item -> + value(limits.get(mark).get(item.getSkuCode())) != value(item.getQuantity())))) + throw conflict("部分包裹/箱必须合箱生成独立新箱,不能并入已有箱"); if(targetMark == null && (request.length()==null || request.width()==null || request.height()==null || request.weight()==null)) throw badRequest("新增目标箱必须填写箱规"); var planItems=quantities.entrySet().stream().map(e -> new ReturnInventoryRequests.PlanItem(e.getKey(),e.getValue())).toList(); String newMark=targetMark==null?packingMarks(List.of(new ReturnInventoryRequests.PlanBox(planItems,request.length(),request.width(),request.height(),request.weight()))).get(0):null; @@ -956,7 +998,7 @@ public class ReturnInventoryService { Map requested = new LinkedHashMap<>(); Map productNames = new LinkedHashMap<>(); for (ReturnInventoryRequests.ValidItem item : request.items()) { - String sku = required(item.skuCode(), "SKU 不能为空").toUpperCase(java.util.Locale.ROOT); + String sku = required(item.skuCode(), "SKU 不能为空"); if (requested.put(sku, item.quantity()) != null) throw badRequest("合箱 SKU 不能重复"); } Map remaining = new LinkedHashMap<>(requested); @@ -965,13 +1007,21 @@ public class ReturnInventoryService { LinkedHashSet consumedReturnNos = new LinkedHashSet<>(); Map> attachedByOrder = new LinkedHashMap<>(); List transfers = new ArrayList<>(); + java.util.Set completedRelabelSources = new java.util.HashSet<>(); long total = 0; for (ReturnInventoryCarton source : sources) { // Read committed quantities under the source lock, never a stale MVCC snapshot. List sourceItems = cartonItems.lockCarton(source.getId()); + boolean hasCompletedRelabel = sourceItems.stream().anyMatch(i -> cartons.relabelledQuantity(source.getId(), i.getSkuCode()) > 0); + if (hasCompletedRelabel) completedRelabelSources.add(source.getId()); + if (hasCompletedRelabel && source.getRelabelApplicationId() == null && sourceItems.stream().anyMatch(i -> { + long free = Math.max(0, value(i.getQuantity()) - value(i.getPackingReservedQuantity())); + long allowed = limits == null ? free : Math.min(free, value(limits.getOrDefault(source.getCartonMark(), Map.of()).get(i.getSkuCode()))); + return Math.min(value(remaining.get(i.getSkuCode())), allowed) != value(i.getQuantity()); + })) throw conflict("已换标与未换标货物不能自动分配来源,请先在换标任务内隔离为独立新箱,或整箱合并"); long sourceTotal = 0; int sourceTypes = 0; for (ReturnInventoryCartonItem item : sourceItems) { - String sku = item.getSkuCode().toUpperCase(java.util.Locale.ROOT); + String sku = item.getSkuCode(); long free = Math.max(0, value(item.getQuantity()) - value(item.getPackingReservedQuantity())); long allowed = limits == null ? free : Math.min(free, value(limits.getOrDefault(source.getCartonMark(), Map.of()).get(sku))); long take = Math.min(value(remaining.get(sku)), allowed); @@ -1016,6 +1066,8 @@ public class ReturnInventoryService { } target.setSourceBatchNo(String.join(",", consumedBatchNos)); target.setSourceReturnNo(String.join(",", consumedReturnNos)); target.setStatus(AVAILABLE); if (existingTarget == null) { + target.setCountStatus("CONFIRMED"); + target.setLabelStatus("WAIT_LABEL"); target.setSkuTypes(requested.size()); target.setTotalQuantity(total); target.setLength(request.length()); target.setWidth(request.width()); target.setHeight(request.height()); target.setWeight(request.weight()); target.setCreatedAt(now); target.setUpdatedAt(now); cartons.insert(target); } @@ -1037,6 +1089,8 @@ public class ReturnInventoryService { } for (TraceTransfer transfer : transfers) packingTrace.insert(IdWorker.getId(), customerId, operation.getId(), transfer.source(), target.getCartonMark(), transfer.returnNo(), transfer.sku(), transfer.quantity(), now); + for (ReturnInventoryCarton source : sources) if (completedRelabelSources.contains(source.getId()) && MERGED.equals(source.getStatus()) && source.getRelabelApplicationId() == null) + relabelItems.moveCompletedReturnCarton(customerId, source.getId(), targetId, target.getCartonMark()); var result = new ReturnInventoryResponses.MergeResult(target.getCartonMark(), requested.keySet().iterator().next(), value(target.getTotalQuantity()), label(target)); operation.setResultCartonId(targetId); operation.setResultJson(json(result)); operation.setUpdatedAt(now); operations.updateById(operation); return result; @@ -1111,7 +1165,12 @@ public class ReturnInventoryService { "AVAILABLE".equals(carton.getStatus()) && "CONFIRMED".equals(carton.getCountStatus()) ? Math.max(0, value(item.getQuantity()) - value(item.getPackingReservedQuantity())) : 0)).toList(); return new ReturnInventoryResponses.CartonDetail(carton.getCartonMark(), carton.getCustomerId(), carton.getOverseasWarehouseId(), carton.getSourceBatchNo(), carton.getSourceReturnNo(), - displayStatus(carton), carton.getFbaCartonMark(), carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), carton.getRelabelNo(), carton.getRelabelStatus(), carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()), carton.getLength(), carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt(), items, carton.getLocationCode(), carton.getPalletCode(), carton.getPutawayAt(), packingTrace.listCartonHistory(carton.getCustomerId(), carton.getCartonMark())); + displayStatus(carton), carton.getFbaCartonMark(), carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), carton.getRelabelNo(), relabelProgressStatus(carton), carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()), carton.getLength(), carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt(), items, carton.getLocationCode(), carton.getPalletCode(), carton.getPutawayAt(), packingTrace.listCartonHistory(carton.getCustomerId(), carton.getCartonMark()), + cartons.totalRelabelledQuantity(carton.getId()), cartons.relabelReservedQuantity(carton.getId())); + } + + private String relabelProgressStatus(ReturnInventoryCarton carton) { + return ReturnRelabelProgress.status(value(carton.getTotalQuantity()), cartons.totalRelabelledQuantity(carton.getId()), cartons.relabelReservedQuantity(carton.getId())); } private static String printableHtml(ReturnInventoryCarton carton) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java index 72dd83856..415ccb48f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java @@ -278,7 +278,7 @@ public class ReturnReceiptService { .toList(); long boxes = value(receipt.getTotalBoxCount()); if (boxes == 0) boxes = lines.stream().mapToLong(ReturnOrderResponses.ReceiptItem::boxCount).sum(); - String cartonMark = "PARCEL".equals(receipt.getUnitType()) ? null : text(receipt.getCartonMark()); + String cartonMark = text(receipt.getCartonMark()); List labelLines = lines.stream() .map(line -> new ReturnInventoryService.ReturnOrderLine(line.skuCode(), line.productNameZh(), line.quantity(), line.boxCount())) .toList(); @@ -382,13 +382,12 @@ public class ReturnReceiptService { if (order == null) throw notFound("退货单不存在"); if (!COMPLETED.equals(order.getStatus())) throw conflict("退货单尚未完成签收"); List result = new ArrayList<>(); - for (ReturnReceipt receipt : receipts.listOrder(order.getId())) { - for (ReturnInventoryCarton carton : cartons.listReceipt(receipt.getId())) { + for (ReturnInventoryCarton carton : cartons.findAvailable(customerId, order.getOverseasWarehouseId())) { + if (!inventory.matchesReturnSource(carton, order.getReturnNo())) continue; if (!ReturnInventoryService.AVAILABLE.equals(carton.getStatus()) || !"CONFIRMED".equals(carton.getCountStatus()) || carton.getRelabelApplicationId() != null) continue; List items = cartonItems.listCarton(carton.getId()).stream() .map(i -> new ReturnOrderResponses.Item(i.getSkuCode(), i.getProductNameZh(), value(i.getQuantity()), value(i.getQuantity()))).toList(); result.add(new ReturnOrderResponses.RelabelCandidate(carton.getId(), carton.getCartonMark(), carton.getSourceReturnNo(), carton.getStatus(), value(carton.getTotalQuantity()), items, carton.getRelabelStatus())); - } } return result; } @@ -419,9 +418,9 @@ public class ReturnReceiptService { long totalQuantity = 0; for (ReturnOrderRequests.RelabelItem requested : request.items()) { String sku = norm(requested.skuCode()); - if (!seen.add(sku)) throw badRequest("同一贴标申请不能重复选择 SKU: " + requested.skuCode()); + if (!seen.add(sku + ":" + text(requested.cartonMark()))) throw badRequest("同一包裹/箱 SKU 不能重复选择: " + requested.skuCode()); ReturnOrderItem received = receivedBySku.get(sku); - if (received == null || requested.quantity() > value(received.getReceivedQuantity())) { + if (text(requested.cartonMark()) == null && (received == null || requested.quantity() > value(received.getReceivedQuantity()))) { throw conflict("SKU " + requested.skuCode() + " 的贴标数量超过当前退货单实收数量"); } totalQuantity = Math.addExact(totalQuantity, requested.quantity()); @@ -460,27 +459,35 @@ public class ReturnReceiptService { app.setFulfillmentStatus("WAIT_APPROVAL"); app.setRemark(text(request.cartonRemark())); app.setStatus("PENDING_REVIEW"); app.setItemCount(request.items().size()); app.setTotalPackageCount(Math.toIntExact(totalQuantity)); app.setNotifiedPackageCount(Math.toIntExact(totalQuantity)); app.setChargeAmount(java.math.BigDecimal.ZERO); app.setChargeCurrency("CNY"); app.setWalletStatus("NOT_REQUIRED"); app.setIdempotencyKey(key); app.setRequestHash(requestHash); app.setCreatedBy(text(operatorId)); app.setCreatedAt(now); app.setUpdatedAt(now); relabelApplications.insert(app); List availableCartons = cartons.findAvailable(customerId, order.getOverseasWarehouseId()); Map selectedCartons = new LinkedHashMap<>(); + Map, Long> allocatedBySourceSku = new LinkedHashMap<>(); for (ReturnOrderRequests.RelabelItem requested : request.items()) { ReturnOrderItem source = receivedBySku.get(norm(requested.skuCode())); long remaining = requested.quantity(); for (ReturnInventoryCarton candidate : availableCartons) { if (remaining == 0) break; - if (!order.getReturnNo().equals(candidate.getSourceReturnNo())) continue; + if (!inventory.matchesReturnSource(candidate, order.getReturnNo())) continue; + if (text(requested.cartonMark()) != null && !requested.cartonMark().equals(candidate.getCartonMark())) continue; long skuQuantity = cartonItems.listCarton(candidate.getId()).stream() .filter(line -> norm(requested.skuCode()).equals(norm(line.getSkuCode()))) .mapToLong(line -> value(line.getQuantity())).sum(); - skuQuantity -= cartons.relabelledQuantity(candidate.getId(), norm(requested.skuCode())); if (skuQuantity <= 0) continue; ReturnInventoryCarton carton = selectedCartons.computeIfAbsent(candidate.getId(), ignored -> cartons.lockId(candidate.getId())); if (carton == null || !"AVAILABLE".equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) throw conflict("退货箱已被其他换标申请占用,请刷新后重试"); if (cartonItems.lockCarton(carton.getId()).stream().anyMatch(line -> value(line.getPackingReservedQuantity()) > 0)) throw conflict("退货 SKU 已被合箱计划锁定,请先完成或取消合箱计划"); + skuQuantity = cartonItems.lockCarton(carton.getId()).stream() + .filter(line -> norm(requested.skuCode()).equals(norm(line.getSkuCode()))) + .mapToLong(line -> value(line.getQuantity())).sum(); + var sourceSku = List.of(carton.getCartonMark(), norm(requested.skuCode())); + skuQuantity -= allocatedBySourceSku.getOrDefault(sourceSku, 0L); + if (skuQuantity <= 0) continue; long allocated = Math.min(remaining, skuQuantity); + allocatedBySourceSku.merge(sourceSku, allocated, Math::addExact); ReturnCartonCountGuard.requireConfirmed(carton); RelabelItem item = new RelabelItem(); item.setId(IdWorker.getId()); item.setApplicationId(appId); item.setCustomerId(customerId); - item.setSkuCode(source.getSkuCode()); - item.setProductNameZh(text(source.getProductNameZh()) == null ? source.getSkuCode() : text(source.getProductNameZh())); + item.setSkuCode(requested.skuCode()); + item.setProductNameZh(source == null || text(source.getProductNameZh()) == null ? requested.skuCode() : text(source.getProductNameZh())); item.setTotalPackageCount(Math.toIntExact(allocated)); item.setNotifiedPackageCount(Math.toIntExact(allocated)); item.setShipmentNo(order.getReturnNo()); item.setLabelRequirement(text(request.cartonRemark())); item.setDestinationWarehouseId(String.valueOf(destination.id())); item.setDestinationFbaWarehouseCode(destination.code()); @@ -653,13 +660,12 @@ public class ReturnReceiptService { if ("PALLET".equals(unitType)) { inventoryCartons = inventory.createReceiptCartons(order, receipt, new ArrayList<>(inventoryLines.values()), boxCountBySku); } else if ("PARCEL".equals(unitType)) { - inventoryCartons = new ArrayList<>(); - for (var line : inventoryLines.values()) - inventoryCartons.add(inventory.createReceiptCarton(order, receipt, null, List.of(line))); + // One receipt represents one physical parcel, including all its SKUs. + inventoryCartons = List.of(inventory.createReceiptCarton(order, receipt, null, new ArrayList<>(inventoryLines.values()))); } else { inventoryCartons = List.of(inventory.createReceiptCarton(order, receipt, unitCode, new ArrayList<>(inventoryLines.values()))); } - receipt.setCartonMark("PARCEL".equals(unitType) ? null : inventoryCartons.get(0).getCartonMark()); + receipt.setCartonMark(inventoryCartons.get(0).getCartonMark()); receipts.updateById(receipt); syncTailCargo(order, receipt, allocations, now); List finalItems = orderItems.listOrder(order.getId()); @@ -802,10 +808,10 @@ public class ReturnReceiptService { return confirmed(order, receipt, COMPLETED.equals(order.getStatus())); } private PdaReturnReceiptResponses.Confirmed confirmed(ReturnOrder order, ReturnReceipt receipt, boolean completed) { - String cartonMark = "PARCEL".equals(receipt.getUnitType()) ? null : text(receipt.getCartonMark()); + String cartonMark = text(receipt.getCartonMark()); String printableHtml = null; List labels = new java.util.ArrayList<>(); - if (receipt.getId() != null && !"PARCEL".equals(receipt.getUnitType())) { + if (receipt.getId() != null) { for (ReturnInventoryCarton carton : cartons.listReceipt(receipt.getId())) { var label = inventory.label(order.getCustomerId(), carton.getCartonMark()); if (label != null) labels.add(new ReturnOrderResponses.CartonLabel(carton.getCartonMark(), label.printableHtml())); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnRelabelProgress.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnRelabelProgress.java new file mode 100644 index 000000000..28fb48e07 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnRelabelProgress.java @@ -0,0 +1,11 @@ +package com.baiyue.lastleg.returnreceipt.service; + +/** Physical piece progress is independent of warehouse identification-label confirmation. */ +public final class ReturnRelabelProgress { + private ReturnRelabelProgress() { } + public static String status(long total, long completed, long reserved) { + if (total > 0 && completed >= total) return "COMPLETED"; + if (completed > 0) return "PARTIALLY_COMPLETED"; + return reserved > 0 ? "PROCESSING" : "UNLABELLED"; + } +} diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 566ef2d3a..e8f84b81c 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -3563,3 +3563,28 @@ CREATE TABLE IF NOT EXISTS ll_appointment_impact_history ( CONSTRAINT fk_appointment_impact_history FOREIGN KEY (container_booking_id) REFERENCES ll_appointment_impact(container_booking_id), CONSTRAINT chk_appointment_impact_action CHECK (action IN ('OBSERVED','CLAIM','RECORD','RESOLVE')) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='船期观察及人工处理追加历史'; + +-- 退货以包裹/箱为最小出库实物单位;部分数量必须先合箱生成新标签箱。 +-- 可重复执行,目标库 baiyue_lastleg。 +USE baiyue_lastleg; + +-- 合箱后的新箱与原包裹共用同一箱唛、标签和来源追溯模型。 +-- 流水约束允许部分合箱/拆分只扣件数,不虚减箱数。 +SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.table_constraints + WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_outbound_movement' + AND constraint_name='chk_ll_return_outbound_movement_delta'), + 'ALTER TABLE ll_return_inventory_outbound_movement DROP CHECK chk_ll_return_outbound_movement_delta, ADD CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0 AND total_before-total_after=quantity AND available_before-available_after=quantity AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)', + 'ALTER TABLE ll_return_inventory_outbound_movement ADD CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0 AND total_before-total_after=quantity AND available_before-available_after=quantity AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 同一包裹可分批合箱/出库,重复提交仍由 outbound_pick_id + SKU 幂等约束保护。 +SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_outbound_movement' + AND index_name='uk_ll_return_outbound_carton_sku'), + 'ALTER TABLE ll_return_inventory_outbound_movement DROP INDEX uk_ll_return_outbound_carton_sku, ADD INDEX idx_ll_return_outbound_carton_sku(customer_id,carton_id,sku_code)', + 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 新签收及新合箱必须打印并扫码确认标签;既有实物按原状态继续作业。 +SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='label_status'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN label_status VARCHAR(32) NULL COMMENT ''WAIT_LABEL待贴标 CONFIRMED已扫码确认标签'''); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 277f26400..e8d1e12a8 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -824,8 +824,8 @@ paths: responses: { '200': { description: 出库单分页 } } /api/platform/v1/last-leg/outbound-orders/return: post: - summary: 创建退货出库单,支持多个散件SKU分别填写出库数量 - description: 权限 platform:overseas-warehouse:outbound:create;散件SKU来源仅允许PARCEL可用库存,不包含箱内库存;实体箱按箱或托盘出库。items逐来源指定SKU及数量,与cartonMarks一一对应,不可同时提交顶层skuCode或quantity,不支持PALLET方式。多个SKU生成同一张出库单和派送单,PDA逐SKU按件数拣货。旧版skuCode与quantity继续支持单SKU来源排序分配。未拣货前占用整个使用来源,拣货后来源余数恢复可用。相同幂等键改数量拒绝。 + summary: 按包裹或箱标签创建整实物退货出库单 + description: 权限 platform:overseas-warehouse:outbound:create;以包裹或箱为最小单位,cartonMarks为实际标签码,PARCEL可包含多个SKU,整包裹/箱或整托出库。新签收和新合箱须先扫码确认贴标。部分包裹及部分箱均须通过合箱生成独立新标签箱后才能出库。提交skuCode、quantity或非空items直接拒绝,不修改库存。来源合箱占用或尚未清点时拒绝,PDA按实物标签扣全部内容,同键重放不重复扣减。 security: [{ bearerAuth: [] }] parameters: [{ name: customerId, in: query, required: true, schema: { type: string } }] requestBody: @@ -840,20 +840,6 @@ paths: overseasWarehouseId: { type: string, maxLength: 128 } returnNo: { type: string, maxLength: 128 } cartonMarks: { type: array, minItems: 1, maxItems: 200, uniqueItems: true, items: { type: string, maxLength: 128 } } - skuCode: { type: string, maxLength: 128, nullable: true } - quantity: { type: integer, format: int64, minimum: 1, nullable: true, description: 单SKU计划件数,不得超过所选来源总数;省略出全部,只允许skuCode已填且非PALLET维度,不接受小数 } - items: - type: array - nullable: true - minItems: 1 - maxItems: 200 - items: - type: object - required: [cartonMark, skuCode, quantity] - properties: - cartonMark: { type: string, maxLength: 128, description: 散件库存来源标识,非实体箱号 } - skuCode: { type: string, maxLength: 128 } - quantity: { type: integer, format: int64, minimum: 1, description: 本来源本次出库件数,不接受小数,不得超过可用数量 } unitType: { type: string, enum: [BOX, PALLET], nullable: true } remark: { type: string, maxLength: 1000, nullable: true } idempotencyKey: { type: string, maxLength: 255 } @@ -1764,7 +1750,7 @@ paths: /api/merchant/v1/last-leg/return-orders/{returnNo}: get: summary: 客户查询退货订单与签收批次 - description: 权限 merchant:overseas-warehouse:return-order:list;返回各 SKU 的预报和实收数量,以及每次 PDA 签收产生的批次号和可选快递单号。cartons 数组包含 receiptNo、palletCode、cartonMark、countStatus、countedAt、items(skuCode、productNameZh、quantity、boxCount)。CONFIRMED 为签收或清点时不可变箱内快照;PENDING 的 items 为空,不返回预分配平均数;UNRECORDED 表示历史未留存箱内快照,不能以已变化库存冒充签收数量。包裹散件不生成实物箱明细。 + description: 权限 merchant:overseas-warehouse:return-order:list;返回各 SKU 的预报和实收数量,以及每次 PDA 签收产生的批次号和可选快递单号。cartons 数组包含 receiptNo、palletCode、cartonMark、countStatus、countedAt、items(skuCode、productNameZh、quantity、boxCount)。CONFIRMED 为签收或清点时不可变箱内快照;PENDING 的 items 为空,不返回预分配平均数;UNRECORDED 表示历史未留存箱内快照,不能以已变化库存冒充签收数量。包裹作为一个独立实物明细,保留同包裹全部SKU及可打印标签。 security: [{ bearerAuth: [] }] parameters: [{ name: returnNo, in: path, required: true, schema: { type: string } }] responses: { '200': { description: 当前客户退货订单详情 }, '404': { description: 当前客户下退货订单不存在 } } @@ -1851,11 +1837,11 @@ paths: - { name: returnNo, in: query, schema: { type: string } } - { name: status, in: query, schema: { type: string } } responses: - '200': { description: 分页 records 每箱一行,包含 cartonMark、overseasWarehouseId、sourceBatchNo、sourceReturnNo、status、skuTypes、totalQuantity、length、width、height、weight、createdAt;total 为实际箱数 } + '200': { description: 分页 records 每箱一行,包含 cartonMark、overseasWarehouseId、sourceBatchNo、sourceReturnNo、status、skuTypes、totalQuantity、length、width、height、weight、createdAt、relabelStatus、relabelledQuantity、relabelReservedQuantity;total 为实际箱数。换标状态取实际当前或最新轮次事实,申请占用为未完成申请件数,两者不从可用库存差值推算 } /api/merchant/v1/last-leg/return-inventory/cartons/{cartonMark}: get: summary: 客户查询退货箱明细 - description: 仅返回当前客户的箱号、签收批次和箱内 SKU 数量。 + description: 仅返回当前客户的箱号、签收批次和箱内 SKU 数量;relabelStatus 为 UNLABELLED/PROCESSING/PARTIALLY_COMPLETED/COMPLETED,relabelledQuantity 为当前或最新轮次实际商品换标件数,relabelReservedQuantity 为未完成申请件数。items.availableQuantity 为实物可用库存,历史换标不会永久扣减下一轮可申请量。 security: [{ bearerAuth: [] }] parameters: [{ name: cartonMark, in: path, required: true, schema: { type: string, maxLength: 128 } }] responses: { '200': { description: 当前客户退货箱明细 }, '404': { description: 当前客户下退货箱不存在 } } @@ -3118,6 +3104,10 @@ components: RelabelInventoryCandidate: type: object properties: + relabelStatus: { type: string, nullable: true, enum: [UNLABELLED, PROCESSING, PARTIALLY_COMPLETED, COMPLETED], description: 退货实物按实际完成件数和未完成申请占用计算 } + relabelledPackages: { type: integer, format: int64, description: 退货实际已完成商品换标件数,不从可用库存差值推导 } + relabelledQuantity: { type: integer, format: int64, minimum: 0 } + relabelReservedQuantity: { type: integer, format: int64, minimum: 0, description: 未完成申请件数,取消或完成后释放 } inventoryBalanceId: { type: string } inboundOrderId: { type: string } inboundOrderNo: { type: string } @@ -3132,7 +3122,7 @@ components: availablePackages: { type: integer, format: int64 } version: { type: integer, format: int64 } positions: { type: array, items: { $ref: '#/components/schemas/RelabelInventoryPosition' } } - sourceType: { type: string, enum: [INBOUND, RETURN_CARTON] } + sourceType: { type: string, enum: [INBOUND, RETURN_CARTON, RETURN_PARCEL] } returnCartonId: { type: string, nullable: true, description: 退货合箱库存主键;sourceType=RETURN_CARTON 时提供 } cartonMark: { type: string, nullable: true, description: 仓库合箱后打印并贴箱的仓库唛头 } skuItems: diff --git a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml index b0dad9ad6..c08d1aba7 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml @@ -272,11 +272,82 @@ paths: itemId: { type: string, pattern: '^[0-9]+$' } skuIndex: { type: integer, minimum: 0 } oldSku: { type: string, maxLength: 255 } + cartonMark: { type: string, maxLength: 128, description: 退货SKU作业必填,须扫描当前实体来源包裹或箱 } + newSkuCode: { type: string, maxLength: 128, description: 退货SKU作业必填,须与批准的目标标签一致 } + quantity: { type: integer, minimum: 1, description: 退货SKU作业必填,须与本明细批准件数一致 } expectedVersion: { type: integer, minimum: 0 } idempotencyKey: { type: string, maxLength: 255 } responses: '200': { description: 更新后的任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } } '409': { description: 已处理、映射不符、认领人或版本冲突 } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/return-skus/complete: + post: + summary: 扫描来源包裹或箱及SKU登记退货按件换标实际完成量 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [itemId, quantity, expectedVersion, idempotencyKey, cartonMark, skuCode] + properties: + itemId: { type: string, pattern: '^[0-9]+$' } + quantity: { type: integer, minimum: 1 } + expectedVersion: { type: integer, minimum: 0 } + idempotencyKey: { type: string, maxLength: 255 } + cartonMark: { type: string, maxLength: 128 } + skuCode: { type: string, maxLength: 128 } + responses: { '200': { description: 更新后Detail;同键同请求重放 }, '409': { description: 来源、SKU、剩余件数或版本冲突 } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/return-skus/complete-total: + post: + summary: 单一来源的退货SKU按件完成 + description: 同SKU涉及多个来源包裹或箱时拒绝自动分配,必须逐来源扫码。 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [skuCode, quantity, expectedVersion, idempotencyKey] + properties: + skuCode: { type: string } + quantity: { type: integer, minimum: 1 } + expectedVersion: { type: integer, minimum: 0 } + idempotencyKey: { type: string } + responses: { '200': { description: 更新后Detail }, '409': { description: 多来源或数量冲突 } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/return-cartons/pack: + post: + summary: 将本任务已完成的退货SKU件数隔离合箱为独立新箱 + description: 必须由认领人在PROCESSING阶段完成所有申请明细后执行;原包裹余量守恒,保留跨退货单追溯。响应Detail的items切换为新箱,history的RETURN_PACK.detail JSON含round、sourceCartonMarks、sourceDeductions、cartonMark、quantity和label.printableHtml。标签为仓库识别标签,与商品换标资料分开。同键重放原结果。 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [sourceCartonMarks, idempotencyKey, length, width, height, weight] + properties: + sourceCartonMarks: { type: array, minItems: 1, maxItems: 100, items: { type: string } } + targetCartonMark: { type: string, nullable: true, description: 必须为空,由系统生成独立新箱 } + idempotencyKey: { type: string, maxLength: 255 } + sourceDeductions: + type: array + description: 可省略由服务端生成;填写时按来源箱和SKU汇总必须与本任务完成明细一致 + items: + type: object + required: [cartonMark, skuCode, quantity] + properties: + cartonMark: { type: string } + skuCode: { type: string } + quantity: { type: integer, format: int64, minimum: 1 } + length: { type: number, exclusiveMinimum: true, minimum: 0 } + width: { type: number, exclusiveMinimum: true, minimum: 0 } + height: { type: number, exclusiveMinimum: true, minimum: 0 } + weight: { type: number, exclusiveMinimum: true, minimum: 0 } + responses: { '200': { description: 更新后Detail含新箱识别标签 }, '409': { description: 未完成申请、来源变动或数量冲突 } } /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/units/skip: post: summary: 登记一件有原因的跳过事实 @@ -751,6 +822,8 @@ components: type: object required: [relabelNo, applicationStatus, operationType, taskStatus, expectedPackages, relabelledPackages, skippedPackages, exceptionPackages, version, completionPhotoFileIds, items, unitFacts, history] properties: + deliveryMethod: { type: string, nullable: true, description: 申请真实运输方式,留仓为空 } + requiresPalletBinding: { type: boolean, description: 退货任务仅TRUCK为true,留仓及快递为false } relabelNo: { type: string } customerOrderNo: { type: string, nullable: true } labelDeadline: { type: string, format: date-time, nullable: true } @@ -791,7 +864,7 @@ components: relabelFileUrl: { type: string, nullable: true } cartonMark: { type: string, nullable: true, description: 我方箱唛 } fbaCartonMark: { type: string, nullable: true, description: 新FBA箱唛 } - operationType: { type: string, enum: [CARTON_LABEL, SKU_LABEL, CARTON_AND_SKU] } + operationType: { type: string, enum: [CARTON_LABEL, SKU_LABEL, CARTON_AND_SKU, RETURN_BULK_SKU_LABEL] } skuItems: { type: array, items: { $ref: '#/components/schemas/RelabelSkuItem' } } expectedPackages: { type: integer } relabelledPackages: { type: integer } @@ -799,6 +872,8 @@ components: exceptionPackages: { type: integer } cartonProcessedPackages: { type: integer, description: 本轮箱唛作业已完成箱数,混合单独立计数 } skuProcessedPackages: { type: integer, description: 本轮SKU作业已完成箱数,两项均完成才增加整箱已换标数 } + returnCartonId: { type: string, nullable: true, description: 真实退货包裹或箱ID } + returnQuantity: { type: integer, format: int64, minimum: 0, description: 当前实物包裹或箱全部件数;不是申请量 } RelabelSkuItem: type: object required: [skuCode, newSkuCode, quantity] @@ -876,7 +951,7 @@ components: inventoryPositionId: { type: string, nullable: true, pattern: '^[0-9]+$', description: 普通库存使用真实仓位键;退货箱库存没有该仓位,提交 null,不提交空字符串或退货库存内部ID } packages: { type: integer, format: int64, minimum: 1 } productQuantity: { type: integer, format: int64, minimum: 1, nullable: true, description: 单SKU退货明细存在returnSkuQuantity时必填且必须等于计划件数;不接受小数。历史无快照明细可省略 } - skuCode: { type: string, maxLength: 128, nullable: true, description: PARCEL散件单SKU扫码值;已装箱来源必须扫描箱唛 } + skuCode: { type: string, maxLength: 128, nullable: true, description: 仅遗留已创建SKU任务使用;新退货任务必须扫描包裹或箱标签 } idempotencyKey: { type: string, minLength: 1, maxLength: 255 } cartonMarks: type: array diff --git a/lastleg-service/src/main/resources/openapi/return-packing-v1.yaml b/lastleg-service/src/main/resources/openapi/return-packing-v1.yaml index b579ee2a8..d526223c8 100644 --- a/lastleg-service/src/main/resources/openapi/return-packing-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/return-packing-v1.yaml @@ -2,7 +2,7 @@ openapi: 3.0.3 info: title: 退货合箱计划与箱级上架 version: 1.0.0 - description: 散SKU查询客户全部同仓PARCEL可用库存;箱合并精确指定来源箱唛并扫描已有目标箱。托盘仅为可选载体。合箱不新增订单货量。 + description: 包裹可包含多个SKU并拥有独立标签;整包裹/箱可直接出库,部分数量须精确选择来源包裹/箱合箱成新标签箱。托盘仅为可选载体。合箱不新增订单货量。 security: [{ bearerAuth: [] }] paths: /api/platform/v1/last-leg/return-packing-plans: @@ -17,7 +17,7 @@ paths: responses: { '200': { description: 业务响应信封 code为200成功,A0400/A0404/A0409校验或状态错误,requestId及traceId保留 }, '403': { description: 权限或角色不符 } } post: summary: 创建并锁定来源库存 - description: return-inventory:list + lastleg:inventory:manage;同客户同仓已签收订单,BOX精确锁来源箱全部SKU,SKU只锁散件指定数量。 + description: return-inventory:list + lastleg:inventory:manage;同客户同仓已签收订单,BOX精确锁来源箱全部SKU,SKU锁定所选包裹/箱的指定数量,来源实物在计划完成或取消前不可出库。 requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/Create' } } } } responses: { '200': { description: 业务响应信封 code为200成功,A0400/A0404/A0409校验或状态错误,requestId及traceId保留 }, '403': { description: 权限或角色不符 } } /api/platform/v1/last-leg/return-packing-plans/{planNo}/review: @@ -46,16 +46,22 @@ paths: /api/pda/v1/last-leg/return-inventory/packing/plans/{planNo}/complete: post: summary: 多箱分配并完成计划 - description: SKU目标总量须等于锁定量。BOX先选择新增目标箱或扫描已有目标箱,sourceDeductions逐来源箱及SKU指定实际扣除量(不超过锁定量),未使用预占释放;目标原库存不扣减,新箱须填写箱规并生成系统箱唛。全事务、同载荷重放标签快照。 + description: SKU目标总量须等于锁定量。SKU仅支持新增目标箱;BOX整箱可扫描已有目标箱,部分扣除仅支持新增目标箱,sourceDeductions逐来源箱及SKU指定实际扣除量(不超过锁定量),未使用预占释放;目标原库存不扣减,新箱须填写箱规并生成系统箱唛。全事务、同载荷重放标签快照。 parameters: [{ name: planNo, in: path, required: true, schema: { type: string } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/Complete' } } } } responses: { '200': { description: 业务响应信封 code为200成功,A0400/A0404/A0409校验或状态错误,requestId及traceId保留 }, '403': { description: 权限或角色不符 } } /api/pda/v1/last-leg/return-inventory/cartons/{cartonMark}: get: summary: 扫描可用来源或目标箱 - description: 仅同仓AVAILABLE实物箱,PARCEL不作为箱选择。 + description: 仅同仓AVAILABLE包裹或箱,返回其全部SKU明细。 parameters: [{ name: cartonMark, in: path, required: true, schema: { type: string } }] responses: { '200': { description: 业务响应信封 code为200成功,A0400/A0404/A0409校验或状态错误,requestId及traceId保留 }, '403': { description: 权限或角色不符 } } + /api/pda/v1/last-leg/return-inventory/cartons/{cartonMark}/confirm-label: + post: + summary: 扫描已贴好的包裹或箱标签 + description: 绑定仓且AVAILABLE实物,幂等确认仓库识别标签;整包裹或箱无需确认此标签也可创建出库单。此操作不登记商品换标完成量。 + parameters: [{ name: cartonMark, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: 标签快照及业务响应 }, '403': { description: 权限或角色不符 } } /api/pda/v1/last-leg/return-inventory/cartons/merge-boxes: post: summary: 来源箱并入已有目标箱 @@ -112,6 +118,7 @@ components: returnNos: { type: array, minItems: 1, maxItems: 100, items: { type: string } } mode: { type: string, enum: [SKU, BOX] } sourceCartonMarks: { type: array, maxItems: 500, items: { type: string } } + sourceDeductions: { type: array, maxItems: 1000, items: { $ref: '#/components/schemas/SourceDeduction' }, description: 部分合箱精确来源及数量,与目标SKU合计一致 } boxes: { type: array, minItems: 1, maxItems: 100, items: { $ref: '#/components/schemas/Box' } } remark: { type: string, maxLength: 1000 } idempotencyKey: { type: string, maxLength: 255 } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java index 8c05d0096..2b26d573e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java @@ -67,6 +67,7 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; @@ -209,105 +210,52 @@ class DeliveryTransferServiceTest { var candidates = service.returnOutboundCandidates("TENANT-A", "WH-1"); - assertEquals(List.of("RC-PAL"), candidates.stream().map(DeliveryResponses.ReturnOutboundCandidate::cartonMark).toList()); + assertEquals(List.of("RC-PAL", "RC-PARCEL"), candidates.stream().map(DeliveryResponses.ReturnOutboundCandidate::cartonMark).toList()); + } + + + + + + + + @Test + void directPartialReturnOutboundRequiresPackingBeforeAnyInventoryLock() { + for (var request : List.of( + new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1", List.of("RP-1"), null, "partial-sku", "SKU-A", "BOX", 1L), + new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1", List.of("BOX-1"), null, "partial-box", null, "BOX", null, + List.of(new DeliveryRequests.ReturnSkuOutboundItem("BOX-1", "SKU-A", 1L))))) { + var error = assertThrows(ResponseStatusException.class, () -> service.createReturnOutbound("TENANT-A", "U-1", request)); + assertTrue(error.getReason().contains("先合箱")); + } + verify(returnCartons, never()).lockMark(anyString()); + verify(outboundOrders, never()).insert(any(OutboundOrder.class)); } @Test - void singleSkuReturnOutboundReservesParcelAndCreatesDelivery() { - ReturnInventoryCarton parcel = returnCarton("RC-PARCEL", null); - parcel.setId(901L); parcel.setSourceUnitType("PARCEL"); parcel.setTotalQuantity(3L); - ReturnInventoryCartonItem line = new ReturnInventoryCartonItem(); - line.setSkuCode("SKU-A"); line.setQuantity(3L); - when(returnCartons.lockMark("RC-PARCEL")).thenReturn(parcel); - when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(line)); - - service.createReturnOutbound("TENANT-A", "U-1", - new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1", - List.of("RC-PARCEL"), null, "sku-return-1", "SKU-A")); - - ArgumentCaptor item = ArgumentCaptor.forClass(OutboundPalletItem.class); - ArgumentCaptor delivery = ArgumentCaptor.forClass(DeliveryOrder.class); - verify(outboundPallets).insert(item.capture()); - verify(deliveryOrders).insert(delivery.capture()); - assertEquals("SKU-A", item.getValue().getSkuCode()); - assertEquals("RC-PARCEL", item.getValue().getCartonMarks()); + void wholeMultiSkuParcelCreatesBarcodePickWithoutSkuQuantity() { + var parcel = returnCarton("RP-1", null); parcel.setId(901L); parcel.setSourceUnitType("PARCEL"); parcel.setTotalQuantity(5L); + var a = new ReturnInventoryCartonItem(); a.setSkuCode("SKU-A"); a.setQuantity(3L); + var b = new ReturnInventoryCartonItem(); b.setSkuCode("SKU-B"); b.setQuantity(2L); + when(returnCartons.lockMark("RP-1")).thenReturn(parcel); + when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(a,b)); + service.createReturnOutbound("TENANT-A", "U-1", new DeliveryRequests.ReturnOutbound("receiver","WH-1","RT-1",List.of("RP-1"),null,"whole-package",null,"BOX")); + var capture=ArgumentCaptor.forClass(OutboundPalletItem.class); verify(outboundPallets).insert(capture.capture()); + assertEquals("RP-1", capture.getValue().getCartonMarks()); + assertNull(capture.getValue().getSkuCode()); assertNull(capture.getValue().getReturnSkuQuantity()); assertEquals("OUTBOUND_PENDING", parcel.getStatus()); - assertEquals("WAIT_DELIVERY", delivery.getValue().getStatus()); - assertEquals(1, delivery.getValue().getPackageCount()); } @Test - void partialSkuOutboundDistributesSixPiecesAcrossTwoFivePieceSources() { - var first = returnCarton("RC-1", null); first.setId(901L); first.setTotalQuantity(5L); - var second = returnCarton("RC-2", null); second.setId(902L); second.setTotalQuantity(5L); - first.setSourceUnitType("PARCEL"); second.setSourceUnitType("PARCEL"); - when(returnCartons.lockMark("RC-1")).thenReturn(first); when(returnCartons.lockMark("RC-2")).thenReturn(second); - var line = new ReturnInventoryCartonItem(); line.setSkuCode("SKU-A"); line.setQuantity(5L); - when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(line)); - when(returnCartonItems.lockCarton(902L)).thenReturn(List.of(line)); - service.createReturnOutbound("TENANT-A", "U-1", new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1", - List.of("RC-1", "RC-2"), null, "partial-six", "SKU-A", "BOX", 6L)); - var capture = ArgumentCaptor.forClass(OutboundPalletItem.class); - verify(outboundPallets, org.mockito.Mockito.times(2)).insert(capture.capture()); - assertEquals(List.of(5L, 1L), capture.getAllValues().stream().map(OutboundPalletItem::getReturnSkuQuantity).toList()); - } - - @Test - void singleSkuReturnOutboundRejectsPackedSingleSkuWithoutReservingInventory() { - ReturnInventoryCarton carton = returnCarton("RC-BOX", "PAL-1"); - carton.setSourceUnitType("BOX"); carton.setTotalQuantity(5L); - when(returnCartons.lockMark("RC-BOX")).thenReturn(carton); - var error = assertThrows(ResponseStatusException.class, () -> service.createReturnOutbound("TENANT-A", "U-1", - new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1", List.of("RC-BOX"), null, "packed-sku", "SKU-A"))); - assertTrue(error.getReason().contains("单SKU出库只允许散件库存")); - verify(outboundOrders, never()).insert(any(OutboundOrder.class)); - verify(returnCartons, never()).updateById(any(ReturnInventoryCarton.class)); - assertEquals("AVAILABLE", carton.getStatus()); - } - - @Test - void multipleLooseSkusCreateOneOutboundWithExactPerSourceQuantities() { - var first = returnCarton("SP-A", null); first.setId(901L); first.setSourceUnitType("PARCEL"); first.setTotalQuantity(5L); - var second = returnCarton("SP-B", null); second.setId(902L); second.setSourceUnitType("PARCEL"); second.setTotalQuantity(8L); - when(returnCartons.lockMark("SP-A")).thenReturn(first); when(returnCartons.lockMark("SP-B")).thenReturn(second); - var a = new ReturnInventoryCartonItem(); a.setSkuCode("SKU-A"); a.setQuantity(5L); - var b = new ReturnInventoryCartonItem(); b.setSkuCode("SKU-B"); b.setQuantity(8L); - when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(a)); when(returnCartonItems.lockCarton(902L)).thenReturn(List.of(b)); - service.createReturnOutbound("TENANT-A", "U-1", new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1", - List.of("SP-A", "SP-B"), null, "multi-loose", null, "BOX", null, - List.of(new DeliveryRequests.ReturnSkuOutboundItem("SP-A", "SKU-A", 2L), new DeliveryRequests.ReturnSkuOutboundItem("SP-B", "SKU-B", 3L)))); - var capture = ArgumentCaptor.forClass(OutboundPalletItem.class); - verify(outboundPallets, org.mockito.Mockito.times(2)).insert(capture.capture()); - assertEquals(List.of("SKU-A", "SKU-B"), capture.getAllValues().stream().map(OutboundPalletItem::getSkuCode).toList()); - assertEquals(List.of(2L, 3L), capture.getAllValues().stream().map(OutboundPalletItem::getReturnSkuQuantity).toList()); - verify(outboundOrders).insert(any(OutboundOrder.class)); verify(deliveryOrders).insert(any(DeliveryOrder.class)); - } - - @Test - void looseItemQuantityCannotExceedItsOwnSource() { - var first = returnCarton("SP-A", null); first.setId(901L); first.setSourceUnitType("PARCEL"); first.setTotalQuantity(1L); - when(returnCartons.lockMark("SP-A")).thenReturn(first); - var a = new ReturnInventoryCartonItem(); a.setSkuCode("SKU-A"); a.setQuantity(1L); - when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(a)); - var error = assertThrows(ResponseStatusException.class, () -> service.createReturnOutbound("TENANT-A", "U-1", new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1", - List.of("SP-A"), null, "over-source", null, "BOX", null, List.of(new DeliveryRequests.ReturnSkuOutboundItem("SP-A", "SKU-A", 2L))))); - assertTrue(error.getReason().contains("超过")); verify(outboundOrders, never()).insert(any(OutboundOrder.class)); - } - - @Test - void singleSkuReturnOutboundRejectsMixedCarton() { - ReturnInventoryCarton mixed = returnCarton("RC-MIXED", null); - mixed.setSourceUnitType("PARCEL"); - mixed.setId(901L); mixed.setTotalQuantity(5L); - ReturnInventoryCartonItem skuA = new ReturnInventoryCartonItem(); skuA.setSkuCode("SKU-A"); skuA.setQuantity(3L); - ReturnInventoryCartonItem skuB = new ReturnInventoryCartonItem(); skuB.setSkuCode("SKU-B"); skuB.setQuantity(2L); - when(returnCartons.lockMark("RC-MIXED")).thenReturn(mixed); - when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(skuA, skuB)); - - assertEquals(409, assertThrows(ResponseStatusException.class, () -> service.createReturnOutbound("TENANT-A", "U-1", - new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1", - List.of("RC-MIXED"), null, "sku-return-2", "SKU-A"))).getStatusCode().value()); - verify(outboundOrders, never()).insert(any(OutboundOrder.class)); + void newlyReceivedParcelCanOutboundBeforeItsLabelIsConfirmed() { + var parcel = returnCarton("RP-1", null); parcel.setLabelStatus("WAIT_LABEL"); + when(returnCartons.lockMark("RP-1")).thenReturn(parcel); + var line = new ReturnInventoryCartonItem(); line.setSkuCode("SKU-A"); line.setQuantity(3L); + when(returnCartonItems.lockCarton(anyLong())).thenReturn(List.of(line)); + service.createReturnOutbound("TENANT-A", "U-1", + new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1", List.of("RP-1"), null, "waiting-label")); + verify(outboundOrders).insert(any(OutboundOrder.class)); + assertEquals("OUTBOUND_PENDING", parcel.getStatus()); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java index a227a7df6..d6a5b1436 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java @@ -71,27 +71,13 @@ class ReturnOutboundClosureTest { verify(outbounds, times(1)).insert(any(OutboundOrder.class)); } - @Test void singleSkuPackedBoxIsExcludedFromLooseOutbound() { - assertThatThrownBy(() -> service.createReturnOutbound("C1", "USER", command("SKU1"))) - .isInstanceOf(ResponseStatusException.class).hasMessageContaining("散件库存"); - verify(items, never()).insert(any(OutboundPalletItem.class)); - assertThat(carton.getStatus()).isEqualTo("AVAILABLE"); - } - @Test void singleSkuCannotConsumeMixedSkuCarton() { - ReturnInventoryCartonItem other = new ReturnInventoryCartonItem(); other.setSkuCode("SKU2"); other.setQuantity(1L); - var first = contents.lockCarton(10L).get(0); - when(contents.lockCarton(10L)).thenReturn(List.of(first, other)); - assertThatThrownBy(() -> service.createReturnOutbound("C1", "USER", command("SKU1"))) - .isInstanceOf(ResponseStatusException.class).hasMessageContaining("SKU"); - verify(outbounds, never()).insert(any(OutboundOrder.class)); - } - @Test void singleSkuParcelReservesAndKeepsScanSku() { + + @Test void wholeParcelUsesItsPhysicalBarcode() { carton.setSourceUnitType("PARCEL"); - service.createReturnOutbound("C1", "USER", command("SKU1")); - verify(items).insert(argThat((OutboundPalletItem i) -> "SKU1".equals(i.getSkuCode()))); - assertThat(carton.getStatus()).isEqualTo("OUTBOUND_PENDING"); + service.createReturnOutbound("C1", "USER", command(null)); + verify(items).insert(argThat((OutboundPalletItem i) -> i.getSkuCode() == null && i.getReturnSkuQuantity() == null && "RC#1-1".equals(i.getCartonMarks()))); } @Test void refusesPackingReservedQuantity() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java index dc82ab31a..0b8dff334 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java @@ -123,29 +123,6 @@ class ReturnOutboundPdaPersistenceTest { assertEquals(1, after.items().stream().flatMap(item -> item.positions().stream()).mapToLong(row -> row.availablePackages()).sum()); } - @Test void multipleLooseSkusPersistPickAndDeductTheirOwnPieceQuantitiesWithReplayProtection() { - jdbc.update("UPDATE ll_return_inventory_carton SET source_unit_type='PARCEL' WHERE id IN (1,2)"); - jdbc.update("UPDATE ll_return_inventory_carton_item SET sku_code='SKU-B' WHERE id=2"); - jdbc.update("UPDATE ll_return_inventory_balance SET total_quantity=5,available_quantity=5,carton_quantity=1 WHERE id=1"); - jdbc.update("INSERT INTO ll_return_inventory_balance(id,customer_id,overseas_warehouse_id,sku_code,total_quantity,available_quantity,carton_quantity) VALUES(10,'C1','WH-1','SKU-B',5,5,1)"); - var task = service.createReturnOutbound("C1", "operator", new DeliveryRequests.ReturnOutbound("recipient", "WH-1", "RT-1", - List.of("BOX-A", "BOX-B"), null, "multi-sku", null, "BOX", null, - List.of(new DeliveryRequests.ReturnSkuOutboundItem("BOX-A", "SKU-A", 2L), new DeliveryRequests.ReturnSkuOutboundItem("BOX-B", "SKU-B", 3L)))); - var detail = service.pdaTaskDetail("WH-1", task.taskNo()); - assertEquals(2, detail.items().size()); - for (var item : detail.items()) { - String sku = item.skuCode(); long quantity = "SKU-A".equals(sku) ? 2 : 3; - var request = new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "pick-" + sku, List.of(), sku, quantity); - service.pdaPick("WH-1", "operator", task.taskNo(), request); - service.pdaPick("WH-1", "operator", task.taskNo(), request); - } - assertEquals(3L, jdbc.queryForObject("SELECT available_quantity FROM ll_return_inventory_balance WHERE id=1", Long.class)); - assertEquals(2L, jdbc.queryForObject("SELECT available_quantity FROM ll_return_inventory_balance WHERE id=10", Long.class)); - assertEquals(5L, jdbc.queryForObject("SELECT SUM(quantity) FROM ll_return_inventory_outbound_movement", Long.class)); - assertEquals(2, jdbc.queryForObject("SELECT COUNT(*) FROM ll_outbound_pda_pick", Integer.class)); - assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM ll_outbound_order", Integer.class)); - assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM ll_delivery_order", Integer.class)); - } @Test void wholeReturnPalletRejectsPartialScansAndChangedContentsThenShipsAtomically() { jdbc.update("UPDATE ll_return_inventory_carton SET pallet_code='PAL-1' WHERE id IN (1,2)"); var task = service.createReturnOutbound("C1", "operator", new DeliveryRequests.ReturnOutbound( diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java index e325d8e1d..74de56c1d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java @@ -308,7 +308,7 @@ class PdaRelabelServiceTest { } @Test - void returnSkuTotalCompletesOneSkuOnceAndAllocatesAcrossSourcePalletItems() { + void returnSkuTotalRejectsAutomaticAllocationAcrossSourceCartons() { application.setStatus("PROCESSING"); application.setNotifiedPackageCount(100); item.setOperationType("RETURN_BULK_SKU_LABEL"); item.setSkuCode("sku-fba100"); item.setNotifiedPackageCount(50); item.setCartonMark("PALLET-A"); @@ -319,15 +319,42 @@ class PdaRelabelServiceTest { when(tasks.lock("100", 1L)).thenReturn(task); when(items.selectList(any())).thenReturn(List.of(item, second)); - var result = service.completeReturnSkuTotal("7", "picker", 900L, "FBR-1", - new PdaRelabelRequests.BulkSkuTotal("sku-fba100", 100, 0, "sku-total-1")); + assertThrows(ResponseStatusException.class, () -> service.completeReturnSkuTotal("7", "picker", 900L, "FBR-1", + new PdaRelabelRequests.BulkSkuTotal("sku-fba100", 100, 0, "sku-total-1"))); + assertTrue(persistedFacts.isEmpty()); + } - assertEquals(100, result.relabelledPackages()); - assertEquals(2, persistedFacts.size()); - assertEquals(List.of(50, 50), persistedFacts.stream().map(PdaRelabelUnitFact::getQuantity).toList()); - assertEquals(List.of(10L, 11L), persistedFacts.stream().map(PdaRelabelUnitFact::getItemId).toList()); + @Test void returnSkuScanRequiresPhysicalSourceNewSkuAndApprovedQuantity() { + application.setStatus("PROCESSING"); application.setDeliveryMethod("EXPRESS"); + item.setOperationType("SKU_LABEL"); item.setReturnCartonId(20L); item.setCartonMark("PARCEL-A"); + item.setNotifiedPackageCount(1); + item.setSkuRelabelItemsJson("[{\"skuCode\":\"OLD\",\"newSkuCode\":\"NEW\",\"quantity\":3}]"); + var cartons = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper.class); + ReflectionTestUtils.setField(service, "returnCartons", cartons); + var carton = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton(); + carton.setId(20L); carton.setCountStatus("CONFIRMED"); carton.setTotalQuantity(10L); + when(cartons.selectById(20L)).thenReturn(carton); + when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application); + when(tasks.lock("100", 1L)).thenReturn(task("PROCESSING", 0)); + when(items.selectOne(any())).thenReturn(item); + assertThrows(ResponseStatusException.class, () -> service.scanSku("7", "picker", 900L, "FBR-1", + new PdaRelabelRequests.SkuScan(10L, 0, "OLD", 0, "wrong-source", "PARCEL-B", "NEW", 3))); + assertThrows(ResponseStatusException.class, () -> service.scanSku("7", "picker", 900L, "FBR-1", + new PdaRelabelRequests.SkuScan(10L, 0, "OLD", 0, "wrong-quantity", "PARCEL-A", "NEW", 2))); + assertTrue(persistedFacts.isEmpty()); + var result = service.scanSku("7", "picker", 900L, "FBR-1", + new PdaRelabelRequests.SkuScan(10L, 0, "OLD", 0, "good", "PARCEL-A", "NEW", 3)); + assertEquals(1, result.relabelledPackages()); assertEquals("EXPRESS", result.deliveryMethod()); + assertEquals(false, result.requiresPalletBinding()); assertEquals(10, result.items().get(0).returnQuantity()); + var second = item(); second.setId(11L); second.setReturnCartonId(20L); second.setCartonMark("PARCEL-A"); + second.setOperationType("SKU_LABEL"); second.setNotifiedPackageCount(1); + second.setSkuRelabelItemsJson(item.getSkuRelabelItemsJson()); + when(items.selectList(any())).thenReturn(List.of(item, second)); + when(items.selectOne(any())).thenReturn(second); + var again = service.scanSku("7", "picker", 900L, "FBR-1", + new PdaRelabelRequests.SkuScan(11L, 0, "OLD", 1, "same-merged-box", "PARCEL-A", "NEW", 3)); + assertEquals(2, again.relabelledPackages()); assertTrue(persistedFacts.stream().allMatch(fact -> fact.getOldLabel() == null)); - assertEquals(1, result.version()); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java index f2260afd3..dbc85a796 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java @@ -452,7 +452,7 @@ class RelabelApplicationServiceTest { ReturnOrder returnOrder = new ReturnOrder(); returnOrder.setId(41L); returnOrder.setCustomerId("10001"); returnOrder.setRelabelApplicationId(application.getId()); returnOrder.setRelabelStatus("PROCESSING"); - when(returnOrders.selectOne(any())).thenReturn(returnOrder); + when(returnOrders.selectList(any())).thenReturn(List.of(returnOrder)); service.changeStatus("10001", "FBR-REVIEW", "operator-2", new RelabelRequests.ChangeStatus(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, "submitted")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuardTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuardTest.java index 806625f7b..60c35f0ff 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuardTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuardTest.java @@ -16,6 +16,27 @@ import static org.mockito.ArgumentMatchers.*; import static org.junit.jupiter.api.Assertions.*; class RelabelBindingGuardTest { + @Test void partialReturnSkuMustBeIsolatedAndLooseFullCartonOnlyRequiresPalletForTruck() { + var guard = new RelabelBindingGuard(); + var items = mock(RelabelItemMapper.class); var histories = mock(PdaRelabelHistoryMapper.class); + var lines = mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper.class); + var cartons = mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper.class); + ReflectionTestUtils.setField(guard, "items", items); ReflectionTestUtils.setField(guard, "histories", histories); + ReflectionTestUtils.setField(guard, "returnLines", lines); ReflectionTestUtils.setField(guard, "returnCartons", cartons); + ReflectionTestUtils.setField(guard, "objectMapper", new ObjectMapper()); + var app = new RelabelApplication(); app.setId(1L); app.setCustomerId("2"); + var item = new RelabelItem(); item.setReturnCartonId(3L); item.setCartonMark("PARCEL"); + item.setOperationType("SKU_LABEL"); item.setNotifiedPackageCount(1); + item.setSkuRelabelItemsJson("[{\"skuCode\":\"A\",\"newSkuCode\":\"NEW-A\",\"quantity\":3}]"); + when(items.selectList(any())).thenReturn(List.of(item)); when(histories.selectList(any())).thenReturn(List.of()); + var stock = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem(); stock.setSkuCode("A"); stock.setQuantity(10L); + when(lines.lockCarton(3L)).thenReturn(List.of(stock)); + assertThrows(ResponseStatusException.class, () -> guard.requireBound(app)); + stock.setQuantity(3L); + assertDoesNotThrow(() -> guard.requireBound(app)); + app.setDeliveryMethod("EXPRESS"); assertDoesNotThrow(() -> guard.requireBound(app)); + app.setDeliveryMethod("TRUCK"); assertThrows(ResponseStatusException.class, () -> guard.requireBound(app)); + } @Test void physicalBindingMustMatchLatestTargetAndCannotSurviveRework() { var guard = new RelabelBindingGuard(); var items = mock(RelabelItemMapper.class); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelReturnCartonCandidateTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelReturnCartonCandidateTest.java index de7433c01..340a2221f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelReturnCartonCandidateTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelReturnCartonCandidateTest.java @@ -36,9 +36,9 @@ class RelabelReturnCartonCandidateTest { ReturnInventoryCartonItem cartonItem = new ReturnInventoryCartonItem(); cartonItem.setCartonId(9001L); cartonItem.setSkuCode("SKU-RETURN"); cartonItem.setProductNameZh("退货商品"); cartonItem.setQuantity(12L); - when(cartons.findAvailable("10001", "WH-1")).thenReturn(List.of(carton)); + when(cartons.findRelabelCandidates("10001", "WH-1")).thenReturn(List.of(carton)); when(cartonItems.listCarton(9001L)).thenReturn(List.of(cartonItem)); - when(cartons.relabelledQuantity(9001L, "SKU-RETURN")).thenReturn(1L); + when(cartons.totalRelabelledQuantity(9001L)).thenReturn(1L); List result = service.candidates("10001", "WH-1", "RC-WH"); @@ -46,8 +46,14 @@ class RelabelReturnCartonCandidateTest { assertEquals("RETURN_CARTON", result.get(0).sourceType()); assertEquals(9001L, result.get(0).returnCartonId()); assertEquals("RC-WH-0001", result.get(0).cartonMark()); - assertEquals(11, result.get(0).availablePackages()); + assertEquals(12, result.get(0).availablePackages()); assertEquals("SKU-RETURN", result.get(0).skuItems().get(0).skuCode()); - assertEquals(11, result.get(0).skuItems().get(0).quantity()); + assertEquals(12, result.get(0).skuItems().get(0).quantity()); + assertEquals(1, result.get(0).relabelledQuantity()); + assertEquals("PARTIALLY_COMPLETED", result.get(0).relabelStatus()); + cartonItem.setPackingReservedQuantity(4L); + result = service.candidates("10001", "WH-1", "RC-WH"); + assertEquals(8, result.get(0).availablePackages()); + assertEquals(1, result.get(0).relabelledQuantity(), "合箱预占不能冒充实际换标完成量"); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/ReturnRelabelPackingServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/ReturnRelabelPackingServiceTest.java new file mode 100644 index 000000000..abb574200 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/ReturnRelabelPackingServiceTest.java @@ -0,0 +1,107 @@ +package com.baiyue.lastleg.relabel.service; + +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import com.baiyue.lastleg.relabel.entity.*; +import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; +import com.baiyue.lastleg.returnreceipt.dto.*; +import com.baiyue.lastleg.returnreceipt.entity.*; +import com.baiyue.lastleg.returnreceipt.mapper.*; +import com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class ReturnRelabelPackingServiceTest { + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void skuPackingMovesCompletionToIndependentCartonAndKeepsOriginalSourceMarks(boolean inheritedCompletion) { + var service = new ReturnRelabelPackingService(); + var cartons = mock(ReturnInventoryCartonMapper.class); + var items = mock(RelabelItemMapper.class); + var inventory = mock(ReturnInventoryService.class); + ReflectionTestUtils.setField(service, "cartons", cartons); + ReflectionTestUtils.setField(service, "items", items); + ReflectionTestUtils.setField(service, "inventory", inventory); + ReflectionTestUtils.setField(service, "json", new ObjectMapper()); + var app = new RelabelApplication(); app.setId(1L); app.setCustomerId("2"); app.setRelabelNo("RL1"); + var first = item(10L, 20L, "A"); var second = item(11L, 21L, "B"); + var a = carton(20L, "A"); var b = carton(21L, "B"); var target = carton(22L, "NEW"); + var history = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper.class); + ReflectionTestUtils.setField(service, "histories", history); + if (inheritedCompletion) { + var prior = item(9L, 20L, "A"); prior.setApplicationId(99L); + prior.setSkuRelabelItemsJson("[{\"skuCode\":\"SKU\",\"quantity\":6}]"); + when(items.priorCompletedReturnItems("2", 20L, 1L)).thenReturn(List.of(prior)); + var facts = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelUnitFactMapper.class); + var fact = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact(); fact.setItemId(9L); fact.setQuantity(1); + when(facts.selectList(any())).thenReturn(List.of(fact)); + ReflectionTestUtils.setField(service, "facts", facts); + var physical = mock(ReturnInventoryCartonItemMapper.class); + var line = new ReturnInventoryCartonItem(); line.setSkuCode("SKU"); line.setQuantity(6L); + when(physical.lockCarton(20L)).thenReturn(List.of(line)); + ReflectionTestUtils.setField(service, "cartonItems", physical); + ReflectionTestUtils.setField(service, "completionLedger", mock(ReturnRelabelCompletionLedgerMapper.class)); + var tasks = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class); + var task = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask(); task.setId(100L); + when(tasks.find("2", 1L)).thenReturn(task); ReflectionTestUtils.setField(service, "tasks", tasks); + } + when(cartons.lockMark("A")).thenReturn(a); when(cartons.lockMark("B")).thenReturn(b); + when(cartons.lockMark("NEW")).thenReturn(target); + when(inventory.mergeBoxes(any(), any())).thenAnswer(call -> { + var request = (ReturnInventoryRequests.BoxMerge)call.getArgument(1); + assertEquals(List.of("A", "B"), request.sourceCartonMarks()); + assertNull(request.targetCartonMark()); + assertEquals(List.of(new ReturnInventoryRequests.SourceDeduction("A", "SKU", 3), + new ReturnInventoryRequests.SourceDeduction("B", "SKU", 3)), request.sourceDeductions()); + return new ReturnInventoryResponses.MergeResult("NEW", "SKU", 6, null); + }); + var result = service.pack(new PdaWarehouseIdentity("op", "picker", 42L), app, List.of(first, second), + new ReturnInventoryRequests.BoxMerge(List.of("A", "B"), null, "key", null, + BigDecimal.TEN, BigDecimal.TEN, BigDecimal.TEN, BigDecimal.ONE)); + assertEquals("NEW", result.cartonMark()); + assertEquals(22L, first.getReturnCartonId()); assertEquals(22L, second.getReturnCartonId()); + assertEquals("A", first.getSourceCartonMark()); assertEquals("B", second.getSourceCartonMark()); + assertEquals("RELABEL_PENDING", target.getStatus()); + verify(cartons).clearRelabel(20L); verify(cartons).clearRelabel(21L); + verify(items).updateById(first); verify(items).updateById(second); + if (inheritedCompletion) { + var saved = org.mockito.ArgumentCaptor.forClass(com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory.class); + verify(history).insert(saved.capture()); + assertEquals("RETURN_COMPLETION_TRANSFER", saved.getValue().getAction()); + assertTrue(saved.getValue().getDetailJson().contains("\"sourceItemIds\":[\"9\"]")); + assertTrue(saved.getValue().getDetailJson().contains("\"quantity\":3")); + assertTrue(saved.getValue().getDetailJson().contains("\"targetCartonMark\":\"NEW\"")); + } else verifyNoInteractions(history); + } + + @Test void wrongSourceAndDifferentDestinationCannotEnterInventoryMutation() { + var service = new ReturnRelabelPackingService(); + var inventory = mock(ReturnInventoryService.class); + ReflectionTestUtils.setField(service, "inventory", inventory); + var app = new RelabelApplication(); + var a = item(10L, 20L, "A"); var b = item(11L, 21L, "B"); b.setDestinationFbaWarehouseCode("OTHER"); + assertThrows(ResponseStatusException.class, () -> service.pack(new PdaWarehouseIdentity("op", "name", 42L), app, + List.of(a), new ReturnInventoryRequests.BoxMerge(List.of("OTHER"), null, "wrong"))); + assertThrows(ResponseStatusException.class, () -> service.pack(new PdaWarehouseIdentity("op", "name", 42L), app, + List.of(a, b), new ReturnInventoryRequests.BoxMerge(List.of("A", "B"), null, "dest"))); + verifyNoInteractions(inventory); + } + private RelabelItem item(long id, long cartonId, String mark) { + var item = new RelabelItem(); item.setId(id); item.setReturnCartonId(cartonId); + item.setCartonMark(mark); item.setSourceCartonMark(mark); item.setOperationType("SKU_LABEL"); + item.setNotifiedPackageCount(1); item.setDestinationFbaWarehouseCode("FBA"); + item.setSkuRelabelItemsJson("[{\"skuCode\":\"SKU\",\"newSkuCode\":\"NEW-SKU\",\"quantity\":3}]"); + return item; + } + private ReturnInventoryCarton carton(long id, String mark) { + var carton = new ReturnInventoryCarton(); carton.setId(id); carton.setCartonMark(mark); + carton.setCustomerId("2"); carton.setRelabelApplicationId(1L); carton.setStatus("RELABEL_PENDING"); + return carton; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java index f5485a34a..a9ae1aab5 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java @@ -127,7 +127,7 @@ class ReturnInventoryServiceTest { } @Test - void pendingReceiptDetailsDoNotExposeProvisionalAveragesAndParcelsHaveNoBoxes() { + void pendingReceiptDetailsDoNotExposeProvisionalAveragesForPhysicalUnits() { var receipt = new ReturnReceipt(); receipt.setId(21L); receipt.setCustomerId("C1"); receipt.setReceiptNo("RR-1"); receipt.setUnitType("PALLET"); var box = new ReturnInventoryCarton(); box.setCartonMark("RC#2-1"); box.setCountStatus("PENDING"); box.setTotalQuantity(5L); when(cartons.listReceipt(21L)).thenReturn(List.of(box)); @@ -135,7 +135,7 @@ class ReturnInventoryServiceTest { assertThat(detail.get(0).countStatus()).isEqualTo("PENDING"); assertThat(detail.get(0).items()).isEmpty(); receipt.setUnitType("PARCEL"); - assertThat(service.receiptContents(receipt)).isEmpty(); + assertThat(service.receiptContents(receipt)).hasSize(1); verifyNoInteractions(cartonItems); } @@ -150,13 +150,14 @@ class ReturnInventoryServiceTest { } @Test - void parcelSigningStoresLooseSkuSourceWithoutGeneratingCartonNumberOrIncreasingBoxCount() { + void parcelSigningCreatesOneLabelledPhysicalUnitWithoutIncreasingBoxCount() { var receipt = new ReturnReceipt(); receipt.setId(21L); receipt.setUnitType("PARCEL"); receipt.setUnitCode("PK-1"); var source = service.createReceiptCarton(completedOrder(), receipt, null, List.of(new ReturnInventoryService.ReceiptLine("A", "商品A", 3))); - assertThat(source.getCartonMark()).startsWith("SP"); + assertThat(source.getCartonMark()).startsWith("RP"); assertThat(source.getSourceUnitType()).isEqualTo("PARCEL"); - verifyNoInteractions(numbers, operations); + verifyNoInteractions(numbers); + assertThat(source.getLabelStatus()).isEqualTo("WAIT_LABEL"); verify(balances).adjust(anyLong(), anyString(), anyString(), eq("A"), eq("商品A"), eq(3L), eq(3L), eq(0L)); } @@ -227,7 +228,7 @@ class ReturnInventoryServiceTest { void cartonPageCountsPhysicalCartonsAndKeepsTheAuthenticatedCustomerScope() { var query = new ReturnInventoryRequests.Query(2, 1, "42", "SKU", null, "AVAILABLE", "RT-1"); ReturnInventoryCarton carton = new ReturnInventoryCarton(); - carton.setCartonMark("RC-SECOND"); + carton.setId(31L); carton.setCartonMark("RC-SECOND"); carton.setSkuTypes(2); carton.setTotalQuantity(5L); when(cartons.merchantCount("10001", query)).thenReturn(2L); @@ -369,6 +370,32 @@ class ReturnInventoryServiceTest { verify(balances).adjustCartonQuantity("10001", "42", "SKU-A", 1L); } + @Test void completedLabelQuantityCannotBeGuessedWhenSplittingAStoredCarton() { + prepareConcurrentSource(); + when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 2L))); + when(cartons.relabelledQuantity(31L, "SKU-A")).thenReturn(1L); + var error = assertThrows(ResponseStatusException.class, () -> service.merge( + new PdaWarehouseIdentity("operator-2", "name", 42L), mergeRequest(1))); + assertThat(error.getReason()).contains("不能自动分配来源", "独立新箱"); + verify(cartonItems, never()).updateById(any(ReturnInventoryCartonItem.class)); + verify(cartons, never()).insert(any(ReturnInventoryCarton.class)); + } + + @Test void wholeCartonMergeCarriesItsCompletedRelabelFactsToTheTarget() { + var source = prepareConcurrentSource(); + when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 2L))); + when(cartons.relabelledQuantity(31L, "SKU-A")).thenReturn(2L); + when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-TARGET"); + when(balances.adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong())).thenReturn(1); + var relabel = org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.mapper.RelabelItemMapper.class); + ReflectionTestUtils.setField(service, "relabelItems", relabel); + service.merge(new PdaWarehouseIdentity("operator-2", "name", 42L), mergeRequest(2)); + var target = ArgumentCaptor.forClass(ReturnInventoryCarton.class); + verify(cartons).insert(target.capture()); + verify(relabel).moveCompletedReturnCarton("10001", 31L, target.getValue().getId(), "RC-TARGET"); + assertThat(source.getTotalQuantity()).isZero(); + } + @Test void sameKeyInsertRaceReplaysCommittedResultAndLabelUsingOnlyCurrentReads() { var request = mergeRequest(1); @@ -468,11 +495,11 @@ class ReturnInventoryServiceTest { } @Test - void planCreationLocksExactSkuAndCancelReleasesOnlyItsReservation() { + void planCreationLocksPhysicalSourceAndCancelReleasesReservation() { var source = new ReturnInventoryCarton(); source.setId(31L); source.setCartonMark("RC-SOURCE"); source.setCustomerId("10001"); source.setOverseasWarehouseId("42"); source.setSourceReturnNo("RT-1"); source.setStatus("AVAILABLE"); source.setSourceUnitType("PARCEL"); - var line = item(41L, "SKU-A", 5); line.setPackingReservedQuantity(1L); + var line = item(41L, "SKU-A", 5); line.setPackingReservedQuantity(0L); when(orders.lockNo("RT-1")).thenReturn(completedOrder()); when(cartons.packingAvailable("10001", "42")).thenReturn(List.of(source)); when(cartons.lockMark("RC-SOURCE")).thenReturn(source); @@ -483,12 +510,14 @@ class ReturnInventoryServiceTest { var request = new ReturnInventoryRequests.PackingPlanCreate(List.of("RT-1"), "SKU", List.of(new ReturnInventoryRequests.PlanBox(List.of(new ReturnInventoryRequests.PlanItem("SKU-A", 2)), null, null, null, null)), null, "plan-key"); var created = service.createPackingPlan("platform", request); - assertThat(created.status()).isEqualTo("PENDING_REVIEW"); assertThat(line.getPackingReservedQuantity()).isEqualTo(3); + assertThat(created.status()).isEqualTo("PENDING_REVIEW"); assertThat(line.getPackingReservedQuantity()).isEqualTo(2); + assertThat(source.getStatus()).isEqualTo("PACKING_RESERVED"); var captor = ArgumentCaptor.forClass(com.baiyue.lastleg.returnreceipt.entity.ReturnPackingPlan.class); verify(packingPlans).insert(captor.capture()); when(packingPlans.lockNo("PLAN-1")).thenReturn(captor.getValue()); assertThat(service.cancelPackingPlan("PLAN-1").status()).isEqualTo("CANCELLED"); - assertThat(line.getPackingReservedQuantity()).isEqualTo(1); + assertThat(line.getPackingReservedQuantity()).isEqualTo(0); + assertThat(source.getStatus()).isEqualTo("AVAILABLE"); verify(balances).adjust(anyLong(), org.mockito.ArgumentMatchers.eq("10001"), org.mockito.ArgumentMatchers.eq("42"), org.mockito.ArgumentMatchers.eq("SKU-A"), org.mockito.ArgumentMatchers.isNull(), org.mockito.ArgumentMatchers.eq(0L), org.mockito.ArgumentMatchers.eq(2L), org.mockito.ArgumentMatchers.eq(0L)); } @@ -502,6 +531,35 @@ class ReturnInventoryServiceTest { verifyNoInteractions(cartonItems, balances); } + @Test + void partialPhysicalBoxCannotBeMergedIntoAnExistingTarget() { + var source = new ReturnInventoryCarton(); source.setId(31L); source.setCartonMark("RC-SOURCE"); + source.setCustomerId("10001"); source.setOverseasWarehouseId("42"); source.setSourceReturnNo("RT-1"); + source.setStatus("AVAILABLE"); source.setSourceUnitType("BOX"); + when(cartons.lockMark("RC-SOURCE")).thenReturn(source); + when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 5))); + var request = new ReturnInventoryRequests.BoxMerge(List.of("RC-SOURCE"), "RC-TARGET", "partial-existing", + List.of(new ReturnInventoryRequests.SourceDeduction("RC-SOURCE", "SKU-A", 1)), null, null, null, null); + var error = assertThrows(ResponseStatusException.class, () -> service.mergeBoxes(new PdaWarehouseIdentity("operator-1", "作业员", 42L), request)); + assertThat(error.getReason()).contains("独立新箱"); + verify(cartonItems, never()).updateById(any(ReturnInventoryCartonItem.class)); + verify(cartons, never()).insert(any(ReturnInventoryCarton.class)); + } + + @Test + void confirmingPhysicalLabelIsIdempotentAndBoundToWarehouse() { + var source = new ReturnInventoryCarton(); source.setId(31L); source.setCartonMark("RC-SOURCE"); + source.setCustomerId("10001"); source.setOverseasWarehouseId("42"); source.setSourceReturnNo("RT-1"); + source.setStatus("AVAILABLE"); source.setLabelStatus("WAIT_LABEL"); + when(cartons.lockMark("RC-SOURCE")).thenReturn(source); + when(cartonItems.listCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 2))); + var identity = new PdaWarehouseIdentity("operator-1", "作业员", 42L); + service.confirmLabel(identity, "RC-SOURCE"); service.confirmLabel(identity, "RC-SOURCE"); + assertThat(source.getLabelStatus()).isEqualTo("CONFIRMED"); + verify(cartons, org.mockito.Mockito.times(1)).updateById(source); + assertThrows(ResponseStatusException.class, () -> service.confirmLabel(new PdaWarehouseIdentity("operator-2", "其他仓", 43L), "RC-SOURCE")); + } + private ReturnInventoryCarton prepareConcurrentSource() { ReturnReceipt receipt = new ReturnReceipt(); receipt.setId(21L); ReturnInventoryCarton snapshot = new ReturnInventoryCarton(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java index 4205b5c2c..604e08124 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java @@ -227,10 +227,13 @@ class ReturnReceiptServiceTest { when(numbers.next(eq("RELABEL"), eq("10"), anyLong())).thenReturn("RL-MULTI"); when(lastlegOrders.selectById(99L)).thenReturn(tail); when(cartons.findAvailable("customer-1", "10")).thenReturn(List.of(first, second)); + when(inventory.matchesReturnSource(any(), eq("RT-MULTI"))).thenCallRealMethod(); when(cartons.lockId(101L)).thenReturn(first); when(cartons.lockId(102L)).thenReturn(second); when(cartonItems.listCarton(101L)).thenReturn(List.of(returnCartonItem("SKU-A", 50))); when(cartonItems.listCarton(102L)).thenReturn(List.of(returnCartonItem("SKU-B", 40))); + when(cartonItems.lockCarton(101L)).thenReturn(List.of(returnCartonItem("SKU-A", 50))); + when(cartonItems.lockCarton(102L)).thenReturn(List.of(returnCartonItem("SKU-B", 40))); var result = service.createRelabel("customer-1", "merchant-1", "RT-MULTI", new ReturnOrderRequests.RelabelCreate(List.of( @@ -263,7 +266,7 @@ class ReturnReceiptServiceTest { } @Test - void secondRelabelCannotAllocateAlreadyLabelledQuantity() { + void completedReturnCartonCanStartAnotherRelabelRound() { ReturnOrder order = order("RT-MULTI", "10", "COMPLETED", 100, 100); order.setLastlegOrderId(99L); order.setRelabelNo("RL-PREVIOUS"); LastlegOrder tail = new LastlegOrder(); tail.setId(99L); @@ -275,16 +278,17 @@ class ReturnReceiptServiceTest { when(numbers.next(eq("RELABEL"), eq("10"), anyLong())).thenReturn("RL-SECOND"); when(lastlegOrders.selectById(99L)).thenReturn(tail); when(cartons.findAvailable("customer-1", "10")).thenReturn(List.of(carton)); + when(inventory.matchesReturnSource(any(), eq("RT-MULTI"))).thenCallRealMethod(); when(cartonItems.listCarton(101L)).thenReturn(List.of(returnCartonItem("SKU-A", 100))); - when(cartons.relabelledQuantity(101L, "SKU-A")).thenReturn(100L); - var error = assertThrows(ResponseStatusException.class, () -> service.createRelabel( + when(cartons.lockId(101L)).thenReturn(carton); + when(cartonItems.lockCarton(101L)).thenReturn(List.of(returnCartonItem("SKU-A", 100))); + var result = service.createRelabel( "customer-1", "merchant-1", "RT-MULTI", new ReturnOrderRequests.RelabelCreate( List.of(new ReturnOrderRequests.RelabelItem("SKU-A", 1)), - "SKU_TOTAL", "200", "XLX6", "second-key", "second attempt"))); - assertEquals(409, error.getStatusCode().value()); - assertTrue(error.getReason().contains("可用实收数量不足")); - verify(relabelItems, never()).insert(any(RelabelItem.class)); - verify(cartons, never()).updateById(any(ReturnInventoryCarton.class)); + "SKU_TOTAL", "200", "XLX6", "second-key", "second attempt")); + assertEquals("RL-SECOND", result.relabelNo()); + verify(relabelItems).insert(any(RelabelItem.class)); + verify(cartons).updateById(carton); } @Test @@ -444,7 +448,7 @@ class ReturnReceiptServiceTest { } @Test - void parcelWithoutTrackingGeneratesParcelCodeAndNeverReturnsBoxLabelsOnReplay() { + void parcelWithoutTrackingKeepsPhysicalMarkOnReplay() { ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PENDING_RECEIPT, 3, 0); ReturnOrderItem line = item(11L, "SKU-A", 3, 0); stubNewReceipt(order, List.of(line), "SP-SOURCE"); @@ -455,7 +459,7 @@ class ReturnReceiptServiceTest { var capture = ArgumentCaptor.forClass(ReturnReceipt.class); verify(receipts).insert(capture.capture()); assertThat(result.unitCode()).startsWith("PK"); - assertThat(result.cartonMark()).isNull(); + assertThat(result.cartonMark()).isEqualTo("SP-SOURCE"); assertThat(result.printableHtml()).isNull(); assertThat(result.labels()).isEmpty(); assertThat(capture.getValue().getTrackingNo()).isNull(); @@ -463,7 +467,7 @@ class ReturnReceiptServiceTest { var replay = service.confirm(identity, request); assertThat(replay.unitCode()).isEqualTo(result.unitCode()); assertThat(replay.labels()).isEmpty(); - verify(inventory, never()).label(anyString(), anyString()); + verify(inventory, times(2)).label("customer-1", "SP-SOURCE"); verify(inventory, times(1)).createReceiptCarton(any(), any(), any(), any()); } diff --git a/scripts/README.md b/scripts/README.md index 5b084247c..43bf3d506 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -497,3 +497,5 @@ Admin 全量中的 `phase4-cross-region-events` 使用内部 `test-event-fault-e `f32-pda-hold-race.mjs` 是显式 TEST 暂扣/PDA拣货并发验收入口,离线合同 `f32-pda-hold-race.test.mjs`。命令为 `node --env-file="" --env-file="" --env-file="<本轮自有PDA配置>" scripts/f32-pda-hold-race.mjs --plan <冻结计划> --report <全新报告> --confirm-test-runtime yes`;只消费已实际入仓、4箱可用、无占用、调度审核通过且同仓PDA canPick=true的本轮独立样本,不创建订单或车次。凭据只读明确TEST/Jenkins/自有PDA配置,不写报告;计划冻结客户、来源订单、仓、余额、位置、排车明细、车次、PDA成员和不可变Lastleg镜像ID。通过Jenkins在指定TEST Lastleg所用MySQL建立仅针对原订单的12秒行锁租约(SELECT FOR UPDATE、固定等待、ROLLBACK,无业务DML),用performance_schema实际锁等待证明两条正式HTTP命令同时竞争,再按暂扣先到/拣货先到两种顺序验证。会真实暂扣、解除本轮暂扣和拣货1箱,逐位置、库存流水、拣货及来源归属、原键重放分别核对;两个阶段顺序执行,不并发造数。结束恢复自有PDA停用状态并退出认证会话,测试单据、部分拣货和资源保留。命令结果未知或断言失败时保存原报告及逐步checkpoint,不自动重发命令、重建样本、删数据或回填SQL;租约自动释放后先核对原单和原命令,未证实的暂扣恢复必须单独按原事实处理。离线通过不代表实际MySQL/PDA或四端页面通过。 + +- `db/return-package-packing-boundary-incremental-20261010.sql`:退货包裹/箱出库及标签字段增量,显式选定 baiyue_lastleg 库后写结构;无凭据、无业务造数,可重复执行,失败停止并检查数据库错误。 diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index 3215f965f..0a65a9557 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -5054,3 +5054,28 @@ SET @return_sku_index_ddl = IF(EXISTS(SELECT 1 FROM information_schema.statistic PREPARE return_sku_index_stmt FROM @return_sku_index_ddl; EXECUTE return_sku_index_stmt; DEALLOCATE PREPARE return_sku_index_stmt; + +-- 退货以包裹/箱为最小出库实物单位;部分数量必须先合箱生成新标签箱。 +-- 可重复执行,目标库 baiyue_lastleg。 +USE baiyue_lastleg; + +-- 合箱后的新箱与原包裹共用同一箱唛、标签和来源追溯模型。 +-- 流水约束允许部分合箱/拆分只扣件数,不虚减箱数。 +SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.table_constraints + WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_outbound_movement' + AND constraint_name='chk_ll_return_outbound_movement_delta'), + 'ALTER TABLE ll_return_inventory_outbound_movement DROP CHECK chk_ll_return_outbound_movement_delta, ADD CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0 AND total_before-total_after=quantity AND available_before-available_after=quantity AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)', + 'ALTER TABLE ll_return_inventory_outbound_movement ADD CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0 AND total_before-total_after=quantity AND available_before-available_after=quantity AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 同一包裹可分批合箱/出库,重复提交仍由 outbound_pick_id + SKU 幂等约束保护。 +SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_outbound_movement' + AND index_name='uk_ll_return_outbound_carton_sku'), + 'ALTER TABLE ll_return_inventory_outbound_movement DROP INDEX uk_ll_return_outbound_carton_sku, ADD INDEX idx_ll_return_outbound_carton_sku(customer_id,carton_id,sku_code)', + 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 新签收及新合箱必须打印并扫码确认标签;既有实物按原状态继续作业。 +SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='label_status'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN label_status VARCHAR(32) NULL COMMENT ''WAIT_LABEL待贴标 CONFIRMED已扫码确认标签'''); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/scripts/db/return-package-packing-boundary-incremental-20261010.sql b/scripts/db/return-package-packing-boundary-incremental-20261010.sql new file mode 100644 index 000000000..90b99d1d0 --- /dev/null +++ b/scripts/db/return-package-packing-boundary-incremental-20261010.sql @@ -0,0 +1,24 @@ +-- 退货以包裹/箱为最小出库实物单位;部分数量必须先合箱生成新标签箱。 +-- 可重复执行,目标库 baiyue_lastleg。 +USE baiyue_lastleg; + +-- 合箱后的新箱与原包裹共用同一箱唛、标签和来源追溯模型。 +-- 流水约束允许部分合箱/拆分只扣件数,不虚减箱数。 +SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.table_constraints + WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_outbound_movement' + AND constraint_name='chk_ll_return_outbound_movement_delta'), + 'ALTER TABLE ll_return_inventory_outbound_movement DROP CHECK chk_ll_return_outbound_movement_delta, ADD CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0 AND total_before-total_after=quantity AND available_before-available_after=quantity AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)', + 'ALTER TABLE ll_return_inventory_outbound_movement ADD CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0 AND total_before-total_after=quantity AND available_before-available_after=quantity AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 同一包裹可分批合箱/出库,重复提交仍由 outbound_pick_id + SKU 幂等约束保护。 +SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_outbound_movement' + AND index_name='uk_ll_return_outbound_carton_sku'), + 'ALTER TABLE ll_return_inventory_outbound_movement DROP INDEX uk_ll_return_outbound_carton_sku, ADD INDEX idx_ll_return_outbound_carton_sku(customer_id,carton_id,sku_code)', + 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 新签收及新合箱必须打印并扫码确认标签;既有实物按原状态继续作业。 +SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='label_status'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN label_status VARCHAR(32) NULL COMMENT ''WAIT_LABEL待贴标 CONFIRMED已扫码确认标签'''); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;