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

P1-③ 告警归因: budget_deviation_alerts+alert_type/attribution/scenario_id, 归因引擎alert_attribution.py(子KPI/科目/量价差/趋势), deviation-check统一写归因+场景, GET /deviation-alerts/{id}/attribution详情(旧告警现场组装)
P1-④ 实际值自动归集: kpi_value_sources/kpi_value_collect_logs表+CRUD+试跑+覆盖率, 采集器kpi_value_collector.py(voucher_details/进销存/cash_plans按entity+period汇总, 幂等upsert不覆盖人工), crontab每日06:30
P2-① 真零基: budget_zero_based_items逐项论证表+generate, method-comparison有论证项逐项求和is_demo=false否则fallback
P2-② 派生规则: budget_derivation_rules配置表, apply-method优先读规则rule_source=configured
P2-⑤ 告警双路径合并: deviation_engine.build_deviation_alert统一函数, 方向列表配置化kpi_alert_higher_better+alert-direction接口
P2-⑥ 现金流分类: cash_plan_classify_rules规则表+cash_plan_unclassified待分类队列, sync-cash-plans未命中进队列不静默跳过
新增: GET /kpis/{kpi_id}/values + 前端kpiApi.values(归集标签页数据源), scenario_suggestions幂等seed(init_db)
测试: test_budget_tech_improve.py 15用例, 预算相关96 passed, 全量646 passed
This commit is contained in:
Hermes CI Fix
2026-08-28 18:03:47 +08:00
parent 3bc68fa1c6
commit 94aeb14e95
16 changed files with 3164 additions and 118 deletions
+334 -81
View File
@@ -803,20 +803,23 @@ def get_kpi_comparison(
def check_budget_deviation(
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth),
):
"""
检查实际vs预测偏差,当偏差超过20%时自动生成预警
检查实际vs预测偏差,当偏差超过阈值时自动生成预警
(2026-08-28 P1-③/P2-⑤: 统一走 build_deviation_alert,写入归因JSON+场景建议)
"""
from app.models import KPIValue, BudgetDeviationAlert
from sqlalchemy import func
from app.utils.deviation_engine import build_deviation_alert
threshold = data.get("threshold", 20) # 默认20%
period = data.get("period") or datetime.now().strftime("%Y-%m")
auto_resolve = data.get("auto_resolve", True) # 是否自动关闭已解决的预警
# 查询该期间有预算的KPI
# 查询该期间有预算的KPI(多租户隔离 entity_id
budget_plans = db.query(BudgetPlan).filter(
BudgetPlan.entity_id == entity_id,
BudgetPlan.period == period,
BudgetPlan.status == "active",
).all()
@@ -832,39 +835,25 @@ def check_budget_deviation(
alerts = []
for bp in budget_plans:
# 查询实际值
actual = db.query(KPIValue).filter(
KPIValue.kpi_id == bp.kpi_id,
KPIValue.period == period,
KPIValue.actual_value.isnot(None),
kpi_obj = db.query(KPIDefinition).filter(
KPIDefinition.id == bp.kpi_id,
KPIDefinition.entity_id == entity_id,
).first()
if not actual or actual.actual_value is None:
if not kpi_obj:
continue
budget_val = bp.budget_value
actual_val = actual.actual_value
if budget_val == 0:
# 统一告警逻辑(方向性/阈值/归因/场景建议)
result = build_deviation_alert(db, kpi_obj, period, entity_id=entity_id, min_rate=threshold)
if not result["triggered"]:
continue
# 计算偏差率
deviation_rate = round((actual_val - budget_val) / budget_val * 100, 2)
# 只有偏差超过阈值才生成预警
if abs(deviation_rate) <= threshold:
continue
deviation_value = round(actual_val - budget_val, 2)
# 判断预警等级
alert_level = "critical" if abs(deviation_rate) > 50 else "warning"
# 生成建议
if deviation_rate > 0:
suggestion = f"实际值超出预算 {deviation_rate}%,建议核查超支原因并采取控制措施"
else:
suggestion = f"实际值低于预算 {abs(deviation_rate)}%,建议分析是否预算过高或业务量未达预期"
deviation = result["deviation"]
budget_val = deviation.get("budget_value")
actual_val = deviation.get("actual_value")
deviation_rate = deviation.get("deviation_rate")
deviation_value = deviation.get("deviation_amount")
if deviation_value is None:
deviation_value = round((actual_val or 0) - (budget_val or 0), 2)
# 检查是否已存在相同的预警
existing_alert = db.query(BudgetDeviationAlert).filter(
@@ -874,6 +863,12 @@ def check_budget_deviation(
).first()
if existing_alert:
# 已存在open告警: 补齐归因(原open告警可能无归因, 幂等补写)
if existing_alert.attribution is None and result["attribution"]:
existing_alert.attribution = result["attribution"]
existing_alert.alert_type = result["alert_type"]
existing_alert.scenario_id = result["scenario_id"]
db.flush()
continue
alert = BudgetDeviationAlert(
@@ -883,14 +878,16 @@ def check_budget_deviation(
actual_value=actual_val,
deviation_rate=deviation_rate,
deviation_value=deviation_value,
alert_level=alert_level,
alert_level=result["level"],
status="open",
suggestion=suggestion,
suggestion=result["suggestion"],
alert_type=result["alert_type"],
attribution=result["attribution"],
scenario_id=result["scenario_id"],
)
db.add(alert)
alerts_generated += 1
kpi_obj = db.query(KPIDefinition).filter(KPIDefinition.id == bp.kpi_id).first()
alerts.append({
"kpi_id": bp.kpi_id,
"kpi_code": kpi_obj.kpi_code if kpi_obj else "",
@@ -900,8 +897,10 @@ def check_budget_deviation(
"actual_value": actual_val,
"deviation_rate": deviation_rate,
"deviation_value": deviation_value,
"alert_level": alert_level,
"suggestion": suggestion,
"alert_level": result["level"],
"suggestion": result["suggestion"],
"alert_type": result["alert_type"],
"attribution": result["attribution"],
})
db.commit()
@@ -922,10 +921,11 @@ def list_deviation_alerts(
alert_level: Optional[str] = Query(None),
status: Optional[str] = Query(None),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""查询预算偏差预警记录"""
"""查询预算偏差预警记录 (2026-08-28: 列表新增 alert_type/attribution/scenario_identity_id隔离)"""
from app.models import BudgetDeviationAlert
query = db.query(BudgetDeviationAlert)
query = db.query(BudgetDeviationAlert).filter(BudgetDeviationAlert.entity_id == entity_id)
if kpi_id:
query = query.filter(BudgetDeviationAlert.kpi_id == kpi_id)
if period:
@@ -952,11 +952,120 @@ def list_deviation_alerts(
"alert_level": a.alert_level,
"status": a.status,
"suggestion": a.suggestion,
"alert_type": a.alert_type,
"attribution": a.attribution,
"scenario_id": a.scenario_id,
"created_at": a.created_at.isoformat() if a.created_at else None,
})
return {"data": result, "total": len(result)}
@router.get("/deviation-alerts/{alert_id}/attribution")
def get_deviation_alert_attribution(
alert_id: int,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""告警归因详情 — 告警 + 归因JSON + 场景建议(联查 scenario_suggestions(P1-③ 2026-08-28)"""
from app.models import BudgetDeviationAlert, ScenarioSuggestion
from app.utils.alert_attribution import match_scenario
alert = db.query(BudgetDeviationAlert).filter(
BudgetDeviationAlert.id == alert_id,
BudgetDeviationAlert.entity_id == entity_id,
).first()
if not alert:
raise HTTPException(404, "预警记录不存在")
kpi_obj = db.query(KPIDefinition).filter(KPIDefinition.id == alert.kpi_id).first()
# 归因(若旧告警无归因字段,现场组装一次)
attribution = alert.attribution
if attribution is None:
from app.utils.alert_attribution import build_attribution
try:
attribution, inferred_type = build_attribution(db, alert.kpi_id, alert.period, alert.alert_type)
alert.attribution = attribution
if alert.alert_type is None:
alert.alert_type = inferred_type
db.commit()
except Exception:
attribution = {}
scenario = None
if alert.scenario_id or alert.alert_type:
scenario = match_scenario(db, alert.alert_type)
return {
"id": alert.id,
"kpi_id": alert.kpi_id,
"kpi_code": kpi_obj.kpi_code if kpi_obj else "",
"kpi_name": kpi_obj.kpi_name if kpi_obj else "",
"period": alert.period,
"budget_value": alert.budget_value,
"actual_value": alert.actual_value,
"deviation_rate": alert.deviation_rate,
"deviation_value": alert.deviation_value,
"alert_level": alert.alert_level,
"status": alert.status,
"suggestion": alert.suggestion,
"alert_type": alert.alert_type,
"attribution": attribution or {},
"scenario": scenario,
"created_at": alert.created_at.isoformat() if alert.created_at else None,
}
@router.get("/alert-direction")
def get_alert_direction(
db: Session = Depends(get_db),
):
"""越高越好型KPI方向配置 (P2-⑤ 2026-08-28: system_configs 可维护)"""
from app.utils.deviation_engine import get_higher_better_codes, CONFIG_KEY_HIGHER_BETTER
from app.models import SystemConfig
cfg = db.query(SystemConfig).filter(
SystemConfig.config_key == CONFIG_KEY_HIGHER_BETTER
).first()
codes = get_higher_better_codes(db)
return {
"config_key": CONFIG_KEY_HIGHER_BETTER,
"codes": codes,
"is_configured": bool(cfg and cfg.config_value),
}
@router.put("/alert-direction")
def update_alert_direction(
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
):
"""维护越高越好型KPI方向配置 (P2-⑤) body: {codes: ["SALES_TOTAL", ...]}"""
import json as _json
from app.utils.deviation_engine import CONFIG_KEY_HIGHER_BETTER
from app.models import SystemConfig
codes = data.get("codes")
if not isinstance(codes, list):
raise HTTPException(400, "codes 必须是非空数组")
codes = [str(c) for c in codes]
cfg = db.query(SystemConfig).filter(
SystemConfig.config_key == CONFIG_KEY_HIGHER_BETTER
).first()
if cfg:
cfg.config_value = _json.dumps(codes, ensure_ascii=False)
else:
db.add(SystemConfig(
config_key=CONFIG_KEY_HIGHER_BETTER,
config_value=_json.dumps(codes, ensure_ascii=False),
description="越高越好型KPI编码列表(实际低于预算才告警)",
))
db.commit()
return {"success": True, "config_key": CONFIG_KEY_HIGHER_BETTER, "codes": codes}
@router.put("/deviation-alerts/{alert_id}")
def update_deviation_alert(
alert_id: int,
@@ -979,11 +1088,18 @@ def update_deviation_alert(
# ──────────────────────────────────────────────
@router.post("/method-comparison")
def budget_method_comparison(data: dict):
def budget_method_comparison(
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""
预算方法三选一对比计算
接收: { entity: "hanke", last_month_budget: 91, current_revenue: 122, ... }
返回三种方法的计算结果
(2026-08-28 P2-①: zero_based 优先读逐项论证项 budget_zero_based_items,
传入 zero_based_kpi_id+zero_based_period 且有论证项 → 逐项求和 is_demo=false;
无论证项 → fallback 旧公式 is_demo=true)
"""
entity = data.get("entity", "hanke")
last_month_budget = data.get("last_month_budget", 91) # 上月预算(万)
@@ -1001,22 +1117,44 @@ def budget_method_comparison(data: dict):
incremental_result = round(last_month_budget * (1 + increment_rate), 1)
incremental_detail = f"上月{last_month_budget}× (1+{increment_rate*100:.0f}%) = {incremental_result}"
# 2. 零基预算: 每项从零论证
zbb_entertainment = round(fixed_costs.get("entertainment", 16) / 2, 1) # 砍半
zbb_misc = round(fixed_costs.get("misc", 12) * 0.7, 1) # 压缩30%
zbb_total = round(
fixed_costs.get("rent", 15)
+ fixed_costs.get("labor", 40)
+ zbb_entertainment
+ zbb_misc,
1,
)
zbb_savings = round(last_month_budget - zbb_total, 1)
zbb_detail = (
f"房租{fixed_costs.get('rent', 15)}万(固定)+人工{fixed_costs.get('labor', 40)}万(砍不掉)"
f"+招待{zbb_entertainment}万(砍半)+杂项{zbb_misc}万(压缩)"
f"={zbb_total}万 ← 省{zbb_savings}"
)
# 2. 零基预算: 优先逐项论证(P2-① 真零基)
zbb_kpi_id = data.get("zero_based_kpi_id")
zbb_period = data.get("zero_based_period")
zbb_is_demo = True
zbb_items = []
if zbb_kpi_id and zbb_period:
from app.models import BudgetZeroBasedItem
zbb_items = db.query(BudgetZeroBasedItem).filter(
BudgetZeroBasedItem.entity_id == entity_id,
BudgetZeroBasedItem.kpi_id == zbb_kpi_id,
BudgetZeroBasedItem.period == zbb_period,
).all()
if zbb_items:
# 真零基: 逐项求和(仅 approved+draft 都算,draft为未定稿)
zbb_total = round(sum(i.proposed_value for i in zbb_items), 1)
zbb_is_demo = False
zbb_detail = "零基逐项论证: " + " + ".join(
f"{i.item_name}{i.proposed_value}" for i in zbb_items
) + f" = {zbb_total}"
zbb_savings = round(last_month_budget - zbb_total, 1)
else:
# fallback 旧演示公式(标注 is_demo
zbb_entertainment = round(fixed_costs.get("entertainment", 16) / 2, 1) # 砍半
zbb_misc = round(fixed_costs.get("misc", 12) * 0.7, 1) # 压缩30%
zbb_total = round(
fixed_costs.get("rent", 15)
+ fixed_costs.get("labor", 40)
+ zbb_entertainment
+ zbb_misc,
1,
)
zbb_savings = round(last_month_budget - zbb_total, 1)
zbb_detail = (
f"房租{fixed_costs.get('rent', 15)}万(固定)+人工{fixed_costs.get('labor', 40)}万(砍不掉)"
f"+招待{zbb_entertainment}万(砍半)+杂项{zbb_misc}万(压缩)"
f"={zbb_total}万 ← 省{zbb_savings}"
)
# 3. 弹性预算: 根据收入水平动态调整
flexible_fixed = round(fixed_costs.get("rent", 15) + fixed_costs.get("labor", 40) * 0.5, 1)
@@ -1053,6 +1191,8 @@ def budget_method_comparison(data: dict):
"result_value": zbb_total,
"savings": zbb_savings,
"detail": zbb_detail,
"is_demo": zbb_is_demo,
"item_count": len(zbb_items),
"pros": "最合理",
"cons": "耗时",
"is_recommended": True,
@@ -1078,16 +1218,21 @@ def budget_method_comparison(data: dict):
def apply_budget_method(
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth),
):
"""应用所选预算编制方法到预算计划(2026-08-26:三法并存,按用户场景选择后落地)
接收: { method: 'incremental'|'zero_based'|'flexible', year: 2026, entity: 'hanke', ... }
说明: 方法计算结果 → 写入/更新预算计划(version标注方法名,便于追溯)
(2026-08-28 P2-②: KPI派生规则可配置 budget_derivation_rules,
percentage_of → base_kpi实际值×rate; incremental → 上月×(1+rate);
无规则 fallback 默认比例(净利2%/费用率22%/毛利18%), 响应带 rule_source)
"""
from app.models import BudgetDerivationRule
method = data.get("method", "zero_based")
year = data.get("year", datetime.now().year)
entity = data.get("entity", "hanke")
entity_id = data.get("entity_id", 1)
# 复用method-comparison计算(获得三法结果)
comp = budget_method_comparison({
@@ -1099,7 +1244,9 @@ def apply_budget_method(
}),
"variable_cost_rate": data.get("variable_cost_rate", 0.4862),
"increment_rate": data.get("increment_rate", 0.05),
})
"zero_based_kpi_id": data.get("zero_based_kpi_id"),
"zero_based_period": data.get("zero_based_period"),
}, db=db, entity_id=entity_id)
# 找所选方法的结果
selected = None
@@ -1111,7 +1258,6 @@ def apply_budget_method(
raise HTTPException(400, "未知预算方法: " + method)
# 找到该年的核心KPI(营业收入/净利润/费用率等)
# 取该年已有预算的KPI,或默认核心4个
kpis = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.status == "active",
@@ -1120,11 +1266,18 @@ def apply_budget_method(
if not kpis:
raise HTTPException(400, "未找到可应用的KPI")
# 方法结果解释为收入预算(核心KPI应用
# incremental/flexible/zero_based 的 result_value 均为"预算总额(万)"
# 写入F_REVENUE年度预算(period=YYYY-00 表示年度)
# 加载派生规则(P2-②
rules = db.query(BudgetDerivationRule).filter(
BudgetDerivationRule.entity_id == entity_id,
BudgetDerivationRule.status == "active",
).all()
rules_by_kpi = {r.kpi_id: r for r in rules}
# 版本
version = f"{method}-{datetime.now().strftime('%Y%m%d')}"
applied = []
used_configured = False
for kpi in kpis:
period = f"{year}-00"
# 删除旧版本的同KPI年度预算
@@ -1134,15 +1287,67 @@ def apply_budget_method(
BudgetPlan.version.like(f"{method}-%"),
).delete()
# 各KPI的应用值(简化:收入用方法结果,其他按比例
# 各KPI的应用值:收入用方法结果,其他优先派生规则(P2-②
if kpi.kpi_code == "F_REVENUE":
budget_val = selected["result_value"]
elif kpi.kpi_code == "F_NET_PROFIT":
budget_val = round(selected["result_value"] * 0.02, 1) # 净利率约2%
elif kpi.kpi_code == "F_COST_RATIO":
budget_val = round(selected["result_value"] * 0.22, 1) # 费用率约22%
else: # F_GROSS_MARGIN
budget_val = round(selected["result_value"] * 0.18, 1) # 毛利率约18%
rule_source = "default"
formula_note = "方法结果"
else:
rule = rules_by_kpi.get(kpi.id)
if rule and rule.params:
rate = float(rule.params.get("rate", 0.02))
if rule.rule_type == "percentage_of" and rule.base_kpi_id:
# 来源KPI实际值 × 比例
base_val = None
base_actual = db.query(KPIValue).filter(
KPIValue.kpi_id == rule.base_kpi_id,
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.calculated_at.desc()).first()
if base_actual:
base_val = base_actual.actual_value
if base_val is not None:
budget_val = round(base_val * rate, 1)
rule_source = "configured"
formula_note = f"派生: 来源KPI实际值{base_val} × {rate}"
else:
budget_val = round(selected["result_value"] * rate, 1)
rule_source = "configured_fallback"
formula_note = f"派生规则无来源实际值, 按方法结果×{rate}"
elif rule.rule_type == "incremental":
# 上月预算 × (1+rate)
prev_period = f"{year-1}-00"
prev_plan = db.query(BudgetPlan).filter(
BudgetPlan.entity_id == entity_id,
BudgetPlan.kpi_id == kpi.id,
BudgetPlan.period == prev_period,
BudgetPlan.status == "active",
).order_by(BudgetPlan.updated_at.desc()).first()
if prev_plan and prev_plan.budget_value is not None:
budget_val = round(prev_plan.budget_value * (1 + rate), 1)
rule_source = "configured"
formula_note = f"派生: 上年预算{prev_plan.budget_value} × (1+{rate})"
else:
budget_val = round(selected["result_value"] * rate, 1)
rule_source = "configured_fallback"
formula_note = f"派生规则无上年预算, 按方法结果×{rate}"
else:
# formula 类型: 暂按方法结果×rate 兜底
budget_val = round(selected["result_value"] * rate, 1)
rule_source = "configured"
formula_note = f"派生规则(formula): 方法结果×{rate}"
else:
# fallback 默认比例
if kpi.kpi_code == "F_NET_PROFIT":
budget_val = round(selected["result_value"] * 0.02, 1) # 净利率约2%
elif kpi.kpi_code == "F_COST_RATIO":
budget_val = round(selected["result_value"] * 0.22, 1) # 费用率约22%
else: # F_GROSS_MARGIN
budget_val = round(selected["result_value"] * 0.18, 1) # 毛利率约18%
rule_source = "default"
formula_note = "默认比例"
if rule_source in ("configured", "configured_fallback"):
used_configured = True
bp = BudgetPlan(
entity_id=entity_id,
@@ -1153,10 +1358,11 @@ def apply_budget_method(
budget_month=0,
version=version,
status="active",
remark=f"{selected['name']}应用({selected['result_value']}万) 来源{method}",
remark=f"{selected['name']}应用({selected['result_value']}万) 来源{method} | {formula_note}",
calc_logic=formula_note,
)
db.add(bp)
applied.append({"kpi_code": kpi.kpi_code, "budget_value": budget_val})
applied.append({"kpi_code": kpi.kpi_code, "budget_value": budget_val, "rule_source": rule_source})
db.commit()
return {
@@ -1167,7 +1373,8 @@ def apply_budget_method(
"total_budget": selected["result_value"],
"detail": selected["detail"],
"applied": applied,
"note": "选择哪种方法取决于场景:增量=稳定业务快速编;零基=成本优化专项;弹性=收入波动大。方法结果写入年度预算(period=YYYY-00),可在版本管理中查看。",
"rule_source": "configured" if used_configured else "default",
"note": "选择哪种方法取决于场景:增量=稳定业务快速编;零基=成本优化专项;弹性=收入波动大。方法结果写入年度预算(period=YYYY-00),可在版本管理中查看。KPI派生规则可在「派生规则配置」中维护(P2-②)。",
}
@@ -1338,35 +1545,77 @@ def sync_cash_plans(
db: Session = Depends(get_db),
):
"""预算→现金流计划联动: 按预算KPI生成/更新收付款计划(修复断点#1)
(2026-08-28 P2-⑥: 分类规则表优先, 未命中进待分类队列不再静默跳过)
收入类KPI(营收/回款/新客) → receive
成本类KPI(费用/厂补/采购) → pay
分类来源: ①cash_plan_classify_rules规则表(精确KPI→关键词) ②默认关键词兜底 ③待分类队列
upsert: 同KPI+同日期+同类型 更新不重复
"""
from app.models import CashPlan
from app.models import CashPlan, CashPlanClassifyRule, CashPlanUnclassified
from datetime import datetime
# 默认关键词兜底(兼容存量,规则表优先)
RECEIVE_KEYS = ("营收", "收入", "销售", "回款", "新客", "收款", "净利润", "毛利")
PAY_KEYS = ("费用", "成本", "厂补", "采购", "返利", "应付", "损耗", "投入")
# 加载分类规则表(P2-⑥)
rules = db.query(CashPlanClassifyRule).filter(
CashPlanClassifyRule.entity_id == entity_id,
CashPlanClassifyRule.status == "active",
).order_by(CashPlanClassifyRule.priority.asc()).all()
kpi_rules = {r.kpi_id: r for r in rules if r.kpi_id}
pattern_rules = [r for r in rules if not r.kpi_id and r.kpi_code_pattern]
def classify_plan_type(kpi) -> Optional[str]:
"""返回 receive/pay/None(未分类)"""
# ① 精确KPI匹配(优先)
if kpi.id in kpi_rules:
return kpi_rules[kpi.id].plan_type
# ② 关键词/编码模式匹配(规则表)
name = (kpi.kpi_name or "") + (kpi.kpi_code or "")
for r in pattern_rules:
if r.kpi_code_pattern and r.kpi_code_pattern in name:
return r.plan_type
# ③ 默认关键词兜底(兼容存量行为)
if any(k in name for k in RECEIVE_KEYS):
return "receive"
if any(k in name for k in PAY_KEYS):
return "pay"
return None
budgets = db.query(BudgetPlan).filter(
BudgetPlan.entity_id == entity_id, BudgetPlan.status == "active"
).all()
kpi_ids = {b.kpi_id for b in budgets}
kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(kpi_ids)).all()} if kpi_ids else {}
created, updated = 0, 0
created, updated, unclassified_count = 0, 0, 0
for b in budgets:
kpi = kpis.get(b.kpi_id)
if not kpi:
continue
name = (kpi.kpi_name or "") + (kpi.kpi_code or "")
if any(k in name for k in RECEIVE_KEYS):
plan_type = "receive"
elif any(k in name for k in PAY_KEYS):
plan_type = "pay"
else:
continue # 无法判类别的KPI跳过
plan_type = classify_plan_type(kpi)
if plan_type is None:
# 无法判类别 → 写入待分类队列(不静默跳过,P2-⑥)
existing_un = db.query(CashPlanUnclassified).filter(
CashPlanUnclassified.entity_id == entity_id,
CashPlanUnclassified.kpi_id == b.kpi_id,
CashPlanUnclassified.period == b.period,
CashPlanUnclassified.status == "pending",
).first()
if not existing_un:
db.add(CashPlanUnclassified(
entity_id=entity_id,
kpi_id=b.kpi_id,
kpi_name=kpi.kpi_name or kpi.kpi_code,
period=b.period,
budget_value=b.budget_value,
reason="未匹配任何分类规则",
status="pending",
))
unclassified_count += 1
continue
year, month = b.budget_year or 2026, b.budget_month or 1
try:
@@ -1395,4 +1644,8 @@ def sync_cash_plans(
))
created += 1
db.commit()
return {"message": f"现金流联动完成: 新建{created}条, 更新{updated}", "created": created, "updated": updated}
return {
"message": f"现金流联动完成: 新建{created}条, 更新{updated}条, 待分类{unclassified_count}",
"created": created, "updated": updated,
"unclassified_count": unclassified_count,
}