Merge remote-tracking branch 'origin/test' into test

This commit is contained in:
2026-10-19 17:41:05 +08:00
26 changed files with 637 additions and 21 deletions
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.delivery.repository;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.HashSet;
/** 出库查询按实物箱汇总,退货混装 SKU 不重复计箱。 */
@Repository
public class OutboundQuantityRepository {
@Resource private OutboundPalletItemMapper pallets;
/** 废弃退货来源依据已冻结箱唛计数,读取已有单据也不会把件数当箱数。 */
public Integer boxes(OutboundOrder outbound) {
if (!"DISCARD_OUTBOUND".equals(outbound.getOutboundType())) return outbound.getPackageCount();
var lines = pallets.findByOutbound(outbound.getCustomerId(), outbound.getId());
if (lines.isEmpty()) return outbound.getPackageCount();
var returnBoxes = new HashSet<String>();
int count = 0;
for (var line : lines) {
if (line.getReturnSkuQuantity() != null && line.getCartonMarks() != null && !line.getCartonMarks().isBlank()) {
returnBoxes.add(line.getInboundCode() + ":" + line.getCartonMarks());
} else {
count = Math.addExact(count, java.util.Objects.requireNonNullElse(line.getPackageCount(), 0));
}
}
return Math.addExact(count, returnBoxes.size());
}
}
@@ -94,6 +94,7 @@ import java.util.stream.Collectors;
@Service
public class DeliveryTransferService {
@Resource private com.baiyue.lastleg.delivery.repository.OutboundQuantityRepository outboundQuantities;
@Resource private DispatchOrderMapper dispatchOrders;
@Resource private DispatchItemMapper dispatchItems;
@Resource private OutboundOrderMapper outboundOrders;
@@ -1631,7 +1632,8 @@ public class DeliveryTransferService {
d.getDeliveryNo(), d.getStatus(), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(),
d.getDestinationWarehouseName(), value(d.getPalletCount()), value(d.getPackageCount()))).toList(), replayed);
}
private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? null : o.getLxCodes(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getGrossWeightKg(), o.getVolumeWeightKg(), o.getVolumeCbm(), o.getCreatedAt(), o.getOutboundType()); }
/** 出库列表及详情头部按物理箱数汇总,退货件数不充当箱数。 */
private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? null : o.getLxCodes(), o.getStatus(), o.getPalletCount(), "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? outboundQuantities.boxes(o) : o.getPackageCount(), o.getGrossWeightKg(), o.getVolumeWeightKg(), o.getVolumeCbm(), o.getCreatedAt(), o.getOutboundType()); }
private DeliveryResponses.OutboundPalletRow outboundPalletRow(OutboundPalletItem item) {
return new DeliveryResponses.OutboundPalletRow(item.getId(), item.getPalletNo(), item.getCartonMarks(),
item.getInboundCode(), item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(),
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.discard.model;
import com.baiyue.lastleg.discard.entity.CargoDiscardItem;
import java.util.HashSet;
import java.util.List;
/** 废弃单汇总使用物理箱数,退货明细的数量仍保留为 SKU 件数。 */
public final class CargoDiscardQuantities {
/** 数量规则不需要实例化。 */
private CargoDiscardQuantities() { }
/** 退货混装箱按箱 ID 去重,其余来源累加已选择的物理箱数。 */
public static int boxes(List<CargoDiscardItem> lines, boolean actual) {
var returnBoxes = new HashSet<Long>();
int total = 0;
for (var line : lines) {
int quantity = actual ? java.util.Objects.requireNonNullElse(line.getActualDiscardPackages(), 0) : line.getDiscardPackages();
if (line.getReturnCartonId() != null) {
if (quantity > 0) returnBoxes.add(line.getReturnCartonId());
} else {
total = Math.addExact(total, quantity);
}
}
return Math.addExact(total, returnBoxes.size());
}
}
@@ -19,8 +19,9 @@ public final class PdaCargoDiscardDtos {
List<@NotBlank String> scannedCartonMarks, Boolean confirmAllCartons) {
public Confirm(List<ActualLine> lines, List<Long> photoFileIds) { this(lines, photoFileIds, null, false); }
}
/** 明细数量按来源标识单位:退货库存为件,其余箱级库存为箱。 */
public record Sku(Long itemId, String skuCode, String productName, int plannedPackages,
Integer actualPackages, String status) { }
Integer actualPackages, String status, String quantityUnit) { }
public record PalletTask(String discardNo, String customerId, String overseasWarehouseId,
String palletNo, Long profileId, String status, List<Sku> skus, String outboundNo,
String scanMode, List<String> cartonMarks) { }
@@ -117,8 +117,8 @@ public class PdaCargoDiscardService {
.forEach(cartonId -> adjustments.finishReturnCarton(application.getCustomerId(), cartonId));
if (items.listApplication(application.getCustomerId(), application.getId()).stream()
.allMatch(i -> "COMPLETED".equals(i.getPdaStatus()))) {
int actualTotal = items.listApplication(application.getCustomerId(), application.getId()).stream()
.mapToInt(i -> i.getActualDiscardPackages() == null ? 0 : i.getActualDiscardPackages()).sum();
int actualTotal = com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(
items.listApplication(application.getCustomerId(), application.getId()), true);
application.setTotalPackages(actualTotal);
application.setTotalFee(application.getFeePerPackage().multiply(java.math.BigDecimal.valueOf(actualTotal)));
discardService.updateLinkedStatus(application.getCustomerId(), application, CargoDiscardService.APPROVED,
@@ -160,16 +160,20 @@ public class PdaCargoDiscardService {
}
private static final String LOOSE = "散箱";
/** 按实托汇总本单所选箱唛,退货件数与物理箱数分别呈现。 */
private List<PdaCargoDiscardDtos.PalletTask> palletTasks(CargoDiscardApplication application) {
var allItems = items.listApplication(application.getCustomerId(), application.getId());
var grouped = allItems.stream().collect(Collectors.groupingBy(i -> physicalCode(application, i), LinkedHashMap::new, Collectors.toList()));
return grouped.entrySet().stream().map(entry -> {
CargoDiscardItem first = entry.getValue().get(0);
var skus = entry.getValue().stream().map(i -> new PdaCargoDiscardDtos.Sku(i.getId(), i.getSkuCode(), i.getProductName(),
i.getDiscardPackages(), i.getActualDiscardPackages(), i.getPdaStatus() == null ? "PENDING" : i.getPdaStatus())).toList();
i.getDiscardPackages(), i.getActualDiscardPackages(), i.getPdaStatus() == null ? "PENDING" : i.getPdaStatus(),
isReturnItem(i) ? "件" : "箱")).toList();
String status = skus.stream().allMatch(i -> "COMPLETED".equals(i.status())) ? "COMPLETED" : "PENDING";
var marks = entry.getValue().stream().flatMap(i -> cartonMarks(application, i).stream()).distinct().sorted().toList();
boolean cartonScan = LOOSE.equals(entry.getKey()) || entry.getValue().stream().anyMatch(i -> Boolean.TRUE.equals(i.getCartonScanRequired()));
// 退货箱只代表本单选择的箱子,不能因为有托盘号就当成整托废弃。
boolean cartonScan = LOOSE.equals(entry.getKey()) || entry.getValue().stream()
.anyMatch(i -> isReturnItem(i) || Boolean.TRUE.equals(i.getCartonScanRequired()));
if (cartonScan && marks.isEmpty()) throw conflict("散箱废弃任务缺少箱唛");
return new PdaCargoDiscardDtos.PalletTask(application.getDiscardNo(), application.getCustomerId(), application.getOverseasWarehouseId(),
entry.getKey(), first.getInventoryPositionId() != null ? first.getInventoryPositionId() : isReturnItem(first) ? first.getReturnCartonId() : first.getProfileId(),
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.discard.repository;
import com.baiyue.lastleg.discard.entity.CargoDiscardApplication;
import com.baiyue.lastleg.discard.mapper.CargoDiscardItemMapper;
import com.baiyue.lastleg.discard.model.CargoDiscardQuantities;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
/** 从申请明细读取物理箱汇总,不修改历史库存或件数。 */
@Repository
public class CargoDiscardQuantityRepository {
@Resource private CargoDiscardItemMapper items;
/** 列表和详情均按所选实物箱展示;空明细保留申请自身快照。 */
public int boxes(CargoDiscardApplication application) {
var lines = items.listApplication(application.getCustomerId(), application.getId());
return lines.isEmpty() ? application.getTotalPackages() : CargoDiscardQuantities.boxes(lines, false);
}
}
@@ -39,7 +39,7 @@ public class CargoDiscardOutboundService {
return outbound == null ? null : outbound.getOutboundNo();
}
// The caller holds the application lock and owns the approval transaction.
/** 审核事务持有申请锁时创建出库投影,汇总箱数与退货件数分开保存。 */
public void create(CargoDiscardApplication application, String operatorId) {
if (outbounds.findByIdempotency(application.getCustomerId(), key(application)) != null) return;
var lines = items.listApplication(application.getCustomerId(), application.getId());
@@ -51,12 +51,13 @@ public class CargoDiscardOutboundService {
outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND", application.getOverseasWarehouseId(), outbound.getId()));
outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType(TYPE);
outbound.setLxCodes(null); outbound.setRecipientInfo("废弃单 " + application.getDiscardNo());
outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(application.getTotalPackages());
outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, false));
outbound.setIdempotencyKey(key(application)); outbound.setRequestHash(key(application));
outbound.setVolumeWeightKg(BigDecimal.ZERO); outbound.setVolumeCbm(BigDecimal.ZERO);
outbound.setCreatedBy(operatorId); outbound.setCreatedAt(at); outbound.setUpdatedAt(at);
var snapshots = new java.util.ArrayList<OutboundPalletItem>();
var palletCodes = new java.util.HashSet<String>();
var returnBoxIds = new java.util.HashSet<Long>();
for (var line : lines) {
boolean returned = line.getReturnCartonId() != null || "RETURN_CARTON".equals(line.getSourceType());
String palletNo;
@@ -83,11 +84,12 @@ public class CargoDiscardOutboundService {
}
if (palletNo != null && !palletNo.isBlank()) palletCodes.add(palletNo);
OutboundPalletItem snapshot = new OutboundPalletItem();
// Stable one-to-one projection key in a separate table, never used to deduct stock.
// 出库明细与申请明细一一对应,仅作投影,不通过此表扣减库存。
snapshot.setId(line.getId()); snapshot.setCustomerId(application.getCustomerId()); snapshot.setOutboundOrderId(outbound.getId());
snapshot.setPalletNo(palletNo); snapshot.setSkuCode(line.getSkuCode()); snapshot.setInboundCode(line.getInboundCode());
snapshot.setOrderRefs(application.getDiscardNo()); snapshot.setCartonMarks(cartonMarks);
snapshot.setPackageCount(line.getDiscardPackages()); snapshot.setPickedPackageCount(0); snapshot.setStatus("WAIT_PICK");
snapshot.setPackageCount(returned ? (returnBoxIds.add(line.getReturnCartonId()) ? 1 : 0) : line.getDiscardPackages());
snapshot.setPickedPackageCount(0); snapshot.setStatus("WAIT_PICK");
if (returned) snapshot.setReturnSkuQuantity((long) line.getDiscardPackages());
snapshot.setVolumeWeightKg(BigDecimal.ZERO); snapshot.setVolumeCbm(BigDecimal.ZERO); snapshot.setCreatedAt(at);
snapshots.add(snapshot);
@@ -96,15 +98,21 @@ public class CargoDiscardOutboundService {
snapshots.forEach(pallets::insert);
}
/** 同步实物箱的拣货进度,退货 SKU 件数不进入箱数汇总。 */
public void sync(CargoDiscardApplication application) {
OutboundOrder outbound = outbounds.lockByIdempotency(application.getCustomerId(), key(application));
if (outbound == null) throw conflict("废弃出库单不存在,请联系平台核对审核结果");
var lines = items.listApplication(application.getCustomerId(), application.getId());
var returnBoxIds = new java.util.HashSet<Long>();
for (var snapshot : pallets.findByOutbound(application.getCustomerId(), outbound.getId())) {
var line = lines.stream().filter(i -> Objects.equals(i.getId(), snapshot.getId())).findFirst()
.orElseThrow(() -> conflict("废弃出库明细关联失效"));
boolean returned = line.getReturnCartonId() != null;
int boxes = returned ? (returnBoxIds.add(line.getReturnCartonId()) ? 1 : 0) : line.getDiscardPackages();
snapshot.setPackageCount(boxes);
if ("COMPLETED".equals(line.getPdaStatus())) {
snapshot.setPickedPackageCount(Objects.requireNonNullElse(line.getActualDiscardPackages(), 0));
snapshot.setPickedPackageCount(returned ? (Objects.requireNonNullElse(line.getActualDiscardPackages(), 0) > 0 ? boxes : 0)
: Objects.requireNonNullElse(line.getActualDiscardPackages(), 0));
snapshot.setStatus("PICKED");
}
if (CargoDiscardService.CANCELLED.equals(application.getStatus())) snapshot.setStatus("CANCELLED");
@@ -117,7 +125,7 @@ public class CargoDiscardOutboundService {
};
outbound.setStatus(status);
outbound.setLxCodes(null);
if ("OUTBOUNDED".equals(status)) outbound.setPackageCount(application.getTotalPackages());
outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, "OUTBOUNDED".equals(status)));
outbound.setUpdatedAt(LocalDateTime.now()); outbounds.updateById(outbound);
}
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
@@ -50,6 +50,7 @@ import java.util.Set;
@Service
public class CargoDiscardService {
@Resource private com.baiyue.lastleg.discard.repository.CargoDiscardQuantityRepository quantities;
@Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator;
public static final String APPROVAL_PERMISSION = "platform:overseas-warehouse:discard:approve";
public static final String PENDING_REVIEW = "PENDING_REVIEW";
@@ -138,6 +139,7 @@ public class CargoDiscardService {
supported ? null : "当前库存缺少完整箱唛,可按全部库存废弃");
}
/** 校验并预留所选库存,物理箱汇总与退货 SKU 件数分别保留。 */
@Transactional
public CargoDiscardResponses.Detail create(String customerId, String operatorId,
CargoDiscardRequests.Create request) {
@@ -198,7 +200,8 @@ public class CargoDiscardService {
} else if (!warehouseId.equals(itemWarehouseId)) {
throw conflict("One cargo discard application cannot span operating warehouses");
}
total = Math.addExact(total, input.discardPackages());
// 退货明细为件数,整箱只在全部 SKU 校验完成后按箱 ID 计一次。
if (!returnSource) total = Math.addExact(total, input.discardPackages());
}
for (Long cartonId : returnCartonCache.keySet()) {
@@ -209,6 +212,7 @@ public class CargoDiscardService {
.map(i -> i.skuCode().trim()).collect(java.util.stream.Collectors.toSet());
if (!availableSkus.equals(requestedSkus)) throw conflict("目前不支持部分废弃,必须选择退货箱内全部 SKU 库存");
}
total = Math.addExact(total, returnCartonCache.size());
LocalDateTime now = LocalDateTime.now();
if (!StringUtils.hasText(warehouseName)) warehouseName = warehouseNames().get(warehouseId);
CargoDiscardApplication application = new CargoDiscardApplication();
@@ -506,10 +510,12 @@ public class CargoDiscardService {
return names;
}
/** 废弃列表与详情按物理箱汇总,并按每箱单价计算应展示费用。 */
private CargoDiscardResponses.Summary summary(CargoDiscardApplication value, Map<String, String> names) {
int boxes = quantities.boxes(value);
return new CargoDiscardResponses.Summary(value.getId(), value.getCustomerId(), value.getDiscardNo(), value.getInboundCodes(),
value.getOverseasWarehouseId(), StringUtils.hasText(value.getOverseasWarehouseName()) ? value.getOverseasWarehouseName() : names.get(value.getOverseasWarehouseId()), value.getTotalPackages(),
value.getTotalFee(), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(),
value.getOverseasWarehouseId(), StringUtils.hasText(value.getOverseasWarehouseName()) ? value.getOverseasWarehouseName() : names.get(value.getOverseasWarehouseId()), boxes,
value.getFeePerPackage() == null ? value.getTotalFee() : value.getFeePerPackage().multiply(BigDecimal.valueOf(boxes)), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(),
value.getReviewedAt(), outboundService.outboundNo(value), value.getCustomerConfirmedAt());
}
@@ -190,7 +190,9 @@ public class WarehouseTransferService {
private List<WarehouseTransferResponses.SourceCarton> unreservedSourceCartons(String customerId,
String warehouseId, String source, List<WarehouseTransferResponses.SourceCarton> cartons) {
if (inventorySources == null) return cartons;
Set<String> occupied = inventorySources.occupiedMarks(customerId, warehouseId, source).stream()
var occupiedMarks = inventorySources.occupiedMarks(customerId, warehouseId, source);
if (occupiedMarks == null || occupiedMarks.isEmpty()) return cartons;
Set<String> occupied = occupiedMarks.stream()
.filter(StringUtils::hasText).map(mark -> mark.trim().toUpperCase(java.util.Locale.ROOT))
.collect(Collectors.toSet());
return cartons.stream().filter(box -> !occupied.contains(box.cartonMark().trim()
@@ -483,7 +485,6 @@ public class WarehouseTransferService {
// 所有同源提交锁定同一入仓记录;取锁后按最新可用箱校验并保存逐箱占用快照。
if (inventorySources != null && inventorySources.lockSource(customerId, transfer.getInboundOrderId()) == null)
throw conflict("库存来源已失效,请重新选择来源单");
if (inventorySources != null) reserveSourceCartons(customerId, transfer);
}
if (Boolean.TRUE.equals(transfer.getNeedRelabel())) {
@@ -499,6 +500,11 @@ public class WarehouseTransferService {
}
}
// 来源箱归属校验通过后再写入占用快照,保证错误箱号返回明确提示。
if (!transfer.getTransferNo().equals(transfer.getInboundCode()) && inventorySources != null) {
reserveSourceCartons(customerId, transfer);
}
LastlegOrder order = requireTailOrder(customerId, transfer);
LocalDateTime now = LocalDateTime.now();
transfer.setStatus(PENDING_REVIEW);