This commit is contained in:
2026-10-19 15:00:25 +08:00
parent 21128b895f
commit 26a203b4eb
14 changed files with 394 additions and 20 deletions
@@ -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);