贴标申请,仓储转运

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
@@ -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); }