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:
+334
-17
@@ -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,需提交审批 → 批准 → 下达后方可作为执行基准"}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""预算状态机口径 —— 单一事实源(2026-10-03,施工单 CMA-20261003 阶段 1)
|
||||
|
||||
设计件:`docs/budget-module-governance-design.md` §1
|
||||
|
||||
口径(逐字固化):
|
||||
|
||||
| status | 语义 | 入执行面 |
|
||||
|---|---|---|
|
||||
| `draft` | 草稿(编制中,可编辑) | 否 |
|
||||
| `submitted` | 待审批 | 否 |
|
||||
| `approved` | **已批准(= 执行基准 / 生效版)** | 是 |
|
||||
| `active` | 已下达(下达动作后)/ 历史遗留「在用」 | 是 |
|
||||
| `rejected` | 已驳回 | 否 |
|
||||
| `archived` | 已归档 | 否 |
|
||||
|
||||
- **执行/对比/差异的唯一基准 = `approved` ∪ `active`**:审批通过即获得执行资格,
|
||||
修的是「执行查询只读 `active` ⇒ 已批准预算从执行视图消失」这一处口径错配。
|
||||
- 同键 `(kpi_id, period)` 多行并存时选行优先级:`active`(已下达)> `approved`,
|
||||
同级取 `updated_at` 最新 —— 保证取行确定性(改前 `bp_map[key] = value` 后写覆盖先写,
|
||||
结果依赖 DB 返回顺序)。
|
||||
- 既有 216 行 `active` 本单**不改标**(口径彻底归一属待 finance/任总确认项,见设计件 §1.3);
|
||||
`active` 在 UI 侧仍映射为 `draft`(历史兼容,`approval_status_of`)。
|
||||
|
||||
**规则**:任何「预算执行基准」查询必须走 `effective_plan_clause()` /
|
||||
`pick_effective()`,**禁止再手写 `BudgetPlan.status == "active"`**。
|
||||
非执行面用法(新建去重、自动分解、滚动延展、方法应用、现金流联动)
|
||||
不在本口径范围内,随「彻底归一」单一起收敛(设计件 §1.5)。
|
||||
"""
|
||||
from sqlalchemy import and_
|
||||
|
||||
from app.models import BudgetPlan
|
||||
|
||||
#: 执行基准状态(生效版)
|
||||
EFFECTIVE_STATUSES = ("approved", "active")
|
||||
#: UI 展示为「草稿」的状态(`active` = 历史遗留「在用」态)
|
||||
DRAFT_LIKE_STATUSES = ("draft", "active")
|
||||
|
||||
#: 已下达态(选行优先级最高)
|
||||
_STATUS_ACTIVE = "active"
|
||||
|
||||
|
||||
def effective_plan_clause(entity_id: int):
|
||||
"""执行基准条件(含账套隔离)—— 单一取数口。
|
||||
|
||||
用法:`db.query(BudgetPlan).filter(BudgetPlan.period == p, effective_plan_clause(eid))`
|
||||
"""
|
||||
if entity_id is None:
|
||||
raise ValueError("effective_plan_clause 必须传 entity_id(账套隔离,禁止漏传落默认账套)")
|
||||
return and_(
|
||||
BudgetPlan.entity_id == entity_id,
|
||||
BudgetPlan.status.in_(EFFECTIVE_STATUSES),
|
||||
)
|
||||
|
||||
|
||||
def effective_status_priority(status: str) -> int:
|
||||
"""选行优先级:`active`(已下达) 最高,其余生效态次之。"""
|
||||
return 0 if status == _STATUS_ACTIVE else 1
|
||||
|
||||
|
||||
def _sort_key(row):
|
||||
ts = getattr(row, "updated_at", None)
|
||||
try:
|
||||
stamp = ts.timestamp() if ts is not None else 0.0
|
||||
except (AttributeError, ValueError, OSError):
|
||||
stamp = 0.0
|
||||
return (effective_status_priority(getattr(row, "status", "") or ""), -stamp)
|
||||
|
||||
|
||||
def pick_effective(rows):
|
||||
"""同一 `(kpi_id, period)` 的多行 → 取生效行;空输入返回 None。"""
|
||||
if not rows:
|
||||
return None
|
||||
return sorted(rows, key=_sort_key)[0]
|
||||
|
||||
|
||||
def effective_value_map(rows):
|
||||
"""行列表 → `{(kpi_id, period): budget_value}`(按生效优先级去重)。"""
|
||||
grouped = {}
|
||||
for r in rows:
|
||||
grouped.setdefault((r.kpi_id, r.period), []).append(r)
|
||||
out = {}
|
||||
for key, rows_of_key in grouped.items():
|
||||
row = pick_effective(rows_of_key)
|
||||
if row is not None:
|
||||
out[key] = row.budget_value
|
||||
return out
|
||||
|
||||
|
||||
def is_effective_status(status: str) -> bool:
|
||||
return (status or "") in EFFECTIVE_STATUSES
|
||||
|
||||
|
||||
def approval_status_of(raw: str) -> str:
|
||||
"""DB status → 前端 `approval_status`(历史兼容:`active` 曾表示在用/草稿态)。"""
|
||||
if not raw:
|
||||
return "draft"
|
||||
if raw in DRAFT_LIKE_STATUSES:
|
||||
return "draft"
|
||||
return raw
|
||||
@@ -13,6 +13,7 @@ from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from app.database import get_session_local
|
||||
from app.utils.budget_status import EFFECTIVE_STATUSES, pick_effective
|
||||
from app.models import KPIDefinition, KPIValue, KPIAlert, BudgetPlan, SystemConfig
|
||||
# 裁定 R1/R2/R3(2026-09-19 财务口径):demo 账套排除 / 期间窗口 / 规整月期间
|
||||
from app.utils.alert_entity_scope import (
|
||||
@@ -93,14 +94,15 @@ def calc_deviation(actual: float, budget: float) -> dict:
|
||||
|
||||
def get_budget_for_kpi(db, kpi_id: int, period: str, version: str = None) -> Optional[float]:
|
||||
"""获取指定KPI在指定期间的预算值"""
|
||||
# 生效版口径(2026-10-03 状态机归一):approved(已批准) ∪ active(已下达)
|
||||
query = db.query(BudgetPlan).filter(
|
||||
BudgetPlan.kpi_id == kpi_id,
|
||||
BudgetPlan.period == period,
|
||||
BudgetPlan.status == "active",
|
||||
BudgetPlan.status.in_(EFFECTIVE_STATUSES),
|
||||
)
|
||||
if version:
|
||||
query = query.filter(BudgetPlan.version == version)
|
||||
plan = query.order_by(BudgetPlan.updated_at.desc()).first()
|
||||
plan = pick_effective(query.all()) # active > approved > updated_at desc
|
||||
return plan.budget_value if plan else None
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
@@ -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、零部署、零重启、零写库。
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user