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)
This commit is contained in:
Hermes CI Fix
2026-10-18 16:32:55 +08:00
parent 75a48f55d2
commit fa1267c323
12 changed files with 1777 additions and 549 deletions
+17
View File
@@ -186,6 +186,15 @@ class TestBudgetVersions:
json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 10000.0},
)
# 状态机口径归一(2026-10-03):审批只接受 submitted 行 → 先提交
sub = client.post(
f"{self.BASE}/versions/submit",
headers=auth_header(token),
json={"version": "v1.0"},
)
assert sub.status_code == 200
assert sub.json()["count"] == 1
resp = client.post(
f"{self.BASE}/versions/approve",
headers=auth_header(token),
@@ -205,6 +214,14 @@ class TestBudgetVersions:
json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 10000.0},
)
# 状态机口径归一(2026-10-03):审批只接受 submitted 行 → 先提交
sub = client.post(
f"{self.BASE}/versions/submit",
headers=auth_header(token),
json={"version": "v1.0"},
)
assert sub.status_code == 200
resp = client.post(
f"{self.BASE}/versions/approve",
headers=auth_header(token),
+277
View File
@@ -0,0 +1,277 @@
# -*- 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"