Files
cma-management/backend/tests/test_budget.py
T
Hermes CI Fix a13a080381 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
2026-08-20 06:57:24 +08:00

571 lines
22 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.
"""预算管理模块测试 — 预算计划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:
"""预算计划CRUD测试"""
BASE = "/api/cma/budget"
def test_list_plans_empty(self, client: TestClient, db: Session):
"""空列表"""
create_test_user(db)
token = get_token_for_user(client)
resp = client.get(f"{self.BASE}/plans", headers=auth_header(token))
assert resp.status_code == 200
assert resp.json()["data"] == []
def test_create_plan(self, client: TestClient, db: Session):
"""创建预算计划"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="BUDGET_KPI_01")
resp = client.post(
f"{self.BASE}/plans",
headers=auth_header(token),
json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 50000.0},
)
assert resp.status_code == 200
assert resp.json()["message"] == "预算已创建"
assert resp.json()["id"] > 0
def test_create_plan_duplicate_upsert(self, client: TestClient, db: Session):
"""重复创建同一KPI+期间 → 更新而非新增"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="BUDGET_KPI_02")
# 创建
client.post(
f"{self.BASE}/plans",
headers=auth_header(token),
json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 30000.0},
)
# 再次创建(更新)
resp = client.post(
f"{self.BASE}/plans",
headers=auth_header(token),
json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 35000.0},
)
assert resp.status_code == 200
assert resp.json()["message"] == "预算已更新"
def test_create_plan_missing_fields(self, client: TestClient, db: Session):
"""缺少必要参数被拒绝"""
create_test_user(db)
token = get_token_for_user(client)
resp = client.post(
f"{self.BASE}/plans",
headers=auth_header(token),
json={"kpi_id": 1}, # 缺少 period 和 budget_value
)
assert resp.status_code == 400
def test_update_plan(self, client: TestClient, db: Session):
"""更新预算计划"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="BUDGET_KPI_03")
create_resp = client.post(
f"{self.BASE}/plans",
headers=auth_header(token),
json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 100.0},
)
plan_id = create_resp.json()["id"]
resp = client.put(
f"{self.BASE}/plans/{plan_id}",
headers=auth_header(token),
json={"budget_value": 200.0, "remark": "已更新"},
)
assert resp.status_code == 200
def test_delete_plan(self, client: TestClient, db: Session):
"""删除预算计划"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="BUDGET_KPI_04")
create_resp = client.post(
f"{self.BASE}/plans",
headers=auth_header(token),
json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 100.0},
)
plan_id = create_resp.json()["id"]
resp = client.delete(f"{self.BASE}/plans/{plan_id}", headers=auth_header(token))
assert resp.status_code == 200
assert resp.json()["message"] == "预算已删除"
# 验证已删除
get_resp = client.get(f"{self.BASE}/plans", headers=auth_header(token))
ids = [p["id"] for p in get_resp.json()["data"]]
assert plan_id not in ids
class TestBudgetAutoDecompose:
"""预算自动分解测试"""
BASE = "/api/cma/budget"
def test_auto_decompose_single_kpi(self, client: TestClient, db: Session):
"""单KPI自动分解年度预算为月度(均分)"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="BUDGET_DECOMPOSE")
resp = client.post(
f"{self.BASE}/auto-decompose",
headers=auth_header(token),
json={"kpi_id": kpi.id, "annual_budget": 120000, "year": 2026, "method": "equal"},
)
assert resp.status_code == 200
data = resp.json()
assert "已分解" in data["message"]
assert len(data["monthly_budgets"]) == 12
# 年度预算12000012个月均分,每月10000
assert data["monthly_budgets"][0]["value"] == 10000.0
def test_auto_decompose_missing(self, client: TestClient, db: Session):
"""没有年度预算数据时尝试分解 → 400"""
create_test_user(db)
token = get_token_for_user(client)
create_test_kpi(db, kpi_code="BUDGET_NO_DATA")
resp = client.post(
f"{self.BASE}/auto-decompose",
headers=auth_header(token),
json={"year": 2026, "method": "equal"},
)
assert resp.status_code == 400
class TestBudgetVersions:
"""预算版本管理测试"""
BASE = "/api/cma/budget"
def test_create_and_submit_version(self, client: TestClient, db: Session):
"""创建预算后查询版本并提交(端点已移除,预期404)"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="BUDGET_VER_KPI")
# 创建一条预算
client.post(
f"{self.BASE}/plans",
headers=auth_header(token),
json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 10000.0},
)
# 版本端点已移除
ver_resp = client.get(f"{self.BASE}/versions", headers=auth_header(token))
assert ver_resp.status_code == 404, "versions端点已移除"
def test_approve_version(self, client: TestClient, db: Session):
"""审批通过版本(端点已移除,预期404"""
create_test_user(db)
token = get_token_for_user(client)
resp = client.post(
f"{self.BASE}/versions/approve",
headers=auth_header(token),
json={"version": "v1.0", "action": "approved"},
)
assert resp.status_code == 404, "versions/approve端点已移除"
def test_reject_version(self, client: TestClient, db: Session):
"""驳回版本(端点已移除,预期404"""
create_test_user(db)
token = get_token_for_user(client)
resp = client.post(
f"{self.BASE}/versions/approve",
headers=auth_header(token),
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