test: CMA自动化测试补覆盖 226→452用例, 覆盖率36%→60%

- 新增8个测试文件(bot_bridge/kpi_causality/cash/predict/reports/tax_compliance/expenses/probe_cost)
- 增强 budget/auth/users + conftest账套模式适配
- 测试驱动修复: bot_bridge导入batch_id→source_batch; cash_forecast extra空dict
- 全量: 451 passed, 1 xfailed; 报告 docs/cma-test-coverage-report.md
This commit is contained in:
Hermes CI Fix
2026-08-20 06:57:24 +08:00
parent dd53212bcc
commit a13a080381
15 changed files with 3275 additions and 6 deletions
+377 -1
View File
@@ -1,8 +1,9 @@
"""预算管理模块测试 — 预算计划CRUD + 自动分解 + 版本"""
"""预算管理模块测试 — 预算计划CRUD + 自动分解 + 版本 + 偏差/对比/配置/滚动"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from tests.conftest import create_test_user, get_token_for_user, auth_header, create_test_kpi
from app.models.budget_plan import BudgetPlan
class TestBudgetPlans:
@@ -192,3 +193,378 @@ class TestBudgetVersions:
json={"version": "v2.0", "action": "rejected"},
)
assert resp.status_code == 404, "versions/approve端点已移除"
class TestBudgetDeviationReport:
"""偏差报告(实际 vs 预算汇总)"""
BASE = "/api/cma/budget"
def _seed(self, db: Session):
kpi = create_test_kpi(db, kpi_code="BUDGET_DEV_KPI")
db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=100.0,
budget_year=2026, budget_month=6, status="active"))
from app.models import KPIValue
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=120.0))
db.commit()
return kpi
def test_deviation_report_over_budget(self, client: TestClient, db: Session):
"""实际超出预算 → 超支统计"""
create_test_user(db)
token = get_token_for_user(client)
self._seed(db)
resp = client.get(f"{self.BASE}/deviation-report?year=2026&month=6",
headers=auth_header(token))
assert resp.status_code == 200
data = resp.json()
assert data["period"] == "2026-06"
assert data["summary"]["total_kpis"] == 1
assert data["summary"]["has_budget"] == 1
assert data["summary"]["over_budget"] == 1
# 120 vs 100 → +20%
assert data["items"][0]["deviation_rate"] == 20.0
def test_deviation_report_alert_level_filter(self, client: TestClient, db: Session):
"""按预警等级过滤(>20%红 / >10%黄)"""
create_test_user(db)
token = get_token_for_user(client)
self._seed(db)
resp = client.get(f"{self.BASE}/deviation-report?year=2026&month=6&alert_level=red",
headers=auth_header(token))
assert resp.status_code == 200
# summary统计全部KPIitems按alert_level过滤
assert resp.json()["summary"]["total_kpis"] == 1
assert len(resp.json()["items"]) == 0 # 20% 不是 >20,非red
resp2 = client.get(f"{self.BASE}/deviation-report?year=2026&month=6&alert_level=yellow",
headers=auth_header(token))
assert resp2.status_code == 200
assert len(resp2.json()["items"]) == 1
class TestBudgetConfig:
"""预算模式配置"""
BASE = "/api/cma/budget"
def test_default_config(self, client: TestClient, db: Session):
"""未配置时默认固定预算"""
create_test_user(db)
token = get_token_for_user(client)
resp = client.get(f"{self.BASE}/config", headers=auth_header(token))
assert resp.status_code == 200
assert resp.json()["budget_mode"] == "fixed"
assert resp.json()["rolling_months"] == 12
def test_set_config_rolling(self, client: TestClient, db: Session):
"""切换为滚动预算"""
create_test_user(db)
token = get_token_for_user(client)
resp = client.post(f"{self.BASE}/config", headers=auth_header(token),
json={"mode": "rolling", "rolling_months": 6})
assert resp.status_code == 200
assert resp.json()["budget_mode"] == "rolling"
get_resp = client.get(f"{self.BASE}/config", headers=auth_header(token))
cfg = get_resp.json()
# GET返回存的JSON {mode:...}(前端兼容 budget_mode || mode
assert (cfg.get("budget_mode") or cfg.get("mode")) == "rolling"
assert cfg.get("rolling_months") == 6
def test_set_config_invalid_mode(self, client: TestClient, db: Session):
"""非法模式 → 400"""
create_test_user(db)
token = get_token_for_user(client)
resp = client.post(f"{self.BASE}/config", headers=auth_header(token),
json={"mode": "weird"})
assert resp.status_code == 400
class TestBudgetRollForward:
"""滚动预算自动延展"""
BASE = "/api/cma/budget"
def test_roll_forward_requires_rolling_mode(self, client: TestClient, db: Session):
"""固定预算模式 → 400"""
create_test_user(db)
token = get_token_for_user(client)
resp = client.post(f"{self.BASE}/roll-forward", headers=auth_header(token))
assert resp.status_code == 400
assert "未配置" in resp.json()["detail"]
def test_roll_forward_success(self, client: TestClient, db: Session):
"""滚动模式延展:删除最早月 + 新增未来月"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="BUDGET_ROLL_KPI")
# 切滚动模式
client.post(f"{self.BASE}/config", headers=auth_header(token),
json={"mode": "rolling", "rolling_months": 12})
# 造12个月预算
from app.models import KPIValue
for m in range(1, 13):
db.add(BudgetPlan(kpi_id=kpi.id, period=f"2026-{m:02d}",
budget_value=1000.0 + m, budget_year=2026,
budget_month=m, status="active"))
db.commit()
resp = client.post(f"{self.BASE}/roll-forward", headers=auth_header(token))
assert resp.status_code == 200
data = resp.json()
assert "滚动预算已延展" in data["message"]
assert len(data["rolled_kpis"]) == 1
# 新增月份 = 当前月(08) + 12 = 明年08
assert data["rolled_kpis"][0]["added_period"].startswith("2027-")
# 最早月(2026-01)被删除
from app.models import BudgetPlan as BP
periods = [p.period for p in db.query(BP).filter(BP.kpi_id == kpi.id).all()]
assert "2026-01" not in periods
assert "2027-08" in periods
class TestBudgetComparison:
"""实际 vs 预测对比"""
BASE = "/api/cma/budget"
def _seed(self, db: Session):
kpi = create_test_kpi(db, kpi_code="BUDGET_CMP_KPI")
db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=100.0,
budget_year=2026, budget_month=6, status="active"))
from app.models import KPIValue
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=80.0))
db.commit()
return kpi
def test_comparison(self, client: TestClient, db: Session):
"""全KPI对比(固定预算12个月)"""
create_test_user(db)
token = get_token_for_user(client)
self._seed(db)
resp = client.get(f"{self.BASE}/comparison?year=2026", headers=auth_header(token))
assert resp.status_code == 200
data = resp.json()
assert data["budget_mode"] == "fixed"
assert len(data["periods"]) == 12
# 6月有预算+实际
june = [m for m in data["months_data"] if m["period"] == "2026-06"][0]
assert june["budget_total"] == 100.0
assert june["actual_total"] == 80.0
assert june["deviation_rate"] == -20.0
def test_comparison_kpi_detail(self, client: TestClient, db: Session):
"""单KPI对比"""
create_test_user(db)
token = get_token_for_user(client)
kpi = self._seed(db)
resp = client.get(f"{self.BASE}/comparison/kpi/{kpi.id}?year=2026",
headers=auth_header(token))
assert resp.status_code == 200
data = resp.json()
assert data["kpi_code"] == "BUDGET_CMP_KPI"
june = [d for d in data["data_points"] if d["period"] == "2026-06"][0]
assert june["budget_value"] == 100.0
assert june["actual_value"] == 80.0
assert june["deviation_rate"] == -20.0
def test_comparison_kpi_not_found(self, client: TestClient, db: Session):
"""KPI不存在 → 404"""
create_test_user(db)
token = get_token_for_user(client)
resp = client.get(f"{self.BASE}/comparison/kpi/99999?year=2026",
headers=auth_header(token))
assert resp.status_code == 404
class TestBudgetDeviationCheck:
"""预算偏差自动预警"""
BASE = "/api/cma/budget"
def _seed(self, db: Session):
kpi = create_test_kpi(db, kpi_code="BUDGET_ALERT_KPI")
db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=100.0,
budget_year=2026, budget_month=6, status="active"))
from app.models import KPIValue
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=150.0)) # +50%
db.commit()
return kpi
def test_deviation_check_generates_alert(self, client: TestClient, db: Session):
"""超阈值生成预警"""
create_test_user(db)
token = get_token_for_user(client)
kpi = self._seed(db)
resp = client.post(f"{self.BASE}/deviation-check", headers=auth_header(token),
json={"period": "2026-06", "threshold": 20})
assert resp.status_code == 200
data = resp.json()
assert data["alerts_generated"] == 1
assert data["alerts"][0]["deviation_rate"] == 50.0
# 50% 不 >50,为 warning>50% 才是 critical
assert data["alerts"][0]["alert_level"] == "warning"
def test_deviation_check_no_budget(self, client: TestClient, db: Session):
"""无预算数据 → 不生成预警"""
create_test_user(db)
token = get_token_for_user(client)
resp = client.post(f"{self.BASE}/deviation-check", headers=auth_header(token),
json={"period": "2026-01"})
assert resp.status_code == 200
assert resp.json()["alerts_generated"] == 0
def test_deviation_check_under_threshold(self, client: TestClient, db: Session):
"""未超阈值不生成预警"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="BUDGET_ALERT_OK")
db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=100.0,
budget_year=2026, budget_month=6, status="active"))
from app.models import KPIValue
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=105.0)) # +5%
db.commit()
resp = client.post(f"{self.BASE}/deviation-check", headers=auth_header(token),
json={"period": "2026-06", "threshold": 20})
assert resp.json()["alerts_generated"] == 0
class TestBudgetDeviationAlerts:
"""偏差预警记录查询/更新"""
BASE = "/api/cma/budget"
def _seed_alert(self, client, db: Session, token: str):
kpi = create_test_kpi(db, kpi_code="BUDGET_DEV_ALERT")
db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=100.0,
budget_year=2026, budget_month=6, status="active"))
from app.models import KPIValue
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=200.0))
db.commit()
client.post(f"{self.BASE}/deviation-check", headers=auth_header(token),
json={"period": "2026-06"})
return kpi
def test_list_alerts(self, client: TestClient, db: Session):
"""预警列表"""
create_test_user(db)
token = get_token_for_user(client)
self._seed_alert(client, db, token)
resp = client.get(f"{self.BASE}/deviation-alerts", headers=auth_header(token))
assert resp.status_code == 200
assert resp.json()["total"] == 1
alert = resp.json()["data"][0]
assert alert["status"] == "open"
assert alert["kpi_code"] == "BUDGET_DEV_ALERT"
assert alert["alert_level"] == "critical"
def test_list_alerts_filters(self, client: TestClient, db: Session):
"""按状态/等级过滤"""
create_test_user(db)
token = get_token_for_user(client)
self._seed_alert(client, db, token)
resp = client.get(f"{self.BASE}/deviation-alerts?status=open&alert_level=critical",
headers=auth_header(token))
assert resp.json()["total"] == 1
resp2 = client.get(f"{self.BASE}/deviation-alerts?status=resolved",
headers=auth_header(token))
assert resp2.json()["total"] == 0
def test_update_alert_resolve(self, client: TestClient, db: Session):
"""标记预警已解决"""
create_test_user(db)
token = get_token_for_user(client)
self._seed_alert(client, db, token)
alert_id = client.get(f"{self.BASE}/deviation-alerts",
headers=auth_header(token)).json()["data"][0]["id"]
resp = client.put(f"{self.BASE}/deviation-alerts/{alert_id}",
headers=auth_header(token), json={"status": "resolved"})
assert resp.status_code == 200
list_resp = client.get(f"{self.BASE}/deviation-alerts",
headers=auth_header(token))
assert list_resp.json()["data"][0]["status"] == "resolved"
def test_update_alert_not_found(self, client: TestClient, db: Session):
"""更新不存在的预警 → 404"""
create_test_user(db)
token = get_token_for_user(client)
resp = client.put(f"{self.BASE}/deviation-alerts/99999",
headers=auth_header(token), json={"status": "resolved"})
assert resp.status_code == 404
class TestBudgetMethodComparison:
"""预算方法三选一对比"""
BASE = "/api/cma/budget"
def test_method_comparison_defaults(self, client: TestClient, db: Session):
"""默认参数返回三种方法"""
create_test_user(db)
token = get_token_for_user(client)
resp = client.post(f"{self.BASE}/method-comparison", headers=auth_header(token),
json={})
assert resp.status_code == 200
data = resp.json()
assert len(data["methods"]) == 3
ids = {m["id"] for m in data["methods"]}
assert ids == {"incremental", "zero_based", "flexible"}
assert data["recommended"] == "zero_based"
def test_method_comparison_custom(self, client: TestClient, db: Session):
"""自定义参数:增量预算结果 = 上月×(1+增幅)"""
create_test_user(db)
token = get_token_for_user(client)
resp = client.post(f"{self.BASE}/method-comparison", headers=auth_header(token),
json={"entity": "bohai", "last_month_budget": 100,
"current_revenue": 200, "increment_rate": 0.1})
assert resp.status_code == 200
data = resp.json()
assert data["entity_name"] == "陕西博海科技(IT服务)"
incremental = [m for m in data["methods"] if m["id"] == "incremental"][0]
assert incremental["result_value"] == 110.0 # 100 × 1.1
class TestBudgetPermissions:
"""权限边界:business角色无访问权限"""
BASE = "/api/cma/budget"
def test_business_role_denied(self, client: TestClient, db: Session):
"""business用户访问预算 → 403"""
import hashlib
from app.models import User
business = User(
username="business_budget",
password_hash=hashlib.sha256("pass123".encode()).hexdigest(),
name="业务员",
role="business",
)
db.add(business)
db.commit()
token = get_token_for_user(client, username="business_budget", password="pass123")
resp = client.get(f"{self.BASE}/plans", headers=auth_header(token))
assert resp.status_code == 403