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)
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,462 @@
|
||||
"""预算系统技术改进测试 (2026-08-28 yanxue-budget-tech-improve)
|
||||
|
||||
覆盖:
|
||||
P1-③ 告警归因(alert_type/attribution/scenario_id + 详情接口)
|
||||
P1-④ 实际值自动归集(映射CRUD/采集器/覆盖率)
|
||||
P2-① 真零基逐项论证(CRUD/generate/method-comparison is_demo)
|
||||
P2-② 派生规则可配置(规则CRUD/apply-method rule_source)
|
||||
P2-⑤ 双路径合并(两出口级别一致, 无第二套阈值逻辑)
|
||||
P2-⑥ 现金流分类规则(待分类队列/一键归类)
|
||||
"""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from tests.conftest import create_test_user, get_token_for_user, auth_header, create_test_kpi
|
||||
from app.models import (
|
||||
BudgetPlan, KPIValue, BudgetDeviationAlert, KPIAlert,
|
||||
KPIValueSource, KPIValueCollectLog,
|
||||
BudgetZeroBasedItem, BudgetDerivationRule,
|
||||
CashPlanClassifyRule, CashPlanUnclassified, CashPlan,
|
||||
ScenarioSuggestion, KPIDefinition,
|
||||
)
|
||||
|
||||
|
||||
class TestP1AlertAttribution:
|
||||
"""P1-③ 告警归因: 告警从'差多少'到'差在哪+怎么办'"""
|
||||
|
||||
BASE = "/api/cma/budget"
|
||||
|
||||
def _setup_alert(self, client, db, kpi_code="ATTRIB_KPI", kpi_name="销售费用", actual=150.0, budget=100.0):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db, kpi_code=kpi_code, kpi_name=kpi_name)
|
||||
db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=budget,
|
||||
budget_year=2026, budget_month=6, status="active"))
|
||||
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=actual))
|
||||
db.commit()
|
||||
resp = client.post(f"{self.BASE}/deviation-check", headers=auth_header(token),
|
||||
json={"period": "2026-06", "threshold": 20})
|
||||
assert resp.status_code == 200
|
||||
return token, kpi, resp
|
||||
|
||||
def test_deviation_check_writes_attribution(self, client: TestClient, db: Session):
|
||||
"""生成告警时同步写 alert_type/attribution/scenario_id"""
|
||||
token, kpi, resp = self._setup_alert(client, db, actual=150.0, budget=100.0)
|
||||
assert resp.json()["alerts_generated"] == 1
|
||||
|
||||
alert = db.query(BudgetDeviationAlert).filter(BudgetDeviationAlert.kpi_id == kpi.id).first()
|
||||
assert alert is not None
|
||||
assert alert.alert_level == "warning"
|
||||
# 归因JSON结构
|
||||
assert alert.attribution is not None
|
||||
attr = alert.attribution
|
||||
assert "dimensions" in attr and "subjects" in attr
|
||||
assert "variance_type" in attr and "trend" in attr
|
||||
assert attr["variance_type"] in ("quantity_diff", "price_diff", "mixed")
|
||||
assert "anomaly" in attr["trend"]
|
||||
# 场景建议关联(费用类KPI → cost_high 模板)
|
||||
if alert.scenario_id:
|
||||
s = db.query(ScenarioSuggestion).filter(ScenarioSuggestion.id == alert.scenario_id).first()
|
||||
assert s is not None
|
||||
assert s.alert_type in ("cash_low", "cash_critical", "cost_high", "revenue_drop")
|
||||
|
||||
def test_attribution_detail_endpoint(self, client: TestClient, db: Session):
|
||||
"""GET /deviation-alerts/{id}/attribution 返回归因+场景建议"""
|
||||
token, kpi, _ = self._setup_alert(client, db, actual=200.0, budget=100.0)
|
||||
alert = db.query(BudgetDeviationAlert).filter(BudgetDeviationAlert.kpi_id == kpi.id).first()
|
||||
|
||||
resp = client.get(f"{self.BASE}/deviation-alerts/{alert.id}/attribution",
|
||||
headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["attribution"] != {}
|
||||
assert "dimensions" in data["attribution"]
|
||||
# scenario 建议联查(无匹配时可空, 有模板时必须带文本)
|
||||
if data["scenario"]:
|
||||
assert data["scenario"]["title"]
|
||||
|
||||
def test_list_alerts_has_attribution_fields(self, client: TestClient, db: Session):
|
||||
"""列表响应新增 alert_type/attribution/scenario_id 字段(可空)"""
|
||||
token, kpi, _ = self._setup_alert(client, db)
|
||||
resp = client.get(f"{self.BASE}/deviation-alerts", headers=auth_header(token))
|
||||
row = resp.json()["data"][0]
|
||||
assert "alert_type" in row
|
||||
assert "attribution" in row
|
||||
assert "scenario_id" in row
|
||||
|
||||
def test_alert_direction_config(self, client: TestClient, db: Session):
|
||||
"""P2-⑤ 方向配置 GET/PUT system_configs"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.get(f"{self.BASE}/alert-direction", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert "SALES_TOTAL" in resp.json()["codes"]
|
||||
|
||||
resp2 = client.put(f"{self.BASE}/alert-direction", headers=auth_header(token),
|
||||
json={"codes": ["SALES_TOTAL", "CUSTOM_COUNT"]})
|
||||
assert resp2.status_code == 200
|
||||
assert resp2.json()["codes"] == ["SALES_TOTAL", "CUSTOM_COUNT"]
|
||||
|
||||
resp3 = client.get(f"{self.BASE}/alert-direction", headers=auth_header(token))
|
||||
assert resp3.json()["codes"] == ["SALES_TOTAL", "CUSTOM_COUNT"]
|
||||
assert resp3.json()["is_configured"] is True
|
||||
|
||||
|
||||
class TestP1ValueCollect:
|
||||
"""P1-④ 实际值自动归集"""
|
||||
|
||||
BASE = "/api/cma/budget"
|
||||
|
||||
def test_value_source_crud_and_collect(self, client: TestClient, db: Session):
|
||||
"""映射CRUD → 采集器 → kpi_values 出现 auto_collect"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db, kpi_code="COLLECT_KPI")
|
||||
|
||||
# 建映射
|
||||
resp = client.post(f"{self.BASE}/value-sources", headers=auth_header(token), json={
|
||||
"kpi_id": kpi.id,
|
||||
"source_table": "voucher_details",
|
||||
"source_field": "credit_amount",
|
||||
"aggregate": "sum",
|
||||
"filter_rule": {"direction": "credit"},
|
||||
"period_field": "period",
|
||||
"unit_conversion": 1,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
# 采集器试跑(不写库)
|
||||
test_resp = client.post(f"{self.BASE}/value-sources/test", headers=auth_header(token), json={
|
||||
"kpi_id": kpi.id,
|
||||
"source_table": "voucher_details",
|
||||
"source_field": "credit_amount",
|
||||
"aggregate": "sum",
|
||||
"filter_rule": {"direction": "credit"},
|
||||
"period_field": "period",
|
||||
})
|
||||
assert test_resp.status_code == 200
|
||||
assert test_resp.json()["value"] is not None
|
||||
|
||||
# 手动触发采集
|
||||
run_resp = client.post(f"{self.BASE}/value-collect/run", headers=auth_header(token),
|
||||
json={"period": "2026-06"})
|
||||
assert run_resp.status_code == 200
|
||||
assert run_resp.json()["collected"] >= 1
|
||||
|
||||
# 验证 kpi_values 落库
|
||||
val = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
KPIValue.period == "2026-06",
|
||||
KPIValue.source_type == "auto_collect",
|
||||
).first()
|
||||
assert val is not None
|
||||
assert val.actual_value is not None
|
||||
assert val.remark and "自动归集" in val.remark
|
||||
|
||||
# 采集日志
|
||||
logs = db.query(KPIValueCollectLog).filter(KPIValueCollectLog.kpi_id == kpi.id).all()
|
||||
assert len(logs) >= 1
|
||||
|
||||
# 覆盖率
|
||||
cov = client.get(f"{self.BASE}/value-sources/coverage", headers=auth_header(token))
|
||||
assert cov.status_code == 200
|
||||
assert cov.json()["mapped_count"] >= 1
|
||||
|
||||
def test_collector_idempotent(self, client: TestClient, db: Session):
|
||||
"""同kpi+period 重复采集 → 更新不新增"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db, kpi_code="COLLECT_IDEMP")
|
||||
client.post(f"{self.BASE}/value-sources", headers=auth_header(token), json={
|
||||
"kpi_id": kpi.id, "source_table": "voucher_details",
|
||||
"source_field": "credit_amount", "aggregate": "sum",
|
||||
})
|
||||
client.post(f"{self.BASE}/value-collect/run", headers=auth_header(token), json={"period": "2026-06"})
|
||||
client.post(f"{self.BASE}/value-collect/run", headers=auth_header(token), json={"period": "2026-06"})
|
||||
|
||||
rows = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
KPIValue.period == "2026-06",
|
||||
KPIValue.source_type == "auto_collect",
|
||||
).all()
|
||||
assert len(rows) == 1
|
||||
|
||||
def test_collector_logs_filter(self, client: TestClient, db: Session):
|
||||
"""采集日志 status 过滤"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db, kpi_code="COLLECT_LOG")
|
||||
client.post(f"{self.BASE}/value-sources", headers=auth_header(token), json={
|
||||
"kpi_id": kpi.id, "source_table": "voucher_details",
|
||||
"source_field": "credit_amount", "aggregate": "sum",
|
||||
})
|
||||
client.post(f"{self.BASE}/value-collect/run", headers=auth_header(token), json={"period": "2026-06"})
|
||||
resp = client.get(f"{self.BASE}/value-collect/logs", headers=auth_header(token),
|
||||
params={"status": "success"})
|
||||
assert resp.json()["total"] >= 1
|
||||
|
||||
|
||||
class TestP2ZeroBased:
|
||||
"""P2-① 真零基逐项论证"""
|
||||
|
||||
BASE = "/api/cma/budget"
|
||||
|
||||
def _setup_kpi_with_plans(self, client, db):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
# 核心4KPI(apply-method 需要)
|
||||
for code in ("F_REVENUE", "F_NET_PROFIT", "F_COST_RATIO", "F_GROSS_MARGIN"):
|
||||
create_test_kpi(db, kpi_code=code, kpi_name=code)
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == "F_REVENUE").first()
|
||||
return token, kpi
|
||||
|
||||
def test_zero_based_items_crud_and_generate(self, client: TestClient, db: Session):
|
||||
"""录入3个科目 → 逐项论证 → generate → budget_plans 出现且金额=Σ建议值"""
|
||||
token, kpi = self._setup_kpi_with_plans(client, db)
|
||||
|
||||
# 录入3个论证项
|
||||
items = [
|
||||
{"item_name": "房租", "item_category": "fixed", "base_value": 15, "proposed_value": 15, "justification": "合同锁定"},
|
||||
{"item_name": "招待费", "item_category": "discretionary", "base_value": 16, "proposed_value": 8, "justification": "压缩50%"},
|
||||
{"item_name": "杂项", "item_category": "discretionary", "base_value": 12, "proposed_value": 8, "justification": "压缩30%"},
|
||||
]
|
||||
for it in items:
|
||||
r = client.post(f"{self.BASE}/zero-based/items", headers=auth_header(token), json={
|
||||
"kpi_id": kpi.id, "period": "2026-06", **it,
|
||||
})
|
||||
assert r.status_code == 200
|
||||
|
||||
# 列表+合计
|
||||
lst = client.get(f"{self.BASE}/zero-based/items", headers=auth_header(token),
|
||||
params={"kpi_id": kpi.id, "period": "2026-06"})
|
||||
assert lst.json()["total"] == 3
|
||||
assert lst.json()["total_proposed"] == 31.0
|
||||
|
||||
# generate → budget_plans
|
||||
gen = client.post(f"{self.BASE}/zero-based/generate", headers=auth_header(token),
|
||||
json={"kpi_id": kpi.id, "period": "2026-06"})
|
||||
assert gen.status_code == 200
|
||||
assert gen.json()["total"] == 31.0
|
||||
|
||||
plan = db.query(BudgetPlan).filter(
|
||||
BudgetPlan.kpi_id == kpi.id,
|
||||
BudgetPlan.period == "2026-06",
|
||||
BudgetPlan.version.like("zbb-%"),
|
||||
).first()
|
||||
assert plan is not None
|
||||
assert plan.budget_value == 31.0
|
||||
assert plan.calc_logic == "zero_based_itemized"
|
||||
|
||||
def test_method_comparison_zero_based_is_demo_false(self, client: TestClient, db: Session):
|
||||
"""method-comparison 传论证KPI → is_demo=false; 不传 → is_demo=true"""
|
||||
token, kpi = self._setup_kpi_with_plans(client, db)
|
||||
client.post(f"{self.BASE}/zero-based/items", headers=auth_header(token), json={
|
||||
"kpi_id": kpi.id, "period": "2026-06",
|
||||
"item_name": "房租", "item_category": "fixed",
|
||||
"base_value": 15, "proposed_value": 15,
|
||||
})
|
||||
|
||||
# 有论证项 → 真零基
|
||||
r1 = client.post(f"{self.BASE}/method-comparison", headers=auth_header(token), json={
|
||||
"zero_based_kpi_id": kpi.id, "zero_based_period": "2026-06",
|
||||
})
|
||||
zbb1 = [m for m in r1.json()["methods"] if m["id"] == "zero_based"][0]
|
||||
assert zbb1["is_demo"] is False
|
||||
assert zbb1["item_count"] == 1
|
||||
|
||||
# 无论证项 → demo fallback
|
||||
r2 = client.post(f"{self.BASE}/method-comparison", headers=auth_header(token), json={})
|
||||
zbb2 = [m for m in r2.json()["methods"] if m["id"] == "zero_based"][0]
|
||||
assert zbb2["is_demo"] is True
|
||||
|
||||
def test_apply_method_zero_based_writes_plan(self, client: TestClient, db: Session):
|
||||
"""apply-method zero_based → 落库 zbb 版本"""
|
||||
token, kpi = self._setup_kpi_with_plans(client, db)
|
||||
client.post(f"{self.BASE}/zero-based/items", headers=auth_header(token), json={
|
||||
"kpi_id": kpi.id, "period": "2026-06",
|
||||
"item_name": "房租", "item_category": "fixed",
|
||||
"base_value": 15, "proposed_value": 15,
|
||||
})
|
||||
r = client.post(f"{self.BASE}/apply-method", headers=auth_header(token), json={
|
||||
"method": "zero_based", "year": 2026,
|
||||
"zero_based_kpi_id": kpi.id, "zero_based_period": "2026-06",
|
||||
})
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
class TestP2DerivationRules:
|
||||
"""P2-② 派生规则可配置"""
|
||||
|
||||
BASE = "/api/cma/budget"
|
||||
|
||||
def _setup(self, client, db):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
for code in ("F_REVENUE", "F_NET_PROFIT", "F_COST_RATIO", "F_GROSS_MARGIN"):
|
||||
create_test_kpi(db, kpi_code=code, kpi_name=code)
|
||||
return token
|
||||
|
||||
def test_rule_crud_and_apply(self, client: TestClient, db: Session):
|
||||
"""配置 F_NET_PROFIT 派生率 5% → apply-method → rule_source=configured 且结果变化"""
|
||||
token = self._setup(client, db)
|
||||
rev = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == "F_REVENUE").first()
|
||||
np_kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == "F_NET_PROFIT").first()
|
||||
|
||||
# 无规则时 apply → default 比例(2%)
|
||||
r_default = client.post(f"{self.BASE}/apply-method", headers=auth_header(token),
|
||||
json={"method": "incremental", "year": 2026})
|
||||
np_default = [a for a in r_default.json()["applied"] if a["kpi_code"] == "F_NET_PROFIT"][0]
|
||||
assert np_default["rule_source"] == "default"
|
||||
assert r_default.json()["rule_source"] == "default"
|
||||
|
||||
# 建规则: percentage_of → 来源F_REVENUE × 5%
|
||||
# 先给来源KPI实际值(真实链路: base_kpi实际值 × rate)
|
||||
db.add(KPIValue(kpi_id=rev.id, period="2026-05", actual_value=2000.0))
|
||||
db.commit()
|
||||
r_rule = client.post(f"{self.BASE}/derivation-rules", headers=auth_header(token), json={
|
||||
"kpi_id": np_kpi.id,
|
||||
"rule_type": "percentage_of",
|
||||
"base_kpi_id": rev.id,
|
||||
"params": {"rate": 0.05},
|
||||
"formula_text": "净利润 = 营业收入 × 5%",
|
||||
})
|
||||
assert r_rule.status_code == 200
|
||||
|
||||
# 配置后 apply → rule_source=configured, 金额=2000×5%=100
|
||||
r2 = client.post(f"{self.BASE}/apply-method", headers=auth_header(token),
|
||||
json={"method": "incremental", "year": 2026})
|
||||
assert r2.json()["rule_source"] == "configured"
|
||||
np_after = [a for a in r2.json()["applied"] if a["kpi_code"] == "F_NET_PROFIT"][0]
|
||||
assert np_after["rule_source"] == "configured"
|
||||
assert np_after["budget_value"] == 100.0
|
||||
|
||||
# 规则列表
|
||||
lst = client.get(f"{self.BASE}/derivation-rules", headers=auth_header(token))
|
||||
assert lst.json()["total"] == 1
|
||||
assert lst.json()["data"][0]["rule_type"] == "percentage_of"
|
||||
|
||||
|
||||
class TestP2SingleAlertPath:
|
||||
"""P2-⑤ 双路径合并: 单一告警逻辑, 两出口级别一致"""
|
||||
|
||||
BASE = "/api/cma/budget"
|
||||
|
||||
def test_single_build_function_two_exits(self, client: TestClient, db: Session):
|
||||
"""run_deviation_check 走统一逻辑写 KPIAlert; deviation-check 写 budget_deviation_alerts"""
|
||||
from app.utils.deviation_engine import build_deviation_alert, run_deviation_check
|
||||
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db, kpi_code="SINGLE_PATH_KPI", kpi_name="测试成本")
|
||||
db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=100.0,
|
||||
budget_year=2026, budget_month=6, status="active"))
|
||||
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=160.0)) # 60% 超支
|
||||
db.commit()
|
||||
|
||||
# KPIAlert 出口: 级别 red(≥30)
|
||||
r = build_deviation_alert(db, kpi, "2026-06")
|
||||
assert r["triggered"] is True
|
||||
assert r["kpi_alert_level"] == "red"
|
||||
assert r["level"] == "critical" # >50
|
||||
|
||||
# budget 出口 API: deviation-check
|
||||
resp = client.post(f"{self.BASE}/deviation-check", headers=auth_header(token),
|
||||
json={"period": "2026-06", "threshold": 20})
|
||||
assert resp.json()["alerts_generated"] == 1
|
||||
|
||||
# run_deviation_check 写 KPIAlert
|
||||
n = run_deviation_check(db, "2026-06")
|
||||
assert n >= 1
|
||||
kpi_alert = db.query(KPIAlert).filter(
|
||||
KPIAlert.kpi_id == kpi.id,
|
||||
KPIAlert.alert_message.contains("[差异预警]"),
|
||||
).first()
|
||||
assert kpi_alert is not None
|
||||
assert kpi_alert.alert_level == "red"
|
||||
assert kpi_alert.suggestion # 非模板空文案
|
||||
|
||||
def test_no_second_threshold_logic(self, client: TestClient, db: Session):
|
||||
"""deviation_engine 中不应再有独立阈值/方向列表(grep 验证在代码review, 此处测函数可用)"""
|
||||
from app.utils.deviation_engine import build_deviation_alert, get_higher_better_codes
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db, kpi_code="HB_KPI", kpi_name="营业收入")
|
||||
assert "SALES_TOTAL" in get_higher_better_codes(db)
|
||||
|
||||
|
||||
class TestP2CashClassify:
|
||||
"""P2-⑥ 现金流分类规则表"""
|
||||
|
||||
BASE = "/api/cma/budget"
|
||||
|
||||
def test_unclassified_queue_and_classify(self, client: TestClient, db: Session):
|
||||
"""无关键词KPI → sync-cash-plans → 待分类队列(不静默跳过) → 一键归类 → CashPlan"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
# 无任何关键词的KPI(不会命中默认关键词)
|
||||
kpi = create_test_kpi(db, kpi_code="MYSTERY_KPI", kpi_name="部门专项投入待定")
|
||||
# 移除'投入'关键词冲突: 名称改无关键词
|
||||
kpi.kpi_name = "神秘专项"
|
||||
db.commit()
|
||||
|
||||
client.post(f"{self.BASE}/plans", headers=auth_header(token),
|
||||
json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 300.0})
|
||||
|
||||
# sync → 进待分类队列
|
||||
resp = client.post(f"{self.BASE}/sync-cash-plans", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["unclassified_count"] >= 1
|
||||
|
||||
item = db.query(CashPlanUnclassified).filter(
|
||||
CashPlanUnclassified.kpi_id == kpi.id,
|
||||
CashPlanUnclassified.status == "pending",
|
||||
).first()
|
||||
assert item is not None
|
||||
assert item.reason == "未匹配任何分类规则"
|
||||
|
||||
# 队列列表
|
||||
lst = client.get(f"{self.BASE}/cash-unclassified", headers=auth_header(token),
|
||||
params={"status": "pending"})
|
||||
assert any(r["kpi_id"] == kpi.id for r in lst.json()["data"])
|
||||
|
||||
# 一键归类 receive
|
||||
cls = client.post(f"{self.BASE}/cash-unclassified/{item.id}/classify", headers=auth_header(token),
|
||||
json={"plan_type": "receive"})
|
||||
assert cls.status_code == 200
|
||||
assert cls.json()["rule_created"] is True
|
||||
|
||||
# 规则自动补建 + CashPlan 生成
|
||||
rule = db.query(CashPlanClassifyRule).filter(
|
||||
CashPlanClassifyRule.entity_id == 1,
|
||||
CashPlanClassifyRule.kpi_id == kpi.id,
|
||||
).first()
|
||||
assert rule is not None and rule.plan_type == "receive"
|
||||
|
||||
plan = db.query(CashPlan).filter(CashPlan.related_kpi_id == kpi.id).first()
|
||||
assert plan is not None and plan.plan_type == "receive"
|
||||
|
||||
# 队列状态 → classified
|
||||
db.refresh(item)
|
||||
assert item.status == "classified"
|
||||
|
||||
def test_rule_priority_over_keyword(self, client: TestClient, db: Session):
|
||||
"""规则表精确匹配优先于默认关键词"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db, kpi_code="OVERRIDE_KPI", kpi_name="营业收入") # 默认会命中 receive
|
||||
|
||||
# 规则表强制 pay
|
||||
r = client.post(f"{self.BASE}/cash-classify-rules", headers=auth_header(token), json={
|
||||
"kpi_id": kpi.id, "plan_type": "pay", "priority": 1,
|
||||
})
|
||||
assert r.status_code == 200
|
||||
|
||||
client.post(f"{self.BASE}/plans", headers=auth_header(token),
|
||||
json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 500.0})
|
||||
client.post(f"{self.BASE}/sync-cash-plans", headers=auth_header(token))
|
||||
|
||||
plan = db.query(CashPlan).filter(CashPlan.related_kpi_id == kpi.id).first()
|
||||
assert plan is not None
|
||||
assert plan.plan_type == "pay" # 规则覆盖关键词
|
||||
Reference in New Issue
Block a user