Files
cma-management/backend/tests/test_budget.py
T
Hermes CI Fix 1a6ea8afae chore: CI流程加固+Schema检查门禁
- deploy.sh: 提交纪律检查(本地未提交修改→中止) + 冒烟测试(健康/登录/KPI/BOT/Schema)
- .woodpecker: 加前端typecheck+后端pytest测试步骤, backend-deploy加提交纪律检查
- 新增schema_check.py: ORM与数据库表结构一致性检查
- 修复budget_plans表缺3列(source_kpi_id/source_type/calc_logic)
- 附带入库: budget测试+文档
2026-08-28 07:18:54 +08:00

1216 lines
50 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
from app.models import KPIValue, SystemConfig, CashPlan
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):
"""创建预算后查询版本并提交(版本管理API 2026-08-25恢复)"""
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},
)
# 版本列表应返回2002026-08-25新增版本管理API
ver_resp = client.get(f"{self.BASE}/versions", headers=auth_header(token))
assert ver_resp.status_code == 200, "versions端点应存在"
versions = ver_resp.json()
assert isinstance(versions, list)
assert len(versions) >= 1
def test_approve_version(self, client: TestClient, db: Session):
"""审批通过版本"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="BUDGET_VER_APPROVE")
client.post(
f"{self.BASE}/plans",
headers=auth_header(token),
json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 10000.0},
)
resp = client.post(
f"{self.BASE}/versions/approve",
headers=auth_header(token),
json={"version": "v1.0", "action": "approved"},
)
assert resp.status_code == 200
assert resp.json()["status"] == "approved"
def test_reject_version(self, client: TestClient, db: Session):
"""驳回版本"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="BUDGET_VER_REJECT")
client.post(
f"{self.BASE}/plans",
headers=auth_header(token),
json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 10000.0},
)
resp = client.post(
f"{self.BASE}/versions/approve",
headers=auth_header(token),
json={"version": "v1.0", "action": "rejected"},
)
assert resp.status_code == 200
assert resp.json()["status"] == "rejected"
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
class TestBudgetContract20260825:
"""2026-08-25修复后的接口契约测试
覆盖今天发现的前后端契约缺口:
1. plans返回dimension/unit字段(前端表格依赖)
2. plans支持keyword搜索(前端搜索框依赖)
3. versions列表返回完整字段
4. 批量分解幂等性(重复执行不产生重复记录)
"""
BASE = "/api/cma/budget"
def test_plans_returns_dimension_and_unit(self, client: TestClient, db: Session):
"""契约:plans必须返回dimension/unit字段(否则前端维度列/单位列空白)"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="CONTRACT_DIM_KPI", dimension="customer", unit="%")
client.post(
f"{self.BASE}/plans",
headers=auth_header(token),
json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 100.0},
)
resp = client.get(f"{self.BASE}/plans", headers=auth_header(token))
assert resp.status_code == 200
items = resp.json()["data"]
assert len(items) >= 1
# 关键:每条必须有dimension和unit
for item in items:
assert "dimension" in item, f"plans缺少dimension字段: {item}"
assert "unit" in item, f"plans缺少unit字段: {item}"
def test_plans_keyword_search(self, client: TestClient, db: Session):
"""契约:plans支持keyword按KPI名称模糊搜索(前端搜索框依赖)"""
create_test_user(db)
token = get_token_for_user(client)
kpi1 = create_test_kpi(db, kpi_code="CONTRACT_KW_AAA", kpi_name="营业收入AAA")
kpi2 = create_test_kpi(db, kpi_code="CONTRACT_KW_BBB", kpi_name="毛利率BBB")
client.post(f"{self.BASE}/plans", headers=auth_header(token),
json={"kpi_id": kpi1.id, "period": "2026-06", "budget_value": 100.0})
client.post(f"{self.BASE}/plans", headers=auth_header(token),
json={"kpi_id": kpi2.id, "period": "2026-06", "budget_value": 200.0})
resp = client.get(f"{self.BASE}/plans", headers=auth_header(token), params={"keyword": "营业收入AAA"})
assert resp.status_code == 200
items = resp.json()["data"]
assert len(items) == 1
assert items[0]["kpi_code"] == "CONTRACT_KW_AAA"
def test_versions_fields(self, client: TestClient, db: Session):
"""契约:versions列表返回version/status/kpi_count/total_budget字段"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="CONTRACT_VER_FIELDS")
client.post(f"{self.BASE}/plans", headers=auth_header(token),
json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 500.0})
resp = client.get(f"{self.BASE}/versions", headers=auth_header(token))
assert resp.status_code == 200
versions = resp.json()
assert len(versions) >= 1
for v in versions:
assert "version" in v
assert "status" in v
assert "kpi_count" in v
assert "total_budget" in v
def test_batch_decompose_idempotent(self, client: TestClient, db: Session):
"""契约:批量分解幂等 — 重复执行不产生重复记录(同KPI+期间+版本唯一)"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="CONTRACT_DECOMP")
# 先创建年度预算(period=2026-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})
# 第一次批量分解
resp1 = client.post(f"{self.BASE}/auto-decompose", headers=auth_header(token),
json={"year": 2026, "method": "equal"})
assert resp1.status_code == 200
# 第二次批量分解(幂等:应该更新而非新增)
resp2 = client.post(f"{self.BASE}/auto-decompose", headers=auth_header(token),
json={"year": 2026, "method": "equal"})
assert resp2.status_code == 200
# 检查无重复(同KPI+期间+版本)
from sqlalchemy import func
rows = db.query(BudgetPlan.kpi_id, BudgetPlan.period, BudgetPlan.version,
func.count().label("cnt")).group_by(
BudgetPlan.kpi_id, BudgetPlan.period, BudgetPlan.version).having(
func.count() > 1).all()
assert len(rows) == 0, f"存在重复预算记录: {rows}"
class TestBudgetPlansEdges:
"""预算计划CRUD异常路径与列表过滤(补齐未覆盖端点)"""
BASE = "/api/cma/budget"
def test_create_plan_kpi_not_found(self, client: TestClient, db: Session):
"""KPI不存在 → 404"""
create_test_user(db)
token = get_token_for_user(client)
resp = client.post(f"{self.BASE}/plans", headers=auth_header(token),
json={"kpi_id": 99999, "period": "2026-06", "budget_value": 100.0})
assert resp.status_code == 404
def test_update_plan_not_found(self, client: TestClient, db: Session):
"""更新不存在的计划 → 404"""
create_test_user(db)
token = get_token_for_user(client)
resp = client.put(f"{self.BASE}/plans/99999", headers=auth_header(token),
json={"budget_value": 100.0})
assert resp.status_code == 404
def test_delete_plan_not_found(self, client: TestClient, db: Session):
"""删除不存在的计划 → 404"""
create_test_user(db)
token = get_token_for_user(client)
resp = client.delete(f"{self.BASE}/plans/99999", headers=auth_header(token))
assert resp.status_code == 404
def test_list_plans_filters(self, client: TestClient, db: Session):
"""列表按 year/period/version 过滤"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="PLAN_FILTER_KPI")
client.post(f"{self.BASE}/plans", headers=auth_header(token),
json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 100.0})
assert client.get(f"{self.BASE}/plans", headers=auth_header(token),
params={"year": 2026}).json()["total"] == 1
assert client.get(f"{self.BASE}/plans", headers=auth_header(token),
params={"year": 2025}).json()["total"] == 0
assert client.get(f"{self.BASE}/plans", headers=auth_header(token),
params={"period": "2026-06"}).json()["total"] == 1
assert client.get(f"{self.BASE}/plans", headers=auth_header(token),
params={"version": "v1.0"}).json()["total"] == 1
class TestBudgetAutoDecomposeEdges:
"""自动分解异常路径与加权模式"""
BASE = "/api/cma/budget"
def test_auto_decompose_single_missing_budget(self, client: TestClient, db: Session):
"""单KPI模式缺 annual_budget → 400"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="BUDGET_DECOMP_MISSING_BUDGET")
resp = client.post(f"{self.BASE}/auto-decompose", headers=auth_header(token),
json={"kpi_id": kpi.id, "year": 2026})
assert resp.status_code == 400
def test_auto_decompose_kpi_not_found(self, client: TestClient, db: Session):
"""单KPI模式KPI不存在 → 404"""
create_test_user(db)
token = get_token_for_user(client)
resp = client.post(f"{self.BASE}/auto-decompose", headers=auth_header(token),
json={"kpi_id": 99999, "annual_budget": 120000, "year": 2026})
assert resp.status_code == 404
def test_auto_decompose_single_weighted(self, client: TestClient, db: Session):
"""单KPI加权分解(去年各月实际值作为权重)"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="BUDGET_DECOMP_WEIGHTED")
db.add(KPIValue(kpi_id=kpi.id, period="2025-01", actual_value=10.0))
db.add(KPIValue(kpi_id=kpi.id, period="2025-02", actual_value=20.0))
db.commit()
resp = client.post(f"{self.BASE}/auto-decompose", headers=auth_header(token),
json={"kpi_id": kpi.id, "annual_budget": 120000, "year": 2026,
"method": "weighted"})
assert resp.status_code == 200
data = resp.json()
assert data["method"] == "weighted"
assert len(data["monthly_budgets"]) == 12
class TestBudgetVersionSubmitDiff:
"""版本提交 + 版本差异对比(补齐未覆盖端点)"""
BASE = "/api/cma/budget"
def test_submit_version(self, client: TestClient, db: Session):
"""提交版本审批:active → submitted"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="VER_SUBMIT_KPI")
client.post(f"{self.BASE}/plans", headers=auth_header(token),
json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 10000.0})
resp = client.post(f"{self.BASE}/versions/submit", headers=auth_header(token),
json={"version": "v1.0"})
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
assert data["status"] == "submitted"
assert data["count"] == 1
def test_submit_version_not_found(self, client: TestClient, db: Session):
"""提交不存在的版本 → 404"""
create_test_user(db)
token = get_token_for_user(client)
resp = client.post(f"{self.BASE}/versions/submit", headers=auth_header(token),
json={"version": "v9.9"})
assert resp.status_code == 404
def test_approve_version_not_found(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": "v9.9", "action": "approved"})
assert resp.status_code == 404
def test_diff_versions(self, client: TestClient, db: Session):
"""版本差异对比:v1.0 vs v2.0 逐KPI差异"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="VER_DIFF_KPI")
client.post(f"{self.BASE}/plans", headers=auth_header(token),
json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 100.0,
"version": "v1.0"})
client.post(f"{self.BASE}/plans", headers=auth_header(token),
json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 150.0,
"version": "v2.0"})
resp = client.post(f"{self.BASE}/versions/diff", headers=auth_header(token),
json={"version_a": "v1.0", "version_b": "v2.0", "year": 2026})
assert resp.status_code == 200
data = resp.json()
assert data["summary"]["changed_count"] == 1
assert data["summary"]["total_a"] == 100.0
assert data["summary"]["total_b"] == 150.0
assert data["diffs"][0]["version_a"] == 100.0
assert data["diffs"][0]["version_b"] == 150.0
def test_diff_versions_missing_params(self, client: TestClient, db: Session):
"""缺少 version_a/version_b → 400"""
create_test_user(db)
token = get_token_for_user(client)
resp = client.post(f"{self.BASE}/versions/diff", headers=auth_header(token),
json={"year": 2026})
assert resp.status_code == 400
def test_versions_year_filter(self, client: TestClient, db: Session):
"""版本列表按年份过滤"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="VER_YEAR_KPI")
client.post(f"{self.BASE}/plans", headers=auth_header(token),
json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 10000.0})
assert len(client.get(f"{self.BASE}/versions", headers=auth_header(token),
params={"year": 2026}).json()) >= 1
assert client.get(f"{self.BASE}/versions", headers=auth_header(token),
params={"year": 2030}).json() == []
class TestBudgetApplyMethod:
"""预算方法落地 apply-method(补齐未覆盖端点)"""
BASE = "/api/cma/budget"
def _seed_core_kpis(self, db: Session):
for code in ["F_REVENUE", "F_NET_PROFIT", "F_COST_RATIO", "F_GROSS_MARGIN"]:
create_test_kpi(db, kpi_code=code, kpi_name=code)
def test_apply_method_zero_based(self, client: TestClient, db: Session):
"""零基预算方法落地到年度预算"""
create_test_user(db)
token = get_token_for_user(client)
self._seed_core_kpis(db)
resp = client.post(f"{self.BASE}/apply-method", headers=auth_header(token),
json={"method": "zero_based", "year": 2026})
assert resp.status_code == 200
data = resp.json()
assert data["method"] == "zero_based"
assert data["total_budget"] == 71.4
assert len(data["applied"]) == 4
codes = {a["kpi_code"] for a in data["applied"]}
assert codes == {"F_REVENUE", "F_NET_PROFIT", "F_COST_RATIO", "F_GROSS_MARGIN"}
revenue = [a for a in data["applied"] if a["kpi_code"] == "F_REVENUE"][0]
assert revenue["budget_value"] == 71.4
def test_apply_method_unknown(self, client: TestClient, db: Session):
"""未知预算方法 → 400"""
create_test_user(db)
token = get_token_for_user(client)
resp = client.post(f"{self.BASE}/apply-method", headers=auth_header(token),
json={"method": "bogus"})
assert resp.status_code == 400
def test_apply_method_no_kpis(self, client: TestClient, db: Session):
"""无核心KPI可应用 → 400"""
create_test_user(db)
token = get_token_for_user(client)
resp = client.post(f"{self.BASE}/apply-method", headers=auth_header(token),
json={"method": "zero_based", "year": 2026})
assert resp.status_code == 400
assert "未找到" in resp.json()["detail"]
class TestBudgetSyncCashPlans:
"""预算→现金流联动(补齐未覆盖端点)"""
BASE = "/api/cma/budget"
def test_sync_cash_plans_create_and_update(self, client: TestClient, db: Session):
"""按预算KPI生成/更新收付款计划(upsert幂等)"""
create_test_user(db)
token = get_token_for_user(client)
kpi_rev = create_test_kpi(db, kpi_code="SYNC_REV_KPI", kpi_name="营业收入")
kpi_pay = create_test_kpi(db, kpi_code="SYNC_PAY_KPI", kpi_name="费用总额")
client.post(f"{self.BASE}/plans", headers=auth_header(token),
json={"kpi_id": kpi_rev.id, "period": "2026-06", "budget_value": 1000.0})
client.post(f"{self.BASE}/plans", headers=auth_header(token),
json={"kpi_id": kpi_pay.id, "period": "2026-06", "budget_value": 500.0})
resp = client.post(f"{self.BASE}/sync-cash-plans", headers=auth_header(token))
assert resp.status_code == 200
data = resp.json()
assert data["created"] == 2
assert data["updated"] == 0
# 再次执行 → 更新而非新增
resp2 = client.post(f"{self.BASE}/sync-cash-plans", headers=auth_header(token))
assert resp2.status_code == 200
assert resp2.json()["created"] == 0
assert resp2.json()["updated"] == 2
plans = db.query(CashPlan).all()
types = {p.plan_type for p in plans}
assert types == {"receive", "pay"}
class TestBudgetGenerateCandidates:
"""KPI→预算候选列表(补齐未覆盖端点)"""
BASE = "/api/cma/budget"
def test_kpi_budget_candidates(self, client: TestClient, db: Session):
"""按类型分类返回预算建议(降本/增收/能力/系统)"""
create_test_user(db)
token = get_token_for_user(client)
kpi_cost = create_test_kpi(db, kpi_code="CAND_COST", category="cost_control", target_value=50.0)
db.add(KPIValue(kpi_id=kpi_cost.id, period="2026-06", actual_value=80.0))
create_test_kpi(db, kpi_code="CAND_REV", category="revenue_growth", target_value=100.0)
create_test_kpi(db, kpi_code="CAND_CAP", category="talent_pipeline", target_value=100.0)
create_test_kpi(db, kpi_code="CAND_SYS", category="supply_chain", target_value=100.0)
db.commit()
resp = client.get(f"{self.BASE}/kpi-budget-candidates", headers=auth_header(token),
params={"year": 2026})
assert resp.status_code == 200
data = resp.json()["data"]
assert set(data.keys()) == {"cost_reduction", "revenue_growth", "capability", "system"}
assert len(data["cost_reduction"]) == 1
assert data["cost_reduction"][0]["suggested_budget"] == 9.0 # (80-50)*0.3
assert data["revenue_growth"][0]["suggested_budget"] == 20.0 # 100*0.2
assert data["capability"][0]["suggested_budget"] == 20000.0 # 2000*10
assert data["system"][0]["suggested_budget"] == 15.0 # 100*0.15
def test_generate_from_kpis(self, client: TestClient, db: Session):
"""从选中KPI生成预算科目(修复source_type/source_kpi_id/calc_logic字段后完整流程)"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="GEN_KPI_01")
resp = client.post(
f"{self.BASE}/generate-from-kpis",
headers=auth_header(token),
json={
"year": 2026,
"month": 7,
"version": "v1.0",
"items": [
{"kpi_id": kpi.id, "budget_amount": 30000.0,
"calc_logic": "目标值100000×0.3=30000", "calc_type": "增收类"}
],
},
)
assert resp.status_code == 200
body = resp.json()
assert body["total_amount"] == 30000.0
assert body["items"][0]["kpi_code"] == "GEN_KPI_01"
# 验证落库字段
plan = db.query(BudgetPlan).filter(BudgetPlan.kpi_id == kpi.id).first()
assert plan is not None
assert plan.source_type == "kpi_generated"
assert plan.source_kpi_id == kpi.id
assert plan.calc_logic == "目标值100000×0.3=30000"
assert "增收类" in plan.remark
def test_generate_from_kpis_empty_items(self, client: TestClient, db: Session):
"""未选择KPI → 400"""
create_test_user(db)
token = get_token_for_user(client)
resp = client.post(
f"{self.BASE}/generate-from-kpis",
headers=auth_header(token),
json={"year": 2026, "month": 7, "items": []},
)
assert resp.status_code == 400
def test_generate_from_kpis_updates_existing(self, client: TestClient, db: Session):
"""同一KPI+期间+版本已存在 → 更新预算值而非新增"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="GEN_KPI_02")
# 先创建一条预算
resp1 = client.post(
f"{self.BASE}/generate-from-kpis",
headers=auth_header(token),
json={"year": 2026, "month": 7, "version": "v1.0",
"items": [{"kpi_id": kpi.id, "budget_amount": 10000.0}]},
)
assert resp1.status_code == 200
first_id = resp1.json()["items"][0]["plan_id"]
# 再次生成 → 更新而非新增
resp2 = client.post(
f"{self.BASE}/generate-from-kpis",
headers=auth_header(token),
json={"year": 2026, "month": 7, "version": "v1.0",
"items": [{"kpi_id": kpi.id, "budget_amount": 25000.0}]},
)
assert resp2.status_code == 200
assert resp2.json()["items"][0]["plan_id"] == first_id
plans = db.query(BudgetPlan).filter(BudgetPlan.kpi_id == kpi.id).all()
assert len(plans) == 1
assert plans[0].budget_value == 25000.0
class TestBudgetDeviationReportEdges:
"""偏差报告边界:under_budget / 维度过滤"""
BASE = "/api/cma/budget"
def test_deviation_report_under_budget(self, client: TestClient, db: Session):
"""实际低于预算 → 结余统计"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="BUDGET_DEV_UNDER")
db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=100.0,
budget_year=2026, budget_month=6, status="active"))
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=80.0))
db.commit()
resp = client.get(f"{self.BASE}/deviation-report?year=2026&month=6",
headers=auth_header(token))
assert resp.status_code == 200
summary = resp.json()["summary"]
assert summary["over_budget"] == 0
assert summary["under_budget"] == 1
def test_deviation_report_dimension_filter(self, client: TestClient, db: Session):
"""按维度过滤KPI"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="BUDGET_DEV_DIM", dimension="customer")
db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=100.0,
budget_year=2026, budget_month=6, status="active"))
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=120.0))
db.commit()
resp = client.get(f"{self.BASE}/deviation-report?year=2026&month=6&dimension=customer",
headers=auth_header(token))
assert resp.json()["summary"]["total_kpis"] == 1
resp2 = client.get(f"{self.BASE}/deviation-report?year=2026&month=6&dimension=finance",
headers=auth_header(token))
assert resp2.json()["summary"]["total_kpis"] == 0
class TestBudgetRollForwardEdges:
"""滚动延展边界:固定模式 / 配置异常"""
BASE = "/api/cma/budget"
def test_roll_forward_fixed_mode(self, client: TestClient, db: Session):
"""已配置固定预算模式 → 400"""
create_test_user(db)
token = get_token_for_user(client)
client.post(f"{self.BASE}/config", headers=auth_header(token),
json={"mode": "fixed"})
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_bad_config(self, client: TestClient, db: Session):
"""预算模式配置为非法JSON → 400"""
create_test_user(db)
token = get_token_for_user(client)
db.add(SystemConfig(config_key="budget_mode", config_value="not-json"))
db.commit()
resp = client.post(f"{self.BASE}/roll-forward", headers=auth_header(token))
assert resp.status_code == 400
assert "配置异常" in resp.json()["detail"]
class TestBudgetComparisonRolling:
"""滚动预算下的实际vs预测对比"""
BASE = "/api/cma/budget"
def test_comparison_rolling_mode(self, client: TestClient, db: Session):
"""滚动模式下只返回 rolling_months 个月份"""
create_test_user(db)
token = get_token_for_user(client)
client.post(f"{self.BASE}/config", headers=auth_header(token),
json={"mode": "rolling", "rolling_months": 3})
resp = client.get(f"{self.BASE}/comparison", headers=auth_header(token))
assert resp.status_code == 200
data = resp.json()
assert data["budget_mode"] == "rolling"
assert len(data["periods"]) == 3
class TestBudgetDeviationCheckEdges:
"""偏差预警边界:critical / 幂等 / 零预算跳过"""
BASE = "/api/cma/budget"
def test_deviation_check_critical(self, client: TestClient, db: Session):
"""偏差超过50% → critical"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="BUDGET_ALERT_CRIT")
db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=100.0,
budget_year=2026, budget_month=6, status="active"))
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=200.0)) # +100%
db.commit()
resp = client.post(f"{self.BASE}/deviation-check", headers=auth_header(token),
json={"period": "2026-06", "threshold": 20})
assert resp.status_code == 200
assert resp.json()["alerts_generated"] == 1
assert resp.json()["alerts"][0]["alert_level"] == "critical"
def test_deviation_check_idempotent(self, client: TestClient, db: Session):
"""重复检查不重复生成预警"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="BUDGET_ALERT_IDEM")
db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=100.0,
budget_year=2026, budget_month=6, status="active"))
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=150.0))
db.commit()
resp1 = client.post(f"{self.BASE}/deviation-check", headers=auth_header(token),
json={"period": "2026-06", "threshold": 20})
assert resp1.json()["alerts_generated"] == 1
resp2 = client.post(f"{self.BASE}/deviation-check", headers=auth_header(token),
json={"period": "2026-06", "threshold": 20})
assert resp2.json()["alerts_generated"] == 0
def test_deviation_check_zero_budget_skip(self, client: TestClient, db: Session):
"""预算为0 → 跳过不预警"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="BUDGET_ALERT_ZERO")
db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=0.0,
budget_year=2026, budget_month=6, status="active"))
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=50.0))
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 TestBudgetDeviationAlertsEdges:
"""偏差预警列表按 kpi_id/period 过滤"""
BASE = "/api/cma/budget"
def test_list_alerts_kpi_period_filter(self, client: TestClient, db: Session):
"""按KPI与期间过滤预警"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="BUDGET_DEV_ALERT_FLT")
db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=100.0,
budget_year=2026, budget_month=6, status="active"))
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"})
resp = client.get(f"{self.BASE}/deviation-alerts", headers=auth_header(token),
params={"kpi_id": kpi.id, "period": "2026-06"})
assert resp.json()["total"] == 1
resp2 = client.get(f"{self.BASE}/deviation-alerts", headers=auth_header(token),
params={"kpi_id": kpi.id, "period": "2026-01"})
assert resp2.json()["total"] == 0