Merge remote-tracking branch 'origin/test' into test
This commit is contained in:
@@ -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,
|
||||
|
||||
+29
@@ -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);
|
||||
|
||||
|
||||
+5
@@ -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(), "[]"),
|
||||
|
||||
+29
-7
@@ -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";
|
||||
|
||||
+216
@@ -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
-5
@@ -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,
|
||||
|
||||
+4
-1
@@ -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;
|
||||
|
||||
+2
@@ -15,4 +15,6 @@ public class ReturnInventoryCartonItem {
|
||||
private String productNameZh;
|
||||
private Long quantity;
|
||||
private Long packingReservedQuantity;
|
||||
/** 当前 SKU 中已经换过标的实物件数,不再允许二次换标。 */
|
||||
private Long relabelledQuantity;
|
||||
}
|
||||
|
||||
+28
@@ -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;
|
||||
}
|
||||
+10
-1
@@ -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,
|
||||
|
||||
+15
-2
@@ -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) - " +
|
||||
|
||||
+5
-1
@@ -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>" +
|
||||
|
||||
+1
-1
@@ -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>" +
|
||||
|
||||
+16
@@ -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);
|
||||
}
|
||||
+2
@@ -17,4 +17,6 @@ public class ReturnInventoryRow {
|
||||
private String skuCode;
|
||||
private String productNameZh;
|
||||
private Long quantity;
|
||||
private Long relabelledQuantity;
|
||||
private Long packingReservedQuantity;
|
||||
}
|
||||
|
||||
+35
-11
@@ -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) {
|
||||
|
||||
+9
-3
@@ -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:
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+97
@@ -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());
|
||||
}
|
||||
}
|
||||
+4
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
+58
-7
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -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);
|
||||
|
||||
+31
-2
@@ -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);
|
||||
|
||||
+189
@@ -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));
|
||||
}
|
||||
}
|
||||
+1
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user