Files
cma-management/backend/tests/test_budget_status_machine.py
T
Hermes CI Fix fa1267c323 feat(budget): effective-version caliber + process-order tabs + 4 missing rings
Task: CMA-20261003 (budget module governance). Local commit only; push is
gated on release approval (see report).

Backend
- app/utils/budget_status.py (new): single source for the effective-version
  caliber. EFFECTIVE_STATUSES = approved + active; effective_plan_clause()
  enforces entity isolation; pick_effective() priority active > approved >
  updated_at desc. Fixes: approved budgets disappeared from the execution
  surface because the three queries only read status='active'.
- budget.py: /comparison, /comparison/kpi/{id}, /deviation-check now read the
  effective surface; versions list gains is_effective / can_release fields;
  submit/approve are fail-closed (draft|active -> submitted -> approved).
- deviation_engine.get_budget_for_kpi: effective surface + deterministic pick.
- 4 missing rings, zero DDL, appended to the same router:
  POST /versions/release (approved -> active, idempotent by operation_logs),
  POST /adjustments (derive a new draft version, base rows untouched),
  GET /control/status (effective budget vs actual -> ok/warn/over),
  GET /assessment (achieve rate + A/B/C/D grade).
  Audit rows written through app.utils.oplog.log_op with entity_id.

Frontend
- BudgetManagement.vue: top-level tabs reordered to the process order
  (strategy, method, driver, input, decompose, versions, release, execution,
  deviation, adjust, assess, rolling). Pane name values unchanged, so old
  bookmarks keep working; routes unchanged.
- Cash classify rules / unclassified queue moved to /cash-plan
  (components/CashClassifyPanel.vue); budget actual-value collection moved to
  /data-manage (components/BudgetCollectPanel.vue). Mount points only: no
  change to cash_plans data, classify logic, collection logic or kpi_values.
- api/index.ts: 4 ring endpoints exposed.

Docs / tests
- docs/budget-module-governance-design.md: phase-0 design doc (status machine,
  tab order, domain boundaries, 4-ring design, cleanup write pack scope).
- tests/test_budget_status_machine.py (new): effective surface, version
  guards, release idempotency, adjustment, control and assessment.
- tests/test_budget.py: existing cases adapted to the fail-closed contract.

(cherry picked from commit 4267fc4d25ee45c5675a541af9777be96fe9f9c2)
2026-10-18 16:32:55 +08:00

