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 import APIRouter, Depends, HTTPException, Query, Request
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy.orm import Session
|
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.database import get_db
|
||||||
|
from app.deps import get_entity_id
|
||||||
from app.auth_middleware import require_auth, require_role
|
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
|
from app.utils.cache import get as cache_get, set as cache_set
|
||||||
import json, hashlib, httpx, os
|
import json, hashlib, httpx, os
|
||||||
from datetime import datetime
|
from datetime import datetime, date
|
||||||
router = APIRouter(prefix="/api/cma/ai", tags=["AI分析"],
|
router = APIRouter(prefix="/api/cma/ai", tags=["AI分析"],
|
||||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
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:
|
async def _call_deepseek(prompt: str) -> str:
|
||||||
"""调用DeepSeek API"""
|
"""调用DeepSeek API"""
|
||||||
api_key = os.getenv("DEEPSEEK_API_KEY", "sk-8e24e6eb87f2475e96ea0980002dc2e8")
|
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", "")
|
return data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||||
|
|
||||||
@router.get("/dashboard-analysis")
|
@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分析驾驶舱数据"""
|
"""AI分析驾驶舱数据"""
|
||||||
# 尝试缓存
|
# 尝试缓存
|
||||||
cache_key = f"dashboard_analysis:{role}"
|
cache_key = f"dashboard_analysis:{role}:{entity_id}"
|
||||||
cached = cache_get("ai", cache_key)
|
cached = cache_get("ai", cache_key)
|
||||||
if cached:
|
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
|
return cached
|
||||||
# 获取当前KPI数据
|
# 获取当前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 = []
|
kpi_summary = []
|
||||||
for k in kpis:
|
for k in kpis:
|
||||||
latest = db.query(KPIValue).filter(KPIValue.kpi_id == k.id).order_by(KPIValue.period.desc()).first()
|
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:
|
except Exception as e:
|
||||||
analysis = f"AI分析暂时不可用: {str(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分钟
|
# 缓存10分钟
|
||||||
cache_set("ai", cache_key, result, ttl_seconds=600)
|
cache_set("ai", cache_key, result, ttl_seconds=600)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("/kpi-analysis/{kpi_id}")
|
@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"""
|
"""AI分析单个KPI"""
|
||||||
# 尝试缓存
|
# 尝试缓存
|
||||||
cache_key = f"kpi_analysis:{kpi_id}"
|
cache_key = f"kpi_analysis:{kpi_id}:{entity_id}"
|
||||||
cached = cache_get("ai", cache_key)
|
cached = cache_get("ai", cache_key)
|
||||||
if cached:
|
if cached:
|
||||||
return cached
|
return cached
|
||||||
@@ -129,7 +313,14 @@ KPI名称:{kpi.kpi_name}
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
analysis = f"分析暂时不可用: {str(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)
|
cache_set("ai", cache_key, result, ttl_seconds=600)
|
||||||
return result
|
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": "建议已忽略"}
|
||||||
+2
-1
@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from app.database import init_db
|
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 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 scripts.erp_sync import run_sync as run_erp_sync
|
||||||
from app.auth_middleware import require_auth
|
from app.auth_middleware import require_auth
|
||||||
@@ -43,6 +43,7 @@ app.include_router(dashboard.router)
|
|||||||
app.include_router(data.router)
|
app.include_router(data.router)
|
||||||
app.include_router(alerts.router)
|
app.include_router(alerts.router)
|
||||||
app.include_router(ai_analysis.router)
|
app.include_router(ai_analysis.router)
|
||||||
|
app.include_router(ai_suggestions.router)
|
||||||
app.include_router(alert_rules.router)
|
app.include_router(alert_rules.router)
|
||||||
app.include_router(users.router)
|
app.include_router(users.router)
|
||||||
app.include_router(thresholds.router)
|
app.include_router(thresholds.router)
|
||||||
|
|||||||
@@ -911,3 +911,25 @@ class CashPlanUnclassified(Base):
|
|||||||
status = Column(String(20), default="pending", comment="pending/classified/ignored")
|
status = Column(String(20), default="pending", comment="pending/classified/ignored")
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
resolved_at = Column(DateTime, nullable=True)
|
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())
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 预算↔现金流↔行动 闭环自检报告
|
||||||
|
**检查时间**: 2026-08-30 11:43:16
|
||||||
|
|
||||||
|
## 账套 #1 · 期间 2026-08
|
||||||
|
- 🔴 营业收入(2026-08)
|
||||||
|
预算 75 / 实际 150000 = 执行率 200000.0%(超预算)
|
||||||
|
现金流计划: 0 条 | 行动方案: 11 条
|
||||||
|
⚠️ 缺失: 现金流(本期间有其他计划但未关联本KPI)
|
||||||
|
💡 预算执行率200000%异常,请同步现金流情况核对(营业收入 2026-08)
|
||||||
|
- 🟡 净利润(2026-08)
|
||||||
|
预算 16.67 / 实际 0 = 执行率 0.0%(低执行)
|
||||||
|
现金流计划: 0 条 | 行动方案: 3 条
|
||||||
|
⚠️ 缺失: 现金流(本期间有其他计划但未关联本KPI)
|
||||||
|
💡 预算执行率0%异常,请同步现金流情况核对(净利润 2026-08)
|
||||||
|
- 🔴 渠补率(2026-08)
|
||||||
|
预算 12.78 / 实际 75 = 执行率 586.9%(超预算)
|
||||||
|
现金流计划: 0 条 | 行动方案: 4 条
|
||||||
|
⚠️ 缺失: 现金流(本期间有其他计划但未关联本KPI)
|
||||||
|
💡 预算执行率587%异常,请同步现金流情况核对(渠补率 2026-08)
|
||||||
|
- 🟡 经营性现金流(2026-08)
|
||||||
|
预算 16.67 / 实际 -93000 = 执行率 -557888.4%(低执行)
|
||||||
|
现金流计划: 0 条 | 行动方案: 0 条
|
||||||
|
⚠️ 缺失: 现金流(本期间有其他计划但未关联本KPI)、行动方案
|
||||||
|
💡 预算执行率-557888%异常,请同步现金流情况核对、行动方案(经营性现金流 2026-08)
|
||||||
|
|
||||||
|
---
|
||||||
|
共发现异常 4 项
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"""预算↔现金流↔行动 三闭环异常自检 — 路线图R5 (2026-08-30)
|
||||||
|
|
||||||
|
预算闭环加固:预算执行率异常(<70% 或 >110%)的KPI,
|
||||||
|
检查是否同步了 现金流计划(CashPlan) 和 行动方案(ActionPlan),
|
||||||
|
缺失则输出提示(防止"预算改了,现金流/行动没跟上")。
|
||||||
|
|
||||||
|
输出:控制台 + reports/closed_loop_check_YYYYMMDD.md
|
||||||
|
用法: /root/cma-management/backend/venv/bin/python3 scripts/closed_loop_check.py [--period 2026-08] [--push]
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import argparse
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from app.database import get_session_local
|
||||||
|
from app.models import KPIDefinition, KPIValue, BudgetPlan, CashPlan, ActionPlan
|
||||||
|
|
||||||
|
LOW_RATIO = 0.7
|
||||||
|
HIGH_RATIO = 1.1
|
||||||
|
REPORTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "reports")
|
||||||
|
|
||||||
|
|
||||||
|
def check_entity(db, entity_id: int, period: str) -> dict:
|
||||||
|
"""检测一个账套的闭环状态"""
|
||||||
|
issues = []
|
||||||
|
rows = db.query(BudgetPlan).filter(
|
||||||
|
BudgetPlan.entity_id == entity_id,
|
||||||
|
BudgetPlan.status == "active",
|
||||||
|
BudgetPlan.period == period,
|
||||||
|
BudgetPlan.budget_value > 0,
|
||||||
|
).all()
|
||||||
|
|
||||||
|
seen = set()
|
||||||
|
for b in rows:
|
||||||
|
key = (b.kpi_id, b.period)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
|
||||||
|
k = db.query(KPIDefinition).filter(KPIDefinition.id == b.kpi_id).first()
|
||||||
|
kpi_name = k.kpi_name if k else f"KPI#{b.kpi_id}"
|
||||||
|
|
||||||
|
actual = db.query(KPIValue).filter(
|
||||||
|
KPIValue.kpi_id == b.kpi_id,
|
||||||
|
KPIValue.period == b.period,
|
||||||
|
KPIValue.actual_value.isnot(None),
|
||||||
|
).order_by(KPIValue.calculated_at.desc()).first()
|
||||||
|
|
||||||
|
actual_val = actual.actual_value if actual else None
|
||||||
|
if actual_val is None:
|
||||||
|
continue
|
||||||
|
ratio = actual_val / b.budget_value
|
||||||
|
abnormal = ratio < LOW_RATIO or ratio > HIGH_RATIO
|
||||||
|
if not abnormal:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 现金流检查:该KPI该期间是否有收付款计划(related_kpi_id 或 budget_plan_id 关联)
|
||||||
|
period_start = datetime.strptime(period + "-01", "%Y-%m-%d")
|
||||||
|
if period.endswith("-12"):
|
||||||
|
period_end = datetime(period_start.year + 1, 1, 1)
|
||||||
|
else:
|
||||||
|
period_end = datetime(period_start.year, period_start.month + 1, 1)
|
||||||
|
cash_plans = db.query(CashPlan).filter(
|
||||||
|
CashPlan.entity_id == entity_id,
|
||||||
|
CashPlan.status.in_(["pending", "completed"]),
|
||||||
|
CashPlan.plan_date >= period_start,
|
||||||
|
CashPlan.plan_date < period_end,
|
||||||
|
).filter(
|
||||||
|
(CashPlan.related_kpi_id == b.kpi_id) | (CashPlan.budget_plan_id == b.id)
|
||||||
|
).count()
|
||||||
|
# 兜底:无关联但期间内有任意现金流计划也算基本闭环
|
||||||
|
any_cash = db.query(CashPlan).filter(
|
||||||
|
CashPlan.entity_id == entity_id,
|
||||||
|
CashPlan.status.in_(["pending", "completed"]),
|
||||||
|
CashPlan.plan_date >= period_start,
|
||||||
|
CashPlan.plan_date < period_end,
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# 行动检查:该KPI是否有非完成的行动方案
|
||||||
|
actions = db.query(ActionPlan).filter(
|
||||||
|
ActionPlan.kpi_id == b.kpi_id,
|
||||||
|
ActionPlan.status.in_(["pending", "in_progress"]),
|
||||||
|
).count()
|
||||||
|
|
||||||
|
missing = []
|
||||||
|
if cash_plans == 0:
|
||||||
|
if any_cash > 0:
|
||||||
|
missing.append("现金流(本期间有其他计划但未关联本KPI)")
|
||||||
|
else:
|
||||||
|
missing.append("现金流")
|
||||||
|
if actions == 0:
|
||||||
|
missing.append("行动方案")
|
||||||
|
|
||||||
|
level = "critical" if ratio > HIGH_RATIO else "warning"
|
||||||
|
issues.append({
|
||||||
|
"kpi_id": b.kpi_id,
|
||||||
|
"kpi_name": kpi_name,
|
||||||
|
"period": period,
|
||||||
|
"budget_value": b.budget_value,
|
||||||
|
"actual_value": actual_val,
|
||||||
|
"exec_ratio": round(ratio * 100, 1),
|
||||||
|
"abnormal_type": "超预算" if ratio > HIGH_RATIO else "低执行",
|
||||||
|
"level": level,
|
||||||
|
"cash_plan_count": cash_plans,
|
||||||
|
"action_plan_count": actions,
|
||||||
|
"missing": missing,
|
||||||
|
"suggestion": (
|
||||||
|
f"预算执行率{ratio*100:.0f}%异常,请同步"
|
||||||
|
+ ("现金流计划" if "现金流" in missing else "现金流情况核对")
|
||||||
|
+ ("、行动方案" if "行动方案" in missing else "")
|
||||||
|
+ f"({kpi_name} {period})"
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"entity_id": entity_id, "period": period, "issues": issues}
|
||||||
|
|
||||||
|
|
||||||
|
def build_report(results: list, checked_at: str) -> str:
|
||||||
|
lines = [f"# 预算↔现金流↔行动 闭环自检报告", f"**检查时间**: {checked_at}", ""]
|
||||||
|
total_issues = 0
|
||||||
|
for r in results:
|
||||||
|
lines.append(f"## 账套 #{r['entity_id']} · 期间 {r['period']}")
|
||||||
|
if not r["issues"]:
|
||||||
|
lines.append("- ✅ 无预算执行率异常")
|
||||||
|
for it in r["issues"]:
|
||||||
|
total_issues += 1
|
||||||
|
icon = "🔴" if it["level"] == "critical" else "🟡"
|
||||||
|
lines.append(f"- {icon} {it['kpi_name']}({it['period']})")
|
||||||
|
lines.append(f" 预算 {it['budget_value']:g} / 实际 {it['actual_value']:g} = 执行率 {it['exec_ratio']}%({it['abnormal_type']})")
|
||||||
|
lines.append(f" 现金流计划: {it['cash_plan_count']} 条 | 行动方案: {it['action_plan_count']} 条")
|
||||||
|
if it["missing"]:
|
||||||
|
lines.append(f" ⚠️ 缺失: {'、'.join(it['missing'])}")
|
||||||
|
lines.append(f" 💡 {it['suggestion']}")
|
||||||
|
else:
|
||||||
|
lines.append(f" ✅ 三闭环已同步")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"---")
|
||||||
|
lines.append(f"共发现异常 {total_issues} 项")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--period", default=datetime.now().strftime("%Y-%m"))
|
||||||
|
parser.add_argument("--entity-id", type=int, default=1)
|
||||||
|
parser.add_argument("--push", action="store_true", help="异常时推送企微(8800/send)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
os.makedirs(REPORTS_DIR, exist_ok=True)
|
||||||
|
checked_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
db = get_session_local()()
|
||||||
|
try:
|
||||||
|
result = check_entity(db, args.entity_id, args.period)
|
||||||
|
report = build_report([result], checked_at)
|
||||||
|
print(report)
|
||||||
|
|
||||||
|
# 写报告文件
|
||||||
|
fname = f"closed_loop_check_{datetime.now().strftime('%Y%m%d')}.md"
|
||||||
|
fpath = os.path.join(REPORTS_DIR, fname)
|
||||||
|
with open(fpath, "w", encoding="utf-8") as f:
|
||||||
|
f.write(report)
|
||||||
|
print(f"\n📄 报告已写入: {fpath}")
|
||||||
|
|
||||||
|
# 异常推送
|
||||||
|
if args.push and result["issues"]:
|
||||||
|
try:
|
||||||
|
import urllib.request
|
||||||
|
import urllib.parse
|
||||||
|
content = f"## 🔄 预算闭环自检({args.period})\n"
|
||||||
|
for it in result["issues"][:10]:
|
||||||
|
content += f"- {it['kpi_name']} 执行率{it['exec_ratio']}% 缺{'/'.join(it['missing']) or '无'}\n"
|
||||||
|
content += f"\n共{len(result['issues'])}项异常,详见系统报告"
|
||||||
|
data = urllib.parse.urlencode({"msg": content, "source": "管理会计OS"}).encode("utf-8")
|
||||||
|
req = urllib.request.Request("http://127.0.0.1:8800/send", data=data)
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
print("推送:", resp.read().decode()[:200])
|
||||||
|
except Exception as e:
|
||||||
|
print(f"推送失败: {e}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"""每日数据找人推送 — 路线图R2 (2026-08-30)
|
||||||
|
|
||||||
|
北极星③:主动推送扩大 —— 异常 + 机会两类。
|
||||||
|
- 异常类:待处理预警(kpi_alerts pending)
|
||||||
|
- 机会类:KPI向好 / 预算余量 / 预测上行(opportunity_detector)
|
||||||
|
复用企微通道 8800/send(公司群中继服务)。
|
||||||
|
|
||||||
|
用法: /root/cma-management/backend/venv/bin/python3 scripts/daily_push.py [--dry-run]
|
||||||
|
cron: 15 9 * * * (alert_generator 9:00 之后)
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import argparse
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from app.database import get_session_local
|
||||||
|
from app.models import KPIDefinition, KPIAlert
|
||||||
|
from scripts.opportunity_detector import detect_all, flatten
|
||||||
|
|
||||||
|
logger = logging.getLogger("cma.daily_push")
|
||||||
|
|
||||||
|
RELAY_URL = "http://127.0.0.1:8800/send"
|
||||||
|
SOURCE = "管理会计OS"
|
||||||
|
|
||||||
|
|
||||||
|
def collect_exceptions(db, limit: int = 10) -> list:
|
||||||
|
"""异常类:待处理预警(red/yellow)"""
|
||||||
|
out = []
|
||||||
|
alerts = db.query(KPIAlert).filter(
|
||||||
|
KPIAlert.status == "pending",
|
||||||
|
KPIAlert.alert_level.in_(["red", "yellow"]),
|
||||||
|
).order_by(KPIAlert.created_at.desc()).limit(limit).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}"
|
||||||
|
icon = "🔴" if a.alert_level == "red" else "🟡"
|
||||||
|
out.append({
|
||||||
|
"type": "exception",
|
||||||
|
"title": f"{icon} {kpi_name} 预警",
|
||||||
|
"detail": f"({a.alert_level}) {a.alert_message}",
|
||||||
|
"kpi_id": a.kpi_id,
|
||||||
|
"kpi_name": kpi_name,
|
||||||
|
"period": "",
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def build_message(exceptions: list, opportunities: list) -> str:
|
||||||
|
"""组装 markdown 推送内容"""
|
||||||
|
now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||||
|
lines = [f"## 📊 管理会计OS · 每日经营播报", f"**{now}**", ""]
|
||||||
|
|
||||||
|
lines.append("### ⚠️ 异常关注")
|
||||||
|
if exceptions:
|
||||||
|
for e in exceptions:
|
||||||
|
lines.append(f"- {e['title']}")
|
||||||
|
lines.append(f" {e['detail']}")
|
||||||
|
else:
|
||||||
|
lines.append("- 今日无待处理预警 ✅")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append("### 🎯 机会发现")
|
||||||
|
if opportunities:
|
||||||
|
for o in opportunities:
|
||||||
|
lines.append(f"- {o['title']}")
|
||||||
|
lines.append(f" {o['detail']}")
|
||||||
|
else:
|
||||||
|
lines.append("- 今日暂无显著机会")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append("---")
|
||||||
|
lines.append("💡 数据找人:异常要处理,机会要把握。详情见 CMA 系统。")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def push_wecom(msg: str) -> dict:
|
||||||
|
"""通过8800中继推送企微"""
|
||||||
|
import urllib.request
|
||||||
|
import urllib.parse
|
||||||
|
data = urllib.parse.urlencode({
|
||||||
|
"msg": msg,
|
||||||
|
"source": SOURCE,
|
||||||
|
"msgtype": "markdown",
|
||||||
|
}).encode("utf-8")
|
||||||
|
req = urllib.request.Request(RELAY_URL, data=data,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
result = json.loads(resp.read().decode("utf-8"))
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": f"推送异常: {e}"}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="只打印不推送")
|
||||||
|
parser.add_argument("--entity-id", type=int, default=1)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
db = get_session_local()()
|
||||||
|
try:
|
||||||
|
exceptions = collect_exceptions(db)
|
||||||
|
opportunities = flatten(detect_all(db, args.entity_id))
|
||||||
|
msg = build_message(exceptions, opportunities)
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print(msg)
|
||||||
|
print(f"\n[DRY-RUN] 异常{len(exceptions)}条 / 机会{len(opportunities)}条")
|
||||||
|
return
|
||||||
|
|
||||||
|
result = push_wecom(msg)
|
||||||
|
print(f"推送结果: {json.dumps(result, ensure_ascii=False)}")
|
||||||
|
print(f"统计: 异常{len(exceptions)}条 / 机会{len(opportunities)}条")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
main()
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""机会检测器 — 路线图R2 数据找人扩大 (2026-08-30)
|
||||||
|
|
||||||
|
北极星③:主动推送扩大 —— 异常 + 机会两类。
|
||||||
|
本脚本检测三类机会(复用 budget/kpi 数据,不新建表):
|
||||||
|
1. KPI向好 (kpi_improving) : 最近3期执行率>110% 且最新期呈上升趋势
|
||||||
|
2. 预算余量 (budget_headroom): 可用预算>30%(预算执行率<70%)
|
||||||
|
3. 滚动机会 (rolling_up) : 预测值上升(kpi_forecast_log 最新>上期)
|
||||||
|
|
||||||
|
输出:机会列表 [{type, title, detail, kpi_id, kpi_name, period}]
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from app.database import get_session_local
|
||||||
|
from app.models import KPIDefinition, KPIValue, BudgetPlan, KpiForecastLog
|
||||||
|
|
||||||
|
HIGH_RATIO = 1.1 # 执行率>110% = 超预期
|
||||||
|
LOW_EXEC_RATIO = 0.7 # 执行率<70% = 预算余量大(可用>30%)
|
||||||
|
|
||||||
|
|
||||||
|
def _exec_ratio(actual, target):
|
||||||
|
if target is None or target == 0:
|
||||||
|
return None
|
||||||
|
return actual / target
|
||||||
|
|
||||||
|
|
||||||
|
def detect_kpi_improving(db, entity_id: int, min_ratio: float = HIGH_RATIO) -> list:
|
||||||
|
"""KPI向好:最近3期执行率均>110%,且最新期>上期(上升中)"""
|
||||||
|
out = []
|
||||||
|
kpis = db.query(KPIDefinition).filter(
|
||||||
|
KPIDefinition.entity_id == entity_id,
|
||||||
|
KPIDefinition.status == "active",
|
||||||
|
).all()
|
||||||
|
now = datetime.now()
|
||||||
|
for k in kpis:
|
||||||
|
if not k.target_value or k.target_value <= 0:
|
||||||
|
continue
|
||||||
|
vals = db.query(KPIValue).filter(
|
||||||
|
KPIValue.kpi_id == k.id,
|
||||||
|
KPIValue.actual_value.isnot(None),
|
||||||
|
).order_by(KPIValue.period.desc()).limit(3).all()
|
||||||
|
if len(vals) < 3:
|
||||||
|
continue
|
||||||
|
ratios = [_exec_ratio(v.actual_value, k.target_value) for v in vals]
|
||||||
|
if any(r is None or r < min_ratio for r in ratios):
|
||||||
|
continue
|
||||||
|
# 最新期 > 上期(上升趋势);若最新期低于上期但整体仍>110%,也算(持续向好)
|
||||||
|
latest, prev = vals[0], vals[1]
|
||||||
|
trend = "上升" if latest.actual_value > prev.actual_value else "高位"
|
||||||
|
out.append({
|
||||||
|
"type": "kpi_improving",
|
||||||
|
"title": f"📈 {k.kpi_name} 持续向好",
|
||||||
|
"detail": (f"{latest.period}实际{latest.actual_value:g}/目标{k.target_value:g}"
|
||||||
|
f" 达成率{ratios[0]*100:.0f}%({trend}),近3期均超110%"),
|
||||||
|
"kpi_id": k.id,
|
||||||
|
"kpi_name": k.kpi_name,
|
||||||
|
"period": latest.period,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def detect_budget_headroom(db, entity_id: int, max_ratio: float = LOW_EXEC_RATIO) -> list:
|
||||||
|
"""预算余量:当月预算执行率<70%(可用预算>30%)
|
||||||
|
|
||||||
|
注意:跳过实际值为负的行(现金流/利润为负是异常不是余量),
|
||||||
|
同 KPI 同期间多版本预算只取一条(去重)。
|
||||||
|
"""
|
||||||
|
out = []
|
||||||
|
period = datetime.now().strftime("%Y-%m")
|
||||||
|
rows = db.query(BudgetPlan).filter(
|
||||||
|
BudgetPlan.entity_id == entity_id,
|
||||||
|
BudgetPlan.status == "active",
|
||||||
|
BudgetPlan.period == period,
|
||||||
|
BudgetPlan.budget_value > 0,
|
||||||
|
).all()
|
||||||
|
seen = set()
|
||||||
|
for b in rows:
|
||||||
|
key = (b.kpi_id, b.period)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
actual = db.query(KPIValue).filter(
|
||||||
|
KPIValue.kpi_id == b.kpi_id,
|
||||||
|
KPIValue.period == b.period,
|
||||||
|
KPIValue.actual_value.isnot(None),
|
||||||
|
).order_by(KPIValue.calculated_at.desc()).first()
|
||||||
|
if not actual or actual.actual_value is None or actual.actual_value <= 0:
|
||||||
|
continue
|
||||||
|
ratio = actual.actual_value / b.budget_value
|
||||||
|
if ratio < max_ratio:
|
||||||
|
k = db.query(KPIDefinition).filter(KPIDefinition.id == b.kpi_id).first()
|
||||||
|
kpi_name = k.kpi_name if k else f"KPI#{b.kpi_id}"
|
||||||
|
headroom = (1 - ratio) * 100
|
||||||
|
out.append({
|
||||||
|
"type": "budget_headroom",
|
||||||
|
"title": f"💼 {kpi_name} 预算余量充足",
|
||||||
|
"detail": (f"{period}预算{b.budget_value:g}/实际{actual.actual_value:g}"
|
||||||
|
f" 执行率{ratio*100:.0f}%,可用预算余量约{headroom:.0f}%"),
|
||||||
|
"kpi_id": b.kpi_id,
|
||||||
|
"kpi_name": kpi_name,
|
||||||
|
"period": period,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def detect_rolling_up(db, entity_id: int) -> list:
|
||||||
|
"""滚动机会:预测值上升(最新预测 > 上期预测)"""
|
||||||
|
out = []
|
||||||
|
# 每个KPI取最近两条预测记录
|
||||||
|
kpi_ids = [r[0] for r in db.query(KpiForecastLog.kpi_id).filter(
|
||||||
|
KpiForecastLog.entity_id == entity_id).distinct().limit(50).all()]
|
||||||
|
for kid in kpi_ids:
|
||||||
|
rows = db.query(KpiForecastLog).filter(
|
||||||
|
KpiForecastLog.entity_id == entity_id,
|
||||||
|
KpiForecastLog.kpi_id == kid,
|
||||||
|
KpiForecastLog.forecast_value.isnot(None),
|
||||||
|
).order_by(KpiForecastLog.created_at.desc(), KpiForecastLog.id.desc()).limit(2).all()
|
||||||
|
if len(rows) < 2:
|
||||||
|
continue
|
||||||
|
latest, prev = rows[0], rows[1]
|
||||||
|
if latest.forecast_value > prev.forecast_value:
|
||||||
|
k = db.query(KPIDefinition).filter(KPIDefinition.id == kid).first()
|
||||||
|
kpi_name = k.kpi_name if k else f"KPI#{kid}"
|
||||||
|
pct = (latest.forecast_value / prev.forecast_value - 1) * 100 if prev.forecast_value else 0
|
||||||
|
out.append({
|
||||||
|
"type": "rolling_up",
|
||||||
|
"title": f"🔮 {kpi_name} 预测上行",
|
||||||
|
"detail": (f"预测值 {prev.forecast_value:g} → {latest.forecast_value:g}"
|
||||||
|
f" (+{pct:.1f}%),{latest.period}期间"),
|
||||||
|
"kpi_id": kid,
|
||||||
|
"kpi_name": kpi_name,
|
||||||
|
"period": latest.period,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def detect_all(db, entity_id: int = 1) -> dict:
|
||||||
|
"""检测全部机会,按类型分组"""
|
||||||
|
return {
|
||||||
|
"kpi_improving": detect_kpi_improving(db, entity_id),
|
||||||
|
"budget_headroom": detect_budget_headroom(db, entity_id),
|
||||||
|
"rolling_up": detect_rolling_up(db, entity_id),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def flatten(detected: dict) -> list:
|
||||||
|
out = []
|
||||||
|
for cat in ("kpi_improving", "budget_headroom", "rolling_up"):
|
||||||
|
out.extend(detected.get(cat, []))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
db = get_session_local()()
|
||||||
|
try:
|
||||||
|
detected = detect_all(db)
|
||||||
|
total = sum(len(v) for v in detected.values())
|
||||||
|
print(json.dumps(detected, ensure_ascii=False, indent=2))
|
||||||
|
print(f"\n机会总数: {total}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""验证年度预算分解幂等 — R5 (2026-08-30)
|
||||||
|
|
||||||
|
调用 /api/cma/budget/auto-decompose 3 次,对比月度预算值是否不变。
|
||||||
|
用法: cd /root/cma-management/backend && ./venv/bin/python3 scripts/verify_decompose_idempotent.py
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
BASE = os.getenv("CMA_BASE", "http://127.0.0.1:8010")
|
||||||
|
|
||||||
|
|
||||||
|
def post(path, body, token=None):
|
||||||
|
req = urllib.request.Request(
|
||||||
|
BASE + path,
|
||||||
|
data=json.dumps(body).encode("utf-8"),
|
||||||
|
headers={"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {token}" if token else ""},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# 登录(账套模式必须 entity_id)
|
||||||
|
login = post("/api/cma/auth/login", {"username": "admin", "password": "admin123", "entity_id": 1})
|
||||||
|
token = login.get("token") or login.get("access_token")
|
||||||
|
if not token:
|
||||||
|
print("❌ 登录失败:", login)
|
||||||
|
sys.exit(1)
|
||||||
|
print("✅ 登录成功")
|
||||||
|
|
||||||
|
runs = []
|
||||||
|
for i in range(3):
|
||||||
|
r = post("/api/cma/budget/auto-decompose", {"year": 2026, "method": "equal", "version": "v1.0"}, token)
|
||||||
|
print(f"第{i+1}次: {r.get('message', '')} created={r.get('created', 0)}")
|
||||||
|
# 提取 (kpi_id -> monthly tuple)
|
||||||
|
snap = {}
|
||||||
|
for res in r.get("results", []):
|
||||||
|
snap[res["kpi_id"]] = tuple(res.get("monthly") or [])
|
||||||
|
runs.append(snap)
|
||||||
|
|
||||||
|
# 对比三次结果
|
||||||
|
same = runs[0] == runs[1] == runs[2]
|
||||||
|
print(f"\n三次结果一致: {'✅ 是(幂等)' if same else '❌ 否(不幂等)'}")
|
||||||
|
if not same:
|
||||||
|
for i in range(1, 3):
|
||||||
|
for kid in runs[0]:
|
||||||
|
if runs[0].get(kid) != runs[i].get(kid):
|
||||||
|
print(f" KPI {kid} 第1次={runs[0].get(kid)} 第{i+1}次={runs[i].get(kid)}")
|
||||||
|
sys.exit(0 if same else 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -82,9 +82,12 @@ import hashlib
|
|||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def setup_db():
|
def setup_db():
|
||||||
"""每个测试函数自动初始化和清理数据库"""
|
"""每个测试函数自动初始化和清理数据库"""
|
||||||
|
from app.utils import cache as cache_util
|
||||||
|
cache_util.delete("ai") # 清AI分析缓存,防测试间Redis污染(dashboard-analysis缓存全局共享)
|
||||||
Base.metadata.create_all(bind=TEST_ENGINE)
|
Base.metadata.create_all(bind=TEST_ENGINE)
|
||||||
yield
|
yield
|
||||||
Base.metadata.drop_all(bind=TEST_ENGINE)
|
Base.metadata.drop_all(bind=TEST_ENGINE)
|
||||||
|
cache_util.delete("ai")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
"""
|
||||||
|
路线图R1:AI建议→一键落地 测试
|
||||||
|
建议CRUD + 应用到KPI/预算/行动方案 + OperationLog留痕 + 已应用/未应用状态
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from tests.conftest import create_test_user, get_token_for_user, auth_header, create_test_kpi
|
||||||
|
from app.models import AISuggestion, KPIDefinition, BudgetPlan, ActionPlan, OperationLog, KPIValue
|
||||||
|
|
||||||
|
|
||||||
|
def _create_suggestion(client, token, kpi_id, **kw):
|
||||||
|
body = {
|
||||||
|
"suggestion_type": "kpi_target",
|
||||||
|
"target_type": "kpi",
|
||||||
|
"target_id": kpi_id,
|
||||||
|
"title": "上调测试KPI目标",
|
||||||
|
"content": "达成率超预期",
|
||||||
|
"suggestion_data": {"kpi_id": kpi_id, "target_value": 150.0},
|
||||||
|
}
|
||||||
|
body.update(kw)
|
||||||
|
return client.post("/api/cma/ai/suggestions", json=body, headers=auth_header(token))
|
||||||
|
|
||||||
|
|
||||||
|
class TestSuggestionCRUD:
|
||||||
|
def test_create_and_list(self, client, db):
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db)
|
||||||
|
|
||||||
|
r = _create_suggestion(client, token, kpi.id)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
data = r.json()["data"]
|
||||||
|
assert data["status"] == "unapplied"
|
||||||
|
assert data["suggestion_type"] == "kpi_target"
|
||||||
|
|
||||||
|
# 列表含未应用
|
||||||
|
lst = client.get("/api/cma/ai/suggestions", headers=auth_header(token)).json()
|
||||||
|
assert lst["total"] == 1
|
||||||
|
assert lst["data"][0]["id"] == data["id"]
|
||||||
|
|
||||||
|
# 详情
|
||||||
|
det = client.get(f"/api/cma/ai/suggestions/{data['id']}", headers=auth_header(token)).json()
|
||||||
|
assert det["data"]["title"] == "上调测试KPI目标"
|
||||||
|
|
||||||
|
def test_create_missing_fields(self, client, db):
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
r = client.post("/api/cma/ai/suggestions", json={"title": "无类型"}, headers=auth_header(token))
|
||||||
|
assert r.status_code == 400
|
||||||
|
r2 = client.post("/api/cma/ai/suggestions", json={"suggestion_type": "kpi_target"}, headers=auth_header(token))
|
||||||
|
assert r2.status_code == 400
|
||||||
|
|
||||||
|
def test_apply_kpi_target(self, client, db):
|
||||||
|
"""应用建议→改KPI目标→操作日志可查"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db, target_value=100.0)
|
||||||
|
|
||||||
|
r = _create_suggestion(client, token, kpi.id)
|
||||||
|
sug_id = r.json()["data"]["id"]
|
||||||
|
|
||||||
|
# 应用:改KPI目标为150
|
||||||
|
app = client.post(
|
||||||
|
f"/api/cma/ai/suggestions/{sug_id}/apply",
|
||||||
|
json={"action": "kpi_target", "target_value": 150.0},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
assert app.status_code == 200, app.text
|
||||||
|
app_data = app.json()["data"]
|
||||||
|
assert app_data["status"] == "applied"
|
||||||
|
assert app_data["applied_by"] == "测试管理员"
|
||||||
|
assert app_data["apply_detail"][0]["before"] == 100.0
|
||||||
|
assert app_data["apply_detail"][0]["after"] == 150.0
|
||||||
|
|
||||||
|
# KPI目标已变更
|
||||||
|
db.refresh(kpi)
|
||||||
|
assert kpi.target_value == 150.0
|
||||||
|
|
||||||
|
# OperationLog留痕
|
||||||
|
logs = db.query(OperationLog).filter(OperationLog.action == "ai_suggestion_apply").all()
|
||||||
|
assert len(logs) == 1
|
||||||
|
assert logs[0].target_type == "kpi"
|
||||||
|
assert logs[0].target_id == kpi.id
|
||||||
|
assert logs[0].detail["suggestion_id"] == sug_id
|
||||||
|
assert logs[0].detail["before"] == 100.0
|
||||||
|
assert logs[0].detail["after"] == 150.0
|
||||||
|
|
||||||
|
# 重复应用被拒绝
|
||||||
|
app2 = client.post(
|
||||||
|
f"/api/cma/ai/suggestions/{sug_id}/apply",
|
||||||
|
json={"action": "kpi_target", "target_value": 200.0},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
assert app2.status_code == 400
|
||||||
|
|
||||||
|
def test_apply_budget_adjust(self, client, db):
|
||||||
|
"""应用建议→调预算(新建/更新BudgetPlan)→操作日志"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db)
|
||||||
|
|
||||||
|
r = _create_suggestion(client, token, kpi.id, suggestion_type="budget_adjust",
|
||||||
|
title="调整预算", suggestion_data={"kpi_id": kpi.id})
|
||||||
|
sug_id = r.json()["data"]["id"]
|
||||||
|
|
||||||
|
app = client.post(
|
||||||
|
f"/api/cma/ai/suggestions/{sug_id}/apply",
|
||||||
|
json={"action": "budget_adjust", "period": "2026-09", "budget_value": 8888.0},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
assert app.status_code == 200, app.text
|
||||||
|
plan = db.query(BudgetPlan).filter(BudgetPlan.kpi_id == kpi.id, BudgetPlan.period == "2026-09").first()
|
||||||
|
assert plan is not None
|
||||||
|
assert plan.budget_value == 8888.0
|
||||||
|
assert plan.source_type == "ai_suggestion"
|
||||||
|
|
||||||
|
# 同期间再应用→更新而非新增
|
||||||
|
app2 = client.post(
|
||||||
|
f"/api/cma/ai/suggestions/{sug_id}/apply",
|
||||||
|
json={"action": "budget_adjust", "period": "2026-09", "budget_value": 9999.0},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
# 已applied被拒;用新建议验证upsert
|
||||||
|
r2 = _create_suggestion(client, token, kpi.id, suggestion_type="budget_adjust",
|
||||||
|
title="调整预算2", suggestion_data={"kpi_id": kpi.id})
|
||||||
|
sug_id2 = r2.json()["data"]["id"]
|
||||||
|
app3 = client.post(
|
||||||
|
f"/api/cma/ai/suggestions/{sug_id2}/apply",
|
||||||
|
json={"action": "budget_adjust", "period": "2026-09", "budget_value": 9999.0},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
assert app3.status_code == 200
|
||||||
|
plans = db.query(BudgetPlan).filter(BudgetPlan.kpi_id == kpi.id, BudgetPlan.period == "2026-09").all()
|
||||||
|
assert len(plans) == 1
|
||||||
|
assert plans[0].budget_value == 9999.0
|
||||||
|
assert app3.json()["data"]["apply_detail"][0]["before"] == 8888.0
|
||||||
|
|
||||||
|
def test_apply_action_plan(self, client, db):
|
||||||
|
"""应用建议→建行动方案→操作日志"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db)
|
||||||
|
|
||||||
|
r = _create_suggestion(client, token, kpi.id, suggestion_type="action_plan",
|
||||||
|
title="建行动方案", suggestion_data={"kpi_id": kpi.id})
|
||||||
|
sug_id = r.json()["data"]["id"]
|
||||||
|
|
||||||
|
app = client.post(
|
||||||
|
f"/api/cma/ai/suggestions/{sug_id}/apply",
|
||||||
|
json={"action": "action_plan", "title": "营收提升专项", "assignee": "张三",
|
||||||
|
"priority": "high", "due_date": "2026-09-30"},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
assert app.status_code == 200, app.text
|
||||||
|
plan = db.query(ActionPlan).filter(ActionPlan.kpi_id == kpi.id, ActionPlan.title == "营收提升专项").first()
|
||||||
|
assert plan is not None
|
||||||
|
assert plan.assignee == "张三"
|
||||||
|
assert plan.priority == "high"
|
||||||
|
assert plan.created_by == "测试管理员"
|
||||||
|
|
||||||
|
logs = db.query(OperationLog).filter(OperationLog.action == "ai_suggestion_apply",
|
||||||
|
OperationLog.target_type == "action_plan").all()
|
||||||
|
assert len(logs) == 1
|
||||||
|
assert logs[0].target_id == plan.id
|
||||||
|
|
||||||
|
def test_dismiss(self, client, db):
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db)
|
||||||
|
r = _create_suggestion(client, token, kpi.id)
|
||||||
|
sug_id = r.json()["data"]["id"]
|
||||||
|
|
||||||
|
d = client.post(f"/api/cma/ai/suggestions/{sug_id}/dismiss", headers=auth_header(token))
|
||||||
|
assert d.status_code == 200
|
||||||
|
det = client.get(f"/api/cma/ai/suggestions/{sug_id}", headers=auth_header(token)).json()
|
||||||
|
assert det["data"]["status"] == "dismissed"
|
||||||
|
# 忽略后应用被拒
|
||||||
|
app = client.post(f"/api/cma/ai/suggestions/{sug_id}/apply",
|
||||||
|
json={"action": "kpi_target", "target_value": 1}, headers=auth_header(token))
|
||||||
|
assert app.status_code == 400
|
||||||
|
|
||||||
|
def test_apply_not_found(self, client, db):
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
app = client.post("/api/cma/ai/suggestions/9999/apply", json={}, headers=auth_header(token))
|
||||||
|
assert app.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestRuleSuggestions:
|
||||||
|
"""dashboard-analysis 自动生成建议(规则驱动)"""
|
||||||
|
|
||||||
|
def test_generate_low_ratio_action(self, client, db):
|
||||||
|
"""执行率<70% → 生成建行动方案建议"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db, target_value=100.0)
|
||||||
|
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=50.0))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 直接调规则生成
|
||||||
|
resp = client.get("/api/cma/ai/dashboard-analysis", headers=auth_header(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
s = db.query(AISuggestion).filter(AISuggestion.target_id == kpi.id).all()
|
||||||
|
assert len(s) >= 1
|
||||||
|
assert any(x.suggestion_type == "action_plan" for x in s)
|
||||||
|
|
||||||
|
# 幂等:再调一次不重复建
|
||||||
|
resp2 = client.get("/api/cma/ai/dashboard-analysis", headers=auth_header(token))
|
||||||
|
s2 = db.query(AISuggestion).filter(AISuggestion.target_id == kpi.id).all()
|
||||||
|
assert len(s2) == len(s)
|
||||||
|
|
||||||
|
def test_generate_high_ratio_target(self, client, db):
|
||||||
|
"""执行率>110% → 生成上调目标建议"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db, target_value=100.0)
|
||||||
|
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=150.0))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
resp = client.get("/api/cma/ai/dashboard-analysis", headers=auth_header(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
s = db.query(AISuggestion).filter(AISuggestion.target_id == kpi.id).all()
|
||||||
|
assert any(x.suggestion_type == "kpi_target" for x in s)
|
||||||
|
assert "suggestions" in resp.json()
|
||||||
|
|
||||||
|
def test_generate_budget_overrun(self, client, db):
|
||||||
|
"""预算执行率>110% → 生成调预算建议"""
|
||||||
|
create_test_user(db)
|
||||||
|
token = get_token_for_user(client)
|
||||||
|
kpi = create_test_kpi(db, target_value=100.0)
|
||||||
|
db.add(KPIValue(kpi_id=kpi.id, period="2026-08", actual_value=200.0))
|
||||||
|
db.add(BudgetPlan(entity_id=1, kpi_id=kpi.id, period="2026-08", budget_value=100.0,
|
||||||
|
budget_year=2026, budget_month=8, status="active"))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
resp = client.get("/api/cma/ai/dashboard-analysis", headers=auth_header(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
s = db.query(AISuggestion).filter(AISuggestion.suggestion_type == "budget_adjust").all()
|
||||||
|
assert len(s) >= 1
|
||||||
@@ -663,9 +663,9 @@ class TestBudgetContract20260825:
|
|||||||
token = get_token_for_user(client)
|
token = get_token_for_user(client)
|
||||||
kpi = create_test_kpi(db, kpi_code="CONTRACT_DECOMP")
|
kpi = create_test_kpi(db, kpi_code="CONTRACT_DECOMP")
|
||||||
|
|
||||||
# 先创建年度预算(period=2026-00 或任意月份记录,让批量分解能聚合到)
|
# 先创建年度预算(period=YYYY-00 年度行,批量分解只取年度行 — 幂等契约)
|
||||||
client.post(f"{self.BASE}/plans", headers=auth_header(token),
|
client.post(f"{self.BASE}/plans", headers=auth_header(token),
|
||||||
json={"kpi_id": kpi.id, "period": "2026-01", "budget_value": 12000.0, "budget_year": 2026, "budget_month": 1})
|
json={"kpi_id": kpi.id, "period": "2026-00", "budget_value": 12000.0, "budget_year": 2026, "budget_month": 0})
|
||||||
|
|
||||||
# 第一次批量分解
|
# 第一次批量分解
|
||||||
resp1 = client.post(f"{self.BASE}/auto-decompose", headers=auth_header(token),
|
resp1 = client.post(f"{self.BASE}/auto-decompose", headers=auth_header(token),
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
"""
|
||||||
|
路线图R2/R5 测试(2026-08-30)
|
||||||
|
R2: 机会检测(KPI向好/预算余量/预测上行)
|
||||||
|
R5: 预算↔现金流↔行动 闭环自检
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from tests.conftest import create_test_kpi
|
||||||
|
from app.models import KPIDefinition, KPIValue, BudgetPlan, CashPlan, ActionPlan, KpiForecastLog
|
||||||
|
|
||||||
|
from scripts.opportunity_detector import (
|
||||||
|
detect_kpi_improving, detect_budget_headroom, detect_rolling_up, detect_all, flatten,
|
||||||
|
)
|
||||||
|
from scripts.closed_loop_check import check_entity, build_report
|
||||||
|
|
||||||
|
|
||||||
|
def _kpi(db, code, target=100.0, **kw):
|
||||||
|
return create_test_kpi(db, kpi_code=code, target_value=target, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
def _value(db, kpi_id, period, actual, entity_id=1):
|
||||||
|
v = KPIValue(kpi_id=kpi_id, period=period, actual_value=actual, entity_id=entity_id)
|
||||||
|
db.add(v)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def _budget(db, kpi_id, period, value, year=None, month=None, entity_id=1):
|
||||||
|
if year is None:
|
||||||
|
year = int(period.split("-")[0])
|
||||||
|
month = int(period.split("-")[1])
|
||||||
|
b = BudgetPlan(entity_id=entity_id, kpi_id=kpi_id, period=period, budget_value=value,
|
||||||
|
budget_year=year, budget_month=month, version="v1.0", status="active")
|
||||||
|
db.add(b)
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
class TestOpportunityR2:
|
||||||
|
def test_kpi_improving(self, db):
|
||||||
|
"""连续3期执行率>110% → KPI向好机会"""
|
||||||
|
kpi = _kpi(db, "OPP_01", target=100.0)
|
||||||
|
_value(db, kpi.id, "2026-04", 120.0)
|
||||||
|
_value(db, kpi.id, "2026-05", 130.0)
|
||||||
|
_value(db, kpi.id, "2026-06", 140.0)
|
||||||
|
db.commit()
|
||||||
|
out = detect_kpi_improving(db, 1)
|
||||||
|
assert len(out) == 1
|
||||||
|
assert out[0]["type"] == "kpi_improving"
|
||||||
|
assert out[0]["kpi_id"] == kpi.id
|
||||||
|
|
||||||
|
def test_kpi_improving_not_enough_data(self, db):
|
||||||
|
"""不足3期不判定"""
|
||||||
|
kpi = _kpi(db, "OPP_02", target=100.0)
|
||||||
|
_value(db, kpi.id, "2026-05", 130.0)
|
||||||
|
_value(db, kpi.id, "2026-06", 140.0)
|
||||||
|
db.commit()
|
||||||
|
assert detect_kpi_improving(db, 1) == []
|
||||||
|
|
||||||
|
def test_kpi_improving_low_ratio_skip(self, db):
|
||||||
|
"""执行率未超110%不判定"""
|
||||||
|
kpi = _kpi(db, "OPP_03", target=100.0)
|
||||||
|
_value(db, kpi.id, "2026-04", 90.0)
|
||||||
|
_value(db, kpi.id, "2026-05", 95.0)
|
||||||
|
_value(db, kpi.id, "2026-06", 100.0)
|
||||||
|
db.commit()
|
||||||
|
assert detect_kpi_improving(db, 1) == []
|
||||||
|
|
||||||
|
def test_budget_headroom(self, db):
|
||||||
|
"""当月预算执行率<70% → 预算余量机会"""
|
||||||
|
kpi = _kpi(db, "OPP_04", target=1000.0)
|
||||||
|
_value(db, kpi.id, "2026-08", 300.0)
|
||||||
|
_budget(db, kpi.id, "2026-08", 1000.0)
|
||||||
|
db.commit()
|
||||||
|
out = detect_budget_headroom(db, 1)
|
||||||
|
assert len(out) == 1
|
||||||
|
assert out[0]["type"] == "budget_headroom"
|
||||||
|
|
||||||
|
def test_budget_headroom_negative_skip(self, db):
|
||||||
|
"""实际值为负(现金流异常)不误判为余量"""
|
||||||
|
kpi = _kpi(db, "OPP_05", target=1000.0)
|
||||||
|
_value(db, kpi.id, "2026-08", -500.0)
|
||||||
|
_budget(db, kpi.id, "2026-08", 1000.0)
|
||||||
|
db.commit()
|
||||||
|
assert detect_budget_headroom(db, 1) == []
|
||||||
|
|
||||||
|
def test_budget_headroom_dedup(self, db):
|
||||||
|
"""同KPI同期间多版本预算只取一条"""
|
||||||
|
kpi = _kpi(db, "OPP_06", target=1000.0)
|
||||||
|
_value(db, kpi.id, "2026-08", 300.0)
|
||||||
|
_budget(db, kpi.id, "2026-08", 1000.0)
|
||||||
|
b2 = _budget(db, kpi.id, "2026-08", 2000.0)
|
||||||
|
b2.version = "v2.0"
|
||||||
|
db.commit()
|
||||||
|
assert len(detect_budget_headroom(db, 1)) == 1
|
||||||
|
|
||||||
|
def test_rolling_up(self, db):
|
||||||
|
"""预测值上升 → 滚动机会"""
|
||||||
|
kpi = _kpi(db, "OPP_07", target=100.0)
|
||||||
|
now = datetime.now()
|
||||||
|
db.add(KpiForecastLog(entity_id=1, kpi_id=kpi.id, kpi_code=kpi.kpi_code,
|
||||||
|
period="2026-07", forecast_value=100.0, model="linear",
|
||||||
|
created_at=now))
|
||||||
|
db.add(KpiForecastLog(entity_id=1, kpi_id=kpi.id, kpi_code=kpi.kpi_code,
|
||||||
|
period="2026-08", forecast_value=130.0, model="linear",
|
||||||
|
created_at=now))
|
||||||
|
db.commit()
|
||||||
|
out = detect_rolling_up(db, 1)
|
||||||
|
assert len(out) == 1
|
||||||
|
assert out[0]["type"] == "rolling_up"
|
||||||
|
|
||||||
|
def test_rolling_down_skip(self, db):
|
||||||
|
"""预测下降不判定为机会"""
|
||||||
|
kpi = _kpi(db, "OPP_08", target=100.0)
|
||||||
|
now = datetime.now()
|
||||||
|
db.add(KpiForecastLog(entity_id=1, kpi_id=kpi.id, kpi_code=kpi.kpi_code,
|
||||||
|
period="2026-07", forecast_value=130.0, model="linear",
|
||||||
|
created_at=now))
|
||||||
|
db.add(KpiForecastLog(entity_id=1, kpi_id=kpi.id, kpi_code=kpi.kpi_code,
|
||||||
|
period="2026-08", forecast_value=100.0, model="linear",
|
||||||
|
created_at=now))
|
||||||
|
db.commit()
|
||||||
|
assert detect_rolling_up(db, 1) == []
|
||||||
|
|
||||||
|
def test_flatten(self):
|
||||||
|
d = {"kpi_improving": [1], "budget_headroom": [2, 3], "rolling_up": []}
|
||||||
|
assert flatten(d) == [1, 2, 3]
|
||||||
|
|
||||||
|
|
||||||
|
class TestClosedLoopR5:
|
||||||
|
def test_overrun_missing_both(self, db):
|
||||||
|
"""超预算且缺现金流/行动 → 提示同步"""
|
||||||
|
kpi = _kpi(db, "CL_01", target=100.0)
|
||||||
|
_value(db, kpi.id, "2026-08", 200.0)
|
||||||
|
_budget(db, kpi.id, "2026-08", 100.0)
|
||||||
|
db.commit()
|
||||||
|
r = check_entity(db, 1, "2026-08")
|
||||||
|
assert len(r["issues"]) == 1
|
||||||
|
it = r["issues"][0]
|
||||||
|
assert it["abnormal_type"] == "超预算"
|
||||||
|
assert "现金流" in it["missing"]
|
||||||
|
assert "行动方案" in it["missing"]
|
||||||
|
|
||||||
|
def test_overrun_has_cash_and_action(self, db):
|
||||||
|
"""超预算但有现金流+行动 → 三闭环同步"""
|
||||||
|
kpi = _kpi(db, "CL_02", target=100.0)
|
||||||
|
_value(db, kpi.id, "2026-08", 200.0)
|
||||||
|
b = _budget(db, kpi.id, "2026-08", 100.0)
|
||||||
|
db.add(CashPlan(entity_id=1, plan_type="receive", related_kpi_id=kpi.id, budget_plan_id=b.id,
|
||||||
|
amount=200.0, plan_date=datetime(2026, 8, 15), status="pending"))
|
||||||
|
db.add(ActionPlan(kpi_id=kpi.id, title="改善计划", status="in_progress"))
|
||||||
|
db.commit()
|
||||||
|
r = check_entity(db, 1, "2026-08")
|
||||||
|
assert len(r["issues"]) == 1
|
||||||
|
assert r["issues"][0]["missing"] == []
|
||||||
|
|
||||||
|
def test_normal_no_issue(self, db):
|
||||||
|
"""执行率正常 → 无异常"""
|
||||||
|
kpi = _kpi(db, "CL_03", target=100.0)
|
||||||
|
_value(db, kpi.id, "2026-08", 100.0)
|
||||||
|
_budget(db, kpi.id, "2026-08", 100.0)
|
||||||
|
db.commit()
|
||||||
|
r = check_entity(db, 1, "2026-08")
|
||||||
|
assert r["issues"] == []
|
||||||
|
|
||||||
|
def test_low_execution(self, db):
|
||||||
|
"""低执行率 → 异常(warning)"""
|
||||||
|
kpi = _kpi(db, "CL_04", target=100.0)
|
||||||
|
_value(db, kpi.id, "2026-08", 50.0)
|
||||||
|
_budget(db, kpi.id, "2026-08", 100.0)
|
||||||
|
db.commit()
|
||||||
|
r = check_entity(db, 1, "2026-08")
|
||||||
|
assert len(r["issues"]) == 1
|
||||||
|
assert r["issues"][0]["abnormal_type"] == "低执行"
|
||||||
|
assert r["issues"][0]["level"] == "warning"
|
||||||
|
|
||||||
|
def test_build_report(self):
|
||||||
|
result = {"entity_id": 1, "period": "2026-08", "issues": [
|
||||||
|
{"kpi_id": 1, "kpi_name": "营收", "period": "2026-08", "budget_value": 100.0,
|
||||||
|
"actual_value": 200.0, "exec_ratio": 200.0, "abnormal_type": "超预算",
|
||||||
|
"level": "critical", "cash_plan_count": 0, "action_plan_count": 0,
|
||||||
|
"missing": ["现金流", "行动方案"], "suggestion": "请同步现金流、行动方案"}
|
||||||
|
]}
|
||||||
|
report = build_report([result], "2026-08-30 12:00:00")
|
||||||
|
assert "闭环自检" in report
|
||||||
|
assert "营收" in report
|
||||||
|
assert "共发现异常 1 项" in report
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# CMA 产品愿景与实施路线图 v1.0(2026-08-30 固化)
|
||||||
|
|
||||||
|
> 提出:任富海 | 整理:项目Bot | 状态:✅ 已确认(北极星)+ 战略全景(8方向)
|
||||||
|
|
||||||
|
## 一、产品愿景(北极星)
|
||||||
|
|
||||||
|
**一句话**:CMA = 管理会计操作系统——数据接入(接口/手工) → 多Bot互动数据 → 数据找人(主动) → 决策建议 → 决策修改+检查(闭环可审计)。
|
||||||
|
|
||||||
|
**四层北极星**:
|
||||||
|
```
|
||||||
|
① 数据接入(财务软件接口/手工录入)
|
||||||
|
↓
|
||||||
|
② 多Bot互动(14Bot协作处理数据)
|
||||||
|
↓
|
||||||
|
③ 数据找人(异常+机会主动推送)
|
||||||
|
↓
|
||||||
|
④ 决策建议 → 决策修改+检查(闭环)
|
||||||
|
```
|
||||||
|
|
||||||
|
**价值主张**:让管理团队从"被数据淹没"到"数据找人、人做决策、决策留痕"——每项决策可追溯(谁/何时/依据什么/结果如何)。
|
||||||
|
|
||||||
|
## 二、战略全景(8 补充方向)
|
||||||
|
|
||||||
|
| 方向 | 定位 | 优先级 |
|
||||||
|
|:--|:--|:--:|
|
||||||
|
| A 产品化/商业化 | CMA→可交付产品(SaaS/私有/实施) | 🔴 |
|
||||||
|
| B 行业纵深 | 白酒经销→贸易→制造(行业包) | 🟠 |
|
||||||
|
| C 数据资产化 | 博海+客户数据→数据产品(DAMA治理) | 🟠 |
|
||||||
|
| D 决策智能 | 提建议→预测决策(敏感性/因果/复盘) | 🔴 |
|
||||||
|
| E AI原生组织方法论 | **护城河**:14Bot/铁律/闭环体系产品化 | 🟡 |
|
||||||
|
| F 生态联盟 | 财务软件对接(用友/金蝶)+渠道 | 🟡 |
|
||||||
|
| G 信任合规 | PIPL/等保/AI可信/审计链 | 🟠 |
|
||||||
|
| H 技术前瞻 | 数字员工/AI同事/Agent自动执行 | 🟡 |
|
||||||
|
|
||||||
|
## 三、实施路线图(按优先级)
|
||||||
|
|
||||||
|
### 🔴 近期(1-3个月)——北极星核心闭环
|
||||||
|
| # | 方向 | 目标 | 关键动作 | 验收 |
|
||||||
|
|:--|:--|:--|:--|:--|
|
||||||
|
| R1 | ④决策智能 | AI建议→一键落地 | ai_analysis 建议可"应用到KPI/预算/行动"(写库+留痕) | 建议生成→点击落地→操作日志可查 |
|
||||||
|
| R2 | ③数据找人 | 主动推送扩大 | 机会/趋势推送(不止异常):KPI向好/预算余量/滚动机会 | 每日推送含异常+机会两类 |
|
||||||
|
| R3 | ①数据接入调研 | 财务软件接口方案 | 调研用友/金蝶/管家婆开放API+实施成本 | 接口可行性报告 |
|
||||||
|
| R4 | A产品化准备 | 酣客试点成案例 | 试点数据闭环+试点报告(作首个客户案例) | 案例文档+官网可引用 |
|
||||||
|
| R5 | 预算bug修复链 | 系统稳定 | 年度分解幂等+预算/现金流/行动闭环加固 | pytest全绿 |
|
||||||
|
|
||||||
|
### 🟠 中期(3-6个月)——产品化+合规
|
||||||
|
| # | 方向 | 目标 | 关键动作 | 验收 |
|
||||||
|
|:--|:--|:--|:--|:--|
|
||||||
|
| M1 | A产品化 | CMA可交付形态 | SaaS多租户完善/私有部署包/实施文档 | 第2-3个客户可用 |
|
||||||
|
| M2 | B行业复制 | 白酒经销行业包 | 行业KPI库/OKR模板/科目模板校准(酣客数据) | 行业包v1 |
|
||||||
|
| M3 | G合规 | 信任背书 | 数据安全分级/PIPL清单/审计链完善 | 合规清单 |
|
||||||
|
| M4 | ①数据接入落地 | 财务软件接口 | 按R3方案接入1个财务软件 | 接口联调通过 |
|
||||||
|
|
||||||
|
### 🟡 远期(6-12个月)——方法论+生态
|
||||||
|
| # | 方向 | 目标 | 关键动作 | 验收 |
|
||||||
|
|:--|:--|:--|:--|:--|
|
||||||
|
| F1 | E方法论 | AI原生组织产品 | 评估+实施+运营三件套方法论文档化 | 方法论v1可售 |
|
||||||
|
| F2 | F生态 | 渠道伙伴 | 代账/咨询/本地IT渠道首批 | 3家伙伴 |
|
||||||
|
| F3 | H前瞻 | 数字员工试点 | AI同事(自动执行例行决策)试点 | 试点报告 |
|
||||||
|
| F4 | D完整版 | 预测性成本智能完整 | 宏观数据回归校准+预测偏差告警完善 | IMA对标 |
|
||||||
|
|
||||||
|
## 四、依赖与飞轮
|
||||||
|
|
||||||
|
```
|
||||||
|
R4酣客案例 → M1产品化 → F1方法论 → F2生态
|
||||||
|
↑____________↑___________________↓
|
||||||
|
数据/案例反哺(B行业包)
|
||||||
|
```
|
||||||
|
|
||||||
|
**飞轮起点**:近期 R1-R5(决策闭环+稳定+案例)——先让内部系统达到"决策可落地可追溯",再谈产品化。
|
||||||
|
|
||||||
|
## 五、北极星四层 → 落地项(映射)
|
||||||
|
|
||||||
|
| 层 | 近期 | 中期 | 远期 |
|
||||||
|
|:--|:--|:--|:--|
|
||||||
|
| ①数据接入 | R3调研 | M4接口落地 | 多软件适配 |
|
||||||
|
| ②多Bot互动 | 保持 | Bot联合决策 | Agent自动执行(F3) |
|
||||||
|
| ③数据找人 | R2推送扩大 | 推送策略化 | AI同事(F3) |
|
||||||
|
| ④决策闭环 | R1建议落地+R5稳定 | 决策复盘闭环 | 预测决策(F4) |
|
||||||
|
|
||||||
|
## 六、节奏建议
|
||||||
|
- **月度检查点**:每月对照路线图验收(R/M/F 项完成度)
|
||||||
|
- **北极星校验**:每季度问"数据找人了吗?决策落地了吗?可追溯吗?"
|
||||||
|
- **资源配置**:近期全栈Bot集中 R1/R2/R5(代码);项目Bot R3调研+R4案例(方案)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# CMA 路线图近期任务清单(R1-R5,2026-08-30 派单)
|
||||||
|
|
||||||
|
> 依据:docs/cma-product-vision-roadmap-v1.md 近期🔴项
|
||||||
|
|
||||||
|
## 已派全栈Bot(代码执行)
|
||||||
|
|
||||||
|
| 任务 | 内容 | 优先级 |
|
||||||
|
|:--|:--|:--:|
|
||||||
|
| **cma-roadmap-r1r2r5-20260830.md** | R1 AI建议→一键落地(应用KPI/预算/行动+留痕)<br>R2 数据找人扩大(机会类推送:KPI向好/预算余量)<br>R5 预算闭环加固(年度分解幂等确认+闭环自检) | P0/P1 |
|
||||||
|
| **data-quality-api-plus-v72-20260830.md** | ⓪预算年度分解累加bug(P0)<br>①数据质量API 7规则<br>②扫描脚本v7.2补做<br>③执行人中文名兼容 | P1 |
|
||||||
|
|
||||||
|
## 项目Bot负责(方案/调研/案例)
|
||||||
|
|
||||||
|
### R3 财务软件接口调研(✅ 已完成初步结论)
|
||||||
|
- **结论:可行**——用友/金蝶云有开放平台API(标准连接器),管家婆有API(erp.btype.list等),金蝶云星空支持表单查询/保存/提交/审核
|
||||||
|
- 2026 ERP API 开放性评估:用友/金蝶表现突出
|
||||||
|
- **待确认**:酣客实际使用哪套财务软件(用友/金蝶/管家婆/其他)→ 决定先接哪个适配器
|
||||||
|
|
||||||
|
### R4 酣客试点成案例(⏳ 待数据确认)
|
||||||
|
- 方案已出:docs/hanke-pilot-plan-20260829.md(3天节奏)
|
||||||
|
- 待你确认:KPI目标值 / 真实实际值来源 / 节奏
|
||||||
|
- 完成后产出:试点报告 = 首个客户案例(产品化飞轮起点)
|
||||||
|
|
||||||
|
## 执行顺序建议
|
||||||
|
1. 全栈:先修 ⓪ 预算分解bug(P0,用户已遇到)→ R1(决策闭环核心)→ R2/R5/数据质量
|
||||||
|
2. 项目Bot:R3 等你告知酣客财务软件 → R4 试点数据确认
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# CMA 战略愿景全景规划(2026-08-30)
|
||||||
|
|
||||||
|
> 提出:任富海 | 整理:项目Bot | 北极星:**数据接入→多Bot互动→数据找人→决策建议→决策修改检查**(四层愿景已确认)
|
||||||
|
|
||||||
|
## 一、北极星愿景(已确认,四层)
|
||||||
|
|
||||||
|
```
|
||||||
|
① 数据接入(财务软件接口/手工录入)
|
||||||
|
↓
|
||||||
|
② 多Bot互动(14Bot协作处理数据)
|
||||||
|
↓
|
||||||
|
③ 数据找人(异常+机会主动推送)
|
||||||
|
↓
|
||||||
|
④ 决策建议 → 决策修改+检查(闭环可审计)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 二、补充战略方向(8个,超越功能层面)
|
||||||
|
|
||||||
|
### A. 产品化与商业化(🔴 最高优先级——让能力变现)
|
||||||
|
- 现状:CMA 是内部系统,但官网已有 6 服务模块(含AI智能体/数据治理)可承接
|
||||||
|
- 方向:CMA → 可交付产品(SaaS/私有部署/实施服务三模式)
|
||||||
|
- 抓手:酣客试点成功 = 首个可复制案例;行业包 4 类(trading/it_service/manufacturing/general)已具雏形
|
||||||
|
|
||||||
|
### B. 行业纵深复制
|
||||||
|
- 白酒经销(酣客) → 贸易/流通 → 制造业/服务业
|
||||||
|
- 每个行业:KPI库/OKR模板/科目模板/预警规则 行业包(已有基础)
|
||||||
|
- 案例链:酣客成功 → 同行业客户 → 跨行业
|
||||||
|
|
||||||
|
### C. 数据资产化
|
||||||
|
- 博海自身资产:知识库/文章/系统数据 → 数据产品
|
||||||
|
- 客户侧:DAMA 数据治理服务(官网已上线)→ 治理→增值
|
||||||
|
- 企业数据资产盘点方法论 = 可售服务
|
||||||
|
|
||||||
|
### D. 决策智能升级(北极星④的深化)
|
||||||
|
- 从"提建议"→"预测决策":敏感性/情景模拟(已雏形)→ 完整预测性成本智能(IMA)
|
||||||
|
- 因果链验证(已建)→ 决策优化建议
|
||||||
|
- AI 复盘 → 组织学习闭环
|
||||||
|
|
||||||
|
### E. AI 原生组织方法论(差异化护城河)
|
||||||
|
- 博海自身 = AI 原生组织样板(14Bot/铁律/闭环)
|
||||||
|
- 对外输出"AI 原生组织落地方法论"(评估+实施+运营)
|
||||||
|
- 这是竞品(传统财务软件商)无法快速复制的能力
|
||||||
|
|
||||||
|
### F. 生态与联盟
|
||||||
|
- 财务软件生态:对接用友/金蝶/管家婆(当前**未接**,北极星①的关键缺口)
|
||||||
|
- 渠道伙伴:代账公司/咨询公司/本地IT服务商
|
||||||
|
- 区域深耕:陕西本地企业数字化
|
||||||
|
|
||||||
|
### G. 信任与合规(销售必要条件)
|
||||||
|
- 数据安全:PIPL/等保/数据分类分级
|
||||||
|
- AI 可信:RAG幻觉治理(Recall 100%已证)+ 验证铁律 = 可信AI叙事
|
||||||
|
- 审计链:操作日志/决策留痕(已有)
|
||||||
|
|
||||||
|
### H. 技术前瞻
|
||||||
|
- 数字员工:AI 同事(分域扫描已发现 CopilotKit/OpenBot 趋势)
|
||||||
|
- Agent 自动执行:决策→自动调度 Bot 执行(北极星②④融合)
|
||||||
|
- 预测智能:KPI趋势/宏观敏感性(已上线 MVP)→ 完整版
|
||||||
|
|
||||||
|
## 三、战略优先级与时间线
|
||||||
|
|
||||||
|
| 阶段 | 方向 | 关键动作 |
|
||||||
|
|:--|:--|:--|
|
||||||
|
| **近期(1-3月)** | A产品化 + D决策智能 + 北极星① | 财务软件接口调研;AI建议→行动一键落地;酣客试点成案例 |
|
||||||
|
| **中期(3-6月)** | B行业复制 + G合规 | 白酒经销行业包完善;数据安全/等保认证;第2-3个客户 |
|
||||||
|
| **远期(6-12月)** | E AI原生组织 + F生态 + H前瞻 | AI原生组织方法论产品化;渠道伙伴;数字员工/AI同事产品 |
|
||||||
|
|
||||||
|
## 四、北极星落地路线(四层 → 实施项)
|
||||||
|
|
||||||
|
| 层 | 当前状态 | 下一步 |
|
||||||
|
|:--|:--|:--|
|
||||||
|
| ① 数据接入 | 手工/Excel/Bot | **财务软件接口**(用友/金蝶/管家婆适配器) |
|
||||||
|
| ② 多Bot互动 | A2A/bot_bridge已有 | Bot联合决策(同数据多Bot出结论) |
|
||||||
|
| ③ 数据找人 | 预警/偏差/预测告警 | 主动推送扩大(机会+趋势,不止异常) |
|
||||||
|
| ④ 决策闭环 | 复盘持久化刚补 | **AI建议→一键落地**(建议落到KPI/预算/行动)+审计 |
|
||||||
|
|
||||||
|
## 五、关键洞察
|
||||||
|
1. **护城河不是 CMA 功能,是"AI原生组织方法论"**(E)——我们自己在用的整套体系(Bot协作/铁律/闭环/验证)就是最强差异化产品
|
||||||
|
2. **产品化(A)是其他一切的前提**——案例→行业包→方法论→生态
|
||||||
|
3. **北极星①(财务软件接口)是技术最大缺口**——直接影响"数据接入"自动化
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# R3 财务软件接口调研(智享通·友加畅捷)2026-08-30 结论
|
||||||
|
|
||||||
|
> 酣客实际财务软件:**智享通**(成都友加畅捷,youjiasoft.com)
|
||||||
|
|
||||||
|
## 一、产品画像
|
||||||
|
- 公司:成都友加畅捷科技有限公司
|
||||||
|
- 产品线:U+通用财务 / T1飞跃(进销存+财务)/ U+分销 / U+云商(SaaS) / U+移动
|
||||||
|
- **架构:本地 C/S 部署**(T1飞跃明确 C/S 架构)——非云 SaaS
|
||||||
|
- **开放 API:官网无开发者平台/公开API文档**(对比用友/金蝶有开放平台)
|
||||||
|
|
||||||
|
## 二、对接路径评估(3选1)
|
||||||
|
|
||||||
|
| 路径 | 说明 | 可行性 | 工作量 |
|
||||||
|
|:--|:--|:--|:--|
|
||||||
|
| **A. Excel导出→CMA导入** ✅推荐 | 智享通导出科目余额/凭证Excel → CMA现有导入(data import已建) | 🟢 **零开发可用** | 0.5天(模板映射) |
|
||||||
|
| B. 数据库直读 | 本地部署 DB(SQL Server等)直接读财务数据 | 🟡 需账套密码+技术配合 | 2-3天 |
|
||||||
|
| C. 官方API | 联系友加畅捷确认是否有接口 | 🟠 官网未见,需商务确认 | 不确定 |
|
||||||
|
|
||||||
|
## 三、推荐方案(北极星①落地路径)
|
||||||
|
**阶段一(立即)**:方案 A——财务每月导出科目余额/凭证 Excel → CMA 导入 → KPI 实际值自动更新
|
||||||
|
- CMA 已有 import-excel 能力(data.py/bot_bridge),只需**模板映射**(智享通导出列 → CMA 科目/KPI)
|
||||||
|
- 实现:建"智享通导出模板"映射表(科目编码→KPI/科目)+ 导入验证
|
||||||
|
|
||||||
|
**阶段二(可选)**:方案 B/C——财务数据量大了再评估直读/官方接口
|
||||||
|
|
||||||
|
## 四、影响
|
||||||
|
北极星①"财务软件接口"对智享通的现实路径 = **导出导入(半自动)** 而非 API 直连(软件无开放API)。这是中小财务软件的现实——CMA 以"导入适配器"覆盖,不依赖厂商 API。
|
||||||
|
|
||||||
|
## 五、待办
|
||||||
|
- [ ] 向酣客财务要一份**智享通导出样例**(科目余额/凭证 Excel 各一)
|
||||||
|
- [ ] 建映射模板(样例列→CMA 字段)
|
||||||
|
- [ ] 全栈实现"智享通导入适配器"(模板映射+校验)
|
||||||
@@ -465,4 +465,13 @@ export const taxApi = {
|
|||||||
seedDemo: (params?: any) => api.post('/tax/demo-data', null, { params }),
|
seedDemo: (params?: any) => api.post('/tax/demo-data', null, { params }),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── AI决策建议(路线图R1 2026-08-30):建议→一键应用到KPI/预算/行动方案 ──
|
||||||
|
export const aiSuggestionApi = {
|
||||||
|
list: (params?: any) => api.get('/ai/suggestions', { params }),
|
||||||
|
get: (id: number) => api.get(`/ai/suggestions/${id}`),
|
||||||
|
create: (data: any) => api.post('/ai/suggestions', data),
|
||||||
|
apply: (id: number, data: any) => api.post(`/ai/suggestions/${id}/apply`, data),
|
||||||
|
dismiss: (id: number) => api.post(`/ai/suggestions/${id}/dismiss`),
|
||||||
|
}
|
||||||
|
|
||||||
export default api
|
export default api
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ interface MenuItem {
|
|||||||
|
|
||||||
// ── 角色路由映射 ──
|
// ── 角色路由映射 ──
|
||||||
export const ROLE_ROUTES: Record<string, string[]> = {
|
export const ROLE_ROUTES: Record<string, string[]> = {
|
||||||
ceo: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'],
|
ceo: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'],
|
||||||
finance: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'],
|
finance: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'],
|
||||||
business: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/budget', '/deviations', '/action-plans', '/knowledge', '/guide', '/customer', '/expenses', '/cash-plan', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'],
|
business: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/alerts', '/budget', '/deviations', '/action-plans', '/knowledge', '/guide', '/customer', '/expenses', '/cash-plan', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'],
|
||||||
it: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/analysis-confidence', '/expenses', '/cash-plan', '/product-matrix', '/tax-compliance', '/data-classification'],
|
it: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/analysis-confidence', '/expenses', '/cash-plan', '/product-matrix', '/tax-compliance', '/data-classification'],
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ROLE_ACTIONS: Record<string, string[]> = {
|
export const ROLE_ACTIONS: Record<string, string[]> = {
|
||||||
@@ -42,6 +42,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
|||||||
|
|
||||||
// ── GROUP 3: 监控与评价(Check)──
|
// ── GROUP 3: 监控与评价(Check)──
|
||||||
{ path: '/dashboard', label: '经营看板', icon: 'DataBoard', roles: ['ceo', 'finance', 'business', 'it'], group: '🟡 C 监控与评价' },
|
{ path: '/dashboard', label: '经营看板', icon: 'DataBoard', roles: ['ceo', 'finance', 'business', 'it'], group: '🟡 C 监控与评价' },
|
||||||
|
{ path: '/ai-suggestions', label: 'AI建议中心', icon: 'Opportunity', roles: ['ceo', 'finance', 'business', 'it'], group: '🟡 C 监控与评价' },
|
||||||
{ path: '/reports', label: '管理报表', icon: 'DataAnalysis', roles: ['ceo', 'finance', 'business'], group: '🟡 C 监控与评价' },
|
{ path: '/reports', label: '管理报表', icon: 'DataAnalysis', roles: ['ceo', 'finance', 'business'], group: '🟡 C 监控与评价' },
|
||||||
{ path: '/dupont-analysis', label: '杜邦分析', icon: 'TrendCharts', roles: ['ceo', 'finance', 'it'], group: '🟡 C 监控与评价' },
|
{ path: '/dupont-analysis', label: '杜邦分析', icon: 'TrendCharts', roles: ['ceo', 'finance', 'it'], group: '🟡 C 监控与评价' },
|
||||||
{ path: '/customer', label: '客户维度', icon: 'User', roles: ['ceo', 'finance', 'business', 'it'], group: '🟡 C 监控与评价' },
|
{ path: '/customer', label: '客户维度', icon: 'User', roles: ['ceo', 'finance', 'business', 'it'], group: '🟡 C 监控与评价' },
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const routes = [
|
|||||||
{ path: '/', component: () => import('@/layouts/MainLayout.vue'), redirect: '/my-dashboard',
|
{ path: '/', component: () => import('@/layouts/MainLayout.vue'), redirect: '/my-dashboard',
|
||||||
children: [
|
children: [
|
||||||
{ path: 'dashboard', name: 'Dashboard', component: () => import('@/views/Dashboard.vue'), meta: { title: '经营看板', roles: ['ceo', 'finance', 'business', 'it'] } },
|
{ path: 'dashboard', name: 'Dashboard', component: () => import('@/views/Dashboard.vue'), meta: { title: '经营看板', roles: ['ceo', 'finance', 'business', 'it'] } },
|
||||||
|
{ path: 'ai-suggestions', name: 'SuggestionCenter', component: () => import('@/views/SuggestionCenter.vue'), meta: { title: 'AI建议中心', roles: ['ceo', 'finance', 'business', 'it'] } },
|
||||||
{ path: 'kpis', name: 'KPIs', component: () => import('@/views/KPIList.vue'), meta: { title: 'KPI字典', roles: ['ceo', 'finance', 'business', 'it'], editable: true } },
|
{ path: 'kpis', name: 'KPIs', component: () => import('@/views/KPIList.vue'), meta: { title: 'KPI字典', roles: ['ceo', 'finance', 'business', 'it'], editable: true } },
|
||||||
{ path: 'kpis/:id', name: 'KPIDetail', component: () => import('@/views/KPIDetail.vue'), meta: { title: 'KPI详情', roles: ['ceo', 'finance', 'business', 'it'], editable: true } },
|
{ path: 'kpis/:id', name: 'KPIDetail', component: () => import('@/views/KPIDetail.vue'), meta: { title: 'KPI详情', roles: ['ceo', 'finance', 'business', 'it'], editable: true } },
|
||||||
{ path: 'maps', name: 'Maps', component: () => import('@/views/MapList.vue'), meta: { title: '战略地图', roles: ['ceo', 'finance'], editable: true } },
|
{ path: 'maps', name: 'Maps', component: () => import('@/views/MapList.vue'), meta: { title: '战略地图', roles: ['ceo', 'finance'], editable: true } },
|
||||||
|
|||||||
@@ -205,16 +205,86 @@
|
|||||||
</el-card>
|
</el-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- AI分析浮窗 -->
|
<!-- AI分析浮窗(R1:AI建议→一键落地) -->
|
||||||
<div v-if="showSidebar" class="ai-panel">
|
<div v-if="showSidebar" class="ai-panel">
|
||||||
<div class="ai-head"><span>🤖 AI 分析</span><el-button text size="small" @click="showSidebar = false">✕</el-button></div>
|
<div class="ai-head">
|
||||||
|
<span>🤖 AI 分析</span>
|
||||||
|
<el-button text size="small" @click="router.push('/ai-suggestions')">建议中心</el-button>
|
||||||
|
<el-button text size="small" @click="showSidebar = false">✕</el-button>
|
||||||
|
</div>
|
||||||
<div class="ai-body">
|
<div class="ai-body">
|
||||||
|
<div class="ai-section-title">📋 决策建议</div>
|
||||||
|
<div v-if="suggestionsLoading" class="ai-loading"><el-icon class="is-loading" :size="16"><Loading /></el-icon><p>加载建议...</p></div>
|
||||||
|
<div v-else-if="suggestions.length === 0" class="ai-empty">暂无待应用建议</div>
|
||||||
|
<div v-else class="sug-list">
|
||||||
|
<div v-for="s in suggestions" :key="s.id" class="sug-card" :class="'type-' + s.suggestion_type">
|
||||||
|
<div class="sug-top">
|
||||||
|
<el-tag size="small" :type="sugTagType(s.suggestion_type)">{{ sugTypeLabel(s.suggestion_type) }}</el-tag>
|
||||||
|
<el-tag v-if="s.status === 'applied'" size="small" type="success">已应用</el-tag>
|
||||||
|
<el-tag v-else size="small" type="info">未应用</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="sug-title">{{ s.title }}</div>
|
||||||
|
<div class="sug-content">{{ s.content }}</div>
|
||||||
|
<div class="sug-foot">
|
||||||
|
<el-button v-if="s.status === 'unapplied'" size="small" type="primary" @click="openApplyDialog(s)">应用到</el-button>
|
||||||
|
<el-button v-if="s.status === 'unapplied'" size="small" @click="dismissSuggestion(s)">忽略</el-button>
|
||||||
|
<span v-if="s.status === 'applied'" class="sug-applied-by">由 {{ s.applied_by }} 于 {{ (s.applied_at || '').slice(0, 16) }} 应用</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-divider />
|
||||||
|
<div class="ai-section-title">💡 AI分析文本</div>
|
||||||
<div v-if="aiLoading" class="ai-loading"><el-icon class="is-loading" :size="20"><Loading /></el-icon><p>分析中...</p></div>
|
<div v-if="aiLoading" class="ai-loading"><el-icon class="is-loading" :size="20"><Loading /></el-icon><p>分析中...</p></div>
|
||||||
<div v-else-if="aiAnalysis" class="ai-content" v-html="renderMd(aiAnalysis)"></div>
|
<div v-else-if="aiAnalysis" class="ai-content" v-html="renderMd(aiAnalysis)"></div>
|
||||||
<el-empty v-else description="暂无分析" />
|
<el-empty v-else description="暂无分析" />
|
||||||
</div>
|
</div>
|
||||||
<div class="ai-foot"><el-button size="small" type="primary" @click="refreshAnalysis" :loading="aiLoading">刷新</el-button></div>
|
<div class="ai-foot">
|
||||||
|
<el-button size="small" type="primary" @click="refreshAnalysis" :loading="aiLoading">刷新分析</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 应用建议弹窗 -->
|
||||||
|
<el-dialog v-model="showApplyDialog" :title="'应用到:' + (applySug?.title || '')" width="520px">
|
||||||
|
<el-form label-width="100px">
|
||||||
|
<template v-if="applySug?.suggestion_type === 'kpi_target'">
|
||||||
|
<el-form-item label="KPI"><el-input :model-value="applyKpiName" disabled /></el-form-item>
|
||||||
|
<el-form-item label="新目标值" required>
|
||||||
|
<el-input-number v-model="applyForm.target_value" :precision="2" style="width:220px" />
|
||||||
|
</el-form-item>
|
||||||
|
<div class="apply-tip">应用后将修改KPI目标值,并写入操作日志留痕。</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="applySug?.suggestion_type === 'budget_adjust'">
|
||||||
|
<el-form-item label="KPI"><el-input :model-value="applyKpiName" disabled /></el-form-item>
|
||||||
|
<el-form-item label="期间" required>
|
||||||
|
<el-input v-model="applyForm.period" placeholder="2026-09" style="width:220px" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="预算值" required>
|
||||||
|
<el-input-number v-model="applyForm.budget_value" :precision="2" style="width:220px" />
|
||||||
|
</el-form-item>
|
||||||
|
<div class="apply-tip">应用后将新增/更新该KPI对应期间的预算,并写入操作日志留痕。</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<el-form-item label="关联KPI"><el-input :model-value="applyKpiName" disabled /></el-form-item>
|
||||||
|
<el-form-item label="计划标题" required><el-input v-model="applyForm.title" /></el-form-item>
|
||||||
|
<el-form-item label="负责人"><el-input v-model="applyForm.assignee" /></el-form-item>
|
||||||
|
<el-form-item label="优先级">
|
||||||
|
<el-select v-model="applyForm.priority" style="width:220px">
|
||||||
|
<el-option label="高" value="high" />
|
||||||
|
<el-option label="中" value="medium" />
|
||||||
|
<el-option label="低" value="low" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="截止日期">
|
||||||
|
<el-date-picker v-model="applyForm.due_date" type="date" value-format="YYYY-MM-DD" style="width:220px" />
|
||||||
|
</el-form-item>
|
||||||
|
<div class="apply-tip">应用后将创建行动方案,并写入操作日志留痕。</div>
|
||||||
|
</template>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showApplyDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="applying" @click="submitApply">确认应用</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<!-- KPI详情弹窗 -->
|
<!-- KPI详情弹窗 -->
|
||||||
<el-dialog v-model="showDetail" :title="selectedKPI?.kpi_name" width="500px">
|
<el-dialog v-model="showDetail" :title="selectedKPI?.kpi_name" width="500px">
|
||||||
@@ -241,7 +311,7 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { Loading, ArrowRight } from '@element-plus/icons-vue'
|
import { Loading, ArrowRight } from '@element-plus/icons-vue'
|
||||||
import { dashboardApi, alertApi } from '../api/index'
|
import { dashboardApi, alertApi, aiSuggestionApi } from '../api/index'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import CountUp from '../components/CountUp.vue'
|
import CountUp from '../components/CountUp.vue'
|
||||||
import GrowthQuality from '../components/GrowthQuality.vue'
|
import GrowthQuality from '../components/GrowthQuality.vue'
|
||||||
@@ -299,6 +369,81 @@ const enabledChannels = ref(0)
|
|||||||
const aiAnalysis = ref('')
|
const aiAnalysis = ref('')
|
||||||
const aiLoading = ref(false)
|
const aiLoading = ref(false)
|
||||||
|
|
||||||
|
// R1: AI决策建议(建议卡 + 应用到弹窗)
|
||||||
|
const suggestions = ref<any[]>([])
|
||||||
|
const suggestionsLoading = ref(false)
|
||||||
|
const showApplyDialog = ref(false)
|
||||||
|
const applySug = ref<any>(null)
|
||||||
|
const applyForm = ref<any>({})
|
||||||
|
const applying = ref(false)
|
||||||
|
const applyKpiName = ref('')
|
||||||
|
|
||||||
|
const sugTypeLabel = (t: string) => ({ kpi_target: '改KPI目标', budget_adjust: '调预算', action_plan: '建行动方案' }[t] || t)
|
||||||
|
const sugTagType = (t: string) => ({ kpi_target: 'warning', budget_adjust: 'danger', action_plan: 'primary' }[t] || 'info')
|
||||||
|
|
||||||
|
async function loadSuggestions() {
|
||||||
|
suggestionsLoading.value = true
|
||||||
|
try {
|
||||||
|
const r: any = await aiSuggestionApi.list({ status: 'unapplied' })
|
||||||
|
suggestions.value = (r as any)?.data || []
|
||||||
|
} catch { suggestions.value = [] }
|
||||||
|
suggestionsLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function openApplyDialog(s: any) {
|
||||||
|
applySug.value = s
|
||||||
|
const sd = s.suggestion_data || {}
|
||||||
|
applyForm.value = {
|
||||||
|
target_value: sd.target_value ?? null,
|
||||||
|
period: sd.period || '',
|
||||||
|
budget_value: sd.budget_value ?? null,
|
||||||
|
title: sd.title || '',
|
||||||
|
assignee: sd.assignee || '',
|
||||||
|
priority: sd.priority || 'medium',
|
||||||
|
due_date: sd.due_date || '',
|
||||||
|
}
|
||||||
|
applyKpiName.value = s.target_name || ''
|
||||||
|
showApplyDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitApply() {
|
||||||
|
if (!applySug.value) return
|
||||||
|
const action = applySug.value.suggestion_type
|
||||||
|
const body: any = { action, kpi_id: applySug.value.target_id || applySug.value.suggestion_data?.kpi_id }
|
||||||
|
if (action === 'kpi_target') {
|
||||||
|
if (applyForm.value.target_value == null) { ElMessage.warning('请输入新目标值'); return }
|
||||||
|
body.target_value = applyForm.value.target_value
|
||||||
|
} else if (action === 'budget_adjust') {
|
||||||
|
if (!applyForm.value.period || applyForm.value.budget_value == null) { ElMessage.warning('请填写期间和预算值'); return }
|
||||||
|
body.period = applyForm.value.period
|
||||||
|
body.budget_value = applyForm.value.budget_value
|
||||||
|
} else {
|
||||||
|
if (!applyForm.value.title) { ElMessage.warning('请输入计划标题'); return }
|
||||||
|
body.title = applyForm.value.title
|
||||||
|
body.assignee = applyForm.value.assignee
|
||||||
|
body.priority = applyForm.value.priority
|
||||||
|
body.due_date = applyForm.value.due_date
|
||||||
|
}
|
||||||
|
applying.value = true
|
||||||
|
try {
|
||||||
|
await aiSuggestionApi.apply(applySug.value.id, body)
|
||||||
|
ElMessage.success('建议已应用并留痕')
|
||||||
|
showApplyDialog.value = false
|
||||||
|
await loadSuggestions()
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.detail || '应用失败')
|
||||||
|
}
|
||||||
|
applying.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dismissSuggestion(s: any) {
|
||||||
|
try {
|
||||||
|
await aiSuggestionApi.dismiss(s.id)
|
||||||
|
ElMessage.success('建议已忽略')
|
||||||
|
await loadSuggestions()
|
||||||
|
} catch { ElMessage.error('忽略失败') }
|
||||||
|
}
|
||||||
|
|
||||||
const aiApi = axios.create({ baseURL: '/api/cma', timeout: 30000 })
|
const aiApi = axios.create({ baseURL: '/api/cma', timeout: 30000 })
|
||||||
aiApi.interceptors.request.use((config: any) => {
|
aiApi.interceptors.request.use((config: any) => {
|
||||||
const token = localStorage.getItem('cma_token')
|
const token = localStorage.getItem('cma_token')
|
||||||
@@ -376,8 +521,8 @@ async function loadKPIAnalysis() {
|
|||||||
if (!selectedKPI.value?.id) return
|
if (!selectedKPI.value?.id) return
|
||||||
loadingDetail.value = true
|
loadingDetail.value = true
|
||||||
try {
|
try {
|
||||||
const r: any = await aiApi.post('/ai/kpi-analysis', { kpi_id: selectedKPI.value.id, period: periodType.value })
|
const r: any = await aiApi.get(`/ai/kpi-analysis/${selectedKPI.value.id}`)
|
||||||
selectedKPIDetail.value = r.data || r.analysis || '暂无分析结果'
|
selectedKPIDetail.value = r.data?.analysis || r.analysis || '暂无分析结果'
|
||||||
} catch { selectedKPIDetail.value = 'AI分析请求失败' }
|
} catch { selectedKPIDetail.value = 'AI分析请求失败' }
|
||||||
loadingDetail.value = false
|
loadingDetail.value = false
|
||||||
}
|
}
|
||||||
@@ -385,8 +530,10 @@ async function loadKPIAnalysis() {
|
|||||||
async function refreshAnalysis() {
|
async function refreshAnalysis() {
|
||||||
aiLoading.value = true
|
aiLoading.value = true
|
||||||
try {
|
try {
|
||||||
const r: any = await aiApi.post('/ai/dashboard-analysis', { role: userRole.value, period: periodType.value })
|
const r: any = await aiApi.get('/ai/dashboard-analysis', { params: { role: userRole.value } })
|
||||||
aiAnalysis.value = r.data || r.analysis || '暂无分析'
|
aiAnalysis.value = r.data?.analysis || r.analysis || '暂无分析'
|
||||||
|
// 建议列表从服务端拉取(含dashboard分析自动生成的规则建议)
|
||||||
|
await loadSuggestions()
|
||||||
} catch { aiAnalysis.value = 'AI分析暂时不可用' }
|
} catch { aiAnalysis.value = 'AI分析暂时不可用' }
|
||||||
aiLoading.value = false
|
aiLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -555,6 +702,21 @@ onMounted(() => { loadData() })
|
|||||||
.ai-foot { padding:10px 16px; border-top:1px solid #f0f0f0; }
|
.ai-foot { padding:10px 16px; border-top:1px solid #f0f0f0; }
|
||||||
.ai-loading { text-align:center; padding:30px 0; color:#999; }
|
.ai-loading { text-align:center; padding:30px 0; color:#999; }
|
||||||
|
|
||||||
|
/* R1: 建议卡 */
|
||||||
|
.ai-section-title { font-size:13px; font-weight:600; color:#333; margin-bottom:10px; }
|
||||||
|
.ai-empty { text-align:center; color:#bbb; padding:16px 0; font-size:12px; }
|
||||||
|
.sug-list { display:flex; flex-direction:column; gap:10px; }
|
||||||
|
.sug-card { border:1px solid #eee; border-radius:8px; padding:10px 12px; background:#fafafa; border-left:3px solid #909399; }
|
||||||
|
.sug-card.type-kpi_target { border-left-color:#e6a23c; }
|
||||||
|
.sug-card.type-budget_adjust { border-left-color:#f56c6c; }
|
||||||
|
.sug-card.type-action_plan { border-left-color:#409eff; }
|
||||||
|
.sug-top { display:flex; gap:6px; margin-bottom:6px; }
|
||||||
|
.sug-title { font-size:13px; font-weight:600; color:#333; margin-bottom:4px; }
|
||||||
|
.sug-content { font-size:12px; color:#666; line-height:1.5; margin-bottom:8px; }
|
||||||
|
.sug-foot { display:flex; gap:6px; align-items:center; }
|
||||||
|
.sug-applied-by { font-size:11px; color:#999; }
|
||||||
|
.apply-tip { font-size:12px; color:#999; padding:0 0 10px 100px; }
|
||||||
|
|
||||||
/* KPI详情弹窗 */
|
/* KPI详情弹窗 */
|
||||||
.dlg-summary { display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; }
|
.dlg-summary { display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; }
|
||||||
.kpi-bar.large { height:10px; background:#f0f0f0; border-radius:5px; overflow:hidden; }
|
.kpi-bar.large { height:10px; background:#f0f0f0; border-radius:5px; overflow:hidden; }
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
<template>
|
||||||
|
<div class="sug-center-page">
|
||||||
|
<div class="page-head">
|
||||||
|
<h3>🤖 AI决策建议中心</h3>
|
||||||
|
<div class="head-right">
|
||||||
|
<el-radio-group v-model="statusFilter" size="small" @change="loadList">
|
||||||
|
<el-radio-button value="">全部</el-radio-button>
|
||||||
|
<el-radio-button value="unapplied">未应用</el-radio-button>
|
||||||
|
<el-radio-button value="applied">已应用</el-radio-button>
|
||||||
|
<el-radio-button value="dismissed">已忽略</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
<el-button size="small" type="primary" @click="loadList" :loading="loading">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sug-grid">
|
||||||
|
<el-empty v-if="!loading && items.length === 0" description="暂无建议" />
|
||||||
|
<div v-for="s in items" :key="s.id" class="sug-card" :class="['type-' + s.suggestion_type, s.status]">
|
||||||
|
<div class="sug-top">
|
||||||
|
<el-tag size="small" :type="sugTagType(s.suggestion_type)">{{ sugTypeLabel(s.suggestion_type) }}</el-tag>
|
||||||
|
<el-tag size="small" :type="statusTagType(s.status)">{{ statusLabel(s.status) }}</el-tag>
|
||||||
|
<span class="sug-source">来源: {{ sourceLabel(s.source) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="sug-title">{{ s.title }}</div>
|
||||||
|
<div class="sug-content">{{ s.content }}</div>
|
||||||
|
<div class="sug-meta">
|
||||||
|
<span>创建: {{ (s.created_at || '').slice(0, 16) }}</span>
|
||||||
|
<span v-if="s.status === 'applied'" class="applied-info">由 {{ s.applied_by }} 于 {{ (s.applied_at || '').slice(0, 16) }} 应用</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="s.status === 'applied' && s.apply_detail?.length" class="apply-detail">
|
||||||
|
<div v-for="(d, i) in s.apply_detail" :key="i" class="apply-detail-item">
|
||||||
|
<el-tag size="mini" type="info">{{ applyTargetLabel(d.target_type) }}</el-tag>
|
||||||
|
<span>{{ d.target_name }}</span>
|
||||||
|
<span class="before-after">改前: {{ fmtVal(d.before) }} → 改后: {{ fmtVal(d.after) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sug-foot">
|
||||||
|
<el-button v-if="s.status === 'unapplied'" size="small" type="primary" @click="openApplyDialog(s)">应用到</el-button>
|
||||||
|
<el-button v-if="s.status === 'unapplied'" size="small" @click="dismissSuggestion(s)">忽略</el-button>
|
||||||
|
<el-button size="small" @click="toggleDetail(s)">{{ s.showDetail ? '收起' : '参数详情' }}</el-button>
|
||||||
|
</div>
|
||||||
|
<pre v-if="s.showDetail" class="sug-json">{{ JSON.stringify(s.suggestion_data, null, 2) }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 应用建议弹窗 -->
|
||||||
|
<el-dialog v-model="showApplyDialog" :title="'应用到:' + (applySug?.title || '')" width="520px">
|
||||||
|
<el-form label-width="100px">
|
||||||
|
<template v-if="applySug?.suggestion_type === 'kpi_target'">
|
||||||
|
<el-form-item label="KPI ID"><el-input :model-value="applySug?.target_id" disabled /></el-form-item>
|
||||||
|
<el-form-item label="新目标值" required>
|
||||||
|
<el-input-number v-model="applyForm.target_value" :precision="2" style="width:220px" />
|
||||||
|
</el-form-item>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="applySug?.suggestion_type === 'budget_adjust'">
|
||||||
|
<el-form-item label="KPI ID"><el-input :model-value="applySug?.target_id" disabled /></el-form-item>
|
||||||
|
<el-form-item label="期间" required>
|
||||||
|
<el-input v-model="applyForm.period" placeholder="2026-09" style="width:220px" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="预算值" required>
|
||||||
|
<el-input-number v-model="applyForm.budget_value" :precision="2" style="width:220px" />
|
||||||
|
</el-form-item>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<el-form-item label="KPI ID"><el-input :model-value="applySug?.target_id" disabled /></el-form-item>
|
||||||
|
<el-form-item label="计划标题" required><el-input v-model="applyForm.title" /></el-form-item>
|
||||||
|
<el-form-item label="负责人"><el-input v-model="applyForm.assignee" /></el-form-item>
|
||||||
|
<el-form-item label="优先级">
|
||||||
|
<el-select v-model="applyForm.priority" style="width:220px">
|
||||||
|
<el-option label="高" value="high" />
|
||||||
|
<el-option label="中" value="medium" />
|
||||||
|
<el-option label="低" value="low" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="截止日期">
|
||||||
|
<el-date-picker v-model="applyForm.due_date" type="date" value-format="YYYY-MM-DD" style="width:220px" />
|
||||||
|
</el-form-item>
|
||||||
|
</template>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showApplyDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="applying" @click="submitApply">确认应用</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { aiSuggestionApi } from '../api/index'
|
||||||
|
|
||||||
|
const statusFilter = ref('')
|
||||||
|
const items = ref<any[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const showApplyDialog = ref(false)
|
||||||
|
const applySug = ref<any>(null)
|
||||||
|
const applyForm = ref<any>({})
|
||||||
|
const applying = ref(false)
|
||||||
|
|
||||||
|
const sugTypeLabel = (t: string) => ({ kpi_target: '改KPI目标', budget_adjust: '调预算', action_plan: '建行动方案' }[t] || t)
|
||||||
|
const sugTagType = (t: string) => ({ kpi_target: 'warning', budget_adjust: 'danger', action_plan: 'primary' }[t] || 'info')
|
||||||
|
const statusLabel = (s: string) => ({ unapplied: '未应用', applied: '已应用', dismissed: '已忽略' }[s] || s)
|
||||||
|
const statusTagType = (s: string) => ({ unapplied: 'info', applied: 'success', dismissed: 'info' }[s] || 'info')
|
||||||
|
const sourceLabel = (s: string) => ({ dashboard: '看板分析', kpi: 'KPI分析', budget: '预算分析', manual: '手动', rule: '规则' }[s] || s)
|
||||||
|
const applyTargetLabel = (t: string) => ({ kpi: 'KPI', budget: '预算', action_plan: '行动方案', alert: '预警' }[t] || t)
|
||||||
|
|
||||||
|
function fmtVal(v: any): string {
|
||||||
|
if (v == null) return '-'
|
||||||
|
return Number.isInteger(v) ? String(v) : Number(v).toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadList() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params: any = {}
|
||||||
|
if (statusFilter.value) params.status = statusFilter.value
|
||||||
|
const r: any = await aiSuggestionApi.list(params)
|
||||||
|
items.value = ((r as any)?.data || []).map((s: any) => ({ ...s, showDetail: false }))
|
||||||
|
} catch { items.value = [] }
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDetail(s: any) { s.showDetail = !s.showDetail }
|
||||||
|
|
||||||
|
function openApplyDialog(s: any) {
|
||||||
|
applySug.value = s
|
||||||
|
const sd = s.suggestion_data || {}
|
||||||
|
applyForm.value = {
|
||||||
|
target_value: sd.target_value ?? null,
|
||||||
|
period: sd.period || '',
|
||||||
|
budget_value: sd.budget_value ?? null,
|
||||||
|
title: sd.title || '',
|
||||||
|
assignee: sd.assignee || '',
|
||||||
|
priority: sd.priority || 'medium',
|
||||||
|
due_date: sd.due_date || '',
|
||||||
|
}
|
||||||
|
showApplyDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitApply() {
|
||||||
|
if (!applySug.value) return
|
||||||
|
const action = applySug.value.suggestion_type
|
||||||
|
const body: any = { action, kpi_id: applySug.value.target_id || applySug.value.suggestion_data?.kpi_id }
|
||||||
|
if (action === 'kpi_target') {
|
||||||
|
if (applyForm.value.target_value == null) { ElMessage.warning('请输入新目标值'); return }
|
||||||
|
body.target_value = applyForm.value.target_value
|
||||||
|
} else if (action === 'budget_adjust') {
|
||||||
|
if (!applyForm.value.period || applyForm.value.budget_value == null) { ElMessage.warning('请填写期间和预算值'); return }
|
||||||
|
body.period = applyForm.value.period
|
||||||
|
body.budget_value = applyForm.value.budget_value
|
||||||
|
} else {
|
||||||
|
if (!applyForm.value.title) { ElMessage.warning('请输入计划标题'); return }
|
||||||
|
body.title = applyForm.value.title
|
||||||
|
body.assignee = applyForm.value.assignee
|
||||||
|
body.priority = applyForm.value.priority
|
||||||
|
body.due_date = applyForm.value.due_date
|
||||||
|
}
|
||||||
|
applying.value = true
|
||||||
|
try {
|
||||||
|
await aiSuggestionApi.apply(applySug.value.id, body)
|
||||||
|
ElMessage.success('建议已应用并留痕')
|
||||||
|
showApplyDialog.value = false
|
||||||
|
await loadList()
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.detail || '应用失败')
|
||||||
|
}
|
||||||
|
applying.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dismissSuggestion(s: any) {
|
||||||
|
try {
|
||||||
|
await aiSuggestionApi.dismiss(s.id)
|
||||||
|
ElMessage.success('建议已忽略')
|
||||||
|
await loadList()
|
||||||
|
} catch { ElMessage.error('忽略失败') }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadList)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sug-center-page { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
||||||
|
.page-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||||
|
.head-right { display: flex; gap: 12px; align-items: center; }
|
||||||
|
.sug-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 14px; }
|
||||||
|
.sug-card { background: #fff; border-radius: 10px; padding: 14px 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.05); border: 1px solid #f0f0f0; border-left: 4px solid #909399; }
|
||||||
|
.sug-card.type-kpi_target { border-left-color: #e6a23c; }
|
||||||
|
.sug-card.type-budget_adjust { border-left-color: #f56c6c; }
|
||||||
|
.sug-card.type-action_plan { border-left-color: #409eff; }
|
||||||
|
.sug-card.applied { background: #f8fbf8; }
|
||||||
|
.sug-card.dismissed { opacity: .6; }
|
||||||
|
.sug-top { display: flex; gap: 6px; align-items: center; margin-bottom: 8px; }
|
||||||
|
.sug-source { font-size: 11px; color: #aaa; margin-left: auto; }
|
||||||
|
.sug-title { font-size: 14px; font-weight: 600; color: #333; margin-bottom: 6px; }
|
||||||
|
.sug-content { font-size: 13px; color: #666; line-height: 1.6; margin-bottom: 10px; }
|
||||||
|
.sug-meta { font-size: 11px; color: #aaa; margin-bottom: 8px; display: flex; gap: 14px; }
|
||||||
|
.applied-info { color: #67c23a; }
|
||||||
|
.apply-detail { background: #f5f7fa; border-radius: 6px; padding: 8px 10px; margin-bottom: 8px; font-size: 12px; color: #555; display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.apply-detail-item { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.before-after { color: #888; }
|
||||||
|
.sug-foot { display: flex; gap: 6px; }
|
||||||
|
.sug-json { background: #f8f8f8; border-radius: 6px; padding: 8px; font-size: 11px; color: #666; margin-top: 8px; max-height: 160px; overflow: auto; }
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user