Files
cma-management/backend/tests/test_budget_tech_improve.py
Hermes CI Fix 94aeb14e95 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
2026-08-28 18:03:47 +08:00

463 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""预算系统技术改进测试 (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)
# 核心4KPIapply-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" # 规则覆盖关键词