test: CMA自动化测试补覆盖 226→452用例, 覆盖率36%→60%
- 新增8个测试文件(bot_bridge/kpi_causality/cash/predict/reports/tax_compliance/expenses/probe_cost) - 增强 budget/auth/users + conftest账套模式适配 - 测试驱动修复: bot_bridge导入batch_id→source_batch; cash_forecast extra空dict - 全量: 451 passed, 1 xfailed; 报告 docs/cma-test-coverage-report.md
This commit is contained in:
@@ -529,7 +529,7 @@ def bot_import_excel(
|
||||
continue
|
||||
|
||||
kv = KPIValue(kpi_id=kpi.id, period=period, actual_value=val,
|
||||
batch_id=hashlib.md5(f"{datetime.now()}".encode()).hexdigest()[:12])
|
||||
source_batch=hashlib.md5(f"{datetime.now()}".encode()).hexdigest()[:12])
|
||||
db.add(kv)
|
||||
count += 1
|
||||
except Exception as e:
|
||||
|
||||
@@ -320,6 +320,7 @@ def calculate_accuracy(entity_id: int, db: Session) -> list:
|
||||
|
||||
def generate_scenario_suggestion(alert_type: str, kpi_name: str, extra: dict = None) -> dict:
|
||||
"""根据预警类型生成情景建议"""
|
||||
extra = extra or {}
|
||||
suggestions = {
|
||||
"cash_low": {
|
||||
"title": "现金流紧张缓解方案",
|
||||
@@ -624,7 +625,7 @@ def forecast_cash_flow_with_plans(
|
||||
def check_cash_alerts(db: Session, entity_id: int = 1) -> dict:
|
||||
"""资金预警 — 缺口前3天预警 + 到期未收款提醒,写入预警中心(kpi_alerts)"""
|
||||
import json as _json
|
||||
from app.models import KPIAlert, CashPlan
|
||||
from app.models import KPIAlert, CashPlan, KPIDefinition
|
||||
|
||||
result = forecast_cash_flow_with_plans(entity_id, db, days=30)
|
||||
new_alerts = []
|
||||
|
||||
@@ -35,6 +35,38 @@ TEST_ENGINE = create_engine(
|
||||
)
|
||||
TEST_SESSION_LOCAL = sessionmaker(autocommit=False, autoflush=False, bind=TEST_ENGINE)
|
||||
|
||||
|
||||
# MySQL-only 的 date_format() 在 SQLite 下注册等价实现(仅测试库)
|
||||
# 生产用 MySQL 原生函数;此处仅为让测试能跑通 expenses 月度累计校验/stats 统计
|
||||
def _sqlite_date_format(dt_val, fmt):
|
||||
if dt_val is None:
|
||||
return None
|
||||
import datetime as _dt
|
||||
if isinstance(dt_val, str):
|
||||
for f in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d", "%Y-%m"):
|
||||
try:
|
||||
dt_val = _dt.datetime.strptime(str(dt_val)[:19], f)
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
else:
|
||||
return None
|
||||
if isinstance(dt_val, _dt.datetime):
|
||||
d = dt_val
|
||||
elif isinstance(dt_val, _dt.date):
|
||||
d = _dt.datetime(dt_val.year, dt_val.month, dt_val.day)
|
||||
else:
|
||||
return None
|
||||
return {
|
||||
"%Y": f"{d.year:04d}",
|
||||
"%Y-%m": f"{d.year:04d}-{d.month:02d}",
|
||||
"%Y-%m-%d": f"{d.year:04d}-{d.month:02d}-{d.day:02d}",
|
||||
}.get(fmt)
|
||||
|
||||
|
||||
from sqlalchemy import event # noqa: E402
|
||||
event.listen(TEST_ENGINE, "connect", lambda dbapi_conn, rec: dbapi_conn.create_function("date_format", 2, _sqlite_date_format))
|
||||
|
||||
# 替换 database 模块的全局引擎
|
||||
db_module._engine = TEST_ENGINE
|
||||
db_module._SessionLocal = TEST_SESSION_LOCAL
|
||||
@@ -69,6 +101,13 @@ def db() -> Generator[Session, None, None]:
|
||||
def client(db) -> Generator[TestClient, None, None]:
|
||||
"""提供测试 HTTP 客户端"""
|
||||
from app.main import app
|
||||
from app.models import Entity
|
||||
|
||||
# 账套模式:确保测试库存在 entity_id=1 的active实体
|
||||
ent = db.query(Entity).filter(Entity.id == 1).first()
|
||||
if not ent:
|
||||
db.add(Entity(id=1, name="测试企业", short_name="测试", status="active"))
|
||||
db.commit()
|
||||
|
||||
# 重写依赖,使用测试数据库
|
||||
app.dependency_overrides[db_module.get_db] = lambda: db
|
||||
@@ -98,12 +137,16 @@ def create_test_user(db: Session, **kwargs) -> User:
|
||||
|
||||
|
||||
def get_token_for_user(client: TestClient, username: str = "testadmin", password: str = "admin123") -> str:
|
||||
"""获取测试用户的token"""
|
||||
"""获取测试用户的token(账套模式:需entity_id)"""
|
||||
resp = client.post("/api/cma/auth/login", json={
|
||||
"username": username,
|
||||
"password": password,
|
||||
"entity_id": 1,
|
||||
})
|
||||
return resp.json()["token"]
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(f"登录失败: {resp.status_code} {resp.text[:300]}")
|
||||
data = resp.json()
|
||||
return data.get("token") or data.get("access_token")
|
||||
|
||||
|
||||
def auth_header(token: str) -> dict:
|
||||
|
||||
@@ -17,6 +17,7 @@ class TestAuth:
|
||||
resp = client.post("/api/cma/auth/login", json={
|
||||
"username": "testadmin",
|
||||
"password": "admin123",
|
||||
"entity_id": 1,
|
||||
})
|
||||
|
||||
assert resp.status_code == 200
|
||||
@@ -78,6 +79,7 @@ class TestAuth:
|
||||
login_resp = client.post("/api/cma/auth/login", json={
|
||||
"username": "newuser",
|
||||
"password": "newpass123",
|
||||
"entity_id": 1,
|
||||
})
|
||||
assert login_resp.status_code == 200
|
||||
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
"""BOT桥接层测试 — CMA供财务/研学Bot调用的主通道(X-BOT-KEY鉴权)
|
||||
|
||||
覆盖 bot_bridge.py 全部18个端点:
|
||||
ping / overview / kpis / kpis{id}/history / strategic-maps / alerts /
|
||||
budget/plans / cost/standard / cost/actual / actions / organization /
|
||||
data-sources / users / query / import / okr/create / okr/list / nlp
|
||||
"""
|
||||
import io
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from openpyxl import Workbook
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import (
|
||||
User, KPIDefinition, KPIValue, KPIAlert, StrategicMap, MapObjective,
|
||||
ActionPlan, OrgNode, DataSourceConfig, Objective,
|
||||
)
|
||||
from app.models.budget_plan import BudgetPlan
|
||||
from app.models.cost_model import StandardCost, ActualCost
|
||||
from tests.conftest import create_test_user, get_token_for_user, auth_header
|
||||
|
||||
BOT_KEY = {"X-BOT-KEY": "cma-bot-finance-2026"}
|
||||
|
||||
|
||||
def _make_excel(kpi_code: str, period: str, value: float) -> bytes:
|
||||
"""生成Excel导入文件(列: kpi_code, period, actual_value)"""
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.append(["kpi_code", "period", "actual_value"])
|
||||
ws.append([kpi_code, period, value])
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _seed_kpi(db: Session, **kwargs) -> KPIDefinition:
|
||||
defaults = dict(
|
||||
kpi_code="BH_REVENUE",
|
||||
kpi_name="营业收入",
|
||||
dimension="finance",
|
||||
status="active",
|
||||
target_value=100.0,
|
||||
unit="万元",
|
||||
frequency="monthly",
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
kpi = KPIDefinition(**defaults)
|
||||
db.add(kpi)
|
||||
db.commit()
|
||||
db.refresh(kpi)
|
||||
return kpi
|
||||
|
||||
|
||||
class TestPingAndAuth:
|
||||
def test_ping_no_key(self, client: TestClient):
|
||||
"""ping 无需鉴权"""
|
||||
resp = client.get("/api/cma/bot/ping")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ok"
|
||||
|
||||
def test_invalid_bot_key(self, client: TestClient):
|
||||
"""无效BOT Key → 401"""
|
||||
resp = client.get("/api/cma/bot/overview", headers={"X-BOT-KEY": "wrong-key"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_missing_bot_key(self, client: TestClient):
|
||||
"""缺BOT Key → 401"""
|
||||
resp = client.get("/api/cma/bot/overview")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
class TestOverviewAndKpis:
|
||||
def test_overview_stats(self, client: TestClient, db: Session):
|
||||
"""总览统计:造数后计数正确"""
|
||||
_seed_kpi(db)
|
||||
db.add(KPIAlert(kpi_id=1, alert_level="red", alert_message="收入下滑", status="pending"))
|
||||
db.commit()
|
||||
|
||||
resp = client.get("/api/cma/bot/overview", headers=BOT_KEY)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["bot"]["name"] == "财务BOT"
|
||||
assert data["stats"]["kpis_total"] == 1
|
||||
assert data["stats"]["alerts_open"] == 1
|
||||
|
||||
def test_kpis_filter_by_dimension(self, client: TestClient, db: Session):
|
||||
"""KPI列表:按维度过滤 + 关联最新实际值"""
|
||||
k1 = _seed_kpi(db, kpi_code="BH_REVENUE", dimension="finance")
|
||||
_seed_kpi(db, kpi_code="BH_CUSTOMER", dimension="customer")
|
||||
db.add(KPIValue(kpi_id=k1.id, period="2026-06", actual_value=88.0, data_status="verified"))
|
||||
db.commit()
|
||||
|
||||
resp = client.get("/api/cma/bot/kpis?dimension=finance", headers=BOT_KEY)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["code"] == "BH_REVENUE"
|
||||
assert data["items"][0]["latest_value"] == 88.0
|
||||
assert data["items"][0]["latest_period"] == "2026-06"
|
||||
|
||||
def test_kpis_status_filter(self, client: TestClient, db: Session):
|
||||
"""KPI列表:status过滤(默认active,inactive被过滤)"""
|
||||
_seed_kpi(db, kpi_code="BH_ACTIVE")
|
||||
_seed_kpi(db, kpi_code="BH_INACTIVE", status="inactive")
|
||||
resp = client.get("/api/cma/bot/kpis", headers=BOT_KEY)
|
||||
assert resp.status_code == 200
|
||||
codes = {i["code"] for i in resp.json()["items"]}
|
||||
assert "BH_ACTIVE" in codes
|
||||
assert "BH_INACTIVE" not in codes
|
||||
|
||||
def test_kpi_history(self, client: TestClient, db: Session):
|
||||
"""KPI历史值"""
|
||||
k = _seed_kpi(db, kpi_code="BH_REVENUE")
|
||||
db.add(KPIValue(kpi_id=k.id, period="2026-07", actual_value=95.0, source_type="manual", data_status="verified"))
|
||||
db.add(KPIValue(kpi_id=k.id, period="2026-06", actual_value=88.0))
|
||||
db.commit()
|
||||
|
||||
resp = client.get(f"/api/cma/bot/kpis/{k.id}/history", headers=BOT_KEY)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["kpi"]["code"] == "BH_REVENUE"
|
||||
# 按期间倒序,最新在前
|
||||
assert data["values"][0]["period"] == "2026-07"
|
||||
assert len(data["values"]) == 2
|
||||
|
||||
def test_kpi_history_not_found(self, client: TestClient):
|
||||
"""不存在的KPI → 404"""
|
||||
resp = client.get("/api/cma/bot/kpis/99999/history", headers=BOT_KEY)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestMapsAlertsBudgetCost:
|
||||
def test_strategic_maps(self, client: TestClient, db: Session):
|
||||
"""战略地图列表(含目标)"""
|
||||
m = StrategicMap(title="博海战略地图", version="v1.0", status="published",
|
||||
dimensions=[{"key": "finance", "name": "财务"}])
|
||||
db.add(m)
|
||||
db.commit()
|
||||
db.refresh(m)
|
||||
db.add(MapObjective(map_id=m.id, dimension_key="finance", name="提升收入"))
|
||||
db.commit()
|
||||
|
||||
resp = client.get("/api/cma/bot/strategic-maps", headers=BOT_KEY)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["title"] == "博海战略地图"
|
||||
assert data["items"][0]["objectives"]["finance"][0]["name"] == "提升收入"
|
||||
|
||||
def test_alerts_filter(self, client: TestClient, db: Session):
|
||||
"""预警列表:按状态/等级过滤"""
|
||||
_seed_kpi(db)
|
||||
db.add(KPIAlert(kpi_id=1, alert_level="red", alert_message="严重", status="pending"))
|
||||
db.add(KPIAlert(kpi_id=1, alert_level="yellow", alert_message="关注", status="resolved"))
|
||||
db.commit()
|
||||
|
||||
resp = client.get("/api/cma/bot/alerts?status=pending&level=red", headers=BOT_KEY)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["level"] == "red"
|
||||
assert data["items"][0]["message"] == "严重"
|
||||
|
||||
def test_budget_plans(self, client: TestClient, db: Session):
|
||||
"""预算计划(按年过滤)"""
|
||||
k = _seed_kpi(db)
|
||||
db.add(BudgetPlan(kpi_id=k.id, period="2026-06", budget_value=50000.0,
|
||||
budget_year=2026, budget_month=6, status="active"))
|
||||
db.commit()
|
||||
|
||||
resp = client.get("/api/cma/bot/budget/plans?year=2026", headers=BOT_KEY)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["budget_value"] == 50000.0
|
||||
|
||||
def test_budget_plans_year_filter(self, client: TestClient, db: Session):
|
||||
"""预算计划:其他年份被过滤"""
|
||||
k = _seed_kpi(db)
|
||||
db.add(BudgetPlan(kpi_id=k.id, period="2025-12", budget_value=100.0,
|
||||
budget_year=2025, budget_month=12, status="active"))
|
||||
db.commit()
|
||||
resp = client.get("/api/cma/bot/budget/plans?year=2024", headers=BOT_KEY)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total"] == 0
|
||||
|
||||
def test_cost_standard(self, client: TestClient, db: Session):
|
||||
"""标准成本"""
|
||||
db.add(StandardCost(product_code="P001", product_name="产品A", cost_type="material",
|
||||
item_name="原料", standard_quantity=2.0, unit="kg",
|
||||
standard_price=10.0, standard_cost=20.0, status="active"))
|
||||
db.commit()
|
||||
resp = client.get("/api/cma/bot/cost/standard", headers=BOT_KEY)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total"] == 1
|
||||
assert resp.json()["items"][0]["standard_cost"] == 20.0
|
||||
|
||||
def test_cost_actual_period(self, client: TestClient, db: Session):
|
||||
"""实际成本(按期间过滤)"""
|
||||
db.add(ActualCost(period="2026-06", product_code="P001", product_name="产品A",
|
||||
cost_type="material", item_name="原料",
|
||||
actual_quantity=3.0, actual_price=12.0, actual_cost=36.0))
|
||||
db.commit()
|
||||
resp = client.get("/api/cma/bot/cost/actual?period=2026-06", headers=BOT_KEY)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total"] == 1
|
||||
assert resp.json()["items"][0]["actual_cost"] == 36.0
|
||||
|
||||
|
||||
class TestActionsOrgSourcesUsers:
|
||||
def test_actions(self, client: TestClient, db: Session):
|
||||
"""行动方案列表(按状态过滤)"""
|
||||
k = _seed_kpi(db)
|
||||
db.add(ActionPlan(kpi_id=k.id, title="提升收入", status="in_progress", priority="high", progress=50))
|
||||
db.add(ActionPlan(kpi_id=k.id, title="已关闭", status="completed", priority="low", progress=100))
|
||||
db.commit()
|
||||
|
||||
resp = client.get("/api/cma/bot/actions?status=in_progress", headers=BOT_KEY)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["title"] == "提升收入"
|
||||
|
||||
def test_organization(self, client: TestClient, db: Session):
|
||||
"""组织架构"""
|
||||
db.add(OrgNode(name="测试组织", code="TEST_ORG_001", level=1, sort_order=1, enabled=1))
|
||||
db.commit()
|
||||
resp = client.get("/api/cma/bot/organization", headers=BOT_KEY)
|
||||
assert resp.status_code == 200
|
||||
codes = [i["code"] for i in resp.json()["items"]]
|
||||
assert "TEST_ORG_001" in codes
|
||||
|
||||
def test_data_sources(self, client: TestClient, db: Session):
|
||||
"""数据源"""
|
||||
db.add(DataSourceConfig(name="ERP", source_type="erp", api_endpoint="http://erp",
|
||||
sync_type="batch", status="active"))
|
||||
db.commit()
|
||||
resp = client.get("/api/cma/bot/data-sources", headers=BOT_KEY)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total"] == 1
|
||||
assert resp.json()["items"][0]["name"] == "ERP"
|
||||
|
||||
def test_users(self, client: TestClient, db: Session):
|
||||
"""用户列表(不返回密码等敏感字段)"""
|
||||
create_test_user(db)
|
||||
resp = client.get("/api/cma/bot/users", headers=BOT_KEY)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total"] >= 1
|
||||
user = resp.json()["items"][0]
|
||||
assert "username" in user
|
||||
assert "password" not in user
|
||||
|
||||
|
||||
class TestUnifiedQuery:
|
||||
def test_query_overview(self, client: TestClient, db: Session):
|
||||
"""统一查询 overview"""
|
||||
_seed_kpi(db)
|
||||
resp = client.get("/api/cma/bot/query?q=overview", headers=BOT_KEY)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["overview"]["kpis"] == 1
|
||||
|
||||
def test_query_kpis_and_budget(self, client: TestClient, db: Session):
|
||||
"""统一查询 kpis / budget"""
|
||||
k = _seed_kpi(db)
|
||||
db.add(BudgetPlan(kpi_id=k.id, period="2026-06", budget_value=10.0,
|
||||
budget_year=2026, budget_month=6, status="active"))
|
||||
db.commit()
|
||||
|
||||
r1 = client.get("/api/cma/bot/query?q=kpis", headers=BOT_KEY)
|
||||
assert r1.status_code == 200
|
||||
assert len(r1.json()["kpis"]) == 1
|
||||
|
||||
r2 = client.get("/api/cma/bot/query?q=budget", headers=BOT_KEY)
|
||||
assert r2.status_code == 200
|
||||
assert len(r2.json()["budget"]) == 1
|
||||
|
||||
def test_query_all(self, client: TestClient, db: Session):
|
||||
"""统一查询 all:返回全部分组"""
|
||||
_seed_kpi(db)
|
||||
resp = client.get("/api/cma/bot/query?q=all", headers=BOT_KEY)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "overview" in data and "kpis" in data and "alerts" in data
|
||||
assert "maps" in data and "budget" in data and "costs" in data
|
||||
assert "actions" in data and "okr" in data
|
||||
|
||||
|
||||
class TestImport:
|
||||
def test_import_excel(self, client: TestClient, db: Session):
|
||||
"""Excel导入KPI实际值"""
|
||||
_seed_kpi(db, kpi_code="BH_REVENUE")
|
||||
files = {"file": ("kpi.xlsx", _make_excel("BH_REVENUE", "2026-08", 99.5),
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}
|
||||
resp = client.post("/api/cma/bot/import", headers=BOT_KEY, files=files)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ok"] is True
|
||||
assert data["imported"] == 1
|
||||
|
||||
# 验证入库
|
||||
val = db.query(KPIValue).filter(KPIValue.period == "2026-08").first()
|
||||
assert val is not None and val.actual_value == 99.5
|
||||
|
||||
def test_import_excel_unknown_kpi(self, client: TestClient, db: Session):
|
||||
"""导入不存在的KPI编码 → 跳过并记录错误"""
|
||||
files = {"file": ("kpi.xlsx", _make_excel("NO_SUCH_KPI", "2026-08", 10.0),
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}
|
||||
resp = client.post("/api/cma/bot/import", headers=BOT_KEY, files=files)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["imported"] == 0
|
||||
assert data["errors"] == 1
|
||||
|
||||
def test_import_bad_file(self, client: TestClient):
|
||||
"""非Excel文件 → 400"""
|
||||
files = {"file": ("bad.txt", b"not an excel", "text/plain")}
|
||||
resp = client.post("/api/cma/bot/import", headers=BOT_KEY, files=files)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_import_missing_value_col(self, client: TestClient, db: Session):
|
||||
"""缺少数值列 → 400"""
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.append(["kpi_code"])
|
||||
ws.append(["BH_REVENUE"])
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
files = {"file": ("kpi.xlsx", buf.getvalue(),
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}
|
||||
resp = client.post("/api/cma/bot/import", headers=BOT_KEY, files=files)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
class TestOkrAndNlp:
|
||||
def test_okr_create_and_list(self, client: TestClient, db: Session):
|
||||
"""Bot创建OKR目标 + 列表"""
|
||||
resp = client.post("/api/cma/bot/okr/create?title=提升净利润&quarter=2026Q3&dimension=finance",
|
||||
headers=BOT_KEY)
|
||||
assert resp.status_code == 200
|
||||
obj_id = resp.json()["id"]
|
||||
assert obj_id > 0
|
||||
|
||||
list_resp = client.get("/api/cma/bot/okr/list?quarter=2026Q3", headers=BOT_KEY)
|
||||
assert list_resp.status_code == 200
|
||||
assert list_resp.json()["total"] == 1
|
||||
assert list_resp.json()["items"][0]["title"] == "提升净利润"
|
||||
|
||||
def test_okr_create_missing_quarter(self, client: TestClient):
|
||||
"""缺quarter → 422"""
|
||||
resp = client.post("/api/cma/bot/okr/create?title=无季度目标", headers=BOT_KEY)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_nlp_intent_mapping(self, client: TestClient, db: Session):
|
||||
"""自然语言意图映射"""
|
||||
_seed_kpi(db)
|
||||
# 中文意图 → 映射到预算
|
||||
resp = client.get("/api/cma/bot/nlp?intent=预算", headers=BOT_KEY)
|
||||
assert resp.status_code == 200
|
||||
assert "budget" in resp.json()
|
||||
|
||||
resp2 = client.get("/api/cma/bot/nlp?intent=总览", headers=BOT_KEY)
|
||||
assert resp2.status_code == 200
|
||||
assert "overview" in resp2.json()
|
||||
@@ -1,8 +1,9 @@
|
||||
"""预算管理模块测试 — 预算计划CRUD + 自动分解 + 版本"""
|
||||
"""预算管理模块测试 — 预算计划CRUD + 自动分解 + 版本 + 偏差/对比/配置/滚动"""
|
||||
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.budget_plan import BudgetPlan
|
||||
|
||||
|
||||
class TestBudgetPlans:
|
||||
@@ -192,3 +193,378 @@ class TestBudgetVersions:
|
||||
json={"version": "v2.0", "action": "rejected"},
|
||||
)
|
||||
assert resp.status_code == 404, "versions/approve端点已移除"
|
||||
|
||||
|
||||
class TestBudgetDeviationReport:
|
||||
"""偏差报告(实际 vs 预算汇总)"""
|
||||
|
||||
BASE = "/api/cma/budget"
|
||||
|
||||
def _seed(self, db: Session):
|
||||
kpi = create_test_kpi(db, kpi_code="BUDGET_DEV_KPI")
|
||||
db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=100.0,
|
||||
budget_year=2026, budget_month=6, status="active"))
|
||||
from app.models import KPIValue
|
||||
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=120.0))
|
||||
db.commit()
|
||||
return kpi
|
||||
|
||||
def test_deviation_report_over_budget(self, client: TestClient, db: Session):
|
||||
"""实际超出预算 → 超支统计"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
self._seed(db)
|
||||
|
||||
resp = client.get(f"{self.BASE}/deviation-report?year=2026&month=6",
|
||||
headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["period"] == "2026-06"
|
||||
assert data["summary"]["total_kpis"] == 1
|
||||
assert data["summary"]["has_budget"] == 1
|
||||
assert data["summary"]["over_budget"] == 1
|
||||
# 120 vs 100 → +20%
|
||||
assert data["items"][0]["deviation_rate"] == 20.0
|
||||
|
||||
def test_deviation_report_alert_level_filter(self, client: TestClient, db: Session):
|
||||
"""按预警等级过滤(>20%红 / >10%黄)"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
self._seed(db)
|
||||
|
||||
resp = client.get(f"{self.BASE}/deviation-report?year=2026&month=6&alert_level=red",
|
||||
headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
# summary统计全部KPI;items按alert_level过滤
|
||||
assert resp.json()["summary"]["total_kpis"] == 1
|
||||
assert len(resp.json()["items"]) == 0 # 20% 不是 >20,非red
|
||||
|
||||
resp2 = client.get(f"{self.BASE}/deviation-report?year=2026&month=6&alert_level=yellow",
|
||||
headers=auth_header(token))
|
||||
assert resp2.status_code == 200
|
||||
assert len(resp2.json()["items"]) == 1
|
||||
|
||||
|
||||
class TestBudgetConfig:
|
||||
"""预算模式配置"""
|
||||
|
||||
BASE = "/api/cma/budget"
|
||||
|
||||
def test_default_config(self, client: TestClient, db: Session):
|
||||
"""未配置时默认固定预算"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.get(f"{self.BASE}/config", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["budget_mode"] == "fixed"
|
||||
assert resp.json()["rolling_months"] == 12
|
||||
|
||||
def test_set_config_rolling(self, client: TestClient, db: Session):
|
||||
"""切换为滚动预算"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.post(f"{self.BASE}/config", headers=auth_header(token),
|
||||
json={"mode": "rolling", "rolling_months": 6})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["budget_mode"] == "rolling"
|
||||
|
||||
get_resp = client.get(f"{self.BASE}/config", headers=auth_header(token))
|
||||
cfg = get_resp.json()
|
||||
# GET返回存的JSON {mode:...}(前端兼容 budget_mode || mode)
|
||||
assert (cfg.get("budget_mode") or cfg.get("mode")) == "rolling"
|
||||
assert cfg.get("rolling_months") == 6
|
||||
|
||||
def test_set_config_invalid_mode(self, client: TestClient, db: Session):
|
||||
"""非法模式 → 400"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.post(f"{self.BASE}/config", headers=auth_header(token),
|
||||
json={"mode": "weird"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
class TestBudgetRollForward:
|
||||
"""滚动预算自动延展"""
|
||||
|
||||
BASE = "/api/cma/budget"
|
||||
|
||||
def test_roll_forward_requires_rolling_mode(self, client: TestClient, db: Session):
|
||||
"""固定预算模式 → 400"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.post(f"{self.BASE}/roll-forward", headers=auth_header(token))
|
||||
assert resp.status_code == 400
|
||||
assert "未配置" in resp.json()["detail"]
|
||||
|
||||
def test_roll_forward_success(self, client: TestClient, db: Session):
|
||||
"""滚动模式延展:删除最早月 + 新增未来月"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db, kpi_code="BUDGET_ROLL_KPI")
|
||||
|
||||
# 切滚动模式
|
||||
client.post(f"{self.BASE}/config", headers=auth_header(token),
|
||||
json={"mode": "rolling", "rolling_months": 12})
|
||||
|
||||
# 造12个月预算
|
||||
from app.models import KPIValue
|
||||
for m in range(1, 13):
|
||||
db.add(BudgetPlan(kpi_id=kpi.id, period=f"2026-{m:02d}",
|
||||
budget_value=1000.0 + m, budget_year=2026,
|
||||
budget_month=m, status="active"))
|
||||
db.commit()
|
||||
|
||||
resp = client.post(f"{self.BASE}/roll-forward", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "滚动预算已延展" in data["message"]
|
||||
assert len(data["rolled_kpis"]) == 1
|
||||
# 新增月份 = 当前月(08) + 12 = 明年08
|
||||
assert data["rolled_kpis"][0]["added_period"].startswith("2027-")
|
||||
|
||||
# 最早月(2026-01)被删除
|
||||
from app.models import BudgetPlan as BP
|
||||
periods = [p.period for p in db.query(BP).filter(BP.kpi_id == kpi.id).all()]
|
||||
assert "2026-01" not in periods
|
||||
assert "2027-08" in periods
|
||||
|
||||
|
||||
class TestBudgetComparison:
|
||||
"""实际 vs 预测对比"""
|
||||
|
||||
BASE = "/api/cma/budget"
|
||||
|
||||
def _seed(self, db: Session):
|
||||
kpi = create_test_kpi(db, kpi_code="BUDGET_CMP_KPI")
|
||||
db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=100.0,
|
||||
budget_year=2026, budget_month=6, status="active"))
|
||||
from app.models import KPIValue
|
||||
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=80.0))
|
||||
db.commit()
|
||||
return kpi
|
||||
|
||||
def test_comparison(self, client: TestClient, db: Session):
|
||||
"""全KPI对比(固定预算12个月)"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
self._seed(db)
|
||||
|
||||
resp = client.get(f"{self.BASE}/comparison?year=2026", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["budget_mode"] == "fixed"
|
||||
assert len(data["periods"]) == 12
|
||||
# 6月有预算+实际
|
||||
june = [m for m in data["months_data"] if m["period"] == "2026-06"][0]
|
||||
assert june["budget_total"] == 100.0
|
||||
assert june["actual_total"] == 80.0
|
||||
assert june["deviation_rate"] == -20.0
|
||||
|
||||
def test_comparison_kpi_detail(self, client: TestClient, db: Session):
|
||||
"""单KPI对比"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = self._seed(db)
|
||||
|
||||
resp = client.get(f"{self.BASE}/comparison/kpi/{kpi.id}?year=2026",
|
||||
headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["kpi_code"] == "BUDGET_CMP_KPI"
|
||||
june = [d for d in data["data_points"] if d["period"] == "2026-06"][0]
|
||||
assert june["budget_value"] == 100.0
|
||||
assert june["actual_value"] == 80.0
|
||||
assert june["deviation_rate"] == -20.0
|
||||
|
||||
def test_comparison_kpi_not_found(self, client: TestClient, db: Session):
|
||||
"""KPI不存在 → 404"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.get(f"{self.BASE}/comparison/kpi/99999?year=2026",
|
||||
headers=auth_header(token))
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestBudgetDeviationCheck:
|
||||
"""预算偏差自动预警"""
|
||||
|
||||
BASE = "/api/cma/budget"
|
||||
|
||||
def _seed(self, db: Session):
|
||||
kpi = create_test_kpi(db, kpi_code="BUDGET_ALERT_KPI")
|
||||
db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=100.0,
|
||||
budget_year=2026, budget_month=6, status="active"))
|
||||
from app.models import KPIValue
|
||||
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=150.0)) # +50%
|
||||
db.commit()
|
||||
return kpi
|
||||
|
||||
def test_deviation_check_generates_alert(self, client: TestClient, db: Session):
|
||||
"""超阈值生成预警"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = self._seed(db)
|
||||
|
||||
resp = client.post(f"{self.BASE}/deviation-check", headers=auth_header(token),
|
||||
json={"period": "2026-06", "threshold": 20})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["alerts_generated"] == 1
|
||||
assert data["alerts"][0]["deviation_rate"] == 50.0
|
||||
# 50% 不 >50,为 warning;>50% 才是 critical
|
||||
assert data["alerts"][0]["alert_level"] == "warning"
|
||||
|
||||
def test_deviation_check_no_budget(self, client: TestClient, db: Session):
|
||||
"""无预算数据 → 不生成预警"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.post(f"{self.BASE}/deviation-check", headers=auth_header(token),
|
||||
json={"period": "2026-01"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["alerts_generated"] == 0
|
||||
|
||||
def test_deviation_check_under_threshold(self, client: TestClient, db: Session):
|
||||
"""未超阈值不生成预警"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db, kpi_code="BUDGET_ALERT_OK")
|
||||
db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=100.0,
|
||||
budget_year=2026, budget_month=6, status="active"))
|
||||
from app.models import KPIValue
|
||||
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=105.0)) # +5%
|
||||
db.commit()
|
||||
|
||||
resp = client.post(f"{self.BASE}/deviation-check", headers=auth_header(token),
|
||||
json={"period": "2026-06", "threshold": 20})
|
||||
assert resp.json()["alerts_generated"] == 0
|
||||
|
||||
|
||||
class TestBudgetDeviationAlerts:
|
||||
"""偏差预警记录查询/更新"""
|
||||
|
||||
BASE = "/api/cma/budget"
|
||||
|
||||
def _seed_alert(self, client, db: Session, token: str):
|
||||
kpi = create_test_kpi(db, kpi_code="BUDGET_DEV_ALERT")
|
||||
db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=100.0,
|
||||
budget_year=2026, budget_month=6, status="active"))
|
||||
from app.models import KPIValue
|
||||
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=200.0))
|
||||
db.commit()
|
||||
client.post(f"{self.BASE}/deviation-check", headers=auth_header(token),
|
||||
json={"period": "2026-06"})
|
||||
return kpi
|
||||
|
||||
def test_list_alerts(self, client: TestClient, db: Session):
|
||||
"""预警列表"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
self._seed_alert(client, db, token)
|
||||
|
||||
resp = client.get(f"{self.BASE}/deviation-alerts", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total"] == 1
|
||||
alert = resp.json()["data"][0]
|
||||
assert alert["status"] == "open"
|
||||
assert alert["kpi_code"] == "BUDGET_DEV_ALERT"
|
||||
assert alert["alert_level"] == "critical"
|
||||
|
||||
def test_list_alerts_filters(self, client: TestClient, db: Session):
|
||||
"""按状态/等级过滤"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
self._seed_alert(client, db, token)
|
||||
|
||||
resp = client.get(f"{self.BASE}/deviation-alerts?status=open&alert_level=critical",
|
||||
headers=auth_header(token))
|
||||
assert resp.json()["total"] == 1
|
||||
|
||||
resp2 = client.get(f"{self.BASE}/deviation-alerts?status=resolved",
|
||||
headers=auth_header(token))
|
||||
assert resp2.json()["total"] == 0
|
||||
|
||||
def test_update_alert_resolve(self, client: TestClient, db: Session):
|
||||
"""标记预警已解决"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
self._seed_alert(client, db, token)
|
||||
|
||||
alert_id = client.get(f"{self.BASE}/deviation-alerts",
|
||||
headers=auth_header(token)).json()["data"][0]["id"]
|
||||
resp = client.put(f"{self.BASE}/deviation-alerts/{alert_id}",
|
||||
headers=auth_header(token), json={"status": "resolved"})
|
||||
assert resp.status_code == 200
|
||||
|
||||
list_resp = client.get(f"{self.BASE}/deviation-alerts",
|
||||
headers=auth_header(token))
|
||||
assert list_resp.json()["data"][0]["status"] == "resolved"
|
||||
|
||||
def test_update_alert_not_found(self, client: TestClient, db: Session):
|
||||
"""更新不存在的预警 → 404"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.put(f"{self.BASE}/deviation-alerts/99999",
|
||||
headers=auth_header(token), json={"status": "resolved"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestBudgetMethodComparison:
|
||||
"""预算方法三选一对比"""
|
||||
|
||||
BASE = "/api/cma/budget"
|
||||
|
||||
def test_method_comparison_defaults(self, client: TestClient, db: Session):
|
||||
"""默认参数返回三种方法"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.post(f"{self.BASE}/method-comparison", headers=auth_header(token),
|
||||
json={})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["methods"]) == 3
|
||||
ids = {m["id"] for m in data["methods"]}
|
||||
assert ids == {"incremental", "zero_based", "flexible"}
|
||||
assert data["recommended"] == "zero_based"
|
||||
|
||||
def test_method_comparison_custom(self, client: TestClient, db: Session):
|
||||
"""自定义参数:增量预算结果 = 上月×(1+增幅)"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.post(f"{self.BASE}/method-comparison", headers=auth_header(token),
|
||||
json={"entity": "bohai", "last_month_budget": 100,
|
||||
"current_revenue": 200, "increment_rate": 0.1})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["entity_name"] == "陕西博海科技(IT服务)"
|
||||
incremental = [m for m in data["methods"] if m["id"] == "incremental"][0]
|
||||
assert incremental["result_value"] == 110.0 # 100 × 1.1
|
||||
|
||||
|
||||
class TestBudgetPermissions:
|
||||
"""权限边界:business角色无访问权限"""
|
||||
|
||||
BASE = "/api/cma/budget"
|
||||
|
||||
def test_business_role_denied(self, client: TestClient, db: Session):
|
||||
"""business用户访问预算 → 403"""
|
||||
import hashlib
|
||||
from app.models import User
|
||||
business = User(
|
||||
username="business_budget",
|
||||
password_hash=hashlib.sha256("pass123".encode()).hexdigest(),
|
||||
name="业务员",
|
||||
role="business",
|
||||
)
|
||||
db.add(business)
|
||||
db.commit()
|
||||
token = get_token_for_user(client, username="business_budget", password="pass123")
|
||||
|
||||
resp = client.get(f"{self.BASE}/plans", headers=auth_header(token))
|
||||
assert resp.status_code == 403
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
"""现金流模块测试 — 收付款计划 + 资金缺口预测 + 看板
|
||||
|
||||
覆盖 cash.py 核心端点:
|
||||
gap-forecast / balance GET+POST / plans CRUD / plans{id}/complete /
|
||||
upcoming / dashboard / check-alerts / alerts/status
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import CashPlan
|
||||
from tests.conftest import create_test_user, get_token_for_user, auth_header
|
||||
|
||||
BASE = "/api/cma/cash"
|
||||
|
||||
|
||||
def _future_date(days: int = 5) -> str:
|
||||
return (datetime.now() + timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _past_date(days: int = 5) -> str:
|
||||
return (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
class TestBalance:
|
||||
def test_get_balance_default(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.get(f"{BASE}/balance", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["entity_id"] == 1
|
||||
|
||||
def test_set_balance(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.post(f"{BASE}/balance", headers=auth_header(token),
|
||||
json={"current_cash": 88.5})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["current_cash"] == 88.5
|
||||
|
||||
get_resp = client.get(f"{BASE}/balance", headers=auth_header(token))
|
||||
assert get_resp.json()["current_cash"] == 88.5
|
||||
|
||||
def test_set_balance_negative(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.post(f"{BASE}/balance", headers=auth_header(token),
|
||||
json={"current_cash": -5})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
class TestPlans:
|
||||
def test_create_plan(self, client: TestClient, db: Session):
|
||||
"""创建收款计划"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.post(f"{BASE}/plans", headers=auth_header(token), json={
|
||||
"plan_type": "receive", "amount": 50.0, "plan_date": _future_date(),
|
||||
"counterparty": "客户A", "owner": "张三",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()["data"]
|
||||
assert data["plan_type"] == "receive"
|
||||
assert data["status"] == "pending"
|
||||
|
||||
def test_create_plan_invalid_type(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.post(f"{BASE}/plans", headers=auth_header(token),
|
||||
json={"plan_type": "weird", "amount": 10, "plan_date": _future_date()})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_create_plan_zero_amount(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.post(f"{BASE}/plans", headers=auth_header(token),
|
||||
json={"plan_type": "pay", "amount": 0, "plan_date": _future_date()})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_create_plan_missing_date(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.post(f"{BASE}/plans", headers=auth_header(token),
|
||||
json={"plan_type": "pay", "amount": 10})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_create_plan_bad_date(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.post(f"{BASE}/plans", headers=auth_header(token),
|
||||
json={"plan_type": "pay", "amount": 10, "plan_date": "not-a-date"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_list_plans_filters(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
client.post(f"{BASE}/plans", headers=auth_header(token),
|
||||
json={"plan_type": "receive", "amount": 10, "plan_date": _future_date()})
|
||||
client.post(f"{BASE}/plans", headers=auth_header(token),
|
||||
json={"plan_type": "pay", "amount": 20, "plan_date": _future_date()})
|
||||
|
||||
resp = client.get(f"{BASE}/plans?plan_type=receive", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total"] == 1
|
||||
assert resp.json()["data"][0]["plan_type"] == "receive"
|
||||
|
||||
def test_list_plans_bad_month(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.get(f"{BASE}/plans?month=bad", headers=auth_header(token))
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_update_plan(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
pid = client.post(f"{BASE}/plans", headers=auth_header(token),
|
||||
json={"plan_type": "receive", "amount": 10, "plan_date": _future_date()}
|
||||
).json()["data"]["id"]
|
||||
resp = client.put(f"{BASE}/plans/{pid}", headers=auth_header(token),
|
||||
json={"amount": 30, "counterparty": "客户B"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["data"]["amount"] == 30.0
|
||||
|
||||
def test_update_plan_not_found(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.put(f"{BASE}/plans/99999", headers=auth_header(token), json={"amount": 10})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_delete_plan(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
pid = client.post(f"{BASE}/plans", headers=auth_header(token),
|
||||
json={"plan_type": "pay", "amount": 10, "plan_date": _future_date()}
|
||||
).json()["data"]["id"]
|
||||
resp = client.delete(f"{BASE}/plans/{pid}", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "计划已删除"
|
||||
|
||||
def test_complete_plan(self, client: TestClient, db: Session):
|
||||
"""标记收款完成"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
pid = client.post(f"{BASE}/plans", headers=auth_header(token),
|
||||
json={"plan_type": "receive", "amount": 10, "plan_date": _future_date()}
|
||||
).json()["data"]["id"]
|
||||
resp = client.post(f"{BASE}/plans/{pid}/complete", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()["data"]
|
||||
assert data["status"] == "completed"
|
||||
assert data["paid_amount"] == 10.0
|
||||
|
||||
def test_complete_plan_not_found(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.post(f"{BASE}/plans/99999/complete", headers=auth_header(token))
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestUpcomingDashboard:
|
||||
def test_upcoming(self, client: TestClient, db: Session):
|
||||
"""到期提醒 + 逾期"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
client.post(f"{BASE}/plans", headers=auth_header(token),
|
||||
json={"plan_type": "receive", "amount": 10, "plan_date": _future_date(3)})
|
||||
client.post(f"{BASE}/plans", headers=auth_header(token),
|
||||
json={"plan_type": "pay", "amount": 20, "plan_date": _past_date(3)})
|
||||
resp = client.get(f"{BASE}/upcoming?days=7", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["upcoming"]) == 1
|
||||
assert len(data["overdue"]) == 1
|
||||
assert data["overdue_receive_amount"] == 0.0
|
||||
assert data["overdue_pay_amount"] == 20.0
|
||||
|
||||
def test_dashboard(self, client: TestClient, db: Session):
|
||||
"""资金看板"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
client.post(f"{BASE}/plans", headers=auth_header(token),
|
||||
json={"plan_type": "receive", "amount": 10, "plan_date": _future_date(3)})
|
||||
resp = client.get(f"{BASE}/dashboard", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "calendar" in data
|
||||
|
||||
def test_dashboard_bad_month(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.get(f"{BASE}/dashboard?month=bad", headers=auth_header(token))
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_gap_forecast(self, client: TestClient, db: Session):
|
||||
"""资金缺口预测"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.get(f"{BASE}/gap-forecast?days=10¤t_cash=100",
|
||||
headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
class TestAlerts:
|
||||
def test_check_alerts(self, client: TestClient, db: Session):
|
||||
"""触发资金预警检查"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.post(f"{BASE}/check-alerts", headers=auth_header(token), json={})
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_alerts_status(self, client: TestClient, db: Session):
|
||||
"""预警状态查询"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.get(f"{BASE}/alerts/status", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "entity_id" in data and "critical_line" in data
|
||||
@@ -0,0 +1,485 @@
|
||||
"""费用审核智能体模块测试 — 规则CRUD + 报销单自动校验 + 审批流 + 看板统计
|
||||
|
||||
覆盖 expenses.py 全部13个端点:
|
||||
rules(CRUD+seed) / reimbursements(submit/list/detail/approve/reject/return/resubmit) / stats
|
||||
"""
|
||||
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
|
||||
from app.models import ExpenseRule, ExpenseReimbursement, User
|
||||
import hashlib
|
||||
|
||||
|
||||
def mk_rule(db: Session, **kwargs) -> ExpenseRule:
|
||||
defaults = {
|
||||
"rule_name": "测试规则", "dimension": "expense_type", "dimension_value": "",
|
||||
"expense_type": "entertainment", "limit_type": "single", "limit_amount": 2000.0,
|
||||
"cycle": "single", "status": "active", "remark": "",
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
r = ExpenseRule(**defaults)
|
||||
db.add(r)
|
||||
db.commit()
|
||||
db.refresh(r)
|
||||
return r
|
||||
|
||||
|
||||
def mk_reimb(db: Session, **kwargs) -> ExpenseReimbursement:
|
||||
defaults = {
|
||||
"reimb_no": "BX_TEST001", "applicant": "张三", "department": "销售部",
|
||||
"expense_type": "entertainment", "title": "客户招待", "amount": 500.0,
|
||||
"status": "pending", "check_result": "pass", "created_by": "test",
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
r = ExpenseReimbursement(**defaults)
|
||||
db.add(r)
|
||||
db.commit()
|
||||
db.refresh(r)
|
||||
return r
|
||||
|
||||
|
||||
class TestRules:
|
||||
"""费用规则 CRUD"""
|
||||
|
||||
BASE = "/api/cma/expenses/rules"
|
||||
|
||||
def test_list_empty(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.get(self.BASE, headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["data"] == []
|
||||
assert resp.json()["total"] == 0
|
||||
|
||||
def test_list_filters(self, client: TestClient, db: Session):
|
||||
"""expense_type / status 过滤"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
mk_rule(db, rule_name="招待限额", expense_type="entertainment")
|
||||
mk_rule(db, rule_name="差旅限额", expense_type="travel")
|
||||
mk_rule(db, rule_name="停用规则", expense_type="office", status="inactive")
|
||||
|
||||
r = client.get(f"{self.BASE}?expense_type=travel", headers=auth_header(token))
|
||||
assert [x["rule_name"] for x in r.json()["data"]] == ["差旅限额"]
|
||||
r = client.get(f"{self.BASE}?status=inactive", headers=auth_header(token))
|
||||
assert [x["rule_name"] for x in r.json()["data"]] == ["停用规则"]
|
||||
# 标签映射
|
||||
r = client.get(f"{self.BASE}?expense_type=entertainment", headers=auth_header(token))
|
||||
assert r.json()["data"][0]["expense_type_label"] == "招待费"
|
||||
|
||||
def test_create_rule(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.post(self.BASE, headers=auth_header(token),
|
||||
json={"rule_name": "招待费单笔限额", "expense_type": "entertainment",
|
||||
"limit_type": "single", "limit_amount": 2000.0})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "规则已创建"
|
||||
assert resp.json()["id"] > 0
|
||||
|
||||
def test_create_rule_missing_params(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.post(self.BASE, headers=auth_header(token), json={"rule_name": "缺参数"})
|
||||
assert resp.status_code == 400
|
||||
assert "缺少必要参数" in resp.json()["detail"]
|
||||
|
||||
def test_create_rule_invalid_expense_type(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.post(self.BASE, headers=auth_header(token),
|
||||
json={"rule_name": "非法类型", "expense_type": "gambling",
|
||||
"limit_amount": 100})
|
||||
assert resp.status_code == 400
|
||||
assert "无效费用类型" in resp.json()["detail"]
|
||||
|
||||
def test_create_rule_invalid_limit_type(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.post(self.BASE, headers=auth_header(token),
|
||||
json={"rule_name": "非法限额", "expense_type": "office",
|
||||
"limit_type": "per_month", "limit_amount": 100})
|
||||
assert resp.status_code == 400
|
||||
assert "无效限额类型" in resp.json()["detail"]
|
||||
|
||||
def test_update_rule(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
rule = mk_rule(db)
|
||||
resp = client.put(f"{self.BASE}/{rule.id}", headers=auth_header(token),
|
||||
json={"rule_name": "新规则名", "limit_amount": 5000.0, "status": "inactive"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "规则已更新"
|
||||
r = client.get(self.BASE, headers=auth_header(token))
|
||||
updated = r.json()["data"][0]
|
||||
assert updated["rule_name"] == "新规则名"
|
||||
assert updated["limit_amount"] == 5000.0
|
||||
assert updated["status"] == "inactive"
|
||||
|
||||
def test_update_rule_not_found(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.put(f"{self.BASE}/99999", headers=auth_header(token), json={"rule_name": "x"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_update_rule_invalid_type(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
rule = mk_rule(db)
|
||||
resp = client.put(f"{self.BASE}/{rule.id}", headers=auth_header(token),
|
||||
json={"expense_type": "bogus"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_delete_rule(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
rule = mk_rule(db)
|
||||
resp = client.delete(f"{self.BASE}/{rule.id}", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "规则已删除"
|
||||
r = client.get(self.BASE, headers=auth_header(token))
|
||||
assert r.json()["data"] == []
|
||||
|
||||
def test_delete_rule_not_found(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.delete(f"{self.BASE}/99999", headers=auth_header(token))
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_seed_rules_idempotent(self, client: TestClient, db: Session):
|
||||
"""预置规则幂等:首次7条,再次0条"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
r1 = client.post(f"{self.BASE}/seed", headers=auth_header(token))
|
||||
assert r1.status_code == 200
|
||||
assert "新增 7 条" in r1.json()["message"]
|
||||
r2 = client.post(f"{self.BASE}/seed", headers=auth_header(token))
|
||||
assert "新增 0 条" in r2.json()["message"]
|
||||
r = client.get(self.BASE, headers=auth_header(token))
|
||||
assert r.json()["total"] == 7
|
||||
|
||||
def test_rule_permission_business_denied(self, client: TestClient, db: Session):
|
||||
"""business角色不能创建规则 → 403"""
|
||||
u = User(username="biz_expense", password_hash=hashlib.sha256("pass123".encode()).hexdigest(),
|
||||
name="业务员", role="business")
|
||||
db.add(u)
|
||||
db.commit()
|
||||
token = get_token_for_user(client, username="biz_expense", password="pass123")
|
||||
resp = client.post(self.BASE, headers=auth_header(token),
|
||||
json={"rule_name": "越权规则", "expense_type": "office", "limit_amount": 10})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
class TestReimbursements:
|
||||
"""报销单提交/校验/审批"""
|
||||
|
||||
BASE = "/api/cma/expenses/reimbursements"
|
||||
|
||||
def test_submit_pass_no_rules(self, client: TestClient, db: Session):
|
||||
"""无规则 → 提交通过,状态 pending"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.post(self.BASE, headers=auth_header(token),
|
||||
json={"expense_type": "entertainment", "title": "客户餐费",
|
||||
"amount": 500.0, "expense_date": "2026-06-10"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["message"] == "报销单已提交"
|
||||
assert data["data"]["auto_check_passed"] is True
|
||||
d = data["data"]
|
||||
assert d["status"] == "pending"
|
||||
assert d["reimb_no"].startswith("BX")
|
||||
assert d["expense_type_label"] == "招待费"
|
||||
assert d["applicant"] == "测试管理员" # 默认取当前用户name
|
||||
|
||||
def test_submit_over_single_limit_returned(self, client: TestClient, db: Session):
|
||||
"""单笔超限 → 自动打回 returned + check_result fail"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
mk_rule(db, rule_name="招待单笔≤100", expense_type="entertainment",
|
||||
limit_type="single", limit_amount=100.0)
|
||||
resp = client.post(self.BASE, headers=auth_header(token),
|
||||
json={"expense_type": "entertainment", "title": "大额招待",
|
||||
"amount": 500.0})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["message"] == "报销单超限,已自动打回"
|
||||
assert data["data"]["auto_check_passed"] is False
|
||||
d = data["data"]
|
||||
assert d["status"] == "returned"
|
||||
assert d["check_result"] == "fail"
|
||||
assert "超限" in d["check_reason"]
|
||||
assert d["check_detail"][0]["passed"] is False
|
||||
|
||||
def test_submit_within_single_limit(self, client: TestClient, db: Session):
|
||||
"""单笔限额内 → 通过,check_detail 有记录"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
mk_rule(db, rule_name="招待单笔≤2000", expense_type="entertainment",
|
||||
limit_type="single", limit_amount=2000.0)
|
||||
resp = client.post(self.BASE, headers=auth_header(token),
|
||||
json={"expense_type": "entertainment", "title": "正常招待",
|
||||
"amount": 500.0})
|
||||
assert resp.status_code == 200
|
||||
d = resp.json()["data"]
|
||||
assert d["status"] == "pending"
|
||||
assert d["check_result"] == "pass"
|
||||
assert d["check_detail"][0]["passed"] is True
|
||||
|
||||
def test_submit_monthly_limit(self, client: TestClient, db: Session):
|
||||
"""月度累计超限 → 第二次自动打回"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
mk_rule(db, rule_name="招待月限额1000", expense_type="entertainment",
|
||||
limit_type="monthly", limit_amount=1000.0, cycle="monthly")
|
||||
r1 = client.post(self.BASE, headers=auth_header(token),
|
||||
json={"expense_type": "entertainment", "title": "第一批",
|
||||
"amount": 500.0, "expense_date": "2026-06-10"})
|
||||
assert r1.json()["data"]["auto_check_passed"] is True
|
||||
r2 = client.post(self.BASE, headers=auth_header(token),
|
||||
json={"expense_type": "entertainment", "title": "第二批",
|
||||
"amount": 800.0, "expense_date": "2026-06-20"})
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["data"]["auto_check_passed"] is False
|
||||
assert r2.json()["data"]["status"] == "returned"
|
||||
# 累计明细:500 + 800 = 1300
|
||||
assert r2.json()["data"]["check_detail"][0]["actual"] == 1300.0
|
||||
|
||||
def test_submit_missing_params(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.post(self.BASE, headers=auth_header(token),
|
||||
json={"title": "没类型没金额"})
|
||||
assert resp.status_code == 400
|
||||
assert "缺少必要参数" in resp.json()["detail"]
|
||||
|
||||
def test_submit_invalid_type(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.post(self.BASE, headers=auth_header(token),
|
||||
json={"expense_type": "bogus", "title": "x", "amount": 100})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_submit_zero_amount(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.post(self.BASE, headers=auth_header(token),
|
||||
json={"expense_type": "office", "title": "x", "amount": 0})
|
||||
assert resp.status_code == 400
|
||||
assert "必须大于0" in resp.json()["detail"]
|
||||
|
||||
def test_submit_bad_date(self, client: TestClient, db: Session):
|
||||
"""非法日期 → expense_date 为 None 不报错"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.post(self.BASE, headers=auth_header(token),
|
||||
json={"expense_type": "office", "title": "x", "amount": 100,
|
||||
"expense_date": "not-a-date"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["data"]["expense_date"] is None
|
||||
|
||||
def test_list_filters(self, client: TestClient, db: Session):
|
||||
"""状态/类型/申请人/关键字 过滤"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
mk_reimb(db, reimb_no="BX0001", applicant="张三", title="客户招待", status="pending")
|
||||
mk_reimb(db, reimb_no="BX0002", applicant="李四", title="差旅住宿", status="approved")
|
||||
mk_reimb(db, reimb_no="BX0003", applicant="张三", title="办公采购", status="rejected",
|
||||
expense_type="office")
|
||||
|
||||
r = client.get(f"{self.BASE}?status=approved", headers=auth_header(token))
|
||||
assert [x["reimb_no"] for x in r.json()["data"]] == ["BX0002"]
|
||||
r = client.get(f"{self.BASE}?expense_type=office", headers=auth_header(token))
|
||||
assert [x["reimb_no"] for x in r.json()["data"]] == ["BX0003"]
|
||||
r = client.get(f"{self.BASE}?applicant=张三", headers=auth_header(token))
|
||||
assert {x["reimb_no"] for x in r.json()["data"]} == {"BX0001", "BX0003"}
|
||||
r = client.get(f"{self.BASE}?keyword=差旅", headers=auth_header(token))
|
||||
assert [x["reimb_no"] for x in r.json()["data"]] == ["BX0002"]
|
||||
r = client.get(f"{self.BASE}?keyword=BX0003", headers=auth_header(token))
|
||||
assert [x["reimb_no"] for x in r.json()["data"]] == ["BX0003"]
|
||||
|
||||
def test_get_detail(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
reimb = mk_reimb(db, reimb_no="BX0099")
|
||||
resp = client.get(f"{self.BASE}/{reimb.id}", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["reimb_no"] == "BX0099"
|
||||
|
||||
def test_get_detail_not_found(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.get(f"{self.BASE}/99999", headers=auth_header(token))
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_approve(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
reimb = mk_reimb(db, reimb_no="BX0100")
|
||||
resp = client.post(f"{self.BASE}/{reimb.id}/approve", headers=auth_header(token),
|
||||
json={"comment": "同意"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "已审批通过"
|
||||
d = resp.json()["data"]
|
||||
assert d["status"] == "approved"
|
||||
assert d["approver"] == "测试管理员"
|
||||
assert d["approve_comment"] == "同意"
|
||||
|
||||
def test_approve_wrong_state(self, client: TestClient, db: Session):
|
||||
"""已审批的单不能再审 → 400"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
reimb = mk_reimb(db, reimb_no="BX0101", status="approved")
|
||||
resp = client.post(f"{self.BASE}/{reimb.id}/approve", headers=auth_header(token))
|
||||
assert resp.status_code == 400
|
||||
assert "不可审批" in resp.json()["detail"]
|
||||
|
||||
def test_approve_not_found(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.post(f"{self.BASE}/99999/approve", headers=auth_header(token))
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_reject_requires_comment(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
reimb = mk_reimb(db, reimb_no="BX0102")
|
||||
resp = client.post(f"{self.BASE}/{reimb.id}/reject", headers=auth_header(token))
|
||||
assert resp.status_code == 400
|
||||
assert "必须填写审批意见" in resp.json()["detail"]
|
||||
|
||||
def test_reject_with_comment(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
reimb = mk_reimb(db, reimb_no="BX0103")
|
||||
resp = client.post(f"{self.BASE}/{reimb.id}/reject", headers=auth_header(token),
|
||||
json={"comment": "发票不合规"})
|
||||
assert resp.status_code == 200
|
||||
d = resp.json()["data"]
|
||||
assert d["status"] == "rejected"
|
||||
assert d["approve_comment"] == "发票不合规"
|
||||
|
||||
def test_return(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
reimb = mk_reimb(db, reimb_no="BX0104")
|
||||
resp = client.post(f"{self.BASE}/{reimb.id}/return", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
d = resp.json()["data"]
|
||||
assert d["status"] == "returned"
|
||||
assert d["approve_comment"] == "人工打回"
|
||||
|
||||
def test_resubmit_after_return(self, client: TestClient, db: Session):
|
||||
"""打回后改金额重新提交 → 重新校验通过"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
mk_rule(db, rule_name="招待单笔≤100", expense_type="entertainment",
|
||||
limit_type="single", limit_amount=100.0)
|
||||
# 超限被自动打回
|
||||
r = client.post(self.BASE, headers=auth_header(token),
|
||||
json={"expense_type": "entertainment", "title": "超标单",
|
||||
"amount": 500.0})
|
||||
rid = r.json()["data"]["id"]
|
||||
assert r.json()["data"]["status"] == "returned"
|
||||
# 改金额到限额内 → 重新提交通过
|
||||
resp = client.post(f"{self.BASE}/{rid}/resubmit", headers=auth_header(token),
|
||||
json={"amount": 80.0})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "已重新提交"
|
||||
d = resp.json()["data"]
|
||||
assert d["status"] == "pending"
|
||||
assert d["amount"] == 80.0
|
||||
assert d["approver"] is None
|
||||
|
||||
def test_resubmit_still_over(self, client: TestClient, db: Session):
|
||||
"""重提仍超限 → 再次打回"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
mk_rule(db, rule_name="招待单笔≤100", expense_type="entertainment",
|
||||
limit_type="single", limit_amount=100.0)
|
||||
r = client.post(self.BASE, headers=auth_header(token),
|
||||
json={"expense_type": "entertainment", "title": "超标单",
|
||||
"amount": 500.0})
|
||||
rid = r.json()["data"]["id"]
|
||||
resp = client.post(f"{self.BASE}/{rid}/resubmit", headers=auth_header(token),
|
||||
json={"amount": 300.0})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "仍超限,已再次打回"
|
||||
assert resp.json()["data"]["status"] == "returned"
|
||||
|
||||
def test_resubmit_wrong_state(self, client: TestClient, db: Session):
|
||||
"""pending 状态不能重提 → 400"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
reimb = mk_reimb(db, reimb_no="BX0105")
|
||||
resp = client.post(f"{self.BASE}/{reimb.id}/resubmit", headers=auth_header(token))
|
||||
assert resp.status_code == 400
|
||||
assert "不可重新提交" in resp.json()["detail"]
|
||||
|
||||
def test_approve_permission_business_denied(self, client: TestClient, db: Session):
|
||||
"""business不能审批 → 403"""
|
||||
reimb = mk_reimb(db, reimb_no="BX0106")
|
||||
u = User(username="biz_approve", password_hash=hashlib.sha256("pass123".encode()).hexdigest(),
|
||||
name="业务员", role="business")
|
||||
db.add(u)
|
||||
db.commit()
|
||||
token = get_token_for_user(client, username="biz_approve", password="pass123")
|
||||
resp = client.post(f"{self.BASE}/{reimb.id}/approve", headers=auth_header(token))
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
class TestStats:
|
||||
"""审核看板统计"""
|
||||
|
||||
BASE = "/api/cma/expenses/stats"
|
||||
|
||||
def test_stats_empty(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.get(self.BASE, headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["counts"]["total"] == 0
|
||||
assert data["monthly_total"] == 0
|
||||
assert len(data["budget_usage"]) == 2
|
||||
|
||||
def test_stats_with_data(self, client: TestClient, db: Session):
|
||||
"""有报销数据 → 状态计数/类型统计/超限预警/月度总额"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
from datetime import datetime
|
||||
mk_reimb(db, reimb_no="BX0201", applicant="张三", amount=500.0,
|
||||
expense_date=datetime(2026, 6, 10), status="pending")
|
||||
mk_reimb(db, reimb_no="BX0202", applicant="李四", amount=300.0,
|
||||
expense_date=datetime(2026, 6, 11), status="approved")
|
||||
mk_reimb(db, reimb_no="BX0203", applicant="王五", amount=800.0,
|
||||
expense_date=datetime(2026, 5, 20), status="approved") # 上期不计
|
||||
# 超限打回
|
||||
mk_reimb(db, reimb_no="BX0204", applicant="赵六", amount=99999.0,
|
||||
expense_date=datetime(2026, 6, 12), status="returned", check_result="fail",
|
||||
check_reason="招待费单笔限额: 超限")
|
||||
resp = client.get(f"{self.BASE}?period=2026-06", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["period"] == "2026-06"
|
||||
assert data["counts"]["pending"] == 1
|
||||
assert data["counts"]["approved"] == 2
|
||||
assert data["counts"]["returned"] == 1
|
||||
assert data["counts"]["total"] == 4
|
||||
# 月度总额 = 500 + 300 = 800(上期不计,returned不计)
|
||||
assert data["monthly_total"] == 800.0
|
||||
assert data["amounts_by_type"][0]["amount"] == 800.0
|
||||
# 超限预警
|
||||
assert data["over_limit_count"] == 1
|
||||
assert data["over_limit"][0]["reimb_no"] == "BX0204"
|
||||
# 预算使用率
|
||||
ent_usage = next(u for u in data["budget_usage"] if u["expense_type"] == "entertainment")
|
||||
assert ent_usage["used"] == 800.0
|
||||
assert ent_usage["usage_rate"] == round(800 / 150000 * 100, 1)
|
||||
# 最近列表
|
||||
assert len(data["recent"]) == 4
|
||||
|
||||
def test_stats_no_token(self, client: TestClient, db: Session):
|
||||
resp = client.get(self.BASE)
|
||||
assert resp.status_code == 403
|
||||
@@ -0,0 +1,352 @@
|
||||
"""KPI因果链模块测试 — 因果网络 + 模拟推演 + CRUD
|
||||
|
||||
覆盖 kpi_causality.py 全部8个端点:
|
||||
full-network / kpi{id}/network / simulate / list / get / create / update / delete
|
||||
权限:读需 ceo/finance/business/it,写需 ceo/finance/it
|
||||
"""
|
||||
import hashlib
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import KPIDefinition, KPICausality, KPIValue, Entity, User, UserEntity
|
||||
from tests.conftest import create_test_user, get_token_for_user, auth_header
|
||||
|
||||
BASE = "/api/cma/kpi-causality"
|
||||
|
||||
|
||||
def _seed_kpi(db: Session, code: str, name: str = None, dimension: str = "finance",
|
||||
entity_id: int = 2) -> KPIDefinition:
|
||||
kpi = KPIDefinition(
|
||||
kpi_code=code,
|
||||
kpi_name=name or code,
|
||||
dimension=dimension,
|
||||
entity_id=entity_id,
|
||||
status="active",
|
||||
target_value=100.0,
|
||||
)
|
||||
db.add(kpi)
|
||||
db.commit()
|
||||
db.refresh(kpi)
|
||||
return kpi
|
||||
|
||||
|
||||
def _seed_entity2(db: Session) -> None:
|
||||
"""博海(id=2)为主测试实体,酣客(id=1)已有(conftest)"""
|
||||
ent = db.query(Entity).filter(Entity.id == 2).first()
|
||||
if not ent:
|
||||
db.add(Entity(id=2, name="博海网络科技", short_name="博海", status="active"))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _seed_chain(db: Session):
|
||||
"""造一条因果链: 收入 → 净利润 (positive, 0.5)"""
|
||||
src = _seed_kpi(db, "BH_REVENUE", "营业收入")
|
||||
tgt = _seed_kpi(db, "BH_NET_PROFIT", "净利润")
|
||||
c = KPICausality(source_kpi_id=src.id, target_kpi_id=tgt.id,
|
||||
strength=0.5, lag_months=1, direction="positive",
|
||||
formula="净利润 = 收入 × 10%")
|
||||
db.add(c)
|
||||
db.commit()
|
||||
db.refresh(c)
|
||||
# 实际值(模拟推演用)
|
||||
db.add(KPIValue(kpi_id=src.id, period="2026-07", actual_value=100.0))
|
||||
db.add(KPIValue(kpi_id=tgt.id, period="2026-07", actual_value=10.0))
|
||||
db.commit()
|
||||
return src, tgt, c
|
||||
|
||||
|
||||
class TestFullNetwork:
|
||||
def test_empty_network(self, client: TestClient, db: Session):
|
||||
"""无数据时网络为空"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.get(f"{BASE}/full-network", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["nodes"] == []
|
||||
assert data["edges"] == []
|
||||
assert data["total_edges"] == 0
|
||||
|
||||
def test_full_network_with_chain(self, client: TestClient, db: Session):
|
||||
"""有因果链时返回节点和边"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
src, tgt, c = _seed_chain(db)
|
||||
|
||||
resp = client.get(f"{BASE}/full-network", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total_edges"] == 1
|
||||
assert data["edges"][0]["source"] == src.id
|
||||
assert data["edges"][0]["target"] == tgt.id
|
||||
# 两个节点都带KPI信息
|
||||
codes = {n["kpi_code"] for n in data["nodes"]}
|
||||
assert codes == {"BH_REVENUE", "BH_NET_PROFIT"}
|
||||
|
||||
|
||||
class TestKpiNetwork:
|
||||
def test_kpi_network(self, client: TestClient, db: Session):
|
||||
"""单KPI上下游网络"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
src, tgt, c = _seed_chain(db)
|
||||
|
||||
# 源KPI的下游
|
||||
resp = client.get(f"{BASE}/kpi/{src.id}/network", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["kpi"]["kpi_code"] == "BH_REVENUE"
|
||||
assert len(data["downstream"]) == 1
|
||||
assert data["downstream"][0]["kpi_code"] == "BH_NET_PROFIT"
|
||||
assert len(data["upstream"]) == 0
|
||||
|
||||
# 目标KPI的上游
|
||||
resp2 = client.get(f"{BASE}/kpi/{tgt.id}/network", headers=auth_header(token))
|
||||
assert resp2.status_code == 200
|
||||
data2 = resp2.json()
|
||||
assert len(data2["upstream"]) == 1
|
||||
assert data2["upstream"][0]["kpi_code"] == "BH_REVENUE"
|
||||
|
||||
def test_kpi_network_not_found(self, client: TestClient, db: Session):
|
||||
"""KPI不存在 → 404"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.get(f"{BASE}/kpi/99999/network", headers=auth_header(token))
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestSimulate:
|
||||
def test_simulate_simple(self, client: TestClient, db: Session):
|
||||
"""模拟推演:收入+10% → 净利润受影响"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
src, tgt, c = _seed_chain(db)
|
||||
|
||||
resp = client.post(f"{BASE}/simulate", headers=auth_header(token), json={
|
||||
"kpi_id": src.id,
|
||||
"new_value": 110.0,
|
||||
"period": "2026-07",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["source"]["kpi_code"] == "BH_REVENUE"
|
||||
assert data["source"]["change_pct"] == 10.0
|
||||
assert data["total_impacted"] == 1
|
||||
impact = data["impacts"][0]
|
||||
assert impact["kpi_code"] == "BH_NET_PROFIT"
|
||||
# 10% × 0.5(强度) × 1(正向) = 5% 影响
|
||||
assert impact["change_pct"] == 5.0
|
||||
# 预测值 = 10 × 1.05 = 10.5
|
||||
assert impact["predicted_value"] == 10.5
|
||||
|
||||
def test_simulate_negative_direction(self, client: TestClient, db: Session):
|
||||
"""负向因果:成本↑ → 净利润↓"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
cost = _seed_kpi(db, "BH_COST", "成本")
|
||||
profit = _seed_kpi(db, "BH_NET_PROFIT", "净利润")
|
||||
c = KPICausality(source_kpi_id=cost.id, target_kpi_id=profit.id,
|
||||
strength=0.8, lag_months=0, direction="negative")
|
||||
db.add(c)
|
||||
db.add(KPIValue(kpi_id=cost.id, period="2026-07", actual_value=50.0))
|
||||
db.add(KPIValue(kpi_id=profit.id, period="2026-07", actual_value=100.0))
|
||||
db.commit()
|
||||
|
||||
resp = client.post(f"{BASE}/simulate", headers=auth_header(token), json={
|
||||
"kpi_id": cost.id, "new_value": 60.0, "period": "2026-07",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["source"]["change_pct"] == 20.0
|
||||
impact = data["impacts"][0]
|
||||
# 20% × 0.8 × (-1) = -16%
|
||||
assert impact["change_pct"] == -16.0
|
||||
# 100 × 0.84 = 84.0
|
||||
assert impact["predicted_value"] == 84.0
|
||||
|
||||
def test_simulate_missing_params(self, client: TestClient, db: Session):
|
||||
"""缺 kpi_id/new_value → 400"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.post(f"{BASE}/simulate", headers=auth_header(token), json={"kpi_id": 1})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_simulate_kpi_not_found(self, client: TestClient, db: Session):
|
||||
"""KPI不存在 → 404"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.post(f"{BASE}/simulate", headers=auth_header(token),
|
||||
json={"kpi_id": 99999, "new_value": 10.0})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestCausalityCRUD:
|
||||
def test_list_empty(self, client: TestClient, db: Session):
|
||||
"""空列表"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.get(BASE, headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["data"] == []
|
||||
assert resp.json()["total"] == 0
|
||||
|
||||
def test_create_and_get(self, client: TestClient, db: Session):
|
||||
"""创建因果链 + 按ID查询"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
src = _seed_kpi(db, "BH_REVENUE", "营业收入")
|
||||
tgt = _seed_kpi(db, "BH_NET_PROFIT", "净利润")
|
||||
|
||||
resp = client.post(BASE, headers=auth_header(token), json={
|
||||
"source_kpi_id": src.id,
|
||||
"target_kpi_id": tgt.id,
|
||||
"strength": 0.6,
|
||||
"lag_months": 2,
|
||||
"direction": "positive",
|
||||
"formula": "净利润 = 收入 × 10%",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
cid = resp.json()["id"]
|
||||
assert resp.json()["strength"] == 0.6
|
||||
|
||||
get_resp = client.get(f"{BASE}/{cid}", headers=auth_header(token))
|
||||
assert get_resp.status_code == 200
|
||||
assert get_resp.json()["source"]["kpi_code"] == "BH_REVENUE"
|
||||
assert get_resp.json()["target"]["kpi_code"] == "BH_NET_PROFIT"
|
||||
|
||||
def test_create_duplicate(self, client: TestClient, db: Session):
|
||||
"""重复创建同一条因果链 → 400"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
src = _seed_kpi(db, "BH_REVENUE", "营业收入")
|
||||
tgt = _seed_kpi(db, "BH_NET_PROFIT", "净利润")
|
||||
|
||||
client.post(BASE, headers=auth_header(token),
|
||||
json={"source_kpi_id": src.id, "target_kpi_id": tgt.id})
|
||||
resp = client.post(BASE, headers=auth_header(token),
|
||||
json={"source_kpi_id": src.id, "target_kpi_id": tgt.id})
|
||||
assert resp.status_code == 400
|
||||
assert "已存在" in resp.json()["detail"]
|
||||
|
||||
def test_create_missing_kpis(self, client: TestClient, db: Session):
|
||||
"""缺源/目标 → 400"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.post(BASE, headers=auth_header(token), json={"source_kpi_id": 1})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_create_same_kpi(self, client: TestClient, db: Session):
|
||||
"""源=目标 → 400"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
src = _seed_kpi(db, "BH_REVENUE", "营业收入")
|
||||
resp = client.post(BASE, headers=auth_header(token),
|
||||
json={"source_kpi_id": src.id, "target_kpi_id": src.id})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_create_kpi_not_found(self, client: TestClient, db: Session):
|
||||
"""KPI不存在 → 404"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
src = _seed_kpi(db, "BH_REVENUE", "营业收入")
|
||||
resp = client.post(BASE, headers=auth_header(token),
|
||||
json={"source_kpi_id": src.id, "target_kpi_id": 99999})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_update(self, client: TestClient, db: Session):
|
||||
"""更新强度/滞后期/方向"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
src, tgt, c = _seed_chain(db)
|
||||
|
||||
resp = client.put(f"{BASE}/{c.id}", headers=auth_header(token), json={
|
||||
"strength": 0.9, "lag_months": 3, "direction": "negative",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["strength"] == 0.9
|
||||
assert resp.json()["lag_months"] == 3
|
||||
assert resp.json()["direction"] == "negative"
|
||||
|
||||
def test_update_not_found(self, client: TestClient, db: Session):
|
||||
"""更新不存在的因果链 → 404"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.put(f"{BASE}/99999", headers=auth_header(token), json={"strength": 0.5})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_delete(self, client: TestClient, db: Session):
|
||||
"""删除因果链"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
src, tgt, c = _seed_chain(db)
|
||||
|
||||
resp = client.delete(f"{BASE}/{c.id}", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "已删除"
|
||||
|
||||
# 列表验证已删除
|
||||
list_resp = client.get(BASE, headers=auth_header(token))
|
||||
assert list_resp.json()["total"] == 0
|
||||
|
||||
def test_delete_not_found(self, client: TestClient, db: Session):
|
||||
"""删除不存在的因果链 → 幂等返回已删除"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.delete(f"{BASE}/99999", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_list_filter(self, client: TestClient, db: Session):
|
||||
"""列表按源/目标KPI过滤"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
src, tgt, c = _seed_chain(db)
|
||||
|
||||
resp = client.get(f"{BASE}?source_kpi_id={src.id}", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total"] == 1
|
||||
|
||||
resp2 = client.get(f"{BASE}?source_kpi_id=99999", headers=auth_header(token))
|
||||
assert resp2.json()["total"] == 0
|
||||
|
||||
|
||||
class TestPermissions:
|
||||
def test_write_requires_ceo_finance_it(self, client: TestClient, db: Session):
|
||||
"""business角色无写权限 → 403"""
|
||||
# business用户
|
||||
business = User(
|
||||
username="business_user",
|
||||
password_hash=hashlib.sha256("pass123".encode()).hexdigest(),
|
||||
name="业务员",
|
||||
role="business",
|
||||
)
|
||||
db.add(business)
|
||||
db.commit()
|
||||
token = get_token_for_user(client, username="business_user", password="pass123")
|
||||
|
||||
src = _seed_kpi(db, "BH_REVENUE", "营业收入")
|
||||
tgt = _seed_kpi(db, "BH_NET_PROFIT", "净利润")
|
||||
resp = client.post(BASE, headers=auth_header(token),
|
||||
json={"source_kpi_id": src.id, "target_kpi_id": tgt.id})
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_read_allowed_for_business(self, client: TestClient, db: Session):
|
||||
"""business角色可读"""
|
||||
business = User(
|
||||
username="business_user2",
|
||||
password_hash=hashlib.sha256("pass123".encode()).hexdigest(),
|
||||
name="业务员",
|
||||
role="business",
|
||||
)
|
||||
db.add(business)
|
||||
db.commit()
|
||||
token = get_token_for_user(client, username="business_user2", password="pass123")
|
||||
|
||||
resp = client.get(BASE, headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_no_token_denied(self, client: TestClient):
|
||||
"""无token → 403"""
|
||||
resp = client.get(BASE)
|
||||
assert resp.status_code == 403
|
||||
@@ -0,0 +1,242 @@
|
||||
"""预测模拟模块测试 — CVP/投资决策/敏感性/情景/现金流/实物期权/增长质量
|
||||
|
||||
覆盖 predict.py 主要端点(纯计算 + 少量DB):
|
||||
cvp / investment / sensitivity / scenario / cvp-detailed / cash-forecast /
|
||||
cash-forecast/history / accuracy / scenario-suggestions /
|
||||
scenario-suggestion/generate / real-option / growth-quality
|
||||
"""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import CashForecast, ForecastAccuracy
|
||||
from tests.conftest import create_test_user, get_token_for_user, auth_header
|
||||
|
||||
BASE = "/api/cma/predict"
|
||||
|
||||
|
||||
class TestCvp:
|
||||
def test_cvp_basic(self, client: TestClient, db: Session):
|
||||
"""本量利分析"""
|
||||
resp = client.post(f"{BASE}/cvp", json={
|
||||
"unit_price": 100, "unit_variable_cost": 60, "fixed_cost": 100000,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["contribution_margin"] == 40
|
||||
assert data["bep_units"] == 2500 # 100000/40
|
||||
assert data["bep_revenue"] == 250000.0
|
||||
|
||||
def test_cvp_with_target_profit(self, client: TestClient, db: Session):
|
||||
"""含目标利润"""
|
||||
resp = client.post(f"{BASE}/cvp", json={
|
||||
"unit_price": 100, "unit_variable_cost": 60, "fixed_cost": 100000,
|
||||
"target_profit": 20000,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
# 目标销量 = (100000+20000)/40 = 3000
|
||||
assert resp.json()["target_units"] == 3000
|
||||
|
||||
def test_cvp_bad_input(self, client: TestClient, db: Session):
|
||||
"""非法输入 → 400"""
|
||||
resp = client.post(f"{BASE}/cvp", json={"unit_price": "abc"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
class TestInvestment:
|
||||
def test_investment_npv_irr(self, client: TestClient, db: Session):
|
||||
"""NPV/IRR计算"""
|
||||
resp = client.post(f"{BASE}/investment", json={
|
||||
"initial_investment": 10000,
|
||||
"discount_rate": 10,
|
||||
"cash_flows": [3000, 3000, 3000, 3000, 3000],
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "npv_analysis" in data
|
||||
assert "irr_analysis" in data
|
||||
|
||||
def test_investment_empty_cashflows(self, client: TestClient, db: Session):
|
||||
"""空现金流 → 400"""
|
||||
resp = client.post(f"{BASE}/investment", json={"initial_investment": 100})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
class TestSensitivityScenario:
|
||||
def test_sensitivity(self, client: TestClient, db: Session):
|
||||
"""敏感性分析"""
|
||||
resp = client.post(f"{BASE}/sensitivity", json={
|
||||
"base_revenue": 1000, "base_cost": 700,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["base_profit"] == 300.0
|
||||
assert len(data["factors"]) > 0
|
||||
assert "revenue_sensitivity" in data["factors"][0]
|
||||
|
||||
def test_scenario(self, client: TestClient, db: Session):
|
||||
"""情景模拟"""
|
||||
resp = client.post(f"{BASE}/scenario", json={
|
||||
"optimistic": {"revenue": 120, "cost": 60},
|
||||
"pessimistic": {"revenue": 80, "cost": 70},
|
||||
"base": {"revenue": 100, "cost": 65},
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
scenarios = {s["scenario"] for s in data["scenarios"]}
|
||||
assert scenarios == {"乐观", "中性", "悲观"}
|
||||
|
||||
def test_scenario_missing(self, client: TestClient, db: Session):
|
||||
"""缺情景 → 400"""
|
||||
resp = client.post(f"{BASE}/scenario", json={"base": {"revenue": 100}})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_cvp_detailed(self, client: TestClient, db: Session):
|
||||
"""CVP详细分析(含保本图/方案推演)"""
|
||||
resp = client.post(f"{BASE}/cvp-detailed", json={
|
||||
"fixed_cost": 617, "variable_cost_rate": 0.4862,
|
||||
"unit_price": 228, "current_volume": 5300,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["contribution_margin_rate"] == 51.38
|
||||
assert len(data["scenarios"]) == 3
|
||||
assert len(data["chart_data"]) > 0
|
||||
assert data["breakeven_units"] > 0
|
||||
|
||||
|
||||
class TestCashForecast:
|
||||
def test_cash_forecast_no_data(self, client: TestClient, db: Session):
|
||||
"""无历史KPI时仍返回预测(退化处理)"""
|
||||
resp = client.post(f"{BASE}/cash-forecast", json={
|
||||
"entity_id": 1, "days": 10, "current_cash": 50.0,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "daily" in data or "forecast" in data or "days" in data
|
||||
|
||||
def test_cash_forecast_history_empty(self, client: TestClient, db: Session):
|
||||
"""历史为空"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.get(f"{BASE}/cash-forecast/history", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["data"] == []
|
||||
|
||||
def test_accuracy_empty(self, client: TestClient, db: Session):
|
||||
"""准确率为空"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.get(f"{BASE}/accuracy", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["data"] == []
|
||||
assert data["summary"]["total_periods"] == 0
|
||||
|
||||
|
||||
class TestScenarioSuggestions:
|
||||
def test_suggestions_all(self, client: TestClient, db: Session):
|
||||
"""获取全部情景建议"""
|
||||
resp = client.get(f"{BASE}/scenario-suggestions")
|
||||
assert resp.status_code == 200
|
||||
types = {s["alert_type"] for s in resp.json()["data"]}
|
||||
assert types == {"cash_low", "cash_critical", "cost_high", "revenue_drop"}
|
||||
|
||||
def test_suggestions_filter(self, client: TestClient, db: Session):
|
||||
"""按类型过滤"""
|
||||
resp = client.get(f"{BASE}/scenario-suggestions?alert_type=cash_low")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()["data"]
|
||||
assert len(data) == 1
|
||||
assert data[0]["alert_type"] == "cash_low"
|
||||
|
||||
def test_generate_suggestion(self, client: TestClient, db: Session):
|
||||
"""动态生成建议"""
|
||||
resp = client.post(f"{BASE}/scenario-suggestion/generate", json={
|
||||
"alert_type": "cost_high", "kpi_name": "销售费用率",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert "title" in resp.json() or "description" in resp.json()
|
||||
|
||||
|
||||
class TestRealOption:
|
||||
def test_bs_call_expansion(self, client: TestClient, db: Session):
|
||||
"""BSM看涨(扩张期权)"""
|
||||
resp = client.post(f"{BASE}/real-option", json={
|
||||
"opt_type": "expansion", "model": "bs",
|
||||
"S0": 100, "X": 80, "t": 3, "r": 0.0174, "sigma": 0.30,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["option_value"] > 0
|
||||
assert "intermediate" in data
|
||||
assert "sensitivity" in data
|
||||
|
||||
def test_bs_put_abandon(self, client: TestClient, db: Session):
|
||||
"""BSM看跌(放弃期权)"""
|
||||
resp = client.post(f"{BASE}/real-option", json={
|
||||
"opt_type": "abandon", "model": "bs",
|
||||
"S0": 100, "X": 120, "t": 2, "r": 0.05, "sigma": 0.40,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["option_value"] >= 0
|
||||
|
||||
def test_binomial_delay(self, client: TestClient, db: Session):
|
||||
"""二叉树(延迟期权)"""
|
||||
resp = client.post(f"{BASE}/real-option", json={
|
||||
"opt_type": "delay", "model": "binomial",
|
||||
"S0": 100, "X": 80, "t": 3, "r": 0.05, "sigma": 0.30, "n_steps": 50,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["option_value"] > 0
|
||||
|
||||
def test_binomial_american_put(self, client: TestClient, db: Session):
|
||||
"""二叉树美式看跌(放弃期权)"""
|
||||
resp = client.post(f"{BASE}/real-option", json={
|
||||
"opt_type": "abandon", "model": "binomial",
|
||||
"S0": 100, "X": 120, "t": 2, "r": 0.05, "sigma": 0.40, "n_steps": 30,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["option_value"] >= 0
|
||||
|
||||
def test_real_option_invalid_params(self, client: TestClient, db: Session):
|
||||
"""非法参数 → 400"""
|
||||
resp = client.post(f"{BASE}/real-option", json={"S0": -5})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_real_option_unsupported_comb(self, client: TestClient, db: Session):
|
||||
"""不支持的组合 → 400"""
|
||||
resp = client.post(f"{BASE}/real-option", json={"opt_type": "weird", "model": "bs"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
class TestGrowthQuality:
|
||||
def test_growth_quality_entity1(self, client: TestClient, db: Session):
|
||||
"""酣客增长质量"""
|
||||
resp = client.post(f"{BASE}/growth-quality", json={"entity_id": 1})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "overall" in data or "level" in data or "score" in data
|
||||
|
||||
def test_growth_quality_entity2(self, client: TestClient, db: Session):
|
||||
"""博海增长质量"""
|
||||
resp = client.post(f"{BASE}/growth-quality", json={"entity_id": 2})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["entity_name"] == "陕西博海网络科技"
|
||||
|
||||
def test_growth_quality_custom(self, client: TestClient, db: Session):
|
||||
"""自定义企业数据(低分场景)"""
|
||||
resp = client.post(f"{BASE}/growth-quality", json={
|
||||
"entity_id": 99,
|
||||
"name": "测试企业",
|
||||
"rebateRate": 90, "trueGrossMargin": 5, "cashRatio": 3,
|
||||
"expenseGrowthRate": 3, "revenueGrowthRate": 1, "mgmtRatio": 500,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["entity_name"] == "测试企业"
|
||||
assert data["overall"] < 2
|
||||
assert data["level_type"] == "danger"
|
||||
# 低分诊断
|
||||
assert "越增长越重" in data["diagnosis"]
|
||||
@@ -0,0 +1,38 @@
|
||||
"""探针:cost 分析端点行为"""
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
from tests.conftest import create_test_user, get_token_for_user, auth_header
|
||||
|
||||
|
||||
class TestProbeCost:
|
||||
BASE = "/api/cma/cost"
|
||||
|
||||
def test_probe_overview(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
r = client.get(f"{self.BASE}/overview?period=2026-06", headers=auth_header(token))
|
||||
print("OVERVIEW", r.status_code, r.text[:150])
|
||||
|
||||
def test_probe_variance(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
r = client.get(f"{self.BASE}/variance?product_code=PROD_A&period=2026-06", headers=auth_header(token))
|
||||
print("VARIANCE", r.status_code, r.text[:200])
|
||||
|
||||
def test_probe_breakdown(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
r = client.get(f"{self.BASE}/breakdown?product_code=PROD_A&period=2026-06", headers=auth_header(token))
|
||||
print("BREAKDOWN", r.status_code, r.text[:200])
|
||||
|
||||
def test_probe_comparison(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
r = client.get(f"{self.BASE}/comparison?entity=hanke", headers=auth_header(token))
|
||||
print("COMPARISON", r.status_code, r.text[:120])
|
||||
|
||||
def test_probe_dashboard(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
r = client.get(f"{self.BASE}/dashboard?period=2026-06", headers=auth_header(token))
|
||||
print("DASHBOARD", r.status_code, r.text[:300])
|
||||
@@ -0,0 +1,845 @@
|
||||
"""报表中心模块测试 — 管理利润表/预算执行/KPI趋势/BSC评分卡/新30号准则三表/杜邦/自动报告生成
|
||||
|
||||
覆盖 reports.py 全部18个端点:
|
||||
profit-summary / budget-execution / kpi-trends / bsc-scorecard /
|
||||
profit-statement(old/new/dual) / mpm-calculate / restatement / category-map /
|
||||
balance-sheet / cash-flow / statutory / statutory/export / dupont /
|
||||
generate(weekly/monthly/special) / history / history/{id}
|
||||
"""
|
||||
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
|
||||
from app.models import (
|
||||
KPIDefinition, KPIValue, BudgetPlan, StrategicMap, KPIAlert, ActionPlan,
|
||||
Subject, ReportHistory,
|
||||
)
|
||||
from app.models.budget_plan import BudgetPlan as BP
|
||||
|
||||
|
||||
# ── 测试数据工厂(报表口径KPI) ──
|
||||
|
||||
def mk_kpi(db: Session, code: str, dim: str = "finance", target: float = 100.0,
|
||||
entity_id: int = 1, name: str = None, unit: str = "万元") -> KPIDefinition:
|
||||
kpi = KPIDefinition(
|
||||
kpi_code=code, kpi_name=name or code, dimension=dim,
|
||||
target_value=target, unit=unit, status="active", frequency="monthly",
|
||||
entity_id=entity_id,
|
||||
)
|
||||
db.add(kpi)
|
||||
db.commit()
|
||||
db.refresh(kpi)
|
||||
return kpi
|
||||
|
||||
|
||||
def mk_val(db: Session, kpi: KPIDefinition, period: str, val: float) -> KPIValue:
|
||||
v = KPIValue(
|
||||
kpi_id=kpi.id, period=period, actual_value=val,
|
||||
source_type="test", source_batch="test", data_status="verified",
|
||||
)
|
||||
db.add(v)
|
||||
db.commit()
|
||||
db.refresh(v)
|
||||
return v
|
||||
|
||||
|
||||
def mk_budget(db: Session, kpi: KPIDefinition, period: str, val: float) -> BudgetPlan:
|
||||
y, m = period.split("-")
|
||||
b = BP(
|
||||
kpi_id=kpi.id, period=period, budget_value=val,
|
||||
budget_year=int(y), budget_month=int(m), status="active",
|
||||
)
|
||||
db.add(b)
|
||||
db.commit()
|
||||
db.refresh(b)
|
||||
return b
|
||||
|
||||
|
||||
def seed_profit_kpis(db: Session, period: str = "2026-06", prev: str = "2026-05"):
|
||||
"""利润表口径KPI:收入/毛利率/净利率/成本率,含上期环比数据"""
|
||||
pairs = [
|
||||
("F_REVENUE", 100.0, 80.0),
|
||||
("F_PROFIT_RATE", 20.0, 15.0),
|
||||
("F_NET_PROFIT_RATE", 10.0, 8.0),
|
||||
("F_COST_RATIO", 60.0, 65.0),
|
||||
]
|
||||
for code, cur, pv in pairs:
|
||||
k = mk_kpi(db, code)
|
||||
mk_val(db, k, period, cur)
|
||||
mk_val(db, k, prev, pv)
|
||||
|
||||
|
||||
def seed_subject_kpis(db: Session, period: str = "2026-06"):
|
||||
"""新30号准则科目映射KPI(经 _get_subject_amount 的 kpi_code_map)"""
|
||||
data = {
|
||||
"F_REVENUE": 100.0, "F_COST": 60.0, "F_SELLING_EXP": 5.0,
|
||||
"F_ADMIN_EXP": 10.0, "F_RD_EXP": 4.0, "F_FINANCE_EXP": 0.5,
|
||||
"F_INTEREST_INCOME": 3.0, "F_INVEST_INCOME": 3.0,
|
||||
"F_INTEREST_EXP": 2.0, "F_TAX_EXP": 1.0, "F_FX_LOSS": 0.5,
|
||||
}
|
||||
kpis = {}
|
||||
for code, val in data.items():
|
||||
k = mk_kpi(db, code)
|
||||
mk_val(db, k, period, val)
|
||||
kpis[code] = k
|
||||
return kpis
|
||||
|
||||
|
||||
class TestProfitSummary:
|
||||
"""报表1:管理利润表"""
|
||||
|
||||
BASE = "/api/cma/reports/profit-summary"
|
||||
|
||||
def test_profit_summary_with_data(self, client: TestClient, db: Session):
|
||||
"""有数据时:营收/变动成本/边际贡献/固定成本/息税前利润 + 环比"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
seed_profit_kpis(db)
|
||||
|
||||
resp = client.get(f"{self.BASE}?period=2026-06", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["period"] == "2026-06"
|
||||
assert data["prev_period"] == "2026-05"
|
||||
items = {i["name"]: i for i in data["items"]}
|
||||
# 营业收入 100 vs 上期80 → +25%
|
||||
assert items["营业收入"]["value"] == 100.0
|
||||
assert items["营业收入"]["prev_value"] == 80.0
|
||||
assert items["营业收入"]["change_rate"] == 25.0
|
||||
# 变动成本 = 营收×50% = 50
|
||||
assert items["减:变动成本"]["value"] == 50.0
|
||||
# 边际贡献 = 毛利 = 100×20% = 20
|
||||
assert items["= 边际贡献"]["value"] == 20.0
|
||||
assert items["= 边际贡献"]["is_subtotal"] is True
|
||||
# 固定成本 = 总成本60 - 变动成本50 = 10
|
||||
assert items["减:固定成本"]["value"] == 10.0
|
||||
# 息税前利润 = 100×10% = 10
|
||||
assert items["= 息税前利润"]["value"] == 10.0
|
||||
assert items["= 息税前利润"]["is_total"] is True
|
||||
|
||||
def test_profit_summary_empty_db(self, client: TestClient, db: Session):
|
||||
"""无任何KPI数据 → 200,值全部为None,不报错"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.get(self.BASE, headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["items"]) == 5
|
||||
assert data["items"][0]["value"] is None
|
||||
|
||||
def test_profit_summary_year_boundary(self, client: TestClient, db: Session):
|
||||
"""1月 → 上期跨年(2026-01 → 2025-12)"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.get(f"{self.BASE}?period=2026-01", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["prev_period"] == "2025-12"
|
||||
|
||||
|
||||
class TestBudgetExecution:
|
||||
"""报表2:预算执行报告"""
|
||||
|
||||
BASE = "/api/cma/reports/budget-execution"
|
||||
|
||||
def _seed(self, db: Session):
|
||||
# 超预算红(30%) / 正常(5%) / 超支黄(-25%→红? abs>20=red) 细分场景
|
||||
k1 = mk_kpi(db, "BH_REVENUE", name="营业收入")
|
||||
mk_val(db, k1, "2026-06", 130.0)
|
||||
mk_budget(db, k1, "2026-06", 100.0) # +30% → red over
|
||||
k2 = mk_kpi(db, "BH_PROFIT", name="净利润")
|
||||
mk_val(db, k2, "2026-06", 105.0)
|
||||
mk_budget(db, k2, "2026-06", 100.0) # +5% → normal
|
||||
k3 = mk_kpi(db, "BH_COST", name="成本", dim="customer")
|
||||
mk_val(db, k3, "2026-06", 75.0)
|
||||
mk_budget(db, k3, "2026-06", 100.0) # -25% → red under
|
||||
k4 = mk_kpi(db, "BH_ZERO_TARGET", name="零目标KPI")
|
||||
mk_val(db, k4, "2026-06", 50.0)
|
||||
mk_budget(db, k4, "2026-06", 0.0) # 预算0 → gray
|
||||
k5 = mk_kpi(db, "BH_EMPTY", name="无数据KPI", target=None) # 完全无数据 → 跳过
|
||||
return [k1, k2, k3, k4, k5]
|
||||
|
||||
def test_budget_execution_summary(self, client: TestClient, db: Session):
|
||||
"""summary统计与alert_level分级"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
self._seed(db)
|
||||
|
||||
resp = client.get(f"{self.BASE}?period=2026-06", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["period"] == "2026-06"
|
||||
s = data["summary"]
|
||||
assert s["total"] == 4 # 无数据KPI被跳过
|
||||
assert s["with_budget"] == 4
|
||||
assert s["over_budget"] == 1 # +30%
|
||||
assert s["under_budget"] == 1 # -25%
|
||||
assert s["normal"] == 2 # +5% 和 gray
|
||||
levels = {i["kpi_code"]: i["alert_level"] for i in data["items"]}
|
||||
assert levels["BH_REVENUE"] == "red"
|
||||
assert levels["BH_PROFIT"] == "normal"
|
||||
assert levels["BH_COST"] == "red"
|
||||
assert levels["BH_ZERO_TARGET"] == "gray"
|
||||
|
||||
def test_budget_execution_filters(self, client: TestClient, db: Session):
|
||||
"""dimension / alert_level 过滤"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
self._seed(db)
|
||||
|
||||
r1 = client.get(f"{self.BASE}?period=2026-06&dimension=finance", headers=auth_header(token))
|
||||
assert {i["kpi_code"] for i in r1.json()["items"]} == {"BH_REVENUE", "BH_PROFIT", "BH_ZERO_TARGET"}
|
||||
|
||||
r2 = client.get(f"{self.BASE}?period=2026-06&alert_level=red", headers=auth_header(token))
|
||||
assert {i["kpi_code"] for i in r2.json()["items"]} == {"BH_REVENUE", "BH_COST"}
|
||||
assert r2.json()["summary"]["total"] == 4 # summary不过滤
|
||||
|
||||
|
||||
class TestKpiTrends:
|
||||
"""报表3:KPI趋势报告"""
|
||||
|
||||
BASE = "/api/cma/reports/kpi-trends"
|
||||
|
||||
def _seed(self, db: Session):
|
||||
k = mk_kpi(db, "BH_REVENUE", name="营业收入", target=120.0)
|
||||
for i, p in enumerate(["2026-01", "2026-02", "2026-03", "2026-04", "2026-05", "2026-06"]):
|
||||
mk_val(db, k, p, 10 + i * 2) # 10,12,14,16,18,20 上升
|
||||
k2 = mk_kpi(db, "BH_EMPTY_TREND", name="无数据KPI")
|
||||
return k, k2
|
||||
|
||||
def test_trend_up(self, client: TestClient, db: Session):
|
||||
"""上升趋势 + 统计值"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
k, _ = self._seed(db)
|
||||
|
||||
resp = client.get(f"{self.BASE}?kpi_id={k.id}", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
rows = resp.json()["data"]
|
||||
assert len(rows) == 1
|
||||
r = rows[0]
|
||||
assert r["kpi_code"] == "BH_REVENUE"
|
||||
assert r["trend_dir"] == "up"
|
||||
assert len(r["trend"]) == 6
|
||||
assert r["avg"] == 15.0
|
||||
assert r["max"] == 20.0
|
||||
assert r["min"] == 10.0
|
||||
assert r["target_value"] == 120.0
|
||||
|
||||
def test_trend_months_limit(self, client: TestClient, db: Session):
|
||||
"""months 限制条数"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
k, _ = self._seed(db)
|
||||
resp = client.get(f"{self.BASE}?kpi_id={k.id}&months=3", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()["data"][0]["trend"]) == 3
|
||||
|
||||
def test_trend_no_data(self, client: TestClient, db: Session):
|
||||
"""无数据KPI → 空trend + stable"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
_, k2 = self._seed(db)
|
||||
resp = client.get(f"{self.BASE}?kpi_id={k2.id}", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
r = resp.json()["data"][0]
|
||||
assert r["trend"] == []
|
||||
assert r["trend_dir"] == "stable"
|
||||
assert r["avg"] is None
|
||||
|
||||
def test_trend_months_out_of_range(self, client: TestClient, db: Session):
|
||||
"""months 越界(2 / 37) → 422"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
for m in (2, 37):
|
||||
resp = client.get(f"{self.BASE}?months={m}", headers=auth_header(token))
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_trend_dimension_filter(self, client: TestClient, db: Session):
|
||||
"""dimension 过滤"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
self._seed(db)
|
||||
resp = client.get(f"{self.BASE}?dimension=customer", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["data"] == []
|
||||
|
||||
|
||||
class TestBscScorecard:
|
||||
"""报表4:四维度绩效评分卡"""
|
||||
|
||||
BASE = "/api/cma/reports/bsc-scorecard"
|
||||
|
||||
def test_scorecard_from_kpis(self, client: TestClient, db: Session):
|
||||
"""无发布地图 → 按维度聚合KPI算分"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
k1 = mk_kpi(db, "BH_REVENUE", dim="finance", target=100.0)
|
||||
mk_val(db, k1, "2026-06", 95.0) # 95分 green
|
||||
k2 = mk_kpi(db, "BH_NET_PROFIT", dim="finance", target=100.0)
|
||||
mk_val(db, k2, "2026-06", 50.0) # 50分 red
|
||||
k3 = mk_kpi(db, "BH_SATISFACTION", dim="customer", target=10.0)
|
||||
mk_val(db, k3, "2026-06", 9.0) # 90分 green
|
||||
k4 = mk_kpi(db, "BH_ZERO_TARGET", dim="learning", target=0.0)
|
||||
mk_val(db, k4, "2026-06", 5.0) # 目标0 → gray 无分
|
||||
|
||||
resp = client.get(f"{self.BASE}?period=2026-06", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["map_id"] is None
|
||||
dims = {d["key"]: d for d in data["dimensions"]}
|
||||
assert dims["finance"]["score"] == 72.5 # (95+50)/2
|
||||
assert dims["customer"]["score"] == 90.0
|
||||
assert dims["learning"]["score"] == 0.0 # 无有效数据
|
||||
assert data["overall_score"] == 54.2 # (72.5+90+0)/3
|
||||
# level 判定
|
||||
fin_kpis = {k["code"]: k for k in dims["finance"]["objectives"][0]["kpis"]}
|
||||
assert fin_kpis["BH_REVENUE"]["level"] == "green"
|
||||
assert fin_kpis["BH_NET_PROFIT"]["level"] == "red"
|
||||
learn_kpis = {k["code"]: k for k in dims["learning"]["objectives"][0]["kpis"]}
|
||||
assert learn_kpis["BH_ZERO_TARGET"]["level"] == "gray"
|
||||
|
||||
def test_scorecard_from_map(self, client: TestClient, db: Session):
|
||||
"""有已发布战略地图 → 走地图维度路径"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
k = mk_kpi(db, "BH_REVENUE", dim="finance", target=100.0)
|
||||
mk_val(db, k, "2026-06", 95.0)
|
||||
dims = [{
|
||||
"key": "finance", "name": "财务维度", "icon": "💰", "color": "#409eff",
|
||||
"objectives": [{"name": "增收", "kpis": ["BH_REVENUE"]}],
|
||||
}]
|
||||
sm = StrategicMap(title="已发布地图", status="published", dimensions=dims,
|
||||
canvas_data={"connections": []})
|
||||
db.add(sm)
|
||||
db.commit()
|
||||
|
||||
resp = client.get(f"{self.BASE}?period=2026-06&map_id={sm.id}", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["map_id"] == sm.id
|
||||
assert data["map_title"] == "已发布地图"
|
||||
assert data["dimensions"][0]["score"] == 95.0
|
||||
assert data["dimensions"][0]["objectives"][0]["kpis"][0]["level"] == "green"
|
||||
|
||||
def test_scorecard_string_dimensions(self, client: TestClient, db: Session):
|
||||
"""dimensions 存为JSON字符串(旧数据兼容)"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
k = mk_kpi(db, "BH_REVENUE", dim="finance", target=100.0)
|
||||
mk_val(db, k, "2026-06", 88.0)
|
||||
import json as _json
|
||||
sm = StrategicMap(title="字符串维度地图", status="published",
|
||||
dimensions=_json.dumps([{
|
||||
"key": "finance", "name": "财务", "icon": "💰",
|
||||
"objectives": [{"name": "增收", "kpis": ["BH_REVENUE"]}],
|
||||
}]),
|
||||
canvas_data={"connections": []})
|
||||
db.add(sm)
|
||||
db.commit()
|
||||
resp = client.get(f"{self.BASE}?period=2026-06", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["map_id"] == sm.id
|
||||
|
||||
|
||||
class TestProfitStatement:
|
||||
"""利润表:旧/新/双列格式"""
|
||||
|
||||
BASE = "/api/cma/reports/profit-statement"
|
||||
|
||||
def test_old_format(self, client: TestClient, db: Session):
|
||||
"""format=old → 复用管理利润表"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
seed_profit_kpis(db)
|
||||
resp = client.get(f"{self.BASE}?period=2026-06&format=old", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "items" in data and len(data["items"]) == 5
|
||||
|
||||
def test_new_format_blocks(self, client: TestClient, db: Session):
|
||||
"""format=new → 五板块 + 附注,净利润=板块之和"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
seed_subject_kpis(db)
|
||||
resp = client.get(f"{self.BASE}?period=2026-06&format=new", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["format"] == "new"
|
||||
assert len(data["blocks"]) == 5
|
||||
keys = {b["key"] for b in data["blocks"]}
|
||||
assert keys == {"operating", "investing", "financing", "tax", "discontinued"}
|
||||
# 经营 21.5(100-60-5-10-4+0.5) + 投资 6(3+3) - 筹资 1.5(2-0.5) - 所得税 1 = 25.0
|
||||
assert data["net_profit"] == 25.0
|
||||
op = next(b for b in data["blocks"] if b["key"] == "operating")
|
||||
assert op["has_real_data"] is True
|
||||
assert data["notes"]["revenue_total"] == 100.0
|
||||
assert len(data["notes"]["key_ratios"]) >= 2
|
||||
|
||||
def test_dual_format(self, client: TestClient, db: Session):
|
||||
"""format=dual → 新旧双列对比"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
seed_subject_kpis(db)
|
||||
resp = client.get(f"{self.BASE}?period=2026-06&format=dual", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["format"] == "dual"
|
||||
assert "old_format" in data and "new_format" in data
|
||||
assert data["new_format"]["net_profit"] == 25.0
|
||||
|
||||
|
||||
class TestMpmCalculate:
|
||||
"""MPM管理层指标计算器"""
|
||||
|
||||
BASE = "/api/cma/reports/mpm-calculate"
|
||||
|
||||
def test_invalid_type(self, client: TestClient, db: Session):
|
||||
"""不支持的指标类型 → 400"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.post(self.BASE, headers=auth_header(token),
|
||||
json={"indicator_type": "not_a_type", "period": "2026-06"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_default_empty_db(self, client: TestClient, db: Session):
|
||||
"""空库 → 基准0,has_real_data False"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.post(self.BASE, headers=auth_header(token),
|
||||
json={"indicator_type": "adjusted_net_profit", "period": "2026-06"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["has_real_data"] is False
|
||||
assert data["base_value"] == 0
|
||||
assert data["final_value"] == 0
|
||||
|
||||
def test_adjusted_net_profit_with_data(self, client: TestClient, db: Session):
|
||||
"""有净利润+投资收益数据 → 调整后净利润 = 21.5 - 3(非经常性投资) = 18.5"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
seed_subject_kpis(db)
|
||||
resp = client.post(self.BASE, headers=auth_header(token),
|
||||
json={"indicator_type": "adjusted_net_profit", "period": "2026-06"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["has_real_data"] is True
|
||||
assert data["base_value"] == 25.0
|
||||
assert data["final_value"] == 22.0 # 25.0 - 3(非经常性投资收益)
|
||||
assert data["adjustment_count"] == 3
|
||||
|
||||
def test_ebitda(self, client: TestClient, db: Session):
|
||||
"""EBITDA = 净利润25 + 所得税1 + 利息支出2 = 28.0"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
seed_subject_kpis(db)
|
||||
resp = client.post(self.BASE, headers=auth_header(token),
|
||||
json={"indicator_type": "ebitda", "period": "2026-06"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["final_value"] == 28.0
|
||||
|
||||
def test_free_cash_flow(self, client: TestClient, db: Session):
|
||||
"""自由现金流 = 经营现金流30(有值时优先)"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
k = mk_kpi(db, "F_OPERATING_CF")
|
||||
mk_val(db, k, "2026-06", 30.0)
|
||||
resp = client.post(self.BASE, headers=auth_header(token),
|
||||
json={"indicator_type": "free_cash_flow", "period": "2026-06"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["base_value"] == 30.0
|
||||
assert data["final_value"] == 30.0
|
||||
|
||||
def test_custom_adjustments(self, client: TestClient, db: Session):
|
||||
"""自定义指标+显式调整金额"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.post(self.BASE, headers=auth_header(token),
|
||||
json={
|
||||
"indicator_type": "custom",
|
||||
"period": "2026-06",
|
||||
"adjustments": [
|
||||
{"code": "adjustment_1", "name": "加回项", "sign": 1,
|
||||
"checked": True, "amount": 100.0},
|
||||
{"code": "adjustment_2", "name": "扣除项", "sign": -1,
|
||||
"checked": True, "amount": 30.0},
|
||||
],
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["final_value"] == 70.0 # 0 + 100 - 30
|
||||
|
||||
|
||||
class TestRestatement:
|
||||
"""追溯调整:新旧口径对比"""
|
||||
|
||||
BASE = "/api/cma/reports/restatement"
|
||||
|
||||
def test_restatement_with_data(self, client: TestClient, db: Session):
|
||||
"""旧口径KPI + 新口径映射 → 对比行与净利润对比"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
# 旧口径
|
||||
for code, val in [("F_REVENUE", 100.0), ("F_COST", 50.0),
|
||||
("F_SELLING_EXP", 3.0), ("F_ADMIN_EXP", 20.0),
|
||||
("F_RD_EXP", 5.0)]:
|
||||
k = mk_kpi(db, code)
|
||||
mk_val(db, k, "2026-06", val)
|
||||
|
||||
resp = client.get(f"{self.BASE}?period=2026-06", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["period"] == "2026-06"
|
||||
names = [i["item_name"] for i in data["items"]]
|
||||
# 管理费用:新旧口径共用F_ADMIN_EXP → 新值=旧值=20(剥离逻辑在KPI口径下不触发)
|
||||
admin = next(i for i in data["items"] if i["item_name"] == "减:管理费用")
|
||||
assert admin["old_value"] == 20.0
|
||||
assert admin["new_value"] == 20.0
|
||||
assert admin["needs_adjustment"] is False
|
||||
# 研发费用单独列示行(新30号准则)
|
||||
rd = next(i for i in data["items"] if i["item_name"] == "减:研发费用(单独列示)")
|
||||
assert rd["new_value"] == 5.0
|
||||
assert rd["needs_adjustment"] is True
|
||||
assert rd["adjustment_reason"] == "新30号准则单独列示"
|
||||
# 旧口径净利润 = 100-50-3-20 = 27;新口径 = 100-50-3-20-5 = 22
|
||||
npc = data["net_profit_comparison"]
|
||||
assert npc["old_net_profit"] == 27.0
|
||||
assert npc["new_net_profit"] == 22.0
|
||||
assert npc["difference"] == -5.0
|
||||
|
||||
def test_restatement_empty(self, client: TestClient, db: Session):
|
||||
"""空库 → 200 正常返回"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.get(f"{self.BASE}?period=2026-06", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert "summary" in resp.json()
|
||||
|
||||
|
||||
class TestCategoryMap:
|
||||
"""科目→新30号准则板块映射"""
|
||||
|
||||
BASE = "/api/cma/reports/category-map"
|
||||
|
||||
def test_fallback_map(self, client: TestClient, db: Session):
|
||||
"""无科目数据 → 硬编码映射回退"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.get(self.BASE, headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] >= 15
|
||||
assert data["mapping"]["6001"] == "operating"
|
||||
assert "operating" in data["grouped"]
|
||||
assert "tax" in data["grouped"]
|
||||
|
||||
def test_subject_map(self, client: TestClient, db: Session):
|
||||
"""有科目数据 → 用科目实际分类"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
db.add(Subject(subject_code="6001", subject_name="主营业务收入",
|
||||
new_standard_category="operating", is_active=1))
|
||||
db.add(Subject(subject_code="6801", subject_name="所得税费用",
|
||||
new_standard_category="tax", is_active=1))
|
||||
db.add(Subject(subject_code="9999", subject_name="停用科目",
|
||||
new_standard_category="operating", is_active=0)) # 不启用 → 排除
|
||||
db.commit()
|
||||
resp = client.get(self.BASE, headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
codes = {m["subject_code"] for m in data["subjects"]}
|
||||
assert codes == {"6001", "6801"}
|
||||
|
||||
|
||||
class TestBalanceSheet:
|
||||
"""资产负债表(新30号准则)"""
|
||||
|
||||
BASE = "/api/cma/reports/balance-sheet"
|
||||
|
||||
def test_balance_sheet_demo(self, client: TestClient, db: Session):
|
||||
"""无凭证数据 → 示例数据,勾稽平衡"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.get(f"{self.BASE}?period=2026-06", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["sections"]) == 5
|
||||
t = data["totals"]
|
||||
assert t["assets"]["end"] == t["liab_equity"]["end"]
|
||||
assert t["balanced"] is True
|
||||
assert data["all_items_have_data"] is False
|
||||
# 行项目含新准则分类
|
||||
first_section_lines = data["sections"][0]["lines"]
|
||||
assert all("ns_category" in l for l in first_section_lines)
|
||||
|
||||
def test_balance_sheet_prev_period_boundary(self, client: TestClient, db: Session):
|
||||
"""1月 → 期初期间跨年"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.get(f"{self.BASE}?period=2026-01", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["prev_period"] == "2025-12"
|
||||
|
||||
|
||||
class TestCashFlow:
|
||||
"""现金流量表"""
|
||||
|
||||
BASE = "/api/cma/reports/cash-flow"
|
||||
|
||||
def test_cash_flow_demo(self, client: TestClient, db: Session):
|
||||
"""无数据 → 示例数据三活动"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.get(f"{self.BASE}?period=2026-06", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["sections"]) == 3
|
||||
assert set(data["summary"].keys()) == {"net_increase", "begin_cash", "end_cash"}
|
||||
assert "fx_effect" in data
|
||||
|
||||
def test_cash_flow_op_kpi_override(self, client: TestClient, db: Session):
|
||||
"""F_OP_CFLOW 存在 → 经营净额被KPI覆盖"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
k = mk_kpi(db, "F_OP_CFLOW")
|
||||
mk_val(db, k, "2026-06", 999.0)
|
||||
resp = client.get(f"{self.BASE}?period=2026-06", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["sections"][0]["net"] == 999.0
|
||||
|
||||
def test_cash_flow_fx_kpi(self, client: TestClient, db: Session):
|
||||
"""F_FX_LOSS 存在 → 汇率影响非demo"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
k = mk_kpi(db, "F_FX_LOSS")
|
||||
mk_val(db, k, "2026-06", 2.5)
|
||||
resp = client.get(f"{self.BASE}?period=2026-06", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
fx = resp.json()["fx_effect"]
|
||||
assert fx["value"] == 2.5
|
||||
assert fx["is_demo"] is False
|
||||
|
||||
|
||||
class TestStatutory:
|
||||
"""对外法定报表 组合视图 + 导出"""
|
||||
|
||||
BASE = "/api/cma/reports/statutory"
|
||||
|
||||
def test_statutory_combined(self, client: TestClient, db: Session):
|
||||
"""三表合一"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
seed_subject_kpis(db)
|
||||
resp = client.get(f"{self.BASE}?period=2026-06", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "profit" in data and "balance_sheet" in data and "cash_flow" in data
|
||||
assert data["profit"]["net_profit"] == 25.0
|
||||
|
||||
def test_statutory_export(self, client: TestClient, db: Session):
|
||||
"""导出xlsx"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.get(f"{self.BASE}/export?period=2026-06", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert "spreadsheetml" in resp.headers.get("content-type", "")
|
||||
assert len(resp.content) > 1000
|
||||
|
||||
|
||||
class TestDupont:
|
||||
"""杜邦分析"""
|
||||
|
||||
BASE = "/api/cma/reports/dupont"
|
||||
|
||||
def test_bohai_fallback(self, client: TestClient, db: Session):
|
||||
"""博海无DB数据 → 回退文档常量"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.get(f"{self.BASE}?entity=bohai", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["entity_name"] == "陕西博海科技(IT服务)"
|
||||
assert data["roe"] > 0
|
||||
assert set(data["factors"].keys()) == {"net_profit_margin", "asset_turnover", "financial_leverage"}
|
||||
assert data["raw_data"]["net_profit"] == 14.13
|
||||
|
||||
def test_bohai_with_db_data(self, client: TestClient, db: Session):
|
||||
"""博海(entity 2) DB有verified KPI → 用DB值"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
k1 = mk_kpi(db, "F_NET_PROFIT", entity_id=2, target=0)
|
||||
mk_val(db, k1, "2026-H1", 20.0)
|
||||
k2 = mk_kpi(db, "F_REVENUE", entity_id=2, target=0)
|
||||
mk_val(db, k2, "2026-H1", 400.0)
|
||||
resp = client.get(f"{self.BASE}?entity=bohai", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["raw_data"]["net_profit"] == 20.0
|
||||
assert data["raw_data"]["revenue"] == 400.0
|
||||
assert data["roe"] > 0
|
||||
|
||||
def test_hanke_no_data(self, client: TestClient, db: Session):
|
||||
"""酣客无数据 → roe None,不报错"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.get(f"{self.BASE}?entity=hanke", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["entity_name"] == "陕西酣客文化传媒(白酒经销)"
|
||||
assert data["roe"] is None
|
||||
assert data["factors"]["net_profit_margin"]["value"] is None
|
||||
|
||||
def test_hanke_with_loss_data(self, client: TestClient, db: Session):
|
||||
"""酣客亏损数据 → 负ROE"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
k1 = mk_kpi(db, "F_REVENUE", entity_id=1, target=0)
|
||||
mk_val(db, k1, "2026-H1", 100.0)
|
||||
k2 = mk_kpi(db, "F_NET_PROFIT", entity_id=1, target=0)
|
||||
mk_val(db, k2, "2026-H1", -10.0)
|
||||
resp = client.get(f"{self.BASE}?entity=hanke", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["roe"] == -10.0
|
||||
assert data["factors"]["net_profit_margin"]["status"] == "🔴"
|
||||
|
||||
def test_unknown_entity(self, client: TestClient, db: Session):
|
||||
"""未知实体 → error"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.get(f"{self.BASE}?entity=xxx", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert "error" in resp.json()
|
||||
|
||||
|
||||
class TestGenerateReports:
|
||||
"""自动报告生成(周报/月报/专项)+ 历史"""
|
||||
|
||||
BASE = "/api/cma/reports"
|
||||
|
||||
def _seed_kpis(self, db: Session):
|
||||
k1 = mk_kpi(db, "BH_REVENUE", target=100.0)
|
||||
mk_val(db, k1, "2026-06", 120.0)
|
||||
mk_val(db, k1, "2026-05", 100.0) # 环比+20%
|
||||
k2 = mk_kpi(db, "BH_NET_PROFIT", target=50.0)
|
||||
mk_val(db, k2, "2026-06", 30.0)
|
||||
# 预警
|
||||
db.add(KPIAlert(kpi_id=k1.id, alert_level="red", alert_message="收入偏离目标", status="pending"))
|
||||
db.add(KPIAlert(kpi_id=k2.id, alert_level="yellow", alert_message="利润预警", status="pending"))
|
||||
# 改善行动
|
||||
db.add(ActionPlan(kpi_id=k1.id, title="提升毛利率", status="in_progress", progress=50))
|
||||
db.commit()
|
||||
return k1, k2
|
||||
|
||||
def test_generate_invalid_type(self, client: TestClient, db: Session):
|
||||
"""非法报告类型 → 400"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.post(f"{self.BASE}/generate", headers=auth_header(token),
|
||||
json={"report_type": "daily"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_generate_monthly(self, client: TestClient, db: Session):
|
||||
"""月报生成 + 入库历史"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
self._seed_kpis(db)
|
||||
resp = client.post(f"{self.BASE}/generate", headers=auth_header(token),
|
||||
json={"report_type": "monthly", "period": "2026-06",
|
||||
"trigger_type": "manual"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["report_type"] == "monthly"
|
||||
assert data["id"] > 0
|
||||
assert "经营分析月报" in data["title"]
|
||||
assert "经营分析月报" in data["markdown"]
|
||||
assert data["json"]["overview"]["red_alerts"] == 1
|
||||
assert len(data["json"]["budget_execution"]) >= 0
|
||||
|
||||
def test_generate_weekly(self, client: TestClient, db: Session):
|
||||
"""周报生成(ISO周期间)"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
self._seed_kpis(db)
|
||||
resp = client.post(f"{self.BASE}/generate", headers=auth_header(token),
|
||||
json={"report_type": "weekly", "period": "2026-W30"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["report_type"] == "weekly"
|
||||
assert "经营分析周报" in data["markdown"]
|
||||
assert len(data["json"]["top_changes"]) >= 1 # 有环比数据
|
||||
|
||||
def test_generate_special_with_alert(self, client: TestClient, db: Session):
|
||||
"""专项报告(事件触发)"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
k1, _ = self._seed_kpis(db)
|
||||
alert = db.query(KPIAlert).filter_by(kpi_id=k1.id).first()
|
||||
resp = client.post(f"{self.BASE}/generate", headers=auth_header(token),
|
||||
json={"report_type": "special", "period": "2026-06",
|
||||
"trigger_type": "event", "alert_ref": str(alert.id)})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["report_type"] == "special"
|
||||
assert "专项报告" in data["title"]
|
||||
assert data["json"]["focus_kpi"] == "BH_REVENUE"
|
||||
assert data["json"]["alert_ref"] == str(alert.id)
|
||||
|
||||
def test_generate_special_no_alerts(self, client: TestClient, db: Session):
|
||||
"""专项报告(无预警数据)"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.post(f"{self.BASE}/generate", headers=auth_header(token),
|
||||
json={"report_type": "special", "period": "2026-06"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["json"]["focus_kpi"] is None
|
||||
|
||||
def test_history_empty_then_generated(self, client: TestClient, db: Session):
|
||||
"""历史:空 → 生成后有条目 → 详情可取"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
# 空
|
||||
r = client.get(f"{self.BASE}/history", headers=auth_header(token))
|
||||
assert r.status_code == 200 and r.json()["total"] == 0
|
||||
# 生成
|
||||
self._seed_kpis(db)
|
||||
gen = client.post(f"{self.BASE}/generate", headers=auth_header(token),
|
||||
json={"report_type": "monthly", "period": "2026-06"})
|
||||
rid = gen.json()["id"]
|
||||
# 历史列表
|
||||
r = client.get(f"{self.BASE}/history", headers=auth_header(token))
|
||||
assert r.json()["total"] >= 1
|
||||
assert r.json()["data"][0]["id"] == rid
|
||||
# 类型过滤
|
||||
r = client.get(f"{self.BASE}/history?report_type=monthly", headers=auth_header(token))
|
||||
assert all(x["report_type"] == "monthly" for x in r.json()["data"])
|
||||
# 详情
|
||||
r = client.get(f"{self.BASE}/history/{rid}", headers=auth_header(token))
|
||||
assert r.status_code == 200
|
||||
assert "markdown" in r.json()
|
||||
# 404
|
||||
r = client.get(f"{self.BASE}/history/99999", headers=auth_header(token))
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
class TestReportsPermissions:
|
||||
"""权限:报表中心仅 ceo/finance/business 可访问"""
|
||||
|
||||
BASE = "/api/cma/reports"
|
||||
|
||||
def test_it_role_denied(self, client: TestClient, db: Session):
|
||||
"""it角色访问报表 → 403"""
|
||||
from app.models import User
|
||||
import hashlib
|
||||
it = User(username="it_reports", password_hash=hashlib.sha256("pass123".encode()).hexdigest(),
|
||||
name="IT运维", role="it")
|
||||
db.add(it)
|
||||
db.commit()
|
||||
token = get_token_for_user(client, username="it_reports", password="pass123")
|
||||
resp = client.get(f"{self.BASE}/profit-summary", headers=auth_header(token))
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_no_token_denied(self, client: TestClient, db: Session):
|
||||
"""无token → 403(router级 require_role 直接拒绝)"""
|
||||
resp = client.get(f"{self.BASE}/profit-summary")
|
||||
assert resp.status_code == 403
|
||||
@@ -0,0 +1,245 @@
|
||||
"""税务合规模块测试 — 税负监控 + 发票校验 + 社保比对
|
||||
|
||||
覆盖 tax_compliance.py 核心端点:
|
||||
records CRUD / burden / check / invoices CRUD / invoices/check /
|
||||
ss CRUD / ss/check / dashboard / demo-data
|
||||
"""
|
||||
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
|
||||
|
||||
BASE = "/api/cma/tax"
|
||||
|
||||
|
||||
class TestTaxRecords:
|
||||
def test_list_empty(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.get(f"{BASE}/records", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["data"] == []
|
||||
|
||||
def test_create(self, client: TestClient, db: Session):
|
||||
"""创建税务记录(自动算税负率)"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.post(f"{BASE}/records", headers=auth_header(token), json={
|
||||
"period": "2026-07", "tax_type": "vat",
|
||||
"tax_payable": 13, "tax_paid": 13, "income": 100,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()["data"]
|
||||
assert data["tax_burden_rate"] == 13.0 # 13/100
|
||||
|
||||
def test_create_missing(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.post(f"{BASE}/records", headers=auth_header(token), json={"period": "2026-07"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_create_invalid_tax_type(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.post(f"{BASE}/records", headers=auth_header(token),
|
||||
json={"period": "2026-07", "tax_type": "garbage"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_update(self, client: TestClient, db: Session):
|
||||
"""更新税务记录(自动重算税负率)"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
cid = client.post(f"{BASE}/records", headers=auth_header(token),
|
||||
json={"period": "2026-07", "tax_type": "vat",
|
||||
"tax_paid": 10, "income": 100}).json()["data"]["id"]
|
||||
resp = client.put(f"{BASE}/records/{cid}", headers=auth_header(token),
|
||||
json={"tax_paid": 15, "income": 100})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["data"]["tax_burden_rate"] == 15.0
|
||||
|
||||
def test_update_not_found(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.put(f"{BASE}/records/99999", headers=auth_header(token), json={})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_delete(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
cid = client.post(f"{BASE}/records", headers=auth_header(token),
|
||||
json={"period": "2026-07", "tax_type": "vat"}).json()["data"]["id"]
|
||||
resp = client.delete(f"{BASE}/records/{cid}", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "税务记录已删除"
|
||||
|
||||
def test_delete_not_found(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.delete(f"{BASE}/records/99999", headers=auth_header(token))
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_list_filters(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
client.post(f"{BASE}/records", headers=auth_header(token),
|
||||
json={"period": "2026-06", "tax_type": "vat"})
|
||||
client.post(f"{BASE}/records", headers=auth_header(token),
|
||||
json={"period": "2026-07", "tax_type": "income"})
|
||||
resp = client.get(f"{BASE}/records?period=2026-07", headers=auth_header(token))
|
||||
assert resp.json()["total"] == 1
|
||||
resp2 = client.get(f"{BASE}/records?tax_type=vat", headers=auth_header(token))
|
||||
assert resp2.json()["total"] == 1
|
||||
|
||||
def test_burden_analysis(self, client: TestClient, db: Session):
|
||||
"""税负分析"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
client.post(f"{BASE}/records", headers=auth_header(token),
|
||||
json={"period": "2026-07", "tax_type": "vat",
|
||||
"tax_paid": 13, "income": 100})
|
||||
resp = client.get(f"{BASE}/burden", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_check_no_data(self, client: TestClient, db: Session):
|
||||
"""税负检查无数据"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.post(f"{BASE}/check", headers=auth_header(token), json={})
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
class TestInvoices:
|
||||
def test_create_and_list(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.post(f"{BASE}/invoices", headers=auth_header(token), json={
|
||||
"invoice_no": "INV001", "amount": 1000, "supplier": "供应商A",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "发票已录入并校验"
|
||||
|
||||
list_resp = client.get(f"{BASE}/invoices", headers=auth_header(token))
|
||||
assert list_resp.status_code == 200
|
||||
assert list_resp.json()["total"] == 1
|
||||
|
||||
def test_create_missing_no(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.post(f"{BASE}/invoices", headers=auth_header(token), json={"amount": 100})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_update_invoice(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
iid = client.post(f"{BASE}/invoices", headers=auth_header(token),
|
||||
json={"invoice_no": "INV002", "amount": 100}).json()["data"]["id"]
|
||||
resp = client.put(f"{BASE}/invoices/{iid}", headers=auth_header(token),
|
||||
json={"amount": 200, "supplier": "供应商B"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["data"]["amount"] == 200
|
||||
|
||||
def test_delete_invoice(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
iid = client.post(f"{BASE}/invoices", headers=auth_header(token),
|
||||
json={"invoice_no": "INV003", "amount": 100}).json()["data"]["id"]
|
||||
resp = client.delete(f"{BASE}/invoices/{iid}", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_check_invoices(self, client: TestClient, db: Session):
|
||||
"""发票校验"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
client.post(f"{BASE}/invoices", headers=auth_header(token),
|
||||
json={"invoice_no": "INV004", "amount": 100, "supplier": ""})
|
||||
resp = client.post(f"{BASE}/invoices/check", headers=auth_header(token), json={})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["abnormal_count"] >= 0
|
||||
|
||||
def test_abnormal_invoices(self, client: TestClient, db: Session):
|
||||
"""异常发票列表"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
client.post(f"{BASE}/invoices", headers=auth_header(token),
|
||||
json={"invoice_no": "INV005", "amount": 100, "supplier": ""})
|
||||
resp = client.get(f"{BASE}/invoices/abnormal", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert "data" in resp.json()
|
||||
|
||||
|
||||
class TestSocialSecurity:
|
||||
def test_crud(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.post(f"{BASE}/ss", headers=auth_header(token), json={
|
||||
"employee": "张三", "period": "2026-07", "base_amount": 5000,
|
||||
"company_amount": 1225, "salary": 6000,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
ss_id = resp.json()["data"]["id"]
|
||||
|
||||
list_resp = client.get(f"{BASE}/ss", headers=auth_header(token))
|
||||
assert list_resp.status_code == 200
|
||||
assert list_resp.json()["total"] == 1
|
||||
|
||||
upd = client.put(f"{BASE}/ss/{ss_id}", headers=auth_header(token),
|
||||
json={"base_amount": 6000})
|
||||
assert upd.status_code == 200
|
||||
|
||||
resp = client.delete(f"{BASE}/ss/{ss_id}", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_ss_check(self, client: TestClient, db: Session):
|
||||
"""社保比对"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
client.post(f"{BASE}/ss", headers=auth_header(token),
|
||||
json={"employee": "李四", "period": "2026-07",
|
||||
"base_amount": 5000, "company_amount": 1225})
|
||||
resp = client.post(f"{BASE}/ss/check", headers=auth_header(token), json={})
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_ss_abnormal(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.get(f"{BASE}/ss/abnormal", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
class TestDashboardDemo:
|
||||
def test_dashboard(self, client: TestClient, db: Session):
|
||||
"""税务驾驶舱"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
client.post(f"{BASE}/records", headers=auth_header(token),
|
||||
json={"period": "2026-07", "tax_type": "vat",
|
||||
"tax_paid": 13, "income": 100})
|
||||
resp = client.get(f"{BASE}/dashboard", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "burden" in data and "invoice" in data and "ss" in data
|
||||
|
||||
@pytest.mark.xfail(reason="SQLite DateTime 不接受字符串日期(生产MySQL可隐式转换);demo-data 为MySQL-only端点", strict=False)
|
||||
def test_demo_data(self, client: TestClient, db: Session):
|
||||
"""生成演示数据(MySQL-only:demo日期为字符串,SQLite DateTime严格模式不兼容)"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
resp = client.post(f"{BASE}/demo-data", headers=auth_header(token), json={})
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
class TestTaxPermissions:
|
||||
def test_business_write_denied(self, client: TestClient, db: Session):
|
||||
"""business用户写税务 → 403"""
|
||||
import hashlib
|
||||
from app.models import User
|
||||
db.add(User(username="tax_biz", password_hash=hashlib.sha256("p".encode()).hexdigest(),
|
||||
name="业务", role="business"))
|
||||
db.commit()
|
||||
token = get_token_for_user(client, username="tax_biz", password="p")
|
||||
resp = client.post(f"{BASE}/records", headers=auth_header(token),
|
||||
json={"period": "2026-07", "tax_type": "vat"})
|
||||
assert resp.status_code == 403
|
||||
@@ -107,7 +107,7 @@ class TestUsers:
|
||||
|
||||
# 验证可以用新密码登录
|
||||
login_resp = client.post("/api/cma/auth/login", json={
|
||||
"username": "passuser", "password": "newsecret",
|
||||
"username": "passuser", "password": "newsecret", "entity_id": 1,
|
||||
})
|
||||
assert login_resp.status_code == 200
|
||||
|
||||
|
||||
Reference in New Issue
Block a user