Files
cma-management/backend/tests/test_reports.py
T
Hermes CI Fix a13a080381 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
2026-08-20 06:57:24 +08:00

846 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""报表中心模块测试 — 管理利润表/预算执行/KPI趋势/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:
"""报表3KPI趋势报告"""
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):
"""空库 → 基准0has_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 → 403router级 require_role 直接拒绝)"""
resp = client.get(f"{self.BASE}/profit-summary")
assert resp.status_code == 403