This commit is contained in:
2026-10-16 13:57:39 +08:00
parent 5855529e97
commit 358feb5567
4 changed files with 166 additions and 14 deletions
@@ -0,0 +1,38 @@
package com.baiyue.lastleg.transfer.repository;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem;
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 jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 封装仓储转运所需的退货来源查询,保留客户和操作仓隔离。 */
@Repository
public class TransferReturnSourceRepository {
@Resource private ReturnOrderMapper orders;
@Resource private ReturnInventoryCartonMapper cartons;
@Resource private ReturnInventoryCartonItemMapper items;
/** 按当前客户和退货单号查询来源单据。 */
public ReturnOrder findOrder(String customerId, String returnNo) {
return orders.findNo(customerId, returnNo);
}
/** 查询当前仓内已完成清点且可用的退货箱,不影响其他页面的历史库存查询。 */
public List<ReturnInventoryCarton> availableCartons(String customerId, String warehouseId, String returnNo) {
return cartons.currentReturnCartons(customerId, warehouseId, returnNo).stream()
.filter(carton -> "AVAILABLE".equals(carton.getStatus()) && "CONFIRMED".equals(carton.getCountStatus())
&& carton.getRelabelApplicationId() == null)
.toList();
}
/** 批量读取已通过客户及仓库隔离校验的来源箱明细。 */
public List<ReturnInventoryCartonItem> cartonItems(List<Long> cartonIds) {
return cartonIds.isEmpty() ? List.of() : items.listCartons(cartonIds);
}
}
@@ -59,6 +59,10 @@ import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletItemMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferRelabelBindingMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferMeasurementPhotoMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem;
import com.baiyue.lastleg.returnreceipt.entity.ReturnOrder;
import com.baiyue.lastleg.transfer.repository.TransferReturnSourceRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
@@ -88,6 +92,8 @@ import java.util.stream.Collectors;
@Service
public class WarehouseTransferService {
/** 退货单在转运来源中的展示编码,避免把退货单号误当成普通入仓码查询。 */
public static final String RETURN_ORDER_PREFIX = "RETURN_ORDER:";
@Resource private com.baiyue.lastleg.service.CompactBusinessNumberService compactNumbers;
@Resource private com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customerDirectory;
@Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge fleetPool;
@@ -140,10 +146,32 @@ public class WarehouseTransferService {
@Resource private InventoryCommandService inventory;
@Resource private InventoryCommandMapper inventoryCommands;
@Resource private ObjectMapper objectMapper;
@Resource private TransferReturnSourceRepository returnSources;
/** 查询来源单据当前可用的箱唛及箱内 SKU,兼容普通入仓与退货库存。 */
public List<WarehouseTransferResponses.SourceCarton> sourceCartons(String customerId, String warehouseId, String inboundCode) {
if (!StringUtils.hasText(warehouseId) || !StringUtils.hasText(inboundCode)) throw badRequest("请选择海外仓和库存来源单据");
return inboundSnapshots.sourceCartons(customerId, warehouseId.trim(), inboundCode.trim()).stream().map(row -> {
String source = inboundCode.trim();
if (isReturnOrderSource(source)) {
String returnNo = returnNoFromSource(source);
ReturnOrder order = returnSources.findOrder(customerId, returnNo);
if (order == null || !warehouseId.trim().equals(order.getOverseasWarehouseId())) {
throw notFound("退货单不存在或不属于当前操作仓");
}
List<ReturnInventoryCarton> cartons = returnSources.availableCartons(customerId, warehouseId.trim(), returnNo);
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 -> {
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();
}
return inboundSnapshots.sourceCartons(customerId, warehouseId.trim(), source).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()));
@@ -161,6 +189,7 @@ public class WarehouseTransferService {
return StringUtils.hasText(request.inboundCode()) ? "" : request.deliveryAddress().trim();
}
/** 创建转运草稿并关联来源单据,退货来源沿其关联尾程订单核对客户归属。 */
@Transactional
public WarehouseTransferResponses.Detail create(String customerId, String operatorId,
WarehouseTransferRequests.Create request) {
@@ -203,18 +232,35 @@ public class WarehouseTransferService {
List<LastlegOrder> selectedOrders;
if (existingInventoryTransfer) {
inboundCode = requestedInboundCode;
inboundOrderId = inboundSnapshots.findId(customerId, inboundCode);
if (inboundOrderId == null) throw notFound("Inbound order was not found for inboundCode " + inboundCode);
if (isReturnOrderSource(inboundCode)) {
ReturnOrder returnOrder = returnSources.findOrder(customerId, returnNoFromSource(inboundCode));
if (returnOrder == null || !request.operatingWarehouseId().trim().equals(returnOrder.getOverseasWarehouseId())
|| returnOrder.getLastlegOrderId() == null) {
throw notFound("退货单不存在、操作仓不匹配或未关联尾程订单");
}
LastlegOrder returnOrderTail = orders.selectById(returnOrder.getLastlegOrderId());
if (returnOrderTail == null || !customerId.equals(returnOrderTail.getCustomerId())) {
throw notFound("退货单关联的尾程订单不存在");
}
inboundOrderId = StringUtils.hasText(returnOrderTail.getSourceInboundId())
? inboundSnapshots.findBySourceInboundId(customerId, returnOrderTail.getSourceInboundId())
: inboundSnapshots.findId(customerId, returnOrderTail.getInboundCode());
if (inboundOrderId == null) throw notFound("退货单关联的入仓快照不存在");
selectedOrders = List.of(returnOrderTail);
} else {
inboundOrderId = inboundSnapshots.findId(customerId, inboundCode);
if (inboundOrderId == null) throw notFound("Inbound order was not found for inboundCode " + inboundCode);
selectedOrders = selectedIds.isEmpty()
? orders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, customerId)
.eq(LastlegOrder::getInboundCode, inboundCode))
: orders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, customerId).in(LastlegOrder::getId, selectedIds));
}
// The merchant form may only submit the inventory inbound code. In that
// case derive all eligible tail orders for the inbound instead of
// rejecting a valid inventory transfer because source IDs were omitted.
selectedOrders = selectedIds.isEmpty()
? orders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, customerId)
.eq(LastlegOrder::getInboundCode, inboundCode))
: orders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, customerId).in(LastlegOrder::getId, selectedIds));
if (selectedOrders.isEmpty() || (!selectedIds.isEmpty() && selectedOrders.size() != selectedIds.size())) {
if (selectedOrders.isEmpty() || (!selectedIds.isEmpty() && !isReturnOrderSource(inboundCode) && selectedOrders.size() != selectedIds.size())) {
throw notFound("One or more selected customer orders do not exist in the current tenant");
}
} else {
@@ -393,6 +439,7 @@ public class WarehouseTransferService {
}
@Transactional
/** 提交草稿前重新校验来源箱归属,防止草稿保存后库存已发生变化。 */
public WarehouseTransferResponses.Detail submit(String customerId, String transferNo, String operatorId) {
WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo);
if (transfer == null) throw notFound("Warehouse transfer order not found");
@@ -401,8 +448,8 @@ public class WarehouseTransferService {
if (Boolean.TRUE.equals(transfer.getNeedRelabel())) {
Set<String> allowedMarks = !transfer.getTransferNo().equals(transfer.getInboundCode())
? inboundSnapshots.sourceCartons(customerId, transfer.getOperatingWarehouseId(), transfer.getInboundCode())
.stream().map(InboundSnapshotMapper.SourceCarton::cartonMark).collect(Collectors.toSet())
? sourceCartons(customerId, transfer.getOperatingWarehouseId(), transfer.getInboundCode())
.stream().map(WarehouseTransferResponses.SourceCarton::cartonMark).collect(Collectors.toSet())
: transferCartons.listOrder(customerId, transfer.getId()).stream()
.flatMap(carton -> java.util.stream.Stream.of(carton.getCartonMark(), carton.getCustomerCartonMark()))
.filter(StringUtils::hasText).collect(Collectors.toSet());
@@ -1748,6 +1795,7 @@ public class WarehouseTransferService {
}
}
/** 校验换标资料及箱唛归属,退货来源同样按当前可用箱校验。 */
private void validateRelabelRequest(String customerId, WarehouseTransferRequests.Create request) {
if (!request.needRelabel()) {
if (request.relabelItems() != null && !request.relabelItems().isEmpty()) {
@@ -1757,8 +1805,8 @@ public class WarehouseTransferService {
}
if (request.relabelItems() == null || request.relabelItems().isEmpty()) return;
Set<String> orderCartonMarks = StringUtils.hasText(request.inboundCode())
? inboundSnapshots.sourceCartons(customerId, request.operatingWarehouseId().trim(), request.inboundCode().trim())
.stream().map(InboundSnapshotMapper.SourceCarton::cartonMark)
? sourceCartons(customerId, request.operatingWarehouseId().trim(), request.inboundCode().trim())
.stream().map(WarehouseTransferResponses.SourceCarton::cartonMark)
.filter(StringUtils::hasText).map(String::trim).collect(Collectors.toSet())
: request.items().stream().flatMap(item -> splitMarks(item.shippingMark()).stream()).collect(Collectors.toSet());
if (!StringUtils.hasText(request.inboundCode()) && orderCartonMarks.isEmpty()) {
@@ -2103,6 +2151,10 @@ public class WarehouseTransferService {
return auth == null ? null : auth.getName();
}
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
/** 根据独立前缀识别退货来源,避免与普通入仓码混淆。 */
private static boolean isReturnOrderSource(String value) { return StringUtils.hasText(value) && value.trim().startsWith(RETURN_ORDER_PREFIX); }
/** 从转运来源编码提取真实退货单号。 */
private static String returnNoFromSource(String value) { return value.trim().substring(RETURN_ORDER_PREFIX.length()).trim(); }
private static String firstText(String first, String second) { return StringUtils.hasText(first) ? first.trim() : trim(second); }
private static long packages(Long value) { return value == null ? 0L : value; }
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
@@ -0,0 +1,60 @@
package com.baiyue.lastleg.transfer.service;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem;
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.transfer.repository.TransferReturnSourceRepository;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class TransferReturnSourceTest {
/** 来源查询应排除占用和未清点箱,并保留整箱内的全部 SKU。 */
@Test
void returnsOnlyAvailableConfirmedCartonsWithContents() {
var orders = mock(ReturnOrderMapper.class);
var cartons = mock(ReturnInventoryCartonMapper.class);
var items = mock(ReturnInventoryCartonItemMapper.class);
var repository = new TransferReturnSourceRepository();
ReflectionTestUtils.setField(repository, "orders", orders);
ReflectionTestUtils.setField(repository, "cartons", cartons);
ReflectionTestUtils.setField(repository, "items", items);
var service = new WarehouseTransferService();
ReflectionTestUtils.setField(service, "returnSources", repository);
var order = new ReturnOrder();
order.setOverseasWarehouseId("WH-1");
when(orders.findNo("C-1", "RET-1")).thenReturn(order);
var available = carton(1L, "AVAILABLE", "CONFIRMED");
when(cartons.currentReturnCartons("C-1", "WH-1", "RET-1")).thenReturn(List.of(
available, carton(2L, "OUTBOUND_PENDING", "CONFIRMED"), carton(3L, "AVAILABLE", "PENDING")));
var sku = new ReturnInventoryCartonItem();
sku.setCartonId(1L); sku.setSkuCode("SKU-1"); sku.setQuantity(3L);
when(items.listCartons(List.of(1L))).thenReturn(List.of(sku));
var result = service.sourceCartons("C-1", "WH-1", "RETURN_ORDER:RET-1");
assertEquals(1, result.size());
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());
assertThrows(ResponseStatusException.class,
() -> service.sourceCartons("C-1", "OTHER-WH", "RETURN_ORDER:RET-1"));
verify(cartons, never()).currentReturnCartons("C-1", "OTHER-WH", "RET-1");
}
/** 构造不同库存状态的退货箱以验证来源筛选边界。 */
private static ReturnInventoryCarton carton(long id, String status, String countStatus) {
var carton = new ReturnInventoryCarton();
carton.setId(id); carton.setCartonMark("BOX-" + id); carton.setTotalQuantity(3L);
carton.setStatus(status); carton.setCountStatus(countStatus);
return carton;
}
}
@@ -490,6 +490,8 @@ class WarehouseTransferServiceTest {
@Test
void submittingOldDraftRechecksRelabelCartonOwnership() {
var transfer = transfer(WarehouseTransferService.DRAFT);
transfer.setOperatingWarehouseId("WH-1");
transfer.setInboundCode("IN-1");
transfer.setNeedRelabel(true);
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
var binding = new com.baiyue.lastleg.transfer.entity.WarehouseTransferRelabelBinding();