merge: retain current overseas inventory fixes with looseboard demand

This commit is contained in:
2026-10-19 15:11:45 +08:00
14 changed files with 394 additions and 20 deletions
@@ -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<DropshipCarton> availableOrderCartons(@Param("customerId") String customerId, @Param("orderId") Long orderId);
@Select("<script>SELECT c.* FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " +
/** 提交选箱时再次核对实际可用库存,避免旧页面绕过候选过滤。 */
@Select("<script>SELECT c.* FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " + AVAILABLE_STOCK +
"AND c.status='AVAILABLE' AND c.id IN <foreach collection='cartonIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"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.id FOR UPDATE</script>")
List<DropshipCarton> 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<String> looseCartonMarks(@Param("customerId") String customerId, @Param("positionId") Long positionId);
/** 按当前可用仓位筛选入库单,不能只依赖箱唛的入库状态。 */
@Select("<script>SELECT o.id order_id,o.inbound_code,o.inbound_order_no,o.overseas_warehouse_id," +
"o.overseas_warehouse_name,o.created_at,p.id profile_id,s.id sku_inventory_id,s.sku_code,s.product_name," +
"COUNT(c.id) available_packages FROM ll_order o " +
@@ -80,33 +92,37 @@ public interface CargoDiscardInventoryMapper {
"JOIN ll_dropship_carton c ON c.customer_id=o.customer_id AND c.order_id=o.id " +
"AND c.profile_id=p.id AND c.status='AVAILABLE' " +
"JOIN ll_dropship_carton_sku cs ON cs.customer_id=c.customer_id AND cs.carton_id=c.id AND cs.sku_code=s.sku_code " +
"WHERE o.customer_id=#{customerId} AND o.order_type='ONE_CLICK_DROPSHIP' " +
"WHERE o.customer_id=#{customerId} AND o.order_type='ONE_CLICK_DROPSHIP' " + AVAILABLE_STOCK +
"<if test='inboundCode != null and inboundCode != \"\"'>AND o.inbound_code LIKE CONCAT('%',#{inboundCode},'%') </if>" +
"GROUP BY o.id,o.inbound_code,o.inbound_order_no,o.overseas_warehouse_id,o.overseas_warehouse_name,o.created_at," +
"p.id,s.id,s.sku_code,s.product_name HAVING COUNT(c.id)&gt;0 ORDER BY o.created_at DESC,o.id DESC,p.id,s.sku_code LIMIT 500</script>")
List<CargoDiscardCandidateRow> 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<DropshipCarton> 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) " +
@@ -6,6 +6,16 @@ import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface RelabelItemMapper extends BaseMapper<RelabelItem> {
/** 批量查询退货箱已完成的换标事实,避免逐箱查询。 */
@org.apache.ibatis.annotations.Select("<script>SELECT DISTINCT i.return_carton_id FROM ll_relabel_item i " +
"JOIN ll_relabel_application a ON a.id=i.application_id AND a.customer_id=i.customer_id " +
"WHERE i.customer_id=#{customerId} AND i.return_carton_id IN " +
"<foreach collection='cartonIds' item='cartonId' open='(' separator=',' close=')'>#{cartonId}</foreach> " +
"AND a.status NOT IN ('CANCELLED','REJECTED') AND a.customer_confirmed_at IS NOT NULL " +
"AND EXISTS(SELECT 1 FROM ll_relabel_pda_unit_fact f WHERE f.item_id=i.id AND f.result='RELABELLED')</script>")
java.util.List<Long> completedReturnCartonIds(@org.apache.ibatis.annotations.Param("customerId") String customerId,
@org.apache.ibatis.annotations.Param("cartonIds") java.util.List<Long> 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 " +
@@ -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<SourceCarton> sourceCartons(@Param("customerId") String customerId,
@@ -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<String> occupiedMarks(@Param("customerId") String customerId,
@Param("warehouseId") String warehouseId, @Param("sourceCode") String sourceCode);
}
@@ -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<String> occupiedMarks(String customerId, String warehouseId, String sourceCode) {
return reservations.occupiedMarks(customerId, warehouseId, sourceCode);
}
/** 读取当前来源单未被有效换标申请使用的箱级快照。 */
public List<InboundSnapshotMapper.SourceCarton> sourceCartons(String customerId, String warehouseId, String inboundCode) {
return snapshots.sourceCartons(customerId, warehouseId, inboundCode);
}
}
@@ -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<ReturnInventoryCarton> 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();
}
/** 批量读取已通过客户及仓库隔离校验的来源箱明细。 */
@@ -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<WarehouseTransferResponses.SourceCarton> sourceCartons(String customerId, String warehouseId, String inboundCode) {
@@ -164,22 +165,36 @@ public class WarehouseTransferService {
List<Long> cartonIds = cartons.stream().map(ReturnInventoryCarton::getId).filter(Objects::nonNull).toList();
Map<Long, List<ReturnInventoryCartonItem>> 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<ReturnInventoryCartonItem> rows = items.getOrDefault(carton.getId(), List.of());
List<WarehouseTransferResponses.SourceCartonSku> 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<WarehouseTransferResponses.SourceCarton> unreservedSourceCartons(String customerId,
String warehouseId, String source, List<WarehouseTransferResponses.SourceCarton> cartons) {
if (inventorySources == null) return cartons;
Set<String> 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<String> 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<String, String>();
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<Long, String>();
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<WarehouseTransferResponses.Summary> page(String customerId, WarehouseTransferRequests.Query query) {
return page(customerId, query, true);
@@ -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());
}
}
}
@@ -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());
}
@@ -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");
@@ -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);