"""预算系统技术改进测试 (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" # 规则覆盖关键词