From 94aeb14e95e15e30a6b9e92bf689008ba3d8c45c Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Fri, 28 Aug 2026 18:03:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=A2=84=E7=AE=97=E7=B3=BB=E7=BB=9F6?= =?UTF-8?q?=E9=A1=B9=E6=8A=80=E6=9C=AF=E6=94=B9=E8=BF=9B=EF=BC=88=E5=91=8A?= =?UTF-8?q?=E8=AD=A6=E5=BD=92=E5=9B=A0/=E5=AE=9E=E9=99=85=E5=80=BC?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=BD=92=E9=9B=86/=E7=9C=9F=E9=9B=B6?= =?UTF-8?q?=E5=9F=BA/=E6=B4=BE=E7=94=9F=E8=A7=84=E5=88=99/=E5=91=8A?= =?UTF-8?q?=E8=AD=A6=E8=B7=AF=E5=BE=84=E7=BB=9F=E4=B8=80/=E7=8E=B0?= =?UTF-8?q?=E9=87=91=E6=B5=81=E5=88=86=E7=B1=BB=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1-③ 告警归因: budget_deviation_alerts+alert_type/attribution/scenario_id, 归因引擎alert_attribution.py(子KPI/科目/量价差/趋势), deviation-check统一写归因+场景, GET /deviation-alerts/{id}/attribution详情(旧告警现场组装) P1-④ 实际值自动归集: kpi_value_sources/kpi_value_collect_logs表+CRUD+试跑+覆盖率, 采集器kpi_value_collector.py(voucher_details/进销存/cash_plans按entity+period汇总, 幂等upsert不覆盖人工), crontab每日06:30 P2-① 真零基: budget_zero_based_items逐项论证表+generate, method-comparison有论证项逐项求和is_demo=false否则fallback P2-② 派生规则: budget_derivation_rules配置表, apply-method优先读规则rule_source=configured P2-⑤ 告警双路径合并: deviation_engine.build_deviation_alert统一函数, 方向列表配置化kpi_alert_higher_better+alert-direction接口 P2-⑥ 现金流分类: cash_plan_classify_rules规则表+cash_plan_unclassified待分类队列, sync-cash-plans未命中进队列不静默跳过 新增: GET /kpis/{kpi_id}/values + 前端kpiApi.values(归集标签页数据源), scenario_suggestions幂等seed(init_db) 测试: test_budget_tech_improve.py 15用例, 预算相关96 passed, 全量646 passed --- backend/app/api/budget.py | 415 ++++++++-- backend/app/api/cash_classify.py | 253 ++++++ backend/app/api/derivation_rules.py | 141 ++++ backend/app/api/kpis.py | 27 + backend/app/api/value_sources.py | 276 +++++++ backend/app/api/zero_based.py | 217 ++++++ backend/app/database.py | 41 + backend/app/main.py | 6 +- backend/app/models/__init__.py | 106 +++ .../deviation_engine.cpython-312.pyc | Bin 12407 -> 16098 bytes backend/app/utils/alert_attribution.py | 203 +++++ backend/app/utils/deviation_engine.py | 141 +++- backend/scripts/kpi_value_collector.py | 233 ++++++ backend/tests/test_budget_tech_improve.py | 462 +++++++++++ frontend/src/api/index.ts | 34 + frontend/src/views/BudgetManagement.vue | 727 +++++++++++++++++- 16 files changed, 3164 insertions(+), 118 deletions(-) create mode 100644 backend/app/api/cash_classify.py create mode 100644 backend/app/api/derivation_rules.py create mode 100644 backend/app/api/value_sources.py create mode 100644 backend/app/api/zero_based.py create mode 100644 backend/app/utils/alert_attribution.py create mode 100644 backend/scripts/kpi_value_collector.py create mode 100644 backend/tests/test_budget_tech_improve.py diff --git a/backend/app/api/budget.py b/backend/app/api/budget.py index 215c0ce2..4830284c 100644 --- a/backend/app/api/budget.py +++ b/backend/app/api/budget.py @@ -803,20 +803,23 @@ def get_kpi_comparison( def check_budget_deviation( data: dict, db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), current_user=Depends(require_auth), ): """ - 检查实际vs预测偏差,当偏差超过20%时自动生成预警 + 检查实际vs预测偏差,当偏差超过阈值时自动生成预警 + (2026-08-28 P1-③/P2-⑤: 统一走 build_deviation_alert,写入归因JSON+场景建议) """ from app.models import KPIValue, BudgetDeviationAlert from sqlalchemy import func + from app.utils.deviation_engine import build_deviation_alert threshold = data.get("threshold", 20) # 默认20% period = data.get("period") or datetime.now().strftime("%Y-%m") - auto_resolve = data.get("auto_resolve", True) # 是否自动关闭已解决的预警 - # 查询该期间的有预算的KPI + # 查询该期间有预算的KPI(多租户隔离 entity_id) budget_plans = db.query(BudgetPlan).filter( + BudgetPlan.entity_id == entity_id, BudgetPlan.period == period, BudgetPlan.status == "active", ).all() @@ -832,39 +835,25 @@ def check_budget_deviation( alerts = [] for bp in budget_plans: - # 查询实际值 - actual = db.query(KPIValue).filter( - KPIValue.kpi_id == bp.kpi_id, - KPIValue.period == period, - KPIValue.actual_value.isnot(None), + kpi_obj = db.query(KPIDefinition).filter( + KPIDefinition.id == bp.kpi_id, + KPIDefinition.entity_id == entity_id, ).first() - - if not actual or actual.actual_value is None: + if not kpi_obj: continue - budget_val = bp.budget_value - actual_val = actual.actual_value - - if budget_val == 0: + # 统一告警逻辑(方向性/阈值/归因/场景建议) + result = build_deviation_alert(db, kpi_obj, period, entity_id=entity_id, min_rate=threshold) + if not result["triggered"]: continue - # 计算偏差率 - deviation_rate = round((actual_val - budget_val) / budget_val * 100, 2) - - # 只有偏差超过阈值才生成预警 - if abs(deviation_rate) <= threshold: - continue - - deviation_value = round(actual_val - budget_val, 2) - - # 判断预警等级 - alert_level = "critical" if abs(deviation_rate) > 50 else "warning" - - # 生成建议 - if deviation_rate > 0: - suggestion = f"实际值超出预算 {deviation_rate}%,建议核查超支原因并采取控制措施" - else: - suggestion = f"实际值低于预算 {abs(deviation_rate)}%,建议分析是否预算过高或业务量未达预期" + deviation = result["deviation"] + budget_val = deviation.get("budget_value") + actual_val = deviation.get("actual_value") + deviation_rate = deviation.get("deviation_rate") + deviation_value = deviation.get("deviation_amount") + if deviation_value is None: + deviation_value = round((actual_val or 0) - (budget_val or 0), 2) # 检查是否已存在相同的预警 existing_alert = db.query(BudgetDeviationAlert).filter( @@ -874,6 +863,12 @@ def check_budget_deviation( ).first() if existing_alert: + # 已存在open告警: 补齐归因(原open告警可能无归因, 幂等补写) + if existing_alert.attribution is None and result["attribution"]: + existing_alert.attribution = result["attribution"] + existing_alert.alert_type = result["alert_type"] + existing_alert.scenario_id = result["scenario_id"] + db.flush() continue alert = BudgetDeviationAlert( @@ -883,14 +878,16 @@ def check_budget_deviation( actual_value=actual_val, deviation_rate=deviation_rate, deviation_value=deviation_value, - alert_level=alert_level, + alert_level=result["level"], status="open", - suggestion=suggestion, + suggestion=result["suggestion"], + alert_type=result["alert_type"], + attribution=result["attribution"], + scenario_id=result["scenario_id"], ) db.add(alert) alerts_generated += 1 - kpi_obj = db.query(KPIDefinition).filter(KPIDefinition.id == bp.kpi_id).first() alerts.append({ "kpi_id": bp.kpi_id, "kpi_code": kpi_obj.kpi_code if kpi_obj else "", @@ -900,8 +897,10 @@ def check_budget_deviation( "actual_value": actual_val, "deviation_rate": deviation_rate, "deviation_value": deviation_value, - "alert_level": alert_level, - "suggestion": suggestion, + "alert_level": result["level"], + "suggestion": result["suggestion"], + "alert_type": result["alert_type"], + "attribution": result["attribution"], }) db.commit() @@ -922,10 +921,11 @@ def list_deviation_alerts( alert_level: Optional[str] = Query(None), status: Optional[str] = Query(None), db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), ): - """查询预算偏差预警记录""" + """查询预算偏差预警记录 (2026-08-28: 列表新增 alert_type/attribution/scenario_id,entity_id隔离)""" from app.models import BudgetDeviationAlert - query = db.query(BudgetDeviationAlert) + query = db.query(BudgetDeviationAlert).filter(BudgetDeviationAlert.entity_id == entity_id) if kpi_id: query = query.filter(BudgetDeviationAlert.kpi_id == kpi_id) if period: @@ -952,11 +952,120 @@ def list_deviation_alerts( "alert_level": a.alert_level, "status": a.status, "suggestion": a.suggestion, + "alert_type": a.alert_type, + "attribution": a.attribution, + "scenario_id": a.scenario_id, "created_at": a.created_at.isoformat() if a.created_at else None, }) return {"data": result, "total": len(result)} +@router.get("/deviation-alerts/{alert_id}/attribution") +def get_deviation_alert_attribution( + alert_id: int, + db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), +): + """告警归因详情 — 告警 + 归因JSON + 场景建议(联查 scenario_suggestions)(P1-③ 2026-08-28)""" + from app.models import BudgetDeviationAlert, ScenarioSuggestion + from app.utils.alert_attribution import match_scenario + + alert = db.query(BudgetDeviationAlert).filter( + BudgetDeviationAlert.id == alert_id, + BudgetDeviationAlert.entity_id == entity_id, + ).first() + if not alert: + raise HTTPException(404, "预警记录不存在") + + kpi_obj = db.query(KPIDefinition).filter(KPIDefinition.id == alert.kpi_id).first() + + # 归因(若旧告警无归因字段,现场组装一次) + attribution = alert.attribution + if attribution is None: + from app.utils.alert_attribution import build_attribution + try: + attribution, inferred_type = build_attribution(db, alert.kpi_id, alert.period, alert.alert_type) + alert.attribution = attribution + if alert.alert_type is None: + alert.alert_type = inferred_type + db.commit() + except Exception: + attribution = {} + + scenario = None + if alert.scenario_id or alert.alert_type: + scenario = match_scenario(db, alert.alert_type) + + return { + "id": alert.id, + "kpi_id": alert.kpi_id, + "kpi_code": kpi_obj.kpi_code if kpi_obj else "", + "kpi_name": kpi_obj.kpi_name if kpi_obj else "", + "period": alert.period, + "budget_value": alert.budget_value, + "actual_value": alert.actual_value, + "deviation_rate": alert.deviation_rate, + "deviation_value": alert.deviation_value, + "alert_level": alert.alert_level, + "status": alert.status, + "suggestion": alert.suggestion, + "alert_type": alert.alert_type, + "attribution": attribution or {}, + "scenario": scenario, + "created_at": alert.created_at.isoformat() if alert.created_at else None, + } + + +@router.get("/alert-direction") +def get_alert_direction( + db: Session = Depends(get_db), +): + """越高越好型KPI方向配置 (P2-⑤ 2026-08-28: system_configs 可维护)""" + from app.utils.deviation_engine import get_higher_better_codes, CONFIG_KEY_HIGHER_BETTER + from app.models import SystemConfig + + cfg = db.query(SystemConfig).filter( + SystemConfig.config_key == CONFIG_KEY_HIGHER_BETTER + ).first() + codes = get_higher_better_codes(db) + return { + "config_key": CONFIG_KEY_HIGHER_BETTER, + "codes": codes, + "is_configured": bool(cfg and cfg.config_value), + } + + +@router.put("/alert-direction") +def update_alert_direction( + data: dict, + db: Session = Depends(get_db), + current_user=Depends(require_auth), +): + """维护越高越好型KPI方向配置 (P2-⑤) body: {codes: ["SALES_TOTAL", ...]}""" + import json as _json + from app.utils.deviation_engine import CONFIG_KEY_HIGHER_BETTER + from app.models import SystemConfig + + codes = data.get("codes") + if not isinstance(codes, list): + raise HTTPException(400, "codes 必须是非空数组") + codes = [str(c) for c in codes] + + cfg = db.query(SystemConfig).filter( + SystemConfig.config_key == CONFIG_KEY_HIGHER_BETTER + ).first() + if cfg: + cfg.config_value = _json.dumps(codes, ensure_ascii=False) + else: + db.add(SystemConfig( + config_key=CONFIG_KEY_HIGHER_BETTER, + config_value=_json.dumps(codes, ensure_ascii=False), + description="越高越好型KPI编码列表(实际低于预算才告警)", + )) + db.commit() + return {"success": True, "config_key": CONFIG_KEY_HIGHER_BETTER, "codes": codes} + + @router.put("/deviation-alerts/{alert_id}") def update_deviation_alert( alert_id: int, @@ -979,11 +1088,18 @@ def update_deviation_alert( # ────────────────────────────────────────────── @router.post("/method-comparison") -def budget_method_comparison(data: dict): +def budget_method_comparison( + data: dict, + db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), +): """ 预算方法三选一对比计算 接收: { entity: "hanke", last_month_budget: 91, current_revenue: 122, ... } 返回三种方法的计算结果 + (2026-08-28 P2-①: zero_based 优先读逐项论证项 budget_zero_based_items, + 传入 zero_based_kpi_id+zero_based_period 且有论证项 → 逐项求和 is_demo=false; + 无论证项 → fallback 旧公式 is_demo=true) """ entity = data.get("entity", "hanke") last_month_budget = data.get("last_month_budget", 91) # 上月预算(万) @@ -1001,22 +1117,44 @@ def budget_method_comparison(data: dict): incremental_result = round(last_month_budget * (1 + increment_rate), 1) incremental_detail = f"上月{last_month_budget}万 × (1+{increment_rate*100:.0f}%) = {incremental_result}万" - # 2. 零基预算: 每项从零论证 - zbb_entertainment = round(fixed_costs.get("entertainment", 16) / 2, 1) # 砍半 - zbb_misc = round(fixed_costs.get("misc", 12) * 0.7, 1) # 压缩30% - zbb_total = round( - fixed_costs.get("rent", 15) - + fixed_costs.get("labor", 40) - + zbb_entertainment - + zbb_misc, - 1, - ) - zbb_savings = round(last_month_budget - zbb_total, 1) - zbb_detail = ( - f"房租{fixed_costs.get('rent', 15)}万(固定)+人工{fixed_costs.get('labor', 40)}万(砍不掉)" - f"+招待{zbb_entertainment}万(砍半)+杂项{zbb_misc}万(压缩)" - f"={zbb_total}万 ← 省{zbb_savings}万" - ) + # 2. 零基预算: 优先逐项论证(P2-① 真零基) + zbb_kpi_id = data.get("zero_based_kpi_id") + zbb_period = data.get("zero_based_period") + zbb_is_demo = True + zbb_items = [] + if zbb_kpi_id and zbb_period: + from app.models import BudgetZeroBasedItem + zbb_items = db.query(BudgetZeroBasedItem).filter( + BudgetZeroBasedItem.entity_id == entity_id, + BudgetZeroBasedItem.kpi_id == zbb_kpi_id, + BudgetZeroBasedItem.period == zbb_period, + ).all() + + if zbb_items: + # 真零基: 逐项求和(仅 approved+draft 都算,draft为未定稿) + zbb_total = round(sum(i.proposed_value for i in zbb_items), 1) + zbb_is_demo = False + zbb_detail = "零基逐项论证: " + " + ".join( + f"{i.item_name}{i.proposed_value}万" for i in zbb_items + ) + f" = {zbb_total}万" + zbb_savings = round(last_month_budget - zbb_total, 1) + else: + # fallback 旧演示公式(标注 is_demo) + zbb_entertainment = round(fixed_costs.get("entertainment", 16) / 2, 1) # 砍半 + zbb_misc = round(fixed_costs.get("misc", 12) * 0.7, 1) # 压缩30% + zbb_total = round( + fixed_costs.get("rent", 15) + + fixed_costs.get("labor", 40) + + zbb_entertainment + + zbb_misc, + 1, + ) + zbb_savings = round(last_month_budget - zbb_total, 1) + zbb_detail = ( + f"房租{fixed_costs.get('rent', 15)}万(固定)+人工{fixed_costs.get('labor', 40)}万(砍不掉)" + f"+招待{zbb_entertainment}万(砍半)+杂项{zbb_misc}万(压缩)" + f"={zbb_total}万 ← 省{zbb_savings}万" + ) # 3. 弹性预算: 根据收入水平动态调整 flexible_fixed = round(fixed_costs.get("rent", 15) + fixed_costs.get("labor", 40) * 0.5, 1) @@ -1053,6 +1191,8 @@ def budget_method_comparison(data: dict): "result_value": zbb_total, "savings": zbb_savings, "detail": zbb_detail, + "is_demo": zbb_is_demo, + "item_count": len(zbb_items), "pros": "最合理", "cons": "耗时", "is_recommended": True, @@ -1078,16 +1218,21 @@ def budget_method_comparison(data: dict): def apply_budget_method( data: dict, db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), current_user=Depends(require_auth), ): """应用所选预算编制方法到预算计划(2026-08-26:三法并存,按用户场景选择后落地) 接收: { method: 'incremental'|'zero_based'|'flexible', year: 2026, entity: 'hanke', ... } 说明: 方法计算结果 → 写入/更新预算计划(version标注方法名,便于追溯) + (2026-08-28 P2-②: KPI派生规则可配置 budget_derivation_rules, + percentage_of → base_kpi实际值×rate; incremental → 上月×(1+rate); + 无规则 fallback 默认比例(净利2%/费用率22%/毛利18%), 响应带 rule_source) """ + from app.models import BudgetDerivationRule + method = data.get("method", "zero_based") year = data.get("year", datetime.now().year) entity = data.get("entity", "hanke") - entity_id = data.get("entity_id", 1) # 复用method-comparison计算(获得三法结果) comp = budget_method_comparison({ @@ -1099,7 +1244,9 @@ def apply_budget_method( }), "variable_cost_rate": data.get("variable_cost_rate", 0.4862), "increment_rate": data.get("increment_rate", 0.05), - }) + "zero_based_kpi_id": data.get("zero_based_kpi_id"), + "zero_based_period": data.get("zero_based_period"), + }, db=db, entity_id=entity_id) # 找所选方法的结果 selected = None @@ -1111,7 +1258,6 @@ def apply_budget_method( raise HTTPException(400, "未知预算方法: " + method) # 找到该年的核心KPI(营业收入/净利润/费用率等) - # 取该年已有预算的KPI,或默认核心4个 kpis = db.query(KPIDefinition).filter( KPIDefinition.entity_id == entity_id, KPIDefinition.status == "active", @@ -1120,11 +1266,18 @@ def apply_budget_method( if not kpis: raise HTTPException(400, "未找到可应用的KPI") - # 方法结果解释为收入预算(核心KPI应用) - # incremental/flexible/zero_based 的 result_value 均为"预算总额(万)" - # 写入F_REVENUE年度预算(period=YYYY-00 表示年度) + # 加载派生规则(P2-②) + rules = db.query(BudgetDerivationRule).filter( + BudgetDerivationRule.entity_id == entity_id, + BudgetDerivationRule.status == "active", + ).all() + rules_by_kpi = {r.kpi_id: r for r in rules} + + # 版本 version = f"{method}-{datetime.now().strftime('%Y%m%d')}" applied = [] + used_configured = False + for kpi in kpis: period = f"{year}-00" # 删除旧版本的同KPI年度预算 @@ -1134,15 +1287,67 @@ def apply_budget_method( BudgetPlan.version.like(f"{method}-%"), ).delete() - # 各KPI的应用值(简化:收入用方法结果,其他按比例) + # 各KPI的应用值:收入用方法结果,其他优先派生规则(P2-②) if kpi.kpi_code == "F_REVENUE": budget_val = selected["result_value"] - elif kpi.kpi_code == "F_NET_PROFIT": - budget_val = round(selected["result_value"] * 0.02, 1) # 净利率约2% - elif kpi.kpi_code == "F_COST_RATIO": - budget_val = round(selected["result_value"] * 0.22, 1) # 费用率约22% - else: # F_GROSS_MARGIN - budget_val = round(selected["result_value"] * 0.18, 1) # 毛利率约18% + rule_source = "default" + formula_note = "方法结果" + else: + rule = rules_by_kpi.get(kpi.id) + if rule and rule.params: + rate = float(rule.params.get("rate", 0.02)) + if rule.rule_type == "percentage_of" and rule.base_kpi_id: + # 来源KPI实际值 × 比例 + base_val = None + base_actual = db.query(KPIValue).filter( + KPIValue.kpi_id == rule.base_kpi_id, + KPIValue.actual_value.isnot(None), + ).order_by(KPIValue.calculated_at.desc()).first() + if base_actual: + base_val = base_actual.actual_value + if base_val is not None: + budget_val = round(base_val * rate, 1) + rule_source = "configured" + formula_note = f"派生: 来源KPI实际值{base_val} × {rate}" + else: + budget_val = round(selected["result_value"] * rate, 1) + rule_source = "configured_fallback" + formula_note = f"派生规则无来源实际值, 按方法结果×{rate}" + elif rule.rule_type == "incremental": + # 上月预算 × (1+rate) + prev_period = f"{year-1}-00" + prev_plan = db.query(BudgetPlan).filter( + BudgetPlan.entity_id == entity_id, + BudgetPlan.kpi_id == kpi.id, + BudgetPlan.period == prev_period, + BudgetPlan.status == "active", + ).order_by(BudgetPlan.updated_at.desc()).first() + if prev_plan and prev_plan.budget_value is not None: + budget_val = round(prev_plan.budget_value * (1 + rate), 1) + rule_source = "configured" + formula_note = f"派生: 上年预算{prev_plan.budget_value} × (1+{rate})" + else: + budget_val = round(selected["result_value"] * rate, 1) + rule_source = "configured_fallback" + formula_note = f"派生规则无上年预算, 按方法结果×{rate}" + else: + # formula 类型: 暂按方法结果×rate 兜底 + budget_val = round(selected["result_value"] * rate, 1) + rule_source = "configured" + formula_note = f"派生规则(formula): 方法结果×{rate}" + else: + # fallback 默认比例 + if kpi.kpi_code == "F_NET_PROFIT": + budget_val = round(selected["result_value"] * 0.02, 1) # 净利率约2% + elif kpi.kpi_code == "F_COST_RATIO": + budget_val = round(selected["result_value"] * 0.22, 1) # 费用率约22% + else: # F_GROSS_MARGIN + budget_val = round(selected["result_value"] * 0.18, 1) # 毛利率约18% + rule_source = "default" + formula_note = "默认比例" + + if rule_source in ("configured", "configured_fallback"): + used_configured = True bp = BudgetPlan( entity_id=entity_id, @@ -1153,10 +1358,11 @@ def apply_budget_method( budget_month=0, version=version, status="active", - remark=f"{selected['name']}应用({selected['result_value']}万) 来源{method}", + remark=f"{selected['name']}应用({selected['result_value']}万) 来源{method} | {formula_note}", + calc_logic=formula_note, ) db.add(bp) - applied.append({"kpi_code": kpi.kpi_code, "budget_value": budget_val}) + applied.append({"kpi_code": kpi.kpi_code, "budget_value": budget_val, "rule_source": rule_source}) db.commit() return { @@ -1167,7 +1373,8 @@ def apply_budget_method( "total_budget": selected["result_value"], "detail": selected["detail"], "applied": applied, - "note": "选择哪种方法取决于场景:增量=稳定业务快速编;零基=成本优化专项;弹性=收入波动大。方法结果写入年度预算(period=YYYY-00),可在版本管理中查看。", + "rule_source": "configured" if used_configured else "default", + "note": "选择哪种方法取决于场景:增量=稳定业务快速编;零基=成本优化专项;弹性=收入波动大。方法结果写入年度预算(period=YYYY-00),可在版本管理中查看。KPI派生规则可在「派生规则配置」中维护(P2-②)。", } @@ -1338,35 +1545,77 @@ def sync_cash_plans( db: Session = Depends(get_db), ): """预算→现金流计划联动: 按预算KPI生成/更新收付款计划(修复断点#1) + (2026-08-28 P2-⑥: 分类规则表优先, 未命中进待分类队列不再静默跳过) 收入类KPI(营收/回款/新客) → receive 成本类KPI(费用/厂补/采购) → pay + 分类来源: ①cash_plan_classify_rules规则表(精确KPI→关键词) ②默认关键词兜底 ③待分类队列 upsert: 同KPI+同日期+同类型 更新不重复 """ - from app.models import CashPlan + from app.models import CashPlan, CashPlanClassifyRule, CashPlanUnclassified from datetime import datetime + # 默认关键词兜底(兼容存量,规则表优先) RECEIVE_KEYS = ("营收", "收入", "销售", "回款", "新客", "收款", "净利润", "毛利") PAY_KEYS = ("费用", "成本", "厂补", "采购", "返利", "应付", "损耗", "投入") + # 加载分类规则表(P2-⑥) + rules = db.query(CashPlanClassifyRule).filter( + CashPlanClassifyRule.entity_id == entity_id, + CashPlanClassifyRule.status == "active", + ).order_by(CashPlanClassifyRule.priority.asc()).all() + kpi_rules = {r.kpi_id: r for r in rules if r.kpi_id} + pattern_rules = [r for r in rules if not r.kpi_id and r.kpi_code_pattern] + + def classify_plan_type(kpi) -> Optional[str]: + """返回 receive/pay/None(未分类)""" + # ① 精确KPI匹配(优先) + if kpi.id in kpi_rules: + return kpi_rules[kpi.id].plan_type + # ② 关键词/编码模式匹配(规则表) + name = (kpi.kpi_name or "") + (kpi.kpi_code or "") + for r in pattern_rules: + if r.kpi_code_pattern and r.kpi_code_pattern in name: + return r.plan_type + # ③ 默认关键词兜底(兼容存量行为) + if any(k in name for k in RECEIVE_KEYS): + return "receive" + if any(k in name for k in PAY_KEYS): + return "pay" + return None + budgets = db.query(BudgetPlan).filter( BudgetPlan.entity_id == entity_id, BudgetPlan.status == "active" ).all() kpi_ids = {b.kpi_id for b in budgets} kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(kpi_ids)).all()} if kpi_ids else {} - created, updated = 0, 0 + created, updated, unclassified_count = 0, 0, 0 for b in budgets: kpi = kpis.get(b.kpi_id) if not kpi: continue - name = (kpi.kpi_name or "") + (kpi.kpi_code or "") - if any(k in name for k in RECEIVE_KEYS): - plan_type = "receive" - elif any(k in name for k in PAY_KEYS): - plan_type = "pay" - else: - continue # 无法判类别的KPI跳过 + plan_type = classify_plan_type(kpi) + if plan_type is None: + # 无法判类别 → 写入待分类队列(不静默跳过,P2-⑥) + existing_un = db.query(CashPlanUnclassified).filter( + CashPlanUnclassified.entity_id == entity_id, + CashPlanUnclassified.kpi_id == b.kpi_id, + CashPlanUnclassified.period == b.period, + CashPlanUnclassified.status == "pending", + ).first() + if not existing_un: + db.add(CashPlanUnclassified( + entity_id=entity_id, + kpi_id=b.kpi_id, + kpi_name=kpi.kpi_name or kpi.kpi_code, + period=b.period, + budget_value=b.budget_value, + reason="未匹配任何分类规则", + status="pending", + )) + unclassified_count += 1 + continue year, month = b.budget_year or 2026, b.budget_month or 1 try: @@ -1395,4 +1644,8 @@ def sync_cash_plans( )) created += 1 db.commit() - return {"message": f"现金流联动完成: 新建{created}条, 更新{updated}条", "created": created, "updated": updated} + return { + "message": f"现金流联动完成: 新建{created}条, 更新{updated}条, 待分类{unclassified_count}条", + "created": created, "updated": updated, + "unclassified_count": unclassified_count, + } diff --git a/backend/app/api/cash_classify.py b/backend/app/api/cash_classify.py new file mode 100644 index 00000000..8bd537f7 --- /dev/null +++ b/backend/app/api/cash_classify.py @@ -0,0 +1,253 @@ +"""现金流分类规则 API — 管理会计OS (P2-⑥ 2026-08-28) + +分类规则管理(cash_plan_classify_rules) + 待分类队列(cash_plan_unclassified) + 一键归类。 +sync-cash-plans 未命中的KPI进入待分类队列,人工一键归类 → 自动补建规则+生成CashPlan。 +""" +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from typing import Optional +from datetime import datetime + +from app.database import get_db +from app.deps import get_entity_id +from app.auth_middleware import require_auth, require_role +from app.models import ( + CashPlanClassifyRule, CashPlanUnclassified, CashPlan, + KPIDefinition, BudgetPlan, +) + +router = APIRouter( + prefix="/api/cma/budget", + tags=["现金流分类"], + dependencies=[Depends(require_role("ceo", "finance", "it"))], +) + + +# ── 分类规则 CRUD ────────────────────────────── + +@router.get("/cash-classify-rules") +def list_classify_rules( + db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), +): + """分类规则列表(按 entity_id 隔离)""" + rows = db.query(CashPlanClassifyRule).filter( + CashPlanClassifyRule.entity_id == entity_id + ).order_by(CashPlanClassifyRule.priority.asc(), CashPlanClassifyRule.id.desc()).all() + + kpi_ids = {r.kpi_id for r in rows if r.kpi_id} + kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(kpi_ids)).all()} if kpi_ids else {} + result = [] + for r in rows: + kpi = kpis.get(r.kpi_id) if r.kpi_id else None + result.append({ + "id": r.id, + "kpi_id": r.kpi_id, + "kpi_code": kpi.kpi_code if kpi else "", + "kpi_name": kpi.kpi_name if kpi else "", + "kpi_code_pattern": r.kpi_code_pattern, + "plan_type": r.plan_type, + "priority": r.priority, + "status": r.status, + "created_at": r.created_at.isoformat() if r.created_at else None, + }) + return {"data": result, "total": len(result)} + + +@router.post("/cash-classify-rules") +def create_classify_rule( + data: dict, + db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), + current_user=Depends(require_auth), +): + """新建分类规则(kpi_id 精确 或 kpi_code_pattern 关键词 二选一)""" + plan_type = data.get("plan_type") + if plan_type not in ("receive", "pay"): + raise HTTPException(400, "plan_type 必须是 receive/pay") + kpi_id = data.get("kpi_id") + pattern = data.get("kpi_code_pattern") + if not kpi_id and not pattern: + raise HTTPException(400, "需要 kpi_id 或 kpi_code_pattern 至少一个") + + row = CashPlanClassifyRule( + entity_id=entity_id, + kpi_id=kpi_id, + kpi_code_pattern=pattern, + plan_type=plan_type, + priority=data.get("priority", 10), + status=data.get("status", "active"), + ) + db.add(row) + db.commit() + db.refresh(row) + return {"message": "分类规则已创建", "id": row.id} + + +@router.put("/cash-classify-rules/{rule_id}") +def update_classify_rule( + rule_id: int, + data: dict, + db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), + current_user=Depends(require_auth), +): + """更新分类规则""" + row = db.query(CashPlanClassifyRule).filter( + CashPlanClassifyRule.id == rule_id, + CashPlanClassifyRule.entity_id == entity_id, + ).first() + if not row: + raise HTTPException(404, "规则不存在") + for field in ("kpi_id", "kpi_code_pattern", "plan_type", "priority", "status"): + if field in data: + setattr(row, field, data[field]) + db.commit() + return {"message": "分类规则已更新", "id": row.id} + + +@router.delete("/cash-classify-rules/{rule_id}") +def delete_classify_rule( + rule_id: int, + db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), + current_user=Depends(require_auth), +): + """删除分类规则""" + row = db.query(CashPlanClassifyRule).filter( + CashPlanClassifyRule.id == rule_id, + CashPlanClassifyRule.entity_id == entity_id, + ).first() + if not row: + raise HTTPException(404, "规则不存在") + db.delete(row) + db.commit() + return {"message": "分类规则已删除"} + + +# ── 待分类队列 ────────────────────────────── + +@router.get("/cash-unclassified") +def list_unclassified( + status: Optional[str] = Query(None, description="pending/classified/ignored"), + db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), +): + """待分类KPI队列""" + query = db.query(CashPlanUnclassified).filter(CashPlanUnclassified.entity_id == entity_id) + if status: + query = query.filter(CashPlanUnclassified.status == status) + rows = query.order_by(CashPlanUnclassified.created_at.desc()).all() + result = [] + for r in rows: + result.append({ + "id": r.id, + "kpi_id": r.kpi_id, + "kpi_name": r.kpi_name, + "period": r.period, + "budget_value": r.budget_value, + "reason": r.reason, + "status": r.status, + "created_at": r.created_at.isoformat() if r.created_at else None, + "resolved_at": r.resolved_at.isoformat() if r.resolved_at else None, + }) + return {"data": result, "total": len(result)} + + +@router.post("/cash-unclassified/{item_id}/classify") +def classify_unclassified( + item_id: int, + data: dict, + db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), + current_user=Depends(require_auth), +): + """一键归类: body {plan_type: receive/pay} + ① 自动补建分类规则 ② 标记队列 classified ③ 联动生成对应 CashPlan + """ + plan_type = data.get("plan_type") + if plan_type not in ("receive", "pay"): + raise HTTPException(400, "plan_type 必须是 receive/pay") + + item = db.query(CashPlanUnclassified).filter( + CashPlanUnclassified.id == item_id, + CashPlanUnclassified.entity_id == entity_id, + CashPlanUnclassified.status == "pending", + ).first() + if not item: + raise HTTPException(404, "待分类记录不存在或已处理") + + # ① 自动补建规则(无精确KPI规则时) + existing_rule = db.query(CashPlanClassifyRule).filter( + CashPlanClassifyRule.entity_id == entity_id, + CashPlanClassifyRule.kpi_id == item.kpi_id, + ).first() + if not existing_rule: + db.add(CashPlanClassifyRule( + entity_id=entity_id, + kpi_id=item.kpi_id, + kpi_code_pattern=None, + plan_type=plan_type, + priority=10, + status="active", + )) + + # ② 标记队列 + item.status = "classified" + item.resolved_at = datetime.now() + + # ③ 联动生成 CashPlan(有期间和预算值时) + plan_created = False + if item.period and item.budget_value is not None: + try: + year, month = int(item.period.split("-")[0]), int(item.period.split("-")[1]) + plan_date = datetime(year, month, 1) + except Exception: + plan_date = None + if plan_date: + existing_plan = db.query(CashPlan).filter( + CashPlan.entity_id == entity_id, + CashPlan.related_kpi_id == item.kpi_id, + CashPlan.plan_type == plan_type, + CashPlan.plan_date == plan_date, + ).first() + if not existing_plan: + db.add(CashPlan( + entity_id=entity_id, + plan_type=plan_type, + related_kpi_id=item.kpi_id, + amount=item.budget_value, + plan_date=plan_date, + description=f"待分类队列归类: {item.kpi_name or ''}", + status="pending", + source="budget_sync", + )) + plan_created = True + + db.commit() + return { + "message": f"已归类为 {plan_type}" + (" 并生成现金流计划" if plan_created else ""), + "plan_type": plan_type, + "rule_created": not existing_rule, + "plan_created": plan_created, + } + + +@router.post("/cash-unclassified/{item_id}/ignore") +def ignore_unclassified( + item_id: int, + db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), + current_user=Depends(require_auth), +): + """忽略该KPI(不生成规则)""" + item = db.query(CashPlanUnclassified).filter( + CashPlanUnclassified.id == item_id, + CashPlanUnclassified.entity_id == entity_id, + ).first() + if not item: + raise HTTPException(404, "待分类记录不存在") + item.status = "ignored" + item.resolved_at = datetime.now() + db.commit() + return {"message": "已忽略"} diff --git a/backend/app/api/derivation_rules.py b/backend/app/api/derivation_rules.py new file mode 100644 index 00000000..fa95fbac --- /dev/null +++ b/backend/app/api/derivation_rules.py @@ -0,0 +1,141 @@ +"""KPI派生规则 API — 管理会计OS (P2-② 2026-08-28) + +派生规则配置(budget_derivation_rules):apply-method 派生KPI时优先读规则, +percentage_of → base_kpi实际值×rate;incremental → 上月×(1+rate);无规则fallback默认比例。 +""" +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from typing import Optional + +from app.database import get_db +from app.deps import get_entity_id +from app.auth_middleware import require_auth, require_role +from app.models import BudgetDerivationRule, KPIDefinition + +router = APIRouter( + prefix="/api/cma/budget", + tags=["派生规则"], + dependencies=[Depends(require_role("ceo", "finance", "it"))], +) + + +@router.get("/derivation-rules") +def list_derivation_rules( + kpi_id: Optional[int] = Query(None), + status: Optional[str] = Query(None), + db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), +): + """派生规则列表(按 entity_id 隔离)""" + query = db.query(BudgetDerivationRule).filter(BudgetDerivationRule.entity_id == entity_id) + if kpi_id: + query = query.filter(BudgetDerivationRule.kpi_id == kpi_id) + if status: + query = query.filter(BudgetDerivationRule.status == status) + rows = query.order_by(BudgetDerivationRule.id.desc()).all() + + all_kpi_ids = {r.kpi_id for r in rows} | {r.base_kpi_id for r in rows if r.base_kpi_id} + kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(all_kpi_ids)).all()} if all_kpi_ids else {} + result = [] + for r in rows: + kpi = kpis.get(r.kpi_id) + base = kpis.get(r.base_kpi_id) if r.base_kpi_id else None + result.append({ + "id": r.id, + "kpi_id": r.kpi_id, + "kpi_code": kpi.kpi_code if kpi else "", + "kpi_name": kpi.kpi_name if kpi else "", + "rule_type": r.rule_type, + "base_kpi_id": r.base_kpi_id, + "base_kpi_code": base.kpi_code if base else "", + "base_kpi_name": base.kpi_name if base else "", + "params": r.params, + "formula_text": r.formula_text, + "status": r.status, + "created_at": r.created_at.isoformat() if r.created_at else None, + }) + return {"data": result, "total": len(result)} + + +@router.post("/derivation-rules") +def create_derivation_rule( + data: dict, + db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), + current_user=Depends(require_auth), +): + """新建派生规则(同KPI同类型唯一)""" + kpi_id = data.get("kpi_id") + rule_type = data.get("rule_type") + if not kpi_id or rule_type not in ("incremental", "percentage_of", "formula"): + raise HTTPException(400, "需要 kpi_id 且 rule_type ∈ incremental/percentage_of/formula") + + kpi = db.query(KPIDefinition).filter( + KPIDefinition.id == kpi_id, + KPIDefinition.entity_id == entity_id, + ).first() + if not kpi: + raise HTTPException(404, "KPI不存在") + + existing = db.query(BudgetDerivationRule).filter( + BudgetDerivationRule.entity_id == entity_id, + BudgetDerivationRule.kpi_id == kpi_id, + BudgetDerivationRule.rule_type == rule_type, + ).first() + if existing: + raise HTTPException(400, f"KPI({kpi_id})已存在 {rule_type} 规则") + + row = BudgetDerivationRule( + entity_id=entity_id, + kpi_id=kpi_id, + rule_type=rule_type, + base_kpi_id=data.get("base_kpi_id"), + params=data.get("params"), + formula_text=data.get("formula_text"), + status=data.get("status", "active"), + ) + db.add(row) + db.commit() + db.refresh(row) + return {"message": "派生规则已创建", "id": row.id} + + +@router.put("/derivation-rules/{rule_id}") +def update_derivation_rule( + rule_id: int, + data: dict, + db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), + current_user=Depends(require_auth), +): + """更新派生规则""" + row = db.query(BudgetDerivationRule).filter( + BudgetDerivationRule.id == rule_id, + BudgetDerivationRule.entity_id == entity_id, + ).first() + if not row: + raise HTTPException(404, "规则不存在") + for field in ("rule_type", "base_kpi_id", "params", "formula_text", "status"): + if field in data: + setattr(row, field, data[field]) + db.commit() + return {"message": "派生规则已更新", "id": row.id} + + +@router.delete("/derivation-rules/{rule_id}") +def delete_derivation_rule( + rule_id: int, + db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), + current_user=Depends(require_auth), +): + """删除派生规则""" + row = db.query(BudgetDerivationRule).filter( + BudgetDerivationRule.id == rule_id, + BudgetDerivationRule.entity_id == entity_id, + ).first() + if not row: + raise HTTPException(404, "规则不存在") + db.delete(row) + db.commit() + return {"message": "派生规则已删除"} diff --git a/backend/app/api/kpis.py b/backend/app/api/kpis.py index 9ffbaf22..42e18392 100644 --- a/backend/app/api/kpis.py +++ b/backend/app/api/kpis.py @@ -525,6 +525,33 @@ def create_kpi_value( return {"message": "已录入", "id": new_val.id, "period": period, "actual_value": float(actual_value)} +@router.get("/{kpi_id}/values") +def list_kpi_values( + kpi_id: int, + db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), + current_user: User = Depends(require_auth), +): + """查询KPI实际值列表(含source_type标记,供归集标签页展示)""" + kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() + if not kpi: + raise HTTPException(404, "KPI不存在") + if kpi.entity_id != entity_id: + raise HTTPException(404, "KPI不存在") + vals = db.query(KPIValue).filter(KPIValue.kpi_id == kpi_id).order_by(KPIValue.period.desc()).all() + return { + "data": [{ + "id": v.id, + "period": v.period, + "actual_value": v.actual_value, + "source_type": v.source_type or "manual", + "source_batch": v.source_batch or "", + "data_status": v.data_status, + "remark": v.remark or "", + } for v in vals] + } + + @router.get("/{kpi_id}") def get_kpi(kpi_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() diff --git a/backend/app/api/value_sources.py b/backend/app/api/value_sources.py new file mode 100644 index 00000000..fa028216 --- /dev/null +++ b/backend/app/api/value_sources.py @@ -0,0 +1,276 @@ +"""实际值自动归集 API — 管理会计OS (P1-④ 2026-08-28) + +取数映射管理(kpi_value_sources) + 手动触发采集 + 采集日志 + 覆盖率统计 +采集器本体: scripts/kpi_value_collector.py(系统 crontab 每日 06:30) +""" +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from typing import Optional + +from app.database import get_db +from app.deps import get_entity_id +from app.auth_middleware import require_auth, require_role +from app.models import KPIValueSource, KPIValueCollectLog, KPIDefinition, KPIValue + +router = APIRouter( + prefix="/api/cma/budget", + tags=["实际值归集"], + dependencies=[Depends(require_role("ceo", "finance", "it"))], +) + + +# ── 取数映射 CRUD ────────────────────────────── + +@router.get("/value-sources") +def list_value_sources( + kpi_id: Optional[int] = Query(None), + status: Optional[str] = Query(None), + db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), +): + """取数映射列表(按 entity_id 隔离)""" + query = db.query(KPIValueSource).filter(KPIValueSource.entity_id == entity_id) + if kpi_id: + query = query.filter(KPIValueSource.kpi_id == kpi_id) + if status: + query = query.filter(KPIValueSource.status == status) + rows = query.order_by(KPIValueSource.id.desc()).all() + + kpi_ids = {r.kpi_id for r in rows} + kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(kpi_ids)).all()} if kpi_ids else {} + result = [] + for r in rows: + kpi = kpis.get(r.kpi_id) + result.append({ + "id": r.id, + "entity_id": r.entity_id, + "kpi_id": r.kpi_id, + "kpi_code": kpi.kpi_code if kpi else "", + "kpi_name": kpi.kpi_name if kpi else "", + "source_table": r.source_table, + "source_field": r.source_field, + "aggregate": r.aggregate, + "filter_rule": r.filter_rule, + "period_field": r.period_field, + "unit_conversion": r.unit_conversion, + "status": r.status, + "created_at": r.created_at.isoformat() if r.created_at else None, + }) + return {"data": result, "total": len(result)} + + +@router.post("/value-sources") +def create_value_source( + data: dict, + db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), + current_user=Depends(require_auth), +): + """新建取数映射""" + kpi_id = data.get("kpi_id") + source_table = data.get("source_table") + source_field = data.get("source_field") + if not kpi_id or not source_table or not source_field: + raise HTTPException(400, "缺少必要参数: kpi_id, source_table, source_field") + + kpi = db.query(KPIDefinition).filter( + KPIDefinition.id == kpi_id, + KPIDefinition.entity_id == entity_id, + ).first() + if not kpi: + raise HTTPException(404, "KPI不存在") + + existing = db.query(KPIValueSource).filter( + KPIValueSource.entity_id == entity_id, + KPIValueSource.kpi_id == kpi_id, + KPIValueSource.source_table == source_table, + ).first() + if existing: + raise HTTPException(400, f"该KPI({kpi_id})已存在 {source_table} 取数映射") + + row = KPIValueSource( + entity_id=entity_id, + kpi_id=kpi_id, + source_table=source_table, + source_field=source_field, + aggregate=data.get("aggregate", "sum"), + filter_rule=data.get("filter_rule"), + period_field=data.get("period_field", "period"), + unit_conversion=data.get("unit_conversion", 1), + status=data.get("status", "active"), + ) + db.add(row) + db.commit() + db.refresh(row) + return {"message": "取数映射已创建", "id": row.id} + + +@router.put("/value-sources/{source_id}") +def update_value_source( + source_id: int, + data: dict, + db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), + current_user=Depends(require_auth), +): + """更新取数映射""" + row = db.query(KPIValueSource).filter( + KPIValueSource.id == source_id, + KPIValueSource.entity_id == entity_id, + ).first() + if not row: + raise HTTPException(404, "映射不存在") + for field in ("source_table", "source_field", "aggregate", "filter_rule", + "period_field", "unit_conversion", "status"): + if field in data: + setattr(row, field, data[field]) + db.commit() + return {"message": "映射已更新", "id": row.id} + + +@router.delete("/value-sources/{source_id}") +def delete_value_source( + source_id: int, + db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), + current_user=Depends(require_auth), +): + """删除取数映射""" + row = db.query(KPIValueSource).filter( + KPIValueSource.id == source_id, + KPIValueSource.entity_id == entity_id, + ).first() + if not row: + raise HTTPException(404, "映射不存在") + db.delete(row) + db.commit() + return {"message": "映射已删除"} + + +@router.post("/value-sources/test") +def test_value_source( + data: dict, + db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), +): + """试跑单条映射返回预览值(不写库)""" + from scripts.kpi_value_collector import collect_for_mapping + + mapping = KPIValueSource( + entity_id=entity_id, + kpi_id=data.get("kpi_id"), + source_table=data.get("source_table"), + source_field=data.get("source_field"), + aggregate=data.get("aggregate", "sum"), + filter_rule=data.get("filter_rule"), + period_field=data.get("period_field", "period"), + unit_conversion=data.get("unit_conversion", 1), + status="active", + ) + period = data.get("period") or _default_period() + try: + value, message = collect_for_mapping(db, mapping, period, write_kpi=False) + return {"success": True, "period": period, "value": value, "message": message} + except Exception as e: + return {"success": False, "period": period, "value": None, "message": str(e)} + + +# ── 采集器触发 ────────────────────────────── + +@router.post("/value-collect/run") +def run_value_collect( + data: dict, + db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), + current_user=Depends(require_auth), +): + """手动触发采集器(可选 period 参数,默认当月)""" + from scripts.kpi_value_collector import run_collector + + period = data.get("period") or _default_period() + kpi_id = data.get("kpi_id") # 可选: 只采集单个KPI + result = run_collector(db, entity_id=entity_id, period=period, kpi_id=kpi_id) + result["period"] = period + return result + + +# ── 采集日志 ────────────────────────────── + +@router.get("/value-collect/logs") +def list_collect_logs( + status: Optional[str] = Query(None), + period: Optional[str] = Query(None), + limit: int = Query(50, ge=1, le=200), + db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), +): + """采集日志(status过滤)""" + query = db.query(KPIValueCollectLog).filter(KPIValueCollectLog.entity_id == entity_id) + if status: + query = query.filter(KPIValueCollectLog.status == status) + if period: + query = query.filter(KPIValueCollectLog.period == period) + rows = query.order_by(KPIValueCollectLog.collected_at.desc()).limit(limit).all() + + kpi_ids = {r.kpi_id for r in rows} + kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(kpi_ids)).all()} if kpi_ids else {} + result = [] + for r in rows: + kpi = kpis.get(r.kpi_id) + result.append({ + "id": r.id, + "kpi_id": r.kpi_id, + "kpi_code": kpi.kpi_code if kpi else "", + "kpi_name": kpi.kpi_name if kpi else "", + "period": r.period, + "source_table": r.source_table, + "collected_value": r.collected_value, + "status": r.status, + "message": r.message, + "collected_at": r.collected_at.isoformat() if r.collected_at else None, + }) + return {"data": result, "total": len(result)} + + +# ── 覆盖率统计 ────────────────────────────── + +@router.get("/value-sources/coverage") +def value_source_coverage( + db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), +): + """覆盖率统计:已配映射KPI数 / 总活跃KPI数 / 未配置清单""" + total_kpis = db.query(KPIDefinition).filter( + KPIDefinition.entity_id == entity_id, + KPIDefinition.status == "active", + ).count() + + mapped_rows = db.query(KPIValueSource).filter( + KPIValueSource.entity_id == entity_id, + KPIValueSource.status == "active", + ).all() + mapped_kpi_ids = {r.kpi_id for r in mapped_rows} + + all_kpis = db.query(KPIDefinition).filter( + KPIDefinition.entity_id == entity_id, + KPIDefinition.status == "active", + ).all() + unmapped = [{ + "kpi_id": k.id, + "kpi_code": k.kpi_code, + "kpi_name": k.kpi_name, + } for k in all_kpis if k.id not in mapped_kpi_ids] + + coverage = round(len(mapped_kpi_ids) / total_kpis * 100, 1) if total_kpis else 0 + return { + "mapped_count": len(mapped_kpi_ids), + "total_kpis": total_kpis, + "coverage_pct": coverage, + "unmapped_count": len(unmapped), + "unmapped": unmapped, + } + + +def _default_period() -> str: + from datetime import datetime + return datetime.now().strftime("%Y-%m") diff --git a/backend/app/api/zero_based.py b/backend/app/api/zero_based.py new file mode 100644 index 00000000..a4d6409a --- /dev/null +++ b/backend/app/api/zero_based.py @@ -0,0 +1,217 @@ +"""零基预算逐项论证 API — 管理会计OS (P2-① 2026-08-28) + +CRUD 逐项论证项(budget_zero_based_items) + generate 生成零基预算写 budget_plans。 +method-comparison 的 zero_based 分支优先读论证项(有数据→逐项求和 is_demo=false)。 +""" +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from typing import Optional +from datetime import datetime + +from app.database import get_db +from app.deps import get_entity_id +from app.auth_middleware import require_auth, require_role +from app.models import BudgetZeroBasedItem, KPIDefinition, BudgetPlan + +router = APIRouter( + prefix="/api/cma/budget", + tags=["零基预算"], + dependencies=[Depends(require_role("ceo", "finance", "it"))], +) + + +@router.get("/zero-based/items") +def list_zero_based_items( + kpi_id: Optional[int] = Query(None), + period: Optional[str] = Query(None), + db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), +): + """零基逐项论证列表(kpi_id+period 过滤)""" + query = db.query(BudgetZeroBasedItem).filter(BudgetZeroBasedItem.entity_id == entity_id) + if kpi_id: + query = query.filter(BudgetZeroBasedItem.kpi_id == kpi_id) + if period: + query = query.filter(BudgetZeroBasedItem.period == period) + rows = query.order_by(BudgetZeroBasedItem.id.desc()).all() + + kpi_ids = {r.kpi_id for r in rows} + kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(kpi_ids)).all()} if kpi_ids else {} + result = [] + for r in rows: + kpi = kpis.get(r.kpi_id) + result.append({ + "id": r.id, + "kpi_id": r.kpi_id, + "kpi_code": kpi.kpi_code if kpi else "", + "kpi_name": kpi.kpi_name if kpi else "", + "period": r.period, + "item_name": r.item_name, + "item_category": r.item_category, + "base_value": r.base_value, + "justification": r.justification, + "proposed_value": r.proposed_value, + "status": r.status, + "created_by": r.created_by, + "created_at": r.created_at.isoformat() if r.created_at else None, + }) + total_proposed = round(sum(r.proposed_value for r in rows), 2) + return {"data": result, "total": len(result), "total_proposed": total_proposed} + + +@router.post("/zero-based/items") +def create_zero_based_item( + data: dict, + db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), + current_user=Depends(require_auth), +): + """新建逐项论证项""" + kpi_id = data.get("kpi_id") + period = data.get("period") + item_name = data.get("item_name") + base_value = data.get("base_value") + proposed_value = data.get("proposed_value") + if not kpi_id or not period or not item_name: + raise HTTPException(400, "缺少必要参数: kpi_id, period, item_name") + if base_value is None: + base_value = 0 + if proposed_value is None: + proposed_value = 0 + + kpi = db.query(KPIDefinition).filter( + KPIDefinition.id == kpi_id, + KPIDefinition.entity_id == entity_id, + ).first() + if not kpi: + raise HTTPException(404, "KPI不存在") + + row = BudgetZeroBasedItem( + entity_id=entity_id, + kpi_id=kpi_id, + period=period, + item_name=item_name, + item_category=data.get("item_category", "discretionary"), + base_value=base_value, + justification=data.get("justification"), + proposed_value=proposed_value, + status=data.get("status", "draft"), + created_by=data.get("created_by") or (current_user.name if hasattr(current_user, "name") else None), + ) + db.add(row) + db.commit() + db.refresh(row) + return {"message": "论证项已创建", "id": row.id} + + +@router.put("/zero-based/items/{item_id}") +def update_zero_based_item( + item_id: int, + data: dict, + db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), + current_user=Depends(require_auth), +): + """更新逐项论证项""" + row = db.query(BudgetZeroBasedItem).filter( + BudgetZeroBasedItem.id == item_id, + BudgetZeroBasedItem.entity_id == entity_id, + ).first() + if not row: + raise HTTPException(404, "论证项不存在") + for field in ("item_name", "item_category", "base_value", "justification", + "proposed_value", "status"): + if field in data: + setattr(row, field, data[field]) + db.commit() + return {"message": "论证项已更新", "id": row.id} + + +@router.delete("/zero-based/items/{item_id}") +def delete_zero_based_item( + item_id: int, + db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), + current_user=Depends(require_auth), +): + """删除逐项论证项""" + row = db.query(BudgetZeroBasedItem).filter( + BudgetZeroBasedItem.id == item_id, + BudgetZeroBasedItem.entity_id == entity_id, + ).first() + if not row: + raise HTTPException(404, "论证项不存在") + db.delete(row) + db.commit() + return {"message": "论证项已删除"} + + +@router.post("/zero-based/generate") +def generate_zero_based_budget( + data: dict, + db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), + current_user=Depends(require_auth), +): + """生成零基预算 = Σ(proposed_value),写 budget_plans(version='zbb-YYYYMMDD',calc_logic='zero_based_itemized')""" + kpi_id = data.get("kpi_id") + period = data.get("period") + year = data.get("year") + if not kpi_id or not period: + raise HTTPException(400, "缺少必要参数: kpi_id, period") + + items = db.query(BudgetZeroBasedItem).filter( + BudgetZeroBasedItem.entity_id == entity_id, + BudgetZeroBasedItem.kpi_id == kpi_id, + BudgetZeroBasedItem.period == period, + ).all() + if not items: + raise HTTPException(400, f"期间 {period} 无逐项论证项,请先录入") + + total = round(sum(i.proposed_value for i in items), 2) + # 解析年份 + if not year: + try: + year = int(period.split("-")[0]) + except Exception: + year = datetime.now().year + month = 0 + try: + month = int(period.split("-")[1]) if "-" in period else 0 + except Exception: + month = 0 + + version = f"zbb-{datetime.now().strftime('%Y%m%d')}" + # 删除同KPI同期间同版本旧预算,防重复 + db.query(BudgetPlan).filter( + BudgetPlan.entity_id == entity_id, + BudgetPlan.kpi_id == kpi_id, + BudgetPlan.period == period, + BudgetPlan.version == version, + ).delete() + + bp = BudgetPlan( + entity_id=entity_id, + kpi_id=kpi_id, + period=period, + budget_value=total, + budget_year=year, + budget_month=month, + version=version, + status="active", + source_type="zero_based", + calc_logic="zero_based_itemized", + remark=f"零基逐项论证生成: {len(items)}项 Σ(proposed_value)={total}", + ) + db.add(bp) + db.commit() + + return { + "message": f"零基预算已生成: {total}({len(items)}项论证)", + "kpi_id": kpi_id, + "period": period, + "total": total, + "item_count": len(items), + "version": version, + "plan_id": bp.id, + } diff --git a/backend/app/database.py b/backend/app/database.py index dfb65580..9e474534 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -97,6 +97,47 @@ def init_db(): except Exception as e: logger.warning(f"user_entities初始化跳过: {e}") + # ── scenario_suggestions 告警场景建议 seed(2026-08-28 告警归因 P1-③)── + # 幂等:仅补缺失的 alert_type,不覆盖已有模板 + try: + inspector = inspect(get_engine()) + if "scenario_suggestions" in inspector.get_table_names(): + Session = get_session_local() + session = Session() + try: + from app.models import ScenarioSuggestion + existing_types = {s.alert_type for s in session.query(ScenarioSuggestion).all()} + seeds = [ + dict(alert_type="cash_low", title="现金流紧张 — 加强回款催收", + description="现金余额接近警戒线,建议优先处理应收款项,压缩非紧急支出。", + action_template="1. 列出未来30天应收清单,逐笔催收\n2. 暂停非紧急采购/费用支出\n3. 与银行沟通短期授信额度", + priority="high", sort_order=1), + dict(alert_type="cash_critical", title="现金流危急 — 立即止血", + description="现金余额已低于安全阈值,存在断流风险,需要立即采取止血措施。", + action_template="1. 冻结一切非必要支出\n2. 高管紧急复盘资金计划\n3. 启动应收账款特别催收\n4. 评估短期融资", + priority="high", sort_order=2), + dict(alert_type="cost_high", title="成本超支 — 核查费用构成", + description="实际成本超出预算,建议拆解到科目明细定位超支源头。", + action_template="1. 查看科目明细拆解,定位超支前3科目\n2. 分析价差/量差成因(单价上涨/用量增加)\n3. 对可控费用制定压降方案", + priority="medium", sort_order=3), + dict(alert_type="revenue_drop", title="收入下滑 — 追量提效", + description="实际收入低于预算,建议从子KPI量级分解查找差距来源。", + action_template="1. 查看子KPI拆解,定位量差最大维度\n2. 分析客户/渠道/产品线缺口\n3. 制定增量获客或转化提升方案", + priority="medium", sort_order=4), + ] + added = 0 + for s in seeds: + if s["alert_type"] not in existing_types: + session.add(ScenarioSuggestion(**s)) + added += 1 + if added: + session.commit() + logger.info(f"scenario_suggestions seed: 新增{added}条场景建议模板") + finally: + session.close() + except Exception as e: + logger.warning(f"scenario_suggestions seed跳过: {e}") + def _seed_org_data(db_session): """插入5层级组织示例数据""" diff --git a/backend/app/main.py b/backend/app/main.py index aaea8906..c4d79abd 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from dotenv import load_dotenv from app.database import init_db -from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification +from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification, value_sources, zero_based, derivation_rules, cash_classify from app.utils.cache import clear_all as clear_cache, delete as delete_cache from scripts.erp_sync import run_sync as run_erp_sync from app.auth_middleware import require_auth @@ -54,6 +54,10 @@ app.include_router(org.router) app.include_router(objectives.router) app.include_router(versions.router) app.include_router(budget.router) +app.include_router(value_sources.router) +app.include_router(zero_based.router) +app.include_router(derivation_rules.router) +app.include_router(cash_classify.router) app.include_router(cost.router) app.include_router(predict.router) app.include_router(growth_quality.router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index edf19ad4..de94e663 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -524,6 +524,9 @@ class BudgetDeviationAlert(Base): alert_level = Column(String(20), default="warning", comment="warning/critical") status = Column(String(20), default="open", comment="open/resolved/ignored") suggestion = Column(String(500), nullable=True, comment="处理建议") + alert_type = Column(String(30), nullable=True, comment="归因场景: cost_high/revenue_drop/cash_low/cash_critical (2026-08-28 告警归因P1-③)") + attribution = Column(JSON, nullable=True, comment="归因JSON: 子KPI拆解+科目拆解+量价差+趋势 (2026-08-28)") + scenario_id = Column(Integer, nullable=True, comment="FK scenario_suggestions.id 场景建议 (2026-08-28)") created_at = Column(DateTime, server_default=func.now()) @@ -805,3 +808,106 @@ class KR(Base): monthly_milestones = Column(JSON, nullable=True, comment="月度里程碑: [{\"month\":\"2026-07\",\"label\":\"...\",\"status\":\"completed\"}]") sort_order = Column(Integer, default=0, comment="排序") created_at = Column(DateTime, server_default=func.now()) + + +# ============================================================ +# 预算系统技术改进 (2026-08-28 yanxue-budget-tech-improve) +# ① kpi_value_sources/kpi_value_collect_logs: 实际值自动归集 P1-④ +# ② budget_zero_based_items: 真零基逐项论证 P2-① +# ③ budget_derivation_rules: 派生规则可配置 P2-② +# ④ cash_plan_classify_rules/cash_plan_unclassified: 现金流分类规则 P2-⑥ +# ============================================================ + +class KPIValueSource(Base): + """KPI实际值取数映射 — 自动归集源头 (P1-④ 2026-08-28)""" + __tablename__ = "kpi_value_sources" + id = Column(Integer, primary_key=True, index=True) + entity_id = Column(Integer, nullable=False, comment="企业ID(多租户隔离)") + kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="目标KPI") + source_table = Column(String(50), nullable=False, comment="源头表: voucher_details/product_inventory/product_inventory_detail/cash_plans") + source_field = Column(String(50), nullable=False, comment="金额字段: credit_amount/debit_amount/amount/qty") + aggregate = Column(String(10), default="sum", comment="sum/avg/count/max/min") + filter_rule = Column(JSON, nullable=True, comment="过滤: {\"subject_code\":\"6601\",\"direction\":\"credit\"}") + period_field = Column(String(50), default="period", comment="期间字段: period/voucher_date") + unit_conversion = Column(Float, default=1, comment="单位倍率(元→万元/10000)") + status = Column(String(20), default="active", comment="active/inactive") + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + __table_args__ = (UniqueConstraint("entity_id", "kpi_id", "source_table", name="uk_source"),) + + +class KPIValueCollectLog(Base): + """实际值采集日志 — 每次自动归集记录 (P1-④ 2026-08-28)""" + __tablename__ = "kpi_value_collect_logs" + id = Column(Integer, primary_key=True, index=True) + entity_id = Column(Integer, nullable=False, comment="企业ID") + kpi_id = Column(Integer, nullable=False, comment="KPI ID") + period = Column(String(20), nullable=False, comment="期间 YYYY-MM") + source_table = Column(String(50), nullable=False, comment="源头表") + collected_value = Column(Float, nullable=True, comment="采集到的值") + status = Column(String(20), default="success", comment="success/failed") + message = Column(String(500), nullable=True, comment="说明/错误信息") + collected_at = Column(DateTime, server_default=func.now()) + + +class BudgetZeroBasedItem(Base): + """零基预算逐项论证项 (P2-① 2026-08-28)""" + __tablename__ = "budget_zero_based_items" + id = Column(Integer, primary_key=True, index=True) + entity_id = Column(Integer, nullable=False, comment="企业ID(多租户隔离)") + kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="关联KPI") + period = Column(String(20), nullable=False, comment="期间 YYYY-MM") + item_name = Column(String(200), nullable=False, comment="费用科目名") + item_category = Column(String(20), default="discretionary", comment="fixed/variable/discretionary") + base_value = Column(Float, nullable=False, comment="基准值(上年/上月实际)") + justification = Column(Text, nullable=True, comment="逐项论证理由(为何保留/削减/取消)") + proposed_value = Column(Float, nullable=False, comment="论证后金额") + status = Column(String(20), default="draft", comment="draft/approved") + created_by = Column(String(100), nullable=True, comment="创建人") + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + +class BudgetDerivationRule(Base): + """KPI派生规则 — apply-method 可配置派生 (P2-② 2026-08-28)""" + __tablename__ = "budget_derivation_rules" + id = Column(Integer, primary_key=True, index=True) + entity_id = Column(Integer, nullable=False, comment="企业ID(多租户隔离)") + kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="目标KPI") + rule_type = Column(String(30), nullable=False, comment="incremental/percentage_of/formula") + base_kpi_id = Column(Integer, nullable=True, comment="来源KPI(percentage_of用)") + params = Column(JSON, nullable=True, comment="{\"rate\":0.02,\"field\":\"net_profit\"}") + formula_text = Column(String(500), nullable=True, comment="可读公式说明") + status = Column(String(20), default="active", comment="active/inactive") + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + __table_args__ = (UniqueConstraint("entity_id", "kpi_id", "rule_type", name="uk_rule"),) + + +class CashPlanClassifyRule(Base): + """现金流收付分类规则 — KPI→receive/pay 可维护 (P2-⑥ 2026-08-28)""" + __tablename__ = "cash_plan_classify_rules" + id = Column(Integer, primary_key=True, index=True) + entity_id = Column(Integer, nullable=False, comment="企业ID(多租户隔离)") + kpi_id = Column(Integer, nullable=True, comment="精确匹配KPI,优先") + kpi_code_pattern = Column(String(200), nullable=True, comment="关键词/编码模式匹配,兜底") + plan_type = Column(String(10), nullable=False, comment="receive/pay") + priority = Column(Integer, default=10, comment="匹配顺序,小优先") + status = Column(String(20), default="active", comment="active/inactive") + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + +class CashPlanUnclassified(Base): + """现金流待分类KPI队列 — 无法判别的KPI不静默跳过 (P2-⑥ 2026-08-28)""" + __tablename__ = "cash_plan_unclassified" + id = Column(Integer, primary_key=True, index=True) + entity_id = Column(Integer, nullable=False, comment="企业ID(多租户隔离)") + kpi_id = Column(Integer, nullable=False, comment="KPI ID") + kpi_name = Column(String(200), nullable=True, comment="KPI名称") + period = Column(String(20), nullable=True, comment="期间") + budget_value = Column(Float, nullable=True, comment="预算值") + reason = Column(String(200), nullable=True, comment="无法分类原因") + status = Column(String(20), default="pending", comment="pending/classified/ignored") + created_at = Column(DateTime, server_default=func.now()) + resolved_at = Column(DateTime, nullable=True) diff --git a/backend/app/utils/__pycache__/deviation_engine.cpython-312.pyc b/backend/app/utils/__pycache__/deviation_engine.cpython-312.pyc index 95b86a8c2abd696fa3170d71af321e238cfd6990..a95d5aeb4ddc4b9dd41fcdd673e0f443f4706ec1 100644 GIT binary patch delta 6870 zcmbU_X?PP?lHINQvSds0eKfv>V+=UqFbS6p;R+;>WP%c+$m+HuTRze)0cNF;ff(4A>)iO%Pes?lFX2Azxj6m$T71x9kRn_ELr}uF(Kb(HrXFr)osa`$$a~L z+n-eLsH*p>>eYKy@BQ%7H=&e&HJS7Te9gb;X>u>@Ny&U^E=3T>2!?PH49QSF(n(4s z<)olg_!KP)r-DSB(x+@uIaN|!G#<)l2v@s^u&ZNNKz@|5-8S@^c zGXtnBK$Xg7GHLKPuqxJ`1;RII87uH)C9%^OHDd$L?E5e{nthKl?95?`rS5bXDnlAB z9R|vTfpVFWBtw=Y7GkmilgE@w)1$CCQ^IMPT)^ama4498O#>D3pr?SPnf&H*g5aK% zG+&8!PJY5RQcKpMF}4(ZNcV%$@k1j4v8G>c5{c4Tq{IFKwy2+!QO;s$moT%~;u8c@ zcuJAzCkeKw=?P(8My|et>Y$2}O(BKSk!6=MVM$4quvbx)CmOeO@P4+Xwym|n+qlc* zX>reC*zI1o-`mzI43)lGD`qsedtGiH%lTa|dmCS7IafXF_p_X+V_W@Re}~JNa@c=Pz99<1NBe`%u1jL zDYpo7E2HHUs5&p~sdojUPkc2-w(H54QY+;7s z5Ho8xZCt(fao74);jOMUYad^;YO`zQs;8e`wOLd(csbrL8mFkYyL|yxR5tN#t)j}; z=4N71h4B#zvS@v%}4`dRrSsd)=zlE1uc# z^vt;&Dv>H=>N)ftQQ>K56jh!!hUG=l!>gc=#_z{GAzhUp&S+#G@N_UE&~meZ5FLd7 z7XY2a9lf!;e1G|oM@RJZEq&RrzU;Qvc1v3@tSz`%v}9Pj9HITP$na9@ktn1}(6;uruRE^l?hPzhXHfQygylcB@E*&ke z{I`rbee+H#dRLrMe~~dKVwp3kAS`9yYYC&}rln-WP#RH|9(Xue{qOHIL}t}rZ<{l} zBS@UUnAW{<|Heqpx|G4=`XR;VZ@es4IY*dr0_NnVrKqpf09(+&+&hd5Ulg zpmW}(wJIM~#HAy#4gqdUZV`gT07Nw`vp>LdHNw}Lf)yO@9MLGx;*vbWq2@|vuy8fl z!~sQRyU*Rq!F&G{37LLBmuci!he|@)zyeVV~grQ zI|FJs3=fFP$!moq3KVIRusLM`=@9m(FzM_UKCL3l1L zpWG!w@Id2=|V!RCv+$%t_>(XDWZ<3&VoBd(s3RIqx)OZPM z8y%GFr7Fo4xebWLSjTMy5WpZ7`}JV#;K10>!T9A%K;>eo zFqnNhZzpzlAlL8IFnPU#x24Hgqu0E+K*L_@MF$% z6@S;3Nt=r*&fN;<;~F89TT%VM{^4*W$xKaHL(_822z&^-5Cnyk;zmN7LWA;qM$l8iZIf*uP_0X?bElNL&0 zlp!VyE{+uU?_Q={y3t$ARd+E79(EqI)lnUFk=XbH^q+uf{*= zq066~U-?bXyY&3I^A}XkT~s-LkwdRX51Tl_k5rh+cbj?CRd}EAL0U`eVI)(ZN06UOO($ zLerJBJT@(hSVK?c5mg?}>-T!XZXdlIq^o*lZjvE6UxKBB_4(R%RB%WSg=qrD69%y*hQA4}^e$HDTz_f*qz4UQP?uoy%_b;8V%BG2bdIWN?nkji;OiX}SriQL>YxB{u-V4#g@5SGLFWTKRMdD+( zvpzseAzKz&HpT6r2S9Kh z%~3#VrqTB>%;gjEml}cGE-XIe$UG;ergD=3bxNi zQI|CR>gjMDz%w~IaC+jcz0uBVW7iLjT{$HCG9BOnRe~-lt#-8Qb;zp6hK@(yI5V-Y zJ9hFn@lQSh(%8wj$B*xc9(o&`=iBhLuIO(MgY{*HiNC*F)(*0D9H{GZbl`aO>}lYS zANnXda5mO;G=BZV=z*iL?!(c8`{Dy0 zBniTOaF-Z=zi0f!K&O_URo|n@Kybf@)5ELRn6&*G?b&-PVV(i5W2X_=fVn{*~m3svHrqeOgjH@+w z2C$g^R$`Xzk~Qo@jj#*0j>dzNlDE**xuyn+-xO45O#c#-MKnEvpK0I?3u?l^lZ3UykuzQXS$K% zbrIXT&L{puW4mKXk5~%(wnxeq4>f$Y{YqP8^Rp3W{TG|-M{Nat_K3ACVl2CpQr!1+ z-|9(%tXKfRwD7Ki&{-#qL~6#*GlzeMJ2E>MF^1sY1e|)Ju=G52Hmj#*P}jL(w4kVu zJHD%jJ-@Sa6UdsD-nniRzDi;Hd80YANA3Be=`{W_3gLG*RcBOB5;~Ro zuitA3qaChjFu`qYN_Wcslt{(`n8o<9@iX@=?V4fjnx7^Wi2jk!g!Q_iE2~{X+*qQk zt5STfoK-hl@%d~O)Mq@o7zjcmzq#mzBL#OJc~yDI7NYaQc|>&JBIW@YKE$l z#j{lUpuVXB#TROdQ&jtst(S@|Ci1|^pq`;o+K}P@VMQY|D>1v@cAup(z*3D=0+m?x zCdqD@A!E?UQHQAmbN|+M`FbW_vRz@&fVKl074<7+oqpa%aMvUKH9&tJh+Dk@Cj)KMg{Pg0c63BMSnZoFzig+odYmT3OP< zJqpsu7ceO#Y=x$Y~?>g5)^tk*;VMrr;uo zp^iI^V87rjpIz-x%2(%$*!7PHa1bd(({$|UAVl-{NBOsAuY3Jj?SK3>O1RSm{VJNMX&1)LRAKvBrvbviq)s7XFoCUFuOo>fwqJL;h%He(ygFXI6Bry<<%mGAoAX zmqyAK4bzK9i>n9M4laolE*mW<>GKU2RDV&hY;b3yF`85Sy-Aamc33rO5mGDZ)z3yA zePZ~Ljgf~p{gI1oe&*KZ7l${$7;lbTm8Hd|q-TIe=z*_z zxuZ8zIu{?VyO5Y7L%nyQPn3)*N~(d5m*jQm49UAcEshmCn4@WOD!i!`}x{Q e0?VQ5dr~>MTKJ*LZ@iIbt5Xo4E7I!hivIq}1jlY}V#IJt7&Sd5& zrJ`Ck&_?xXEvVulq;6=GDm2m>B&1H8G<6S6$)GE`S~oO}QBb9&+wB$(){n0GM%-<$IsAJ*$8FxMCrAZXoqa=uM6U z&lxIoo>PJ&y==F!&c<^^p(r#j64)-m%7>>9K~;E>{n8L!am=1b%d3epmXy_GHls;$ zLM_NCO&TnaVMWy>s;C8;VMX&764seo*|#ip&waizO0!ut3d-$iIU^4%X(gjZ2j#@5 zk|9w!mx~tEWGWwBv>aD5!^w=YI#<*j2|1OBFLJ04?E0stEGc27W_+oj&>}ShNt1?> zDOI5qD@p5gN?w)KLY~&MUVGz{nsF#e^QvYm`x+mUQw1d=X#yEs(xLUJ-GJI+E+uF3 z5};vyQ7^k-_Zy)^T!h`Qzs38f2OTOufx@&3K_i02*V86=)a>YeAtkE{iOVW&Mv>*n zu$5D{;D$)Q@tCq^0Qd4D(D2>ViyF0%;rN2qP?p9=b4l9H891(+y--~McYa&lD?D8T zq7kF+hGqhhcp^&_Js-3UU(temCny4rvFO243{&@C6Ou%Ckgj9C}KdD*0E_6vU|_Ts00^!}yc>ZYhhBok5NH=NFv%2c zQ4Aqc`-s<1AoWQAnpjk1N;e_O7d1XjpGMYG_$=k9G8RuS>_v?|@VlyekgXei2C<#Y z9;grM#i`c2G^fyHmc&VNXo&6u33<9NaFU1MXY1C95e%N{Ve|vGGa{7R)TnnNU4vZw ztZ64}Z}jm8Sbt+iiXj1V)fk49YK`6e466yB550)w!wBL4^3Vj(C@<@MLKEya;Wq1a zWD*1l6Pns3{32+Kbv1>h1Bec=gH12AeQD+vGpre@oEcVVJNsi(Tj%3lO828BJt@y3 zhKZ!R5bQ>fMj*3I%UbIP0nJwfz;=FQZH2Gu__BcU7_iPyUs%>F_!0}F4JaIEg&zj@ z@D}^$>N_>}IBr~2F@^dnajd$->nQP8PzKhxv1DYYgYMNttjxF5{BfpY1M90~BqDyH z74EoH63gesgmH`;uPRm12)na1g5+*L9WPs#iycTOO#x4jjGachYiiBtra z_L8+?n&(Oe;yY%C@*C9Fio{4eI$`yfOpuZQRJo2FS?g<2JBg~tYrUsGr6LLRqQ+MBc=kASH?tSmu?8B}$e*w)NEYm>lldC^`F&q(TfUW8dOJU%_Bt6vK*?bhx z2w$FF2ar691cB-=e-#NC_HK7;sDPtA9RBO;Q+F?&|Kukp?|%3Fvf1##NPs!ld&-CZ z(#aD>T@}Gr1b7f>2D}&<j4ujKP^6QgzP;(AY`K8m-XT@T9Ik0ytYDl|T* zsCY9PWj|d%;K3X{GJz3=AzZ&9V7UmK58;iu*@MFu_ftjk){kK~p zm!G?|J$AN#YVT|PcicfR=nnngijV90*e^EBSzB%|@0@ySs%LgtFPq=k-aNH^X6;Pk z{nYi;%;67uX7?YNm0z0MPiOb#XY=Z8uyCt-Y&yEpz&Gp`^fb)x5jdmsBhfP_dTxn6 z$iS@VrhfKp&o+LPv7W7sFx;`H^wtV%Rq;5xA9`^dh^qsc8aFVvWye#vDy%RLz)3 zW%Ek;O5jI%Y~9aIo6=cQNGV(ByYPtr1o8!d^9G*hKNC#6_^_7aogZ_dkGcB0T zzMEX%9dq5J?;~^FoVo5y?@e>t%hJ5V!Aobl<~i6dx9A)D=H{#3y*%%^yltMteWvrF ZzR%~_;1<>PvmW=hChq4=wc9!k{|3q8NL2s; diff --git a/backend/app/utils/alert_attribution.py b/backend/app/utils/alert_attribution.py new file mode 100644 index 00000000..810017bd --- /dev/null +++ b/backend/app/utils/alert_attribution.py @@ -0,0 +1,203 @@ +"""告警归因引擎 — 管理会计OS (P1-③ 2026-08-28) + +告警从"差多少"到"差在哪+怎么办": + - 子KPI维度拆解: 查 kpi_hierarchy 下级KPI各自差异(量差方向) + - 科目明细拆解: 查 kpi_subject_map → voucher_details 汇总科目发生额(价差方向) + - 趋势归因: 复用 deviation_engine.check_trend_anomaly 连续3期检测 + - 场景建议: 按 alert_type 联查 scenario_suggestions + +attribution JSON 结构: +{ + "dimensions": [{"kpi_id":1,"kpi_name":"销售费用","deviation_value":-3.2,"deviation_rate":-18.6,"weight":0.5}], + "subjects": [{"subject_code":"6601","subject_name":"销售费用","amount_diff":2.1,"share_pct":34.5}], + "variance_type": "quantity_diff|price_diff|mixed", + "trend": {"anomaly":true,"type":"continuous_decline","periods":["2026-06","2026-07","2026-08"],"message":"连续3期下滑"} +} +""" +import logging +from typing import Optional + +from sqlalchemy.orm import Session +from sqlalchemy import func + +from app.models import KPIHierarchy, KPISubjectMap, VoucherDetail, ScenarioSuggestion, KPIDefinition + +logger = logging.getLogger("cma.alert_attribution") + +# 收入型KPI特征(量差方向: 子KPI量级偏离) +REVENUE_TYPE_CODES = ( + "SALES_TOTAL", "REVENUE", "F_REVENUE", "SALES_PROFIT_RATE", + "CUSTOMER_COUNT", "NEW_CUSTOMER", "TURNOVER_RATE", +) + + +def build_dimension_attribution(db: Session, kpi_id: int, period: str) -> list: + """子KPI维度拆解 — 查 kpi_hierarchy 下级KPI各自差异(实际vs预算)""" + from app.models import KPIValue, BudgetPlan + + children = db.query(KPIHierarchy).filter( + KPIHierarchy.parent_kpi_id == kpi_id + ).all() + if not children: + return [] + + result = [] + for rel in children: + child_id = rel.child_kpi_id + actual = db.query(KPIValue).filter( + KPIValue.kpi_id == child_id, + KPIValue.period == period, + KPIValue.actual_value.isnot(None), + ).order_by(KPIValue.calculated_at.desc()).first() + budget = db.query(BudgetPlan).filter( + BudgetPlan.kpi_id == child_id, + BudgetPlan.period == period, + BudgetPlan.status == "active", + ).order_by(BudgetPlan.updated_at.desc()).first() + + av = actual.actual_value if actual else None + bv = budget.budget_value if budget else None + dev_value = None + dev_rate = None + if av is not None and bv is not None and bv != 0: + dev_value = round(av - bv, 2) + dev_rate = round(dev_value / bv * 100, 2) + + child_kpi = db.query(KPIDefinition).filter(KPIDefinition.id == child_id).first() + result.append({ + "kpi_id": child_id, + "kpi_name": child_kpi.kpi_name if child_kpi else f"KPI-{child_id}", + "actual_value": av, + "budget_value": bv, + "deviation_value": dev_value, + "deviation_rate": dev_rate, + "weight": float(rel.weight or 0), + }) + # 按偏差绝对值降序,最异常的排前面 + result.sort(key=lambda x: -(abs(x["deviation_value"]) if x["deviation_value"] is not None else 0)) + return result + + +def build_subject_attribution(db: Session, kpi_id: int, period: str) -> list: + """科目明细拆解 — 查 kpi_subject_map → voucher_details 汇总科目发生额""" + mappings = db.query(KPISubjectMap).filter(KPISubjectMap.kpi_id == kpi_id).all() + if not mappings: + return [] + + result = [] + for m in mappings: + q = db.query( + func.coalesce(func.sum(VoucherDetail.debit_amount), 0), + func.coalesce(func.sum(VoucherDetail.credit_amount), 0), + ).filter( + VoucherDetail.subject_code == m.subject_code, + VoucherDetail.period == period, + ) + row = q.first() + debit_sum = float(row[0] or 0) + credit_sum = float(row[1] or 0) + # 方向: credit贷方(收入/流入) / debit借方(费用/流出) + if m.calc_type == "ratio": + amount = credit_sum - debit_sum + elif m.calc_type in ("avg", "other"): + amount = (credit_sum - debit_sum) / 2 + else: # sum + amount = credit_sum - debit_sum + amount = round(amount * float(m.weight or 1.0), 2) + + result.append({ + "subject_code": m.subject_code, + "subject_name": m.remark or m.subject_code, + "amount_diff": amount, + "calc_type": m.calc_type, + "weight": float(m.weight or 1.0), + }) + + total = sum(abs(r["amount_diff"]) for r in result) or 0 + for r in result: + r["share_pct"] = round(abs(r["amount_diff"]) / total * 100, 1) if total else 0 + result.sort(key=lambda x: -abs(x["amount_diff"])) + return result + + +def detect_variance_type(kpi_code: str, dimensions: list, subjects: list) -> str: + """量价差判定简化版: + 成本型KPI科目发生额偏离 → price_diff(价差) + 收入型KPI子KPI量级偏离 → quantity_diff(量差) + 两者都有 → mixed + """ + has_dimension_dev = any(d.get("deviation_value") is not None and abs(d["deviation_value"]) > 0.01 for d in dimensions) + has_subject_dev = any(abs(s.get("amount_diff", 0)) > 0.01 for s in subjects) + + is_revenue = any(code in (kpi_code or "").upper() for code in REVENUE_TYPE_CODES) + + if is_revenue: + # 收入型: 子KPI(量)偏离为主 → quantity_diff + if has_dimension_dev: + return "quantity_diff" + if has_subject_dev: + return "price_diff" + return "mixed" + else: + # 成本型: 科目发生额(价)偏离为主 → price_diff + if has_subject_dev: + return "price_diff" + if has_dimension_dev: + return "quantity_diff" + return "mixed" + + +def match_scenario(db: Session, alert_type: Optional[str]) -> Optional[dict]: + """按 alert_type 取 scenario_suggestions 建议(四类模板)""" + if not alert_type: + return None + s = db.query(ScenarioSuggestion).filter( + ScenarioSuggestion.alert_type == alert_type + ).order_by(ScenarioSuggestion.sort_order.asc(), ScenarioSuggestion.id.asc()).first() + if not s: + return None + return { + "scenario_id": s.id, + "alert_type": s.alert_type, + "title": s.title, + "description": s.description, + "action_template": s.action_template, + "priority": s.priority, + } + + +def build_attribution(db: Session, kpi_id: int, period: str, alert_type: Optional[str] = None) -> dict: + """组装完整归因JSON(供告警生成/详情接口共用)""" + from app.utils.deviation_engine import check_trend_anomaly + + kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() + kpi_code = kpi.kpi_code if kpi else "" + + dimensions = build_dimension_attribution(db, kpi_id, period) + subjects = build_subject_attribution(db, kpi_id, period) + variance_type = detect_variance_type(kpi_code, dimensions, subjects) + trend = check_trend_anomaly(db, kpi_id, period, consecutive=3) + + # 默认场景归类(未显式传入时按KPI名称特征推断) + if not alert_type: + alert_type = infer_alert_type(kpi_code, kpi.kpi_name if kpi else "") + + attribution = { + "dimensions": dimensions, + "subjects": subjects, + "variance_type": variance_type, + "trend": trend, + } + return attribution, alert_type + + +def infer_alert_type(kpi_code: str = "", kpi_name: str = "") -> str: + """按KPI特征推断告警场景类型(四类: cash_low/cash_critical/cost_high/revenue_drop)""" + text = (kpi_code or "").upper() + (kpi_name or "") + if any(k in text for k in ("CASH", "现金", "货币资金", "资金")): + return "cash_low" + if any(k in text for k in ("COST", "费用", "成本", "支出")): + return "cost_high" + if any(k in text for k in ("REVENUE", "收入", "销售", "营收")): + return "revenue_drop" + return "cost_high" diff --git a/backend/app/utils/deviation_engine.py b/backend/app/utils/deviation_engine.py index c3230054..3d2af02e 100644 --- a/backend/app/utils/deviation_engine.py +++ b/backend/app/utils/deviation_engine.py @@ -8,15 +8,40 @@ 4. 差异预警触发(集成到现有预警系统) """ import logging +import json from datetime import datetime from typing import Optional from app.database import get_session_local -from app.models import KPIDefinition, KPIValue, KPIAlert, BudgetPlan +from app.models import KPIDefinition, KPIValue, KPIAlert, BudgetPlan, SystemConfig logger = logging.getLogger("cma.deviation") +# 越高越好型KPI默认列表(P2-⑤ 2026-08-28: 提为 system_configs 可配置) +DEFAULT_HIGHER_BETTER = [ + "SALES_TOTAL", "CUSTOMER_COUNT", "SALES_PROFIT_RATE", + "RECEIVABLE_TURNOVER", "TURNOVER_RATE", + "CUSTOMER_SATISFACTION", "ORDER_DELIVERY_RATE", +] +CONFIG_KEY_HIGHER_BETTER = "kpi_alert_higher_better" + + +def get_higher_better_codes(db, entity_id: int = None) -> list: + """读取越高越好型KPI编码列表(system_configs 可维护,无配置回落默认)""" + cfg = db.query(SystemConfig).filter( + SystemConfig.config_key == CONFIG_KEY_HIGHER_BETTER + ).first() + if cfg and cfg.config_value: + try: + codes = json.loads(cfg.config_value) + if isinstance(codes, list): + return [str(c) for c in codes] + except Exception: + logger.warning("system_configs[%s] 解析失败, 回落默认", CONFIG_KEY_HIGHER_BETTER) + return list(DEFAULT_HIGHER_BETTER) + + # ============================================================ # 差异计算 # ============================================================ @@ -210,8 +235,83 @@ def check_trend_anomaly(db, kpi_id: int, period: str, consecutive: int = 3) -> d # 差异预警触发 # ============================================================ +def build_deviation_alert(db, kpi, period: str, entity_id: int = 1, min_rate: float = 10.0) -> dict: + """统一告警构建 — 双出口共享一套逻辑 (P2-⑤ 2026-08-28) + + 预算告警(budget_deviation_alerts) 与 KPIAlert 都调用本函数,差异仅级别映射: + - budget 出口: warning/critical @ 20/50 + - KPIAlert 出口: yellow/red @ 10/30 + 归因(P1-③): attribution 拆解 + 场景建议 由 alert_attribution 组装。 + + 返回: + triggered: bool 是否触发 + level: budget出口级别 warning/critical + kpi_alert_level: KPIAlert出口级别 yellow/red + deviation: calc_period_deviation 结果 + suggestion: 模板建议文案 + alert_type: 归因场景类型 + attribution: 归因JSON dict + scenario_id: 场景建议ID + """ + from app.utils.alert_attribution import build_attribution, match_scenario + + deviation = calc_period_deviation(db, kpi.id, period) + if deviation.get("deviation_rate") is None: + return {"triggered": False} + + rate = abs(deviation["deviation_rate"]) + actual = deviation.get("actual_value") + budget = deviation.get("budget_value") + + # 方向性:越高越好型(配置化,system_configs.kpi_alert_higher_better) + higher_better = kpi.kpi_code in get_higher_better_codes(db, entity_id) + + if higher_better: + # 实际低于预算才是问题 + if not (actual is not None and budget is not None and actual < budget and rate >= min_rate): + return {"triggered": False} + suggestion = ( + f"实际值低于预算 {rate}%,建议分析业务量未达预期的原因(子KPI拆解见归因)," + f"制定增量获客或转化提升计划" + ) + else: + # 实际高于预算才是问题(成本型) + if not (actual is not None and budget is not None and actual > budget and rate >= min_rate): + return {"triggered": False} + suggestion = ( + f"实际值超出预算 {rate}%,建议核查超支原因(科目明细拆解见归因)并采取控制措施" + ) + + # 级别映射(双出口) + budget_level = "critical" if rate > 50 else "warning" + kpi_alert_level = "red" if rate >= 30 else "yellow" + + # 归因组装 (P1-③) + alert_type = None + attribution = None + scenario_id = None + try: + attribution, alert_type = build_attribution(db, kpi.id, period) + scenario = match_scenario(db, alert_type) + if scenario: + scenario_id = scenario["scenario_id"] + except Exception as e: # 归因失败不阻断告警主流程 + logger.warning("归因组装失败 kpi=%s: %s", kpi.kpi_code, e) + + return { + "triggered": True, + "level": budget_level, + "kpi_alert_level": kpi_alert_level, + "deviation": deviation, + "suggestion": suggestion, + "alert_type": alert_type, + "attribution": attribution, + "scenario_id": scenario_id, + } + + def run_deviation_check(db_session, period: str = None) -> int: - """运行差异预警检查,返回新增预警数""" + """运行差异预警检查,返回新增预警数(统一走 build_deviation_alert,P2-⑤)""" if period is None: period = datetime.now().strftime("%Y-%m") @@ -221,33 +321,10 @@ def run_deviation_check(db_session, period: str = None) -> int: new_count = 0 for kpi in kpis: - # 1. 差异预警:实际 vs 预算 - deviation = calc_period_deviation(db_session, kpi.id, period) - if deviation.get("deviation_rate") is not None: - rate = abs(deviation["deviation_rate"]) - - # 差异化阈值:越高越好型 vs 越低越好型 - higher_better = kpi.kpi_code in [ - "SALES_TOTAL", "CUSTOMER_COUNT", "SALES_PROFIT_RATE", - "RECEIVABLE_TURNOVER", "TURNOVER_RATE", - "CUSTOMER_SATISFACTION", "ORDER_DELIVERY_RATE", - ] - - if higher_better: - # 实际低于预算才是问题 - if deviation["actual_value"] < deviation["budget_value"] and rate >= 10: - level = "yellow" if rate >= 10 else "green" - level = "red" if rate >= 30 else level - else: - continue - else: - # 实际高于预算才是问题(成本型) - if deviation["actual_value"] > deviation["budget_value"] and rate >= 10: - level = "yellow" if rate >= 10 else "green" - level = "red" if rate >= 30 else level - else: - continue - + # 1. 差异预警:实际 vs 预算(统一逻辑) + result = build_deviation_alert(db_session, kpi, period) + if result["triggered"]: + deviation = result["deviation"] alert_msg = ( f"{kpi.kpi_name}[{period}] 差异预警: 实际{deviation['actual_value']} " f"vs 预算{deviation['budget_value']}," @@ -265,13 +342,15 @@ def run_deviation_check(db_session, period: str = None) -> int: if not existing: alert = KPIAlert( kpi_id=kpi.id, - alert_level=level, + alert_level=result["kpi_alert_level"], alert_message=f"[差异预警] {alert_msg}", + alert_type=result["alert_type"] or "actual", + suggestion=result["suggestion"], status="pending", ) db_session.add(alert) new_count += 1 - logger.info(f" 新增差异预警 [{level}] {kpi.kpi_name}: 差异率{deviation['deviation_rate']}%") + logger.info(f" 新增差异预警 [{result['kpi_alert_level']}] {kpi.kpi_name}: 差异率{deviation['deviation_rate']}%") # 2. 趋势异常检测(每期检查连续3期) trend = check_trend_anomaly(db_session, kpi.id, period, consecutive=3) diff --git a/backend/scripts/kpi_value_collector.py b/backend/scripts/kpi_value_collector.py new file mode 100644 index 00000000..20991b00 --- /dev/null +++ b/backend/scripts/kpi_value_collector.py @@ -0,0 +1,233 @@ +"""KPI实际值自动归集采集器 — 管理会计OS (P1-④ 2026-08-28) + +按 kpi_value_sources 取数映射,从源头表(科目余额/进销存/现金流水)自动汇总写入 kpi_values。 +- 源头: voucher_details(网银凭证明细) / product_inventory(库存汇总) / product_inventory_detail(库存明细) / cash_plans(收付款计划) +- 严格按 entity_id + period 过滤,避免跨账套/跨期串数 +- 幂等: 同 kpi_id+period 已有 auto_collect 记录则更新;人工 excel/manual 写入不覆盖 +- 调度: 系统 crontab 每日 06:30 (参考 auto_verify_cron.py 模式) + +用法: + /usr/bin/python3 scripts/kpi_value_collector.py # 全量采集当月 + /usr/bin/python3 scripts/kpi_value_collector.py 2026-08 # 指定期间 + /usr/bin/python3 scripts/kpi_value_collector.py 2026-08 5 # 指定期间+KPI +""" +import sys +import logging +from datetime import datetime +from typing import Optional, Tuple + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", +) +logger = logging.getLogger("cma.kpi_collector") + +# 保证从 backend 目录直接运行时能 import app +if __name__ == "__main__": + import os + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from sqlalchemy import func +from app.database import get_session_local +from app.models import ( + KPIValueSource, KPIValueCollectLog, KPIValue, KPIDefinition, + VoucherDetail, CashPlan, +) + + +# 源头表 → ORM模型映射(动态 import 避免循环依赖) +def _source_model(table: str): + if table == "voucher_details": + return VoucherDetail + if table == "cash_plans": + return CashPlan + # product_inventory / product_inventory_detail 无ORM模型 → SQLAlchemy Table 反射 + from sqlalchemy import Table, MetaData + from app.database import get_engine + md = MetaData() + return Table(table, md, autoload_with=get_engine()) + + +def _field_expression(model, field: str, aggregate: str = "sum"): + """聚合表达式: sum/avg/count/max/min""" + col = getattr(model, field) + if aggregate == "count": + return func.count(col) + if aggregate == "avg": + return func.avg(col) + if aggregate == "max": + return func.max(col) + if aggregate == "min": + return func.min(col) + return func.sum(col) + + +def collect_for_mapping(db, mapping: KPIValueSource, period: str, write_kpi: bool = True) -> Tuple[Optional[float], str]: + """执行单条取数映射,返回 (采集值, 说明)。write_kpi=False 时为试跑模式(不写库)。""" + table = mapping.source_table + field = mapping.source_field + aggregate = mapping.aggregate or "sum" + filter_rule = mapping.filter_rule or {} + period_field = mapping.period_field or "period" + unit = mapping.unit_conversion or 1 + + model = _source_model(table) + + # 构建查询 + col = getattr(model, field, None) + if col is None: + return None, f"字段 {field} 不存在于表 {table}" + + q = db.query(_field_expression(model, field, aggregate)) + + # entity_id 过滤(所有源头表都有) + q = q.filter(model.entity_id == mapping.entity_id) + + # 期间过滤 + if period_field == "voucher_date": + # voucher_date 是 DATE 类型 → 按 %Y-%m 前缀匹配 + q = q.filter(func.date_format(model.voucher_date, "%Y-%m") == period) + else: + pf = getattr(model, period_field, None) + if pf is None: + return None, f"期间字段 {period_field} 不存在于表 {table}" + q = q.filter(pf == period) + + # 过滤规则: subject_code / direction / plan_type / carry_forward + subject_code = filter_rule.get("subject_code") + if subject_code and hasattr(model, "subject_code"): + q = q.filter(model.subject_code == subject_code) + direction = filter_rule.get("direction") + if direction: + # direction 覆盖: credit→只算贷方, debit→只算借方 + if direction == "credit" and hasattr(model, "credit_amount"): + q = db.query(_field_expression(model, "credit_amount", aggregate)) + q = q.filter(model.entity_id == mapping.entity_id) + if period_field == "voucher_date": + q = q.filter(func.date_format(model.voucher_date, "%Y-%m") == period) + else: + q = q.filter(getattr(model, period_field) == period) + field = "credit_amount" + elif direction == "debit" and hasattr(model, "debit_amount"): + q = db.query(_field_expression(model, "debit_amount", aggregate)) + q = q.filter(model.entity_id == mapping.entity_id) + if period_field == "voucher_date": + q = q.filter(func.date_format(model.voucher_date, "%Y-%m") == period) + else: + q = q.filter(getattr(model, period_field) == period) + field = "debit_amount" + plan_type = filter_rule.get("plan_type") + if plan_type and hasattr(model, "plan_type"): + q = q.filter(model.plan_type == plan_type) + if filter_rule.get("exclude_carry_forward") and hasattr(model, "carry_forward"): + q = q.filter(model.carry_forward == 0) + + value = q.scalar() + value = float(value or 0) + value = round(value * unit, 2) + + message = f"表{table}.{field} {aggregate}(period={period}) × {unit}" + if subject_code: + message += f", 科目{subject_code}" + if direction: + message += f", 方向{direction}" + if plan_type: + message += f", 类型{plan_type}" + + if write_kpi: + # upsert kpi_values: 同 kpi_id+period 已有 auto_collect 记录则更新 + existing = db.query(KPIValue).filter( + KPIValue.kpi_id == mapping.kpi_id, + KPIValue.period == period, + KPIValue.source_type == "auto_collect", + ).first() + if existing: + existing.actual_value = value + existing.source_batch = _batch_no() + existing.remark = f"自动归集: {table}" + existing.data_status = "pending" + else: + db.add(KPIValue( + entity_id=mapping.entity_id, + kpi_id=mapping.kpi_id, + period=period, + actual_value=value, + source_type="auto_collect", + source_batch=_batch_no(), + data_status="pending", + remark=f"自动归集: {table}", + )) + + return value, message + + +def _batch_no() -> str: + return f"auto-{datetime.now().strftime('%Y%m%d%H%M%S')}" + + +def run_collector(db, entity_id: Optional[int] = None, period: Optional[str] = None, kpi_id: Optional[int] = None) -> dict: + """运行采集器:遍历 active 映射 → 汇总 → upsert kpi_values → 写采集日志""" + if period is None: + period = datetime.now().strftime("%Y-%m") + + query = db.query(KPIValueSource).filter(KPIValueSource.status == "active") + if entity_id is not None: + query = query.filter(KPIValueSource.entity_id == entity_id) + if kpi_id is not None: + query = query.filter(KPIValueSource.kpi_id == kpi_id) + mappings = query.all() + + if not mappings: + return {"success": True, "collected": 0, "failed": 0, "message": "无激活取数映射"} + + collected, failed = 0, 0 + errors = [] + for m in mappings: + try: + value, message = collect_for_mapping(db, m, period, write_kpi=True) + db.add(KPIValueCollectLog( + entity_id=m.entity_id, + kpi_id=m.kpi_id, + period=period, + source_table=m.source_table, + collected_value=value, + status="success", + message=message, + )) + collected += 1 + except Exception as e: + failed += 1 + errors.append({"kpi_id": m.kpi_id, "source_table": m.source_table, "error": str(e)}) + db.add(KPIValueCollectLog( + entity_id=m.entity_id, + kpi_id=m.kpi_id, + period=period, + source_table=m.source_table, + collected_value=None, + status="failed", + message=str(e)[:500], + )) + logger.error("采集失败 kpi=%s table=%s: %s", m.kpi_id, m.source_table, e) + + db.commit() + logger.info("采集完成: 成功%s 失败%s (period=%s)", collected, failed, period) + return { + "success": failed == 0, + "collected": collected, + "failed": failed, + "period": period, + "errors": errors[:20], + } + + +if __name__ == "__main__": + period_arg = sys.argv[1] if len(sys.argv) > 1 else None + kpi_arg = int(sys.argv[2]) if len(sys.argv) > 2 and sys.argv[2].isdigit() else None + db = get_session_local()() + try: + r = run_collector(db, period=period_arg, kpi_id=kpi_arg) + print(f"实际值自动归集完成: 成功{r['collected']} 失败{r['failed']} (period={r.get('period')})") + for e in r.get("errors", []): + print(f" 失败: kpi={e['kpi_id']} table={e['source_table']} -> {e['error']}") + finally: + db.close() diff --git a/backend/tests/test_budget_tech_improve.py b/backend/tests/test_budget_tech_improve.py new file mode 100644 index 00000000..8719e0ed --- /dev/null +++ b/backend/tests/test_budget_tech_improve.py @@ -0,0 +1,462 @@ +"""预算系统技术改进测试 (2026-08-28 yanxue-budget-tech-improve) + +覆盖: + P1-③ 告警归因(alert_type/attribution/scenario_id + 详情接口) + P1-④ 实际值自动归集(映射CRUD/采集器/覆盖率) + P2-① 真零基逐项论证(CRUD/generate/method-comparison is_demo) + P2-② 派生规则可配置(规则CRUD/apply-method rule_source) + P2-⑤ 双路径合并(两出口级别一致, 无第二套阈值逻辑) + P2-⑥ 现金流分类规则(待分类队列/一键归类) +""" +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 import ( + BudgetPlan, KPIValue, BudgetDeviationAlert, KPIAlert, + KPIValueSource, KPIValueCollectLog, + BudgetZeroBasedItem, BudgetDerivationRule, + CashPlanClassifyRule, CashPlanUnclassified, CashPlan, + ScenarioSuggestion, KPIDefinition, +) + + +class TestP1AlertAttribution: + """P1-③ 告警归因: 告警从'差多少'到'差在哪+怎么办'""" + + BASE = "/api/cma/budget" + + def _setup_alert(self, client, db, kpi_code="ATTRIB_KPI", kpi_name="销售费用", actual=150.0, budget=100.0): + create_test_user(db) + token = get_token_for_user(client) + kpi = create_test_kpi(db, kpi_code=kpi_code, kpi_name=kpi_name) + db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=budget, + budget_year=2026, budget_month=6, status="active")) + db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=actual)) + db.commit() + resp = client.post(f"{self.BASE}/deviation-check", headers=auth_header(token), + json={"period": "2026-06", "threshold": 20}) + assert resp.status_code == 200 + return token, kpi, resp + + def test_deviation_check_writes_attribution(self, client: TestClient, db: Session): + """生成告警时同步写 alert_type/attribution/scenario_id""" + token, kpi, resp = self._setup_alert(client, db, actual=150.0, budget=100.0) + assert resp.json()["alerts_generated"] == 1 + + alert = db.query(BudgetDeviationAlert).filter(BudgetDeviationAlert.kpi_id == kpi.id).first() + assert alert is not None + assert alert.alert_level == "warning" + # 归因JSON结构 + assert alert.attribution is not None + attr = alert.attribution + assert "dimensions" in attr and "subjects" in attr + assert "variance_type" in attr and "trend" in attr + assert attr["variance_type"] in ("quantity_diff", "price_diff", "mixed") + assert "anomaly" in attr["trend"] + # 场景建议关联(费用类KPI → cost_high 模板) + if alert.scenario_id: + s = db.query(ScenarioSuggestion).filter(ScenarioSuggestion.id == alert.scenario_id).first() + assert s is not None + assert s.alert_type in ("cash_low", "cash_critical", "cost_high", "revenue_drop") + + def test_attribution_detail_endpoint(self, client: TestClient, db: Session): + """GET /deviation-alerts/{id}/attribution 返回归因+场景建议""" + token, kpi, _ = self._setup_alert(client, db, actual=200.0, budget=100.0) + alert = db.query(BudgetDeviationAlert).filter(BudgetDeviationAlert.kpi_id == kpi.id).first() + + resp = client.get(f"{self.BASE}/deviation-alerts/{alert.id}/attribution", + headers=auth_header(token)) + assert resp.status_code == 200 + data = resp.json() + assert data["attribution"] != {} + assert "dimensions" in data["attribution"] + # scenario 建议联查(无匹配时可空, 有模板时必须带文本) + if data["scenario"]: + assert data["scenario"]["title"] + + def test_list_alerts_has_attribution_fields(self, client: TestClient, db: Session): + """列表响应新增 alert_type/attribution/scenario_id 字段(可空)""" + token, kpi, _ = self._setup_alert(client, db) + resp = client.get(f"{self.BASE}/deviation-alerts", headers=auth_header(token)) + row = resp.json()["data"][0] + assert "alert_type" in row + assert "attribution" in row + assert "scenario_id" in row + + def test_alert_direction_config(self, client: TestClient, db: Session): + """P2-⑤ 方向配置 GET/PUT system_configs""" + create_test_user(db) + token = get_token_for_user(client) + + resp = client.get(f"{self.BASE}/alert-direction", headers=auth_header(token)) + assert resp.status_code == 200 + assert "SALES_TOTAL" in resp.json()["codes"] + + resp2 = client.put(f"{self.BASE}/alert-direction", headers=auth_header(token), + json={"codes": ["SALES_TOTAL", "CUSTOM_COUNT"]}) + assert resp2.status_code == 200 + assert resp2.json()["codes"] == ["SALES_TOTAL", "CUSTOM_COUNT"] + + resp3 = client.get(f"{self.BASE}/alert-direction", headers=auth_header(token)) + assert resp3.json()["codes"] == ["SALES_TOTAL", "CUSTOM_COUNT"] + assert resp3.json()["is_configured"] is True + + +class TestP1ValueCollect: + """P1-④ 实际值自动归集""" + + BASE = "/api/cma/budget" + + def test_value_source_crud_and_collect(self, client: TestClient, db: Session): + """映射CRUD → 采集器 → kpi_values 出现 auto_collect""" + create_test_user(db) + token = get_token_for_user(client) + kpi = create_test_kpi(db, kpi_code="COLLECT_KPI") + + # 建映射 + resp = client.post(f"{self.BASE}/value-sources", headers=auth_header(token), json={ + "kpi_id": kpi.id, + "source_table": "voucher_details", + "source_field": "credit_amount", + "aggregate": "sum", + "filter_rule": {"direction": "credit"}, + "period_field": "period", + "unit_conversion": 1, + }) + assert resp.status_code == 200 + + # 采集器试跑(不写库) + test_resp = client.post(f"{self.BASE}/value-sources/test", headers=auth_header(token), json={ + "kpi_id": kpi.id, + "source_table": "voucher_details", + "source_field": "credit_amount", + "aggregate": "sum", + "filter_rule": {"direction": "credit"}, + "period_field": "period", + }) + assert test_resp.status_code == 200 + assert test_resp.json()["value"] is not None + + # 手动触发采集 + run_resp = client.post(f"{self.BASE}/value-collect/run", headers=auth_header(token), + json={"period": "2026-06"}) + assert run_resp.status_code == 200 + assert run_resp.json()["collected"] >= 1 + + # 验证 kpi_values 落库 + val = db.query(KPIValue).filter( + KPIValue.kpi_id == kpi.id, + KPIValue.period == "2026-06", + KPIValue.source_type == "auto_collect", + ).first() + assert val is not None + assert val.actual_value is not None + assert val.remark and "自动归集" in val.remark + + # 采集日志 + logs = db.query(KPIValueCollectLog).filter(KPIValueCollectLog.kpi_id == kpi.id).all() + assert len(logs) >= 1 + + # 覆盖率 + cov = client.get(f"{self.BASE}/value-sources/coverage", headers=auth_header(token)) + assert cov.status_code == 200 + assert cov.json()["mapped_count"] >= 1 + + def test_collector_idempotent(self, client: TestClient, db: Session): + """同kpi+period 重复采集 → 更新不新增""" + create_test_user(db) + token = get_token_for_user(client) + kpi = create_test_kpi(db, kpi_code="COLLECT_IDEMP") + client.post(f"{self.BASE}/value-sources", headers=auth_header(token), json={ + "kpi_id": kpi.id, "source_table": "voucher_details", + "source_field": "credit_amount", "aggregate": "sum", + }) + client.post(f"{self.BASE}/value-collect/run", headers=auth_header(token), json={"period": "2026-06"}) + client.post(f"{self.BASE}/value-collect/run", headers=auth_header(token), json={"period": "2026-06"}) + + rows = db.query(KPIValue).filter( + KPIValue.kpi_id == kpi.id, + KPIValue.period == "2026-06", + KPIValue.source_type == "auto_collect", + ).all() + assert len(rows) == 1 + + def test_collector_logs_filter(self, client: TestClient, db: Session): + """采集日志 status 过滤""" + create_test_user(db) + token = get_token_for_user(client) + kpi = create_test_kpi(db, kpi_code="COLLECT_LOG") + client.post(f"{self.BASE}/value-sources", headers=auth_header(token), json={ + "kpi_id": kpi.id, "source_table": "voucher_details", + "source_field": "credit_amount", "aggregate": "sum", + }) + client.post(f"{self.BASE}/value-collect/run", headers=auth_header(token), json={"period": "2026-06"}) + resp = client.get(f"{self.BASE}/value-collect/logs", headers=auth_header(token), + params={"status": "success"}) + assert resp.json()["total"] >= 1 + + +class TestP2ZeroBased: + """P2-① 真零基逐项论证""" + + BASE = "/api/cma/budget" + + def _setup_kpi_with_plans(self, client, db): + create_test_user(db) + token = get_token_for_user(client) + # 核心4KPI(apply-method 需要) + for code in ("F_REVENUE", "F_NET_PROFIT", "F_COST_RATIO", "F_GROSS_MARGIN"): + create_test_kpi(db, kpi_code=code, kpi_name=code) + kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == "F_REVENUE").first() + return token, kpi + + def test_zero_based_items_crud_and_generate(self, client: TestClient, db: Session): + """录入3个科目 → 逐项论证 → generate → budget_plans 出现且金额=Σ建议值""" + token, kpi = self._setup_kpi_with_plans(client, db) + + # 录入3个论证项 + items = [ + {"item_name": "房租", "item_category": "fixed", "base_value": 15, "proposed_value": 15, "justification": "合同锁定"}, + {"item_name": "招待费", "item_category": "discretionary", "base_value": 16, "proposed_value": 8, "justification": "压缩50%"}, + {"item_name": "杂项", "item_category": "discretionary", "base_value": 12, "proposed_value": 8, "justification": "压缩30%"}, + ] + for it in items: + r = client.post(f"{self.BASE}/zero-based/items", headers=auth_header(token), json={ + "kpi_id": kpi.id, "period": "2026-06", **it, + }) + assert r.status_code == 200 + + # 列表+合计 + lst = client.get(f"{self.BASE}/zero-based/items", headers=auth_header(token), + params={"kpi_id": kpi.id, "period": "2026-06"}) + assert lst.json()["total"] == 3 + assert lst.json()["total_proposed"] == 31.0 + + # generate → budget_plans + gen = client.post(f"{self.BASE}/zero-based/generate", headers=auth_header(token), + json={"kpi_id": kpi.id, "period": "2026-06"}) + assert gen.status_code == 200 + assert gen.json()["total"] == 31.0 + + plan = db.query(BudgetPlan).filter( + BudgetPlan.kpi_id == kpi.id, + BudgetPlan.period == "2026-06", + BudgetPlan.version.like("zbb-%"), + ).first() + assert plan is not None + assert plan.budget_value == 31.0 + assert plan.calc_logic == "zero_based_itemized" + + def test_method_comparison_zero_based_is_demo_false(self, client: TestClient, db: Session): + """method-comparison 传论证KPI → is_demo=false; 不传 → is_demo=true""" + token, kpi = self._setup_kpi_with_plans(client, db) + client.post(f"{self.BASE}/zero-based/items", headers=auth_header(token), json={ + "kpi_id": kpi.id, "period": "2026-06", + "item_name": "房租", "item_category": "fixed", + "base_value": 15, "proposed_value": 15, + }) + + # 有论证项 → 真零基 + r1 = client.post(f"{self.BASE}/method-comparison", headers=auth_header(token), json={ + "zero_based_kpi_id": kpi.id, "zero_based_period": "2026-06", + }) + zbb1 = [m for m in r1.json()["methods"] if m["id"] == "zero_based"][0] + assert zbb1["is_demo"] is False + assert zbb1["item_count"] == 1 + + # 无论证项 → demo fallback + r2 = client.post(f"{self.BASE}/method-comparison", headers=auth_header(token), json={}) + zbb2 = [m for m in r2.json()["methods"] if m["id"] == "zero_based"][0] + assert zbb2["is_demo"] is True + + def test_apply_method_zero_based_writes_plan(self, client: TestClient, db: Session): + """apply-method zero_based → 落库 zbb 版本""" + token, kpi = self._setup_kpi_with_plans(client, db) + client.post(f"{self.BASE}/zero-based/items", headers=auth_header(token), json={ + "kpi_id": kpi.id, "period": "2026-06", + "item_name": "房租", "item_category": "fixed", + "base_value": 15, "proposed_value": 15, + }) + r = client.post(f"{self.BASE}/apply-method", headers=auth_header(token), json={ + "method": "zero_based", "year": 2026, + "zero_based_kpi_id": kpi.id, "zero_based_period": "2026-06", + }) + assert r.status_code == 200 + + +class TestP2DerivationRules: + """P2-② 派生规则可配置""" + + BASE = "/api/cma/budget" + + def _setup(self, client, db): + create_test_user(db) + token = get_token_for_user(client) + for code in ("F_REVENUE", "F_NET_PROFIT", "F_COST_RATIO", "F_GROSS_MARGIN"): + create_test_kpi(db, kpi_code=code, kpi_name=code) + return token + + def test_rule_crud_and_apply(self, client: TestClient, db: Session): + """配置 F_NET_PROFIT 派生率 5% → apply-method → rule_source=configured 且结果变化""" + token = self._setup(client, db) + rev = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == "F_REVENUE").first() + np_kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == "F_NET_PROFIT").first() + + # 无规则时 apply → default 比例(2%) + r_default = client.post(f"{self.BASE}/apply-method", headers=auth_header(token), + json={"method": "incremental", "year": 2026}) + np_default = [a for a in r_default.json()["applied"] if a["kpi_code"] == "F_NET_PROFIT"][0] + assert np_default["rule_source"] == "default" + assert r_default.json()["rule_source"] == "default" + + # 建规则: percentage_of → 来源F_REVENUE × 5% + # 先给来源KPI实际值(真实链路: base_kpi实际值 × rate) + db.add(KPIValue(kpi_id=rev.id, period="2026-05", actual_value=2000.0)) + db.commit() + r_rule = client.post(f"{self.BASE}/derivation-rules", headers=auth_header(token), json={ + "kpi_id": np_kpi.id, + "rule_type": "percentage_of", + "base_kpi_id": rev.id, + "params": {"rate": 0.05}, + "formula_text": "净利润 = 营业收入 × 5%", + }) + assert r_rule.status_code == 200 + + # 配置后 apply → rule_source=configured, 金额=2000×5%=100 + r2 = client.post(f"{self.BASE}/apply-method", headers=auth_header(token), + json={"method": "incremental", "year": 2026}) + assert r2.json()["rule_source"] == "configured" + np_after = [a for a in r2.json()["applied"] if a["kpi_code"] == "F_NET_PROFIT"][0] + assert np_after["rule_source"] == "configured" + assert np_after["budget_value"] == 100.0 + + # 规则列表 + lst = client.get(f"{self.BASE}/derivation-rules", headers=auth_header(token)) + assert lst.json()["total"] == 1 + assert lst.json()["data"][0]["rule_type"] == "percentage_of" + + +class TestP2SingleAlertPath: + """P2-⑤ 双路径合并: 单一告警逻辑, 两出口级别一致""" + + BASE = "/api/cma/budget" + + def test_single_build_function_two_exits(self, client: TestClient, db: Session): + """run_deviation_check 走统一逻辑写 KPIAlert; deviation-check 写 budget_deviation_alerts""" + from app.utils.deviation_engine import build_deviation_alert, run_deviation_check + + create_test_user(db) + token = get_token_for_user(client) + kpi = create_test_kpi(db, kpi_code="SINGLE_PATH_KPI", kpi_name="测试成本") + db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=100.0, + budget_year=2026, budget_month=6, status="active")) + db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=160.0)) # 60% 超支 + db.commit() + + # KPIAlert 出口: 级别 red(≥30) + r = build_deviation_alert(db, kpi, "2026-06") + assert r["triggered"] is True + assert r["kpi_alert_level"] == "red" + assert r["level"] == "critical" # >50 + + # budget 出口 API: deviation-check + resp = client.post(f"{self.BASE}/deviation-check", headers=auth_header(token), + json={"period": "2026-06", "threshold": 20}) + assert resp.json()["alerts_generated"] == 1 + + # run_deviation_check 写 KPIAlert + n = run_deviation_check(db, "2026-06") + assert n >= 1 + kpi_alert = db.query(KPIAlert).filter( + KPIAlert.kpi_id == kpi.id, + KPIAlert.alert_message.contains("[差异预警]"), + ).first() + assert kpi_alert is not None + assert kpi_alert.alert_level == "red" + assert kpi_alert.suggestion # 非模板空文案 + + def test_no_second_threshold_logic(self, client: TestClient, db: Session): + """deviation_engine 中不应再有独立阈值/方向列表(grep 验证在代码review, 此处测函数可用)""" + from app.utils.deviation_engine import build_deviation_alert, get_higher_better_codes + create_test_user(db) + token = get_token_for_user(client) + kpi = create_test_kpi(db, kpi_code="HB_KPI", kpi_name="营业收入") + assert "SALES_TOTAL" in get_higher_better_codes(db) + + +class TestP2CashClassify: + """P2-⑥ 现金流分类规则表""" + + BASE = "/api/cma/budget" + + def test_unclassified_queue_and_classify(self, client: TestClient, db: Session): + """无关键词KPI → sync-cash-plans → 待分类队列(不静默跳过) → 一键归类 → CashPlan""" + create_test_user(db) + token = get_token_for_user(client) + # 无任何关键词的KPI(不会命中默认关键词) + kpi = create_test_kpi(db, kpi_code="MYSTERY_KPI", kpi_name="部门专项投入待定") + # 移除'投入'关键词冲突: 名称改无关键词 + kpi.kpi_name = "神秘专项" + db.commit() + + client.post(f"{self.BASE}/plans", headers=auth_header(token), + json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 300.0}) + + # sync → 进待分类队列 + resp = client.post(f"{self.BASE}/sync-cash-plans", headers=auth_header(token)) + assert resp.status_code == 200 + assert resp.json()["unclassified_count"] >= 1 + + item = db.query(CashPlanUnclassified).filter( + CashPlanUnclassified.kpi_id == kpi.id, + CashPlanUnclassified.status == "pending", + ).first() + assert item is not None + assert item.reason == "未匹配任何分类规则" + + # 队列列表 + lst = client.get(f"{self.BASE}/cash-unclassified", headers=auth_header(token), + params={"status": "pending"}) + assert any(r["kpi_id"] == kpi.id for r in lst.json()["data"]) + + # 一键归类 receive + cls = client.post(f"{self.BASE}/cash-unclassified/{item.id}/classify", headers=auth_header(token), + json={"plan_type": "receive"}) + assert cls.status_code == 200 + assert cls.json()["rule_created"] is True + + # 规则自动补建 + CashPlan 生成 + rule = db.query(CashPlanClassifyRule).filter( + CashPlanClassifyRule.entity_id == 1, + CashPlanClassifyRule.kpi_id == kpi.id, + ).first() + assert rule is not None and rule.plan_type == "receive" + + plan = db.query(CashPlan).filter(CashPlan.related_kpi_id == kpi.id).first() + assert plan is not None and plan.plan_type == "receive" + + # 队列状态 → classified + db.refresh(item) + assert item.status == "classified" + + def test_rule_priority_over_keyword(self, client: TestClient, db: Session): + """规则表精确匹配优先于默认关键词""" + create_test_user(db) + token = get_token_for_user(client) + kpi = create_test_kpi(db, kpi_code="OVERRIDE_KPI", kpi_name="营业收入") # 默认会命中 receive + + # 规则表强制 pay + r = client.post(f"{self.BASE}/cash-classify-rules", headers=auth_header(token), json={ + "kpi_id": kpi.id, "plan_type": "pay", "priority": 1, + }) + assert r.status_code == 200 + + client.post(f"{self.BASE}/plans", headers=auth_header(token), + json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 500.0}) + client.post(f"{self.BASE}/sync-cash-plans", headers=auth_header(token)) + + plan = db.query(CashPlan).filter(CashPlan.related_kpi_id == kpi.id).first() + assert plan is not None + assert plan.plan_type == "pay" # 规则覆盖关键词 diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 4f9c5cdb..7fc1e49c 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -47,6 +47,8 @@ export const kpiApi = { hierarchy: (params?: any) => api.get('/kpis/hierarchy', { params }), // KPI-8: 因果链追踪 causalityChain: (id: number) => api.get(`/kpis/${id}/causality-chain`), + // KPI实际值列表(归集标签页) + values: (id: number, params?: any) => api.get(`/kpis/${id}/values`, { params }), } export const mapApi = { @@ -208,6 +210,38 @@ export const budgetApi = { deviationCheck: (data: any) => api.post('/budget/deviation-check', data), listDeviationAlerts: (params?: any) => api.get('/budget/deviation-alerts', { params }), updateDeviationAlert: (id: number, data: any) => api.put(`/budget/deviation-alerts/${id}`, data), + // P1-③ 告警归因 + getAlertAttribution: (alertId: number) => api.get(`/budget/deviation-alerts/${alertId}/attribution`), + getAlertDirection: () => api.get('/budget/alert-direction'), + updateAlertDirection: (data: any) => api.put('/budget/alert-direction', data), + // P1-④ 实际值自动归集 + valueSources: (params?: any) => api.get('/budget/value-sources', { params }), + createValueSource: (data: any) => api.post('/budget/value-sources', data), + updateValueSource: (id: number, data: any) => api.put(`/budget/value-sources/${id}`, data), + deleteValueSource: (id: number) => api.delete(`/budget/value-sources/${id}`), + testValueSource: (data: any) => api.post('/budget/value-sources/test', data), + runValueCollect: (data: any) => api.post('/budget/value-collect/run', data), + valueCollectLogs: (params?: any) => api.get('/budget/value-collect/logs', { params }), + valueSourceCoverage: () => api.get('/budget/value-sources/coverage'), + // P2-① 零基逐项论证 + zeroBasedItems: (params?: any) => api.get('/budget/zero-based/items', { params }), + createZeroBasedItem: (data: any) => api.post('/budget/zero-based/items', data), + updateZeroBasedItem: (id: number, data: any) => api.put(`/budget/zero-based/items/${id}`, data), + deleteZeroBasedItem: (id: number) => api.delete(`/budget/zero-based/items/${id}`), + generateZeroBased: (data: any) => api.post('/budget/zero-based/generate', data), + // P2-② 派生规则 + derivationRules: (params?: any) => api.get('/budget/derivation-rules', { params }), + createDerivationRule: (data: any) => api.post('/budget/derivation-rules', data), + updateDerivationRule: (id: number, data: any) => api.put(`/budget/derivation-rules/${id}`, data), + deleteDerivationRule: (id: number) => api.delete(`/budget/derivation-rules/${id}`), + // P2-⑥ 现金流分类规则 + 待分类队列 + cashClassifyRules: () => api.get('/budget/cash-classify-rules'), + createCashClassifyRule: (data: any) => api.post('/budget/cash-classify-rules', data), + updateCashClassifyRule: (id: number, data: any) => api.put(`/budget/cash-classify-rules/${id}`, data), + deleteCashClassifyRule: (id: number) => api.delete(`/budget/cash-classify-rules/${id}`), + 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`), } export const costApi = { diff --git a/frontend/src/views/BudgetManagement.vue b/frontend/src/views/BudgetManagement.vue index fc65b92f..0d2237e0 100644 --- a/frontend/src/views/BudgetManagement.vue +++ b/frontend/src/views/BudgetManagement.vue @@ -203,6 +203,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
暂无待分类KPI,同步现金流计划后未命中规则的KPI会出现在这里
+
+
+
@@ -248,6 +296,82 @@ 确认选择({{ selectedMethodName }}) 刷新计算 + + + + + + + + + + + + + + + + + + + + +
请选择KPI和期间后录入逐项论证
+
+ + + + + + + + + + + + + + + + + + + + + +
@@ -255,6 +379,96 @@ + + + +
{{ c.label }}
{{ c.value }}
+
+
+ + +
+ + + + + 新建映射 + ▶ 立即采集 + 刷新 +
+ + + + + + + + + + + + + + + + + + + +
+ + +
+ + 已自动归集 + 需人工确认 + 全部 + + 对账页将按数据来源分类显示,自动归集数据可追溯采集批次 +
+ + + + + + + + + + + +
+ + +
+ + + + 查询 +
+ + + + + + + + + + + + +
+
+
+ @@ -367,10 +581,11 @@ 已忽略 - + @@ -378,6 +593,80 @@

