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(
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth),
):
"""
检查实际vs预测偏差,当偏差超过20%时自动生成预警
检查实际vs预测偏差,当偏差超过阈值时自动生成预警
(2026-08-28 P1-③/P2-⑤: 统一走 build_deviation_alert,写入归因JSON+场景建议)
"""
from app.models import KPIValue, BudgetDeviationAlert
from sqlalchemy import func
from app.utils.deviation_engine import build_deviation_alert
threshold = data.get("threshold", 20) # 默认20%
period = data.get("period") or datetime.now().strftime("%Y-%m")
auto_resolve = data.get("auto_resolve", True) # 是否自动关闭已解决的预警
# 查询该期间有预算的KPI
# 查询该期间有预算的KPI(多租户隔离 entity_id
budget_plans = db.query(BudgetPlan).filter(
BudgetPlan.entity_id == entity_id,
BudgetPlan.period == period,
BudgetPlan.status == "active",
).all()
@@ -832,39 +835,25 @@ def check_budget_deviation(
alerts = []
for bp in budget_plans:
# 查询实际值
actual = db.query(KPIValue).filter(
KPIValue.kpi_id == bp.kpi_id,
KPIValue.period == period,
KPIValue.actual_value.isnot(None),
kpi_obj = db.query(KPIDefinition).filter(
KPIDefinition.id == bp.kpi_id,
KPIDefinition.entity_id == entity_id,
).first()
if not actual or actual.actual_value is None:
if not kpi_obj:
continue
budget_val = bp.budget_value
actual_val = actual.actual_value
if budget_val == 0:
# 统一告警逻辑(方向性/阈值/归因/场景建议)
result = build_deviation_alert(db, kpi_obj, period, entity_id=entity_id, min_rate=threshold)
if not result["triggered"]:
continue
# 计算偏差率
deviation_rate = round((actual_val - budget_val) / budget_val * 100, 2)
# 只有偏差超过阈值才生成预警
if abs(deviation_rate) <= threshold:
continue
deviation_value = round(actual_val - budget_val, 2)
# 判断预警等级
alert_level = "critical" if abs(deviation_rate) > 50 else "warning"
# 生成建议
if deviation_rate > 0:
suggestion = f"实际值超出预算 {deviation_rate}%,建议核查超支原因并采取控制措施"
else:
suggestion = f"实际值低于预算 {abs(deviation_rate)}%,建议分析是否预算过高或业务量未达预期"
deviation = result["deviation"]
budget_val = deviation.get("budget_value")
actual_val = deviation.get("actual_value")
deviation_rate = deviation.get("deviation_rate")
deviation_value = deviation.get("deviation_amount")
if deviation_value is None:
deviation_value = round((actual_val or 0) - (budget_val or 0), 2)
# 检查是否已存在相同的预警
existing_alert = db.query(BudgetDeviationAlert).filter(
@@ -874,6 +863,12 @@ def check_budget_deviation(
).first()
if existing_alert:
# 已存在open告警: 补齐归因(原open告警可能无归因, 幂等补写)
if existing_alert.attribution is None and result["attribution"]:
existing_alert.attribution = result["attribution"]
existing_alert.alert_type = result["alert_type"]
existing_alert.scenario_id = result["scenario_id"]
db.flush()
continue
alert = BudgetDeviationAlert(
@@ -883,14 +878,16 @@ def check_budget_deviation(
actual_value=actual_val,
deviation_rate=deviation_rate,
deviation_value=deviation_value,
alert_level=alert_level,
alert_level=result["level"],
status="open",
suggestion=suggestion,
suggestion=result["suggestion"],
alert_type=result["alert_type"],
attribution=result["attribution"],
scenario_id=result["scenario_id"],
)
db.add(alert)
alerts_generated += 1
kpi_obj = db.query(KPIDefinition).filter(KPIDefinition.id == bp.kpi_id).first()
alerts.append({
"kpi_id": bp.kpi_id,
"kpi_code": kpi_obj.kpi_code if kpi_obj else "",
@@ -900,8 +897,10 @@ def check_budget_deviation(
"actual_value": actual_val,
"deviation_rate": deviation_rate,
"deviation_value": deviation_value,
"alert_level": alert_level,
"suggestion": suggestion,
"alert_level": result["level"],
"suggestion": result["suggestion"],
"alert_type": result["alert_type"],
"attribution": result["attribution"],
})
db.commit()
@@ -922,10 +921,11 @@ def list_deviation_alerts(
alert_level: Optional[str] = Query(None),
status: Optional[str] = Query(None),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""查询预算偏差预警记录"""
"""查询预算偏差预警记录 (2026-08-28: 列表新增 alert_type/attribution/scenario_identity_id隔离)"""
from app.models import BudgetDeviationAlert
query = db.query(BudgetDeviationAlert)
query = db.query(BudgetDeviationAlert).filter(BudgetDeviationAlert.entity_id == entity_id)
if kpi_id:
query = query.filter(BudgetDeviationAlert.kpi_id == kpi_id)
if period:
@@ -952,11 +952,120 @@ def list_deviation_alerts(
"alert_level": a.alert_level,
"status": a.status,
"suggestion": a.suggestion,
"alert_type": a.alert_type,
"attribution": a.attribution,
"scenario_id": a.scenario_id,
"created_at": a.created_at.isoformat() if a.created_at else None,
})
return {"data": result, "total": len(result)}
@router.get("/deviation-alerts/{alert_id}/attribution")
def get_deviation_alert_attribution(
alert_id: int,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""告警归因详情 — 告警 + 归因JSON + 场景建议(联查 scenario_suggestions(P1-③ 2026-08-28)"""
from app.models import BudgetDeviationAlert, ScenarioSuggestion
from app.utils.alert_attribution import match_scenario
alert = db.query(BudgetDeviationAlert).filter(
BudgetDeviationAlert.id == alert_id,
BudgetDeviationAlert.entity_id == entity_id,
).first()
if not alert:
raise HTTPException(404, "预警记录不存在")
kpi_obj = db.query(KPIDefinition).filter(KPIDefinition.id == alert.kpi_id).first()
# 归因(若旧告警无归因字段,现场组装一次)
attribution = alert.attribution
if attribution is None:
from app.utils.alert_attribution import build_attribution
try:
attribution, inferred_type = build_attribution(db, alert.kpi_id, alert.period, alert.alert_type)
alert.attribution = attribution
if alert.alert_type is None:
alert.alert_type = inferred_type
db.commit()
except Exception:
attribution = {}
scenario = None
if alert.scenario_id or alert.alert_type:
scenario = match_scenario(db, alert.alert_type)
return {
"id": alert.id,
"kpi_id": alert.kpi_id,
"kpi_code": kpi_obj.kpi_code if kpi_obj else "",
"kpi_name": kpi_obj.kpi_name if kpi_obj else "",
"period": alert.period,
"budget_value": alert.budget_value,
"actual_value": alert.actual_value,
"deviation_rate": alert.deviation_rate,
"deviation_value": alert.deviation_value,
"alert_level": alert.alert_level,
"status": alert.status,
"suggestion": alert.suggestion,
"alert_type": alert.alert_type,
"attribution": attribution or {},
"scenario": scenario,
"created_at": alert.created_at.isoformat() if alert.created_at else None,
}
@router.get("/alert-direction")
def get_alert_direction(
db: Session = Depends(get_db),
):
"""越高越好型KPI方向配置 (P2-⑤ 2026-08-28: system_configs 可维护)"""
from app.utils.deviation_engine import get_higher_better_codes, CONFIG_KEY_HIGHER_BETTER
from app.models import SystemConfig
cfg = db.query(SystemConfig).filter(
SystemConfig.config_key == CONFIG_KEY_HIGHER_BETTER
).first()
codes = get_higher_better_codes(db)
return {
"config_key": CONFIG_KEY_HIGHER_BETTER,
"codes": codes,
"is_configured": bool(cfg and cfg.config_value),
}
@router.put("/alert-direction")
def update_alert_direction(
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
):
"""维护越高越好型KPI方向配置 (P2-⑤) body: {codes: ["SALES_TOTAL", ...]}"""
import json as _json
from app.utils.deviation_engine import CONFIG_KEY_HIGHER_BETTER
from app.models import SystemConfig
codes = data.get("codes")
if not isinstance(codes, list):
raise HTTPException(400, "codes 必须是非空数组")
codes = [str(c) for c in codes]
cfg = db.query(SystemConfig).filter(
SystemConfig.config_key == CONFIG_KEY_HIGHER_BETTER
).first()
if cfg:
cfg.config_value = _json.dumps(codes, ensure_ascii=False)
else:
db.add(SystemConfig(
config_key=CONFIG_KEY_HIGHER_BETTER,
config_value=_json.dumps(codes, ensure_ascii=False),
description="越高越好型KPI编码列表(实际低于预算才告警)",
))
db.commit()
return {"success": True, "config_key": CONFIG_KEY_HIGHER_BETTER, "codes": codes}
@router.put("/deviation-alerts/{alert_id}")
def update_deviation_alert(
alert_id: int,
@@ -979,11 +1088,18 @@ def update_deviation_alert(
# ──────────────────────────────────────────────
@router.post("/method-comparison")
def budget_method_comparison(data: dict):
def budget_method_comparison(
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""
预算方法三选一对比计算
接收: { entity: "hanke", last_month_budget: 91, current_revenue: 122, ... }
返回三种方法的计算结果
(2026-08-28 P2-①: zero_based 优先读逐项论证项 budget_zero_based_items,
传入 zero_based_kpi_id+zero_based_period 且有论证项 → 逐项求和 is_demo=false;
无论证项 → fallback 旧公式 is_demo=true)
"""
entity = data.get("entity", "hanke")
last_month_budget = data.get("last_month_budget", 91) # 上月预算(万)
@@ -1001,22 +1117,44 @@ def budget_method_comparison(data: dict):
incremental_result = round(last_month_budget * (1 + increment_rate), 1)
incremental_detail = f"上月{last_month_budget}× (1+{increment_rate*100:.0f}%) = {incremental_result}"
# 2. 零基预算: 每项从零论证
zbb_entertainment = round(fixed_costs.get("entertainment", 16) / 2, 1) # 砍半
zbb_misc = round(fixed_costs.get("misc", 12) * 0.7, 1) # 压缩30%
zbb_total = round(
fixed_costs.get("rent", 15)
+ fixed_costs.get("labor", 40)
+ zbb_entertainment
+ zbb_misc,
1,
)
zbb_savings = round(last_month_budget - zbb_total, 1)
zbb_detail = (
f"房租{fixed_costs.get('rent', 15)}万(固定)+人工{fixed_costs.get('labor', 40)}万(砍不掉)"
f"+招待{zbb_entertainment}万(砍半)+杂项{zbb_misc}万(压缩)"
f"={zbb_total}万 ← 省{zbb_savings}"
)
# 2. 零基预算: 优先逐项论证(P2-① 真零基)
zbb_kpi_id = data.get("zero_based_kpi_id")
zbb_period = data.get("zero_based_period")
zbb_is_demo = True
zbb_items = []
if zbb_kpi_id and zbb_period:
from app.models import BudgetZeroBasedItem
zbb_items = db.query(BudgetZeroBasedItem).filter(
BudgetZeroBasedItem.entity_id == entity_id,
BudgetZeroBasedItem.kpi_id == zbb_kpi_id,
BudgetZeroBasedItem.period == zbb_period,
).all()
if zbb_items:
# 真零基: 逐项求和(仅 approved+draft 都算,draft为未定稿)
zbb_total = round(sum(i.proposed_value for i in zbb_items), 1)
zbb_is_demo = False
zbb_detail = "零基逐项论证: " + " + ".join(
f"{i.item_name}{i.proposed_value}" for i in zbb_items
) + f" = {zbb_total}"
zbb_savings = round(last_month_budget - zbb_total, 1)
else:
# fallback 旧演示公式(标注 is_demo
zbb_entertainment = round(fixed_costs.get("entertainment", 16) / 2, 1) # 砍半
zbb_misc = round(fixed_costs.get("misc", 12) * 0.7, 1) # 压缩30%
zbb_total = round(
fixed_costs.get("rent", 15)
+ fixed_costs.get("labor", 40)
+ zbb_entertainment
+ zbb_misc,
1,
)
zbb_savings = round(last_month_budget - zbb_total, 1)
zbb_detail = (
f"房租{fixed_costs.get('rent', 15)}万(固定)+人工{fixed_costs.get('labor', 40)}万(砍不掉)"
f"+招待{zbb_entertainment}万(砍半)+杂项{zbb_misc}万(压缩)"
f"={zbb_total}万 ← 省{zbb_savings}"
)
# 3. 弹性预算: 根据收入水平动态调整
flexible_fixed = round(fixed_costs.get("rent", 15) + fixed_costs.get("labor", 40) * 0.5, 1)
@@ -1053,6 +1191,8 @@ def budget_method_comparison(data: dict):
"result_value": zbb_total,
"savings": zbb_savings,
"detail": zbb_detail,
"is_demo": zbb_is_demo,
"item_count": len(zbb_items),
"pros": "最合理",
"cons": "耗时",
"is_recommended": True,
@@ -1078,16 +1218,21 @@ def budget_method_comparison(data: dict):
def apply_budget_method(
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth),
):
"""应用所选预算编制方法到预算计划(2026-08-26:三法并存,按用户场景选择后落地)
接收: { method: 'incremental'|'zero_based'|'flexible', year: 2026, entity: 'hanke', ... }
说明: 方法计算结果 → 写入/更新预算计划(version标注方法名,便于追溯)
(2026-08-28 P2-②: KPI派生规则可配置 budget_derivation_rules,
percentage_of → base_kpi实际值×rate; incremental → 上月×(1+rate);
无规则 fallback 默认比例(净利2%/费用率22%/毛利18%), 响应带 rule_source)
"""
from app.models import BudgetDerivationRule
method = data.get("method", "zero_based")
year = data.get("year", datetime.now().year)
entity = data.get("entity", "hanke")
entity_id = data.get("entity_id", 1)
# 复用method-comparison计算(获得三法结果)
comp = budget_method_comparison({
@@ -1099,7 +1244,9 @@ def apply_budget_method(
}),
"variable_cost_rate": data.get("variable_cost_rate", 0.4862),
"increment_rate": data.get("increment_rate", 0.05),
})
"zero_based_kpi_id": data.get("zero_based_kpi_id"),
"zero_based_period": data.get("zero_based_period"),
}, db=db, entity_id=entity_id)
# 找所选方法的结果
selected = None
@@ -1111,7 +1258,6 @@ def apply_budget_method(
raise HTTPException(400, "未知预算方法: " + method)
# 找到该年的核心KPI(营业收入/净利润/费用率等)
# 取该年已有预算的KPI,或默认核心4个
kpis = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.status == "active",
@@ -1120,11 +1266,18 @@ def apply_budget_method(
if not kpis:
raise HTTPException(400, "未找到可应用的KPI")
# 方法结果解释为收入预算(核心KPI应用
# incremental/flexible/zero_based 的 result_value 均为"预算总额(万)"
# 写入F_REVENUE年度预算(period=YYYY-00 表示年度)
# 加载派生规则(P2-②
rules = db.query(BudgetDerivationRule).filter(
BudgetDerivationRule.entity_id == entity_id,
BudgetDerivationRule.status == "active",
).all()
rules_by_kpi = {r.kpi_id: r for r in rules}
# 版本
version = f"{method}-{datetime.now().strftime('%Y%m%d')}"
applied = []
used_configured = False
for kpi in kpis:
period = f"{year}-00"
# 删除旧版本的同KPI年度预算
@@ -1134,15 +1287,67 @@ def apply_budget_method(
BudgetPlan.version.like(f"{method}-%"),
).delete()
# 各KPI的应用值(简化:收入用方法结果,其他按比例
# 各KPI的应用值:收入用方法结果,其他优先派生规则(P2-②
if kpi.kpi_code == "F_REVENUE":
budget_val = selected["result_value"]
elif kpi.kpi_code == "F_NET_PROFIT":
budget_val = round(selected["result_value"] * 0.02, 1) # 净利率约2%
elif kpi.kpi_code == "F_COST_RATIO":
budget_val = round(selected["result_value"] * 0.22, 1) # 费用率约22%
else: # F_GROSS_MARGIN
budget_val = round(selected["result_value"] * 0.18, 1) # 毛利率约18%
rule_source = "default"
formula_note = "方法结果"
else:
rule = rules_by_kpi.get(kpi.id)
if rule and rule.params:
rate = float(rule.params.get("rate", 0.02))
if rule.rule_type == "percentage_of" and rule.base_kpi_id:
# 来源KPI实际值 × 比例
base_val = None
base_actual = db.query(KPIValue).filter(
KPIValue.kpi_id == rule.base_kpi_id,
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.calculated_at.desc()).first()
if base_actual:
base_val = base_actual.actual_value
if base_val is not None:
budget_val = round(base_val * rate, 1)
rule_source = "configured"
formula_note = f"派生: 来源KPI实际值{base_val} × {rate}"
else:
budget_val = round(selected["result_value"] * rate, 1)
rule_source = "configured_fallback"
formula_note = f"派生规则无来源实际值, 按方法结果×{rate}"
elif rule.rule_type == "incremental":
# 上月预算 × (1+rate)
prev_period = f"{year-1}-00"
prev_plan = db.query(BudgetPlan).filter(
BudgetPlan.entity_id == entity_id,
BudgetPlan.kpi_id == kpi.id,
BudgetPlan.period == prev_period,
BudgetPlan.status == "active",
).order_by(BudgetPlan.updated_at.desc()).first()
if prev_plan and prev_plan.budget_value is not None:
budget_val = round(prev_plan.budget_value * (1 + rate), 1)
rule_source = "configured"
formula_note = f"派生: 上年预算{prev_plan.budget_value} × (1+{rate})"
else:
budget_val = round(selected["result_value"] * rate, 1)
rule_source = "configured_fallback"
formula_note = f"派生规则无上年预算, 按方法结果×{rate}"
else:
# formula 类型: 暂按方法结果×rate 兜底
budget_val = round(selected["result_value"] * rate, 1)
rule_source = "configured"
formula_note = f"派生规则(formula): 方法结果×{rate}"
else:
# fallback 默认比例
if kpi.kpi_code == "F_NET_PROFIT":
budget_val = round(selected["result_value"] * 0.02, 1) # 净利率约2%
elif kpi.kpi_code == "F_COST_RATIO":
budget_val = round(selected["result_value"] * 0.22, 1) # 费用率约22%
else: # F_GROSS_MARGIN
budget_val = round(selected["result_value"] * 0.18, 1) # 毛利率约18%
rule_source = "default"
formula_note = "默认比例"
if rule_source in ("configured", "configured_fallback"):
used_configured = True
bp = BudgetPlan(
entity_id=entity_id,
@@ -1153,10 +1358,11 @@ def apply_budget_method(
budget_month=0,
version=version,
status="active",
remark=f"{selected['name']}应用({selected['result_value']}万) 来源{method}",
remark=f"{selected['name']}应用({selected['result_value']}万) 来源{method} | {formula_note}",
calc_logic=formula_note,
)
db.add(bp)
applied.append({"kpi_code": kpi.kpi_code, "budget_value": budget_val})
applied.append({"kpi_code": kpi.kpi_code, "budget_value": budget_val, "rule_source": rule_source})
db.commit()
return {
@@ -1167,7 +1373,8 @@ def apply_budget_method(
"total_budget": selected["result_value"],
"detail": selected["detail"],
"applied": applied,
"note": "选择哪种方法取决于场景:增量=稳定业务快速编;零基=成本优化专项;弹性=收入波动大。方法结果写入年度预算(period=YYYY-00),可在版本管理中查看。",
"rule_source": "configured" if used_configured else "default",
"note": "选择哪种方法取决于场景:增量=稳定业务快速编;零基=成本优化专项;弹性=收入波动大。方法结果写入年度预算(period=YYYY-00),可在版本管理中查看。KPI派生规则可在「派生规则配置」中维护(P2-②)。",
}
@@ -1338,35 +1545,77 @@ def sync_cash_plans(
db: Session = Depends(get_db),
):
"""预算→现金流计划联动: 按预算KPI生成/更新收付款计划(修复断点#1)
(2026-08-28 P2-⑥: 分类规则表优先, 未命中进待分类队列不再静默跳过)
收入类KPI(营收/回款/新客) → receive
成本类KPI(费用/厂补/采购) → pay
分类来源: ①cash_plan_classify_rules规则表(精确KPI→关键词) ②默认关键词兜底 ③待分类队列
upsert: 同KPI+同日期+同类型 更新不重复
"""
from app.models import CashPlan
from app.models import CashPlan, CashPlanClassifyRule, CashPlanUnclassified
from datetime import datetime
# 默认关键词兜底(兼容存量,规则表优先)
RECEIVE_KEYS = ("营收", "收入", "销售", "回款", "新客", "收款", "净利润", "毛利")
PAY_KEYS = ("费用", "成本", "厂补", "采购", "返利", "应付", "损耗", "投入")
# 加载分类规则表(P2-⑥)
rules = db.query(CashPlanClassifyRule).filter(
CashPlanClassifyRule.entity_id == entity_id,
CashPlanClassifyRule.status == "active",
).order_by(CashPlanClassifyRule.priority.asc()).all()
kpi_rules = {r.kpi_id: r for r in rules if r.kpi_id}
pattern_rules = [r for r in rules if not r.kpi_id and r.kpi_code_pattern]
def classify_plan_type(kpi) -> Optional[str]:
"""返回 receive/pay/None(未分类)"""
# ① 精确KPI匹配(优先)
if kpi.id in kpi_rules:
return kpi_rules[kpi.id].plan_type
# ② 关键词/编码模式匹配(规则表)
name = (kpi.kpi_name or "") + (kpi.kpi_code or "")
for r in pattern_rules:
if r.kpi_code_pattern and r.kpi_code_pattern in name:
return r.plan_type
# ③ 默认关键词兜底(兼容存量行为)
if any(k in name for k in RECEIVE_KEYS):
return "receive"
if any(k in name for k in PAY_KEYS):
return "pay"
return None
budgets = db.query(BudgetPlan).filter(
BudgetPlan.entity_id == entity_id, BudgetPlan.status == "active"
).all()
kpi_ids = {b.kpi_id for b in budgets}
kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(kpi_ids)).all()} if kpi_ids else {}
created, updated = 0, 0
created, updated, unclassified_count = 0, 0, 0
for b in budgets:
kpi = kpis.get(b.kpi_id)
if not kpi:
continue
name = (kpi.kpi_name or "") + (kpi.kpi_code or "")
if any(k in name for k in RECEIVE_KEYS):
plan_type = "receive"
elif any(k in name for k in PAY_KEYS):
plan_type = "pay"
else:
continue # 无法判类别的KPI跳过
plan_type = classify_plan_type(kpi)
if plan_type is None:
# 无法判类别 → 写入待分类队列(不静默跳过,P2-⑥)
existing_un = db.query(CashPlanUnclassified).filter(
CashPlanUnclassified.entity_id == entity_id,
CashPlanUnclassified.kpi_id == b.kpi_id,
CashPlanUnclassified.period == b.period,
CashPlanUnclassified.status == "pending",
).first()
if not existing_un:
db.add(CashPlanUnclassified(
entity_id=entity_id,
kpi_id=b.kpi_id,
kpi_name=kpi.kpi_name or kpi.kpi_code,
period=b.period,
budget_value=b.budget_value,
reason="未匹配任何分类规则",
status="pending",
))
unclassified_count += 1
continue
year, month = b.budget_year or 2026, b.budget_month or 1
try:
@@ -1395,4 +1644,8 @@ def sync_cash_plans(
))
created += 1
db.commit()
return {"message": f"现金流联动完成: 新建{created}条, 更新{updated}", "created": created, "updated": updated}
return {
"message": f"现金流联动完成: 新建{created}条, 更新{updated}条, 待分类{unclassified_count}",
"created": created, "updated": updated,
"unclassified_count": unclassified_count,
}
+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)}
@router.get("/{kpi_id}/values")
def list_kpi_values(
kpi_id: int,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user: User = Depends(require_auth),
):
"""查询KPI实际值列表(含source_type标记,供归集标签页展示)"""
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
if kpi.entity_id != entity_id:
raise HTTPException(404, "KPI不存在")
vals = db.query(KPIValue).filter(KPIValue.kpi_id == kpi_id).order_by(KPIValue.period.desc()).all()
return {
"data": [{
"id": v.id,
"period": v.period,
"actual_value": v.actual_value,
"source_type": v.source_type or "manual",
"source_batch": v.source_batch or "",
"data_status": v.data_status,
"remark": v.remark or "",
} for v in vals]
}
@router.get("/{kpi_id}")
def get_kpi(kpi_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
+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:
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):
"""插入5层级组织示例数据"""
+5 -1
View File
@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from dotenv import load_dotenv
from app.database import init_db
from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification
from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification, value_sources, zero_based, derivation_rules, cash_classify
from app.utils.cache import clear_all as clear_cache, delete as delete_cache
from scripts.erp_sync import run_sync as run_erp_sync
from app.auth_middleware import require_auth
@@ -54,6 +54,10 @@ app.include_router(org.router)
app.include_router(objectives.router)
app.include_router(versions.router)
app.include_router(budget.router)
app.include_router(value_sources.router)
app.include_router(zero_based.router)
app.include_router(derivation_rules.router)
app.include_router(cash_classify.router)
app.include_router(cost.router)
app.include_router(predict.router)
app.include_router(growth_quality.router)
+106
View File
@@ -524,6 +524,9 @@ class BudgetDeviationAlert(Base):
alert_level = Column(String(20), default="warning", comment="warning/critical")
status = Column(String(20), default="open", comment="open/resolved/ignored")
suggestion = Column(String(500), nullable=True, comment="处理建议")
alert_type = Column(String(30), nullable=True, comment="归因场景: cost_high/revenue_drop/cash_low/cash_critical (2026-08-28 告警归因P1-③)")
attribution = Column(JSON, nullable=True, comment="归因JSON: 子KPI拆解+科目拆解+量价差+趋势 (2026-08-28)")
scenario_id = Column(Integer, nullable=True, comment="FK scenario_suggestions.id 场景建议 (2026-08-28)")
created_at = Column(DateTime, server_default=func.now())
@@ -805,3 +808,106 @@ class KR(Base):
monthly_milestones = Column(JSON, nullable=True, comment="月度里程碑: [{\"month\":\"2026-07\",\"label\":\"...\",\"status\":\"completed\"}]")
sort_order = Column(Integer, default=0, comment="排序")
created_at = Column(DateTime, server_default=func.now())
# ============================================================
# 预算系统技术改进 (2026-08-28 yanxue-budget-tech-improve)
# ① kpi_value_sources/kpi_value_collect_logs: 实际值自动归集 P1-④
# ② budget_zero_based_items: 真零基逐项论证 P2-①
# ③ budget_derivation_rules: 派生规则可配置 P2-②
# ④ cash_plan_classify_rules/cash_plan_unclassified: 现金流分类规则 P2-⑥
# ============================================================
class KPIValueSource(Base):
"""KPI实际值取数映射 — 自动归集源头 (P1-④ 2026-08-28)"""
__tablename__ = "kpi_value_sources"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, nullable=False, comment="企业ID(多租户隔离)")
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="目标KPI")
source_table = Column(String(50), nullable=False, comment="源头表: voucher_details/product_inventory/product_inventory_detail/cash_plans")
source_field = Column(String(50), nullable=False, comment="金额字段: credit_amount/debit_amount/amount/qty")
aggregate = Column(String(10), default="sum", comment="sum/avg/count/max/min")
filter_rule = Column(JSON, nullable=True, comment="过滤: {\"subject_code\":\"6601\",\"direction\":\"credit\"}")
period_field = Column(String(50), default="period", comment="期间字段: period/voucher_date")
unit_conversion = Column(Float, default=1, comment="单位倍率(元→万元/10000)")
status = Column(String(20), default="active", comment="active/inactive")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
__table_args__ = (UniqueConstraint("entity_id", "kpi_id", "source_table", name="uk_source"),)
class KPIValueCollectLog(Base):
"""实际值采集日志 — 每次自动归集记录 (P1-④ 2026-08-28)"""
__tablename__ = "kpi_value_collect_logs"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, nullable=False, comment="企业ID")
kpi_id = Column(Integer, nullable=False, comment="KPI ID")
period = Column(String(20), nullable=False, comment="期间 YYYY-MM")
source_table = Column(String(50), nullable=False, comment="源头表")
collected_value = Column(Float, nullable=True, comment="采集到的值")
status = Column(String(20), default="success", comment="success/failed")
message = Column(String(500), nullable=True, comment="说明/错误信息")
collected_at = Column(DateTime, server_default=func.now())
class BudgetZeroBasedItem(Base):
"""零基预算逐项论证项 (P2-① 2026-08-28)"""
__tablename__ = "budget_zero_based_items"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, nullable=False, comment="企业ID(多租户隔离)")
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="关联KPI")
period = Column(String(20), nullable=False, comment="期间 YYYY-MM")
item_name = Column(String(200), nullable=False, comment="费用科目名")
item_category = Column(String(20), default="discretionary", comment="fixed/variable/discretionary")
base_value = Column(Float, nullable=False, comment="基准值(上年/上月实际)")
justification = Column(Text, nullable=True, comment="逐项论证理由(为何保留/削减/取消)")
proposed_value = Column(Float, nullable=False, comment="论证后金额")
status = Column(String(20), default="draft", comment="draft/approved")
created_by = Column(String(100), nullable=True, comment="创建人")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class BudgetDerivationRule(Base):
"""KPI派生规则 — apply-method 可配置派生 (P2-② 2026-08-28)"""
__tablename__ = "budget_derivation_rules"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, nullable=False, comment="企业ID(多租户隔离)")
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="目标KPI")
rule_type = Column(String(30), nullable=False, comment="incremental/percentage_of/formula")
base_kpi_id = Column(Integer, nullable=True, comment="来源KPI(percentage_of用)")
params = Column(JSON, nullable=True, comment="{\"rate\":0.02,\"field\":\"net_profit\"}")
formula_text = Column(String(500), nullable=True, comment="可读公式说明")
status = Column(String(20), default="active", comment="active/inactive")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
__table_args__ = (UniqueConstraint("entity_id", "kpi_id", "rule_type", name="uk_rule"),)
class CashPlanClassifyRule(Base):
"""现金流收付分类规则 — KPI→receive/pay 可维护 (P2-⑥ 2026-08-28)"""
__tablename__ = "cash_plan_classify_rules"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, nullable=False, comment="企业ID(多租户隔离)")
kpi_id = Column(Integer, nullable=True, comment="精确匹配KPI,优先")
kpi_code_pattern = Column(String(200), nullable=True, comment="关键词/编码模式匹配,兜底")
plan_type = Column(String(10), nullable=False, comment="receive/pay")
priority = Column(Integer, default=10, comment="匹配顺序,小优先")
status = Column(String(20), default="active", comment="active/inactive")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class CashPlanUnclassified(Base):
"""现金流待分类KPI队列 — 无法判别的KPI不静默跳过 (P2-⑥ 2026-08-28)"""
__tablename__ = "cash_plan_unclassified"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, nullable=False, comment="企业ID(多租户隔离)")
kpi_id = Column(Integer, nullable=False, comment="KPI ID")
kpi_name = Column(String(200), nullable=True, comment="KPI名称")
period = Column(String(20), nullable=True, comment="期间")
budget_value = Column(Float, nullable=True, comment="预算值")
reason = Column(String(200), nullable=True, comment="无法分类原因")
status = Column(String(20), default="pending", comment="pending/classified/ignored")
created_at = Column(DateTime, server_default=func.now())
resolved_at = Column(DateTime, nullable=True)
+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. 差异预警触发(集成到现有预警系统)
"""
import logging
import json
from datetime import datetime
from typing import Optional
from app.database import get_session_local
from app.models import KPIDefinition, KPIValue, KPIAlert, BudgetPlan
from app.models import KPIDefinition, KPIValue, KPIAlert, BudgetPlan, SystemConfig
logger = logging.getLogger("cma.deviation")
# 越高越好型KPI默认列表(P2-⑤ 2026-08-28: 提为 system_configs 可配置)
DEFAULT_HIGHER_BETTER = [
"SALES_TOTAL", "CUSTOMER_COUNT", "SALES_PROFIT_RATE",
"RECEIVABLE_TURNOVER", "TURNOVER_RATE",
"CUSTOMER_SATISFACTION", "ORDER_DELIVERY_RATE",
]
CONFIG_KEY_HIGHER_BETTER = "kpi_alert_higher_better"
def get_higher_better_codes(db, entity_id: int = None) -> list:
"""读取越高越好型KPI编码列表(system_configs 可维护,无配置回落默认)"""
cfg = db.query(SystemConfig).filter(
SystemConfig.config_key == CONFIG_KEY_HIGHER_BETTER
).first()
if cfg and cfg.config_value:
try:
codes = json.loads(cfg.config_value)
if isinstance(codes, list):
return [str(c) for c in codes]
except Exception:
logger.warning("system_configs[%s] 解析失败, 回落默认", CONFIG_KEY_HIGHER_BETTER)
return list(DEFAULT_HIGHER_BETTER)
# ============================================================
# 差异计算
# ============================================================
@@ -210,8 +235,83 @@ def check_trend_anomaly(db, kpi_id: int, period: str, consecutive: int = 3) -> d
# 差异预警触发
# ============================================================
def build_deviation_alert(db, kpi, period: str, entity_id: int = 1, min_rate: float = 10.0) -> dict:
"""统一告警构建 — 双出口共享一套逻辑 (P2-⑤ 2026-08-28)
预算告警(budget_deviation_alerts) 与 KPIAlert 都调用本函数,差异仅级别映射:
- budget 出口: warning/critical @ 20/50
- KPIAlert 出口: yellow/red @ 10/30
归因(P1-③): attribution 拆解 + 场景建议 由 alert_attribution 组装。
返回:
triggered: bool 是否触发
level: budget出口级别 warning/critical
kpi_alert_level: KPIAlert出口级别 yellow/red
deviation: calc_period_deviation 结果
suggestion: 模板建议文案
alert_type: 归因场景类型
attribution: 归因JSON dict
scenario_id: 场景建议ID
"""
from app.utils.alert_attribution import build_attribution, match_scenario
deviation = calc_period_deviation(db, kpi.id, period)
if deviation.get("deviation_rate") is None:
return {"triggered": False}
rate = abs(deviation["deviation_rate"])
actual = deviation.get("actual_value")
budget = deviation.get("budget_value")
# 方向性:越高越好型(配置化,system_configs.kpi_alert_higher_better
higher_better = kpi.kpi_code in get_higher_better_codes(db, entity_id)
if higher_better:
# 实际低于预算才是问题
if not (actual is not None and budget is not None and actual < budget and rate >= min_rate):
return {"triggered": False}
suggestion = (
f"实际值低于预算 {rate}%,建议分析业务量未达预期的原因(子KPI拆解见归因),"
f"制定增量获客或转化提升计划"
)
else:
# 实际高于预算才是问题(成本型)
if not (actual is not None and budget is not None and actual > budget and rate >= min_rate):
return {"triggered": False}
suggestion = (
f"实际值超出预算 {rate}%,建议核查超支原因(科目明细拆解见归因)并采取控制措施"
)
# 级别映射(双出口)
budget_level = "critical" if rate > 50 else "warning"
kpi_alert_level = "red" if rate >= 30 else "yellow"
# 归因组装 (P1-③)
alert_type = None
attribution = None
scenario_id = None
try:
attribution, alert_type = build_attribution(db, kpi.id, period)
scenario = match_scenario(db, alert_type)
if scenario:
scenario_id = scenario["scenario_id"]
except Exception as e: # 归因失败不阻断告警主流程
logger.warning("归因组装失败 kpi=%s: %s", kpi.kpi_code, e)
return {
"triggered": True,
"level": budget_level,
"kpi_alert_level": kpi_alert_level,
"deviation": deviation,
"suggestion": suggestion,
"alert_type": alert_type,
"attribution": attribution,
"scenario_id": scenario_id,
}
def run_deviation_check(db_session, period: str = None) -> int:
"""运行差异预警检查,返回新增预警数"""
"""运行差异预警检查,返回新增预警数(统一走 build_deviation_alertP2-⑤)"""
if period is None:
period = datetime.now().strftime("%Y-%m")
@@ -221,33 +321,10 @@ def run_deviation_check(db_session, period: str = None) -> int:
new_count = 0
for kpi in kpis:
# 1. 差异预警:实际 vs 预算
deviation = calc_period_deviation(db_session, kpi.id, period)
if deviation.get("deviation_rate") is not None:
rate = abs(deviation["deviation_rate"])
# 差异化阈值:越高越好型 vs 越低越好型
higher_better = kpi.kpi_code in [
"SALES_TOTAL", "CUSTOMER_COUNT", "SALES_PROFIT_RATE",
"RECEIVABLE_TURNOVER", "TURNOVER_RATE",
"CUSTOMER_SATISFACTION", "ORDER_DELIVERY_RATE",
]
if higher_better:
# 实际低于预算才是问题
if deviation["actual_value"] < deviation["budget_value"] and rate >= 10:
level = "yellow" if rate >= 10 else "green"
level = "red" if rate >= 30 else level
else:
continue
else:
# 实际高于预算才是问题(成本型)
if deviation["actual_value"] > deviation["budget_value"] and rate >= 10:
level = "yellow" if rate >= 10 else "green"
level = "red" if rate >= 30 else level
else:
continue
# 1. 差异预警:实际 vs 预算(统一逻辑)
result = build_deviation_alert(db_session, kpi, period)
if result["triggered"]:
deviation = result["deviation"]
alert_msg = (
f"{kpi.kpi_name}[{period}] 差异预警: 实际{deviation['actual_value']} "
f"vs 预算{deviation['budget_value']}"
@@ -265,13 +342,15 @@ def run_deviation_check(db_session, period: str = None) -> int:
if not existing:
alert = KPIAlert(
kpi_id=kpi.id,
alert_level=level,
alert_level=result["kpi_alert_level"],
alert_message=f"[差异预警] {alert_msg}",
alert_type=result["alert_type"] or "actual",
suggestion=result["suggestion"],
status="pending",
)
db_session.add(alert)
new_count += 1
logger.info(f" 新增差异预警 [{level}] {kpi.kpi_name}: 差异率{deviation['deviation_rate']}%")
logger.info(f" 新增差异预警 [{result['kpi_alert_level']}] {kpi.kpi_name}: 差异率{deviation['deviation_rate']}%")
# 2. 趋势异常检测(每期检查连续3期)
trend = check_trend_anomaly(db_session, kpi.id, period, consecutive=3)
+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 }),
// KPI-8: 因果链追踪
causalityChain: (id: number) => api.get(`/kpis/${id}/causality-chain`),
// KPI实际值列表(归集标签页)
values: (id: number, params?: any) => api.get(`/kpis/${id}/values`, { params }),
}
export const mapApi = {
@@ -208,6 +210,38 @@ export const budgetApi = {
deviationCheck: (data: any) => api.post('/budget/deviation-check', data),
listDeviationAlerts: (params?: any) => api.get('/budget/deviation-alerts', { params }),
updateDeviationAlert: (id: number, data: any) => api.put(`/budget/deviation-alerts/${id}`, data),
// P1-③ 告警归因
getAlertAttribution: (alertId: number) => api.get(`/budget/deviation-alerts/${alertId}/attribution`),
getAlertDirection: () => api.get('/budget/alert-direction'),
updateAlertDirection: (data: any) => api.put('/budget/alert-direction', data),
// P1-④ 实际值自动归集
valueSources: (params?: any) => api.get('/budget/value-sources', { params }),
createValueSource: (data: any) => api.post('/budget/value-sources', data),
updateValueSource: (id: number, data: any) => api.put(`/budget/value-sources/${id}`, data),
deleteValueSource: (id: number) => api.delete(`/budget/value-sources/${id}`),
testValueSource: (data: any) => api.post('/budget/value-sources/test', data),
runValueCollect: (data: any) => api.post('/budget/value-collect/run', data),
valueCollectLogs: (params?: any) => api.get('/budget/value-collect/logs', { params }),
valueSourceCoverage: () => api.get('/budget/value-sources/coverage'),
// P2-① 零基逐项论证
zeroBasedItems: (params?: any) => api.get('/budget/zero-based/items', { params }),
createZeroBasedItem: (data: any) => api.post('/budget/zero-based/items', data),
updateZeroBasedItem: (id: number, data: any) => api.put(`/budget/zero-based/items/${id}`, data),
deleteZeroBasedItem: (id: number) => api.delete(`/budget/zero-based/items/${id}`),
generateZeroBased: (data: any) => api.post('/budget/zero-based/generate', data),
// P2-② 派生规则
derivationRules: (params?: any) => api.get('/budget/derivation-rules', { params }),
createDerivationRule: (data: any) => api.post('/budget/derivation-rules', data),
updateDerivationRule: (id: number, data: any) => api.put(`/budget/derivation-rules/${id}`, data),
deleteDerivationRule: (id: number) => api.delete(`/budget/derivation-rules/${id}`),
// P2-⑥ 现金流分类规则 + 待分类队列
cashClassifyRules: () => api.get('/budget/cash-classify-rules'),
createCashClassifyRule: (data: any) => api.post('/budget/cash-classify-rules', data),
updateCashClassifyRule: (id: number, data: any) => api.put(`/budget/cash-classify-rules/${id}`, data),
deleteCashClassifyRule: (id: number) => api.delete(`/budget/cash-classify-rules/${id}`),
cashUnclassified: (params?: any) => api.get('/budget/cash-unclassified', { params }),
classifyCashUnclassified: (id: number, data: any) => api.post(`/budget/cash-unclassified/${id}/classify`, data),
ignoreCashUnclassified: (id: number) => api.post(`/budget/cash-unclassified/${id}/ignore`),
}
export const costApi = {
+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="80"><template #default="{ row }"><el-tag v-if="row.alert_level === 'red'" size="small" type="danger">严重</el-tag><el-tag v-else-if="row.alert_level === 'yellow'" size="small" type="warning">关注</el-tag><el-tag v-else size="small" type="success">正常</el-tag></template></el-table-column>
</el-table>
<!-- P2- 现金流分类规则 + 待分类队列 -->
<el-row :gutter="16" style="margin-top:20px;">
<el-col :span="12">
<el-card shadow="never">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span><strong>现金流分类规则</strong> <span style="font-size:12px;color:#999;">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 label="编制方法" name="method">
@@ -248,6 +296,82 @@
<el-button v-if="selectedMethod" type="primary" @click="confirmMethod">确认选择{{ selectedMethodName }}</el-button>
<el-button @click="refreshMethodComparison">刷新计算</el-button>
</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>
</el-tab-pane>
@@ -255,6 +379,96 @@
<DriverFactorBudget />
</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-tabs v-model="rollingTab" type="border-card" style="margin-top:4px;">
<el-tab-pane label="实际vs预测对比" name="comparison">
@@ -367,10 +581,11 @@
<el-tag v-else type="info" size="small">已忽略</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<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>
<el-button size="small" type="primary" plain @click="showAlertAttribution(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>
</el-table-column>
</el-table>
@@ -378,6 +593,80 @@
<p>暂无偏差预警点击执行偏差检查扫描当前期间</p>
</div>
</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-tab-pane>
</el-tabs>
@@ -437,6 +726,106 @@
</div>
<template #footer><el-button @click="showActionDialog = false">关闭</el-button></template>
</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>
</template>
@@ -954,12 +1343,339 @@ async function reopenAlert(row: any) {
} 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) => {
if (tab === 'decompose') loadBudget()
else if (tab === 'strategy') loadStrategyBudget()
else if (tab === 'versions') loadVersions()
else if (tab === 'execution') loadExecutionReport()
else if (tab === 'method') refreshMethodComparison()
else if (tab === 'execution') { loadExecutionReport(); loadCashClassify() }
else if (tab === 'method') { refreshMethodComparison(); loadDerivationRules() }
else if (tab === 'collect') loadCollectData()
else if (tab === 'driver') {
// DriverFactorBudget handles its own loading on mount
}
@@ -973,6 +1689,7 @@ onMounted(async () => {
loadBudget()
loadBudgetConfig()
loadComparisonKpiOptions()
loadZbbKpiOptions()
})
</script>