From 5e0640513226d9d9f2d766c075f79832c9d290ba Mon Sep 17 00:00:00 2001
From: 云 <2163098428@qq.com>
Date: 星期三, 09 九月 2026 13:45:44 +0800
Subject: [PATCH] feat(bi): 新增BI决策支持KPI历史快照与预测功能

---
 yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/service/decision/KpiSnapshotTask.java                     |  110 ++++++++
 yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/service/decision/BiForecastEngine.java                    |  189 ++++++++++++++
 yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/dal/mysql/decision/BiKpiValueHistoryMapper.java           |   63 ++++
 yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/service/decision/BiForecastService.java                   |   25 +
 yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/controller/admin/decision/DecisionForecastController.java |   25 +
 yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/dal/dataobject/decision/BiKpiValueHistoryDO.java          |   77 +++++
 yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/service/decision/BiDecisionKpiService.java                |   12 
 yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/controller/admin/decision/DecisionKpiController.java      |   24 +
 yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/service/decision/BiForecastServiceImpl.java               |  106 +++++++-
 yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/service/decision/BiDecisionKpiServiceImpl.java            |  107 ++++++++
 yudao-server/src/main/resources/application-test.yaml                                                              |    9 
 yudao-server/src/main/resources/application-local.yaml                                                             |    9 
 12 files changed, 739 insertions(+), 17 deletions(-)

diff --git a/yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/controller/admin/decision/DecisionForecastController.java b/yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/controller/admin/decision/DecisionForecastController.java
index 2f003bb..580ba22 100644
--- a/yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/controller/admin/decision/DecisionForecastController.java
+++ b/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')")
diff --git a/yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/controller/admin/decision/DecisionKpiController.java b/yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/controller/admin/decision/DecisionKpiController.java
index de14c43..d4ca1ce 100644
--- a/yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/controller/admin/decision/DecisionKpiController.java
+++ b/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));
+    }
+
 }
\ No newline at end of file
diff --git a/yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/dal/dataobject/decision/BiKpiValueHistoryDO.java b/yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/dal/dataobject/decision/BiKpiValueHistoryDO.java
new file mode 100644
index 0000000..b69aa87
--- /dev/null
+++ b/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;
+
+    /**
+     * 缁熻鍛ㄦ湡锛歨our/day/week/month
+     */
+    private String periodType;
+
+    /**
+     * 涓氬姟缁村害鍊硷紝榛樿 all
+     */
+    private String dimensionValue;
+
+    /**
+     * 澶囨敞
+     */
+    private String remark;
+
+}
\ No newline at end of file
diff --git a/yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/dal/mysql/decision/BiKpiValueHistoryMapper.java b/yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/dal/mysql/decision/BiKpiValueHistoryMapper.java
new file mode 100644
index 0000000..ede8513
--- /dev/null
+++ b/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));
+    }
+
+}
\ No newline at end of file
diff --git a/yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/service/decision/BiDecisionKpiService.java b/yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/service/decision/BiDecisionKpiService.java
index 8511efe..7ffe4aa 100644
--- a/yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/service/decision/BiDecisionKpiService.java
+++ b/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);
+
 }
\ No newline at end of file
diff --git a/yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/service/decision/BiDecisionKpiServiceImpl.java b/yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/service/decision/BiDecisionKpiServiceImpl.java
index 5eea031..6e115e4 100644
--- a/yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/service/decision/BiDecisionKpiServiceImpl.java
+++ b/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
      */
diff --git a/yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/service/decision/BiForecastEngine.java b/yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/service/decision/BiForecastEngine.java
new file mode 100644
index 0000000..03ad5a4
--- /dev/null
+++ b/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/绾挎�у洖褰�/瀛h妭鎸囨暟/鍚屾瘮鐜瘮澶栨帹 绛夌粺璁¢娴嬬畻娉曪紝
+ * 杈撳叆鍘嗗彶搴忓垪锛岃繑鍥為娴嬪�煎強 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  绉诲姩骞冲潎绐楀彛锛圫MA/WMA 浣跨敤锛�
+     * @param period  瀛h妭鍛ㄦ湡闀垮害锛圫EASONAL 浣跨敤锛�
+     * @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);
+    }
+
+    /**
+     * 瀛h妭鎸囨暟棰勬祴锛氭寜鍛ㄦ湡褰掍竴鍖栵紝鐢ㄥ懆鏈熷唴鍚岀浉浣嶅潎鍊煎鎺�
+     */
+    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);
+    }
+
+}
\ No newline at end of file
diff --git a/yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/service/decision/BiForecastService.java b/yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/service/decision/BiForecastService.java
index 3026616..e565613 100644
--- a/yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/service/decision/BiForecastService.java
+++ b/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);
 
     /**
diff --git a/yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/service/decision/BiForecastServiceImpl.java b/yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/service/decision/BiForecastServiceImpl.java
index 478da23..84ceec9 100644
--- a/yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/service/decision/BiForecastServiceImpl.java
+++ b/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
diff --git a/yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/service/decision/KpiSnapshotTask.java b/yudao-module-bi/src/main/java/cn/iocoder/yudao/module/bi/service/decision/KpiSnapshotTask.java
new file mode 100644
index 0000000..3759216
--- /dev/null
+++ b/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;
+        }
+    }
+
+}
\ No newline at end of file
diff --git a/yudao-server/src/main/resources/application-local.yaml b/yudao-server/src/main/resources/application-local.yaml
index 61026f9..5063739 100644
--- a/yudao-server/src/main/resources/application-local.yaml
+++ b/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锛岄粯璁ゆ瘡灏忔椂鏁寸偣
diff --git a/yudao-server/src/main/resources/application-test.yaml b/yudao-server/src/main/resources/application-test.yaml
index 5e34067..ef4c1cf 100644
--- a/yudao-server/src/main/resources/application-test.yaml
+++ b/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锛岄粯璁ゆ瘡灏忔椂鏁寸偣

--
Gitblit v1.9.3