xiaoyi
昨天 8782476e9cb4480be5da588888d08022b7942d0c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
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;
    }
}