From fa1267c3238ca5f8f30b82de4bd255406be2bd11 Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Sat, 3 Oct 2026 21:40:45 +0800 Subject: [PATCH] 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) --- backend/app/api/budget.py | 351 ++++++- backend/app/utils/budget_status.py | 100 ++ backend/app/utils/deviation_engine.py | 6 +- backend/tests/test_budget.py | 17 + backend/tests/test_budget_status_machine.py | 277 ++++++ docs/budget-module-governance-design.md | 199 ++++ frontend/src/api/index.ts | 5 + .../src/components/BudgetCollectPanel.vue | 288 ++++++ frontend/src/components/CashClassifyPanel.vue | 175 ++++ frontend/src/views/BudgetManagement.vue | 896 +++++++----------- frontend/src/views/CashPlan.vue | 6 + frontend/src/views/DataManage.vue | 6 + 12 files changed, 1777 insertions(+), 549 deletions(-) create mode 100644 backend/app/utils/budget_status.py create mode 100644 backend/tests/test_budget_status_machine.py create mode 100644 docs/budget-module-governance-design.md create mode 100644 frontend/src/components/BudgetCollectPanel.vue create mode 100644 frontend/src/components/CashClassifyPanel.vue diff --git a/backend/app/api/budget.py b/backend/app/api/budget.py index 6cd42512..4e784ef3 100644 --- a/backend/app/api/budget.py +++ b/backend/app/api/budget.py @@ -14,6 +14,14 @@ from app.deps import get_entity_id from app.auth_middleware import require_auth, require_role from app.models import BudgetPlan, KPIDefinition, OperationLog, KPIValue from app.utils.kpi_value_scope import actual_only_clause +from app.utils.oplog import log_op +from app.utils.budget_status import ( + EFFECTIVE_STATUSES, + effective_plan_clause, + effective_value_map, + pick_effective, + approval_status_of, +) router = APIRouter(prefix="/api/cma/budget", tags=["预算管理"], dependencies=[Depends(require_role("ceo", "finance", "it"))], @@ -730,18 +738,15 @@ def get_budget_comparison( ) if kpi_id: query = query.filter(BudgetPlan.kpi_id == kpi_id) + # 生效版口径(2026-10-03 状态机归一):approved(已批准) ∪ active(已下达) query = query.filter( BudgetPlan.period.in_(periods), - BudgetPlan.status == "active", - BudgetPlan.entity_id == entity_id, # 账套隔离 2026-09-21 + effective_plan_clause(entity_id), # 含账套隔离 ) budget_plans = query.all() - # 按KPI+期间索引 - bp_map = {} - for bp in budget_plans: - key = (bp.kpi_id, bp.period) - bp_map[key] = bp.budget_value + # 按KPI+期间索引(同键多版本 → 取生效行 active > approved > updated_at desc) + bp_map = effective_value_map(budget_plans) # 查询实际值 kpi_ids = set(bp.kpi_id for bp in budget_plans) @@ -831,12 +836,13 @@ def get_kpi_comparison( periods = [f"{y}-{m:02d}" for m in range(1, 13)] # 预算值 + # 生效版口径(2026-10-03 状态机归一;保持原「未按账套过滤」行为不变) budgets = db.query(BudgetPlan).filter( BudgetPlan.kpi_id == kpi_id, BudgetPlan.period.in_(periods), - BudgetPlan.status == "active", + BudgetPlan.status.in_(EFFECTIVE_STATUSES), ).all() - budget_map = {bp.period: bp.budget_value for bp in budgets} + budget_map = {p: v for (_kid, p), v in effective_value_map(budgets).items()} # 实际值 actuals = db.query(KPIValue).filter( @@ -898,11 +904,16 @@ def check_budget_deviation( period = data.get("period") or datetime.now().strftime("%Y-%m") # 查询该期间有预算的KPI(多租户隔离 entity_id) - budget_plans = db.query(BudgetPlan).filter( - BudgetPlan.entity_id == entity_id, + # 生效版口径(2026-10-03 状态机归一):approved(已批准) ∪ active(已下达) + _effective_rows = db.query(BudgetPlan).filter( BudgetPlan.period == period, - BudgetPlan.status == "active", + effective_plan_clause(entity_id), ).all() + # 同 KPI 多版本并存 → 取生效行(active > approved > updated_at desc),防重复告警 + _by_kpi = {} + for _bp in _effective_rows: + _by_kpi.setdefault(_bp.kpi_id, []).append(_bp) + budget_plans = [r for r in (pick_effective(v) for v in _by_kpi.values()) if r is not None] if not budget_plans: return { @@ -1520,7 +1531,7 @@ def list_budget_versions( # 状态机对齐(2026-09-07 UI修复):DB 默认态是 active(当前在用/草稿态), # 前端审批按钮语义用 draft=可提交审批/submitted=待审批/approved=已批准只读/rejected=已驳回 # → approval_status 把 active 映射为 draft,同时保留 status 原字段兼容其他引用(下拉"当前"标记等) - approval_status = "draft" if raw_status == "active" else raw_status + approval_status = approval_status_of(raw_status) # 单一实现(预算状态机口径) updated_str = v["updated_at"].strftime("%Y-%m-%d %H:%M") if v["updated_at"] else "" result.append({ "version": v["version"], @@ -1530,6 +1541,8 @@ def list_budget_versions( "plan_count": v["kpi_count"], # 前端列 prop 别名(BudgetManagement.vue versions tab) "total_budget": round(v["total_budget"], 2), "period_count": len(v["periods"]), + "is_effective": raw_status in EFFECTIVE_STATUSES, # 是否执行基准 + "can_release": raw_status == "approved", # 是否可下达 "year": year, "updated_at": updated_str, "last_updated": updated_str, # 前端列 prop 别名 @@ -1552,10 +1565,13 @@ def submit_budget_version( ).all() if not rows: raise HTTPException(404, f"版本 {version} 不存在") - for r in rows: + submittable = [r for r in rows if (r.status or "") in ("draft", "active")] + if not submittable: + raise HTTPException(400, f"版本 {version} 无可提交行(需 draft/active,当前: {sorted({r.status for r in rows})})") + for r in submittable: r.status = "submitted" db.commit() - return {"success": True, "version": version, "status": "submitted", "count": len(rows)} + return {"success": True, "version": version, "status": "submitted", "count": len(submittable)} @router.post("/versions/approve") @@ -1574,10 +1590,13 @@ def approve_budget_version( if not rows: raise HTTPException(404, f"版本 {version} 不存在") new_status = "approved" if action == "approved" else "rejected" - for r in rows: + decidable = [r for r in rows if (r.status or "") == "submitted"] + if not decidable: + raise HTTPException(400, f"版本 {version} 无可审批行(需 submitted,当前: {sorted({r.status for r in rows})})") + for r in decidable: r.status = new_status db.commit() - return {"success": True, "version": version, "status": new_status, "count": len(rows)} + return {"success": True, "version": version, "status": new_status, "count": len(decidable)} @router.post("/versions/diff") @@ -1781,3 +1800,301 @@ def sync_cash_plans( "unclassified_count": unclassified_count, "skipped_annual": skipped_annual, } + + +# ============ 预算闭环补齐(2026-10-03 施工单 CMA-20261003 阶段 3:最小可用骨架) ============ +# 设计件 docs/budget-module-governance-design.md §4:控制 / 调整 / 下达 / 考核 +# 零 DDL:全部基于既有 budget_plans / kpi_values / operation_logs。 + +DEFAULT_CONTROL_THRESHOLD_PCT = 20.0 + + +def _budget_control_threshold(db) -> float: + """控制阈值(%):system_configs.budget_control_threshold,缺省 20%。""" + from app.models import SystemConfig + import json as _json + cfg = db.query(SystemConfig).filter( + SystemConfig.config_key == "budget_control_threshold" + ).first() + if cfg: + try: + return float(_json.loads(cfg.config_value)) + except (ValueError, TypeError): + return DEFAULT_CONTROL_THRESHOLD_PCT + return DEFAULT_CONTROL_THRESHOLD_PCT + + +def _effective_rows_for_periods(db, entity_id: int, periods): + """期间集合内的生效预算行(同键取生效行,防同 KPI 多版本重复计算)。""" + rows = db.query(BudgetPlan).filter( + BudgetPlan.period.in_(periods), + effective_plan_clause(entity_id), + ).all() + 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.append((key[0], key[1], row)) + return out + + +def _direction_of(kpi, higher_better_codes) -> str: + return "higher" if (kpi.kpi_code in higher_better_codes or kpi.kpi_name in higher_better_codes) else "lower" + + +@router.get("/control/status") +def get_budget_control_status( + year: Optional[int] = Query(None), + period: Optional[str] = Query(None), + db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), +): + """预算控制状态(缺失环「控制」最小骨架,只读):生效预算 vs 实际 → ok/warn/over。""" + from app.utils.deviation_engine import get_higher_better_codes + y = year or datetime.now().year + periods = [period] if period else [f"{y}-{m:02d}" for m in range(1, 13)] + threshold = _budget_control_threshold(db) + + eff = _effective_rows_for_periods(db, entity_id, periods) + kpi_ids = {kid for kid, _p, _r in eff} + kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(kpi_ids)).all()} if kpi_ids else {} + actuals = {} + if kpi_ids: + for v in db.query(KPIValue).filter( + KPIValue.kpi_id.in_(kpi_ids), + KPIValue.period.in_(periods), + KPIValue.actual_value.isnot(None), + actual_only_clause(), + ).all(): + actuals[(v.kpi_id, v.period)] = v.actual_value + higher_better = set(get_higher_better_codes(db, entity_id)) + + items = [] + summary = {"total": 0, "ok": 0, "warn": 0, "over": 0, "no_actual": 0} + for kid, per, plan in sorted(eff, key=lambda x: (x[0], x[1])): + kpi = kpis.get(kid) + if kpi is None: + continue + budget = plan.budget_value or 0 + actual = actuals.get((kid, per)) + direction = _direction_of(kpi, higher_better) + level, rate, deviation_rate = "no_actual", None, None + if actual is not None and budget: + rate = round(actual / budget * 100, 2) + deviation_rate = round((actual - budget) / abs(budget) * 100, 2) + if direction == "higher": + level = "over" if deviation_rate <= -threshold else ("warn" if deviation_rate <= -threshold * 0.8 else "ok") + else: + level = "over" if deviation_rate >= threshold else ("warn" if deviation_rate >= threshold * 0.8 else "ok") + summary["total"] += 1 + summary[level] = summary.get(level, 0) + 1 + items.append({ + "kpi_id": kid, + "kpi_code": kpi.kpi_code, + "kpi_name": kpi.kpi_name, + "unit": kpi.unit or "", + "period": per, + "basis_version": plan.version, + "basis_status": plan.status, + "direction": direction, + "budget_value": budget, + "actual_value": actual, + "execution_rate": rate, + "deviation_rate": deviation_rate, + "control_level": level, + }) + return {"year": y, "period": period, "threshold_pct": threshold, "summary": summary, "items": items} + + +@router.get("/assessment") +def get_budget_assessment( + year: Optional[int] = Query(None), + db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), +): + """预算考核(缺失环「考核」最小骨架,只读):生效预算 vs 实际 → 达成率 + 等级 A/B/C/D。""" + from app.utils.deviation_engine import get_higher_better_codes + y = year or datetime.now().year + periods = [f"{y}-{m:02d}" for m in range(1, 13)] + + eff = _effective_rows_for_periods(db, entity_id, periods) + kpi_ids = {kid for kid, _p, _r in eff} + kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(kpi_ids)).all()} if kpi_ids else {} + actuals = {} + if kpi_ids: + for v in db.query(KPIValue).filter( + KPIValue.kpi_id.in_(kpi_ids), + KPIValue.period.in_(periods), + KPIValue.actual_value.isnot(None), + actual_only_clause(), + ).all(): + actuals[(v.kpi_id, v.period)] = v.actual_value + higher_better = set(get_higher_better_codes(db, entity_id)) + + agg = {} + for kid, per, plan in eff: + row = agg.setdefault(kid, {"budget_total": 0.0, "actual_total": 0.0, "periods": set(), "basis_version": plan.version, + "basis_status": plan.status, "_ts": plan.updated_at}) + row["budget_total"] += plan.budget_value or 0 + row["periods"].add(per) + if plan.updated_at and (row["_ts"] is None or plan.updated_at > row["_ts"]): + row["_ts"] = plan.updated_at + row["basis_version"] = plan.version + row["basis_status"] = plan.status + if (kid, per) in actuals: + row["actual_total"] += actuals[(kid, per)] or 0 + + items = [] + summary = {"kpi_count": 0, "a": 0, "b": 0, "c": 0, "d": 0, "na": 0, + "total_budget": 0.0, "total_actual": 0.0} + for kid, row in sorted(agg.items()): + kpi = kpis.get(kid) + if kpi is None: + continue + direction = _direction_of(kpi, higher_better) + bt, at = round(row["budget_total"], 2), round(row["actual_total"], 2) + achieve, grade = None, "NA" + if bt and row["periods"]: + achieve = round((at / bt * 100) if direction == "higher" else (bt / at * 100 if at else 0.0), 2) + grade = "A" if achieve >= 100 else ("B" if achieve >= 90 else ("C" if achieve >= 80 else "D")) + summary["kpi_count"] += 1 + summary[grade.lower()] = summary.get(grade.lower(), 0) + 1 + summary["total_budget"] += bt + summary["total_actual"] += at + items.append({ + "kpi_id": kid, + "kpi_code": kpi.kpi_code, + "kpi_name": kpi.kpi_name, + "unit": kpi.unit or "", + "direction": direction, + "basis_version": row["basis_version"], + "basis_status": row["basis_status"], + "period_count": len(row["periods"]), + "budget_total": bt, + "actual_total": at, + "achieve_rate": achieve, + "grade": grade, + }) + summary["total_budget"] = round(summary["total_budget"], 2) + summary["total_actual"] = round(summary["total_actual"], 2) + summary["achieve_rate"] = round(summary["total_actual"] / summary["total_budget"] * 100, 2) if summary["total_budget"] else None + return {"year": y, "summary": summary, "items": items} + + +@router.post("/versions/release") +def release_budget_version( + data: dict, + db: Session = Depends(get_db), + current_user=Depends(require_auth), + entity_id: int = Depends(get_entity_id), +): + """预算下达(缺失环「下达」):已批准版本 → 已下达(active)。fail-closed + 幂等。""" + version = (data.get("version") or "").strip() + if not version: + raise HTTPException(400, "需要 version") + rows = db.query(BudgetPlan).filter( + BudgetPlan.entity_id == entity_id, + BudgetPlan.version == version, + ).all() + if not rows: + raise HTTPException(404, f"版本 {version} 不存在") + approved = [r for r in rows if (r.status or "") == "approved"] + if not approved: + # 幂等判据 = 「本版本确有下达历史」(operation_logs 记录),而不是 active 现状: + # 迁移前遗留的 active 行 = 在用/草稿态,不能当成「已下达」(设计件 §1.2/§1.3)。 + released_before = any( + isinstance(l.detail, dict) + and l.detail.get("op") == "release" + and l.detail.get("version") == version + for l in db.query(OperationLog) + .filter(OperationLog.target_type == "budget_version") + .order_by(OperationLog.id.desc()) + .limit(500) + .all() + ) + if released_before and any((r.status or "") == "active" for r in rows): + return {"success": True, "version": version, "status": "active", "count": 0, "already": True} + raise HTTPException(400, f"版本 {version} 不可下达(需 approved,当前: {sorted({r.status for r in rows})})") + for r in approved: + r.status = "active" + log_op(db, "update", target_type="budget_version", target_id=None, + detail={"op": "release", "version": version, "count": len(approved), + "from_status": "approved", "to_status": "active"}, + entity_id=entity_id) + db.commit() + return {"success": True, "version": version, "status": "active", "count": len(approved), "already": False} + + +@router.post("/adjustments") +def create_budget_adjustment( + data: dict, + db: Session = Depends(get_db), + current_user=Depends(require_auth), + entity_id: int = Depends(get_entity_id), +): + """预算调整(缺失环「调整」最小骨架):基于生效基线版本派生新版本(draft),原版本零改动。""" + base_version = (data.get("base_version") or "").strip() + new_version = (data.get("new_version") or "").strip() + reason = (data.get("reason") or "").strip() + items = data.get("items") or [] + if not base_version or not new_version: + raise HTTPException(400, "需要 base_version 与 new_version") + if new_version == base_version: + raise HTTPException(400, "new_version 不能与 base_version 相同") + if not reason: + raise HTTPException(400, "调整单必须填写 reason(可追溯)") + if db.query(BudgetPlan).filter( + BudgetPlan.entity_id == entity_id, BudgetPlan.version == new_version + ).first(): + raise HTTPException(400, f"版本 {new_version} 已存在(调整单只允许派生新版本,不覆盖)") + base_rows = db.query(BudgetPlan).filter( + BudgetPlan.entity_id == entity_id, + BudgetPlan.version == base_version, + BudgetPlan.status.in_(EFFECTIVE_STATUSES), + ).all() + if not base_rows: + raise HTTPException(404, f"基线版本 {base_version} 不存在或非生效版") + + overrides = {} + for it in items: + kid, per = it.get("kpi_id"), it.get("period") + if kid is None or not per or it.get("budget_value") is None: + raise HTTPException(400, "items 每项需 kpi_id / period / budget_value") + overrides[(int(kid), per)] = float(it["budget_value"]) + + operator = current_user.name if hasattr(current_user, "name") else "" + applied, unmatched = 0, len(overrides) + for r in base_rows: + key = (r.kpi_id, r.period) + if key in overrides: + val = overrides.pop(key) + applied += 1 + unmatched -= 1 + else: + val = r.budget_value + db.add(BudgetPlan( + entity_id=entity_id, + map_id=r.map_id, + kpi_id=r.kpi_id, + period=r.period, + budget_value=val, + budget_year=r.budget_year, + budget_month=r.budget_month, + version=new_version, + status="draft", + source_type=r.source_type or "manual", + calc_logic=r.calc_logic, + remark=f"调整单:{reason} 基线:{base_version}"[:500], + created_by=operator, + )) + log_op(db, "create", target_type="budget_adjustment", target_id=None, + detail={"base_version": base_version, "new_version": new_version, "reason": reason, + "copied": len(base_rows), "applied_overrides": applied, "unmatched_overrides": unmatched}, + entity_id=entity_id) + db.commit() + return {"success": True, "new_version": new_version, "base_version": base_version, "reason": reason, + "created": len(base_rows), "applied_overrides": applied, "unmatched_overrides": unmatched, + "status": "draft", "note": "调整版本已生成为 draft,需提交审批 → 批准 → 下达后方可作为执行基准"} diff --git a/backend/app/utils/budget_status.py b/backend/app/utils/budget_status.py new file mode 100644 index 00000000..ca9932dd --- /dev/null +++ b/backend/app/utils/budget_status.py @@ -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 diff --git a/backend/app/utils/deviation_engine.py b/backend/app/utils/deviation_engine.py index 2c2e96d7..dc73b573 100644 --- a/backend/app/utils/deviation_engine.py +++ b/backend/app/utils/deviation_engine.py @@ -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 diff --git a/backend/tests/test_budget.py b/backend/tests/test_budget.py index 61ae818d..96e4e39e 100644 --- a/backend/tests/test_budget.py +++ b/backend/tests/test_budget.py @@ -186,6 +186,15 @@ class TestBudgetVersions: json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 10000.0}, ) + # 状态机口径归一(2026-10-03):审批只接受 submitted 行 → 先提交 + sub = client.post( + f"{self.BASE}/versions/submit", + headers=auth_header(token), + json={"version": "v1.0"}, + ) + assert sub.status_code == 200 + assert sub.json()["count"] == 1 + resp = client.post( f"{self.BASE}/versions/approve", headers=auth_header(token), @@ -205,6 +214,14 @@ class TestBudgetVersions: json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 10000.0}, ) + # 状态机口径归一(2026-10-03):审批只接受 submitted 行 → 先提交 + sub = client.post( + f"{self.BASE}/versions/submit", + headers=auth_header(token), + json={"version": "v1.0"}, + ) + assert sub.status_code == 200 + resp = client.post( f"{self.BASE}/versions/approve", headers=auth_header(token), diff --git a/backend/tests/test_budget_status_machine.py b/backend/tests/test_budget_status_machine.py new file mode 100644 index 00000000..e7b4d78f --- /dev/null +++ b/backend/tests/test_budget_status_machine.py @@ -0,0 +1,277 @@ +# -*- 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" diff --git a/docs/budget-module-governance-design.md b/docs/budget-module-governance-design.md new file mode 100644 index 00000000..c56a81ef --- /dev/null +++ b/docs/budget-module-governance-design.md @@ -0,0 +1,199 @@ +# 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 迁移方案与兼容 + +- 只改 `` 内 `` 的**出现顺序**与标签文案,`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` 记 `调整单: 基线:` → 写 `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、零部署、零重启、零写库。 diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index e32ee205..feaa64e1 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -263,6 +263,11 @@ export const budgetApi = { cashUnclassified: (params?: any) => api.get('/budget/cash-unclassified', { params }), classifyCashUnclassified: (id: number, data: any) => api.post(`/budget/cash-unclassified/${id}/classify`, data), ignoreCashUnclassified: (id: number) => api.post(`/budget/cash-unclassified/${id}/ignore`), + // 预算治理四环(budget-governance 2026-10-03):控制状态 / 下达 / 调整 / 考核 + versionRelease: (data: any) => api.post('/budget/versions/release', data), + adjustments: (data: any) => api.post('/budget/adjustments', data), + assessment: (params?: any) => api.get('/budget/assessment', { params }), + controlStatus: (params?: any) => api.get('/budget/control/status', { params }), } export const costApi = { diff --git a/frontend/src/components/BudgetCollectPanel.vue b/frontend/src/components/BudgetCollectPanel.vue new file mode 100644 index 00000000..b479b72d --- /dev/null +++ b/frontend/src/components/BudgetCollectPanel.vue @@ -0,0 +1,288 @@ + + + diff --git a/frontend/src/components/CashClassifyPanel.vue b/frontend/src/components/CashClassifyPanel.vue new file mode 100644 index 00000000..a341ab27 --- /dev/null +++ b/frontend/src/components/CashClassifyPanel.vue @@ -0,0 +1,175 @@ + + + diff --git a/frontend/src/views/BudgetManagement.vue b/frontend/src/views/BudgetManagement.vue index 64082313..8b2f636b 100644 --- a/frontend/src/views/BudgetManagement.vue +++ b/frontend/src/views/BudgetManagement.vue @@ -15,104 +15,6 @@
- -
- 选择战略地图 - - - - 已选择 - 共 {{ total }} 条KPI -
-
- - - - - - - 查询 - + 新增预算 - 年度分解 - - KPI列表维度汇总 - - 批量保存 ({{ changedRows.length }}) - 取消 - -
- 预算模式: - - {{ budgetMode === 'rolling' ? '滚动预算(滚动12月)' : '固定预算(固定年度)' }} - - 系统级设置,影响全部账套 · 更改请到【系统设置】 -
- 延展 -
- - -
- - - - - - - 均分(1/12)按历史权重 - - - - - - - - - -

请先在预算录入中选择战略地图

- -
- - 刷新 -
- - - - - - - - - - - - -
对比 {{ selectedVersions[0].version }} vs {{ selectedVersions[1].version }}
-
- - -
- - - - 刷新 - ⇄ 同步现金流计划 -
- -
{{ card.label }}
{{ card.value }}
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
暂无待分类KPI,同步现金流计划后未命中规则的KPI会出现在这里
-
-
-
-
-

选择预算编制方法 — CMA P1 预算编制(增量预算 vs 零基预算 vs 弹性预算)

@@ -380,94 +180,263 @@ - + +
+ 选择战略地图 + + + + 已选择 + 共 {{ total }} 条KPI +
+
+ + + + + + + 查询 + + 新增预算 + 年度分解 + + KPI列表维度汇总 + + 批量保存 ({{ changedRows.length }}) + 取消 + +
+ 预算模式: + + {{ budgetMode === 'rolling' ? '滚动预算(滚动12月)' : '固定预算(固定年度)' }} + + 系统级设置,影响全部账套 · 更改请到【系统设置】 +
+ 延展 +
+ + +
+ + + + + + + 均分(1/12)按历史权重 + + + + + + + + + + + +
+ + 刷新 +
+ + + + + + + + + + + + +
对比 {{ selectedVersions[0].version }} vs {{ selectedVersions[1].version }}
+
+ + +
+ + 刷新 +
+ + + + + + + + + + + + +
确认将版本 {{ releaseTarget?.version }} 下达为生效执行版本?
+
下达后该版本状态由 approved 变为 active。
+ +
+
+ + + + +
+ KPI总数:{{ controlData.summary?.total ?? 0 }} + 正常:{{ controlData.summary?.ok ?? 0 }} + 关注:{{ controlData.summary?.warn ?? 0 }} + 超支:{{ controlData.summary?.over ?? 0 }} +
+ + + + + + + + + +
暂无控制状态数据
+
+ +
+ + + + 刷新 +
- -
{{ c.label }}
{{ c.value }}
-
+
{{ card.label }}
{{ card.value }}
- - -
- - - - + 新建映射 - ▶ 立即采集 - 刷新 -
- - - - - - - - - - - - - - - - - - - -
+ + + + + + + + + + + + +
- -
- - 已自动归集 - 需人工确认 - 全部 - - 对账页将按数据来源分类显示,自动归集数据可追溯采集批次 -
- - - - - - - - - - - -
+ +
+ + 刷新 +
+ + + + + + + + +
暂无差异数据
+
- -
- - + + + + + + + - 查询 + + + + + 生成调整版本 + + + + + +
+ + 刷新 +
+
+ +
+
{{ c.label }}
+
{{ c.value }}
- - - - - - - - - - - - - - +
+
+ + + + + + + + +
@@ -801,61 +770,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 元→万元填 0.0001 - - - - - - - - - - - - - - - 收付 - - - - -
@@ -878,21 +792,6 @@ const filterYear = ref(currentYear); const filterMonth = ref(0); const searchKpi const loading = ref(false); const budgetList = ref([]); const page = ref(1); const pageSize = ref(20); const total = ref(0); const noMap = ref(false) const batchEditMode = ref(false); const batchSavingAll = ref(false) -// ── 现金流联动(断点修复#1, 2026-08-27) ── -const syncingCash = ref(false) -async function syncCashPlans() { - syncingCash.value = true - try { - const r: any = await api.post('/budget/sync-cash-plans') - ElMessage.success(r.message || "现金流计划已同步") - loadExecutionReport() - } catch (e: any) { - ElMessage.error("同步失败: " + (e?.message || "")) - } finally { - syncingCash.value = false - } -} - // ── 战略地图选择 ── const publishedMaps = ref([]) const selectedMapId = ref(null) @@ -1501,210 +1400,147 @@ async function deleteDerivationRule(row: any) { } catch { } } + // ══════════════════════════════════════════════ -// P1-④ 实际值自动归集 +// 预算控制状态(GET /budget/control/status) // ══════════════════════════════════════════════ -const collectTab = ref('mappings') -const collectKpiId = ref(null) -const collectCoverageCards = ref([]) -const valueSources = ref([]) -const showValueSourceDialog = ref(false) -const valueSourceForm = ref({}) -const collectRunning = ref(false) -const collectLogStatus = ref('') -const collectLogs = ref([]) -const valueTagFilter = ref('auto_collect') -const valueTagRows = ref([]) -async function loadCollectData() { - await Promise.all([loadValueSources(), loadCollectCoverage(), loadValueTags(), loadCollectLogs()]) -} -async function loadValueSources() { +const controlLoading = ref(false) +const controlData = ref(null) +const controlItems = ref([]) +async function loadControlStatus() { + controlLoading.value = true try { - const params: any = { entity_id: getEntityId() } - if (collectKpiId.value) params.kpi_id = collectKpiId.value - const r: any = await budgetApi.valueSources(params) - valueSources.value = r.data || [] - } catch { valueSources.value = [] } -} -async function loadCollectCoverage() { - try { - const r: any = await budgetApi.valueSourceCoverage({ entity_id: getEntityId() }) - const c = r.data || r || {} - collectCoverageCards.value = [ - { label: '已配映射KPI', value: c.mapped_count ?? 0, color: '#409eff' }, - { label: '活跃KPI总数', value: c.total_kpis ?? 0, color: '#606266' }, - { label: '覆盖率', value: `${c.coverage_pct ?? 0}%`, color: '#67c23a' }, - { label: '未配置KPI', value: c.unmapped_count ?? 0, color: '#e6a23c' }, - ] - } catch { collectCoverageCards.value = [] } -} -async function loadValueTags() { - try { - const r: any = await kpiApi.list({ page_size: 100, entity_id: getEntityId() }) - const d = r.data || r || [] - const kpis = Array.isArray(d) ? d : (d.items || []) - const rows: any[] = [] - for (const k of kpis) { - const vr: any = await kpiApi.values(k.id, { entity_id: getEntityId() }).catch(() => null) - const vals = vr?.data || [] - for (const v of (Array.isArray(vals) ? vals : (vals.items || []))) { - rows.push({ kpi_code: k.kpi_code, kpi_name: k.kpi_name, period: v.period, actual_value: v.actual_value, source_type: v.source_type || 'manual', source_batch: v.source_batch || '', remark: v.remark || '' }) - } - } - const recent = rows.filter((r: any) => r.actual_value != null).sort((a: any, b: any) => (b.period || '').localeCompare(a.period || '')).slice(0, 100) - valueTagRows.value = valueTagFilter.value === 'all' ? recent : recent.filter((r: any) => (valueTagFilter.value === 'auto_collect' ? r.source_type === 'auto_collect' : r.source_type !== 'auto_collect')) - } catch { valueTagRows.value = [] } -} -async function loadCollectLogs() { - try { - const params: any = { entity_id: getEntityId() } - if (collectLogStatus.value) params.status = collectLogStatus.value - const r: any = await budgetApi.valueCollectLogs(params) - collectLogs.value = r.data || [] - } catch { collectLogs.value = [] } -} -function openValueSourceDialog() { - valueSourceForm.value = { kpi_id: null, source_table: 'voucher_details', source_field: 'credit_amount', aggregate: 'sum', direction: '', subject_code: '', period_field: 'period', unit_conversion: 1 } - showValueSourceDialog.value = true -} -async function saveValueSource() { - const f = valueSourceForm.value - if (!f.kpi_id) { ElMessage.warning('请选择KPI'); return } - const filter: any = {} - if (f.direction) filter.direction = f.direction - if (f.subject_code) filter.subject_code = f.subject_code - try { - await budgetApi.createValueSource({ - kpi_id: f.kpi_id, - source_table: f.source_table, - source_field: f.source_field, - aggregate: f.aggregate, - filter_rule: Object.keys(filter).length ? filter : null, - period_field: f.period_field, - unit_conversion: f.unit_conversion, - entity_id: getEntityId(), - }) - ElMessage.success('映射已创建') - showValueSourceDialog.value = false - loadValueSources() - loadCollectCoverage() - } catch (e: any) { ElMessage.error(e?.response?.data?.detail || '创建失败') } -} -async function deleteValueSource(row: any) { - try { - await ElMessageBox.confirm(`确认删除映射(${row.kpi_name} ← ${row.source_table})?`, '确认') - await budgetApi.deleteValueSource(row.id) - ElMessage.success('已删除') - loadValueSources() - loadCollectCoverage() - } catch { } -} -async function toggleValueSource(row: any) { - try { - await budgetApi.updateValueSource(row.id, { status: row.status === 'active' ? 'inactive' : 'active' }) - row.status = row.status === 'active' ? 'inactive' : 'active' - } catch { ElMessage.error('操作失败') } -} -async function testValueSource(row: any) { - try { - const r: any = await budgetApi.testValueSource({ - kpi_id: row.kpi_id, - source_table: row.source_table, - source_field: row.source_field, - aggregate: row.aggregate, - filter_rule: row.filter_rule, - period_field: row.period_field, - unit_conversion: row.unit_conversion, - period: zbbPeriod.value, - }) - const d = r.data || r - if (d.success) ElMessage.success(`试跑值: ${formatNumber(d.value)} (${d.message || ''})`) - else ElMessage.error(`试跑失败: ${d.message || ''}`) - } catch { ElMessage.error('试跑失败') } -} -async function runValueCollect() { - collectRunning.value = true - try { - const r: any = await budgetApi.runValueCollect({ period: zbbPeriod.value, entity_id: getEntityId() }) - const d = r.data || r - ElMessage.success(`采集完成: 成功${d.collected ?? 0} 失败${d.failed ?? 0}`) - loadCollectData() - } catch { ElMessage.error('采集失败') } - collectRunning.value = false + const r: any = await budgetApi.controlStatus({ year: execFilterYear.value, entity_id: getEntityId() }) + const d = r.data || r || {} + controlData.value = d + controlItems.value = d.items || [] + } catch { controlData.value = null; controlItems.value = [] } + controlLoading.value = false } // ══════════════════════════════════════════════ -// P2-⑥ 现金流分类规则 + 待分类队列 +// 预算下达(GET /budget/versions + POST /budget/versions/release) // ══════════════════════════════════════════════ -const cashClassifyRules = ref([]) -const showClassifyRuleDialog = ref(false) -const classifyRuleForm = ref({}) -const unclassifiedRows = ref([]) -const unclassifiedCount = ref(0) -async function loadCashClassify() { +const releaseYear = ref(currentYear) +const releaseLoading = ref(false) +const releaseList = ref([]) +const showReleaseConfirm = ref(false) +const releaseTarget = ref(null) +const releasing = ref(false) +async function loadReleaseVersions() { + releaseLoading.value = true try { - const r: any = await budgetApi.cashClassifyRules({ entity_id: getEntityId() }) - cashClassifyRules.value = r.data || [] - } catch { cashClassifyRules.value = [] } - try { - const r: any = await budgetApi.cashUnclassified({ status: 'pending', entity_id: getEntityId() }) - unclassifiedRows.value = r.data || [] - unclassifiedCount.value = (r.data || []).length - } catch { unclassifiedRows.value = []; unclassifiedCount.value = 0 } + const r: any = await budgetApi.versions({ year: releaseYear.value, entity_id: getEntityId() }) + const d = Array.isArray(r) ? r : (r.data || []) + releaseList.value = d + } catch { ElMessage.error('加载版本列表失败') } + releaseLoading.value = false } -function openClassifyRuleDialog() { - classifyRuleForm.value = { kpi_id: null, kpi_code_pattern: '', plan_type: 'receive', priority: 10 } - showClassifyRuleDialog.value = true -} -async function saveClassifyRule() { - const f = classifyRuleForm.value - if (!f.kpi_id && !f.kpi_code_pattern) { ElMessage.warning('请选择KPI或填写关键词'); return } +function askRelease(row: any) { releaseTarget.value = row; showReleaseConfirm.value = true } +async function doRelease() { + const row = releaseTarget.value + if (!row) return + releasing.value = true try { - await budgetApi.createCashClassifyRule({ - kpi_id: f.kpi_id || null, - kpi_code_pattern: f.kpi_code_pattern || null, - plan_type: f.plan_type, - priority: f.priority || 10, - entity_id: getEntityId(), - }) - ElMessage.success('规则已创建') - showClassifyRuleDialog.value = false - loadCashClassify() - } catch (e: any) { ElMessage.error(e?.response?.data?.detail || '创建失败') } + const r: any = await budgetApi.versionRelease({ version: row.version }) + ElMessage.success(r.already ? `版本「${row.version}」已是下达态` : `版本「${row.version}」已下达(${r.count ?? 0} 条)`) + showReleaseConfirm.value = false + loadReleaseVersions() + } catch (e: any) { + ElMessage.error(e?.response?.data?.detail || e?.message || '下达失败') + } + releasing.value = false } -async function deleteClassifyRule(row: any) { + +// ══════════════════════════════════════════════ +// 差异分析(GET /budget/deviation-report) +// ══════════════════════════════════════════════ +const deviationYear = ref(currentYear) +const deviationLoading = ref(false) +const deviationItems = ref([]) +async function loadDeviationReport() { + deviationLoading.value = true try { - await ElMessageBox.confirm('确认删除该分类规则?', '确认') - await budgetApi.deleteCashClassifyRule(row.id) - ElMessage.success('已删除') - loadCashClassify() - } catch { } + const r: any = await budgetApi.deviationReport({ year: deviationYear.value, entity_id: getEntityId() }) + deviationItems.value = r.items || [] + } catch { deviationItems.value = [] } + deviationLoading.value = false } -async function classifyUnclassified(row: any, planType: string) { + +// ══════════════════════════════════════════════ +// 预算调整(POST /budget/adjustments) +// ══════════════════════════════════════════════ +const adjustForm = ref({ base_version: '', new_version: '', reason: '' }) +const adjustBaseVersions = ref([]) +const adjusting = ref(false) +const adjustResult = ref('') +async function loadAdjustBaseVersions() { try { - const r: any = await budgetApi.classifyCashUnclassified(row.id, { plan_type: planType }) - ElMessage.success(r.message || `已归类为${planType === 'receive' ? '收' : '付'}`) - loadCashClassify() - } catch (e: any) { ElMessage.error(e?.response?.data?.detail || '归类失败') } + const r: any = await budgetApi.versions({ entity_id: getEntityId() }) + const d = Array.isArray(r) ? r : (r.data || []) + adjustBaseVersions.value = d.filter((v: any) => v.status === 'approved' || v.status === 'active') + } catch { adjustBaseVersions.value = [] } } -async function ignoreUnclassified(row: any) { +async function doAdjust() { + const f = adjustForm.value + if (!f.base_version || !f.new_version) { ElMessage.warning('请选择基线版本并填写新版本号'); return } + adjusting.value = true try { - await budgetApi.ignoreCashUnclassified(row.id) - ElMessage.success('已忽略') - loadCashClassify() - } catch { ElMessage.error('操作失败') } + const r: any = await budgetApi.adjustments({ base_version: f.base_version, new_version: f.new_version, reason: f.reason, items: [] }) + adjustResult.value = `已生成调整版本 ${r.new_version}(新建 ${r.created ?? 0} 条 / 覆盖 ${r.applied_overrides ?? 0} 条${r.unmatched_overrides ? ` / 未匹配 ${r.unmatched_overrides} 条` : ''})` + ElMessage.success('调整版本已生成') + adjustForm.value = { base_version: '', new_version: '', reason: '' } + loadAdjustBaseVersions() + } catch (e: any) { + ElMessage.error(e?.response?.data?.detail || e?.message || '生成调整版本失败') + } + adjusting.value = false +} + +// ══════════════════════════════════════════════ +// 预算考核(GET /budget/assessment) +// ══════════════════════════════════════════════ +const assessYear = ref(currentYear) +const assessLoading = ref(false) +const assessData = ref(null) +const assessItems = ref([]) +const assessCards = computed(() => { + const s = assessData.value?.summary || {} + return [ + { label: 'KPI数', value: s.kpi_count ?? 0, color: '#409eff' }, + { label: 'A', value: s.a ?? 0, color: '#67c23a' }, + { label: 'B', value: s.b ?? 0, color: '#409eff' }, + { label: 'C', value: s.c ?? 0, color: '#e6a23c' }, + { label: 'D', value: s.d ?? 0, color: '#f56c6c' }, + { label: '总预算', value: formatNumber(s.total_budget), color: '#606266' }, + { label: '总实际', value: formatNumber(s.total_actual), color: '#606266' }, + { label: '达成率', value: s.achieve_rate != null ? `${s.achieve_rate}%` : '--', color: '#e6a23c' }, + ] +}) +async function loadAssessment() { + assessLoading.value = true + try { + const r: any = await budgetApi.assessment({ year: assessYear.value, entity_id: getEntityId() }) + const d = r.data || r || {} + assessData.value = d + assessItems.value = d.items || [] + } catch { assessData.value = null; assessItems.value = [] } + assessLoading.value = false } watch(activeTab, (tab) => { if (tab === 'decompose') loadBudget() else if (tab === 'strategy') loadStrategyBudget() else if (tab === 'versions') loadVersions() - else if (tab === 'execution') { loadExecutionReport(); loadCashClassify() } + else if (tab === 'execution') { loadExecutionReport(); loadControlStatus() } else if (tab === 'method') { refreshMethodComparison(); loadDerivationRules() } - else if (tab === 'collect') loadCollectData() else if (tab === 'driver') { // DriverFactorBudget handles its own loading on mount } + else if (tab === 'release') loadReleaseVersions() + else if (tab === 'deviation') loadDeviationReport() + else if (tab === 'adjust') loadAdjustBaseVersions() + else if (tab === 'assess') loadAssessment() else if (tab === 'rolling') { loadComparison() loadDeviationAlerts() diff --git a/frontend/src/views/CashPlan.vue b/frontend/src/views/CashPlan.vue index 280e26bc..f9d2d512 100644 --- a/frontend/src/views/CashPlan.vue +++ b/frontend/src/views/CashPlan.vue @@ -272,6 +272,11 @@
+ + + + +
@@ -316,6 +321,7 @@ import { ref, computed, reactive, onMounted, onBeforeUnmount, nextTick } from 'v import { ElMessage, ElMessageBox } from 'element-plus' import { cashApi } from '../api/index' import MyDialog from '../components/MyDialog.vue' +import CashClassifyPanel from '../components/CashClassifyPanel.vue' const entityId = Number(localStorage.getItem('cma_entity_id') || 1) diff --git a/frontend/src/views/DataManage.vue b/frontend/src/views/DataManage.vue index bba522b4..ae86e94b 100644 --- a/frontend/src/views/DataManage.vue +++ b/frontend/src/views/DataManage.vue @@ -99,6 +99,11 @@ + + + + + @@ -142,6 +147,7 @@ import { ElMessage, ElMessageBox } from 'element-plus' import { UploadFilled } from '@element-plus/icons-vue' import { dataApi, budgetApi } from '../api/index' import MyDialog from '../components/MyDialog.vue' +import BudgetCollectPanel from '../components/BudgetCollectPanel.vue' const activeTab = ref('import')