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汇总余额'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货库存按客户海外仓SKU汇总余额';
CREATE TABLE ll_return_inventory_carton ( CREATE TABLE ll_return_inventory_carton (
original_packaging TINYINT(1) NULL COMMENT '是否使用原箱原包装',
label_status varchar(32) DEFAULT NULL COMMENT 'WAIT_LABEL待贴标 CONFIRMED已扫码确认标签', label_status varchar(32) DEFAULT NULL COMMENT 'WAIT_LABEL待贴标 CONFIRMED已扫码确认标签',
new_carton_mark varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标后新箱唛', new_carton_mark varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标后新箱唛',
id bigint NOT NULL COMMENT '退货库存箱ID', id bigint NOT NULL COMMENT '退货库存箱ID',
@@ -2565,7 +2566,9 @@ CREATE TABLE ll_return_receipt (
length DECIMAL(12,3) NULL COMMENT '实测尺寸cm', length DECIMAL(12,3) NULL COMMENT '实测尺寸cm',
width DECIMAL(12,3) NULL COMMENT '实测尺寸cm', width DECIMAL(12,3) NULL COMMENT '实测尺寸cm',
height 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退货实收签收事实'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PDA退货实收签收事实';
CREATE TABLE ll_return_receipt_batch ( CREATE TABLE ll_return_receipt_batch (
@@ -258,11 +258,13 @@ public interface DispatchCandidateMapper {
@Param("relabelNo") String relabelNo); @Param("relabelNo") String relabelNo);
@Select("<script>SELECT a.customer_id AS customerId,a.lastleg_order_id AS orderId,a.overseas_warehouse_id AS overseasWarehouseId," + @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," + "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," + "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 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," + "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 " + "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 " + "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 " + "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, String palletCode, String status, int palletVersion,
@JsonSerialize(using = ToStringSerializer.class) Long locationId, @JsonSerialize(using = ToStringSerializer.class) Long locationId,
String locationCode, String destinationWarehouseCode, 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, public record PrintablePallet(@JsonSerialize(using = ToStringSerializer.class) long palletId,
String palletCode, int palletVersion, String palletCode, int palletVersion,
String relabelNo, String printableHtml) { } String relabelNo, String printableHtml) { }
@@ -316,7 +316,8 @@ public class PdaPalletOperationService {
PalletOperationRequests.RelabelDisassemble request) { PalletOperationRequests.RelabelDisassemble request) {
requireRelabelDestination(identity.overseasWarehouseId(), selected, target); requireRelabelDestination(identity.overseasWarehouseId(), selected, target);
Set<String> alreadyBound = boundRelabelMarks(application); 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("退货换标箱已在本轮完成组托,请刷新后重试"); throw conflict("退货换标箱已在本轮完成组托,请刷新后重试");
long quantity = 0; long quantity = 0;
List<ReturnInventoryCarton> cartons = new ArrayList<>(); List<ReturnInventoryCarton> cartons = new ArrayList<>();
@@ -330,8 +331,8 @@ public class PdaPalletOperationService {
quantity += value(item.getNotifiedPackageCount()); quantity += value(item.getNotifiedPackageCount());
cartons.add(carton); cartons.add(carton);
} }
if (request.actualSource() == null || request.actualSource().positionId() != cartons.get(0).getId() if (!rebind && (request.actualSource() == null || request.actualSource().positionId() != cartons.get(0).getId()
|| request.actualSource().packages() != quantity) || request.actualSource().packages() != quantity))
throw conflict("退货换标拆出数量必须等于本批已完成贴标数量"); throw conflict("退货换标拆出数量必须等于本批已完成贴标数量");
String key = required(request.idempotencyKey(), "幂等键不能为空"); String key = required(request.idempotencyKey(), "幂等键不能为空");
String requestHash = hash(request); String requestHash = hash(request);
@@ -836,6 +837,8 @@ public class PdaPalletOperationService {
Set<String> destinations = new HashSet<>(); Set<String> destinations = new HashSet<>();
selected.forEach(item -> destinations.add(text(item.getDestinationFbaWarehouseCode()))); selected.forEach(item -> destinations.add(text(item.getDestinationFbaWarehouseCode())));
if (destinations.size() != 1 || destinations.contains("")) throw conflict("请按已维护的新目的仓分批组托"); 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())) { for (PalletInventoryRow row : mapper.content(String.valueOf(warehouseId), target.palletId())) {
var bound = relabelItems.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<RelabelItem>() 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())); .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())); value(row.getReceivingPackages()), value(row.getVersion()));
}).toList(); }).toList();
long total = rows.stream().mapToLong(PdaPalletOperationService::rowPackages).sum(); 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()); String locationCode = locationCode(warehouseId, pallet.locationId());
return new PalletOperationResponses.PalletContent(pallet.palletId(), pallet.palletCode(), pallet.status(), return new PalletOperationResponses.PalletContent(pallet.palletId(), pallet.palletCode(), pallet.status(),
pallet.version(), pallet.locationId(), locationCode, destinations.size() == 1 ? destinations.iterator().next() : null, 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) { private PdaPalletOperation begin(PdaWarehouseIdentity identity, String type, String key, String requestHash) {
@@ -42,7 +42,18 @@ public final class PdaRelabelResponses {
List<SkuItem> skuItems, List<SkuItem> skuItems,
int expectedPackages, int relabelledPackages, int skippedPackages, int expectedPackages, int relabelledPackages, int skippedPackages,
int exceptionPackages, int cartonProcessedPackages, int skuProcessedPackages, 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, public Item(Long itemId, String skuCode, String productNameZh, String shipmentNo, String trackingCode,
String newSkuCode, String labelRequirement, String relabelFileId, String relabelFileName, String relabelFileUrl, String newSkuCode, String labelRequirement, String relabelFileId, String relabelFileName, String relabelFileUrl,
String cartonMark, String fbaCartonMark, String operationType, List<SkuItem> skuItems, int expectedPackages, String cartonMark, String fbaCartonMark, String operationType, List<SkuItem> skuItems, int expectedPackages,
@@ -92,6 +92,7 @@ public class PdaRelabelService {
@Resource private PdaRelabelTaskMapper tasks; @Resource private PdaRelabelTaskMapper tasks;
@Resource private PdaRelabelUnitFactMapper unitFacts; @Resource private PdaRelabelUnitFactMapper unitFacts;
@Resource private ReturnInventoryCartonMapper returnCartons; @Resource private ReturnInventoryCartonMapper returnCartons;
@Resource private com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper sourcePositions;
@Resource private PdaRelabelOperationMapper operations; @Resource private PdaRelabelOperationMapper operations;
@Resource private PdaRelabelHistoryMapper histories; @Resource private PdaRelabelHistoryMapper histories;
@Resource private RelabelApplicationService applicationService; @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(), "CARTON") ? 1 : 0),
count(itemFacts, "RELABELLED") + (itemFacts.isEmpty() && mixedStageDone(task, item.getId(), "SKU") ? 1 : 0), count(itemFacts, "RELABELLED") + (itemFacts.isEmpty() && mixedStageDone(task, item.getId(), "SKU") ? 1 : 0),
item.getDestinationFbaWarehouseCode(), item.getReturnCartonId(), item.getDestinationFbaWarehouseCode(), item.getReturnCartonId(),
returnQuantity(item)); returnQuantity(item), sourcePalletCode(item), item.getReturnCartonId() != null ? "RETURN_CARTON" : "INVENTORY_REFERENCE");
}).toList(); }).toList();
List<PdaRelabelResponses.UnitFact> factResponses = facts.stream().map(fact -> List<PdaRelabelResponses.UnitFact> factResponses = facts.stream().map(fact ->
new PdaRelabelResponses.UnitFact(fact.getId(), fact.getItemId(), fact.getOldLabel(), fact.getNewLabel(), new PdaRelabelResponses.UnitFact(fact.getId(), fact.getItemId(), fact.getOldLabel(), fact.getNewLabel(),
@@ -881,6 +882,16 @@ public class PdaRelabelService {
List.class, PdaRelabelResponses.SkuItem.class)); List.class, PdaRelabelResponses.SkuItem.class));
} catch (JsonProcessingException exception) { throw new IllegalStateException("Cannot read relabel SKU details", exception); } } 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) { private long returnQuantity(RelabelItem item) {
var carton = item.getReturnCartonId() == null ? null : returnCartons.selectById(item.getReturnCartonId()); var carton = item.getReturnCartonId() == null ? null : returnCartons.selectById(item.getReturnCartonId());
return carton == null || carton.getTotalQuantity() == null ? 0L : carton.getTotalQuantity(); return carton == null || carton.getTotalQuantity() == null ? 0L : carton.getTotalQuantity();
@@ -25,11 +25,18 @@ public final class PdaReturnReceiptRequests {
@Size(max = 16) String unitType, @Size(max = 16) String unitType,
@Size(max = 128) String unitCode, @Size(max = 128) String unitCode,
BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight, BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight,
Long totalBoxCount) { Long totalBoxCount, Boolean originalPackaging) {
public Confirm(String returnNo, String trackingNo, String startedBy, String palletCode, public Confirm(String returnNo, String trackingNo, String startedBy, String palletCode,
List<@Valid Item> items, String idempotencyKey) { List<@Valid Item> items, String idempotencyKey) {
this(returnNo, trackingNo, startedBy, palletCode, items, 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, 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.Valid;
import jakarta.validation.constraints.*; import jakarta.validation.constraints.*;
import java.util.List; import java.util.List;
import java.math.BigDecimal;
public final class ReturnCartonCountDtos { public final class ReturnCartonCountDtos {
private ReturnCartonCountDtos() { } private ReturnCartonCountDtos() { }
public record Query(@Min(1) long page, @Min(1) @Max(50) long size, @Size(max=128) String keyword) { } 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 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 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 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) { } 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 width;
private BigDecimal height; private BigDecimal height;
private BigDecimal weight; private BigDecimal weight;
private Boolean originalPackaging;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
private LocalDateTime mergedAt; private LocalDateTime mergedAt;
@@ -23,6 +23,10 @@ public class ReturnReceipt {
private BigDecimal width; private BigDecimal width;
private BigDecimal height; private BigDecimal height;
private BigDecimal weight; 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 palletCode;
private String cartonMark; private String cartonMark;
private String trackingNo; private String trackingNo;
@@ -10,6 +10,10 @@ import java.util.List;
@Mapper @Mapper
public interface ReturnInventoryCartonMapper extends BaseMapper<ReturnInventoryCarton> { 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)") @Select("SELECT EXISTS(SELECT 1 FROM ll_return_inventory_outbound_movement WHERE carton_id=#{cartonId} AND quantity>0)")
boolean hasOutbound(@Param("cartonId") long cartonId); 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}") @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<>(); Set<String> marks = new HashSet<>();
for (CountBox box : request.boxes()) { for (CountBox box : request.boxes()) {
if (!byMark.containsKey(box.cartonMark()) || !marks.add(box.cartonMark())) throw error(HttpStatus.BAD_REQUEST, "箱号不属于本次签收或重复填写"); 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<>(); Set<String> skus = new HashSet<>();
for (CountItem line : box.items()) { for (CountItem line : box.items()) {
if (!expected.containsKey(line.skuCode()) || !skus.add(line.skuCode()) || line.quantity() <= 0) throw error(HttpStatus.BAD_REQUEST, "SKU 不属于本次签收、数量无效或同箱重复"); 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.setCustomerId(box.getCustomerId()); item.setSkuCode(line.skuCode()); item.setProductNameZh(names.get(line.skuCode())); item.setQuantity(line.quantity());
item.setPackingReservedQuantity(0L); items.insert(item); 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); box.setCountStatus("CONFIRMED"); box.setCountedAt(now); box.setCountedBy(identity.operatorKey()); box.setUpdatedAt(now); cartons.updateById(box);
} }
for (String sku : expected.keySet()) { for (String sku : expected.keySet()) {
@@ -167,7 +168,8 @@ public class ReturnCartonCountService {
private Task task(ReturnReceipt receipt, List<ReturnInventoryCarton> boxes) { 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<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() 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(), 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); 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. // Pallet measurements describe the carrier, never each child carton.
if ("BOX".equals(receipt.getUnitType()) || "PARCEL".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.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); carton.setStatus(AVAILABLE); carton.setSkuTypes(lines.size()); carton.setTotalQuantity(total); carton.setCreatedAt(now); carton.setUpdatedAt(now);
cartons.insert(carton); cartons.insert(carton);
@@ -1086,6 +1087,16 @@ public class ReturnInventoryService {
} }
if (existingTarget != null) { if (existingTarget != null) {
target.setTotalQuantity(cartonItems.totalQuantity(targetId)); target.setSkuTypes(cartonItems.skuTypes(targetId)); 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); 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(), 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)) { || request.height().signum() <= 0 || request.weight().signum() <= 0)) {
throw badRequest("托盘签收必须填写有效的长、宽、高和重量"); 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(); LocalDateTime now = LocalDateTime.now();
long receiptId = IdWorker.getId(); long receiptId = IdWorker.getId();
ReturnReceipt receipt = new ReturnReceipt(); ReturnReceipt receipt = new ReturnReceipt();
@@ -629,6 +637,8 @@ public class ReturnReceiptService {
receipt.setTotalBoxCount(totalBoxCount); receipt.setTotalBoxCount(totalBoxCount);
receipt.setPalletCode("PALLET".equals(unitType) ? unitCode : ("BOX".equals(unitType) ? text(request.palletCode()) : null)); 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.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.setReceivedQuantity(receiptQuantity); receipt.setOperatorId(identity.operatorKey());
receipt.setOperatorName(identity.operatorName()); receipt.setIdempotencyKey(key); receipt.setRequestHash(requestHash); receipt.setOperatorName(identity.operatorName()); receipt.setIdempotencyKey(key); receipt.setRequestHash(requestHash);
receipt.setSignedAt(now); receipt.setCreatedAt(now); receipt.setSignedAt(now); receipt.setCreatedAt(now);
@@ -151,12 +151,6 @@ public class WarehouseTransferService {
validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(), validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(),
request.privateDestinationAddress(), request.privateDestinationPostalCode(), request.privateDestinationAddress(), request.privateDestinationPostalCode(),
request.privateDestinationContactName(), request.privateDestinationContactPhone()); 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 key = request.idempotencyKey().trim();
String requestHash = hash(request); String requestHash = hash(request);
WarehouseTransferOrder replay = transfers.findKey(customerId, key); WarehouseTransferOrder replay = transfers.findKey(customerId, key);
@@ -292,8 +286,6 @@ public class WarehouseTransferService {
validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(), validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(),
request.privateDestinationAddress(), request.privateDestinationPostalCode(), request.privateDestinationAddress(), request.privateDestinationPostalCode(),
request.privateDestinationContactName(), request.privateDestinationContactPhone()); request.privateDestinationContactName(), request.privateDestinationContactPhone());
validateTransferDestinationRequired(request.destinationType(), request.fbaWarehouseCode(),
request.needRelabel() && request.relabelItems() != null && !request.relabelItems().isEmpty());
LastlegOrder order = requireTailOrder(customerId, transfer); LastlegOrder order = requireTailOrder(customerId, transfer);
LocalDateTime now = LocalDateTime.now(); 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) { private static void applyDestination(WarehouseTransferOrder transfer, WarehouseTransferRequests.Create request) {
String type = trim(request.destinationType()); String type = trim(request.destinationType());
if (type == null && StringUtils.hasText(request.fbaWarehouseCode())) type = "FBA"; 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) 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预报与实收明细'; ) ENGINE=InnoDB COMMENT='客户退货订单的SKU预报与实收明细';
CREATE TABLE IF NOT EXISTS ll_return_receipt ( 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 '创建时间', 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')) 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退货实收签收事实'; ) ENGINE=InnoDB COMMENT='PDA退货实收签收事实';
@@ -3018,6 +3019,10 @@ EXECUTE ll_return_receipt_alter_stmt;
DEALLOCATE PREPARE 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')); 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; 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 ( 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实收数量', 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) 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')); 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; 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 ( CREATE TABLE IF NOT EXISTS ll_return_inventory_carton (
original_packaging TINYINT(1) NULL COMMENT '是否使用原箱原包装',
new_carton_mark VARCHAR(255) 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 '合箱完成时间', 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')) 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='退货实物库存箱及箱唛'; ) 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')); 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; 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 的数据库初始化器。 -- 已合箱的来源箱保留审计记录,但其箱内库存必须归零;动态 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')); 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; 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: 缺少仓储转运查看权限 } } responses: { '200': { description: 当前客户仓储转运单分页 }, '403': { description: 缺少仓储转运查看权限 } }
post: post:
summary: 客户创建仓储转运单 summary: 客户创建仓储转运单
description: 权限 merchant:overseas-warehouse:transfer:create;客户范围只取认证 Token。 description: 权限 merchant:overseas-warehouse:transfer:create;客户范围只取认证 Token。目的去向可暂未确定(destinationType 为 NONE 或不传),允许保存草稿、编辑和提交;入库确认后可在出库时绑定 FBA 仓或私人仓,也可选择入库库存发起贴标申请出库;普通转运出库前必须补齐目的仓。
security: [{ bearerAuth: [] }] security: [{ bearerAuth: [] }]
responses: { '200': { description: 创建后的仓储转运单 }, '403': { description: 缺少仓储转运创建权限 }, '409': { description: 幂等、库存或状态冲突 } } responses: { '200': { description: 创建后的仓储转运单 }, '403': { description: 缺少仓储转运创建权限 }, '409': { description: 幂等、库存或状态冲突 } }
/api/merchant/v1/last-leg/warehouse-transfers/{transferNo}: /api/merchant/v1/last-leg/warehouse-transfers/{transferNo}:
@@ -92,6 +92,88 @@ class PdaPalletOperationServiceTest {
service.configureTransactions(new NoOpTransactionManager()); 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() { @Test void partialReturnRelabelKeepsUnlabelledRemainderOnOriginalPallet() {
RelabelApplication app = new RelabelApplication(); app.setId(600L); app.setCustomerId("100"); app.setRelabelNo("RL-1"); 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"); 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); 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 @Test
void candidatesUseCustomerNameOnlyFromMatchingCustomerOrder() { void candidatesUseCustomerNameOnlyFromMatchingCustomerOrder() {
var orders = org.mockito.Mockito.mock(com.baiyue.lastleg.mapper.LastlegOrderMapper.class); 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(cartons.lockReceipt(1L)).thenReturn(List.of(a,b));
when(receiptItems.listReceipt(1L)).thenReturn(expected); 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) { 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() { @Test void unequalBoxesAreConfirmedWithoutChangingSkuUnitBalanceAndRetryIsStable() {
prepare(List.of(expected("SKU1",10))); prepare(List.of(expected("SKU1",10)));
@@ -97,7 +98,7 @@ class ReturnCartonCountServiceTest {
when(items.lockCarton(3L)).thenReturn(List.of(provisional(3,"SKU2",4))); 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","SKU1",1)).thenReturn(1);
when(balances.adjustCartonQuantity("C1","123","SKU2",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); service.confirm(identity,"RR-1",request);
verify(balances).adjustCartonQuantity("C1","123","SKU1",1); verify(balances).adjustCartonQuantity("C1","123","SKU2",1); verify(balances).adjustCartonQuantity("C1","123","SKU1",1); verify(balances).adjustCartonQuantity("C1","123","SKU2",1);
verifyNoMoreInteractions(balances); assertThat(a.getSkuTypes()).isEqualTo(2); verifyNoMoreInteractions(balances); assertThat(a.getSkuTypes()).isEqualTo(2);
@@ -110,7 +111,7 @@ class ReturnCartonCountServiceTest {
} }
@Test void duplicateOrForeignBoxesCannotBeSubmitted() { @Test void duplicateOrForeignBoxesCannotBeSubmitted() {
prepare(List.of(expected("SKU1",10))); 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("重复"); assertThatThrownBy(() -> service.confirm(identity,"RR-1",request)).isInstanceOf(ResponseStatusException.class).hasMessageContaining("重复");
verify(items,never()).deleteCarton(anyLong()); verify(items,never()).deleteCarton(anyLong());
} }
@@ -454,7 +454,7 @@ class ReturnReceiptServiceTest {
stubNewReceipt(order, List.of(line), "SP-SOURCE"); stubNewReceipt(order, List.of(line), "SP-SOURCE");
var request = new PdaReturnReceiptRequests.Confirm("RT-1", null, "SKU", null, var request = new PdaReturnReceiptRequests.Confirm("RT-1", null, "SKU", null,
List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1)), "parcel-key", 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 result = service.confirm(identity, request);
var capture = ArgumentCaptor.forClass(ReturnReceipt.class); var capture = ArgumentCaptor.forClass(ReturnReceipt.class);
verify(receipts).insert(capture.capture()); verify(receipts).insert(capture.capture());
@@ -611,6 +611,6 @@ class ReturnReceiptServiceTest {
// These focused tests exercise receipt/idempotency behavior at the // These focused tests exercise receipt/idempotency behavior at the
// carton level. Pallet receipts have a separate multi-carton contract. // carton level. Pallet receipts have a separate multi-carton contract.
return new PdaReturnReceiptRequests.Confirm("RT-1", trackingNo, startedBy, palletCode, boxed, key, 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 com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; 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.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import org.mockito.Mock; import org.mockito.Mock;
@@ -438,20 +441,65 @@ class WarehouseTransferServiceTest {
verifyNoInteractions(transfers); verifyNoInteractions(transfers);
} }
@Test @ParameterizedTest
void transferRejectsRequestWithoutDestination() { @NullSource
@ValueSource(strings = {"NONE"})
void transferWithoutDestinationCanBeCreatedAndSubmitted(String destinationType) {
WarehouseTransferRequests.Create request = new WarehouseTransferRequests.Create( 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), 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", null, null, null, null, "Destination will be supplied before outbound", false,
List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 5, "MARK-1"))); 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, when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L);
() -> service.create("TENANT-A", "USER-1", request)); 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()); service.create("TENANT-A", "USER-1", request);
assertEquals("仓储转运必须选择 FBA 仓或私人仓", exception.getReason()); var result = service.submit("TENANT-A", "WTR-1", "USER-1");
verifyNoInteractions(transfers);
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 @Test
+2
View File
@@ -1,5 +1,7 @@
# 后端脚本管理与全量 HTTP 验收索引 # 后端脚本管理与全量 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`;内部模块安全合同见模块索引。 `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,11 +101,21 @@ 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)'); 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; 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 SELECT table_name,column_name,column_type,is_nullable,column_default FROM information_schema.columns
WHERE table_schema=DATABASE() AND ( WHERE table_schema=DATABASE() AND (
(table_name='ll_outbound_pallet_item' AND column_name='return_sku_quantity') OR (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; (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 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 FROM information_schema.statistics WHERE table_schema=DATABASE() AND index_name IN