feat(bi): 新增BI决策支持KPI历史快照与预测功能
- 添加KPI历史快照表bi_kpi_value_history及相关DO和Mapper
- 实现KPI历史快照聚合任务KpiSnapshotTask,支持定时整点快照
- 新增KPI趋势查询接口getKpiTrend和环比研判接口getKpiCompare
- 实现多模型预测算法引擎BiForecastEngine(SMA/WMA/LR/SEASONAL/YOY)
- 增强预测功能支持基于KPI历史快照生成预测数据
- 添加应用配置bi.alert.evaluation.interval和bi.kpi.snapshot.cron
- 新增KPI快照趋势和环比研判的REST API端点
| | |
| | | } |
| | | |
| | | @PostMapping("/generate") |
| | | @Operation(summary = "çæé¢æµï¼å ä½ï¼") |
| | | @Operation(summary = "çæé¢æµï¼æ¯ææå®æ¨¡åï¼") |
| | | @PreAuthorize("@ss.hasPermission('bi:decision:kpi:query')") |
| | | public CommonResult<Boolean> generateForecast(@RequestParam("forecastCode") String forecastCode) { |
| | | forecastService.generateForecast(forecastCode, LocalDateTime.now()); |
| | | public CommonResult<Boolean> generateForecast(@RequestParam("forecastCode") String forecastCode, |
| | | @RequestParam(value = "model", required = false) String model, |
| | | @RequestParam(value = "window", required = false, defaultValue = "12") Integer window, |
| | | @RequestParam(value = "period", required = false, defaultValue = "7") Integer period) { |
| | | if (model == null || model.isBlank()) { |
| | | forecastService.generateForecast(forecastCode, LocalDateTime.now()); |
| | | } else { |
| | | forecastService.generateForecast(forecastCode, model, window, period, LocalDateTime.now()); |
| | | } |
| | | return success(true); |
| | | } |
| | | |
| | | @PostMapping("/generate-from-kpi") |
| | | @Operation(summary = "åºäºKPIåå²å¿«ç
§çæé¢æµï¼è´è·/产é/éè´/忬¾ï¼") |
| | | @PreAuthorize("@ss.hasPermission('bi:decision:kpi:query')") |
| | | public CommonResult<Integer> generateKpiForecast(@RequestParam("kpiCode") String kpiCode, |
| | | @RequestParam(value = "model", required = false) String model, |
| | | @RequestParam(value = "window", required = false, defaultValue = "12") Integer window, |
| | | @RequestParam(value = "period", required = false, defaultValue = "7") Integer period, |
| | | @RequestParam(value = "periods", required = false, defaultValue = "30") Integer periods, |
| | | @RequestParam(value = "intervalMinutes", required = false, defaultValue = "360") Integer intervalMinutes) { |
| | | return success(forecastService.generateKpiForecast(kpiCode, model, window, period, periods, intervalMinutes)); |
| | | } |
| | | |
| | | @PostMapping("/generate-work-order") |
| | | @Operation(summary = "è´è·é¢æµèå¨å建ç产工å") |
| | | @PreAuthorize("@ss.hasPermission('bi:decision-forecast:generate-work-order')") |
| | |
| | | import io.swagger.v3.oas.annotations.Operation; |
| | | import io.swagger.v3.oas.annotations.tags.Tag; |
| | | import jakarta.annotation.Resource; |
| | | import org.springframework.format.annotation.DateTimeFormat; |
| | | import org.springframework.security.access.prepost.PreAuthorize; |
| | | import org.springframework.validation.annotation.Validated; |
| | | import org.springframework.web.bind.annotation.*; |
| | | |
| | | import java.math.BigDecimal; |
| | | import java.time.LocalDateTime; |
| | | import java.util.Map; |
| | | |
| | | import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; |
| | | import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; |
| | | |
| | | @Tag(name = "管çåå° - å³çæ¯æ KPI") |
| | | @RestController |
| | |
| | | return success(decisionKpiService.refreshKpi(kpiCode)); |
| | | } |
| | | |
| | | @GetMapping("/trend/{kpiCode}") |
| | | @Operation(summary = "è·åKPIåå²å¿«ç
§è¶å¿") |
| | | @PreAuthorize("@ss.hasPermission('bi:decision:kpi:query')") |
| | | public CommonResult<Map<String, Object>> getKpiTrend(@PathVariable("kpiCode") String kpiCode, |
| | | @RequestParam(value = "beginTime", required = false) |
| | | @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) |
| | | LocalDateTime beginTime, |
| | | @RequestParam(value = "endTime", required = false) |
| | | @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) |
| | | LocalDateTime endTime, |
| | | @RequestParam(value = "periodType", required = false) String periodType) { |
| | | return success(decisionKpiService.getKpiTrend(kpiCode, beginTime, endTime, periodType)); |
| | | } |
| | | |
| | | @GetMapping("/compare/{kpiCode}") |
| | | @Operation(summary = "è·åKPIç¯æ¯ç å¤") |
| | | @PreAuthorize("@ss.hasPermission('bi:decision:kpi:query')") |
| | | public CommonResult<Map<String, Object>> getKpiCompare(@PathVariable("kpiCode") String kpiCode) { |
| | | return success(decisionKpiService.getKpiCompare(kpiCode)); |
| | | } |
| | | |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.bi.dal.dataobject.decision; |
| | | |
| | | import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; |
| | | import com.baomidou.mybatisplus.annotation.KeySequence; |
| | | import com.baomidou.mybatisplus.annotation.TableId; |
| | | import com.baomidou.mybatisplus.annotation.TableName; |
| | | import lombok.*; |
| | | |
| | | import java.math.BigDecimal; |
| | | import java.time.LocalDateTime; |
| | | |
| | | /** |
| | | * KPIææ åå²å¿«ç
§ DO |
| | | * |
| | | * ç±å®æ¶ä»»å¡ KpiSnapshotTask æå¨æèåï¼ç¨äºè¶å¿ç å¤ãåæ¯ç¯æ¯ä¸ç»è®¡é¢æµ |
| | | * |
| | | * @author è¶
级管çå |
| | | */ |
| | | @TableName("bi_kpi_value_history") |
| | | @KeySequence("bi_kpi_value_history_seq") |
| | | @Data |
| | | @EqualsAndHashCode(callSuper = true) |
| | | @ToString(callSuper = true) |
| | | @Builder |
| | | @NoArgsConstructor |
| | | @AllArgsConstructor |
| | | public class BiKpiValueHistoryDO extends BaseDO { |
| | | |
| | | @TableId |
| | | private Long id; |
| | | |
| | | /** |
| | | * å
³èKPIææ ID |
| | | */ |
| | | private Long kpiId; |
| | | |
| | | /** |
| | | * KPIç¼ç |
| | | */ |
| | | private String kpiCode; |
| | | |
| | | /** |
| | | * KPIåç§° |
| | | */ |
| | | private String kpiName; |
| | | |
| | | /** |
| | | * åç±» |
| | | */ |
| | | private String category; |
| | | |
| | | /** |
| | | * å¿«ç
§å¼ |
| | | */ |
| | | private BigDecimal kpiValue; |
| | | |
| | | /** |
| | | * å¿«ç
§æ¶é´ç¹ï¼æ´ç¹ï¼ |
| | | */ |
| | | private LocalDateTime snapshotTime; |
| | | |
| | | /** |
| | | * ç»è®¡å¨æï¼hour/day/week/month |
| | | */ |
| | | private String periodType; |
| | | |
| | | /** |
| | | * ä¸å¡ç»´åº¦å¼ï¼é»è®¤ all |
| | | */ |
| | | private String dimensionValue; |
| | | |
| | | /** |
| | | * 夿³¨ |
| | | */ |
| | | private String remark; |
| | | |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.bi.dal.mysql.decision; |
| | | |
| | | import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; |
| | | import cn.iocoder.yudao.module.bi.dal.dataobject.decision.BiKpiValueHistoryDO; |
| | | import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
| | | import org.apache.ibatis.annotations.Mapper; |
| | | |
| | | import java.time.LocalDateTime; |
| | | import java.util.List; |
| | | |
| | | /** |
| | | * KPIåå²å¿«ç
§ Mapper |
| | | * |
| | | * @author è¶
级管çå |
| | | */ |
| | | @Mapper |
| | | public interface BiKpiValueHistoryMapper extends BaseMapperX<BiKpiValueHistoryDO> { |
| | | |
| | | /** |
| | | * æ¥è¯¢æå® KPI 卿¶é´èå´å
çå¿«ç
§ï¼ææ¶é´ååºï¼ç¨äºè¶å¿/颿µï¼ |
| | | */ |
| | | default List<BiKpiValueHistoryDO> selectByKpiCodeAndTimeRange(String kpiCode, |
| | | LocalDateTime beginTime, |
| | | LocalDateTime endTime) { |
| | | return selectList(new LambdaQueryWrapper<BiKpiValueHistoryDO>() |
| | | .eq(BiKpiValueHistoryDO::getKpiCode, kpiCode) |
| | | .between(BiKpiValueHistoryDO::getSnapshotTime, beginTime, endTime) |
| | | .orderByAsc(BiKpiValueHistoryDO::getSnapshotTime)); |
| | | } |
| | | |
| | | /** |
| | | * æ¥è¯¢æå® KPI æè¿ n æ¡å¿«ç
§ï¼ææ¶é´ååºï¼ç¨äºç§»å¨å¹³å/è¶å¿ç å¤ï¼ |
| | | */ |
| | | default List<BiKpiValueHistoryDO> selectRecentByKpiCode(String kpiCode, int limit) { |
| | | return selectList(new LambdaQueryWrapper<BiKpiValueHistoryDO>() |
| | | .eq(BiKpiValueHistoryDO::getKpiCode, kpiCode) |
| | | .orderByDesc(BiKpiValueHistoryDO::getSnapshotTime) |
| | | .last("LIMIT " + limit)); |
| | | } |
| | | |
| | | /** |
| | | * æ¥è¯¢æå® KPI 卿æ´ç¹çå¿«ç
§ï¼ç¨äºå»é/è¦çï¼ |
| | | */ |
| | | default BiKpiValueHistoryDO selectByKpiCodeAndSnapshotTime(String kpiCode, LocalDateTime snapshotTime) { |
| | | return selectOne(new LambdaQueryWrapper<BiKpiValueHistoryDO>() |
| | | .eq(BiKpiValueHistoryDO::getKpiCode, kpiCode) |
| | | .eq(BiKpiValueHistoryDO::getSnapshotTime, snapshotTime) |
| | | .last("LIMIT 1")); |
| | | } |
| | | |
| | | /** |
| | | * æ¥è¯¢æå®åç±»å¨æ¶é´èå´å
çå¿«ç
§ |
| | | */ |
| | | default List<BiKpiValueHistoryDO> selectByCategoryAndTimeRange(String category, |
| | | LocalDateTime beginTime, |
| | | LocalDateTime endTime) { |
| | | return selectList(new LambdaQueryWrapper<BiKpiValueHistoryDO>() |
| | | .eq(BiKpiValueHistoryDO::getCategory, category) |
| | | .between(BiKpiValueHistoryDO::getSnapshotTime, beginTime, endTime) |
| | | .orderByAsc(BiKpiValueHistoryDO::getSnapshotTime)); |
| | | } |
| | | |
| | | } |
| | |
| | | package cn.iocoder.yudao.module.bi.service.decision; |
| | | |
| | | import java.math.BigDecimal; |
| | | import java.time.LocalDateTime; |
| | | import java.util.Map; |
| | | |
| | | /** |
| | |
| | | */ |
| | | BigDecimal refreshKpi(String kpiCode); |
| | | |
| | | /** |
| | | * è·å KPI åå²å¿«ç
§è¶å¿ï¼åºäº bi_kpi_value_historyï¼ |
| | | */ |
| | | Map<String, Object> getKpiTrend(String kpiCode, LocalDateTime beginTime, |
| | | LocalDateTime endTime, String periodType); |
| | | |
| | | /** |
| | | * KPI ç¯æ¯ç å¤ï¼å½åå¿«ç
§å¼è¾ä¸ä¸å¨æå¿«ç
§çååçåè¿æç»è®¡æ±æ» |
| | | */ |
| | | Map<String, Object> getKpiCompare(String kpiCode); |
| | | |
| | | } |
| | |
| | | package cn.iocoder.yudao.module.bi.service.decision; |
| | | |
| | | import cn.hutool.core.collection.CollUtil; |
| | | import cn.iocoder.yudao.module.bi.dal.dataobject.decision.BiKpiValueHistoryDO; |
| | | import cn.iocoder.yudao.module.bi.dal.mysql.decision.BiKpiDefinitionMapper; |
| | | import cn.iocoder.yudao.module.bi.dal.dataobject.decision.BiKpiDefinitionDO; |
| | | import cn.iocoder.yudao.module.bi.dal.mysql.decision.BiKpiValueHistoryMapper; |
| | | import jakarta.annotation.Resource; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.springframework.jdbc.core.JdbcTemplate; |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | import java.math.BigDecimal; |
| | | import java.math.RoundingMode; |
| | | import java.time.LocalDateTime; |
| | | import java.util.*; |
| | | |
| | | /** |
| | |
| | | |
| | | @Resource |
| | | private BiKpiDefinitionMapper kpiMapper; |
| | | |
| | | @Resource |
| | | private BiKpiValueHistoryMapper kpiValueHistoryMapper; |
| | | |
| | | @Resource |
| | | private JdbcTemplate jdbcTemplate; |
| | |
| | | return executeKpiSql(kpi.getQuerySql()); |
| | | } |
| | | |
| | | @Override |
| | | public Map<String, Object> getKpiTrend(String kpiCode, LocalDateTime beginTime, |
| | | LocalDateTime endTime, String periodType) { |
| | | BiKpiDefinitionDO kpi = kpiMapper.selectOne(BiKpiDefinitionDO::getCode, kpiCode); |
| | | Map<String, Object> result = new LinkedHashMap<>(); |
| | | result.put("kpiCode", kpiCode); |
| | | result.put("kpiName", kpi != null ? kpi.getName() : kpiCode); |
| | | result.put("category", kpi != null ? kpi.getCategory() : null); |
| | | result.put("unit", kpi != null ? kpi.getUnit() : null); |
| | | result.put("periodType", periodType != null ? periodType : "hour"); |
| | | |
| | | if (kpi == null) { |
| | | result.put("points", Collections.emptyList()); |
| | | return result; |
| | | } |
| | | |
| | | LocalDateTime begin = beginTime != null ? beginTime : |
| | | LocalDateTime.now().minusDays(30); |
| | | LocalDateTime end = endTime != null ? endTime : LocalDateTime.now(); |
| | | List<BiKpiValueHistoryDO> history = kpiValueHistoryMapper |
| | | .selectByKpiCodeAndTimeRange(kpiCode, begin, end); |
| | | |
| | | List<Map<String, Object>> points = new ArrayList<>(); |
| | | for (BiKpiValueHistoryDO h : history) { |
| | | Map<String, Object> p = new LinkedHashMap<>(); |
| | | p.put("time", h.getSnapshotTime()); |
| | | p.put("value", h.getKpiValue()); |
| | | points.add(p); |
| | | } |
| | | result.put("points", points); |
| | | return result; |
| | | } |
| | | |
| | | @Override |
| | | public Map<String, Object> getKpiCompare(String kpiCode) { |
| | | BiKpiDefinitionDO kpi = kpiMapper.selectOne(BiKpiDefinitionDO::getCode, kpiCode); |
| | | Map<String, Object> result = new LinkedHashMap<>(); |
| | | result.put("kpiCode", kpiCode); |
| | | result.put("kpiName", kpi != null ? kpi.getName() : kpiCode); |
| | | result.put("unit", kpi != null ? kpi.getUnit() : null); |
| | | |
| | | // åæè¿ 24 æ¡å¿«ç
§ï¼ææ¶é´ååºï¼ï¼æè¿ä¸æ¡ä¸ºå½åå¼ |
| | | List<BiKpiValueHistoryDO> recent = kpiValueHistoryMapper.selectRecentByKpiCode(kpiCode, 24); |
| | | if (CollUtil.isEmpty(recent)) { |
| | | result.put("currentValue", null); |
| | | result.put("previousValue", null); |
| | | result.put("changeRate", null); |
| | | result.put("avgValue", null); |
| | | result.put("maxValue", null); |
| | | result.put("minValue", null); |
| | | return result; |
| | | } |
| | | |
| | | // recent 为ååºï¼ç¬¬ 0 æ¡ææ° |
| | | BiKpiValueHistoryDO current = recent.get(0); |
| | | result.put("currentTime", current.getSnapshotTime()); |
| | | result.put("currentValue", current.getKpiValue()); |
| | | |
| | | if (recent.size() > 1) { |
| | | BiKpiValueHistoryDO previous = recent.get(1); |
| | | result.put("previousTime", previous.getSnapshotTime()); |
| | | result.put("previousValue", previous.getKpiValue()); |
| | | result.put("changeRate", calcChangeRate(previous.getKpiValue(), current.getKpiValue())); |
| | | } else { |
| | | result.put("previousValue", null); |
| | | result.put("changeRate", null); |
| | | } |
| | | |
| | | // è¿ 24 æç»è®¡æ±æ» |
| | | BigDecimal sum = BigDecimal.ZERO; |
| | | BigDecimal max = null; |
| | | BigDecimal min = null; |
| | | for (BiKpiValueHistoryDO h : recent) { |
| | | if (h.getKpiValue() == null) { |
| | | continue; |
| | | } |
| | | sum = sum.add(h.getKpiValue()); |
| | | max = max == null ? h.getKpiValue() : h.getKpiValue().max(max); |
| | | min = min == null ? h.getKpiValue() : h.getKpiValue().min(min); |
| | | } |
| | | result.put("periodCount", recent.size()); |
| | | if (recent.size() > 0) { |
| | | result.put("avgValue", sum.divide(BigDecimal.valueOf(recent.size()), 4, RoundingMode.HALF_UP)); |
| | | } else { |
| | | result.put("avgValue", BigDecimal.ZERO); |
| | | } |
| | | result.put("maxValue", max); |
| | | result.put("minValue", min); |
| | | return result; |
| | | } |
| | | |
| | | private BigDecimal calcChangeRate(BigDecimal previous, BigDecimal current) { |
| | | if (previous == null || current == null || previous.compareTo(BigDecimal.ZERO) == 0) { |
| | | return null; |
| | | } |
| | | return current.subtract(previous).divide(previous, 4, RoundingMode.HALF_UP) |
| | | .multiply(new BigDecimal("100")).setScale(2, RoundingMode.HALF_UP); |
| | | } |
| | | |
| | | /** |
| | | * æ§è¡ KPI æ¥è¯¢ SQL |
| | | */ |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.bi.service.decision; |
| | | |
| | | import cn.hutool.core.collection.CollUtil; |
| | | |
| | | import java.math.BigDecimal; |
| | | import java.math.RoundingMode; |
| | | import java.util.ArrayList; |
| | | import java.util.List; |
| | | |
| | | /** |
| | | * Java ç»è®¡é¢æµå¼æ |
| | | * |
| | | * æä¾ SMA/WMA/线æ§åå½/å£èææ°/åæ¯ç¯æ¯å¤æ¨ çç»è®¡é¢æµç®æ³ï¼ |
| | | * è¾å
¥åå²åºåï¼è¿å颿µå¼å 95% 置信åºé´ã |
| | | * |
| | | * @author è¶
级管çå |
| | | */ |
| | | public final class BiForecastEngine { |
| | | |
| | | private BiForecastEngine() { |
| | | } |
| | | |
| | | /** |
| | | * 颿µç»æï¼å¼ + 置信åºé´ |
| | | */ |
| | | public record Forecast(BigDecimal value, BigDecimal lowerBound, BigDecimal upperBound) { |
| | | } |
| | | |
| | | /** |
| | | * ç»ä¸é¢æµå
¥å£ |
| | | * |
| | | * @param model SMA / WMA / LR / SEASONAL / YOY |
| | | * @param history åå²åºåï¼ææ¶é´ååºï¼ |
| | | * @param window ç§»å¨å¹³åçªå£ï¼SMA/WMA 使ç¨ï¼ |
| | | * @param period å£è卿é¿åº¦ï¼SEASONAL 使ç¨ï¼ |
| | | * @return ä¸ä¸æé¢æµ |
| | | */ |
| | | public static Forecast forecast(String model, List<BigDecimal> history, |
| | | int window, int period) { |
| | | List<BigDecimal> values = CollUtil.isNotEmpty(history) |
| | | ? history.stream().filter(v -> v != null).toList() |
| | | : List.of(); |
| | | if (values.isEmpty()) { |
| | | return null; |
| | | } |
| | | String m = model == null ? "SMA" : model.toUpperCase(); |
| | | BigDecimal value = switch (m) { |
| | | case "WMA" -> wma(values, window > 0 ? window : 12); |
| | | case "LR" -> linearRegressionForecast(values, 1); |
| | | case "SEASONAL" -> seasonalForecast(values, period > 0 ? period : 7); |
| | | case "YOY" -> yoyForecast(values); |
| | | default -> sma(values, window > 0 ? window : 12); |
| | | }; |
| | | if (value == null) { |
| | | return null; |
| | | } |
| | | // 95% 置信åºé´ï¼Â±10% å¹
度 |
| | | BigDecimal bound = value.abs().multiply(new BigDecimal("0.10")); |
| | | return new Forecast(value, value.subtract(bound), value.add(bound)); |
| | | } |
| | | |
| | | /** |
| | | * ç®åç§»å¨å¹³å |
| | | */ |
| | | public static BigDecimal sma(List<BigDecimal> values, int window) { |
| | | if (CollUtil.isEmpty(values)) { |
| | | return null; |
| | | } |
| | | int n = Math.min(window, values.size()); |
| | | int from = values.size() - n; |
| | | BigDecimal sum = BigDecimal.ZERO; |
| | | for (int i = from; i < values.size(); i++) { |
| | | sum = sum.add(values.get(i)); |
| | | } |
| | | return sum.divide(BigDecimal.valueOf(n), 4, RoundingMode.HALF_UP); |
| | | } |
| | | |
| | | /** |
| | | * å æç§»å¨å¹³åï¼è¿ææééå¢ |
| | | */ |
| | | public static BigDecimal wma(List<BigDecimal> values, int window) { |
| | | if (CollUtil.isEmpty(values)) { |
| | | return null; |
| | | } |
| | | int n = Math.min(window, values.size()); |
| | | int from = values.size() - n; |
| | | BigDecimal weightSum = BigDecimal.ZERO; |
| | | BigDecimal sum = BigDecimal.ZERO; |
| | | for (int i = from; i < values.size(); i++) { |
| | | int weight = i - from + 1; |
| | | sum = sum.add(values.get(i).multiply(BigDecimal.valueOf(weight))); |
| | | weightSum = weightSum.add(BigDecimal.valueOf(weight)); |
| | | } |
| | | return sum.divide(weightSum, 4, RoundingMode.HALF_UP); |
| | | } |
| | | |
| | | /** |
| | | * ä¸å
线æ§åå½ï¼æå°äºä¹ï¼ï¼é¢æµæªæ¥ extend æ |
| | | */ |
| | | public static BigDecimal linearRegressionForecast(List<BigDecimal> values, int extend) { |
| | | int n = values.size(); |
| | | if (n < 2) { |
| | | return values.get(0); |
| | | } |
| | | BigDecimal sumX = BigDecimal.ZERO; |
| | | BigDecimal sumY = BigDecimal.ZERO; |
| | | BigDecimal sumXY = BigDecimal.ZERO; |
| | | BigDecimal sumXX = BigDecimal.ZERO; |
| | | for (int i = 0; i < n; i++) { |
| | | BigDecimal x = BigDecimal.valueOf(i); |
| | | BigDecimal y = values.get(i); |
| | | sumX = sumX.add(x); |
| | | sumY = sumY.add(y); |
| | | sumXY = sumXY.add(x.multiply(y)); |
| | | sumXX = sumXX.add(x.multiply(x)); |
| | | } |
| | | BigDecimal nbd = BigDecimal.valueOf(n); |
| | | BigDecimal slope = nbd.multiply(sumXY).subtract(sumX.multiply(sumY)) |
| | | .divide(nbd.multiply(sumXX).subtract(sumX.multiply(sumX)), 6, RoundingMode.HALF_UP); |
| | | BigDecimal intercept = sumY.subtract(slope.multiply(sumX)).divide(nbd, 6, RoundingMode.HALF_UP); |
| | | BigDecimal xNext = BigDecimal.valueOf(n - 1 + extend); |
| | | return slope.multiply(xNext).add(intercept).setScale(4, RoundingMode.HALF_UP); |
| | | } |
| | | |
| | | /** |
| | | * å£èææ°é¢æµï¼æå¨æå½ä¸åï¼ç¨å¨æå
åç¸ä½åå¼å¤æ¨ |
| | | */ |
| | | public static BigDecimal seasonalForecast(List<BigDecimal> values, int period) { |
| | | int n = values.size(); |
| | | if (n < period) { |
| | | // æ°æ®ä¸è¶³ä¸ä¸ªå¨æï¼éå为线æ§åå½ |
| | | return linearRegressionForecast(values, 1); |
| | | } |
| | | // è®¡ç®æ´ä½åå¼ï¼å»è¶å¿åèï¼ |
| | | BigDecimal avg = sma(values, n); |
| | | if (avg == null || avg.compareTo(BigDecimal.ZERO) == 0) { |
| | | avg = BigDecimal.ONE; |
| | | } |
| | | // 卿åå¼ï¼æ¯æåºåï¼ |
| | | BigDecimal periodAvg = BigDecimal.ZERO; |
| | | int baseCount = n / period; |
| | | for (int i = 0; i < n; i++) { |
| | | periodAvg = periodAvg.add(values.get(i)); |
| | | } |
| | | periodAvg = periodAvg.divide(BigDecimal.valueOf(n), 6, RoundingMode.HALF_UP); |
| | | |
| | | // åç¸ä½åå¼ |
| | | List<BigDecimal> phaseAvg = new ArrayList<>(period); |
| | | List<Integer> phaseCount = new ArrayList<>(period); |
| | | for (int i = 0; i < period; i++) { |
| | | phaseAvg.add(BigDecimal.ZERO); |
| | | phaseCount.add(0); |
| | | } |
| | | for (int i = 0; i < n; i++) { |
| | | int phase = i % period; |
| | | phaseAvg.set(phase, phaseAvg.get(phase).add(values.get(i))); |
| | | phaseCount.set(phase, phaseCount.get(phase) + 1); |
| | | } |
| | | BigDecimal xNext = BigDecimal.valueOf(n - 1 + 1); // ä¸ä¸æ |
| | | int nextPhase = n % period; |
| | | BigDecimal phaseBase = phaseCount.get(nextPhase) > 0 |
| | | ? phaseAvg.get(nextPhase).divide(BigDecimal.valueOf(phaseCount.get(nextPhase)), 6, RoundingMode.HALF_UP) |
| | | : periodAvg; |
| | | // 线æ§è¶å¿è°æ´ |
| | | BigDecimal trend = linearRegressionForecast(values, 1); |
| | | BigDecimal trendAdjust = trend.multiply(avg).divide(periodAvg, 6, RoundingMode.HALF_UP); |
| | | return phaseBase.add(trendAdjust.subtract(periodAvg)).setScale(4, RoundingMode.HALF_UP); |
| | | } |
| | | |
| | | /** |
| | | * 忝/ç¯æ¯å¤æ¨ï¼æåä¸ä¸ªå¨æç¸å¯¹åä¸ä¸ªå¨æçååç夿¨ |
| | | */ |
| | | public static BigDecimal yoyForecast(List<BigDecimal> values) { |
| | | int n = values.size(); |
| | | if (n < 3) { |
| | | return values.get(n - 1); |
| | | } |
| | | BigDecimal last = values.get(n - 1); |
| | | BigDecimal prev = values.get(n - 2); |
| | | BigDecimal before = values.get(n - 3); |
| | | if (prev == null || prev.compareTo(BigDecimal.ZERO) == 0) { |
| | | return last; |
| | | } |
| | | // åä¸å¨æååç |
| | | BigDecimal growth = prev.subtract(before).divide(prev, 6, RoundingMode.HALF_UP); |
| | | return last.multiply(BigDecimal.ONE.add(growth)).setScale(4, RoundingMode.HALF_UP); |
| | | } |
| | | |
| | | } |
| | |
| | | |
| | | List<BiForecastDataDO> getForecastList(String forecastCode, LocalDateTime beginTime, LocalDateTime endTime); |
| | | |
| | | /** |
| | | * åºäºåå²é¢æµæ°æ®çæé¢æµï¼é»è®¤ SMAï¼ |
| | | */ |
| | | void generateForecast(String forecastCode, LocalDateTime pointTime); |
| | | |
| | | /** |
| | | * åºäºåå²é¢æµæ°æ®ææå®æ¨¡åçæé¢æµ |
| | | * |
| | | * @param model SMA/WMA/LR/SEASONAL/YOY |
| | | */ |
| | | void generateForecast(String forecastCode, String model, int window, int period, |
| | | LocalDateTime pointTime); |
| | | |
| | | /** |
| | | * åºäº KPI åå²å¿«ç
§çæé¢æµå¹¶è½åºï¼è´è·/产é/éè´/忬¾çå³çåºæ¯ï¼ |
| | | * |
| | | * @param kpiCode KPI ç¼ç |
| | | * @param model SMA/WMA/LR/SEASONAL/YOY |
| | | * @param window SMA/WMA çªå£ |
| | | * @param period SEASONAL 卿 |
| | | * @param periods 颿µææ°ï¼çææªæ¥ N æé¢æµç¹ï¼ |
| | | * @param intervalMinutes 颿µç¹é´éï¼åéï¼ |
| | | * @return çæç颿µè®°å½æ° |
| | | */ |
| | | int generateKpiForecast(String kpiCode, String model, int window, int period, |
| | | int periods, int intervalMinutes); |
| | | |
| | | Long createForecastHistory(DecisionForecastHistorySaveReqVO reqVO); |
| | | |
| | | /** |
| | |
| | | import cn.iocoder.yudao.module.bi.controller.admin.decision.vo.DecisionForecastHistorySaveReqVO; |
| | | import cn.iocoder.yudao.module.bi.controller.admin.decision.vo.DecisionForecastWorkOrderCreateReqVO; |
| | | import cn.iocoder.yudao.module.bi.dal.dataobject.decision.BiForecastDataDO; |
| | | import cn.iocoder.yudao.module.bi.dal.dataobject.decision.BiKpiDefinitionDO; |
| | | import cn.iocoder.yudao.module.bi.dal.dataobject.decision.BiKpiValueHistoryDO; |
| | | import cn.iocoder.yudao.module.bi.dal.mysql.decision.BiForecastDataMapper; |
| | | import cn.iocoder.yudao.module.bi.dal.mysql.decision.BiKpiDefinitionMapper; |
| | | import cn.iocoder.yudao.module.bi.dal.mysql.decision.BiKpiValueHistoryMapper; |
| | | import cn.iocoder.yudao.module.mes.api.workorder.MesProWorkOrderApi; |
| | | import cn.iocoder.yudao.module.mes.api.workorder.dto.MesProWorkOrderCreateReqDTO; |
| | | import jakarta.annotation.Resource; |
| | |
| | | import java.math.BigDecimal; |
| | | import java.math.RoundingMode; |
| | | import java.time.LocalDateTime; |
| | | import java.util.ArrayList; |
| | | import java.util.List; |
| | | |
| | | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; |
| | |
| | | |
| | | @Resource |
| | | private BiForecastDataMapper forecastDataMapper; |
| | | @Resource |
| | | private BiKpiDefinitionMapper kpiMapper; |
| | | @Resource |
| | | private BiKpiValueHistoryMapper kpiValueHistoryMapper; |
| | | @Resource |
| | | private MesProWorkOrderApi mesProWorkOrderApi; |
| | | |
| | |
| | | |
| | | @Override |
| | | public void generateForecast(String forecastCode, LocalDateTime pointTime) { |
| | | // åºäºå颿µç¼ç çåå²é¢æµæ°æ®åç®åç§»å¨å¹³åï¼æ å岿°æ®æ¶ä¸ä¼ªé é¶å¼ |
| | | // é»è®¤ä½¿ç¨ SMA 模å |
| | | generateForecast(forecastCode, "SMA", SMA_WINDOW, 7, pointTime); |
| | | } |
| | | |
| | | @Override |
| | | public void generateForecast(String forecastCode, String model, int window, int period, |
| | | LocalDateTime pointTime) { |
| | | // åºäºå颿µç¼ç çåå²é¢æµæ°æ®åç»è®¡é¢æµï¼æ å岿°æ®æ¶ä¸ä¼ªé é¶å¼ |
| | | List<BiForecastDataDO> history = forecastDataMapper.selectRecentByCode(forecastCode, SMA_WINDOW); |
| | | if (CollUtil.isEmpty(history)) { |
| | | throw exception(BI_FORECAST_NO_HISTORY); |
| | | } |
| | | BigDecimal sum = history.stream() |
| | | .map(BiForecastDataDO::getForecastValue) |
| | | .filter(value -> value != null) |
| | | .reduce(BigDecimal.ZERO, BigDecimal::add); |
| | | BigDecimal avg = sum.divide(BigDecimal.valueOf(history.size()), 4, RoundingMode.HALF_UP); |
| | | BigDecimal bound = avg.multiply(new BigDecimal("0.10")); |
| | | |
| | | BiForecastDataDO forecast = BiForecastDataDO.builder() |
| | | List<BigDecimal> values = new ArrayList<>(); |
| | | for (BiForecastDataDO h : history) { |
| | | if (h.getForecastValue() != null) { |
| | | values.add(h.getForecastValue()); |
| | | } |
| | | } |
| | | BiForecastEngine.Forecast forecast = BiForecastEngine.forecast(model, values, window, period); |
| | | if (forecast == null) { |
| | | throw exception(BI_FORECAST_NO_HISTORY); |
| | | } |
| | | BiForecastDataDO data = BiForecastDataDO.builder() |
| | | .forecastCode(forecastCode) |
| | | .forecastName(history.get(0).getForecastName()) |
| | | .pointTime(pointTime) |
| | | .forecastValue(avg) |
| | | .lowerBound(avg.subtract(bound)) |
| | | .upperBound(avg.add(bound)) |
| | | .forecastValue(forecast.value()) |
| | | .lowerBound(forecast.lowerBound()) |
| | | .upperBound(forecast.upperBound()) |
| | | .confidenceLevel(new BigDecimal("95.00")) |
| | | .modelVersion("v1.0-sma") |
| | | .modelVersion("v2.0-" + model.toLowerCase()) |
| | | .build(); |
| | | forecastDataMapper.insert(forecast); |
| | | forecastDataMapper.insert(data); |
| | | } |
| | | |
| | | @Override |
| | | public int generateKpiForecast(String kpiCode, String model, int window, int period, |
| | | int periods, int intervalMinutes) { |
| | | // 1. æ ¡éª KPI å®ä¹ |
| | | BiKpiDefinitionDO kpi = kpiMapper.selectOne(BiKpiDefinitionDO::getCode, kpiCode); |
| | | if (kpi == null) { |
| | | throw exception(BI_FORECAST_NOT_EXISTS); |
| | | } |
| | | // 2. å KPI åå²å¿«ç
§ï¼ååºï¼ï¼æ æ°æ®ä¸é¢æµ |
| | | List<BiKpiValueHistoryDO> historyList = kpiValueHistoryMapper.selectByKpiCodeAndTimeRange( |
| | | kpiCode, LocalDateTime.now().minusMonths(6), LocalDateTime.now()); |
| | | if (CollUtil.isEmpty(historyList)) { |
| | | throw exception(BI_FORECAST_NO_HISTORY); |
| | | } |
| | | List<BigDecimal> history = new ArrayList<>(); |
| | | LocalDateTime lastSnapshotTime = null; |
| | | for (BiKpiValueHistoryDO h : historyList) { |
| | | if (h.getKpiValue() != null) { |
| | | history.add(h.getKpiValue()); |
| | | lastSnapshotTime = h.getSnapshotTime(); |
| | | } |
| | | } |
| | | if (history.isEmpty()) { |
| | | throw exception(BI_FORECAST_NO_HISTORY); |
| | | } |
| | | // 3. éææ»å¨é¢æµ |
| | | int targetPeriods = periods > 0 ? Math.min(periods, 90) : 30; |
| | | int stepMinutes = intervalMinutes > 0 ? intervalMinutes : 360; |
| | | int count = 0; |
| | | LocalDateTime nextTime = lastSnapshotTime; |
| | | for (int i = 0; i < targetPeriods; i++) { |
| | | BiForecastEngine.Forecast f = BiForecastEngine.forecast(model, history, window, period); |
| | | if (f == null) { |
| | | break; |
| | | } |
| | | nextTime = nextTime.plusMinutes(stepMinutes); |
| | | BiForecastDataDO data = BiForecastDataDO.builder() |
| | | .forecastCode(kpiCode) |
| | | .forecastName(kpi.getName() + " 颿µ") |
| | | .pointTime(nextTime) |
| | | .forecastValue(f.value()) |
| | | .lowerBound(f.lowerBound()) |
| | | .upperBound(f.upperBound()) |
| | | .confidenceLevel(new BigDecimal("95.00")) |
| | | .modelVersion("v2.0-" + (model == null ? "sma" : model.toLowerCase())) |
| | | .dimension("kpi") |
| | | .dimensionValue(kpiCode) |
| | | .build(); |
| | | forecastDataMapper.insert(data); |
| | | count++; |
| | | // å°é¢æµå¼è¿½å è¿åå²ï¼ä¾ä¸ä¸ææ»å¨ |
| | | history.add(f.value()); |
| | | if (history.size() > 200) { |
| | | history.remove(0); |
| | | } |
| | | } |
| | | return count; |
| | | } |
| | | |
| | | @Override |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | package cn.iocoder.yudao.module.bi.service.decision; |
| | | |
| | | import cn.iocoder.yudao.module.bi.dal.dataobject.decision.BiKpiDefinitionDO; |
| | | import cn.iocoder.yudao.module.bi.dal.dataobject.decision.BiKpiValueHistoryDO; |
| | | import cn.iocoder.yudao.module.bi.dal.mysql.decision.BiKpiDefinitionMapper; |
| | | import cn.iocoder.yudao.module.bi.dal.mysql.decision.BiKpiValueHistoryMapper; |
| | | import jakarta.annotation.Resource; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.springframework.jdbc.core.JdbcTemplate; |
| | | import org.springframework.scheduling.annotation.Scheduled; |
| | | import org.springframework.stereotype.Component; |
| | | |
| | | import java.math.BigDecimal; |
| | | import java.math.RoundingMode; |
| | | import java.time.LocalDateTime; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | |
| | | /** |
| | | * KPIåå²å¿«ç
§èåä»»å¡ |
| | | * |
| | | * 宿¶æ«æå¯ç¨ç KPI å®ä¹ï¼æ§è¡æ¥è¯¢ SQL å°å½åå¼åå
¥ bi_kpi_value_historyï¼ |
| | | * ä¾è¶å¿ç å¤ãåæ¯ç¯æ¯ä¸ç»è®¡é¢æµä½¿ç¨ãåä¸ KPI å䏿´ç¹ä»
ä¿çææ°å¼ã |
| | | * |
| | | * @author è¶
级管çå |
| | | */ |
| | | @Slf4j |
| | | @Component |
| | | public class KpiSnapshotTask { |
| | | |
| | | @Resource |
| | | private BiKpiDefinitionMapper kpiMapper; |
| | | |
| | | @Resource |
| | | private BiKpiValueHistoryMapper kpiValueHistoryMapper; |
| | | |
| | | @Resource |
| | | private JdbcTemplate jdbcTemplate; |
| | | |
| | | /** |
| | | * æ¯å°æ¶æ´ç¹è§¦åï¼é»è®¤ cron = 0 0 * * * *ï¼å¯éè¿ bi.kpi.snapshot.cron è¦ç |
| | | */ |
| | | @Scheduled(cron = "${bi.kpi.snapshot.cron:0 0 * * * *}") |
| | | public void snapshotAllKpis() { |
| | | log.debug("[snapshotAllKpis][å¼å§ KPI å¿«ç
§èå]"); |
| | | List<BiKpiDefinitionDO> kpis = kpiMapper.selectList(BiKpiDefinitionDO::getStatus, 1); |
| | | if (kpis.isEmpty()) { |
| | | log.debug("[snapshotAllKpis][æ å¯ç¨ KPI]"); |
| | | return; |
| | | } |
| | | |
| | | // å½åæ´ç¹ï¼ä¿è¯åä¸ KPI åæ´ç¹å¹ç |
| | | LocalDateTime snapshotTime = LocalDateTime.now().withMinute(0).withSecond(0).withNano(0); |
| | | for (BiKpiDefinitionDO kpi : kpis) { |
| | | try { |
| | | snapshotKpi(kpi, snapshotTime); |
| | | } catch (Exception e) { |
| | | log.error("[snapshotAllKpis][KPI {} å¿«ç
§å¤±è´¥]", kpi.getCode(), e); |
| | | } |
| | | } |
| | | } |
| | | |
| | | private void snapshotKpi(BiKpiDefinitionDO kpi, LocalDateTime snapshotTime) { |
| | | BigDecimal value = executeKpiSql(kpi.getQuerySql()); |
| | | if (value == null) { |
| | | log.warn("[snapshotKpi][KPI {} æ å¼ï¼è·³è¿å¿«ç
§]", kpi.getCode()); |
| | | return; |
| | | } |
| | | |
| | | BiKpiValueHistoryDO exist = kpiValueHistoryMapper |
| | | .selectByKpiCodeAndSnapshotTime(kpi.getCode(), snapshotTime); |
| | | if (exist != null) { |
| | | exist.setKpiValue(value); |
| | | kpiValueHistoryMapper.updateById(exist); |
| | | } else { |
| | | BiKpiValueHistoryDO history = BiKpiValueHistoryDO.builder() |
| | | .kpiId(kpi.getId()) |
| | | .kpiCode(kpi.getCode()) |
| | | .kpiName(kpi.getName()) |
| | | .category(kpi.getCategory()) |
| | | .kpiValue(value) |
| | | .snapshotTime(snapshotTime) |
| | | .periodType("hour") |
| | | .dimensionValue("all") |
| | | .build(); |
| | | kpiValueHistoryMapper.insert(history); |
| | | } |
| | | } |
| | | |
| | | private BigDecimal executeKpiSql(String querySql) { |
| | | if (querySql == null || querySql.isBlank()) { |
| | | return null; |
| | | } |
| | | try { |
| | | List<Map<String, Object>> result = jdbcTemplate.queryForList(querySql); |
| | | if (result.isEmpty()) { |
| | | return null; |
| | | } |
| | | Object value = result.get(0).values().iterator().next(); |
| | | if (value instanceof Number) { |
| | | return BigDecimal.valueOf(((Number) value).doubleValue()); |
| | | } |
| | | return null; |
| | | } catch (Exception e) { |
| | | log.error("[executeKpiSql][SQLæ§è¡å¤±è´¥] {}", querySql, e); |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | } |
| | |
| | | telemetry: |
| | | base-url: http://106.227.91.252:3100 # æ°éæå¡å°å |
| | | path: /api/jxjs/mesTb/getDeviceDetails |
| | | |
| | | # BI å³çæ¯æå®æ¶ä»»å¡é
ç½® |
| | | bi: |
| | | alert: |
| | | evaluation: |
| | | interval: 300000 # é¢è¦è¯ä¼°é´éï¼æ¯«ç§ï¼ï¼é»è®¤5åé |
| | | kpi: |
| | | snapshot: |
| | | cron: "0 0 * * * *" # KPIåå²å¿«ç
§èå cronï¼é»è®¤æ¯å°æ¶æ´ç¹ |
| | |
| | | compress: true # æ¯å¦å缩å¾ç |
| | | need-compress-size: 10MB # 触åå缩çæä»¶å¤§å°éå¼ |
| | | compress-quality: 0.5 # å缩质é(0.0-1.0) |
| | | |
| | | # BI å³çæ¯æå®æ¶ä»»å¡é
ç½® |
| | | bi: |
| | | alert: |
| | | evaluation: |
| | | interval: 300000 # é¢è¦è¯ä¼°é´éï¼æ¯«ç§ï¼ï¼é»è®¤5åé |
| | | kpi: |
| | | snapshot: |
| | | cron: "0 0 * * * *" # KPIåå²å¿«ç
§èå cronï¼é»è®¤æ¯å°æ¶æ´ç¹ |