feat: 预算系统6项技术改进(告警归因/实际值自动归集/真零基/派生规则/告警路径统一/现金流分类)

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
This commit is contained in:
Hermes CI Fix
2026-08-28 18:03:47 +08:00
parent 3bc68fa1c6
commit 94aeb14e95
16 changed files with 3164 additions and 118 deletions
+334 -81
View File
@@ -803,20 +803,23 @@ def get_kpi_comparison(
def check_budget_deviation( def check_budget_deviation(
data: dict, data: dict,
db: Session = Depends(get_db), db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth), current_user=Depends(require_auth),
): ):
""" """
检查实际vs预测偏差,当偏差超过20%时自动生成预警 检查实际vs预测偏差,当偏差超过阈值时自动生成预警
(2026-08-28 P1-③/P2-⑤: 统一走 build_deviation_alert,写入归因JSON+场景建议)
""" """
from app.models import KPIValue, BudgetDeviationAlert from app.models import KPIValue, BudgetDeviationAlert
from sqlalchemy import func from sqlalchemy import func
from app.utils.deviation_engine import build_deviation_alert
threshold = data.get("threshold", 20) # 默认20% threshold = data.get("threshold", 20) # 默认20%
period = data.get("period") or datetime.now().strftime("%Y-%m") 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( budget_plans = db.query(BudgetPlan).filter(
BudgetPlan.entity_id == entity_id,
BudgetPlan.period == period, BudgetPlan.period == period,
BudgetPlan.status == "active", BudgetPlan.status == "active",
).all() ).all()
@@ -832,39 +835,25 @@ def check_budget_deviation(
alerts = [] alerts = []
for bp in budget_plans: for bp in budget_plans:
# 查询实际值 kpi_obj = db.query(KPIDefinition).filter(
actual = db.query(KPIValue).filter( KPIDefinition.id == bp.kpi_id,
KPIValue.kpi_id == bp.kpi_id, KPIDefinition.entity_id == entity_id,
KPIValue.period == period,
KPIValue.actual_value.isnot(None),
).first() ).first()
if not kpi_obj:
if not actual or actual.actual_value is None:
continue continue
budget_val = bp.budget_value # 统一告警逻辑(方向性/阈值/归因/场景建议)
actual_val = actual.actual_value result = build_deviation_alert(db, kpi_obj, period, entity_id=entity_id, min_rate=threshold)
if not result["triggered"]:
if budget_val == 0:
continue continue
# 计算偏差率 deviation = result["deviation"]
deviation_rate = round((actual_val - budget_val) / budget_val * 100, 2) budget_val = deviation.get("budget_value")
actual_val = deviation.get("actual_value")
# 只有偏差超过阈值才生成预警 deviation_rate = deviation.get("deviation_rate")
if abs(deviation_rate) <= threshold: deviation_value = deviation.get("deviation_amount")
continue if deviation_value is None:
deviation_value = round((actual_val or 0) - (budget_val or 0), 2)
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)}%,建议分析是否预算过高或业务量未达预期"
# 检查是否已存在相同的预警 # 检查是否已存在相同的预警
existing_alert = db.query(BudgetDeviationAlert).filter( existing_alert = db.query(BudgetDeviationAlert).filter(
@@ -874,6 +863,12 @@ def check_budget_deviation(
).first() ).first()
if existing_alert: 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 continue
alert = BudgetDeviationAlert( alert = BudgetDeviationAlert(
@@ -883,14 +878,16 @@ def check_budget_deviation(
actual_value=actual_val, actual_value=actual_val,
deviation_rate=deviation_rate, deviation_rate=deviation_rate,
deviation_value=deviation_value, deviation_value=deviation_value,
alert_level=alert_level, alert_level=result["level"],
status="open", status="open",
suggestion=suggestion, suggestion=result["suggestion"],
alert_type=result["alert_type"],
attribution=result["attribution"],
scenario_id=result["scenario_id"],
) )
db.add(alert) db.add(alert)
alerts_generated += 1 alerts_generated += 1
kpi_obj = db.query(KPIDefinition).filter(KPIDefinition.id == bp.kpi_id).first()
alerts.append({ alerts.append({
"kpi_id": bp.kpi_id, "kpi_id": bp.kpi_id,
"kpi_code": kpi_obj.kpi_code if kpi_obj else "", "kpi_code": kpi_obj.kpi_code if kpi_obj else "",
@@ -900,8 +897,10 @@ def check_budget_deviation(
"actual_value": actual_val, "actual_value": actual_val,
"deviation_rate": deviation_rate, "deviation_rate": deviation_rate,
"deviation_value": deviation_value, "deviation_value": deviation_value,
"alert_level": alert_level, "alert_level": result["level"],
"suggestion": suggestion, "suggestion": result["suggestion"],
"alert_type": result["alert_type"],
"attribution": result["attribution"],
}) })
db.commit() db.commit()
@@ -922,10 +921,11 @@ def list_deviation_alerts(
alert_level: Optional[str] = Query(None), alert_level: Optional[str] = Query(None),
status: Optional[str] = Query(None), status: Optional[str] = Query(None),
db: Session = Depends(get_db), db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
): ):
"""查询预算偏差预警记录""" """查询预算偏差预警记录 (2026-08-28: 列表新增 alert_type/attribution/scenario_identity_id隔离)"""
from app.models import BudgetDeviationAlert from app.models import BudgetDeviationAlert
query = db.query(BudgetDeviationAlert) query = db.query(BudgetDeviationAlert).filter(BudgetDeviationAlert.entity_id == entity_id)
if kpi_id: if kpi_id:
query = query.filter(BudgetDeviationAlert.kpi_id == kpi_id) query = query.filter(BudgetDeviationAlert.kpi_id == kpi_id)
if period: if period:
@@ -952,11 +952,120 @@ def list_deviation_alerts(
"alert_level": a.alert_level, "alert_level": a.alert_level,
"status": a.status, "status": a.status,
"suggestion": a.suggestion, "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, "created_at": a.created_at.isoformat() if a.created_at else None,
}) })
return {"data": result, "total": len(result)} 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}") @router.put("/deviation-alerts/{alert_id}")
def update_deviation_alert( def update_deviation_alert(
alert_id: int, alert_id: int,
@@ -979,11 +1088,18 @@ def update_deviation_alert(
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
@router.post("/method-comparison") @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, ... } 接收: { 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") entity = data.get("entity", "hanke")
last_month_budget = data.get("last_month_budget", 91) # 上月预算(万) 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_result = round(last_month_budget * (1 + increment_rate), 1)
incremental_detail = f"上月{last_month_budget}× (1+{increment_rate*100:.0f}%) = {incremental_result}" incremental_detail = f"上月{last_month_budget}× (1+{increment_rate*100:.0f}%) = {incremental_result}"
# 2. 零基预算: 每项从零论证 # 2. 零基预算: 优先逐项论证(P2-① 真零基)
zbb_entertainment = round(fixed_costs.get("entertainment", 16) / 2, 1) # 砍半 zbb_kpi_id = data.get("zero_based_kpi_id")
zbb_misc = round(fixed_costs.get("misc", 12) * 0.7, 1) # 压缩30% zbb_period = data.get("zero_based_period")
zbb_total = round( zbb_is_demo = True
fixed_costs.get("rent", 15) zbb_items = []
+ fixed_costs.get("labor", 40) if zbb_kpi_id and zbb_period:
+ zbb_entertainment from app.models import BudgetZeroBasedItem
+ zbb_misc, zbb_items = db.query(BudgetZeroBasedItem).filter(
1, BudgetZeroBasedItem.entity_id == entity_id,
) BudgetZeroBasedItem.kpi_id == zbb_kpi_id,
zbb_savings = round(last_month_budget - zbb_total, 1) BudgetZeroBasedItem.period == zbb_period,
zbb_detail = ( ).all()
f"房租{fixed_costs.get('rent', 15)}万(固定)+人工{fixed_costs.get('labor', 40)}万(砍不掉)"
f"+招待{zbb_entertainment}万(砍半)+杂项{zbb_misc}万(压缩)" if zbb_items:
f"={zbb_total}万 ← 省{zbb_savings}" # 真零基: 逐项求和(仅 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. 弹性预算: 根据收入水平动态调整 # 3. 弹性预算: 根据收入水平动态调整
flexible_fixed = round(fixed_costs.get("rent", 15) + fixed_costs.get("labor", 40) * 0.5, 1) 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, "result_value": zbb_total,
"savings": zbb_savings, "savings": zbb_savings,
"detail": zbb_detail, "detail": zbb_detail,
"is_demo": zbb_is_demo,
"item_count": len(zbb_items),
"pros": "最合理", "pros": "最合理",
"cons": "耗时", "cons": "耗时",
"is_recommended": True, "is_recommended": True,
@@ -1078,16 +1218,21 @@ def budget_method_comparison(data: dict):
def apply_budget_method( def apply_budget_method(
data: dict, data: dict,
db: Session = Depends(get_db), db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth), current_user=Depends(require_auth),
): ):
"""应用所选预算编制方法到预算计划(2026-08-26:三法并存,按用户场景选择后落地) """应用所选预算编制方法到预算计划(2026-08-26:三法并存,按用户场景选择后落地)
接收: { method: 'incremental'|'zero_based'|'flexible', year: 2026, entity: 'hanke', ... } 接收: { method: 'incremental'|'zero_based'|'flexible', year: 2026, entity: 'hanke', ... }
说明: 方法计算结果 → 写入/更新预算计划(version标注方法名,便于追溯) 说明: 方法计算结果 → 写入/更新预算计划(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") method = data.get("method", "zero_based")
year = data.get("year", datetime.now().year) year = data.get("year", datetime.now().year)
entity = data.get("entity", "hanke") entity = data.get("entity", "hanke")
entity_id = data.get("entity_id", 1)
# 复用method-comparison计算(获得三法结果) # 复用method-comparison计算(获得三法结果)
comp = budget_method_comparison({ comp = budget_method_comparison({
@@ -1099,7 +1244,9 @@ def apply_budget_method(
}), }),
"variable_cost_rate": data.get("variable_cost_rate", 0.4862), "variable_cost_rate": data.get("variable_cost_rate", 0.4862),
"increment_rate": data.get("increment_rate", 0.05), "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 selected = None
@@ -1111,7 +1258,6 @@ def apply_budget_method(
raise HTTPException(400, "未知预算方法: " + method) raise HTTPException(400, "未知预算方法: " + method)
# 找到该年的核心KPI(营业收入/净利润/费用率等) # 找到该年的核心KPI(营业收入/净利润/费用率等)
# 取该年已有预算的KPI,或默认核心4个
kpis = db.query(KPIDefinition).filter( kpis = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id, KPIDefinition.entity_id == entity_id,
KPIDefinition.status == "active", KPIDefinition.status == "active",
@@ -1120,11 +1266,18 @@ def apply_budget_method(
if not kpis: if not kpis:
raise HTTPException(400, "未找到可应用的KPI") raise HTTPException(400, "未找到可应用的KPI")
# 方法结果解释为收入预算(核心KPI应用 # 加载派生规则(P2-②
# incremental/flexible/zero_based 的 result_value 均为"预算总额(万)" rules = db.query(BudgetDerivationRule).filter(
# 写入F_REVENUE年度预算(period=YYYY-00 表示年度) 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')}" version = f"{method}-{datetime.now().strftime('%Y%m%d')}"
applied = [] applied = []
used_configured = False
for kpi in kpis: for kpi in kpis:
period = f"{year}-00" period = f"{year}-00"
# 删除旧版本的同KPI年度预算 # 删除旧版本的同KPI年度预算
@@ -1134,15 +1287,67 @@ def apply_budget_method(
BudgetPlan.version.like(f"{method}-%"), BudgetPlan.version.like(f"{method}-%"),
).delete() ).delete()
# 各KPI的应用值(简化:收入用方法结果,其他按比例 # 各KPI的应用值:收入用方法结果,其他优先派生规则(P2-②
if kpi.kpi_code == "F_REVENUE": if kpi.kpi_code == "F_REVENUE":
budget_val = selected["result_value"] budget_val = selected["result_value"]
elif kpi.kpi_code == "F_NET_PROFIT": rule_source = "default"
budget_val = round(selected["result_value"] * 0.02, 1) # 净利率约2% formula_note = "方法结果"
elif kpi.kpi_code == "F_COST_RATIO": else:
budget_val = round(selected["result_value"] * 0.22, 1) # 费用率约22% rule = rules_by_kpi.get(kpi.id)
else: # F_GROSS_MARGIN if rule and rule.params:
budget_val = round(selected["result_value"] * 0.18, 1) # 毛利率约18% 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( bp = BudgetPlan(
entity_id=entity_id, entity_id=entity_id,
@@ -1153,10 +1358,11 @@ def apply_budget_method(
budget_month=0, budget_month=0,
version=version, version=version,
status="active", 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) 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() db.commit()
return { return {
@@ -1167,7 +1373,8 @@ def apply_budget_method(
"total_budget": selected["result_value"], "total_budget": selected["result_value"],
"detail": selected["detail"], "detail": selected["detail"],
"applied": applied, "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), db: Session = Depends(get_db),
): ):
"""预算→现金流计划联动: 按预算KPI生成/更新收付款计划(修复断点#1) """预算→现金流计划联动: 按预算KPI生成/更新收付款计划(修复断点#1)
(2026-08-28 P2-⑥: 分类规则表优先, 未命中进待分类队列不再静默跳过)
收入类KPI(营收/回款/新客) → receive 收入类KPI(营收/回款/新客) → receive
成本类KPI(费用/厂补/采购) → pay 成本类KPI(费用/厂补/采购) → pay
分类来源: ①cash_plan_classify_rules规则表(精确KPI→关键词) ②默认关键词兜底 ③待分类队列
upsert: 同KPI+同日期+同类型 更新不重复 upsert: 同KPI+同日期+同类型 更新不重复
""" """
from app.models import CashPlan from app.models import CashPlan, CashPlanClassifyRule, CashPlanUnclassified
from datetime import datetime from datetime import datetime
# 默认关键词兜底(兼容存量,规则表优先)
RECEIVE_KEYS = ("营收", "收入", "销售", "回款", "新客", "收款", "净利润", "毛利") RECEIVE_KEYS = ("营收", "收入", "销售", "回款", "新客", "收款", "净利润", "毛利")
PAY_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( budgets = db.query(BudgetPlan).filter(
BudgetPlan.entity_id == entity_id, BudgetPlan.status == "active" BudgetPlan.entity_id == entity_id, BudgetPlan.status == "active"
).all() ).all()
kpi_ids = {b.kpi_id for b in budgets} 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 {} 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: for b in budgets:
kpi = kpis.get(b.kpi_id) kpi = kpis.get(b.kpi_id)
if not kpi: if not kpi:
continue continue
name = (kpi.kpi_name or "") + (kpi.kpi_code or "") plan_type = classify_plan_type(kpi)
if any(k in name for k in RECEIVE_KEYS): if plan_type is None:
plan_type = "receive" # 无法判类别 → 写入待分类队列(不静默跳过,P2-⑥)
elif any(k in name for k in PAY_KEYS): existing_un = db.query(CashPlanUnclassified).filter(
plan_type = "pay" CashPlanUnclassified.entity_id == entity_id,
else: CashPlanUnclassified.kpi_id == b.kpi_id,
continue # 无法判类别的KPI跳过 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 year, month = b.budget_year or 2026, b.budget_month or 1
try: try:
@@ -1395,4 +1644,8 @@ def sync_cash_plans(
)) ))
created += 1 created += 1
db.commit() 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,
}
+253
View File
@@ -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": "已忽略"}
+141
View File
@@ -0,0 +1,141 @@
"""KPI派生规则 API — 管理会计OS (P2-② 2026-08-28)
派生规则配置(budget_derivation_rules)apply-method 派生KPI时优先读规则
percentage_of base_kpi实际值×rateincremental 上月×(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": "派生规则已删除"}
+27
View File
@@ -525,6 +525,33 @@ def create_kpi_value(
return {"message": "已录入", "id": new_val.id, "period": period, "actual_value": float(actual_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}") @router.get("/{kpi_id}")
def get_kpi(kpi_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_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() kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
+276
View File
@@ -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")
+217
View File
@@ -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_plansversion='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,
}
+41
View File
@@ -97,6 +97,47 @@ def init_db():
except Exception as e: except Exception as e:
logger.warning(f"user_entities初始化跳过: {e}") logger.warning(f"user_entities初始化跳过: {e}")
# ── scenario_suggestions 告警场景建议 seed2026-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): def _seed_org_data(db_session):
"""插入5层级组织示例数据""" """插入5层级组织示例数据"""
+5 -1
View File
@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from dotenv import load_dotenv from dotenv import load_dotenv
from app.database import init_db 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 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 scripts.erp_sync import run_sync as run_erp_sync
from app.auth_middleware import require_auth from app.auth_middleware import require_auth
@@ -54,6 +54,10 @@ app.include_router(org.router)
app.include_router(objectives.router) app.include_router(objectives.router)
app.include_router(versions.router) app.include_router(versions.router)
app.include_router(budget.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(cost.router)
app.include_router(predict.router) app.include_router(predict.router)
app.include_router(growth_quality.router) app.include_router(growth_quality.router)
+106
View File
@@ -524,6 +524,9 @@ class BudgetDeviationAlert(Base):
alert_level = Column(String(20), default="warning", comment="warning/critical") alert_level = Column(String(20), default="warning", comment="warning/critical")
status = Column(String(20), default="open", comment="open/resolved/ignored") status = Column(String(20), default="open", comment="open/resolved/ignored")
suggestion = Column(String(500), nullable=True, comment="处理建议") 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()) 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\"}]") monthly_milestones = Column(JSON, nullable=True, comment="月度里程碑: [{\"month\":\"2026-07\",\"label\":\"...\",\"status\":\"completed\"}]")
sort_order = Column(Integer, default=0, comment="排序") sort_order = Column(Integer, default=0, comment="排序")
created_at = Column(DateTime, server_default=func.now()) 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)
+203
View File
@@ -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"
+110 -31
View File
@@ -8,15 +8,40 @@
4. 差异预警触发集成到现有预警系统 4. 差异预警触发集成到现有预警系统
""" """
import logging import logging
import json
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from app.database import get_session_local 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") 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: def run_deviation_check(db_session, period: str = None) -> int:
"""运行差异预警检查,返回新增预警数""" """运行差异预警检查,返回新增预警数(统一走 build_deviation_alertP2-⑤)"""
if period is None: if period is None:
period = datetime.now().strftime("%Y-%m") period = datetime.now().strftime("%Y-%m")
@@ -221,33 +321,10 @@ def run_deviation_check(db_session, period: str = None) -> int:
new_count = 0 new_count = 0
for kpi in kpis: for kpi in kpis:
# 1. 差异预警:实际 vs 预算 # 1. 差异预警:实际 vs 预算(统一逻辑)
deviation = calc_period_deviation(db_session, kpi.id, period) result = build_deviation_alert(db_session, kpi, period)
if deviation.get("deviation_rate") is not None: if result["triggered"]:
rate = abs(deviation["deviation_rate"]) deviation = result["deviation"]
# 差异化阈值:越高越好型 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
alert_msg = ( alert_msg = (
f"{kpi.kpi_name}[{period}] 差异预警: 实际{deviation['actual_value']} " f"{kpi.kpi_name}[{period}] 差异预警: 实际{deviation['actual_value']} "
f"vs 预算{deviation['budget_value']}" f"vs 预算{deviation['budget_value']}"
@@ -265,13 +342,15 @@ def run_deviation_check(db_session, period: str = None) -> int:
if not existing: if not existing:
alert = KPIAlert( alert = KPIAlert(
kpi_id=kpi.id, kpi_id=kpi.id,
alert_level=level, alert_level=result["kpi_alert_level"],
alert_message=f"[差异预警] {alert_msg}", alert_message=f"[差异预警] {alert_msg}",
alert_type=result["alert_type"] or "actual",
suggestion=result["suggestion"],
status="pending", status="pending",
) )
db_session.add(alert) db_session.add(alert)
new_count += 1 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期) # 2. 趋势异常检测(每期检查连续3期)
trend = check_trend_anomaly(db_session, kpi.id, period, consecutive=3) trend = check_trend_anomaly(db_session, kpi.id, period, consecutive=3)
+233
View File
@@ -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()
+462
View File
@@ -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)
# 核心4KPIapply-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" # 规则覆盖关键词
+34
View File
@@ -47,6 +47,8 @@ export const kpiApi = {
hierarchy: (params?: any) => api.get('/kpis/hierarchy', { params }), hierarchy: (params?: any) => api.get('/kpis/hierarchy', { params }),
// KPI-8: 因果链追踪 // KPI-8: 因果链追踪
causalityChain: (id: number) => api.get(`/kpis/${id}/causality-chain`), causalityChain: (id: number) => api.get(`/kpis/${id}/causality-chain`),
// KPI实际值列表(归集标签页)
values: (id: number, params?: any) => api.get(`/kpis/${id}/values`, { params }),
} }
export const mapApi = { export const mapApi = {
@@ -208,6 +210,38 @@ export const budgetApi = {
deviationCheck: (data: any) => api.post('/budget/deviation-check', data), deviationCheck: (data: any) => api.post('/budget/deviation-check', data),
listDeviationAlerts: (params?: any) => api.get('/budget/deviation-alerts', { params }), listDeviationAlerts: (params?: any) => api.get('/budget/deviation-alerts', { params }),
updateDeviationAlert: (id: number, data: any) => api.put(`/budget/deviation-alerts/${id}`, data), 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 = { export const costApi = {
+722 -5
View File
@@ -203,6 +203,54 @@
<el-table-column label="执行率" width="100"><template #default="{ row }"><el-progress :percentage="row.execution_rate || 0" :status="row.execution_rate > 100 ? 'exception' : row.execution_rate > 80 ? 'warning' : 'success'" :stroke-width="16" :text-inside="true" /></template></el-table-column> <el-table-column label="执行率" width="100"><template #default="{ row }"><el-progress :percentage="row.execution_rate || 0" :status="row.execution_rate > 100 ? 'exception' : row.execution_rate > 80 ? 'warning' : 'success'" :stroke-width="16" :text-inside="true" /></template></el-table-column>
<el-table-column label="预警" width="80"><template #default="{ row }"><el-tag v-if="row.alert_level === 'red'" size="small" type="danger">严重</el-tag><el-tag v-else-if="row.alert_level === 'yellow'" size="small" type="warning">关注</el-tag><el-tag v-else size="small" type="success">正常</el-tag></template></el-table-column> <el-table-column label="预警" width="80"><template #default="{ row }"><el-tag v-if="row.alert_level === 'red'" size="small" type="danger">严重</el-tag><el-tag v-else-if="row.alert_level === 'yellow'" size="small" type="warning">关注</el-tag><el-tag v-else size="small" type="success">正常</el-tag></template></el-table-column>
</el-table> </el-table>
<!-- P2- 现金流分类规则 + 待分类队列 -->
<el-row :gutter="16" style="margin-top:20px;">
<el-col :span="12">
<el-card shadow="never">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span><strong>现金流分类规则</strong> <span style="font-size:12px;color:#999;">KPIreceive/pay 可维护</span></span>
<el-button type="primary" size="small" @click="showClassifyRuleDialog = true">+ 新建规则</el-button>
</div>
</template>
<el-table :data="cashClassifyRules" border stripe size="small" style="width:100%;" max-height="240">
<el-table-column prop="kpi_code" label="KPI编码" width="110"><template #default="{ row }">{{ row.kpi_code || row.kpi_code_pattern || '--' }}</template></el-table-column>
<el-table-column prop="kpi_name" label="名称/关键词" min-width="110"><template #default="{ row }">{{ row.kpi_name || row.kpi_code_pattern || '--' }}</template></el-table-column>
<el-table-column label="类型" width="80">
<template #default="{ row }"><el-tag size="small" :type="row.plan_type === 'receive' ? 'success' : 'danger'">{{ row.plan_type === 'receive' ? '收' : '付' }}</el-tag></template>
</el-table-column>
<el-table-column prop="priority" label="优先级" width="70" />
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ row }"><el-button size="small" link type="danger" @click="deleteClassifyRule(row)">删除</el-button></template>
</el-table-column>
</el-table>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="never">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span><strong>待分类队列</strong> <span style="font-size:12px;color:#999;">无法判别的KPI不再静默跳过</span></span>
<el-badge :value="unclassifiedCount" :hidden="unclassifiedCount === 0" type="danger"><el-button size="small" @click="loadCashClassify">刷新</el-button></el-badge>
</div>
</template>
<el-table :data="unclassifiedRows" border stripe size="small" style="width:100%;" max-height="240">
<el-table-column prop="kpi_name" label="KPI" min-width="120" />
<el-table-column prop="period" label="期间" width="80" />
<el-table-column prop="budget_value" label="预算值" width="90"><template #default="{ row }">{{ formatNumber(row.budget_value) }}</template></el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button size="small" link type="success" @click="classifyUnclassified(row, 'receive')"></el-button>
<el-button size="small" link type="danger" @click="classifyUnclassified(row, 'pay')"></el-button>
<el-button size="small" link @click="ignoreUnclassified(row)">忽略</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="unclassifiedRows.length === 0" style="padding:16px 0;text-align:center;color:#999;font-size:13px;">暂无待分类KPI同步现金流计划后未命中规则的KPI会出现在这里</div>
</el-card>
</el-col>
</el-row>
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="编制方法" name="method"> <el-tab-pane label="编制方法" name="method">
@@ -248,6 +296,82 @@
<el-button v-if="selectedMethod" type="primary" @click="confirmMethod">确认选择{{ selectedMethodName }}</el-button> <el-button v-if="selectedMethod" type="primary" @click="confirmMethod">确认选择{{ selectedMethodName }}</el-button>
<el-button @click="refreshMethodComparison">刷新计算</el-button> <el-button @click="refreshMethodComparison">刷新计算</el-button>
</div> </div>
<!-- P2- 零基逐项论证 -->
<el-card shadow="never" style="margin-top:20px;">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;">
<span><strong>零基逐项论证</strong> <span style="font-size:12px;color:#999;">真零基逐项输入基准/论证/建议值 生成预算</span></span>
<div style="display:flex;gap:8px;align-items:center;">
<el-select v-model="zbbKpiId" placeholder="选择KPI" filterable style="width:200px;" size="small" @change="loadZeroBasedItems">
<el-option v-for="k in zbbKpiOptions" :key="k.id" :label="`${k.kpi_code} - ${k.kpi_name}`" :value="k.id" />
</el-select>
<el-select v-model="zbbPeriod" placeholder="期间" style="width:100px;" size="small" @change="loadZeroBasedItems">
<el-option v-for="p in zbbPeriodOptions" :key="p" :label="p" :value="p" />
</el-select>
<el-button type="primary" size="small" @click="addZeroBasedItem">+ 论证项</el-button>
<el-button type="warning" size="small" :loading="zbbGenerating" :disabled="!zbbKpiId || !zbbPeriod" @click="generateZeroBased">生成零基预算</el-button>
</div>
</div>
</template>
<el-alert v-if="zbbTotalProposed != null" :title="`论证项合计: ${formatNumber(zbbTotalProposed)} 万(Σ建议值)`" type="success" :closable="false" show-icon style="margin-bottom:10px;" />
<el-table :data="zeroBasedItems" border stripe size="small" style="width:100%;">
<el-table-column prop="item_name" label="费用科目" min-width="120" />
<el-table-column prop="item_category" label="类别" width="100">
<template #default="{ row }">
<el-tag size="small" :type="row.item_category === 'fixed' ? 'info' : row.item_category === 'variable' ? 'warning' : 'primary'">
{{ row.item_category === 'fixed' ? '固定' : row.item_category === 'variable' ? '变动' : '酌量' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="base_value" label="基准值" width="90"><template #default="{ row }">{{ formatNumber(row.base_value) }}</template></el-table-column>
<el-table-column prop="justification" label="逐项论证理由" min-width="160" />
<el-table-column prop="proposed_value" label="论证后金额" width="100"><template #default="{ row }"><b>{{ formatNumber(row.proposed_value) }}</b></template></el-table-column>
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }"><el-tag size="small" :type="row.status === 'approved' ? 'success' : 'info'">{{ row.status === 'approved' ? '已批准' : '草稿' }}</el-tag></template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button size="small" link type="primary" @click="editZeroBasedItem(row)">编辑</el-button>
<el-button size="small" link type="danger" @click="deleteZeroBasedItem(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="zeroBasedItems.length === 0" style="padding:20px 0;text-align:center;color:#999;font-size:13px;">请选择KPI和期间后录入逐项论证</div>
</el-card>
<!-- P2- 派生规则配置 -->
<el-card shadow="never" style="margin-top:16px;">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span><strong>派生规则配置</strong> <span style="font-size:12px;color:#999;">apply-method 派生KPI时优先读规则替代固定比例</span></span>
<el-button type="primary" size="small" @click="showDerivationRuleDialog = true">+ 新建规则</el-button>
</div>
</template>
<el-table :data="derivationRules" border stripe size="small" style="width:100%;">
<el-table-column prop="kpi_code" label="目标KPI编码" width="110" />
<el-table-column prop="kpi_name" label="目标KPI" min-width="110" />
<el-table-column prop="rule_type" label="规则类型" width="130">
<template #default="{ row }">
<el-tag size="small" :type="row.rule_type === 'percentage_of' ? 'primary' : row.rule_type === 'incremental' ? 'warning' : 'info'">
{{ row.rule_type === 'percentage_of' ? '按来源比例' : row.rule_type === 'incremental' ? '增量' : '公式' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="参数" width="120">
<template #default="{ row }"><span v-if="row.params">{{ (row.params.rate != null ? `比例 ${(row.params.rate * 100).toFixed(0)}%` : '') }}</span></template>
</el-table-column>
<el-table-column label="来源KPI" width="130">
<template #default="{ row }"><span v-if="row.base_kpi_code">{{ row.base_kpi_code }}</span><span v-else style="color:#ccc;">--</span></template>
</el-table-column>
<el-table-column prop="formula_text" label="公式说明" min-width="160" />
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ row }">
<el-button size="small" link type="danger" @click="deleteDerivationRule(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div> </div>
</el-tab-pane> </el-tab-pane>
@@ -255,6 +379,96 @@
<DriverFactorBudget /> <DriverFactorBudget />
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="实际值归集" name="collect">
<el-row :gutter="16" style="margin-bottom:16px;">
<el-col :span="6" v-for="c in collectCoverageCards" :key="c.label">
<el-card shadow="hover"><div style="text-align:center;"><div style="font-size:12px;color:#999;">{{ c.label }}</div><div style="font-size:22px;font-weight:600;margin-top:4px;" :style="{color: c.color}">{{ c.value }}</div></div></el-card>
</el-col>
</el-row>
<el-tabs v-model="collectTab" type="border-card" style="margin-top:4px;">
<el-tab-pane label="取数映射" name="mappings">
<div style="display:flex;gap:8px;margin-bottom:12px;align-items:center;flex-wrap:wrap;">
<el-select v-model="collectKpiId" placeholder="选择KPI(可选)" filterable clearable style="width:220px;" @change="loadValueSources">
<el-option v-for="k in zbbKpiOptions" :key="k.id" :label="`${k.kpi_code} - ${k.kpi_name}`" :value="k.id" />
</el-select>
<el-button type="primary" @click="showValueSourceDialog = true">+ 新建映射</el-button>
<el-button type="warning" :loading="collectRunning" @click="runValueCollect"> 立即采集</el-button>
<el-button @click="loadCollectData">刷新</el-button>
</div>
<el-table :data="valueSources" border stripe size="small" style="width:100%;">
<el-table-column prop="kpi_code" label="KPI编码" width="100" />
<el-table-column prop="kpi_name" label="KPI名称" min-width="120" />
<el-table-column prop="source_table" label="源头表" width="160">
<template #default="{ row }"><el-tag size="small">{{ row.source_table }}</el-tag></template>
</el-table-column>
<el-table-column prop="source_field" label="金额字段" width="110" />
<el-table-column prop="aggregate" label="聚合" width="70" />
<el-table-column label="过滤规则" width="140">
<template #default="{ row }"><span style="font-size:12px;">{{ JSON.stringify(row.filter_rule || {}) }}</span></template>
</el-table-column>
<el-table-column prop="unit_conversion" label="倍率" width="60" />
<el-table-column label="状态" width="80">
<template #default="{ row }"><el-tag size="small" :type="row.status === 'active' ? 'success' : 'info'">{{ row.status === 'active' ? '启用' : '停用' }}</el-tag></template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button size="small" link type="primary" @click="testValueSource(row)">试跑</el-button>
<el-button size="small" link type="warning" @click="toggleValueSource(row)">{{ row.status === 'active' ? '停用' : '启用' }}</el-button>
<el-button size="small" link type="danger" @click="deleteValueSource(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="实际值标签" name="values">
<div style="display:flex;gap:8px;margin-bottom:12px;align-items:center;">
<el-radio-group v-model="valueTagFilter" size="small">
<el-radio-button value="auto_collect">已自动归集</el-radio-button>
<el-radio-button value="manual">需人工确认</el-radio-button>
<el-radio-button value="all">全部</el-radio-button>
</el-radio-group>
<span style="font-size:12px;color:#999;">对账页将按数据来源分类显示自动归集数据可追溯采集批次</span>
</div>
<el-table :data="valueTagRows" border stripe size="small" style="width:100%;">
<el-table-column prop="kpi_code" label="KPI编码" width="100" />
<el-table-column prop="kpi_name" label="KPI名称" min-width="120" />
<el-table-column prop="period" label="期间" width="90" />
<el-table-column prop="actual_value" label="实际值" width="110"><template #default="{ row }">{{ formatNumber(row.actual_value) }}</template></el-table-column>
<el-table-column label="来源" width="130">
<template #default="{ row }">
<el-tag v-if="row.source_type === 'auto_collect'" size="small" type="success">已自动归集</el-tag>
<el-tag v-else-if="row.source_type === 'manual' || row.source_type === 'excel'" size="small" type="warning">需人工确认</el-tag>
<el-tag v-else size="small" type="info">{{ row.source_type || 'manual' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="source_batch" label="采集批次" width="170"><template #default="{ row }"><span style="font-size:12px;">{{ row.source_batch || '--' }}</span></template></el-table-column>
<el-table-column prop="remark" label="备注" min-width="150" />
</el-table>
</el-tab-pane>
<el-tab-pane label="采集日志" name="logs">
<div style="display:flex;gap:8px;margin-bottom:12px;align-items:center;">
<el-select v-model="collectLogStatus" placeholder="状态" clearable style="width:110px;" size="small">
<el-option label="成功" value="success" /><el-option label="失败" value="failed" />
</el-select>
<el-button size="small" type="primary" @click="loadCollectLogs">查询</el-button>
</div>
<el-table :data="collectLogs" border stripe size="small" style="width:100%;">
<el-table-column prop="kpi_code" label="KPI编码" width="100" />
<el-table-column prop="kpi_name" label="KPI名称" min-width="110" />
<el-table-column prop="period" label="期间" width="90" />
<el-table-column prop="source_table" label="源头表" width="140" />
<el-table-column prop="collected_value" label="采集值" width="100"><template #default="{ row }">{{ formatNumber(row.collected_value) }}</template></el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }"><el-tag size="small" :type="row.status === 'success' ? 'success' : 'danger'">{{ row.status === 'success' ? '成功' : '失败' }}</el-tag></template>
</el-table-column>
<el-table-column prop="message" label="说明" min-width="180" />
<el-table-column prop="collected_at" label="采集时间" width="160" />
</el-table>
</el-tab-pane>
</el-tabs>
</el-tab-pane>
<el-tab-pane label="持续规划" name="rolling"> <el-tab-pane label="持续规划" name="rolling">
<el-tabs v-model="rollingTab" type="border-card" style="margin-top:4px;"> <el-tabs v-model="rollingTab" type="border-card" style="margin-top:4px;">
<el-tab-pane label="实际vs预测对比" name="comparison"> <el-tab-pane label="实际vs预测对比" name="comparison">
@@ -367,10 +581,11 @@
<el-tag v-else type="info" size="small">已忽略</el-tag> <el-tag v-else type="info" size="small">已忽略</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="120" fixed="right"> <el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button v-if="row.status === 'open'" size="small" type="success" @click="resolveAlert(row)">标记解决</el-button> <el-button size="small" type="primary" plain @click="showAlertAttribution(row)">归因</el-button>
<el-button v-else-if="row.status === 'resolved'" size="small" @click="reopenAlert(row)">重新打开</el-button> <el-button v-if="row.status === 'open'" size="small" type="success" @click="resolveAlert(row)">解决</el-button>
<el-button v-else-if="row.status === 'resolved'" size="small" @click="reopenAlert(row)">重开</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -378,6 +593,80 @@
<p>暂无偏差预警点击执行偏差检查扫描当前期间</p> <p>暂无偏差预警点击执行偏差检查扫描当前期间</p>
</div> </div>
</el-tab-pane> </el-tab-pane>
<!-- 告警归因详情弹窗 (P1-) -->
<el-dialog v-model="showAttributionDialog" title="告警归因分析" width="720" append-to-body>
<template v-if="attributionDetail">
<el-descriptions :column="2" border size="small" style="margin-bottom:12px;">
<el-descriptions-item label="KPI">{{ attributionDetail.kpi_name }}</el-descriptions-item>
<el-descriptions-item label="期间">{{ attributionDetail.period }}</el-descriptions-item>
<el-descriptions-item label="预算">{{ formatNumber(attributionDetail.budget_value) }}</el-descriptions-item>
<el-descriptions-item label="实际">{{ formatNumber(attributionDetail.actual_value) }}</el-descriptions-item>
<el-descriptions-item label="偏差率">
<el-tag :type="Math.abs(attributionDetail.deviation_rate) > 50 ? 'danger' : 'warning'" size="small">
{{ attributionDetail.deviation_rate > 0 ? '+' : '' }}{{ attributionDetail.deviation_rate }}%
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="量价差">
<el-tag size="small" :type="attributionDetail.attribution?.variance_type === 'quantity_diff' ? 'warning' : attributionDetail.attribution?.variance_type === 'price_diff' ? 'danger' : 'info'">
{{ attributionDetail.attribution?.variance_type === 'quantity_diff' ? '量差' : attributionDetail.attribution?.variance_type === 'price_diff' ? '价差' : attributionDetail.attribution?.variance_type === 'mixed' ? '量价混合' : '--' }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
<!-- 趋势标识 -->
<div v-if="attributionDetail.attribution?.trend?.anomaly" style="margin-bottom:12px;">
<el-alert :title="attributionDetail.attribution.trend.message" type="warning" show-icon :closable="false">
<template #default>
<span style="font-size:12px;">{{ (attributionDetail.attribution.trend.periods || []).join(' → ') }}</span>
</template>
</el-alert>
</div>
<!-- 子KPI维度拆解 -->
<div v-if="attributionDetail.attribution?.dimensions?.length" style="margin-bottom:12px;">
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">📊 子KPI维度拆解量差方向</div>
<el-table :data="attributionDetail.attribution.dimensions" border stripe size="small">
<el-table-column prop="kpi_name" label="子KPI" min-width="120" />
<el-table-column prop="weight" label="权重" width="70"><template #default="{ row }">{{ row.weight }}%</template></el-table-column>
<el-table-column prop="budget_value" label="预算" width="90"><template #default="{ row }">{{ formatNumber(row.budget_value) }}</template></el-table-column>
<el-table-column prop="actual_value" label="实际" width="90"><template #default="{ row }">{{ formatNumber(row.actual_value) }}</template></el-table-column>
<el-table-column label="差异率" width="100">
<template #default="{ row }">
<span v-if="row.deviation_rate != null" :style="{ color: row.deviation_rate > 0 ? '#f56c6c' : row.deviation_rate < 0 ? '#67c23a' : '#999' }">
{{ row.deviation_rate > 0 ? '+' : '' }}{{ row.deviation_rate }}%
</span>
<span v-else style="color:#ccc;">--</span>
</template>
</el-table-column>
</el-table>
</div>
<!-- 科目明细拆解 -->
<div v-if="attributionDetail.attribution?.subjects?.length" style="margin-bottom:12px;">
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">💡 科目明细拆解价差方向</div>
<el-table :data="attributionDetail.attribution.subjects" border stripe size="small">
<el-table-column prop="subject_code" label="科目编码" width="90" />
<el-table-column prop="subject_name" label="科目" min-width="110" />
<el-table-column prop="amount_diff" label="发生额差" width="100"><template #default="{ row }">{{ formatNumber(row.amount_diff) }}</template></el-table-column>
<el-table-column prop="share_pct" label="占比" width="80"><template #default="{ row }">{{ row.share_pct }}%</template></el-table-column>
</el-table>
</div>
<!-- 场景建议 -->
<div v-if="attributionDetail.scenario" style="border:1px solid #e6a23c;border-radius:8px;padding:12px;background:#fdf6ec;">
<div style="font-weight:600;font-size:13px;color:#e6a23c;margin-bottom:6px;">🎯 场景建议{{ attributionDetail.scenario.title }}</div>
<div style="font-size:13px;color:#606266;white-space:pre-line;">{{ attributionDetail.scenario.description }}</div>
<div v-if="attributionDetail.scenario.action_template" style="margin-top:8px;font-size:12px;color:#606266;background:#fff;border-radius:6px;padding:8px 10px;white-space:pre-line;">
<strong>行动模板</strong>{{ attributionDetail.scenario.action_template }}
</div>
</div>
<div v-else style="border:1px solid #ebeef5;border-radius:8px;padding:12px;color:#999;font-size:13px;">
暂无匹配的场景建议模板
</div>
</template>
<template #footer><el-button @click="showAttributionDialog = false">关闭</el-button></template>
</el-dialog>
</el-tabs> </el-tabs>
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
@@ -437,6 +726,106 @@
</div> </div>
<template #footer><el-button @click="showActionDialog = false">关闭</el-button></template> <template #footer><el-button @click="showActionDialog = false">关闭</el-button></template>
</MyDialog> </MyDialog>
<!-- P2- 零基论证项编辑弹窗 -->
<MyDialog v-model="showZbbItemDialog" :title="zbbItemForm.id ? '编辑论证项' : '新增论证项'" :width="520">
<el-form :model="zbbItemForm" label-width="100px">
<el-form-item label="费用科目"><el-input v-model="zbbItemForm.item_name" placeholder="如: 招待费" /></el-form-item>
<el-form-item label="类别">
<el-select v-model="zbbItemForm.item_category" style="width:100%;">
<el-option label="固定" value="fixed" /><el-option label="变动" value="variable" /><el-option label="酌量" value="discretionary" />
</el-select>
</el-form-item>
<el-form-item label="基准值"><el-input-number v-model="zbbItemForm.base_value" :min="0" :precision="2" style="width:200px;" /></el-form-item>
<el-form-item label="论证理由"><el-input v-model="zbbItemForm.justification" type="textarea" :rows="2" placeholder="为何保留/削减/取消" /></el-form-item>
<el-form-item label="论证后金额"><el-input-number v-model="zbbItemForm.proposed_value" :min="0" :precision="2" style="width:200px;" /></el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="zbbItemForm.status"><el-radio value="draft">草稿</el-radio><el-radio value="approved">已批准</el-radio></el-radio-group>
</el-form-item>
</el-form>
<template #footer><el-button @click="showZbbItemDialog = false">取消</el-button><el-button type="primary" @click="saveZeroBasedItem">保存</el-button></template>
</MyDialog>
<!-- P2- 派生规则新建弹窗 -->
<MyDialog v-model="showDerivationRuleDialog" title="新建派生规则" :width="520">
<el-form :model="derivationRuleForm" label-width="100px">
<el-form-item label="目标KPI">
<el-select v-model="derivationRuleForm.kpi_id" filterable style="width:100%;">
<el-option v-for="k in zbbKpiOptions" :key="k.id" :label="`${k.kpi_code} - ${k.kpi_name}`" :value="k.id" />
</el-select>
</el-form-item>
<el-form-item label="规则类型">
<el-select v-model="derivationRuleForm.rule_type" style="width:100%;">
<el-option label="按来源KPI比例 (percentage_of)" value="percentage_of" />
<el-option label="增量 (incremental)" value="incremental" />
<el-option label="公式 (formula)" value="formula" />
</el-select>
</el-form-item>
<el-form-item v-if="derivationRuleForm.rule_type === 'percentage_of'" label="来源KPI">
<el-select v-model="derivationRuleForm.base_kpi_id" filterable style="width:100%;">
<el-option v-for="k in zbbKpiOptions" :key="k.id" :label="`${k.kpi_code} - ${k.kpi_name}`" :value="k.id" />
</el-select>
</el-form-item>
<el-form-item label="比例(%)"><el-input-number v-model="derivationRuleForm.rate_pct" :min="0" :max="100" :precision="2" style="width:200px;" /></el-form-item>
<el-form-item label="公式说明"><el-input v-model="derivationRuleForm.formula_text" placeholder="可读公式说明" /></el-form-item>
</el-form>
<template #footer><el-button @click="showDerivationRuleDialog = false">取消</el-button><el-button type="primary" @click="saveDerivationRule">保存</el-button></template>
</MyDialog>
<!-- P1- 取数映射新建弹窗 -->
<MyDialog v-model="showValueSourceDialog" title="新建取数映射" :width="560">
<el-form :model="valueSourceForm" label-width="100px">
<el-form-item label="目标KPI">
<el-select v-model="valueSourceForm.kpi_id" filterable style="width:100%;">
<el-option v-for="k in zbbKpiOptions" :key="k.id" :label="`${k.kpi_code} - ${k.kpi_name}`" :value="k.id" />
</el-select>
</el-form-item>
<el-form-item label="源头表">
<el-select v-model="valueSourceForm.source_table" style="width:100%;">
<el-option label="网银凭证明细 voucher_details" value="voucher_details" />
<el-option label="库存汇总 product_inventory" value="product_inventory" />
<el-option label="库存明细 product_inventory_detail" value="product_inventory_detail" />
<el-option label="收付款计划 cash_plans" value="cash_plans" />
</el-select>
</el-form-item>
<el-form-item label="金额字段">
<el-select v-model="valueSourceForm.source_field" style="width:100%;">
<el-option v-for="f in ['credit_amount','debit_amount','amount','qty','out_amount','in_amount','end_amount']" :key="f" :label="f" :value="f" />
</el-select>
</el-form-item>
<el-form-item label="聚合方式">
<el-select v-model="valueSourceForm.aggregate" style="width:120px;"><el-option v-for="a in ['sum','avg','count','max','min']" :key="a" :label="a" :value="a" /></el-select>
</el-form-item>
<el-form-item label="方向过滤">
<el-select v-model="valueSourceForm.direction" clearable placeholder="不限定" style="width:150px;">
<el-option label="贷方(收入)" value="credit" /><el-option label="借方(支出)" value="debit" />
</el-select>
</el-form-item>
<el-form-item label="科目过滤"><el-input v-model="valueSourceForm.subject_code" placeholder="如: 6601 销售费用" /></el-form-item>
<el-form-item label="期间字段">
<el-select v-model="valueSourceForm.period_field" style="width:150px;"><el-option label="period" value="period" /><el-option label="voucher_date" value="voucher_date" /></el-select>
</el-form-item>
<el-form-item label="单位倍率"><el-input-number v-model="valueSourceForm.unit_conversion" :min="0.0001" :precision="4" style="width:150px;" /><span style="font-size:12px;color:#999;margin-left:6px;">万元填 0.0001</span></el-form-item>
</el-form>
<template #footer><el-button @click="showValueSourceDialog = false">取消</el-button><el-button type="primary" @click="saveValueSource">保存</el-button></template>
</MyDialog>
<!-- P2- 分类规则新建弹窗 -->
<MyDialog v-model="showClassifyRuleDialog" title="新建现金流分类规则" :width="500">
<el-form :model="classifyRuleForm" label-width="110px">
<el-form-item label="KPI(精确)">
<el-select v-model="classifyRuleForm.kpi_id" filterable clearable placeholder="或使用关键词" style="width:100%;">
<el-option v-for="k in zbbKpiOptions" :key="k.id" :label="`${k.kpi_code} - ${k.kpi_name}`" :value="k.id" />
</el-select>
</el-form-item>
<el-form-item label="关键词(兜底)"><el-input v-model="classifyRuleForm.kpi_code_pattern" placeholder="如: 保证金" /></el-form-item>
<el-form-item label="收付类型">
<el-radio-group v-model="classifyRuleForm.plan_type"><el-radio value="receive">收</el-radio><el-radio value="pay"></el-radio></el-radio-group>
</el-form-item>
<el-form-item label="优先级"><el-input-number v-model="classifyRuleForm.priority" :min="1" :max="100" style="width:120px;" /></el-form-item>
</el-form>
<template #footer><el-button @click="showClassifyRuleDialog = false">取消</el-button><el-button type="primary" @click="saveClassifyRule">保存</el-button></template>
</MyDialog>
</div> </div>
</div> </div>
</template> </template>
@@ -954,12 +1343,339 @@ async function reopenAlert(row: any) {
} catch { ElMessage.error('操作失败') } } catch { ElMessage.error('操作失败') }
} }
//
// P1-
//
const showAttributionDialog = ref(false)
const attributionDetail = ref<any>(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<any[]>([])
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<number | null>(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<any[]>([])
const zbbTotalProposed = ref<number | null>(null)
const showZbbItemDialog = ref(false)
const zbbItemForm = ref<any>({})
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<any[]>([])
const showDerivationRuleDialog = ref(false)
const derivationRuleForm = ref<any>({})
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<number | null>(null)
const collectCoverageCards = ref<any[]>([])
const valueSources = ref<any[]>([])
const showValueSourceDialog = ref(false)
const valueSourceForm = ref<any>({})
const collectRunning = ref(false)
const collectLogStatus = ref('')
const collectLogs = ref<any[]>([])
const valueTagFilter = ref('auto_collect')
const valueTagRows = ref<any[]>([])
async function loadCollectData() {
await Promise.all([loadValueSources(), loadCollectCoverage(), loadValueTags(), loadCollectLogs()])
}
async function loadValueSources() {
try {
const params: any = { entity_id: getEntityId() }
if (collectKpiId.value) params.kpi_id = collectKpiId.value
const r: any = await budgetApi.valueSources(params)
valueSources.value = r.data || []
} catch { valueSources.value = [] }
}
async function loadCollectCoverage() {
try {
const r: any = await budgetApi.valueSourceCoverage({ entity_id: getEntityId() })
const c = r.data || r || {}
collectCoverageCards.value = [
{ label: '已配映射KPI', value: c.mapped_count ?? 0, color: '#409eff' },
{ label: '活跃KPI总数', value: c.total_kpis ?? 0, color: '#606266' },
{ label: '覆盖率', value: `${c.coverage_pct ?? 0}%`, color: '#67c23a' },
{ label: '未配置KPI', value: c.unmapped_count ?? 0, color: '#e6a23c' },
]
} catch { collectCoverageCards.value = [] }
}
async function loadValueTags() {
try {
const r: any = await kpiApi.list({ page_size: 100, entity_id: getEntityId() })
const d = r.data || r || []
const kpis = Array.isArray(d) ? d : (d.items || [])
const rows: any[] = []
for (const k of kpis) {
const vr: any = await kpiApi.values(k.id, { entity_id: getEntityId() }).catch(() => null)
const vals = vr?.data || []
for (const v of (Array.isArray(vals) ? vals : (vals.items || []))) {
rows.push({ kpi_code: k.kpi_code, kpi_name: k.kpi_name, period: v.period, actual_value: v.actual_value, source_type: v.source_type || 'manual', source_batch: v.source_batch || '', remark: v.remark || '' })
}
}
const recent = rows.filter((r: any) => r.actual_value != null).sort((a: any, b: any) => (b.period || '').localeCompare(a.period || '')).slice(0, 100)
valueTagRows.value = valueTagFilter.value === 'all' ? recent : recent.filter((r: any) => (valueTagFilter.value === 'auto_collect' ? r.source_type === 'auto_collect' : r.source_type !== 'auto_collect'))
} catch { valueTagRows.value = [] }
}
async function loadCollectLogs() {
try {
const params: any = { entity_id: getEntityId() }
if (collectLogStatus.value) params.status = collectLogStatus.value
const r: any = await budgetApi.valueCollectLogs(params)
collectLogs.value = r.data || []
} catch { collectLogs.value = [] }
}
function openValueSourceDialog() {
valueSourceForm.value = { kpi_id: null, source_table: 'voucher_details', source_field: 'credit_amount', aggregate: 'sum', direction: '', subject_code: '', period_field: 'period', unit_conversion: 1 }
showValueSourceDialog.value = true
}
async function saveValueSource() {
const f = valueSourceForm.value
if (!f.kpi_id) { ElMessage.warning('请选择KPI'); return }
const filter: any = {}
if (f.direction) filter.direction = f.direction
if (f.subject_code) filter.subject_code = f.subject_code
try {
await budgetApi.createValueSource({
kpi_id: f.kpi_id,
source_table: f.source_table,
source_field: f.source_field,
aggregate: f.aggregate,
filter_rule: Object.keys(filter).length ? filter : null,
period_field: f.period_field,
unit_conversion: f.unit_conversion,
entity_id: getEntityId(),
})
ElMessage.success('映射已创建')
showValueSourceDialog.value = false
loadValueSources()
loadCollectCoverage()
} catch (e: any) { ElMessage.error(e?.response?.data?.detail || '创建失败') }
}
async function deleteValueSource(row: any) {
try {
await ElMessageBox.confirm(`确认删除映射(${row.kpi_name}${row.source_table})?`, '确认')
await budgetApi.deleteValueSource(row.id)
ElMessage.success('已删除')
loadValueSources()
loadCollectCoverage()
} catch { }
}
async function toggleValueSource(row: any) {
try {
await budgetApi.updateValueSource(row.id, { status: row.status === 'active' ? 'inactive' : 'active' })
row.status = row.status === 'active' ? 'inactive' : 'active'
} catch { ElMessage.error('操作失败') }
}
async function testValueSource(row: any) {
try {
const r: any = await budgetApi.testValueSource({
kpi_id: row.kpi_id,
source_table: row.source_table,
source_field: row.source_field,
aggregate: row.aggregate,
filter_rule: row.filter_rule,
period_field: row.period_field,
unit_conversion: row.unit_conversion,
period: zbbPeriod.value,
})
const d = r.data || r
if (d.success) ElMessage.success(`试跑值: ${formatNumber(d.value)} (${d.message || ''})`)
else ElMessage.error(`试跑失败: ${d.message || ''}`)
} catch { ElMessage.error('试跑失败') }
}
async function runValueCollect() {
collectRunning.value = true
try {
const r: any = await budgetApi.runValueCollect({ period: zbbPeriod.value, entity_id: getEntityId() })
const d = r.data || r
ElMessage.success(`采集完成: 成功${d.collected ?? 0} 失败${d.failed ?? 0}`)
loadCollectData()
} catch { ElMessage.error('采集失败') }
collectRunning.value = false
}
//
// P2- +
//
const cashClassifyRules = ref<any[]>([])
const showClassifyRuleDialog = ref(false)
const classifyRuleForm = ref<any>({})
const unclassifiedRows = ref<any[]>([])
const unclassifiedCount = ref(0)
async function loadCashClassify() {
try {
const r: any = await budgetApi.cashClassifyRules({ entity_id: getEntityId() })
cashClassifyRules.value = r.data || []
} catch { cashClassifyRules.value = [] }
try {
const r: any = await budgetApi.cashUnclassified({ status: 'pending', entity_id: getEntityId() })
unclassifiedRows.value = r.data || []
unclassifiedCount.value = (r.data || []).length
} catch { unclassifiedRows.value = []; unclassifiedCount.value = 0 }
}
function openClassifyRuleDialog() {
classifyRuleForm.value = { kpi_id: null, kpi_code_pattern: '', plan_type: 'receive', priority: 10 }
showClassifyRuleDialog.value = true
}
async function saveClassifyRule() {
const f = classifyRuleForm.value
if (!f.kpi_id && !f.kpi_code_pattern) { ElMessage.warning('请选择KPI或填写关键词'); return }
try {
await budgetApi.createCashClassifyRule({
kpi_id: f.kpi_id || null,
kpi_code_pattern: f.kpi_code_pattern || null,
plan_type: f.plan_type,
priority: f.priority || 10,
entity_id: getEntityId(),
})
ElMessage.success('规则已创建')
showClassifyRuleDialog.value = false
loadCashClassify()
} catch (e: any) { ElMessage.error(e?.response?.data?.detail || '创建失败') }
}
async function deleteClassifyRule(row: any) {
try {
await ElMessageBox.confirm('确认删除该分类规则?', '确认')
await budgetApi.deleteCashClassifyRule(row.id)
ElMessage.success('已删除')
loadCashClassify()
} catch { }
}
async function classifyUnclassified(row: any, planType: string) {
try {
const r: any = await budgetApi.classifyCashUnclassified(row.id, { plan_type: planType })
ElMessage.success(r.message || `已归类为${planType === 'receive' ? '收' : '付'}`)
loadCashClassify()
} catch (e: any) { ElMessage.error(e?.response?.data?.detail || '归类失败') }
}
async function ignoreUnclassified(row: any) {
try {
await budgetApi.ignoreCashUnclassified(row.id)
ElMessage.success('已忽略')
loadCashClassify()
} catch { ElMessage.error('操作失败') }
}
watch(activeTab, (tab) => { watch(activeTab, (tab) => {
if (tab === 'decompose') loadBudget() if (tab === 'decompose') loadBudget()
else if (tab === 'strategy') loadStrategyBudget() else if (tab === 'strategy') loadStrategyBudget()
else if (tab === 'versions') loadVersions() else if (tab === 'versions') loadVersions()
else if (tab === 'execution') loadExecutionReport() else if (tab === 'execution') { loadExecutionReport(); loadCashClassify() }
else if (tab === 'method') refreshMethodComparison() else if (tab === 'method') { refreshMethodComparison(); loadDerivationRules() }
else if (tab === 'collect') loadCollectData()
else if (tab === 'driver') { else if (tab === 'driver') {
// DriverFactorBudget handles its own loading on mount // DriverFactorBudget handles its own loading on mount
} }
@@ -973,6 +1689,7 @@ onMounted(async () => {
loadBudget() loadBudget()
loadBudgetConfig() loadBudgetConfig()
loadComparisonKpiOptions() loadComparisonKpiOptions()
loadZbbKpiOptions()
}) })
</script> </script>