1. plans返回dimension/unit字段(前端维度列/单位列依赖) 2. plans支持keyword搜索(前端搜索框依赖) 3. versions列表字段完整性(version/status/kpi_count/total_budget) 4. 批量分解幂等性(重复执行不产生重复记录) + 更新过时测试: versions端点从'预期404'改为'预期200'(API已恢复) 结果: 35 passed
686 lines
27 KiB
Python
686 lines
27 KiB
Python
"""预算管理模块测试 — 预算计划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):
|
||
"""创建预算后查询版本并提交(版本管理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},
|
||
)
|
||
|
||
# 版本列表应返回200(2026-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统计全部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
|
||
|
||
|
||
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}"
|