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:
Hermes CI Fix
2026-10-18 16:32:55 +08:00
parent 75a48f55d2
commit fa1267c323
12 changed files with 1777 additions and 549 deletions
+334 -17
View File
@@ -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,需提交审批 → 批准 → 下达后方可作为执行基准"}
+100
View File
@@ -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
+4 -2
View File
@@ -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
+17
View File
@@ -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),
+277
View File
@@ -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"
+199
View File
@@ -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 迁移方案与兼容
- 只改 `<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、零部署、零重启、零写库。
+5
View File
@@ -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 = {
@@ -0,0 +1,288 @@
<template>
<div>
<el-row :gutter="16" style="margin-bottom:16px;">
<el-col :span="6" v-for="c in collectCoverageCards" :key="c.label">
<el-card shadow="hover"><div style="text-align:center;"><div style="font-size:12px;color:#999;">{{ c.label }}</div><div style="font-size:22px;font-weight:600;margin-top:4px;" :style="{color: c.color}">{{ c.value }}</div></div></el-card>
</el-col>
</el-row>
<el-tabs v-model="collectTab" type="border-card" style="margin-top:4px;">
<el-tab-pane label="取数映射" name="mappings">
<div style="display:flex;gap:8px;margin-bottom:12px;align-items:center;flex-wrap:wrap;">
<el-select v-model="collectKpiId" placeholder="选择KPI(可选)" filterable clearable style="width:220px;" @change="loadValueSources">
<el-option v-for="k in zbbKpiOptions" :key="k.id" :label="`${k.kpi_code} - ${k.kpi_name}`" :value="k.id" />
</el-select>
<el-button type="primary" @click="showValueSourceDialog = true">+ 新建映射</el-button>
<el-button type="warning" :loading="collectRunning" @click="runValueCollect">▶ 立即采集</el-button>
<el-button @click="loadCollectData">刷新</el-button>
</div>
<el-table :data="valueSources" border stripe size="small" style="width:100%;">
<el-table-column prop="kpi_code" label="KPI编码" width="100" />
<el-table-column prop="kpi_name" label="KPI名称" min-width="120" />
<el-table-column prop="source_table" label="源头表" width="160">
<template #default="{ row }"><el-tag size="small">{{ row.source_table }}</el-tag></template>
</el-table-column>
<el-table-column prop="source_field" label="金额字段" width="110" />
<el-table-column prop="aggregate" label="聚合" width="70" />
<el-table-column label="过滤规则" width="140">
<template #default="{ row }"><span style="font-size:12px;">{{ JSON.stringify(row.filter_rule || {}) }}</span></template>
</el-table-column>
<el-table-column prop="unit_conversion" label="倍率" width="60" />
<el-table-column label="状态" width="80">
<template #default="{ row }"><el-tag size="small" :type="row.status === 'active' ? 'success' : 'info'">{{ row.status === 'active' ? '启用' : '停用' }}</el-tag></template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button size="small" link type="primary" @click="testValueSource(row)">试跑</el-button>
<el-button size="small" link type="warning" @click="toggleValueSource(row)">{{ row.status === 'active' ? '停用' : '启用' }}</el-button>
<el-button size="small" link type="danger" @click="deleteValueSource(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="实际值标签" name="values">
<div style="display:flex;gap:8px;margin-bottom:12px;align-items:center;">
<el-radio-group v-model="valueTagFilter" size="small">
<el-radio-button value="auto_collect">已自动归集</el-radio-button>
<el-radio-button value="manual">需人工确认</el-radio-button>
<el-radio-button value="all">全部</el-radio-button>
</el-radio-group>
<span style="font-size:12px;color:#999;">对账页将按数据来源分类显示,自动归集数据可追溯采集批次</span>
</div>
<el-table :data="valueTagRows" border stripe size="small" style="width:100%;">
<el-table-column prop="kpi_code" label="KPI编码" width="100" />
<el-table-column prop="kpi_name" label="KPI名称" min-width="120" />
<el-table-column prop="period" label="期间" width="90" />
<el-table-column prop="actual_value" label="实际值" width="110"><template #default="{ row }">{{ formatNumber(row.actual_value) }}</template></el-table-column>
<el-table-column label="来源" width="130">
<template #default="{ row }">
<el-tag v-if="row.source_type === 'auto_collect'" size="small" type="success">已自动归集</el-tag>
<el-tag v-else-if="row.source_type === 'manual' || row.source_type === 'excel'" size="small" type="warning">需人工确认</el-tag>
<el-tag v-else size="small" type="info">{{ row.source_type || 'manual' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="source_batch" label="采集批次" width="170"><template #default="{ row }"><span style="font-size:12px;">{{ row.source_batch || '--' }}</span></template></el-table-column>
<el-table-column prop="remark" label="备注" min-width="150" />
</el-table>
</el-tab-pane>
<el-tab-pane label="采集日志" name="logs">
<div style="display:flex;gap:8px;margin-bottom:12px;align-items:center;">
<el-select v-model="collectLogStatus" placeholder="状态" clearable style="width:110px;" size="small">
<el-option label="成功" value="success" /><el-option label="失败" value="failed" />
</el-select>
<el-button size="small" type="primary" @click="loadCollectLogs">查询</el-button>
</div>
<el-table :data="collectLogs" border stripe size="small" style="width:100%;">
<el-table-column prop="kpi_code" label="KPI编码" width="100" />
<el-table-column prop="kpi_name" label="KPI名称" min-width="110" />
<el-table-column prop="period" label="期间" width="90" />
<el-table-column prop="source_table" label="源头表" width="140" />
<el-table-column prop="collected_value" label="采集值" width="100"><template #default="{ row }">{{ formatNumber(row.collected_value) }}</template></el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }"><el-tag size="small" :type="row.status === 'success' ? 'success' : 'danger'">{{ row.status === 'success' ? '成功' : '失败' }}</el-tag></template>
</el-table-column>
<el-table-column prop="message" label="说明" min-width="180" />
<el-table-column prop="collected_at" label="采集时间" width="160" />
</el-table>
</el-tab-pane>
</el-tabs>
</div>
<!-- P1-④ 取数映射新建弹窗 -->
<MyDialog v-model="showValueSourceDialog" title="新建取数映射" :width="560">
<el-form :model="valueSourceForm" label-width="100px">
<el-form-item label="目标KPI">
<el-select v-model="valueSourceForm.kpi_id" filterable style="width:100%;">
<el-option v-for="k in zbbKpiOptions" :key="k.id" :label="`${k.kpi_code} - ${k.kpi_name}`" :value="k.id" />
</el-select>
</el-form-item>
<el-form-item label="源头表">
<el-select v-model="valueSourceForm.source_table" style="width:100%;">
<el-option label="网银凭证明细 voucher_details" value="voucher_details" />
<el-option label="库存汇总 product_inventory" value="product_inventory" />
<el-option label="库存明细 product_inventory_detail" value="product_inventory_detail" />
<el-option label="收付款计划 cash_plans" value="cash_plans" />
</el-select>
</el-form-item>
<el-form-item label="金额字段">
<el-select v-model="valueSourceForm.source_field" style="width:100%;">
<el-option v-for="f in ['credit_amount','debit_amount','amount','qty','out_amount','in_amount','end_amount']" :key="f" :label="f" :value="f" />
</el-select>
</el-form-item>
<el-form-item label="聚合方式">
<el-select v-model="valueSourceForm.aggregate" style="width:120px;"><el-option v-for="a in ['sum','avg','count','max','min']" :key="a" :label="a" :value="a" /></el-select>
</el-form-item>
<el-form-item label="方向过滤">
<el-select v-model="valueSourceForm.direction" clearable placeholder="不限定" style="width:150px;">
<el-option label="贷方(收入)" value="credit" /><el-option label="借方(支出)" value="debit" />
</el-select>
</el-form-item>
<el-form-item label="科目过滤"><el-input v-model="valueSourceForm.subject_code" placeholder="如: 6601 销售费用" /></el-form-item>
<el-form-item label="期间字段">
<el-select v-model="valueSourceForm.period_field" style="width:150px;"><el-option label="period" value="period" /><el-option label="voucher_date" value="voucher_date" /></el-select>
</el-form-item>
<el-form-item label="单位倍率"><el-input-number v-model="valueSourceForm.unit_conversion" :min="0.0001" :precision="4" style="width:150px;" /><span style="font-size:12px;color:#999;margin-left:6px;">元→万元填 0.0001</span></el-form-item>
</el-form>
<template #footer><el-button @click="showValueSourceDialog = false">取消</el-button><el-button type="primary" @click="saveValueSource">保存</el-button></template>
</MyDialog>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { budgetApi, kpiApi } from '../api/index'
import MyDialog from './MyDialog.vue'
const currentYear = new Date().getFullYear()
const currentMonth = new Date().getMonth() + 1
function getEntityId() {
return Number(localStorage.getItem('cma_entity_id') || 1)
}
function formatNumber(v: any, unit?: string) { if (v === null || v === undefined) return '--'; const n = Number(v); const p = unit === '%' ? 2 : 0; return n.toLocaleString('zh-CN', { minimumFractionDigits: p, maximumFractionDigits: p }) + (unit ? ` ${unit}` : '') }
// ── 通用KPI选项 / 期间(自 BudgetManagement.vue 迁入,原为 method 与 collect 共用;此处组件内自带一份) ──
const zbbKpiOptions = ref<any[]>([])
async function loadKpiOptions() {
try {
const r: any = await kpiApi.list({ page_size: 200, entity_id: getEntityId() })
const d = r.data || r || []
zbbKpiOptions.value = Array.isArray(d) ? d : (d.items || [])
} catch { zbbKpiOptions.value = [] }
}
const zbbPeriod = ref(`${currentYear}-${String(currentMonth).padStart(2, '0')}`)
// ══════════════════════════════════════════════
// P1-④ 实际值自动归集
// ══════════════════════════════════════════════
const collectTab = ref('mappings')
const collectKpiId = ref<number | null>(null)
const collectCoverageCards = ref<any[]>([])
const valueSources = ref<any[]>([])
const showValueSourceDialog = ref(false)
const valueSourceForm = ref<any>({})
const collectRunning = ref(false)
const collectLogStatus = ref('')
const collectLogs = ref<any[]>([])
const valueTagFilter = ref('auto_collect')
const valueTagRows = ref<any[]>([])
async function loadCollectData() {
await Promise.all([loadValueSources(), loadCollectCoverage(), loadValueTags(), loadCollectLogs()])
}
async function loadValueSources() {
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
}
onMounted(() => { loadKpiOptions(); loadCollectData() })
</script>
@@ -0,0 +1,175 @@
<template>
<div>
<div style="display:flex;gap:8px;margin-bottom:12px;align-items:center;flex-wrap:wrap;">
<el-button type="warning" :loading="syncingCash" @click="syncCashPlans">⇄ 同步现金流计划</el-button>
<span style="font-size:12px;color:#999;">同步预算KPI到收付款计划后,未命中分类规则的KPI进入待分类队列</span>
</div>
<!-- P2-⑥ 现金流分类规则 + 待分类队列 -->
<el-row :gutter="16" style="margin-top:20px;">
<el-col :span="12">
<el-card shadow="never">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span><strong>现金流分类规则</strong> <span style="font-size:12px;color:#999;">(KPI→receive/pay 可维护)</span></span>
<el-button type="primary" size="small" @click="showClassifyRuleDialog = true">+ 新建规则</el-button>
</div>
</template>
<el-table :data="cashClassifyRules" border stripe size="small" style="width:100%;" max-height="240">
<el-table-column prop="kpi_code" label="KPI编码" width="110"><template #default="{ row }">{{ row.kpi_code || row.kpi_code_pattern || '--' }}</template></el-table-column>
<el-table-column prop="kpi_name" label="名称/关键词" min-width="110"><template #default="{ row }">{{ row.kpi_name || row.kpi_code_pattern || '--' }}</template></el-table-column>
<el-table-column label="类型" width="80">
<template #default="{ row }"><el-tag size="small" :type="row.plan_type === 'receive' ? 'success' : 'danger'">{{ row.plan_type === 'receive' ? '收' : '付' }}</el-tag></template>
</el-table-column>
<el-table-column prop="priority" label="优先级" width="70" />
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ row }"><el-button size="small" link type="danger" @click="deleteClassifyRule(row)">删除</el-button></template>
</el-table-column>
</el-table>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="never">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span><strong>待分类队列</strong> <span style="font-size:12px;color:#999;">(无法判别的KPI不再静默跳过)</span></span>
<el-badge :value="unclassifiedCount" :hidden="unclassifiedCount === 0" type="danger"><el-button size="small" @click="loadCashClassify">刷新</el-button></el-badge>
</div>
</template>
<el-table :data="unclassifiedRows" border stripe size="small" style="width:100%;" max-height="240">
<el-table-column prop="kpi_name" label="KPI" min-width="120" />
<el-table-column prop="period" label="期间" width="80" />
<el-table-column prop="budget_value" label="预算值" width="90"><template #default="{ row }">{{ formatNumber(row.budget_value) }}</template></el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button size="small" link type="success" @click="classifyUnclassified(row, 'receive')">收</el-button>
<el-button size="small" link type="danger" @click="classifyUnclassified(row, 'pay')">付</el-button>
<el-button size="small" link @click="ignoreUnclassified(row)">忽略</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="unclassifiedRows.length === 0" style="padding:16px 0;text-align:center;color:#999;font-size:13px;">暂无待分类KPI,同步现金流计划后未命中规则的KPI会出现在这里</div>
</el-card>
</el-col>
</el-row>
<!-- P2-⑥ 分类规则新建弹窗 -->
<MyDialog v-model="showClassifyRuleDialog" title="新建现金流分类规则" :width="500">
<el-form :model="classifyRuleForm" label-width="110px">
<el-form-item label="KPI(精确)">
<el-select v-model="classifyRuleForm.kpi_id" filterable clearable placeholder="或使用关键词" style="width:100%;">
<el-option v-for="k in zbbKpiOptions" :key="k.id" :label="`${k.kpi_code} - ${k.kpi_name}`" :value="k.id" />
</el-select>
</el-form-item>
<el-form-item label="关键词(兜底)"><el-input v-model="classifyRuleForm.kpi_code_pattern" placeholder="如: 保证金" /></el-form-item>
<el-form-item label="收付类型">
<el-radio-group v-model="classifyRuleForm.plan_type"><el-radio value="receive">收</el-radio><el-radio value="pay">付</el-radio></el-radio-group>
</el-form-item>
<el-form-item label="优先级"><el-input-number v-model="classifyRuleForm.priority" :min="1" :max="100" style="width:120px;" /></el-form-item>
</el-form>
<template #footer><el-button @click="showClassifyRuleDialog = false">取消</el-button><el-button type="primary" @click="saveClassifyRule">保存</el-button></template>
</MyDialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { budgetApi, kpiApi } from '../api/index'
import api from '../api/index'
import MyDialog from './MyDialog.vue'
function getEntityId() {
return Number(localStorage.getItem('cma_entity_id') || 1)
}
function formatNumber(v: any, unit?: string) { if (v === null || v === undefined) return '--'; const n = Number(v); const p = unit === '%' ? 2 : 0; return n.toLocaleString('zh-CN', { minimumFractionDigits: p, maximumFractionDigits: p }) + (unit ? ` ${unit}` : '') }
// ── 通用KPI选项(分类规则弹窗用) ──
const zbbKpiOptions = ref<any[]>([])
async function loadKpiOptions() {
try {
const r: any = await kpiApi.list({ page_size: 200, entity_id: getEntityId() })
const d = r.data || r || []
zbbKpiOptions.value = Array.isArray(d) ? d : (d.items || [])
} catch { zbbKpiOptions.value = [] }
}
// ── 现金流联动(自 BudgetManagement.vue 迁入;原实现引用了未定义的 api 变量,此处补上 axios 实例 import) ──
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 || "现金流计划已同步")
loadCashClassify()
} catch (e: any) {
ElMessage.error("同步失败: " + (e?.message || ""))
} finally {
syncingCash.value = false
}
}
// ══════════════════════════════════════════════
// P2-⑥ 现金流分类规则 + 待分类队列
// ══════════════════════════════════════════════
const cashClassifyRules = ref<any[]>([])
const showClassifyRuleDialog = ref(false)
const classifyRuleForm = ref<any>({})
const unclassifiedRows = ref<any[]>([])
const unclassifiedCount = ref(0)
async function loadCashClassify() {
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 }
}
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 }
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 || '创建失败') }
}
async function deleteClassifyRule(row: any) {
try {
await ElMessageBox.confirm('确认删除该分类规则?', '确认')
await budgetApi.deleteCashClassifyRule(row.id)
ElMessage.success('已删除')
loadCashClassify()
} catch { }
}
async function classifyUnclassified(row: any, planType: string) {
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 || '归类失败') }
}
async function ignoreUnclassified(row: any) {
try {
await budgetApi.ignoreCashUnclassified(row.id)
ElMessage.success('已忽略')
loadCashClassify()
} catch { ElMessage.error('操作失败') }
}
onMounted(() => { loadKpiOptions(); loadCashClassify() })
</script>
+366 -530
View File
@@ -15,104 +15,6 @@
</div>
<div v-else>
<el-tabs v-model="activeTab" style="margin-top:16px;">
<el-tab-pane label="预算录入" name="input">
<div style="margin-bottom:8px;font-size:12px;color:#909399;display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
选择战略地图
<el-select v-model="selectedMapId" placeholder="选择已发布的战略地图" style="width:260px;" @change="onMapSelect">
<el-option v-for="m in publishedMaps" :key="m.id" :label="m.title" :value="m.id" />
</el-select>
<el-tag v-if="selectedMapId" size="small" type="success">已选择</el-tag>
<span style="color:#999;">共 {{ total }} 条KPI</span>
</div>
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;align-items:center;">
<el-select v-model="filterYear" placeholder="年份" style="width:100px;" @change="loadBudget"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select>
<el-select v-model="filterVersion" placeholder="版本" style="width:180px;" @change="loadBudget" clearable>
<el-option v-for="v in versionsList" :key="v.version" :label="v.version + (v.status === 'active' ? ' (当前)' : '')" :value="v.version" />
</el-select>
<el-select v-model="filterMonth" placeholder="月份" style="width:90px;" @change="loadBudget"><el-option label="全年" :value="0" /><el-option v-for="m in 12" :key="m" :label="`${m}月`" :value="m" /></el-select>
<el-input v-model="searchKpi" placeholder="搜索KPI名称" clearable style="width:200px;" @clear="loadBudget" @keyup.enter="loadBudget" />
<el-button type="primary" @click="loadBudget">查询</el-button>
<el-button type="success" @click="showAddBudget = true">+ 新增预算</el-button>
<el-button @click="showDecompose = true" :disabled="!filterYear">年度分解</el-button>
<el-divider direction="vertical" />
<el-radio-group v-model="budgetViewMode" size="small" @change="onViewModeChange"><el-radio-button value="list">KPI列表</el-radio-button><el-radio-button value="summary">维度汇总</el-radio-button></el-radio-group>
<el-switch v-model="batchEditMode" active-text="批量编辑" inactive-text="逐行编辑" @change="onBatchEditToggle" />
<el-button v-if="batchEditMode" type="primary" @click="batchSaveAll" :loading="batchSavingAll" :disabled="changedRows.length === 0">批量保存 ({{ changedRows.length }})</el-button>
<el-button v-if="batchEditMode" @click="batchCancelAll">取消</el-button>
<el-divider direction="vertical" />
<div style="display:flex;align-items:center;gap:6px;font-size:12px;">
<span style="color:#909399;">预算模式:</span>
<el-tag size="small" :type="budgetMode === 'rolling' ? 'warning' : 'info'" effect="plain">
{{ budgetMode === 'rolling' ? '滚动预算(滚动12月)' : '固定预算(固定年度)' }}
</el-tag>
<span style="color:#c0c4cc;">系统级设置,影响全部账套 · 更改请到【系统设置】</span>
</div>
<el-button v-if="budgetMode === 'rolling'" size="small" type="warning" @click="doRollForward" :loading="rollingForward">延展</el-button>
</div>
<template v-if="budgetViewMode === 'list'">
<el-table :data="budgetList" v-loading="loading" border stripe size="small" style="width:100%;" :row-class-name="rowClass" :empty-text="filterYear ? '暂无数据,在行内编辑填值后保存即可创建' : '请先选择年份'">
<el-table-column type="index" label="#" width="40" />
<el-table-column prop="kpi_code" label="KPI编码" width="120" />
<el-table-column prop="kpi_name" label="KPI名称" min-width="160" />
<el-table-column prop="dimension" label="维度" width="80"><template #default="{ row }"><el-tag size="small" :type="dimTag(row.dimension)">{{ dimLabel(row.dimension) }}</el-tag></template></el-table-column>
<el-table-column prop="period" label="期间" width="90" />
<el-table-column prop="budget_value" label="预算值" width="170">
<template #default="{ row }"><el-input-number v-if="row._editing" v-model="row._editValue" :min="0" :precision="row.precision || 0" :step="row.step || 1" controls-position="right" style="width:155px;" @change="onEditChange(row)" /><span v-else>{{ formatNumber(row.budget_value, row.unit) }}</span></template>
</el-table-column>
<el-table-column prop="unit" label="单位" width="60" />
<!-- 行动方案列(非财务维度显示) -->
<el-table-column label="行动方案" width="120">
<template #default="{ row }">
<span v-if="row.dimension === 'finance'" style="color:#ccc;font-size:12px;">—</span>
<el-button v-else size="small" link type="primary" @click="showActionPlans(row)">
{{ row._actionCount != null ? `${row._actionCount}项` : '关联' }}
</el-button>
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button v-if="!batchEditMode && !row._editing" size="small" @click="startEdit(row)">编辑</el-button>
<template v-else-if="!batchEditMode"><el-button size="small" type="primary" @click="saveEdit(row)" :loading="row._saving">保存</el-button><el-button size="small" @click="cancelEdit(row)">取消</el-button></template>
<el-button v-if="!batchEditMode && row.id" size="small" type="danger" plain @click="doDelete(row)">删除</el-button>
<el-tag v-else-if="!batchEditMode" size="small" type="info" effect="plain" style="cursor:pointer" @click="startEdit(row)">待创建</el-tag>
</template>
</el-table-column>
</el-table>
</template>
<template v-if="budgetViewMode === 'summary'">
<el-row :gutter="16" style="margin-bottom:16px;">
<el-col :span="6" v-for="card in summaryCards" :key="card.key">
<el-card shadow="hover" :body-style="{ borderLeft: '4px solid ' + card.color }">
<div style="text-align:center;"><div style="font-size:14px;margin-bottom:4px;">{{ card.icon }} {{ card.name }}</div><div style="font-size:24px;font-weight:600;color:#333;">{{ formatNumber(card.totalBudget) }}</div><div style="font-size:11px;color:#999;margin-top:4px;">{{ card.kpiCount }}个KPI · 占比{{ card.ratio }}%</div></div>
</el-card>
</el-col>
</el-row>
<el-table :data="dimSummaryRows" border stripe size="small" style="width:100%;">
<el-table-column prop="dimension" label="维度" width="100"><template #default="{ row }"><el-tag size="small" :type="dimTag(row.dimension)">{{ dimLabel(row.dimension) }}</el-tag></template></el-table-column>
<el-table-column prop="kpiCount" label="KPI数" width="80" />
<el-table-column prop="totalBudget" label="预算总额" width="140"><template #default="{ row }">{{ formatNumber(row.totalBudget) }}</template></el-table-column>
<el-table-column prop="ratio" label="占比" width="100"><template #default="{ row }"><el-progress :percentage="row.ratio" :stroke-width="12" text-inside /></template></el-table-column>
<el-table-column prop="avgPerKpi" label="KPI均值" width="140"><template #default="{ row }">{{ formatNumber(row.avgPerKpi) }}</template></el-table-column>
</el-table>
</template>
</el-tab-pane>
<el-tab-pane label="年度分解" name="decompose">
<el-card>
<template #header><div style="display:flex;justify-content:space-between;align-items:center;"><span>年度预算 → 月度自动分解</span><el-button type="primary" @click="doDecompose" :loading="decomposing">执行分解</el-button></div></template>
<el-form :model="decomposeForm" label-width="120px" style="max-width:500px;">
<el-form-item label="年份"><el-select v-model="decomposeForm.year" style="width:150px;"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select></el-form-item>
<el-form-item label="分解方式"><el-radio-group v-model="decomposeForm.method"><el-radio value="equal">均分(1/12)</el-radio><el-radio value="weighted">按历史权重</el-radio></el-radio-group></el-form-item>
</el-form>
<el-alert v-if="decomposeResult" :title="decomposeResult" type="success" show-icon :closable="false" style="margin-top:16px;" />
<el-table v-if="decomposeDetails.length > 0" :data="decomposeDetails" border stripe size="small" style="width:100%;margin-top:12px;" max-height="300">
<el-table-column prop="kpi_code" label="KPI编码" width="120" /><el-table-column prop="kpi_name" label="KPI名称" min-width="150" />
<el-table-column prop="annual_budget" label="年度预算" width="120"><template #default="{ row }">{{ formatNumber(row.annual_budget) }}</template></el-table-column>
<el-table-column prop="method" label="方式" width="80" /><el-table-column prop="monthly_count" label="月数" width="60" />
</el-table>
</el-card>
</el-tab-pane>
<el-tab-pane label="战略预算编制" name="strategy">
<div v-if="!selectedMap" style="padding:40px 0;text-align:center;color:#999;"><p>请先在预算录入中选择战略地图</p></div>
<template v-else>
@@ -152,108 +54,6 @@
</template>
</el-tab-pane>
<el-tab-pane label="版本管理" name="versions">
<div style="display:flex;gap:12px;margin-bottom:16px;align-items:center;">
<el-select v-model="versionYear" placeholder="年份" style="width:120px;"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select>
<el-button type="primary" @click="loadVersions">刷新</el-button>
</div>
<el-table :data="versionsList" v-loading="versionsLoading" border stripe size="small" style="width:100%;">
<el-table-column prop="version" label="版本" width="90"><template #default="{ row }"><el-tag :type="row.version === currentVersion ? 'success' : 'info'" size="small">{{ row.version }}</el-tag></template></el-table-column>
<el-table-column prop="approval_status" label="状态" width="90">
<template #default="{ row }"><el-tag v-if="row.approval_status === 'approved'" type="success" size="small">已批准</el-tag><el-tag v-else-if="row.approval_status === 'submitted'" type="warning" size="small">待审批</el-tag><el-tag v-else-if="row.approval_status === 'rejected'" type="danger" size="small">已驳回</el-tag><el-tag v-else type="info" size="small">草稿</el-tag></template>
</el-table-column>
<el-table-column prop="plan_count" label="预算条目数" width="100" />
<el-table-column prop="total_budget" label="预算总额" width="130"><template #default="{ row }">{{ formatNumber(row.total_budget) }}</template></el-table-column>
<el-table-column prop="last_updated" label="最后更新" min-width="150" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button v-if="row.approval_status === 'draft' || row.approval_status === 'active'" size="small" @click="submitVersion(row)">提交审批</el-button>
<template v-if="row.approval_status === 'submitted'"><el-button size="small" type="success" @click="approveVersion(row, 'approved')">批准</el-button><el-button size="small" type="danger" @click="approveVersion(row, 'rejected')">驳回</el-button></template>
<el-button v-if="row.approval_status === 'rejected'" size="small" @click="resubmitVersion(row)">重新提交</el-button>
<el-checkbox v-model="row._selected" @change="onVersionSelect(row)" :disabled="selectedVersions.length >= 2 && !row._selected" style="margin-left:8px;" />
</template>
</el-table-column>
</el-table>
<div v-if="selectedVersions.length === 2" style="margin-top:16px;"><el-button type="primary" @click="loadVersionDiff" :loading="diffLoading">对比 {{ selectedVersions[0].version }} vs {{ selectedVersions[1].version }}</el-button></div>
</el-tab-pane>
<el-tab-pane label="预算执行" name="execution">
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;align-items:center;">
<el-select v-model="execFilterYear" placeholder="年份" style="width:100px;" @change="loadExecutionReport"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select>
<el-select v-model="execFilterMonth" placeholder="截止月份" style="width:110px;" @change="loadExecutionReport"><el-option v-for="m in 12" :key="m" :label="`至${m}月`" :value="m" /></el-select>
<el-select v-model="execFilterDim" placeholder="维度" clearable style="width:110px;" @change="loadExecutionReport"><el-option label="财务" value="finance" /><el-option label="客户" value="customer" /><el-option label="内部流程" value="process" /><el-option label="学习成长" value="learning" /></el-select>
<el-button type="primary" @click="loadExecutionReport">刷新</el-button>
<el-button type="warning" :loading="syncingCash" @click="syncCashPlans">⇄ 同步现金流计划</el-button>
</div>
<el-row :gutter="16" style="margin-bottom:16px;">
<el-col :span="6" v-for="card in execSummaryCards" :key="card.label"><el-card shadow="hover"><div style="text-align:center;"><div style="font-size:12px;color:#999;">{{ card.label }}</div><div style="font-size:22px;font-weight:600;margin-top:4px;" :style="{color: card.color}">{{ card.value }}</div></div></el-card></el-col>
</el-row>
<el-table :data="execReport" v-loading="execLoading" border stripe size="small" style="width:100%;">
<el-table-column prop="kpi_code" label="编码" width="110" /><el-table-column prop="kpi_name" label="KPI名称" min-width="140" />
<el-table-column prop="dimension" label="维度" width="80"><template #default="{ row }"><el-tag size="small" :type="dimTag(row.dimension)">{{ dimLabel(row.dimension) }}</el-tag></template></el-table-column>
<el-table-column prop="budget_value" label="预算值" width="130"><template #default="{ row }">{{ formatNumber(row.budget_value, row.unit) }}</template></el-table-column>
<!-- 战略目标 vs 预算(2026-08-27: 允许不同, 差异可见可解释) -->
<el-table-column prop="strategic_target" label="战略目标" width="120"><template #default="{ row }"><span :style="{color: row.target_gap_level === 'high' ? '#f56c6c' : row.target_gap_level === 'medium' ? '#e6a23c' : '#606266'}">{{ formatNumber(row.strategic_target, row.unit) }}</span></template></el-table-column>
<el-table-column label="目标差异" width="110"><template #default="{ row }">
<span v-if="row.target_gap_pct != null" :style="{color: row.target_gap_level === 'high' ? '#f56c6c' : row.target_gap_level === 'medium' ? '#e6a23c' : '#67c23a', fontWeight:600}">{{ row.target_gap_pct > 0 ? '+' : '' }}{{ row.target_gap_pct }}%</span>
<el-tooltip v-if="row.target_gap_level === 'high'" content="预算偏离战略目标超20%,需说明原因" placement="top"><span style="margin-left:4px;cursor:help;">⚠️</span></el-tooltip>
<span v-else style="color:#c0c4cc;">-</span>
</template></el-table-column>
<el-table-column prop="actual_value" label="实际值" width="130"><template #default="{ row }">{{ formatNumber(row.actual_value, row.unit) }}</template></el-table-column>
<el-table-column prop="deviation_value" label="偏差" width="130"><template #default="{ row }"><span :style="{color: row.deviation_value > 0 ? '#f56c6c' : row.deviation_value < 0 ? '#67c23a' : '#999'}">{{ row.deviation_value > 0 ? '+' : '' }}{{ formatNumber(row.deviation_value, row.unit) }}</span></template></el-table-column>
<el-table-column label="执行率" width="100"><template #default="{ row }"><el-progress v-if="row.execution_rate != null" :percentage="row.execution_rate" :status="row.execution_rate > 100 ? 'exception' : row.execution_rate > 80 ? 'warning' : 'success'" :stroke-width="16" :text-inside="true" /><span v-else style="color:#909399">-</span></template></el-table-column>
<el-table-column label="预警" width="80"><template #default="{ row }"><el-tag v-if="row.alert_level === 'red'" size="small" type="danger">严重</el-tag><el-tag v-else-if="row.alert_level === 'yellow'" size="small" type="warning">关注</el-tag><el-tag v-else size="small" type="success">正常</el-tag></template></el-table-column>
</el-table>
<!-- P2-⑥ 现金流分类规则 + 待分类队列 -->
<el-row :gutter="16" style="margin-top:20px;">
<el-col :span="12">
<el-card shadow="never">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span><strong>现金流分类规则</strong> <span style="font-size:12px;color:#999;">(KPI→receive/pay 可维护)</span></span>
<el-button type="primary" size="small" @click="showClassifyRuleDialog = true">+ 新建规则</el-button>
</div>
</template>
<el-table :data="cashClassifyRules" border stripe size="small" style="width:100%;" max-height="240">
<el-table-column prop="kpi_code" label="KPI编码" width="110"><template #default="{ row }">{{ row.kpi_code || row.kpi_code_pattern || '--' }}</template></el-table-column>
<el-table-column prop="kpi_name" label="名称/关键词" min-width="110"><template #default="{ row }">{{ row.kpi_name || row.kpi_code_pattern || '--' }}</template></el-table-column>
<el-table-column label="类型" width="80">
<template #default="{ row }"><el-tag size="small" :type="row.plan_type === 'receive' ? 'success' : 'danger'">{{ row.plan_type === 'receive' ? '收' : '付' }}</el-tag></template>
</el-table-column>
<el-table-column prop="priority" label="优先级" width="70" />
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ row }"><el-button size="small" link type="danger" @click="deleteClassifyRule(row)">删除</el-button></template>
</el-table-column>
</el-table>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="never">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span><strong>待分类队列</strong> <span style="font-size:12px;color:#999;">(无法判别的KPI不再静默跳过)</span></span>
<el-badge :value="unclassifiedCount" :hidden="unclassifiedCount === 0" type="danger"><el-button size="small" @click="loadCashClassify">刷新</el-button></el-badge>
</div>
</template>
<el-table :data="unclassifiedRows" border stripe size="small" style="width:100%;" max-height="240">
<el-table-column prop="kpi_name" label="KPI" min-width="120" />
<el-table-column prop="period" label="期间" width="80" />
<el-table-column prop="budget_value" label="预算值" width="90"><template #default="{ row }">{{ formatNumber(row.budget_value) }}</template></el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button size="small" link type="success" @click="classifyUnclassified(row, 'receive')">收</el-button>
<el-button size="small" link type="danger" @click="classifyUnclassified(row, 'pay')">付</el-button>
<el-button size="small" link @click="ignoreUnclassified(row)">忽略</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="unclassifiedRows.length === 0" style="padding:16px 0;text-align:center;color:#999;font-size:13px;">暂无待分类KPI,同步现金流计划后未命中规则的KPI会出现在这里</div>
</el-card>
</el-col>
</el-row>
</el-tab-pane>
<el-tab-pane label="编制方法" name="method">
<div>
<p style="font-size:14px;color:#606266;margin-bottom:16px;">选择预算编制方法 — CMA P1 预算编制(增量预算 vs 零基预算 vs 弹性预算)</p>
@@ -380,94 +180,263 @@
<DriverFactorBudget />
</el-tab-pane>
<el-tab-pane label="实际值归集" name="collect">
<el-tab-pane label="预算录入" name="input">
<div style="margin-bottom:8px;font-size:12px;color:#909399;display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
选择战略地图
<el-select v-model="selectedMapId" placeholder="选择已发布的战略地图" style="width:260px;" @change="onMapSelect">
<el-option v-for="m in publishedMaps" :key="m.id" :label="m.title" :value="m.id" />
</el-select>
<el-tag v-if="selectedMapId" size="small" type="success">已选择</el-tag>
<span style="color:#999;">共 {{ total }} 条KPI</span>
</div>
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;align-items:center;">
<el-select v-model="filterYear" placeholder="年份" style="width:100px;" @change="loadBudget"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select>
<el-select v-model="filterVersion" placeholder="版本" style="width:180px;" @change="loadBudget" clearable>
<el-option v-for="v in versionsList" :key="v.version" :label="v.version + (v.status === 'active' ? ' (当前)' : '')" :value="v.version" />
</el-select>
<el-select v-model="filterMonth" placeholder="月份" style="width:90px;" @change="loadBudget"><el-option label="全年" :value="0" /><el-option v-for="m in 12" :key="m" :label="`${m}月`" :value="m" /></el-select>
<el-input v-model="searchKpi" placeholder="搜索KPI名称" clearable style="width:200px;" @clear="loadBudget" @keyup.enter="loadBudget" />
<el-button type="primary" @click="loadBudget">查询</el-button>
<el-button type="success" @click="showAddBudget = true">+ 新增预算</el-button>
<el-button @click="showDecompose = true" :disabled="!filterYear">年度分解</el-button>
<el-divider direction="vertical" />
<el-radio-group v-model="budgetViewMode" size="small" @change="onViewModeChange"><el-radio-button value="list">KPI列表</el-radio-button><el-radio-button value="summary">维度汇总</el-radio-button></el-radio-group>
<el-switch v-model="batchEditMode" active-text="批量编辑" inactive-text="逐行编辑" @change="onBatchEditToggle" />
<el-button v-if="batchEditMode" type="primary" @click="batchSaveAll" :loading="batchSavingAll" :disabled="changedRows.length === 0">批量保存 ({{ changedRows.length }})</el-button>
<el-button v-if="batchEditMode" @click="batchCancelAll">取消</el-button>
<el-divider direction="vertical" />
<div style="display:flex;align-items:center;gap:6px;font-size:12px;">
<span style="color:#909399;">预算模式:</span>
<el-tag size="small" :type="budgetMode === 'rolling' ? 'warning' : 'info'" effect="plain">
{{ budgetMode === 'rolling' ? '滚动预算(滚动12月)' : '固定预算(固定年度)' }}
</el-tag>
<span style="color:#c0c4cc;">系统级设置,影响全部账套 · 更改请到【系统设置】</span>
</div>
<el-button v-if="budgetMode === 'rolling'" size="small" type="warning" @click="doRollForward" :loading="rollingForward">延展</el-button>
</div>
<template v-if="budgetViewMode === 'list'">
<el-table :data="budgetList" v-loading="loading" border stripe size="small" style="width:100%;" :row-class-name="rowClass" :empty-text="filterYear ? '暂无数据,在行内编辑填值后保存即可创建' : '请先选择年份'">
<el-table-column type="index" label="#" width="40" />
<el-table-column prop="kpi_code" label="KPI编码" width="120" />
<el-table-column prop="kpi_name" label="KPI名称" min-width="160" />
<el-table-column prop="dimension" label="维度" width="80"><template #default="{ row }"><el-tag size="small" :type="dimTag(row.dimension)">{{ dimLabel(row.dimension) }}</el-tag></template></el-table-column>
<el-table-column prop="period" label="期间" width="90" />
<el-table-column prop="budget_value" label="预算值" width="170">
<template #default="{ row }"><el-input-number v-if="row._editing" v-model="row._editValue" :min="0" :precision="row.precision || 0" :step="row.step || 1" controls-position="right" style="width:155px;" @change="onEditChange(row)" /><span v-else>{{ formatNumber(row.budget_value, row.unit) }}</span></template>
</el-table-column>
<el-table-column prop="unit" label="单位" width="60" />
<!-- 行动方案列(非财务维度显示) -->
<el-table-column label="行动方案" width="120">
<template #default="{ row }">
<span v-if="row.dimension === 'finance'" style="color:#ccc;font-size:12px;">—</span>
<el-button v-else size="small" link type="primary" @click="showActionPlans(row)">
{{ row._actionCount != null ? `${row._actionCount}项` : '关联' }}
</el-button>
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button v-if="!batchEditMode && !row._editing" size="small" @click="startEdit(row)">编辑</el-button>
<template v-else-if="!batchEditMode"><el-button size="small" type="primary" @click="saveEdit(row)" :loading="row._saving">保存</el-button><el-button size="small" @click="cancelEdit(row)">取消</el-button></template>
<el-button v-if="!batchEditMode && row.id" size="small" type="danger" plain @click="doDelete(row)">删除</el-button>
<el-tag v-else-if="!batchEditMode" size="small" type="info" effect="plain" style="cursor:pointer" @click="startEdit(row)">待创建</el-tag>
</template>
</el-table-column>
</el-table>
</template>
<template v-if="budgetViewMode === 'summary'">
<el-row :gutter="16" style="margin-bottom:16px;">
<el-col :span="6" v-for="card in summaryCards" :key="card.key">
<el-card shadow="hover" :body-style="{ borderLeft: '4px solid ' + card.color }">
<div style="text-align:center;"><div style="font-size:14px;margin-bottom:4px;">{{ card.icon }} {{ card.name }}</div><div style="font-size:24px;font-weight:600;color:#333;">{{ formatNumber(card.totalBudget) }}</div><div style="font-size:11px;color:#999;margin-top:4px;">{{ card.kpiCount }}个KPI · 占比{{ card.ratio }}%</div></div>
</el-card>
</el-col>
</el-row>
<el-table :data="dimSummaryRows" border stripe size="small" style="width:100%;">
<el-table-column prop="dimension" label="维度" width="100"><template #default="{ row }"><el-tag size="small" :type="dimTag(row.dimension)">{{ dimLabel(row.dimension) }}</el-tag></template></el-table-column>
<el-table-column prop="kpiCount" label="KPI数" width="80" />
<el-table-column prop="totalBudget" label="预算总额" width="140"><template #default="{ row }">{{ formatNumber(row.totalBudget) }}</template></el-table-column>
<el-table-column prop="ratio" label="占比" width="100"><template #default="{ row }"><el-progress :percentage="row.ratio" :stroke-width="12" text-inside /></template></el-table-column>
<el-table-column prop="avgPerKpi" label="KPI均值" width="140"><template #default="{ row }">{{ formatNumber(row.avgPerKpi) }}</template></el-table-column>
</el-table>
</template>
</el-tab-pane>
<el-tab-pane label="年度分解" name="decompose">
<el-card>
<template #header><div style="display:flex;justify-content:space-between;align-items:center;"><span>年度预算 → 月度自动分解</span><el-button type="primary" @click="doDecompose" :loading="decomposing">执行分解</el-button></div></template>
<el-form :model="decomposeForm" label-width="120px" style="max-width:500px;">
<el-form-item label="年份"><el-select v-model="decomposeForm.year" style="width:150px;"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select></el-form-item>
<el-form-item label="分解方式"><el-radio-group v-model="decomposeForm.method"><el-radio value="equal">均分(1/12)</el-radio><el-radio value="weighted">按历史权重</el-radio></el-radio-group></el-form-item>
</el-form>
<el-alert v-if="decomposeResult" :title="decomposeResult" type="success" show-icon :closable="false" style="margin-top:16px;" />
<el-table v-if="decomposeDetails.length > 0" :data="decomposeDetails" border stripe size="small" style="width:100%;margin-top:12px;" max-height="300">
<el-table-column prop="kpi_code" label="KPI编码" width="120" /><el-table-column prop="kpi_name" label="KPI名称" min-width="150" />
<el-table-column prop="annual_budget" label="年度预算" width="120"><template #default="{ row }">{{ formatNumber(row.annual_budget) }}</template></el-table-column>
<el-table-column prop="method" label="方式" width="80" /><el-table-column prop="monthly_count" label="月数" width="60" />
</el-table>
</el-card>
</el-tab-pane>
<el-tab-pane label="版本审批" name="versions">
<div style="display:flex;gap:12px;margin-bottom:16px;align-items:center;">
<el-select v-model="versionYear" placeholder="年份" style="width:120px;"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select>
<el-button type="primary" @click="loadVersions">刷新</el-button>
</div>
<el-table :data="versionsList" v-loading="versionsLoading" border stripe size="small" style="width:100%;">
<el-table-column prop="version" label="版本" width="90"><template #default="{ row }"><el-tag :type="row.version === currentVersion ? 'success' : 'info'" size="small">{{ row.version }}</el-tag></template></el-table-column>
<el-table-column prop="approval_status" label="状态" width="90">
<template #default="{ row }"><el-tag v-if="row.approval_status === 'approved'" type="success" size="small">已批准</el-tag><el-tag v-else-if="row.approval_status === 'submitted'" type="warning" size="small">待审批</el-tag><el-tag v-else-if="row.approval_status === 'rejected'" type="danger" size="small">已驳回</el-tag><el-tag v-else type="info" size="small">草稿</el-tag></template>
</el-table-column>
<el-table-column prop="plan_count" label="预算条目数" width="100" />
<el-table-column prop="total_budget" label="预算总额" width="130"><template #default="{ row }">{{ formatNumber(row.total_budget) }}</template></el-table-column>
<el-table-column prop="last_updated" label="最后更新" min-width="150" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button v-if="row.approval_status === 'draft' || row.approval_status === 'active'" size="small" @click="submitVersion(row)">提交审批</el-button>
<template v-if="row.approval_status === 'submitted'"><el-button size="small" type="success" @click="approveVersion(row, 'approved')">批准</el-button><el-button size="small" type="danger" @click="approveVersion(row, 'rejected')">驳回</el-button></template>
<el-button v-if="row.approval_status === 'rejected'" size="small" @click="resubmitVersion(row)">重新提交</el-button>
<el-checkbox v-model="row._selected" @change="onVersionSelect(row)" :disabled="selectedVersions.length >= 2 && !row._selected" style="margin-left:8px;" />
</template>
</el-table-column>
</el-table>
<div v-if="selectedVersions.length === 2" style="margin-top:16px;"><el-button type="primary" @click="loadVersionDiff" :loading="diffLoading">对比 {{ selectedVersions[0].version }} vs {{ selectedVersions[1].version }}</el-button></div>
</el-tab-pane>
<el-tab-pane label="下达" name="release">
<div style="display:flex;gap:12px;margin-bottom:16px;align-items:center;">
<el-select v-model="releaseYear" placeholder="年份" style="width:120px;" @change="loadReleaseVersions"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select>
<el-button type="primary" @click="loadReleaseVersions">刷新</el-button>
</div>
<el-alert title="审批通过(approved)的版本可正式下达为生效执行版本(active)" type="info" :closable="false" show-icon style="margin-bottom:12px;" />
<el-table :data="releaseList" v-loading="releaseLoading" border stripe size="small" style="width:100%;">
<el-table-column prop="version" label="版本" width="110"><template #default="{ row }"><el-tag size="small" :type="row.status === 'active' ? 'success' : 'info'">{{ row.version }}</el-tag></template></el-table-column>
<el-table-column prop="status" label="状态" width="100"><template #default="{ row }"><el-tag v-if="row.status === 'active'" type="success" size="small">已下达</el-tag><el-tag v-else-if="row.status === 'approved'" type="warning" size="small">已批准</el-tag><el-tag v-else size="small" type="info">{{ row.approval_status || row.status }}</el-tag></template></el-table-column>
<el-table-column prop="kpi_count" label="KPI数" width="90" />
<el-table-column prop="total_budget" label="预算总额" width="140"><template #default="{ row }">{{ formatNumber(row.total_budget) }}</template></el-table-column>
<el-table-column prop="updated_at" label="更新时间" min-width="150" />
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }"><el-button size="small" type="primary" :disabled="row.status !== 'approved'" @click="askRelease(row)">下达</el-button></template>
</el-table-column>
</el-table>
<MyDialog v-model="showReleaseConfirm" title="确认下达" :width="460">
<div style="font-size:14px;">确认将版本 <b>{{ releaseTarget?.version }}</b> 下达为生效执行版本?</div>
<div style="font-size:12px;color:#909399;margin-top:8px;">下达后该版本状态由 approved 变为 active。</div>
<template #footer>
<el-button @click="showReleaseConfirm = false">取消</el-button>
<el-button type="primary" :loading="releasing" @click="doRelease">确认下达</el-button>
</template>
</MyDialog>
</el-tab-pane>
<el-tab-pane label="预算执行" name="execution">
<el-card shadow="never" style="margin-bottom:16px;">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span><strong>预算控制状态</strong> <span style="font-size:12px;color:#999;">(生效预算 vs 实际值,阈值 {{ controlData?.threshold_pct ?? 20 }}%)</span></span>
<el-button size="small" @click="loadControlStatus">刷新</el-button>
</div>
</template>
<div v-if="controlData" style="display:flex;gap:24px;margin-bottom:12px;font-size:13px;flex-wrap:wrap;">
<span>KPI总数:<b>{{ controlData.summary?.total ?? 0 }}</b></span>
<span style="color:#67c23a;">正常:<b>{{ controlData.summary?.ok ?? 0 }}</b></span>
<span style="color:#e6a23c;">关注:<b>{{ controlData.summary?.warn ?? 0 }}</b></span>
<span style="color:#f56c6c;">超支:<b>{{ controlData.summary?.over ?? 0 }}</b></span>
</div>
<el-table :data="controlItems" v-loading="controlLoading" border stripe size="small" style="width:100%;" max-height="240">
<el-table-column prop="kpi_code" label="KPI编码" width="110" />
<el-table-column prop="kpi_name" label="KPI名称" min-width="140" />
<el-table-column prop="period" label="期间" width="90" />
<el-table-column prop="budget_value" label="预算" width="120"><template #default="{ row }">{{ formatNumber(row.budget_value, row.unit) }}</template></el-table-column>
<el-table-column prop="actual_value" label="实际" width="120"><template #default="{ row }">{{ formatNumber(row.actual_value, row.unit) }}</template></el-table-column>
<el-table-column label="执行率" width="100"><template #default="{ row }"><span v-if="row.execution_rate != null">{{ row.execution_rate }}%</span><span v-else style="color:#ccc;">--</span></template></el-table-column>
<el-table-column label="控制级" width="90"><template #default="{ row }"><el-tag size="small" :type="row.control_level === 'over' ? 'danger' : row.control_level === 'warn' ? 'warning' : 'success'">{{ row.control_level === 'over' ? '超支' : row.control_level === 'warn' ? '关注' : '正常' }}</el-tag></template></el-table-column>
</el-table>
<div v-if="controlItems.length === 0 && !controlLoading" style="padding:20px 0;text-align:center;color:#999;font-size:13px;">暂无控制状态数据</div>
</el-card>
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;align-items:center;">
<el-select v-model="execFilterYear" placeholder="年份" style="width:100px;" @change="loadExecutionReport"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select>
<el-select v-model="execFilterMonth" placeholder="截止月份" style="width:110px;" @change="loadExecutionReport"><el-option v-for="m in 12" :key="m" :label="`至${m}月`" :value="m" /></el-select>
<el-select v-model="execFilterDim" placeholder="维度" clearable style="width:110px;" @change="loadExecutionReport"><el-option label="财务" value="finance" /><el-option label="客户" value="customer" /><el-option label="内部流程" value="process" /><el-option label="学习成长" value="learning" /></el-select>
<el-button type="primary" @click="loadExecutionReport">刷新</el-button>
</div>
<el-row :gutter="16" style="margin-bottom:16px;">
<el-col :span="6" v-for="c in collectCoverageCards" :key="c.label">
<el-card shadow="hover"><div style="text-align:center;"><div style="font-size:12px;color:#999;">{{ c.label }}</div><div style="font-size:22px;font-weight:600;margin-top:4px;" :style="{color: c.color}">{{ c.value }}</div></div></el-card>
</el-col>
<el-col :span="6" v-for="card in execSummaryCards" :key="card.label"><el-card shadow="hover"><div style="text-align:center;"><div style="font-size:12px;color:#999;">{{ card.label }}</div><div style="font-size:22px;font-weight:600;margin-top:4px;" :style="{color: card.color}">{{ card.value }}</div></div></el-card></el-col>
</el-row>
<el-tabs v-model="collectTab" type="border-card" style="margin-top:4px;">
<el-tab-pane label="取数映射" name="mappings">
<div style="display:flex;gap:8px;margin-bottom:12px;align-items:center;flex-wrap:wrap;">
<el-select v-model="collectKpiId" placeholder="选择KPI(可选)" filterable clearable style="width:220px;" @change="loadValueSources">
<el-option v-for="k in zbbKpiOptions" :key="k.id" :label="`${k.kpi_code} - ${k.kpi_name}`" :value="k.id" />
</el-select>
<el-button type="primary" @click="showValueSourceDialog = true">+ 新建映射</el-button>
<el-button type="warning" :loading="collectRunning" @click="runValueCollect">▶ 立即采集</el-button>
<el-button @click="loadCollectData">刷新</el-button>
</div>
<el-table :data="valueSources" border stripe size="small" style="width:100%;">
<el-table-column prop="kpi_code" label="KPI编码" width="100" />
<el-table-column prop="kpi_name" label="KPI名称" min-width="120" />
<el-table-column prop="source_table" label="源头表" width="160">
<template #default="{ row }"><el-tag size="small">{{ row.source_table }}</el-tag></template>
</el-table-column>
<el-table-column prop="source_field" label="金额字段" width="110" />
<el-table-column prop="aggregate" label="聚合" width="70" />
<el-table-column label="过滤规则" width="140">
<template #default="{ row }"><span style="font-size:12px;">{{ JSON.stringify(row.filter_rule || {}) }}</span></template>
</el-table-column>
<el-table-column prop="unit_conversion" label="倍率" width="60" />
<el-table-column label="状态" width="80">
<template #default="{ row }"><el-tag size="small" :type="row.status === 'active' ? 'success' : 'info'">{{ row.status === 'active' ? '启用' : '停用' }}</el-tag></template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button size="small" link type="primary" @click="testValueSource(row)">试跑</el-button>
<el-button size="small" link type="warning" @click="toggleValueSource(row)">{{ row.status === 'active' ? '停用' : '启用' }}</el-button>
<el-button size="small" link type="danger" @click="deleteValueSource(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-table :data="execReport" v-loading="execLoading" border stripe size="small" style="width:100%;">
<el-table-column prop="kpi_code" label="编码" width="110" /><el-table-column prop="kpi_name" label="KPI名称" min-width="140" />
<el-table-column prop="dimension" label="维度" width="80"><template #default="{ row }"><el-tag size="small" :type="dimTag(row.dimension)">{{ dimLabel(row.dimension) }}</el-tag></template></el-table-column>
<el-table-column prop="budget_value" label="预算值" width="130"><template #default="{ row }">{{ formatNumber(row.budget_value, row.unit) }}</template></el-table-column>
<!-- 战略目标 vs 预算(2026-08-27: 允许不同, 差异可见可解释) -->
<el-table-column prop="strategic_target" label="战略目标" width="120"><template #default="{ row }"><span :style="{color: row.target_gap_level === 'high' ? '#f56c6c' : row.target_gap_level === 'medium' ? '#e6a23c' : '#606266'}">{{ formatNumber(row.strategic_target, row.unit) }}</span></template></el-table-column>
<el-table-column label="目标差异" width="110"><template #default="{ row }">
<span v-if="row.target_gap_pct != null" :style="{color: row.target_gap_level === 'high' ? '#f56c6c' : row.target_gap_level === 'medium' ? '#e6a23c' : '#67c23a', fontWeight:600}">{{ row.target_gap_pct > 0 ? '+' : '' }}{{ row.target_gap_pct }}%</span>
<el-tooltip v-if="row.target_gap_level === 'high'" content="预算偏离战略目标超20%,需说明原因" placement="top"><span style="margin-left:4px;cursor:help;">⚠️</span></el-tooltip>
<span v-else style="color:#c0c4cc;">-</span>
</template></el-table-column>
<el-table-column prop="actual_value" label="实际值" width="130"><template #default="{ row }">{{ formatNumber(row.actual_value, row.unit) }}</template></el-table-column>
<el-table-column prop="deviation_value" label="偏差" width="130"><template #default="{ row }"><span :style="{color: row.deviation_value > 0 ? '#f56c6c' : row.deviation_value < 0 ? '#67c23a' : '#999'}">{{ row.deviation_value > 0 ? '+' : '' }}{{ formatNumber(row.deviation_value, row.unit) }}</span></template></el-table-column>
<el-table-column label="执行率" width="100"><template #default="{ row }"><el-progress v-if="row.execution_rate != null" :percentage="row.execution_rate" :status="row.execution_rate > 100 ? 'exception' : row.execution_rate > 80 ? 'warning' : 'success'" :stroke-width="16" :text-inside="true" /><span v-else style="color:#909399">-</span></template></el-table-column>
<el-table-column label="预警" width="80"><template #default="{ row }"><el-tag v-if="row.alert_level === 'red'" size="small" type="danger">严重</el-tag><el-tag v-else-if="row.alert_level === 'yellow'" size="small" type="warning">关注</el-tag><el-tag v-else size="small" type="success">正常</el-tag></template></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="实际值标签" name="values">
<div style="display:flex;gap:8px;margin-bottom:12px;align-items:center;">
<el-radio-group v-model="valueTagFilter" size="small">
<el-radio-button value="auto_collect">已自动归集</el-radio-button>
<el-radio-button value="manual">需人工确认</el-radio-button>
<el-radio-button value="all">全部</el-radio-button>
</el-radio-group>
<span style="font-size:12px;color:#999;">对账页将按数据来源分类显示,自动归集数据可追溯采集批次</span>
</div>
<el-table :data="valueTagRows" border stripe size="small" style="width:100%;">
<el-table-column prop="kpi_code" label="KPI编码" width="100" />
<el-table-column prop="kpi_name" label="KPI名称" min-width="120" />
<el-table-column prop="period" label="期间" width="90" />
<el-table-column prop="actual_value" label="实际值" width="110"><template #default="{ row }">{{ formatNumber(row.actual_value) }}</template></el-table-column>
<el-table-column label="来源" width="130">
<template #default="{ row }">
<el-tag v-if="row.source_type === 'auto_collect'" size="small" type="success">已自动归集</el-tag>
<el-tag v-else-if="row.source_type === 'manual' || row.source_type === 'excel'" size="small" type="warning">需人工确认</el-tag>
<el-tag v-else size="small" type="info">{{ row.source_type || 'manual' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="source_batch" label="采集批次" width="170"><template #default="{ row }"><span style="font-size:12px;">{{ row.source_batch || '--' }}</span></template></el-table-column>
<el-table-column prop="remark" label="备注" min-width="150" />
</el-table>
</el-tab-pane>
<el-tab-pane label="差异分析" name="deviation">
<div style="display:flex;gap:12px;margin-bottom:16px;align-items:center;">
<el-select v-model="deviationYear" placeholder="年份" style="width:120px;" @change="loadDeviationReport"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select>
<el-button type="primary" @click="loadDeviationReport">刷新</el-button>
</div>
<el-table :data="deviationItems" v-loading="deviationLoading" border stripe size="small" style="width:100%;">
<el-table-column prop="kpi_code" label="KPI编码" width="120" />
<el-table-column prop="kpi_name" label="KPI名称" min-width="150" />
<el-table-column prop="budget_value" label="预算" width="130"><template #default="{ row }">{{ formatNumber(row.budget_value, row.unit) }}</template></el-table-column>
<el-table-column prop="actual_value" label="实际" width="130"><template #default="{ row }">{{ formatNumber(row.actual_value, row.unit) }}</template></el-table-column>
<el-table-column label="偏差率" width="110"><template #default="{ row }"><el-tag v-if="row.deviation_rate != null" :type="Math.abs(row.deviation_rate) > 20 ? 'danger' : Math.abs(row.deviation_rate) > 10 ? 'warning' : 'success'" size="small">{{ row.deviation_rate > 0 ? '+' : '' }}{{ row.deviation_rate }}%</el-tag><span v-else style="color:#ccc;">--</span></template></el-table-column>
<el-table-column label="状态" width="90"><template #default="{ row }"><el-tag v-if="row.is_over_budget" type="danger" size="small">超预算</el-tag><el-tag v-else-if="row.deviation_rate != null && Math.abs(row.deviation_rate) > 10" type="warning" size="small">关注</el-tag><el-tag v-else size="small" type="success">正常</el-tag></template></el-table-column>
</el-table>
<div v-if="deviationItems.length === 0 && !deviationLoading" style="padding:40px 0;text-align:center;color:#999;">暂无差异数据</div>
</el-tab-pane>
<el-tab-pane label="采集日志" name="logs">
<div style="display:flex;gap:8px;margin-bottom:12px;align-items:center;">
<el-select v-model="collectLogStatus" placeholder="状态" clearable style="width:110px;" size="small">
<el-option label="成功" value="success" /><el-option label="失败" value="failed" />
<el-tab-pane label="预算调整" name="adjust">
<el-card shadow="never">
<template #header><span>生成调整版本(复制基线版本 → 覆盖指定值 → 记录调整原因;原版本数值不变)</span></template>
<el-form :model="adjustForm" label-width="110px" style="max-width:560px;">
<el-form-item label="基线版本">
<el-select v-model="adjustForm.base_version" placeholder="选择生效版本(approved / active)" style="width:100%;">
<el-option v-for="v in adjustBaseVersions" :key="v.version" :label="`${v.version}(${v.status})`" :value="v.version" />
</el-select>
<el-button size="small" type="primary" @click="loadCollectLogs">查询</el-button>
</el-form-item>
<el-form-item label="新版本号"><el-input v-model="adjustForm.new_version" placeholder="如 2026-ADJ1(不可与既有版本重名)" /></el-form-item>
<el-form-item label="调整原因"><el-input v-model="adjustForm.reason" type="textarea" :rows="3" placeholder="本次预算调整的原因" /></el-form-item>
</el-form>
<el-button type="primary" :loading="adjusting" :disabled="!adjustForm.base_version || !adjustForm.new_version" @click="doAdjust">生成调整版本</el-button>
<el-alert v-if="adjustResult" :title="adjustResult" type="success" show-icon :closable="false" style="margin-top:12px;" />
</el-card>
</el-tab-pane>
<el-tab-pane label="预算考核" name="assess">
<div style="display:flex;gap:12px;margin-bottom:16px;align-items:center;">
<el-select v-model="assessYear" placeholder="年份" style="width:120px;" @change="loadAssessment"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select>
<el-button type="primary" @click="loadAssessment">刷新</el-button>
</div>
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;">
<el-card v-for="c in assessCards" :key="c.label" shadow="hover" style="flex:1;min-width:110px;">
<div style="text-align:center;">
<div style="font-size:12px;color:#999;">{{ c.label }}</div>
<div style="font-size:22px;font-weight:600;margin-top:4px;" :style="{ color: c.color }">{{ c.value }}</div>
</div>
<el-table :data="collectLogs" border stripe size="small" style="width:100%;">
<el-table-column prop="kpi_code" label="KPI编码" width="100" />
<el-table-column prop="kpi_name" label="KPI名称" min-width="110" />
<el-table-column prop="period" label="期间" width="90" />
<el-table-column prop="source_table" label="源头表" width="140" />
<el-table-column prop="collected_value" label="采集值" width="100"><template #default="{ row }">{{ formatNumber(row.collected_value) }}</template></el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }"><el-tag size="small" :type="row.status === 'success' ? 'success' : 'danger'">{{ row.status === 'success' ? '成功' : '失败' }}</el-tag></template>
</el-table-column>
<el-table-column prop="message" label="说明" min-width="180" />
<el-table-column prop="collected_at" label="采集时间" width="160" />
</el-table>
</el-tab-pane>
</el-tabs>
</el-card>
</div>
<el-table :data="assessItems" v-loading="assessLoading" border stripe size="small" style="width:100%;">
<el-table-column prop="kpi_code" label="KPI编码" width="110" />
<el-table-column prop="kpi_name" label="KPI名称" min-width="140" />
<el-table-column prop="basis_version" label="基准版本" width="110" />
<el-table-column prop="budget_total" label="预算" width="120"><template #default="{ row }">{{ formatNumber(row.budget_total, row.unit) }}</template></el-table-column>
<el-table-column prop="actual_total" label="实际" width="120"><template #default="{ row }">{{ formatNumber(row.actual_total, row.unit) }}</template></el-table-column>
<el-table-column label="达成率" width="110"><template #default="{ row }"><span v-if="row.achieve_rate != null">{{ row.achieve_rate }}%</span><span v-else style="color:#ccc;">--</span></template></el-table-column>
<el-table-column label="等级" width="80"><template #default="{ row }"><el-tag size="small" :type="row.grade === 'A' ? 'success' : row.grade === 'B' ? 'primary' : row.grade === 'C' ? 'warning' : row.grade === 'D' ? 'danger' : 'info'">{{ row.grade }}</el-tag></template></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="持续规划" name="rolling">
@@ -801,61 +770,6 @@
</el-form>
<template #footer><el-button @click="showDerivationRuleDialog = false">取消</el-button><el-button type="primary" @click="saveDerivationRule">保存</el-button></template>
</MyDialog>
<!-- P1-④ 取数映射新建弹窗 -->
<MyDialog v-model="showValueSourceDialog" title="新建取数映射" :width="560">
<el-form :model="valueSourceForm" label-width="100px">
<el-form-item label="目标KPI">
<el-select v-model="valueSourceForm.kpi_id" filterable style="width:100%;">
<el-option v-for="k in zbbKpiOptions" :key="k.id" :label="`${k.kpi_code} - ${k.kpi_name}`" :value="k.id" />
</el-select>
</el-form-item>
<el-form-item label="源头表">
<el-select v-model="valueSourceForm.source_table" style="width:100%;">
<el-option label="网银凭证明细 voucher_details" value="voucher_details" />
<el-option label="库存汇总 product_inventory" value="product_inventory" />
<el-option label="库存明细 product_inventory_detail" value="product_inventory_detail" />
<el-option label="收付款计划 cash_plans" value="cash_plans" />
</el-select>
</el-form-item>
<el-form-item label="金额字段">
<el-select v-model="valueSourceForm.source_field" style="width:100%;">
<el-option v-for="f in ['credit_amount','debit_amount','amount','qty','out_amount','in_amount','end_amount']" :key="f" :label="f" :value="f" />
</el-select>
</el-form-item>
<el-form-item label="聚合方式">
<el-select v-model="valueSourceForm.aggregate" style="width:120px;"><el-option v-for="a in ['sum','avg','count','max','min']" :key="a" :label="a" :value="a" /></el-select>
</el-form-item>
<el-form-item label="方向过滤">
<el-select v-model="valueSourceForm.direction" clearable placeholder="不限定" style="width:150px;">
<el-option label="贷方(收入)" value="credit" /><el-option label="借方(支出)" value="debit" />
</el-select>
</el-form-item>
<el-form-item label="科目过滤"><el-input v-model="valueSourceForm.subject_code" placeholder="如: 6601 销售费用" /></el-form-item>
<el-form-item label="期间字段">
<el-select v-model="valueSourceForm.period_field" style="width:150px;"><el-option label="period" value="period" /><el-option label="voucher_date" value="voucher_date" /></el-select>
</el-form-item>
<el-form-item label="单位倍率"><el-input-number v-model="valueSourceForm.unit_conversion" :min="0.0001" :precision="4" style="width:150px;" /><span style="font-size:12px;color:#999;margin-left:6px;">元→万元填 0.0001</span></el-form-item>
</el-form>
<template #footer><el-button @click="showValueSourceDialog = false">取消</el-button><el-button type="primary" @click="saveValueSource">保存</el-button></template>
</MyDialog>
<!-- P2-⑥ 分类规则新建弹窗 -->
<MyDialog v-model="showClassifyRuleDialog" title="新建现金流分类规则" :width="500">
<el-form :model="classifyRuleForm" label-width="110px">
<el-form-item label="KPI(精确)">
<el-select v-model="classifyRuleForm.kpi_id" filterable clearable placeholder="或使用关键词" style="width:100%;">
<el-option v-for="k in zbbKpiOptions" :key="k.id" :label="`${k.kpi_code} - ${k.kpi_name}`" :value="k.id" />
</el-select>
</el-form-item>
<el-form-item label="关键词(兜底)"><el-input v-model="classifyRuleForm.kpi_code_pattern" placeholder="如: 保证金" /></el-form-item>
<el-form-item label="收付类型">
<el-radio-group v-model="classifyRuleForm.plan_type"><el-radio value="receive">收</el-radio><el-radio value="pay">付</el-radio></el-radio-group>
</el-form-item>
<el-form-item label="优先级"><el-input-number v-model="classifyRuleForm.priority" :min="1" :max="100" style="width:120px;" /></el-form-item>
</el-form>
<template #footer><el-button @click="showClassifyRuleDialog = false">取消</el-button><el-button type="primary" @click="saveClassifyRule">保存</el-button></template>
</MyDialog>
</div>
</div>
</template>
@@ -878,21 +792,6 @@ const filterYear = ref(currentYear); const filterMonth = ref(0); const searchKpi
const loading = ref(false); const budgetList = ref<any[]>([]); 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<any[]>([])
const selectedMapId = ref<number | null>(null)
@@ -1501,210 +1400,147 @@ async function deleteDerivationRule(row: any) {
} catch { }
}
// ══════════════════════════════════════════════
// P1-④ 实际值自动归集
// 预算控制状态(GET /budget/control/status)
// ══════════════════════════════════════════════
const collectTab = ref('mappings')
const collectKpiId = ref<number | null>(null)
const collectCoverageCards = ref<any[]>([])
const valueSources = ref<any[]>([])
const showValueSourceDialog = ref(false)
const valueSourceForm = ref<any>({})
const collectRunning = ref(false)
const collectLogStatus = ref('')
const collectLogs = ref<any[]>([])
const valueTagFilter = ref('auto_collect')
const valueTagRows = ref<any[]>([])
async function loadCollectData() {
await Promise.all([loadValueSources(), loadCollectCoverage(), loadValueTags(), loadCollectLogs()])
}
async function loadValueSources() {
const controlLoading = ref(false)
const controlData = ref<any>(null)
const controlItems = ref<any[]>([])
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<any[]>([])
const showClassifyRuleDialog = ref(false)
const classifyRuleForm = ref<any>({})
const unclassifiedRows = ref<any[]>([])
const unclassifiedCount = ref(0)
async function loadCashClassify() {
const releaseYear = ref(currentYear)
const releaseLoading = ref(false)
const releaseList = ref<any[]>([])
const showReleaseConfirm = ref(false)
const releaseTarget = ref<any>(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<any[]>([])
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<any[]>([])
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<any>(null)
const assessItems = ref<any[]>([])
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()
+6
View File
@@ -272,6 +272,11 @@
</div>
</el-card>
</el-tab-pane>
<!-- 现金流分类规则/待分类队列(自预算管理「预算执行」迁入 · 资金域) -->
<el-tab-pane label="分类规则" name="classify" lazy>
<CashClassifyPanel />
</el-tab-pane>
</el-tabs>
<!-- ── 新建/编辑弹窗 ── -->
@@ -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)
+6
View File
@@ -99,6 +99,11 @@
</el-table>
</el-card>
</el-tab-pane>
<!-- 预算实际值归集(自预算管理「实际值归集」迁入 · KPI 取数域) -->
<el-tab-pane label="预算取数" name="budget-collect" lazy>
<BudgetCollectPanel />
</el-tab-pane>
</el-tabs>
<MyDialog v-model="showSourceForm" :title="sourceForm.id ? '编辑数据源' : '新增数据源'" :width="550">
@@ -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')