暂无偏差预警,点击「执行偏差检查」扫描当前期间

+ + + + + +
@@ -437,6 +726,106 @@ + + + + + + + + + + + + + + + 草稿已批准 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 元→万元填 0.0001 + + + + + + + + + + + + + + + + + + + + @@ -954,12 +1343,339 @@ async function reopenAlert(row: any) { } catch { ElMessage.error('操作失败') } } +// ══════════════════════════════════════════════ +// P1-③ 告警归因 +// ══════════════════════════════════════════════ +const showAttributionDialog = ref(false) +const attributionDetail = ref(null) +async function showAlertAttribution(row: any) { + try { + const r: any = await budgetApi.getAlertAttribution(row.id) + attributionDetail.value = r.data || r + showAttributionDialog.value = true + } catch (e) { ElMessage.error('加载归因详情失败') } +} + +// ══════════════════════════════════════════════ +// 通用 KPI 选项(零基/取数映射/派生规则/分类规则共用) +// ══════════════════════════════════════════════ +const zbbKpiOptions = ref([]) +async function loadZbbKpiOptions() { + 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 = [] } +} + +// ══════════════════════════════════════════════ +// P2-① 零基逐项论证 +// ══════════════════════════════════════════════ +const zbbKpiId = ref(null) +const zbbPeriod = ref(`${currentYear}-${String(currentMonth).padStart(2, '0')}`) +const zbbPeriodOptions = computed(() => { + const opts: string[] = [] + const now = new Date() + for (let i = 5; i >= 0; i--) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1) + opts.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`) + } + return opts +}) +const zeroBasedItems = ref([]) +const zbbTotalProposed = ref(null) +const showZbbItemDialog = ref(false) +const zbbItemForm = ref({}) +const zbbGenerating = ref(false) +async function loadZeroBasedItems() { + if (!zbbKpiId.value) { zeroBasedItems.value = []; zbbTotalProposed.value = null; return } + try { + const r: any = await budgetApi.zeroBasedItems({ kpi_id: zbbKpiId.value, period: zbbPeriod.value, entity_id: getEntityId() }) + zeroBasedItems.value = r.data || [] + zbbTotalProposed.value = r.total_proposed ?? null + } catch { zeroBasedItems.value = [] } +} +function addZeroBasedItem() { + if (!zbbKpiId.value) { ElMessage.warning('请先选择KPI'); return } + zbbItemForm.value = { kpi_id: zbbKpiId.value, period: zbbPeriod.value, item_name: '', item_category: 'discretionary', base_value: 0, justification: '', proposed_value: 0, status: 'draft' } + showZbbItemDialog.value = true +} +function editZeroBasedItem(row: any) { + zbbItemForm.value = { ...row } + showZbbItemDialog.value = true +} +async function saveZeroBasedItem() { + const f = zbbItemForm.value + if (!f.item_name) { ElMessage.warning('请填写费用科目'); return } + try { + if (f.id) await budgetApi.updateZeroBasedItem(f.id, f) + else await budgetApi.createZeroBasedItem(f) + ElMessage.success('已保存') + showZbbItemDialog.value = false + loadZeroBasedItems() + } catch (e) { ElMessage.error('保存失败') } +} +async function deleteZeroBasedItem(row: any) { + try { + await ElMessageBox.confirm(`确认删除论证项「${row.item_name}」?`, '确认') + await budgetApi.deleteZeroBasedItem(row.id) + ElMessage.success('已删除') + loadZeroBasedItems() + } catch { } +} +async function generateZeroBased() { + if (!zbbKpiId.value || !zbbPeriod.value) return + zbbGenerating.value = true + try { + const r: any = await budgetApi.generateZeroBased({ kpi_id: zbbKpiId.value, period: zbbPeriod.value, entity_id: getEntityId() }) + ElMessage.success(r.message || '零基预算已生成') + } catch (e: any) { ElMessage.error(e?.response?.data?.detail || '生成失败') } + zbbGenerating.value = false +} + +// ══════════════════════════════════════════════ +// P2-② 派生规则配置 +// ══════════════════════════════════════════════ +const derivationRules = ref([]) +const showDerivationRuleDialog = ref(false) +const derivationRuleForm = ref({}) +async function loadDerivationRules() { + try { + const r: any = await budgetApi.derivationRules({ entity_id: getEntityId() }) + derivationRules.value = r.data || [] + } catch { derivationRules.value = [] } +} +function openDerivationRuleDialog() { + derivationRuleForm.value = { kpi_id: null, rule_type: 'percentage_of', base_kpi_id: null, rate_pct: 2, formula_text: '' } + showDerivationRuleDialog.value = true +} +async function saveDerivationRule() { + const f = derivationRuleForm.value + if (!f.kpi_id) { ElMessage.warning('请选择目标KPI'); return } + try { + await budgetApi.createDerivationRule({ + kpi_id: f.kpi_id, + rule_type: f.rule_type, + base_kpi_id: f.rule_type === 'percentage_of' ? f.base_kpi_id : null, + params: { rate: (f.rate_pct ?? 0) / 100 }, + formula_text: f.formula_text, + entity_id: getEntityId(), + }) + ElMessage.success('规则已创建') + showDerivationRuleDialog.value = false + loadDerivationRules() + } catch (e: any) { ElMessage.error(e?.response?.data?.detail || '创建失败') } +} +async function deleteDerivationRule(row: any) { + try { + await ElMessageBox.confirm(`确认删除规则「${row.kpi_name || row.kpi_code}」?`, '确认') + await budgetApi.deleteDerivationRule(row.id) + ElMessage.success('已删除') + loadDerivationRules() + } catch { } +} + +// ══════════════════════════════════════════════ +// P1-④ 实际值自动归集 +// ══════════════════════════════════════════════ +const collectTab = ref('mappings') +const collectKpiId = ref(null) +const collectCoverageCards = ref([]) +const valueSources = ref([]) +const showValueSourceDialog = ref(false) +const valueSourceForm = ref({}) +const collectRunning = ref(false) +const collectLogStatus = ref('') +const collectLogs = ref([]) +const valueTagFilter = ref('auto_collect') +const valueTagRows = ref([]) +async function loadCollectData() { + await Promise.all([loadValueSources(), loadCollectCoverage(), loadValueTags(), loadCollectLogs()]) +} +async function loadValueSources() { + 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 +} + +// ══════════════════════════════════════════════ +// P2-⑥ 现金流分类规则 + 待分类队列 +// ══════════════════════════════════════════════ +const cashClassifyRules = ref([]) +const showClassifyRuleDialog = ref(false) +const classifyRuleForm = ref({}) +const unclassifiedRows = ref([]) +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('操作失败') } +} + watch(activeTab, (tab) => { if (tab === 'decompose') loadBudget() else if (tab === 'strategy') loadStrategyBudget() else if (tab === 'versions') loadVersions() - else if (tab === 'execution') loadExecutionReport() - else if (tab === 'method') refreshMethodComparison() + else if (tab === 'execution') { loadExecutionReport(); loadCashClassify() } + else if (tab === 'method') { refreshMethodComparison(); loadDerivationRules() } + else if (tab === 'collect') loadCollectData() else if (tab === 'driver') { // DriverFactorBudget handles its own loading on mount } @@ -973,6 +1689,7 @@ onMounted(async () => { loadBudget() loadBudgetConfig() loadComparisonKpiOptions() + loadZbbKpiOptions() })