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" # 规则覆盖关键词
|
||||
@@ -47,6 +47,8 @@ export const kpiApi = {
|
||||
hierarchy: (params?: any) => api.get('/kpis/hierarchy', { params }),
|
||||
// KPI-8: 因果链追踪
|
||||
causalityChain: (id: number) => api.get(`/kpis/${id}/causality-chain`),
|
||||
// KPI实际值列表(归集标签页)
|
||||
values: (id: number, params?: any) => api.get(`/kpis/${id}/values`, { params }),
|
||||
}
|
||||
|
||||
export const mapApi = {
|
||||
@@ -208,6 +210,38 @@ export const budgetApi = {
|
||||
deviationCheck: (data: any) => api.post('/budget/deviation-check', data),
|
||||
listDeviationAlerts: (params?: any) => api.get('/budget/deviation-alerts', { params }),
|
||||
updateDeviationAlert: (id: number, data: any) => api.put(`/budget/deviation-alerts/${id}`, data),
|
||||
// P1-③ 告警归因
|
||||
getAlertAttribution: (alertId: number) => api.get(`/budget/deviation-alerts/${alertId}/attribution`),
|
||||
getAlertDirection: () => api.get('/budget/alert-direction'),
|
||||
updateAlertDirection: (data: any) => api.put('/budget/alert-direction', data),
|
||||
// P1-④ 实际值自动归集
|
||||
valueSources: (params?: any) => api.get('/budget/value-sources', { params }),
|
||||
createValueSource: (data: any) => api.post('/budget/value-sources', data),
|
||||
updateValueSource: (id: number, data: any) => api.put(`/budget/value-sources/${id}`, data),
|
||||
deleteValueSource: (id: number) => api.delete(`/budget/value-sources/${id}`),
|
||||
testValueSource: (data: any) => api.post('/budget/value-sources/test', data),
|
||||
runValueCollect: (data: any) => api.post('/budget/value-collect/run', data),
|
||||
valueCollectLogs: (params?: any) => api.get('/budget/value-collect/logs', { params }),
|
||||
valueSourceCoverage: () => api.get('/budget/value-sources/coverage'),
|
||||
// P2-① 零基逐项论证
|
||||
zeroBasedItems: (params?: any) => api.get('/budget/zero-based/items', { params }),
|
||||
createZeroBasedItem: (data: any) => api.post('/budget/zero-based/items', data),
|
||||
updateZeroBasedItem: (id: number, data: any) => api.put(`/budget/zero-based/items/${id}`, data),
|
||||
deleteZeroBasedItem: (id: number) => api.delete(`/budget/zero-based/items/${id}`),
|
||||
generateZeroBased: (data: any) => api.post('/budget/zero-based/generate', data),
|
||||
// P2-② 派生规则
|
||||
derivationRules: (params?: any) => api.get('/budget/derivation-rules', { params }),
|
||||
createDerivationRule: (data: any) => api.post('/budget/derivation-rules', data),
|
||||
updateDerivationRule: (id: number, data: any) => api.put(`/budget/derivation-rules/${id}`, data),
|
||||
deleteDerivationRule: (id: number) => api.delete(`/budget/derivation-rules/${id}`),
|
||||
// P2-⑥ 现金流分类规则 + 待分类队列
|
||||
cashClassifyRules: () => api.get('/budget/cash-classify-rules'),
|
||||
createCashClassifyRule: (data: any) => api.post('/budget/cash-classify-rules', data),
|
||||
updateCashClassifyRule: (id: number, data: any) => api.put(`/budget/cash-classify-rules/${id}`, data),
|
||||
deleteCashClassifyRule: (id: number) => api.delete(`/budget/cash-classify-rules/${id}`),
|
||||
cashUnclassified: (params?: any) => api.get('/budget/cash-unclassified', { params }),
|
||||
classifyCashUnclassified: (id: number, data: any) => api.post(`/budget/cash-unclassified/${id}/classify`, data),
|
||||
ignoreCashUnclassified: (id: number) => api.post(`/budget/cash-unclassified/${id}/ignore`),
|
||||
}
|
||||
|
||||
export const costApi = {
|
||||
|
||||
@@ -203,6 +203,54 @@
|
||||
<el-table-column label="执行率" width="100"><template #default="{ row }"><el-progress :percentage="row.execution_rate || 0" :status="row.execution_rate > 100 ? 'exception' : row.execution_rate > 80 ? 'warning' : 'success'" :stroke-width="16" :text-inside="true" /></template></el-table-column>
|
||||
<el-table-column label="预警" width="80"><template #default="{ row }"><el-tag v-if="row.alert_level === 'red'" size="small" type="danger">严重</el-tag><el-tag v-else-if="row.alert_level === 'yellow'" size="small" type="warning">关注</el-tag><el-tag v-else size="small" type="success">正常</el-tag></template></el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- P2-⑥ 现金流分类规则 + 待分类队列 -->
|
||||
<el-row :gutter="16" style="margin-top:20px;">
|
||||
<el-col :span="12">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<span><strong>现金流分类规则</strong> <span style="font-size:12px;color:#999;">(KPI→receive/pay 可维护)</span></span>
|
||||
<el-button type="primary" size="small" @click="showClassifyRuleDialog = true">+ 新建规则</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="cashClassifyRules" border stripe size="small" style="width:100%;" max-height="240">
|
||||
<el-table-column prop="kpi_code" label="KPI编码" width="110"><template #default="{ row }">{{ row.kpi_code || row.kpi_code_pattern || '--' }}</template></el-table-column>
|
||||
<el-table-column prop="kpi_name" label="名称/关键词" min-width="110"><template #default="{ row }">{{ row.kpi_name || row.kpi_code_pattern || '--' }}</template></el-table-column>
|
||||
<el-table-column label="类型" width="80">
|
||||
<template #default="{ row }"><el-tag size="small" :type="row.plan_type === 'receive' ? 'success' : 'danger'">{{ row.plan_type === 'receive' ? '收' : '付' }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="priority" label="优先级" width="70" />
|
||||
<el-table-column label="操作" width="80" fixed="right">
|
||||
<template #default="{ row }"><el-button size="small" link type="danger" @click="deleteClassifyRule(row)">删除</el-button></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<span><strong>待分类队列</strong> <span style="font-size:12px;color:#999;">(无法判别的KPI不再静默跳过)</span></span>
|
||||
<el-badge :value="unclassifiedCount" :hidden="unclassifiedCount === 0" type="danger"><el-button size="small" @click="loadCashClassify">刷新</el-button></el-badge>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="unclassifiedRows" border stripe size="small" style="width:100%;" max-height="240">
|
||||
<el-table-column prop="kpi_name" label="KPI" min-width="120" />
|
||||
<el-table-column prop="period" label="期间" width="80" />
|
||||
<el-table-column prop="budget_value" label="预算值" width="90"><template #default="{ row }">{{ formatNumber(row.budget_value) }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" link type="success" @click="classifyUnclassified(row, 'receive')">收</el-button>
|
||||
<el-button size="small" link type="danger" @click="classifyUnclassified(row, 'pay')">付</el-button>
|
||||
<el-button size="small" link @click="ignoreUnclassified(row)">忽略</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="unclassifiedRows.length === 0" style="padding:16px 0;text-align:center;color:#999;font-size:13px;">暂无待分类KPI,同步现金流计划后未命中规则的KPI会出现在这里</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="编制方法" name="method">
|
||||
@@ -248,6 +296,82 @@
|
||||
<el-button v-if="selectedMethod" type="primary" @click="confirmMethod">确认选择({{ selectedMethodName }})</el-button>
|
||||
<el-button @click="refreshMethodComparison">刷新计算</el-button>
|
||||
</div>
|
||||
|
||||
<!-- P2-① 零基逐项论证 -->
|
||||
<el-card shadow="never" style="margin-top:20px;">
|
||||
<template #header>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;">
|
||||
<span><strong>零基逐项论证</strong> <span style="font-size:12px;color:#999;">(真零基:逐项输入基准/论证/建议值 → 生成预算)</span></span>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<el-select v-model="zbbKpiId" placeholder="选择KPI" filterable style="width:200px;" size="small" @change="loadZeroBasedItems">
|
||||
<el-option v-for="k in zbbKpiOptions" :key="k.id" :label="`${k.kpi_code} - ${k.kpi_name}`" :value="k.id" />
|
||||
</el-select>
|
||||
<el-select v-model="zbbPeriod" placeholder="期间" style="width:100px;" size="small" @change="loadZeroBasedItems">
|
||||
<el-option v-for="p in zbbPeriodOptions" :key="p" :label="p" :value="p" />
|
||||
</el-select>
|
||||
<el-button type="primary" size="small" @click="addZeroBasedItem">+ 论证项</el-button>
|
||||
<el-button type="warning" size="small" :loading="zbbGenerating" :disabled="!zbbKpiId || !zbbPeriod" @click="generateZeroBased">生成零基预算</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-alert v-if="zbbTotalProposed != null" :title="`论证项合计: ${formatNumber(zbbTotalProposed)} 万(Σ建议值)`" type="success" :closable="false" show-icon style="margin-bottom:10px;" />
|
||||
<el-table :data="zeroBasedItems" border stripe size="small" style="width:100%;">
|
||||
<el-table-column prop="item_name" label="费用科目" min-width="120" />
|
||||
<el-table-column prop="item_category" label="类别" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.item_category === 'fixed' ? 'info' : row.item_category === 'variable' ? 'warning' : 'primary'">
|
||||
{{ row.item_category === 'fixed' ? '固定' : row.item_category === 'variable' ? '变动' : '酌量' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="base_value" label="基准值" width="90"><template #default="{ row }">{{ formatNumber(row.base_value) }}</template></el-table-column>
|
||||
<el-table-column prop="justification" label="逐项论证理由" min-width="160" />
|
||||
<el-table-column prop="proposed_value" label="论证后金额" width="100"><template #default="{ row }"><b>{{ formatNumber(row.proposed_value) }}</b></template></el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="80">
|
||||
<template #default="{ row }"><el-tag size="small" :type="row.status === 'approved' ? 'success' : 'info'">{{ row.status === 'approved' ? '已批准' : '草稿' }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" link type="primary" @click="editZeroBasedItem(row)">编辑</el-button>
|
||||
<el-button size="small" link type="danger" @click="deleteZeroBasedItem(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="zeroBasedItems.length === 0" style="padding:20px 0;text-align:center;color:#999;font-size:13px;">请选择KPI和期间后录入逐项论证</div>
|
||||
</el-card>
|
||||
|
||||
<!-- P2-② 派生规则配置 -->
|
||||
<el-card shadow="never" style="margin-top:16px;">
|
||||
<template #header>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<span><strong>派生规则配置</strong> <span style="font-size:12px;color:#999;">(apply-method 派生KPI时优先读规则,替代固定比例)</span></span>
|
||||
<el-button type="primary" size="small" @click="showDerivationRuleDialog = true">+ 新建规则</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="derivationRules" border stripe size="small" style="width:100%;">
|
||||
<el-table-column prop="kpi_code" label="目标KPI编码" width="110" />
|
||||
<el-table-column prop="kpi_name" label="目标KPI" min-width="110" />
|
||||
<el-table-column prop="rule_type" label="规则类型" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.rule_type === 'percentage_of' ? 'primary' : row.rule_type === 'incremental' ? 'warning' : 'info'">
|
||||
{{ row.rule_type === 'percentage_of' ? '按来源比例' : row.rule_type === 'incremental' ? '增量' : '公式' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="参数" width="120">
|
||||
<template #default="{ row }"><span v-if="row.params">{{ (row.params.rate != null ? `比例 ${(row.params.rate * 100).toFixed(0)}%` : '') }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来源KPI" width="130">
|
||||
<template #default="{ row }"><span v-if="row.base_kpi_code">{{ row.base_kpi_code }}</span><span v-else style="color:#ccc;">--</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="formula_text" label="公式说明" min-width="160" />
|
||||
<el-table-column label="操作" width="80" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" link type="danger" @click="deleteDerivationRule(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
@@ -255,6 +379,96 @@
|
||||
<DriverFactorBudget />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="实际值归集" name="collect">
|
||||
<el-row :gutter="16" style="margin-bottom:16px;">
|
||||
<el-col :span="6" v-for="c in collectCoverageCards" :key="c.label">
|
||||
<el-card shadow="hover"><div style="text-align:center;"><div style="font-size:12px;color:#999;">{{ c.label }}</div><div style="font-size:22px;font-weight:600;margin-top:4px;" :style="{color: c.color}">{{ c.value }}</div></div></el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-tabs v-model="collectTab" type="border-card" style="margin-top:4px;">
|
||||
<el-tab-pane label="取数映射" name="mappings">
|
||||
<div style="display:flex;gap:8px;margin-bottom:12px;align-items:center;flex-wrap:wrap;">
|
||||
<el-select v-model="collectKpiId" placeholder="选择KPI(可选)" filterable clearable style="width:220px;" @change="loadValueSources">
|
||||
<el-option v-for="k in zbbKpiOptions" :key="k.id" :label="`${k.kpi_code} - ${k.kpi_name}`" :value="k.id" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="showValueSourceDialog = true">+ 新建映射</el-button>
|
||||
<el-button type="warning" :loading="collectRunning" @click="runValueCollect">▶ 立即采集</el-button>
|
||||
<el-button @click="loadCollectData">刷新</el-button>
|
||||
</div>
|
||||
<el-table :data="valueSources" border stripe size="small" style="width:100%;">
|
||||
<el-table-column prop="kpi_code" label="KPI编码" width="100" />
|
||||
<el-table-column prop="kpi_name" label="KPI名称" min-width="120" />
|
||||
<el-table-column prop="source_table" label="源头表" width="160">
|
||||
<template #default="{ row }"><el-tag size="small">{{ row.source_table }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="source_field" label="金额字段" width="110" />
|
||||
<el-table-column prop="aggregate" label="聚合" width="70" />
|
||||
<el-table-column label="过滤规则" width="140">
|
||||
<template #default="{ row }"><span style="font-size:12px;">{{ JSON.stringify(row.filter_rule || {}) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="unit_conversion" label="倍率" width="60" />
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }"><el-tag size="small" :type="row.status === 'active' ? 'success' : 'info'">{{ row.status === 'active' ? '启用' : '停用' }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" link type="primary" @click="testValueSource(row)">试跑</el-button>
|
||||
<el-button size="small" link type="warning" @click="toggleValueSource(row)">{{ row.status === 'active' ? '停用' : '启用' }}</el-button>
|
||||
<el-button size="small" link type="danger" @click="deleteValueSource(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="实际值标签" name="values">
|
||||
<div style="display:flex;gap:8px;margin-bottom:12px;align-items:center;">
|
||||
<el-radio-group v-model="valueTagFilter" size="small">
|
||||
<el-radio-button value="auto_collect">已自动归集</el-radio-button>
|
||||
<el-radio-button value="manual">需人工确认</el-radio-button>
|
||||
<el-radio-button value="all">全部</el-radio-button>
|
||||
</el-radio-group>
|
||||
<span style="font-size:12px;color:#999;">对账页将按数据来源分类显示,自动归集数据可追溯采集批次</span>
|
||||
</div>
|
||||
<el-table :data="valueTagRows" border stripe size="small" style="width:100%;">
|
||||
<el-table-column prop="kpi_code" label="KPI编码" width="100" />
|
||||
<el-table-column prop="kpi_name" label="KPI名称" min-width="120" />
|
||||
<el-table-column prop="period" label="期间" width="90" />
|
||||
<el-table-column prop="actual_value" label="实际值" width="110"><template #default="{ row }">{{ formatNumber(row.actual_value) }}</template></el-table-column>
|
||||
<el-table-column label="来源" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.source_type === 'auto_collect'" size="small" type="success">已自动归集</el-tag>
|
||||
<el-tag v-else-if="row.source_type === 'manual' || row.source_type === 'excel'" size="small" type="warning">需人工确认</el-tag>
|
||||
<el-tag v-else size="small" type="info">{{ row.source_type || 'manual' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="source_batch" label="采集批次" width="170"><template #default="{ row }"><span style="font-size:12px;">{{ row.source_batch || '--' }}</span></template></el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="150" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="采集日志" name="logs">
|
||||
<div style="display:flex;gap:8px;margin-bottom:12px;align-items:center;">
|
||||
<el-select v-model="collectLogStatus" placeholder="状态" clearable style="width:110px;" size="small">
|
||||
<el-option label="成功" value="success" /><el-option label="失败" value="failed" />
|
||||
</el-select>
|
||||
<el-button size="small" type="primary" @click="loadCollectLogs">查询</el-button>
|
||||
</div>
|
||||
<el-table :data="collectLogs" border stripe size="small" style="width:100%;">
|
||||
<el-table-column prop="kpi_code" label="KPI编码" width="100" />
|
||||
<el-table-column prop="kpi_name" label="KPI名称" min-width="110" />
|
||||
<el-table-column prop="period" label="期间" width="90" />
|
||||
<el-table-column prop="source_table" label="源头表" width="140" />
|
||||
<el-table-column prop="collected_value" label="采集值" width="100"><template #default="{ row }">{{ formatNumber(row.collected_value) }}</template></el-table-column>
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }"><el-tag size="small" :type="row.status === 'success' ? 'success' : 'danger'">{{ row.status === 'success' ? '成功' : '失败' }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="message" label="说明" min-width="180" />
|
||||
<el-table-column prop="collected_at" label="采集时间" width="160" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="持续规划" name="rolling">
|
||||
<el-tabs v-model="rollingTab" type="border-card" style="margin-top:4px;">
|
||||
<el-tab-pane label="实际vs预测对比" name="comparison">
|
||||
@@ -367,10 +581,11 @@
|
||||
<el-tag v-else type="info" size="small">已忽略</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.status === 'open'" size="small" type="success" @click="resolveAlert(row)">标记解决</el-button>
|
||||
<el-button v-else-if="row.status === 'resolved'" size="small" @click="reopenAlert(row)">重新打开</el-button>
|
||||
<el-button size="small" type="primary" plain @click="showAlertAttribution(row)">归因</el-button>
|
||||
<el-button v-if="row.status === 'open'" size="small" type="success" @click="resolveAlert(row)">解决</el-button>
|
||||
<el-button v-else-if="row.status === 'resolved'" size="small" @click="reopenAlert(row)">重开</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -378,6 +593,80 @@
|
||||
<p>暂无偏差预警,点击「执行偏差检查」扫描当前期间</p>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 告警归因详情弹窗 (P1-③) -->
|
||||
<el-dialog v-model="showAttributionDialog" title="告警归因分析" width="720" append-to-body>
|
||||
<template v-if="attributionDetail">
|
||||
<el-descriptions :column="2" border size="small" style="margin-bottom:12px;">
|
||||
<el-descriptions-item label="KPI">{{ attributionDetail.kpi_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="期间">{{ attributionDetail.period }}</el-descriptions-item>
|
||||
<el-descriptions-item label="预算">{{ formatNumber(attributionDetail.budget_value) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="实际">{{ formatNumber(attributionDetail.actual_value) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="偏差率">
|
||||
<el-tag :type="Math.abs(attributionDetail.deviation_rate) > 50 ? 'danger' : 'warning'" size="small">
|
||||
{{ attributionDetail.deviation_rate > 0 ? '+' : '' }}{{ attributionDetail.deviation_rate }}%
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="量价差">
|
||||
<el-tag size="small" :type="attributionDetail.attribution?.variance_type === 'quantity_diff' ? 'warning' : attributionDetail.attribution?.variance_type === 'price_diff' ? 'danger' : 'info'">
|
||||
{{ attributionDetail.attribution?.variance_type === 'quantity_diff' ? '量差' : attributionDetail.attribution?.variance_type === 'price_diff' ? '价差' : attributionDetail.attribution?.variance_type === 'mixed' ? '量价混合' : '--' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<!-- 趋势标识 -->
|
||||
<div v-if="attributionDetail.attribution?.trend?.anomaly" style="margin-bottom:12px;">
|
||||
<el-alert :title="attributionDetail.attribution.trend.message" type="warning" show-icon :closable="false">
|
||||
<template #default>
|
||||
<span style="font-size:12px;">{{ (attributionDetail.attribution.trend.periods || []).join(' → ') }}</span>
|
||||
</template>
|
||||
</el-alert>
|
||||
</div>
|
||||
|
||||
<!-- 子KPI维度拆解 -->
|
||||
<div v-if="attributionDetail.attribution?.dimensions?.length" style="margin-bottom:12px;">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">📊 子KPI维度拆解(量差方向)</div>
|
||||
<el-table :data="attributionDetail.attribution.dimensions" border stripe size="small">
|
||||
<el-table-column prop="kpi_name" label="子KPI" min-width="120" />
|
||||
<el-table-column prop="weight" label="权重" width="70"><template #default="{ row }">{{ row.weight }}%</template></el-table-column>
|
||||
<el-table-column prop="budget_value" label="预算" width="90"><template #default="{ row }">{{ formatNumber(row.budget_value) }}</template></el-table-column>
|
||||
<el-table-column prop="actual_value" label="实际" width="90"><template #default="{ row }">{{ formatNumber(row.actual_value) }}</template></el-table-column>
|
||||
<el-table-column label="差异率" width="100">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.deviation_rate != null" :style="{ color: row.deviation_rate > 0 ? '#f56c6c' : row.deviation_rate < 0 ? '#67c23a' : '#999' }">
|
||||
{{ row.deviation_rate > 0 ? '+' : '' }}{{ row.deviation_rate }}%
|
||||
</span>
|
||||
<span v-else style="color:#ccc;">--</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 科目明细拆解 -->
|
||||
<div v-if="attributionDetail.attribution?.subjects?.length" style="margin-bottom:12px;">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">💡 科目明细拆解(价差方向)</div>
|
||||
<el-table :data="attributionDetail.attribution.subjects" border stripe size="small">
|
||||
<el-table-column prop="subject_code" label="科目编码" width="90" />
|
||||
<el-table-column prop="subject_name" label="科目" min-width="110" />
|
||||
<el-table-column prop="amount_diff" label="发生额差" width="100"><template #default="{ row }">{{ formatNumber(row.amount_diff) }}</template></el-table-column>
|
||||
<el-table-column prop="share_pct" label="占比" width="80"><template #default="{ row }">{{ row.share_pct }}%</template></el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 场景建议 -->
|
||||
<div v-if="attributionDetail.scenario" style="border:1px solid #e6a23c;border-radius:8px;padding:12px;background:#fdf6ec;">
|
||||
<div style="font-weight:600;font-size:13px;color:#e6a23c;margin-bottom:6px;">🎯 场景建议:{{ attributionDetail.scenario.title }}</div>
|
||||
<div style="font-size:13px;color:#606266;white-space:pre-line;">{{ attributionDetail.scenario.description }}</div>
|
||||
<div v-if="attributionDetail.scenario.action_template" style="margin-top:8px;font-size:12px;color:#606266;background:#fff;border-radius:6px;padding:8px 10px;white-space:pre-line;">
|
||||
<strong>行动模板:</strong>{{ attributionDetail.scenario.action_template }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else style="border:1px solid #ebeef5;border-radius:8px;padding:12px;color:#999;font-size:13px;">
|
||||
暂无匹配的场景建议模板
|
||||
</div>
|
||||
</template>
|
||||
<template #footer><el-button @click="showAttributionDialog = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
</el-tabs>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
@@ -437,6 +726,106 @@
|
||||
</div>
|
||||
<template #footer><el-button @click="showActionDialog = false">关闭</el-button></template>
|
||||
</MyDialog>
|
||||
|
||||
<!-- P2-① 零基论证项编辑弹窗 -->
|
||||
<MyDialog v-model="showZbbItemDialog" :title="zbbItemForm.id ? '编辑论证项' : '新增论证项'" :width="520">
|
||||
<el-form :model="zbbItemForm" label-width="100px">
|
||||
<el-form-item label="费用科目"><el-input v-model="zbbItemForm.item_name" placeholder="如: 招待费" /></el-form-item>
|
||||
<el-form-item label="类别">
|
||||
<el-select v-model="zbbItemForm.item_category" style="width:100%;">
|
||||
<el-option label="固定" value="fixed" /><el-option label="变动" value="variable" /><el-option label="酌量" value="discretionary" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="基准值"><el-input-number v-model="zbbItemForm.base_value" :min="0" :precision="2" style="width:200px;" /></el-form-item>
|
||||
<el-form-item label="论证理由"><el-input v-model="zbbItemForm.justification" type="textarea" :rows="2" placeholder="为何保留/削减/取消" /></el-form-item>
|
||||
<el-form-item label="论证后金额"><el-input-number v-model="zbbItemForm.proposed_value" :min="0" :precision="2" style="width:200px;" /></el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-radio-group v-model="zbbItemForm.status"><el-radio value="draft">草稿</el-radio><el-radio value="approved">已批准</el-radio></el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="showZbbItemDialog = false">取消</el-button><el-button type="primary" @click="saveZeroBasedItem">保存</el-button></template>
|
||||
</MyDialog>
|
||||
|
||||
<!-- P2-② 派生规则新建弹窗 -->
|
||||
<MyDialog v-model="showDerivationRuleDialog" title="新建派生规则" :width="520">
|
||||
<el-form :model="derivationRuleForm" label-width="100px">
|
||||
<el-form-item label="目标KPI">
|
||||
<el-select v-model="derivationRuleForm.kpi_id" filterable style="width:100%;">
|
||||
<el-option v-for="k in zbbKpiOptions" :key="k.id" :label="`${k.kpi_code} - ${k.kpi_name}`" :value="k.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="规则类型">
|
||||
<el-select v-model="derivationRuleForm.rule_type" style="width:100%;">
|
||||
<el-option label="按来源KPI比例 (percentage_of)" value="percentage_of" />
|
||||
<el-option label="增量 (incremental)" value="incremental" />
|
||||
<el-option label="公式 (formula)" value="formula" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="derivationRuleForm.rule_type === 'percentage_of'" label="来源KPI">
|
||||
<el-select v-model="derivationRuleForm.base_kpi_id" filterable style="width:100%;">
|
||||
<el-option v-for="k in zbbKpiOptions" :key="k.id" :label="`${k.kpi_code} - ${k.kpi_name}`" :value="k.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="比例(%)"><el-input-number v-model="derivationRuleForm.rate_pct" :min="0" :max="100" :precision="2" style="width:200px;" /></el-form-item>
|
||||
<el-form-item label="公式说明"><el-input v-model="derivationRuleForm.formula_text" placeholder="可读公式说明" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="showDerivationRuleDialog = false">取消</el-button><el-button type="primary" @click="saveDerivationRule">保存</el-button></template>
|
||||
</MyDialog>
|
||||
|
||||
<!-- P1-④ 取数映射新建弹窗 -->
|
||||
<MyDialog v-model="showValueSourceDialog" title="新建取数映射" :width="560">
|
||||
<el-form :model="valueSourceForm" label-width="100px">
|
||||
<el-form-item label="目标KPI">
|
||||
<el-select v-model="valueSourceForm.kpi_id" filterable style="width:100%;">
|
||||
<el-option v-for="k in zbbKpiOptions" :key="k.id" :label="`${k.kpi_code} - ${k.kpi_name}`" :value="k.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="源头表">
|
||||
<el-select v-model="valueSourceForm.source_table" style="width:100%;">
|
||||
<el-option label="网银凭证明细 voucher_details" value="voucher_details" />
|
||||
<el-option label="库存汇总 product_inventory" value="product_inventory" />
|
||||
<el-option label="库存明细 product_inventory_detail" value="product_inventory_detail" />
|
||||
<el-option label="收付款计划 cash_plans" value="cash_plans" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="金额字段">
|
||||
<el-select v-model="valueSourceForm.source_field" style="width:100%;">
|
||||
<el-option v-for="f in ['credit_amount','debit_amount','amount','qty','out_amount','in_amount','end_amount']" :key="f" :label="f" :value="f" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="聚合方式">
|
||||
<el-select v-model="valueSourceForm.aggregate" style="width:120px;"><el-option v-for="a in ['sum','avg','count','max','min']" :key="a" :label="a" :value="a" /></el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="方向过滤">
|
||||
<el-select v-model="valueSourceForm.direction" clearable placeholder="不限定" style="width:150px;">
|
||||
<el-option label="贷方(收入)" value="credit" /><el-option label="借方(支出)" value="debit" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="科目过滤"><el-input v-model="valueSourceForm.subject_code" placeholder="如: 6601 销售费用" /></el-form-item>
|
||||
<el-form-item label="期间字段">
|
||||
<el-select v-model="valueSourceForm.period_field" style="width:150px;"><el-option label="period" value="period" /><el-option label="voucher_date" value="voucher_date" /></el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="单位倍率"><el-input-number v-model="valueSourceForm.unit_conversion" :min="0.0001" :precision="4" style="width:150px;" /><span style="font-size:12px;color:#999;margin-left:6px;">元→万元填 0.0001</span></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="showValueSourceDialog = false">取消</el-button><el-button type="primary" @click="saveValueSource">保存</el-button></template>
|
||||
</MyDialog>
|
||||
|
||||
<!-- P2-⑥ 分类规则新建弹窗 -->
|
||||
<MyDialog v-model="showClassifyRuleDialog" title="新建现金流分类规则" :width="500">
|
||||
<el-form :model="classifyRuleForm" label-width="110px">
|
||||
<el-form-item label="KPI(精确)">
|
||||
<el-select v-model="classifyRuleForm.kpi_id" filterable clearable placeholder="或使用关键词" style="width:100%;">
|
||||
<el-option v-for="k in zbbKpiOptions" :key="k.id" :label="`${k.kpi_code} - ${k.kpi_name}`" :value="k.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="关键词(兜底)"><el-input v-model="classifyRuleForm.kpi_code_pattern" placeholder="如: 保证金" /></el-form-item>
|
||||
<el-form-item label="收付类型">
|
||||
<el-radio-group v-model="classifyRuleForm.plan_type"><el-radio value="receive">收</el-radio><el-radio value="pay">付</el-radio></el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="优先级"><el-input-number v-model="classifyRuleForm.priority" :min="1" :max="100" style="width:120px;" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="showClassifyRuleDialog = false">取消</el-button><el-button type="primary" @click="saveClassifyRule">保存</el-button></template>
|
||||
</MyDialog>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -954,12 +1343,339 @@ async function reopenAlert(row: any) {
|
||||
} catch { ElMessage.error('操作失败') }
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// P1-③ 告警归因
|
||||
// ══════════════════════════════════════════════
|
||||
const showAttributionDialog = ref(false)
|
||||
const attributionDetail = ref<any>(null)
|
||||
async function showAlertAttribution(row: any) {
|
||||
try {
|
||||
const r: any = await budgetApi.getAlertAttribution(row.id)
|
||||
attributionDetail.value = r.data || r
|
||||
showAttributionDialog.value = true
|
||||
} catch (e) { ElMessage.error('加载归因详情失败') }
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// 通用 KPI 选项(零基/取数映射/派生规则/分类规则共用)
|
||||
// ══════════════════════════════════════════════
|
||||
const zbbKpiOptions = ref<any[]>([])
|
||||
async function loadZbbKpiOptions() {
|
||||
try {
|
||||
const r: any = await kpiApi.list({ page_size: 200, entity_id: getEntityId() })
|
||||
const d = r.data || r || []
|
||||
zbbKpiOptions.value = Array.isArray(d) ? d : (d.items || [])
|
||||
} catch { zbbKpiOptions.value = [] }
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// P2-① 零基逐项论证
|
||||
// ══════════════════════════════════════════════
|
||||
const zbbKpiId = ref<number | null>(null)
|
||||
const zbbPeriod = ref(`${currentYear}-${String(currentMonth).padStart(2, '0')}`)
|
||||
const zbbPeriodOptions = computed(() => {
|
||||
const opts: string[] = []
|
||||
const now = new Date()
|
||||
for (let i = 5; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
|
||||
opts.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`)
|
||||
}
|
||||
return opts
|
||||
})
|
||||
const zeroBasedItems = ref<any[]>([])
|
||||
const zbbTotalProposed = ref<number | null>(null)
|
||||
const showZbbItemDialog = ref(false)
|
||||
const zbbItemForm = ref<any>({})
|
||||
const zbbGenerating = ref(false)
|
||||
async function loadZeroBasedItems() {
|
||||
if (!zbbKpiId.value) { zeroBasedItems.value = []; zbbTotalProposed.value = null; return }
|
||||
try {
|
||||
const r: any = await budgetApi.zeroBasedItems({ kpi_id: zbbKpiId.value, period: zbbPeriod.value, entity_id: getEntityId() })
|
||||
zeroBasedItems.value = r.data || []
|
||||
zbbTotalProposed.value = r.total_proposed ?? null
|
||||
} catch { zeroBasedItems.value = [] }
|
||||
}
|
||||
function addZeroBasedItem() {
|
||||
if (!zbbKpiId.value) { ElMessage.warning('请先选择KPI'); return }
|
||||
zbbItemForm.value = { kpi_id: zbbKpiId.value, period: zbbPeriod.value, item_name: '', item_category: 'discretionary', base_value: 0, justification: '', proposed_value: 0, status: 'draft' }
|
||||
showZbbItemDialog.value = true
|
||||
}
|
||||
function editZeroBasedItem(row: any) {
|
||||
zbbItemForm.value = { ...row }
|
||||
showZbbItemDialog.value = true
|
||||
}
|
||||
async function saveZeroBasedItem() {
|
||||
const f = zbbItemForm.value
|
||||
if (!f.item_name) { ElMessage.warning('请填写费用科目'); return }
|
||||
try {
|
||||
if (f.id) await budgetApi.updateZeroBasedItem(f.id, f)
|
||||
else await budgetApi.createZeroBasedItem(f)
|
||||
ElMessage.success('已保存')
|
||||
showZbbItemDialog.value = false
|
||||
loadZeroBasedItems()
|
||||
} catch (e) { ElMessage.error('保存失败') }
|
||||
}
|
||||
async function deleteZeroBasedItem(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除论证项「${row.item_name}」?`, '确认')
|
||||
await budgetApi.deleteZeroBasedItem(row.id)
|
||||
ElMessage.success('已删除')
|
||||
loadZeroBasedItems()
|
||||
} catch { }
|
||||
}
|
||||
async function generateZeroBased() {
|
||||
if (!zbbKpiId.value || !zbbPeriod.value) return
|
||||
zbbGenerating.value = true
|
||||
try {
|
||||
const r: any = await budgetApi.generateZeroBased({ kpi_id: zbbKpiId.value, period: zbbPeriod.value, entity_id: getEntityId() })
|
||||
ElMessage.success(r.message || '零基预算已生成')
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.detail || '生成失败') }
|
||||
zbbGenerating.value = false
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// P2-② 派生规则配置
|
||||
// ══════════════════════════════════════════════
|
||||
const derivationRules = ref<any[]>([])
|
||||
const showDerivationRuleDialog = ref(false)
|
||||
const derivationRuleForm = ref<any>({})
|
||||
async function loadDerivationRules() {
|
||||
try {
|
||||
const r: any = await budgetApi.derivationRules({ entity_id: getEntityId() })
|
||||
derivationRules.value = r.data || []
|
||||
} catch { derivationRules.value = [] }
|
||||
}
|
||||
function openDerivationRuleDialog() {
|
||||
derivationRuleForm.value = { kpi_id: null, rule_type: 'percentage_of', base_kpi_id: null, rate_pct: 2, formula_text: '' }
|
||||
showDerivationRuleDialog.value = true
|
||||
}
|
||||
async function saveDerivationRule() {
|
||||
const f = derivationRuleForm.value
|
||||
if (!f.kpi_id) { ElMessage.warning('请选择目标KPI'); return }
|
||||
try {
|
||||
await budgetApi.createDerivationRule({
|
||||
kpi_id: f.kpi_id,
|
||||
rule_type: f.rule_type,
|
||||
base_kpi_id: f.rule_type === 'percentage_of' ? f.base_kpi_id : null,
|
||||
params: { rate: (f.rate_pct ?? 0) / 100 },
|
||||
formula_text: f.formula_text,
|
||||
entity_id: getEntityId(),
|
||||
})
|
||||
ElMessage.success('规则已创建')
|
||||
showDerivationRuleDialog.value = false
|
||||
loadDerivationRules()
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.detail || '创建失败') }
|
||||
}
|
||||
async function deleteDerivationRule(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除规则「${row.kpi_name || row.kpi_code}」?`, '确认')
|
||||
await budgetApi.deleteDerivationRule(row.id)
|
||||
ElMessage.success('已删除')
|
||||
loadDerivationRules()
|
||||
} catch { }
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// P1-④ 实际值自动归集
|
||||
// ══════════════════════════════════════════════
|
||||
const collectTab = ref('mappings')
|
||||
const collectKpiId = ref<number | null>(null)
|
||||
const collectCoverageCards = ref<any[]>([])
|
||||
const valueSources = ref<any[]>([])
|
||||
const showValueSourceDialog = ref(false)
|
||||
const valueSourceForm = ref<any>({})
|
||||
const collectRunning = ref(false)
|
||||
const collectLogStatus = ref('')
|
||||
const collectLogs = ref<any[]>([])
|
||||
const valueTagFilter = ref('auto_collect')
|
||||
const valueTagRows = ref<any[]>([])
|
||||
async function loadCollectData() {
|
||||
await Promise.all([loadValueSources(), loadCollectCoverage(), loadValueTags(), loadCollectLogs()])
|
||||
}
|
||||
async function loadValueSources() {
|
||||
try {
|
||||
const params: any = { entity_id: getEntityId() }
|
||||
if (collectKpiId.value) params.kpi_id = collectKpiId.value
|
||||
const r: any = await budgetApi.valueSources(params)
|
||||
valueSources.value = r.data || []
|
||||
} catch { valueSources.value = [] }
|
||||
}
|
||||
async function loadCollectCoverage() {
|
||||
try {
|
||||
const r: any = await budgetApi.valueSourceCoverage({ entity_id: getEntityId() })
|
||||
const c = r.data || r || {}
|
||||
collectCoverageCards.value = [
|
||||
{ label: '已配映射KPI', value: c.mapped_count ?? 0, color: '#409eff' },
|
||||
{ label: '活跃KPI总数', value: c.total_kpis ?? 0, color: '#606266' },
|
||||
{ label: '覆盖率', value: `${c.coverage_pct ?? 0}%`, color: '#67c23a' },
|
||||
{ label: '未配置KPI', value: c.unmapped_count ?? 0, color: '#e6a23c' },
|
||||
]
|
||||
} catch { collectCoverageCards.value = [] }
|
||||
}
|
||||
async function loadValueTags() {
|
||||
try {
|
||||
const r: any = await kpiApi.list({ page_size: 100, entity_id: getEntityId() })
|
||||
const d = r.data || r || []
|
||||
const kpis = Array.isArray(d) ? d : (d.items || [])
|
||||
const rows: any[] = []
|
||||
for (const k of kpis) {
|
||||
const vr: any = await kpiApi.values(k.id, { entity_id: getEntityId() }).catch(() => null)
|
||||
const vals = vr?.data || []
|
||||
for (const v of (Array.isArray(vals) ? vals : (vals.items || []))) {
|
||||
rows.push({ kpi_code: k.kpi_code, kpi_name: k.kpi_name, period: v.period, actual_value: v.actual_value, source_type: v.source_type || 'manual', source_batch: v.source_batch || '', remark: v.remark || '' })
|
||||
}
|
||||
}
|
||||
const recent = rows.filter((r: any) => r.actual_value != null).sort((a: any, b: any) => (b.period || '').localeCompare(a.period || '')).slice(0, 100)
|
||||
valueTagRows.value = valueTagFilter.value === 'all' ? recent : recent.filter((r: any) => (valueTagFilter.value === 'auto_collect' ? r.source_type === 'auto_collect' : r.source_type !== 'auto_collect'))
|
||||
} catch { valueTagRows.value = [] }
|
||||
}
|
||||
async function loadCollectLogs() {
|
||||
try {
|
||||
const params: any = { entity_id: getEntityId() }
|
||||
if (collectLogStatus.value) params.status = collectLogStatus.value
|
||||
const r: any = await budgetApi.valueCollectLogs(params)
|
||||
collectLogs.value = r.data || []
|
||||
} catch { collectLogs.value = [] }
|
||||
}
|
||||
function openValueSourceDialog() {
|
||||
valueSourceForm.value = { kpi_id: null, source_table: 'voucher_details', source_field: 'credit_amount', aggregate: 'sum', direction: '', subject_code: '', period_field: 'period', unit_conversion: 1 }
|
||||
showValueSourceDialog.value = true
|
||||
}
|
||||
async function saveValueSource() {
|
||||
const f = valueSourceForm.value
|
||||
if (!f.kpi_id) { ElMessage.warning('请选择KPI'); return }
|
||||
const filter: any = {}
|
||||
if (f.direction) filter.direction = f.direction
|
||||
if (f.subject_code) filter.subject_code = f.subject_code
|
||||
try {
|
||||
await budgetApi.createValueSource({
|
||||
kpi_id: f.kpi_id,
|
||||
source_table: f.source_table,
|
||||
source_field: f.source_field,
|
||||
aggregate: f.aggregate,
|
||||
filter_rule: Object.keys(filter).length ? filter : null,
|
||||
period_field: f.period_field,
|
||||
unit_conversion: f.unit_conversion,
|
||||
entity_id: getEntityId(),
|
||||
})
|
||||
ElMessage.success('映射已创建')
|
||||
showValueSourceDialog.value = false
|
||||
loadValueSources()
|
||||
loadCollectCoverage()
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.detail || '创建失败') }
|
||||
}
|
||||
async function deleteValueSource(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除映射(${row.kpi_name} ← ${row.source_table})?`, '确认')
|
||||
await budgetApi.deleteValueSource(row.id)
|
||||
ElMessage.success('已删除')
|
||||
loadValueSources()
|
||||
loadCollectCoverage()
|
||||
} catch { }
|
||||
}
|
||||
async function toggleValueSource(row: any) {
|
||||
try {
|
||||
await budgetApi.updateValueSource(row.id, { status: row.status === 'active' ? 'inactive' : 'active' })
|
||||
row.status = row.status === 'active' ? 'inactive' : 'active'
|
||||
} catch { ElMessage.error('操作失败') }
|
||||
}
|
||||
async function testValueSource(row: any) {
|
||||
try {
|
||||
const r: any = await budgetApi.testValueSource({
|
||||
kpi_id: row.kpi_id,
|
||||
source_table: row.source_table,
|
||||
source_field: row.source_field,
|
||||
aggregate: row.aggregate,
|
||||
filter_rule: row.filter_rule,
|
||||
period_field: row.period_field,
|
||||
unit_conversion: row.unit_conversion,
|
||||
period: zbbPeriod.value,
|
||||
})
|
||||
const d = r.data || r
|
||||
if (d.success) ElMessage.success(`试跑值: ${formatNumber(d.value)} (${d.message || ''})`)
|
||||
else ElMessage.error(`试跑失败: ${d.message || ''}`)
|
||||
} catch { ElMessage.error('试跑失败') }
|
||||
}
|
||||
async function runValueCollect() {
|
||||
collectRunning.value = true
|
||||
try {
|
||||
const r: any = await budgetApi.runValueCollect({ period: zbbPeriod.value, entity_id: getEntityId() })
|
||||
const d = r.data || r
|
||||
ElMessage.success(`采集完成: 成功${d.collected ?? 0} 失败${d.failed ?? 0}`)
|
||||
loadCollectData()
|
||||
} catch { ElMessage.error('采集失败') }
|
||||
collectRunning.value = false
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// P2-⑥ 现金流分类规则 + 待分类队列
|
||||
// ══════════════════════════════════════════════
|
||||
const cashClassifyRules = ref<any[]>([])
|
||||
const showClassifyRuleDialog = ref(false)
|
||||
const classifyRuleForm = ref<any>({})
|
||||
const unclassifiedRows = ref<any[]>([])
|
||||
const unclassifiedCount = ref(0)
|
||||
async function loadCashClassify() {
|
||||
try {
|
||||
const r: any = await budgetApi.cashClassifyRules({ entity_id: getEntityId() })
|
||||
cashClassifyRules.value = r.data || []
|
||||
} catch { cashClassifyRules.value = [] }
|
||||
try {
|
||||
const r: any = await budgetApi.cashUnclassified({ status: 'pending', entity_id: getEntityId() })
|
||||
unclassifiedRows.value = r.data || []
|
||||
unclassifiedCount.value = (r.data || []).length
|
||||
} catch { unclassifiedRows.value = []; unclassifiedCount.value = 0 }
|
||||
}
|
||||
function openClassifyRuleDialog() {
|
||||
classifyRuleForm.value = { kpi_id: null, kpi_code_pattern: '', plan_type: 'receive', priority: 10 }
|
||||
showClassifyRuleDialog.value = true
|
||||
}
|
||||
async function saveClassifyRule() {
|
||||
const f = classifyRuleForm.value
|
||||
if (!f.kpi_id && !f.kpi_code_pattern) { ElMessage.warning('请选择KPI或填写关键词'); return }
|
||||
try {
|
||||
await budgetApi.createCashClassifyRule({
|
||||
kpi_id: f.kpi_id || null,
|
||||
kpi_code_pattern: f.kpi_code_pattern || null,
|
||||
plan_type: f.plan_type,
|
||||
priority: f.priority || 10,
|
||||
entity_id: getEntityId(),
|
||||
})
|
||||
ElMessage.success('规则已创建')
|
||||
showClassifyRuleDialog.value = false
|
||||
loadCashClassify()
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.detail || '创建失败') }
|
||||
}
|
||||
async function deleteClassifyRule(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确认删除该分类规则?', '确认')
|
||||
await budgetApi.deleteCashClassifyRule(row.id)
|
||||
ElMessage.success('已删除')
|
||||
loadCashClassify()
|
||||
} catch { }
|
||||
}
|
||||
async function classifyUnclassified(row: any, planType: string) {
|
||||
try {
|
||||
const r: any = await budgetApi.classifyCashUnclassified(row.id, { plan_type: planType })
|
||||
ElMessage.success(r.message || `已归类为${planType === 'receive' ? '收' : '付'}`)
|
||||
loadCashClassify()
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.detail || '归类失败') }
|
||||
}
|
||||
async function ignoreUnclassified(row: any) {
|
||||
try {
|
||||
await budgetApi.ignoreCashUnclassified(row.id)
|
||||
ElMessage.success('已忽略')
|
||||
loadCashClassify()
|
||||
} catch { ElMessage.error('操作失败') }
|
||||
}
|
||||
|
||||
watch(activeTab, (tab) => {
|
||||
if (tab === 'decompose') loadBudget()
|
||||
else if (tab === 'strategy') loadStrategyBudget()
|
||||
else if (tab === 'versions') loadVersions()
|
||||
else if (tab === 'execution') loadExecutionReport()
|
||||
else if (tab === 'method') refreshMethodComparison()
|
||||
else if (tab === 'execution') { loadExecutionReport(); loadCashClassify() }
|
||||
else if (tab === 'method') { refreshMethodComparison(); loadDerivationRules() }
|
||||
else if (tab === 'collect') loadCollectData()
|
||||
else if (tab === 'driver') {
|
||||
// DriverFactorBudget handles its own loading on mount
|
||||
}
|
||||
@@ -973,6 +1689,7 @@ onMounted(async () => {
|
||||
loadBudget()
|
||||
loadBudgetConfig()
|
||||
loadComparisonKpiOptions()
|
||||
loadZbbKpiOptions()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user