feat: 路线图R1决策建议一键落地+R2机会推送+R5预算闭环

R1(P0): AI建议一键应用到KPI/预算/行动方案
- 新表 ai_suggestions + AISuggestion 模型(init_db自动建)
- /api/cma/ai/suggestions CRUD + /{id}/apply(复用kpis/budget/action_plans) + dismiss
- 应用写 OperationLog(action=ai_suggestion_apply, detail含suggestion_id/before/after)
- 规则驱动建议生成 generate_rule_suggestions(低执行率/高执行率/预算超支/pending预警)
- 幂等: 同entity+type+target_id+title+unapplied不重复建; applied后拒绝重复应用
- 前端: Dashboard AI面板建议卡(应用到/忽略) + 建议中心页 /ai-suggestions

R2(P1): 数据找人扩大-机会类推送
- scripts/opportunity_detector.py: KPI向好(执行率>110%)/预算余量(<70%且actual>0)/预测上行
- scripts/daily_push.py: 异常+机会 每日9:15推企微(8800/send, --dry-run调试)
- crontab: 15 9 * * * (alert_generator 9:00之后)

R5(P0): 预算闭环加固
- auto-decompose批量幂等: 只取年度行(period=YYYY-00)+同KPI多版本取一行
- scripts/closed_loop_check.py: 预算执行率异常→检查现金流/行动同步→缺失提示+报告
- scripts/verify_decompose_idempotent.py: 幂等验证脚本

