贴标申请,仓储转运

This commit is contained in:
2026-10-12 11:18:58 +08:00
parent 29b562ebf5
commit b4b00ae3dd
39 changed files with 990 additions and 315 deletions
@@ -2414,6 +2414,7 @@ CREATE TABLE ll_return_inventory_balance (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货库存按客户海外仓SKU汇总余额';
CREATE TABLE ll_return_inventory_carton (
label_status varchar(32) DEFAULT NULL COMMENT 'WAIT_LABEL待贴标 CONFIRMED已扫码确认标签',
new_carton_mark varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标后新箱唛',
id bigint NOT NULL COMMENT '退货库存箱ID',
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键',
@@ -486,7 +486,6 @@ public class DeliveryTransferService {
public List<DeliveryResponses.ReturnOutboundCandidate> returnOutboundCandidates(String customerId, String warehouseId) {
if (!StringUtils.hasText(warehouseId)) return List.of();
return returnCartons.findAvailable(required(customerId, "Customer is required"), warehouseId.trim()).stream()
.filter(carton -> !"PARCEL".equals(carton.getSourceUnitType()))
.map(c -> new DeliveryResponses.ReturnOutboundCandidate(c.getSourceReturnNo(), c.getOverseasWarehouseId(),
c.getCartonMark(), c.getPalletCode(), c.getFbaCartonMark(), c.getDestinationWarehouseCode(), c.getDestinationWarehouseName(),
value(c.getTotalQuantity()), c.getStatus())).toList();
@@ -499,23 +498,16 @@ public class DeliveryTransferService {
OutboundOrder existing = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim());
if (existing != null) return replayReturnOutbound(customerId, existing, requestHash);
String warehouseId = request.overseasWarehouseId().trim();
boolean itemMode = request.items() != null;
boolean skuMode = itemMode || StringUtils.hasText(request.skuCode());
Map<String, DeliveryRequests.ReturnSkuOutboundItem> requestedItems = new LinkedHashMap<>();
if (itemMode) {
if (request.items().isEmpty() || StringUtils.hasText(request.skuCode()) || request.quantity() != null || "PALLET".equals(request.unitType()))
throw conflict("散件明细出库不可同时提交统一SKU、数量或整托方式");
for (var line : request.items()) {
if (line == null || !StringUtils.hasText(line.cartonMark()) || !StringUtils.hasText(line.skuCode()) || line.quantity() == null || line.quantity() <= 0
|| requestedItems.putIfAbsent(line.cartonMark().trim(), line) != null) throw conflict("散件出库明细不能为空、数量必须大于0且来源不可重复");
}
}
if (request.quantity() != null && (!StringUtils.hasText(request.skuCode()) || request.quantity() <= 0
|| "PALLET".equals(request.unitType()))) throw conflict("按件数出库必须选择单SKU,不能同时整托出库");
// A return outbound is always a physical package/carton operation. SKU and
// quantity are selection fields for the packing workflow, never a direct
// outbound command. Partial package/carton quantities must be materialized
// into a new labelled carton before they can be picked.
boolean itemMode = request.items() != null && !request.items().isEmpty();
boolean skuMode = itemMode || StringUtils.hasText(request.skuCode()) || request.quantity() != null;
if (skuMode)
throw conflict("退货出库最小单位为包裹/箱;部分数量请先合箱并打印新箱标签后再出库");
List<String> marks = request.cartonMarks().stream().map(String::trim).filter(StringUtils::hasText).distinct().toList();
if (marks.size() != request.cartonMarks().size()) throw conflict("箱唛不能为空且不可重复");
if (itemMode && (marks.size() != requestedItems.size() || !requestedItems.keySet().containsAll(marks)))
throw conflict("散件出库明细与所选来源不一致");
LocalDateTime now = LocalDateTime.now();
List<ReturnInventoryCarton> locked = new ArrayList<>();
for (String mark : marks.stream().sorted().toList()) {
@@ -529,37 +521,18 @@ public class DeliveryTransferService {
existing = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim());
if (existing != null) return replayReturnOutbound(customerId, existing, requestHash);
for (ReturnInventoryCarton carton : locked) {
if (skuMode && !"PARCEL".equals(carton.getSourceUnitType()))
throw conflict("单SKU出库只允许散件库存;箱内库存请按箱或托盘出库");
if ("PARCEL".equals(carton.getSourceUnitType()) && !skuMode)
throw conflict("包裹散件只能按 SKU 出库,不能按箱号或托盘出库");
if ("PACKING_RESERVED".equals(carton.getStatus()))
throw conflict("该包裹/箱正在合箱,请先完成或取消合箱");
com.baiyue.lastleg.returnreceipt.service.ReturnCartonCountGuard.requireConfirmed(carton);
if (!"AVAILABLE".equals(carton.getStatus())) throw conflict("退货箱已被其他作业占用:" + carton.getCartonMark());
var contents = returnCartonItems.lockCarton(carton.getId());
if (contents.isEmpty() || contents.stream().anyMatch(line -> value(line.getPackingReservedQuantity()) > 0))
throw conflict("退货箱缺少明细或已被合箱计划锁定:" + carton.getCartonMark());
String selectedSku = itemMode ? requestedItems.get(carton.getCartonMark()).skuCode().trim() : trim(request.skuCode());
if (skuMode && (contents.size() != 1
|| !selectedSku.equals(contents.get(0).getSkuCode())))
throw conflict("所选散件来源必须仅包含指定SKU");
if (itemMode && requestedItems.get(carton.getCartonMark()).quantity() > value(contents.get(0).getQuantity()))
throw conflict("出库件数超过所选散件来源可用数量:" + carton.getCartonMark());
if ("PALLET".equals(request.unitType()) && !StringUtils.hasText(carton.getPalletCode()))
throw conflict("整托出库必须存在托盘绑定");
}
Map<String, Long> skuQuantities = new LinkedHashMap<>();
if (itemMode) requestedItems.forEach((mark, line) -> skuQuantities.put(mark, line.quantity()));
else if (StringUtils.hasText(request.skuCode())) {
long available = locked.stream().mapToLong(c -> value(c.getTotalQuantity())).sum();
long remaining = request.quantity() == null ? available : request.quantity();
if (remaining > available) throw conflict("出库件数超过所选来源库存数量");
for (ReturnInventoryCarton carton : locked) {
long selected = Math.min(remaining, value(carton.getTotalQuantity()));
if (selected > 0) skuQuantities.put(carton.getCartonMark(), selected);
remaining -= selected;
}
locked = locked.stream().filter(c -> skuQuantities.containsKey(c.getCartonMark())).toList();
}
// No quantity allocation is performed here. The selected physical units
// are shipped whole; partials arrive only as newly-created merge cartons.
if ("PALLET".equals(request.unitType())) {
String pallet = locked.get(0).getPalletCode();
if (locked.stream().anyMatch(c -> !Objects.equals(pallet, c.getPalletCode()))) throw conflict("整托出库只能选择同一托盘");
@@ -580,9 +553,8 @@ public class DeliveryTransferService {
OutboundPalletItem item = new OutboundPalletItem(); item.setId(IdWorker.getId()); item.setCustomerId(customerId);
item.setOutboundOrderId(outbound.getId()); item.setPackageCount(1); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK");
item.setCartonMarks(carton.getCartonMark()); item.setInboundCode(carton.getSourceReturnNo());
// Packed stock is still picked by its physical carton mark; loose parcels use SKU scanning.
item.setSkuCode("PARCEL".equals(carton.getSourceUnitType()) ? itemMode ? requestedItems.get(carton.getCartonMark()).skuCode().trim() : trim(request.skuCode()) : null);
item.setReturnSkuQuantity(skuQuantities.get(carton.getCartonMark()));
// Packages and cartons are picked by their physical barcode.
item.setSkuCode(null); item.setReturnSkuQuantity(null);
item.setPalletNo("PALLET".equals(request.unitType()) ? carton.getPalletCode() : null);
item.setCreatedAt(now); outboundPallets.insert(item);
carton.setStatus("OUTBOUND_PENDING"); carton.setUpdatedAt(now); returnCartons.updateById(carton);
@@ -38,7 +38,19 @@ public final class RelabelResponses {
String destinationFbaWarehouseName, String skuCode, String productNameZh,
long availablePackages, long version, List<InventoryPosition> positions,
String sourceType, Long returnCartonId, String cartonMark,
List<SkuItem> skuItems, long relabelledPackages) {}
List<SkuItem> skuItems, long relabelledPackages, String relabelStatus,
long relabelledQuantity, long relabelReservedQuantity) {
public InventoryCandidate(Long inventoryBalanceId, Long inboundOrderId, String inboundOrderNo, String inboundCode,
String overseasWarehouseId, String overseasWarehouseCode, String overseasWarehouseName,
String destinationFbaWarehouseCode, String destinationFbaWarehouseName, String skuCode, String productNameZh,
long availablePackages, long version, List<InventoryPosition> positions, String sourceType,
Long returnCartonId, String cartonMark, List<SkuItem> skuItems, long relabelledPackages) {
this(inventoryBalanceId, inboundOrderId, inboundOrderNo, inboundCode, overseasWarehouseId,
overseasWarehouseCode, overseasWarehouseName, destinationFbaWarehouseCode, destinationFbaWarehouseName,
skuCode, productNameZh, availablePackages, version, positions, sourceType, returnCartonId, cartonMark,
skuItems, relabelledPackages, null, relabelledPackages, 0);
}
}
public record HistoricalOrder(Long id, String orderNo, String sourceOrderNo, String outboundOrderNo,
String relabelOrderNo, String orderType, String status, Integer packageCount,
LocalDateTime createdAt) {}
@@ -6,6 +6,25 @@ import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface RelabelItemMapper extends BaseMapper<RelabelItem> {
@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 " +
"AND EXISTS(SELECT 1 FROM ll_relabel_pda_unit_fact f WHERE f.item_id=ri.id AND f.result='RELABELLED') " +
"AND NOT EXISTS(SELECT 1 FROM ll_relabel_item newer JOIN ll_relabel_application na ON na.id=newer.application_id " +
"WHERE newer.return_carton_id=ri.return_carton_id AND newer.application_id<>#{currentApplicationId} AND na.status NOT IN ('CANCELLED','REJECTED') " +
"AND (COALESCE(newer.source_carton_mark,newer.carton_mark)=COALESCE(ri.source_carton_mark,ri.carton_mark) " +
"OR COALESCE(newer.source_carton_mark,newer.carton_mark)=(SELECT c.carton_mark FROM ll_return_inventory_carton c WHERE c.id=#{cartonId})) " +
"AND (na.created_at>ra.created_at OR (na.created_at=ra.created_at AND na.id>ra.id))) ORDER BY ri.id")
java.util.List<RelabelItem> priorCompletedReturnItems(@org.apache.ibatis.annotations.Param("customerId") String customerId,
@org.apache.ibatis.annotations.Param("cartonId") long cartonId,
@org.apache.ibatis.annotations.Param("currentApplicationId") long currentApplicationId);
@org.apache.ibatis.annotations.Update("UPDATE ll_relabel_item i JOIN ll_relabel_application a ON a.id=i.application_id " +
"SET i.return_carton_id=#{targetId},i.carton_mark=#{targetMark} WHERE i.customer_id=#{customerId} " +
"AND i.return_carton_id=#{sourceId} AND a.status NOT IN ('CANCELLED','REJECTED') AND a.customer_confirmed_at IS NOT NULL")
int moveCompletedReturnCarton(@org.apache.ibatis.annotations.Param("customerId") String customerId,
@org.apache.ibatis.annotations.Param("sourceId") long sourceId,
@org.apache.ibatis.annotations.Param("targetId") long targetId,
@org.apache.ibatis.annotations.Param("targetMark") String targetMark);
@org.apache.ibatis.annotations.Select("SELECT id FROM ll_inventory_balance WHERE id=#{balanceId} AND customer_id=#{customerId} FOR UPDATE")
Long lockSourceBalance(@org.apache.ibatis.annotations.Param("customerId") String customerId,
@org.apache.ibatis.annotations.Param("balanceId") Long balanceId);
@@ -86,6 +86,13 @@ public class PdaRelabelController {
identity.overseasWarehouseId(), relabelNo, request));
}
@PostMapping("/{relabelNo}/return-cartons/pack")
public ApiResponse<PdaRelabelResponses.Detail> packReturn(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @Valid @RequestBody com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests.BoxMerge request) {
Identity identity = identity(user);
return ApiResponse.success(service.packReturn(identity.operatorKey(), identity.operatorName(), identity.overseasWarehouseId(), relabelNo, request));
}
@PostMapping("/{relabelNo}/return-skus/complete-total")
public ApiResponse<PdaRelabelResponses.Detail> completeReturnSkuTotal(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.BulkSkuTotal request) {
@@ -17,7 +17,13 @@ public final class PdaRelabelRequests {
public record SkuScan(@NotNull @Positive Long itemId, @Min(0) int skuIndex,
@NotBlank @Size(max = 255) String oldSku,
@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 255) String idempotencyKey) { }
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 128) String cartonMark,
@Size(max = 128) String newSkuCode, @Positive Integer quantity) {
public SkuScan(Long itemId, int skuIndex, String oldSku, Integer expectedVersion, String idempotencyKey) {
this(itemId, skuIndex, oldSku, expectedVersion, idempotencyKey, null, null, null);
}
}
public record CartonScan(@NotBlank @Size(max = 255) String cartonMark,
@NotNull @Min(0) Integer expectedVersion,
@@ -40,7 +46,13 @@ public final class PdaRelabelRequests {
public record BulkSku(@NotNull @Positive Long itemId,
@Positive int quantity,
@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 255) String idempotencyKey) { }
@NotBlank @Size(max = 255) String idempotencyKey,
@NotBlank @Size(max = 128) String cartonMark,
@NotBlank @Size(max = 128) String skuCode) {
public BulkSku(Long itemId, int quantity, Integer expectedVersion, String idempotencyKey) {
this(itemId, quantity, expectedVersion, idempotencyKey, null, null);
}
}
public record BulkSkuTotal(@NotBlank @Size(max = 255) String skuCode,
@Positive int quantity,
@@ -20,7 +20,20 @@ public final class PdaRelabelResponses {
String operatorName, String reviewerName, List<Long> completionPhotoFileIds,
String completionRemark, String reviewDecision, String reviewRemark,
LocalDateTime claimedAt, LocalDateTime submittedAt, LocalDateTime reviewedAt,
List<Item> items, List<UnitFact> unitFacts, List<History> history) { }
List<Item> items, List<UnitFact> unitFacts, List<History> history, String deliveryMethod,
boolean requiresPalletBinding) {
public Detail(String relabelNo, String customerOrderNo, LocalDateTime labelDeadline, String contactName,
String contactPhone, String applicationStatus, String operationType, String taskStatus, int expectedPackages,
int relabelledPackages, int skippedPackages, int exceptionPackages, int version, String operatorName,
String reviewerName, List<Long> completionPhotoFileIds, String completionRemark, String reviewDecision,
String reviewRemark, LocalDateTime claimedAt, LocalDateTime submittedAt, LocalDateTime reviewedAt,
List<Item> items, List<UnitFact> unitFacts, List<History> history) {
this(relabelNo, customerOrderNo, labelDeadline, contactName, contactPhone, applicationStatus, operationType,
taskStatus, expectedPackages, relabelledPackages, skippedPackages, exceptionPackages, version,
operatorName, reviewerName, completionPhotoFileIds, completionRemark, reviewDecision, reviewRemark,
claimedAt, submittedAt, reviewedAt, items, unitFacts, history, null, false);
}
}
public record Item(Long itemId, String skuCode, String productNameZh, String shipmentNo,
String trackingCode, String newSkuCode, String labelRequirement,
@@ -29,7 +42,18 @@ public final class PdaRelabelResponses {
List<SkuItem> skuItems,
int expectedPackages, int relabelledPackages, int skippedPackages,
int exceptionPackages, int cartonProcessedPackages, int skuProcessedPackages,
String destinationFbaWarehouseCode) { }
String destinationFbaWarehouseCode, Long returnCartonId, long returnQuantity) {
public Item(Long itemId, String skuCode, String productNameZh, String shipmentNo, String trackingCode,
String newSkuCode, String labelRequirement, String relabelFileId, String relabelFileName, String relabelFileUrl,
String cartonMark, String fbaCartonMark, String operationType, List<SkuItem> skuItems, int expectedPackages,
int relabelledPackages, int skippedPackages, int exceptionPackages, int cartonProcessedPackages,
int skuProcessedPackages, String destinationFbaWarehouseCode) {
this(itemId, skuCode, productNameZh, shipmentNo, trackingCode, newSkuCode, labelRequirement,
relabelFileId, relabelFileName, relabelFileUrl, cartonMark, fbaCartonMark, operationType, skuItems,
expectedPackages, relabelledPackages, skippedPackages, exceptionPackages, cartonProcessedPackages,
skuProcessedPackages, destinationFbaWarehouseCode, null, 0);
}
}
public record SkuItem(String skuCode, String newSkuCode, int quantity, String remark) { }
public record UnitFact(Long factId, Long itemId, String oldLabel, String newLabel,
@@ -52,6 +52,32 @@ import java.util.stream.Collectors;
@Service
public class PdaRelabelService {
@Resource private com.baiyue.lastleg.relabel.service.ReturnRelabelPackingService returnPacking;
@Transactional
public PdaRelabelResponses.Detail packReturn(String operatorKey, String operatorName, long warehouseId,
String relabelNo, com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests.BoxMerge request) {
UnitCommand command = new UnitCommand(relabelNo, "RETURN_PACK", request);
var replay = replay(operatorKey, warehouseId, relabelNo, "RETURN_PACK", request.idempotencyKey(), command);
if (replay != null) return replay;
var app = requireApplication(relabelNo, warehouseId, true);
var task = requireOwnedTask(app, operatorKey, true);
var selected = itemList(app).stream().filter(i -> i.getReturnCartonId() != null
&& Set.of("RETURN_BULK_SKU_LABEL", "SKU_LABEL").contains(operationType(i))).toList();
if (selected.isEmpty() || selected.size() != itemList(app).size()) throw conflict("仅退货按件换标任务可使用此合箱操作");
var facts = factList(task);
for (var item : selected) if (quantity(facts.stream().filter(f -> item.getId().equals(f.getItemId())).toList(), "RELABELLED")
!= value(item.getNotifiedPackageCount())) throw conflict("请先完成每个来源包裹/箱的换标数量登记,再合箱");
var sourceDeductions = returnPacking.deductions(selected);
var packed = returnPacking.pack(new com.baiyue.lastleg.pda.security.PdaWarehouseIdentity(operatorKey, operatorName, warehouseId), app, selected, request);
updateTask(task, task.getVersion());
insertHistory(task, "RETURN_PACK", PROCESSING, PROCESSING, operatorKey, operatorName,
Map.of("cartonMark", packed.cartonMark(), "quantity", packed.totalQuantity(), "label", packed.label(),
"round", mixedRound(task), "sourceCartonMarks", request.sourceCartonMarks(), "sourceDeductions", sourceDeductions));
var result = detail(app, task);
saveOperation(task, operatorKey, "RETURN_PACK", request.idempotencyKey(), command, result);
return result;
}
@Resource private com.baiyue.lastleg.relabel.service.RelabelBindingGuard bindingGuard;
public static final String AVAILABLE = "AVAILABLE";
public static final String PROCESSING = "PROCESSING";
@@ -230,11 +256,16 @@ public class PdaRelabelService {
var task = requireOwnedTask(app, operatorKey, true);
requireVersion(task, request.expectedVersion());
var item = requireItem(app, request.itemId());
if (item.getReturnCartonId() != null && !Objects.equals(item.getCartonMark(), trim(request.cartonMark())))
throw conflict("请扫描当前来源包裹/箱号后再扫描 SKU");
if (!Set.of("SKU_LABEL", "CARTON_AND_SKU").contains(operationType(item))) throw conflict("该箱没有SKU换标任务");
if (value(item.getNotifiedPackageCount()) != 1) throw conflict("SKU逐条作业要求每个仓库唛头独立一箱");
var skus = readSkuItems(item.getSkuRelabelItemsJson());
if (request.skuIndex() < 0 || request.skuIndex() >= skus.size()) throw conflict("SKU明细不存在");
var sku = skus.get(request.skuIndex());
if (item.getReturnCartonId() != null && (!Objects.equals(sku.newSkuCode(), trim(request.newSkuCode()))
|| !Objects.equals(sku.quantity(), request.quantity())))
throw conflict("新 SKU 和件数必须与已批准的来源箱明细一致");
if (!Objects.equals(sku.skuCode(), request.oldSku().trim()) || !StringUtils.hasText(sku.newSkuCode()) || sku.quantity() <= 0)
throw conflict("旧SKU或绑定的新标签、数量不正确");
if (factList(task).stream().anyMatch(fact -> item.getId().equals(fact.getItemId()))
@@ -275,6 +306,9 @@ public class PdaRelabelService {
requireVersion(task, request.expectedVersion());
RelabelItem item = requireItem(application, request.itemId());
if (!"RETURN_BULK_SKU_LABEL".equals(operationType(item))) throw conflict("当前明细不是退货按件贴标任务");
if (!Objects.equals(item.getCartonMark(), trim(request.cartonMark()))
|| !Objects.toString(item.getSkuCode(), "").equalsIgnoreCase(Objects.toString(trim(request.skuCode()), "")))
throw conflict("请扫描当前来源包裹/箱号和对应 SKU,不能登记其他来源");
int completed = quantity(factList(task).stream().filter(fact -> item.getId().equals(fact.getItemId())).toList(), "RELABELLED");
int remaining = value(item.getNotifiedPackageCount()) - completed;
if (request.quantity() > remaining) throw conflict("本次完成数量超过该 SKU 剩余贴标数量");
@@ -309,6 +343,8 @@ public class PdaRelabelService {
.filter(item -> skuCode.equalsIgnoreCase(Objects.toString(item.getSkuCode(), "").trim()))
.toList();
if (skuItems.isEmpty()) throw notFound("当前换标单没有该退货 SKU");
if (skuItems.stream().map(RelabelItem::getCartonMark).distinct().count() != 1)
throw conflict("同 SKU 来自多个包裹/箱,请逐来源扫码登记,不能自动分配");
List<PdaRelabelUnitFact> existingFacts = factList(task);
Map<Long, Integer> completedByItem = new HashMap<>();
for (RelabelItem item : skuItems) {
@@ -574,6 +610,9 @@ public class PdaRelabelService {
throw conflict("Relabel item already has all required package facts");
}
String normalizedOldLabel = trim(oldLabel);
// A merged return carton can carry several independent SKU task items.
// Product-label facts are unique by item; old_label's global key is for physical carton-label scans.
if (item.getReturnCartonId() != null && "SKU_LABEL".equals(operationType(item))) normalizedOldLabel = null;
if (normalizedOldLabel != null && unitFacts.findOldLabel(normalizedOldLabel) != null) {
throw conflict("Old label was already processed by another relabel unit");
}
@@ -746,7 +785,8 @@ public class PdaRelabelService {
quantity(itemFacts, "EXCEPTION"),
count(itemFacts, "RELABELLED") + (itemFacts.isEmpty() && mixedStageDone(task, item.getId(), "CARTON") ? 1 : 0),
count(itemFacts, "RELABELLED") + (itemFacts.isEmpty() && mixedStageDone(task, item.getId(), "SKU") ? 1 : 0),
item.getDestinationFbaWarehouseCode());
item.getDestinationFbaWarehouseCode(), item.getReturnCartonId(),
returnQuantity(item));
}).toList();
List<PdaRelabelResponses.UnitFact> factResponses = facts.stream().map(fact ->
new PdaRelabelResponses.UnitFact(fact.getId(), fact.getItemId(), fact.getOldLabel(), fact.getNewLabel(),
@@ -768,7 +808,7 @@ public class PdaRelabelService {
task == null ? null : task.getCompletionRemark(), task == null ? null : task.getReviewDecision(),
task == null ? null : task.getReviewRemark(), task == null ? null : task.getClaimedAt(),
task == null ? null : task.getSubmittedAt(), task == null ? null : task.getReviewedAt(),
itemResponses, factResponses, historyResponses);
itemResponses, factResponses, historyResponses, application.getDeliveryMethod(), "TRUCK".equals(application.getDeliveryMethod()));
}
private String effectiveTaskStatus(RelabelApplication application, PdaRelabelTask task) {
@@ -841,6 +881,10 @@ public class PdaRelabelService {
List.class, PdaRelabelResponses.SkuItem.class));
} catch (JsonProcessingException exception) { throw new IllegalStateException("Cannot read relabel SKU details", exception); }
}
private long returnQuantity(RelabelItem item) {
var carton = item.getReturnCartonId() == null ? null : returnCartons.selectById(item.getReturnCartonId());
return carton == null || carton.getTotalQuantity() == null ? 0L : carton.getTotalQuantity();
}
private static String operationType(RelabelItem item) {
return StringUtils.hasText(item.getOperationType()) ? item.getOperationType() : "CARTON_LABEL";
}
@@ -264,7 +264,7 @@ public class RelabelApplicationService implements WalletResultHandler {
balance.getProductNameZh(), available, value(balance.getVersion()), positions,
"INBOUND", null, null, List.of(), 0);
}).filter(candidate -> candidate.availablePackages() > 0).toList());
for (ReturnInventoryCarton carton : returnCartons.findAvailable(customerId, operationWarehouseId.trim())) {
for (ReturnInventoryCarton carton : returnCartons.findRelabelCandidates(customerId, operationWarehouseId.trim())) {
String keyword = trim(inboundCode);
if (StringUtils.hasText(keyword)
&& !containsIgnoreCase(carton.getCartonMark(), keyword)
@@ -272,22 +272,22 @@ public class RelabelApplicationService implements WalletResultHandler {
List<RelabelResponses.SkuItem> cartonSkus = returnCartonItems.listCarton(carton.getId()).stream()
.filter(item -> value(item.getQuantity()) > 0)
.map(item -> {
long used = returnCartons.relabelledQuantity(carton.getId(), item.getSkuCode());
long remaining = value(item.getQuantity()) - used - value(item.getPackingReservedQuantity());
long remaining = "AVAILABLE".equals(carton.getStatus())
? value(item.getQuantity()) - value(item.getPackingReservedQuantity()) : 0;
return new RelabelResponses.SkuItem(item.getSkuCode(), null,
Math.toIntExact(Math.max(remaining, 0)), null);
}).filter(item -> item.quantity() > 0).toList();
}).toList();
if (cartonSkus.isEmpty()) continue;
String skuCodes = cartonSkus.stream().map(RelabelResponses.SkuItem::skuCode)
.collect(Collectors.joining(","));
long availablePackages = cartonSkus.stream().mapToLong(RelabelResponses.SkuItem::quantity).sum();
if (availablePackages <= 0) continue;
long relabelledPackages = carton.getTotalQuantity() == null ? 0
: Math.max(0, carton.getTotalQuantity() - availablePackages);
long relabelledPackages = returnCartons.totalRelabelledQuantity(carton.getId());
long reserved = returnCartons.relabelReservedQuantity(carton.getId());
result.add(new RelabelResponses.InventoryCandidate(null, null, carton.getSourceReturnNo(),
null, carton.getOverseasWarehouseId(), null, null, carton.getDestinationWarehouseCode(),
carton.getDestinationWarehouseName(), skuCodes, "退货入库库存", availablePackages, 0, List.of(),
"PARCEL".equals(carton.getSourceUnitType()) ? "RETURN_PARCEL" : "RETURN_CARTON", carton.getId(), carton.getCartonMark(), cartonSkus, relabelledPackages));
"PARCEL".equals(carton.getSourceUnitType()) ? "RETURN_PARCEL" : "RETURN_CARTON", carton.getId(), carton.getCartonMark(), cartonSkus, relabelledPackages,
com.baiyue.lastleg.returnreceipt.service.ReturnRelabelProgress.status(value(carton.getTotalQuantity()), relabelledPackages, reserved), relabelledPackages, reserved));
}
return result;
}
@@ -370,6 +370,7 @@ public class RelabelApplicationService implements WalletResultHandler {
Set<String> selectedCartonMarks = new HashSet<>();
Map<Long, ReturnInventoryCarton> selectedReturnCartons = new LinkedHashMap<>();
Map<Long, Long> selectedReturnPackages = new LinkedHashMap<>();
Map<Long, Map<String, Long>> selectedReturnSkus = new LinkedHashMap<>();
for (RelabelRequests.Item item : request.items()) {
validateOperation(item);
if (item.notifiedPackageCount() > item.totalPackageCount()) {
@@ -401,9 +402,8 @@ public class RelabelApplicationService implements WalletResultHandler {
final long sourceReturnCartonId = carton.getId();
Map<String, Long> availableSku = returnCartonItems.lockCarton(sourceReturnCartonId).stream()
.collect(Collectors.toMap(line -> line.getSkuCode().toUpperCase(java.util.Locale.ROOT),
line -> Math.max(0, value(line.getQuantity()) - value(line.getPackingReservedQuantity())
- returnCartons.relabelledQuantity(sourceReturnCartonId, line.getSkuCode())), Long::sum));
Map<String, Long> requestedSku = new java.util.HashMap<>();
line -> Math.max(0, value(line.getQuantity()) - value(line.getPackingReservedQuantity())), Long::sum));
Map<String, Long> requestedSku = selectedReturnSkus.computeIfAbsent(carton.getId(), ignored -> new java.util.HashMap<>());
if (item.skuItems() != null) for (var skuItem : item.skuItems()) {
long quantity = requestedSku.merge(skuItem.skuCode().toUpperCase(java.util.Locale.ROOT), (long) skuItem.quantity(), Long::sum);
if (quantity > availableSku.getOrDefault(skuItem.skuCode().toUpperCase(java.util.Locale.ROOT), 0L))
@@ -414,10 +414,9 @@ public class RelabelApplicationService implements WalletResultHandler {
if (Set.of("CARTON_LABEL", "CARTON_AND_SKU").contains(item.operationType())
&& (item.totalPackageCount() != 1 || item.notifiedPackageCount() != 1))
throw conflict("退货箱唛换标按真实箱计数,每个来源箱只能填写一箱");
long previouslyRelabelled = returnCartons.relabelledQuantity(carton.getId(), item.skuCode());
long requestedPackages = selectedReturnPackages.merge(carton.getId(),
(long) item.notifiedPackageCount(), Math::addExact);
if (requestedPackages + previouslyRelabelled > value(carton.getTotalQuantity())) {
if (requestedPackages > value(carton.getTotalQuantity())) {
throw conflict("退货托盘 " + carton.getCartonMark() + " 可用箱数不足");
}
sources.add(new SourceInventory(item, null, carton));
@@ -495,7 +494,8 @@ public class RelabelApplicationService implements WalletResultHandler {
}
}
for (String returnNo : selectedReturnCartons.values().stream().map(ReturnInventoryCarton::getSourceReturnNo)
.filter(StringUtils::hasText).distinct().sorted().toList()) {
.filter(StringUtils::hasText).flatMap(value -> java.util.Arrays.stream(value.split(",")))
.map(String::trim).filter(StringUtils::hasText).distinct().sorted().toList()) {
ReturnOrder returnOrder = returnOrders.lockCustomerNo(customerId, returnNo);
if (returnOrder != null) {
returnOrder.setRelabelApplicationId(applicationId);
@@ -618,11 +618,15 @@ public class RelabelApplicationService implements WalletResultHandler {
}
if (next.equals(application.getStatus())) return detail(customerId, relabelNo);
validateTransition(application.getStatus(), next);
if (Set.of("REJECTED", "CANCELLED").contains(next) && itemList(application.getId()).stream()
.filter(item -> item.getReturnCartonId() != null)
.anyMatch(item -> returnCartons.totalRelabelledQuantity(item.getReturnCartonId()) > 0))
throw conflict("该退货任务已有实际商品换标事实,不能取消并恢复为未换标库存;请按异常完工或退回重做处理");
if (PENDING_REVIEW.equals(application.getStatus()) && PENDING_PROCESSING.equals(next)
&& itemList(application.getId()).stream().anyMatch(item -> "RETURN_BULK_SKU_LABEL".equals(operationType(item)))) {
boolean hasCartonFile = StringUtils.hasText(application.getCartonTotalFileId());
boolean hasSkuFile = StringUtils.hasText(application.getSkuTotalFileId());
if (hasCartonFile == hasSkuFile) throw conflict("退货贴标审核前必须且只能上传一个箱唛总文件或 SKU 总文件");
if (!hasCartonFile && !hasSkuFile) throw conflict("退货贴标审核前必须上传箱唛总文件或 SKU 总文件,两类资料可分别上传");
}
if ("PROCESSING".equals(next)
&& !Set.of("FROZEN", "NOT_REQUIRED").contains(application.getWalletStatus())) {
@@ -686,11 +690,10 @@ public class RelabelApplicationService implements WalletResultHandler {
insertOrderHistory(customerId, order.getId(), orderBefore, orderStatus, request.remark(), now);
}
}
ReturnOrder returnOrder = returnOrders.selectOne(new LambdaQueryWrapper<ReturnOrder>()
List<ReturnOrder> linkedReturnOrders = returnOrders.selectList(new LambdaQueryWrapper<ReturnOrder>()
.eq(ReturnOrder::getRelabelApplicationId, application.getId())
.eq(ReturnOrder::getCustomerId, customerId)
.last("LIMIT 1"));
if (returnOrder != null) {
.eq(ReturnOrder::getCustomerId, customerId));
for (ReturnOrder returnOrder : linkedReturnOrders) {
returnOrder.setRelabelStatus(next);
returnOrder.setUpdatedAt(now);
returnOrders.updateById(returnOrder);
@@ -938,11 +941,10 @@ public class RelabelApplicationService implements WalletResultHandler {
request.remark(), now);
}
}
ReturnOrder returnOrder = returnOrders.selectOne(new LambdaQueryWrapper<ReturnOrder>()
List<ReturnOrder> completedReturnOrders = returnOrders.selectList(new LambdaQueryWrapper<ReturnOrder>()
.eq(ReturnOrder::getRelabelApplicationId, application.getId())
.eq(ReturnOrder::getCustomerId, customerId)
.last("LIMIT 1"));
if (returnOrder != null) {
.eq(ReturnOrder::getCustomerId, customerId));
for (ReturnOrder returnOrder : completedReturnOrders) {
returnOrder.setRelabelStatus("COMPLETED");
returnOrder.setUpdatedAt(now);
returnOrders.updateById(returnOrder);
@@ -961,6 +963,7 @@ public class RelabelApplicationService implements WalletResultHandler {
carton.setStatus(StringUtils.hasText(application.getOutboundOrderNo()) ? "OUTBOUND_PENDING"
: awaitingTruck ? "RELABEL_PENDING" : "AVAILABLE");
carton.setUpdatedAt(now); returnCartons.updateById(carton);
if (carton.getRelabelApplicationId() == null) returnCartons.clearRelabel(carton.getId());
}
insertHistory(customerId, application.getId(), before, application.getStatus(), operatorId,
request.remark(), now);
@@ -1295,6 +1298,7 @@ public class RelabelApplicationService implements WalletResultHandler {
&& "RELABEL_PENDING".equals(carton.getStatus())) {
carton.setRelabelApplicationId(null); carton.setRelabelNo(null); carton.setRelabelStatus(null);
carton.setStatus("AVAILABLE"); carton.setUpdatedAt(LocalDateTime.now()); returnCartons.updateById(carton);
returnCartons.clearRelabel(carton.getId());
}
continue;
}
@@ -22,12 +22,13 @@ public class RelabelBindingGuard {
@Resource private PdaRelabelHistoryMapper histories;
@Resource private InventoryPositionMapper positions;
@Resource private ReturnInventoryCartonMapper returnCartons;
@Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper returnLines;
@Resource private ObjectMapper objectMapper;
public void requireBound(RelabelApplication app) {
List<RelabelItem> cartons = items.selectList(new QueryWrapper<RelabelItem>()
.eq("application_id", app.getId()).eq("customer_id", app.getCustomerId()))
.stream().filter(item -> Set.of("CARTON_LABEL", "CARTON_AND_SKU", "RETURN_BULK_SKU_LABEL")
.stream().filter(item -> (item.getReturnCartonId() != null && "SKU_LABEL".equals(item.getOperationType())) || Set.of("CARTON_LABEL", "CARTON_AND_SKU", "RETURN_BULK_SKU_LABEL")
.contains(item.getOperationType())).toList();
if (cartons.isEmpty()) return;
Map<String, Set<String>> bindings = new HashMap<>();
@@ -44,7 +45,7 @@ public class RelabelBindingGuard {
} catch (Exception e) { throw conflict("组托记录无法读取,请核对后重试"); }
}
for (RelabelItem item : cartons) {
if (item.getReturnCartonId() != null && !"RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) {
if (item.getReturnCartonId() != null && !Set.of("RETURN_BULK_SKU_LABEL", "SKU_LABEL").contains(item.getOperationType())) {
var carton = returnCartons.selectById(item.getReturnCartonId());
if (carton == null || !app.getCustomerId().equals(carton.getCustomerId())) throw conflict("退货箱来源缺失");
if (!"TRUCK".equals(app.getDeliveryMethod())) continue;
@@ -52,7 +53,20 @@ public class RelabelBindingGuard {
throw conflict("退货箱尚未完成排车托盘绑定");
continue;
}
if ("RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) {
if (Set.of("RETURN_BULK_SKU_LABEL", "SKU_LABEL").contains(item.getOperationType()) && item.getReturnCartonId() != null) {
var sameCarton = cartons.stream().filter(i -> Objects.equals(i.getReturnCartonId(), item.getReturnCartonId())).toList();
var stock = item.getReturnCartonId() == null ? List.<com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem>of() : returnLines.lockCarton(item.getReturnCartonId());
var selected = new HashMap<String, Long>();
for (var i : sameCarton) {
if ("RETURN_BULK_SKU_LABEL".equals(i.getOperationType())) selected.merge(i.getSkuCode().toUpperCase(Locale.ROOT), (long)i.getNotifiedPackageCount(), Long::sum);
else try {
for (var sku : objectMapper.readTree(i.getSkuRelabelItemsJson())) selected.merge(sku.path("skuCode").asText().toUpperCase(Locale.ROOT), sku.path("quantity").asLong(), Long::sum);
} catch (Exception failure) { throw conflict("换标明细无法读取"); }
}
var actual = new HashMap<String, Long>();
stock.forEach(i -> actual.merge(i.getSkuCode().toUpperCase(Locale.ROOT), i.getQuantity(), Long::sum));
if (!selected.equals(actual)) throw conflict("部分换标货物必须先合箱生成独立新箱,再提交完工");
if (!"TRUCK".equals(app.getDeliveryMethod())) continue;
var carton = item.getReturnCartonId() == null ? null : returnCartons.selectById(item.getReturnCartonId());
if (carton == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(carton.getPalletCode())
|| !"PROCESSING".equals(carton.getRelabelStatus()))
@@ -0,0 +1,136 @@
package com.baiyue.lastleg.relabel.service;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests;
import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.util.*;
/** Called inside the owned PDA task transaction; materializes only its completed pieces. */
@Service
public class ReturnRelabelPackingService {
@Resource private ReturnInventoryService inventory;
@Resource private ReturnInventoryCartonMapper cartons;
@Resource private RelabelItemMapper items;
@Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper cartonItems;
@Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnRelabelCompletionLedgerMapper completionLedger;
@Resource private com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelUnitFactMapper facts;
@Resource private com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper histories;
@Resource private com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper tasks;
@Resource private com.fasterxml.jackson.databind.ObjectMapper json;
@org.springframework.transaction.annotation.Transactional
public ReturnInventoryResponses.MergeResult pack(PdaWarehouseIdentity identity, RelabelApplication app,
List<RelabelItem> selected, ReturnInventoryRequests.BoxMerge request) {
if (request.targetCartonMark() != null || selected.isEmpty()) throw conflict("换标合箱必须生成独立新箱");
var marks = selected.stream().map(RelabelItem::getCartonMark).distinct().sorted().toList();
if (!new HashSet<>(marks).equals(new HashSet<>(request.sourceCartonMarks()))) throw conflict("请扫描本次换标全部来源包裹/箱");
if (selected.stream().map(RelabelItem::getDestinationFbaWarehouseCode).distinct().count() != 1)
throw conflict("不同目的仓不能合箱");
var deductions = deductions(selected);
if (request.sourceDeductions() != null && !totals(deductions).equals(totals(request.sourceDeductions())))
throw conflict("合箱数量必须等于本次已完成换标的明细数量");
var inheritedTransfers = new ArrayList<Map<String, Object>>();
for (String mark : marks) {
var source = cartons.lockMark(mark);
if (source == null || !app.getCustomerId().equals(source.getCustomerId())
|| !app.getId().equals(source.getRelabelApplicationId()) || !"RELABEL_PENDING".equals(source.getStatus()))
throw conflict("来源包裹/箱已变化,请刷新");
var prior = items.priorCompletedReturnItems(app.getCustomerId(), source.getId(), app.getId());
if (!prior.isEmpty()) {
var ids = prior.stream().map(i -> i.getId().toString()).toList();
var raw = inheritedQuantities(prior);
var physical = new HashMap<String, Long>();
cartonItems.lockCarton(source.getId()).forEach(i -> physical.merge(i.getSkuCode().toUpperCase(Locale.ROOT), i.getQuantity(), Math::addExact));
var moved = new ArrayList<Map<String, Object>>();
for (var entry : totals(deductions).entrySet()) {
if (!mark.equals(entry.getKey().get(0))) continue;
String sku = entry.getKey().get(1).toUpperCase(Locale.ROOT);
long inherited = raw.getOrDefault(sku, 0L) - completionLedger.deductedQuantity(ids, sku);
long stock = physical.getOrDefault(sku, 0L);
if (inherited < 0 || inherited > stock) throw conflict("历史换标实物归属与来源库存不一致,请核对来源");
if (inherited > 0 && inherited < stock && entry.getValue() < stock)
throw conflict("已换标与未换标货物来源不能自动猜测,请整箱隔离后重新换标");
long transfer = Math.min(inherited, entry.getValue());
if (transfer > 0) moved.add(Map.of("skuCode", sku, "quantity", transfer));
}
if (!moved.isEmpty()) inheritedTransfers.add(Map.of("sourceItemIds", ids, "sourceCartonMark", mark, "items", moved));
}
source.setStatus("AVAILABLE"); cartons.updateById(source);
}
var result = inventory.mergeBoxes(identity, new ReturnInventoryRequests.BoxMerge(marks, null,
"RELABEL-PACK:" + app.getId() + ":" + request.idempotencyKey(), deductions,
request.length(), request.width(), request.height(), request.weight()));
for (String mark : marks) {
var source = cartons.lockMark(mark);
cartons.clearRelabel(source.getId());
}
var target = cartons.lockMark(result.cartonMark());
target.setStatus("RELABEL_PENDING"); target.setRelabelApplicationId(app.getId());
target.setRelabelNo(app.getRelabelNo()); target.setRelabelStatus("PROCESSING");
target.setDestinationWarehouseCode(selected.get(0).getDestinationFbaWarehouseCode());
cartons.updateById(target);
for (var item : selected) {
item.setReturnCartonId(target.getId()); item.setCartonMark(target.getCartonMark());
items.updateById(item);
}
if (!inheritedTransfers.isEmpty()) {
var task = tasks.find(app.getCustomerId(), app.getId());
for (var transfer : inheritedTransfers) {
var detail = new LinkedHashMap<String, Object>(transfer);
detail.put("targetCartonMark", result.cartonMark()); detail.put("targetApplicationId", app.getId().toString());
var history = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory();
history.setId(com.baomidou.mybatisplus.core.toolkit.IdWorker.getId()); history.setTaskId(task.getId());
history.setApplicationId(app.getId()); history.setCustomerId(app.getCustomerId());
history.setAction("RETURN_COMPLETION_TRANSFER"); history.setFromStatus("PROCESSING"); history.setToStatus("PROCESSING");
history.setOperatorKey(identity.operatorKey()); history.setOperatorName(identity.operatorName());
try { history.setDetailJson(json.writeValueAsString(detail)); }
catch (java.io.IOException failure) { throw new IllegalStateException(failure); }
history.setCreatedAt(java.time.LocalDateTime.now()); histories.insert(history);
}
}
return result;
}
private Map<String, Long> inheritedQuantities(List<RelabelItem> prior) {
var completed = facts.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact>()
.in("item_id", prior.stream().map(RelabelItem::getId).toList()).eq("result", "RELABELLED"));
var result = new HashMap<String, Long>();
for (var item : prior) {
long count = completed.stream().filter(f -> item.getId().equals(f.getItemId())).mapToLong(f -> f.getQuantity() == null ? 1 : f.getQuantity()).sum();
if ("RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) result.merge(item.getSkuCode().toUpperCase(Locale.ROOT), count, Math::addExact);
else for (var deduction : deductions(List.of(item)))
result.merge(deduction.skuCode().toUpperCase(Locale.ROOT), Math.multiplyExact(count, deduction.quantity()), Math::addExact);
}
return result;
}
public List<ReturnInventoryRequests.SourceDeduction> deductions(List<RelabelItem> selected) {
var deductions = new ArrayList<ReturnInventoryRequests.SourceDeduction>();
for (var i : selected) {
if ("RETURN_BULK_SKU_LABEL".equals(i.getOperationType())) deductions.add(new ReturnInventoryRequests.SourceDeduction(i.getCartonMark(), i.getSkuCode(), i.getNotifiedPackageCount()));
else try {
for (var sku : json.readTree(i.getSkuRelabelItemsJson())) deductions.add(new ReturnInventoryRequests.SourceDeduction(i.getCartonMark(), sku.path("skuCode").asText(), sku.path("quantity").asLong()));
} catch (java.io.IOException failure) { throw conflict("换标 SKU 明细无法读取"); }
}
return deductions;
}
private Map<List<String>, Long> totals(List<ReturnInventoryRequests.SourceDeduction> lines) {
var result = new LinkedHashMap<List<String>, Long>();
for (var line : lines) {
if (line.quantity() <= 0) throw conflict("合箱扣减数量必须为正数");
result.merge(List.of(line.cartonMark(), line.skuCode()), line.quantity(), Math::addExact);
}
return result;
}
private RuntimeException conflict(String reason) {
return new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, reason);
}
}
@@ -24,6 +24,11 @@ public class PdaReturnInventoryController {
@Resource private ReturnInventoryService service;
@Resource private com.baiyue.lastleg.returnreceipt.service.ReturnCartonCountService counting;
@PostMapping("/cartons/{cartonMark}/confirm-label")
public ApiResponse<ReturnInventoryResponses.Label> confirmLabel(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String cartonMark) {
return ApiResponse.success(service.confirmLabel(PdaWarehouseIdentity.require(user), cartonMark));
}
@GetMapping("/counting/tasks")
public ApiResponse<com.baiyue.common.core.api.PageResult<com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.Task>> countingTasks(
@AuthenticationPrincipal AuthenticatedUser user, @Valid com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.Query query) {
@@ -45,9 +45,13 @@ public final class ReturnInventoryRequests {
@NotEmpty @Size(max = 100) List<@Valid PlanBox> boxes,
@Size(max = 1000) String remark,
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 500) List<@NotBlank String> sourceCartonMarks) {
@Size(max = 500) List<@NotBlank String> sourceCartonMarks,
@Size(max = 1000) List<@Valid SourceDeduction> sourceDeductions) {
public PackingPlanCreate(List<String> returnNos, String mode, List<PlanBox> boxes, String remark, String idempotencyKey, List<String> sourceCartonMarks) {
this(returnNos, mode, boxes, remark, idempotencyKey, sourceCartonMarks, null);
}
public PackingPlanCreate(List<String> returnNos, String mode, List<PlanBox> boxes, String remark, String idempotencyKey) {
this(returnNos, mode, boxes, remark, idempotencyKey, List.of());
this(returnNos, mode, boxes, remark, idempotencyKey, List.of(), null);
}
}
@@ -12,7 +12,8 @@ public final class ReturnInventoryResponses {
public record CartonSummary(String cartonMark, String overseasWarehouseId, String sourceBatchNo,
String sourceReturnNo, String status, int skuTypes, long totalQuantity,
BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight,
LocalDateTime createdAt) { }
LocalDateTime createdAt, String relabelStatus, long relabelledQuantity,
long relabelReservedQuantity) { }
public record InventoryItem(String overseasWarehouseId, String cartonMark, String sourceBatchNo, String status, String skuCode,
String productNameZh, long quantity, int cartonSkuTypes, long cartonTotalQuantity,
@@ -23,7 +24,8 @@ public final class ReturnInventoryResponses {
String fbaCartonMark, String destinationWarehouseCode, String destinationWarehouseName, String relabelNo, String relabelStatus,
int skuTypes, long totalQuantity, BigDecimal length, BigDecimal width, BigDecimal height,
BigDecimal weight, LocalDateTime receivedAt, List<CartonItem> items,
String locationCode, String palletCode, LocalDateTime putawayAt, List<MergeHistory> mergeHistory) { }
String locationCode, String palletCode, LocalDateTime putawayAt, List<MergeHistory> mergeHistory,
long relabelledQuantity, long relabelReservedQuantity) { }
public record MergeHistory(String planNo, String sourceCartonMark, String targetCartonMark, String returnNo,
String skuCode, long quantity, LocalDateTime createdAt) { }
public record CartonItem(String skuCode, String productNameZh, long quantity, long availableQuantity) {
@@ -57,7 +59,8 @@ public final class ReturnInventoryResponses {
String sourceUnitType, String sourceBatchNo, String status, int skuTypes, long totalQuantity,
String fbaCartonMark, String destinationWarehouseCode, String destinationWarehouseName, String relabelNo, String relabelStatus,
BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight,
LocalDateTime createdAt, String locationCode, String palletCode, LocalDateTime putawayAt) { }
LocalDateTime createdAt, String locationCode, String palletCode, LocalDateTime putawayAt,
long relabelledQuantity, long relabelReservedQuantity) { }
public record PlatformMovement(String customerId, String overseasWarehouseId, String skuCode, String productNameZh,
String movementType, String referenceNo, long totalDelta, long availableDelta,
LocalDateTime occurredAt) { }
@@ -35,6 +35,8 @@ public final class ReturnOrderRequests {
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String cartonRemark) {}
public record RelabelItem(@NotBlank @Size(max = 128) String skuCode,
@Positive long quantity) {}
@Positive long quantity, @Size(max = 128) String cartonMark) {
public RelabelItem(String skuCode, long quantity) { this(skuCode, quantity, null); }
}
public record Review(@NotBlank String decision, @Size(max = 500) String remark) { }
}
@@ -31,6 +31,8 @@ public class ReturnInventoryCarton {
private LocalDateTime relabelCompletedAt;
private String status;
private String countStatus = "CONFIRMED";
/** WAIT_LABEL until the operator scans the affixed warehouse label; null for historical units. */
private String labelStatus;
private LocalDateTime countedAt;
private String countedBy;
private Integer skuTypes;
@@ -15,7 +15,9 @@ public interface ReturnInventoryBalanceMapper extends BaseMapper<ReturnInventory
@Update("UPDATE ll_return_inventory_balance SET total_quantity=total_quantity-#{quantity}, " +
"available_quantity=available_quantity-#{quantity},updated_at=NOW(3) " +
"WHERE id=#{id} AND customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} " +
"AND total_quantity>=#{quantity} AND available_quantity>=#{quantity} AND carton_quantity>=1")
// PARCEL is a labelled physical unit but is not counted as a box in
// carton_quantity. Piece shipment must therefore only guard quantities.
"AND total_quantity>=#{quantity} AND available_quantity>=#{quantity}")
int deductOutboundPieces(@Param("id") long id, @Param("customerId") String customerId,
@Param("warehouseId") String warehouseId, @Param("quantity") long quantity);
String FREE_STOCK_COLUMNS = "b.id,b.customer_id,b.overseas_warehouse_id,b.sku_code,b.product_name_zh,b.total_quantity,b.carton_quantity,b.created_at,b.updated_at," +
@@ -10,6 +10,8 @@ import java.util.List;
@Mapper
public interface ReturnInventoryCartonMapper extends BaseMapper<ReturnInventoryCarton> {
@Update("UPDATE ll_return_inventory_carton SET relabel_application_id=NULL,relabel_no=NULL,relabel_status=NULL WHERE id=#{id}")
int clearRelabel(@Param("id") long id);
@Select("SELECT * FROM ll_return_inventory_carton WHERE source_receipt_id=#{receiptId} ORDER BY id FOR UPDATE")
List<ReturnInventoryCarton> lockReceipt(@Param("receiptId") long receiptId);
@Select("SELECT * FROM ll_return_inventory_carton WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} AND status='AVAILABLE' AND count_status='CONFIRMED' ORDER BY carton_mark")
@@ -37,6 +39,9 @@ public interface ReturnInventoryCartonMapper extends BaseMapper<ReturnInventoryC
"<if test='warehouseId != null and warehouseId != \"\"'>AND overseas_warehouse_id=#{warehouseId} </if>" +
"ORDER BY created_at DESC,id DESC LIMIT 500</script>")
List<ReturnInventoryCarton> findAvailable(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId);
@Select("SELECT * FROM ll_return_inventory_carton WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} " +
"AND count_status='CONFIRMED' AND status IN ('AVAILABLE','RELABEL_PENDING','PACKING_RESERVED') ORDER BY created_at DESC,id DESC LIMIT 500")
List<ReturnInventoryCarton> findRelabelCandidates(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId);
@Select("SELECT * FROM ll_return_inventory_carton WHERE carton_mark=#{cartonMark} LIMIT 1")
ReturnInventoryCarton findMark(@Param("cartonMark") String cartonMark);
@@ -57,12 +62,46 @@ public interface ReturnInventoryCartonMapper extends BaseMapper<ReturnInventoryC
@Update("UPDATE ll_return_inventory_carton SET status='AVAILABLE',updated_at=NOW(3) WHERE id=#{id} AND customer_id=#{customerId} AND status='DISCARD_PENDING'")
int releaseDiscard(@Param("id") long id, @Param("customerId") String customerId);
@Select("SELECT COALESCE(SUM(ri.notified_package_count),0) FROM ll_relabel_item ri " +
String CURRENT_RELABEL_ROUND = " AND (ri.application_id=(SELECT c.relabel_application_id FROM ll_return_inventory_carton c WHERE c.id=#{cartonId}) " +
"OR ((SELECT c.relabel_application_id FROM ll_return_inventory_carton c WHERE c.id=#{cartonId}) IS NULL " +
"AND NOT EXISTS(SELECT 1 FROM ll_relabel_item newer JOIN ll_relabel_application na ON na.id=newer.application_id " +
"WHERE newer.return_carton_id=ri.return_carton_id AND na.status NOT IN ('CANCELLED','REJECTED') " +
"AND (COALESCE(newer.source_carton_mark,newer.carton_mark)=COALESCE(ri.source_carton_mark,ri.carton_mark) " +
"OR COALESCE(newer.source_carton_mark,newer.carton_mark)=(SELECT c.carton_mark FROM ll_return_inventory_carton c WHERE c.id=#{cartonId})) " +
"AND (na.created_at>ra.created_at OR (na.created_at=ra.created_at AND na.id>ra.id)))))";
String COMPLETION_DEDUCTION = "COALESCE((SELECT SUM(j.quantity) FROM ll_relabel_pda_history h " +
"JOIN JSON_TABLE(CASE WHEN h.action='RETURN_COMPLETION_TRANSFER' AND JSON_VALID(h.detail_json) THEN h.detail_json ELSE '{}' END,'$.items[*]' COLUMNS(sku_code VARCHAR(128) PATH '$.skuCode',quantity BIGINT PATH '$.quantity')) j " +
"WHERE h.action='RETURN_COMPLETION_TRANSFER' AND EXISTS(SELECT 1 FROM ll_relabel_item ri " +
"JOIN ll_relabel_application ra ON ra.id=ri.application_id " +
"WHERE ri.return_carton_id=#{cartonId} AND ri.sku_code=#{skuCode} " +
"AND ra.status NOT IN ('CANCELLED','REJECTED')")
"WHERE ri.return_carton_id=#{cartonId} AND JSON_CONTAINS(CASE WHEN JSON_VALID(h.detail_json) THEN h.detail_json ELSE '{}' END," +
"JSON_QUOTE(CAST(ri.id AS CHAR)),'$.sourceItemIds') AND ra.status NOT IN ('CANCELLED','REJECTED') " + CURRENT_RELABEL_ROUND + ")";
@Select("SELECT COALESCE(SUM(CASE WHEN ri.operation_type='RETURN_BULK_SKU_LABEL' THEN f.quantity " +
"ELSE f.quantity * COALESCE((SELECT SUM(j.quantity) FROM JSON_TABLE(CASE WHEN JSON_VALID(ri.sku_relabel_items_json) THEN ri.sku_relabel_items_json ELSE '[]' END, '$[*]' " +
"COLUMNS(sku_code VARCHAR(128) PATH '$.skuCode', quantity BIGINT PATH '$.quantity')) j WHERE UPPER(j.sku_code)=UPPER(#{skuCode})),0) END),0) - " +
COMPLETION_DEDUCTION + " AND UPPER(j.sku_code)=UPPER(#{skuCode})),0) FROM ll_relabel_item ri " +
"JOIN ll_relabel_application ra ON ra.id=ri.application_id " +
"JOIN ll_relabel_pda_unit_fact f ON f.item_id=ri.id AND f.result='RELABELLED' " +
"WHERE ri.return_carton_id=#{cartonId} AND (ri.operation_type<>'RETURN_BULK_SKU_LABEL' OR UPPER(ri.sku_code)=UPPER(#{skuCode})) " +
"AND ra.status NOT IN ('CANCELLED','REJECTED')" + CURRENT_RELABEL_ROUND)
long relabelledQuantity(@Param("cartonId") long cartonId, @Param("skuCode") String skuCode);
String RELABEL_ITEM_QUANTITY = "CASE WHEN ri.operation_type='RETURN_BULK_SKU_LABEL' THEN ri.notified_package_count ELSE " +
"COALESCE((SELECT SUM(j.quantity) FROM JSON_TABLE(CASE WHEN JSON_VALID(ri.sku_relabel_items_json) THEN ri.sku_relabel_items_json ELSE '[]' END, '$[*]' " +
"COLUMNS(quantity BIGINT PATH '$.quantity')) j),0) END";
@Select("SELECT COALESCE(SUM(CASE WHEN ri.operation_type='RETURN_BULK_SKU_LABEL' THEN f.quantity ELSE f.quantity * " +
"COALESCE((SELECT SUM(j.quantity) FROM JSON_TABLE(CASE WHEN JSON_VALID(ri.sku_relabel_items_json) THEN ri.sku_relabel_items_json ELSE '[]' END,'$[*]' COLUMNS(quantity BIGINT PATH '$.quantity')) j),0) END),0) - " +
COMPLETION_DEDUCTION + "),0) " +
"FROM ll_relabel_item ri JOIN ll_relabel_application ra ON ra.id=ri.application_id " +
"JOIN ll_relabel_pda_unit_fact f ON f.item_id=ri.id AND f.result='RELABELLED' " +
"WHERE ri.return_carton_id=#{cartonId} AND ra.status NOT IN ('CANCELLED','REJECTED')" + CURRENT_RELABEL_ROUND)
long totalRelabelledQuantity(@Param("cartonId") long cartonId);
@Select("SELECT COALESCE(SUM(GREATEST(0,(" + RELABEL_ITEM_QUANTITY + ") - " +
"COALESCE((SELECT SUM(CASE WHEN ri.operation_type='RETURN_BULK_SKU_LABEL' THEN f.quantity ELSE f.quantity * (" +
RELABEL_ITEM_QUANTITY + ") END) FROM ll_relabel_pda_unit_fact f WHERE f.item_id=ri.id AND f.result='RELABELLED'),0))),0) " +
"FROM ll_relabel_item ri JOIN ll_relabel_application ra ON ra.id=ri.application_id " +
"WHERE ri.return_carton_id=#{cartonId} AND ra.status NOT IN ('CANCELLED','REJECTED','COMPLETED')")
long relabelReservedQuantity(@Param("cartonId") long cartonId);
@Update("UPDATE ll_return_inventory_carton SET total_quantity=#{remaining},sku_types=#{skuTypes},status='AVAILABLE'," +
"relabel_application_id=NULL,relabel_no=NULL,relabel_status=NULL,updated_at=NOW(3) " +
"WHERE id=#{id} AND customer_id=#{customerId} AND relabel_application_id=#{applicationId} " +
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.returnreceipt.mapper;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** Immutable PDA history records exact completed physical pieces moved out of a historical item group. */
@Mapper
public interface ReturnRelabelCompletionLedgerMapper {
@Select("<script>SELECT COALESCE(SUM(j.quantity),0) FROM ll_relabel_pda_history h " +
"JOIN JSON_TABLE(CASE WHEN h.action='RETURN_COMPLETION_TRANSFER' AND JSON_VALID(h.detail_json) THEN h.detail_json ELSE '{}' END,'$.items[*]' COLUMNS(sku_code VARCHAR(128) PATH '$.skuCode',quantity BIGINT PATH '$.quantity')) j " +
"WHERE h.action='RETURN_COMPLETION_TRANSFER' AND UPPER(j.sku_code)=UPPER(#{skuCode}) " +
"AND EXISTS(SELECT 1 FROM JSON_TABLE(CASE WHEN h.action='RETURN_COMPLETION_TRANSFER' AND JSON_VALID(h.detail_json) THEN h.detail_json ELSE '{}' END,'$.sourceItemIds[*]' COLUMNS(item_id VARCHAR(32) PATH '$')) x " +
"WHERE x.item_id IN <foreach item='id' collection='itemIds' open='(' separator=',' close=')'>#{id}</foreach>)</script>")
long deductedQuantity(@Param("itemIds") List<String> itemIds, @Param("skuCode") String skuCode);
}
@@ -56,6 +56,7 @@ public class ReturnInventoryService {
public static final String MERGED = "MERGED";
@Resource private ReturnInventoryCartonMapper cartons;
@Resource private com.baiyue.lastleg.relabel.mapper.RelabelItemMapper relabelItems;
@Resource private ReturnInventoryCartonItemMapper cartonItems;
@Resource private ReturnInventoryBalanceMapper balances;
@Resource private ReturnInventoryOperationMapper operations;
@@ -129,13 +130,27 @@ public class ReturnInventoryService {
if (!List.of("BOX", "SKU").contains(mode)) throw badRequest("合箱方式只能选择 BOX 或 SKU");
List<String> marks = request.sourceCartonMarks() == null ? List.of() : request.sourceCartonMarks().stream().distinct().sorted().toList();
if ("BOX".equals(mode) && marks.isEmpty()) throw badRequest("按箱合箱必须选择来源箱唛");
if ("SKU".equals(mode) && !marks.isEmpty()) throw badRequest("按 SKU 合箱不能同时选择来源箱");
// SKU mode is the partial-packing mode. It may explicitly name one or
// more physical packages/boxes; the quantities in `boxes` are then
// deducted from those sources and materialized as a new labelled carton.
Map<String, Long> requested = planQuantities(request.boxes());
List<ReturnInventoryRequests.SourceDeduction> exact = request.sourceDeductions();
if (exact != null) {
if (!"SKU".equals(mode) || exact.isEmpty() || marks.isEmpty()) throw badRequest("部分合箱必须指定来源包裹/箱及数量");
var seen = new java.util.HashSet<String>();
for (var line : exact) if (!marks.contains(line.cartonMark()) || line.quantity() <= 0
|| !seen.add(line.cartonMark() + ":" + line.skuCode())) throw badRequest("部分合箱来源重复或不属于所选包裹/箱");
Map<String, Long> totals = new LinkedHashMap<>();
exact.forEach(line -> totals.merge(line.skuCode(), line.quantity(), Math::addExact));
if (!totals.equals(requested)) throw conflict("来源扣除数量必须与目标箱数量一致");
}
List<SourceItem> allocation = new ArrayList<>();
List<ReturnInventoryCarton> sources = "BOX".equals(mode)
? marks.stream().map(cartons::lockMark).toList()
: packingSourceCartons(selected).stream().filter(c -> "PARCEL".equals(c.getSourceUnitType()))
.sorted(Comparator.comparing(ReturnInventoryCarton::getCartonMark)).map(c -> cartons.lockMark(c.getCartonMark())).toList();
: (marks.isEmpty()
? packingSourceCartons(selected).stream()
.sorted(Comparator.comparing(ReturnInventoryCarton::getCartonMark)).map(c -> cartons.lockMark(c.getCartonMark())).toList()
: marks.stream().map(cartons::lockMark).toList());
Map<String, Long> remaining = new LinkedHashMap<>(requested);
for (ReturnInventoryCarton carton : sources) {
if (carton == null || !first.getCustomerId().equals(carton.getCustomerId())
@@ -143,10 +158,12 @@ public class ReturnInventoryService {
|| !AVAILABLE.equals(carton.getStatus())
|| !sourceMatches(carton, returnNos)) throw conflict("来源箱不属于所选退货单或已被占用");
ReturnCartonCountGuard.requireConfirmed(carton);
if ("BOX".equals(mode) && "PARCEL".equals(carton.getSourceUnitType())) throw badRequest("包裹散 SKU 不能作为整箱选择");
for (ReturnInventoryCartonItem item : cartonItems.lockCarton(carton.getId())) {
long free = Math.max(0, value(item.getQuantity()) - value(item.getPackingReservedQuantity()));
long take = "BOX".equals(mode) ? free : Math.min(free, value(remaining.get(item.getSkuCode())));
if (value(item.getPackingReservedQuantity()) > 0) throw conflict("来源包裹/箱已有合箱预占");
long take = "BOX".equals(mode) ? free : exact == null ? Math.min(free, value(remaining.get(item.getSkuCode())))
: exact.stream().filter(line -> carton.getCartonMark().equals(line.cartonMark()) && item.getSkuCode().equals(line.skuCode())).mapToLong(ReturnInventoryRequests.SourceDeduction::quantity).sum();
if (take > free) throw conflict("来源包裹/箱扣除数量超过可用库存");
if ("BOX".equals(mode) && value(item.getPackingReservedQuantity()) > 0) throw conflict("来源箱已有合箱预占");
if (take <= 0) continue;
if ("SKU".equals(mode)) remaining.put(item.getSkuCode(), remaining.get(item.getSkuCode()) - take);
@@ -154,7 +171,9 @@ public class ReturnInventoryService {
cartonItems.updateById(item);
allocation.add(new SourceItem(carton.getCartonMark(), item.getSkuCode(), take));
}
if ("BOX".equals(mode)) { carton.setStatus("PACKING_RESERVED"); cartons.updateById(carton); }
if (allocation.stream().anyMatch(line -> line.cartonMark().equals(carton.getCartonMark()))) {
carton.setStatus("PACKING_RESERVED"); cartons.updateById(carton);
}
}
if (allocation.isEmpty() || ("SKU".equals(mode) && remaining.values().stream().anyMatch(q -> q > 0))) throw conflict("来源库存不足,无法锁定合箱数量");
Map<String, Long> reserved = sourceQuantities(allocation);
@@ -282,6 +301,8 @@ public class ReturnInventoryService {
return new ReturnInventoryResponses.PackingPlanCompletion(plan.getPlanNo(), read(plan.getResultLabelsJson(), new TypeReference<List<ReturnInventoryResponses.Label>>() { }));
}
if (!"READY".equals(plan.getStatus())) throw conflict("合箱计划尚未审核或已取消");
if ("SKU".equals(plan.getMode()) && request.boxes().stream().anyMatch(box -> text(box.targetCartonMark()) != null))
throw conflict("部分包裹/箱必须合箱生成独立新箱");
if ("BOX".equals(plan.getMode()) && request.boxes().size() != 1) throw badRequest("箱与箱合并必须扫描一个已有目标箱,不支持拆分来源箱");
List<SourceItem> allocation = read(plan.getSourceItemsJson(), new TypeReference<>() { });
if (allocation.isEmpty()) throw conflict("合箱计划缺少来源锁定明细,请重新创建");
@@ -289,7 +310,7 @@ public class ReturnInventoryService {
? text(request.boxes().get(0).targetCartonMark()) : null;
List<SourceItem> sourceAllocation = plannedTarget == null ? allocation : allocation.stream()
.filter(source -> !plannedTarget.equals(source.cartonMark())).toList();
if ("BOX".equals(plan.getMode()) && request.sourceDeductions() != null) {
if (request.sourceDeductions() != null) {
Map<String, Long> seen = new LinkedHashMap<>();
List<SourceItem> selected = new ArrayList<>();
for (var deduction : request.sourceDeductions()) {
@@ -299,8 +320,13 @@ public class ReturnInventoryService {
selected.add(new SourceItem(deduction.cartonMark(), deduction.skuCode(), deduction.quantity()));
}
sourceAllocation = selected;
if ("SKU".equals(plan.getMode()) && !new java.util.HashSet<>(sourceAllocation).equals(new java.util.HashSet<>(allocation)))
throw conflict("部分合箱实际来源与计划锁定来源数量不一致");
}
final List<SourceItem> selectedAllocation = sourceAllocation;
if (plannedTarget != null && allocation.stream().anyMatch(source -> !plannedTarget.equals(source.cartonMark())
&& selectedAllocation.stream().filter(s -> s.cartonMark().equals(source.cartonMark()) && s.skuCode().equals(source.skuCode())).mapToLong(SourceItem::quantity).sum() != source.quantity()))
throw conflict("部分包裹/箱必须合箱生成独立新箱,不能并入已有箱");
if (!sourceQuantities(sourceAllocation).equals(planQuantities(request.boxes()))) throw conflict("来源箱 SKU 数量合计必须与计划锁定数量完全一致");
List<String> returnNos = read(plan.getReturnNosJson(), new TypeReference<>() { });
Map<String, Map<String, Long>> limits = new LinkedHashMap<>();
@@ -464,8 +490,8 @@ public class ReturnInventoryService {
required(lines.get(0).skuCode(), "SKU 不能为空");
String cartonMark = text(requestedMark);
long cartonId = IdWorker.getId();
// A parcel row tracks loose SKU provenance; it is never a physical carton or printable box label.
if ("PARCEL".equals(receipt.getUnitType())) cartonMark = "SP" + hash(List.of(receipt.getId(), lines.get(0).skuCode())).substring(0, 24).toUpperCase(java.util.Locale.ROOT);
// One durable barcode identifies a physical parcel, irrespective of SKU count.
if ("PARCEL".equals(receipt.getUnitType())) cartonMark = "RP" + hash(List.of(receipt.getId())).substring(0, 24).toUpperCase(java.util.Locale.ROOT);
else if (cartonMark == null) cartonMark = numbers.next("RETURN_CARTON", order.getOverseasWarehouseId(), cartonId);
if (cartons.findMark(cartonMark) != null) throw conflict("箱号已存在: " + cartonMark);
LocalDateTime now = LocalDateTime.now();
@@ -476,10 +502,11 @@ public class ReturnInventoryService {
carton.setSourceReturnNo(order.getReturnNo()); carton.setCartonMark(cartonMark);
carton.setPalletCode(text(receipt.getPalletCode()));
carton.setSourceUnitType(receipt.getUnitType());
carton.setLabelStatus("PALLET".equals(receipt.getUnitType()) ? null : "WAIT_LABEL");
carton.setCountStatus("PALLET".equals(receipt.getUnitType()) ? "PENDING" : "CONFIRMED");
carton.setSourceUnitCode(normalizedUnitCode);
// Pallet measurements describe the carrier, never each child carton.
if ("BOX".equals(receipt.getUnitType())) {
if ("BOX".equals(receipt.getUnitType()) || "PARCEL".equals(receipt.getUnitType())) {
carton.setLength(receipt.getLength()); carton.setWidth(receipt.getWidth()); carton.setHeight(receipt.getHeight()); carton.setWeight(receipt.getWeight());
}
carton.setStatus(AVAILABLE); carton.setSkuTypes(lines.size()); carton.setTotalQuantity(total); carton.setCreatedAt(now); carton.setUpdatedAt(now);
@@ -492,7 +519,7 @@ public class ReturnInventoryService {
balances.adjust(IdWorker.getId(), order.getCustomerId(), order.getOverseasWarehouseId(), line.skuCode(),
text(line.productNameZh()), line.quantity(), line.quantity(), "PARCEL".equals(receipt.getUnitType()) ? 0 : 1);
}
if ("BOX".equals(receipt.getUnitType())) {
if ("BOX".equals(receipt.getUnitType()) || "PARCEL".equals(receipt.getUnitType())) {
var contents = lines.stream().map(line -> new com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.Sku(line.skuCode(), line.productNameZh(), line.quantity())).toList();
var snapshot = new com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.Task(receipt.getReceiptNo(), order.getReturnNo(), receipt.getPalletCode(), "CONFIRMED", contents,
List.of(new com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.Box(cartonMark, contents)));
@@ -551,7 +578,8 @@ public class ReturnInventoryService {
.map(carton -> new ReturnInventoryResponses.CartonSummary(carton.getCartonMark(), carton.getOverseasWarehouseId(),
carton.getSourceBatchNo(), carton.getSourceReturnNo(), displayStatus(carton),
carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()),
carton.getLength(), carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt())).toList();
carton.getLength(), carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt(),
relabelProgressStatus(carton), cartons.totalRelabelledQuantity(carton.getId()), cartons.relabelReservedQuantity(carton.getId()))).toList();
return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size());
}
@@ -594,8 +622,9 @@ public class ReturnInventoryService {
List<ReturnInventoryResponses.PlatformCarton> records = total == 0 ? List.of() : cartons.platformPage(customerId, warehouseId, cartonMark,
returnNo, status, sku, (request.page() - 1) * request.size(), request.size()).stream().map(carton -> new ReturnInventoryResponses.PlatformCarton(
carton.getCustomerId(), carton.getOverseasWarehouseId(), carton.getCartonMark(), carton.getSourceReturnNo(), carton.getSourceUnitType(), carton.getSourceBatchNo(),
displayStatus(carton), carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()), carton.getFbaCartonMark(), carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), carton.getRelabelNo(), carton.getRelabelStatus(), carton.getLength(),
carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt(), carton.getLocationCode(), carton.getPalletCode(), carton.getPutawayAt())).toList();
displayStatus(carton), carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()), carton.getFbaCartonMark(), carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), carton.getRelabelNo(), relabelProgressStatus(carton), carton.getLength(),
carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt(), carton.getLocationCode(), carton.getPalletCode(), carton.getPutawayAt(),
cartons.totalRelabelledQuantity(carton.getId()), cartons.relabelReservedQuantity(carton.getId()))).toList();
return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size());
}
@@ -652,10 +681,20 @@ public class ReturnInventoryService {
return label(carton);
}
@Transactional
public ReturnInventoryResponses.Label confirmLabel(PdaWarehouseIdentity identity, String cartonMark) {
ReturnInventoryCarton carton = cartons.lockMark(required(cartonMark, "请扫描已贴好的包裹/箱标签"));
if (carton == null || !String.valueOf(identity.overseasWarehouseId()).equals(carton.getOverseasWarehouseId())) throw notFound("当前仓没有该包裹/箱");
if (!AVAILABLE.equals(carton.getStatus())) throw conflict("已占用或已拆空的包裹/箱不能确认贴标");
if (!"CONFIRMED".equals(carton.getLabelStatus())) {
carton.setLabelStatus("CONFIRMED"); carton.setUpdatedAt(LocalDateTime.now()); cartons.updateById(carton);
}
return label(carton);
}
/** Print only persisted physical cartons; a parcel's SKU rows are not cartons. */
@Transactional(readOnly = true)
public List<ReturnOrderResponses.ReceiptCarton> receiptContents(ReturnReceipt receipt) {
if ("PARCEL".equals(receipt.getUnitType())) return List.of();
var snapshot = operations.receiptContents(receipt.getCustomerId(), receipt.getId());
if (snapshot != null) {
var task = read(snapshot.getResultJson(), new TypeReference<com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.Task>() { });
@@ -673,7 +712,6 @@ public class ReturnInventoryService {
List<ReturnOrderLine> lines) {
List<ReturnInventoryCarton> existing = cartons.listReceipt(receipt.getId()).stream()
.filter(carton -> !"MERGED".equals(carton.getStatus())).toList();
if ("PARCEL".equals(receipt.getUnitType())) return List.of();
long expected = "BOX".equals(receipt.getUnitType()) ? Math.max(1, value(receipt.getTotalBoxCount()))
: lines.stream().mapToLong(line -> Math.max(0, line.boxCount())).sum();
if (expected <= 0 || existing.size() >= expected) {
@@ -831,7 +869,7 @@ public class ReturnInventoryService {
public ReturnInventoryResponses.CartonDetail scanCarton(PdaWarehouseIdentity identity, String mark) {
ReturnInventoryCarton carton = cartons.findMark(required(mark, "箱唛不能为空"));
if (carton == null || !String.valueOf(identity.overseasWarehouseId()).equals(carton.getOverseasWarehouseId())) throw notFound("当前仓没有该退货箱");
if (!AVAILABLE.equals(carton.getStatus()) || "PARCEL".equals(carton.getSourceUnitType())) throw conflict("该箱不是可用实物箱");
if (!AVAILABLE.equals(carton.getStatus())) throw conflict("该包裹/箱不是可用实物单元");
ReturnCartonCountGuard.requireConfirmed(carton);
return cartonDetail(carton);
}
@@ -851,7 +889,7 @@ public class ReturnInventoryService {
for (String mark : request.sourceCartonMarks().stream().distinct().sorted().toList()) {
if (mark.equals(request.targetCartonMark())) throw badRequest("来源箱不能与目标箱相同");
ReturnInventoryCarton carton = cartons.lockMark(mark);
if (carton == null || !AVAILABLE.equals(carton.getStatus()) || "PARCEL".equals(carton.getSourceUnitType())
if (carton == null || !AVAILABLE.equals(carton.getStatus())
|| !String.valueOf(identity.overseasWarehouseId()).equals(carton.getOverseasWarehouseId())) throw conflict("来源箱不存在或不可合并");
if (customer != null && !customer.equals(carton.getCustomerId())) throw conflict("不同客户的箱不能合并");
ReturnCartonCountGuard.requireConfirmed(carton);
@@ -868,6 +906,10 @@ public class ReturnInventoryService {
}
if(request.sourceDeductions() != null && request.sourceDeductions().stream().anyMatch(d -> !limits.containsKey(d.cartonMark()) || !limits.get(d.cartonMark()).containsKey(d.skuCode()))) throw badRequest("扣除明细不属于已扫描来源箱");
String targetMark = text(request.targetCartonMark());
if (targetMark != null && request.sourceCartonMarks().stream().anyMatch(mark ->
cartonItems.lockCarton(cartons.lockMark(mark).getId()).stream().anyMatch(item ->
value(limits.get(mark).get(item.getSkuCode())) != value(item.getQuantity()))))
throw conflict("部分包裹/箱必须合箱生成独立新箱,不能并入已有箱");
if(targetMark == null && (request.length()==null || request.width()==null || request.height()==null || request.weight()==null)) throw badRequest("新增目标箱必须填写箱规");
var planItems=quantities.entrySet().stream().map(e -> new ReturnInventoryRequests.PlanItem(e.getKey(),e.getValue())).toList();
String newMark=targetMark==null?packingMarks(List.of(new ReturnInventoryRequests.PlanBox(planItems,request.length(),request.width(),request.height(),request.weight()))).get(0):null;
@@ -956,7 +998,7 @@ public class ReturnInventoryService {
Map<String, Long> requested = new LinkedHashMap<>();
Map<String, String> productNames = new LinkedHashMap<>();
for (ReturnInventoryRequests.ValidItem item : request.items()) {
String sku = required(item.skuCode(), "SKU 不能为空").toUpperCase(java.util.Locale.ROOT);
String sku = required(item.skuCode(), "SKU 不能为空");
if (requested.put(sku, item.quantity()) != null) throw badRequest("合箱 SKU 不能重复");
}
Map<String, Long> remaining = new LinkedHashMap<>(requested);
@@ -965,13 +1007,21 @@ public class ReturnInventoryService {
LinkedHashSet<String> consumedReturnNos = new LinkedHashSet<>();
Map<Long, Map<String, Long>> attachedByOrder = new LinkedHashMap<>();
List<TraceTransfer> transfers = new ArrayList<>();
java.util.Set<Long> completedRelabelSources = new java.util.HashSet<>();
long total = 0;
for (ReturnInventoryCarton source : sources) {
// Read committed quantities under the source lock, never a stale MVCC snapshot.
List<ReturnInventoryCartonItem> sourceItems = cartonItems.lockCarton(source.getId());
boolean hasCompletedRelabel = sourceItems.stream().anyMatch(i -> cartons.relabelledQuantity(source.getId(), i.getSkuCode()) > 0);
if (hasCompletedRelabel) completedRelabelSources.add(source.getId());
if (hasCompletedRelabel && source.getRelabelApplicationId() == null && sourceItems.stream().anyMatch(i -> {
long free = Math.max(0, value(i.getQuantity()) - value(i.getPackingReservedQuantity()));
long allowed = limits == null ? free : Math.min(free, value(limits.getOrDefault(source.getCartonMark(), Map.of()).get(i.getSkuCode())));
return Math.min(value(remaining.get(i.getSkuCode())), allowed) != value(i.getQuantity());
})) throw conflict("已换标与未换标货物不能自动分配来源,请先在换标任务内隔离为独立新箱,或整箱合并");
long sourceTotal = 0; int sourceTypes = 0;
for (ReturnInventoryCartonItem item : sourceItems) {
String sku = item.getSkuCode().toUpperCase(java.util.Locale.ROOT);
String sku = item.getSkuCode();
long free = Math.max(0, value(item.getQuantity()) - value(item.getPackingReservedQuantity()));
long allowed = limits == null ? free : Math.min(free, value(limits.getOrDefault(source.getCartonMark(), Map.of()).get(sku)));
long take = Math.min(value(remaining.get(sku)), allowed);
@@ -1016,6 +1066,8 @@ public class ReturnInventoryService {
}
target.setSourceBatchNo(String.join(",", consumedBatchNos)); target.setSourceReturnNo(String.join(",", consumedReturnNos)); target.setStatus(AVAILABLE);
if (existingTarget == null) {
target.setCountStatus("CONFIRMED");
target.setLabelStatus("WAIT_LABEL");
target.setSkuTypes(requested.size()); target.setTotalQuantity(total); target.setLength(request.length()); target.setWidth(request.width());
target.setHeight(request.height()); target.setWeight(request.weight()); target.setCreatedAt(now); target.setUpdatedAt(now); cartons.insert(target);
}
@@ -1037,6 +1089,8 @@ public class ReturnInventoryService {
}
for (TraceTransfer transfer : transfers) packingTrace.insert(IdWorker.getId(), customerId, operation.getId(),
transfer.source(), target.getCartonMark(), transfer.returnNo(), transfer.sku(), transfer.quantity(), now);
for (ReturnInventoryCarton source : sources) if (completedRelabelSources.contains(source.getId()) && MERGED.equals(source.getStatus()) && source.getRelabelApplicationId() == null)
relabelItems.moveCompletedReturnCarton(customerId, source.getId(), targetId, target.getCartonMark());
var result = new ReturnInventoryResponses.MergeResult(target.getCartonMark(), requested.keySet().iterator().next(), value(target.getTotalQuantity()), label(target));
operation.setResultCartonId(targetId); operation.setResultJson(json(result)); operation.setUpdatedAt(now); operations.updateById(operation);
return result;
@@ -1111,7 +1165,12 @@ public class ReturnInventoryService {
"AVAILABLE".equals(carton.getStatus()) && "CONFIRMED".equals(carton.getCountStatus())
? Math.max(0, value(item.getQuantity()) - value(item.getPackingReservedQuantity())) : 0)).toList();
return new ReturnInventoryResponses.CartonDetail(carton.getCartonMark(), carton.getCustomerId(), carton.getOverseasWarehouseId(), carton.getSourceBatchNo(), carton.getSourceReturnNo(),
displayStatus(carton), carton.getFbaCartonMark(), carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), carton.getRelabelNo(), carton.getRelabelStatus(), carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()), carton.getLength(), carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt(), items, carton.getLocationCode(), carton.getPalletCode(), carton.getPutawayAt(), packingTrace.listCartonHistory(carton.getCustomerId(), carton.getCartonMark()));
displayStatus(carton), carton.getFbaCartonMark(), carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), carton.getRelabelNo(), relabelProgressStatus(carton), carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()), carton.getLength(), carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt(), items, carton.getLocationCode(), carton.getPalletCode(), carton.getPutawayAt(), packingTrace.listCartonHistory(carton.getCustomerId(), carton.getCartonMark()),
cartons.totalRelabelledQuantity(carton.getId()), cartons.relabelReservedQuantity(carton.getId()));
}
private String relabelProgressStatus(ReturnInventoryCarton carton) {
return ReturnRelabelProgress.status(value(carton.getTotalQuantity()), cartons.totalRelabelledQuantity(carton.getId()), cartons.relabelReservedQuantity(carton.getId()));
}
private static String printableHtml(ReturnInventoryCarton carton) {
@@ -278,7 +278,7 @@ public class ReturnReceiptService {
.toList();
long boxes = value(receipt.getTotalBoxCount());
if (boxes == 0) boxes = lines.stream().mapToLong(ReturnOrderResponses.ReceiptItem::boxCount).sum();
String cartonMark = "PARCEL".equals(receipt.getUnitType()) ? null : text(receipt.getCartonMark());
String cartonMark = text(receipt.getCartonMark());
List<ReturnInventoryService.ReturnOrderLine> labelLines = lines.stream()
.map(line -> new ReturnInventoryService.ReturnOrderLine(line.skuCode(), line.productNameZh(), line.quantity(), line.boxCount()))
.toList();
@@ -382,13 +382,12 @@ public class ReturnReceiptService {
if (order == null) throw notFound("退货单不存在");
if (!COMPLETED.equals(order.getStatus())) throw conflict("退货单尚未完成签收");
List<ReturnOrderResponses.RelabelCandidate> result = new ArrayList<>();
for (ReturnReceipt receipt : receipts.listOrder(order.getId())) {
for (ReturnInventoryCarton carton : cartons.listReceipt(receipt.getId())) {
for (ReturnInventoryCarton carton : cartons.findAvailable(customerId, order.getOverseasWarehouseId())) {
if (!inventory.matchesReturnSource(carton, order.getReturnNo())) continue;
if (!ReturnInventoryService.AVAILABLE.equals(carton.getStatus()) || !"CONFIRMED".equals(carton.getCountStatus()) || carton.getRelabelApplicationId() != null) continue;
List<ReturnOrderResponses.Item> items = cartonItems.listCarton(carton.getId()).stream()
.map(i -> new ReturnOrderResponses.Item(i.getSkuCode(), i.getProductNameZh(), value(i.getQuantity()), value(i.getQuantity()))).toList();
result.add(new ReturnOrderResponses.RelabelCandidate(carton.getId(), carton.getCartonMark(), carton.getSourceReturnNo(), carton.getStatus(), value(carton.getTotalQuantity()), items, carton.getRelabelStatus()));
}
}
return result;
}
@@ -419,9 +418,9 @@ public class ReturnReceiptService {
long totalQuantity = 0;
for (ReturnOrderRequests.RelabelItem requested : request.items()) {
String sku = norm(requested.skuCode());
if (!seen.add(sku)) throw badRequest("同一贴标申请不能重复选择 SKU: " + requested.skuCode());
if (!seen.add(sku + ":" + text(requested.cartonMark()))) throw badRequest("同一包裹/箱 SKU 不能重复选择: " + requested.skuCode());
ReturnOrderItem received = receivedBySku.get(sku);
if (received == null || requested.quantity() > value(received.getReceivedQuantity())) {
if (text(requested.cartonMark()) == null && (received == null || requested.quantity() > value(received.getReceivedQuantity()))) {
throw conflict("SKU " + requested.skuCode() + " 的贴标数量超过当前退货单实收数量");
}
totalQuantity = Math.addExact(totalQuantity, requested.quantity());
@@ -460,27 +459,35 @@ public class ReturnReceiptService {
app.setFulfillmentStatus("WAIT_APPROVAL"); app.setRemark(text(request.cartonRemark())); app.setStatus("PENDING_REVIEW"); app.setItemCount(request.items().size()); app.setTotalPackageCount(Math.toIntExact(totalQuantity)); app.setNotifiedPackageCount(Math.toIntExact(totalQuantity)); app.setChargeAmount(java.math.BigDecimal.ZERO); app.setChargeCurrency("CNY"); app.setWalletStatus("NOT_REQUIRED"); app.setIdempotencyKey(key); app.setRequestHash(requestHash); app.setCreatedBy(text(operatorId)); app.setCreatedAt(now); app.setUpdatedAt(now); relabelApplications.insert(app);
List<ReturnInventoryCarton> availableCartons = cartons.findAvailable(customerId, order.getOverseasWarehouseId());
Map<Long, ReturnInventoryCarton> selectedCartons = new LinkedHashMap<>();
Map<List<String>, Long> allocatedBySourceSku = new LinkedHashMap<>();
for (ReturnOrderRequests.RelabelItem requested : request.items()) {
ReturnOrderItem source = receivedBySku.get(norm(requested.skuCode()));
long remaining = requested.quantity();
for (ReturnInventoryCarton candidate : availableCartons) {
if (remaining == 0) break;
if (!order.getReturnNo().equals(candidate.getSourceReturnNo())) continue;
if (!inventory.matchesReturnSource(candidate, order.getReturnNo())) continue;
if (text(requested.cartonMark()) != null && !requested.cartonMark().equals(candidate.getCartonMark())) continue;
long skuQuantity = cartonItems.listCarton(candidate.getId()).stream()
.filter(line -> norm(requested.skuCode()).equals(norm(line.getSkuCode())))
.mapToLong(line -> value(line.getQuantity())).sum();
skuQuantity -= cartons.relabelledQuantity(candidate.getId(), norm(requested.skuCode()));
if (skuQuantity <= 0) continue;
ReturnInventoryCarton carton = selectedCartons.computeIfAbsent(candidate.getId(), ignored -> cartons.lockId(candidate.getId()));
if (carton == null || !"AVAILABLE".equals(carton.getStatus()) || carton.getRelabelApplicationId() != null)
throw conflict("退货箱已被其他换标申请占用,请刷新后重试");
if (cartonItems.lockCarton(carton.getId()).stream().anyMatch(line -> value(line.getPackingReservedQuantity()) > 0))
throw conflict("退货 SKU 已被合箱计划锁定,请先完成或取消合箱计划");
skuQuantity = cartonItems.lockCarton(carton.getId()).stream()
.filter(line -> norm(requested.skuCode()).equals(norm(line.getSkuCode())))
.mapToLong(line -> value(line.getQuantity())).sum();
var sourceSku = List.of(carton.getCartonMark(), norm(requested.skuCode()));
skuQuantity -= allocatedBySourceSku.getOrDefault(sourceSku, 0L);
if (skuQuantity <= 0) continue;
long allocated = Math.min(remaining, skuQuantity);
allocatedBySourceSku.merge(sourceSku, allocated, Math::addExact);
ReturnCartonCountGuard.requireConfirmed(carton);
RelabelItem item = new RelabelItem(); item.setId(IdWorker.getId()); item.setApplicationId(appId); item.setCustomerId(customerId);
item.setSkuCode(source.getSkuCode());
item.setProductNameZh(text(source.getProductNameZh()) == null ? source.getSkuCode() : text(source.getProductNameZh()));
item.setSkuCode(requested.skuCode());
item.setProductNameZh(source == null || text(source.getProductNameZh()) == null ? requested.skuCode() : text(source.getProductNameZh()));
item.setTotalPackageCount(Math.toIntExact(allocated)); item.setNotifiedPackageCount(Math.toIntExact(allocated));
item.setShipmentNo(order.getReturnNo()); item.setLabelRequirement(text(request.cartonRemark()));
item.setDestinationWarehouseId(String.valueOf(destination.id())); item.setDestinationFbaWarehouseCode(destination.code());
@@ -653,13 +660,12 @@ public class ReturnReceiptService {
if ("PALLET".equals(unitType)) {
inventoryCartons = inventory.createReceiptCartons(order, receipt, new ArrayList<>(inventoryLines.values()), boxCountBySku);
} else if ("PARCEL".equals(unitType)) {
inventoryCartons = new ArrayList<>();
for (var line : inventoryLines.values())
inventoryCartons.add(inventory.createReceiptCarton(order, receipt, null, List.of(line)));
// One receipt represents one physical parcel, including all its SKUs.
inventoryCartons = List.of(inventory.createReceiptCarton(order, receipt, null, new ArrayList<>(inventoryLines.values())));
} else {
inventoryCartons = List.of(inventory.createReceiptCarton(order, receipt, unitCode, new ArrayList<>(inventoryLines.values())));
}
receipt.setCartonMark("PARCEL".equals(unitType) ? null : inventoryCartons.get(0).getCartonMark());
receipt.setCartonMark(inventoryCartons.get(0).getCartonMark());
receipts.updateById(receipt);
syncTailCargo(order, receipt, allocations, now);
List<ReturnOrderItem> finalItems = orderItems.listOrder(order.getId());
@@ -802,10 +808,10 @@ public class ReturnReceiptService {
return confirmed(order, receipt, COMPLETED.equals(order.getStatus()));
}
private PdaReturnReceiptResponses.Confirmed confirmed(ReturnOrder order, ReturnReceipt receipt, boolean completed) {
String cartonMark = "PARCEL".equals(receipt.getUnitType()) ? null : text(receipt.getCartonMark());
String cartonMark = text(receipt.getCartonMark());
String printableHtml = null;
List<ReturnOrderResponses.CartonLabel> labels = new java.util.ArrayList<>();
if (receipt.getId() != null && !"PARCEL".equals(receipt.getUnitType())) {
if (receipt.getId() != null) {
for (ReturnInventoryCarton carton : cartons.listReceipt(receipt.getId())) {
var label = inventory.label(order.getCustomerId(), carton.getCartonMark());
if (label != null) labels.add(new ReturnOrderResponses.CartonLabel(carton.getCartonMark(), label.printableHtml()));
@@ -0,0 +1,11 @@
package com.baiyue.lastleg.returnreceipt.service;
/** Physical piece progress is independent of warehouse identification-label confirmation. */
public final class ReturnRelabelProgress {
private ReturnRelabelProgress() { }
public static String status(long total, long completed, long reserved) {
if (total > 0 && completed >= total) return "COMPLETED";
if (completed > 0) return "PARTIALLY_COMPLETED";
return reserved > 0 ? "PROCESSING" : "UNLABELLED";
}
}
@@ -3563,3 +3563,28 @@ CREATE TABLE IF NOT EXISTS ll_appointment_impact_history (
CONSTRAINT fk_appointment_impact_history FOREIGN KEY (container_booking_id) REFERENCES ll_appointment_impact(container_booking_id),
CONSTRAINT chk_appointment_impact_action CHECK (action IN ('OBSERVED','CLAIM','RECORD','RESOLVE'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='船期观察及人工处理追加历史';
-- 退货以包裹/箱为最小出库实物单位;部分数量必须先合箱生成新标签箱。
-- 可重复执行,目标库 baiyue_lastleg。
USE baiyue_lastleg;
-- 合箱后的新箱与原包裹共用同一箱唛、标签和来源追溯模型。
-- 流水约束允许部分合箱/拆分只扣件数,不虚减箱数。
SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.table_constraints
WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_outbound_movement'
AND constraint_name='chk_ll_return_outbound_movement_delta'),
'ALTER TABLE ll_return_inventory_outbound_movement DROP CHECK chk_ll_return_outbound_movement_delta, ADD CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0 AND total_before-total_after=quantity AND available_before-available_after=quantity AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)',
'ALTER TABLE ll_return_inventory_outbound_movement ADD CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0 AND total_before-total_after=quantity AND available_before-available_after=quantity AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 同一包裹可分批合箱/出库,重复提交仍由 outbound_pick_id + SKU 幂等约束保护。
SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.statistics
WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_outbound_movement'
AND index_name='uk_ll_return_outbound_carton_sku'),
'ALTER TABLE ll_return_inventory_outbound_movement DROP INDEX uk_ll_return_outbound_carton_sku, ADD INDEX idx_ll_return_outbound_carton_sku(customer_id,carton_id,sku_code)',
'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 新签收及新合箱必须打印并扫码确认标签;既有实物按原状态继续作业。
SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='label_status'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN label_status VARCHAR(32) NULL COMMENT ''WAIT_LABEL待贴标 CONFIRMED已扫码确认标签''');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -824,8 +824,8 @@ paths:
responses: { '200': { description: 出库单分页 } }
/api/platform/v1/last-leg/outbound-orders/return:
post:
summary: 创建退货出库单,支持多个散件SKU分别填写出库数量
description: 权限 platform:overseas-warehouse:outbound:create;散件SKU来源仅允许PARCEL可用库存,不包含箱内库存;实体箱按箱或托盘出库。items逐来源指定SKU及数量,与cartonMarks一一对应,不可同时提交顶层skuCode或quantity,不支持PALLET方式。多个SKU生成同一张出库单和派送单,PDA逐SKU按件数拣货。旧版skuCode与quantity继续支持单SKU来源排序分配。未拣货前占用整个使用来源,拣货后来源余数恢复可用。相同幂等键改数量拒绝。
summary: 按包裹或箱标签创建整实物退货出库单
description: 权限 platform:overseas-warehouse:outbound:create;以包裹或箱为最小单位,cartonMarks为实际标签码,PARCEL可包含多个SKU,整包裹/箱或整托出库。新签收和新合箱须先扫码确认贴标。部分包裹及部分箱均须通过合箱生成独立新标签箱后才能出库。提交skuCode、quantity或非空items直接拒绝,不修改库存。来源合箱占用或尚未清点时拒绝,PDA按实物标签扣全部内容,同键重放不重复扣减。
security: [{ bearerAuth: [] }]
parameters: [{ name: customerId, in: query, required: true, schema: { type: string } }]
requestBody:
@@ -840,20 +840,6 @@ paths:
overseasWarehouseId: { type: string, maxLength: 128 }
returnNo: { type: string, maxLength: 128 }
cartonMarks: { type: array, minItems: 1, maxItems: 200, uniqueItems: true, items: { type: string, maxLength: 128 } }
skuCode: { type: string, maxLength: 128, nullable: true }
quantity: { type: integer, format: int64, minimum: 1, nullable: true, description: 单SKU计划件数,不得超过所选来源总数;省略出全部,只允许skuCode已填且非PALLET维度,不接受小数 }
items:
type: array
nullable: true
minItems: 1
maxItems: 200
items:
type: object
required: [cartonMark, skuCode, quantity]
properties:
cartonMark: { type: string, maxLength: 128, description: 散件库存来源标识,非实体箱号 }
skuCode: { type: string, maxLength: 128 }
quantity: { type: integer, format: int64, minimum: 1, description: 本来源本次出库件数,不接受小数,不得超过可用数量 }
unitType: { type: string, enum: [BOX, PALLET], nullable: true }
remark: { type: string, maxLength: 1000, nullable: true }
idempotencyKey: { type: string, maxLength: 255 }
@@ -1764,7 +1750,7 @@ paths:
/api/merchant/v1/last-leg/return-orders/{returnNo}:
get:
summary: 客户查询退货订单与签收批次
description: 权限 merchant:overseas-warehouse:return-order:list;返回各 SKU 的预报和实收数量,以及每次 PDA 签收产生的批次号和可选快递单号。cartons 数组包含 receiptNo、palletCode、cartonMark、countStatus、countedAt、items(skuCode、productNameZh、quantity、boxCount)。CONFIRMED 为签收或清点时不可变箱内快照;PENDING 的 items 为空,不返回预分配平均数;UNRECORDED 表示历史未留存箱内快照,不能以已变化库存冒充签收数量。包裹散件不生成实物箱明细。
description: 权限 merchant:overseas-warehouse:return-order:list;返回各 SKU 的预报和实收数量,以及每次 PDA 签收产生的批次号和可选快递单号。cartons 数组包含 receiptNo、palletCode、cartonMark、countStatus、countedAt、items(skuCode、productNameZh、quantity、boxCount)。CONFIRMED 为签收或清点时不可变箱内快照;PENDING 的 items 为空,不返回预分配平均数;UNRECORDED 表示历史未留存箱内快照,不能以已变化库存冒充签收数量。包裹作为一个独立实物明细,保留同包裹全部SKU及可打印标签。
security: [{ bearerAuth: [] }]
parameters: [{ name: returnNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 当前客户退货订单详情 }, '404': { description: 当前客户下退货订单不存在 } }
@@ -1851,11 +1837,11 @@ paths:
- { name: returnNo, in: query, schema: { type: string } }
- { name: status, in: query, schema: { type: string } }
responses:
'200': { description: 分页 records 每箱一行,包含 cartonMark、overseasWarehouseId、sourceBatchNo、sourceReturnNo、status、skuTypes、totalQuantity、length、width、height、weight、createdAt;total 为实际箱数 }
'200': { description: 分页 records 每箱一行,包含 cartonMark、overseasWarehouseId、sourceBatchNo、sourceReturnNo、status、skuTypes、totalQuantity、length、width、height、weight、createdAt、relabelStatus、relabelledQuantity、relabelReservedQuantity;total 为实际箱数。换标状态取实际当前或最新轮次事实,申请占用为未完成申请件数,两者不从可用库存差值推算 }
/api/merchant/v1/last-leg/return-inventory/cartons/{cartonMark}:
get:
summary: 客户查询退货箱明细
description: 仅返回当前客户的箱号、签收批次和箱内 SKU 数量。
description: 仅返回当前客户的箱号、签收批次和箱内 SKU 数量;relabelStatus 为 UNLABELLED/PROCESSING/PARTIALLY_COMPLETED/COMPLETED,relabelledQuantity 为当前或最新轮次实际商品换标件数,relabelReservedQuantity 为未完成申请件数。items.availableQuantity 为实物可用库存,历史换标不会永久扣减下一轮可申请量。
security: [{ bearerAuth: [] }]
parameters: [{ name: cartonMark, in: path, required: true, schema: { type: string, maxLength: 128 } }]
responses: { '200': { description: 当前客户退货箱明细 }, '404': { description: 当前客户下退货箱不存在 } }
@@ -3118,6 +3104,10 @@ components:
RelabelInventoryCandidate:
type: object
properties:
relabelStatus: { type: string, nullable: true, enum: [UNLABELLED, PROCESSING, PARTIALLY_COMPLETED, COMPLETED], description: 退货实物按实际完成件数和未完成申请占用计算 }
relabelledPackages: { type: integer, format: int64, description: 退货实际已完成商品换标件数,不从可用库存差值推导 }
relabelledQuantity: { type: integer, format: int64, minimum: 0 }
relabelReservedQuantity: { type: integer, format: int64, minimum: 0, description: 未完成申请件数,取消或完成后释放 }
inventoryBalanceId: { type: string }
inboundOrderId: { type: string }
inboundOrderNo: { type: string }
@@ -3132,7 +3122,7 @@ components:
availablePackages: { type: integer, format: int64 }
version: { type: integer, format: int64 }
positions: { type: array, items: { $ref: '#/components/schemas/RelabelInventoryPosition' } }
sourceType: { type: string, enum: [INBOUND, RETURN_CARTON] }
sourceType: { type: string, enum: [INBOUND, RETURN_CARTON, RETURN_PARCEL] }
returnCartonId: { type: string, nullable: true, description: 退货合箱库存主键;sourceType=RETURN_CARTON 时提供 }
cartonMark: { type: string, nullable: true, description: 仓库合箱后打印并贴箱的仓库唛头 }
skuItems:
@@ -272,11 +272,82 @@ paths:
itemId: { type: string, pattern: '^[0-9]+$' }
skuIndex: { type: integer, minimum: 0 }
oldSku: { type: string, maxLength: 255 }
cartonMark: { type: string, maxLength: 128, description: 退货SKU作业必填,须扫描当前实体来源包裹或箱 }
newSkuCode: { type: string, maxLength: 128, description: 退货SKU作业必填,须与批准的目标标签一致 }
quantity: { type: integer, minimum: 1, description: 退货SKU作业必填,须与本明细批准件数一致 }
expectedVersion: { type: integer, minimum: 0 }
idempotencyKey: { type: string, maxLength: 255 }
responses:
'200': { description: 更新后的任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }
'409': { description: 已处理、映射不符、认领人或版本冲突 }
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/return-skus/complete:
post:
summary: 扫描来源包裹或箱及SKU登记退货按件换标实际完成量
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [itemId, quantity, expectedVersion, idempotencyKey, cartonMark, skuCode]
properties:
itemId: { type: string, pattern: '^[0-9]+$' }
quantity: { type: integer, minimum: 1 }
expectedVersion: { type: integer, minimum: 0 }
idempotencyKey: { type: string, maxLength: 255 }
cartonMark: { type: string, maxLength: 128 }
skuCode: { type: string, maxLength: 128 }
responses: { '200': { description: 更新后Detail;同键同请求重放 }, '409': { description: 来源、SKU、剩余件数或版本冲突 } }
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/return-skus/complete-total:
post:
summary: 单一来源的退货SKU按件完成
description: 同SKU涉及多个来源包裹或箱时拒绝自动分配,必须逐来源扫码。
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [skuCode, quantity, expectedVersion, idempotencyKey]
properties:
skuCode: { type: string }
quantity: { type: integer, minimum: 1 }
expectedVersion: { type: integer, minimum: 0 }
idempotencyKey: { type: string }
responses: { '200': { description: 更新后Detail }, '409': { description: 多来源或数量冲突 } }
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/return-cartons/pack:
post:
summary: 将本任务已完成的退货SKU件数隔离合箱为独立新箱
description: 必须由认领人在PROCESSING阶段完成所有申请明细后执行;原包裹余量守恒,保留跨退货单追溯。响应Detail的items切换为新箱,history的RETURN_PACK.detail JSON含round、sourceCartonMarks、sourceDeductions、cartonMark、quantity和label.printableHtml。标签为仓库识别标签,与商品换标资料分开。同键重放原结果。
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [sourceCartonMarks, idempotencyKey, length, width, height, weight]
properties:
sourceCartonMarks: { type: array, minItems: 1, maxItems: 100, items: { type: string } }
targetCartonMark: { type: string, nullable: true, description: 必须为空,由系统生成独立新箱 }
idempotencyKey: { type: string, maxLength: 255 }
sourceDeductions:
type: array
description: 可省略由服务端生成;填写时按来源箱和SKU汇总必须与本任务完成明细一致
items:
type: object
required: [cartonMark, skuCode, quantity]
properties:
cartonMark: { type: string }
skuCode: { type: string }
quantity: { type: integer, format: int64, minimum: 1 }
length: { type: number, exclusiveMinimum: true, minimum: 0 }
width: { type: number, exclusiveMinimum: true, minimum: 0 }
height: { type: number, exclusiveMinimum: true, minimum: 0 }
weight: { type: number, exclusiveMinimum: true, minimum: 0 }
responses: { '200': { description: 更新后Detail含新箱识别标签 }, '409': { description: 未完成申请、来源变动或数量冲突 } }
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/units/skip:
post:
summary: 登记一件有原因的跳过事实
@@ -751,6 +822,8 @@ components:
type: object
required: [relabelNo, applicationStatus, operationType, taskStatus, expectedPackages, relabelledPackages, skippedPackages, exceptionPackages, version, completionPhotoFileIds, items, unitFacts, history]
properties:
deliveryMethod: { type: string, nullable: true, description: 申请真实运输方式,留仓为空 }
requiresPalletBinding: { type: boolean, description: 退货任务仅TRUCK为true,留仓及快递为false }
relabelNo: { type: string }
customerOrderNo: { type: string, nullable: true }
labelDeadline: { type: string, format: date-time, nullable: true }
@@ -791,7 +864,7 @@ components:
relabelFileUrl: { type: string, nullable: true }
cartonMark: { type: string, nullable: true, description: 我方箱唛 }
fbaCartonMark: { type: string, nullable: true, description: 新FBA箱唛 }
operationType: { type: string, enum: [CARTON_LABEL, SKU_LABEL, CARTON_AND_SKU] }
operationType: { type: string, enum: [CARTON_LABEL, SKU_LABEL, CARTON_AND_SKU, RETURN_BULK_SKU_LABEL] }
skuItems: { type: array, items: { $ref: '#/components/schemas/RelabelSkuItem' } }
expectedPackages: { type: integer }
relabelledPackages: { type: integer }
@@ -799,6 +872,8 @@ components:
exceptionPackages: { type: integer }
cartonProcessedPackages: { type: integer, description: 本轮箱唛作业已完成箱数,混合单独立计数 }
skuProcessedPackages: { type: integer, description: 本轮SKU作业已完成箱数,两项均完成才增加整箱已换标数 }
returnCartonId: { type: string, nullable: true, description: 真实退货包裹或箱ID }
returnQuantity: { type: integer, format: int64, minimum: 0, description: 当前实物包裹或箱全部件数;不是申请量 }
RelabelSkuItem:
type: object
required: [skuCode, newSkuCode, quantity]
@@ -876,7 +951,7 @@ components:
inventoryPositionId: { type: string, nullable: true, pattern: '^[0-9]+$', description: 普通库存使用真实仓位键;退货箱库存没有该仓位,提交 null,不提交空字符串或退货库存内部ID }
packages: { type: integer, format: int64, minimum: 1 }
productQuantity: { type: integer, format: int64, minimum: 1, nullable: true, description: 单SKU退货明细存在returnSkuQuantity时必填且必须等于计划件数;不接受小数。历史无快照明细可省略 }
skuCode: { type: string, maxLength: 128, nullable: true, description: PARCEL散件单SKU扫码值;已装箱来源必须扫描箱唛 }
skuCode: { type: string, maxLength: 128, nullable: true, description: 仅遗留已创建SKU任务使用;新退货任务必须扫描包裹或箱标签 }
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
cartonMarks:
type: array
@@ -2,7 +2,7 @@ openapi: 3.0.3
info:
title: 退货合箱计划与箱级上架
version: 1.0.0
description: 散SKU查询客户全部同仓PARCEL可用库存;箱合并精确指定来源箱唛并扫描已有目标箱。托盘仅为可选载体。合箱不新增订单货量。
description: 包裹可包含多个SKU并拥有独立标签;整包裹/箱可直接出库,部分数量须精确选择来源包裹/箱合箱成新标签箱。托盘仅为可选载体。合箱不新增订单货量。
security: [{ bearerAuth: [] }]
paths:
/api/platform/v1/last-leg/return-packing-plans:
@@ -17,7 +17,7 @@ paths:
responses: { '200': { description: 业务响应信封 code为200成功,A0400/A0404/A0409校验或状态错误,requestId及traceId保留 }, '403': { description: 权限或角色不符 } }
post:
summary: 创建并锁定来源库存
description: return-inventory:list + lastleg:inventory:manage;同客户同仓已签收订单,BOX精确锁来源箱全部SKU,SKU只锁散件指定数量。
description: return-inventory:list + lastleg:inventory:manage;同客户同仓已签收订单,BOX精确锁来源箱全部SKU,SKU锁定所选包裹/箱的指定数量,来源实物在计划完成或取消前不可出库。
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/Create' } } } }
responses: { '200': { description: 业务响应信封 code为200成功,A0400/A0404/A0409校验或状态错误,requestId及traceId保留 }, '403': { description: 权限或角色不符 } }
/api/platform/v1/last-leg/return-packing-plans/{planNo}/review:
@@ -46,16 +46,22 @@ paths:
/api/pda/v1/last-leg/return-inventory/packing/plans/{planNo}/complete:
post:
summary: 多箱分配并完成计划
description: SKU目标总量须等于锁定量。BOX先选择新增目标箱或扫描已有目标箱,sourceDeductions逐来源箱及SKU指定实际扣除量(不超过锁定量),未使用预占释放;目标原库存不扣减,新箱须填写箱规并生成系统箱唛。全事务、同载荷重放标签快照。
description: SKU目标总量须等于锁定量。SKU仅支持新增目标箱;BOX整箱可扫描已有目标箱,部分扣除仅支持新增目标箱,sourceDeductions逐来源箱及SKU指定实际扣除量(不超过锁定量),未使用预占释放;目标原库存不扣减,新箱须填写箱规并生成系统箱唛。全事务、同载荷重放标签快照。
parameters: [{ name: planNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/Complete' } } } }
responses: { '200': { description: 业务响应信封 code为200成功,A0400/A0404/A0409校验或状态错误,requestId及traceId保留 }, '403': { description: 权限或角色不符 } }
/api/pda/v1/last-leg/return-inventory/cartons/{cartonMark}:
get:
summary: 扫描可用来源或目标箱
description: 仅同仓AVAILABLE实物箱,PARCEL不作为箱选择。
description: 仅同仓AVAILABLE包裹或箱,返回其全部SKU明细。
parameters: [{ name: cartonMark, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 业务响应信封 code为200成功,A0400/A0404/A0409校验或状态错误,requestId及traceId保留 }, '403': { description: 权限或角色不符 } }
/api/pda/v1/last-leg/return-inventory/cartons/{cartonMark}/confirm-label:
post:
summary: 扫描已贴好的包裹或箱标签
description: 绑定仓且AVAILABLE实物,幂等确认仓库识别标签;整包裹或箱无需确认此标签也可创建出库单。此操作不登记商品换标完成量。
parameters: [{ name: cartonMark, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 标签快照及业务响应 }, '403': { description: 权限或角色不符 } }
/api/pda/v1/last-leg/return-inventory/cartons/merge-boxes:
post:
summary: 来源箱并入已有目标箱
@@ -112,6 +118,7 @@ components:
returnNos: { type: array, minItems: 1, maxItems: 100, items: { type: string } }
mode: { type: string, enum: [SKU, BOX] }
sourceCartonMarks: { type: array, maxItems: 500, items: { type: string } }
sourceDeductions: { type: array, maxItems: 1000, items: { $ref: '#/components/schemas/SourceDeduction' }, description: 部分合箱精确来源及数量,与目标SKU合计一致 }
boxes: { type: array, minItems: 1, maxItems: 100, items: { $ref: '#/components/schemas/Box' } }
remark: { type: string, maxLength: 1000 }
idempotencyKey: { type: string, maxLength: 255 }
@@ -67,6 +67,7 @@ import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
@@ -209,105 +210,52 @@ class DeliveryTransferServiceTest {
var candidates = service.returnOutboundCandidates("TENANT-A", "WH-1");
assertEquals(List.of("RC-PAL"), candidates.stream().map(DeliveryResponses.ReturnOutboundCandidate::cartonMark).toList());
assertEquals(List.of("RC-PAL", "RC-PARCEL"), candidates.stream().map(DeliveryResponses.ReturnOutboundCandidate::cartonMark).toList());
}
@Test
void directPartialReturnOutboundRequiresPackingBeforeAnyInventoryLock() {
for (var request : List.of(
new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1", List.of("RP-1"), null, "partial-sku", "SKU-A", "BOX", 1L),
new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1", List.of("BOX-1"), null, "partial-box", null, "BOX", null,
List.of(new DeliveryRequests.ReturnSkuOutboundItem("BOX-1", "SKU-A", 1L))))) {
var error = assertThrows(ResponseStatusException.class, () -> service.createReturnOutbound("TENANT-A", "U-1", request));
assertTrue(error.getReason().contains("先合箱"));
}
verify(returnCartons, never()).lockMark(anyString());
verify(outboundOrders, never()).insert(any(OutboundOrder.class));
}
@Test
void singleSkuReturnOutboundReservesParcelAndCreatesDelivery() {
ReturnInventoryCarton parcel = returnCarton("RC-PARCEL", null);
parcel.setId(901L); parcel.setSourceUnitType("PARCEL"); parcel.setTotalQuantity(3L);
ReturnInventoryCartonItem line = new ReturnInventoryCartonItem();
line.setSkuCode("SKU-A"); line.setQuantity(3L);
when(returnCartons.lockMark("RC-PARCEL")).thenReturn(parcel);
when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(line));
service.createReturnOutbound("TENANT-A", "U-1",
new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1",
List.of("RC-PARCEL"), null, "sku-return-1", "SKU-A"));
ArgumentCaptor<OutboundPalletItem> item = ArgumentCaptor.forClass(OutboundPalletItem.class);
ArgumentCaptor<DeliveryOrder> delivery = ArgumentCaptor.forClass(DeliveryOrder.class);
verify(outboundPallets).insert(item.capture());
verify(deliveryOrders).insert(delivery.capture());
assertEquals("SKU-A", item.getValue().getSkuCode());
assertEquals("RC-PARCEL", item.getValue().getCartonMarks());
void wholeMultiSkuParcelCreatesBarcodePickWithoutSkuQuantity() {
var parcel = returnCarton("RP-1", null); parcel.setId(901L); parcel.setSourceUnitType("PARCEL"); parcel.setTotalQuantity(5L);
var a = new ReturnInventoryCartonItem(); a.setSkuCode("SKU-A"); a.setQuantity(3L);
var b = new ReturnInventoryCartonItem(); b.setSkuCode("SKU-B"); b.setQuantity(2L);
when(returnCartons.lockMark("RP-1")).thenReturn(parcel);
when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(a,b));
service.createReturnOutbound("TENANT-A", "U-1", new DeliveryRequests.ReturnOutbound("receiver","WH-1","RT-1",List.of("RP-1"),null,"whole-package",null,"BOX"));
var capture=ArgumentCaptor.forClass(OutboundPalletItem.class); verify(outboundPallets).insert(capture.capture());
assertEquals("RP-1", capture.getValue().getCartonMarks());
assertNull(capture.getValue().getSkuCode()); assertNull(capture.getValue().getReturnSkuQuantity());
assertEquals("OUTBOUND_PENDING", parcel.getStatus());
assertEquals("WAIT_DELIVERY", delivery.getValue().getStatus());
assertEquals(1, delivery.getValue().getPackageCount());
}
@Test
void partialSkuOutboundDistributesSixPiecesAcrossTwoFivePieceSources() {
var first = returnCarton("RC-1", null); first.setId(901L); first.setTotalQuantity(5L);
var second = returnCarton("RC-2", null); second.setId(902L); second.setTotalQuantity(5L);
first.setSourceUnitType("PARCEL"); second.setSourceUnitType("PARCEL");
when(returnCartons.lockMark("RC-1")).thenReturn(first); when(returnCartons.lockMark("RC-2")).thenReturn(second);
var line = new ReturnInventoryCartonItem(); line.setSkuCode("SKU-A"); line.setQuantity(5L);
when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(line));
when(returnCartonItems.lockCarton(902L)).thenReturn(List.of(line));
service.createReturnOutbound("TENANT-A", "U-1", new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1",
List.of("RC-1", "RC-2"), null, "partial-six", "SKU-A", "BOX", 6L));
var capture = ArgumentCaptor.forClass(OutboundPalletItem.class);
verify(outboundPallets, org.mockito.Mockito.times(2)).insert(capture.capture());
assertEquals(List.of(5L, 1L), capture.getAllValues().stream().map(OutboundPalletItem::getReturnSkuQuantity).toList());
}
@Test
void singleSkuReturnOutboundRejectsPackedSingleSkuWithoutReservingInventory() {
ReturnInventoryCarton carton = returnCarton("RC-BOX", "PAL-1");
carton.setSourceUnitType("BOX"); carton.setTotalQuantity(5L);
when(returnCartons.lockMark("RC-BOX")).thenReturn(carton);
var error = assertThrows(ResponseStatusException.class, () -> service.createReturnOutbound("TENANT-A", "U-1",
new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1", List.of("RC-BOX"), null, "packed-sku", "SKU-A")));
assertTrue(error.getReason().contains("单SKU出库只允许散件库存"));
verify(outboundOrders, never()).insert(any(OutboundOrder.class));
verify(returnCartons, never()).updateById(any(ReturnInventoryCarton.class));
assertEquals("AVAILABLE", carton.getStatus());
}
@Test
void multipleLooseSkusCreateOneOutboundWithExactPerSourceQuantities() {
var first = returnCarton("SP-A", null); first.setId(901L); first.setSourceUnitType("PARCEL"); first.setTotalQuantity(5L);
var second = returnCarton("SP-B", null); second.setId(902L); second.setSourceUnitType("PARCEL"); second.setTotalQuantity(8L);
when(returnCartons.lockMark("SP-A")).thenReturn(first); when(returnCartons.lockMark("SP-B")).thenReturn(second);
var a = new ReturnInventoryCartonItem(); a.setSkuCode("SKU-A"); a.setQuantity(5L);
var b = new ReturnInventoryCartonItem(); b.setSkuCode("SKU-B"); b.setQuantity(8L);
when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(a)); when(returnCartonItems.lockCarton(902L)).thenReturn(List.of(b));
service.createReturnOutbound("TENANT-A", "U-1", new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1",
List.of("SP-A", "SP-B"), null, "multi-loose", null, "BOX", null,
List.of(new DeliveryRequests.ReturnSkuOutboundItem("SP-A", "SKU-A", 2L), new DeliveryRequests.ReturnSkuOutboundItem("SP-B", "SKU-B", 3L))));
var capture = ArgumentCaptor.forClass(OutboundPalletItem.class);
verify(outboundPallets, org.mockito.Mockito.times(2)).insert(capture.capture());
assertEquals(List.of("SKU-A", "SKU-B"), capture.getAllValues().stream().map(OutboundPalletItem::getSkuCode).toList());
assertEquals(List.of(2L, 3L), capture.getAllValues().stream().map(OutboundPalletItem::getReturnSkuQuantity).toList());
verify(outboundOrders).insert(any(OutboundOrder.class)); verify(deliveryOrders).insert(any(DeliveryOrder.class));
}
@Test
void looseItemQuantityCannotExceedItsOwnSource() {
var first = returnCarton("SP-A", null); first.setId(901L); first.setSourceUnitType("PARCEL"); first.setTotalQuantity(1L);
when(returnCartons.lockMark("SP-A")).thenReturn(first);
var a = new ReturnInventoryCartonItem(); a.setSkuCode("SKU-A"); a.setQuantity(1L);
when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(a));
var error = assertThrows(ResponseStatusException.class, () -> service.createReturnOutbound("TENANT-A", "U-1", new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1",
List.of("SP-A"), null, "over-source", null, "BOX", null, List.of(new DeliveryRequests.ReturnSkuOutboundItem("SP-A", "SKU-A", 2L)))));
assertTrue(error.getReason().contains("超过")); verify(outboundOrders, never()).insert(any(OutboundOrder.class));
}
@Test
void singleSkuReturnOutboundRejectsMixedCarton() {
ReturnInventoryCarton mixed = returnCarton("RC-MIXED", null);
mixed.setSourceUnitType("PARCEL");
mixed.setId(901L); mixed.setTotalQuantity(5L);
ReturnInventoryCartonItem skuA = new ReturnInventoryCartonItem(); skuA.setSkuCode("SKU-A"); skuA.setQuantity(3L);
ReturnInventoryCartonItem skuB = new ReturnInventoryCartonItem(); skuB.setSkuCode("SKU-B"); skuB.setQuantity(2L);
when(returnCartons.lockMark("RC-MIXED")).thenReturn(mixed);
when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(skuA, skuB));
assertEquals(409, assertThrows(ResponseStatusException.class, () -> service.createReturnOutbound("TENANT-A", "U-1",
new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1",
List.of("RC-MIXED"), null, "sku-return-2", "SKU-A"))).getStatusCode().value());
verify(outboundOrders, never()).insert(any(OutboundOrder.class));
void newlyReceivedParcelCanOutboundBeforeItsLabelIsConfirmed() {
var parcel = returnCarton("RP-1", null); parcel.setLabelStatus("WAIT_LABEL");
when(returnCartons.lockMark("RP-1")).thenReturn(parcel);
var line = new ReturnInventoryCartonItem(); line.setSkuCode("SKU-A"); line.setQuantity(3L);
when(returnCartonItems.lockCarton(anyLong())).thenReturn(List.of(line));
service.createReturnOutbound("TENANT-A", "U-1",
new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1", List.of("RP-1"), null, "waiting-label"));
verify(outboundOrders).insert(any(OutboundOrder.class));
assertEquals("OUTBOUND_PENDING", parcel.getStatus());
}
@Test
@@ -71,27 +71,13 @@ class ReturnOutboundClosureTest {
verify(outbounds, times(1)).insert(any(OutboundOrder.class));
}
@Test void singleSkuPackedBoxIsExcludedFromLooseOutbound() {
assertThatThrownBy(() -> service.createReturnOutbound("C1", "USER", command("SKU1")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("散件库存");
verify(items, never()).insert(any(OutboundPalletItem.class));
assertThat(carton.getStatus()).isEqualTo("AVAILABLE");
}
@Test void singleSkuCannotConsumeMixedSkuCarton() {
ReturnInventoryCartonItem other = new ReturnInventoryCartonItem(); other.setSkuCode("SKU2"); other.setQuantity(1L);
var first = contents.lockCarton(10L).get(0);
when(contents.lockCarton(10L)).thenReturn(List.of(first, other));
assertThatThrownBy(() -> service.createReturnOutbound("C1", "USER", command("SKU1")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("SKU");
verify(outbounds, never()).insert(any(OutboundOrder.class));
}
@Test void singleSkuParcelReservesAndKeepsScanSku() {
@Test void wholeParcelUsesItsPhysicalBarcode() {
carton.setSourceUnitType("PARCEL");
service.createReturnOutbound("C1", "USER", command("SKU1"));
verify(items).insert(argThat((OutboundPalletItem i) -> "SKU1".equals(i.getSkuCode())));
assertThat(carton.getStatus()).isEqualTo("OUTBOUND_PENDING");
service.createReturnOutbound("C1", "USER", command(null));
verify(items).insert(argThat((OutboundPalletItem i) -> i.getSkuCode() == null && i.getReturnSkuQuantity() == null && "RC#1-1".equals(i.getCartonMarks())));
}
@Test void refusesPackingReservedQuantity() {
@@ -123,29 +123,6 @@ class ReturnOutboundPdaPersistenceTest {
assertEquals(1, after.items().stream().flatMap(item -> item.positions().stream()).mapToLong(row -> row.availablePackages()).sum());
}
@Test void multipleLooseSkusPersistPickAndDeductTheirOwnPieceQuantitiesWithReplayProtection() {
jdbc.update("UPDATE ll_return_inventory_carton SET source_unit_type='PARCEL' WHERE id IN (1,2)");
jdbc.update("UPDATE ll_return_inventory_carton_item SET sku_code='SKU-B' WHERE id=2");
jdbc.update("UPDATE ll_return_inventory_balance SET total_quantity=5,available_quantity=5,carton_quantity=1 WHERE id=1");
jdbc.update("INSERT INTO ll_return_inventory_balance(id,customer_id,overseas_warehouse_id,sku_code,total_quantity,available_quantity,carton_quantity) VALUES(10,'C1','WH-1','SKU-B',5,5,1)");
var task = service.createReturnOutbound("C1", "operator", new DeliveryRequests.ReturnOutbound("recipient", "WH-1", "RT-1",
List.of("BOX-A", "BOX-B"), null, "multi-sku", null, "BOX", null,
List.of(new DeliveryRequests.ReturnSkuOutboundItem("BOX-A", "SKU-A", 2L), new DeliveryRequests.ReturnSkuOutboundItem("BOX-B", "SKU-B", 3L))));
var detail = service.pdaTaskDetail("WH-1", task.taskNo());
assertEquals(2, detail.items().size());
for (var item : detail.items()) {
String sku = item.skuCode(); long quantity = "SKU-A".equals(sku) ? 2 : 3;
var request = new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "pick-" + sku, List.of(), sku, quantity);
service.pdaPick("WH-1", "operator", task.taskNo(), request);
service.pdaPick("WH-1", "operator", task.taskNo(), request);
}
assertEquals(3L, jdbc.queryForObject("SELECT available_quantity FROM ll_return_inventory_balance WHERE id=1", Long.class));
assertEquals(2L, jdbc.queryForObject("SELECT available_quantity FROM ll_return_inventory_balance WHERE id=10", Long.class));
assertEquals(5L, jdbc.queryForObject("SELECT SUM(quantity) FROM ll_return_inventory_outbound_movement", Long.class));
assertEquals(2, jdbc.queryForObject("SELECT COUNT(*) FROM ll_outbound_pda_pick", Integer.class));
assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM ll_outbound_order", Integer.class));
assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM ll_delivery_order", Integer.class));
}
@Test void wholeReturnPalletRejectsPartialScansAndChangedContentsThenShipsAtomically() {
jdbc.update("UPDATE ll_return_inventory_carton SET pallet_code='PAL-1' WHERE id IN (1,2)");
var task = service.createReturnOutbound("C1", "operator", new DeliveryRequests.ReturnOutbound(
@@ -308,7 +308,7 @@ class PdaRelabelServiceTest {
}
@Test
void returnSkuTotalCompletesOneSkuOnceAndAllocatesAcrossSourcePalletItems() {
void returnSkuTotalRejectsAutomaticAllocationAcrossSourceCartons() {
application.setStatus("PROCESSING"); application.setNotifiedPackageCount(100);
item.setOperationType("RETURN_BULK_SKU_LABEL"); item.setSkuCode("sku-fba100");
item.setNotifiedPackageCount(50); item.setCartonMark("PALLET-A");
@@ -319,15 +319,42 @@ class PdaRelabelServiceTest {
when(tasks.lock("100", 1L)).thenReturn(task);
when(items.selectList(any())).thenReturn(List.of(item, second));
var result = service.completeReturnSkuTotal("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.BulkSkuTotal("sku-fba100", 100, 0, "sku-total-1"));
assertThrows(ResponseStatusException.class, () -> service.completeReturnSkuTotal("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.BulkSkuTotal("sku-fba100", 100, 0, "sku-total-1")));
assertTrue(persistedFacts.isEmpty());
}
assertEquals(100, result.relabelledPackages());
assertEquals(2, persistedFacts.size());
assertEquals(List.of(50, 50), persistedFacts.stream().map(PdaRelabelUnitFact::getQuantity).toList());
assertEquals(List.of(10L, 11L), persistedFacts.stream().map(PdaRelabelUnitFact::getItemId).toList());
@Test void returnSkuScanRequiresPhysicalSourceNewSkuAndApprovedQuantity() {
application.setStatus("PROCESSING"); application.setDeliveryMethod("EXPRESS");
item.setOperationType("SKU_LABEL"); item.setReturnCartonId(20L); item.setCartonMark("PARCEL-A");
item.setNotifiedPackageCount(1);
item.setSkuRelabelItemsJson("[{\"skuCode\":\"OLD\",\"newSkuCode\":\"NEW\",\"quantity\":3}]");
var cartons = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper.class);
ReflectionTestUtils.setField(service, "returnCartons", cartons);
var carton = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton();
carton.setId(20L); carton.setCountStatus("CONFIRMED"); carton.setTotalQuantity(10L);
when(cartons.selectById(20L)).thenReturn(carton);
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
when(tasks.lock("100", 1L)).thenReturn(task("PROCESSING", 0));
when(items.selectOne(any())).thenReturn(item);
assertThrows(ResponseStatusException.class, () -> service.scanSku("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.SkuScan(10L, 0, "OLD", 0, "wrong-source", "PARCEL-B", "NEW", 3)));
assertThrows(ResponseStatusException.class, () -> service.scanSku("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.SkuScan(10L, 0, "OLD", 0, "wrong-quantity", "PARCEL-A", "NEW", 2)));
assertTrue(persistedFacts.isEmpty());
var result = service.scanSku("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.SkuScan(10L, 0, "OLD", 0, "good", "PARCEL-A", "NEW", 3));
assertEquals(1, result.relabelledPackages()); assertEquals("EXPRESS", result.deliveryMethod());
assertEquals(false, result.requiresPalletBinding()); assertEquals(10, result.items().get(0).returnQuantity());
var second = item(); second.setId(11L); second.setReturnCartonId(20L); second.setCartonMark("PARCEL-A");
second.setOperationType("SKU_LABEL"); second.setNotifiedPackageCount(1);
second.setSkuRelabelItemsJson(item.getSkuRelabelItemsJson());
when(items.selectList(any())).thenReturn(List.of(item, second));
when(items.selectOne(any())).thenReturn(second);
var again = service.scanSku("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.SkuScan(11L, 0, "OLD", 1, "same-merged-box", "PARCEL-A", "NEW", 3));
assertEquals(2, again.relabelledPackages());
assertTrue(persistedFacts.stream().allMatch(fact -> fact.getOldLabel() == null));
assertEquals(1, result.version());
}
@Test
@@ -452,7 +452,7 @@ class RelabelApplicationServiceTest {
ReturnOrder returnOrder = new ReturnOrder();
returnOrder.setId(41L); returnOrder.setCustomerId("10001");
returnOrder.setRelabelApplicationId(application.getId()); returnOrder.setRelabelStatus("PROCESSING");
when(returnOrders.selectOne(any())).thenReturn(returnOrder);
when(returnOrders.selectList(any())).thenReturn(List.of(returnOrder));
service.changeStatus("10001", "FBR-REVIEW", "operator-2",
new RelabelRequests.ChangeStatus(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, "submitted"));
@@ -16,6 +16,27 @@ import static org.mockito.ArgumentMatchers.*;
import static org.junit.jupiter.api.Assertions.*;
class RelabelBindingGuardTest {
@Test void partialReturnSkuMustBeIsolatedAndLooseFullCartonOnlyRequiresPalletForTruck() {
var guard = new RelabelBindingGuard();
var items = mock(RelabelItemMapper.class); var histories = mock(PdaRelabelHistoryMapper.class);
var lines = mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper.class);
var cartons = mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper.class);
ReflectionTestUtils.setField(guard, "items", items); ReflectionTestUtils.setField(guard, "histories", histories);
ReflectionTestUtils.setField(guard, "returnLines", lines); ReflectionTestUtils.setField(guard, "returnCartons", cartons);
ReflectionTestUtils.setField(guard, "objectMapper", new ObjectMapper());
var app = new RelabelApplication(); app.setId(1L); app.setCustomerId("2");
var item = new RelabelItem(); item.setReturnCartonId(3L); item.setCartonMark("PARCEL");
item.setOperationType("SKU_LABEL"); item.setNotifiedPackageCount(1);
item.setSkuRelabelItemsJson("[{\"skuCode\":\"A\",\"newSkuCode\":\"NEW-A\",\"quantity\":3}]");
when(items.selectList(any())).thenReturn(List.of(item)); when(histories.selectList(any())).thenReturn(List.of());
var stock = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem(); stock.setSkuCode("A"); stock.setQuantity(10L);
when(lines.lockCarton(3L)).thenReturn(List.of(stock));
assertThrows(ResponseStatusException.class, () -> guard.requireBound(app));
stock.setQuantity(3L);
assertDoesNotThrow(() -> guard.requireBound(app));
app.setDeliveryMethod("EXPRESS"); assertDoesNotThrow(() -> guard.requireBound(app));
app.setDeliveryMethod("TRUCK"); assertThrows(ResponseStatusException.class, () -> guard.requireBound(app));
}
@Test void physicalBindingMustMatchLatestTargetAndCannotSurviveRework() {
var guard = new RelabelBindingGuard();
var items = mock(RelabelItemMapper.class);
@@ -36,9 +36,9 @@ class RelabelReturnCartonCandidateTest {
ReturnInventoryCartonItem cartonItem = new ReturnInventoryCartonItem();
cartonItem.setCartonId(9001L); cartonItem.setSkuCode("SKU-RETURN");
cartonItem.setProductNameZh("退货商品"); cartonItem.setQuantity(12L);
when(cartons.findAvailable("10001", "WH-1")).thenReturn(List.of(carton));
when(cartons.findRelabelCandidates("10001", "WH-1")).thenReturn(List.of(carton));
when(cartonItems.listCarton(9001L)).thenReturn(List.of(cartonItem));
when(cartons.relabelledQuantity(9001L, "SKU-RETURN")).thenReturn(1L);
when(cartons.totalRelabelledQuantity(9001L)).thenReturn(1L);
List<RelabelResponses.InventoryCandidate> result = service.candidates("10001", "WH-1", "RC-WH");
@@ -46,8 +46,14 @@ class RelabelReturnCartonCandidateTest {
assertEquals("RETURN_CARTON", result.get(0).sourceType());
assertEquals(9001L, result.get(0).returnCartonId());
assertEquals("RC-WH-0001", result.get(0).cartonMark());
assertEquals(11, result.get(0).availablePackages());
assertEquals(12, result.get(0).availablePackages());
assertEquals("SKU-RETURN", result.get(0).skuItems().get(0).skuCode());
assertEquals(11, result.get(0).skuItems().get(0).quantity());
assertEquals(12, result.get(0).skuItems().get(0).quantity());
assertEquals(1, result.get(0).relabelledQuantity());
assertEquals("PARTIALLY_COMPLETED", result.get(0).relabelStatus());
cartonItem.setPackingReservedQuantity(4L);
result = service.candidates("10001", "WH-1", "RC-WH");
assertEquals(8, result.get(0).availablePackages());
assertEquals(1, result.get(0).relabelledQuantity(), "合箱预占不能冒充实际换标完成量");
}
}
@@ -0,0 +1,107 @@
package com.baiyue.lastleg.relabel.service;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.relabel.entity.*;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.returnreceipt.dto.*;
import com.baiyue.lastleg.returnreceipt.entity.*;
import com.baiyue.lastleg.returnreceipt.mapper.*;
import com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class ReturnRelabelPackingServiceTest {
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
void skuPackingMovesCompletionToIndependentCartonAndKeepsOriginalSourceMarks(boolean inheritedCompletion) {
var service = new ReturnRelabelPackingService();
var cartons = mock(ReturnInventoryCartonMapper.class);
var items = mock(RelabelItemMapper.class);
var inventory = mock(ReturnInventoryService.class);
ReflectionTestUtils.setField(service, "cartons", cartons);
ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "json", new ObjectMapper());
var app = new RelabelApplication(); app.setId(1L); app.setCustomerId("2"); app.setRelabelNo("RL1");
var first = item(10L, 20L, "A"); var second = item(11L, 21L, "B");
var a = carton(20L, "A"); var b = carton(21L, "B"); var target = carton(22L, "NEW");
var history = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper.class);
ReflectionTestUtils.setField(service, "histories", history);
if (inheritedCompletion) {
var prior = item(9L, 20L, "A"); prior.setApplicationId(99L);
prior.setSkuRelabelItemsJson("[{\"skuCode\":\"SKU\",\"quantity\":6}]");
when(items.priorCompletedReturnItems("2", 20L, 1L)).thenReturn(List.of(prior));
var facts = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelUnitFactMapper.class);
var fact = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact(); fact.setItemId(9L); fact.setQuantity(1);
when(facts.selectList(any())).thenReturn(List.of(fact));
ReflectionTestUtils.setField(service, "facts", facts);
var physical = mock(ReturnInventoryCartonItemMapper.class);
var line = new ReturnInventoryCartonItem(); line.setSkuCode("SKU"); line.setQuantity(6L);
when(physical.lockCarton(20L)).thenReturn(List.of(line));
ReflectionTestUtils.setField(service, "cartonItems", physical);
ReflectionTestUtils.setField(service, "completionLedger", mock(ReturnRelabelCompletionLedgerMapper.class));
var tasks = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class);
var task = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask(); task.setId(100L);
when(tasks.find("2", 1L)).thenReturn(task); ReflectionTestUtils.setField(service, "tasks", tasks);
}
when(cartons.lockMark("A")).thenReturn(a); when(cartons.lockMark("B")).thenReturn(b);
when(cartons.lockMark("NEW")).thenReturn(target);
when(inventory.mergeBoxes(any(), any())).thenAnswer(call -> {
var request = (ReturnInventoryRequests.BoxMerge)call.getArgument(1);
assertEquals(List.of("A", "B"), request.sourceCartonMarks());
assertNull(request.targetCartonMark());
assertEquals(List.of(new ReturnInventoryRequests.SourceDeduction("A", "SKU", 3),
new ReturnInventoryRequests.SourceDeduction("B", "SKU", 3)), request.sourceDeductions());
return new ReturnInventoryResponses.MergeResult("NEW", "SKU", 6, null);
});
var result = service.pack(new PdaWarehouseIdentity("op", "picker", 42L), app, List.of(first, second),
new ReturnInventoryRequests.BoxMerge(List.of("A", "B"), null, "key", null,
BigDecimal.TEN, BigDecimal.TEN, BigDecimal.TEN, BigDecimal.ONE));
assertEquals("NEW", result.cartonMark());
assertEquals(22L, first.getReturnCartonId()); assertEquals(22L, second.getReturnCartonId());
assertEquals("A", first.getSourceCartonMark()); assertEquals("B", second.getSourceCartonMark());
assertEquals("RELABEL_PENDING", target.getStatus());
verify(cartons).clearRelabel(20L); verify(cartons).clearRelabel(21L);
verify(items).updateById(first); verify(items).updateById(second);
if (inheritedCompletion) {
var saved = org.mockito.ArgumentCaptor.forClass(com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory.class);
verify(history).insert(saved.capture());
assertEquals("RETURN_COMPLETION_TRANSFER", saved.getValue().getAction());
assertTrue(saved.getValue().getDetailJson().contains("\"sourceItemIds\":[\"9\"]"));
assertTrue(saved.getValue().getDetailJson().contains("\"quantity\":3"));
assertTrue(saved.getValue().getDetailJson().contains("\"targetCartonMark\":\"NEW\""));
} else verifyNoInteractions(history);
}
@Test void wrongSourceAndDifferentDestinationCannotEnterInventoryMutation() {
var service = new ReturnRelabelPackingService();
var inventory = mock(ReturnInventoryService.class);
ReflectionTestUtils.setField(service, "inventory", inventory);
var app = new RelabelApplication();
var a = item(10L, 20L, "A"); var b = item(11L, 21L, "B"); b.setDestinationFbaWarehouseCode("OTHER");
assertThrows(ResponseStatusException.class, () -> service.pack(new PdaWarehouseIdentity("op", "name", 42L), app,
List.of(a), new ReturnInventoryRequests.BoxMerge(List.of("OTHER"), null, "wrong")));
assertThrows(ResponseStatusException.class, () -> service.pack(new PdaWarehouseIdentity("op", "name", 42L), app,
List.of(a, b), new ReturnInventoryRequests.BoxMerge(List.of("A", "B"), null, "dest")));
verifyNoInteractions(inventory);
}
private RelabelItem item(long id, long cartonId, String mark) {
var item = new RelabelItem(); item.setId(id); item.setReturnCartonId(cartonId);
item.setCartonMark(mark); item.setSourceCartonMark(mark); item.setOperationType("SKU_LABEL");
item.setNotifiedPackageCount(1); item.setDestinationFbaWarehouseCode("FBA");
item.setSkuRelabelItemsJson("[{\"skuCode\":\"SKU\",\"newSkuCode\":\"NEW-SKU\",\"quantity\":3}]");
return item;
}
private ReturnInventoryCarton carton(long id, String mark) {
var carton = new ReturnInventoryCarton(); carton.setId(id); carton.setCartonMark(mark);
carton.setCustomerId("2"); carton.setRelabelApplicationId(1L); carton.setStatus("RELABEL_PENDING");
return carton;
}
}
@@ -127,7 +127,7 @@ class ReturnInventoryServiceTest {
}
@Test
void pendingReceiptDetailsDoNotExposeProvisionalAveragesAndParcelsHaveNoBoxes() {
void pendingReceiptDetailsDoNotExposeProvisionalAveragesForPhysicalUnits() {
var receipt = new ReturnReceipt(); receipt.setId(21L); receipt.setCustomerId("C1"); receipt.setReceiptNo("RR-1"); receipt.setUnitType("PALLET");
var box = new ReturnInventoryCarton(); box.setCartonMark("RC#2-1"); box.setCountStatus("PENDING"); box.setTotalQuantity(5L);
when(cartons.listReceipt(21L)).thenReturn(List.of(box));
@@ -135,7 +135,7 @@ class ReturnInventoryServiceTest {
assertThat(detail.get(0).countStatus()).isEqualTo("PENDING");
assertThat(detail.get(0).items()).isEmpty();
receipt.setUnitType("PARCEL");
assertThat(service.receiptContents(receipt)).isEmpty();
assertThat(service.receiptContents(receipt)).hasSize(1);
verifyNoInteractions(cartonItems);
}
@@ -150,13 +150,14 @@ class ReturnInventoryServiceTest {
}
@Test
void parcelSigningStoresLooseSkuSourceWithoutGeneratingCartonNumberOrIncreasingBoxCount() {
void parcelSigningCreatesOneLabelledPhysicalUnitWithoutIncreasingBoxCount() {
var receipt = new ReturnReceipt(); receipt.setId(21L); receipt.setUnitType("PARCEL"); receipt.setUnitCode("PK-1");
var source = service.createReceiptCarton(completedOrder(), receipt, null,
List.of(new ReturnInventoryService.ReceiptLine("A", "商品A", 3)));
assertThat(source.getCartonMark()).startsWith("SP");
assertThat(source.getCartonMark()).startsWith("RP");
assertThat(source.getSourceUnitType()).isEqualTo("PARCEL");
verifyNoInteractions(numbers, operations);
verifyNoInteractions(numbers);
assertThat(source.getLabelStatus()).isEqualTo("WAIT_LABEL");
verify(balances).adjust(anyLong(), anyString(), anyString(), eq("A"), eq("商品A"), eq(3L), eq(3L), eq(0L));
}
@@ -227,7 +228,7 @@ class ReturnInventoryServiceTest {
void cartonPageCountsPhysicalCartonsAndKeepsTheAuthenticatedCustomerScope() {
var query = new ReturnInventoryRequests.Query(2, 1, "42", "SKU", null, "AVAILABLE", "RT-1");
ReturnInventoryCarton carton = new ReturnInventoryCarton();
carton.setCartonMark("RC-SECOND");
carton.setId(31L); carton.setCartonMark("RC-SECOND");
carton.setSkuTypes(2);
carton.setTotalQuantity(5L);
when(cartons.merchantCount("10001", query)).thenReturn(2L);
@@ -369,6 +370,32 @@ class ReturnInventoryServiceTest {
verify(balances).adjustCartonQuantity("10001", "42", "SKU-A", 1L);
}
@Test void completedLabelQuantityCannotBeGuessedWhenSplittingAStoredCarton() {
prepareConcurrentSource();
when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 2L)));
when(cartons.relabelledQuantity(31L, "SKU-A")).thenReturn(1L);
var error = assertThrows(ResponseStatusException.class, () -> service.merge(
new PdaWarehouseIdentity("operator-2", "name", 42L), mergeRequest(1)));
assertThat(error.getReason()).contains("不能自动分配来源", "独立新箱");
verify(cartonItems, never()).updateById(any(ReturnInventoryCartonItem.class));
verify(cartons, never()).insert(any(ReturnInventoryCarton.class));
}
@Test void wholeCartonMergeCarriesItsCompletedRelabelFactsToTheTarget() {
var source = prepareConcurrentSource();
when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 2L)));
when(cartons.relabelledQuantity(31L, "SKU-A")).thenReturn(2L);
when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-TARGET");
when(balances.adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong())).thenReturn(1);
var relabel = org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.mapper.RelabelItemMapper.class);
ReflectionTestUtils.setField(service, "relabelItems", relabel);
service.merge(new PdaWarehouseIdentity("operator-2", "name", 42L), mergeRequest(2));
var target = ArgumentCaptor.forClass(ReturnInventoryCarton.class);
verify(cartons).insert(target.capture());
verify(relabel).moveCompletedReturnCarton("10001", 31L, target.getValue().getId(), "RC-TARGET");
assertThat(source.getTotalQuantity()).isZero();
}
@Test
void sameKeyInsertRaceReplaysCommittedResultAndLabelUsingOnlyCurrentReads() {
var request = mergeRequest(1);
@@ -468,11 +495,11 @@ class ReturnInventoryServiceTest {
}
@Test
void planCreationLocksExactSkuAndCancelReleasesOnlyItsReservation() {
void planCreationLocksPhysicalSourceAndCancelReleasesReservation() {
var source = new ReturnInventoryCarton(); source.setId(31L); source.setCartonMark("RC-SOURCE");
source.setCustomerId("10001"); source.setOverseasWarehouseId("42"); source.setSourceReturnNo("RT-1");
source.setStatus("AVAILABLE"); source.setSourceUnitType("PARCEL");
var line = item(41L, "SKU-A", 5); line.setPackingReservedQuantity(1L);
var line = item(41L, "SKU-A", 5); line.setPackingReservedQuantity(0L);
when(orders.lockNo("RT-1")).thenReturn(completedOrder());
when(cartons.packingAvailable("10001", "42")).thenReturn(List.of(source));
when(cartons.lockMark("RC-SOURCE")).thenReturn(source);
@@ -483,12 +510,14 @@ class ReturnInventoryServiceTest {
var request = new ReturnInventoryRequests.PackingPlanCreate(List.of("RT-1"), "SKU",
List.of(new ReturnInventoryRequests.PlanBox(List.of(new ReturnInventoryRequests.PlanItem("SKU-A", 2)), null, null, null, null)), null, "plan-key");
var created = service.createPackingPlan("platform", request);
assertThat(created.status()).isEqualTo("PENDING_REVIEW"); assertThat(line.getPackingReservedQuantity()).isEqualTo(3);
assertThat(created.status()).isEqualTo("PENDING_REVIEW"); assertThat(line.getPackingReservedQuantity()).isEqualTo(2);
assertThat(source.getStatus()).isEqualTo("PACKING_RESERVED");
var captor = ArgumentCaptor.forClass(com.baiyue.lastleg.returnreceipt.entity.ReturnPackingPlan.class);
verify(packingPlans).insert(captor.capture());
when(packingPlans.lockNo("PLAN-1")).thenReturn(captor.getValue());
assertThat(service.cancelPackingPlan("PLAN-1").status()).isEqualTo("CANCELLED");
assertThat(line.getPackingReservedQuantity()).isEqualTo(1);
assertThat(line.getPackingReservedQuantity()).isEqualTo(0);
assertThat(source.getStatus()).isEqualTo("AVAILABLE");
verify(balances).adjust(anyLong(), org.mockito.ArgumentMatchers.eq("10001"), org.mockito.ArgumentMatchers.eq("42"), org.mockito.ArgumentMatchers.eq("SKU-A"), org.mockito.ArgumentMatchers.isNull(), org.mockito.ArgumentMatchers.eq(0L), org.mockito.ArgumentMatchers.eq(2L), org.mockito.ArgumentMatchers.eq(0L));
}
@@ -502,6 +531,35 @@ class ReturnInventoryServiceTest {
verifyNoInteractions(cartonItems, balances);
}
@Test
void partialPhysicalBoxCannotBeMergedIntoAnExistingTarget() {
var source = new ReturnInventoryCarton(); source.setId(31L); source.setCartonMark("RC-SOURCE");
source.setCustomerId("10001"); source.setOverseasWarehouseId("42"); source.setSourceReturnNo("RT-1");
source.setStatus("AVAILABLE"); source.setSourceUnitType("BOX");
when(cartons.lockMark("RC-SOURCE")).thenReturn(source);
when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 5)));
var request = new ReturnInventoryRequests.BoxMerge(List.of("RC-SOURCE"), "RC-TARGET", "partial-existing",
List.of(new ReturnInventoryRequests.SourceDeduction("RC-SOURCE", "SKU-A", 1)), null, null, null, null);
var error = assertThrows(ResponseStatusException.class, () -> service.mergeBoxes(new PdaWarehouseIdentity("operator-1", "作业员", 42L), request));
assertThat(error.getReason()).contains("独立新箱");
verify(cartonItems, never()).updateById(any(ReturnInventoryCartonItem.class));
verify(cartons, never()).insert(any(ReturnInventoryCarton.class));
}
@Test
void confirmingPhysicalLabelIsIdempotentAndBoundToWarehouse() {
var source = new ReturnInventoryCarton(); source.setId(31L); source.setCartonMark("RC-SOURCE");
source.setCustomerId("10001"); source.setOverseasWarehouseId("42"); source.setSourceReturnNo("RT-1");
source.setStatus("AVAILABLE"); source.setLabelStatus("WAIT_LABEL");
when(cartons.lockMark("RC-SOURCE")).thenReturn(source);
when(cartonItems.listCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 2)));
var identity = new PdaWarehouseIdentity("operator-1", "作业员", 42L);
service.confirmLabel(identity, "RC-SOURCE"); service.confirmLabel(identity, "RC-SOURCE");
assertThat(source.getLabelStatus()).isEqualTo("CONFIRMED");
verify(cartons, org.mockito.Mockito.times(1)).updateById(source);
assertThrows(ResponseStatusException.class, () -> service.confirmLabel(new PdaWarehouseIdentity("operator-2", "其他仓", 43L), "RC-SOURCE"));
}
private ReturnInventoryCarton prepareConcurrentSource() {
ReturnReceipt receipt = new ReturnReceipt(); receipt.setId(21L);
ReturnInventoryCarton snapshot = new ReturnInventoryCarton();
@@ -227,10 +227,13 @@ class ReturnReceiptServiceTest {
when(numbers.next(eq("RELABEL"), eq("10"), anyLong())).thenReturn("RL-MULTI");
when(lastlegOrders.selectById(99L)).thenReturn(tail);
when(cartons.findAvailable("customer-1", "10")).thenReturn(List.of(first, second));
when(inventory.matchesReturnSource(any(), eq("RT-MULTI"))).thenCallRealMethod();
when(cartons.lockId(101L)).thenReturn(first);
when(cartons.lockId(102L)).thenReturn(second);
when(cartonItems.listCarton(101L)).thenReturn(List.of(returnCartonItem("SKU-A", 50)));
when(cartonItems.listCarton(102L)).thenReturn(List.of(returnCartonItem("SKU-B", 40)));
when(cartonItems.lockCarton(101L)).thenReturn(List.of(returnCartonItem("SKU-A", 50)));
when(cartonItems.lockCarton(102L)).thenReturn(List.of(returnCartonItem("SKU-B", 40)));
var result = service.createRelabel("customer-1", "merchant-1", "RT-MULTI",
new ReturnOrderRequests.RelabelCreate(List.of(
@@ -263,7 +266,7 @@ class ReturnReceiptServiceTest {
}
@Test
void secondRelabelCannotAllocateAlreadyLabelledQuantity() {
void completedReturnCartonCanStartAnotherRelabelRound() {
ReturnOrder order = order("RT-MULTI", "10", "COMPLETED", 100, 100);
order.setLastlegOrderId(99L); order.setRelabelNo("RL-PREVIOUS");
LastlegOrder tail = new LastlegOrder(); tail.setId(99L);
@@ -275,16 +278,17 @@ class ReturnReceiptServiceTest {
when(numbers.next(eq("RELABEL"), eq("10"), anyLong())).thenReturn("RL-SECOND");
when(lastlegOrders.selectById(99L)).thenReturn(tail);
when(cartons.findAvailable("customer-1", "10")).thenReturn(List.of(carton));
when(inventory.matchesReturnSource(any(), eq("RT-MULTI"))).thenCallRealMethod();
when(cartonItems.listCarton(101L)).thenReturn(List.of(returnCartonItem("SKU-A", 100)));
when(cartons.relabelledQuantity(101L, "SKU-A")).thenReturn(100L);
var error = assertThrows(ResponseStatusException.class, () -> service.createRelabel(
when(cartons.lockId(101L)).thenReturn(carton);
when(cartonItems.lockCarton(101L)).thenReturn(List.of(returnCartonItem("SKU-A", 100)));
var result = service.createRelabel(
"customer-1", "merchant-1", "RT-MULTI", new ReturnOrderRequests.RelabelCreate(
List.of(new ReturnOrderRequests.RelabelItem("SKU-A", 1)),
"SKU_TOTAL", "200", "XLX6", "second-key", "second attempt")));
assertEquals(409, error.getStatusCode().value());
assertTrue(error.getReason().contains("可用实收数量不足"));
verify(relabelItems, never()).insert(any(RelabelItem.class));
verify(cartons, never()).updateById(any(ReturnInventoryCarton.class));
"SKU_TOTAL", "200", "XLX6", "second-key", "second attempt"));
assertEquals("RL-SECOND", result.relabelNo());
verify(relabelItems).insert(any(RelabelItem.class));
verify(cartons).updateById(carton);
}
@Test
@@ -444,7 +448,7 @@ class ReturnReceiptServiceTest {
}
@Test
void parcelWithoutTrackingGeneratesParcelCodeAndNeverReturnsBoxLabelsOnReplay() {
void parcelWithoutTrackingKeepsPhysicalMarkOnReplay() {
ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PENDING_RECEIPT, 3, 0);
ReturnOrderItem line = item(11L, "SKU-A", 3, 0);
stubNewReceipt(order, List.of(line), "SP-SOURCE");
@@ -455,7 +459,7 @@ class ReturnReceiptServiceTest {
var capture = ArgumentCaptor.forClass(ReturnReceipt.class);
verify(receipts).insert(capture.capture());
assertThat(result.unitCode()).startsWith("PK");
assertThat(result.cartonMark()).isNull();
assertThat(result.cartonMark()).isEqualTo("SP-SOURCE");
assertThat(result.printableHtml()).isNull();
assertThat(result.labels()).isEmpty();
assertThat(capture.getValue().getTrackingNo()).isNull();
@@ -463,7 +467,7 @@ class ReturnReceiptServiceTest {
var replay = service.confirm(identity, request);
assertThat(replay.unitCode()).isEqualTo(result.unitCode());
assertThat(replay.labels()).isEmpty();
verify(inventory, never()).label(anyString(), anyString());
verify(inventory, times(2)).label("customer-1", "SP-SOURCE");
verify(inventory, times(1)).createReceiptCarton(any(), any(), any(), any());
}
+2
View File
@@ -497,3 +497,5 @@ Admin 全量中的 `phase4-cross-region-events` 使用内部 `test-event-fault-e
`f32-pda-hold-race.mjs` 是显式 TEST 暂扣/PDA拣货并发验收入口,离线合同 `f32-pda-hold-race.test.mjs`。命令为 `node --env-file="<TEST配置>" --env-file="<Jenkins配置>" --env-file="<本轮自有PDA配置>" scripts/f32-pda-hold-race.mjs --plan <冻结计划> --report <全新报告> --confirm-test-runtime yes`;只消费已实际入仓、4箱可用、无占用、调度审核通过且同仓PDA canPick=true的本轮独立样本,不创建订单或车次。凭据只读明确TEST/Jenkins/自有PDA配置,不写报告;计划冻结客户、来源订单、仓、余额、位置、排车明细、车次、PDA成员和不可变Lastleg镜像ID。通过Jenkins在指定TEST Lastleg所用MySQL建立仅针对原订单的12秒行锁租约(SELECT FOR UPDATE、固定等待、ROLLBACK,无业务DML),用performance_schema实际锁等待证明两条正式HTTP命令同时竞争,再按暂扣先到/拣货先到两种顺序验证。会真实暂扣、解除本轮暂扣和拣货1箱,逐位置、库存流水、拣货及来源归属、原键重放分别核对;两个阶段顺序执行,不并发造数。结束恢复自有PDA停用状态并退出认证会话,测试单据、部分拣货和资源保留。命令结果未知或断言失败时保存原报告及逐步checkpoint,不自动重发命令、重建样本、删数据或回填SQL;租约自动释放后先核对原单和原命令,未证实的暂扣恢复必须单独按原事实处理。离线通过不代表实际MySQL/PDA或四端页面通过。
- `db/return-package-packing-boundary-incremental-20261010.sql`:退货包裹/箱出库及标签字段增量,显式选定 baiyue_lastleg 库后写结构;无凭据、无业务造数,可重复执行,失败停止并检查数据库错误。
+25
View File
@@ -5054,3 +5054,28 @@ SET @return_sku_index_ddl = IF(EXISTS(SELECT 1 FROM information_schema.statistic
PREPARE return_sku_index_stmt FROM @return_sku_index_ddl;
EXECUTE return_sku_index_stmt;
DEALLOCATE PREPARE return_sku_index_stmt;
-- 退货以包裹/箱为最小出库实物单位;部分数量必须先合箱生成新标签箱。
-- 可重复执行,目标库 baiyue_lastleg。
USE baiyue_lastleg;
-- 合箱后的新箱与原包裹共用同一箱唛、标签和来源追溯模型。
-- 流水约束允许部分合箱/拆分只扣件数,不虚减箱数。
SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.table_constraints
WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_outbound_movement'
AND constraint_name='chk_ll_return_outbound_movement_delta'),
'ALTER TABLE ll_return_inventory_outbound_movement DROP CHECK chk_ll_return_outbound_movement_delta, ADD CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0 AND total_before-total_after=quantity AND available_before-available_after=quantity AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)',
'ALTER TABLE ll_return_inventory_outbound_movement ADD CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0 AND total_before-total_after=quantity AND available_before-available_after=quantity AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 同一包裹可分批合箱/出库,重复提交仍由 outbound_pick_id + SKU 幂等约束保护。
SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.statistics
WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_outbound_movement'
AND index_name='uk_ll_return_outbound_carton_sku'),
'ALTER TABLE ll_return_inventory_outbound_movement DROP INDEX uk_ll_return_outbound_carton_sku, ADD INDEX idx_ll_return_outbound_carton_sku(customer_id,carton_id,sku_code)',
'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 新签收及新合箱必须打印并扫码确认标签;既有实物按原状态继续作业。
SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='label_status'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN label_status VARCHAR(32) NULL COMMENT ''WAIT_LABEL待贴标 CONFIRMED已扫码确认标签''');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -0,0 +1,24 @@
-- 退货以包裹/箱为最小出库实物单位;部分数量必须先合箱生成新标签箱。
-- 可重复执行,目标库 baiyue_lastleg。
USE baiyue_lastleg;
-- 合箱后的新箱与原包裹共用同一箱唛、标签和来源追溯模型。
-- 流水约束允许部分合箱/拆分只扣件数,不虚减箱数。
SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.table_constraints
WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_outbound_movement'
AND constraint_name='chk_ll_return_outbound_movement_delta'),
'ALTER TABLE ll_return_inventory_outbound_movement DROP CHECK chk_ll_return_outbound_movement_delta, ADD CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0 AND total_before-total_after=quantity AND available_before-available_after=quantity AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)',
'ALTER TABLE ll_return_inventory_outbound_movement ADD CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0 AND total_before-total_after=quantity AND available_before-available_after=quantity AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 同一包裹可分批合箱/出库,重复提交仍由 outbound_pick_id + SKU 幂等约束保护。
SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.statistics
WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_outbound_movement'
AND index_name='uk_ll_return_outbound_carton_sku'),
'ALTER TABLE ll_return_inventory_outbound_movement DROP INDEX uk_ll_return_outbound_carton_sku, ADD INDEX idx_ll_return_outbound_carton_sku(customer_id,carton_id,sku_code)',
'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 新签收及新合箱必须打印并扫码确认标签;既有实物按原状态继续作业。
SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='label_status'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN label_status VARCHAR(32) NULL COMMENT ''WAIT_LABEL待贴标 CONFIRMED已扫码确认标签''');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;