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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user