Files
cma-management/docs/budget-module-governance-design.md
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

200 lines
17 KiB
Markdown
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.
# CMA 预算管理模块治理设计 v0.1
> 施工单:`CMA-20261003.md`(研学调度 2026-10-03|P1)· 阶段 0 设计件
> 依据:`/srv/hermes/wisdom/_wisdom/reference/CMA预算管理-子项与编制顺序审查-20261003.md`(研学只读实测,快照 2026-10-03 18:05 CST)
> 基准:管理会计应用指引「预算管理」200-203 号 + 本模块自带流程图 `docs/budget-flow-verify-20260827.md`
> 落点:代码全部在 worktree 分支 `wt/budget-governance`(基于 origin/main `8631ce7`),**不 push、不部署**;生产写库清单只编制不执行。
---
## 0. 结论摘要(四问)
| 问 | 结论 |
|---|---|
| 状态机口径归一 | `status` 收敛为 `draft → submitted → approved(已批准=生效基准) → active(已下达)`,旁支 `rejected / archived`;**执行/对比/差异的唯一基准 = `approved` ∪ `active`**(approved 优先)。修的是「执行查询只读 `active`」这一处口径错配 |
| 页签顺序归位 | 12 序:战略预算编制 → 编制方法 → 驱动因子预算 → 预算录入 → 年度分解 → 版本审批 → 下达 → 预算执行 → 差异分析 → 预算调整 → 预算考核 → 持续规划 |
| 分域边界 | 现金流分类规则/待分类队列 → 收付款计划页(资金域);实际值归集(取数映射/实际值标签/采集日志)→ 数据管理页(KPI 取数域)。**只挪前端入口与挂载位置**,数据与逻辑零改动 |
| 缺失 4 环 | 控制 / 调整 / 下达 / 考核 各出「最小可用骨架」= 零 DDL 的真实接口 + 页面(一条端到端路径),完整功能拆后续单 |
**本设计件唯一待确认项(阻塞彻底归一,不阻塞本单代码)**:若把「生效」定义为**唯一态**(新建写 `draft`、历史 216 行 `active` 改标 `draft`),执行面将从 216 行收缩为 85 行覆盖的 8 个核心 KPI —— 这是**财务预算口径变更**,须 finance/任总确认后另单落地。本单采取**零 status 数据迁移**的兼容口径(见 §1.3)。
---
## 1. 问一:状态机口径归一
### 1.1 现状(实测)
- `budget_plans.status` 现网取值:`active` 216 行 / `approved` 85 行(301 行总量,entity1 276 · entity2 25)。
- 新建行写 `active`(`backend/app/api/budget.py:128`),前端把 `active` 映射为 `draft`(`budget.py:1504`)⇒ **`active` 一个值同时承担「草稿/在用」与「执行基准」两种语义**(审查件指出的根因)。
- 执行/对比/差异三处查询只读 `status == 'active'`:
- `backend/app/api/budget.py:716`(`/comparison` 实际 vs 预算)
- `backend/app/api/budget.py:818`(`/comparison/kpi/{kpi_id}`)
- `backend/app/api/budget.py:885`(`/deviation-check` 偏差预警)
- `backend/app/utils/deviation_engine.py:94-105`(`get_budget_for_kpi`,`calc_period_deviation` 的取数口)
- 审批链:`/versions/submit` 置 `submitted`、`/versions/approve` 置 `approved`(`budget.py:1522-1561`)。
- ⇒ 版本一旦获批即离开执行面;**系统实际拿「在用/草稿」版本当执行基准**。审批既非执行前置、也不作执行依据。
### 1.2 目标状态机(唯一语义)
```
draft 草稿(编制中,可编辑)
│ submit
▼
submitted 待审批(锁定编辑,等待审批人)
│ approve │ reject
▼ ▼
approved 已批准 rejected 已驳回(可回退 draft 重编)
│ release(下达)
▼
active 已下达(生效执行;下达动作 = 缺失环之一,见 §4.3)
│
▼
archived 已归档(被新版本取代;只做 status 改标,不删行)
```
- **执行基准(生效版)= `status ∈ {approved, active}`**:审批通过即获得执行资格,下达只改变「是否已正式下达」的管理状态,不再把已批准预算踢出执行面。
- 同一 `(entity_id, kpi_id, period)` 出现多行生效版时,取行优先级 **`active` > `approved`,同级取 `updated_at` 最新**,保证查询确定性(现状 `bp_map[key] = value` 后写覆盖先写,结果依赖返回顺序)。
- `draft / submitted / rejected / archived` **一律不入执行面**。
### 1.3 既有数据的迁移口径(零改标,先立口径)
| 数据 | 处置 | 理由 |
|---|---|---|
| `approved` 85 行 | **不改 status**。口径切换后自动进入执行面(改前 0 → 改后 85) | 它们本就是「已批准」,改标反而破坏语义;`approved` 在新机里就是生效态 |
| `active` 216 行 | **本单不改标** | 见下方「待确认项」 |
| 其余字段类脏数据 | 阶段 4 写包 B1–B4(月=0 / source_type / map_id / 版本命名) | 与状态机解耦,见 §5 |
**待确认项(须 finance/任总拍板后才可执行)**:把「生效」收敛为唯一态,需要把 216 行历史 `active` 改标 `draft`(一条 status 改标 DML,可 1:1 回滚)。其后果是执行面由 216 行收缩为 8 个核心 KPI 的 85 行 —— 属财务预算口径变更,且会改变现网所有预算执行/偏差告警数字。**本单不执行该 DML**,只把口径与回滚方案写进 §5 待批清单。
### 1.4 UI / 接口兼容
- `GET /versions` 的 `approval_status` 映射(`budget.py:1498-1517`)扩展为:`active → draft`(遗留在用,保留现映射)、`draft → draft`、`submitted/approved/rejected/archived → 原值`;`status` 原字段保留(下拉「当前」标记等仍在用)。
- 版本列表新增 `is_effective: bool`(该版本是否为执行基准)与 `can_release`(是否可下达),供前端按钮显隐;不改任何既有字段语义。
### 1.5 单一实现(防第三处手写副本)
新增 `backend/app/utils/budget_status.py`:
```python
EFFECTIVE_STATUSES = ("approved", "active") # 执行基准
DRAFT_LIKE = ("draft", "active") # UI 展示为「草稿」
def effective_plan_filter(entity_id) # 返回 SQLAlchemy 条件列表(含 entity 隔离)
def pick_effective(rows) # 同键多行选行:active > approved > updated_at desc
def approval_status_of(raw) # DB status → UI approval_status
```
**规则**:全仓任何「预算执行基准」查询只能经 `effective_plan_filter()`,禁止再出现 `BudgetPlan.status == "active"`(执行面);既有非执行面用法(新建去重 `:109`、自动分解 `:219-391`、滚动延展 `:590`、方法应用 `:1406`、现金流联动 `:1680`)**本单不动**,避免行为漂移(它们属数据写入/生成路径,随「彻底归一」单一起收敛)。
---
## 2. 问二:页签顺序归位
### 2.1 目标顺序(12 序)
| # | 页签(name) | 环节 | 变更 |
|:-:|---|---|---|
| 1 | 战略预算编制(`strategy`) | 战略驱动 | 由第 3 提至第 1 |
| 2 | 编制方法(`method`) | 选方法 | 由第 6 提至第 2 |
| 3 | 驱动因子预算(`driver`) | 驱动因子/明细 | 由第 7 提至第 3 |
| 4 | 预算录入(`input`) | 明细编制 | 由第 1 降至第 4(明细在方法与驱动因子之后) |
| 5 | 年度分解(`decompose`) | 汇总平衡 | 保持(前移到编制之后) |
| 6 | 版本审批(`versions`) | 审批 | 由第 4 后移,标签由「版本管理」改为「版本审批」 |
| 7 | 下达(`release`) | **下达(新环)** | 新增 |
| 8 | 预算执行(`execution`) | 执行/控制 | 由第 5 后移;现金流分类块迁出(§3) |
| 9 | 差异分析(`deviation`) | 差异分析 | 从「持续规划 > 偏差告警」提升为一级页签 |
| 10 | 预算调整(`adjust`) | **调整(新环)** | 新增 |
| 11 | 预算考核(`assess`) | **考核(新环)** | 新增 |
| 12 | 持续规划(`rolling`) | 滚动/预测 | 保持尾部 |
### 2.2 迁移方案与兼容
- 只改 `<el-tabs>` 内 `<el-tab-pane>` 的**出现顺序**与标签文案,`v-model="activeTab"` 的 name 值全部不变 ⇒ **旧书签(?tab=xxx)/ 已保存的 activeTab / 路由 query 全部继续可用**。
- 归位后与模块自带流程图(战略编制→录入→分解→审批→执行→滚动)一致,并补齐流程图缺的下达/差异/调整/考核。
- 前端零新依赖,弹窗继续走 `MyDialog`(`el-dialog` 在本项目线上不可见)。
---
## 3. 问三:分域边界(迁出,只挪入口)
| 迁出项 | 现状 | 迁至 | 权限变化 | 边界 |
|---|---|---|---|---|
| 现金流分类规则 + 待分类队列 | 「预算执行」页签内嵌(`BudgetManagement.vue` 207-253) | 收付款计划页 `/cash-plan`(资金域)新增页签「分类规则」,组件 `components/CashClassifyPanel.vue` | `/cash-plan` roles = `ceo, finance, business, it` ⇒ 比 `/budget`(`ceo, finance, it`)多 `business` 可见该面板 ⇒ **资金域入口对商务可见**(分类规则维护仍受后端 `require_role` 约束,不变) | 不动 `cash_plans` / `CashPlanClassifyRule` 数据与分类逻辑;接口仍挂 `/api/cma/budget/cash-classify-rules`(路由归属后续随资金域单再迁,避免本单改后端契约) |
| 实际值归集(取数映射 / 实际值标签 / 采集日志) | 「实际值归集」页签(`BudgetManagement.vue` 382-470) | 数据管理页 `/data-manage`(KPI 取数域)新增页签「预算取数」,组件 `components/BudgetCollectPanel.vue` | 两端 roles 相同(`ceo, finance, it`)⇒ **无权限变化** | 不动采集逻辑、不碰 `kpi_values` / `kpi_value_sources` 数据 |
| 现金流联动按钮(`⇄ 同步现金流计划`) | 「预算执行」页签 | 随分类规则面板一并迁至收付款计划页 | 同上 | 仍调 `POST /budget/sync-cash-plans`(契约不变),`cash_plans` 数据零改动 |
**归属口径**:「预算」模块只保留预算自身的生命周期环节;跨域资产业务(现金流分类/计划)归资金域,数据取数归 KPI 数据域。本单只做**入口归位**,接口前缀与后端文件归属留待对应域的单子处理(避免一次改两处契约)。
---
## 4. 问四:缺失 4 环设计
设计原则:**最小可用骨架 = 零 DDL + 真实接口 + 页面(每环至少一条端到端路径)**;表结构设计与完整功能拆后续单。
> **落地说明(实现期,2026-10-03)**:四个端点实际**追加在 `backend/app/api/budget.py` 末尾**(同 router 同 `prefix=/api/cma/budget`)——本仓「新 router 漏注册 `main.py` ⇒ 404」是既有坑,同文件追加免改注册;因此**没有** `backend/app/api/budget_rings.py` 这个文件(设计稿的落点在此按实现订正)。
### 4.1 预算控制(`control`)
- 语义:执行中的控制点 —— 生效预算 vs 实际值,按方向(越高越好 / 越低越好,复用 `deviation_engine.higher_better_key`)给出控制级 `ok / warn / over`。
- 接口:`GET /api/cma/budget/control/status?year=&period=`(只读)。
- 判据(最小版):`over` = 改善型 KPI 实际 < 预算 ×(1−阈值) 或 费用型实际 > 预算;`warn` = 越过阈值的 80%;阈值取 `system_configs.budget_control_threshold`(缺省 20%;实现期从设计稿的 `deviation_threshold` 订正为独立键,避免与 `/deviation-check` 的请求阈值互相影响)。
- 页面:预算执行页签顶部「控制状态」卡片(`over` 计数 + 明细表)。
- **不在本单**:硬控制(阻断提交/付款)、控制点规则表、与司库支付联动。
### 4.2 预算调整(`adjust`)
- 语义:正式调整/追加单 —— 以既有版本为基线派生新版本,保留原版本与调整原因,形成可追溯的调整链(现网只有隐式的「版本/滚动」)。
- 接口:`POST /api/cma/budget/adjustments`,body `{base_version, new_version, reason, items:[{kpi_id, period, budget_value}]}`;行为 = 复制基线行 → 新版本行(`status='draft'`)→ 覆盖 items 指定值 → `remark` 记 `调整单:<reason> 基线:<base_version>` → 写 `OperationLog`。
- 约束:`new_version` 必须不存在(防覆盖);基线必须存在且为生效版;**原版本数值一行不改**。
- 页面:新增「预算调整」页签(表单 + 调整链列表)。
- **不在本单**:调整单表、审批流、调整额度上下限校验(须 DDL,另单)。
### 4.3 预算下达(`release`)
- 语义:审批通过后的正式下达动作,把版本由 `approved` 置 `active`,是「审批 → 执行」链路的闭合点。
- 接口:`POST /api/cma/budget/versions/release`,body `{version}`;仅允许 `approved` 行 → `active`(行内混态/非 approved 一律 400,fail-closed);返回 `count`。写 `OperationLog`。
- 幂等:重复下达返回 `released: 0, already: true`(不报错、不改数据)。
- 页面:新增「下达」页签(待下达版本列表 + 下达按钮 + 确认弹窗 `MyDialog`)。
- **不在本单**:下达审批双签、下达通知推送、下达版本唯一性强制(见 §1.3 待确认项)。
### 4.4 预算考核(`assess`)
- 语义:执行结果考核 —— 按生效预算 vs 实际值算达成率并分级,形成年度预算考核表。
- 接口:`GET /api/cma/budget/assessment?year=`(只读):逐 KPI 输出 `budget_total / actual_total / achieve_rate / grade(A/B/C/D) / basis_version`,并给汇总。
- 分级(最小版,按方向分叉):改善型 `≥100% → A / ≥90% → B / ≥80% → C / else D`;费用型反向(`≤100% → A`)。
- 页面:新增「预算考核」页签(汇总卡 + 明细表)。
- **不在本单**:考核指标权重、与绩效模块打通、考核周期冻结。
---
## 5. 阶段 4:数据清理写包(只编制,执行交 ops)
清单(研学实测快照 2026-10-03 18:05 CST):
| # | 项 | 规模 | 处置 | 回滚 |
|:-:|---|---|---|---|
| B1 | `budget_month = 0` | 16 行 | **归档**:`status → 'archived'` + `remark` 前缀 `[年合计][归档] `(初稿「补月」方案在编制期被否,理由见下) | `before-image` 1:1 还原 `status` + `remark` |
| B2 | `source_type` 全 NULL | 301 行 | 按版本/来源补录(`manual` / `kpi_generated` / `roll_forward`),映射表逐版本固定 | 1:1 置回 NULL |
| B3 | `map_id` 悬空 | 244 行 → 可回填 155 行(余 89 行地图维度无对应 KPI,保持 NULL) | 按 `kpi_id` 归属战略地图回填(权威源 = 已发布地图 `strategic_maps.dimensions` 的 `kpi_code`;`kpi_definitions.map_id` 全表 0 行有值、`map_objectives.kpis` 仅 5 行非空,均不可用) | 1:1 置回 NULL |
| B4 | 版本命名混乱 | 8 种 | 规范化映射表,**只改 `version` 标签**(如 `v2026H2 → 2026H2`),不改数值 | 1:1 反向映射还原 |
**写包硬约束**:四件 `apply.sql` / `rollback.sql` / `verify.sql` / `before-image.rows.json`(逐行 id + 改前值 + `md5`);`reuse: forbidden`;仅 UPDATE 所列字段;值来源逐条绑请求件指纹;执行交 ops 队列常态路由,**本单不写库**。
**B1 处置变更(编制期实测推翻初稿「补月」方案)**:这 16 行 `period='2026-00'` 是**年合计行**(值 = 年度合计)。若把 `budget_month` 补成 1..12 任一月,`POST /budget/sync-cash-plans` 会以 `datetime(budget_year, budget_month, 1)` 生成收付款计划,upsert 键 `(entity, related_kpi_id, plan_type, plan_date)` 会与「该月的月度行」**撞键互相覆盖**(= 把年度总额写进某月度计划)。归档对现网可证中性:执行/对比/控制/考核按 `period ∈ YYYY-01..12` 取数,`2026-00` 本不在窗口内;版本列表不按 status 过滤;`sync-cash-plans` 只取 `active` ⇒ 归档行不再参与联动。残余 `budget_month=0`(模型注释 1-12)留待「年合计独立表达」另单。
**B3 覆盖率(实测)**:权威源 = 已发布战略地图 `strategic_maps.dimensions` 的 `kpi_code`(`kpi_definitions.map_id` 全表 0 行有值、`map_objectives.kpis` 仅 5 行非空,均不可用)。244 行可回填 **155** 行(entity1→map 53 = 150、entity2→map 49 = 5),余 **89 行**在地图维度中无对应 KPI(如 `F_GROSS_MARGIN`)⇒ **保持 NULL 不编造**,作数据缺口回报 finance。
**写包落地**:`/srv/hermes/shared/budget-cleanup-20261003/`(`apply.sql` / `rollback.sql` / `verify.sql` / `before-image.rows.json` / `README.md` / `manifest.sha256`),投 ops 队列由常态路由代执。
**与状态机的耦合**:§1.3 的「216 行 active → draft」DML 属**待确认项**,不进本单写包;确认后另编独立写包(同一纪律)。
---
## 6. 边界自证(本单不碰什么)
- 不改 `kpi_definitions` 结构(`direction` / `target_value`)——属在途单 `CMA-KPI-20261003.md`。
- 不碰 `reports/dupont`、`predict/cvp` 与缺件 KPI 注册——属在途单 `finance-20261003-*`。
- 不动 `cash_plans` / `CashPlanClassifyRule` 数据与分类逻辑、不动取数采集逻辑、不碰 `kpi_values`。
- 不删预算行:阶段 4 一律 UPDATE(`status` 改标或补值),rollback 1:1。
- 零 push、零部署、零重启、零写库。