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:
+201
-10
@@ -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
|
||||
|
||||
|
||||
@@ -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": "建议已忽略"}
|
||||
Reference in New Issue
Block a user