Merge remote-tracking branch 'origin/test' into test

This commit is contained in:
2026-10-12 20:27:50 +08:00
24 changed files with 303 additions and 50 deletions
@@ -2414,6 +2414,7 @@ CREATE TABLE ll_return_inventory_balance (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货库存按客户海外仓SKU汇总余额';
CREATE TABLE ll_return_inventory_carton (
original_packaging TINYINT(1) NULL COMMENT '是否使用原箱原包装',
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',
@@ -2565,7 +2566,9 @@ CREATE TABLE ll_return_receipt (
length DECIMAL(12,3) NULL COMMENT '实测尺寸cm',
width DECIMAL(12,3) NULL COMMENT '实测尺寸cm',
height DECIMAL(12,3) NULL COMMENT '实测尺寸cm',
weight DECIMAL(12,3) NULL COMMENT '实测重量kg'
weight DECIMAL(12,3) NULL COMMENT '实测重量kg',
original_packaging TINYINT(1) NULL COMMENT '是否使用原箱原包装',
measurement_source VARCHAR(32) NULL COMMENT '尺寸重量来源'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PDA退货实收签收事实';
CREATE TABLE ll_return_receipt_batch (
@@ -258,11 +258,13 @@ public interface DispatchCandidateMapper {
@Param("relabelNo") String relabelNo);
@Select("<script>SELECT a.customer_id AS customerId,a.lastleg_order_id AS orderId,a.overseas_warehouse_id AS overseasWarehouseId," +
"MIN(ri.source_warehouse_name) AS overseasWarehouseName,a.relabel_no AS orderNo,a.relabel_no AS orderRefs," +
"MIN(ri.source_warehouse_name) AS overseasWarehouseName,'RETURN_RELABEL' AS orderType,a.relabel_no AS orderNo,a.relabel_no AS orderRefs," +
"GROUP_CONCAT(DISTINCT c.source_return_no ORDER BY c.source_return_no SEPARATOR ',') AS inboundCode," +
"c.pallet_code AS palletNo,COUNT(DISTINCT c.id) AS packageCount,'RELABEL' AS sourceStage,1 AS returnCartonSource," +
"GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') AS relabelCartonMarks," +
"GROUP_CONCAT(DISTINCT ri.destination_fba_warehouse_code ORDER BY ri.destination_fba_warehouse_code SEPARATOR ',') AS fbaWarehouseCode," +
"COALESCE((SELECT CASE WHEN COUNT(*)=COUNT(x.weight) THEN SUM(x.weight) END FROM (SELECT DISTINCT c2.id,c2.weight FROM ll_return_inventory_carton c2 JOIN ll_relabel_item ri2 ON ri2.return_carton_id=c2.id AND ri2.customer_id=c2.customer_id JOIN ll_relabel_application a2 ON a2.id=ri2.application_id AND a2.customer_id=ri2.customer_id WHERE c2.customer_id=a.customer_id AND c2.pallet_code=c.pallet_code AND c2.status='RELABEL_PENDING' AND a2.id=a.id) x),(SELECT rr.weight FROM ll_return_receipt rr WHERE rr.customer_id=a.customer_id AND rr.unit_type='PALLET' AND rr.unit_code=c.pallet_code ORDER BY rr.signed_at DESC LIMIT 1)) AS grossWeightKg," +
"COALESCE((SELECT CASE WHEN COUNT(*)=COUNT(x.volume) THEN SUM(x.volume) END FROM (SELECT DISTINCT c2.id,(c2.length*c2.width*c2.height)/1000000 AS volume FROM ll_return_inventory_carton c2 JOIN ll_relabel_item ri2 ON ri2.return_carton_id=c2.id AND ri2.customer_id=c2.customer_id JOIN ll_relabel_application a2 ON a2.id=ri2.application_id AND a2.customer_id=ri2.customer_id WHERE c2.customer_id=a.customer_id AND c2.pallet_code=c.pallet_code AND c2.status='RELABEL_PENDING' AND a2.id=a.id) x),(SELECT rr.length*rr.width*rr.height/1000000 FROM ll_return_receipt rr WHERE rr.customer_id=a.customer_id AND rr.unit_type='PALLET' AND rr.unit_code=c.pallet_code ORDER BY rr.signed_at DESC LIMIT 1)) AS volumeCbm," +
"GROUP_CONCAT(DISTINCT ri.destination_fba_warehouse_code ORDER BY ri.destination_fba_warehouse_code SEPARATOR ',') AS destinationWarehouseCode " +
"FROM ll_relabel_application a JOIN ll_relabel_item ri ON ri.application_id=a.id AND ri.customer_id=a.customer_id " +
"JOIN ll_return_inventory_carton c ON c.id=ri.return_carton_id AND c.customer_id=a.customer_id " +
@@ -25,7 +25,14 @@ public final class PalletOperationResponses {
String palletCode, String status, int palletVersion,
@JsonSerialize(using = ToStringSerializer.class) Long locationId,
String locationCode, String destinationWarehouseCode,
long totalPackages, List<InventoryLine> inventory) { }
long totalPackages, List<InventoryLine> inventory,
java.math.BigDecimal grossWeightKg, java.math.BigDecimal volumeCbm) {
public PalletContent(long palletId, String palletCode, String status, int palletVersion, Long locationId,
String locationCode, String destinationWarehouseCode, long totalPackages, List<InventoryLine> inventory) {
this(palletId, palletCode, status, palletVersion, locationId, locationCode, destinationWarehouseCode,
totalPackages, inventory, null, null);
}
}
public record PrintablePallet(@JsonSerialize(using = ToStringSerializer.class) long palletId,
String palletCode, int palletVersion,
String relabelNo, String printableHtml) { }
@@ -316,7 +316,8 @@ public class PdaPalletOperationService {
PalletOperationRequests.RelabelDisassemble request) {
requireRelabelDestination(identity.overseasWarehouseId(), selected, target);
Set<String> alreadyBound = boundRelabelMarks(application);
if (selected.stream().map(RelabelItem::getCartonMark).anyMatch(alreadyBound::contains))
boolean rebind = request.actualSource() == null && selected.stream().allMatch(item -> alreadyBound.contains(item.getCartonMark()));
if (!rebind && selected.stream().map(RelabelItem::getCartonMark).anyMatch(alreadyBound::contains))
throw conflict("退货换标箱已在本轮完成组托,请刷新后重试");
long quantity = 0;
List<ReturnInventoryCarton> cartons = new ArrayList<>();
@@ -330,8 +331,8 @@ public class PdaPalletOperationService {
quantity += value(item.getNotifiedPackageCount());
cartons.add(carton);
}
if (request.actualSource() == null || request.actualSource().positionId() != cartons.get(0).getId()
|| request.actualSource().packages() != quantity)
if (!rebind && (request.actualSource() == null || request.actualSource().positionId() != cartons.get(0).getId()
|| request.actualSource().packages() != quantity))
throw conflict("退货换标拆出数量必须等于本批已完成贴标数量");
String key = required(request.idempotencyKey(), "幂等键不能为空");
String requestHash = hash(request);
@@ -836,6 +837,8 @@ public class PdaPalletOperationService {
Set<String> destinations = new HashSet<>();
selected.forEach(item -> destinations.add(text(item.getDestinationFbaWarehouseCode())));
if (destinations.size() != 1 || destinations.contains("")) throw conflict("请按已维护的新目的仓分批组托");
returnCartons.palletCartons(String.valueOf(warehouseId), target.palletCode())
.forEach(carton -> destinations.add(text(carton.getDestinationWarehouseCode())));
for (PalletInventoryRow row : mapper.content(String.valueOf(warehouseId), target.palletId())) {
var bound = relabelItems.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<RelabelItem>()
.eq("customer_id", row.getCustomerId()).eq("source_inventory_position_id", row.getPositionId()));
@@ -898,10 +901,22 @@ public class PdaPalletOperationService {
value(row.getReceivingPackages()), value(row.getVersion()));
}).toList();
long total = rows.stream().mapToLong(PdaPalletOperationService::rowPackages).sum();
var boxes = returnCartons.palletCartons(String.valueOf(warehouseId), pallet.palletCode());
boxes.forEach(box -> destinations.add(text(box.getDestinationWarehouseCode())));
total += boxes.size();
// Derive totals from current physical bindings, so retries and rebinds cannot double count.
java.math.BigDecimal weight = rows.isEmpty() ? java.math.BigDecimal.ZERO : null;
java.math.BigDecimal volume = rows.isEmpty() ? java.math.BigDecimal.ZERO : null;
for (var box : boxes) {
if (weight != null) weight = box.getWeight() == null || box.getWeight().signum() <= 0 ? null : weight.add(box.getWeight());
if (volume != null) volume = box.getLength() == null || box.getWidth() == null || box.getHeight() == null
|| box.getLength().signum() <= 0 || box.getWidth().signum() <= 0 || box.getHeight().signum() <= 0 ? null
: volume.add(box.getLength().multiply(box.getWidth()).multiply(box.getHeight()).movePointLeft(6));
}
String locationCode = locationCode(warehouseId, pallet.locationId());
return new PalletOperationResponses.PalletContent(pallet.palletId(), pallet.palletCode(), pallet.status(),
pallet.version(), pallet.locationId(), locationCode, destinations.size() == 1 ? destinations.iterator().next() : null,
total, inventory);
total, inventory, weight, volume);
}
private PdaPalletOperation begin(PdaWarehouseIdentity identity, String type, String key, String requestHash) {
@@ -42,7 +42,18 @@ public final class PdaRelabelResponses {
List<SkuItem> skuItems,
int expectedPackages, int relabelledPackages, int skippedPackages,
int exceptionPackages, int cartonProcessedPackages, int skuProcessedPackages,
String destinationFbaWarehouseCode, Long returnCartonId, long returnQuantity) {
String destinationFbaWarehouseCode, Long returnCartonId, long returnQuantity,
String sourcePalletCode, String sourcePalletBasis) {
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, Long returnCartonId, long returnQuantity) {
this(itemId, skuCode, productNameZh, shipmentNo, trackingCode, newSkuCode, labelRequirement,
relabelFileId, relabelFileName, relabelFileUrl, cartonMark, fbaCartonMark, operationType, skuItems,
expectedPackages, relabelledPackages, skippedPackages, exceptionPackages, cartonProcessedPackages,
skuProcessedPackages, destinationFbaWarehouseCode, returnCartonId, returnQuantity, null, null);
}
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,
@@ -92,6 +92,7 @@ public class PdaRelabelService {
@Resource private PdaRelabelTaskMapper tasks;
@Resource private PdaRelabelUnitFactMapper unitFacts;
@Resource private ReturnInventoryCartonMapper returnCartons;
@Resource private com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper sourcePositions;
@Resource private PdaRelabelOperationMapper operations;
@Resource private PdaRelabelHistoryMapper histories;
@Resource private RelabelApplicationService applicationService;
@@ -786,7 +787,7 @@ public class PdaRelabelService {
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.getReturnCartonId(),
returnQuantity(item));
returnQuantity(item), sourcePalletCode(item), item.getReturnCartonId() != null ? "RETURN_CARTON" : "INVENTORY_REFERENCE");
}).toList();
List<PdaRelabelResponses.UnitFact> factResponses = facts.stream().map(fact ->
new PdaRelabelResponses.UnitFact(fact.getId(), fact.getItemId(), fact.getOldLabel(), fact.getNewLabel(),
@@ -881,6 +882,16 @@ public class PdaRelabelService {
List.class, PdaRelabelResponses.SkuItem.class));
} catch (JsonProcessingException exception) { throw new IllegalStateException("Cannot read relabel SKU details", exception); }
}
private String sourcePalletCode(RelabelItem item) {
if (item.getReturnCartonId() != null) {
var carton = returnCartons == null ? null : returnCartons.selectById(item.getReturnCartonId());
return carton != null && Objects.equals(item.getCustomerId(), carton.getCustomerId()) ? carton.getPalletCode() : null;
}
var position = sourcePositions == null || item.getSourceInventoryPositionId() == null ? null
: sourcePositions.findTenantPosition(item.getCustomerId(), item.getSourceInventoryPositionId());
return position != null ? position.getPalletNo() : null;
}
private long returnQuantity(RelabelItem item) {
var carton = item.getReturnCartonId() == null ? null : returnCartons.selectById(item.getReturnCartonId());
return carton == null || carton.getTotalQuantity() == null ? 0L : carton.getTotalQuantity();
@@ -25,11 +25,18 @@ public final class PdaReturnReceiptRequests {
@Size(max = 16) String unitType,
@Size(max = 128) String unitCode,
BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight,
Long totalBoxCount) {
Long totalBoxCount, Boolean originalPackaging) {
public Confirm(String returnNo, String trackingNo, String startedBy, String palletCode,
List<@Valid Item> items, String idempotencyKey) {
this(returnNo, trackingNo, startedBy, palletCode, items, idempotencyKey,
null, null, null, null, null, null, null);
null, null, null, null, null, null, null, null);
}
public Confirm(String returnNo, String trackingNo, String startedBy, String palletCode,
List<@Valid Item> items, String idempotencyKey, String unitType, String unitCode,
BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight,
Long totalBoxCount) {
this(returnNo, trackingNo, startedBy, palletCode, items, idempotencyKey, unitType, unitCode,
length, width, height, weight, totalBoxCount, null);
}
}
public record Item(@NotBlank @Size(max = 128) String skuCode,
@@ -3,14 +3,20 @@ package com.baiyue.lastleg.returnreceipt.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.util.List;
import java.math.BigDecimal;
public final class ReturnCartonCountDtos {
private ReturnCartonCountDtos() { }
public record Query(@Min(1) long page, @Min(1) @Max(50) long size, @Size(max=128) String keyword) { }
public record Sku(String skuCode, String productNameZh, long quantity) { }
public record Box(String cartonMark, List<Sku> items) { }
public record Box(String cartonMark, List<Sku> items, BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight) {
public Box(String cartonMark, List<Sku> items) { this(cartonMark, items, null, null, null, null); }
}
public record Task(String receiptNo, String returnNo, String palletCode, String status, List<Sku> expectedItems, List<Box> boxes) { }
public record CountItem(@NotBlank @Size(max=128) String skuCode, @Min(1) long quantity) { }
public record CountBox(@NotBlank @Size(max=128) String cartonMark, @NotEmpty @Size(max=100) List<@NotNull @Valid CountItem> items) { }
public record CountBox(@NotBlank @Size(max=128) String cartonMark, @NotEmpty @Size(max=100) List<@NotNull @Valid CountItem> items,
@Positive BigDecimal length, @Positive BigDecimal width, @Positive BigDecimal height, @Positive BigDecimal weight) {
public CountBox(String cartonMark, List<@NotNull @Valid CountItem> items) { this(cartonMark, items, null, null, null, null); }
}
public record Confirm(@NotBlank @Size(max=128) String idempotencyKey, @NotEmpty @Size(max=500) List<@NotNull @Valid CountBox> boxes) { }
}
@@ -41,6 +41,7 @@ public class ReturnInventoryCarton {
private BigDecimal width;
private BigDecimal height;
private BigDecimal weight;
private Boolean originalPackaging;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private LocalDateTime mergedAt;
@@ -23,6 +23,10 @@ public class ReturnReceipt {
private BigDecimal width;
private BigDecimal height;
private BigDecimal weight;
/** Whether the signed physical unit reused its original box/packaging. */
private Boolean originalPackaging;
/** PALLET_MEASURED for a measured carrier, UNIT_SUM for child-unit aggregation. */
private String measurementSource;
private String palletCode;
private String cartonMark;
private String trackingNo;
@@ -10,6 +10,10 @@ import java.util.List;
@Mapper
public interface ReturnInventoryCartonMapper extends BaseMapper<ReturnInventoryCarton> {
@Select("SELECT * FROM ll_return_inventory_carton WHERE overseas_warehouse_id=#{warehouseId} " +
"AND pallet_code=#{palletCode} AND total_quantity>0 " +
"AND status IN ('AVAILABLE','PACKING_RESERVED','RELABEL_PENDING','OUTBOUND_PENDING','DISCARD_PENDING') ORDER BY id")
List<ReturnInventoryCarton> palletCartons(@Param("warehouseId") String warehouseId, @Param("palletCode") String palletCode);
@Select("SELECT EXISTS(SELECT 1 FROM ll_return_inventory_outbound_movement WHERE carton_id=#{cartonId} AND quantity>0)")
boolean hasOutbound(@Param("cartonId") long cartonId);
@Update("UPDATE ll_return_inventory_carton SET relabel_application_id=NULL,relabel_no=NULL,relabel_status=NULL WHERE id=#{id}")
@@ -129,6 +129,7 @@ public class ReturnCartonCountService {
Set<String> marks = new HashSet<>();
for (CountBox box : request.boxes()) {
if (!byMark.containsKey(box.cartonMark()) || !marks.add(box.cartonMark())) throw error(HttpStatus.BAD_REQUEST, "箱号不属于本次签收或重复填写");
if (box.length() == null || box.width() == null || box.height() == null || box.weight() == null || box.length().signum() <= 0 || box.width().signum() <= 0 || box.height().signum() <= 0 || box.weight().signum() <= 0) throw error(HttpStatus.BAD_REQUEST, "每个箱子必须填写有效的长、宽、高和重量");
Set<String> skus = new HashSet<>();
for (CountItem line : box.items()) {
if (!expected.containsKey(line.skuCode()) || !skus.add(line.skuCode()) || line.quantity() <= 0) throw error(HttpStatus.BAD_REQUEST, "SKU 不属于本次签收、数量无效或同箱重复");
@@ -144,7 +145,7 @@ public class ReturnCartonCountService {
item.setCustomerId(box.getCustomerId()); item.setSkuCode(line.skuCode()); item.setProductNameZh(names.get(line.skuCode())); item.setQuantity(line.quantity());
item.setPackingReservedQuantity(0L); items.insert(item);
}
box.setSkuTypes(input.items().size()); box.setTotalQuantity(input.items().stream().mapToLong(CountItem::quantity).sum());
box.setSkuTypes(input.items().size()); box.setTotalQuantity(input.items().stream().mapToLong(CountItem::quantity).sum()); box.setLength(input.length()); box.setWidth(input.width()); box.setHeight(input.height()); box.setWeight(input.weight());
box.setCountStatus("CONFIRMED"); box.setCountedAt(now); box.setCountedBy(identity.operatorKey()); box.setUpdatedAt(now); cartons.updateById(box);
}
for (String sku : expected.keySet()) {
@@ -167,7 +168,8 @@ public class ReturnCartonCountService {
private Task task(ReturnReceipt receipt, List<ReturnInventoryCarton> boxes) {
List<Sku> expected = receiptItems.listReceipt(receipt.getId()).stream().map(line -> new Sku(line.getSkuCode(), null, line.getReceivedQuantity())).toList();
List<Box> contents = boxes.stream().map(box -> new Box(box.getCartonMark(), items.listCarton(box.getId()).stream()
.map(line -> new Sku(line.getSkuCode(), line.getProductNameZh(), "PENDING".equals(box.getCountStatus()) ? 0 : line.getQuantity())).toList())).toList();
.map(line -> new Sku(line.getSkuCode(), line.getProductNameZh(), "PENDING".equals(box.getCountStatus()) ? 0 : line.getQuantity())).toList(),
box.getLength(), box.getWidth(), box.getHeight(), box.getWeight())).toList();
return new Task(receipt.getReceiptNo(), boxes.isEmpty() ? null : boxes.get(0).getSourceReturnNo(), receipt.getPalletCode(),
boxes.stream().allMatch(box -> "CONFIRMED".equals(box.getCountStatus())) ? "CONFIRMED" : sessions.findReceipt(receipt.getId()) != null ? "COUNTING" : "PENDING", expected, contents);
}
@@ -509,6 +509,7 @@ public class ReturnInventoryService {
// Pallet measurements describe the carrier, never each child carton.
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.setOriginalPackaging(receipt.getOriginalPackaging());
}
carton.setStatus(AVAILABLE); carton.setSkuTypes(lines.size()); carton.setTotalQuantity(total); carton.setCreatedAt(now); carton.setUpdatedAt(now);
cartons.insert(carton);
@@ -1086,6 +1087,16 @@ public class ReturnInventoryService {
}
if (existingTarget != null) {
target.setTotalQuantity(cartonItems.totalQuantity(targetId)); target.setSkuTypes(cartonItems.skuTypes(targetId));
if (request.weight() != null) target.setWeight(request.weight());
else {
BigDecimal combinedWeight = target.getWeight();
for (var source : sources) combinedWeight = combinedWeight == null || source.getWeight() == null
? null : combinedWeight.add(source.getWeight());
target.setWeight(combinedWeight);
}
if (request.length() != null) target.setLength(request.length());
if (request.width() != null) target.setWidth(request.width());
if (request.height() != null) target.setHeight(request.height());
target.setUpdatedAt(now); target.setLocationCode(null); target.setPutawayAt(null); cartons.updateById(target); cartons.clearPutaway(targetId);
}
for (TraceTransfer transfer : transfers) packingTrace.insert(IdWorker.getId(), customerId, operation.getId(),
@@ -618,6 +618,14 @@ public class ReturnReceiptService {
|| request.height().signum() <= 0 || request.weight().signum() <= 0)) {
throw badRequest("托盘签收必须填写有效的长、宽、高和重量");
}
if ("BOX".equals(unitType) || "PARCEL".equals(unitType)) {
if (request.originalPackaging() == null) throw badRequest("箱子或包裹必须选择是否使用原箱原包装");
if (request.length() == null || request.width() == null || request.height() == null || request.weight() == null
|| request.length().signum() <= 0 || request.width().signum() <= 0
|| request.height().signum() <= 0 || request.weight().signum() <= 0) {
throw badRequest("箱子或包裹必须填写有效的长、宽、高和重量");
}
}
LocalDateTime now = LocalDateTime.now();
long receiptId = IdWorker.getId();
ReturnReceipt receipt = new ReturnReceipt();
@@ -629,6 +637,8 @@ public class ReturnReceiptService {
receipt.setTotalBoxCount(totalBoxCount);
receipt.setPalletCode("PALLET".equals(unitType) ? unitCode : ("BOX".equals(unitType) ? text(request.palletCode()) : null));
receipt.setLength(request.length()); receipt.setWidth(request.width()); receipt.setHeight(request.height()); receipt.setWeight(request.weight());
receipt.setOriginalPackaging(request.originalPackaging());
receipt.setMeasurementSource("PALLET".equals(unitType) ? "PALLET_MEASURED" : "UNIT_MEASURED");
receipt.setReceivedQuantity(receiptQuantity); receipt.setOperatorId(identity.operatorKey());
receipt.setOperatorName(identity.operatorName()); receipt.setIdempotencyKey(key); receipt.setRequestHash(requestHash);
receipt.setSignedAt(now); receipt.setCreatedAt(now);
@@ -151,12 +151,6 @@ public class WarehouseTransferService {
validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(),
request.privateDestinationAddress(), request.privateDestinationPostalCode(),
request.privateDestinationContactName(), request.privateDestinationContactPhone());
// A split relabel transfer may intentionally defer the whole-order
// destination: relabeled cartons go through relabel dispatch while the
// remaining cartons are bound later from warehouse-transfer outbound.
// All other transfer types must still select FBA or PRIVATE up front.
validateTransferDestinationRequired(request.destinationType(), request.fbaWarehouseCode(),
request.needRelabel() && request.relabelItems() != null && !request.relabelItems().isEmpty());
String key = request.idempotencyKey().trim();
String requestHash = hash(request);
WarehouseTransferOrder replay = transfers.findKey(customerId, key);
@@ -292,8 +286,6 @@ public class WarehouseTransferService {
validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(),
request.privateDestinationAddress(), request.privateDestinationPostalCode(),
request.privateDestinationContactName(), request.privateDestinationContactPhone());
validateTransferDestinationRequired(request.destinationType(), request.fbaWarehouseCode(),
request.needRelabel() && request.relabelItems() != null && !request.relabelItems().isEmpty());
LastlegOrder order = requireTailOrder(customerId, transfer);
LocalDateTime now = LocalDateTime.now();
@@ -1508,15 +1500,6 @@ public class WarehouseTransferService {
}
}
private static void validateTransferDestinationRequired(String destinationType, String fbaWarehouseCode,
boolean allowDeferredForRelabel) {
String type = trim(destinationType);
if (type == null && StringUtils.hasText(fbaWarehouseCode)) type = "FBA";
if ((type == null || "NONE".equalsIgnoreCase(type)) && !allowDeferredForRelabel) {
throw badRequest("仓储转运必须选择 FBA 仓或私人仓");
}
}
private static void applyDestination(WarehouseTransferOrder transfer, WarehouseTransferRequests.Create request) {
String type = trim(request.destinationType());
if (type == null && StringUtils.hasText(request.fbaWarehouseCode())) type = "FBA";
@@ -2978,6 +2978,7 @@ CREATE TABLE IF NOT EXISTS ll_return_order_item (
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_item_order_sku(return_order_id,sku_code), KEY idx_ll_return_item_sku(sku_code,received_quantity), CONSTRAINT chk_ll_return_item_quantity CHECK(expected_quantity>0 AND received_quantity>=0 AND received_quantity<=expected_quantity)
) ENGINE=InnoDB COMMENT='客户退货订单的SKU预报与实收明细';
CREATE TABLE IF NOT EXISTS ll_return_receipt (
original_packaging TINYINT(1) NULL COMMENT '是否使用原箱原包装', measurement_source VARCHAR(32) NULL COMMENT '尺寸重量来源',
id BIGINT NOT NULL COMMENT 'PDA退货签收单ID', return_order_id BIGINT NOT NULL COMMENT '退货订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', receipt_no VARCHAR(128) NOT NULL COMMENT '签收单号', receipt_batch_no VARCHAR(128) NOT NULL COMMENT '签收批次号', tracking_no VARCHAR(128) NULL COMMENT '本次签收包裹的可选快递单号', bound_by VARCHAR(16) NOT NULL COMMENT '开始签收方式,RETURN_NO退货单号或SKU候选', status VARCHAR(32) NOT NULL COMMENT '签收状态,当前SIGNED', received_quantity BIGINT NOT NULL COMMENT '本次实收数量', operator_id VARCHAR(128) NOT NULL COMMENT 'PDA操作人ID', operator_name VARCHAR(128) NULL COMMENT 'PDA操作人名称', idempotency_key VARCHAR(255) NOT NULL COMMENT 'PDA确认幂等键', request_hash CHAR(64) NOT NULL COMMENT 'PDA确认请求SHA-256', signed_at DATETIME(3) NOT NULL COMMENT '签收时间', created_at DATETIME(3) NOT NULL COMMENT '创建时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_receipt_no(receipt_no), UNIQUE KEY uk_ll_return_receipt_batch(receipt_batch_no), UNIQUE KEY uk_ll_return_receipt_operator_key(operator_id,idempotency_key), KEY idx_ll_return_receipt_order(return_order_id,signed_at), CONSTRAINT chk_ll_return_receipt_quantity CHECK(received_quantity>0), CONSTRAINT chk_ll_return_receipt_bound CHECK(bound_by IN('RETURN_NO','SKU'))
) ENGINE=InnoDB COMMENT='PDA退货实收签收事实';
@@ -3018,6 +3019,10 @@ EXECUTE ll_return_receipt_alter_stmt;
DEALLOCATE PREPARE ll_return_receipt_alter_stmt;
SET @ll_return_receipt_add_pallet = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='pallet_code'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN pallet_code VARCHAR(128) NULL COMMENT ''本次签收绑定的托盘码'' AFTER receipt_batch_no'));
PREPARE ll_return_receipt_add_pallet_stmt FROM @ll_return_receipt_add_pallet; EXECUTE ll_return_receipt_add_pallet_stmt; DEALLOCATE PREPARE ll_return_receipt_add_pallet_stmt;
SET @ll_return_receipt_add_measurement_facts = (SELECT IF(COUNT(*)=0, 'ALTER TABLE ll_return_receipt ADD COLUMN original_packaging TINYINT(1) NULL COMMENT ''是否使用原箱原包装'' AFTER weight', 'SELECT 1') FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='original_packaging');
PREPARE ll_return_receipt_add_measurement_facts_stmt FROM @ll_return_receipt_add_measurement_facts; EXECUTE ll_return_receipt_add_measurement_facts_stmt; DEALLOCATE PREPARE ll_return_receipt_add_measurement_facts_stmt;
SET @ll_return_receipt_add_measurement_facts = (SELECT IF(COUNT(*)=0, 'ALTER TABLE ll_return_receipt ADD COLUMN measurement_source VARCHAR(32) NULL COMMENT ''尺寸重量来源'' AFTER original_packaging', 'SELECT 1') FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='measurement_source');
PREPARE ll_return_receipt_add_measurement_facts_stmt FROM @ll_return_receipt_add_measurement_facts; EXECUTE ll_return_receipt_add_measurement_facts_stmt; DEALLOCATE PREPARE ll_return_receipt_add_measurement_facts_stmt;
CREATE TABLE IF NOT EXISTS ll_return_receipt_item (
id BIGINT NOT NULL COMMENT '签收SKU明细ID', receipt_id BIGINT NOT NULL COMMENT 'PDA退货签收单ID', return_order_item_id BIGINT NOT NULL COMMENT '退货预报明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', received_quantity BIGINT NOT NULL COMMENT '本次SKU实收数量',
PRIMARY KEY(id), KEY idx_ll_return_receipt_item_receipt(receipt_id), KEY idx_ll_return_receipt_item_order_item(return_order_item_id), CONSTRAINT chk_ll_return_receipt_item_quantity CHECK(received_quantity>0)
@@ -3031,12 +3036,15 @@ CREATE TABLE IF NOT EXISTS ll_return_receipt_batch (
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='carton_mark'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN carton_mark VARCHAR(128) NULL COMMENT ''签收生成或绑定的退货箱号'' AFTER receipt_batch_no'));
PREPARE return_receipt_carton_mark_stmt FROM @ddl; EXECUTE return_receipt_carton_mark_stmt; DEALLOCATE PREPARE return_receipt_carton_mark_stmt;
CREATE TABLE IF NOT EXISTS ll_return_inventory_carton (
original_packaging TINYINT(1) NULL COMMENT '是否使用原箱原包装',
new_carton_mark VARCHAR(255) NULL COMMENT '换标后新箱唛',
id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', source_receipt_id BIGINT NULL COMMENT '来源PDA签收单ID', source_batch_no VARCHAR(128) NULL COMMENT '来源签收批次号', carton_mark VARCHAR(128) NOT NULL COMMENT '退货箱号和箱唛编码', status VARCHAR(32) NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', sku_types INT NOT NULL COMMENT '箱内SKU种类数', total_quantity BIGINT NOT NULL COMMENT '箱内总件数', length DECIMAL(12,3) NULL COMMENT '箱长CM', width DECIMAL(12,3) NULL COMMENT '箱宽CM', height DECIMAL(12,3) NULL COMMENT '箱高CM', weight DECIMAL(12,3) NULL COMMENT '箱重KG', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', merged_at DATETIME(3) NULL COMMENT '合箱完成时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED','DISCARD_PENDING') AND sku_types>0 AND total_quantity>0) OR (status IN('MERGED','DISCARDED') AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED','DISCARD_PENDING','DISCARDED'))
) ENGINE=InnoDB COMMENT='退货实物库存箱及箱唛';
SET @ll_return_carton_add_pallet = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='pallet_code'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN pallet_code VARCHAR(128) NULL COMMENT ''签收时绑定的托盘码'' AFTER source_batch_no'));
PREPARE ll_return_carton_add_pallet_stmt FROM @ll_return_carton_add_pallet; EXECUTE ll_return_carton_add_pallet_stmt; DEALLOCATE PREPARE ll_return_carton_add_pallet_stmt;
SET @ll_return_carton_add_original_packaging = (SELECT IF(COUNT(*)=0, 'ALTER TABLE ll_return_inventory_carton ADD COLUMN original_packaging TINYINT(1) NULL COMMENT ''是否使用原箱原包装'' AFTER weight', 'SELECT 1') FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='original_packaging');
PREPARE ll_return_carton_add_original_packaging_stmt FROM @ll_return_carton_add_original_packaging; EXECUTE ll_return_carton_add_original_packaging_stmt; DEALLOCATE PREPARE ll_return_carton_add_original_packaging_stmt;
-- 已合箱的来源箱保留审计记录,但其箱内库存必须归零;动态 SQL 兼容 Spring 的数据库初始化器。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity', 'SELECT 1'));
PREPARE return_inventory_carton_check_drop_stmt FROM @ddl; EXECUTE return_inventory_carton_check_drop_stmt; DEALLOCATE PREPARE return_inventory_carton_check_drop_stmt;
@@ -1214,7 +1214,7 @@ paths:
responses: { '200': { description: 当前客户仓储转运单分页 }, '403': { description: 缺少仓储转运查看权限 } }
post:
summary: 客户创建仓储转运单
description: 权限 merchant:overseas-warehouse:transfer:create;客户范围只取认证 Token。
description: 权限 merchant:overseas-warehouse:transfer:create;客户范围只取认证 Token。目的去向可暂未确定(destinationType 为 NONE 或不传),允许保存草稿、编辑和提交;入库确认后可在出库时绑定 FBA 仓或私人仓,也可选择入库库存发起贴标申请出库;普通转运出库前必须补齐目的仓。
security: [{ bearerAuth: [] }]
responses: { '200': { description: 创建后的仓储转运单 }, '403': { description: 缺少仓储转运创建权限 }, '409': { description: 幂等、库存或状态冲突 } }
/api/merchant/v1/last-leg/warehouse-transfers/{transferNo}:
@@ -92,6 +92,88 @@ class PdaPalletOperationServiceTest {
service.configureTransactions(new NoOpTransactionManager());
}
@Test void returnPalletTotalsFollowCurrentBindingsWithoutCountingRetriesTwice() {
var first = new ReturnInventoryCarton(); first.setWeight(new java.math.BigDecimal("5"));
first.setLength(new java.math.BigDecimal("30")); first.setWidth(new java.math.BigDecimal("20"));
first.setHeight(new java.math.BigDecimal("20")); first.setDestinationWarehouseCode("SCK8");
var second = new ReturnInventoryCarton(); second.setWeight(new java.math.BigDecimal("11"));
second.setLength(new java.math.BigDecimal("40")); second.setWidth(new java.math.BigDecimal("30"));
second.setHeight(new java.math.BigDecimal("30")); second.setDestinationWarehouseCode("SCK8");
var pallet = new PalletPhysicalGateway.PalletReference(10L, "TARGET", 88L, null, "OCCUPIED", "SCK8", 2);
when(returnCartons.palletCartons("88", "TARGET")).thenReturn(List.of(first), List.of(first, second), List.of(first, second), List.of(first));
var before = (com.baiyue.lastleg.palletops.dto.PalletOperationResponses.PalletContent) ReflectionTestUtils.invokeMethod(service, "content", 88L, pallet);
assertEquals(0, before.grossWeightKg().compareTo(new java.math.BigDecimal("5")));
var added = (com.baiyue.lastleg.palletops.dto.PalletOperationResponses.PalletContent) ReflectionTestUtils.invokeMethod(service, "content", 88L, pallet);
assertEquals(2, added.totalPackages());
assertEquals(0, added.grossWeightKg().compareTo(new java.math.BigDecimal("16")));
assertEquals(0, added.volumeCbm().compareTo(new java.math.BigDecimal("0.048")));
assertEquals(added, ReflectionTestUtils.invokeMethod(service, "content", 88L, pallet));
assertEquals(before, ReflectionTestUtils.invokeMethod(service, "content", 88L, pallet));
}
@Test void returnRebindingRequiresCurrentRoundBindingAndAddsToExistingPallet() {
var workTasks = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class);
var workHistory = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper.class);
ReflectionTestUtils.setField(service, "relabelWorkTasks", workTasks);
ReflectionTestUtils.setField(service, "relabelWorkHistory", workHistory);
var app = new RelabelApplication(); app.setId(600L); app.setCustomerId("100");
var task = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask(); task.setId(700L);
when(workTasks.find("100", 600L)).thenReturn(task);
var item = new RelabelItem(); item.setReturnCartonId(801L); item.setCartonMark("BOX-1");
item.setDestinationFbaWarehouseCode("SCK8"); item.setNotifiedPackageCount(1);
var box = new ReturnInventoryCarton(); box.setId(801L); box.setCustomerId("100");
box.setPalletCode("SOURCE"); box.setRelabelApplicationId(600L); box.setStatus("RELABEL_PENDING");
box.setTotalQuantity(10L); box.setWeight(new java.math.BigDecimal("11"));
box.setLength(new java.math.BigDecimal("40")); box.setWidth(new java.math.BigDecimal("30"));
box.setHeight(new java.math.BigDecimal("30")); box.setDestinationWarehouseCode("SCK8");
var existing = new ReturnInventoryCarton(); existing.setWeight(new java.math.BigDecimal("5"));
existing.setLength(new java.math.BigDecimal("30")); existing.setWidth(new java.math.BigDecimal("20"));
existing.setHeight(new java.math.BigDecimal("20")); existing.setDestinationWarehouseCode("SCK8");
var source = new PalletPhysicalGateway.PalletReference(20L, "SOURCE", 88L, 1020L, "OCCUPIED", "SCK8", 1);
var target = new PalletPhysicalGateway.PalletReference(10L, "TARGET", 88L, 1010L, "OCCUPIED", "SCK8", 2);
when(returnCartons.lockId(801L)).thenReturn(box);
when(returnCartons.palletCartons("88", "TARGET")).thenAnswer(call ->
"TARGET".equals(box.getPalletCode()) ? List.of(existing, box) : List.of(existing));
var request = new PalletOperationRequests.RelabelDisassemble("rebind-current", "RL-1",
new PalletOperationRequests.PalletExpectation("SOURCE", 1),
new PalletOperationRequests.PalletExpectation("TARGET", 2), List.of("BOX-1"), "rebind");
assertThrows(ResponseStatusException.class, () -> ReflectionTestUtils.invokeMethod(service,
"bindReturnRelabelPallet", identity(), app, List.of(item), source, target, request));
verifyNoInteractions(physical);
var binding = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory();
binding.setDetailJson("{\"round\":\"INITIAL\",\"cartonMarks\":[\"BOX-1\"]}");
when(workHistory.selectList(any())).thenReturn(List.of(binding));
task.setReviewedAt(LocalDateTime.of(2026, 10, 12, 12, 0));
assertThrows(ResponseStatusException.class, () -> ReflectionTestUtils.invokeMethod(service,
"bindReturnRelabelPallet", identity(), app, List.of(item), source, target, request));
task.setReviewedAt(null);
when(physical.resolveLocation(88L, 1020L)).thenReturn(
new PalletPhysicalGateway.LocationReference(1020L, "SOURCE-LOC", "STORAGE", "ACTIVE"));
when(physical.resolveLocation(88L, 1010L)).thenReturn(
new PalletPhysicalGateway.LocationReference(1010L, "TARGET-LOC", "STORAGE", "ACTIVE"));
when(physical.resolve("SOURCE")).thenReturn(source);
when(physical.resolve("TARGET")).thenReturn(target);
var result = (com.baiyue.lastleg.palletops.dto.PalletOperationResponses.Operation)
ReflectionTestUtils.invokeMethod(service, "bindReturnRelabelPallet", identity(), app,
List.of(item), source, target, request);
assertEquals("TARGET", box.getPalletCode());
assertEquals(0, result.pallets().get(0).totalPackages());
assertEquals(2, result.pallets().get(1).totalPackages());
assertEquals(0, result.pallets().get(1).grossWeightKg().compareTo(new java.math.BigDecimal("16")));
assertEquals(0, result.pallets().get(1).volumeCbm().compareTo(new java.math.BigDecimal("0.048")));
verify(returnCartons).updateById(box);
verify(workHistory).insert(any(com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory.class));
}
@Test void missingCartonMeasurementDoesNotBecomeZeroWeight() {
var box = new ReturnInventoryCarton(); box.setWeight(new java.math.BigDecimal("5"));
when(returnCartons.palletCartons("88", "TARGET")).thenReturn(List.of(box));
var pallet = new PalletPhysicalGateway.PalletReference(10L, "TARGET", 88L, null, "OCCUPIED", "SCK8", 2);
var result = (com.baiyue.lastleg.palletops.dto.PalletOperationResponses.PalletContent) ReflectionTestUtils.invokeMethod(service, "content", 88L, pallet);
assertNull(result.volumeCbm());
assertEquals(0, result.grossWeightKg().compareTo(new java.math.BigDecimal("5")));
}
@Test void partialReturnRelabelKeepsUnlabelledRemainderOnOriginalPallet() {
RelabelApplication app = new RelabelApplication(); app.setId(600L); app.setCustomerId("100"); app.setRelabelNo("RL-1");
RelabelItem item = new RelabelItem(); item.setId(701L); item.setSkuCode("SKU-1");
@@ -82,6 +82,31 @@ class PdaRelabelServiceTest {
lenient().when(tasks.updateWithVersion(any(), anyInt())).thenReturn(1);
}
@Test
void detailDistinguishesInventoryReferenceFromRecordedReturnPallet() {
when(applications.findAnyByRelabelNo("FBR-1")).thenReturn(application);
var positions = org.mockito.Mockito.mock(com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper.class);
ReflectionTestUtils.setField(service, "sourcePositions", positions);
item.setSourceInventoryPositionId(30L);
var position = new com.baiyue.lastleg.inventory.entity.InventoryPosition();
position.setPalletNo("PLT-REFERENCE");
when(positions.findTenantPosition("100", 30L)).thenReturn(position);
var ordinary = service.detail("7", 900L, "FBR-1").items().get(0);
assertEquals("PLT-REFERENCE", ordinary.sourcePalletCode());
assertEquals("INVENTORY_REFERENCE", ordinary.sourcePalletBasis());
var cartons = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper.class);
ReflectionTestUtils.setField(service, "returnCartons", cartons);
item.setReturnCartonId(40L);
var carton = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton();
carton.setCustomerId("100"); carton.setPalletCode("PLT-RETURN"); carton.setTotalQuantity(20L);
when(cartons.selectById(40L)).thenReturn(carton);
var returned = service.detail("7", 900L, "FBR-1").items().get(0);
assertEquals("PLT-RETURN", returned.sourcePalletCode());
assertEquals("RETURN_CARTON", returned.sourcePalletBasis());
carton.setCustomerId("OTHER");
assertEquals(null, service.detail("7", 900L, "FBR-1").items().get(0).sourcePalletCode());
}
@Test
void candidatesUseCustomerNameOnlyFromMatchingCustomerOrder() {
var orders = org.mockito.Mockito.mock(com.baiyue.lastleg.mapper.LastlegOrderMapper.class);
@@ -65,8 +65,9 @@ class ReturnCartonCountServiceTest {
when(cartons.lockReceipt(1L)).thenReturn(List.of(a,b));
when(receiptItems.listReceipt(1L)).thenReturn(expected);
}
private CountBox measuredBox(String mark, List<CountItem> lines) { return new CountBox(mark, lines, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE); }
private Confirm unequal(long first, long second) {
return new Confirm("retry", List.of(new CountBox(a.getCartonMark(),List.of(new CountItem("SKU1",first))),new CountBox(b.getCartonMark(),List.of(new CountItem("SKU1",second)))));
return new Confirm("retry", List.of(measuredBox(a.getCartonMark(),List.of(new CountItem("SKU1",first))),measuredBox(b.getCartonMark(),List.of(new CountItem("SKU1",second)))));
}
@Test void unequalBoxesAreConfirmedWithoutChangingSkuUnitBalanceAndRetryIsStable() {
prepare(List.of(expected("SKU1",10)));
@@ -97,7 +98,7 @@ class ReturnCartonCountServiceTest {
when(items.lockCarton(3L)).thenReturn(List.of(provisional(3,"SKU2",4)));
when(balances.adjustCartonQuantity("C1","123","SKU1",1)).thenReturn(1);
when(balances.adjustCartonQuantity("C1","123","SKU2",1)).thenReturn(1);
var request = new Confirm("mixed",List.of(new CountBox(a.getCartonMark(),List.of(new CountItem("SKU1",2),new CountItem("SKU2",1))),new CountBox(b.getCartonMark(),List.of(new CountItem("SKU1",4),new CountItem("SKU2",3)))));
var request = new Confirm("mixed",List.of(measuredBox(a.getCartonMark(),List.of(new CountItem("SKU1",2),new CountItem("SKU2",1))),measuredBox(b.getCartonMark(),List.of(new CountItem("SKU1",4),new CountItem("SKU2",3)))));
service.confirm(identity,"RR-1",request);
verify(balances).adjustCartonQuantity("C1","123","SKU1",1); verify(balances).adjustCartonQuantity("C1","123","SKU2",1);
verifyNoMoreInteractions(balances); assertThat(a.getSkuTypes()).isEqualTo(2);
@@ -110,7 +111,7 @@ class ReturnCartonCountServiceTest {
}
@Test void duplicateOrForeignBoxesCannotBeSubmitted() {
prepare(List.of(expected("SKU1",10)));
var request = new Confirm("dup",List.of(new CountBox(a.getCartonMark(),List.of(new CountItem("SKU1",5))),new CountBox(a.getCartonMark(),List.of(new CountItem("SKU1",5)))));
var request = new Confirm("dup",List.of(measuredBox(a.getCartonMark(),List.of(new CountItem("SKU1",5))),measuredBox(a.getCartonMark(),List.of(new CountItem("SKU1",5)))));
assertThatThrownBy(() -> service.confirm(identity,"RR-1",request)).isInstanceOf(ResponseStatusException.class).hasMessageContaining("重复");
verify(items,never()).deleteCarton(anyLong());
}
@@ -454,7 +454,7 @@ class ReturnReceiptServiceTest {
stubNewReceipt(order, List.of(line), "SP-SOURCE");
var request = new PdaReturnReceiptRequests.Confirm("RT-1", null, "SKU", null,
List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1)), "parcel-key",
"PARCEL", null, null, null, null, null, null);
"PARCEL", null, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, null, true);
var result = service.confirm(identity, request);
var capture = ArgumentCaptor.forClass(ReturnReceipt.class);
verify(receipts).insert(capture.capture());
@@ -611,6 +611,6 @@ class ReturnReceiptServiceTest {
// These focused tests exercise receipt/idempotency behavior at the
// carton level. Pallet receipts have a separate multi-carton contract.
return new PdaReturnReceiptRequests.Confirm("RT-1", trackingNo, startedBy, palletCode, boxed, key,
"BOX", palletCode, null, null, null, null, 1L);
"BOX", palletCode, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, 1L, true);
}
}
@@ -47,6 +47,9 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
@@ -438,20 +441,65 @@ class WarehouseTransferServiceTest {
verifyNoInteractions(transfers);
}
@Test
void transferRejectsRequestWithoutDestination() {
@ParameterizedTest
@NullSource
@ValueSource(strings = {"NONE"})
void transferWithoutDestinationCanBeCreatedAndSubmitted(String destinationType) {
WarehouseTransferRequests.Create request = new WarehouseTransferRequests.Create(
"IN-1", List.of(11L), "WH-1", null, "123 Pickup Road", 5,
"IN-1", List.of(11L), "WH-1", null, destinationType, null, null, null,
null, null, null, null, "123 Pickup Road", null, null, 5,
new BigDecimal("0.2500"), "WAREHOUSE_PICKUP", LocalDateTime.of(2026, 9, 12, 10, 0),
null, null, null, "FBA destination will be supplied later", false, "key-no-fba",
List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 5, "MARK-1")));
null, null, null, null, "Destination will be supplied before outbound", false,
null, null, null, null, null, "key-no-fba",
List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 5, "MARK-1")), null);
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.create("TENANT-A", "USER-1", request));
when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L);
when(orders.selectList(any())).thenReturn(List.of(historicalOrder()));
WarehouseTransferOrder draft = transfer(WarehouseTransferService.DRAFT);
draft.setFbaWarehouseCode(null);
when(transfers.findNo(eq("TENANT-A"), anyString())).thenReturn(draft);
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(draft);
when(orders.selectById(draft.getLastlegOrderId())).thenReturn(tailOrder());
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
assertEquals(400, exception.getStatusCode().value());
assertEquals("仓储转运必须选择 FBA 仓或私人仓", exception.getReason());
verifyNoInteractions(transfers);
service.create("TENANT-A", "USER-1", request);
var result = service.submit("TENANT-A", "WTR-1", "USER-1");
ArgumentCaptor<WarehouseTransferOrder> created = ArgumentCaptor.forClass(WarehouseTransferOrder.class);
verify(transfers).insert(created.capture());
assertNull(created.getValue().getDestinationType());
assertNull(created.getValue().getFbaWarehouseCode());
assertEquals(WarehouseTransferService.PENDING_REVIEW, result.status());
verify(relabelApplications, never()).insert(any(RelabelApplication.class));
}
@Test
void merchantCanClearDestinationWhenEditingDraft() {
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.DRAFT);
transfer.setDestinationType("FBA");
transfer.setDestinationWarehouseCode("ONT8");
LastlegOrder order = tailOrder();
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(orders.selectById(transfer.getLastlegOrderId())).thenReturn(order);
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
WarehouseTransferRequests.Create request = new WarehouseTransferRequests.Create(
"IN-1", List.of(11L), "WH-1", null, "NONE", null, null, null,
null, null, null, null, "123 Pickup Road", null, null, 5,
new BigDecimal("0.2500"), "WAREHOUSE_PICKUP", LocalDateTime.of(2026, 9, 12, 10, 0),
null, null, null, null, null, false, null, null, null, null, null, "edit-no-fba",
List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 5, "MARK-1")), null);
var result = service.updateDraft("TENANT-A", "WTR-1", "USER-1", request);
assertEquals(WarehouseTransferService.DRAFT, result.status());
assertNull(transfer.getDestinationType());
assertNull(transfer.getDestinationWarehouseCode());
assertNull(transfer.getFbaWarehouseCode());
assertNull(order.getFbaWarehouseCode());
verify(transfers).updateById(transfer);
}
@Test
+2
View File
@@ -1,5 +1,7 @@
# 后端脚本管理与全量 HTTP 验收索引
`db/return-consolidated-incremental-20261012.sql`:10月10日/12日退货、签收、清点、换标与按件出库增量汇总,显式 `USE baiyue_lastleg`,MySQL 8.0.16+ 已有业务库可重复执行。本轮补齐签收原包装、尺寸重量来源及库存箱原包装三个字段;逐列判断存在性,不回填签收/清点事实,不改库存数量。连接实例及凭据由执行者明确指定,整份执行、遇错停止;保留执行错误,修复后可重跑,末尾输出字段、索引与约束供核对。托盘重量、体积汇总是应用逻辑,不新增资源库字段;不自动执行到测试或生产环境。
`procurement-sample-test-env.mjs` 是采购样品正常全链路 TEST 专项入口,默认或 `--plan` 零网络。真实执行使用 `node --env-file="<授权目录>/test-acceptance.env" scripts/procurement-sample-test-env.mjs --execute --environment TEST --run-id <唯一短批次> --warehouse-id <已核对有效海外收货仓>`。只允许当前 TEST 工作区及指定配置;由正式 API 创建独立客户并完成资质,人工充值10元,上传凭证/面单,调用内部 `procurement-sample-fulfillment.mjs` 完成两箱正常履约。充值、文件和采购请求前落盘,结果未知停写;报告独占 `reports/procurement-sample-<批次>/report.json`,不覆盖旧报告,不自动重跑,不执行 SQL/部署/删除。结束退出自身平台与客户会话,保留样本。通过只表示该正常路径,不替代异常、退款、权限、真实页面和请求量验收。离线入口合同为 `procurement-sample-test-env.test.mjs`;内部模块安全合同见模块索引。
@@ -101,13 +101,23 @@ PREPARE return_index_stmt FROM @return_index_ddl; EXECUTE return_index_stmt; DEA
SET @return_index_ddl = IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_outbound_movement' AND index_name='idx_ll_return_outbound_carton_sku'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_outbound_movement ADD INDEX idx_ll_return_outbound_carton_sku(customer_id,carton_id,sku_code)');
PREPARE return_index_stmt FROM @return_index_ddl; EXECUTE return_index_stmt; DEALLOCATE PREPARE return_index_stmt;
-- 五、原包装及尺寸重量来源。逐列检查,支持部分执行后重跑。
-- 托盘重量/体积由当前绑定箱的实测尺寸重量汇总,无需新增托盘字段。
SET @return_measurement_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='original_packaging'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN original_packaging TINYINT(1) NULL COMMENT ''是否使用原箱原包装'' AFTER weight');
PREPARE return_measurement_stmt FROM @return_measurement_ddl; EXECUTE return_measurement_stmt; DEALLOCATE PREPARE return_measurement_stmt;
SET @return_measurement_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='measurement_source'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN measurement_source VARCHAR(32) NULL COMMENT ''尺寸重量来源 PALLET_MEASURED托盘实测 UNIT_SUM子箱汇总'' AFTER original_packaging');
PREPARE return_measurement_stmt FROM @return_measurement_ddl; EXECUTE return_measurement_stmt; DEALLOCATE PREPARE return_measurement_stmt;
SET @return_measurement_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='original_packaging'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN original_packaging TINYINT(1) NULL COMMENT ''是否使用原箱原包装'' AFTER weight');
PREPARE return_measurement_stmt FROM @return_measurement_ddl; EXECUTE return_measurement_stmt; DEALLOCATE PREPARE return_measurement_stmt;
-- 校验输出:核对新增字段、索引和守恒约束。
SELECT table_name,column_name,column_type,is_nullable,column_default FROM information_schema.columns
WHERE table_schema=DATABASE() AND (
(table_name='ll_outbound_pallet_item' AND column_name='return_sku_quantity') OR
(table_name='ll_return_inventory_carton' AND column_name IN('label_status','count_status','counted_at','counted_by')) OR
(table_name='ll_return_inventory_carton' AND column_name IN('label_status','count_status','counted_at','counted_by','original_packaging')) OR
(table_name='ll_return_receipt' AND column_name IN('original_packaging','measurement_source')) OR
(table_name='ll_relabel_pda_unit_fact' AND column_name='quantity')) ORDER BY table_name,column_name;
SELECT table_name,index_name,non_unique,GROUP_CONCAT(column_name ORDER BY seq_in_index) AS indexed_columns
FROM information_schema.statistics WHERE table_schema=DATABASE() AND index_name IN
('idx_ll_return_outbound_carton_sku','uk_ll_return_outbound_pick_sku','idx_ll_relabel_application_order','uk_ll_return_count_session_receipt','idx_ll_return_count') GROUP BY table_name,index_name,non_unique;
SELECT constraint_name,check_clause FROM information_schema.check_constraints WHERE constraint_schema=DATABASE() AND constraint_name='chk_ll_return_outbound_movement_delta';
SELECT constraint_name,check_clause FROM information_schema.check_constraints WHERE constraint_schema=DATABASE() AND constraint_name='chk_ll_return_outbound_movement_delta';