Files
cma-management/backend/app/utils/budget_status.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

101 lines
3.8 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 -*-
"""预算状态机口径 —— 单一事实源(2026-10-03,施工单 CMA-20261003 阶段 1)
设计件:`docs/budget-module-governance-design.md` §1
口径(逐字固化):
| status | 语义 | 入执行面 |
|---|---|---|
| `draft` | 草稿(编制中,可编辑) | 否 |
| `submitted` | 待审批 | 否 |
| `approved` | **已批准(= 执行基准 / 生效版)** | 是 |
| `active` | 已下达(下达动作后)/ 历史遗留「在用」 | 是 |
| `rejected` | 已驳回 | 否 |
| `archived` | 已归档 | 否 |
- **执行/对比/差异的唯一基准 = `approved` ∪ `active`**:审批通过即获得执行资格,
修的是「执行查询只读 `active` ⇒ 已批准预算从执行视图消失」这一处口径错配。
- 同键 `(kpi_id, period)` 多行并存时选行优先级:`active`(已下达)> `approved`,
同级取 `updated_at` 最新 —— 保证取行确定性(改前 `bp_map[key] = value` 后写覆盖先写,
结果依赖 DB 返回顺序)。
- 既有 216 行 `active` 本单**不改标**(口径彻底归一属待 finance/任总确认项,见设计件 §1.3);
`active` 在 UI 侧仍映射为 `draft`(历史兼容,`approval_status_of`)。
**规则**:任何「预算执行基准」查询必须走 `effective_plan_clause()` /
`pick_effective()`,**禁止再手写 `BudgetPlan.status == "active"`**。
非执行面用法(新建去重、自动分解、滚动延展、方法应用、现金流联动)
不在本口径范围内,随「彻底归一」单一起收敛(设计件 §1.5)。
"""
from sqlalchemy import and_
from app.models import BudgetPlan
#: 执行基准状态(生效版)
EFFECTIVE_STATUSES = ("approved", "active")
#: UI 展示为「草稿」的状态(`active` = 历史遗留「在用」态)
DRAFT_LIKE_STATUSES = ("draft", "active")
#: 已下达态(选行优先级最高)
_STATUS_ACTIVE = "active"
def effective_plan_clause(entity_id: int):
"""执行基准条件(含账套隔离)—— 单一取数口。
用法:`db.query(BudgetPlan).filter(BudgetPlan.period == p, effective_plan_clause(eid))`
"""
if entity_id is None:
raise ValueError("effective_plan_clause 必须传 entity_id(账套隔离,禁止漏传落默认账套)")
return and_(
BudgetPlan.entity_id == entity_id,
BudgetPlan.status.in_(EFFECTIVE_STATUSES),
)
def effective_status_priority(status: str) -> int:
"""选行优先级:`active`(已下达) 最高,其余生效态次之。"""
return 0 if status == _STATUS_ACTIVE else 1
def _sort_key(row):
ts = getattr(row, "updated_at", None)
try:
stamp = ts.timestamp() if ts is not None else 0.0
except (AttributeError, ValueError, OSError):
stamp = 0.0
return (effective_status_priority(getattr(row, "status", "") or ""), -stamp)
def pick_effective(rows):
"""同一 `(kpi_id, period)` 的多行 → 取生效行;空输入返回 None。"""
if not rows:
return None
return sorted(rows, key=_sort_key)[0]
def effective_value_map(rows):
"""行列表 → `{(kpi_id, period): budget_value}`(按生效优先级去重)。"""
grouped = {}
for r in rows:
grouped.setdefault((r.kpi_id, r.period), []).append(r)
out = {}
for key, rows_of_key in grouped.items():
row = pick_effective(rows_of_key)
if row is not None:
out[key] = row.budget_value
return out
def is_effective_status(status: str) -> bool:
return (status or "") in EFFECTIVE_STATUSES
def approval_status_of(raw: str) -> str:
"""DB status → 前端 `approval_status`(历史兼容:`active` 曾表示在用/草稿态)。"""
if not raw:
return "draft"
if raw in DRAFT_LIKE_STATUSES:
return "draft"
return raw