diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 2a6702155..5729d61f6 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -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换标库存转换和冲回记录'; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java index 1becabfeb..47391ee83 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java @@ -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 positions, String sourceType, Long returnCartonId, String cartonMark, List skuItems, long relabelledPackages, String relabelStatus, - long relabelledQuantity, long relabelReservedQuantity, Long stockQuantity, String inventoryStatus, String occupiedRelabelNo, List mergedIntoCartonMarks, boolean hasOutbound) { + long relabelledQuantity, long relabelReservedQuantity, Long stockQuantity, String inventoryStatus, String occupiedRelabelNo, List mergedIntoCartonMarks, boolean hasOutbound, + List 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, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelApplicationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelApplicationMapper.java index 7184b7048..c0cc1f19e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelApplicationMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelApplicationMapper.java @@ -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 { + // 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); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java index eff51a3c3..76cc186ff 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java @@ -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() + .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 previousFacts = factList(task); insertHistory(task, "REWORK_ARCHIVE", WAIT_REVIEW, AVAILABLE, operatorKey, operatorName, Map.of("facts", previousFacts, "photoFileIds", Objects.toString(task.getCompletionPhotoFileIds(), "[]"), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java index 681be318e..37e764e0c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java @@ -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 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 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 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 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"; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/ReturnSkuConversionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/ReturnSkuConversionService.java new file mode 100644 index 000000000..b73f5a853 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/ReturnSkuConversionService.java @@ -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 items, List facts, String round) { + apply(app, items, facts, round, List.of()); + } + + @Transactional + public void apply(RelabelApplication app, List items, List facts, String round, + List 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(); + 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 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(); + 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 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 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 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); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryResponses.java index 610a1d433..723cd5e31 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryResponses.java @@ -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 items, String locationCode, String palletCode, LocalDateTime putawayAt, List mergeHistory, - long relabelledQuantity, long relabelReservedQuantity) { } + long relabelledQuantity, long relabelReservedQuantity, List 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