"""预算管理模块测试 — 预算计划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 # 年度预算120000,12个月均分,每月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统计全部KPI;items按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