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