diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 90708d047..861cb876d 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -69,7 +69,7 @@ | CN / `baiyue_iam` | `migrations/cn/baiyue_iam/V1__align_schema_baseline.sql` | 收敛客户编号、昵称和资质原文件名定义;收紧前检查存量数据 | | CN / `baiyue_firstleg` | `migrations/cn/baiyue_firstleg/V1__align_schema_baseline.sql` | 收敛一件代发托盘码非空定义;收紧前检查存量数据 | | CN / `baiyue_file` | `migrations/cn/baiyue_file/V1__align_schema_baseline.sql`、`V2__widen_business_type.sql` | 补齐 PDA 发布表和固定渠道,并支持正式长业务类型 | -| OVERSEAS / `baiyue_lastleg` | `migrations/overseas/baiyue_lastleg/V1__align_lastleg_production_schema.sql`、`V2__align_schema_baseline.sql`、`V3__relax_valid_nullable_fields.sql` | 补齐尾程字段并收敛字段定义;放宽退货出库和预入仓批量 SKU 换标合法空值 | +| OVERSEAS / `baiyue_lastleg` | `migrations/overseas/baiyue_lastleg/V1__align_lastleg_production_schema.sql`、`V2__align_schema_baseline.sql`、`V3__relax_valid_nullable_fields.sql`、`V4__cargo_discard_return_inventory.sql` | 补齐尾程字段并收敛字段定义;放宽退货出库和预入仓批量 SKU 换标合法空值;货物废弃支持退货库存箱 | 上述新增候选当前只完成编写和离线检查,尚未在测试或生产执行。测试通过后必须复用同一份 SQL 和 manifest 晋级生产,不得重新生成或修改候选。`scripts/prod-database-schema-acceptance.mjs` 仍只负责历史尾程 V1 的专用只读核对/受控执行,不代表本批跨 schema 发布入口;本批按迁移 manifest 管理,实际执行须等待环境执行器接入或当前轮明确授权后的受控人工执行。 diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V4__cargo_discard_return_inventory.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V4__cargo_discard_return_inventory.sql new file mode 100644 index 000000000..ff1784f50 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V4__cargo_discard_return_inventory.sql @@ -0,0 +1,24 @@ +ALTER TABLE ll_cargo_discard_item + MODIFY COLUMN order_id BIGINT NULL COMMENT '一件代发订单ID', + MODIFY COLUMN profile_id BIGINT NULL COMMENT '托盘档案ID', + MODIFY COLUMN sku_inventory_id BIGINT NULL COMMENT 'SKU库存记录ID'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity', 'SELECT 1')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED','DISCARD_PENDING') AND sku_types>0 AND total_quantity>0) OR (status IN('MERGED','DISCARDED') AND sku_types=0 AND total_quantity=0)); + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_status', 'SELECT 1')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED','DISCARD_PENDING','DISCARDED')); + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='source_type'), + 'ALTER TABLE ll_cargo_discard_item ADD COLUMN source_type VARCHAR(32) NOT NULL DEFAULT ''DROPSHIP'' COMMENT ''库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON'' AFTER order_id', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='return_carton_id'), + 'ALTER TABLE ll_cargo_discard_item ADD COLUMN return_carton_id BIGINT NULL COMMENT ''退货库存箱ID'' AFTER source_type', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND index_name='idx_ll_cargo_discard_return_carton'), + 'ALTER TABLE ll_cargo_discard_item ADD KEY idx_ll_cargo_discard_return_carton(customer_id,return_carton_id)', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index f84fba221..c75df3757 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -140,10 +140,12 @@ CREATE TABLE ll_cargo_discard_item ( id bigint NOT NULL COMMENT '主键ID', application_id bigint NOT NULL COMMENT '申请单ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', - order_id bigint NOT NULL COMMENT '订单ID', + order_id bigint DEFAULT NULL COMMENT '一件代发订单ID', + source_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'DROPSHIP' COMMENT '库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON', + return_carton_id bigint DEFAULT NULL COMMENT '退货库存箱ID', inbound_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入仓单号快照', - profile_id bigint NOT NULL COMMENT '托盘档案ID', - sku_inventory_id bigint NOT NULL COMMENT 'SKU库存记录ID', + profile_id bigint DEFAULT NULL COMMENT '托盘档案ID', + sku_inventory_id bigint DEFAULT NULL COMMENT 'SKU库存记录ID', sku_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码', product_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '产品名称', available_packages_snapshot int NOT NULL COMMENT '可用箱数快照', @@ -157,6 +159,7 @@ CREATE TABLE ll_cargo_discard_item ( UNIQUE KEY uk_ll_cargo_discard_item (application_id,profile_id,sku_code), KEY idx_ll_cargo_discard_item_list (customer_id,application_id), KEY idx_ll_cargo_discard_item_inbound (customer_id,inbound_code,sku_code) + ,KEY idx_ll_cargo_discard_return_carton (customer_id,return_carton_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='货物废弃SKU明细'; CREATE TABLE ll_cargo_discard_status_history ( @@ -2384,8 +2387,8 @@ CREATE TABLE ll_return_inventory_carton ( UNIQUE KEY uk_ll_return_inventory_carton_mark (carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt (source_receipt_id), KEY idx_ll_return_inventory_carton_customer (customer_id,overseas_warehouse_id,status,created_at), - CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK ((((status in (_utf8mb4'AVAILABLE',_utf8mb4'RELABEL_PENDING',_utf8mb4'OUTBOUNDED')) and (sku_types > 0) and (total_quantity > 0)) or ((status = _utf8mb4'MERGED') and (sku_types = 0) and (total_quantity = 0)))), - CONSTRAINT chk_ll_return_inventory_carton_status CHECK ((status in (_utf8mb4'AVAILABLE',_utf8mb4'RELABEL_PENDING',_utf8mb4'MERGED',_utf8mb4'OUTBOUNDED'))) + CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK ((((status in (_utf8mb4'AVAILABLE',_utf8mb4'RELABEL_PENDING',_utf8mb4'OUTBOUNDED',_utf8mb4'DISCARD_PENDING')) and (sku_types > 0) and (total_quantity > 0)) or ((status in (_utf8mb4'MERGED',_utf8mb4'DISCARDED')) and (sku_types = 0) and (total_quantity = 0)))), + CONSTRAINT chk_ll_return_inventory_carton_status CHECK ((status in (_utf8mb4'AVAILABLE',_utf8mb4'RELABEL_PENDING',_utf8mb4'MERGED',_utf8mb4'OUTBOUNDED',_utf8mb4'DISCARD_PENDING',_utf8mb4'DISCARDED'))) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货实物库存箱及箱唛'; CREATE TABLE ll_return_inventory_carton_item ( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java index 415d0f873..f368e7224 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java @@ -19,7 +19,7 @@ public final class CargoDiscardRequests { @NotBlank @Size(max = 255) String idempotencyKey, @Size(max = 1000) String remark) {} - public record Item(@NotNull Long orderId, @NotBlank @Size(max = 128) String skuCode, + public record Item(Long orderId, Long returnCartonId, @NotBlank @Size(max = 128) String skuCode, @Positive int discardPackages) {} public record Review(@NotBlank String decision, @Size(max = 1000) String remark) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java index 7d906b439..1fa1c8e8f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java @@ -7,7 +7,8 @@ import java.util.List; public final class CargoDiscardResponses { private CargoDiscardResponses() {} - public record Candidate(Long orderId, String inboundCode, String inboundOrderNo, + public record Candidate(Long orderId, String sourceType, Long returnCartonId, String cartonMark, + String inboundCode, String inboundOrderNo, String overseasWarehouseId, String overseasWarehouseName, LocalDateTime inboundCreatedAt, long availablePackages, List skus) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardItem.java index ee1560a1e..bd21d4164 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardItem.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardItem.java @@ -14,6 +14,8 @@ public class CargoDiscardItem { private Long applicationId; private String customerId; private Long orderId; + private String sourceType; + private Long returnCartonId; private String inboundCode; private Long profileId; private Long skuInventoryId; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java index a94ee568a..7ffd2a9b5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java @@ -10,6 +10,8 @@ import com.baiyue.lastleg.discard.service.CargoDiscardService; import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile; import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper; import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; import jakarta.annotation.Resource; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -27,6 +29,7 @@ public class PdaCargoDiscardService { @Resource private DropshipOrderProfileMapper profiles; @Resource private CargoDiscardInventoryAdjustmentService adjustments; @Resource private CargoDiscardService discardService; + @Resource private ReturnInventoryCartonMapper returnCartons; public List tasks(PdaWarehouseIdentity identity) { return applications.selectList(new LambdaQueryWrapper() @@ -69,6 +72,9 @@ public class PdaCargoDiscardService { item.setActualDiscardPackages(actuals.get(item.getId())); item.setPdaStatus("COMPLETED"); item.setPdaOperatorId(identity.operatorKey()); item.setPdaCompletedAt(now); items.updateById(item); } + task.skus().stream().map(PdaCargoDiscardDtos.Sku::itemId).map(items::selectById) + .filter(this::isReturnItem).map(CargoDiscardItem::getReturnCartonId).filter(Objects::nonNull).distinct() + .forEach(cartonId -> adjustments.finishReturnCarton(application.getCustomerId(), cartonId)); if (items.listApplication(application.getCustomerId(), application.getId()).stream() .allMatch(i -> "COMPLETED".equals(i.getPdaStatus()))) { int actualTotal = items.listApplication(application.getCustomerId(), application.getId()).stream() @@ -83,17 +89,31 @@ public class PdaCargoDiscardService { private List palletTasks(CargoDiscardApplication application) { return items.listApplication(application.getCustomerId(), application.getId()).stream() - .collect(Collectors.groupingBy(CargoDiscardItem::getProfileId, LinkedHashMap::new, Collectors.toList())) + .collect(Collectors.groupingBy(this::physicalGroupKey, LinkedHashMap::new, Collectors.toList())) .entrySet().stream().map(entry -> { - DropshipOrderProfile profile = profiles.selectById(entry.getKey()); - if (profile == null || !application.getCustomerId().equals(profile.getCustomerId())) throw conflict("废弃明细关联托盘不存在"); + CargoDiscardItem first = entry.getValue().get(0); + boolean returnSource = isReturnItem(first); + DropshipOrderProfile profile = returnSource ? null : profiles.selectById(first.getProfileId()); + ReturnInventoryCarton returnCarton = returnSource ? returnCartons.selectById(first.getReturnCartonId()) : null; + if (!returnSource && (profile == null || !application.getCustomerId().equals(profile.getCustomerId()))) throw conflict("废弃明细关联托盘不存在"); + if (returnSource && (returnCarton == null || !application.getCustomerId().equals(returnCarton.getCustomerId()))) throw conflict("废弃明细关联退货箱不存在"); List skus = entry.getValue().stream().map(i -> new PdaCargoDiscardDtos.Sku( i.getId(), i.getSkuCode(), i.getProductName(), i.getDiscardPackages(), i.getActualDiscardPackages(), i.getPdaStatus() == null ? "PENDING" : i.getPdaStatus())).toList(); String status = skus.stream().allMatch(s -> "COMPLETED".equals(s.status())) ? "COMPLETED" : "PENDING"; return new PdaCargoDiscardDtos.PalletTask(application.getDiscardNo(), application.getCustomerId(), - application.getOverseasWarehouseId(), profile.getPalletNo(), profile.getId(), status, skus); + application.getOverseasWarehouseId(), returnSource ? firstNonBlank(returnCarton.getPalletCode(), returnCarton.getCartonMark()) : profile.getPalletNo(), + returnSource ? returnCarton.getId() : profile.getId(), status, skus); }).toList(); } + private String physicalGroupKey(CargoDiscardItem item) { + return isReturnItem(item) ? "RETURN:" + item.getReturnCartonId() : "DROPSHIP:" + item.getProfileId(); + } + private boolean isReturnItem(CargoDiscardItem item) { + return "RETURN_CARTON".equals(item.getSourceType()) || item.getReturnCartonId() != null; + } + private static String firstNonBlank(String first, String second) { + return first != null && !first.isBlank() ? first : second; + } private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentService.java index 51f106d68..3eef8cdd8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentService.java @@ -15,6 +15,9 @@ import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper; import com.baiyue.lastleg.dropship.mapper.DropshipSkuInventoryMapper; import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; import jakarta.annotation.Resource; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -37,11 +40,14 @@ public class CargoDiscardInventoryAdjustmentService { @Resource private DropshipOrderProfileMapper profiles; @Resource private InventoryPositionMapper positions; @Resource private InventoryBalanceMapper balances; + @Resource private ReturnInventoryCartonMapper returnCartons; + @Resource private ReturnInventoryCartonItemMapper returnCartonItems; @Transactional(propagation = Propagation.MANDATORY) public AdjustmentResult discardPalletItem(String customerId, CargoDiscardApplication application, CargoDiscardItem item, List links, int actualPackages, String operatorId, LocalDateTime occurredAt) { + if (isReturnItem(item)) return discardReturnItem(customerId, item, actualPackages); if (actualPackages < 0 || actualPackages > links.size()) throw conflict("实际废弃数量超出该托盘申请数量"); DropshipSkuInventory sku = inventory.lockSkuInventory(customerId, item.getSkuInventoryId()); if (sku == null) throw conflict("托盘 SKU 库存不存在: " + item.getSkuCode()); @@ -88,6 +94,36 @@ public class CargoDiscardInventoryAdjustmentService { return new AdjustmentResult(before, actualPackages, after); } + private AdjustmentResult discardReturnItem(String customerId, CargoDiscardItem item, int actualPackages) { + if (actualPackages < 0 || actualPackages > item.getDiscardPackages()) { + throw conflict("实际废弃数量超出退货库存申请数量"); + } + ReturnInventoryCartonItem sku = returnCartonItems.lockSku(item.getReturnCartonId(), item.getSkuCode()); + if (sku == null || !customerId.equals(sku.getCustomerId())) throw conflict("退货库存 SKU 不存在"); + long before = value(sku.getQuantity()); + if (before < actualPackages || (actualPackages > 0 && returnCartonItems.deduct(sku.getId(), actualPackages) != 1)) { + throw conflict("退货库存数量不足"); + } + return new AdjustmentResult(before, actualPackages, before - actualPackages); + } + + @Transactional(propagation = Propagation.MANDATORY) + public void finishReturnCarton(String customerId, long cartonId) { + var carton = returnCartons.lockId(cartonId); + if (carton == null || !customerId.equals(carton.getCustomerId())) throw conflict("退货库存箱不存在"); + if ("AVAILABLE".equals(carton.getStatus()) || "DISCARDED".equals(carton.getStatus())) return; + long remaining = returnCartonItems.totalQuantity(cartonId); + int skuTypes = returnCartonItems.skuTypes(cartonId); + String status = remaining == 0 ? "DISCARDED" : "AVAILABLE"; + if (returnCartons.finishDiscard(cartonId, customerId, status, remaining, skuTypes) != 1) { + throw conflict("退货库存箱状态已变化"); + } + } + + private static boolean isReturnItem(CargoDiscardItem item) { + return "RETURN_CARTON".equals(item.getSourceType()) || item.getReturnCartonId() != null; + } + @Transactional(propagation = Propagation.MANDATORY) public AdjustmentResult discard(String customerId, CargoDiscardApplication application, List items, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java index 969cf160a..c7d1268ef 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java @@ -22,6 +22,10 @@ import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; @@ -64,6 +68,8 @@ public class CargoDiscardService { @Resource private CargoDiscardInventoryAdjustmentService adjustments; @Resource private LastlegOrderMapper orders; @Resource private LastlegOrderStatusHistoryMapper orderHistories; + @Resource private ReturnInventoryCartonMapper returnCartons; + @Resource private ReturnInventoryCartonItemMapper returnCartonItems; @Resource private ObjectMapper objectMapper; @Value("${baiyue.lastleg.cargo-discard.fee-per-package-usd:5.00}") private BigDecimal feePerPackage = new BigDecimal("5.00"); @@ -75,9 +81,25 @@ public class CargoDiscardService { CandidateAccumulator value = grouped.computeIfAbsent(row.getOrderId(), ignored -> new CandidateAccumulator(row)); value.add(row); } - return grouped.values().stream() + List result = grouped.values().stream() .map(value -> value.response(inventory.countAvailableOrder(customerId, value.first.getOrderId()))) - .toList(); + .collect(java.util.stream.Collectors.toCollection(java.util.ArrayList::new)); + String keyword = trim(inboundCode); + if (returnCartons == null || returnCartonItems == null) return result; + for (ReturnInventoryCarton carton : returnCartons.findAvailable(customerId, null)) { + if (StringUtils.hasText(keyword) && !containsIgnoreCase(carton.getSourceReturnNo(), keyword) + && !containsIgnoreCase(carton.getCartonMark(), keyword)) continue; + List skus = returnCartonItems.listCarton(carton.getId()).stream() + .filter(item -> value(item.getQuantity()) > 0) + .map(item -> new CargoDiscardResponses.CandidateSku(item.getSkuCode(), item.getProductNameZh(), value(item.getQuantity()))) + .toList(); + long available = skus.stream().mapToLong(CargoDiscardResponses.CandidateSku::availablePackages).sum(); + if (available <= 0) continue; + result.add(new CargoDiscardResponses.Candidate(null, "RETURN_CARTON", carton.getId(), carton.getCartonMark(), + carton.getSourceReturnNo(), carton.getSourceReturnNo(), carton.getOverseasWarehouseId(), null, + null, available, skus)); + } + return result; } @Transactional @@ -94,18 +116,39 @@ public class CargoDiscardService { } Set requestedDimensions = new LinkedHashSet<>(); Map orderCache = new HashMap<>(); + Map returnCartonCache = new HashMap<>(); String warehouseId = null; String warehouseName = null; int total = 0; for (CargoDiscardRequests.Item input : request.items()) { - String dimension = input.orderId() + ":" + input.skuCode().trim(); + boolean returnSource = input.returnCartonId() != null; + if (returnSource == (input.orderId() != null)) { + throw badRequest("Each discard item must identify exactly one inventory source"); + } + String dimension = (returnSource ? "RETURN:" + input.returnCartonId() : "ORDER:" + input.orderId()) + + ":" + input.skuCode().trim(); if (!requestedDimensions.add(dimension)) throw badRequest("Duplicate order/SKU discard item: " + dimension); - LastlegOrder order = requireDropshipOrder(customerId, input.orderId()); - orderCache.put(order.getId(), order); + String itemWarehouseId; + String itemWarehouseName; + if (returnSource) { + ReturnInventoryCarton carton = requireReturnCarton(customerId, input.returnCartonId()); + ReturnInventoryCartonItem sku = returnCartonItems.lockSku(carton.getId(), input.skuCode().trim()); + if (sku == null || value(sku.getQuantity()) < input.discardPackages()) { + throw conflict("Insufficient return inventory for SKU " + input.skuCode().trim()); + } + returnCartonCache.put(carton.getId(), carton); + itemWarehouseId = carton.getOverseasWarehouseId(); + itemWarehouseName = null; + } else { + LastlegOrder order = requireDropshipOrder(customerId, input.orderId()); + orderCache.put(order.getId(), order); + itemWarehouseId = order.getOverseasWarehouseId(); + itemWarehouseName = order.getOverseasWarehouseName(); + } if (warehouseId == null) { - warehouseId = required(order.getOverseasWarehouseId(), "Order has no operating overseas warehouse"); - warehouseName = trim(order.getOverseasWarehouseName()); - } else if (!warehouseId.equals(order.getOverseasWarehouseId())) { + warehouseId = required(itemWarehouseId, "Inventory has no operating overseas warehouse"); + warehouseName = trim(itemWarehouseName); + } else if (!warehouseId.equals(itemWarehouseId)) { throw conflict("One cargo discard application cannot span operating warehouses"); } total = Math.addExact(total, input.discardPackages()); @@ -116,7 +159,9 @@ public class CargoDiscardService { application.setId(IdWorker.getId()); application.setCustomerId(customerId); application.setDiscardNo(orderNoGenerator.next("DISCARD", warehouseId, application.getId())); application.setOverseasWarehouseId(warehouseId); application.setOverseasWarehouseName(warehouseName); - application.setInboundCodes(orderCache.values().stream().map(LastlegOrder::getInboundCode) + application.setInboundCodes(java.util.stream.Stream.concat( + orderCache.values().stream().map(LastlegOrder::getInboundCode), + returnCartonCache.values().stream().map(ReturnInventoryCarton::getSourceReturnNo)) .distinct().sorted().collect(java.util.stream.Collectors.joining(","))); application.setTotalPackages(total); application.setFeePerPackage(feePerPackage); application.setTotalFee(feePerPackage.multiply(BigDecimal.valueOf(total))); @@ -157,8 +202,14 @@ public class CargoDiscardService { initialOrderHistory.setEventId("CARGO_DISCARD_CREATE:" + application.getId()); initialOrderHistory.setRemark(trim(request.remark())); initialOrderHistory.setCreatedAt(now); orderHistories.insert(initialOrderHistory); + Set reservedReturnCartons = new LinkedHashSet<>(); for (CargoDiscardRequests.Item input : request.items()) { - reserve(customerId, application, orderCache.get(input.orderId()), input, now); + if (input.returnCartonId() != null) { + reserveReturn(customerId, application, returnCartonCache.get(input.returnCartonId()), input, now, + reservedReturnCartons); + } else { + reserve(customerId, application, orderCache.get(input.orderId()), input, now); + } } history(customerId, application.getId(), null, PENDING_REVIEW, operatorId, request.remark(), now); return detail(customerId, application.getDiscardNo()); @@ -215,7 +266,7 @@ public class CargoDiscardService { .collect(java.util.stream.Collectors.groupingBy(CargoDiscardCarton::getItemId)); String historyRemark = request.remark(); if (REJECTED.equals(next)) { - release(customerId, links); + release(customerId, links, applicationItems); } LocalDateTime reviewedAt = now(); @@ -275,7 +326,7 @@ public class CargoDiscardService { throw conflict("客户只能取消待审核的废弃申请"); } List links = discardCartons.listApplication(customerId, application.getId()); - release(customerId, links); + release(customerId, links, items.listApplication(customerId, application.getId())); LocalDateTime at = now(); String from = application.getStatus(); application.setStatus(CANCELLED); application.setReviewedBy(platform ? operatorId : application.getReviewedBy()); @@ -309,6 +360,7 @@ public class CargoDiscardService { if (sku == null) throw conflict("SKU inventory disappeared while reserving cargo"); CargoDiscardItem item = new CargoDiscardItem(); item.setId(IdWorker.getId()); item.setApplicationId(application.getId()); item.setCustomerId(customerId); + item.setSourceType("DROPSHIP"); item.setOrderId(order.getId()); item.setInboundCode(order.getInboundCode()); item.setProfileId(entry.getKey()); item.setSkuInventoryId(sku.getId()); item.setSkuCode(skuCode); item.setProductName(sku.getProductName()); item.setAvailablePackagesSnapshot(Math.toIntExact(inventory.countAvailableProfile( @@ -326,14 +378,50 @@ public class CargoDiscardService { } } - private void release(String customerId, List links) { + private void release(String customerId, List links, List applicationItems) { for (CargoDiscardCarton link : links) { if (inventory.changeCartonStatus(customerId, link.getCartonId(), "DISCARD_PENDING", "AVAILABLE") != 1) { throw conflict("Reserved cargo status changed before rejection"); } } + applicationItems.stream().filter(this::isReturnItem).map(CargoDiscardItem::getReturnCartonId) + .filter(java.util.Objects::nonNull).distinct().forEach(cartonId -> { + if (returnCartons.releaseDiscard(cartonId, customerId) != 1) { + throw conflict("Reserved return inventory status changed before rejection"); + } + }); } + private void reserveReturn(String customerId, CargoDiscardApplication application, ReturnInventoryCarton carton, + CargoDiscardRequests.Item input, LocalDateTime now, Set reservedCartons) { + ReturnInventoryCartonItem sku = returnCartonItems.lockSku(carton.getId(), input.skuCode().trim()); + if (sku == null || value(sku.getQuantity()) < input.discardPackages()) { + throw conflict("Available return inventory changed; retry the discard request"); + } + if (reservedCartons.add(carton.getId()) && returnCartons.markDiscardPending(carton.getId(), customerId) != 1) { + throw conflict("Return inventory was reserved concurrently; retry the discard request"); + } + CargoDiscardItem item = new CargoDiscardItem(); + item.setId(IdWorker.getId()); item.setApplicationId(application.getId()); item.setCustomerId(customerId); + item.setSourceType("RETURN_CARTON"); item.setReturnCartonId(carton.getId()); item.setInboundCode(carton.getSourceReturnNo()); + item.setSkuCode(sku.getSkuCode()); item.setProductName(sku.getProductNameZh()); + item.setAvailablePackagesSnapshot(Math.toIntExact(value(sku.getQuantity()))); + item.setDiscardPackages(input.discardPackages()); item.setCreatedAt(now); items.insert(item); + } + + private ReturnInventoryCarton requireReturnCarton(String customerId, Long cartonId) { + ReturnInventoryCarton carton = returnCartons.lockId(cartonId); + if (carton == null || !customerId.equals(carton.getCustomerId())) throw notFound("Return inventory carton not found"); + if (!"AVAILABLE".equals(carton.getStatus())) throw conflict("Return inventory carton is not available"); + return carton; + } + + private boolean isReturnItem(CargoDiscardItem item) { + return "RETURN_CARTON".equals(item.getSourceType()) || item.getReturnCartonId() != null; + } + + private static long value(Long value) { return value == null ? 0L : value; } + private LastlegOrder requireDropshipOrder(String customerId, Long orderId) { LastlegOrder order = orders.selectById(orderId); if (order == null || !customerId.equals(order.getCustomerId())) throw notFound("Inbound order not found"); @@ -385,6 +473,9 @@ public class CargoDiscardService { private static LocalDateTime now() { return LocalDateTime.now(); } private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static boolean containsIgnoreCase(String value, String keyword) { + return value != null && keyword != null && value.toLowerCase(java.util.Locale.ROOT).contains(keyword.toLowerCase(java.util.Locale.ROOT)); + } private static String required(String value, String message) { String result = trim(value); if (result == null) throw conflict(message); return result; } private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } @@ -401,7 +492,7 @@ public class CargoDiscardService { private CargoDiscardResponses.Candidate response(long physicalPackages) { List skuViews = skus.values().stream() .map(value -> new CargoDiscardResponses.CandidateSku(value.skuCode, value.productName, value.packages)).toList(); - return new CargoDiscardResponses.Candidate(first.getOrderId(), first.getInboundCode(), first.getInboundOrderNo(), + return new CargoDiscardResponses.Candidate(first.getOrderId(), "DROPSHIP", null, null, first.getInboundCode(), first.getInboundOrderNo(), first.getOverseasWarehouseId(), first.getOverseasWarehouseName(), first.getCreatedAt(), physicalPackages, skuViews); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonItemMapper.java index 374525575..918f82f60 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonItemMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonItemMapper.java @@ -5,6 +5,7 @@ import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; import java.util.List; @@ -14,4 +15,16 @@ public interface ReturnInventoryCartonItemMapper extends BaseMapper listCarton(@Param("cartonId") long cartonId); @Select("SELECT * FROM ll_return_inventory_carton_item WHERE carton_id=#{cartonId} ORDER BY sku_code FOR UPDATE") List lockCarton(@Param("cartonId") long cartonId); + + @Select("SELECT * FROM ll_return_inventory_carton_item WHERE carton_id=#{cartonId} AND sku_code=#{skuCode} LIMIT 1 FOR UPDATE") + ReturnInventoryCartonItem lockSku(@Param("cartonId") long cartonId, @Param("skuCode") String skuCode); + + @Update("UPDATE ll_return_inventory_carton_item SET quantity=quantity-#{quantity} WHERE id=#{id} AND quantity>=#{quantity}") + int deduct(@Param("id") long id, @Param("quantity") long quantity); + + @Select("SELECT COALESCE(SUM(quantity),0) FROM ll_return_inventory_carton_item WHERE carton_id=#{cartonId}") + long totalQuantity(@Param("cartonId") long cartonId); + + @Select("SELECT COUNT(*) FROM ll_return_inventory_carton_item WHERE carton_id=#{cartonId} AND quantity>0") + int skuTypes(@Param("cartonId") long cartonId); } 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 19b529321..324935209 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 @@ -25,7 +25,9 @@ public interface ReturnInventoryCartonMapper extends BaseMapper merchantPage(@Param("customerId") String customerId, @Param("query") com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests.Query query, @Param("offset") long offset); - @Select("SELECT * FROM ll_return_inventory_carton WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} AND status='AVAILABLE' ORDER BY created_at DESC,id DESC LIMIT 500") + @Select("") List findAvailable(@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); @@ -36,6 +38,17 @@ public interface ReturnInventoryCartonMapper extends BaseMapper0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED')) + PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED','DISCARD_PENDING') AND sku_types>0 AND total_quantity>0) OR (status IN('MERGED','DISCARDED') AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED','DISCARD_PENDING','DISCARDED')) ) ENGINE=InnoDB COMMENT='退货实物库存箱及箱唛'; SET @ll_return_carton_add_pallet = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='pallet_code'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN pallet_code VARCHAR(128) NULL COMMENT ''签收时绑定的托盘码'' AFTER source_return_no')); PREPARE ll_return_carton_add_pallet_stmt FROM @ll_return_carton_add_pallet; EXECUTE ll_return_carton_add_pallet_stmt; DEALLOCATE PREPARE ll_return_carton_add_pallet_stmt; -- 已合箱的来源箱保留审计记录,但其箱内库存必须归零;动态 SQL 兼容 Spring 的数据库初始化器。 SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity', 'SELECT 1')); PREPARE return_inventory_carton_check_drop_stmt FROM @ddl; EXECUTE return_inventory_carton_check_drop_stmt; DEALLOCATE PREPARE return_inventory_carton_check_drop_stmt; -ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)); +ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED','DISCARD_PENDING') AND sku_types>0 AND total_quantity>0) OR (status IN('MERGED','DISCARDED') AND sku_types=0 AND total_quantity=0)); SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_status', 'SELECT 1')); PREPARE return_inventory_carton_status_drop_stmt FROM @ddl; EXECUTE return_inventory_carton_status_drop_stmt; DEALLOCATE PREPARE return_inventory_carton_status_drop_stmt; -ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED')); +ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED','DISCARD_PENDING','DISCARDED')); SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='source_return_no'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN source_return_no VARCHAR(2000) NULL COMMENT ''来源退货单号'' AFTER source_batch_no')); PREPARE return_inventory_carton_return_no_stmt FROM @ddl; EXECUTE return_inventory_carton_return_no_stmt; DEALLOCATE PREPARE return_inventory_carton_return_no_stmt; ALTER TABLE ll_return_inventory_carton MODIFY COLUMN source_batch_no TEXT NULL COMMENT '实际消耗来源签收批次号,多个批次逗号分隔', MODIFY COLUMN source_return_no TEXT NULL COMMENT '实际消耗来源退货单号,多个单号逗号分隔'; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardMysqlAcceptanceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardMysqlAcceptanceTest.java index 58883d2ea..264983bbe 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardMysqlAcceptanceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardMysqlAcceptanceTest.java @@ -64,8 +64,8 @@ class CargoDiscardMysqlAcceptanceTest { seedCartons(990001003L, 990001103L, 990001601L, "PALLET-C", "SKU-MAT-BLUE", "MAT", 7); var approveRequest = new CargoDiscardRequests.Create(List.of( - new CargoDiscardRequests.Item(990001001L, "SKU-BOX-RED", 3), - new CargoDiscardRequests.Item(990001001L, "SKU-RACK-KITCHEN", 2)), + new CargoDiscardRequests.Item(990001001L, null, "SKU-BOX-RED", 3), + new CargoDiscardRequests.Item(990001001L, null, "SKU-RACK-KITCHEN", 2)), "ACCEPT-DISCARD-APPROVE", "验收:破损货物,同意废弃"); var approved = service.create(CUSTOMER, "acceptance-creator", approveRequest); assertEquals(20L, skuPackages(990001001L)); @@ -75,7 +75,7 @@ class CargoDiscardMysqlAcceptanceTest { assertEquals(15L, skuPackages(990001001L)); var rejected = service.create(CUSTOMER, "acceptance-creator", new CargoDiscardRequests.Create( - List.of(new CargoDiscardRequests.Item(990001002L, "SKU-LAMP-WHITE", 4)), + List.of(new CargoDiscardRequests.Item(990001002L, null, "SKU-LAMP-WHITE", 4)), "ACCEPT-DISCARD-REJECT", "验收:外箱可修复")); assertEquals(10L, skuPackages(990001002L)); rejected = service.review(CUSTOMER, rejected.summary().discardNo(), "acceptance-reviewer", @@ -84,14 +84,14 @@ class CargoDiscardMysqlAcceptanceTest { assertEquals(10L, skuPackages(990001002L)); var pending = service.create(CUSTOMER, "acceptance-creator", new CargoDiscardRequests.Create( - List.of(new CargoDiscardRequests.Item(990001003L, "SKU-MAT-BLUE", 2)), + List.of(new CargoDiscardRequests.Item(990001003L, null, "SKU-MAT-BLUE", 2)), "ACCEPT-DISCARD-PENDING", "验收:等待仓库审核")); assertEquals(CargoDiscardService.PENDING_REVIEW, pending.summary().status()); assertEquals(7L, skuPackages(990001003L)); assertThrows(ResponseStatusException.class, () -> service.create(CUSTOMER, "acceptance-creator", new CargoDiscardRequests.Create(List.of( - new CargoDiscardRequests.Item(990001003L, "SKU-MAT-BLUE", 99)), + new CargoDiscardRequests.Item(990001003L, null, "SKU-MAT-BLUE", 99)), "ACCEPT-DISCARD-INSUFFICIENT", "验收:库存不足应失败"))); assertEquals(1, count("SELECT COUNT(*) FROM ll_cargo_discard_application WHERE customer_id=? AND status='APPROVED'")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java index fbe05b4c4..d89cdf7c7 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java @@ -17,6 +17,10 @@ import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; import com.baiyue.lastleg.service.LastlegOrderNoGenerator; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -51,6 +55,8 @@ class CargoDiscardServiceTest { @Mock CargoDiscardInventoryAdjustmentService adjustments; @Mock LastlegOrderMapper orders; @Mock LastlegOrderStatusHistoryMapper orderHistories; + @Mock ReturnInventoryCartonMapper returnCartons; + @Mock ReturnInventoryCartonItemMapper returnCartonItems; private CargoDiscardService service; private final AtomicReference storedApplication = new AtomicReference<>(); @@ -70,6 +76,8 @@ class CargoDiscardServiceTest { ReflectionTestUtils.setField(service, "orderNoGenerator", new LastlegOrderNoGenerator()); ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "orderHistories", orderHistories); + ReflectionTestUtils.setField(service, "returnCartons", returnCartons); + ReflectionTestUtils.setField(service, "returnCartonItems", returnCartonItems); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); ReflectionTestUtils.setField(service, "feePerPackage", new BigDecimal("5.00")); lenient().when(applications.insert(any(CargoDiscardApplication.class))).thenAnswer(invocation -> { storedApplication.set(invocation.getArgument(0)); return 1; }); @@ -94,7 +102,7 @@ class CargoDiscardServiceTest { when(inventory.changeCartonStatus(eq("100"), anyLong(), eq("AVAILABLE"), eq("DISCARD_PENDING"))).thenReturn(1); var detail = service.create("100", "7", new CargoDiscardRequests.Create( - List.of(new CargoDiscardRequests.Item(101L, "SKU-1", 2)), "discard-create-1", "damaged")); + List.of(new CargoDiscardRequests.Item(101L, null, "SKU-1", 2)), "discard-create-1", "damaged")); assertEquals(CargoDiscardService.PENDING_REVIEW, detail.summary().status()); assertEquals(2, detail.summary().totalPackages()); @@ -117,6 +125,34 @@ class CargoDiscardServiceTest { assertEquals(2, result.get(0).skus().size()); } + @Test + void candidatesIncludeAvailableReturnCartonWithSourceIdentity() { + ReturnInventoryCarton carton = new ReturnInventoryCarton(); + carton.setId(901L); + carton.setCustomerId("100"); + carton.setOverseasWarehouseId("WH-1"); + carton.setSourceReturnNo("RT-20260928-001"); + carton.setCartonMark("RC-LAX-001"); + carton.setStatus("AVAILABLE"); + carton.setTotalQuantity(4L); + ReturnInventoryCartonItem sku = new ReturnInventoryCartonItem(); + sku.setCartonId(901L); + sku.setSkuCode("SKU-RETURN"); + sku.setProductNameZh("退货商品"); + sku.setQuantity(4L); + when(returnCartons.findAvailable("100", null)).thenReturn(List.of(carton)); + when(returnCartonItems.listCarton(901L)).thenReturn(List.of(sku)); + + var result = service.candidates("100", "RT-20260928-001"); + + assertEquals(1, result.size()); + assertEquals("RETURN_CARTON", result.get(0).sourceType()); + assertEquals(901L, result.get(0).returnCartonId()); + assertEquals("RC-LAX-001", result.get(0).cartonMark()); + assertEquals(4, result.get(0).availablePackages()); + assertEquals("SKU-RETURN", result.get(0).skus().get(0).skuCode()); + } + @Test void approvalPushesToPdaWithoutDeductingInventory() { CargoDiscardApplication application = pendingApplication();