This commit is contained in:
2026-10-20 11:33:20 +08:00
parent 16ac817524
commit 6fa5d281ec
61 changed files with 31358 additions and 8 deletions
@@ -34,11 +34,5 @@ public final class CargoDiscardRequests {
public record Query(@Min(1) long page, @Min(1) @Max(200) long size,
String discardNo, String inboundCode, String overseasWarehouseId,
String status, LocalDateTime createdFrom, LocalDateTime createdTo,
LocalDateTime completedFrom, LocalDateTime completedTo) {
/** 未筛完成时间的调用保持创建时间查询语义。 */
public Query(long page, long size, String discardNo, String inboundCode, String overseasWarehouseId,
String status, LocalDateTime createdFrom, LocalDateTime createdTo) {
this(page, size, discardNo, inboundCode, overseasWarehouseId, status, createdFrom, createdTo, null, null);
}
}
LocalDateTime completedFrom, LocalDateTime completedTo) {}
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.discard.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/** 只读取废弃明细真实关联的本库托盘身份。 */
@Mapper
public interface CargoDiscardDimensionMapper {
/** 无托盘事实以空串保留,避免把不适用维度伪造为零。 */
@Select("""
SELECT COALESCE(CASE WHEN i.return_carton_id IS NOT NULL THEN r.pallet_code
WHEN i.inventory_position_id IS NOT NULL THEN p.pallet_no ELSE d.pallet_no END,'')
FROM ll_cargo_discard_item i
LEFT JOIN ll_return_inventory_carton r ON r.id=i.return_carton_id AND r.customer_id=i.customer_id
LEFT JOIN ll_inventory_position p ON p.id=i.inventory_position_id AND p.customer_id=i.customer_id
LEFT JOIN ll_dropship_order_profile d ON d.id=i.profile_id AND d.customer_id=i.customer_id
WHERE i.customer_id=#{customerId} AND i.application_id=#{applicationId}
""")
List<String> palletNos(@Param("customerId") String customerId, @Param("applicationId") Long applicationId);
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.discard.repository;
import com.baiyue.lastleg.discard.entity.CargoDiscardItem;
import com.baiyue.lastleg.discard.mapper.CargoDiscardItemMapper;
import com.baiyue.lastleg.discard.mapper.CargoDiscardDimensionMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 封装数量统计的明细与来源托盘读取。 */
@Repository
public class CargoDiscardDimensionRepository {
@Resource private CargoDiscardItemMapper items;
@Resource private CargoDiscardDimensionMapper dimensions;
/** 读取一票客户隔离明细。 */
public List<CargoDiscardItem> items(String customer, Long application) { return items.listApplication(customer, application); }
/** 读取一票托盘身份,保留无事实行。 */
public List<String> pallets(String customer, Long application) { return dimensions.palletNos(customer, application); }
}
@@ -0,0 +1,36 @@
package com.baiyue.lastleg.discard.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.lastleg.discard.dto.CargoDiscardRequests;
import com.baiyue.lastleg.discard.entity.CargoDiscardApplication;
import com.baiyue.lastleg.discard.mapper.CargoDiscardApplicationMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import java.util.List;
/** 列表和全筛选统计共用同一客户及时间过滤条件。 */
@Repository
public class CargoDiscardQueryRepository {
@Resource private CargoDiscardApplicationMapper applications;
/** 返回全部筛选单据,不受页面大小影响。 */
public List<CargoDiscardApplication> list(String customerId, CargoDiscardRequests.Query query) {
return applications.selectList(filter(customerId, query));
}
/** 完成时间使用客户确认时间,与客户端完成时间展示保持一致。 */
public static LambdaQueryWrapper<CargoDiscardApplication> filter(String customerId, CargoDiscardRequests.Query query) {
return new LambdaQueryWrapper<CargoDiscardApplication>()
.eq(StringUtils.hasText(customerId), CargoDiscardApplication::getCustomerId, customerId)
.like(StringUtils.hasText(query.discardNo()), CargoDiscardApplication::getDiscardNo, query.discardNo())
.like(StringUtils.hasText(query.inboundCode()), CargoDiscardApplication::getInboundCodes, query.inboundCode())
.eq(StringUtils.hasText(query.overseasWarehouseId()), CargoDiscardApplication::getOverseasWarehouseId, query.overseasWarehouseId())
.eq(StringUtils.hasText(query.status()), CargoDiscardApplication::getStatus, query.status())
.ge(query.createdFrom() != null, CargoDiscardApplication::getCreatedAt, query.createdFrom())
.le(query.createdTo() != null, CargoDiscardApplication::getCreatedAt, query.createdTo())
.ge(query.completedFrom() != null, CargoDiscardApplication::getCustomerConfirmedAt, query.completedFrom())
.le(query.completedTo() != null, CargoDiscardApplication::getCustomerConfirmedAt, query.completedTo())
.orderByDesc(CargoDiscardApplication::getCreatedAt).orderByDesc(CargoDiscardApplication::getId);
}
}
@@ -0,0 +1,54 @@
package com.baiyue.lastleg.discard.service;
import com.baiyue.lastleg.discard.dto.CargoDiscardRequests;
import com.baiyue.lastleg.discard.model.CargoDiscardQuantities;
import com.baiyue.lastleg.discard.repository.CargoDiscardQueryRepository;
import com.baiyue.lastleg.discard.repository.CargoDiscardDimensionRepository;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.util.HashSet;
/** 按全部筛选单据统计票、SKU、件、箱和真实来源托盘。 */
@Service
public class CargoDiscardStatisticsService {
@Resource private CargoDiscardQueryRepository queries;
@Resource private CargoDiscardDimensionRepository dimensions;
public record Statistics(long orderCount, int skuCount, long requestedPackages, Long actualPackages,
Long requestedPieces, Long actualPieces, Integer palletCount) {}
/** 已确认实际数独立汇总;缺失件数或托盘事实返回空值,不从箱数换算。 */
public Statistics statistics(String customer, CargoDiscardRequests.Query query) {
var applications = queries.list(customer, query);
var skus = new HashSet<String>(); var pallets = new HashSet<String>();
long boxes = 0, actualBoxes = 0, pieces = 0, actualPieces = 0;
boolean piecesKnown = true, actualPiecesKnown = true, actualKnown = false, palletsKnown = true;
for (var application : applications) {
var lines = dimensions.items(application.getCustomerId(), application.getId());
boxes += lines.isEmpty() ? application.getTotalPackages() : CargoDiscardQuantities.boxes(lines, false);
if (lines.isEmpty()) { piecesKnown = false; palletsKnown = false; }
var confirmed = lines.stream().filter(i -> i.getActualDiscardPackages() != null).toList();
if (!confirmed.isEmpty()) { actualKnown = true; actualBoxes += CargoDiscardQuantities.boxes(confirmed, true); }
for (var line : lines) {
if (line.getSkuCode() != null && !line.getSkuCode().isBlank()) skus.add(line.getSkuCode());
if (line.getReturnCartonId() == null) {
piecesKnown = false;
if (line.getActualDiscardPackages() != null) actualPiecesKnown = false;
} else {
pieces += line.getDiscardPackages();
if (line.getActualDiscardPackages() != null) actualPieces += line.getActualDiscardPackages();
}
}
var sourcePallets = dimensions.pallets(application.getCustomerId(), application.getId());
if (sourcePallets.isEmpty()) palletsKnown = false;
for (var pallet : sourcePallets) {
if (pallet == null || pallet.isBlank() || "散箱".equals(pallet)) palletsKnown = false;
else pallets.add(application.getCustomerId() + ":" + application.getOverseasWarehouseId() + ":" + pallet);
}
}
return new Statistics(applications.size(), skus.size(), boxes, actualKnown ? actualBoxes : null,
!applications.isEmpty() && piecesKnown ? pieces : null,
actualKnown && actualPiecesKnown ? actualPieces : null,
!applications.isEmpty() && palletsKnown ? pallets.size() : null);
}
}
@@ -0,0 +1,34 @@
package com.baiyue.lastleg.relabel.model;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;
/** 列表与数量汇总共用客户范围及筛选条件,避免分页和汇总口径漂移。 */
public final class RelabelQueryFilter {
/** 禁止创建无状态筛选工具实例。 */
private RelabelQueryFilter() { }
/** 构造完整筛选条件;同时间单据再按主键排序,保持分页稳定。 */
public static LambdaQueryWrapper<RelabelApplication> build(String customerId, RelabelRequests.Query query) {
if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo()))
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "createdFrom must not be after createdTo");
var wrapper = new LambdaQueryWrapper<RelabelApplication>()
.eq(StringUtils.hasText(customerId), RelabelApplication::getCustomerId, customerId)
.like(StringUtils.hasText(query.relabelNo()), RelabelApplication::getRelabelNo, query.relabelNo())
.like(StringUtils.hasText(query.customerOrderNo()), RelabelApplication::getCustomerOrderNo, query.customerOrderNo())
.eq(StringUtils.hasText(query.overseasWarehouseId()), RelabelApplication::getOverseasWarehouseId, query.overseasWarehouseId())
.ge(query.createdFrom() != null, RelabelApplication::getCreatedAt, query.createdFrom())
.le(query.createdTo() != null, RelabelApplication::getCreatedAt, query.createdTo())
.apply(StringUtils.hasText(query.skuCode()), "EXISTS (SELECT 1 FROM ll_relabel_item ri WHERE ri.application_id=ll_relabel_application.id AND ri.customer_id=ll_relabel_application.customer_id AND (ri.sku_code LIKE CONCAT('%',{0},'%') OR JSON_SEARCH(CASE WHEN JSON_VALID(ri.sku_relabel_items_json) THEN ri.sku_relabel_items_json ELSE JSON_ARRAY() END, 'one', CONCAT('%',{0},'%'), NULL, '$[*].skuCode') IS NOT NULL))", query.skuCode())
.orderByDesc(RelabelApplication::getCreatedAt).orderByDesc(RelabelApplication::getId);
String status = query.status() == null ? "" : query.status().trim();
if ("PENDING_PROCESSING".equals(status)) wrapper.eq(RelabelApplication::getStatus, status);
else if ("COMPLETED".equals(status)) wrapper.in(RelabelApplication::getStatus, "READY_FOR_OUTBOUND", "COMPLETED");
else if (StringUtils.hasText(status)) wrapper.eq(RelabelApplication::getStatus, status);
return wrapper;
}
}
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.relabel.repository;
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.relabel.model.RelabelQueryFilter;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.springframework.stereotype.Repository;
import jakarta.annotation.Resource;
import java.util.List;
/** 数量查询只读取本服务申请与箱任务,不跨服务读库存库。 */
@Repository
public class RelabelQuantityRepository {
@Resource private RelabelApplicationMapper applications;
@Resource private RelabelItemMapper items;
/** 读取全部筛选结果,不附加当前页限制。 */
public List<RelabelApplication> filtered(String customerId, RelabelRequests.Query query) {
return applications.selectList(RelabelQueryFilter.build(customerId, query));
}
/** 明细同时限定客户与申请,避免跨客户聚合关联。 */
public List<RelabelItem> items(RelabelApplication application) {
return items.selectList(new LambdaQueryWrapper<RelabelItem>()
.eq(RelabelItem::getApplicationId, application.getId())
.eq(RelabelItem::getCustomerId, application.getCustomerId()));
}
}
@@ -0,0 +1,89 @@
package com.baiyue.lastleg.relabel.service;
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.repository.RelabelQuantityRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
/** 分开统计贴标件数、真实箱任务和未知维度,不按固定箱规换算。 */
@Service
public class RelabelQuantityService {
@Resource private RelabelQuantityRepository repository;
@Resource private ObjectMapper objectMapper;
public record Quantity(Long labelPieceCount, Long cartonCount, int skuCount, boolean duplicateCarton,
String quantityUnit, Long sourcePalletCount, Long targetPalletCount) { }
public record Totals(long orderCount, long skuCount, long knownLabelPieceCount, long unknownPieceOrders,
long knownCartonCount, long unknownCartonOrders,
Map<String, Quantity> orders) { }
/** 按全部筛选单据聚合;未知件数和箱数保留未知单数,不伪造为零。 */
@Transactional(readOnly = true)
public Totals totals(String customerId, RelabelRequests.Query query) {
var dimensions = new LinkedHashMap<String, Quantity>();
var skus = new HashSet<String>();
long pieces = 0, cartons = 0, unknownPieces = 0, unknownCartons = 0;
for (var application : repository.filtered(customerId, query)) {
var detail = repository.items(application);
Quantity quantity = measure(detail);
dimensions.put(application.getRelabelNo(), quantity);
detail.forEach(item -> collectSkus(item, skus));
if (quantity.labelPieceCount() == null) unknownPieces++; else pieces = Math.addExact(pieces, quantity.labelPieceCount());
if (quantity.cartonCount() == null) unknownCartons++; else cartons = Math.addExact(cartons, quantity.cartonCount());
}
return new Totals(dimensions.size(), skus.size(), pieces, unknownPieces, cartons, unknownCartons, dimensions);
}
/** 箱级任务按箱唛去重;历史重复关联不重复增加件数并显式告警。 */
public Quantity measure(List<RelabelItem> items) {
if (items.isEmpty()) return new Quantity(null, null, 0, false, "UNKNOWN", null, null);
long pieces = 0;
boolean unknownPieces = false, bulk = false, duplicate = false, missingCarton = false;
var cartons = new HashSet<String>();
var piecesSeen = new HashSet<String>();
var skus = new HashSet<String>();
for (var item : items) {
collectSkus(item, skus);
String mark = item.getCartonMark() == null ? "" : item.getCartonMark().trim();
if (mark.isEmpty()) missingCarton = true;
else if (!cartons.add(mark) && !"RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) duplicate = true;
if ("RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) {
bulk = true;
if (item.getNotifiedPackageCount() == null) unknownPieces = true;
else if (piecesSeen.add(mark + "|" + item.getSkuCode() + "|" + item.getNewSkuCode())) pieces = Math.addExact(pieces, item.getNotifiedPackageCount());
else duplicate = true;
} else if ("CARTON_LABEL".equals(item.getOperationType())) {
// 只换箱唛不能推导商品件数,保留未提供口径。
unknownPieces = true;
} else {
try {
var lines = objectMapper.readTree(Objects.requireNonNullElse(item.getSkuRelabelItemsJson(), "[]"));
if (!lines.isArray() || lines.isEmpty()) unknownPieces = true;
for (var line : lines) {
String key = mark + "|" + line.path("skuCode").asText() + "|" + line.path("newSkuCode").asText();
if (!line.path("quantity").canConvertToLong()) unknownPieces = true;
else if (piecesSeen.add(key)) pieces = Math.addExact(pieces, line.path("quantity").asLong());
}
} catch (Exception exception) { unknownPieces = true; }
}
}
// 按件退货来源可能是托盘或包裹标识,不能将标识数冒充真实箱数。
return new Quantity(unknownPieces || duplicate ? null : pieces, bulk || missingCarton ? null : (long) cartons.size(),
skus.size(), duplicate, bulk ? "PIECE" : "CARTON", null, null);
}
/** SKU种数来自子项;旧快照无子项时才采用旧SKU汇总字段。 */
private void collectSkus(RelabelItem item, Set<String> skus) {
try {
var lines = objectMapper.readTree(Objects.requireNonNullElse(item.getSkuRelabelItemsJson(), "[]"));
if (lines.isArray() && !lines.isEmpty()) {
for (var line : lines) if (!line.path("skuCode").asText().isBlank()) skus.add(line.path("skuCode").asText().trim());
return;
}
} catch (Exception ignored) { /* 旧快照解析失败时只展示已有旧SKU,不推算件数。 */ }
for (var sku : Objects.requireNonNullElse(item.getSkuCode(), "").split(",")) if (!sku.isBlank()) skus.add(sku.trim());
}
}