fix(dropship): serialize outbound creation and pick exact carton subsets

This commit is contained in:
2026-09-15 11:42:19 +08:00
parent 88e2fb0eb1
commit 3ebe254c45
10 changed files with 155 additions and 16 deletions
@@ -25,6 +25,7 @@ public class OutboundOrder {
private Integer packageCount;
private Integer palletCount;
private String idempotencyKey;
private String requestHash;
private String createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@@ -15,6 +15,8 @@ public interface OutboundOrderMapper extends BaseMapper<OutboundOrder> {
OutboundOrder findGlobalByNo(@Param("outboundNo") String outboundNo);
@Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
OutboundOrder findByIdempotency(@Param("customerId") String customerId, @Param("key") String key);
@Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1 FOR UPDATE")
OutboundOrder lockByIdempotency(@Param("customerId") String customerId, @Param("key") String key);
@Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1")
OutboundOrder findByDispatch(@Param("customerId") String customerId, @Param("dispatchOrderId") Long dispatchOrderId);
@Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1 FOR UPDATE")
@@ -73,6 +73,11 @@ import java.time.format.DateTimeFormatter;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import java.math.BigDecimal;
import java.util.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
@Service
public class DeliveryTransferService {
@@ -103,6 +108,7 @@ public class DeliveryTransferService {
@Resource private RelabelItemMapper relabelItems;
@Resource private RelabelApplicationService relabelService;
@Resource private LastlegOrderNoGenerator orderNoGenerator;
@Resource private ObjectMapper objectMapper;
@Transactional
public DeliveryResponses.TransferResult platformTransfer(String dispatchNo, String operatorId,
@@ -254,17 +260,33 @@ public class DeliveryTransferService {
@Transactional
public DeliveryResponses.PdaTask createOneClick(String customerId, String operatorId, DeliveryRequests.OneClickOutbound request) {
String requestHash = oneClickRequestHash(request);
OutboundOrder existing = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim());
if (existing != null) return taskResult(existing, pdaTasks.lockByOutbound(customerId, existing.getId()));
if (existing != null) return replayOneClick(existing, requestHash);
LocalDateTime now = LocalDateTime.now();
String selectedWarehouseId = validateOneClickItems(customerId, request.items());
InventoryPosition firstPosition = inventoryPositions.selectById(request.items().get(0).inventoryPositionId());
if (firstPosition == null || !customerId.equals(firstPosition.getCustomerId())) {
throw conflict("所选库存不是可用的一件代发库存,或已被其他作业占用");
}
String selectedWarehouseId = required(firstPosition.getOverseasWarehouseId(), "库存缺少所属海外仓");
OutboundOrder outbound = new OutboundOrder();
outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId);
outbound.setOutboundNo(orderNoGenerator.next("ONE_CLICK_DROPSHIP", selectedWarehouseId, outbound.getId()));
outbound.setOutboundType("ONE_CLICK_DROPSHIP"); outbound.setStatus("WAIT_PICK"); outbound.setRecipientInfo(request.recipient().trim());
outbound.setPackageCount(request.items().stream().mapToInt(i -> i.packages().intValue()).sum()); outbound.setPalletCount(request.items().size());
outbound.setIdempotencyKey(request.idempotencyKey().trim()); outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now);
outboundOrders.insert(outbound);
outbound.setRequestHash(requestHash);
// Claim the unique command key before taking inventory locks. A racing insert
// waits for the first transaction; use a current read after it commits, not
// the REPEATABLE READ snapshot established by the initial lookup.
try {
outboundOrders.insert(outbound);
} catch (DuplicateKeyException duplicate) {
OutboundOrder concurrent = outboundOrders.lockByIdempotency(customerId, request.idempotencyKey().trim());
if (concurrent == null) throw duplicate;
return replayOneClick(concurrent, requestHash);
}
selectedWarehouseId = validateOneClickItems(customerId, request.items());
Set<Long> physicalPalletIds = new HashSet<>();
for (DeliveryRequests.OneClickItem input : request.items()) {
InventoryPosition position = inventoryPositions.selectById(input.inventoryPositionId());
@@ -286,6 +308,23 @@ public class DeliveryTransferService {
return taskResult(outbound, task);
}
private DeliveryResponses.PdaTask replayOneClick(OutboundOrder existing, String requestHash) {
if (!"ONE_CLICK_DROPSHIP".equals(existing.getOutboundType())
|| !requestHash.equals(existing.getRequestHash())) {
throw conflict("相同幂等键不能用于不同的一件代发出库请求");
}
return taskResult(existing, pdaTasks.lockByOutbound(existing.getCustomerId(), existing.getId()));
}
String oneClickRequestHash(DeliveryRequests.OneClickOutbound request) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(objectMapper.writeValueAsString(request).getBytes(StandardCharsets.UTF_8)));
} catch (JsonProcessingException | NoSuchAlgorithmException error) {
throw new IllegalStateException("无法生成一件代发出库请求摘要", error);
}
}
public List<DeliveryResponses.OneClickCandidate> oneClickCandidates(String customerId, String warehouseId) {
return dropshipProfiles.listOutboundCandidates(customerId,
StringUtils.hasText(warehouseId) ? warehouseId.trim() : null).stream()
@@ -491,8 +530,19 @@ public class DeliveryTransferService {
}
DropshipOrderProfile profile = dropshipProfiles.findPosition(customerId, request.inventoryPositionId());
if (profile == null) throw conflict("Scanned pallet is not one-click dropship inventory");
List<DropshipCarton> cartons = StringUtils.hasText(item.getCartonMarks())
? Arrays.stream(item.getCartonMarks().split(",")).map(String::trim).filter(StringUtils::hasText)
List<String> submittedMarks = request.cartonMarks() == null ? List.of() : request.cartonMarks();
List<String> selectedMarks = submittedMarks.stream().map(String::trim).filter(StringUtils::hasText).distinct().sorted().toList();
Set<String> frozenMarks = new HashSet<>(Arrays.asList(Optional.ofNullable(item.getCartonMarks()).orElse("").split(",")));
if (!submittedMarks.isEmpty() && (selectedMarks.size() != submittedMarks.size()
|| selectedMarks.size() != request.packages()
|| StringUtils.hasText(item.getCartonMarks()) && !frozenMarks.containsAll(selectedMarks))) {
throw conflict("拣货箱唛必须唯一、数量一致且属于当前出库明细");
}
if (StringUtils.hasText(item.getCartonMarks()) && selectedMarks.isEmpty()) {
throw conflict("请提交本次实际拣货的指定箱唛");
}
List<DropshipCarton> cartons = !selectedMarks.isEmpty()
? selectedMarks.stream()
.map(mark -> dropshipCartons.findByProfileAndMark(customerId, profile.getId(), mark)).filter(Objects::nonNull)
.filter(carton -> "AVAILABLE".equals(carton.getStatus()) && item.getSkuCode().equals(carton.getSkuCode())).toList()
: dropshipCartons.lockAvailable(customerId, profile.getId(), item.getSkuCode(), request.packages());
@@ -644,6 +694,8 @@ public class DeliveryTransferService {
OutboundPalletItem item) {
if (item.getWarehousePalletId() == null) return List.of();
List<DeliveryResponses.PdaPickPosition> result = new ArrayList<>();
Set<String> frozenMarks = new HashSet<>(Arrays.asList(
Optional.ofNullable(item.getCartonMarks()).orElse("").split(",")));
for (InventoryPosition position : inventoryPositions.listPallet(customerId, item.getWarehousePalletId())) {
long available = value(position.getAvailablePackages());
if (available <= 0 || !warehouseId.equals(position.getOverseasWarehouseId())) continue;
@@ -654,7 +706,9 @@ public class DeliveryTransferService {
List<String> marks = profile == null ? List.of() : dropshipCartons.listProfile(customerId, profile.getId())
.stream().filter(carton -> "AVAILABLE".equals(carton.getStatus()))
.filter(carton -> Objects.equals(item.getSkuCode(), carton.getSkuCode()))
.filter(carton -> !StringUtils.hasText(item.getCartonMarks()) || frozenMarks.contains(carton.getCartonMark()))
.map(DropshipCarton::getCartonMark).filter(StringUtils::hasText).distinct().limit(available).toList();
if (profile != null && marks.isEmpty()) continue;
if (!matchesOutboundItem(customerId, warehouseId, item, balance) && marks.isEmpty()) continue;
if (marks.isEmpty()) {
result.add(pdaPosition(position, item, balance, null, available, value(position.getPickedPackages())));
@@ -669,7 +723,7 @@ public class DeliveryTransferService {
InventoryBalance balance, String cartonMark,
long available, long picked) {
return new DeliveryResponses.PdaPickPosition(position.getId(), position.getPalletNo(), cartonMark,
balance.getSkuCode(), balance.getInboundCode(), available, picked);
item.getSkuCode(), balance.getInboundCode(), available, picked);
}
private boolean matchesOutboundItem(String customerId, String warehouseId, OutboundPalletItem item,