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:
@@ -0,0 +1,100 @@
|
||||
# -*- 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
|
||||
@@ -13,6 +13,7 @@ from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from app.database import get_session_local
|
||||
from app.utils.budget_status import EFFECTIVE_STATUSES, pick_effective
|
||||
from app.models import KPIDefinition, KPIValue, KPIAlert, BudgetPlan, SystemConfig
|
||||
# 裁定 R1/R2/R3(2026-09-19 财务口径):demo 账套排除 / 期间窗口 / 规整月期间
|
||||
from app.utils.alert_entity_scope import (
|
||||
@@ -93,14 +94,15 @@ def calc_deviation(actual: float, budget: float) -> dict:
|
||||
|
||||
def get_budget_for_kpi(db, kpi_id: int, period: str, version: str = None) -> Optional[float]:
|
||||
"""获取指定KPI在指定期间的预算值"""
|
||||
# 生效版口径(2026-10-03 状态机归一):approved(已批准) ∪ active(已下达)
|
||||
query = db.query(BudgetPlan).filter(
|
||||
BudgetPlan.kpi_id == kpi_id,
|
||||
BudgetPlan.period == period,
|
||||
BudgetPlan.status == "active",
|
||||
BudgetPlan.status.in_(EFFECTIVE_STATUSES),
|
||||
)
|
||||
if version:
|
||||
query = query.filter(BudgetPlan.version == version)
|
||||
plan = query.order_by(BudgetPlan.updated_at.desc()).first()
|
||||
plan = pick_effective(query.all()) # active > approved > updated_at desc
|
||||
return plan.budget_value if plan else None
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user