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)
278 lines
13 KiB
Python
278 lines
13 KiB
Python
# -*- 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"
|