贴标申请,仓储转运

This commit is contained in:
2026-10-13 13:46:16 +08:00
parent cebcdfb66e
commit 89a05a2348
11 changed files with 75 additions and 31 deletions
@@ -2,7 +2,7 @@
-- MySQL 8.0+;只允许目标测试环境的服务 schema 为空,已有表立即报错;禁止 --force/遇错继续。
-- 字段、索引、约束及注释直接定义在 CREATE TABLE 内;不回填、不清理、不改造存量。
-- 国内 IAM 管理两区域权限;海外不访问国内库。执行方法及系统种子清单见同目录 README.md。
-- DDL 隐式提交;失败后停止,不从中间续跑,也不把部分初始化当成成功。
-- DDL 隐式提交;失败后停止,不从中间续跑,也不把部分初始化当成成功。utf8mb4_0900_ai_ci
SET NAMES utf8mb4 COLLATE utf8mb4_0900_ai_ci;
SET SESSION sql_mode = 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION';
@@ -4,4 +4,9 @@ import java.time.LocalDateTime;
/** A relabel application keeps its own status within the received parent order. */
public record RelabelSubOrder(Long lastlegOrderId, String relabelNo, String status,
Integer quantity, LocalDateTime createdAt) { }
Integer quantity, String quantityUnit, LocalDateTime createdAt) {
public RelabelSubOrder(Long lastlegOrderId, String relabelNo, String status,
Integer quantity, LocalDateTime createdAt) {
this(lastlegOrderId, relabelNo, status, quantity, "件", createdAt);
}
}
@@ -56,7 +56,8 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
"WHERE g.container_booking_id=ll_order.source_container_booking_id " +
"AND g.node_sequence>=40 AND g.actual_at IS NOT NULL) THEN 1 ELSE 0 END END)={0})";
@Select("<script>SELECT r.lastleg_order_id,r.relabel_no,r.status,r.notified_package_count AS quantity,r.created_at " +
@Select("<script>SELECT r.lastleg_order_id,r.relabel_no,r.status,r.notified_package_count AS quantity, " +
"CASE WHEN NOT EXISTS (SELECT 1 FROM ll_relabel_item ri WHERE ri.application_id=r.id AND ri.operation_type&lt;&gt;'CARTON_LABEL') THEN '箱' ELSE '件' END AS quantity_unit,r.created_at " +
"FROM ll_relabel_application r JOIN ll_order o ON o.id=r.lastleg_order_id AND o.customer_id=r.customer_id " +
"WHERE r.lastleg_order_id IN <foreach collection='orderIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"ORDER BY r.created_at,r.id</script>")
@@ -34,7 +34,7 @@ public final class PalletOperationRequests {
@NotEmpty @Size(max = 20) List<@Valid TargetMove> targets,
@Size(max = 500) String reason) { }
/** actualSource为现场确认的原入仓库存行;cartonMarks确认本批目标绑定,不推断历史箱唛位置。 */
/** source为空时表示退货箱没有来源托盘,现场仅扫描箱唛后绑定目标托盘;actualSource为现场确认的原入仓库存行。 */
public record RelabelDisassemble(@NotBlank @Size(max = 128) String idempotencyKey,
@NotBlank @Size(max = 64) String relabelNo,
@Valid PalletExpectation source,
@@ -105,13 +105,16 @@ public class PdaPalletOperationService {
|| !Objects.equals(carton.getOverseasWarehouseId(), app.getOverseasWarehouseId())
|| !Objects.equals(carton.getStatus(), "RELABEL_PENDING"))
throw conflict("退货换标来源托盘不存在或已被占用");
PalletPhysicalGateway.PalletReference pallet = physical.resolve(carton.getPalletCode());
if (pallet == null || pallet.warehouseId() != identity.overseasWarehouseId())
String palletCode = text(carton.getPalletCode());
PalletPhysicalGateway.PalletReference pallet = palletCode.isBlank() || "UNBOUND".equalsIgnoreCase(palletCode)
? null : physical.resolve(palletCode);
if (palletCode.isBlank() || "UNBOUND".equalsIgnoreCase(palletCode)) palletCode = "UNBOUND";
if (pallet != null && pallet.warehouseId() != identity.overseasWarehouseId())
throw conflict("退货换标来源托盘不属于当前仓库");
long quantity = entry.getValue().stream().anyMatch(item -> "RETURN_BULK_SKU_LABEL".equals(item.getOperationType()))
? value(carton.getTotalQuantity()) : 1;
result.add(new PalletOperationResponses.RelabelSource(carton.getId(), 0L,
pallet.palletId(), pallet.palletCode(), null, carton.getSourceBatchNo(), quantity, quantity,
pallet == null ? null : pallet.palletId(), palletCode, null, carton.getSourceBatchNo(), quantity, quantity,
entry.getValue().stream().map(RelabelItem::getCartonMark).filter(Objects::nonNull).toList()));
}
return result;
@@ -225,11 +228,15 @@ public class PdaPalletOperationService {
if (!Objects.equals(application.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) {
throw conflict("换标单不属于 Token 冻结的海外仓");
}
Map<String, PalletPhysicalGateway.PalletReference> pallets = resolvePallets(identity.overseasWarehouseId(),
request.source(), List.of(request.target()));
PalletPhysicalGateway.PalletReference source = pallets.get(code(request.source().palletCode()));
PalletPhysicalGateway.PalletReference target = pallets.get(code(request.target().palletCode()));
if (source.palletId() == target.palletId()) throw conflict("原托盘和目标托盘不能相同");
if (request.target() == null) throw badRequest("目标托盘不能为空");
PalletPhysicalGateway.PalletReference target = resolvePallets(identity.overseasWarehouseId(),
List.of(request.target())).get(code(request.target().palletCode()));
PalletPhysicalGateway.PalletReference source = request.source() == null ? null
: resolvePallets(identity.overseasWarehouseId(), List.of(request.source())).get(code(request.source().palletCode()));
if (source != null && source.palletId() == target.palletId()) throw conflict("原托盘和目标托盘不能相同");
Map<String, PalletPhysicalGateway.PalletReference> pallets = new LinkedHashMap<>();
if (source != null) pallets.put(source.palletCode(), source);
pallets.put(target.palletCode(), target);
List<String> marks = request.cartonMarks().stream().map(String::trim).filter(value -> !value.isEmpty()).toList();
if (marks.size() != request.cartonMarks().size() || new HashSet<>(marks).size() != marks.size()) {
throw badRequest("箱唛不能为空且不能重复");
@@ -245,6 +252,13 @@ public class PdaPalletOperationService {
Map<Long, List<RelabelItem>> itemsByPosition = new LinkedHashMap<>();
Map<Long, InventoryPosition> anchors = new LinkedHashMap<>();
boolean returnRelabel = selected.stream().allMatch(item -> item.getReturnCartonId() != null);
boolean unboundReturnRelabel = returnRelabel && selected.stream().allMatch(item -> {
ReturnInventoryCarton carton = returnCartons.selectById(item.getReturnCartonId());
return carton != null && (!org.springframework.util.StringUtils.hasText(carton.getPalletCode())
|| "UNBOUND".equalsIgnoreCase(carton.getPalletCode()));
});
if (unboundReturnRelabel && source != null) throw conflict("无来源托盘的退货箱不能提交来源托盘");
if (!unboundReturnRelabel && source == null) throw conflict("请先确认来源托盘");
InventoryPosition actual = returnRelabel || request.actualSource() == null
? null : positions.selectById(request.actualSource().positionId());
if (!returnRelabel && request.actualSource() != null && (actual == null
@@ -269,7 +283,7 @@ public class PdaPalletOperationService {
}
}
if (returnRelabel) {
return bindReturnRelabelPallet(identity, application, selected, source, target, request);
return bindReturnRelabelPallet(identity, application, selected, source, target, request, unboundReturnRelabel);
}
for (RelabelItem item : selected) {
if (item.getSourceInventoryPositionId() == null || item.getNotifiedPackageCount() == null
@@ -313,7 +327,7 @@ public class PdaPalletOperationService {
private PalletOperationResponses.Operation bindReturnRelabelPallet(PdaWarehouseIdentity identity,
RelabelApplication application, List<RelabelItem> selected,
PalletPhysicalGateway.PalletReference source, PalletPhysicalGateway.PalletReference target,
PalletOperationRequests.RelabelDisassemble request) {
PalletOperationRequests.RelabelDisassemble request, boolean unboundSource) {
requireRelabelDestination(identity.overseasWarehouseId(), selected, target);
Set<String> alreadyBound = boundRelabelMarks(application);
boolean rebind = request.actualSource() == null && selected.stream().allMatch(item -> alreadyBound.contains(item.getCartonMark()));
@@ -324,14 +338,15 @@ public class PdaPalletOperationService {
for (RelabelItem item : selected) {
ReturnInventoryCarton carton = returnCartons.lockId(item.getReturnCartonId());
if (carton == null || !Objects.equals(carton.getCustomerId(), application.getCustomerId())
|| !Objects.equals(carton.getPalletCode(), source.palletCode())
|| (unboundSource ? (org.springframework.util.StringUtils.hasText(carton.getPalletCode()) && !"UNBOUND".equalsIgnoreCase(carton.getPalletCode()))
: !Objects.equals(carton.getPalletCode(), source.palletCode()))
|| !Objects.equals(carton.getRelabelApplicationId(), application.getId())
|| !Objects.equals(carton.getStatus(), "RELABEL_PENDING"))
throw conflict("退货换标来源托盘或库存状态已变化,请刷新后重试");
quantity += value(item.getNotifiedPackageCount());
cartons.add(carton);
}
if (!rebind && (request.actualSource() == null || request.actualSource().positionId() != cartons.get(0).getId()
if (!rebind && !unboundSource && (request.actualSource() == null || request.actualSource().positionId() != cartons.get(0).getId()
|| request.actualSource().packages() != quantity))
throw conflict("退货换标拆出数量必须等于本批已完成贴标数量");
String key = required(request.idempotencyKey(), "幂等键不能为空");
@@ -345,7 +360,8 @@ public class PdaPalletOperationService {
RelabelItem item = selected.get(index);
ReturnInventoryCarton carton = returnCartons.lockId(item.getReturnCartonId());
if (carton == null || !Objects.equals(carton.getCustomerId(), application.getCustomerId())
|| !Objects.equals(carton.getPalletCode(), source.palletCode())
|| (unboundSource ? (org.springframework.util.StringUtils.hasText(carton.getPalletCode()) && !"UNBOUND".equalsIgnoreCase(carton.getPalletCode()))
: !Objects.equals(carton.getPalletCode(), source.palletCode()))
|| !Objects.equals(carton.getRelabelApplicationId(), application.getId())
|| !"RELABEL_PENDING".equals(carton.getStatus()))
throw conflict("退货换标来源库存已变化,请刷新后重试");
@@ -363,9 +379,10 @@ public class PdaPalletOperationService {
}
}
recordReturnRelabelBinding(identity, application, selected, target.palletCode());
List<PalletOperationResponses.PalletContent> contents = List.of(
content(identity.overseasWarehouseId(), physical.resolve(source.palletCode())),
content(identity.overseasWarehouseId(), physical.resolve(target.palletCode())));
List<PalletOperationResponses.PalletContent> contents = source == null
? List.of(content(identity.overseasWarehouseId(), physical.resolve(target.palletCode())))
: List.of(content(identity.overseasWarehouseId(), physical.resolve(source.palletCode())),
content(identity.overseasWarehouseId(), physical.resolve(target.palletCode())));
PalletOperationResponses.Operation result = new PalletOperationResponses.Operation(operation.getId(),
operation.getOperationType(), key, "COMPLETED", false, LocalDateTime.now(), contents);
operation.setStatus("COMPLETED"); operation.setResultJson(write(result));
@@ -19,7 +19,8 @@ public final class RelabelResponses {
String shippingLabelFileId, String shippingLabelFileName, String shippingLabelFileUrl,
String fulfillmentStatus, String outboundOrderNo, LocalDateTime customerConfirmedAt,
String reviewChannel, String reviewRemark, LocalDateTime createdAt,
LocalDateTime reviewedAt, LocalDateTime relabelCompletedAt, LocalDateTime outboundAt) {}
LocalDateTime reviewedAt, LocalDateTime relabelCompletedAt, LocalDateTime outboundAt,
String productNameZh, String labelRequirement) {}
public record Milestones(LocalDateTime reviewedAt, LocalDateTime relabelCompletedAt,
LocalDateTime outboundAt) {}
public record Item(Long id, String skuCode, String productImageFileId, String productImageFileName,
@@ -1187,7 +1187,11 @@ public class RelabelApplicationService implements WalletResultHandler {
private RelabelResponses.Summary summary(RelabelApplication value, List<RelabelItem> detailItems) {
LastlegOrder order = orders.selectById(value.getLastlegOrderId());
RelabelResponses.Milestones milestones = applications.findMilestones(value.getCustomerId(), value.getId());
String skuCodes = detailItems.stream().map(RelabelItem::getSkuCode).filter(Objects::nonNull).distinct().collect(Collectors.joining(","));
String skuCodes = detailItems.stream().flatMap(item -> {
List<RelabelResponses.SkuItem> skuItems = readSkuItems(item.getSkuRelabelItemsJson());
return skuItems.isEmpty() ? java.util.Arrays.stream(Objects.requireNonNullElse(item.getSkuCode(), "").split(","))
: skuItems.stream().map(RelabelResponses.SkuItem::skuCode);
}).filter(StringUtils::hasText).map(String::trim).distinct().collect(Collectors.joining(","));
return new RelabelResponses.Summary(value.getId(), value.getRelabelNo(), value.getLastlegOrderId(),
order == null ? null : order.getOrderNo(), value.getCustomerOrderNo(),
value.getOverseasWarehouseId(),
@@ -1206,7 +1210,11 @@ public class RelabelApplicationService implements WalletResultHandler {
null,
value.getCreatedAt(), milestones == null ? null : milestones.reviewedAt(),
milestones == null ? null : milestones.relabelCompletedAt(),
milestones == null ? null : milestones.outboundAt());
milestones == null ? null : milestones.outboundAt(),
detailItems.stream().map(RelabelItem::getProductNameZh).filter(StringUtils::hasText)
.map(String::trim).distinct().collect(Collectors.joining("、")),
detailItems.stream().map(RelabelItem::getLabelRequirement).filter(StringUtils::hasText)
.map(String::trim).distinct().collect(Collectors.joining(";")));
}
@Override public boolean supports(String businessType) { return "RELABEL_ORDER".equals(businessType); }
@@ -27,13 +27,13 @@ public interface ReturnInventoryBalanceMapper extends BaseMapper<ReturnInventory
@Param("warehouseId") String warehouseId, @Param("quantity") long quantity);
String FREE_STOCK_COLUMNS = "b.id,b.customer_id,b.overseas_warehouse_id,b.sku_code,b.product_name_zh,b.total_quantity,b.carton_quantity,b.created_at,b.updated_at," +
"LEAST(b.available_quantity,COALESCE((SELECT SUM(GREATEST(0,i.quantity-COALESCE(i.packing_reserved_quantity,0))) " +
"FROM ll_return_inventory_carton c JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id AND i.customer_id=c.customer_id " +
"WHERE c.customer_id=b.customer_id AND c.overseas_warehouse_id=b.overseas_warehouse_id AND i.sku_code=b.sku_code " +
"FROM ll_return_inventory_carton c JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id AND i.customer_id COLLATE utf8mb4_0900_ai_ci=c.customer_id COLLATE utf8mb4_0900_ai_ci " +
"WHERE c.customer_id COLLATE utf8mb4_0900_ai_ci=b.customer_id COLLATE utf8mb4_0900_ai_ci AND c.overseas_warehouse_id COLLATE utf8mb4_0900_ai_ci=b.overseas_warehouse_id COLLATE utf8mb4_0900_ai_ci AND i.sku_code COLLATE utf8mb4_0900_ai_ci=b.sku_code COLLATE utf8mb4_0900_ai_ci " +
"AND c.status='AVAILABLE' AND c.count_status='CONFIRMED'),0)) AS available_quantity," +
"COALESCE((SELECT SUM(i.quantity) FROM ll_return_inventory_carton c " +
"JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id AND i.customer_id=c.customer_id " +
"WHERE c.customer_id=b.customer_id AND c.overseas_warehouse_id=b.overseas_warehouse_id " +
"AND i.sku_code=b.sku_code AND c.status='RELABEL_PENDING'),0) AS relabel_locked_quantity";
"JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id AND i.customer_id COLLATE utf8mb4_0900_ai_ci=c.customer_id COLLATE utf8mb4_0900_ai_ci " +
"WHERE c.customer_id COLLATE utf8mb4_0900_ai_ci=b.customer_id COLLATE utf8mb4_0900_ai_ci AND c.overseas_warehouse_id COLLATE utf8mb4_0900_ai_ci=b.overseas_warehouse_id COLLATE utf8mb4_0900_ai_ci " +
"AND i.sku_code COLLATE utf8mb4_0900_ai_ci=b.sku_code COLLATE utf8mb4_0900_ai_ci AND c.status='RELABEL_PENDING'),0) AS relabel_locked_quantity";
@Select("SELECT * FROM ll_return_inventory_balance WHERE customer_id=#{customerId} " +
"AND overseas_warehouse_id=#{warehouseId} AND sku_code=#{skuCode} FOR UPDATE")
ReturnInventoryBalance lockSku(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId,
@@ -72,8 +72,8 @@ public interface ReturnInventoryCartonMapper extends BaseMapper<ReturnInventoryC
"OR ((SELECT c.relabel_application_id FROM ll_return_inventory_carton c WHERE c.id=#{cartonId}) IS NULL " +
"AND NOT EXISTS(SELECT 1 FROM ll_relabel_item newer JOIN ll_relabel_application na ON na.id=newer.application_id " +
"WHERE newer.return_carton_id=ri.return_carton_id AND na.status NOT IN ('CANCELLED','REJECTED') " +
"AND (COALESCE(newer.source_carton_mark,newer.carton_mark)=COALESCE(ri.source_carton_mark,ri.carton_mark) " +
"OR COALESCE(newer.source_carton_mark,newer.carton_mark)=(SELECT c.carton_mark FROM ll_return_inventory_carton c WHERE c.id=#{cartonId})) " +
"AND (COALESCE(newer.source_carton_mark,newer.carton_mark) COLLATE utf8mb4_0900_ai_ci=COALESCE(ri.source_carton_mark,ri.carton_mark) COLLATE utf8mb4_0900_ai_ci " +
"OR COALESCE(newer.source_carton_mark,newer.carton_mark) COLLATE utf8mb4_0900_ai_ci=(SELECT c.carton_mark FROM ll_return_inventory_carton c WHERE c.id=#{cartonId}) COLLATE utf8mb4_0900_ai_ci) " +
"AND (na.created_at>ra.created_at OR (na.created_at=ra.created_at AND na.id>ra.id)))))";
String ALL_COMPLETION_DEDUCTION = "COALESCE((SELECT SUM(j.quantity) FROM ll_relabel_pda_history h " +
"JOIN JSON_TABLE(CASE WHEN h.action='RETURN_COMPLETION_TRANSFER' AND JSON_VALID(h.detail_json) THEN h.detail_json ELSE '{}' END,'$.items[*]' COLUMNS(sku_code VARCHAR(128) PATH '$.skuCode',quantity BIGINT PATH '$.quantity')) j " +
@@ -3064,8 +3064,11 @@ components:
labelDeadline: { type: string, format: date-time }
status: { $ref: '#/components/schemas/RelabelStatus' }
itemCount: { type: integer }
skuCodes: { type: string, description: 汇总全部箱内 skuItems 的旧 SKU 并去重,以逗号分隔;无 SKU 换标子明细时汇总主明细 SKU }
totalPackageCount: { type: integer }
notifiedPackageCount: { type: integer }
productNameZh: { type: string, description: 明细非空中文品名按原顺序去重并以顿号连接 }
labelRequirement: { type: string, description: 明细非空贴标要求按原顺序去重并以中文分号连接 }
deliveryMethod: { type: string, nullable: true, enum: [TRUCK, PICKUP, EXPRESS] }
printShippingLabel: { type: boolean }
shippingLabelFileId: { type: string, nullable: true }
@@ -74,11 +74,20 @@ class RelabelApplicationServiceTest {
var outbound = reviewed.plusHours(2);
when(applications.findMilestones("10001", 42L))
.thenReturn(new RelabelResponses.Milestones(reviewed, completed, outbound));
RelabelResponses.Summary result = ReflectionTestUtils.invokeMethod(service, "summary", application, List.of());
var first = new RelabelItem(); first.setProductNameZh(" 商品甲 "); first.setLabelRequirement(" 贴标要求111 ");
first.setSkuCode("SKU-FIRST");
first.setSkuRelabelItemsJson("[{\"skuCode\":\"SKU-FIRST\",\"newSkuCode\":\"NEW-FIRST\",\"quantity\":10},{\"skuCode\":\"SKU-SECOND\",\"newSkuCode\":\"NEW-SECOND\",\"quantity\":5}]");
var second = new RelabelItem(); second.setProductNameZh("商品乙"); second.setLabelRequirement("贴标要求111");
var empty = new RelabelItem(); empty.setProductNameZh(" ");
RelabelResponses.Summary result = ReflectionTestUtils.invokeMethod(service, "summary", application, List.of(first, second, empty));
assertNotNull(result);
assertEquals(reviewed, result.reviewedAt());
assertEquals(completed, result.relabelCompletedAt());
assertEquals(outbound, result.outboundAt());
assertEquals("商品甲、商品乙", result.productNameZh());
assertEquals("贴标要求111", result.labelRequirement());
assertEquals("SKU-FIRST,SKU-SECOND", result.skuCodes());
assertEquals(3, result.itemCount());
}
@Test void returnInventoryCandidateUsesRealProductNames() {