""" 路线图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