package cn.iocoder.yudao.module.srm.service.statistics;
|
|
import cn.iocoder.yudao.framework.common.pojo.PageParam;
|
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
import cn.iocoder.yudao.module.srm.controller.admin.statistics.vo.SrmCostAnalysisRespVO;
|
import cn.iocoder.yudao.module.srm.dal.dataobject.SrmSupplierDO;
|
import cn.iocoder.yudao.module.srm.dal.dataobject.SrmSupplierQuoteDO;
|
import cn.iocoder.yudao.module.srm.dal.dataobject.SrmTenderMaterialDO;
|
import cn.iocoder.yudao.module.srm.dal.mysql.SrmSupplierMapper;
|
import cn.iocoder.yudao.module.srm.dal.mysql.SrmSupplierQuoteMapper;
|
import cn.iocoder.yudao.module.srm.dal.mysql.SrmTenderMaterialMapper;
|
import jakarta.annotation.Resource;
|
import lombok.extern.slf4j.Slf4j;
|
import org.springframework.stereotype.Service;
|
|
import java.math.BigDecimal;
|
import java.math.RoundingMode;
|
import java.time.LocalDateTime;
|
import java.time.YearMonth;
|
import java.util.*;
|
import java.util.stream.Collectors;
|
|
/**
|
* 采购成本分析(基于 SRM 招标需求与供应商报价)。
|
*
|
* <p>口径说明:
|
* <ul>
|
* <li>采购需求量取招标物料需求数量合计;</li>
|
* <li>加权均价 = Σ(报价单价 × 需求数量) / Σ(需求数量),多供应商报价一并计入;</li>
|
* <li>价格变动 = 最近两次报价之差;不足两次时为空;</li>
|
* <li>不依赖 ERP 采购订单,未启用 ERP 的项目同样可用。</li>
|
* </ul>
|
*/
|
@Slf4j
|
@Service
|
public class SrmCostAnalysisService {
|
|
@Resource private SrmTenderMaterialMapper tenderMaterialMapper;
|
@Resource private SrmSupplierQuoteMapper supplierQuoteMapper;
|
@Resource private SrmSupplierMapper supplierMapper;
|
|
public PageResult<SrmCostAnalysisRespVO> page(PageParam page, String productName) {
|
List<SrmTenderMaterialDO> materials = tenderMaterialMapper.selectList(
|
new LambdaQueryWrapperX<SrmTenderMaterialDO>()
|
.likeIfPresent(SrmTenderMaterialDO::getProductName, productName));
|
if (materials.isEmpty()) return PageResult.empty();
|
|
// 按物料聚合(同一物料可能出现在多个招标项目)
|
Map<String, List<SrmTenderMaterialDO>> grouped = materials.stream()
|
.filter(m -> m.getProductId() != null || m.getProductName() != null)
|
.collect(Collectors.groupingBy(
|
m -> m.getProductId() != null ? "ID:" + m.getProductId() : "NAME:" + m.getProductName(),
|
LinkedHashMap::new, Collectors.toList()));
|
|
List<SrmCostAnalysisRespVO> rows = new ArrayList<>();
|
for (var entry : grouped.entrySet()) {
|
List<SrmTenderMaterialDO> group = entry.getValue();
|
SrmTenderMaterialDO first = group.get(0);
|
Set<Long> materialIds = group.stream().map(SrmTenderMaterialDO::getId)
|
.filter(Objects::nonNull).collect(Collectors.toSet());
|
if (materialIds.isEmpty()) continue;
|
|
List<SrmSupplierQuoteDO> quotes = supplierQuoteMapper.selectList(
|
new LambdaQueryWrapperX<SrmSupplierQuoteDO>()
|
.in(SrmSupplierQuoteDO::getTenderMaterialId, materialIds));
|
if (quotes.isEmpty()) continue;
|
|
// 需求数量映射
|
Map<Long, BigDecimal> qtyMap = group.stream()
|
.filter(m -> m.getId() != null)
|
.collect(Collectors.toMap(SrmTenderMaterialDO::getId,
|
m -> m.getQuantity() == null ? BigDecimal.ZERO : m.getQuantity(), (a, b) -> a));
|
|
BigDecimal totalQty = qtyMap.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add);
|
BigDecimal weightedSum = BigDecimal.ZERO;
|
BigDecimal weightQty = BigDecimal.ZERO;
|
for (SrmSupplierQuoteDO q : quotes) {
|
if (q.getQuotePrice() == null) continue;
|
BigDecimal qty = qtyMap.getOrDefault(q.getTenderMaterialId(), BigDecimal.ZERO);
|
if (qty.signum() <= 0) continue;
|
weightedSum = weightedSum.add(q.getQuotePrice().multiply(qty));
|
weightQty = weightQty.add(qty);
|
}
|
|
var row = new SrmCostAnalysisRespVO();
|
row.setProductId(first.getProductId());
|
row.setProductCode(first.getProductCode());
|
row.setProductName(first.getProductName());
|
row.setProductSpec(first.getProductSpec());
|
row.setUnit(first.getUnit());
|
row.setTotalQuantity(totalQty);
|
row.setEstimatedPrice(first.getEstimatedPrice());
|
row.setQuoteCount(quotes.size());
|
row.setSupplierCount((int) quotes.stream().map(SrmSupplierQuoteDO::getSupplierId)
|
.filter(Objects::nonNull).distinct().count());
|
row.setWeightedAvgPrice(weightQty.signum() > 0
|
? weightedSum.divide(weightQty, 4, RoundingMode.HALF_UP) : null);
|
row.setMinPrice(quotes.stream().map(SrmSupplierQuoteDO::getQuotePrice)
|
.filter(Objects::nonNull).min(Comparator.naturalOrder()).orElse(null));
|
row.setMaxPrice(quotes.stream().map(SrmSupplierQuoteDO::getQuotePrice)
|
.filter(Objects::nonNull).max(Comparator.naturalOrder()).orElse(null));
|
if (first.getEstimatedPrice() != null && first.getEstimatedPrice().signum() > 0
|
&& row.getWeightedAvgPrice() != null) {
|
row.setDeviationRate(row.getWeightedAvgPrice().subtract(first.getEstimatedPrice())
|
.multiply(BigDecimal.valueOf(100))
|
.divide(first.getEstimatedPrice(), 2, RoundingMode.HALF_UP));
|
}
|
|
// 报价排序(按创建时间)
|
List<SrmSupplierQuoteDO> ordered = quotes.stream()
|
.filter(q -> q.getQuotePrice() != null)
|
.sorted(Comparator.comparing(SrmSupplierQuoteDO::getCreateTime,
|
Comparator.nullsFirst(Comparator.naturalOrder())))
|
.toList();
|
if (!ordered.isEmpty()) {
|
row.setLastQuoteTime(ordered.get(ordered.size() - 1).getCreateTime());
|
if (ordered.size() >= 2) {
|
row.setPriceChange(ordered.get(ordered.size() - 1).getQuotePrice()
|
.subtract(ordered.get(ordered.size() - 2).getQuotePrice()));
|
}
|
}
|
|
// 供应商名称
|
Set<Long> supplierIds = quotes.stream().map(SrmSupplierQuoteDO::getSupplierId)
|
.filter(Objects::nonNull).collect(Collectors.toSet());
|
Map<Long, String> supplierNames = supplierIds.isEmpty() ? Map.of()
|
: supplierMapper.selectByIds(supplierIds).stream()
|
.collect(Collectors.toMap(SrmSupplierDO::getId, SrmSupplierDO::getName, (a, b) -> a));
|
row.setQuotes(quotes.stream().map(q -> {
|
var item = new SrmCostAnalysisRespVO.QuoteItem();
|
item.setSupplierId(q.getSupplierId());
|
item.setSupplierName(supplierNames.get(q.getSupplierId()));
|
item.setQuotePrice(q.getQuotePrice());
|
item.setTaxRate(q.getTaxRate());
|
item.setDeliveryCycle(q.getDeliveryCycle());
|
item.setQuoteTime(q.getCreateTime());
|
return item;
|
}).toList());
|
rows.add(row);
|
}
|
|
// 手动分页
|
rows.sort((a, b) -> {
|
BigDecimal pa = a.getWeightedAvgPrice() == null ? BigDecimal.ZERO : a.getWeightedAvgPrice();
|
BigDecimal pb = b.getWeightedAvgPrice() == null ? BigDecimal.ZERO : b.getWeightedAvgPrice();
|
return pb.compareTo(pa);
|
});
|
int total = rows.size();
|
int from = Math.min((page.getPageNo() - 1) * page.getPageSize(), total);
|
int to = Math.min(from + page.getPageSize(), total);
|
return new PageResult<>(rows.subList(from, to), (long) total);
|
}
|
|
/** 单物料报价趋势(按月平均报价) */
|
public List<SrmCostAnalysisRespVO.TrendPoint> trend(Long productId) {
|
if (productId == null) return List.of();
|
List<SrmTenderMaterialDO> materials = tenderMaterialMapper.selectList(
|
new LambdaQueryWrapperX<SrmTenderMaterialDO>().eq(SrmTenderMaterialDO::getProductId, productId));
|
Set<Long> materialIds = materials.stream().map(SrmTenderMaterialDO::getId)
|
.filter(Objects::nonNull).collect(Collectors.toSet());
|
if (materialIds.isEmpty()) return List.of();
|
List<SrmSupplierQuoteDO> quotes = supplierQuoteMapper.selectList(
|
new LambdaQueryWrapperX<SrmSupplierQuoteDO>()
|
.in(SrmSupplierQuoteDO::getTenderMaterialId, materialIds));
|
Map<YearMonth, List<BigDecimal>> byMonth = new TreeMap<>();
|
for (SrmSupplierQuoteDO q : quotes) {
|
if (q.getQuotePrice() == null || q.getCreateTime() == null) continue;
|
byMonth.computeIfAbsent(YearMonth.from(q.getCreateTime()), k -> new ArrayList<>())
|
.add(q.getQuotePrice());
|
}
|
List<SrmCostAnalysisRespVO.TrendPoint> result = new ArrayList<>();
|
byMonth.forEach((month, prices) -> {
|
var p = new SrmCostAnalysisRespVO.TrendPoint();
|
p.setMonth(month.toString());
|
p.setCount(prices.size());
|
p.setAvgPrice(prices.stream().reduce(BigDecimal.ZERO, BigDecimal::add)
|
.divide(BigDecimal.valueOf(prices.size()), 4, RoundingMode.HALF_UP));
|
result.add(p);
|
});
|
return result;
|
}
|
}
|