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:
@@ -82,9 +82,12 @@ import hashlib
|
||||
@pytest.fixture(autouse=True)
|
||||
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)
|
||||
yield
|
||||
Base.metadata.drop_all(bind=TEST_ENGINE)
|
||||
cache_util.delete("ai")
|
||||
|
||||
|
||||
@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)
|
||||
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),
|
||||
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),
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user