贴标申请,仓储转运

This commit is contained in:
2026-10-12 21:18:29 +08:00
parent 8526697b08
commit fb98b1a8ee
30 changed files with 914 additions and 48 deletions
@@ -2464,6 +2464,7 @@ CREATE TABLE ll_return_inventory_carton_item (
sku_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码',
product_name_zh varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中文品名',
quantity bigint NOT NULL COMMENT '该SKU箱内数量',
relabelled_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '当前SKU已经换标的实物件数,不可二次换标',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_return_inventory_carton_item (carton_id,sku_code),
KEY idx_ll_return_inventory_carton_item_customer (customer_id,sku_code),
@@ -4254,3 +4255,30 @@ CREATE TABLE IF NOT EXISTS ll_appointment_impact_history (
CONSTRAINT fk_appointment_impact_history FOREIGN KEY (container_booking_id) REFERENCES ll_appointment_impact(container_booking_id),
CONSTRAINT chk_appointment_impact_action CHECK (action IN ('OBSERVED','CLAIM','RECORD','RESOLVE'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='船期观察及人工处理追加历史';
-- SKU 换标实物转换:先执行本增量,再部署服务。不会自动改写存量业务事实。
CREATE TABLE ll_return_sku_conversion (
id BIGINT NOT NULL COMMENT 'SKU库存转换记录ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '操作海外仓ID',
application_id BIGINT NOT NULL COMMENT '换标申请ID',
relabel_no VARCHAR(64) NOT NULL COMMENT '换标单号',
item_id BIGINT NOT NULL COMMENT '换标箱级明细ID',
round_key VARCHAR(64) NOT NULL COMMENT '作业轮次,退回重做使用新轮次',
carton_id BIGINT NOT NULL COMMENT '实物来源包裹或箱ID',
carton_mark VARCHAR(128) NOT NULL COMMENT '完工时仓库识别码',
old_sku_code VARCHAR(128) NOT NULL COMMENT '换标前SKU',
new_sku_code VARCHAR(128) NOT NULL COMMENT '换标后SKU',
product_name_zh VARCHAR(255) NULL COMMENT '中文品名快照',
quantity BIGINT NOT NULL COMMENT '实际完成转换件数',
status VARCHAR(32) NOT NULL COMMENT 'APPLIED待客户确认/CONFIRMED已确认/REVERSED退回冲回',
ownership_json LONGTEXT NOT NULL COMMENT '本次转换件数的来源退货单快照,供精确冲回',
completed_at DATETIME(3) NOT NULL COMMENT '实物SKU转换时间',
confirmed_at DATETIME(3) NULL COMMENT '客户确认时间',
reversed_at DATETIME(3) NULL COMMENT '退回冲回时间',
PRIMARY KEY(id),
UNIQUE KEY uk_return_sku_conversion_round(item_id,round_key,old_sku_code,new_sku_code),
KEY idx_return_sku_conversion_app(customer_id,application_id,status),
KEY idx_return_sku_conversion_carton(customer_id,carton_id),
CONSTRAINT chk_return_sku_conversion_quantity CHECK(quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='退货实物SKU换标库存转换和冲回记录';
@@ -18,7 +18,10 @@ public final class RelabelResponses {
String walletErrorMessage, String deliveryMethod, boolean printShippingLabel,
String shippingLabelFileId, String shippingLabelFileName, String shippingLabelFileUrl,
String fulfillmentStatus, String outboundOrderNo, LocalDateTime customerConfirmedAt,
String reviewChannel, String reviewRemark, LocalDateTime createdAt) {}
String reviewChannel, String reviewRemark, LocalDateTime createdAt,
LocalDateTime reviewedAt, LocalDateTime relabelCompletedAt, LocalDateTime outboundAt) {}
public record Milestones(LocalDateTime reviewedAt, LocalDateTime relabelCompletedAt,
LocalDateTime outboundAt) {}
public record Item(Long id, String skuCode, String productImageFileId, String productImageFileName,
String productImageUrl, String productNameZh, int totalPackageCount,
int notifiedPackageCount, String shipmentNo, String trackingCode,
@@ -39,7 +42,8 @@ public final class RelabelResponses {
long availablePackages, long version, List<InventoryPosition> positions,
String sourceType, Long returnCartonId, String cartonMark,
List<SkuItem> skuItems, long relabelledPackages, String relabelStatus,
long relabelledQuantity, long relabelReservedQuantity, Long stockQuantity, String inventoryStatus, String occupiedRelabelNo, List<String> mergedIntoCartonMarks, boolean hasOutbound) {
long relabelledQuantity, long relabelReservedQuantity, Long stockQuantity, String inventoryStatus, String occupiedRelabelNo, List<String> mergedIntoCartonMarks, boolean hasOutbound,
List<com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses.SkuConversion> skuConversions) {
public InventoryCandidate(Long inventoryBalanceId, Long inboundOrderId, String inboundOrderNo, String inboundCode,
String overseasWarehouseId, String overseasWarehouseCode, String overseasWarehouseName,
String destinationFbaWarehouseCode, String destinationFbaWarehouseName, String skuCode, String productNameZh,
@@ -48,7 +52,7 @@ public final class RelabelResponses {
this(inventoryBalanceId, inboundOrderId, inboundOrderNo, inboundCode, overseasWarehouseId,
overseasWarehouseCode, overseasWarehouseName, destinationFbaWarehouseCode, destinationFbaWarehouseName,
skuCode, productNameZh, availablePackages, version, positions, sourceType, returnCartonId, cartonMark,
skuItems, relabelledPackages, null, relabelledPackages, 0, null, null, null, List.of(), false);
skuItems, relabelledPackages, null, relabelledPackages, 0, null, null, null, List.of(), false, List.of());
}
}
public record HistoricalOrder(Long id, String orderNo, String sourceOrderNo, String outboundOrderNo,
@@ -1,6 +1,7 @@
package com.baiyue.lastleg.relabel.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.relabel.dto.RelabelResponses;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@@ -9,6 +10,34 @@ import org.apache.ibatis.annotations.Update;
@Mapper
public interface RelabelApplicationMapper extends BaseMapper<RelabelApplication> {
// Read existing facts so historical applications receive the same timestamps as new ones.
// COMPLETED alone is insufficient: SKU-only work can complete without leaving the warehouse.
@Select("""
SELECT
(SELECT MAX(h.created_at) FROM ll_relabel_status_history h
WHERE h.customer_id=a.customer_id AND h.application_id=a.id
AND h.from_status='PENDING_REVIEW' AND h.to_status IN ('PENDING_PROCESSING','REJECTED')) AS reviewed_at,
CASE WHEN a.status IN ('CUSTOMER_CONFIRM_PENDING','READY_FOR_OUTBOUND','COMPLETED') THEN
COALESCE((SELECT MAX(t.submitted_at) FROM ll_relabel_pda_task t
WHERE t.customer_id=a.customer_id AND t.application_id=a.id),
(SELECT MAX(h.created_at) FROM ll_relabel_status_history h
WHERE h.customer_id=a.customer_id AND h.application_id=a.id
AND h.to_status='CUSTOMER_CONFIRM_PENDING'))
END AS relabel_completed_at,
COALESCE((SELECT MAX(t.completed_at) FROM ll_outbound_pda_task t
JOIN ll_outbound_order o ON o.id=t.outbound_order_id AND o.customer_id=t.customer_id
WHERE o.customer_id=a.customer_id AND o.outbound_no=a.outbound_order_no
AND o.status='OUTBOUNDED' AND t.status='COMPLETED'),
(SELECT MAX(w.outbound_at) FROM ll_warehouse_transfer_order w
WHERE w.customer_id=a.customer_id AND w.relabel_application_id=a.id AND w.relabel_no=a.relabel_no),
(SELECT MAX(h.created_at) FROM ll_relabel_status_history h
WHERE h.customer_id=a.customer_id AND h.application_id=a.id
AND h.from_status='READY_FOR_OUTBOUND' AND h.to_status='COMPLETED')) AS outbound_at
FROM ll_relabel_application a WHERE a.customer_id=#{customerId} AND a.id=#{applicationId}
""")
RelabelResponses.Milestones findMilestones(
@Param("customerId") String customerId, @Param("applicationId") Long applicationId);
@Select("SELECT * FROM ll_relabel_application WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} LIMIT 1")
RelabelApplication findNo(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo);
@@ -52,6 +52,7 @@ import java.util.stream.Collectors;
@Service
public class PdaRelabelService {
@Resource private com.baiyue.lastleg.relabel.service.ReturnSkuConversionService skuConversions;
@Resource private com.baiyue.lastleg.relabel.service.ReturnRelabelPackingService returnPacking;
@Transactional
@@ -500,6 +501,9 @@ public class PdaRelabelService {
StringUtils.hasText(order.getWarehouseRemark()) ? order.getWarehouseRemark() + "\n" + note : note));
}
}
skuConversions.apply(application, detailItems, factList(task), mixedRound(task), histories.selectList(new LambdaQueryWrapper<PdaRelabelHistory>()
.eq(PdaRelabelHistory::getTaskId, task.getId()).eq(PdaRelabelHistory::getCustomerId, application.getCustomerId())
.eq(PdaRelabelHistory::getAction, "SKU_SCAN")));
String before = task.getStatus();
task.setStatus(WAIT_REVIEW); task.setCompletionPhotoFileIds(write(photoIds));
task.setCompletionRemark(trim(request.remark())); task.setSubmittedAt(LocalDateTime.now());
@@ -549,6 +553,7 @@ public class PdaRelabelService {
if (!WAIT_REVIEW.equals(task.getStatus())) throw conflict("Relabel task is not awaiting review");
requireVersion(task, request.expectedVersion());
// Preserve the previous round before freeing the active-label uniqueness constraint.
skuConversions.reverse(application);
List<PdaRelabelUnitFact> previousFacts = factList(task);
insertHistory(task, "REWORK_ARCHIVE", WAIT_REVIEW, AVAILABLE, operatorKey, operatorName,
Map.of("facts", previousFacts, "photoFileIds", Objects.toString(task.getCompletionPhotoFileIds(), "[]"),
@@ -93,6 +93,8 @@ public class RelabelApplicationService implements WalletResultHandler {
@Resource private InventoryPositionMapper inventoryPositions;
@Resource private ReturnInventoryCartonMapper returnCartons;
@Resource private ReturnInventoryCartonItemMapper returnCartonItems;
@Resource private com.baiyue.lastleg.relabel.service.ReturnSkuConversionService skuConversions;
@Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnSkuConversionMapper skuConversionRows;
@Resource private ReturnOrderMapper returnOrders;
@Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper returnPackingTraces;
@Resource private RelabelDocumentFileGateway documentFiles;
@@ -278,15 +280,17 @@ public class RelabelApplicationService implements WalletResultHandler {
.filter(item -> value(item.getQuantity()) > 0)
.map(item -> {
long remaining = "AVAILABLE".equals(carton.getStatus())
? value(item.getQuantity()) - value(item.getPackingReservedQuantity()) : 0;
? remainingRelabelQuantity(carton.getId(), item) : 0;
return new RelabelResponses.SkuItem(item.getSkuCode(), null,
Math.toIntExact(Math.max(remaining, 0)), null);
}).toList();
}).filter(item -> !"AVAILABLE".equals(carton.getStatus()) || item.quantity() > 0).toList();
if (cartonSkus.isEmpty() && !java.util.Set.of("MERGED", "OUTBOUNDED").contains(carton.getStatus())) continue;
String skuCodes = cartonSkus.stream().map(RelabelResponses.SkuItem::skuCode)
.collect(Collectors.joining(","));
long availablePackages = cartonSkus.stream().mapToLong(RelabelResponses.SkuItem::quantity).sum();
long relabelledPackages = returnCartons.totalRelabelledQuantity(carton.getId());
long relabelledPackages = Math.max(returnCartons.totalRelabelledQuantity(carton.getId()),
cartonLines.stream().mapToLong(line -> value(line.getRelabelledQuantity())
+ returnCartons.completedSkuQuantity(carton.getId(), line.getSkuCode())).sum());
long reserved = returnCartons.relabelReservedQuantity(carton.getId());
result.add(new RelabelResponses.InventoryCandidate(null, null, carton.getSourceReturnNo(),
null, carton.getOverseasWarehouseId(), null, null, carton.getDestinationWarehouseCode(),
@@ -295,7 +299,9 @@ public class RelabelApplicationService implements WalletResultHandler {
com.baiyue.lastleg.returnreceipt.service.ReturnRelabelProgress.status(value(carton.getTotalQuantity()), relabelledPackages, reserved), relabelledPackages, reserved, java.util.Set.of("MERGED", "OUTBOUNDED").contains(carton.getStatus()) ? 0L : value(carton.getTotalQuantity()), carton.getStatus(), carton.getRelabelNo(),
returnPackingTraces.listCartonHistory(customerId, carton.getCartonMark()).stream()
.filter(history -> carton.getCartonMark().equals(history.sourceCartonMark()))
.map(history -> history.targetCartonMark()).distinct().toList(), returnCartons.hasOutbound(carton.getId())));
.map(history -> history.targetCartonMark()).distinct().toList(), returnCartons.hasOutbound(carton.getId()),
skuConversionRows.listCarton(customerId, carton.getId()).stream()
.map(com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses.SkuConversion::from).toList()));
}
return result;
}
@@ -408,14 +414,18 @@ public class RelabelApplicationService implements WalletResultHandler {
final long sourceReturnCartonId = carton.getId();
Map<String, Long> availableSku = returnCartonItems.lockCarton(sourceReturnCartonId).stream()
.collect(Collectors.toMap(line -> line.getSkuCode().toUpperCase(java.util.Locale.ROOT),
line -> Math.max(0, value(line.getQuantity()) - value(line.getPackingReservedQuantity())), Long::sum));
line -> remainingRelabelQuantity(sourceReturnCartonId, line), Long::sum));
if (availableSku.values().stream().noneMatch(quantity -> quantity > 0))
throw conflict("来源箱 SKU 已全部换标,没有剩余可换标数量");
Map<String, Long> requestedSku = selectedReturnSkus.computeIfAbsent(carton.getId(), ignored -> new java.util.HashMap<>());
if (item.skuItems() != null) for (var skuItem : item.skuItems()) {
long quantity = requestedSku.merge(skuItem.skuCode().toUpperCase(java.util.Locale.ROOT), (long) skuItem.quantity(), Long::sum);
if (quantity > availableSku.getOrDefault(skuItem.skuCode().toUpperCase(java.util.Locale.ROOT), 0L))
throw conflict("SKU " + skuItem.skuCode() + " 的换标数量超过来源箱可用数量");
}
if ("CARTON_AND_SKU".equals(operationType(item)) && !requestedSku.equals(availableSku))
if ("CARTON_AND_SKU".equals(operationType(item)) && (!requestedSku.equals(availableSku)
|| returnCartonItems.lockCarton(sourceReturnCartonId).stream()
.anyMatch(line -> remainingRelabelQuantity(sourceReturnCartonId, line) < value(line.getQuantity()))))
throw conflict("包裹/箱混贴必须包含实物全部 SKU 和件数;部分数量请先隔离为独立新箱");
if (returnCartonItems.lockCarton(carton.getId()).stream().anyMatch(line -> value(line.getPackingReservedQuantity()) > 0))
throw conflict("退货箱SKU已被合箱计划锁定");
@@ -906,6 +916,7 @@ public class RelabelApplicationService implements WalletResultHandler {
throw conflict("Upload the shipping label before confirming relabel completion");
}
List<RelabelItem> relabelItems = itemList(application.getId());
skuConversions.confirm(application);
WarehouseTransferOrder linkedTransfer = warehouseTransfers == null ? null
: warehouseTransfers.lockByRelabelNo(customerId, relabelNo);
boolean existingStockTransfer = linkedTransfer != null
@@ -1175,6 +1186,7 @@ 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(","));
return new RelabelResponses.Summary(value.getId(), value.getRelabelNo(), value.getLastlegOrderId(),
order == null ? null : order.getOrderNo(), value.getCustomerOrderNo(),
@@ -1192,7 +1204,9 @@ public class RelabelApplicationService implements WalletResultHandler {
value.getFulfillmentStatus(), value.getOutboundOrderNo(), value.getCustomerConfirmedAt(),
order != null && "RELABEL_APPLICATION".equals(order.getOrderType()) ? "TAIL_ORDER" : "RELABEL_ORDER",
null,
value.getCreatedAt());
value.getCreatedAt(), milestones == null ? null : milestones.reviewedAt(),
milestones == null ? null : milestones.relabelCompletedAt(),
milestones == null ? null : milestones.outboundAt());
}
@Override public boolean supports(String businessType) { return "RELABEL_ORDER".equals(businessType); }
@@ -1290,6 +1304,11 @@ public class RelabelApplicationService implements WalletResultHandler {
private record SourceInventory(RelabelRequests.Item request, InventoryBalance balance,
ReturnInventoryCarton returnCarton) {}
private long remainingRelabelQuantity(long cartonId, ReturnInventoryCartonItem line) {
return Math.max(0, value(line.getQuantity()) - value(line.getPackingReservedQuantity())
- value(line.getRelabelledQuantity())
- returnCartons.completedSkuQuantity(cartonId, line.getSkuCode()));
}
private static long value(Long value) { return value == null ? 0L : value; }
private static int intValue(Integer value) { return value == null ? 0 : value; }
private static boolean containsIgnoreCase(String value, String fragment) {
@@ -1364,6 +1383,9 @@ public class RelabelApplicationService implements WalletResultHandler {
&& (item.skuItems() == null || item.skuItems().isEmpty())) {
throw badRequest("SKU label changes require at least one SKU detail");
}
if (item.returnCartonId() != null && item.skuItems() != null && item.skuItems().stream()
.anyMatch(sku -> sku.skuCode().trim().equalsIgnoreCase(sku.newSkuCode().trim())))
throw badRequest("退货 SKU 转换的新旧编码必须不同");
}
private static String operationType(RelabelRequests.Item item) {
return StringUtils.hasText(item.operationType()) ? item.operationType().trim().toUpperCase() : "CARTON_LABEL";
@@ -0,0 +1,216 @@
package com.baiyue.lastleg.relabel.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory;
import com.baiyue.lastleg.returnreceipt.entity.*;
import com.baiyue.lastleg.returnreceipt.mapper.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.*;
/** Converts physical contents and SKU balances together; the carton itself never gains or loses pieces. */
@Service
public class ReturnSkuConversionService {
@Resource private ReturnSkuConversionMapper conversions;
@Resource private ReturnInventoryCartonMapper cartons;
@Resource private ReturnInventoryCartonItemMapper contents;
@Resource private ReturnInventoryBalanceMapper balances;
@Resource private ReturnPackingTraceMapper traces;
@Resource private ReturnInventoryOperationMapper operations;
@Resource private ObjectMapper json;
@Transactional
public void apply(RelabelApplication app, List<RelabelItem> items, List<PdaRelabelUnitFact> facts, String round) {
apply(app, items, facts, round, List.of());
}
@Transactional
public void apply(RelabelApplication app, List<RelabelItem> items, List<PdaRelabelUnitFact> facts, String round,
List<PdaRelabelHistory> scans) {
var existing = new ArrayList<>(conversions.lockApplication(app.getCustomerId(), app.getId()));
for (var item : items.stream().sorted(Comparator.comparing(RelabelItem::getReturnCartonId,
Comparator.nullsLast(Comparator.naturalOrder())).thenComparing(RelabelItem::getId)).toList()) {
if (item.getReturnCartonId() == null || !Set.of("SKU_LABEL", "RETURN_BULK_SKU_LABEL", "CARTON_AND_SKU").contains(item.getOperationType())) continue;
long completed = facts.stream().filter(f -> item.getId().equals(f.getItemId()) && "RELABELLED".equals(f.getResult()))
.mapToLong(f -> f.getQuantity() == null ? 1 : f.getQuantity()).sum();
if (completed == 0 && scans.isEmpty()) continue;
var carton = ownedCarton(app, item.getReturnCartonId());
try {
if ("RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) {
// 按件贴标未声明 SKU 编码变更时只保留作业事实,不生成不存在的新 SKU。
if (completed == 0 || item.getNewSkuCode() == null || item.getNewSkuCode().isBlank()) continue;
convert(app, item, carton, item.getSkuCode(), item.getNewSkuCode(), completed, round, existing);
} else {
var approved = json.readTree(item.getSkuRelabelItemsJson());
var doneIndices = new HashSet<Integer>();
if (completed == 0) for (var scan : scans) {
if (!"SKU_SCAN".equals(scan.getAction())) continue;
var detail = json.readTree(scan.getDetailJson());
if (!round.equals(detail.path("round").asText()) || !item.getId().toString().equals(detail.path("itemId").asText())) continue;
int index = detail.path("skuIndex").asInt(-1);
if (index < 0 || index >= approved.size()) throw conflict("SKU 扫描明细索引无效");
var sku = approved.get(index);
if (!sku.path("skuCode").asText().equals(detail.path("oldSku").asText())
|| !sku.path("newSkuCode").asText().equals(detail.path("newSku").asText())
|| sku.path("quantity").asLong() != detail.path("quantity").asLong()) throw conflict("SKU 扫描记录与批准明细不一致");
doneIndices.add(index);
}
for (int index = 0; index < approved.size(); index++) {
if (completed == 0 && !doneIndices.contains(index)) continue;
var sku = approved.get(index);
convert(app, item, carton, sku.path("skuCode").asText(), sku.path("newSkuCode").asText(),
Math.multiplyExact(completed == 0 ? 1 : completed, sku.path("quantity").asLong()), round, existing);
}
}
} catch (java.io.IOException failure) { throw conflict("换标 SKU 明细无法读取"); }
refreshTypes(carton);
}
}
private void convert(RelabelApplication app, RelabelItem item, ReturnInventoryCarton carton,
String oldSku, String newSku, long quantity, String round, List<ReturnSkuConversion> existing) {
oldSku = code(oldSku); newSku = code(newSku);
if (quantity <= 0 || oldSku.equalsIgnoreCase(newSku)) throw conflict("新旧 SKU 必须不同且换标数量必须大于零");
for (var row : existing) if (item.getId().equals(row.getItemId()) && round.equals(row.getRoundKey())
&& oldSku.equalsIgnoreCase(row.getOldSkuCode()) && newSku.equalsIgnoreCase(row.getNewSkuCode())) {
if (row.getQuantity() != quantity || "REVERSED".equals(row.getStatus())) throw conflict("本轮换标库存转换已变化");
return;
}
var source = contents.lockSku(carton.getId(), oldSku);
if (source == null || n(source.getQuantity()) - n(source.getPackingReservedQuantity()) - n(source.getRelabelledQuantity()) < quantity)
throw conflict("旧 SKU 的未换标库存不足:" + oldSku);
var target = contents.lockSku(carton.getId(), newSku);
boolean removed = source.getQuantity() == quantity, added = target == null;
var ownership = ownership(carton, oldSku, source.getQuantity());
String product = source.getProductNameZh();
source.setQuantity(source.getQuantity() - quantity);
if (removed) contents.deleteById(source.getId()); else contents.updateById(source);
if (target == null) {
target = new ReturnInventoryCartonItem(); target.setId(IdWorker.getId()); target.setCartonId(carton.getId());
target.setCustomerId(app.getCustomerId()); target.setSkuCode(newSku); target.setProductNameZh(product);
target.setQuantity(quantity); target.setPackingReservedQuantity(0L); target.setRelabelledQuantity(quantity);
contents.insert(target);
} else {
target.setQuantity(Math.addExact(n(target.getQuantity()), quantity));
target.setRelabelledQuantity(Math.addExact(n(target.getRelabelledQuantity()), quantity)); contents.updateById(target);
}
adjustBalances(carton, oldSku, newSku, product, quantity, removed, added);
var row = new ReturnSkuConversion(); row.setId(IdWorker.getId()); row.setCustomerId(app.getCustomerId());
row.setOverseasWarehouseId(carton.getOverseasWarehouseId()); row.setApplicationId(app.getId()); row.setRelabelNo(app.getRelabelNo());
row.setItemId(item.getId()); row.setRoundKey(round); row.setCartonId(carton.getId()); row.setCartonMark(carton.getCartonMark());
row.setOldSkuCode(oldSku); row.setNewSkuCode(newSku); row.setProductNameZh(product); row.setQuantity(quantity);
row.setStatus("APPLIED"); row.setCompletedAt(LocalDateTime.now());
var movedOwners = new ArrayList<ReturnPackingTraceMapper.Ownership>();
long remaining = quantity;
for (var owner : ownership) {
long moved = Math.min(remaining, owner.quantity());
if (moved > 0) movedOwners.add(new ReturnPackingTraceMapper.Ownership(owner.returnNo(), moved));
remaining -= moved;
}
if (remaining != 0) throw conflict("SKU 换标来源追溯数量不足");
try { row.setOwnershipJson(json.writeValueAsString(movedOwners)); }
catch (java.io.IOException failure) { throw new IllegalStateException("Cannot save SKU conversion ownership", failure); }
conversions.insert(row); existing.add(row); traceConversion(row, movedOwners, false);
}
@Transactional
public void confirm(RelabelApplication app) {
for (var row : conversions.lockApplication(app.getCustomerId(), app.getId())) if ("APPLIED".equals(row.getStatus())) {
row.setStatus("CONFIRMED"); row.setConfirmedAt(LocalDateTime.now()); conversions.updateById(row);
}
}
@Transactional
public void reverse(RelabelApplication app) {
var rows = new ArrayList<>(conversions.lockApplication(app.getCustomerId(), app.getId())); Collections.reverse(rows);
for (var row : rows) {
if ("CONFIRMED".equals(row.getStatus())) throw conflict("已确认的 SKU 库存转换不能退回");
if (!"APPLIED".equals(row.getStatus())) continue;
var carton = ownedCarton(app, row.getCartonId());
var source = contents.lockSku(carton.getId(), row.getNewSkuCode());
long quantity = row.getQuantity();
if (source == null || n(source.getQuantity()) < quantity || n(source.getRelabelledQuantity()) < quantity
|| n(source.getPackingReservedQuantity()) > 0) throw conflict("已换标 SKU 库存已变化,不能退回");
List<ReturnPackingTraceMapper.Ownership> ownership;
try { ownership = json.readValue(row.getOwnershipJson(), new com.fasterxml.jackson.core.type.TypeReference<>() {}); }
catch (java.io.IOException failure) { throw conflict("换标来源追溯记录无法读取"); }
var target = contents.lockSku(carton.getId(), row.getOldSkuCode());
boolean removed = source.getQuantity() == quantity, added = target == null;
source.setQuantity(source.getQuantity() - quantity); source.setRelabelledQuantity(source.getRelabelledQuantity() - quantity);
if (removed) contents.deleteById(source.getId()); else contents.updateById(source);
if (target == null) {
target = new ReturnInventoryCartonItem(); target.setId(IdWorker.getId()); target.setCartonId(carton.getId());
target.setCustomerId(app.getCustomerId()); target.setSkuCode(row.getOldSkuCode()); target.setProductNameZh(row.getProductNameZh());
target.setQuantity(quantity); target.setPackingReservedQuantity(0L); target.setRelabelledQuantity(0L); contents.insert(target);
} else { target.setQuantity(Math.addExact(n(target.getQuantity()), quantity)); contents.updateById(target); }
adjustBalances(carton, row.getNewSkuCode(), row.getOldSkuCode(), row.getProductNameZh(), quantity, removed, added);
traceConversion(row, ownership, true);
row.setStatus("REVERSED"); row.setReversedAt(LocalDateTime.now()); conversions.updateById(row); refreshTypes(carton);
}
}
private void adjustBalances(ReturnInventoryCarton carton, String oldSku, String newSku, String product,
long quantity, boolean removed, boolean added) {
boolean box = !"PARCEL".equals(carton.getSourceUnitType());
if (balances.deductConversion(carton.getCustomerId(), carton.getOverseasWarehouseId(), oldSku, quantity, box && removed ? 1 : 0) != 1)
throw conflict("退货 SKU 余额不足:" + oldSku);
if (balances.adjust(IdWorker.getId(), carton.getCustomerId(), carton.getOverseasWarehouseId(), newSku,
product, quantity, quantity, box && added ? 1 : 0) <= 0) throw conflict("新 SKU 库存入账失败");
}
private ReturnInventoryCarton ownedCarton(RelabelApplication app, long id) {
var carton = cartons.lockId(id);
if (carton == null || !app.getCustomerId().equals(carton.getCustomerId())
|| !app.getOverseasWarehouseId().equals(carton.getOverseasWarehouseId())
|| !app.getId().equals(carton.getRelabelApplicationId()) || !"RELABEL_PENDING".equals(carton.getStatus()))
throw conflict("换标来源箱或包裹已变化");
return carton;
}
private void refreshTypes(ReturnInventoryCarton carton) {
var lines = contents.lockCarton(carton.getId());
if (lines.stream().mapToLong(i -> n(i.getQuantity())).sum() != n(carton.getTotalQuantity())) throw conflict("SKU 转换后箱内件数不守恒");
carton.setSkuTypes(lines.size()); carton.setUpdatedAt(LocalDateTime.now()); cartons.updateById(carton);
}
private List<ReturnPackingTraceMapper.Ownership> ownership(ReturnInventoryCarton carton, String sku, long quantity) {
var result = new ArrayList<>(traces.ownership(carton.getCustomerId(), carton.getCartonMark(), sku));
long traced = result.stream().mapToLong(ReturnPackingTraceMapper.Ownership::quantity).sum();
if (traced < quantity) {
String[] sources = code(carton.getSourceReturnNo()).split(",");
if (sources.length != 1) throw conflict("混箱 SKU 缺少来源追溯,不能转换库存");
result.add(new ReturnPackingTraceMapper.Ownership(sources[0], quantity - traced));
}
return result;
}
private void traceConversion(ReturnSkuConversion row, List<ReturnPackingTraceMapper.Ownership> ownership, boolean reverse) {
var op = new ReturnInventoryOperation(); op.setId(IdWorker.getId()); op.setOperatorId("SKU_CONVERSION:" + row.getCustomerId());
op.setIdempotencyKey(row.getId() + (reverse ? ":REVERSE" : ":APPLY")); op.setRequestHash(op.getIdempotencyKey());
op.setOperationType(reverse ? "SKU_CONVERSION_REVERSE" : "SKU_CONVERSION"); op.setResultCartonId(row.getCartonId());
op.setResultJson("{}"); op.setCreatedAt(LocalDateTime.now()); op.setUpdatedAt(op.getCreatedAt()); operations.insert(op);
long remaining = row.getQuantity();
for (var owner : ownership) {
long moved = Math.min(remaining, owner.quantity()); if (moved == 0) continue;
traces.insert(IdWorker.getId(), row.getCustomerId(), op.getId(), row.getCartonMark(), "", owner.returnNo(),
reverse ? row.getNewSkuCode() : row.getOldSkuCode(), moved, op.getCreatedAt());
traces.insert(IdWorker.getId(), row.getCustomerId(), op.getId(), "", row.getCartonMark(), owner.returnNo(),
reverse ? row.getOldSkuCode() : row.getNewSkuCode(), moved, op.getCreatedAt());
remaining -= moved;
}
if (remaining != 0) throw conflict("SKU 换标来源追溯数量不足");
}
private static long n(Long value) { return value == null ? 0 : value; }
private static String code(String value) {
if (value == null || value.isBlank()) throw conflict("SKU 或来源单号缺失"); return value.trim();
}
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -17,18 +17,30 @@ public final class ReturnInventoryResponses {
public record InventoryItem(String overseasWarehouseId, String cartonMark, String sourceBatchNo, String status, String skuCode,
String productNameZh, long quantity, int cartonSkuTypes, long cartonTotalQuantity,
LocalDateTime receivedAt) { }
LocalDateTime receivedAt, long relabelledQuantity, long remainingRelabelQuantity) { }
public record SummaryItem(String overseasWarehouseId, String skuCode, String productNameZh,
long totalQuantity, long availableQuantity, long cartonQuantity, LocalDateTime updatedAt) { }
long totalQuantity, long availableQuantity, long cartonQuantity, LocalDateTime updatedAt,
long relabelLockedQuantity) { }
public record CartonDetail(String cartonMark, String customerId, String overseasWarehouseId, String sourceBatchNo, String sourceReturnNo, String status,
String fbaCartonMark, String destinationWarehouseCode, String destinationWarehouseName, String relabelNo, String relabelStatus,
int skuTypes, long totalQuantity, BigDecimal length, BigDecimal width, BigDecimal height,
BigDecimal weight, LocalDateTime receivedAt, List<CartonItem> items,
String locationCode, String palletCode, LocalDateTime putawayAt, List<MergeHistory> mergeHistory,
long relabelledQuantity, long relabelReservedQuantity) { }
long relabelledQuantity, long relabelReservedQuantity, List<SkuConversion> skuConversions) { }
public record SkuConversion(String relabelNo, String cartonMark, String oldSkuCode, String newSkuCode,
long quantity, String status, LocalDateTime completedAt) {
public static SkuConversion from(com.baiyue.lastleg.returnreceipt.entity.ReturnSkuConversion row) {
return new SkuConversion(row.getRelabelNo(), row.getCartonMark(), row.getOldSkuCode(), row.getNewSkuCode(),
row.getQuantity(), row.getStatus(), row.getCompletedAt());
}
}
public record MergeHistory(String planNo, String sourceCartonMark, String targetCartonMark, String returnNo,
String skuCode, long quantity, LocalDateTime createdAt) { }
public record CartonItem(String skuCode, String productNameZh, long quantity, long availableQuantity) {
public record CartonItem(String skuCode, String productNameZh, long quantity, long availableQuantity,
long relabelledQuantity, long remainingRelabelQuantity) {
public CartonItem(String skuCode, String productNameZh, long quantity, long availableQuantity) {
this(skuCode, productNameZh, quantity, availableQuantity, 0, availableQuantity);
}
public CartonItem(String skuCode, String productNameZh, long quantity) { this(skuCode, productNameZh, quantity, quantity); }
}
public record Label(String cartonMark, String overseasWarehouseId, String customerId, String sourceBatchNo, String sourceReturnNo,
@@ -54,7 +66,7 @@ public final class ReturnInventoryResponses {
public record PackingPlanCompletion(String planNo, List<Label> labels) { }
public record PlatformStock(String customerId, String overseasWarehouseId, String skuCode, String productNameZh,
long totalQuantity, long availableQuantity, long cartonQuantity, LocalDateTime updatedAt,
long looseAvailableQuantity) { }
long looseAvailableQuantity, long relabelLockedQuantity) { }
public record PlatformCarton(String customerId, String overseasWarehouseId, String cartonMark, String sourceReturnNo,
String sourceUnitType, String sourceBatchNo, String status, int skuTypes, long totalQuantity,
String fbaCartonMark, String destinationWarehouseCode, String destinationWarehouseName, String relabelNo, String relabelStatus,
@@ -16,9 +16,12 @@ public class ReturnInventoryBalance {
private String overseasWarehouseId;
private String skuCode;
private String productNameZh;
/** 实物总库存;只有实际出库才会减少。 */
/** 当前 SKU 的实物总库存;实际出库及 SKU 转换会减少,SKU 转换前后全仓件数守恒。 */
private Long totalQuantity;
private Long availableQuantity;
/** 因换标整箱锁定的 SKU 件数,仅查询投影,不写入余额表。 */
@com.baomidou.mybatisplus.annotation.TableField(exist = false)
private Long relabelLockedQuantity;
private Long cartonQuantity;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@@ -15,4 +15,6 @@ public class ReturnInventoryCartonItem {
private String productNameZh;
private Long quantity;
private Long packingReservedQuantity;
/** 当前 SKU 中已经换过标的实物件数,不再允许二次换标。 */
private Long relabelledQuantity;
}
@@ -0,0 +1,28 @@
package com.baiyue.lastleg.returnreceipt.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_return_sku_conversion")
public class ReturnSkuConversion {
@TableId(type = IdType.INPUT) private Long id;
private String customerId;
private String overseasWarehouseId;
private Long applicationId;
private String relabelNo;
private Long itemId;
private String roundKey;
private Long cartonId;
private String cartonMark;
private String oldSkuCode;
private String newSkuCode;
private String productNameZh;
private Long quantity;
private String status;
private String ownershipJson;
private LocalDateTime completedAt;
private LocalDateTime confirmedAt;
private LocalDateTime reversedAt;
}
@@ -12,6 +12,11 @@ import java.util.List;
@Mapper
public interface ReturnInventoryBalanceMapper extends BaseMapper<ReturnInventoryBalance> {
@Update("UPDATE ll_return_inventory_balance SET total_quantity=total_quantity-#{quantity},available_quantity=available_quantity-#{quantity}," +
"carton_quantity=carton_quantity-#{cartons},updated_at=NOW(3) WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} " +
"AND sku_code=#{skuCode} AND total_quantity>=#{quantity} AND available_quantity>=#{quantity} AND carton_quantity>=#{cartons}")
int deductConversion(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId,
@Param("skuCode") String skuCode, @Param("quantity") long quantity, @Param("cartons") long cartons);
@Update("UPDATE ll_return_inventory_balance SET total_quantity=total_quantity-#{quantity}, " +
"available_quantity=available_quantity-#{quantity},updated_at=NOW(3) " +
"WHERE id=#{id} AND customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} " +
@@ -24,7 +29,11 @@ public interface ReturnInventoryBalanceMapper extends BaseMapper<ReturnInventory
"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 " +
"AND c.status='AVAILABLE' AND c.count_status='CONFIRMED'),0)) AS available_quantity";
"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";
@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,
@@ -75,12 +75,25 @@ public interface ReturnInventoryCartonMapper extends BaseMapper<ReturnInventoryC
"AND (COALESCE(newer.source_carton_mark,newer.carton_mark)=COALESCE(ri.source_carton_mark,ri.carton_mark) " +
"OR COALESCE(newer.source_carton_mark,newer.carton_mark)=(SELECT c.carton_mark FROM ll_return_inventory_carton c WHERE c.id=#{cartonId})) " +
"AND (na.created_at>ra.created_at OR (na.created_at=ra.created_at AND na.id>ra.id)))))";
String COMPLETION_DEDUCTION = "COALESCE((SELECT SUM(j.quantity) FROM ll_relabel_pda_history h " +
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 " +
"WHERE h.action='RETURN_COMPLETION_TRANSFER' AND EXISTS(SELECT 1 FROM ll_relabel_item ri " +
"JOIN ll_relabel_application ra ON ra.id=ri.application_id " +
"WHERE ri.return_carton_id=#{cartonId} AND JSON_CONTAINS(CASE WHEN JSON_VALID(h.detail_json) THEN h.detail_json ELSE '{}' END," +
"JSON_QUOTE(CAST(ri.id AS CHAR)),'$.sourceItemIds') AND ra.status NOT IN ('CANCELLED','REJECTED') " + CURRENT_RELABEL_ROUND + ")";
"JSON_QUOTE(CAST(ri.id AS CHAR)),'$.sourceItemIds') AND ra.status NOT IN ('CANCELLED','REJECTED') ";
String COMPLETION_DEDUCTION = ALL_COMPLETION_DEDUCTION + CURRENT_RELABEL_ROUND + ")";
// All rounds count here: selecting a later partial application must not erase earlier completed pieces.
@Select("SELECT GREATEST(0,COALESCE(SUM(CASE WHEN ri.operation_type='RETURN_BULK_SKU_LABEL' THEN f.quantity " +
"ELSE f.quantity * COALESCE((SELECT SUM(j.quantity) FROM JSON_TABLE(CASE WHEN JSON_VALID(ri.sku_relabel_items_json) THEN ri.sku_relabel_items_json ELSE '[]' END,'$[*]' " +
"COLUMNS(sku_code VARCHAR(128) PATH '$.skuCode',quantity BIGINT PATH '$.quantity')) j WHERE UPPER(j.sku_code)=UPPER(#{skuCode})),0) END),0) - " +
ALL_COMPLETION_DEDUCTION + ") AND UPPER(j.sku_code)=UPPER(#{skuCode})),0)) " +
"FROM ll_relabel_item ri JOIN ll_relabel_application ra ON ra.id=ri.application_id " +
"JOIN ll_relabel_pda_unit_fact f ON f.item_id=ri.id AND f.result='RELABELLED' " +
"WHERE ri.return_carton_id=#{cartonId} AND (ri.operation_type<>'RETURN_BULK_SKU_LABEL' OR UPPER(ri.sku_code)=UPPER(#{skuCode})) " +
"AND ra.status NOT IN ('CANCELLED','REJECTED') AND NOT EXISTS(SELECT 1 FROM ll_return_sku_conversion sc " +
"WHERE sc.item_id=ri.id AND sc.status IN ('APPLIED','CONFIRMED') AND UPPER(sc.old_sku_code)=UPPER(#{skuCode}))")
long completedSkuQuantity(@Param("cartonId") long cartonId, @Param("skuCode") String skuCode);
@Select("SELECT COALESCE(SUM(CASE WHEN ri.operation_type='RETURN_BULK_SKU_LABEL' THEN f.quantity " +
"ELSE f.quantity * COALESCE((SELECT SUM(j.quantity) FROM JSON_TABLE(CASE WHEN JSON_VALID(ri.sku_relabel_items_json) THEN ri.sku_relabel_items_json ELSE '[]' END, '$[*]' " +
"COLUMNS(sku_code VARCHAR(128) PATH '$.skuCode', quantity BIGINT PATH '$.quantity')) j WHERE UPPER(j.sku_code)=UPPER(#{skuCode})),0) END),0) - " +
@@ -17,7 +17,11 @@ public interface ReturnInventoryMovementMapper extends com.baomidou.mybatisplus.
"UNION ALL SELECT c.customer_id,c.overseas_warehouse_id,i.sku_code,i.product_name_zh,'MERGE',c.carton_mark,0,-i.quantity,c.created_at " +
"FROM ll_return_inventory_carton c JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id WHERE c.source_batch_no='MERGED'" +
" UNION ALL SELECT customer_id,overseas_warehouse_id,sku_code,product_name_zh,'OUTBOUND',outbound_no," +
"-quantity,-quantity,occurred_at FROM ll_return_inventory_outbound_movement";
"-quantity,-quantity,occurred_at FROM ll_return_inventory_outbound_movement" +
" UNION ALL SELECT customer_id,overseas_warehouse_id,old_sku_code,product_name_zh,'SKU_CONVERSION_OUT',relabel_no,-quantity,-quantity,completed_at FROM ll_return_sku_conversion" +
" UNION ALL SELECT customer_id,overseas_warehouse_id,new_sku_code,product_name_zh,'SKU_CONVERSION_IN',relabel_no,quantity,quantity,completed_at FROM ll_return_sku_conversion" +
" UNION ALL SELECT customer_id,overseas_warehouse_id,old_sku_code,product_name_zh,'SKU_CONVERSION_REVERSE_IN',relabel_no,quantity,quantity,reversed_at FROM ll_return_sku_conversion WHERE status='REVERSED'" +
" UNION ALL SELECT customer_id,overseas_warehouse_id,new_sku_code,product_name_zh,'SKU_CONVERSION_REVERSE_OUT',relabel_no,-quantity,-quantity,reversed_at FROM ll_return_sku_conversion WHERE status='REVERSED'";
@Select("<script>SELECT COUNT(*) FROM (" + MOVEMENTS + ") m WHERE 1=1 " +
"<if test='customerId != null and customerId != \"\"'>AND m.customerId=#{customerId} </if>" +
@@ -21,7 +21,7 @@ public interface ReturnInventoryQueryMapper {
@Param("status") String status, @Param("returnNo") String returnNo);
@Select("<script>SELECT c.id carton_id,c.overseas_warehouse_id,c.carton_mark,c.source_batch_no,(CASE WHEN c.count_status='PENDING' AND c.status='AVAILABLE' THEN 'PENDING_COUNT' ELSE c.status END) AS status,c.sku_types,c.total_quantity,c.created_at, " +
"i.sku_code,i.product_name_zh,i.quantity FROM ll_return_inventory_carton c JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id " +
"i.sku_code,i.product_name_zh,i.quantity,i.relabelled_quantity,i.packing_reserved_quantity FROM ll_return_inventory_carton c JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id " +
"WHERE c.customer_id=#{customerId} " +
"<if test='status != null and status != \"\"'>AND (CASE WHEN c.count_status='PENDING' AND c.status='AVAILABLE' THEN 'PENDING_COUNT' ELSE c.status END)=#{status} </if>" +
"<if test='returnNo != null and returnNo != \"\"'>AND (c.source_return_no LIKE CONCAT('%',#{returnNo},'%') OR EXISTS (SELECT 1 FROM ll_return_receipt r JOIN ll_return_order o ON o.id=r.return_order_id WHERE r.id=c.source_receipt_id AND o.return_no LIKE CONCAT('%',#{returnNo},'%'))) </if>" +
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.returnreceipt.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnSkuConversion;
import org.apache.ibatis.annotations.*;
import java.util.List;
@Mapper
public interface ReturnSkuConversionMapper extends BaseMapper<ReturnSkuConversion> {
@Select("SELECT * FROM ll_return_sku_conversion WHERE customer_id=#{customerId} AND application_id=#{applicationId} ORDER BY id FOR UPDATE")
List<ReturnSkuConversion> lockApplication(@Param("customerId") String customerId, @Param("applicationId") long applicationId);
@Select("SELECT sc.* FROM ll_return_sku_conversion sc WHERE sc.customer_id=#{customerId} AND (sc.carton_id=#{cartonId} " +
"OR EXISTS(SELECT 1 FROM ll_relabel_item ri WHERE ri.id=sc.item_id AND ri.return_carton_id=#{cartonId})) ORDER BY sc.completed_at DESC,sc.id DESC")
List<ReturnSkuConversion> listCarton(@Param("customerId") String customerId, @Param("cartonId") long cartonId);
}
@@ -17,4 +17,6 @@ public class ReturnInventoryRow {
private String skuCode;
private String productNameZh;
private Long quantity;
private Long relabelledQuantity;
private Long packingReservedQuantity;
}
@@ -59,6 +59,7 @@ public class ReturnInventoryService {
@Resource private com.baiyue.lastleg.relabel.mapper.RelabelItemMapper relabelItems;
@Resource private ReturnInventoryCartonItemMapper cartonItems;
@Resource private ReturnInventoryBalanceMapper balances;
@Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnSkuConversionMapper skuConversions;
@Resource private ReturnInventoryOperationMapper operations;
@Resource private ReturnInventoryQueryMapper queries;
@Resource private ReturnInventoryMovementMapper movements;
@@ -474,6 +475,7 @@ public class ReturnInventoryService {
if (movements.insert(movement) != 1) throw new IllegalStateException("退货出库流水写入失败");
if (partial) {
content.setQuantity(content.getQuantity() - quantity);
content.setRelabelledQuantity(Math.min(value(content.getRelabelledQuantity()), content.getQuantity()));
if (cartonItems.updateById(content) != 1) throw conflict("退货箱内数量已变化,请重新核对");
}
}
@@ -581,7 +583,7 @@ public class ReturnInventoryService {
carton.getSourceBatchNo(), carton.getSourceReturnNo(), displayStatus(carton),
carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()),
carton.getLength(), carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt(),
relabelProgressStatus(carton), cartons.totalRelabelledQuantity(carton.getId()), cartons.relabelReservedQuantity(carton.getId()))).toList();
relabelProgressStatus(carton), completedRelabelQuantity(carton), cartons.relabelReservedQuantity(carton.getId()))).toList();
return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size());
}
@@ -603,7 +605,8 @@ public class ReturnInventoryService {
(request.page() - 1) * request.size(), request.size()).stream().map(balance -> new ReturnInventoryResponses.PlatformStock(
balance.getCustomerId(), balance.getOverseasWarehouseId(), balance.getSkuCode(), balance.getProductNameZh(),
value(balance.getTotalQuantity()), value(balance.getAvailableQuantity()), value(balance.getCartonQuantity()), balance.getUpdatedAt(),
cartonItems.looseAvailableQuantity(balance.getCustomerId(), balance.getOverseasWarehouseId(), balance.getSkuCode()))).toList();
cartonItems.looseAvailableQuantity(balance.getCustomerId(), balance.getOverseasWarehouseId(), balance.getSkuCode()),
value(balance.getRelabelLockedQuantity()))).toList();
return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size());
}
@@ -626,7 +629,7 @@ public class ReturnInventoryService {
carton.getCustomerId(), carton.getOverseasWarehouseId(), carton.getCartonMark(), carton.getSourceReturnNo(), carton.getSourceUnitType(), carton.getSourceBatchNo(),
displayStatus(carton), carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()), carton.getFbaCartonMark(), carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), carton.getRelabelNo(), relabelProgressStatus(carton), carton.getLength(),
carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt(), carton.getLocationCode(), carton.getPalletCode(), carton.getPutawayAt(),
cartons.totalRelabelledQuantity(carton.getId()), cartons.relabelReservedQuantity(carton.getId()))).toList();
completedRelabelQuantity(carton), cartons.relabelReservedQuantity(carton.getId()))).toList();
return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size());
}
@@ -1005,6 +1008,7 @@ public class ReturnInventoryService {
}
Map<String, Long> remaining = new LinkedHashMap<>(requested);
Map<String, Integer> emptiedBySku = new LinkedHashMap<>();
Map<String, Long> movedRelabelled = new LinkedHashMap<>();
LinkedHashSet<String> consumedBatchNos = new LinkedHashSet<>();
LinkedHashSet<String> consumedReturnNos = new LinkedHashSet<>();
Map<Long, Map<String, Long>> attachedByOrder = new LinkedHashMap<>();
@@ -1014,7 +1018,7 @@ public class ReturnInventoryService {
for (ReturnInventoryCarton source : sources) {
// Read committed quantities under the source lock, never a stale MVCC snapshot.
List<ReturnInventoryCartonItem> sourceItems = cartonItems.lockCarton(source.getId());
boolean hasCompletedRelabel = sourceItems.stream().anyMatch(i -> cartons.relabelledQuantity(source.getId(), i.getSkuCode()) > 0);
boolean hasCompletedRelabel = sourceItems.stream().anyMatch(i -> cartons.completedSkuQuantity(source.getId(), i.getSkuCode()) > 0);
if (hasCompletedRelabel) completedRelabelSources.add(source.getId());
if (hasCompletedRelabel && source.getRelabelApplicationId() == null && sourceItems.stream().anyMatch(i -> {
long free = Math.max(0, value(i.getQuantity()) - value(i.getPackingReservedQuantity()));
@@ -1028,6 +1032,12 @@ public class ReturnInventoryService {
long allowed = limits == null ? free : Math.min(free, value(limits.getOrDefault(source.getCartonMark(), Map.of()).get(sku)));
long take = Math.min(value(remaining.get(sku)), allowed);
if (take > 0) {
long labelled = value(item.getRelabelledQuantity());
if (labelled > 0 && labelled < value(item.getQuantity()) && take != value(item.getQuantity()))
throw conflict("已换标 SKU 只能按当前完整来源明细移箱,不能猜测部分实物来源");
long movedLabelled = Math.min(labelled, take);
movedRelabelled.merge(sku, movedLabelled, Math::addExact);
item.setRelabelledQuantity(labelled - movedLabelled);
if (limits != null) limits.get(source.getCartonMark()).put(sku, allowed - take);
consumedBatchNos.add(required(source.getSourceBatchNo(), "来源签收批次缺失,无法合箱"));
consumedReturnNos.addAll(java.util.Arrays.asList(required(source.getSourceReturnNo(), "来源退货单缺失,无法合箱").split(",")));
@@ -1079,8 +1089,11 @@ public class ReturnInventoryService {
if (targetItem == null) {
targetItem = new ReturnInventoryCartonItem(); targetItem.setId(IdWorker.getId()); targetItem.setCartonId(targetId);
targetItem.setCustomerId(customerId); targetItem.setSkuCode(entry.getKey()); targetItem.setProductNameZh(productNames.get(entry.getKey()));
targetItem.setQuantity(entry.getValue()); targetItem.setPackingReservedQuantity(0L); cartonItems.insert(targetItem);
} else { targetItem.setQuantity(Math.addExact(value(targetItem.getQuantity()), entry.getValue())); cartonItems.updateById(targetItem); }
targetItem.setQuantity(entry.getValue()); targetItem.setPackingReservedQuantity(0L);
targetItem.setRelabelledQuantity(movedRelabelled.getOrDefault(entry.getKey(), 0L)); cartonItems.insert(targetItem);
} else { targetItem.setQuantity(Math.addExact(value(targetItem.getQuantity()), entry.getValue()));
targetItem.setRelabelledQuantity(Math.addExact(value(targetItem.getRelabelledQuantity()), movedRelabelled.getOrDefault(entry.getKey(), 0L)));
cartonItems.updateById(targetItem); }
long cartonDelta = (addedSku ? 1L : 0L) - emptiedBySku.getOrDefault(entry.getKey(), 0);
if (balances.adjustCartonQuantity(customerId, target.getOverseasWarehouseId(), entry.getKey(), cartonDelta) != 1)
throw new IllegalStateException("退货合箱库存箱数更新失败: " + entry.getKey());
@@ -1159,12 +1172,15 @@ public class ReturnInventoryService {
private ReturnInventoryResponses.InventoryItem item(ReturnInventoryRow row) {
return new ReturnInventoryResponses.InventoryItem(row.getOverseasWarehouseId(), row.getCartonMark(), row.getSourceBatchNo(), row.getStatus(),
row.getSkuCode(), row.getProductNameZh(), value(row.getQuantity()), row.getSkuTypes() == null ? 0 : row.getSkuTypes(),
value(row.getTotalQuantity()), row.getCreatedAt());
value(row.getTotalQuantity()), row.getCreatedAt(), value(row.getRelabelledQuantity()),
AVAILABLE.equals(row.getStatus()) ? Math.max(0, value(row.getQuantity()) - value(row.getPackingReservedQuantity())
- value(row.getRelabelledQuantity()) - cartons.completedSkuQuantity(row.getCartonId(), row.getSkuCode())) : 0);
}
private ReturnInventoryResponses.SummaryItem summary(com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryBalance balance) {
return new ReturnInventoryResponses.SummaryItem(balance.getOverseasWarehouseId(), balance.getSkuCode(), balance.getProductNameZh(),
value(balance.getTotalQuantity()), value(balance.getAvailableQuantity()), value(balance.getCartonQuantity()), balance.getUpdatedAt());
value(balance.getTotalQuantity()), value(balance.getAvailableQuantity()), value(balance.getCartonQuantity()), balance.getUpdatedAt(),
value(balance.getRelabelLockedQuantity()));
}
private static String displayStatus(ReturnInventoryCarton carton) {
@@ -1175,14 +1191,22 @@ public class ReturnInventoryService {
List<ReturnInventoryResponses.CartonItem> items = cartonItems.listCarton(carton.getId()).stream()
.map(item -> new ReturnInventoryResponses.CartonItem(item.getSkuCode(), item.getProductNameZh(), item.getQuantity(),
"AVAILABLE".equals(carton.getStatus()) && "CONFIRMED".equals(carton.getCountStatus())
? Math.max(0, value(item.getQuantity()) - value(item.getPackingReservedQuantity())) : 0)).toList();
? Math.max(0, value(item.getQuantity()) - value(item.getPackingReservedQuantity())) : 0,
value(item.getRelabelledQuantity()), "AVAILABLE".equals(carton.getStatus())
? Math.max(0, value(item.getQuantity()) - value(item.getPackingReservedQuantity()) - value(item.getRelabelledQuantity())
- cartons.completedSkuQuantity(carton.getId(), item.getSkuCode())) : 0)).toList();
return new ReturnInventoryResponses.CartonDetail(carton.getCartonMark(), carton.getCustomerId(), carton.getOverseasWarehouseId(), carton.getSourceBatchNo(), carton.getSourceReturnNo(),
displayStatus(carton), carton.getFbaCartonMark(), carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), carton.getRelabelNo(), relabelProgressStatus(carton), carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()), carton.getLength(), carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt(), items, carton.getLocationCode(), carton.getPalletCode(), carton.getPutawayAt(), packingTrace.listCartonHistory(carton.getCustomerId(), carton.getCartonMark()),
cartons.totalRelabelledQuantity(carton.getId()), cartons.relabelReservedQuantity(carton.getId()));
completedRelabelQuantity(carton), cartons.relabelReservedQuantity(carton.getId()),
skuConversions.listCarton(carton.getCustomerId(), carton.getId()).stream().map(ReturnInventoryResponses.SkuConversion::from).toList());
}
private String relabelProgressStatus(ReturnInventoryCarton carton) {
return ReturnRelabelProgress.status(value(carton.getTotalQuantity()), cartons.totalRelabelledQuantity(carton.getId()), cartons.relabelReservedQuantity(carton.getId()));
return ReturnRelabelProgress.status(value(carton.getTotalQuantity()), completedRelabelQuantity(carton), cartons.relabelReservedQuantity(carton.getId()));
}
private long completedRelabelQuantity(ReturnInventoryCarton carton) {
return Math.max(cartons.totalRelabelledQuantity(carton.getId()), cartonItems.listCarton(carton.getId()).stream()
.mapToLong(line -> value(line.getRelabelledQuantity()) + cartons.completedSkuQuantity(carton.getId(), line.getSkuCode())).sum());
}
private static String printableHtml(ReturnInventoryCarton carton) {
@@ -386,7 +386,9 @@ public class ReturnReceiptService {
if (!inventory.matchesReturnSource(carton, order.getReturnNo())) continue;
if (!ReturnInventoryService.AVAILABLE.equals(carton.getStatus()) || !"CONFIRMED".equals(carton.getCountStatus()) || carton.getRelabelApplicationId() != null) continue;
List<ReturnOrderResponses.Item> items = cartonItems.listCarton(carton.getId()).stream()
.map(i -> new ReturnOrderResponses.Item(i.getSkuCode(), i.getProductNameZh(), value(i.getQuantity()), value(i.getQuantity()))).toList();
.map(i -> new ReturnOrderResponses.Item(i.getSkuCode(), i.getProductNameZh(), remainingRelabelQuantity(carton.getId(), i), remainingRelabelQuantity(carton.getId(), i)))
.filter(i -> i.receivedQuantity() > 0).toList();
if (items.isEmpty()) continue;
result.add(new ReturnOrderResponses.RelabelCandidate(carton.getId(), carton.getCartonMark(), carton.getSourceReturnNo(), carton.getStatus(), value(carton.getTotalQuantity()), items, carton.getRelabelStatus()));
}
return result;
@@ -468,7 +470,7 @@ public class ReturnReceiptService {
if (text(requested.cartonMark()) != null && !requested.cartonMark().equals(candidate.getCartonMark())) continue;
long skuQuantity = cartonItems.listCarton(candidate.getId()).stream()
.filter(line -> norm(requested.skuCode()).equals(norm(line.getSkuCode())))
.mapToLong(line -> value(line.getQuantity())).sum();
.mapToLong(line -> remainingRelabelQuantity(candidate.getId(), line)).sum();
if (skuQuantity <= 0) continue;
ReturnInventoryCarton carton = selectedCartons.computeIfAbsent(candidate.getId(), ignored -> cartons.lockId(candidate.getId()));
if (carton == null || !"AVAILABLE".equals(carton.getStatus()) || carton.getRelabelApplicationId() != null)
@@ -477,7 +479,7 @@ public class ReturnReceiptService {
throw conflict("退货 SKU 已被合箱计划锁定,请先完成或取消合箱计划");
skuQuantity = cartonItems.lockCarton(carton.getId()).stream()
.filter(line -> norm(requested.skuCode()).equals(norm(line.getSkuCode())))
.mapToLong(line -> value(line.getQuantity())).sum();
.mapToLong(line -> remainingRelabelQuantity(carton.getId(), line)).sum();
var sourceSku = List.of(carton.getCartonMark(), norm(requested.skuCode()));
skuQuantity -= allocatedBySourceSku.getOrDefault(sourceSku, 0L);
if (skuQuantity <= 0) continue;
@@ -848,6 +850,10 @@ public class ReturnReceiptService {
item.getProductNameZh(), value(item.getExpectedQuantity()), value(item.getReceivedQuantity())); }
private PdaReturnReceiptResponses.Item pdaItem(ReturnOrderItem item) { return new PdaReturnReceiptResponses.Item(item.getSkuCode(),
item.getProductNameZh(), value(item.getExpectedQuantity()), value(item.getReceivedQuantity())); }
private long remainingRelabelQuantity(long cartonId, com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem line) {
return Math.max(0, value(line.getQuantity()) - value(line.getPackingReservedQuantity()) - value(line.getRelabelledQuantity())
- cartons.completedSkuQuantity(cartonId, line.getSkuCode()));
}
private static long value(Long value) { return value == null ? 0 : value; }
private static String text(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static String norm(String value) { return text(value) == null ? null : text(value).toUpperCase(java.util.Locale.ROOT); }
@@ -3597,6 +3597,35 @@ PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='label_status'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN label_status VARCHAR(32) NULL COMMENT ''WAIT_LABEL待贴标 CONFIRMED已扫码确认标签''');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- SKU 换标实物转换:先执行本增量,再部署服务。不会自动改写存量业务事实。
SET @return_sku_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton_item' AND column_name='relabelled_quantity'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton_item ADD COLUMN relabelled_quantity BIGINT NOT NULL DEFAULT 0 COMMENT ''当前SKU已经换标的实物件数,不可二次换标'''));
PREPARE return_sku_stmt FROM @return_sku_ddl; EXECUTE return_sku_stmt; DEALLOCATE PREPARE return_sku_stmt;
CREATE TABLE IF NOT EXISTS ll_return_sku_conversion (
id BIGINT NOT NULL COMMENT 'SKU库存转换记录ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '操作海外仓ID',
application_id BIGINT NOT NULL COMMENT '换标申请ID',
relabel_no VARCHAR(64) NOT NULL COMMENT '换标单号',
item_id BIGINT NOT NULL COMMENT '换标箱级明细ID',
round_key VARCHAR(64) NOT NULL COMMENT '作业轮次,退回重做使用新轮次',
carton_id BIGINT NOT NULL COMMENT '实物来源包裹或箱ID',
carton_mark VARCHAR(128) NOT NULL COMMENT '完工时仓库识别码',
old_sku_code VARCHAR(128) NOT NULL COMMENT '换标前SKU',
new_sku_code VARCHAR(128) NOT NULL COMMENT '换标后SKU',
product_name_zh VARCHAR(255) NULL COMMENT '中文品名快照',
quantity BIGINT NOT NULL COMMENT '实际完成转换件数',
status VARCHAR(32) NOT NULL COMMENT 'APPLIED待客户确认/CONFIRMED已确认/REVERSED退回冲回',
ownership_json LONGTEXT NOT NULL COMMENT '本次转换件数的来源退货单快照,供精确冲回',
completed_at DATETIME(3) NOT NULL COMMENT '实物SKU转换时间',
confirmed_at DATETIME(3) NULL COMMENT '客户确认时间',
reversed_at DATETIME(3) NULL COMMENT '退回冲回时间',
PRIMARY KEY(id),
UNIQUE KEY uk_return_sku_conversion_round(item_id,round_key,old_sku_code,new_sku_code),
KEY idx_return_sku_conversion_app(customer_id,application_id,status),
KEY idx_return_sku_conversion_carton(customer_id,carton_id),
CONSTRAINT chk_return_sku_conversion_quantity CHECK(quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='退货实物SKU换标库存转换和冲回记录';
-- 平台自营领星统计:独立事实与不可变发布版本;仅空库初始化,不自动执行。
CREATE TABLE ll_selfop_outbound_discovery_scope (
@@ -0,0 +1,28 @@
-- SKU 换标实物转换:先执行本增量,再部署服务。不会自动改写存量业务事实。
SET @return_sku_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton_item' AND column_name='relabelled_quantity'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton_item ADD COLUMN relabelled_quantity BIGINT NOT NULL DEFAULT 0 COMMENT ''当前SKU已经换标的实物件数,不可二次换标'''));
PREPARE return_sku_stmt FROM @return_sku_ddl; EXECUTE return_sku_stmt; DEALLOCATE PREPARE return_sku_stmt;
CREATE TABLE IF NOT EXISTS ll_return_sku_conversion (
id BIGINT NOT NULL COMMENT 'SKU库存转换记录ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键',
overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '操作海外仓ID',
application_id BIGINT NOT NULL COMMENT '换标申请ID',
relabel_no VARCHAR(64) NOT NULL COMMENT '换标单号',
item_id BIGINT NOT NULL COMMENT '换标箱级明细ID',
round_key VARCHAR(64) NOT NULL COMMENT '作业轮次,退回重做使用新轮次',
carton_id BIGINT NOT NULL COMMENT '实物来源包裹或箱ID',
carton_mark VARCHAR(128) NOT NULL COMMENT '完工时仓库识别码',
old_sku_code VARCHAR(128) NOT NULL COMMENT '换标前SKU',
new_sku_code VARCHAR(128) NOT NULL COMMENT '换标后SKU',
product_name_zh VARCHAR(255) NULL COMMENT '中文品名快照',
quantity BIGINT NOT NULL COMMENT '实际完成转换件数',
status VARCHAR(32) NOT NULL COMMENT 'APPLIED待客户确认/CONFIRMED已确认/REVERSED退回冲回',
ownership_json LONGTEXT NOT NULL COMMENT '本次转换件数的来源退货单快照,供精确冲回',
completed_at DATETIME(3) NOT NULL COMMENT '实物SKU转换时间',
confirmed_at DATETIME(3) NULL COMMENT '客户确认时间',
reversed_at DATETIME(3) NULL COMMENT '退回冲回时间',
PRIMARY KEY(id),
UNIQUE KEY uk_return_sku_conversion_round(item_id,round_key,old_sku_code,new_sku_code),
KEY idx_return_sku_conversion_app(customer_id,application_id,status),
KEY idx_return_sku_conversion_carton(customer_id,carton_id),
CONSTRAINT chk_return_sku_conversion_quantity CHECK(quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='退货实物SKU换标库存转换和冲回记录';
@@ -1815,7 +1815,7 @@ paths:
/api/merchant/v1/last-leg/return-inventory/summary:
get:
summary: 客户查询退货库存 SKU 汇总
description: 客户范围只取认证 Token。按海外仓和 SKU 汇总 PDA 实收上架数量及可用箱数。
description: 客户范围只取认证 Token。按海外仓和 SKU 汇总 PDA 实收上架数量及可用箱数。relabelLockedQuantity 为换标处理中整箱锁定的该 SKU 在库件数,包含同箱未申请换标但无法使用的余量;不包含合箱、待清点或出库占用,不等同于未完成换标任务件数。
security: [{ bearerAuth: [] }]
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
@@ -3074,6 +3074,9 @@ components:
customerConfirmedAt: { type: string, format: date-time, nullable: true }
reviewChannel: { type: string, enum: [TAIL_ORDER, RELABEL_ORDER], description: TAIL_ORDER 表示客户直接贴标,RELABEL_ORDER 表示其他业务关联换标 }
reviewRemark: { type: string, nullable: true, description: 当前驳回状态最近一次审核驳回原因 }
reviewedAt: { type: string, format: date-time, nullable: true, description: 最近一次申请审核通过或驳回时间 }
relabelCompletedAt: { type: string, format: date-time, nullable: true, description: 本轮贴标作业提交完成时间,退回重做后完成前为空 }
outboundAt: { type: string, format: date-time, nullable: true, description: 实际出库时间,无需出库的纯 SKU 贴标完成不会产生出库时间 }
createdAt: { type: string, format: date-time }
RelabelDetail:
type: object
@@ -3104,6 +3107,7 @@ components:
RelabelInventoryCandidate:
type: object
properties:
skuConversions: { type: array, items: { $ref: '#/components/schemas/ReturnSkuConversion' }, description: 当前实物关联的旧新SKU转换、实际件数和客户确认状态 }
relabelStatus: { type: string, nullable: true, enum: [UNLABELLED, PROCESSING, PARTIALLY_COMPLETED, COMPLETED], description: 退货实物按实际完成件数和未完成申请占用计算 }
relabelledPackages: { type: integer, format: int64, description: 退货实际已完成商品换标件数,不从可用库存差值推导 }
relabelledQuantity: { type: integer, format: int64, minimum: 0 }
@@ -3122,7 +3126,7 @@ components:
availablePackages: { type: integer, format: int64 }
version: { type: integer, format: int64 }
positions: { type: array, items: { $ref: '#/components/schemas/RelabelInventoryPosition' } }
sourceType: { type: string, enum: [INBOUND, RETURN_CARTON, RETURN_PARCEL], description: RETURN_PARCEL与RETURN_CARTON均支持整实体箱唛、SKU或混贴,实物单位计1;SKU按件,部分数量先隔离为独立新箱 }
sourceType: { type: string, enum: [INBOUND, RETURN_CARTON, RETURN_PARCEL], description: RETURN_PARCEL与RETURN_CARTON均支持整实体箱唛、SKU或混贴,实物单位计1;SKU按件,编码转换保留原识别码及总件数,部分出库需先隔离为独立新箱 }
returnCartonId: { type: string, nullable: true, description: 退货合箱库存主键;sourceType=RETURN_CARTON 时提供 }
cartonMark: { type: string, nullable: true, description: 仓库合箱后打印并贴箱的仓库唛头 }
skuItems:
@@ -3135,6 +3139,16 @@ components:
newSkuCode: { type: string, nullable: true }
quantity: { type: integer, minimum: 1 }
remark: { type: string, nullable: true }
ReturnSkuConversion:
type: object
properties:
relabelNo: { type: string }
cartonMark: { type: string, description: 完工时的包裹或箱识别码 }
oldSkuCode: { type: string }
newSkuCode: { type: string }
quantity: { type: integer, format: int64, minimum: 1 }
status: { type: string, enum: [APPLIED, CONFIRMED, REVERSED] }
completedAt: { type: string, format: date-time }
RelabelInventoryPosition:
type: object
properties:
@@ -2,7 +2,7 @@ openapi: 3.0.3
info:
title: 退货合箱计划与箱级上架
version: 1.0.0
description: 包裹可包含多个SKU并拥有独立标签;整包裹/箱可直接出库,部分数量须精确选择来源包裹/箱合箱成新标签箱。托盘仅为可选载体。合箱不新增订单货量。
description: 包裹可包含多个SKU并拥有独立标签;整包裹/箱可直接出库,部分数量须精确选择来源包裹/箱合箱成新标签箱。托盘仅为可选载体。合箱不新增订单货量。SKU编码换标完工时原子扣旧SKU、加新SKU,总件数守恒;客户确认前保持RELABEL_PENDING占用,退回重做冲回本轮转换。CartonDetail返回skuConversions(换标单号、完工识别码、旧新SKU、实际数量、APPLIED/CONFIRMED/REVERSED状态和时间),items返回当前quantity、availableQuantity、relabelledQuantity、remainingRelabelQuantity;新SKU已换标件数不可二次换标。
security: [{ bearerAuth: [] }]
paths:
/api/platform/v1/last-leg/return-packing-plans:
@@ -46,7 +46,7 @@ class ReturnOutboundPdaPersistenceTest {
// Persist production entities through production MyBatis mappers, with real transactions.
for (Class<?> entity : List.of(OutboundOrder.class, OutboundPalletItem.class, OutboundPdaTask.class,
OutboundPdaPick.class, DeliveryOrder.class, ReturnInventoryCarton.class, ReturnInventoryCartonItem.class, ReturnInventoryBalance.class,
ReturnInventoryOutboundMovement.class, ReturnOrder.class, ReturnOrderItem.class, ReturnReceipt.class, ReturnReceiptItem.class)) {
ReturnInventoryOutboundMovement.class, ReturnSkuConversion.class, ReturnOrder.class, ReturnOrderItem.class, ReturnReceipt.class, ReturnReceiptItem.class)) {
String columns = Arrays.stream(entity.getDeclaredFields()).filter(field -> !Modifier.isStatic(field.getModifiers()))
.map(field -> field.getName().replaceAll("([A-Z])", "_$1").toLowerCase() + " "
+ (field.getType() == String.class ? "VARCHAR(2000)" : field.getType() == LocalDateTime.class ? "TIMESTAMP"
@@ -0,0 +1,97 @@
package com.baiyue.lastleg.relabel;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
class RelabelMilestonesPersistenceTest {
JdbcTemplate jdbc;
RelabelApplicationMapper mapper;
final LocalDateTime review = LocalDateTime.of(2026, 10, 12, 10, 0);
final LocalDateTime completion = review.plusHours(1);
final LocalDateTime outbound = review.plusHours(2);
@BeforeEach void setup() throws Exception {
var ds = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL", "sa", "");
// Keep the in-memory database alive across mapper connections.
ds.setUrl(ds.getUrl() + ";DB_CLOSE_DELAY=-1");
jdbc = new JdbcTemplate(ds);
jdbc.execute("CREATE TABLE ll_relabel_application(id BIGINT, customer_id VARCHAR(128), status VARCHAR(32), relabel_no VARCHAR(64), outbound_order_no VARCHAR(64))");
jdbc.execute("CREATE TABLE ll_relabel_status_history(customer_id VARCHAR(128), application_id BIGINT, from_status VARCHAR(32), to_status VARCHAR(32), created_at TIMESTAMP)");
jdbc.execute("CREATE TABLE ll_relabel_pda_task(customer_id VARCHAR(128), application_id BIGINT, submitted_at TIMESTAMP)");
jdbc.execute("CREATE TABLE ll_outbound_order(id BIGINT, customer_id VARCHAR(128), outbound_no VARCHAR(64), status VARCHAR(32))");
jdbc.execute("CREATE TABLE ll_outbound_pda_task(customer_id VARCHAR(128), outbound_order_id BIGINT, completed_at TIMESTAMP, status VARCHAR(32))");
jdbc.execute("CREATE TABLE ll_warehouse_transfer_order(customer_id VARCHAR(128), relabel_application_id BIGINT, relabel_no VARCHAR(64), outbound_at TIMESTAMP)");
var config = new MybatisConfiguration();
config.setMapUnderscoreToCamelCase(true);
config.addMapper(RelabelApplicationMapper.class);
var factory = new MybatisSqlSessionFactoryBean();
factory.setDataSource(ds); factory.setConfiguration(config);
mapper = new SqlSessionTemplate(factory.getObject()).getMapper(RelabelApplicationMapper.class);
jdbc.update("INSERT INTO ll_relabel_application VALUES (1,'C','PENDING_REVIEW','RL-1',NULL)");
}
void history(String from, String to, LocalDateTime at) {
jdbc.update("INSERT INTO ll_relabel_status_history VALUES ('C',1,?,?,?)", from, to, at);
}
@Test void historicalCompletedApplicationUsesActualFactsAndCustomerScope() {
history("PENDING_REVIEW", "PENDING_PROCESSING", review);
history("PROCESSING", "CUSTOMER_CONFIRM_PENDING", completion.plusMinutes(1));
history("READY_FOR_OUTBOUND", "COMPLETED", outbound.plusMinutes(1));
jdbc.update("INSERT INTO ll_relabel_pda_task VALUES ('C',1,?)", completion);
jdbc.update("UPDATE ll_relabel_application SET status='COMPLETED', outbound_order_no='OB-1'");
jdbc.update("INSERT INTO ll_outbound_order VALUES (2,'C','OB-1','OUTBOUNDED')");
jdbc.update("INSERT INTO ll_outbound_pda_task VALUES ('C',2,?,'COMPLETED')", outbound);
jdbc.update("INSERT INTO ll_relabel_status_history VALUES ('OTHER',1,'PENDING_REVIEW','PENDING_PROCESSING',?)", outbound);
var result = mapper.findMilestones("C", 1L);
assertEquals(review, result.reviewedAt());
assertEquals(completion, result.relabelCompletedAt());
assertEquals(outbound, result.outboundAt());
assertNull(mapper.findMilestones("OTHER", 1L));
}
@Test void pendingAndReworkDoNotInventCompletion() {
var pending = mapper.findMilestones("C", 1L);
// MyBatis returns null for a projection whose columns are all null; the service handles it.
assertNull(pending);
history("PENDING_REVIEW", "PENDING_PROCESSING", review);
history("PROCESSING", "CUSTOMER_CONFIRM_PENDING", completion);
jdbc.update("INSERT INTO ll_relabel_pda_task VALUES ('C',1,?)", completion);
jdbc.update("UPDATE ll_relabel_application SET status='PROCESSING'");
var rework = mapper.findMilestones("C", 1L);
assertEquals(review, rework.reviewedAt());
assertNull(rework.relabelCompletedAt()); assertNull(rework.outboundAt());
}
@Test void skuOnlyCompletionIsNotOutboundAndResubmissionUsesLatestReview() {
history("PENDING_REVIEW", "REJECTED", review.minusHours(1));
history("PENDING_REVIEW", "PENDING_PROCESSING", review);
history("PROCESSING", "CUSTOMER_CONFIRM_PENDING", completion);
history("CUSTOMER_CONFIRM_PENDING", "COMPLETED", completion.plusMinutes(5));
jdbc.update("UPDATE ll_relabel_application SET status='COMPLETED'");
var result = mapper.findMilestones("C", 1L);
assertEquals(review, result.reviewedAt()); assertEquals(completion, result.relabelCompletedAt());
assertNull(result.outboundAt());
}
@Test void truckDepartureAndLinkedTransferUseOutboundFacts() {
history("READY_FOR_OUTBOUND", "COMPLETED", outbound);
jdbc.update("UPDATE ll_relabel_application SET status='COMPLETED'");
assertEquals(outbound, mapper.findMilestones("C", 1L).outboundAt());
jdbc.update("DELETE FROM ll_relabel_status_history");
jdbc.update("UPDATE ll_relabel_application SET status='READY_FOR_OUTBOUND'");
jdbc.update("INSERT INTO ll_warehouse_transfer_order VALUES ('C',1,'RL-1',?)", outbound);
assertEquals(outbound, mapper.findMilestones("C", 1L).outboundAt());
}
}
@@ -52,6 +52,7 @@ class PdaRelabelServiceTest {
@Mock PdaRelabelHistoryMapper histories;
@Mock RelabelApplicationService applicationService;
@Mock RelabelPhotoFileGateway photoFiles;
@Mock com.baiyue.lastleg.relabel.service.ReturnSkuConversionService skuConversions;
PdaRelabelService service;
RelabelApplication application;
RelabelItem item;
@@ -60,6 +61,7 @@ class PdaRelabelServiceTest {
@BeforeEach
void setUp() {
service = new PdaRelabelService();
ReflectionTestUtils.setField(service, "skuConversions", skuConversions);
ReflectionTestUtils.setField(service, "applications", applications);
ReflectionTestUtils.setField(service, "bindingGuard", org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.service.RelabelBindingGuard.class));
ReflectionTestUtils.setField(service, "items", items);
@@ -450,6 +452,7 @@ class PdaRelabelServiceTest {
new PdaRelabelRequests.Complete(List.of(501L), 1, "complete-1", "done"));
verify(photoFiles).validate("100", 20L, 501L);
verify(skuConversions).apply(eq(application), org.mockito.ArgumentMatchers.anyList(), org.mockito.ArgumentMatchers.anyList(), anyString(), org.mockito.ArgumentMatchers.anyList());
assertEquals(PdaRelabelService.WAIT_REVIEW, result.taskStatus());
assertEquals("CUSTOMER_CONFIRM_PENDING", result.applicationStatus());
verify(applicationService).changeStatus(eq("100"), eq("FBR-1"), eq("7"), any());
@@ -483,6 +486,7 @@ class PdaRelabelServiceTest {
assertEquals("picker", task.getOperatorName());
assertTrue(result.completionPhotoFileIds().isEmpty());
verify(applicationService).resetForRework("100", "FBR-1", "8", "wrong label");
verify(skuConversions).reverse(application);
verify(applicationService, never()).changeStatus(anyString(), anyString(), anyString(), any());
}
@@ -66,6 +66,21 @@ import static org.mockito.Mockito.times;
@ExtendWith(MockitoExtension.class)
class RelabelApplicationServiceTest {
@Test void summaryReturnsMilestonesFromCustomerScopedFacts() {
var application = new RelabelApplication();
application.setId(42L); application.setCustomerId("10001");
var reviewed = LocalDateTime.of(2026, 10, 12, 10, 0);
var completed = reviewed.plusHours(1);
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());
assertNotNull(result);
assertEquals(reviewed, result.reviewedAt());
assertEquals(completed, result.relabelCompletedAt());
assertEquals(outbound, result.outboundAt());
}
@Test void returnInventoryCandidateUsesRealProductNames() {
var cartonMapper = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper.class);
var lineMapper = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper.class);
@@ -85,6 +100,37 @@ class RelabelApplicationServiceTest {
assertEquals(4L, candidate.stockQuantity());
assertEquals("AVAILABLE", candidate.inventoryStatus());
assertEquals(List.of(), candidate.mergedIntoCartonMarks());
carton.setStatus("RELABEL_PENDING");
var occupied = service.candidates("C", "W", null).get(0);
assertEquals(0, occupied.availablePackages());
assertEquals(2, occupied.skuItems().size(), "处理中库存保留占用信息,但不可再次选择");
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(longs = {0, 2, 5, 8})
void returnCandidatesOnlyOfferUnlabelledPieces(long completed) {
var cartons = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper.class);
var lines = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper.class);
ReflectionTestUtils.setField(service, "returnCartons", cartons);
ReflectionTestUtils.setField(service, "returnCartonItems", lines);
ReflectionTestUtils.setField(service, "returnPackingTraces", org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.class));
var carton = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton();
carton.setId(20L); carton.setStatus("AVAILABLE"); carton.setTotalQuantity(10L);
var first = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem();
first.setSkuCode("A"); first.setQuantity(5L);
var second = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem();
second.setSkuCode("B"); second.setQuantity(5L); second.setPackingReservedQuantity(1L);
when(cartons.findRelabelCandidates("C", "W")).thenReturn(List.of(carton));
when(lines.listCarton(20L)).thenReturn(List.of(first, second));
when(cartons.completedSkuQuantity(20L, "A")).thenReturn(5L);
when(cartons.completedSkuQuantity(20L, "B")).thenReturn(completed);
var candidates = service.candidates("C", "W", null);
if (completed >= 4) assertTrue(candidates.isEmpty());
else {
assertEquals(1, candidates.size());
assertEquals(4 - completed, candidates.get(0).availablePackages());
assertEquals(List.of(new RelabelResponses.SkuItem("B", null, (int) (4 - completed), null)), candidates.get(0).skuItems());
assertEquals(10L, candidates.get(0).stockQuantity());
}
}
@Mock RelabelApplicationMapper applications;
@Mock RelabelItemMapper items;
@@ -110,6 +156,8 @@ class RelabelApplicationServiceTest {
@BeforeEach
void setUp() {
service = new RelabelApplicationService();
ReflectionTestUtils.setField(service, "skuConversions", org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.service.ReturnSkuConversionService.class));
ReflectionTestUtils.setField(service, "skuConversionRows", org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnSkuConversionMapper.class));
ReflectionTestUtils.setField(service, "pdaTasks", pdaTasks);
lenient().when(pdaTasks.lock(any(), any())).thenAnswer(call -> {
var task = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask();
@@ -1009,8 +1057,8 @@ class RelabelApplicationServiceTest {
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"CARTON_LABEL", "SKU_LABEL", "CARTON_AND_SKU"})
void intactParcelSupportsAllLabelOperationsWithoutMergingIntoBox(String type) {
@org.junit.jupiter.params.provider.CsvSource({"CARTON_LABEL,0", "SKU_LABEL,0", "SKU_LABEL,2", "CARTON_AND_SKU,0"})
void intactParcelSupportsAllLabelOperationsWithoutMergingIntoBox(String type, long completed) {
var cartons = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper.class);
var lines = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper.class);
ReflectionTestUtils.setField(service, "returnCartons", cartons); ReflectionTestUtils.setField(service, "returnCartonItems", lines);
@@ -1020,6 +1068,7 @@ class RelabelApplicationServiceTest {
parcel.setCountStatus("CONFIRMED"); parcel.setTotalQuantity(5L);
var line = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem(); line.setSkuCode("SKU-A"); line.setQuantity(5L);
when(cartons.lockId(901L)).thenReturn(parcel); when(lines.lockCarton(901L)).thenReturn(List.of(line));
when(cartons.completedSkuQuantity(901L, "SKU-A")).thenReturn(completed);
when(cartons.markRelabelPending(anyLong(), anyLong(), anyString(), any())).thenReturn(1);
var app = application(11L, "RL-PARCEL", "10001", "parcel-key", null, "PENDING_REVIEW"); app.setLastlegOrderId(21L);
when(applications.findNo(eq("10001"), any())).thenReturn(app);
@@ -1027,7 +1076,7 @@ class RelabelApplicationServiceTest {
var detail = new RelabelRequests.Item(null, null, null, "SKU-A", null, null, null,
"Return goods", 1, 1, "RT-1", null, "NEW-A", null, null, null, null, null,
"PARCEL-1", "FBA-PARCEL", "XLX1", type,
"CARTON_LABEL".equals(type) ? List.of() : List.of(new RelabelRequests.SkuItem("SKU-A", "NEW-A", 5, null)), 901L);
"CARTON_LABEL".equals(type) ? List.of() : List.of(new RelabelRequests.SkuItem("SKU-A", "NEW-A", (int) (5 - completed), null)), 901L);
service.create("10001", "operator", new RelabelRequests.Create("RT-1", "WH-1", LocalDateTime.now().plusDays(1),
"Contact", "19900000000", null, null, "parcel-key", List.of(detail), "SKU_LABEL".equals(type) ? null : "EXPRESS", false));
var stored = ArgumentCaptor.forClass(RelabelItem.class); verify(items).insert(stored.capture());
@@ -1054,8 +1103,9 @@ class RelabelApplicationServiceTest {
assertTrue(error.getReason().contains("独立新箱")); verify(applications, never()).insert(any(RelabelApplication.class));
}
@Test
void returnSkuRelabelRejectsQuantityBeyondSelectedSourceBox() {
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(longs = {3, 5})
void returnSkuRelabelRejectsAlreadyLabelledPieces(long completed) {
var cartonMapper = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper.class);
var lineMapper = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper.class);
ReflectionTestUtils.setField(service, "returnCartons", cartonMapper);
@@ -1068,14 +1118,15 @@ class RelabelApplicationServiceTest {
line.setSkuCode("SKU-A"); line.setQuantity(5L); line.setPackingReservedQuantity(0L);
when(cartonMapper.lockId(901L)).thenReturn(carton);
when(lineMapper.lockCarton(901L)).thenReturn(List.of(line));
when(cartonMapper.completedSkuQuantity(901L, "SKU-A")).thenReturn(completed);
var detail = new RelabelRequests.Item(null, null, null, "SKU-A", null, null, null,
"Return goods", 1, 1, "RT-1", null, "NEW-A", null, null,
null, null, null, "RETURN-BOX-1", null, null, "SKU_LABEL",
List.of(new RelabelRequests.SkuItem("SKU-A", "NEW-A", 6, null)), 901L);
List.of(new RelabelRequests.SkuItem("SKU-A", "NEW-A", 3, null)), 901L);
var request = new RelabelRequests.Create("RT-1", "WH-1", LocalDateTime.now().plusDays(1),
"Li", "13800000000", null, null, "return-over-quantity", List.of(detail), null, false);
var error = assertThrows(ResponseStatusException.class, () -> service.create("10001", "operator-1", request));
assertTrue(error.getReason().contains("超过来源箱可用数量"));
assertTrue(error.getReason().contains(completed == 5 ? "已全部换标" : "超过来源箱可用数量"));
verify(applications, never()).insert(any(RelabelApplication.class));
}
@@ -20,6 +20,7 @@ class RelabelReturnCartonCandidateTest {
@Test
void candidatesIncludeAvailableMergedReturnCartonByWarehouseMark() {
RelabelApplicationService service = new RelabelApplicationService();
ReflectionTestUtils.setField(service, "skuConversionRows", mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnSkuConversionMapper.class));
InventoryBalanceMapper balances = mock(InventoryBalanceMapper.class);
InventoryPositionMapper positions = mock(InventoryPositionMapper.class);
ReturnInventoryCartonMapper cartons = mock(ReturnInventoryCartonMapper.class);
@@ -42,6 +42,34 @@ import org.springframework.web.server.ResponseStatusException;
@ExtendWith(MockitoExtension.class)
class ReturnInventoryServiceTest {
@Test
void platformStockIncludesRelabelLockedQuantity() {
var balance = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryBalance();
balance.setCustomerId("10001"); balance.setOverseasWarehouseId("42"); balance.setSkuCode("SKU-A");
balance.setTotalQuantity(100L); balance.setAvailableQuantity(50L); balance.setRelabelLockedQuantity(30L);
when(balances.platformCount("10001", "42", "SKU-A")).thenReturn(1L);
when(balances.platformPage("10001", "42", "SKU-A", 0L, 20L)).thenReturn(List.of(balance));
var result = service.platformStockPage(new ReturnInventoryRequests.PlatformQuery(1, 20, "10001", "42", "SKU-A", null, null, null));
assertThat(result.records().get(0).relabelLockedQuantity()).isEqualTo(30L);
assertThat(result.records().get(0).availableQuantity()).isEqualTo(50L);
}
@Test
void summarySeparatesRelabelLockedStockFromOtherUnavailableStock() {
var balance = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryBalance();
balance.setOverseasWarehouseId("42"); balance.setSkuCode("SKU-A");
balance.setTotalQuantity(100L); balance.setAvailableQuantity(50L);
balance.setRelabelLockedQuantity(30L); balance.setCartonQuantity(3L);
when(balances.countPage("10001", "42", "SKU-A")).thenReturn(1L);
when(balances.page("10001", "42", "SKU-A", 0L, 20L)).thenReturn(List.of(balance));
var result = service.summaryPage("10001", new ReturnInventoryRequests.SummaryQuery(1, 20, "42", "SKU-A"));
assertThat(result.records().get(0).relabelLockedQuantity()).isEqualTo(30L);
assertThat(result.records().get(0).availableQuantity()).isEqualTo(50L);
assertThat(result.records().get(0).totalQuantity()).isEqualTo(100L);
}
@Mock ReturnInventoryCartonMapper cartons;
@Mock ReturnInventoryCartonItemMapper cartonItems;
@Mock ReturnInventoryBalanceMapper balances;
@@ -60,6 +88,7 @@ class ReturnInventoryServiceTest {
@BeforeEach
void setUp() {
service = new ReturnInventoryService();
ReflectionTestUtils.setField(service, "skuConversions", org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnSkuConversionMapper.class));
ReflectionTestUtils.setField(service, "cartons", cartons);
ReflectionTestUtils.setField(service, "cartonItems", cartonItems);
ReflectionTestUtils.setField(service, "balances", balances);
@@ -374,7 +403,7 @@ class ReturnInventoryServiceTest {
@Test void completedLabelQuantityCannotBeGuessedWhenSplittingAStoredCarton() {
prepareConcurrentSource();
when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 2L)));
when(cartons.relabelledQuantity(31L, "SKU-A")).thenReturn(1L);
when(cartons.completedSkuQuantity(31L, "SKU-A")).thenReturn(1L);
var error = assertThrows(ResponseStatusException.class, () -> service.merge(
new PdaWarehouseIdentity("operator-2", "name", 42L), mergeRequest(1)));
assertThat(error.getReason()).contains("不能自动分配来源", "独立新箱");
@@ -385,7 +414,7 @@ class ReturnInventoryServiceTest {
@Test void wholeCartonMergeCarriesItsCompletedRelabelFactsToTheTarget() {
var source = prepareConcurrentSource();
when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 2L)));
when(cartons.relabelledQuantity(31L, "SKU-A")).thenReturn(2L);
when(cartons.completedSkuQuantity(31L, "SKU-A")).thenReturn(2L);
when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-TARGET");
when(balances.adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong())).thenReturn(1);
var relabel = org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.mapper.RelabelItemMapper.class);
@@ -0,0 +1,189 @@
package com.baiyue.lastleg.returnreceipt.service;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.service.ReturnSkuConversionService;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact;
import com.baiyue.lastleg.returnreceipt.entity.*;
import com.baiyue.lastleg.returnreceipt.mapper.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.*;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import org.springframework.web.server.ResponseStatusException;
import java.lang.reflect.Modifier;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
/** Real production SQL, contents, balances, provenance and rollback in one database transaction. */
class ReturnSkuConversionPersistenceTest {
JdbcTemplate jdbc;
ReturnSkuConversionService service, target;
ReturnInventoryBalanceMapper balances;
DataSourceTransactionManager transactions;
@BeforeEach void setUp() throws Exception {
var ds = new DriverManagerDataSource("jdbc:h2:mem:sku-conversion-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
jdbc = new JdbcTemplate(ds); transactions = new DataSourceTransactionManager(ds);
for (var entity : List.of(ReturnInventoryCarton.class, ReturnInventoryCartonItem.class, ReturnInventoryBalance.class,
ReturnSkuConversion.class, ReturnInventoryOperation.class, RelabelItem.class)) {
var columns = Arrays.stream(entity.getDeclaredFields()).filter(f -> !Modifier.isStatic(f.getModifiers()))
.filter(f -> f.getAnnotation(com.baomidou.mybatisplus.annotation.TableField.class) == null
|| f.getAnnotation(com.baomidou.mybatisplus.annotation.TableField.class).exist())
.map(f -> f.getName().replaceAll("([A-Z])", "_$1").toLowerCase() + " "
+ (f.getType() == String.class ? "VARCHAR(8000)" : f.getType() == LocalDateTime.class ? "TIMESTAMP"
: f.getType() == java.math.BigDecimal.class ? "DECIMAL(20,6)" : "BIGINT")
+ (f.getName().equals("id") ? " PRIMARY KEY" : "")).collect(Collectors.joining(","));
jdbc.execute("CREATE TABLE " + entity.getAnnotation(TableName.class).value() + " (" + columns + ")");
}
jdbc.execute("CREATE UNIQUE INDEX balance_sku ON ll_return_inventory_balance(customer_id,overseas_warehouse_id,sku_code)");
jdbc.execute("CREATE UNIQUE INDEX conversion_round ON ll_return_sku_conversion(item_id,round_key,old_sku_code,new_sku_code)");
jdbc.execute("CREATE TABLE ll_return_packing_trace(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),operation_id BIGINT,source_carton_mark VARCHAR(128),target_carton_mark VARCHAR(128),return_no VARCHAR(64),sku_code VARCHAR(128),quantity BIGINT,created_at TIMESTAMP)");
var config = new MybatisConfiguration(); config.setMapUnderscoreToCamelCase(true);
for (var mapper : List.of(ReturnSkuConversionMapper.class, ReturnInventoryCartonMapper.class,
ReturnInventoryCartonItemMapper.class, ReturnInventoryBalanceMapper.class, ReturnPackingTraceMapper.class,
ReturnInventoryOperationMapper.class)) config.addMapper(mapper);
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(config);
var session = new SqlSessionTemplate(factory.getObject());
target = new ReturnSkuConversionService();
ReflectionTestUtils.setField(target, "conversions", session.getMapper(ReturnSkuConversionMapper.class));
ReflectionTestUtils.setField(target, "cartons", session.getMapper(ReturnInventoryCartonMapper.class));
ReflectionTestUtils.setField(target, "contents", session.getMapper(ReturnInventoryCartonItemMapper.class));
balances = session.getMapper(ReturnInventoryBalanceMapper.class);
ReflectionTestUtils.setField(target, "balances", balances);
ReflectionTestUtils.setField(target, "traces", session.getMapper(ReturnPackingTraceMapper.class));
ReflectionTestUtils.setField(target, "operations", session.getMapper(ReturnInventoryOperationMapper.class));
ReflectionTestUtils.setField(target, "json", new ObjectMapper());
var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(transactions, new AnnotationTransactionAttributeSource()));
service = (ReturnSkuConversionService) proxy.getProxy();
}
@ParameterizedTest @ValueSource(strings={"BOX", "PARCEL"})
void partialConversionPreservesPhysicalUnitAndLocksUntilConfirmation(String type) {
fixture(type); apply(1, 11, 8, "INITIAL");
assertStock("A", 12, 0); assertStock("B", 8, 8);
assertEquals(20, count("SELECT total_quantity FROM ll_return_inventory_carton WHERE id=101"));
assertEquals(2, count("SELECT sku_types FROM ll_return_inventory_carton WHERE id=101"));
assertEquals(12, balance("A", "total_quantity")); assertEquals(8, balance("B", "total_quantity"));
assertEquals("BOX".equals(type) ? 1 : 0, balance("B", "carton_quantity"));
var locked = balances.page("C", "W", "B", 0, 20).get(0);
assertEquals(0, locked.getAvailableQuantity()); assertEquals(8, locked.getRelabelLockedQuantity());
assertEquals(8, count("SELECT SUM(quantity) FROM ll_return_packing_trace WHERE target_carton_mark='UNIT-1' AND sku_code='B'"));
service.confirm(app(1));
jdbc.update("UPDATE ll_return_inventory_carton SET status='AVAILABLE',relabel_application_id=NULL WHERE id=101");
assertEquals(8, balances.page("C", "W", "B", 0, 20).get(0).getAvailableQuantity());
assertEquals("CONFIRMED", jdbc.queryForObject("SELECT status FROM ll_return_sku_conversion", String.class));
}
@Test void replayAndLaterPartialRoundDoNotConvertPriorPiecesTwice() {
fixture("BOX"); apply(1, 11, 8, "INITIAL"); apply(1, 11, 8, "INITIAL");
assertEquals(1, count("SELECT COUNT(*) FROM ll_return_sku_conversion")); assertStock("A", 12, 0);
service.confirm(app(1)); jdbc.update("UPDATE ll_return_inventory_carton SET relabel_application_id=2 WHERE id=101");
apply(2, 12, 5, "INITIAL");
assertStock("A", 7, 0); assertStock("B", 13, 13); assertEquals(20, balance("A", "total_quantity") + balance("B", "total_quantity"));
var repeated = item(13, 2, "B", "C", 1);
assertThrows(ResponseStatusException.class, () -> service.apply(app(2), List.of(repeated), List.of(fact(13, 1, "RELABELLED")), "INITIAL"));
assertStock("B", 13, 13);
}
@Test void fullConversionRemovesOldContentsButKeepsTheCartonAndOriginalReceipt() {
fixture("BOX"); apply(1, 11, 20, "INITIAL");
assertEquals(0, count("SELECT COUNT(*) FROM ll_return_inventory_carton_item WHERE sku_code='A'"));
assertStock("B", 20, 20); assertEquals(0, balance("A", "total_quantity")); assertEquals(0, balance("A", "carton_quantity"));
assertEquals(1, count("SELECT COUNT(*) FROM ll_return_inventory_carton WHERE carton_mark='UNIT-1' AND source_return_no='RT-1'"));
}
@Test void conversionIntoExistingSkuKeepsItsUnlabelledPiecesSelectable() {
fixture("BOX");
jdbc.update("INSERT INTO ll_return_inventory_carton_item(id,carton_id,customer_id,sku_code,quantity,relabelled_quantity,packing_reserved_quantity) VALUES(202,101,'C','B',4,0,0)");
jdbc.update("UPDATE ll_return_inventory_carton SET total_quantity=24,sku_types=2 WHERE id=101");
balances.adjust(302, "C", "W", "B", "Goods", 4, 4, 1);
apply(1, 11, 8, "INITIAL"); assertStock("B", 12, 8); assertEquals(1, balance("B", "carton_quantity"));
assertEquals(4, count("SELECT quantity-relabelled_quantity FROM ll_return_inventory_carton_item WHERE sku_code='B'"));
}
@Test void reworkReversesOnlyCurrentRoundAndCanBeAppliedAgain() {
fixture("PARCEL"); apply(1, 11, 8, "INITIAL"); service.reverse(app(1)); service.reverse(app(1));
assertStock("A", 20, 0); assertEquals(0, balance("B", "total_quantity"));
assertEquals(0, count("SELECT COUNT(*) FROM ll_return_inventory_carton_item WHERE sku_code='B'"));
apply(1, 11, 8, "REWORK-1"); assertStock("A", 12, 0); assertStock("B", 8, 8);
assertEquals(2, count("SELECT COUNT(*) FROM ll_return_sku_conversion"));
service.confirm(app(1)); assertThrows(ResponseStatusException.class, () -> service.reverse(app(1)));
}
@Test void onlyActualCompletedFactsProduceInventoryAndBulkCountsPieces() {
fixture("PARCEL"); var item = item(11, 1, "A", "B", 20); item.setOperationType("RETURN_BULK_SKU_LABEL");
service.apply(app(1), List.of(item), List.of(fact(11, 8, "RELABELLED"), fact(11, 12, "EXCEPTION")), "INITIAL");
assertStock("A", 12, 0); assertStock("B", 8, 8);
}
@Test void balanceFailureRollsBackContentsLedgerAndOwnership() {
fixture("BOX"); var failing = spy(balances);
doReturn(0).when(failing).adjust(anyLong(), anyString(), anyString(), eq("B"), any(), anyLong(), anyLong(), anyLong());
ReflectionTestUtils.setField(target, "balances", failing);
assertThrows(ResponseStatusException.class, () -> apply(1, 11, 8, "INITIAL"));
assertStock("A", 20, 0); assertEquals(20, balance("A", "total_quantity"));
assertEquals(0, count("SELECT COUNT(*) FROM ll_return_inventory_carton_item WHERE sku_code='B'"));
assertEquals(0, count("SELECT COUNT(*) FROM ll_return_sku_conversion"));
assertEquals(0, count("SELECT COUNT(*) FROM ll_return_packing_trace"));
}
@Test void manualCompletionOnlyConvertsSkuLinesActuallyScannedInCurrentRound() {
fixture("BOX");
var item = item(11,1,"A","B",8);
item.setSkuRelabelItemsJson("[{\"skuCode\":\"A\",\"newSkuCode\":\"B\",\"quantity\":8},{\"skuCode\":\"A\",\"newSkuCode\":\"C\",\"quantity\":4}]");
var scan = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory(); scan.setAction("SKU_SCAN");
scan.setDetailJson("{\"round\":\"CURRENT\",\"itemId\":\"11\",\"skuIndex\":0,\"oldSku\":\"A\",\"newSku\":\"B\",\"quantity\":8}");
var previous = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory(); previous.setAction("SKU_SCAN");
previous.setDetailJson("{\"round\":\"PREVIOUS\",\"itemId\":\"11\",\"skuIndex\":1,\"oldSku\":\"A\",\"newSku\":\"C\",\"quantity\":4}");
service.apply(app(1), List.of(item), List.of(fact(11,1,"EXCEPTION")), "CURRENT", List.of(scan,previous));
assertStock("A",12,0); assertStock("B",8,8);
assertEquals(0,count("SELECT COUNT(*) FROM ll_return_inventory_carton_item WHERE sku_code='C'"));
}
@Test void foreignCustomerOrWarehouseCannotConvertStock() {
fixture("BOX"); var foreign = app(1); foreign.setCustomerId("OTHER");
assertThrows(ResponseStatusException.class, () -> service.apply(foreign, List.of(item(11,1,"A","B",8)), List.of(fact(11,1,"RELABELLED")), "INITIAL"));
foreign.setCustomerId("C"); foreign.setOverseasWarehouseId("OTHER");
assertThrows(ResponseStatusException.class, () -> service.apply(foreign, List.of(item(11,1,"A","B",8)), List.of(fact(11,1,"RELABELLED")), "INITIAL"));
assertStock("A", 20, 0);
}
void fixture(String type) {
jdbc.update("INSERT INTO ll_return_inventory_carton(id,customer_id,overseas_warehouse_id,carton_mark,source_return_no,source_unit_type,status,count_status,total_quantity,sku_types,relabel_application_id) VALUES(101,'C','W','UNIT-1','RT-1',?,'RELABEL_PENDING','CONFIRMED',20,1,1)",type);
jdbc.update("INSERT INTO ll_return_inventory_carton_item(id,carton_id,customer_id,sku_code,product_name_zh,quantity,relabelled_quantity,packing_reserved_quantity) VALUES(201,101,'C','A','Goods',20,0,0)");
balances.adjust(301,"C","W","A","Goods",20,20,"BOX".equals(type)?1:0);
}
void apply(long appId, long itemId, int quantity, String round) {
service.apply(app(appId), List.of(item(itemId,appId,"A","B",quantity)), List.of(fact(itemId,1,"RELABELLED")), round);
}
RelabelApplication app(long id) { var app=new RelabelApplication(); app.setId(id); app.setCustomerId("C"); app.setOverseasWarehouseId("W"); app.setRelabelNo("RL-"+id); return app; }
RelabelItem item(long id,long appId,String oldSku,String newSku,int quantity) {
var item=new RelabelItem(); item.setId(id); item.setApplicationId(appId); item.setReturnCartonId(101L);
item.setOperationType("SKU_LABEL"); item.setSkuCode(oldSku); item.setNewSkuCode(newSku);
item.setSkuRelabelItemsJson("[{\"skuCode\":\""+oldSku+"\",\"newSkuCode\":\""+newSku+"\",\"quantity\":"+quantity+"}]"); return item;
}
PdaRelabelUnitFact fact(long itemId,int quantity,String result) { var f=new PdaRelabelUnitFact(); f.setItemId(itemId); f.setQuantity(quantity); f.setResult(result); return f; }
long count(String sql) { return jdbc.queryForObject(sql,Long.class); }
long balance(String sku,String column) { return jdbc.queryForObject("SELECT "+column+" FROM ll_return_inventory_balance WHERE sku_code=?",Long.class,sku); }
void assertStock(String sku,long quantity,long labelled) {
assertEquals(quantity,jdbc.queryForObject("SELECT quantity FROM ll_return_inventory_carton_item WHERE sku_code=?",Long.class,sku));
assertEquals(labelled,jdbc.queryForObject("SELECT relabelled_quantity FROM ll_return_inventory_carton_item WHERE sku_code=?",Long.class,sku));
}
}
@@ -216,6 +216,7 @@ class WarehouseTransferRelabelPersistenceTest {
when(tasks.lock("7",11L)).thenReturn(task);when(tasks.updateWithVersion(any(),any())).thenReturn(1);
ReflectionTestUtils.setField(service,"pdaTasks",tasks);
ReflectionTestUtils.setField(service,"returnOrders",mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper.class));
ReflectionTestUtils.setField(service,"skuConversions",mock(com.baiyue.lastleg.relabel.service.ReturnSkuConversionService.class));
ReflectionTestUtils.setField(service,"histories",mock(com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper.class));
ReflectionTestUtils.setField(service,"orderHistories",mock(com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper.class));
var orders=mock(com.baiyue.lastleg.mapper.LastlegOrderMapper.class);var order=new com.baiyue.lastleg.entity.LastlegOrder();