278 lines
13 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.
# -*- coding: utf-8 -*-
"""预算状态机口径归一 + 缺失 4 环最小骨架 —— 用例(施工单 CMA-20261003)
覆盖:
1) 生效面口径:approved(已批准) 行进入执行/对比面(改前被排除)
2) 选行优先级:同键 active(已下达) > approved
3) 审批 fail-closed:未提交版本审批 → 400(新契约锁定)
4) 下达:approved → active(幂等;非 approved → 400)
5) 调整:派生新版本(draft)、原版本零改动、同名版本 → 400、无 reason → 400
6) 控制 / 考核端点形状
"""
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
BASE = "/api/cma/budget"
def _create_plan(client, token, kpi_id, value=10000.0, version="v1.0", period="2026-06"):
r = client.post(f"{BASE}/plans", headers=auth_header(token),
json={"kpi_id": kpi_id, "period": period, "budget_value": value, "version": version})
assert r.status_code == 200, r.text
return r
def _submit_and_approve(client, token, version="v1.0", action="approved"):
s = client.post(f"{BASE}/versions/submit", headers=auth_header(token), json={"version": version})
assert s.status_code == 200, s.text
a = client.post(f"{BASE}/versions/approve", headers=auth_header(token),
json={"version": version, "action": action})
assert a.status_code == 200, a.text
return a
class TestEffectiveSurface:
"""生效面口径:approved ∪ active"""
def test_approved_plan_enters_comparison(self, client: TestClient, db: Session):
"""已批准预算必须出现在 /comparison 执行面(改前只读 active ⇒ 消失)"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="BUDGET_EFF_APPROVED")
_create_plan(client, token, kpi.id, value=1234.0)
_submit_and_approve(client, token)
resp = client.get(f"{BASE}/comparison?year=2026", headers=auth_header(token))
assert resp.status_code == 200
months = {m["period"]: m for m in resp.json()["months_data"]}
assert months["2026-06"]["budget_total"] == 1234.0, "已批准版本应进入执行面"
def test_kpi_comparison_reads_approved(self, client: TestClient, db: Session):
"""单KPI对比同样读生效面(approved)"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="BUDGET_EFF_KPI")
_create_plan(client, token, kpi.id, value=777.0)
_submit_and_approve(client, token)
resp = client.get(f"{BASE}/comparison/kpi/{kpi.id}?year=2026", headers=auth_header(token))
assert resp.status_code == 200
points = {p["period"]: p for p in resp.json()["data_points"]}
assert points["2026-06"]["budget_value"] == 777.0
def test_draft_not_in_surface(self, client: TestClient, db: Session):
"""草稿(submitted) 不入执行面:提交后 /comparison 该 KPI 无预算值"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="BUDGET_EFF_DRAFT")
_create_plan(client, token, kpi.id, value=555.0)
client.post(f"{BASE}/versions/submit", headers=auth_header(token), json={"version": "v1.0"})
resp = client.get(f"{BASE}/comparison?year=2026", headers=auth_header(token))
months = {m["period"]: m for m in resp.json()["months_data"]}
assert months["2026-06"]["budget_total"] == 0, "submitted 不应作为执行基准"
def test_effective_pick_priority_active_over_approved(self, db: Session):
"""选行优先级:active(已下达) > approved > updated_at desc(纯函数,零 DB)"""
from app.utils.budget_status import pick_effective
from datetime import datetime, timedelta
class R:
def __init__(self, status, ts, val):
self.status, self.updated_at, self.budget_value = status, ts, val
now = datetime(2026, 10, 3, 12, 0, 0)
approved_newer = R("approved", now, 1.0)
active_older = R("active", now - timedelta(days=10), 2.0)
assert pick_effective([approved_newer, active_older]).budget_value == 2.0, "已下达优先"
a = R("approved", now, 3.0)
b = R("approved", now - timedelta(days=1), 4.0)
assert pick_effective([b, a]).budget_value == 3.0, "同级取 updated_at 最新"
assert pick_effective([]) is None
def test_approval_status_mapping(self):
"""UI 兼容映射:active/draft → draft,approved 原样"""
from app.utils.budget_status import approval_status_of
assert approval_status_of("active") == "draft"
assert approval_status_of("draft") == "draft"
assert approval_status_of("approved") == "approved"
assert approval_status_of("submitted") == "submitted"
assert approval_status_of(None) == "draft"
class TestVersionReleaseAndGuards:
"""下达(缺失环)与审批 fail-closed"""
def test_approve_without_submit_400(self, client: TestClient, db: Session):
"""新契约负例:未提交版本直接审批 → 400"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="BUDGET_GUARD_APPROVE")
_create_plan(client, token, kpi.id)
resp = client.post(f"{BASE}/versions/approve", headers=auth_header(token),
json={"version": "v1.0", "action": "approved"})
assert resp.status_code == 400
assert "submitted" in resp.json()["detail"]
def test_release_requires_approved(self, client: TestClient, db: Session):
"""未批准版本不可下达 → 400"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="BUDGET_REL_400")
_create_plan(client, token, kpi.id)
resp = client.post(f"{BASE}/versions/release", headers=auth_header(token), json={"version": "v1.0"})
assert resp.status_code == 400
def test_release_then_idempotent(self, client: TestClient, db: Session):
"""下达:approved → active;重复下达幂等 already=True"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="BUDGET_REL_OK")
_create_plan(client, token, kpi.id)
_submit_and_approve(client, token)
r1 = client.post(f"{BASE}/versions/release", headers=auth_header(token), json={"version": "v1.0"})
assert r1.status_code == 200
assert r1.json()["status"] == "active" and r1.json()["count"] == 1 and r1.json()["already"] is False
r2 = client.post(f"{BASE}/versions/release", headers=auth_header(token), json={"version": "v1.0"})
assert r2.status_code == 200
assert r2.json()["already"] is True and r2.json()["count"] == 0
row = db.query(BudgetPlan).filter(BudgetPlan.kpi_id == kpi.id).first()
db.refresh(row)
assert row.status == "active"
def test_version_list_flags(self, client: TestClient, db: Session):
"""版本列表新增 is_effective / can_release 标记"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="BUDGET_VER_FLAG")
_create_plan(client, token, kpi.id)
_submit_and_approve(client, token)
versions = client.get(f"{BASE}/versions", headers=auth_header(token)).json()
row = [v for v in versions if v["version"] == "v1.0"][0]
assert row["status"] == "approved"
assert row["approval_status"] == "approved"
assert row["is_effective"] is True
assert row["can_release"] is True
class TestBudgetAdjustment:
"""调整(缺失环):派生新版本"""
def test_adjustment_creates_draft_and_keeps_base(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="BUDGET_ADJ_OK")
_create_plan(client, token, kpi.id, value=1000.0)
_submit_and_approve(client, token)
resp = client.post(f"{BASE}/adjustments", headers=auth_header(token), json={
"base_version": "v1.0", "new_version": "v1.0-adj-20261003", "reason": "追加渠道投入",
"items": [{"kpi_id": kpi.id, "period": "2026-06", "budget_value": 1500.0}],
})
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["created"] == 1 and body["applied_overrides"] == 1 and body["unmatched_overrides"] == 0
base_row = db.query(BudgetPlan).filter(
BudgetPlan.kpi_id == kpi.id, BudgetPlan.version == "v1.0").first()
db.refresh(base_row)
assert base_row.budget_value == 1000.0, "基线版本数值不得被调整单改动"
adj_row = db.query(BudgetPlan).filter(
BudgetPlan.kpi_id == kpi.id, BudgetPlan.version == "v1.0-adj-20261003").first()
assert adj_row is not None and adj_row.status == "draft" and adj_row.budget_value == 1500.0
assert "调整单:追加渠道投入" in (adj_row.remark or "")
def test_adjustment_existing_version_400(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="BUDGET_ADJ_DUP")
_create_plan(client, token, kpi.id)
resp = client.post(f"{BASE}/adjustments", headers=auth_header(token), json={
"base_version": "v1.0", "new_version": "v1.0", "reason": "x"})
assert resp.status_code == 400
def test_adjustment_requires_reason(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="BUDGET_ADJ_NOREASON")
_create_plan(client, token, kpi.id)
resp = client.post(f"{BASE}/adjustments", headers=auth_header(token), json={
"base_version": "v1.0", "new_version": "v1.1", "reason": ""})
assert resp.status_code == 400
def test_adjustment_unknown_base_404(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
resp = client.post(f"{BASE}/adjustments", headers=auth_header(token), json={
"base_version": "v9.9", "new_version": "v9.9-adj", "reason": "x"})
assert resp.status_code == 404
class TestControlAndAssessment:
"""控制 / 考核端点(最小骨架)"""
def test_control_status_shape_and_levels(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="BUDGET_CTRL_KPI", kpi_name="渠道费用", unit="万元")
_create_plan(client, token, kpi.id, value=100.0)
_submit_and_approve(client, token)
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=150.0))
db.commit()
resp = client.get(f"{BASE}/control/status?year=2026", headers=auth_header(token))
assert resp.status_code == 200, resp.text
body = resp.json()
assert set(["total", "ok", "warn", "over", "no_actual"]).issubset(body["summary"].keys())
row = [i for i in body["items"] if i["kpi_id"] == kpi.id and i["period"] == "2026-06"][0]
assert row["basis_version"] == "v1.0"
assert row["execution_rate"] == 150.0
# 费用型(非 higher_better)= 越低越好 ⇒ 超支 50% > 阈值 20% ⇒ over
assert row["control_level"] == "over"
def test_control_excludes_draft(self, client: TestClient, db: Session):
"""草稿版本不入控制面"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="BUDGET_CTRL_DRAFT")
_create_plan(client, token, kpi.id, value=100.0)
client.post(f"{BASE}/versions/submit", headers=auth_header(token), json={"version": "v1.0"})
resp = client.get(f"{BASE}/control/status?year=2026", headers=auth_header(token))
ids = {i["kpi_id"] for i in resp.json()["items"]}
assert kpi.id not in ids
def test_assessment_shape_and_grade(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="SALES_TOTAL", kpi_name="营业收入", unit="万元")
_create_plan(client, token, kpi.id, value=100.0)
_submit_and_approve(client, token)
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=95.0))
db.commit()
resp = client.get(f"{BASE}/assessment?year=2026", headers=auth_header(token))
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["year"] == 2026 and "summary" in body
row = [i for i in body["items"] if i["kpi_id"] == kpi.id][0]
assert row["budget_total"] == 100.0
assert row["actual_total"] == 95.0
assert row["achieve_rate"] == 95.0
assert row["grade"] == "B"