Files
cma-management/backend/tests/test_budget.py
T
Hermes CI Fix 046a7cf8b9 test: 预算接口契约测试 — 覆盖2026-08-25修复的4个契约缺口
1. plans返回dimension/unit字段(前端维度列/单位列依赖)
2. plans支持keyword搜索(前端搜索框依赖)
3. versions列表字段完整性(version/status/kpi_count/total_budget)
4. 批量分解幂等性(重复执行不产生重复记录)
+ 更新过时测试: versions端点从'预期404'改为'预期200'(API已恢复)

结果: 35 passed
2026-08-25 18:22:04 +08:00

686 lines
27 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):
"""创建预算后查询版本并提交(版本管理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}"