贴标申请,仓储转运
This commit is contained in:
@@ -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和隔离键',
|
||||
|
||||
+14
-42
@@ -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) {}
|
||||
|
||||
+19
@@ -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);
|
||||
|
||||
+7
@@ -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) {
|
||||
|
||||
+14
-2
@@ -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,
|
||||
|
||||
+26
-2
@@ -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,
|
||||
|
||||
+46
-2
@@ -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";
|
||||
}
|
||||
|
||||
+27
-23
@@ -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;
|
||||
}
|
||||
|
||||
+17
-3
@@ -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()))
|
||||
|
||||
+136
@@ -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);
|
||||
}
|
||||
}
|
||||
+5
@@ -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) {
|
||||
|
||||
+6
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-3
@@ -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) { }
|
||||
|
||||
+3
-1
@@ -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) { }
|
||||
}
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+3
-1
@@ -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," +
|
||||
|
||||
+42
-3
@@ -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} " +
|
||||
|
||||
+15
@@ -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);
|
||||
}
|
||||
+80
-21
@@ -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) {
|
||||
|
||||
+22
-16
@@ -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()));
|
||||
|
||||
+11
@@ -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 }
|
||||
|
||||
+40
-92
@@ -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
|
||||
|
||||
+4
-18
@@ -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() {
|
||||
|
||||
-23
@@ -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(
|
||||
|
||||
+35
-8
@@ -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
|
||||
|
||||
+1
-1
@@ -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"));
|
||||
|
||||
+21
@@ -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);
|
||||
|
||||
+10
-4
@@ -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(), "合箱预占不能冒充实际换标完成量");
|
||||
}
|
||||
}
|
||||
|
||||
+107
@@ -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;
|
||||
}
|
||||
}
|
||||
+68
-10
@@ -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();
|
||||
|
||||
+15
-11
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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 库后写结构;无凭据、无业务造数,可重复执行,失败停止并检查数据库错误。
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user