测试: test_ai_suggestions(10例)+test_roadmap_r2r5(14例); 修test_budget幂等契约适配年度行
全量: 673 passed
This commit is contained in:
Hermes CI Fix
2026-08-30 12:05:36 +08:00
parent 5b920df8a0
commit ad68471b29
22 changed files with 2170 additions and 25 deletions
+201 -10
View File
@@ -2,17 +2,185 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from sqlalchemy import func, text as sa_text
from sqlalchemy import func, text as sa_text, or_
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPIValue, KPIAlert, StrategicMap, User, ActionPlan
from app.models import KPIDefinition, KPIValue, KPIAlert, StrategicMap, User, ActionPlan, BudgetPlan, AISuggestion
from app.utils.cache import get as cache_get, set as cache_set
import json, hashlib, httpx, os
from datetime import datetime
from datetime import datetime, date
router = APIRouter(prefix="/api/cma/ai", tags=["AI分析"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
# ============================================================
# R1 决策建议生成(规则驱动,稳定可复现,落库 ai_suggestions
# ============================================================
def _sug_dict(s: AISuggestion) -> dict:
return {
"id": s.id,
"entity_id": s.entity_id,
"source": s.source,
"suggestion_type": s.suggestion_type,
"target_type": s.target_type,
"target_id": s.target_id,
"title": s.title,
"content": s.content,
"suggestion_data": s.suggestion_data or {},
"status": s.status,
"applied_by": s.applied_by,
"applied_at": s.applied_at.isoformat() if s.applied_at else None,
"apply_detail": s.apply_detail or [],
"created_at": s.created_at.isoformat() if s.created_at else None,
}
def _existing_unapplied(db: Session, entity_id: int, suggestion_type: str,
target_id: int, title: str) -> bool:
"""幂等:同entity+类型+目标+标题的未应用建议存在则跳过"""
return db.query(AISuggestion).filter(
AISuggestion.entity_id == entity_id,
AISuggestion.suggestion_type == suggestion_type,
AISuggestion.target_id == target_id,
AISuggestion.title == title,
AISuggestion.status == "unapplied",
).first() is not None
def generate_rule_suggestions(db: Session, entity_id: int,
source: str = "dashboard", user_id: int = None,
kpi_id: int = None) -> list:
"""从数据规则生成决策建议并落库(R1,路线图2026-08-30
规则:
1. KPI执行率<70% → 建议建行动方案(异常类)
2. KPI执行率>110% → 建议上调KPI目标(机会类)
3. 预算执行率>110% → 建议调预算(预算类)
4. 有pending预警 → 建议建行动方案处理预警
幂等:同 entity+type+target_id+title+status=unapplied 不重复建。
"""
now = datetime.now()
period = now.strftime("%Y-%m")
created = []
def _add(suggestion_type: str, target_type: str, tid: int,
title: str, content: str, suggestion_data: dict):
nonlocal created
if _existing_unapplied(db, entity_id, suggestion_type, tid, title):
return
sug = AISuggestion(
entity_id=entity_id,
user_id=user_id,
source=source,
suggestion_type=suggestion_type,
target_type=target_type,
target_id=tid,
title=title,
content=content,
suggestion_data=suggestion_data,
status="unapplied",
)
db.add(sug)
created.append(sug)
# 查询KPI(可按kpi_id过滤)
q = db.query(KPIDefinition).filter(KPIDefinition.entity_id == entity_id,
KPIDefinition.status == "active")
if kpi_id:
q = q.filter(KPIDefinition.id == kpi_id)
kpis = q.all()
for k in kpis:
latest = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
or_(
KPIValue.entity_id == entity_id,
KPIValue.entity_id.is_(None),
),
).order_by(KPIValue.period.desc()).first()
if not latest or latest.actual_value is None:
continue
actual = latest.actual_value
target = k.target_value
ratio = (actual / target) if target else None
# 1. 异常:执行率<70% → 建行动方案
if ratio is not None and ratio < 0.7:
title = f"提升 {k.kpi_name}:达成率仅{ratio*100:.0f}%"
content = (f"KPI[{k.kpi_name}] 最新期间{latest.period}实际值{actual:g}"
f"目标{target:g},达成率{ratio*100:.1f}%,低于70%预警线。"
f"建议制定专项改善行动方案。")
_add("action_plan", "kpi", k.id, title, content, {
"kpi_id": k.id, "priority": "high",
"title": f"改善: {k.kpi_name}达成率提升",
})
# 2. 机会:执行率>110% → 上调KPI目标
elif ratio is not None and ratio > 1.1:
new_target = round(actual * 1.05, 2)
title = f"上调 {k.kpi_name} 目标:达成率{ratio*100:.0f}%超预期"
content = (f"KPI[{k.kpi_name}] 达成率{ratio*100:.1f}%超过110%"
f"建议将目标从{target:g}上调至{new_target:g},保持牵引力。")
_add("kpi_target", "kpi", k.id, title, content, {
"kpi_id": k.id, "target_value": new_target,
})
# 3. 预算执行率>110% → 调预算
budget_rows = db.query(BudgetPlan).filter(
BudgetPlan.entity_id == entity_id,
BudgetPlan.status == "active",
BudgetPlan.period == period,
).all()
for b in budget_rows:
actual = db.query(func.max(KPIValue.actual_value)).filter(
KPIValue.kpi_id == b.kpi_id,
KPIValue.period == b.period,
).scalar()
if actual is None or b.budget_value is None or b.budget_value <= 0:
continue
exec_ratio = actual / b.budget_value
if exec_ratio > 1.1:
kpi_name = "KPI"
k = db.query(KPIDefinition).filter(KPIDefinition.id == b.kpi_id).first()
if k:
kpi_name = k.kpi_name
title = f"调整 {kpi_name} 预算:执行率{exec_ratio*100:.0f}%超预算"
content = (f"预算[{kpi_name}] {period}预算值{b.budget_value:g}"
f"实际{actual:g},执行率{exec_ratio*100:.1f}%超过110%。"
f"建议同步调整预算/现金流/行动方案。")
_add("budget_adjust", "budget", b.kpi_id, title, content, {
"kpi_id": b.kpi_id, "period": period, "budget_value": round(actual, 2),
})
# 4. pending预警 → 建行动方案
alerts = db.query(KPIAlert).filter(KPIAlert.status == "pending").all()
for a in alerts:
k = db.query(KPIDefinition).filter(KPIDefinition.id == a.kpi_id).first()
kpi_name = k.kpi_name if k else f"KPI#{a.kpi_id}"
title = f"处理预警:{kpi_name} {a.alert_message[:30]}"
content = f"存在待处理预警({a.alert_level}级):{a.alert_message}。建议建立行动方案跟进。"
_add("action_plan", "alert", a.id, title, content, {
"kpi_id": a.kpi_id, "priority": "high" if a.alert_level == "red" else "medium",
"alert_id": a.id,
"title": f"处理预警: {kpi_name}",
})
if created:
db.commit()
for s in created:
db.refresh(s)
return created
def _unapplied_suggestions(db: Session, entity_id: int, limit: int = 20) -> list:
items = db.query(AISuggestion).filter(
AISuggestion.entity_id == entity_id,
AISuggestion.status == "unapplied",
).order_by(AISuggestion.created_at.desc()).limit(limit).all()
return [_sug_dict(s) for s in items]
async def _call_deepseek(prompt: str) -> str:
"""调用DeepSeek API"""
api_key = os.getenv("DEEPSEEK_API_KEY", "sk-8e24e6eb87f2475e96ea0980002dc2e8")
@@ -34,15 +202,23 @@ async def _call_deepseek(prompt: str) -> str:
return data.get("choices", [{}])[0].get("message", {}).get("content", "")
@router.get("/dashboard-analysis")
async def dashboard_analysis(role: str = Query("ceo"), db: Session = Depends(get_db)):
async def dashboard_analysis(role: str = Query("ceo"), db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""AI分析驾驶舱数据"""
# 尝试缓存
cache_key = f"dashboard_analysis:{role}"
cache_key = f"dashboard_analysis:{role}:{entity_id}"
cached = cache_get("ai", cache_key)
if cached:
# 缓存命中(LLM文本10分钟内不重复调用),但轻量规则建议仍执行(幂等)
try:
generate_rule_suggestions(db, entity_id, source="dashboard")
except Exception:
pass
cached["suggestions"] = _unapplied_suggestions(db, entity_id)
return cached
# 获取当前KPI数据
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
kpis = db.query(KPIDefinition).filter(KPIDefinition.entity_id == entity_id,
KPIDefinition.status == "active").all()
kpi_summary = []
for k in kpis:
latest = db.query(KPIValue).filter(KPIValue.kpi_id == k.id).order_by(KPIValue.period.desc()).first()
@@ -81,17 +257,25 @@ async def dashboard_analysis(role: str = Query("ceo"), db: Session = Depends(get
except Exception as e:
analysis = f"AI分析暂时不可用: {str(e)}"
result = {"analysis": analysis, "kpi_count": len(kpi_summary), "alert_count": alerts}
# R1: 规则驱动生成可落地决策建议(幂等落库)
try:
generate_rule_suggestions(db, entity_id, source="dashboard")
except Exception as e:
pass
result = {"analysis": analysis, "kpi_count": len(kpi_summary), "alert_count": alerts,
"suggestions": _unapplied_suggestions(db, entity_id)}
# 缓存10分钟
cache_set("ai", cache_key, result, ttl_seconds=600)
return result
@router.get("/kpi-analysis/{kpi_id}")
async def kpi_analysis(kpi_id: int, db: Session = Depends(get_db)):
async def kpi_analysis(kpi_id: int, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""AI分析单个KPI"""
# 尝试缓存
cache_key = f"kpi_analysis:{kpi_id}"
cache_key = f"kpi_analysis:{kpi_id}:{entity_id}"
cached = cache_get("ai", cache_key)
if cached:
return cached
@@ -129,7 +313,14 @@ KPI名称:{kpi.kpi_name}
except Exception as e:
analysis = f"分析暂时不可用: {str(e)}"
result = {"kpi_name": kpi.kpi_name, "analysis": analysis}
# R1: 生成该KPI的可落地建议
try:
generate_rule_suggestions(db, entity_id, source="kpi", kpi_id=kpi_id)
except Exception as e:
pass
result = {"kpi_name": kpi.kpi_name, "analysis": analysis,
"suggestions": _unapplied_suggestions(db, entity_id)}
cache_set("ai", cache_key, result, ttl_seconds=600)
return result
+336
View File
@@ -0,0 +1,336 @@
"""AI决策建议 — 一键应用到KPI/预算/行动方案 (路线图R1 2026-08-30)
北极星④决策闭环:AI建议 → 点击应用 → 写库变更 → OperationLog留痕 → 前端可查已应用/未应用。
应用动作复用现有 kpis/budget/action_plans 数据模型,不新建业务接口。
"""
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import Optional
from datetime import datetime
from app.database import get_db
from app.deps import get_entity_id, resolve_entity_for_request
from app.auth_middleware import require_auth, require_role
from app.models import AISuggestion, KPIDefinition, KPIValue, OperationLog, BudgetPlan, ActionPlan
router = APIRouter(prefix="/api/cma/ai/suggestions", tags=["AI建议"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
def _sug_dict(s: AISuggestion) -> dict:
return {
"id": s.id,
"entity_id": s.entity_id,
"user_id": s.user_id,
"source": s.source,
"suggestion_type": s.suggestion_type,
"target_type": s.target_type,
"target_id": s.target_id,
"title": s.title,
"content": s.content,
"suggestion_data": s.suggestion_data or {},
"status": s.status,
"applied_by": s.applied_by,
"applied_at": s.applied_at.isoformat() if s.applied_at else None,
"apply_detail": s.apply_detail or [],
"created_at": s.created_at.isoformat() if s.created_at else None,
}
@router.post("")
def create_suggestion(
request: Request,
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
):
"""创建AI建议(前端AI分析/手动保存建议)"""
suggestion_type = data.get("suggestion_type") or data.get("type")
title = (data.get("title") or "").strip()
if not suggestion_type:
raise HTTPException(400, "缺少 suggestion_type (kpi_target/budget_adjust/action_plan)")
if not title:
raise HTTPException(400, "缺少 title")
entity_id = resolve_entity_for_request(request, data.get("entity_id") or 1)
sug = AISuggestion(
entity_id=entity_id,
user_id=getattr(current_user, "id", None),
source=data.get("source", "manual"),
suggestion_type=suggestion_type,
target_type=data.get("target_type", "kpi"),
target_id=data.get("target_id"),
title=title,
content=data.get("content"),
suggestion_data=data.get("suggestion_data") or {},
status="unapplied",
)
db.add(sug)
db.commit()
db.refresh(sug)
return {"success": True, "message": "建议已保存", "data": _sug_dict(sug)}
@router.get("")
def list_suggestions(
status: Optional[str] = Query(None, description="unapplied/applied/dismissed"),
suggestion_type: Optional[str] = Query(None),
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
):
"""建议列表(前端建议中心/详情页查看已应用/未应用状态)"""
query = db.query(AISuggestion).filter(AISuggestion.entity_id == entity_id)
if status:
query = query.filter(AISuggestion.status == status)
if suggestion_type:
query = query.filter(AISuggestion.suggestion_type == suggestion_type)
items = query.order_by(AISuggestion.created_at.desc()).limit(200).all()
return {"data": [_sug_dict(s) for s in items], "total": len(items)}
@router.get("/{suggestion_id}")
def get_suggestion(suggestion_id: int, db: Session = Depends(get_db)):
"""建议详情"""
s = db.query(AISuggestion).filter(AISuggestion.id == suggestion_id).first()
if not s:
raise HTTPException(404, "建议不存在")
return {"data": _sug_dict(s)}
def _apply_kpi_target(db: Session, sug: AISuggestion, params: dict, current_user) -> dict:
"""改KPI目标"""
target_value = params.get("target_value")
if target_value is None:
raise HTTPException(400, "应用kpi_target需要 target_value")
kpi_id = params.get("kpi_id") or sug.target_id
if not kpi_id:
raise HTTPException(400, "缺少 kpi_id")
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, f"KPI {kpi_id} 不存在")
before = kpi.target_value
kpi.target_value = float(target_value)
db.flush()
detail_item = {
"target_type": "kpi",
"target_id": kpi.id,
"target_name": kpi.kpi_name,
"action": "update_target_value",
"before": before,
"after": float(target_value),
}
db.add(OperationLog(
user_id=getattr(current_user, "id", None),
action="ai_suggestion_apply",
target_type="kpi",
target_id=kpi.id,
detail={
"suggestion_id": sug.id,
"suggestion_title": sug.title,
"apply_action": "kpi_target",
"before": before,
"after": float(target_value),
},
))
return detail_item
def _apply_budget_adjust(db: Session, sug: AISuggestion, params: dict, current_user) -> dict:
"""调预算(BudgetPlan upsert,按 kpi_id+period"""
period = params.get("period")
budget_value = params.get("budget_value")
if not period or budget_value is None:
raise HTTPException(400, "应用budget_adjust需要 period + budget_value")
kpi_id = params.get("kpi_id") or sug.target_id
if not kpi_id:
raise HTTPException(400, "缺少 kpi_id")
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, f"KPI {kpi_id} 不存在")
# 解析期间 2026-09 → year=2026 month=9
try:
parts = period.split("-")
year = int(parts[0])
month = int(parts[1])
except Exception:
raise HTTPException(400, f"period格式错误: {period} (需要 YYYY-MM)")
plan = db.query(BudgetPlan).filter(
BudgetPlan.entity_id == sug.entity_id,
BudgetPlan.kpi_id == kpi_id,
BudgetPlan.period == period,
BudgetPlan.budget_year == year,
BudgetPlan.budget_month == month,
BudgetPlan.status == "active",
).first()
before = None
if plan:
before = plan.budget_value
plan.budget_value = float(budget_value)
else:
plan = BudgetPlan(
entity_id=sug.entity_id,
kpi_id=kpi_id,
period=period,
budget_value=float(budget_value),
budget_year=year,
budget_month=month,
version="v1.0",
status="active",
source_type="ai_suggestion",
calc_logic=f"AI建议应用 #{sug.id}: {sug.title}",
created_by=getattr(current_user, "name", "") or "",
)
db.add(plan)
db.flush()
detail_item = {
"target_type": "budget",
"target_id": plan.id,
"target_name": f"{kpi.kpi_name}[{period}]",
"action": "update_budget" if before is not None else "create_budget",
"before": before,
"after": float(budget_value),
}
db.add(OperationLog(
user_id=getattr(current_user, "id", None),
action="ai_suggestion_apply",
target_type="budget",
target_id=plan.id,
detail={
"suggestion_id": sug.id,
"suggestion_title": sug.title,
"apply_action": "budget_adjust",
"kpi_id": kpi_id,
"period": period,
"before": before,
"after": float(budget_value),
},
))
return detail_item
def _apply_action_plan(db: Session, sug: AISuggestion, params: dict, current_user) -> dict:
"""建行动方案"""
title = (params.get("title") or "").strip() or sug.title
kpi_id = params.get("kpi_id") or sug.target_id
if not kpi_id:
raise HTTPException(400, "缺少 kpi_id")
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, f"KPI {kpi_id} 不存在")
due_date = None
if params.get("due_date"):
try:
due_date = datetime.strptime(str(params["due_date"])[:10], "%Y-%m-%d")
except Exception:
due_date = None
plan = ActionPlan(
kpi_id=kpi_id,
title=title,
description=params.get("description") or sug.content or f"由AI建议 #{sug.id} 生成: {sug.title}",
assignee=params.get("assignee") or "",
priority=params.get("priority") or "medium",
due_date=due_date,
status="pending",
progress=0,
created_by=getattr(current_user, "name", "") or "ai_suggestion",
)
db.add(plan)
db.flush()
detail_item = {
"target_type": "action_plan",
"target_id": plan.id,
"target_name": title,
"action": "create_action_plan",
"before": None,
"after": plan.id,
}
db.add(OperationLog(
user_id=getattr(current_user, "id", None),
action="ai_suggestion_apply",
target_type="action_plan",
target_id=plan.id,
detail={
"suggestion_id": sug.id,
"suggestion_title": sug.title,
"apply_action": "action_plan",
"kpi_id": kpi_id,
"plan_title": title,
},
))
return detail_item
_APPLYERS = {
"kpi_target": _apply_kpi_target,
"budget_adjust": _apply_budget_adjust,
"action_plan": _apply_action_plan,
}
@router.post("/{suggestion_id}/apply")
def apply_suggestion(
suggestion_id: int,
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
):
"""应用建议:改KPI目标 / 调预算 / 建行动方案(写库+操作日志留痕)
Body 示例:
{"action": "kpi_target", "target_value": 2000000}
{"action": "budget_adjust", "period": "2026-09", "budget_value": 100000}
{"action": "action_plan", "title": "...", "assignee": "...", "priority": "high", "due_date": "2026-09-30"}
"""
sug = db.query(AISuggestion).filter(AISuggestion.id == suggestion_id).first()
if not sug:
raise HTTPException(404, "建议不存在")
if sug.status == "applied":
raise HTTPException(400, "该建议已应用,不能重复应用")
if sug.status == "dismissed":
raise HTTPException(400, "该建议已忽略,如需应用请重新创建")
action = data.get("action") or sug.suggestion_type
applier = _APPLYERS.get(action)
if not applier:
raise HTTPException(400, f"不支持的应用动作: {action} (支持 kpi_target/budget_adjust/action_plan)")
# 应用参数 = 请求体参数 覆盖 建议默认参数
params = dict(sug.suggestion_data or {})
params.update({k: v for k, v in data.items() if k != "action" and v is not None})
detail_item = applier(db, sug, params, current_user)
sug.status = "applied"
sug.applied_by = getattr(current_user, "name", "") or ""
sug.applied_user_id = getattr(current_user, "id", None)
sug.applied_at = datetime.now()
sug.apply_detail = [detail_item]
db.commit()
db.refresh(sug)
return {
"success": True,
"message": "建议已应用并留痕",
"data": _sug_dict(sug),
}
@router.post("/{suggestion_id}/dismiss")
def dismiss_suggestion(
suggestion_id: int,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
):
"""忽略建议"""
sug = db.query(AISuggestion).filter(AISuggestion.id == suggestion_id).first()
if not sug:
raise HTTPException(404, "建议不存在")
sug.status = "dismissed"
db.commit()
return {"success": True, "message": "建议已忽略"}
+2 -1
View File
@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from dotenv import load_dotenv
from app.database import init_db
from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification, value_sources, zero_based, derivation_rules, cash_classify
from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, ai_suggestions, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification, value_sources, zero_based, derivation_rules, cash_classify
from app.utils.cache import clear_all as clear_cache, delete as delete_cache
from scripts.erp_sync import run_sync as run_erp_sync
from app.auth_middleware import require_auth
@@ -43,6 +43,7 @@ app.include_router(dashboard.router)
app.include_router(data.router)
app.include_router(alerts.router)
app.include_router(ai_analysis.router)
app.include_router(ai_suggestions.router)
app.include_router(alert_rules.router)
app.include_router(users.router)
app.include_router(thresholds.router)
+22
View File
@@ -911,3 +911,25 @@ class CashPlanUnclassified(Base):
status = Column(String(20), default="pending", comment="pending/classified/ignored")
created_at = Column(DateTime, server_default=func.now())
resolved_at = Column(DateTime, nullable=True)
class AISuggestion(Base):
"""AI决策建议 — 一键应用到KPI/预算/行动方案 (路线图R1 2026-08-30)"""
__tablename__ = "ai_suggestions"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, comment="企业ID")
user_id = Column(Integer, nullable=True, comment="建议创建人ID")
source = Column(String(30), default="dashboard", comment="来源: dashboard/kpi/budget/manual/rule")
suggestion_type = Column(String(30), nullable=False, comment="kpi_target/budget_adjust/action_plan")
target_type = Column(String(30), nullable=False, comment="kpi/budget/action_plan")
target_id = Column(Integer, nullable=True, comment="目标ID (KPI ID/预算KPI ID等)")
title = Column(String(300), nullable=False, comment="建议标题")
content = Column(Text, nullable=True, comment="建议内容/理由")
suggestion_data = Column(JSON, nullable=True, comment="应用参数: {target_value, period, budget_value, plan_title, ...}")
status = Column(String(20), default="unapplied", comment="unapplied/applied/dismissed")
applied_by = Column(String(100), nullable=True, comment="应用人姓名")
applied_user_id = Column(Integer, nullable=True, comment="应用人ID")
applied_at = Column(DateTime, nullable=True, comment="应用时间")
apply_detail = Column(JSON, nullable=True, comment="应用结果明细: [{target_type,target_id,action,before,after}]")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())