3 天以前 5e0640513226d9d9f2d766c075f79832c9d290ba
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端点
已添加4个文件
已修改8个文件
756 ■■■■■ 文件已修改
yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/controller/admin/decision/DecisionForecastController.java 25 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/controller/admin/decision/DecisionKpiController.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/dal/dataobject/decision/BiKpiValueHistoryDO.java 77 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/dal/mysql/decision/BiKpiValueHistoryMapper.java 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/service/decision/BiDecisionKpiService.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/service/decision/BiDecisionKpiServiceImpl.java 107 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/service/decision/BiForecastEngine.java 189 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/service/decision/BiForecastService.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/service/decision/BiForecastServiceImpl.java 106 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/service/decision/KpiSnapshotTask.java 110 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-server/src/main/resources/application-local.yaml 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-server/src/main/resources/application-test.yaml 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/controller/admin/decision/DecisionForecastController.java
@@ -48,13 +48,32 @@
    }
    @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')")
yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/controller/admin/decision/DecisionKpiController.java
@@ -6,14 +6,17 @@
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
@@ -52,4 +55,25 @@
        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));
    }
}
yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/dal/dataobject/decision/BiKpiValueHistoryDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,77 @@
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;
}
yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/dal/mysql/decision/BiKpiValueHistoryMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,63 @@
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));
    }
}
yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/service/decision/BiDecisionKpiService.java
@@ -1,6 +1,7 @@
package cn.iocoder.yudao.module.bi.service.decision;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Map;
/**
@@ -32,4 +33,15 @@
     */
    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);
}
yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/service/decision/BiDecisionKpiServiceImpl.java
@@ -1,13 +1,18 @@
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.*;
/**
@@ -23,6 +28,9 @@
    @Resource
    private BiKpiDefinitionMapper kpiMapper;
    @Resource
    private BiKpiValueHistoryMapper kpiValueHistoryMapper;
    @Resource
    private JdbcTemplate jdbcTemplate;
@@ -105,6 +113,105 @@
        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
     */
yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/service/decision/BiForecastEngine.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,189 @@
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);
    }
}
yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/service/decision/BiForecastService.java
@@ -16,8 +16,33 @@
    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);
    /**
yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/service/decision/BiForecastServiceImpl.java
@@ -5,7 +5,11 @@
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;
@@ -16,6 +20,7 @@
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;
@@ -40,6 +45,10 @@
    @Resource
    private BiForecastDataMapper forecastDataMapper;
    @Resource
    private BiKpiDefinitionMapper kpiMapper;
    @Resource
    private BiKpiValueHistoryMapper kpiValueHistoryMapper;
    @Resource
    private MesProWorkOrderApi mesProWorkOrderApi;
@@ -72,29 +81,98 @@
    @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
yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/service/decision/KpiSnapshotTask.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,110 @@
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;
        }
    }
}
yudao-server/src/main/resources/application-local.yaml
@@ -204,3 +204,12 @@
    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,默认每小时整点
yudao-server/src/main/resources/application-test.yaml
@@ -220,3 +220,12 @@
    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,默认每小时整点