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)
101 lines
3.8 KiB
Python
101 lines
3.8 KiB
Python
# -*- 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
|