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:
+334
-81
@@ -803,20 +803,23 @@ def get_kpi_comparison(
|
||||
def check_budget_deviation(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""
|
||||
检查实际vs预测偏差,当偏差超过20%时自动生成预警
|
||||
检查实际vs预测偏差,当偏差超过阈值时自动生成预警
|
||||
(2026-08-28 P1-③/P2-⑤: 统一走 build_deviation_alert,写入归因JSON+场景建议)
|
||||
"""
|
||||
from app.models import KPIValue, BudgetDeviationAlert
|
||||
from sqlalchemy import func
|
||||
from app.utils.deviation_engine import build_deviation_alert
|
||||
|
||||
threshold = data.get("threshold", 20) # 默认20%
|
||||
period = data.get("period") or datetime.now().strftime("%Y-%m")
|
||||
auto_resolve = data.get("auto_resolve", True) # 是否自动关闭已解决的预警
|
||||
|
||||
# 查询该期间的有预算的KPI
|
||||
# 查询该期间有预算的KPI(多租户隔离 entity_id)
|
||||
budget_plans = db.query(BudgetPlan).filter(
|
||||
BudgetPlan.entity_id == entity_id,
|
||||
BudgetPlan.period == period,
|
||||
BudgetPlan.status == "active",
|
||||
).all()
|
||||
@@ -832,39 +835,25 @@ def check_budget_deviation(
|
||||
alerts = []
|
||||
|
||||
for bp in budget_plans:
|
||||
# 查询实际值
|
||||
actual = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == bp.kpi_id,
|
||||
KPIValue.period == period,
|
||||
KPIValue.actual_value.isnot(None),
|
||||
kpi_obj = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == bp.kpi_id,
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).first()
|
||||
|
||||
if not actual or actual.actual_value is None:
|
||||
if not kpi_obj:
|
||||
continue
|
||||
|
||||
budget_val = bp.budget_value
|
||||
actual_val = actual.actual_value
|
||||
|
||||
if budget_val == 0:
|
||||
# 统一告警逻辑(方向性/阈值/归因/场景建议)
|
||||
result = build_deviation_alert(db, kpi_obj, period, entity_id=entity_id, min_rate=threshold)
|
||||
if not result["triggered"]:
|
||||
continue
|
||||
|
||||
# 计算偏差率
|
||||
deviation_rate = round((actual_val - budget_val) / budget_val * 100, 2)
|
||||
|
||||
# 只有偏差超过阈值才生成预警
|
||||
if abs(deviation_rate) <= threshold:
|
||||
continue
|
||||
|
||||
deviation_value = round(actual_val - budget_val, 2)
|
||||
|
||||
# 判断预警等级
|
||||
alert_level = "critical" if abs(deviation_rate) > 50 else "warning"
|
||||
|
||||
# 生成建议
|
||||
if deviation_rate > 0:
|
||||
suggestion = f"实际值超出预算 {deviation_rate}%,建议核查超支原因并采取控制措施"
|
||||
else:
|
||||
suggestion = f"实际值低于预算 {abs(deviation_rate)}%,建议分析是否预算过高或业务量未达预期"
|
||||
deviation = result["deviation"]
|
||||
budget_val = deviation.get("budget_value")
|
||||
actual_val = deviation.get("actual_value")
|
||||
deviation_rate = deviation.get("deviation_rate")
|
||||
deviation_value = deviation.get("deviation_amount")
|
||||
if deviation_value is None:
|
||||
deviation_value = round((actual_val or 0) - (budget_val or 0), 2)
|
||||
|
||||
# 检查是否已存在相同的预警
|
||||
existing_alert = db.query(BudgetDeviationAlert).filter(
|
||||
@@ -874,6 +863,12 @@ def check_budget_deviation(
|
||||
).first()
|
||||
|
||||
if existing_alert:
|
||||
# 已存在open告警: 补齐归因(原open告警可能无归因, 幂等补写)
|
||||
if existing_alert.attribution is None and result["attribution"]:
|
||||
existing_alert.attribution = result["attribution"]
|
||||
existing_alert.alert_type = result["alert_type"]
|
||||
existing_alert.scenario_id = result["scenario_id"]
|
||||
db.flush()
|
||||
continue
|
||||
|
||||
alert = BudgetDeviationAlert(
|
||||
@@ -883,14 +878,16 @@ def check_budget_deviation(
|
||||
actual_value=actual_val,
|
||||
deviation_rate=deviation_rate,
|
||||
deviation_value=deviation_value,
|
||||
alert_level=alert_level,
|
||||
alert_level=result["level"],
|
||||
status="open",
|
||||
suggestion=suggestion,
|
||||
suggestion=result["suggestion"],
|
||||
alert_type=result["alert_type"],
|
||||
attribution=result["attribution"],
|
||||
scenario_id=result["scenario_id"],
|
||||
)
|
||||
db.add(alert)
|
||||
alerts_generated += 1
|
||||
|
||||
kpi_obj = db.query(KPIDefinition).filter(KPIDefinition.id == bp.kpi_id).first()
|
||||
alerts.append({
|
||||
"kpi_id": bp.kpi_id,
|
||||
"kpi_code": kpi_obj.kpi_code if kpi_obj else "",
|
||||
@@ -900,8 +897,10 @@ def check_budget_deviation(
|
||||
"actual_value": actual_val,
|
||||
"deviation_rate": deviation_rate,
|
||||
"deviation_value": deviation_value,
|
||||
"alert_level": alert_level,
|
||||
"suggestion": suggestion,
|
||||
"alert_level": result["level"],
|
||||
"suggestion": result["suggestion"],
|
||||
"alert_type": result["alert_type"],
|
||||
"attribution": result["attribution"],
|
||||
})
|
||||
|
||||
db.commit()
|
||||
@@ -922,10 +921,11 @@ def list_deviation_alerts(
|
||||
alert_level: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""查询预算偏差预警记录"""
|
||||
"""查询预算偏差预警记录 (2026-08-28: 列表新增 alert_type/attribution/scenario_id,entity_id隔离)"""
|
||||
from app.models import BudgetDeviationAlert
|
||||
query = db.query(BudgetDeviationAlert)
|
||||
query = db.query(BudgetDeviationAlert).filter(BudgetDeviationAlert.entity_id == entity_id)
|
||||
if kpi_id:
|
||||
query = query.filter(BudgetDeviationAlert.kpi_id == kpi_id)
|
||||
if period:
|
||||
@@ -952,11 +952,120 @@ def list_deviation_alerts(
|
||||
"alert_level": a.alert_level,
|
||||
"status": a.status,
|
||||
"suggestion": a.suggestion,
|
||||
"alert_type": a.alert_type,
|
||||
"attribution": a.attribution,
|
||||
"scenario_id": a.scenario_id,
|
||||
"created_at": a.created_at.isoformat() if a.created_at else None,
|
||||
})
|
||||
return {"data": result, "total": len(result)}
|
||||
|
||||
|
||||
@router.get("/deviation-alerts/{alert_id}/attribution")
|
||||
def get_deviation_alert_attribution(
|
||||
alert_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""告警归因详情 — 告警 + 归因JSON + 场景建议(联查 scenario_suggestions)(P1-③ 2026-08-28)"""
|
||||
from app.models import BudgetDeviationAlert, ScenarioSuggestion
|
||||
from app.utils.alert_attribution import match_scenario
|
||||
|
||||
alert = db.query(BudgetDeviationAlert).filter(
|
||||
BudgetDeviationAlert.id == alert_id,
|
||||
BudgetDeviationAlert.entity_id == entity_id,
|
||||
).first()
|
||||
if not alert:
|
||||
raise HTTPException(404, "预警记录不存在")
|
||||
|
||||
kpi_obj = db.query(KPIDefinition).filter(KPIDefinition.id == alert.kpi_id).first()
|
||||
|
||||
# 归因(若旧告警无归因字段,现场组装一次)
|
||||
attribution = alert.attribution
|
||||
if attribution is None:
|
||||
from app.utils.alert_attribution import build_attribution
|
||||
try:
|
||||
attribution, inferred_type = build_attribution(db, alert.kpi_id, alert.period, alert.alert_type)
|
||||
alert.attribution = attribution
|
||||
if alert.alert_type is None:
|
||||
alert.alert_type = inferred_type
|
||||
db.commit()
|
||||
except Exception:
|
||||
attribution = {}
|
||||
|
||||
scenario = None
|
||||
if alert.scenario_id or alert.alert_type:
|
||||
scenario = match_scenario(db, alert.alert_type)
|
||||
|
||||
return {
|
||||
"id": alert.id,
|
||||
"kpi_id": alert.kpi_id,
|
||||
"kpi_code": kpi_obj.kpi_code if kpi_obj else "",
|
||||
"kpi_name": kpi_obj.kpi_name if kpi_obj else "",
|
||||
"period": alert.period,
|
||||
"budget_value": alert.budget_value,
|
||||
"actual_value": alert.actual_value,
|
||||
"deviation_rate": alert.deviation_rate,
|
||||
"deviation_value": alert.deviation_value,
|
||||
"alert_level": alert.alert_level,
|
||||
"status": alert.status,
|
||||
"suggestion": alert.suggestion,
|
||||
"alert_type": alert.alert_type,
|
||||
"attribution": attribution or {},
|
||||
"scenario": scenario,
|
||||
"created_at": alert.created_at.isoformat() if alert.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/alert-direction")
|
||||
def get_alert_direction(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""越高越好型KPI方向配置 (P2-⑤ 2026-08-28: system_configs 可维护)"""
|
||||
from app.utils.deviation_engine import get_higher_better_codes, CONFIG_KEY_HIGHER_BETTER
|
||||
from app.models import SystemConfig
|
||||
|
||||
cfg = db.query(SystemConfig).filter(
|
||||
SystemConfig.config_key == CONFIG_KEY_HIGHER_BETTER
|
||||
).first()
|
||||
codes = get_higher_better_codes(db)
|
||||
return {
|
||||
"config_key": CONFIG_KEY_HIGHER_BETTER,
|
||||
"codes": codes,
|
||||
"is_configured": bool(cfg and cfg.config_value),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/alert-direction")
|
||||
def update_alert_direction(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""维护越高越好型KPI方向配置 (P2-⑤) body: {codes: ["SALES_TOTAL", ...]}"""
|
||||
import json as _json
|
||||
from app.utils.deviation_engine import CONFIG_KEY_HIGHER_BETTER
|
||||
from app.models import SystemConfig
|
||||
|
||||
codes = data.get("codes")
|
||||
if not isinstance(codes, list):
|
||||
raise HTTPException(400, "codes 必须是非空数组")
|
||||
codes = [str(c) for c in codes]
|
||||
|
||||
cfg = db.query(SystemConfig).filter(
|
||||
SystemConfig.config_key == CONFIG_KEY_HIGHER_BETTER
|
||||
).first()
|
||||
if cfg:
|
||||
cfg.config_value = _json.dumps(codes, ensure_ascii=False)
|
||||
else:
|
||||
db.add(SystemConfig(
|
||||
config_key=CONFIG_KEY_HIGHER_BETTER,
|
||||
config_value=_json.dumps(codes, ensure_ascii=False),
|
||||
description="越高越好型KPI编码列表(实际低于预算才告警)",
|
||||
))
|
||||
db.commit()
|
||||
return {"success": True, "config_key": CONFIG_KEY_HIGHER_BETTER, "codes": codes}
|
||||
|
||||
|
||||
@router.put("/deviation-alerts/{alert_id}")
|
||||
def update_deviation_alert(
|
||||
alert_id: int,
|
||||
@@ -979,11 +1088,18 @@ def update_deviation_alert(
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@router.post("/method-comparison")
|
||||
def budget_method_comparison(data: dict):
|
||||
def budget_method_comparison(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""
|
||||
预算方法三选一对比计算
|
||||
接收: { entity: "hanke", last_month_budget: 91, current_revenue: 122, ... }
|
||||
返回三种方法的计算结果
|
||||
(2026-08-28 P2-①: zero_based 优先读逐项论证项 budget_zero_based_items,
|
||||
传入 zero_based_kpi_id+zero_based_period 且有论证项 → 逐项求和 is_demo=false;
|
||||
无论证项 → fallback 旧公式 is_demo=true)
|
||||
"""
|
||||
entity = data.get("entity", "hanke")
|
||||
last_month_budget = data.get("last_month_budget", 91) # 上月预算(万)
|
||||
@@ -1001,22 +1117,44 @@ def budget_method_comparison(data: dict):
|
||||
incremental_result = round(last_month_budget * (1 + increment_rate), 1)
|
||||
incremental_detail = f"上月{last_month_budget}万 × (1+{increment_rate*100:.0f}%) = {incremental_result}万"
|
||||
|
||||
# 2. 零基预算: 每项从零论证
|
||||
zbb_entertainment = round(fixed_costs.get("entertainment", 16) / 2, 1) # 砍半
|
||||
zbb_misc = round(fixed_costs.get("misc", 12) * 0.7, 1) # 压缩30%
|
||||
zbb_total = round(
|
||||
fixed_costs.get("rent", 15)
|
||||
+ fixed_costs.get("labor", 40)
|
||||
+ zbb_entertainment
|
||||
+ zbb_misc,
|
||||
1,
|
||||
)
|
||||
zbb_savings = round(last_month_budget - zbb_total, 1)
|
||||
zbb_detail = (
|
||||
f"房租{fixed_costs.get('rent', 15)}万(固定)+人工{fixed_costs.get('labor', 40)}万(砍不掉)"
|
||||
f"+招待{zbb_entertainment}万(砍半)+杂项{zbb_misc}万(压缩)"
|
||||
f"={zbb_total}万 ← 省{zbb_savings}万"
|
||||
)
|
||||
# 2. 零基预算: 优先逐项论证(P2-① 真零基)
|
||||
zbb_kpi_id = data.get("zero_based_kpi_id")
|
||||
zbb_period = data.get("zero_based_period")
|
||||
zbb_is_demo = True
|
||||
zbb_items = []
|
||||
if zbb_kpi_id and zbb_period:
|
||||
from app.models import BudgetZeroBasedItem
|
||||
zbb_items = db.query(BudgetZeroBasedItem).filter(
|
||||
BudgetZeroBasedItem.entity_id == entity_id,
|
||||
BudgetZeroBasedItem.kpi_id == zbb_kpi_id,
|
||||
BudgetZeroBasedItem.period == zbb_period,
|
||||
).all()
|
||||
|
||||
if zbb_items:
|
||||
# 真零基: 逐项求和(仅 approved+draft 都算,draft为未定稿)
|
||||
zbb_total = round(sum(i.proposed_value for i in zbb_items), 1)
|
||||
zbb_is_demo = False
|
||||
zbb_detail = "零基逐项论证: " + " + ".join(
|
||||
f"{i.item_name}{i.proposed_value}万" for i in zbb_items
|
||||
) + f" = {zbb_total}万"
|
||||
zbb_savings = round(last_month_budget - zbb_total, 1)
|
||||
else:
|
||||
# fallback 旧演示公式(标注 is_demo)
|
||||
zbb_entertainment = round(fixed_costs.get("entertainment", 16) / 2, 1) # 砍半
|
||||
zbb_misc = round(fixed_costs.get("misc", 12) * 0.7, 1) # 压缩30%
|
||||
zbb_total = round(
|
||||
fixed_costs.get("rent", 15)
|
||||
+ fixed_costs.get("labor", 40)
|
||||
+ zbb_entertainment
|
||||
+ zbb_misc,
|
||||
1,
|
||||
)
|
||||
zbb_savings = round(last_month_budget - zbb_total, 1)
|
||||
zbb_detail = (
|
||||
f"房租{fixed_costs.get('rent', 15)}万(固定)+人工{fixed_costs.get('labor', 40)}万(砍不掉)"
|
||||
f"+招待{zbb_entertainment}万(砍半)+杂项{zbb_misc}万(压缩)"
|
||||
f"={zbb_total}万 ← 省{zbb_savings}万"
|
||||
)
|
||||
|
||||
# 3. 弹性预算: 根据收入水平动态调整
|
||||
flexible_fixed = round(fixed_costs.get("rent", 15) + fixed_costs.get("labor", 40) * 0.5, 1)
|
||||
@@ -1053,6 +1191,8 @@ def budget_method_comparison(data: dict):
|
||||
"result_value": zbb_total,
|
||||
"savings": zbb_savings,
|
||||
"detail": zbb_detail,
|
||||
"is_demo": zbb_is_demo,
|
||||
"item_count": len(zbb_items),
|
||||
"pros": "最合理",
|
||||
"cons": "耗时",
|
||||
"is_recommended": True,
|
||||
@@ -1078,16 +1218,21 @@ def budget_method_comparison(data: dict):
|
||||
def apply_budget_method(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""应用所选预算编制方法到预算计划(2026-08-26:三法并存,按用户场景选择后落地)
|
||||
接收: { method: 'incremental'|'zero_based'|'flexible', year: 2026, entity: 'hanke', ... }
|
||||
说明: 方法计算结果 → 写入/更新预算计划(version标注方法名,便于追溯)
|
||||
(2026-08-28 P2-②: KPI派生规则可配置 budget_derivation_rules,
|
||||
percentage_of → base_kpi实际值×rate; incremental → 上月×(1+rate);
|
||||
无规则 fallback 默认比例(净利2%/费用率22%/毛利18%), 响应带 rule_source)
|
||||
"""
|
||||
from app.models import BudgetDerivationRule
|
||||
|
||||
method = data.get("method", "zero_based")
|
||||
year = data.get("year", datetime.now().year)
|
||||
entity = data.get("entity", "hanke")
|
||||
entity_id = data.get("entity_id", 1)
|
||||
|
||||
# 复用method-comparison计算(获得三法结果)
|
||||
comp = budget_method_comparison({
|
||||
@@ -1099,7 +1244,9 @@ def apply_budget_method(
|
||||
}),
|
||||
"variable_cost_rate": data.get("variable_cost_rate", 0.4862),
|
||||
"increment_rate": data.get("increment_rate", 0.05),
|
||||
})
|
||||
"zero_based_kpi_id": data.get("zero_based_kpi_id"),
|
||||
"zero_based_period": data.get("zero_based_period"),
|
||||
}, db=db, entity_id=entity_id)
|
||||
|
||||
# 找所选方法的结果
|
||||
selected = None
|
||||
@@ -1111,7 +1258,6 @@ def apply_budget_method(
|
||||
raise HTTPException(400, "未知预算方法: " + method)
|
||||
|
||||
# 找到该年的核心KPI(营业收入/净利润/费用率等)
|
||||
# 取该年已有预算的KPI,或默认核心4个
|
||||
kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
KPIDefinition.status == "active",
|
||||
@@ -1120,11 +1266,18 @@ def apply_budget_method(
|
||||
if not kpis:
|
||||
raise HTTPException(400, "未找到可应用的KPI")
|
||||
|
||||
# 方法结果解释为收入预算(核心KPI应用)
|
||||
# incremental/flexible/zero_based 的 result_value 均为"预算总额(万)"
|
||||
# 写入F_REVENUE年度预算(period=YYYY-00 表示年度)
|
||||
# 加载派生规则(P2-②)
|
||||
rules = db.query(BudgetDerivationRule).filter(
|
||||
BudgetDerivationRule.entity_id == entity_id,
|
||||
BudgetDerivationRule.status == "active",
|
||||
).all()
|
||||
rules_by_kpi = {r.kpi_id: r for r in rules}
|
||||
|
||||
# 版本
|
||||
version = f"{method}-{datetime.now().strftime('%Y%m%d')}"
|
||||
applied = []
|
||||
used_configured = False
|
||||
|
||||
for kpi in kpis:
|
||||
period = f"{year}-00"
|
||||
# 删除旧版本的同KPI年度预算
|
||||
@@ -1134,15 +1287,67 @@ def apply_budget_method(
|
||||
BudgetPlan.version.like(f"{method}-%"),
|
||||
).delete()
|
||||
|
||||
# 各KPI的应用值(简化:收入用方法结果,其他按比例)
|
||||
# 各KPI的应用值:收入用方法结果,其他优先派生规则(P2-②)
|
||||
if kpi.kpi_code == "F_REVENUE":
|
||||
budget_val = selected["result_value"]
|
||||
elif kpi.kpi_code == "F_NET_PROFIT":
|
||||
budget_val = round(selected["result_value"] * 0.02, 1) # 净利率约2%
|
||||
elif kpi.kpi_code == "F_COST_RATIO":
|
||||
budget_val = round(selected["result_value"] * 0.22, 1) # 费用率约22%
|
||||
else: # F_GROSS_MARGIN
|
||||
budget_val = round(selected["result_value"] * 0.18, 1) # 毛利率约18%
|
||||
rule_source = "default"
|
||||
formula_note = "方法结果"
|
||||
else:
|
||||
rule = rules_by_kpi.get(kpi.id)
|
||||
if rule and rule.params:
|
||||
rate = float(rule.params.get("rate", 0.02))
|
||||
if rule.rule_type == "percentage_of" and rule.base_kpi_id:
|
||||
# 来源KPI实际值 × 比例
|
||||
base_val = None
|
||||
base_actual = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == rule.base_kpi_id,
|
||||
KPIValue.actual_value.isnot(None),
|
||||
).order_by(KPIValue.calculated_at.desc()).first()
|
||||
if base_actual:
|
||||
base_val = base_actual.actual_value
|
||||
if base_val is not None:
|
||||
budget_val = round(base_val * rate, 1)
|
||||
rule_source = "configured"
|
||||
formula_note = f"派生: 来源KPI实际值{base_val} × {rate}"
|
||||
else:
|
||||
budget_val = round(selected["result_value"] * rate, 1)
|
||||
rule_source = "configured_fallback"
|
||||
formula_note = f"派生规则无来源实际值, 按方法结果×{rate}"
|
||||
elif rule.rule_type == "incremental":
|
||||
# 上月预算 × (1+rate)
|
||||
prev_period = f"{year-1}-00"
|
||||
prev_plan = db.query(BudgetPlan).filter(
|
||||
BudgetPlan.entity_id == entity_id,
|
||||
BudgetPlan.kpi_id == kpi.id,
|
||||
BudgetPlan.period == prev_period,
|
||||
BudgetPlan.status == "active",
|
||||
).order_by(BudgetPlan.updated_at.desc()).first()
|
||||
if prev_plan and prev_plan.budget_value is not None:
|
||||
budget_val = round(prev_plan.budget_value * (1 + rate), 1)
|
||||
rule_source = "configured"
|
||||
formula_note = f"派生: 上年预算{prev_plan.budget_value} × (1+{rate})"
|
||||
else:
|
||||
budget_val = round(selected["result_value"] * rate, 1)
|
||||
rule_source = "configured_fallback"
|
||||
formula_note = f"派生规则无上年预算, 按方法结果×{rate}"
|
||||
else:
|
||||
# formula 类型: 暂按方法结果×rate 兜底
|
||||
budget_val = round(selected["result_value"] * rate, 1)
|
||||
rule_source = "configured"
|
||||
formula_note = f"派生规则(formula): 方法结果×{rate}"
|
||||
else:
|
||||
# fallback 默认比例
|
||||
if kpi.kpi_code == "F_NET_PROFIT":
|
||||
budget_val = round(selected["result_value"] * 0.02, 1) # 净利率约2%
|
||||
elif kpi.kpi_code == "F_COST_RATIO":
|
||||
budget_val = round(selected["result_value"] * 0.22, 1) # 费用率约22%
|
||||
else: # F_GROSS_MARGIN
|
||||
budget_val = round(selected["result_value"] * 0.18, 1) # 毛利率约18%
|
||||
rule_source = "default"
|
||||
formula_note = "默认比例"
|
||||
|
||||
if rule_source in ("configured", "configured_fallback"):
|
||||
used_configured = True
|
||||
|
||||
bp = BudgetPlan(
|
||||
entity_id=entity_id,
|
||||
@@ -1153,10 +1358,11 @@ def apply_budget_method(
|
||||
budget_month=0,
|
||||
version=version,
|
||||
status="active",
|
||||
remark=f"{selected['name']}应用({selected['result_value']}万) 来源{method}",
|
||||
remark=f"{selected['name']}应用({selected['result_value']}万) 来源{method} | {formula_note}",
|
||||
calc_logic=formula_note,
|
||||
)
|
||||
db.add(bp)
|
||||
applied.append({"kpi_code": kpi.kpi_code, "budget_value": budget_val})
|
||||
applied.append({"kpi_code": kpi.kpi_code, "budget_value": budget_val, "rule_source": rule_source})
|
||||
|
||||
db.commit()
|
||||
return {
|
||||
@@ -1167,7 +1373,8 @@ def apply_budget_method(
|
||||
"total_budget": selected["result_value"],
|
||||
"detail": selected["detail"],
|
||||
"applied": applied,
|
||||
"note": "选择哪种方法取决于场景:增量=稳定业务快速编;零基=成本优化专项;弹性=收入波动大。方法结果写入年度预算(period=YYYY-00),可在版本管理中查看。",
|
||||
"rule_source": "configured" if used_configured else "default",
|
||||
"note": "选择哪种方法取决于场景:增量=稳定业务快速编;零基=成本优化专项;弹性=收入波动大。方法结果写入年度预算(period=YYYY-00),可在版本管理中查看。KPI派生规则可在「派生规则配置」中维护(P2-②)。",
|
||||
}
|
||||
|
||||
|
||||
@@ -1338,35 +1545,77 @@ def sync_cash_plans(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""预算→现金流计划联动: 按预算KPI生成/更新收付款计划(修复断点#1)
|
||||
(2026-08-28 P2-⑥: 分类规则表优先, 未命中进待分类队列不再静默跳过)
|
||||
|
||||
收入类KPI(营收/回款/新客) → receive
|
||||
成本类KPI(费用/厂补/采购) → pay
|
||||
分类来源: ①cash_plan_classify_rules规则表(精确KPI→关键词) ②默认关键词兜底 ③待分类队列
|
||||
upsert: 同KPI+同日期+同类型 更新不重复
|
||||
"""
|
||||
from app.models import CashPlan
|
||||
from app.models import CashPlan, CashPlanClassifyRule, CashPlanUnclassified
|
||||
from datetime import datetime
|
||||
|
||||
# 默认关键词兜底(兼容存量,规则表优先)
|
||||
RECEIVE_KEYS = ("营收", "收入", "销售", "回款", "新客", "收款", "净利润", "毛利")
|
||||
PAY_KEYS = ("费用", "成本", "厂补", "采购", "返利", "应付", "损耗", "投入")
|
||||
|
||||
# 加载分类规则表(P2-⑥)
|
||||
rules = db.query(CashPlanClassifyRule).filter(
|
||||
CashPlanClassifyRule.entity_id == entity_id,
|
||||
CashPlanClassifyRule.status == "active",
|
||||
).order_by(CashPlanClassifyRule.priority.asc()).all()
|
||||
kpi_rules = {r.kpi_id: r for r in rules if r.kpi_id}
|
||||
pattern_rules = [r for r in rules if not r.kpi_id and r.kpi_code_pattern]
|
||||
|
||||
def classify_plan_type(kpi) -> Optional[str]:
|
||||
"""返回 receive/pay/None(未分类)"""
|
||||
# ① 精确KPI匹配(优先)
|
||||
if kpi.id in kpi_rules:
|
||||
return kpi_rules[kpi.id].plan_type
|
||||
# ② 关键词/编码模式匹配(规则表)
|
||||
name = (kpi.kpi_name or "") + (kpi.kpi_code or "")
|
||||
for r in pattern_rules:
|
||||
if r.kpi_code_pattern and r.kpi_code_pattern in name:
|
||||
return r.plan_type
|
||||
# ③ 默认关键词兜底(兼容存量行为)
|
||||
if any(k in name for k in RECEIVE_KEYS):
|
||||
return "receive"
|
||||
if any(k in name for k in PAY_KEYS):
|
||||
return "pay"
|
||||
return None
|
||||
|
||||
budgets = db.query(BudgetPlan).filter(
|
||||
BudgetPlan.entity_id == entity_id, BudgetPlan.status == "active"
|
||||
).all()
|
||||
kpi_ids = {b.kpi_id for b in budgets}
|
||||
kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(kpi_ids)).all()} if kpi_ids else {}
|
||||
|
||||
created, updated = 0, 0
|
||||
created, updated, unclassified_count = 0, 0, 0
|
||||
for b in budgets:
|
||||
kpi = kpis.get(b.kpi_id)
|
||||
if not kpi:
|
||||
continue
|
||||
name = (kpi.kpi_name or "") + (kpi.kpi_code or "")
|
||||
if any(k in name for k in RECEIVE_KEYS):
|
||||
plan_type = "receive"
|
||||
elif any(k in name for k in PAY_KEYS):
|
||||
plan_type = "pay"
|
||||
else:
|
||||
continue # 无法判类别的KPI跳过
|
||||
plan_type = classify_plan_type(kpi)
|
||||
if plan_type is None:
|
||||
# 无法判类别 → 写入待分类队列(不静默跳过,P2-⑥)
|
||||
existing_un = db.query(CashPlanUnclassified).filter(
|
||||
CashPlanUnclassified.entity_id == entity_id,
|
||||
CashPlanUnclassified.kpi_id == b.kpi_id,
|
||||
CashPlanUnclassified.period == b.period,
|
||||
CashPlanUnclassified.status == "pending",
|
||||
).first()
|
||||
if not existing_un:
|
||||
db.add(CashPlanUnclassified(
|
||||
entity_id=entity_id,
|
||||
kpi_id=b.kpi_id,
|
||||
kpi_name=kpi.kpi_name or kpi.kpi_code,
|
||||
period=b.period,
|
||||
budget_value=b.budget_value,
|
||||
reason="未匹配任何分类规则",
|
||||
status="pending",
|
||||
))
|
||||
unclassified_count += 1
|
||||
continue
|
||||
|
||||
year, month = b.budget_year or 2026, b.budget_month or 1
|
||||
try:
|
||||
@@ -1395,4 +1644,8 @@ def sync_cash_plans(
|
||||
))
|
||||
created += 1
|
||||
db.commit()
|
||||
return {"message": f"现金流联动完成: 新建{created}条, 更新{updated}条", "created": created, "updated": updated}
|
||||
return {
|
||||
"message": f"现金流联动完成: 新建{created}条, 更新{updated}条, 待分类{unclassified_count}条",
|
||||
"created": created, "updated": updated,
|
||||
"unclassified_count": unclassified_count,
|
||||
}
|
||||
|
||||
@@ -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": "已忽略"}
|
||||
@@ -0,0 +1,141 @@
|
||||
"""KPI派生规则 API — 管理会计OS (P2-② 2026-08-28)
|
||||
|
||||
派生规则配置(budget_derivation_rules):apply-method 派生KPI时优先读规则,
|
||||
percentage_of → base_kpi实际值×rate;incremental → 上月×(1+rate);无规则fallback默认比例。
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import BudgetDerivationRule, KPIDefinition
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/cma/budget",
|
||||
tags=["派生规则"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "it"))],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/derivation-rules")
|
||||
def list_derivation_rules(
|
||||
kpi_id: Optional[int] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""派生规则列表(按 entity_id 隔离)"""
|
||||
query = db.query(BudgetDerivationRule).filter(BudgetDerivationRule.entity_id == entity_id)
|
||||
if kpi_id:
|
||||
query = query.filter(BudgetDerivationRule.kpi_id == kpi_id)
|
||||
if status:
|
||||
query = query.filter(BudgetDerivationRule.status == status)
|
||||
rows = query.order_by(BudgetDerivationRule.id.desc()).all()
|
||||
|
||||
all_kpi_ids = {r.kpi_id for r in rows} | {r.base_kpi_id for r in rows if r.base_kpi_id}
|
||||
kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(all_kpi_ids)).all()} if all_kpi_ids else {}
|
||||
result = []
|
||||
for r in rows:
|
||||
kpi = kpis.get(r.kpi_id)
|
||||
base = kpis.get(r.base_kpi_id) if r.base_kpi_id else None
|
||||
result.append({
|
||||
"id": r.id,
|
||||
"kpi_id": r.kpi_id,
|
||||
"kpi_code": kpi.kpi_code if kpi else "",
|
||||
"kpi_name": kpi.kpi_name if kpi else "",
|
||||
"rule_type": r.rule_type,
|
||||
"base_kpi_id": r.base_kpi_id,
|
||||
"base_kpi_code": base.kpi_code if base else "",
|
||||
"base_kpi_name": base.kpi_name if base else "",
|
||||
"params": r.params,
|
||||
"formula_text": r.formula_text,
|
||||
"status": r.status,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
})
|
||||
return {"data": result, "total": len(result)}
|
||||
|
||||
|
||||
@router.post("/derivation-rules")
|
||||
def create_derivation_rule(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""新建派生规则(同KPI同类型唯一)"""
|
||||
kpi_id = data.get("kpi_id")
|
||||
rule_type = data.get("rule_type")
|
||||
if not kpi_id or rule_type not in ("incremental", "percentage_of", "formula"):
|
||||
raise HTTPException(400, "需要 kpi_id 且 rule_type ∈ incremental/percentage_of/formula")
|
||||
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == kpi_id,
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
|
||||
existing = db.query(BudgetDerivationRule).filter(
|
||||
BudgetDerivationRule.entity_id == entity_id,
|
||||
BudgetDerivationRule.kpi_id == kpi_id,
|
||||
BudgetDerivationRule.rule_type == rule_type,
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(400, f"KPI({kpi_id})已存在 {rule_type} 规则")
|
||||
|
||||
row = BudgetDerivationRule(
|
||||
entity_id=entity_id,
|
||||
kpi_id=kpi_id,
|
||||
rule_type=rule_type,
|
||||
base_kpi_id=data.get("base_kpi_id"),
|
||||
params=data.get("params"),
|
||||
formula_text=data.get("formula_text"),
|
||||
status=data.get("status", "active"),
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return {"message": "派生规则已创建", "id": row.id}
|
||||
|
||||
|
||||
@router.put("/derivation-rules/{rule_id}")
|
||||
def update_derivation_rule(
|
||||
rule_id: int,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""更新派生规则"""
|
||||
row = db.query(BudgetDerivationRule).filter(
|
||||
BudgetDerivationRule.id == rule_id,
|
||||
BudgetDerivationRule.entity_id == entity_id,
|
||||
).first()
|
||||
if not row:
|
||||
raise HTTPException(404, "规则不存在")
|
||||
for field in ("rule_type", "base_kpi_id", "params", "formula_text", "status"):
|
||||
if field in data:
|
||||
setattr(row, field, data[field])
|
||||
db.commit()
|
||||
return {"message": "派生规则已更新", "id": row.id}
|
||||
|
||||
|
||||
@router.delete("/derivation-rules/{rule_id}")
|
||||
def delete_derivation_rule(
|
||||
rule_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""删除派生规则"""
|
||||
row = db.query(BudgetDerivationRule).filter(
|
||||
BudgetDerivationRule.id == rule_id,
|
||||
BudgetDerivationRule.entity_id == entity_id,
|
||||
).first()
|
||||
if not row:
|
||||
raise HTTPException(404, "规则不存在")
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
return {"message": "派生规则已删除"}
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
@@ -0,0 +1,217 @@
|
||||
"""零基预算逐项论证 API — 管理会计OS (P2-① 2026-08-28)
|
||||
|
||||
CRUD 逐项论证项(budget_zero_based_items) + generate 生成零基预算写 budget_plans。
|
||||
method-comparison 的 zero_based 分支优先读论证项(有数据→逐项求和 is_demo=false)。
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import BudgetZeroBasedItem, KPIDefinition, BudgetPlan
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/cma/budget",
|
||||
tags=["零基预算"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "it"))],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/zero-based/items")
|
||||
def list_zero_based_items(
|
||||
kpi_id: Optional[int] = Query(None),
|
||||
period: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""零基逐项论证列表(kpi_id+period 过滤)"""
|
||||
query = db.query(BudgetZeroBasedItem).filter(BudgetZeroBasedItem.entity_id == entity_id)
|
||||
if kpi_id:
|
||||
query = query.filter(BudgetZeroBasedItem.kpi_id == kpi_id)
|
||||
if period:
|
||||
query = query.filter(BudgetZeroBasedItem.period == period)
|
||||
rows = query.order_by(BudgetZeroBasedItem.id.desc()).all()
|
||||
|
||||
kpi_ids = {r.kpi_id for r in rows}
|
||||
kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(kpi_ids)).all()} if kpi_ids else {}
|
||||
result = []
|
||||
for r in rows:
|
||||
kpi = kpis.get(r.kpi_id)
|
||||
result.append({
|
||||
"id": r.id,
|
||||
"kpi_id": r.kpi_id,
|
||||
"kpi_code": kpi.kpi_code if kpi else "",
|
||||
"kpi_name": kpi.kpi_name if kpi else "",
|
||||
"period": r.period,
|
||||
"item_name": r.item_name,
|
||||
"item_category": r.item_category,
|
||||
"base_value": r.base_value,
|
||||
"justification": r.justification,
|
||||
"proposed_value": r.proposed_value,
|
||||
"status": r.status,
|
||||
"created_by": r.created_by,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
})
|
||||
total_proposed = round(sum(r.proposed_value for r in rows), 2)
|
||||
return {"data": result, "total": len(result), "total_proposed": total_proposed}
|
||||
|
||||
|
||||
@router.post("/zero-based/items")
|
||||
def create_zero_based_item(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""新建逐项论证项"""
|
||||
kpi_id = data.get("kpi_id")
|
||||
period = data.get("period")
|
||||
item_name = data.get("item_name")
|
||||
base_value = data.get("base_value")
|
||||
proposed_value = data.get("proposed_value")
|
||||
if not kpi_id or not period or not item_name:
|
||||
raise HTTPException(400, "缺少必要参数: kpi_id, period, item_name")
|
||||
if base_value is None:
|
||||
base_value = 0
|
||||
if proposed_value is None:
|
||||
proposed_value = 0
|
||||
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == kpi_id,
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
|
||||
row = BudgetZeroBasedItem(
|
||||
entity_id=entity_id,
|
||||
kpi_id=kpi_id,
|
||||
period=period,
|
||||
item_name=item_name,
|
||||
item_category=data.get("item_category", "discretionary"),
|
||||
base_value=base_value,
|
||||
justification=data.get("justification"),
|
||||
proposed_value=proposed_value,
|
||||
status=data.get("status", "draft"),
|
||||
created_by=data.get("created_by") or (current_user.name if hasattr(current_user, "name") else None),
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return {"message": "论证项已创建", "id": row.id}
|
||||
|
||||
|
||||
@router.put("/zero-based/items/{item_id}")
|
||||
def update_zero_based_item(
|
||||
item_id: int,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""更新逐项论证项"""
|
||||
row = db.query(BudgetZeroBasedItem).filter(
|
||||
BudgetZeroBasedItem.id == item_id,
|
||||
BudgetZeroBasedItem.entity_id == entity_id,
|
||||
).first()
|
||||
if not row:
|
||||
raise HTTPException(404, "论证项不存在")
|
||||
for field in ("item_name", "item_category", "base_value", "justification",
|
||||
"proposed_value", "status"):
|
||||
if field in data:
|
||||
setattr(row, field, data[field])
|
||||
db.commit()
|
||||
return {"message": "论证项已更新", "id": row.id}
|
||||
|
||||
|
||||
@router.delete("/zero-based/items/{item_id}")
|
||||
def delete_zero_based_item(
|
||||
item_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""删除逐项论证项"""
|
||||
row = db.query(BudgetZeroBasedItem).filter(
|
||||
BudgetZeroBasedItem.id == item_id,
|
||||
BudgetZeroBasedItem.entity_id == entity_id,
|
||||
).first()
|
||||
if not row:
|
||||
raise HTTPException(404, "论证项不存在")
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
return {"message": "论证项已删除"}
|
||||
|
||||
|
||||
@router.post("/zero-based/generate")
|
||||
def generate_zero_based_budget(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""生成零基预算 = Σ(proposed_value),写 budget_plans(version='zbb-YYYYMMDD',calc_logic='zero_based_itemized')"""
|
||||
kpi_id = data.get("kpi_id")
|
||||
period = data.get("period")
|
||||
year = data.get("year")
|
||||
if not kpi_id or not period:
|
||||
raise HTTPException(400, "缺少必要参数: kpi_id, period")
|
||||
|
||||
items = db.query(BudgetZeroBasedItem).filter(
|
||||
BudgetZeroBasedItem.entity_id == entity_id,
|
||||
BudgetZeroBasedItem.kpi_id == kpi_id,
|
||||
BudgetZeroBasedItem.period == period,
|
||||
).all()
|
||||
if not items:
|
||||
raise HTTPException(400, f"期间 {period} 无逐项论证项,请先录入")
|
||||
|
||||
total = round(sum(i.proposed_value for i in items), 2)
|
||||
# 解析年份
|
||||
if not year:
|
||||
try:
|
||||
year = int(period.split("-")[0])
|
||||
except Exception:
|
||||
year = datetime.now().year
|
||||
month = 0
|
||||
try:
|
||||
month = int(period.split("-")[1]) if "-" in period else 0
|
||||
except Exception:
|
||||
month = 0
|
||||
|
||||
version = f"zbb-{datetime.now().strftime('%Y%m%d')}"
|
||||
# 删除同KPI同期间同版本旧预算,防重复
|
||||
db.query(BudgetPlan).filter(
|
||||
BudgetPlan.entity_id == entity_id,
|
||||
BudgetPlan.kpi_id == kpi_id,
|
||||
BudgetPlan.period == period,
|
||||
BudgetPlan.version == version,
|
||||
).delete()
|
||||
|
||||
bp = BudgetPlan(
|
||||
entity_id=entity_id,
|
||||
kpi_id=kpi_id,
|
||||
period=period,
|
||||
budget_value=total,
|
||||
budget_year=year,
|
||||
budget_month=month,
|
||||
version=version,
|
||||
status="active",
|
||||
source_type="zero_based",
|
||||
calc_logic="zero_based_itemized",
|
||||
remark=f"零基逐项论证生成: {len(items)}项 Σ(proposed_value)={total}",
|
||||
)
|
||||
db.add(bp)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"零基预算已生成: {total}({len(items)}项论证)",
|
||||
"kpi_id": kpi_id,
|
||||
"period": period,
|
||||
"total": total,
|
||||
"item_count": len(items),
|
||||
"version": version,
|
||||
"plan_id": bp.id,
|
||||
}
|
||||
@@ -97,6 +97,47 @@ def init_db():
|
||||
except Exception as e:
|
||||
logger.warning(f"user_entities初始化跳过: {e}")
|
||||
|
||||
# ── scenario_suggestions 告警场景建议 seed(2026-08-28 告警归因 P1-③)──
|
||||
# 幂等:仅补缺失的 alert_type,不覆盖已有模板
|
||||
try:
|
||||
inspector = inspect(get_engine())
|
||||
if "scenario_suggestions" in inspector.get_table_names():
|
||||
Session = get_session_local()
|
||||
session = Session()
|
||||
try:
|
||||
from app.models import ScenarioSuggestion
|
||||
existing_types = {s.alert_type for s in session.query(ScenarioSuggestion).all()}
|
||||
seeds = [
|
||||
dict(alert_type="cash_low", title="现金流紧张 — 加强回款催收",
|
||||
description="现金余额接近警戒线,建议优先处理应收款项,压缩非紧急支出。",
|
||||
action_template="1. 列出未来30天应收清单,逐笔催收\n2. 暂停非紧急采购/费用支出\n3. 与银行沟通短期授信额度",
|
||||
priority="high", sort_order=1),
|
||||
dict(alert_type="cash_critical", title="现金流危急 — 立即止血",
|
||||
description="现金余额已低于安全阈值,存在断流风险,需要立即采取止血措施。",
|
||||
action_template="1. 冻结一切非必要支出\n2. 高管紧急复盘资金计划\n3. 启动应收账款特别催收\n4. 评估短期融资",
|
||||
priority="high", sort_order=2),
|
||||
dict(alert_type="cost_high", title="成本超支 — 核查费用构成",
|
||||
description="实际成本超出预算,建议拆解到科目明细定位超支源头。",
|
||||
action_template="1. 查看科目明细拆解,定位超支前3科目\n2. 分析价差/量差成因(单价上涨/用量增加)\n3. 对可控费用制定压降方案",
|
||||
priority="medium", sort_order=3),
|
||||
dict(alert_type="revenue_drop", title="收入下滑 — 追量提效",
|
||||
description="实际收入低于预算,建议从子KPI量级分解查找差距来源。",
|
||||
action_template="1. 查看子KPI拆解,定位量差最大维度\n2. 分析客户/渠道/产品线缺口\n3. 制定增量获客或转化提升方案",
|
||||
priority="medium", sort_order=4),
|
||||
]
|
||||
added = 0
|
||||
for s in seeds:
|
||||
if s["alert_type"] not in existing_types:
|
||||
session.add(ScenarioSuggestion(**s))
|
||||
added += 1
|
||||
if added:
|
||||
session.commit()
|
||||
logger.info(f"scenario_suggestions seed: 新增{added}条场景建议模板")
|
||||
finally:
|
||||
session.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"scenario_suggestions seed跳过: {e}")
|
||||
|
||||
|
||||
def _seed_org_data(db_session):
|
||||
"""插入5层级组织示例数据"""
|
||||
|
||||
+5
-1
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Binary file not shown.
@@ -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"
|
||||
@@ -8,15 +8,40 @@
|
||||
4. 差异预警触发(集成到现有预警系统)
|
||||
"""
|
||||
import logging
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from app.database import get_session_local
|
||||
from app.models import KPIDefinition, KPIValue, KPIAlert, BudgetPlan
|
||||
from app.models import KPIDefinition, KPIValue, KPIAlert, BudgetPlan, SystemConfig
|
||||
|
||||
logger = logging.getLogger("cma.deviation")
|
||||
|
||||
|
||||
# 越高越好型KPI默认列表(P2-⑤ 2026-08-28: 提为 system_configs 可配置)
|
||||
DEFAULT_HIGHER_BETTER = [
|
||||
"SALES_TOTAL", "CUSTOMER_COUNT", "SALES_PROFIT_RATE",
|
||||
"RECEIVABLE_TURNOVER", "TURNOVER_RATE",
|
||||
"CUSTOMER_SATISFACTION", "ORDER_DELIVERY_RATE",
|
||||
]
|
||||
CONFIG_KEY_HIGHER_BETTER = "kpi_alert_higher_better"
|
||||
|
||||
|
||||
def get_higher_better_codes(db, entity_id: int = None) -> list:
|
||||
"""读取越高越好型KPI编码列表(system_configs 可维护,无配置回落默认)"""
|
||||
cfg = db.query(SystemConfig).filter(
|
||||
SystemConfig.config_key == CONFIG_KEY_HIGHER_BETTER
|
||||
).first()
|
||||
if cfg and cfg.config_value:
|
||||
try:
|
||||
codes = json.loads(cfg.config_value)
|
||||
if isinstance(codes, list):
|
||||
return [str(c) for c in codes]
|
||||
except Exception:
|
||||
logger.warning("system_configs[%s] 解析失败, 回落默认", CONFIG_KEY_HIGHER_BETTER)
|
||||
return list(DEFAULT_HIGHER_BETTER)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 差异计算
|
||||
# ============================================================
|
||||
@@ -210,8 +235,83 @@ def check_trend_anomaly(db, kpi_id: int, period: str, consecutive: int = 3) -> d
|
||||
# 差异预警触发
|
||||
# ============================================================
|
||||
|
||||
def build_deviation_alert(db, kpi, period: str, entity_id: int = 1, min_rate: float = 10.0) -> dict:
|
||||
"""统一告警构建 — 双出口共享一套逻辑 (P2-⑤ 2026-08-28)
|
||||
|
||||
预算告警(budget_deviation_alerts) 与 KPIAlert 都调用本函数,差异仅级别映射:
|
||||
- budget 出口: warning/critical @ 20/50
|
||||
- KPIAlert 出口: yellow/red @ 10/30
|
||||
归因(P1-③): attribution 拆解 + 场景建议 由 alert_attribution 组装。
|
||||
|
||||
返回:
|
||||
triggered: bool 是否触发
|
||||
level: budget出口级别 warning/critical
|
||||
kpi_alert_level: KPIAlert出口级别 yellow/red
|
||||
deviation: calc_period_deviation 结果
|
||||
suggestion: 模板建议文案
|
||||
alert_type: 归因场景类型
|
||||
attribution: 归因JSON dict
|
||||
scenario_id: 场景建议ID
|
||||
"""
|
||||
from app.utils.alert_attribution import build_attribution, match_scenario
|
||||
|
||||
deviation = calc_period_deviation(db, kpi.id, period)
|
||||
if deviation.get("deviation_rate") is None:
|
||||
return {"triggered": False}
|
||||
|
||||
rate = abs(deviation["deviation_rate"])
|
||||
actual = deviation.get("actual_value")
|
||||
budget = deviation.get("budget_value")
|
||||
|
||||
# 方向性:越高越好型(配置化,system_configs.kpi_alert_higher_better)
|
||||
higher_better = kpi.kpi_code in get_higher_better_codes(db, entity_id)
|
||||
|
||||
if higher_better:
|
||||
# 实际低于预算才是问题
|
||||
if not (actual is not None and budget is not None and actual < budget and rate >= min_rate):
|
||||
return {"triggered": False}
|
||||
suggestion = (
|
||||
f"实际值低于预算 {rate}%,建议分析业务量未达预期的原因(子KPI拆解见归因),"
|
||||
f"制定增量获客或转化提升计划"
|
||||
)
|
||||
else:
|
||||
# 实际高于预算才是问题(成本型)
|
||||
if not (actual is not None and budget is not None and actual > budget and rate >= min_rate):
|
||||
return {"triggered": False}
|
||||
suggestion = (
|
||||
f"实际值超出预算 {rate}%,建议核查超支原因(科目明细拆解见归因)并采取控制措施"
|
||||
)
|
||||
|
||||
# 级别映射(双出口)
|
||||
budget_level = "critical" if rate > 50 else "warning"
|
||||
kpi_alert_level = "red" if rate >= 30 else "yellow"
|
||||
|
||||
# 归因组装 (P1-③)
|
||||
alert_type = None
|
||||
attribution = None
|
||||
scenario_id = None
|
||||
try:
|
||||
attribution, alert_type = build_attribution(db, kpi.id, period)
|
||||
scenario = match_scenario(db, alert_type)
|
||||
if scenario:
|
||||
scenario_id = scenario["scenario_id"]
|
||||
except Exception as e: # 归因失败不阻断告警主流程
|
||||
logger.warning("归因组装失败 kpi=%s: %s", kpi.kpi_code, e)
|
||||
|
||||
return {
|
||||
"triggered": True,
|
||||
"level": budget_level,
|
||||
"kpi_alert_level": kpi_alert_level,
|
||||
"deviation": deviation,
|
||||
"suggestion": suggestion,
|
||||
"alert_type": alert_type,
|
||||
"attribution": attribution,
|
||||
"scenario_id": scenario_id,
|
||||
}
|
||||
|
||||
|
||||
def run_deviation_check(db_session, period: str = None) -> int:
|
||||
"""运行差异预警检查,返回新增预警数"""
|
||||
"""运行差异预警检查,返回新增预警数(统一走 build_deviation_alert,P2-⑤)"""
|
||||
if period is None:
|
||||
period = datetime.now().strftime("%Y-%m")
|
||||
|
||||
@@ -221,33 +321,10 @@ def run_deviation_check(db_session, period: str = None) -> int:
|
||||
|
||||
new_count = 0
|
||||
for kpi in kpis:
|
||||
# 1. 差异预警:实际 vs 预算
|
||||
deviation = calc_period_deviation(db_session, kpi.id, period)
|
||||
if deviation.get("deviation_rate") is not None:
|
||||
rate = abs(deviation["deviation_rate"])
|
||||
|
||||
# 差异化阈值:越高越好型 vs 越低越好型
|
||||
higher_better = kpi.kpi_code in [
|
||||
"SALES_TOTAL", "CUSTOMER_COUNT", "SALES_PROFIT_RATE",
|
||||
"RECEIVABLE_TURNOVER", "TURNOVER_RATE",
|
||||
"CUSTOMER_SATISFACTION", "ORDER_DELIVERY_RATE",
|
||||
]
|
||||
|
||||
if higher_better:
|
||||
# 实际低于预算才是问题
|
||||
if deviation["actual_value"] < deviation["budget_value"] and rate >= 10:
|
||||
level = "yellow" if rate >= 10 else "green"
|
||||
level = "red" if rate >= 30 else level
|
||||
else:
|
||||
continue
|
||||
else:
|
||||
# 实际高于预算才是问题(成本型)
|
||||
if deviation["actual_value"] > deviation["budget_value"] and rate >= 10:
|
||||
level = "yellow" if rate >= 10 else "green"
|
||||
level = "red" if rate >= 30 else level
|
||||
else:
|
||||
continue
|
||||
|
||||
# 1. 差异预警:实际 vs 预算(统一逻辑)
|
||||
result = build_deviation_alert(db_session, kpi, period)
|
||||
if result["triggered"]:
|
||||
deviation = result["deviation"]
|
||||
alert_msg = (
|
||||
f"{kpi.kpi_name}[{period}] 差异预警: 实际{deviation['actual_value']} "
|
||||
f"vs 预算{deviation['budget_value']},"
|
||||
@@ -265,13 +342,15 @@ def run_deviation_check(db_session, period: str = None) -> int:
|
||||
if not existing:
|
||||
alert = KPIAlert(
|
||||
kpi_id=kpi.id,
|
||||
alert_level=level,
|
||||
alert_level=result["kpi_alert_level"],
|
||||
alert_message=f"[差异预警] {alert_msg}",
|
||||
alert_type=result["alert_type"] or "actual",
|
||||
suggestion=result["suggestion"],
|
||||
status="pending",
|
||||
)
|
||||
db_session.add(alert)
|
||||
new_count += 1
|
||||
logger.info(f" 新增差异预警 [{level}] {kpi.kpi_name}: 差异率{deviation['deviation_rate']}%")
|
||||
logger.info(f" 新增差异预警 [{result['kpi_alert_level']}] {kpi.kpi_name}: 差异率{deviation['deviation_rate']}%")
|
||||
|
||||
# 2. 趋势异常检测(每期检查连续3期)
|
||||
trend = check_trend_anomaly(db_session, kpi.id, period, consecutive=3)
|
||||
|
||||
Reference in New Issue
Block a user