feat: 持续规划 — 滚动预算+自动延展+对比线+偏差告警
This commit is contained in:
@@ -336,6 +336,524 @@ def get_deviation_report(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# 滚动/固定预算切换
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/config")
|
||||||
|
def get_budget_config(db: Session = Depends(get_db)):
|
||||||
|
"""获取预算模式配置"""
|
||||||
|
from app.models import SystemConfig
|
||||||
|
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == "budget_mode").first()
|
||||||
|
if not cfg:
|
||||||
|
return {"budget_mode": "fixed", "rolling_months": 12, "description": "固定预算(年度)"}
|
||||||
|
import json
|
||||||
|
try:
|
||||||
|
val = json.loads(cfg.config_value)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
val = {"mode": "fixed", "rolling_months": 12}
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/config")
|
||||||
|
def set_budget_config(
|
||||||
|
data: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""设置预算模式"""
|
||||||
|
from app.models import SystemConfig
|
||||||
|
import json
|
||||||
|
|
||||||
|
mode = data.get("mode", "fixed")
|
||||||
|
rolling_months = data.get("rolling_months", 12)
|
||||||
|
if mode not in ("fixed", "rolling"):
|
||||||
|
raise HTTPException(400, "预算模式必须是 fixed 或 rolling")
|
||||||
|
|
||||||
|
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == "budget_mode").first()
|
||||||
|
val = json.dumps({"mode": mode, "rolling_months": rolling_months}, ensure_ascii=False)
|
||||||
|
if cfg:
|
||||||
|
cfg.config_value = val
|
||||||
|
else:
|
||||||
|
cfg = SystemConfig(
|
||||||
|
config_key="budget_mode",
|
||||||
|
config_value=val,
|
||||||
|
description="预算模式: fixed=固定预算, rolling=滚动预算",
|
||||||
|
)
|
||||||
|
db.add(cfg)
|
||||||
|
db.commit()
|
||||||
|
return {"message": f"预算模式已切换为{'滚动预算' if mode == 'rolling' else '固定预算'}", "budget_mode": mode, "rolling_months": rolling_months}
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# 滚动预算自动延展
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/roll-forward")
|
||||||
|
def budget_roll_forward(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
滚动预算自动延展:
|
||||||
|
- 删除最早一个月的预测数据
|
||||||
|
- 新增未来一个月的预测数据(取最近三个月均值)
|
||||||
|
- 返回延展结果
|
||||||
|
"""
|
||||||
|
from app.models import SystemConfig
|
||||||
|
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == "budget_mode").first()
|
||||||
|
import json
|
||||||
|
if not cfg:
|
||||||
|
raise HTTPException(400, "未配置预算模式,请先设置")
|
||||||
|
try:
|
||||||
|
val = json.loads(cfg.config_value)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
raise HTTPException(400, "预算模式配置异常")
|
||||||
|
|
||||||
|
if val.get("mode") != "rolling":
|
||||||
|
raise HTTPException(400, "当前为固定预算模式,无需延展")
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
current_year, current_month = now.year, now.month
|
||||||
|
|
||||||
|
# 获取所有active的预算记录
|
||||||
|
plans = db.query(BudgetPlan).filter(BudgetPlan.status == "active").all()
|
||||||
|
|
||||||
|
# 按KPI分组
|
||||||
|
from collections import defaultdict
|
||||||
|
kpi_plans = defaultdict(list)
|
||||||
|
for p in plans:
|
||||||
|
kpi_plans[p.kpi_id].append(p)
|
||||||
|
|
||||||
|
rolled_kpis = []
|
||||||
|
for kpi_id, p_list in kpi_plans.items():
|
||||||
|
# 按期间排序
|
||||||
|
p_list.sort(key=lambda x: (x.budget_year, x.budget_month))
|
||||||
|
|
||||||
|
# 找出最早的一个月并删除
|
||||||
|
if p_list:
|
||||||
|
oldest = p_list[0]
|
||||||
|
db.query(BudgetPlan).filter(BudgetPlan.id == oldest.id).delete()
|
||||||
|
|
||||||
|
# 计算新增月份的预算值(取最近三个月均值)
|
||||||
|
recent_values = [p.budget_value for p in p_list[-3:]] if len(p_list) >= 3 else [p.budget_value for p in p_list]
|
||||||
|
avg_value = round(sum(recent_values) / len(recent_values), 2) if recent_values else 0
|
||||||
|
|
||||||
|
# 计算新的月份(当前月 + 12个月后)
|
||||||
|
new_year = current_year
|
||||||
|
new_month = current_month + val.get("rolling_months", 12)
|
||||||
|
while new_month > 12:
|
||||||
|
new_month -= 12
|
||||||
|
new_year += 1
|
||||||
|
|
||||||
|
new_period = f"{new_year}-{new_month:02d}"
|
||||||
|
|
||||||
|
# 检查是否已存在
|
||||||
|
existing = db.query(BudgetPlan).filter(
|
||||||
|
BudgetPlan.kpi_id == kpi_id,
|
||||||
|
BudgetPlan.period == new_period,
|
||||||
|
BudgetPlan.status == "active",
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not existing:
|
||||||
|
bp = BudgetPlan(
|
||||||
|
kpi_id=kpi_id,
|
||||||
|
period=new_period,
|
||||||
|
budget_value=avg_value,
|
||||||
|
budget_year=new_year,
|
||||||
|
budget_month=new_month,
|
||||||
|
version="rolling",
|
||||||
|
status="active",
|
||||||
|
remark=f"滚动延展自{current_year}-{current_month:02d}",
|
||||||
|
created_by=current_user.name if hasattr(current_user, "name") else "",
|
||||||
|
)
|
||||||
|
db.add(bp)
|
||||||
|
rolled_kpis.append({
|
||||||
|
"kpi_id": kpi_id,
|
||||||
|
"removed_period": f"{p_list[0].budget_year}-{p_list[0].budget_month:02d}" if p_list else None,
|
||||||
|
"added_period": new_period,
|
||||||
|
"predicted_value": avg_value,
|
||||||
|
})
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {
|
||||||
|
"message": f"滚动预算已延展,处理了 {len(rolled_kpis)} 个KPI",
|
||||||
|
"rolled_kpis": rolled_kpis,
|
||||||
|
"current_month": f"{current_year}-{current_month:02d}",
|
||||||
|
"rolling_months": val.get("rolling_months", 12),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# 实际vs预测对比
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/comparison")
|
||||||
|
def get_budget_comparison(
|
||||||
|
kpi_id: Optional[int] = Query(None),
|
||||||
|
year: Optional[int] = Query(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取实际值vs预测值对比数据
|
||||||
|
返回:各月预算值、实际值、偏差率,以及分界点标记
|
||||||
|
"""
|
||||||
|
from app.models import KPIValue, SystemConfig
|
||||||
|
import json
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
y = year or now.year
|
||||||
|
|
||||||
|
# 判断预算模式
|
||||||
|
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == "budget_mode").first()
|
||||||
|
budget_mode = "fixed"
|
||||||
|
rolling_months = 12
|
||||||
|
if cfg:
|
||||||
|
try:
|
||||||
|
val = json.loads(cfg.config_value)
|
||||||
|
budget_mode = val.get("mode", "fixed")
|
||||||
|
rolling_months = val.get("rolling_months", 12)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 确定查询的月份范围
|
||||||
|
if budget_mode == "rolling":
|
||||||
|
# 滚动预算:从当月起的 rolling_months 个月
|
||||||
|
start_year, start_month = now.year, now.month
|
||||||
|
periods = []
|
||||||
|
for i in range(rolling_months):
|
||||||
|
m = start_month + i
|
||||||
|
yy = start_year
|
||||||
|
while m > 12:
|
||||||
|
m -= 12
|
||||||
|
yy += 1
|
||||||
|
periods.append(f"{yy}-{m:02d}")
|
||||||
|
else:
|
||||||
|
# 固定预算:全年1-12月
|
||||||
|
periods = [f"{y}-{m:02d}" for m in range(1, 13)]
|
||||||
|
|
||||||
|
# 查询预算数据
|
||||||
|
query = db.query(BudgetPlan).join(
|
||||||
|
KPIDefinition, BudgetPlan.kpi_id == KPIDefinition.id
|
||||||
|
)
|
||||||
|
if kpi_id:
|
||||||
|
query = query.filter(BudgetPlan.kpi_id == kpi_id)
|
||||||
|
query = query.filter(BudgetPlan.period.in_(periods), BudgetPlan.status == "active")
|
||||||
|
budget_plans = query.all()
|
||||||
|
|
||||||
|
# 按KPI+期间索引
|
||||||
|
bp_map = {}
|
||||||
|
for bp in budget_plans:
|
||||||
|
key = (bp.kpi_id, bp.period)
|
||||||
|
bp_map[key] = bp.budget_value
|
||||||
|
|
||||||
|
# 查询实际值
|
||||||
|
kpi_ids = set(bp.kpi_id for bp in budget_plans)
|
||||||
|
actual_values = {}
|
||||||
|
if kpi_ids:
|
||||||
|
values = db.query(KPIValue).filter(
|
||||||
|
KPIValue.kpi_id.in_(kpi_ids),
|
||||||
|
KPIValue.period.in_(periods),
|
||||||
|
KPIValue.actual_value.isnot(None),
|
||||||
|
).all()
|
||||||
|
for v in values:
|
||||||
|
key = (v.kpi_id, v.period)
|
||||||
|
actual_values[key] = v.actual_value
|
||||||
|
|
||||||
|
# 构建对比数据
|
||||||
|
now_period = now.strftime("%Y-%m")
|
||||||
|
months_data = []
|
||||||
|
for period in periods:
|
||||||
|
monthly = {"period": period, "is_current_period": period == now_period}
|
||||||
|
total_budget = 0
|
||||||
|
total_actual = 0
|
||||||
|
count_budget = 0
|
||||||
|
count_actual = 0
|
||||||
|
for kpi_id_item in kpi_ids:
|
||||||
|
bp_key = (kpi_id_item, period)
|
||||||
|
if bp_key in bp_map:
|
||||||
|
total_budget += bp_map[bp_key] or 0
|
||||||
|
count_budget += 1
|
||||||
|
if bp_key in actual_values:
|
||||||
|
total_actual += actual_values[bp_key] or 0
|
||||||
|
count_actual += 1
|
||||||
|
|
||||||
|
monthly["budget_total"] = round(total_budget, 2)
|
||||||
|
monthly["actual_total"] = round(total_actual, 2)
|
||||||
|
monthly["kpi_count"] = len(kpi_ids)
|
||||||
|
|
||||||
|
# 分界点标记
|
||||||
|
if budget_mode == "rolling":
|
||||||
|
# 滚动预算下,当前月之后为预测值
|
||||||
|
monthly["is_prediction"] = period > now_period
|
||||||
|
else:
|
||||||
|
monthly["is_prediction"] = period > now_period
|
||||||
|
|
||||||
|
# 偏差率
|
||||||
|
if monthly["budget_total"] and monthly["budget_total"] > 0:
|
||||||
|
monthly["deviation_rate"] = round(
|
||||||
|
(monthly["actual_total"] - monthly["budget_total"]) / monthly["budget_total"] * 100, 2
|
||||||
|
) if monthly["actual_total"] is not None else None
|
||||||
|
else:
|
||||||
|
monthly["deviation_rate"] = None
|
||||||
|
|
||||||
|
months_data.append(monthly)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"periods": periods,
|
||||||
|
"budget_mode": budget_mode,
|
||||||
|
"year": y,
|
||||||
|
"current_period": now_period,
|
||||||
|
"months_data": months_data,
|
||||||
|
"total_kpis": len(kpi_ids),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/comparison/kpi/{kpi_id}")
|
||||||
|
def get_kpi_comparison(
|
||||||
|
kpi_id: int,
|
||||||
|
year: Optional[int] = Query(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取单个KPI的实际vs预测对比数据(用于图表展示)
|
||||||
|
"""
|
||||||
|
from app.models import KPIValue, SystemConfig
|
||||||
|
import json
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
y = year or now.year
|
||||||
|
|
||||||
|
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||||
|
if not kpi:
|
||||||
|
raise HTTPException(404, "KPI不存在")
|
||||||
|
|
||||||
|
periods = [f"{y}-{m:02d}" for m in range(1, 13)]
|
||||||
|
|
||||||
|
# 预算值
|
||||||
|
budgets = db.query(BudgetPlan).filter(
|
||||||
|
BudgetPlan.kpi_id == kpi_id,
|
||||||
|
BudgetPlan.period.in_(periods),
|
||||||
|
BudgetPlan.status == "active",
|
||||||
|
).all()
|
||||||
|
budget_map = {bp.period: bp.budget_value for bp in budgets}
|
||||||
|
|
||||||
|
# 实际值
|
||||||
|
actuals = db.query(KPIValue).filter(
|
||||||
|
KPIValue.kpi_id == kpi_id,
|
||||||
|
KPIValue.period.in_(periods),
|
||||||
|
KPIValue.actual_value.isnot(None),
|
||||||
|
).all()
|
||||||
|
actual_map = {av.period: av.actual_value for av in actuals}
|
||||||
|
|
||||||
|
now_period = now.strftime("%Y-%m")
|
||||||
|
data_points = []
|
||||||
|
for period in periods:
|
||||||
|
bv = budget_map.get(period)
|
||||||
|
av = actual_map.get(period)
|
||||||
|
dr = None
|
||||||
|
if bv and bv > 0 and av is not None:
|
||||||
|
dr = round((av - bv) / bv * 100, 2)
|
||||||
|
|
||||||
|
data_points.append({
|
||||||
|
"period": period,
|
||||||
|
"budget_value": bv,
|
||||||
|
"actual_value": av,
|
||||||
|
"deviation_rate": dr,
|
||||||
|
"is_prediction": period > now_period,
|
||||||
|
"is_current_period": period == now_period,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"kpi_id": kpi.id,
|
||||||
|
"kpi_code": kpi.kpi_code,
|
||||||
|
"kpi_name": kpi.kpi_name,
|
||||||
|
"unit": kpi.unit or "",
|
||||||
|
"year": y,
|
||||||
|
"current_period": now_period,
|
||||||
|
"data_points": data_points,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# 预测偏差告警
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/deviation-check")
|
||||||
|
def check_budget_deviation(
|
||||||
|
data: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
检查实际vs预测偏差,当偏差超过20%时自动生成预警
|
||||||
|
"""
|
||||||
|
from app.models import KPIValue, BudgetDeviationAlert
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
threshold = data.get("threshold", 20) # 默认20%
|
||||||
|
period = data.get("period") or datetime.now().strftime("%Y-%m")
|
||||||
|
auto_resolve = data.get("auto_resolve", True) # 是否自动关闭已解决的预警
|
||||||
|
|
||||||
|
# 查询该期间的有预算的KPI
|
||||||
|
budget_plans = db.query(BudgetPlan).filter(
|
||||||
|
BudgetPlan.period == period,
|
||||||
|
BudgetPlan.status == "active",
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if not budget_plans:
|
||||||
|
return {
|
||||||
|
"message": f"期间 {period} 无预算数据",
|
||||||
|
"alerts_generated": 0,
|
||||||
|
"alerts": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
alerts_generated = 0
|
||||||
|
alerts = []
|
||||||
|
|
||||||
|
for bp in budget_plans:
|
||||||
|
# 查询实际值
|
||||||
|
actual = db.query(KPIValue).filter(
|
||||||
|
KPIValue.kpi_id == bp.kpi_id,
|
||||||
|
KPIValue.period == period,
|
||||||
|
KPIValue.actual_value.isnot(None),
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not actual or actual.actual_value is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
budget_val = bp.budget_value
|
||||||
|
actual_val = actual.actual_value
|
||||||
|
|
||||||
|
if budget_val == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 计算偏差率
|
||||||
|
deviation_rate = round((actual_val - budget_val) / budget_val * 100, 2)
|
||||||
|
|
||||||
|
# 只有偏差超过阈值才生成预警
|
||||||
|
if abs(deviation_rate) <= threshold:
|
||||||
|
continue
|
||||||
|
|
||||||
|
deviation_value = round(actual_val - budget_val, 2)
|
||||||
|
|
||||||
|
# 判断预警等级
|
||||||
|
alert_level = "critical" if abs(deviation_rate) > 50 else "warning"
|
||||||
|
|
||||||
|
# 生成建议
|
||||||
|
if deviation_rate > 0:
|
||||||
|
suggestion = f"实际值超出预算 {deviation_rate}%,建议核查超支原因并采取控制措施"
|
||||||
|
else:
|
||||||
|
suggestion = f"实际值低于预算 {abs(deviation_rate)}%,建议分析是否预算过高或业务量未达预期"
|
||||||
|
|
||||||
|
# 检查是否已存在相同的预警
|
||||||
|
existing_alert = db.query(BudgetDeviationAlert).filter(
|
||||||
|
BudgetDeviationAlert.kpi_id == bp.kpi_id,
|
||||||
|
BudgetDeviationAlert.period == period,
|
||||||
|
BudgetDeviationAlert.status == "open",
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing_alert:
|
||||||
|
continue
|
||||||
|
|
||||||
|
alert = BudgetDeviationAlert(
|
||||||
|
kpi_id=bp.kpi_id,
|
||||||
|
period=period,
|
||||||
|
budget_value=budget_val,
|
||||||
|
actual_value=actual_val,
|
||||||
|
deviation_rate=deviation_rate,
|
||||||
|
deviation_value=deviation_value,
|
||||||
|
alert_level=alert_level,
|
||||||
|
status="open",
|
||||||
|
suggestion=suggestion,
|
||||||
|
)
|
||||||
|
db.add(alert)
|
||||||
|
alerts_generated += 1
|
||||||
|
|
||||||
|
kpi_obj = db.query(KPIDefinition).filter(KPIDefinition.id == bp.kpi_id).first()
|
||||||
|
alerts.append({
|
||||||
|
"kpi_id": bp.kpi_id,
|
||||||
|
"kpi_code": kpi_obj.kpi_code if kpi_obj else "",
|
||||||
|
"kpi_name": kpi_obj.kpi_name if kpi_obj else "",
|
||||||
|
"period": period,
|
||||||
|
"budget_value": budget_val,
|
||||||
|
"actual_value": actual_val,
|
||||||
|
"deviation_rate": deviation_rate,
|
||||||
|
"deviation_value": deviation_value,
|
||||||
|
"alert_level": alert_level,
|
||||||
|
"suggestion": suggestion,
|
||||||
|
})
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": f"检查完成,生成了 {alerts_generated} 条预警",
|
||||||
|
"period": period,
|
||||||
|
"threshold": threshold,
|
||||||
|
"alerts_generated": alerts_generated,
|
||||||
|
"alerts": alerts,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/deviation-alerts")
|
||||||
|
def list_deviation_alerts(
|
||||||
|
kpi_id: Optional[int] = Query(None),
|
||||||
|
period: Optional[str] = Query(None),
|
||||||
|
alert_level: Optional[str] = Query(None),
|
||||||
|
status: Optional[str] = Query(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""查询预算偏差预警记录"""
|
||||||
|
from app.models import BudgetDeviationAlert
|
||||||
|
query = db.query(BudgetDeviationAlert)
|
||||||
|
if kpi_id:
|
||||||
|
query = query.filter(BudgetDeviationAlert.kpi_id == kpi_id)
|
||||||
|
if period:
|
||||||
|
query = query.filter(BudgetDeviationAlert.period == period)
|
||||||
|
if alert_level:
|
||||||
|
query = query.filter(BudgetDeviationAlert.alert_level == alert_level)
|
||||||
|
if status:
|
||||||
|
query = query.filter(BudgetDeviationAlert.status == status)
|
||||||
|
|
||||||
|
alerts = query.order_by(BudgetDeviationAlert.created_at.desc()).all()
|
||||||
|
result = []
|
||||||
|
for a in alerts:
|
||||||
|
kpi_obj = db.query(KPIDefinition).filter(KPIDefinition.id == a.kpi_id).first()
|
||||||
|
result.append({
|
||||||
|
"id": a.id,
|
||||||
|
"kpi_id": a.kpi_id,
|
||||||
|
"kpi_code": kpi_obj.kpi_code if kpi_obj else "",
|
||||||
|
"kpi_name": kpi_obj.kpi_name if kpi_obj else "",
|
||||||
|
"period": a.period,
|
||||||
|
"budget_value": a.budget_value,
|
||||||
|
"actual_value": a.actual_value,
|
||||||
|
"deviation_rate": a.deviation_rate,
|
||||||
|
"deviation_value": a.deviation_value,
|
||||||
|
"alert_level": a.alert_level,
|
||||||
|
"status": a.status,
|
||||||
|
"suggestion": a.suggestion,
|
||||||
|
"created_at": a.created_at.isoformat() if a.created_at else None,
|
||||||
|
})
|
||||||
|
return {"data": result, "total": len(result)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/deviation-alerts/{alert_id}")
|
||||||
|
def update_deviation_alert(
|
||||||
|
alert_id: int,
|
||||||
|
data: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""更新偏差预警(如标记已解决)"""
|
||||||
|
from app.models import BudgetDeviationAlert
|
||||||
|
alert = db.query(BudgetDeviationAlert).filter(BudgetDeviationAlert.id == alert_id).first()
|
||||||
|
if not alert:
|
||||||
|
raise HTTPException(404, "预警记录不存在")
|
||||||
|
if "status" in data:
|
||||||
|
alert.status = data["status"]
|
||||||
|
db.commit()
|
||||||
|
return {"message": "预警已更新"}
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
# 功能6: 预算方法三选一向导 (CMA P1 - 增量/零基/弹性)
|
# 功能6: 预算方法三选一向导 (CMA P1 - 增量/零基/弹性)
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
|
|||||||
@@ -382,6 +382,32 @@ class CashForecast(Base):
|
|||||||
created_at = Column(DateTime, server_default=func.now())
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class SystemConfig(Base):
|
||||||
|
"""系统配置 — key-value存储"""
|
||||||
|
__tablename__ = "system_configs"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
config_key = Column(String(100), unique=True, nullable=False, comment="配置键")
|
||||||
|
config_value = Column(String(500), nullable=True, comment="配置值")
|
||||||
|
description = Column(String(500), nullable=True, comment="配置说明")
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetDeviationAlert(Base):
|
||||||
|
"""预算偏差预警记录"""
|
||||||
|
__tablename__ = "budget_deviation_alerts"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="关联KPI")
|
||||||
|
period = Column(String(20), nullable=False, comment="期间 YYYY-MM")
|
||||||
|
budget_value = Column(Float, nullable=True, comment="预算值")
|
||||||
|
actual_value = Column(Float, nullable=True, comment="实际值")
|
||||||
|
deviation_rate = Column(Float, nullable=True, comment="偏差率 %")
|
||||||
|
deviation_value = Column(Float, nullable=True, comment="偏差绝对值")
|
||||||
|
alert_level = Column(String(20), default="warning", comment="warning/critical")
|
||||||
|
status = Column(String(20), default="open", comment="open/resolved/ignored")
|
||||||
|
suggestion = Column(String(500), nullable=True, comment="处理建议")
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
class ForecastAccuracy(Base):
|
class ForecastAccuracy(Base):
|
||||||
"""预测准确率 — 上期预测 vs 本期实际"""
|
"""预测准确率 — 上期预测 vs 本期实际"""
|
||||||
__tablename__ = "forecast_accuracy"
|
__tablename__ = "forecast_accuracy"
|
||||||
|
|||||||
@@ -150,6 +150,15 @@ export const budgetApi = {
|
|||||||
autoDecompose: (data: any) => api.post('/budget/auto-decompose', data),
|
autoDecompose: (data: any) => api.post('/budget/auto-decompose', data),
|
||||||
deviationReport: (params?: any) => api.get('/budget/deviation-report', { params }),
|
deviationReport: (params?: any) => api.get('/budget/deviation-report', { params }),
|
||||||
methodComparison: (data: any) => api.post('/budget/method-comparison', data),
|
methodComparison: (data: any) => api.post('/budget/method-comparison', data),
|
||||||
|
// 持续规划
|
||||||
|
getConfig: () => api.get('/budget/config'),
|
||||||
|
setConfig: (data: any) => api.post('/budget/config', data),
|
||||||
|
rollForward: () => api.post('/budget/roll-forward'),
|
||||||
|
getComparison: (params?: any) => api.get('/budget/comparison', { params }),
|
||||||
|
getKpiComparison: (kpiId: number, params?: any) => api.get(`/budget/comparison/kpi/${kpiId}`, { params }),
|
||||||
|
deviationCheck: (data: any) => api.post('/budget/deviation-check', data),
|
||||||
|
listDeviationAlerts: (params?: any) => api.get('/budget/deviation-alerts', { params }),
|
||||||
|
updateDeviationAlert: (id: number, data: any) => api.put(`/budget/deviation-alerts/${id}`, data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const costApi = {
|
export const costApi = {
|
||||||
|
|||||||
@@ -29,6 +29,21 @@
|
|||||||
<el-switch v-model="batchEditMode" active-text="批量编辑" inactive-text="逐行编辑" @change="onBatchEditToggle" />
|
<el-switch v-model="batchEditMode" active-text="批量编辑" inactive-text="逐行编辑" @change="onBatchEditToggle" />
|
||||||
<el-button v-if="batchEditMode" type="primary" @click="batchSaveAll" :loading="batchSavingAll" :disabled="changedRows.length === 0">批量保存 ({{ changedRows.length }})</el-button>
|
<el-button v-if="batchEditMode" type="primary" @click="batchSaveAll" :loading="batchSavingAll" :disabled="changedRows.length === 0">批量保存 ({{ changedRows.length }})</el-button>
|
||||||
<el-button v-if="batchEditMode" @click="batchCancelAll">取消</el-button>
|
<el-button v-if="batchEditMode" @click="batchCancelAll">取消</el-button>
|
||||||
|
<el-divider direction="vertical" />
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;font-size:12px;">
|
||||||
|
<span style="color:#909399;">预算模式:</span>
|
||||||
|
<el-switch
|
||||||
|
v-model="budgetMode"
|
||||||
|
active-value="rolling"
|
||||||
|
inactive-value="fixed"
|
||||||
|
active-text="滚动预算"
|
||||||
|
inactive-text="固定预算"
|
||||||
|
@change="onBudgetModeChange"
|
||||||
|
/>
|
||||||
|
<el-tag v-if="budgetMode === 'rolling'" size="small" type="warning" effect="plain">滚动12月</el-tag>
|
||||||
|
<el-tag v-else size="small" type="info" effect="plain">固定年度</el-tag>
|
||||||
|
</div>
|
||||||
|
<el-button v-if="budgetMode === 'rolling'" size="small" type="warning" @click="doRollForward" :loading="rollingForward">延展</el-button>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="budgetViewMode === 'list'">
|
<template v-if="budgetViewMode === 'list'">
|
||||||
<el-table :data="budgetList" v-loading="loading" border stripe size="small" style="width:100%;" :row-class-name="rowClass" :empty-text="filterYear ? '暂无数据,在行内编辑填值后保存即可创建' : '请先选择年份'">
|
<el-table :data="budgetList" v-loading="loading" border stripe size="small" style="width:100%;" :row-class-name="rowClass" :empty-text="filterYear ? '暂无数据,在行内编辑填值后保存即可创建' : '请先选择年份'">
|
||||||
@@ -222,6 +237,132 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="持续规划" name="rolling">
|
||||||
|
<el-tabs v-model="rollingTab" type="border-card" style="margin-top:4px;">
|
||||||
|
<el-tab-pane label="实际vs预测对比" name="comparison">
|
||||||
|
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;align-items:center;">
|
||||||
|
<el-select v-model="comparisonYear" placeholder="年份" style="width:100px;"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select>
|
||||||
|
<el-select v-model="comparisonKpiId" placeholder="选择KPI(可选)" filterable clearable style="width:220px;" @change="loadComparison">
|
||||||
|
<el-option v-for="k in comparisonKpiOptions" :key="k.id" :label="`${k.kpi_code} - ${k.kpi_name}`" :value="k.id" />
|
||||||
|
</el-select>
|
||||||
|
<el-button type="primary" @click="loadComparison">刷新</el-button>
|
||||||
|
<el-tag v-if="budgetMode === 'rolling'" size="small" type="warning">滚动预算 - 当前月之后为预测值</el-tag>
|
||||||
|
<el-tag v-else size="small" type="info">固定预算</el-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 对比图表 -->
|
||||||
|
<el-card v-if="comparisonData.length > 0" shadow="hover" style="margin-bottom:16px;">
|
||||||
|
<div ref="comparisonChartRef" style="width:100%;height:360px;"></div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 分界点指示 & 摘要 -->
|
||||||
|
<el-row :gutter="16" style="margin-bottom:16px;" v-if="comparisonSummary">
|
||||||
|
<el-col :span="6" v-for="s in comparisonSummary" :key="s.label">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<div style="text-align:center;">
|
||||||
|
<div style="font-size:12px;color:#999;">{{ s.label }}</div>
|
||||||
|
<div style="font-size:20px;font-weight:600;margin-top:4px;" :style="{color: s.color}">{{ s.value }}</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<!-- 对比详情表格 -->
|
||||||
|
<el-table :data="comparisonData" v-loading="comparisonLoading" border stripe size="small" style="width:100%;">
|
||||||
|
<el-table-column prop="period" label="期间" width="100" />
|
||||||
|
<el-table-column prop="budget_total" label="预算值" width="150"><template #default="{ row }">{{ formatNumber(row.budget_total) }}</template></el-table-column>
|
||||||
|
<el-table-column prop="actual_total" label="实际值" width="150"><template #default="{ row }">{{ row.actual_total != null ? formatNumber(row.actual_total) : '--' }}</template></el-table-column>
|
||||||
|
<el-table-column label="偏差率" width="130">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.deviation_rate != null" :type="Math.abs(row.deviation_rate) > 20 ? 'danger' : Math.abs(row.deviation_rate) > 10 ? 'warning' : 'success'" size="small">
|
||||||
|
{{ row.deviation_rate > 0 ? '+' : '' }}{{ row.deviation_rate }}%
|
||||||
|
</el-tag>
|
||||||
|
<span v-else style="color:#ccc;">--</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="类型标记" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.is_prediction" size="small" type="warning">预测值</el-tag>
|
||||||
|
<el-tag v-else-if="row.is_current_period" size="small" type="danger">当前期</el-tag>
|
||||||
|
<el-tag v-else size="small" type="success">已发生</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div v-if="comparisonData.length === 0 && !comparisonLoading" style="padding:40px 0;text-align:center;color:#999;">
|
||||||
|
<p>暂无对比数据,请先录入预算和实际值</p>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="偏差告警" name="alerts">
|
||||||
|
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;align-items:center;">
|
||||||
|
<el-select v-model="alertFilterPeriod" placeholder="期间" style="width:120px;">
|
||||||
|
<el-option v-for="p in alertPeriodOptions" :key="p" :label="p" :value="p" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="alertFilterLevel" placeholder="级别" clearable style="width:100px;">
|
||||||
|
<el-option label="严重" value="critical" />
|
||||||
|
<el-option label="警告" value="warning" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="alertFilterStatus" placeholder="状态" clearable style="width:100px;">
|
||||||
|
<el-option label="未处理" value="open" />
|
||||||
|
<el-option label="已解决" value="resolved" />
|
||||||
|
<el-option label="已忽略" value="ignored" />
|
||||||
|
</el-select>
|
||||||
|
<el-button type="primary" @click="loadDeviationAlerts">查询</el-button>
|
||||||
|
<el-button type="danger" @click="doDeviationCheck" :loading="deviationChecking">执行偏差检查 (超20%告警)</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-alert
|
||||||
|
v-if="deviationAlertMessage"
|
||||||
|
:title="deviationAlertMessage"
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
:closable="true"
|
||||||
|
style="margin-bottom:12px;"
|
||||||
|
@close="deviationAlertMessage = ''"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-table :data="deviationAlerts" v-loading="alertLoading" border stripe size="small" style="width:100%;">
|
||||||
|
<el-table-column prop="period" label="期间" width="90" />
|
||||||
|
<el-table-column prop="kpi_code" label="KPI编码" width="100" />
|
||||||
|
<el-table-column prop="kpi_name" label="KPI名称" min-width="140" />
|
||||||
|
<el-table-column prop="budget_value" label="预算值" width="120"><template #default="{ row }">{{ formatNumber(row.budget_value) }}</template></el-table-column>
|
||||||
|
<el-table-column prop="actual_value" label="实际值" width="120"><template #default="{ row }">{{ formatNumber(row.actual_value) }}</template></el-table-column>
|
||||||
|
<el-table-column prop="deviation_rate" label="偏差率" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="Math.abs(row.deviation_rate) > 50 ? 'danger' : 'warning'" size="small">
|
||||||
|
{{ row.deviation_rate > 0 ? '+' : '' }}{{ row.deviation_rate }}%
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="级别" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.alert_level === 'critical' ? 'danger' : 'warning'" size="small">
|
||||||
|
{{ row.alert_level === 'critical' ? '严重' : '警告' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="suggestion" label="建议" min-width="180" />
|
||||||
|
<el-table-column label="状态" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.status === 'open'" type="danger" size="small">未处理</el-tag>
|
||||||
|
<el-tag v-else-if="row.status === 'resolved'" type="success" size="small">已解决</el-tag>
|
||||||
|
<el-tag v-else type="info" size="small">已忽略</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="120" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button v-if="row.status === 'open'" size="small" type="success" @click="resolveAlert(row)">标记解决</el-button>
|
||||||
|
<el-button v-else-if="row.status === 'resolved'" size="small" @click="reopenAlert(row)">重新打开</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div v-if="deviationAlerts.length === 0 && !alertLoading" style="padding:40px 0;text-align:center;color:#999;">
|
||||||
|
<p>暂无偏差预警,点击「执行偏差检查」扫描当前期间</p>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
|
|
||||||
<MyDialog v-model="showAddBudget" title="新增预算" :width="500">
|
<MyDialog v-model="showAddBudget" title="新增预算" :width="500">
|
||||||
@@ -283,13 +424,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed, watch } from 'vue'
|
import { ref, onMounted, computed, watch, nextTick } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { budgetApi, kpiApi, mapApi, actionPlanApi } from '../api/index'
|
import { budgetApi, kpiApi, mapApi, actionPlanApi } from '../api/index'
|
||||||
import MyDialog from '../components/MyDialog.vue'
|
import MyDialog from '../components/MyDialog.vue'
|
||||||
|
|
||||||
const activeTab = ref('input')
|
const activeTab = ref('input')
|
||||||
const currentYear = new Date().getFullYear()
|
const currentYear = new Date().getFullYear()
|
||||||
|
const currentMonth = new Date().getMonth() + 1
|
||||||
const yearOptions = computed(() => { const y: number[] = []; for (let i = currentYear - 2; i <= currentYear + 2; i++) y.push(i); return y })
|
const yearOptions = computed(() => { const y: number[] = []; for (let i = currentYear - 2; i <= currentYear + 2; i++) y.push(i); return y })
|
||||||
const filterYear = ref(currentYear); const filterMonth = ref(0); const searchKpi = ref('')
|
const filterYear = ref(currentYear); const filterMonth = ref(0); const searchKpi = ref('')
|
||||||
const loading = ref(false); const budgetList = ref<any[]>([]); const page = ref(1); const pageSize = ref(20); const total = ref(0); const noMap = ref(false)
|
const loading = ref(false); const budgetList = ref<any[]>([]); const page = ref(1); const pageSize = ref(20); const total = ref(0); const noMap = ref(false)
|
||||||
@@ -524,16 +666,247 @@ function confirmMethod() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 滚动/固定预算切换 ──
|
||||||
|
const budgetMode = ref('fixed')
|
||||||
|
const rollingForward = ref(false)
|
||||||
|
|
||||||
|
async function loadBudgetConfig() {
|
||||||
|
try {
|
||||||
|
const r: any = await budgetApi.getConfig()
|
||||||
|
budgetMode.value = r.budget_mode || r.mode || 'fixed'
|
||||||
|
} catch { budgetMode.value = 'fixed' }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onBudgetModeChange(mode: string) {
|
||||||
|
try {
|
||||||
|
await budgetApi.setConfig({ mode, rolling_months: 12 })
|
||||||
|
ElMessage.success(`预算模式已切换为${mode === 'rolling' ? '滚动预算' : '固定预算'}`)
|
||||||
|
loadBudget()
|
||||||
|
} catch (e: any) {
|
||||||
|
budgetMode.value = mode === 'rolling' ? 'fixed' : 'rolling'
|
||||||
|
ElMessage.error(e?.response?.data?.detail || '切换失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doRollForward() {
|
||||||
|
rollingForward.value = true
|
||||||
|
try {
|
||||||
|
const r: any = await budgetApi.rollForward()
|
||||||
|
ElMessage.success(r.message || '滚动预算已延展')
|
||||||
|
if (r.rolled_kpis?.length > 0) {
|
||||||
|
ElMessage.info(`新增了 ${r.rolled_kpis.length} 个KPI的未来一个月预测`)
|
||||||
|
}
|
||||||
|
loadBudget()
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.detail || '延展失败')
|
||||||
|
}
|
||||||
|
rollingForward.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 实际vs预测对比 ──
|
||||||
|
const rollingTab = ref('comparison')
|
||||||
|
const comparisonYear = ref(currentYear)
|
||||||
|
const comparisonKpiId = ref<number | null>(null)
|
||||||
|
const comparisonLoading = ref(false)
|
||||||
|
const comparisonData = ref<any[]>([])
|
||||||
|
const comparisonSummary = ref<any[]>([])
|
||||||
|
const comparisonKpiOptions = ref<any[]>([])
|
||||||
|
const comparisonChartRef = ref<HTMLElement | null>(null)
|
||||||
|
let comparisonChart: any = null
|
||||||
|
|
||||||
|
async function loadComparisonKpiOptions() {
|
||||||
|
try {
|
||||||
|
const r: any = await kpiApi.list({ page_size: 200 })
|
||||||
|
const d = r.data || r || []
|
||||||
|
comparisonKpiOptions.value = Array.isArray(d) ? d : (d.items || [])
|
||||||
|
} catch { comparisonKpiOptions.value = [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadComparison() {
|
||||||
|
comparisonLoading.value = true
|
||||||
|
try {
|
||||||
|
const params: any = { year: comparisonYear.value }
|
||||||
|
if (comparisonKpiId.value) params.kpi_id = comparisonKpiId.value
|
||||||
|
const r: any = await budgetApi.getComparison(params)
|
||||||
|
comparisonData.value = r.months_data || []
|
||||||
|
// 计算摘要
|
||||||
|
const md = comparisonData.value
|
||||||
|
const totalBudget = md.reduce((s: number, m: any) => s + (m.budget_total || 0), 0)
|
||||||
|
const totalActual = md.reduce((s: number, m: any) => s + (m.actual_total || 0), 0)
|
||||||
|
const maxDevRate = Math.max(...md.filter((m: any) => m.deviation_rate != null).map((m: any) => Math.abs(m.deviation_rate)), 0)
|
||||||
|
const alertCount = md.filter((m: any) => m.deviation_rate != null && Math.abs(m.deviation_rate) > 20).length
|
||||||
|
comparisonSummary.value = [
|
||||||
|
{ label: '预算总额', value: formatNumber(totalBudget), color: '#409eff' },
|
||||||
|
{ label: '实际总额', value: totalActual > 0 ? formatNumber(totalActual) : '--', color: totalActual > totalBudget ? '#f56c6c' : '#67c23a' },
|
||||||
|
{ label: '最大偏差率', value: maxDevRate > 0 ? `${maxDevRate}%` : '--', color: maxDevRate > 20 ? '#f56c6c' : '#67c23a' },
|
||||||
|
{ label: '超20%偏差月数', value: `${alertCount}个月`, color: alertCount > 0 ? '#f56c6c' : '#67c23a' },
|
||||||
|
]
|
||||||
|
// 渲染echarts图表
|
||||||
|
await nextTick()
|
||||||
|
renderComparisonChart()
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error('加载对比数据失败')
|
||||||
|
comparisonData.value = []
|
||||||
|
}
|
||||||
|
comparisonLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderComparisonChart() {
|
||||||
|
if (!comparisonChartRef.value) return
|
||||||
|
// 使用ECharts
|
||||||
|
const echarts = (window as any).echarts
|
||||||
|
if (!echarts) {
|
||||||
|
// 如果没有全局echarts,尝试从已挂载的组件获取
|
||||||
|
import('echarts').then(echarts => {
|
||||||
|
doRenderChart(echarts)
|
||||||
|
}).catch(() => {
|
||||||
|
// ECharts可能已经在vendor bundle中
|
||||||
|
if ((window as any).echarts) doRenderChart((window as any).echarts)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
doRenderChart(echarts)
|
||||||
|
}
|
||||||
|
|
||||||
|
function doRenderChart(echarts: any) {
|
||||||
|
if (comparisonChart) comparisonChart.dispose()
|
||||||
|
comparisonChart = echarts.init(comparisonChartRef.value!)
|
||||||
|
const data = comparisonData.value
|
||||||
|
const periods = data.map((d: any) => d.period)
|
||||||
|
const budgetValues = data.map((d: any) => d.budget_total != null ? d.budget_total : null)
|
||||||
|
const actualValues = data.map((d: any) => d.actual_total != null ? d.actual_total : null)
|
||||||
|
|
||||||
|
// 找到分界点(当前期之后为预测)
|
||||||
|
const nowPeriod = `${currentYear}-${String(currentMonth).padStart(2, '0')}`
|
||||||
|
const splitIndex = periods.findIndex((p: string) => p > nowPeriod)
|
||||||
|
|
||||||
|
const option = {
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
legend: { data: ['预算值', '实际值'] },
|
||||||
|
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||||
|
xAxis: { type: 'category', data: periods, axisLabel: { rotate: 45 } },
|
||||||
|
yAxis: { type: 'value' },
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '预算值',
|
||||||
|
type: 'line',
|
||||||
|
data: budgetValues,
|
||||||
|
smooth: true,
|
||||||
|
lineStyle: { width: 2, color: '#409eff' },
|
||||||
|
itemStyle: { color: '#409eff' },
|
||||||
|
markLine: splitIndex >= 0 ? {
|
||||||
|
data: [{ xAxis: periods[splitIndex] || periods[splitIndex - 1] }],
|
||||||
|
label: { formatter: '▼ 预测开始', color: '#e6a23c' },
|
||||||
|
lineStyle: { color: '#e6a23c', type: 'dashed', width: 2 },
|
||||||
|
} : undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '实际值',
|
||||||
|
type: 'bar',
|
||||||
|
data: actualValues,
|
||||||
|
barWidth: '20%',
|
||||||
|
itemStyle: {
|
||||||
|
color: (params: any) => {
|
||||||
|
const item = data[params.dataIndex]
|
||||||
|
if (!item) return '#67c23a'
|
||||||
|
if (item.actual_total == null) return '#d9d9d9'
|
||||||
|
if (item.deviation_rate != null && Math.abs(item.deviation_rate) > 20) return '#f56c6c'
|
||||||
|
if (item.deviation_rate != null && Math.abs(item.deviation_rate) > 10) return '#e6a23c'
|
||||||
|
return '#67c23a'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
comparisonChart.setOption(option)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 偏差告警 ──
|
||||||
|
const alertFilterPeriod = ref(`${currentYear}-${String(currentMonth).padStart(2, '0')}`)
|
||||||
|
const alertFilterLevel = ref('')
|
||||||
|
const alertFilterStatus = ref('')
|
||||||
|
const alertLoading = ref(false)
|
||||||
|
const deviationAlerts = ref<any[]>([])
|
||||||
|
const deviationChecking = ref(false)
|
||||||
|
const deviationAlertMessage = ref('')
|
||||||
|
|
||||||
|
const alertPeriodOptions = computed(() => {
|
||||||
|
const opts: string[] = []
|
||||||
|
for (let m = 1; m <= 12; m++) {
|
||||||
|
opts.push(`${currentYear}-${String(m).padStart(2, '0')}`)
|
||||||
|
}
|
||||||
|
return opts
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadDeviationAlerts() {
|
||||||
|
alertLoading.value = true
|
||||||
|
try {
|
||||||
|
const params: any = {}
|
||||||
|
if (alertFilterPeriod.value) params.period = alertFilterPeriod.value
|
||||||
|
if (alertFilterLevel.value) params.alert_level = alertFilterLevel.value
|
||||||
|
if (alertFilterStatus.value) params.status = alertFilterStatus.value
|
||||||
|
const r: any = await budgetApi.listDeviationAlerts(params)
|
||||||
|
deviationAlerts.value = r.data || []
|
||||||
|
} catch { deviationAlerts.value = [] }
|
||||||
|
alertLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doDeviationCheck() {
|
||||||
|
deviationChecking.value = true
|
||||||
|
deviationAlertMessage.value = ''
|
||||||
|
try {
|
||||||
|
const r: any = await budgetApi.deviationCheck({
|
||||||
|
period: alertFilterPeriod.value,
|
||||||
|
threshold: 20,
|
||||||
|
})
|
||||||
|
if (r.alerts_generated > 0) {
|
||||||
|
deviationAlertMessage.value = `偏差检查完成:生成了 ${r.alerts_generated} 条预警`
|
||||||
|
ElMessage.warning(`发现 ${r.alerts_generated} 条偏差预警`)
|
||||||
|
} else {
|
||||||
|
deviationAlertMessage.value = '偏差检查完成:未发现超过20%的偏差'
|
||||||
|
ElMessage.success('未发现偏差预警')
|
||||||
|
}
|
||||||
|
loadDeviationAlerts()
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.detail || '偏差检查失败')
|
||||||
|
}
|
||||||
|
deviationChecking.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveAlert(row: any) {
|
||||||
|
try {
|
||||||
|
await budgetApi.updateDeviationAlert(row.id, { status: 'resolved' })
|
||||||
|
ElMessage.success('已标记为已解决')
|
||||||
|
row.status = 'resolved'
|
||||||
|
loadDeviationAlerts()
|
||||||
|
} catch { ElMessage.error('操作失败') }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reopenAlert(row: any) {
|
||||||
|
try {
|
||||||
|
await budgetApi.updateDeviationAlert(row.id, { status: 'open' })
|
||||||
|
ElMessage.success('已重新打开')
|
||||||
|
row.status = 'open'
|
||||||
|
loadDeviationAlerts()
|
||||||
|
} catch { ElMessage.error('操作失败') }
|
||||||
|
}
|
||||||
|
|
||||||
watch(activeTab, (tab) => {
|
watch(activeTab, (tab) => {
|
||||||
if (tab === 'decompose') loadBudget()
|
if (tab === 'decompose') loadBudget()
|
||||||
else if (tab === 'strategy') loadStrategyBudget()
|
else if (tab === 'strategy') loadStrategyBudget()
|
||||||
else if (tab === 'versions') loadVersions()
|
else if (tab === 'versions') loadVersions()
|
||||||
else if (tab === 'execution') loadExecutionReport()
|
else if (tab === 'execution') loadExecutionReport()
|
||||||
else if (tab === 'method') refreshMethodComparison()
|
else if (tab === 'method') refreshMethodComparison()
|
||||||
|
else if (tab === 'rolling') {
|
||||||
|
loadComparison()
|
||||||
|
loadDeviationAlerts()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadPublishedMaps()
|
await loadPublishedMaps()
|
||||||
loadBudget()
|
loadBudget()
|
||||||
|
loadBudgetConfig()
|
||||||
|
loadComparisonKpiOptions()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user