diff --git a/docs/overseas-inventory-mutation-flow.png b/docs/overseas-inventory-mutation-flow.png
new file mode 100644
index 000000000..e6447284e
Binary files /dev/null and b/docs/overseas-inventory-mutation-flow.png differ
diff --git a/docs/overseas-inventory-mutation-flow.svg b/docs/overseas-inventory-mutation-flow.svg
new file mode 100644
index 000000000..fc3c9ae65
--- /dev/null
+++ b/docs/overseas-inventory-mutation-flow.svg
@@ -0,0 +1,90 @@
+
diff --git a/docs/overseas-inventory-mutation-test-map.md b/docs/overseas-inventory-mutation-test-map.md
new file mode 100644
index 000000000..8e9e7fff7
--- /dev/null
+++ b/docs/overseas-inventory-mutation-test-map.md
@@ -0,0 +1,40 @@
+# 海外业务库存变动测试整理
+
+范围依据:`lastleg-service/AGENTS.md`、`docs/backend-business-index.md` 及当前海外仓库存、入仓、出库、换标、转运、废弃、异常退回和 PDA 作业入口。
+
+## 先统一库存口径
+
+- **可用库存**:按客户、海外仓、目的仓、入仓单汇总箱数;SKU、箱唛和托盘是明细快照/作业维度。
+- **冻结库存**:拣货、换标、废弃、转运等处理中暂时不可重复使用;冻结不等于已经扣减。
+- **库存流水**:每一次入账、冻结、释放、扣减、恢复、SKU 转换和位置变化都应能按业务单号、幂等键追溯。
+- **托盘不是库存归属**:合托后一个物理托盘可以包含多个入仓单,出库、换标和退回必须按托盘组成明细核平。
+
+## 测试场景矩阵
+
+| 场景 | 触发与前置 | 库存变动 | 测试重点 |
+|---|---|---|---|
+| 头程接柜/海外入仓 | 装柜完成事件 → 生成尾程单/入仓计划;PDA 签收、实收、上架、完成 | 在途冻结 → 入仓后 `AVAILABLE` 增加;写库存位置和流水 | 柜号/客户/入仓单隔离;卸柜照片门禁;重复事件不重复入账;失败整事务回滚 |
+| 普通排车出库/派送 | 计划已入仓;排车审核、拣货、箱数核平、发车 | `AVAILABLE` → 冻结/已拣;原子出库后可用库存扣减 | 整托/部分托按组成明细核平;未审核、数量不平、车辆门禁时不得出库;同键重放幂等 |
+| 一件代发 | 已完成入库且有可用箱唛/SKU;创建 `WAIT_PICK` 出库单 | SKU/箱唛库存冻结 → PDA 拣货 → `OUTBOUNDED` 扣减 | 候选只能来自真实可用库存;一单不得跨客户/跨仓;整托扫描任一明细失败必须整体回滚 |
+| 客户换标 | 历史入仓库存可用;客户申请、PDA 扫旧 SKU/箱唛、完工、客户确认 | 旧 SKU/箱唛冻结;换标完成后旧库存转出、新 SKU/箱唛转入;库存总箱数守恒 | 同箱多 SKU 不重复冻结;只移动本单 `hold_packages`;已生成出库单禁止再次拆托;照片/复核/客户确认门禁 |
+| 仓储转运 | 来源库存可用;创建转运、测量、换标(可选)、出库、目标仓收货 | 来源仓冻结/扣减 → 目标仓入账;换标时 SKU 转换 | 来源客户、入仓单和箱数核对;部分转运数量守恒;目标仓与来源仓不能串;重复完成不重复入账 |
+| 货物废弃 | 一件代发/库存货物申请废弃;审批后由绑定海外 PDA 实际确认 | `AVAILABLE` → `DISCARD_PENDING`;实际确认后 SKU、余额、位置扣减并记 `DISCARDED` | 申请阶段只锁箱不扣库存;驳回释放;批准不是实际扣减;实际数量与逐箱流水一致;失败整批回滚 |
+| 异常退回入仓 | 只能选择原出库拣货事实;异常单受理并完成退回 | 原出库来源核验 → `RETURN_STOCK_IN` 恢复到真实目标托盘/库位 | 不能伪造出库来源、借用其他客户主单;退回数量与原实发核平;异常单完成与派送 `RETURNED` 同事务 |
+| PDA 盘点调整 | 绑定海外仓;盘点锁定库存位置并提交实盘数量 | 按差异产生增加/减少调整流水;无差异不产生虚假变动 | 需要 `inventory:adjust`;并发版本校验;差异正负方向正确;盘点重复提交幂等 |
+| PDA 移库/托盘拆合/换绑 | 同仓有效托盘/库位;资源锁成功 | 数量净额不变,仅位置/托盘组成变化,写位置流水 | 不跨仓;托盘组成明细保留;资源锁释放;出库/换标占用时禁止绕过协调单独移动 |
+
+## 不应误判为库存变动
+
+- 目的仓修改、暂扣/解除、报价、排车预排、外接预约、车辆/司机资料:只改变状态、资格、资源占用或费用快照。
+- 海外采购样品履约:按当前契约走 Admin 台账,**不接入 PDA,也不写 WMS 可用库存**。
+- 退货申请/废弃申请/转派送建单:申请或建单阶段只形成冻结/候选/出库单,必须到实际收货、实际扣减或实际出库节点才确认数量变化。
+
+## 每条库存用例的统一断言
+
+1. 变动前后按客户、海外仓、目的仓、入仓单、托盘组成核对数量守恒。
+2. 查询余额、位置明细和库存流水三处结果一致,且业务单号/幂等键可追溯。
+3. 重复请求、重复事件、并发操作不得新增第二笔库存变动。
+4. 任一校验、权限、资源锁或跨服务依赖失败时,库存、单据状态和流水一起回滚。
+5. 客户越权、跨仓、跨客户、伪造来源入仓单/出库单均应拒绝。
+
+流程图:`overseas-inventory-mutation-flow.svg` / `overseas-inventory-mutation-flow.png`
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java
index 67cad5b89..758b47ccd 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java
@@ -13,6 +13,15 @@ import java.util.List;
@Mapper
public interface CargoDiscardInventoryMapper {
+ // 箱唛状态可能保留入库快照;废弃必须同时存在当前客户、同托盘的真实可用库存。
+ String AVAILABLE_STOCK = " AND EXISTS (SELECT 1 FROM ll_dropship_order_profile dp " +
+ "JOIN ll_inventory_position pos ON pos.id=dp.inventory_position_id AND pos.customer_id=dp.customer_id " +
+ "AND pos.pallet_id=dp.warehouse_pallet_id " +
+ "JOIN ll_inventory_balance bal ON bal.id=pos.balance_id AND bal.customer_id=pos.customer_id " +
+ "WHERE dp.customer_id=c.customer_id AND dp.order_id=c.order_id AND dp.id=c.profile_id " +
+ "AND dp.status='ACTIVE' AND pos.status='OCCUPIED' AND pos.available_packages>0 " +
+ "AND bal.available_packages>0) ";
+
@Select("SELECT p.* FROM ll_inventory_position p WHERE p.customer_id=#{customerId} AND p.id=#{positionId} FOR UPDATE")
com.baiyue.lastleg.inventory.entity.InventoryPosition lockGenericPosition(@Param("customerId") String customerId, @Param("positionId") Long positionId);
@@ -50,10 +59,12 @@ public interface CargoDiscardInventoryMapper {
int changeGenericCartonStatus(@Param("customerId") String customerId, @Param("cartonId") Long cartonId,
@Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus);
- @Select("SELECT c.* FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} AND c.status='AVAILABLE' ORDER BY c.profile_id,c.id")
+ /** 仅列出仍有实际可用库存的箱子,排除已出库托盘的残留箱唛。 */
+ @Select("SELECT c.* FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} AND c.status='AVAILABLE' " + AVAILABLE_STOCK + " ORDER BY c.profile_id,c.id")
List availableOrderCartons(@Param("customerId") String customerId, @Param("orderId") Long orderId);
- @Select("")
List lockSelected(@Param("customerId") String customerId, @Param("orderId") Long orderId,
@@ -72,6 +83,7 @@ public interface CargoDiscardInventoryMapper {
@Select("SELECT DISTINCT carton_mark FROM ll_inventory_ledger WHERE customer_id=#{customerId} AND position_id=#{positionId} " +
"AND business_type IN ('STOCK_IN','RETURN_STOCK_IN') AND carton_mark IS NOT NULL AND carton_mark<>'' ORDER BY carton_mark")
List looseCartonMarks(@Param("customerId") String customerId, @Param("positionId") Long positionId);
+ /** 按当前可用仓位筛选入库单,不能只依赖箱唛的入库状态。 */
@Select("")
List candidates(@Param("customerId") String customerId,
@Param("inboundCode") String inboundCode);
- @Select("SELECT COUNT(*) FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " +
+ /** SKU 可选数量仅统计实际仍在可用仓位的箱子。 */
+ @Select("SELECT COUNT(*) FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " + AVAILABLE_STOCK +
"AND c.status='AVAILABLE' AND EXISTS (SELECT 1 FROM ll_dropship_carton_sku cs " +
"WHERE cs.customer_id=c.customer_id AND cs.carton_id=c.id AND cs.sku_code=#{skuCode})")
long countAvailable(@Param("customerId") String customerId, @Param("orderId") Long orderId,
@Param("skuCode") String skuCode);
- @Select("SELECT COUNT(*) FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " +
+ /** 托盘 SKU 数量与候选及提交使用相同库存门禁。 */
+ @Select("SELECT COUNT(*) FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " + AVAILABLE_STOCK +
"AND c.profile_id=#{profileId} AND c.status='AVAILABLE' AND EXISTS (SELECT 1 FROM ll_dropship_carton_sku cs " +
"WHERE cs.customer_id=c.customer_id AND cs.carton_id=c.id AND cs.sku_code=#{skuCode})")
long countAvailableProfile(@Param("customerId") String customerId, @Param("orderId") Long orderId,
@Param("profileId") Long profileId, @Param("skuCode") String skuCode);
- @Select("SELECT c.* FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " +
+ /** 全部废弃锁箱时排除已出库或无实际可用库存的托盘。 */
+ @Select("SELECT c.* FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " + AVAILABLE_STOCK +
"AND c.status='AVAILABLE' AND EXISTS (SELECT 1 FROM ll_dropship_carton_sku cs " +
"WHERE cs.customer_id=c.customer_id AND cs.carton_id=c.id AND cs.sku_code=#{skuCode}) " +
"ORDER BY c.profile_id,c.carton_sequence,c.id LIMIT #{limit} FOR UPDATE")
List lockAvailable(@Param("customerId") String customerId, @Param("orderId") Long orderId,
@Param("skuCode") String skuCode, @Param("limit") int limit);
- @Select("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} AND status='AVAILABLE'")
+ /** 入库单按实际可用托盘统计物理箱数,不重复累计混装 SKU。 */
+ @Select("SELECT COUNT(*) FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} AND c.status='AVAILABLE' " + AVAILABLE_STOCK)
long countAvailableOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId);
@Update("UPDATE ll_dropship_carton SET status=#{toStatus},version=version+1,updated_at=NOW(3) " +
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 3ef755bed..6248b1cda 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,16 @@ import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface RelabelItemMapper extends BaseMapper {
+ /** 批量查询退货箱已完成的换标事实,避免逐箱查询。 */
+ @org.apache.ibatis.annotations.Select("")
+ java.util.List completedReturnCartonIds(@org.apache.ibatis.annotations.Param("customerId") String customerId,
+ @org.apache.ibatis.annotations.Param("cartonIds") java.util.List cartonIds);
+
@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 " +
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/InboundSnapshotMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/InboundSnapshotMapper.java
index 01a2cfa45..8e40695fe 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/InboundSnapshotMapper.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/InboundSnapshotMapper.java
@@ -5,11 +5,22 @@ import org.apache.ibatis.annotations.Select;
@Mapper public interface InboundSnapshotMapper {
record SourceCarton(String cartonMark, String skuCode, String productNameZh, Long quantity,
String destinationWarehouseCode, String destinationWarehouseName, String skuItemsJson) {}
+ /** 查询来源单当前仍可转运的箱级明细,排除已经存在有效换标记录的原箱。 */
@Select("""
SELECT c.carton_mark cartonMark,c.sku_code skuCode,NULL productNameZh,NULL quantity,
c.destination_warehouse_code destinationWarehouseCode,c.destination_warehouse_name destinationWarehouseName,NULL skuItemsJson
FROM ll_inbound_plan_carton c JOIN ll_order o ON o.id=c.order_id AND o.customer_id=c.customer_id
WHERE c.customer_id=#{customerId} AND o.inbound_code=#{inboundCode} AND o.overseas_warehouse_id=#{warehouseId}
+ AND NOT EXISTS (
+ SELECT 1 FROM ll_relabel_item ri
+ JOIN ll_relabel_application ra ON ra.id=ri.application_id AND ra.customer_id=ri.customer_id
+ WHERE ri.customer_id=c.customer_id AND ri.source_inbound_code=o.inbound_code
+ AND ra.overseas_warehouse_id=#{warehouseId}
+ AND UPPER(TRIM(COALESCE(NULLIF(ri.source_carton_mark,''),ri.carton_mark)))=UPPER(TRIM(c.carton_mark))
+ AND ra.status NOT IN ('CANCELLED','REJECTED')
+ )
+
+
UNION ALL
SELECT c.carton_mark,i.sku_code,i.product_name_zh,c.counted_quantity,
t.destination_warehouse_code,t.destination_warehouse_name,c.sku_items_json
@@ -18,6 +29,17 @@ import org.apache.ibatis.annotations.Select;
JOIN ll_order o ON o.id=t.lastleg_order_id AND o.customer_id=t.customer_id
JOIN ll_warehouse_transfer_item i ON i.id=c.transfer_item_id AND i.customer_id=c.customer_id AND i.transfer_order_id=t.id
WHERE c.customer_id=#{customerId} AND o.inbound_code=#{inboundCode} AND t.operating_warehouse_id=#{warehouseId} AND c.status='COUNTED'
+ AND NOT EXISTS (
+ SELECT 1 FROM ll_relabel_item ri
+ JOIN ll_relabel_application ra ON ra.id=ri.application_id AND ra.customer_id=ri.customer_id
+ WHERE ri.customer_id=c.customer_id AND ri.source_inbound_code=o.inbound_code
+ AND ra.overseas_warehouse_id=#{warehouseId}
+ AND (UPPER(TRIM(COALESCE(NULLIF(ri.source_carton_mark,''),ri.carton_mark)))=UPPER(TRIM(c.carton_mark))
+ OR UPPER(TRIM(COALESCE(NULLIF(ri.source_carton_mark,''),ri.carton_mark)))=UPPER(TRIM(NULLIF(c.customer_carton_mark,''))))
+ AND ra.status NOT IN ('CANCELLED','REJECTED')
+ )
+
+
ORDER BY cartonMark
""")
java.util.List sourceCartons(@Param("customerId") String customerId,
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/TransferSourceReservationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/TransferSourceReservationMapper.java
new file mode 100644
index 000000000..e2c1b5cb4
--- /dev/null
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/TransferSourceReservationMapper.java
@@ -0,0 +1,24 @@
+package com.baiyue.lastleg.transfer.mapper;
+
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import java.util.List;
+
+/** 使用提交单据的原箱快照表达业务占用,不提前扣减实物库存。 */
+@Mapper
+public interface TransferSourceReservationMapper {
+ /** 按同一入仓快照串行提交,防止不同草稿同时抢占原箱。 */
+ @Select("SELECT id FROM ll_inbound_order WHERE customer_id=#{customerId} AND id=#{inboundOrderId} FOR UPDATE")
+ Long lockInbound(@Param("customerId") String customerId, @Param("inboundOrderId") Long inboundOrderId);
+
+ /** 读取已提交转运的原箱;驳回、取消和草稿不占用,可重新选择。 */
+ @Select("SELECT COALESCE(NULLIF(c.customer_carton_mark,''), c.carton_mark) FROM ll_warehouse_transfer_carton c "
+ + "JOIN ll_warehouse_transfer_order t ON t.id=c.transfer_order_id AND t.customer_id=c.customer_id "
+ + "WHERE t.customer_id=#{customerId} AND t.operating_warehouse_id=#{warehouseId} "
+ + "AND t.inbound_code=#{sourceCode} AND t.transfer_no <> t.inbound_code "
+ + "AND t.status NOT IN ('DRAFT','REJECTED','CANCELLED') "
+ + "AND COALESCE(NULLIF(c.customer_carton_mark,''), c.carton_mark) IS NOT NULL")
+ List occupiedMarks(@Param("customerId") String customerId,
+ @Param("warehouseId") String warehouseId, @Param("sourceCode") String sourceCode);
+}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferInventorySourceRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferInventorySourceRepository.java
new file mode 100644
index 000000000..85fc1c81b
--- /dev/null
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferInventorySourceRepository.java
@@ -0,0 +1,28 @@
+package com.baiyue.lastleg.transfer.repository;
+
+import com.baiyue.lastleg.transfer.mapper.InboundSnapshotMapper;
+import jakarta.annotation.Resource;
+import org.springframework.stereotype.Repository;
+import java.util.List;
+
+/** 封装库存转运来源箱查询,SQL和客户隔离由Mapper统一执行。 */
+@Repository
+public class TransferInventorySourceRepository {
+ @Resource private InboundSnapshotMapper snapshots;
+ @Resource private com.baiyue.lastleg.transfer.mapper.TransferSourceReservationMapper reservations;
+
+ /** 同一来源入仓单的所有库存转运先取相同的行锁。 */
+ public Long lockSource(String customerId, Long inboundOrderId) {
+ return reservations.lockInbound(customerId, inboundOrderId);
+ }
+
+ /** 读取当前客户及操作仓中已提交转运的原箱快照。 */
+ public List occupiedMarks(String customerId, String warehouseId, String sourceCode) {
+ return reservations.occupiedMarks(customerId, warehouseId, sourceCode);
+ }
+
+ /** 读取当前来源单未被有效换标申请使用的箱级快照。 */
+ public List sourceCartons(String customerId, String warehouseId, String inboundCode) {
+ return snapshots.sourceCartons(customerId, warehouseId, inboundCode);
+ }
+}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferReturnSourceRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferReturnSourceRepository.java
index 7a7be18df..11d28d7d1 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferReturnSourceRepository.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferReturnSourceRepository.java
@@ -6,6 +6,7 @@ import com.baiyue.lastleg.returnreceipt.entity.ReturnOrder;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper;
+import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
@@ -17,6 +18,7 @@ public class TransferReturnSourceRepository {
@Resource private ReturnOrderMapper orders;
@Resource private ReturnInventoryCartonMapper cartons;
@Resource private ReturnInventoryCartonItemMapper items;
+ @Resource private RelabelItemMapper relabelItems;
/** 按当前客户和退货单号查询来源单据。 */
public ReturnOrder findOrder(String customerId, String returnNo) {
@@ -25,10 +27,15 @@ public class TransferReturnSourceRepository {
/** 查询当前仓内已完成清点且可用的退货箱,不影响其他页面的历史库存查询。 */
public List availableCartons(String customerId, String warehouseId, String returnNo) {
- return cartons.currentReturnCartons(customerId, warehouseId, returnNo).stream()
+ var available = cartons.currentReturnCartons(customerId, warehouseId, returnNo).stream()
.filter(carton -> "AVAILABLE".equals(carton.getStatus()) && "CONFIRMED".equals(carton.getCountStatus())
&& carton.getRelabelApplicationId() == null)
.toList();
+ if (available.isEmpty()) return available;
+ // 已完成换标的箱虽然释放了当前占用字段,但不能再次作为原箱来源转运。
+ var completed = new java.util.HashSet<>(relabelItems.completedReturnCartonIds(customerId,
+ available.stream().map(ReturnInventoryCarton::getId).toList()));
+ return available.stream().filter(carton -> !completed.contains(carton.getId())).toList();
}
/** 批量读取已通过客户及仓库隔离校验的来源箱明细。 */
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java
index 534792f67..d53b25d82 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java
@@ -149,6 +149,7 @@ public class WarehouseTransferService {
@Resource private InventoryCommandMapper inventoryCommands;
@Resource private ObjectMapper objectMapper;
@Resource private TransferReturnSourceRepository returnSources;
+ @Resource private com.baiyue.lastleg.transfer.repository.TransferInventorySourceRepository inventorySources;
/** 查询来源单据当前可用的箱唛及箱内 SKU,兼容普通入仓与退货库存。 */
public List sourceCartons(String customerId, String warehouseId, String inboundCode) {
@@ -164,22 +165,36 @@ public class WarehouseTransferService {
List cartonIds = cartons.stream().map(ReturnInventoryCarton::getId).filter(Objects::nonNull).toList();
Map> items = cartonIds.isEmpty() ? Map.of()
: returnSources.cartonItems(cartonIds).stream().collect(Collectors.groupingBy(ReturnInventoryCartonItem::getCartonId));
- return cartons.stream().map(carton -> {
+ return unreservedSourceCartons(customerId, warehouseId.trim(), source, cartons.stream().map(carton -> {
List rows = items.getOrDefault(carton.getId(), List.of());
List contents = rows.stream()
.filter(item -> item.getQuantity() != null && item.getQuantity() > 0)
.map(item -> new WarehouseTransferResponses.SourceCartonSku(item.getSkuCode(), item.getQuantity())).toList();
return new WarehouseTransferResponses.SourceCarton(carton.getCartonMark(), null, carton.getTotalQuantity(),
carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), contents);
- }).toList();
+ }).toList());
}
- return inboundSnapshots.sourceCartons(customerId, warehouseId.trim(), source).stream().map(row -> {
+ var sourceRows = inventorySources != null
+ ? inventorySources.sourceCartons(customerId, warehouseId.trim(), source)
+ : inboundSnapshots.sourceCartons(customerId, warehouseId.trim(), source);
+ return unreservedSourceCartons(customerId, warehouseId.trim(), source, sourceRows.stream().map(row -> {
var carton = new WarehouseTransferCarton(); carton.setSkuItemsJson(row.skuItemsJson());
var contents = cartonContents(carton).stream().map(item -> new WarehouseTransferResponses.SourceCartonSku(item.skuCode(),item.quantity())).toList();
if (contents.isEmpty()) contents = List.of(new WarehouseTransferResponses.SourceCartonSku(row.skuCode(),row.quantity()));
return new WarehouseTransferResponses.SourceCarton(row.cartonMark(),row.productNameZh(),row.quantity(),
row.destinationWarehouseCode(),row.destinationWarehouseName(),contents);
- }).toList();
+ }).toList());
+ }
+
+ /** 待审核即按原箱快照占用;所有来源查询复用此过滤,避免重复提交同一实物箱。 */
+ private List unreservedSourceCartons(String customerId,
+ String warehouseId, String source, List cartons) {
+ if (inventorySources == null) return cartons;
+ Set occupied = inventorySources.occupiedMarks(customerId, warehouseId, source).stream()
+ .filter(StringUtils::hasText).map(mark -> mark.trim().toUpperCase(java.util.Locale.ROOT))
+ .collect(Collectors.toSet());
+ return cartons.stream().filter(box -> !occupied.contains(box.cartonMark().trim()
+ .toUpperCase(java.util.Locale.ROOT))).toList();
}
private static LocalDateTime arrivalTime(WarehouseTransferRequests.Create request) {
return StringUtils.hasText(request.inboundCode()) ? null : request.estimatedArrivalAt();
@@ -334,7 +349,7 @@ public class WarehouseTransferService {
}
/** Platform-created transfers keep the existing create-and-submit behavior. */
- @Transactional
+ @Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
public WarehouseTransferResponses.Detail createAndSubmit(String customerId, String operatorId,
WarehouseTransferRequests.Create request) {
WarehouseTransferResponses.Detail draft = create(customerId, operatorId, request);
@@ -456,7 +471,7 @@ public class WarehouseTransferService {
return detail(customerId, transferNo);
}
- @Transactional
+ @Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
/** 提交草稿前重新校验来源箱归属,防止草稿保存后库存已发生变化。 */
public WarehouseTransferResponses.Detail submit(String customerId, String transferNo, String operatorId) {
WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo);
@@ -464,6 +479,13 @@ public class WarehouseTransferService {
if (PENDING_REVIEW.equals(transfer.getStatus())) return detail(customerId, transferNo);
if (!DRAFT.equals(transfer.getStatus())) throw conflict("Only a draft warehouse transfer order can be submitted");
+ if (!transfer.getTransferNo().equals(transfer.getInboundCode())) {
+ // 所有同源提交锁定同一入仓记录;取锁后按最新可用箱校验并保存逐箱占用快照。
+ if (inventorySources != null && inventorySources.lockSource(customerId, transfer.getInboundOrderId()) == null)
+ throw conflict("库存来源已失效,请重新选择来源单");
+ if (inventorySources != null) reserveSourceCartons(customerId, transfer);
+ }
+
if (Boolean.TRUE.equals(transfer.getNeedRelabel())) {
Set allowedMarks = !transfer.getTransferNo().equals(transfer.getInboundCode())
? sourceCartons(customerId, transfer.getOperatingWarehouseId(), transfer.getInboundCode())
@@ -500,6 +522,29 @@ public class WarehouseTransferService {
return detail(customerId, transferNo);
}
+ /** 提交时逐箱核对来源快照并记录占用,禁止并发提交时静默替换用户选择。 */
+ private void reserveSourceCartons(String customerId, WarehouseTransferOrder transfer) {
+ var available = new java.util.LinkedHashMap();
+ for (var box : sourceCartons(customerId, transfer.getOperatingWarehouseId(), transfer.getInboundCode())) {
+ available.put(box.cartonMark().trim().toUpperCase(java.util.Locale.ROOT), box.cartonMark());
+ }
+ var selected = transferCartons.listOrder(customerId, transfer.getId());
+ if (selected.size() != transfer.getPackageCount() || selected.isEmpty())
+ throw conflict("转运箱数与来源箱明细不一致,请刷新后重新提交");
+ var reserved = new java.util.LinkedHashMap();
+ for (var carton : selected) {
+ String mark = StringUtils.hasText(carton.getCustomerCartonMark())
+ ? carton.getCustomerCartonMark() : carton.getCartonMark();
+ if (!StringUtils.hasText(mark)) throw conflict("转运箱缺少来源箱号,请刷新来源单");
+ String canonical = available.remove(mark.trim().toUpperCase(java.util.Locale.ROOT));
+ if (canonical == null) throw conflict("来源箱已被提交或不可用,请刷新来源单:" + mark);
+ reserved.put(carton.getId(), canonical);
+ }
+ for (var carton : selected) {
+ carton.setCustomerCartonMark(reserved.get(carton.getId()));
+ transferCartons.updateById(carton);
+ }
+ }
@Transactional(readOnly = true)
public PageResult page(String customerId, WarehouseTransferRequests.Query query) {
return page(customerId, query, true);
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardAvailableStockMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardAvailableStockMapperTest.java
new file mode 100644
index 000000000..bd325c5ad
--- /dev/null
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardAvailableStockMapperTest.java
@@ -0,0 +1,70 @@
+package com.baiyue.lastleg.discard;
+
+import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper;
+import org.apache.ibatis.mapping.Environment;
+import org.apache.ibatis.session.Configuration;
+import org.apache.ibatis.session.SqlSessionFactoryBuilder;
+import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
+import org.junit.jupiter.api.Test;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.DriverManagerDataSource;
+
+import java.util.List;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/** 使用真实 Mapper SQL 验证已出库但箱唛状态残留时的废弃门禁。 */
+class CargoDiscardAvailableStockMapperTest {
+ /** 全部出库不可选;部分托盘仍有库存时只返回该托盘,提交旧箱号同样失败。 */
+ @Test
+ void filtersShippedStockFromCandidatesCountsAndSelection() {
+ var source = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
+ var db = new JdbcTemplate(source);
+ db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(20),inbound_code VARCHAR(50),inbound_order_no VARCHAR(50),overseas_warehouse_id VARCHAR(20),overseas_warehouse_name VARCHAR(50),created_at TIMESTAMP,order_type VARCHAR(30))");
+ db.execute("CREATE TABLE ll_dropship_order_profile(id BIGINT,customer_id VARCHAR(20),order_id BIGINT,inventory_position_id BIGINT,warehouse_pallet_id BIGINT,status VARCHAR(20))");
+ db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(20),balance_id BIGINT,pallet_id BIGINT,status VARCHAR(20),available_packages BIGINT)");
+ db.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(20),available_packages BIGINT)");
+ db.execute("CREATE TABLE ll_dropship_carton(id BIGINT,customer_id VARCHAR(20),order_id BIGINT,profile_id BIGINT,status VARCHAR(20),carton_sequence INT)");
+ db.execute("CREATE TABLE ll_dropship_carton_sku(customer_id VARCHAR(20),carton_id BIGINT,sku_code VARCHAR(20))");
+ db.execute("CREATE TABLE ll_dropship_sku_inventory(id BIGINT,customer_id VARCHAR(20),order_id BIGINT,profile_id BIGINT,sku_code VARCHAR(20),product_name VARCHAR(50))");
+ db.update("INSERT INTO ll_order VALUES(1,'C','EXXHWK2809191130','IP-WH-20260917-979650','WH','测试仓',CURRENT_TIMESTAMP,'ONE_CLICK_DROPSHIP')");
+ db.update("INSERT INTO ll_dropship_order_profile VALUES(10,'C',1,100,1000,'ACTIVE'),(20,'C',1,200,2000,'ACTIVE')");
+ db.update("INSERT INTO ll_inventory_position VALUES(100,'C',10000,1000,'OCCUPIED',0),(200,'C',20000,2000,'OCCUPIED',0)");
+ db.update("INSERT INTO ll_inventory_balance VALUES(10000,'C',0),(20000,'C',0)");
+ // 复现库存已扣完,箱唛仍为 AVAILABLE 的状态,不能因此暴露可废弃库存。
+ db.update("INSERT INTO ll_dropship_carton VALUES(11,'C',1,10,'AVAILABLE',1),(21,'C',1,20,'AVAILABLE',1)");
+ db.update("INSERT INTO ll_dropship_carton_sku VALUES('C',11,'SKU-A'),('C',21,'SKU-A'),('C',21,'SKU-B')");
+ db.update("INSERT INTO ll_dropship_sku_inventory VALUES(1,'C',1,10,'SKU-A','货物A'),(2,'C',1,20,'SKU-A','货物A'),(3,'C',1,20,'SKU-B','货物B')");
+ var configuration = new Configuration(new Environment("test", new JdbcTransactionFactory(), source));
+ configuration.setMapUnderscoreToCamelCase(true);
+ configuration.addMapper(CargoDiscardInventoryMapper.class);
+ try (var session = new SqlSessionFactoryBuilder().build(configuration).openSession(true)) {
+ var mapper = session.getMapper(CargoDiscardInventoryMapper.class);
+ assertTrue(mapper.candidates("C", null).isEmpty());
+ assertEquals(0, mapper.countAvailableOrder("C", 1L));
+ assertTrue(mapper.availableOrderCartons("C", 1L).isEmpty());
+ assertTrue(mapper.lockSelected("C", 1L, "SKU-A", List.of(11L)).isEmpty());
+ assertTrue(mapper.lockAvailable("C", 1L, "SKU-A", 20).isEmpty());
+
+ db.update("UPDATE ll_inventory_position SET available_packages=1 WHERE id=200");
+ db.update("UPDATE ll_inventory_balance SET available_packages=1 WHERE id=20000");
+ session.clearCache();
+ assertEquals(2, mapper.candidates("C", "EXXHWK").size());
+ assertEquals(1, mapper.countAvailableOrder("C", 1L));
+ assertEquals(1, mapper.countAvailable("C", 1L, "SKU-A"));
+ assertEquals(0, mapper.countAvailableProfile("C", 1L, 10L, "SKU-A"));
+ assertEquals(1, mapper.countAvailableProfile("C", 1L, 20L, "SKU-B"));
+ assertEquals(List.of(21L), mapper.availableOrderCartons("C", 1L).stream().map(c -> c.getId()).toList());
+ assertEquals(List.of(21L), mapper.lockSelected("C", 1L, "SKU-A", List.of(11L, 21L)).stream().map(c -> c.getId()).toList());
+ assertEquals(List.of(21L), mapper.lockAvailable("C", 1L, "SKU-A", 20).stream().map(c -> c.getId()).toList());
+ assertTrue(mapper.candidates("OTHER", null).isEmpty());
+ assertEquals(0, mapper.countAvailableOrder("OTHER", 1L));
+
+ // 仓位与余额必须属于相同客户,不能借用其他客户的正库存。
+ db.update("UPDATE ll_inventory_balance SET customer_id='OTHER' WHERE id=20000");
+ session.clearCache();
+ assertTrue(mapper.candidates("C", null).isEmpty());
+ }
+ }
+}
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferInventorySourceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferInventorySourceTest.java
index 8bc35b8ca..e3696f080 100644
--- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferInventorySourceTest.java
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferInventorySourceTest.java
@@ -24,23 +24,30 @@ class TransferInventorySourceTest {
assertFalse(request(null,null,null,null).isInboundModeValid());
assertTrue(request(null,null,"CUSTOMER_DELIVERY",LocalDateTime.now()).isInboundModeValid());
}
+ /** 已换标或正在换标的原箱不再展示;撤销、驳回及其他客户的记录不能误删当前箱。 */
@Test void originalBoxesIncludeBothSourcesButNeverAnotherCustomerWarehouseOrInbound() {
var ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
var db=new JdbcTemplate(ds);
db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,inbound_code VARCHAR,overseas_warehouse_id VARCHAR)");
db.execute("CREATE TABLE ll_inbound_plan_carton(customer_id VARCHAR,order_id BIGINT,carton_mark VARCHAR,sku_code VARCHAR,destination_warehouse_code VARCHAR,destination_warehouse_name VARCHAR)");
- db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT,customer_id VARCHAR,lastleg_order_id BIGINT,operating_warehouse_id VARCHAR,destination_warehouse_code VARCHAR,destination_warehouse_name VARCHAR)");
+ db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT,customer_id VARCHAR,lastleg_order_id BIGINT,operating_warehouse_id VARCHAR,destination_warehouse_code VARCHAR,destination_warehouse_name VARCHAR,inbound_code VARCHAR,status VARCHAR)");
db.execute("CREATE TABLE ll_warehouse_transfer_item(id BIGINT,customer_id VARCHAR,transfer_order_id BIGINT,sku_code VARCHAR,product_name_zh VARCHAR)");
- db.execute("CREATE TABLE ll_warehouse_transfer_carton(customer_id VARCHAR,transfer_order_id BIGINT,transfer_item_id BIGINT,carton_mark VARCHAR,counted_quantity BIGINT,sku_items_json VARCHAR,status VARCHAR)");
+ db.execute("CREATE TABLE ll_warehouse_transfer_carton(customer_id VARCHAR,transfer_order_id BIGINT,transfer_item_id BIGINT,carton_mark VARCHAR,customer_carton_mark VARCHAR,counted_quantity BIGINT,sku_items_json VARCHAR,status VARCHAR)");
+ db.execute("CREATE TABLE ll_relabel_application(id BIGINT,customer_id VARCHAR,overseas_warehouse_id VARCHAR,status VARCHAR,customer_confirmed_at TIMESTAMP)");
+ db.execute("CREATE TABLE ll_relabel_item(application_id BIGINT,customer_id VARCHAR,source_inbound_code VARCHAR,source_carton_mark VARCHAR,carton_mark VARCHAR)");
+ db.execute("CREATE TABLE ll_relabel_pda_unit_fact(item_id BIGINT,result VARCHAR)");
db.update("INSERT INTO ll_order VALUES(1,'A','IN','WH'),(2,'B','IN','WH'),(3,'A','IN','OTHER'),(4,'A','OTHER','WH')");
- db.update("INSERT INTO ll_inbound_plan_carton VALUES('A',1,'FIRST','S','ONT8','Amazon'),('B',2,'FOREIGN','S','ONT8','Amazon'),('A',3,'WRONG-WH','S','ONT8','Amazon'),('A',4,'WRONG-IN','S','ONT8','Amazon'),('B',1,'FORGED','S','ONT8','Amazon')");
- db.update("INSERT INTO ll_warehouse_transfer_order VALUES(10,'A',1,'WH','ONT8','Amazon')");
+ db.update("INSERT INTO ll_inbound_plan_carton VALUES('A',1,'FIRST','S','ONT8','Amazon'),('A',1,'RELABELED','S','ONT8','Amazon'),('B',2,'FOREIGN','S','ONT8','Amazon'),('A',3,'WRONG-WH','S','ONT8','Amazon'),('A',4,'WRONG-IN','S','ONT8','Amazon'),('B',1,'FORGED','S','ONT8','Amazon')");
+ db.update("INSERT INTO ll_relabel_application VALUES(51,'A','WH','COMPLETED',CURRENT_TIMESTAMP()),(52,'A','WH','PROCESSING',NULL),(53,'A','WH','CANCELLED',NULL),(54,'A','WH','REJECTED',NULL),(55,'B','WH','COMPLETED',CURRENT_TIMESTAMP()),(56,'A','OTHER','COMPLETED',CURRENT_TIMESTAMP())");
+ db.update("INSERT INTO ll_relabel_item VALUES(51,'A','IN','RELABELED','RELABELED'),(52,'A','IN',NULL,'CUSTOMER-OLD'),(53,'A','IN',NULL,'FIRST'),(54,'A','IN',NULL,'FIRST'),(55,'B','IN',NULL,'FIRST'),(56,'A','IN',NULL,'FIRST')");
+ db.update("INSERT INTO ll_relabel_pda_unit_fact VALUES(51,'RELABELLED')");
+ db.update("INSERT INTO ll_warehouse_transfer_order VALUES(10,'A',1,'WH','ONT8','Amazon','IN','DRAFT'),(20,'A',1,'WH','ONT8','Amazon','IN','PENDING_REVIEW')");
db.update("INSERT INTO ll_warehouse_transfer_item VALUES(11,'A',10,'SKU','Product')");
- db.update("INSERT INTO ll_warehouse_transfer_carton VALUES('A',10,11,'MEASURED',20,NULL,'COUNTED'),('A',10,11,'EXPECTED',NULL,NULL,'EXPECTED'),('B',10,11,'FOREIGN2',20,NULL,'COUNTED')");
+ db.update("INSERT INTO ll_warehouse_transfer_carton VALUES('A',10,11,'MEASURED',NULL,20,NULL,'COUNTED'),('A',10,11,'RELABELED-TRANSFER','CUSTOMER-OLD',20,NULL,'COUNTED'),('A',10,11,'EXPECTED',NULL,NULL,NULL,'EXPECTED'),('A',20,11,'FIRST',NULL,20,NULL,'COUNTED'),('B',10,11,'FOREIGN2',NULL,20,NULL,'COUNTED')");
var config=new Configuration(new Environment("test",new JdbcTransactionFactory(),ds)); config.addMapper(InboundSnapshotMapper.class);
try(var session=new SqlSessionFactoryBuilder().build(config).openSession()) {
var result=session.getMapper(InboundSnapshotMapper.class).sourceCartons("A","WH","IN");
- assertEquals(List.of("FIRST","MEASURED"),result.stream().map(InboundSnapshotMapper.SourceCarton::cartonMark).toList());
+ assertEquals(List.of("FIRST", "MEASURED"),result.stream().map(InboundSnapshotMapper.SourceCarton::cartonMark).toList());
assertEquals("ONT8",result.get(0).destinationWarehouseCode());
assertNull(result.get(0).quantity()); assertEquals(20L,result.get(1).quantity());
}
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferReturnSourceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferReturnSourceTest.java
index f7532f5ff..7546fde91 100644
--- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferReturnSourceTest.java
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferReturnSourceTest.java
@@ -6,6 +6,7 @@ import com.baiyue.lastleg.returnreceipt.entity.ReturnOrder;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper;
+import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.transfer.repository.TransferReturnSourceRepository;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
@@ -23,12 +24,15 @@ class TransferReturnSourceTest {
var orders = mock(ReturnOrderMapper.class);
var cartons = mock(ReturnInventoryCartonMapper.class);
var items = mock(ReturnInventoryCartonItemMapper.class);
+ var relabelItems = mock(RelabelItemMapper.class);
var repository = new TransferReturnSourceRepository();
ReflectionTestUtils.setField(repository, "orders", orders);
ReflectionTestUtils.setField(repository, "cartons", cartons);
ReflectionTestUtils.setField(repository, "items", items);
+ ReflectionTestUtils.setField(repository, "relabelItems", relabelItems);
var service = new WarehouseTransferService();
ReflectionTestUtils.setField(service, "returnSources", repository);
+ ReflectionTestUtils.setField(service, "inventorySources", mock(com.baiyue.lastleg.transfer.repository.TransferInventorySourceRepository.class));
var order = new ReturnOrder();
order.setOverseasWarehouseId("WH-1");
when(orders.findNo("C-1", "RET-1")).thenReturn(order);
@@ -38,6 +42,7 @@ class TransferReturnSourceTest {
var sku = new ReturnInventoryCartonItem();
sku.setCartonId(1L); sku.setSkuCode("SKU-1"); sku.setQuantity(3L);
when(items.listCartons(List.of(1L))).thenReturn(List.of(sku));
+ when(relabelItems.completedReturnCartonIds("C-1", List.of(1L))).thenReturn(List.of());
var result = service.sourceCartons("C-1", "WH-1", "RETURN_ORDER:RET-1");
@@ -45,6 +50,9 @@ class TransferReturnSourceTest {
assertEquals("BOX-1", result.get(0).cartonMark());
assertEquals("SKU-1", result.get(0).items().get(0).skuCode());
assertEquals(3L, result.get(0).items().get(0).quantity());
+ when(relabelItems.completedReturnCartonIds("C-1", List.of(1L))).thenReturn(List.of(1L));
+ assertTrue(service.sourceCartons("C-1", "WH-1", "RETURN_ORDER:RET-1").isEmpty(),
+ "释放占用后的已换标退货箱不再回到原单明细");
assertThrows(ResponseStatusException.class,
() -> service.sourceCartons("C-1", "OTHER-WH", "RETURN_ORDER:RET-1"));
verify(cartons, never()).currentReturnCartons("C-1", "OTHER-WH", "RET-1");
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java
index 9d5867743..bb587c13a 100644
--- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java
@@ -80,6 +80,7 @@ import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
@ExtendWith(MockitoExtension.class)
@@ -247,6 +248,12 @@ class WarehouseTransferServiceTest {
ReflectionTestUtils.setField(service, "warehouseResources", warehouseResources);
ReflectionTestUtils.setField(service, "measurementPhotoFiles", measurementPhotoFiles);
ReflectionTestUtils.setField(service, "inboundSnapshots", inboundSnapshots);
+ var inventorySourceRepository = new com.baiyue.lastleg.transfer.repository.TransferInventorySourceRepository();
+ ReflectionTestUtils.setField(inventorySourceRepository, "snapshots", inboundSnapshots);
+ var reservations = mock(com.baiyue.lastleg.transfer.mapper.TransferSourceReservationMapper.class);
+ lenient().when(reservations.lockInbound(anyString(), any())).thenReturn(101L);
+ ReflectionTestUtils.setField(inventorySourceRepository, "reservations", reservations);
+ ReflectionTestUtils.setField(service, "inventorySources", inventorySourceRepository);
ReflectionTestUtils.setField(service, "inboundOrders", inboundOrders);
ReflectionTestUtils.setField(service, "inboundPlans", inboundPlans);
ReflectionTestUtils.setField(service, "inboundPlanLines", inboundPlanLines);