Files
cma-management/backend/tests/test_kpi_forecast.py
T

309 lines
13 KiB
Python
Raw 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.
"""KPI预测引擎测试 — 预测性成本智能 MVP
覆盖:线性回归预测 / 移动平均预测 / 置信区间 / 趋势方向判定 /
边界情况(历史<2条、无历史、未来期数=0)/ entity_id 多租户隔离。
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models import Entity, KPIValue
from app.utils import kpi_forecast_engine as engine
from tests.conftest import create_test_kpi, create_test_user, get_token_for_user, auth_header
BASE = "/api/cma/predict"
# ── 测试数据工厂 ──
def _seed_history(db: Session, kpi, values: list, entity_id: int = 1, start_period: str = "2026-01"):
"""为KPI写入连续期数的历史值"""
for i, v in enumerate(values):
month = int(start_period.split("-")[1]) + i
year = int(start_period.split("-")[0]) + (month - 1) // 12
month = (month - 1) % 12 + 1
db.add(KPIValue(
kpi_id=kpi.id, entity_id=entity_id,
period=f"{year:04d}-{month:02d}", actual_value=float(v),
))
db.commit()
def _seed_entity2(db: Session) -> Entity:
ent = db.query(Entity).filter(Entity.id == 2).first()
if not ent:
ent = Entity(id=2, name="博海网络科技", short_name="博海", status="active")
db.add(ent)
db.commit()
return ent
# ── 纯函数单元测试 ──
class TestNextPeriod:
def test_increment(self):
assert engine.next_period("2026-05", 1) == "2026-06"
def test_cross_year(self):
assert engine.next_period("2026-12", 1) == "2027-01"
assert engine.next_period("2026-01", 2) == "2026-03"
def test_malformed_fallback(self):
assert engine.next_period("未知期", 1) == "未知期+1"
class TestLinearForecast:
def test_known_line(self):
"""y = 2x + 1 (x=0..4) → 预测 x=5→11, x=6→13"""
values = [1.0, 3.0, 5.0, 7.0, 9.0]
res = engine.linear_forecast(values, periods=3)
assert len(res["forecast"]) == 3
assert res["forecast"][0]["predicted"] == pytest.approx(11.0, abs=1e-6)
assert res["forecast"][1]["predicted"] == pytest.approx(13.0, abs=1e-6)
assert res["forecast"][2]["predicted"] == pytest.approx(15.0, abs=1e-6)
# 完全拟合 → R²=1
assert res["r2"] == pytest.approx(1.0, abs=1e-6)
def test_confidence_interval_bounds(self):
"""每个预测点 lower ≤ predicted ≤ upper"""
import random
random.seed(42)
values = [random.uniform(50, 150) for _ in range(10)]
res = engine.linear_forecast(values, periods=4)
for f in res["forecast"]:
assert f["lower"] <= f["predicted"] <= f["upper"]
class TestMovingAverageForecast:
def test_window_mean(self):
"""最近3期均值作为预测值"""
values = [10.0, 20.0, 30.0, 40.0, 50.0]
res = engine.moving_average_forecast(values, periods=3)
assert len(res["forecast"]) == 3
assert res["mean"] == pytest.approx(40.0) # (30+40+50)/3
for f in res["forecast"]:
assert f["predicted"] == pytest.approx(40.0)
assert f["lower"] <= f["predicted"] <= f["upper"]
def test_window_smaller_than_history(self):
values = [5.0, 6.0]
res = engine.moving_average_forecast(values, periods=2)
assert res["window"] == 2
assert res["forecast"][0]["predicted"] == pytest.approx(5.5)
class TestTrendAndConfidence:
def test_trend_up(self):
assert engine.judge_trend(8.0) == "up"
def test_trend_down(self):
assert engine.judge_trend(-8.0) == "down"
def test_trend_flat(self):
assert engine.judge_trend(1.0) == "flat"
assert engine.judge_trend(-1.0) == "flat"
def test_confidence_high(self):
assert engine.compute_confidence(20, "linear", r2=0.9) == "high"
def test_confidence_medium(self):
assert engine.compute_confidence(8, "linear", r2=0.5) == "medium"
def test_confidence_low(self):
assert engine.compute_confidence(3, "linear", r2=0.1) == "low"
# 移动平均高波动 → low
assert engine.compute_confidence(10, "moving_average", cv=0.8) == "low"
# ── 引擎级测试(真实DB ──
class TestForecastKpi:
def test_forecast_kpi_structure(self, db: Session):
"""完整返回结构"""
kpi = create_test_kpi(db, kpi_code="F_TEST_REV", kpi_name="测试收入",
dimension="finance", unit="万元")
_seed_history(db, kpi, [100, 110, 120, 130, 140])
r = engine.forecast_kpi(1, "F_TEST_REV", db, periods=3)
assert r is not None
assert r["kpi"]["code"] == "F_TEST_REV"
assert r["kpi"]["name"] == "测试收入"
assert r["kpi"]["unit"] == "万元"
assert r["trend"] == "up"
assert r["confidence"] in ("high", "medium", "low")
assert r["history_count"] == 5
assert len(r["history"]) == 5
assert len(r["forecast"]) == 3
# 历史升序
periods = [h["period"] for h in r["history"]]
assert periods == sorted(periods)
# 预测期号在历史之后
assert r["forecast"][0]["period"] > periods[-1]
assert r["next_target"] == r["forecast"][0]["predicted"]
assert "测试收入" in r["summary"]
def test_forecast_kpi_moving_average_model(self, db: Session):
kpi = create_test_kpi(db, kpi_code="F_TEST_MA", kpi_name="测试费用", unit="元")
_seed_history(db, kpi, [10, 12, 11, 13, 12])
r = engine.forecast_kpi(1, "F_TEST_MA", db, periods=2, model="moving_average")
assert r["model"] == "moving_average"
assert len(r["forecast"]) == 2
def test_forecast_kpi_insufficient_history(self, db: Session):
kpi = create_test_kpi(db, kpi_code="F_TEST_1PT", kpi_name="单点KPI")
_seed_history(db, kpi, [100.0])
assert engine.forecast_kpi(1, "F_TEST_1PT", db) is None
def test_forecast_kpi_no_history(self, db: Session):
create_test_kpi(db, kpi_code="F_TEST_NOHIST", kpi_name="无历史KPI")
assert engine.forecast_kpi(1, "F_TEST_NOHIST", db) is None
def test_forecast_kpi_unknown_code(self, db: Session):
assert engine.forecast_kpi(1, "F_NOT_EXIST", db) is None
def test_forecast_kpi_zero_periods(self, db: Session):
kpi = create_test_kpi(db, kpi_code="F_TEST_ZP", kpi_name="零期KPI")
_seed_history(db, kpi, [100, 110, 120])
r = engine.forecast_kpi(1, "F_TEST_ZP", db, periods=0)
assert r is not None
assert r["forecast"] == []
assert r["next_target"] is None
# ── API 端点测试 ──
class TestKpiForecastApi:
def test_single_forecast(self, client: TestClient, db: Session):
kpi = create_test_kpi(db, kpi_code="F_API_REV", kpi_name="API收入",
dimension="finance", unit="万元")
_seed_history(db, kpi, [100, 108, 116, 124, 132])
resp = client.get(f"{BASE}/kpi-forecast", params={
"entity_id": 1, "kpi_code": "F_API_REV", "periods": 3,
})
assert resp.status_code == 200
data = resp.json()
assert data["kpi"]["code"] == "F_API_REV"
assert data["trend"] in ("up", "down", "flat")
assert len(data["forecast"]) == 3
assert data["next_target"] is not None
assert data["summary"]
def test_moving_average_param(self, client: TestClient, db: Session):
kpi = create_test_kpi(db, kpi_code="F_API_MA", kpi_name="API费用", unit="元")
_seed_history(db, kpi, [5, 6, 7, 6, 8])
resp = client.get(f"{BASE}/kpi-forecast", params={
"entity_id": 1, "kpi_code": "F_API_MA", "model": "moving_average",
})
assert resp.status_code == 200
assert resp.json()["model"] == "moving_average"
def test_insufficient_history_404(self, client: TestClient, db: Session):
kpi = create_test_kpi(db, kpi_code="F_API_LESS", kpi_name="数据不足")
_seed_history(db, kpi, [100.0])
resp = client.get(f"{BASE}/kpi-forecast", params={"entity_id": 1, "kpi_code": "F_API_LESS"})
assert resp.status_code == 404
def test_no_history_404(self, client: TestClient, db: Session):
create_test_kpi(db, kpi_code="F_API_EMPTY", kpi_name="空历史")
resp = client.get(f"{BASE}/kpi-forecast", params={"entity_id": 1, "kpi_code": "F_API_EMPTY"})
assert resp.status_code == 404
def test_zero_periods(self, client: TestClient, db: Session):
kpi = create_test_kpi(db, kpi_code="F_API_ZERO", kpi_name="零期")
_seed_history(db, kpi, [100, 110, 120])
resp = client.get(f"{BASE}/kpi-forecast", params={
"entity_id": 1, "kpi_code": "F_API_ZERO", "periods": 0,
})
assert resp.status_code == 200
data = resp.json()
assert data["forecast"] == []
assert data["next_target"] is None
def test_bad_model_400(self, client: TestClient, db: Session):
kpi = create_test_kpi(db, kpi_code="F_API_BADM", kpi_name="坏模型")
_seed_history(db, kpi, [1, 2, 3, 4])
resp = client.get(f"{BASE}/kpi-forecast", params={
"entity_id": 1, "kpi_code": "F_API_BADM", "model": "lstm",
})
assert resp.status_code == 400
def test_bad_periods_400(self, client: TestClient, db: Session):
resp = client.get(f"{BASE}/kpi-forecast", params={
"entity_id": 1, "kpi_code": "F_API_BADM", "periods": -1,
})
assert resp.status_code == 400
class TestKpiForecastFinanceApi:
def test_batch_finance(self, client: TestClient, db: Session):
"""只返回财务维度且历史≥3条的KPI,按可预测性排序"""
good1 = create_test_kpi(db, kpi_code="F_BATCH_REV", kpi_name="批量收入", dimension="finance")
_seed_history(db, good1, [100, 105, 110, 115, 120, 125, 130, 135]) # 8条, 完美上升 → high
good2 = create_test_kpi(db, kpi_code="F_BATCH_COST", kpi_name="批量成本", dimension="finance")
_seed_history(db, good2, [50, 52, 51, 53]) # 4条 → low/medium
short = create_test_kpi(db, kpi_code="F_BATCH_SHORT", kpi_name="数据不足", dimension="finance")
_seed_history(db, short, [10, 20]) # 2条 → 应被排除
non_fin = create_test_kpi(db, kpi_code="C_BATCH_SAT", kpi_name="客户满意", dimension="customer")
_seed_history(db, non_fin, [80, 82, 84, 86]) # 非财务 → 应被排除
resp = client.get(f"{BASE}/kpi-forecast/finance", params={"entity_id": 1})
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 2
codes = [d["kpi"]["code"] for d in data["data"]]
assert "F_BATCH_REV" in codes and "F_BATCH_COST" in codes
assert "F_BATCH_SHORT" not in codes
assert "C_BATCH_SAT" not in codes
# 可预测性排序:F_BATCH_REV(8条 high) 应排前面
assert data["data"][0]["kpi"]["code"] == "F_BATCH_REV"
def test_batch_zero_periods(self, client: TestClient, db: Session):
kpi = create_test_kpi(db, kpi_code="F_BATCH_ZP", kpi_name="零期批量", dimension="finance")
_seed_history(db, kpi, [1, 2, 3, 4])
resp = client.get(f"{BASE}/kpi-forecast/finance", params={"entity_id": 1, "periods": 0})
assert resp.status_code == 200
assert resp.json()["data"][0]["forecast"] == []
class TestEntityIsolation:
def test_entity2_kpi_invisible_to_entity1(self, client: TestClient, db: Session):
"""entity2 的KPI在 entity1 下查询 → 404(不串数据)"""
_seed_entity2(db)
kpi2 = create_test_kpi(db, kpi_code="F_ISO_E2", kpi_name="博海专属KPI",
dimension="finance", entity_id=2)
_seed_history(db, kpi2, [100, 110, 120, 130], entity_id=2)
# 无token时 entity_id 走 query 参数 → entity1
resp = client.get(f"{BASE}/kpi-forecast", params={
"entity_id": 1, "kpi_code": "F_ISO_E2",
})
assert resp.status_code == 404
# entity2 自己能查到
resp = client.get(f"{BASE}/kpi-forecast", params={
"entity_id": 2, "kpi_code": "F_ISO_E2",
})
assert resp.status_code == 200
assert resp.json()["entity_id"] == 2
def test_batch_isolation(self, client: TestClient, db: Session):
"""批量预测:entity1 结果不含 entity2 的财务KPI"""
_seed_entity2(db)
kpi1 = create_test_kpi(db, kpi_code="F_ISO_E1", kpi_name="酣客收入", dimension="finance")
_seed_history(db, kpi1, [100, 110, 120, 130, 140], entity_id=1)
kpi2 = create_test_kpi(db, kpi_code="F_ISO_E2B", kpi_name="博海收入", dimension="finance", entity_id=2)
_seed_history(db, kpi2, [200, 210, 220, 230], entity_id=2)
resp = client.get(f"{BASE}/kpi-forecast/finance", params={"entity_id": 1})
codes = [d["kpi"]["code"] for d in resp.json()["data"]]
assert "F_ISO_E1" in codes
assert "F_ISO_E2B" not in codes
def test_token_binds_entity(self, client: TestClient, db: Session):
"""token绑定entity1时显式传entity_id=2 → 403 越权拦截"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="F_ISO_TK", kpi_name="TokenKPI")
_seed_history(db, kpi, [1, 2, 3, 4])
resp = client.get(f"{BASE}/kpi-forecast", params={
"entity_id": 2, "kpi_code": "F_ISO_TK",
}, headers=auth_header(token))
assert resp.status_code == 403