Files
cma-management/backend/tests/test_ai_suggestions.py
T
Hermes CI Fix ad68471b29 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
2026-08-30 12:05:36 +08:00

243 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
路线图R1AI建议→一键落地 测试
建议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