feat: P0/P1/P2全部功能 — 四层泳道/视角切换/KPI看板/预警/差异反打/预算/知识面板/回顾会/情景预测/Excel导入/角色权限
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,2 @@
|
||||
import sys
|
||||
sys.path.insert(0, "/root/cma-management/backend")
|
||||
@@ -0,0 +1,230 @@
|
||||
"""
|
||||
改善行动计划模块测试
|
||||
"""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timedelta
|
||||
from tests.conftest import create_test_user, get_token_for_user, auth_header, create_test_kpi
|
||||
from app.models import ActionPlan
|
||||
|
||||
|
||||
def create_test_plan(db: Session, **kwargs) -> ActionPlan:
|
||||
"""创建测试行动计划"""
|
||||
defaults = {
|
||||
"kpi_id": 1,
|
||||
"title": "测试改善计划",
|
||||
"assignee": "张三",
|
||||
"priority": "medium",
|
||||
"status": "pending",
|
||||
"progress": 0,
|
||||
"created_by": "testadmin",
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
plan = ActionPlan(**defaults)
|
||||
db.add(plan)
|
||||
db.commit()
|
||||
db.refresh(plan)
|
||||
return plan
|
||||
|
||||
|
||||
class TestActionPlans:
|
||||
"""改善行动计划CRUD测试"""
|
||||
|
||||
def test_list_empty(self, client: TestClient, db: Session):
|
||||
"""空列表"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.get("/api/cma/action-plans", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["data"] == []
|
||||
assert data["total"] == 0
|
||||
|
||||
def test_create_plan(self, client: TestClient, db: Session):
|
||||
"""创建行动计划"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
|
||||
resp = client.post(
|
||||
"/api/cma/action-plans",
|
||||
headers=auth_header(token),
|
||||
json={
|
||||
"title": "提升营收10%",
|
||||
"kpi_id": kpi.id,
|
||||
"assignee": "张三",
|
||||
"priority": "high",
|
||||
"description": "通过增加销售渠道提升营收",
|
||||
"due_date": (datetime.now() + timedelta(days=30)).isoformat(),
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["title"] == "提升营收10%"
|
||||
assert data["kpi_id"] == kpi.id
|
||||
assert data["priority"] == "high"
|
||||
assert data["status"] == "pending"
|
||||
assert data["progress"] == 0
|
||||
|
||||
def test_create_plan_missing_required(self, client: TestClient, db: Session):
|
||||
"""缺少必填字段"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.post(
|
||||
"/api/cma/action-plans",
|
||||
headers=auth_header(token),
|
||||
json={"title": "不完整计划"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "缺少" in resp.json()["detail"]
|
||||
|
||||
def test_list_with_data(self, client: TestClient, db: Session):
|
||||
"""有数据时列表正常"""
|
||||
user = create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
create_test_plan(db, kpi_id=kpi.id, title="计划A")
|
||||
create_test_plan(db, kpi_id=kpi.id, title="计划B")
|
||||
|
||||
resp = client.get("/api/cma/action-plans", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 2
|
||||
assert len(data["data"]) == 2
|
||||
|
||||
def test_filter_by_status(self, client: TestClient, db: Session):
|
||||
"""按状态过滤"""
|
||||
user = create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
create_test_plan(db, kpi_id=kpi.id, title="待开始", status="pending")
|
||||
create_test_plan(db, kpi_id=kpi.id, title="进行中", status="in_progress")
|
||||
create_test_plan(db, kpi_id=kpi.id, title="已完成", status="completed")
|
||||
|
||||
resp = client.get("/api/cma/action-plans?status=in_progress", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["data"][0]["title"] == "进行中"
|
||||
|
||||
def test_filter_by_keyword(self, client: TestClient, db: Session):
|
||||
"""关键词搜索"""
|
||||
user = create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
create_test_plan(db, kpi_id=kpi.id, title="提升营收计划")
|
||||
create_test_plan(db, kpi_id=kpi.id, title="降低成本计划")
|
||||
|
||||
resp = client.get("/api/cma/action-plans?keyword=营收", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert "营收" in data["data"][0]["title"]
|
||||
|
||||
def test_update_plan(self, client: TestClient, db: Session):
|
||||
"""更新行动计划"""
|
||||
user = create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
plan = create_test_plan(db, kpi_id=kpi.id)
|
||||
|
||||
resp = client.put(
|
||||
f"/api/cma/action-plans/{plan.id}",
|
||||
headers=auth_header(token),
|
||||
json={
|
||||
"title": "已修改的计划",
|
||||
"status": "in_progress",
|
||||
"progress": 50,
|
||||
"priority": "high",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["title"] == "已修改的计划"
|
||||
assert data["status"] == "in_progress"
|
||||
assert data["progress"] == 50
|
||||
assert data["priority"] == "high"
|
||||
|
||||
def test_update_plan_not_found(self, client: TestClient, db: Session):
|
||||
"""更新不存在的计划"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.put(
|
||||
"/api/cma/action-plans/9999",
|
||||
headers=auth_header(token),
|
||||
json={"title": "不存在"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_update_plan_progress_clamp(self, client: TestClient, db: Session):
|
||||
"""进度值被限制在0-100"""
|
||||
user = create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
plan = create_test_plan(db, kpi_id=kpi.id)
|
||||
|
||||
resp = client.put(
|
||||
f"/api/cma/action-plans/{plan.id}",
|
||||
headers=auth_header(token),
|
||||
json={"progress": 150},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["progress"] == 100
|
||||
|
||||
resp = client.put(
|
||||
f"/api/cma/action-plans/{plan.id}",
|
||||
headers=auth_header(token),
|
||||
json={"progress": -10},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["progress"] == 0
|
||||
|
||||
def test_delete_plan(self, client: TestClient, db: Session):
|
||||
"""删除行动计划"""
|
||||
user = create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
plan = create_test_plan(db, kpi_id=kpi.id)
|
||||
|
||||
resp = client.delete(
|
||||
f"/api/cma/action-plans/{plan.id}",
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "已删除"
|
||||
|
||||
# 验证已删除
|
||||
get_resp = client.get("/api/cma/action-plans", headers=auth_header(token))
|
||||
assert get_resp.json()["total"] == 0
|
||||
|
||||
def test_delete_plan_not_found(self, client: TestClient, db: Session):
|
||||
"""删除不存在的计划"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.delete(
|
||||
"/api/cma/action-plans/9999",
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_stats(self, client: TestClient, db: Session):
|
||||
"""统计接口"""
|
||||
user = create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
create_test_plan(db, kpi_id=kpi.id, title="待开始", status="pending")
|
||||
create_test_plan(db, kpi_id=kpi.id, title="进行中", status="in_progress")
|
||||
create_test_plan(db, kpi_id=kpi.id, title="已完成", status="completed")
|
||||
|
||||
resp = client.get("/api/cma/action-plans/stats", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 3
|
||||
assert data["pending"] == 1
|
||||
assert data["in_progress"] == 1
|
||||
assert data["completed"] == 1
|
||||
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
AI分析引擎/CEO简报模块测试
|
||||
"""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
from tests.conftest import create_test_user, get_token_for_user, auth_header, create_test_kpi
|
||||
from app.models import KPIAlert, KPIValue, ActionPlan
|
||||
|
||||
|
||||
class TestAiAnalysis:
|
||||
"""AI分析/CEO简报测试"""
|
||||
|
||||
def test_brief_no_data(self, client: TestClient, db: Session):
|
||||
"""无数据时简报返回暂无数据"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.get("/api/cma/ai/brief", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["brief"]["conclusion"] == "暂无数据,无法生成简报"
|
||||
assert data["brief"]["concerns"] == []
|
||||
assert data["brief"]["actions"] == []
|
||||
|
||||
def test_brief_with_data(self, client: TestClient, db: Session):
|
||||
"""有KPI数据时简报正常生成(不调用AI,因为AI会超时但应正常返回)"""
|
||||
user = create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
kpi_val = KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=85.0)
|
||||
db.add(kpi_val)
|
||||
db.commit()
|
||||
|
||||
# 请求简报——由于没有真正的 DeepSeek API key,会返回错误文本但不会崩溃
|
||||
resp = client.get("/api/cma/ai/brief?timeout=5", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# 确保返回结构完整
|
||||
assert "brief" in data
|
||||
assert "generated_at" in data
|
||||
# 可能因为无实际API key而返回错误,但不会崩溃
|
||||
assert isinstance(data["brief"]["concerns"], list)
|
||||
assert isinstance(data["brief"]["actions"], list)
|
||||
|
||||
def test_dashboard_analysis_no_data(self, client: TestClient, db: Session):
|
||||
"""无数据时AI驾驶舱分析"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.get("/api/cma/ai/dashboard-analysis", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "analysis" in data
|
||||
assert data["kpi_count"] == 0
|
||||
assert data["alert_count"] == 0
|
||||
|
||||
def test_dashboard_analysis_with_kpi(self, client: TestClient, db: Session):
|
||||
"""有KPI数据时驾驶舱分析正常返回"""
|
||||
user = create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
kpi_val = KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=90.0)
|
||||
db.add(kpi_val)
|
||||
db.commit()
|
||||
|
||||
resp = client.get("/api/cma/ai/dashboard-analysis", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["kpi_count"] == 1
|
||||
|
||||
def test_kpi_analysis_not_found(self, client: TestClient, db: Session):
|
||||
"""分析不存在的KPI"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.get("/api/cma/ai/kpi-analysis/9999", headers=auth_header(token))
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_kpi_analysis_success(self, client: TestClient, db: Session):
|
||||
"""分析单个KPI"""
|
||||
user = create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
kpi_val = KPIValue(kpi_id=kpi.id, period="2026-04", actual_value=80.0)
|
||||
db.add(kpi_val)
|
||||
kpi_val2 = KPIValue(kpi_id=kpi.id, period="2026-05", actual_value=85.0)
|
||||
db.add(kpi_val2)
|
||||
kpi_val3 = KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=90.0)
|
||||
db.add(kpi_val3)
|
||||
db.commit()
|
||||
|
||||
resp = client.get(f"/api/cma/ai/kpi-analysis/{kpi.id}", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["kpi_name"] == "测试KPI"
|
||||
assert "analysis" in data
|
||||
|
||||
def test_review_plans_empty(self, client: TestClient, db: Session):
|
||||
"""无改善计划时的复盘"""
|
||||
user = create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
create_test_user(db, username="ceo_user", name="CEO用户", role="ceo")
|
||||
|
||||
resp = client.post("/api/cma/ai/review-plans", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert "暂无改善行动计划" in resp.json()["analysis"]
|
||||
|
||||
def test_review_plans_with_data(self, client: TestClient, db: Session):
|
||||
"""有改善计划时的复盘"""
|
||||
user = create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
plan = ActionPlan(
|
||||
alert_id=None,
|
||||
kpi_id=kpi.id,
|
||||
title="提升营收10%",
|
||||
assignee="张三",
|
||||
priority="high",
|
||||
status="in_progress",
|
||||
progress=50,
|
||||
created_by="系统",
|
||||
)
|
||||
db.add(plan)
|
||||
db.commit()
|
||||
|
||||
resp = client.post("/api/cma/ai/review-plans", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "analysis" in data
|
||||
assert data["stats"]["total"] == 1
|
||||
assert data["stats"]["in_progress"] == 1
|
||||
|
||||
def test_ask_empty_question(self, client: TestClient, db: Session):
|
||||
"""空问题被拒绝"""
|
||||
user = create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.post(
|
||||
"/api/cma/ai/ask",
|
||||
headers=auth_header(token),
|
||||
json={"question": ""},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
@@ -0,0 +1,274 @@
|
||||
"""
|
||||
预警闭环模块测试
|
||||
"""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timedelta
|
||||
from tests.conftest import create_test_user, get_token_for_user, auth_header, create_test_kpi
|
||||
from app.models import KPIAlert, ActionPlan
|
||||
|
||||
|
||||
def create_test_alert(db: Session, **kwargs) -> KPIAlert:
|
||||
"""创建测试预警"""
|
||||
defaults = {
|
||||
"kpi_id": 1,
|
||||
"alert_level": "yellow",
|
||||
"alert_message": "测试预警消息",
|
||||
"status": "pending",
|
||||
"assignee": None,
|
||||
"created_at": datetime.now(),
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
alert = KPIAlert(**defaults)
|
||||
db.add(alert)
|
||||
db.commit()
|
||||
db.refresh(alert)
|
||||
return alert
|
||||
|
||||
|
||||
class TestAlerts:
|
||||
"""预警闭环测试"""
|
||||
|
||||
def test_list_alerts_empty(self, client: TestClient, db: Session):
|
||||
"""空预警列表"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.get("/api/cma/alerts", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 0
|
||||
assert data["data"] == []
|
||||
|
||||
def test_list_alerts_with_data(self, client: TestClient, db: Session):
|
||||
"""有预警数据时列表正常"""
|
||||
user = create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
alert = create_test_alert(db, kpi_id=kpi.id, alert_message="营收下滑预警")
|
||||
|
||||
resp = client.get("/api/cma/alerts", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["data"][0]["alert_message"] == "营收下滑预警"
|
||||
|
||||
def test_list_alerts_filter_by_status(self, client: TestClient, db: Session):
|
||||
"""按状态过滤预警"""
|
||||
user = create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
create_test_alert(db, kpi_id=kpi.id, alert_message="黄色预警", status="pending")
|
||||
create_test_alert(db, kpi_id=kpi.id, alert_message="处理中", status="processing")
|
||||
create_test_alert(db, kpi_id=kpi.id, alert_message="已解决", status="resolved")
|
||||
|
||||
resp = client.get("/api/cma/alerts?status=pending", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["data"][0]["status"] == "pending"
|
||||
|
||||
def test_process_alert_success(self, client: TestClient, db: Session):
|
||||
"""标记预警为处理中"""
|
||||
user = create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
alert = create_test_alert(db, kpi_id=kpi.id)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/cma/alerts/{alert.id}/process",
|
||||
headers=auth_header(token),
|
||||
json={"assignee": "张三"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "已标记为处理中"
|
||||
assert resp.json()["alert"]["status"] == "processing"
|
||||
assert resp.json()["alert"]["assignee"] == "张三"
|
||||
|
||||
def test_process_alert_missing_assignee(self, client: TestClient, db: Session):
|
||||
"""缺少处理人参数"""
|
||||
user = create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
alert = create_test_alert(db, kpi_id=kpi.id)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/cma/alerts/{alert.id}/process",
|
||||
headers=auth_header(token),
|
||||
json={},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "缺少" in resp.json()["detail"]
|
||||
|
||||
def test_process_alert_not_found(self, client: TestClient, db: Session):
|
||||
"""处理不存在的预警"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.post(
|
||||
"/api/cma/alerts/9999/process",
|
||||
headers=auth_header(token),
|
||||
json={"assignee": "张三"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_process_resolved_alert_fails(self, client: TestClient, db: Session):
|
||||
"""已处理的预警不能重复处理"""
|
||||
user = create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
alert = create_test_alert(db, kpi_id=kpi.id, status="resolved")
|
||||
|
||||
resp = client.post(
|
||||
f"/api/cma/alerts/{alert.id}/process",
|
||||
headers=auth_header(token),
|
||||
json={"assignee": "张三"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "已处理" in resp.json()["detail"]
|
||||
|
||||
def test_resolve_alert(self, client: TestClient, db: Session):
|
||||
"""解决预警"""
|
||||
user = create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
alert = create_test_alert(db, kpi_id=kpi.id)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/cma/alerts/{alert.id}/resolve",
|
||||
headers=auth_header(token),
|
||||
json={"assignee": "李四", "resolution": "已采取措施改善"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["message"] == "已处理"
|
||||
assert data["alert"]["status"] == "resolved"
|
||||
assert data["suggest_create_action_plan"] is True
|
||||
|
||||
def test_resolve_alert_not_found(self, client: TestClient, db: Session):
|
||||
"""解决不存在的预警"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.post(
|
||||
"/api/cma/alerts/9999/resolve",
|
||||
headers=auth_header(token),
|
||||
json={},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_escalate_yellow_to_red(self, client: TestClient, db: Session):
|
||||
"""升级黄色预警为红色"""
|
||||
user = create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
alert = create_test_alert(db, kpi_id=kpi.id, alert_level="yellow")
|
||||
|
||||
resp = client.post(
|
||||
f"/api/cma/alerts/{alert.id}/escalate",
|
||||
headers=auth_header(token),
|
||||
json={"assignee": "上级主管"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "已升级"
|
||||
assert resp.json()["alert"]["alert_level"] == "red"
|
||||
|
||||
def test_escalate_red_stays_red(self, client: TestClient, db: Session):
|
||||
"""已经是红色的预警保持红色"""
|
||||
user = create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
alert = create_test_alert(db, kpi_id=kpi.id, alert_level="red")
|
||||
|
||||
resp = client.post(
|
||||
f"/api/cma/alerts/{alert.id}/escalate",
|
||||
headers=auth_header(token),
|
||||
json={"assignee": "上级主管"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["alert"]["alert_level"] == "red"
|
||||
|
||||
def test_escalate_missing_assignee(self, client: TestClient, db: Session):
|
||||
"""升级预警缺少处理人"""
|
||||
user = create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
alert = create_test_alert(db, kpi_id=kpi.id)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/cma/alerts/{alert.id}/escalate",
|
||||
headers=auth_header(token),
|
||||
json={},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_check_timeout(self, client: TestClient, db: Session):
|
||||
"""超时预警检测"""
|
||||
user = create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
# 创建超过24小时且仍为pending的预警
|
||||
old_time = datetime.now() - timedelta(hours=48)
|
||||
create_test_alert(db, kpi_id=kpi.id, created_at=old_time, alert_level="yellow")
|
||||
# 创建一个未超时的
|
||||
create_test_alert(db, kpi_id=kpi.id, alert_level="yellow")
|
||||
|
||||
resp = client.get("/api/cma/alerts/check-timeout", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total_timeout"] == 1
|
||||
assert data["upgraded_count"] == 1
|
||||
|
||||
def test_create_action_plan_from_alert(self, client: TestClient, db: Session):
|
||||
"""从预警创建改善行动计划"""
|
||||
user = create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
alert = create_test_alert(db, kpi_id=kpi.id, alert_level="red", alert_message="营收严重下滑")
|
||||
|
||||
resp = client.post(
|
||||
f"/api/cma/alerts/{alert.id}/create-action-plan",
|
||||
headers=auth_header(token),
|
||||
json={"assignee": "王五", "created_by": "系统"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["message"] == "改善行动计划已创建"
|
||||
assert data["plan_id"] is not None
|
||||
assert data["priority"] == "high" # red → high
|
||||
|
||||
def test_create_action_plan_duplicate(self, client: TestClient, db: Session):
|
||||
"""重复创建行动计划被拒绝"""
|
||||
user = create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
alert = create_test_alert(db, kpi_id=kpi.id, alert_message="测试预警")
|
||||
|
||||
# 第一次创建
|
||||
client.post(
|
||||
f"/api/cma/alerts/{alert.id}/create-action-plan",
|
||||
headers=auth_header(token),
|
||||
json={"assignee": "王五"},
|
||||
)
|
||||
|
||||
# 重复创建
|
||||
resp = client.post(
|
||||
f"/api/cma/alerts/{alert.id}/create-action-plan",
|
||||
headers=auth_header(token),
|
||||
json={"assignee": "王五"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "已关联" in resp.json()["detail"]
|
||||
|
||||
def test_create_action_plan_alert_not_found(self, client: TestClient, db: Session):
|
||||
"""基于不存在的预警创建行动计划"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.post(
|
||||
"/api/cma/alerts/9999/create-action-plan",
|
||||
headers=auth_header(token),
|
||||
json={"assignee": "王五"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
@@ -0,0 +1,208 @@
|
||||
"""预算管理模块测试 — 预算计划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
|
||||
|
||||
|
||||
class TestBudgetPlans:
|
||||
"""预算计划CRUD测试"""
|
||||
|
||||
BASE = "/api/cma/budget"
|
||||
|
||||
def test_list_plans_empty(self, client: TestClient, db: Session):
|
||||
"""空列表"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.get(f"{self.BASE}/plans", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["data"] == []
|
||||
|
||||
def test_create_plan(self, client: TestClient, db: Session):
|
||||
"""创建预算计划"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db, kpi_code="BUDGET_KPI_01")
|
||||
|
||||
resp = client.post(
|
||||
f"{self.BASE}/plans",
|
||||
headers=auth_header(token),
|
||||
json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 50000.0},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "预算已创建"
|
||||
assert resp.json()["id"] > 0
|
||||
|
||||
def test_create_plan_duplicate_upsert(self, client: TestClient, db: Session):
|
||||
"""重复创建同一KPI+期间 → 更新而非新增"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db, kpi_code="BUDGET_KPI_02")
|
||||
|
||||
# 创建
|
||||
client.post(
|
||||
f"{self.BASE}/plans",
|
||||
headers=auth_header(token),
|
||||
json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 30000.0},
|
||||
)
|
||||
# 再次创建(更新)
|
||||
resp = client.post(
|
||||
f"{self.BASE}/plans",
|
||||
headers=auth_header(token),
|
||||
json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 35000.0},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "预算已更新"
|
||||
|
||||
def test_create_plan_missing_fields(self, client: TestClient, db: Session):
|
||||
"""缺少必要参数被拒绝"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.post(
|
||||
f"{self.BASE}/plans",
|
||||
headers=auth_header(token),
|
||||
json={"kpi_id": 1}, # 缺少 period 和 budget_value
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_update_plan(self, client: TestClient, db: Session):
|
||||
"""更新预算计划"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db, kpi_code="BUDGET_KPI_03")
|
||||
|
||||
create_resp = client.post(
|
||||
f"{self.BASE}/plans",
|
||||
headers=auth_header(token),
|
||||
json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 100.0},
|
||||
)
|
||||
plan_id = create_resp.json()["id"]
|
||||
|
||||
resp = client.put(
|
||||
f"{self.BASE}/plans/{plan_id}",
|
||||
headers=auth_header(token),
|
||||
json={"budget_value": 200.0, "remark": "已更新"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_delete_plan(self, client: TestClient, db: Session):
|
||||
"""删除预算计划"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db, kpi_code="BUDGET_KPI_04")
|
||||
|
||||
create_resp = client.post(
|
||||
f"{self.BASE}/plans",
|
||||
headers=auth_header(token),
|
||||
json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 100.0},
|
||||
)
|
||||
plan_id = create_resp.json()["id"]
|
||||
|
||||
resp = client.delete(f"{self.BASE}/plans/{plan_id}", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "预算已删除"
|
||||
|
||||
# 验证已删除
|
||||
get_resp = client.get(f"{self.BASE}/plans", headers=auth_header(token))
|
||||
ids = [p["id"] for p in get_resp.json()["data"]]
|
||||
assert plan_id not in ids
|
||||
|
||||
|
||||
class TestBudgetAutoDecompose:
|
||||
"""预算自动分解测试"""
|
||||
|
||||
BASE = "/api/cma/budget"
|
||||
|
||||
def test_auto_decompose_single_kpi(self, client: TestClient, db: Session):
|
||||
"""单KPI自动分解年度预算为月度(均分)"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db, kpi_code="BUDGET_DECOMPOSE")
|
||||
|
||||
resp = client.post(
|
||||
f"{self.BASE}/auto-decompose",
|
||||
headers=auth_header(token),
|
||||
json={"kpi_id": kpi.id, "annual_budget": 120000, "year": 2026, "method": "equal"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "已分解" in data["message"]
|
||||
assert len(data["results"]) == 1
|
||||
# 年度预算120000,12个月均分,每月10000
|
||||
monthly = data["results"][0]["monthly"]
|
||||
assert len(monthly) == 12
|
||||
assert monthly[0]["value"] == 10000.0
|
||||
|
||||
def test_auto_decompose_missing(self, client: TestClient, db: Session):
|
||||
"""没有年度预算数据时尝试分解 → 400"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
create_test_kpi(db, kpi_code="BUDGET_NO_DATA")
|
||||
|
||||
resp = client.post(
|
||||
f"{self.BASE}/auto-decompose",
|
||||
headers=auth_header(token),
|
||||
json={"year": 2026, "method": "equal"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
class TestBudgetVersions:
|
||||
"""预算版本管理测试"""
|
||||
|
||||
BASE = "/api/cma/budget"
|
||||
|
||||
def test_create_and_submit_version(self, client: TestClient, db: Session):
|
||||
"""创建预算后查询版本并提交"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db, kpi_code="BUDGET_VER_KPI")
|
||||
|
||||
# 创建一条预算
|
||||
client.post(
|
||||
f"{self.BASE}/plans",
|
||||
headers=auth_header(token),
|
||||
json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 10000.0},
|
||||
)
|
||||
|
||||
# 查询版本
|
||||
ver_resp = client.get(f"{self.BASE}/versions", headers=auth_header(token))
|
||||
assert ver_resp.status_code == 200
|
||||
assert len(ver_resp.json()["data"]) >= 1
|
||||
|
||||
# 提交版本
|
||||
submit_resp = client.post(
|
||||
f"{self.BASE}/versions/submit",
|
||||
headers=auth_header(token),
|
||||
json={"version": "v1.0"},
|
||||
)
|
||||
assert submit_resp.status_code == 200
|
||||
assert "已提交审批" in submit_resp.json()["message"]
|
||||
|
||||
def test_approve_version(self, client: TestClient, db: Session):
|
||||
"""审批通过版本"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.post(
|
||||
f"{self.BASE}/versions/approve",
|
||||
headers=auth_header(token),
|
||||
json={"version": "v1.0", "action": "approved"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "已批准" in resp.json()["message"]
|
||||
|
||||
def test_reject_version(self, client: TestClient, db: Session):
|
||||
"""驳回版本"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.post(
|
||||
f"{self.BASE}/versions/approve",
|
||||
headers=auth_header(token),
|
||||
json={"version": "v2.0", "action": "rejected"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "已驳回" in resp.json()["message"]
|
||||
@@ -0,0 +1,204 @@
|
||||
"""成本分析模块测试 — 标准成本/实际成本CRUD + ABC + 看板"""
|
||||
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 StandardCost, ActualCost, AbcActivity
|
||||
|
||||
|
||||
class TestStandardCost:
|
||||
"""标准成本卡片CRUD测试"""
|
||||
|
||||
BASE = "/api/cma/cost"
|
||||
|
||||
def test_list_standard_costs_empty(self, client: TestClient, db: Session):
|
||||
"""空列表"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.get(f"{self.BASE}/standard-costs", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["data"] == []
|
||||
|
||||
def test_create_standard_cost(self, client: TestClient, db: Session):
|
||||
"""创建标准成本卡片"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.post(
|
||||
f"{self.BASE}/standard-costs",
|
||||
headers=auth_header(token),
|
||||
json={
|
||||
"product_code": "PROD_A",
|
||||
"product_name": "产品A",
|
||||
"cost_type": "material",
|
||||
"item_name": "原材料X",
|
||||
"standard_quantity": 10.0,
|
||||
"standard_price": 5.0,
|
||||
"unit": "kg",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "标准成本已创建"
|
||||
|
||||
# 验证列表中有数据
|
||||
list_resp = client.get(f"{self.BASE}/standard-costs", headers=auth_header(token))
|
||||
assert len(list_resp.json()["data"]) == 1
|
||||
|
||||
def test_update_standard_cost(self, client: TestClient, db: Session):
|
||||
"""修改标准成本卡片"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
sc = StandardCost(
|
||||
product_code="PROD_B", product_name="产品B", cost_type="labor",
|
||||
item_name="人工", standard_quantity=100, unit="小时",
|
||||
standard_price=50.0, standard_cost=5000.0,
|
||||
)
|
||||
db.add(sc)
|
||||
db.commit()
|
||||
|
||||
resp = client.put(
|
||||
f"{self.BASE}/standard-costs/{sc.id}",
|
||||
headers=auth_header(token),
|
||||
json={"standard_price": 55.0, "remark": "调薪后更新"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "已更新"
|
||||
|
||||
def test_delete_standard_cost(self, client: TestClient, db: Session):
|
||||
"""删除(归档)标准成本卡片"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
sc = StandardCost(
|
||||
product_code="PROD_C", product_name="产品C", cost_type="overhead",
|
||||
item_name="水电费", standard_quantity=1, unit="项",
|
||||
standard_price=1000.0, standard_cost=1000.0,
|
||||
)
|
||||
db.add(sc)
|
||||
db.commit()
|
||||
|
||||
resp = client.delete(
|
||||
f"{self.BASE}/standard-costs/{sc.id}",
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "已归档"
|
||||
|
||||
|
||||
class TestActualCost:
|
||||
"""实际成本CRUD测试"""
|
||||
|
||||
BASE = "/api/cma/cost"
|
||||
|
||||
def test_create_actual_cost(self, client: TestClient, db: Session):
|
||||
"""录入实际成本"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.post(
|
||||
f"{self.BASE}/actual-costs",
|
||||
headers=auth_header(token),
|
||||
json={
|
||||
"period": "2026-06",
|
||||
"product_code": "PROD_A",
|
||||
"product_name": "产品A",
|
||||
"cost_type": "material",
|
||||
"item_name": "原材料X",
|
||||
"actual_quantity": 12.0,
|
||||
"actual_price": 4.8,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "实际成本已录入"
|
||||
|
||||
def test_list_actual_costs(self, client: TestClient, db: Session):
|
||||
"""查询实际成本"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
ac = ActualCost(
|
||||
period="2026-06", product_code="PROD_A", product_name="产品A",
|
||||
cost_type="material", item_name="原材料X",
|
||||
actual_quantity=10, actual_price=5.0, actual_cost=50.0,
|
||||
)
|
||||
db.add(ac)
|
||||
db.commit()
|
||||
|
||||
resp = client.get(f"{self.BASE}/actual-costs?period=2026-06", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()["data"]) == 1
|
||||
|
||||
|
||||
class TestAbcCost:
|
||||
"""ABC作业成本测试"""
|
||||
|
||||
BASE = "/api/cma/cost"
|
||||
|
||||
def test_create_abc_activity(self, client: TestClient, db: Session):
|
||||
"""创建ABC作业中心"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.post(
|
||||
f"{self.BASE}/abc/activities",
|
||||
headers=auth_header(token),
|
||||
json={
|
||||
"activity_code": "ACT_001",
|
||||
"activity_name": "机器调试",
|
||||
"cost_driver": "调试次数",
|
||||
"driver_unit": "次",
|
||||
"total_cost": 50000.0,
|
||||
"driver_volume": 100,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "作业中心已创建"
|
||||
|
||||
def test_list_abc_activities(self, client: TestClient, db: Session):
|
||||
"""查询ABC作业中心列表"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
act = AbcActivity(
|
||||
activity_code="ACT_002", activity_name="品质检验",
|
||||
cost_driver="检验批次", driver_unit="批",
|
||||
total_cost=30000.0, driver_volume=200, driver_rate=150.0,
|
||||
)
|
||||
db.add(act)
|
||||
db.commit()
|
||||
|
||||
resp = client.get(f"{self.BASE}/abc/activities", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()["data"]) >= 1
|
||||
assert resp.json()["data"][0]["activity_code"] == "ACT_002"
|
||||
|
||||
def test_abc_allocate(self, client: TestClient, db: Session):
|
||||
"""执行ABC成本分配"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
act = AbcActivity(
|
||||
activity_code="ACT_003", activity_name="订单处理",
|
||||
cost_driver="订单数", driver_unit="单",
|
||||
total_cost=20000.0, driver_volume=400, driver_rate=50.0,
|
||||
)
|
||||
db.add(act)
|
||||
db.commit()
|
||||
|
||||
resp = client.post(
|
||||
f"{self.BASE}/abc/allocate",
|
||||
headers=auth_header(token),
|
||||
json={
|
||||
"activity_id": act.id,
|
||||
"period": "2026-06",
|
||||
"product_code": "PROD_A",
|
||||
"product_name": "产品A",
|
||||
"driver_consumed": 30,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# 30 * 50 = 1500
|
||||
assert data["allocated_cost"] == 1500.0
|
||||
@@ -0,0 +1,446 @@
|
||||
"""
|
||||
驾驶舱模块测试
|
||||
"""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
from tests.conftest import create_test_user, get_token_for_user, auth_header, create_test_kpi
|
||||
from app.models import KPIAlert, KPIValue
|
||||
|
||||
|
||||
class TestDashboard:
|
||||
"""驾驶舱核心接口测试"""
|
||||
|
||||
def test_summary_empty(self, client: TestClient, db: Session):
|
||||
"""空系统时的驾驶舱摘要"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.get("/api/cma/dashboard/summary", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["kpi_total"] == 0
|
||||
assert data["alert_count"] == 0
|
||||
assert data["dimension_stats"] == []
|
||||
|
||||
def test_summary_with_data(self, client: TestClient, db: Session):
|
||||
"""有数据时的驾驶舱摘要"""
|
||||
user = create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db, kpi_code="F_REVENUE", dimension="finance")
|
||||
kpi2 = create_test_kpi(db, kpi_code="C_SATISFACTION", kpi_name="客户满意度", dimension="customer")
|
||||
alert = KPIAlert(kpi_id=kpi.id, alert_level="red", alert_message="营收预警", status="pending")
|
||||
db.add(alert)
|
||||
db.commit()
|
||||
|
||||
resp = client.get("/api/cma/dashboard/summary", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["kpi_total"] == 2
|
||||
assert data["alert_count"] == 1
|
||||
dims = {d["dimension"]: d["count"] for d in data["dimension_stats"]}
|
||||
assert dims.get("finance") == 1
|
||||
assert dims.get("customer") == 1
|
||||
|
||||
def test_kpis_empty(self, client: TestClient, db: Session):
|
||||
"""无KPI时驾驶舱KPI列表"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.get("/api/cma/dashboard/kpis", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["data"] == []
|
||||
|
||||
def test_kpis_with_data(self, client: TestClient, db: Session):
|
||||
"""有KPI时驾驶舱KPI列表"""
|
||||
user = create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db, target_value=100.0)
|
||||
kpi_val = KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=85.0)
|
||||
db.add(kpi_val)
|
||||
db.commit()
|
||||
|
||||
resp = client.get("/api/cma/dashboard/kpis", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["data"]) == 1
|
||||
assert data["data"][0]["kpi_name"] == "测试KPI"
|
||||
assert data["data"][0]["actual_value"] == 85.0
|
||||
assert data["data"][0]["target_value"] == 100.0
|
||||
|
||||
def test_kpis_with_alert(self, client: TestClient, db: Session):
|
||||
"""KPI列表显示预警状态"""
|
||||
user = create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
kpi_val = KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=50.0)
|
||||
db.add(kpi_val)
|
||||
alert = KPIAlert(kpi_id=kpi.id, alert_level="red", alert_message="严重偏离目标", status="pending")
|
||||
db.add(alert)
|
||||
db.commit()
|
||||
|
||||
resp = client.get("/api/cma/dashboard/kpis", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["data"]) == 1
|
||||
assert data["data"][0]["alert_level"] == "red"
|
||||
assert data["data"][0]["alert_message"] == "严重偏离目标"
|
||||
|
||||
def test_my_kpis(self, client: TestClient, db: Session):
|
||||
"""我的KPI接口"""
|
||||
user = create_test_user(db, username="biz_user", name="业务经理", role="business")
|
||||
token = get_token_for_user(client, username="biz_user", password="admin123")
|
||||
kpi = create_test_kpi(db, responsible_user="biz_user")
|
||||
kpi2 = create_test_kpi(db, kpi_code="F_OTHER", kpi_name="无关KPI", responsible_user="其他人")
|
||||
|
||||
resp = client.get("/api/cma/dashboard/my-kpis", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# business角色只看到自己的KPI
|
||||
for k in data["data"]:
|
||||
assert k["responsible_user"] == "biz_user"
|
||||
|
||||
def test_my_kpis_ceo_sees_all(self, client: TestClient, db: Session):
|
||||
"""CEO角色的my-kpis看到所有有预警的KPI"""
|
||||
user = create_test_user(db, username="ceo_user", name="CEO", role="ceo")
|
||||
token = get_token_for_user(client, username="ceo_user", password="admin123")
|
||||
kpi = create_test_kpi(db, kpi_code="F_KPI_A", responsible_user="张三")
|
||||
|
||||
resp = client.get("/api/cma/dashboard/my-kpis", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["user_role"] == "ceo"
|
||||
|
||||
def test_alert_stats_empty(self, client: TestClient, db: Session):
|
||||
"""无预警时的预警统计"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.get("/api/cma/dashboard/alert-stats", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total_pending"] == 0
|
||||
assert data["by_severity"] == {"red": 0, "yellow": 0, "green": 0}
|
||||
assert data["by_dimension"] == []
|
||||
|
||||
def test_alert_stats_with_data(self, client: TestClient, db: Session):
|
||||
"""有预警时的预警统计"""
|
||||
user = create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db, dimension="finance")
|
||||
kpi2 = create_test_kpi(db, kpi_code="C_CODE", kpi_name="客户KPI", dimension="customer")
|
||||
alert1 = KPIAlert(kpi_id=kpi.id, alert_level="red", alert_message="红色预警", status="pending")
|
||||
alert2 = KPIAlert(kpi_id=kpi.id, alert_level="yellow", alert_message="黄色预警", status="pending")
|
||||
alert3 = KPIAlert(kpi_id=kpi2.id, alert_level="yellow", alert_message="客户预警", status="pending")
|
||||
db.add_all([alert1, alert2, alert3])
|
||||
db.commit()
|
||||
|
||||
resp = client.get("/api/cma/dashboard/alert-stats", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total_pending"] == 3
|
||||
assert data["by_severity"].get("red") == 1
|
||||
assert data["by_severity"].get("yellow") == 2
|
||||
assert len(data["by_dimension"]) == 2
|
||||
dim_dict = {d["dimension"]: d["count"] for d in data["by_dimension"]}
|
||||
assert dim_dict.get("finance") == 2
|
||||
assert dim_dict.get("customer") == 1
|
||||
|
||||
def test_my_dashboard(self, client: TestClient, db: Session):
|
||||
"""个人工作台接口"""
|
||||
user = create_test_user(db, username="ceo_user", name="CEO", role="ceo")
|
||||
token = get_token_for_user(client, username="ceo_user", password="admin123")
|
||||
kpi = create_test_kpi(db)
|
||||
|
||||
resp = client.get("/api/cma/dashboard/my-dashboard", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "kpis" in data
|
||||
assert "action_plans" in data
|
||||
assert "reminders" in data
|
||||
|
||||
def test_predict_empty(self, client: TestClient, db: Session):
|
||||
"""无数据时预测返回空列表"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.get("/api/cma/dashboard/predict", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["predictions"] == []
|
||||
|
||||
|
||||
# ── Epic 2 新增接口测试 ──────────────────────────
|
||||
|
||||
class TestTrendAnalysisPost:
|
||||
"""POST /api/cma/dashboard/trend-analysis"""
|
||||
|
||||
def test_trend_analysis_with_kpi_ids(self, client: TestClient, db: Session):
|
||||
"""指定KPI ID进行趋势分析"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
db.add(KPIValue(kpi_id=kpi.id, period="2026-05", actual_value=80.0))
|
||||
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=85.0))
|
||||
db.commit()
|
||||
|
||||
resp = client.post("/api/cma/dashboard/trend-analysis",
|
||||
headers=auth_header(token),
|
||||
json={"kpi_ids": [kpi.id], "period_type": "month", "compare_type": "mom"})
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) > 0
|
||||
|
||||
def test_trend_analysis_no_kpi_ids(self, client: TestClient, db: Session):
|
||||
"""不传KPI ID时默认取所有活跃KPI"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=85.0))
|
||||
db.commit()
|
||||
|
||||
resp = client.post("/api/cma/dashboard/trend-analysis",
|
||||
headers=auth_header(token),
|
||||
json={"period_type": "month", "compare_type": "mom"})
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) >= 1
|
||||
|
||||
def test_trend_analysis_inactive_kpi(self, client: TestClient, db: Session):
|
||||
"""指定不存在的KPI ID时返回空"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
create_test_kpi(db)
|
||||
|
||||
resp = client.post("/api/cma/dashboard/trend-analysis",
|
||||
headers=auth_header(token),
|
||||
json={"kpi_ids": [9999], "period_type": "month", "compare_type": "mom"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json().get("data") == []
|
||||
|
||||
def test_trend_analysis_unauthorized(self, client: TestClient, db: Session):
|
||||
"""未认证无法访问"""
|
||||
resp = client.post("/api/cma/dashboard/trend-analysis", json={})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
class TestGetTrendAnalysis:
|
||||
"""GET /api/cma/dashboard/trend-analysis"""
|
||||
|
||||
def test_get_trend_with_ids(self, client: TestClient, db: Session):
|
||||
"""指定KPI查询趋势对比数据"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
db.add(KPIValue(kpi_id=kpi.id, period="2026-04", actual_value=70.0))
|
||||
db.add(KPIValue(kpi_id=kpi.id, period="2026-05", actual_value=80.0))
|
||||
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=85.0))
|
||||
db.commit()
|
||||
|
||||
resp = client.get(f"/api/cma/dashboard/trend-analysis?kpi_ids={kpi.id}",
|
||||
headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["data"]) == 1
|
||||
assert data["data"][0]["kpi_name"] == "测试KPI"
|
||||
assert len(data["data"][0]["data"]) == 3
|
||||
|
||||
def test_get_trend_no_ids(self, client: TestClient, db: Session):
|
||||
"""不传KPI ID返回空"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.get("/api/cma/dashboard/trend-analysis",
|
||||
headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["data"] == []
|
||||
|
||||
|
||||
class TestPredictWithData:
|
||||
"""GET /api/cma/dashboard/predict (有数据)"""
|
||||
|
||||
def test_predict_with_enough_data(self, client: TestClient, db: Session):
|
||||
"""有足够数据点(>=3)时进行预测"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
for i, val in enumerate([60.0, 65.0, 70.0, 75.0]):
|
||||
db.add(KPIValue(kpi_id=kpi.id, period=f"2026-{3+i:02d}", actual_value=val))
|
||||
db.commit()
|
||||
|
||||
resp = client.get("/api/cma/dashboard/predict", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["predictions"]) >= 1
|
||||
assert data["predictable_count"] >= 1
|
||||
|
||||
|
||||
class TestKpisEnhanced:
|
||||
"""GET /api/cma/dashboard/kpis/enhanced"""
|
||||
|
||||
def test_enhanced_with_data(self, client: TestClient, db: Session):
|
||||
"""增强版KPI列表"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
db.add(KPIValue(kpi_id=kpi.id, period="2026-05", actual_value=80.0))
|
||||
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=85.0))
|
||||
db.commit()
|
||||
|
||||
resp = client.get("/api/cma/dashboard/kpis/enhanced", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["data"]) == 1
|
||||
assert data["data"][0]["kpi_name"] == "测试KPI"
|
||||
|
||||
def test_enhanced_empty(self, client: TestClient, db: Session):
|
||||
"""无KPI时返回空"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.get("/api/cma/dashboard/kpis/enhanced", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["data"] == []
|
||||
|
||||
|
||||
class TestKpiTrend:
|
||||
"""GET /api/cma/dashboard/kpi-trend"""
|
||||
|
||||
def test_kpi_trend_with_ids(self, client: TestClient, db: Session):
|
||||
"""KPI趋势分析详细版"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
db.add(KPIValue(kpi_id=kpi.id, period="2026-04", actual_value=70.0))
|
||||
db.add(KPIValue(kpi_id=kpi.id, period="2026-05", actual_value=80.0))
|
||||
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=85.0))
|
||||
db.commit()
|
||||
|
||||
resp = client.get(f"/api/cma/dashboard/kpi-trend?kpi_ids={kpi.id}",
|
||||
headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["kpis"]) == 1
|
||||
assert len(data["kpis"][0]["periods"]) == 3
|
||||
assert "summary" in data
|
||||
|
||||
def test_kpi_trend_no_ids(self, client: TestClient, db: Session):
|
||||
"""不传ID时默认取前5个活跃KPI"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=85.0))
|
||||
db.commit()
|
||||
|
||||
resp = client.get("/api/cma/dashboard/kpi-trend", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()["kpis"]) >= 1
|
||||
|
||||
|
||||
class TestKpiComparison:
|
||||
"""GET /api/cma/dashboard/kpi-comparison"""
|
||||
|
||||
def test_kpi_comparison(self, client: TestClient, db: Session):
|
||||
"""KPI多区间对比"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
db.add(KPIValue(kpi_id=kpi.id, period="2026-05", actual_value=80.0))
|
||||
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=85.0))
|
||||
db.commit()
|
||||
|
||||
resp = client.get(f"/api/cma/dashboard/kpi-comparison?kpi_id={kpi.id}",
|
||||
headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["kpi"]["kpi_name"] == "测试KPI"
|
||||
assert "comparisons" in data
|
||||
|
||||
def test_kpi_comparison_missing_id(self, client: TestClient, db: Session):
|
||||
"""缺少必填kpi_id参数"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.get("/api/cma/dashboard/kpi-comparison",
|
||||
headers=auth_header(token))
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_kpi_comparison_not_found(self, client: TestClient, db: Session):
|
||||
"""不存在的KPI ID"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.get("/api/cma/dashboard/kpi-comparison?kpi_id=9999",
|
||||
headers=auth_header(token))
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestAlertTrend:
|
||||
"""GET /api/cma/dashboard/alert-trend"""
|
||||
|
||||
def test_alert_trend_empty(self, client: TestClient, db: Session):
|
||||
"""无预警时的趋势"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.get("/api/cma/dashboard/alert-trend?days=30",
|
||||
headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "daily_alerts" in data
|
||||
assert "summary" in data
|
||||
|
||||
def test_alert_trend_with_data(self, client: TestClient, db: Session):
|
||||
"""有预警时的按天分布"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
db.add(KPIAlert(kpi_id=kpi.id, alert_level="red", alert_message="测试",
|
||||
status="pending", created_at=datetime.now()))
|
||||
db.commit()
|
||||
|
||||
resp = client.get("/api/cma/dashboard/alert-trend?days=30",
|
||||
headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "daily_alerts" in data
|
||||
|
||||
|
||||
class TestExport:
|
||||
"""GET /api/cma/dashboard/export"""
|
||||
|
||||
def test_export_csv(self, client: TestClient, db: Session):
|
||||
"""导出CSV文件"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=85.0))
|
||||
db.commit()
|
||||
|
||||
resp = client.get("/api/cma/dashboard/export", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
ct = resp.headers.get("content-type", "")
|
||||
assert "csv" in ct or "text" in ct or "plain" in ct
|
||||
|
||||
def test_export_with_kpi_ids(self, client: TestClient, db: Session):
|
||||
"""带KPI ID过滤的导出"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=85.0))
|
||||
db.commit()
|
||||
|
||||
resp = client.get(f"/api/cma/dashboard/export?kpi_ids={kpi.id}",
|
||||
headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
assert "TEST_001" in body or "测试KPI" in body
|
||||
|
||||
def test_export_empty(self, client: TestClient, db: Session):
|
||||
"""无KPI时的导出"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.get("/api/cma/dashboard/export", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert "KPI编码" in resp.text
|
||||
@@ -0,0 +1,173 @@
|
||||
"""数据管理模块测试 — Excel导入/导出/数据源CRUD"""
|
||||
import io
|
||||
import pandas as pd
|
||||
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
|
||||
|
||||
|
||||
class TestDataSources:
|
||||
"""数据源CRUD测试"""
|
||||
|
||||
DATA_ENDPOINT = "/api/cma/data"
|
||||
|
||||
def test_list_sources_empty(self, client: TestClient, db: Session):
|
||||
"""空列表"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.get(f"{self.DATA_ENDPOINT}/sources", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["data"] == []
|
||||
|
||||
def test_create_source(self, client: TestClient, db: Session):
|
||||
"""创建数据源"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.post(
|
||||
f"{self.DATA_ENDPOINT}/sources",
|
||||
headers=auth_header(token),
|
||||
json={"name": "ERP数据源", "source_type": "erp", "api_endpoint": "http://erp.example.com"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()["data"]
|
||||
assert data["name"] == "ERP数据源"
|
||||
assert data["source_type"] == "erp"
|
||||
assert data["status"] == "active"
|
||||
|
||||
def test_update_source(self, client: TestClient, db: Session):
|
||||
"""更新数据源"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
# 先创建
|
||||
create_resp = client.post(
|
||||
f"{self.DATA_ENDPOINT}/sources",
|
||||
headers=auth_header(token),
|
||||
json={"name": "旧名称", "source_type": "manual"},
|
||||
)
|
||||
source_id = create_resp.json()["data"]["id"]
|
||||
|
||||
# 更新
|
||||
resp = client.put(
|
||||
f"{self.DATA_ENDPOINT}/sources/{source_id}",
|
||||
headers=auth_header(token),
|
||||
json={"name": "新名称"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["data"]["name"] == "新名称"
|
||||
|
||||
def test_delete_source(self, client: TestClient, db: Session):
|
||||
"""删除数据源"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
create_resp = client.post(
|
||||
f"{self.DATA_ENDPOINT}/sources",
|
||||
headers=auth_header(token),
|
||||
json={"name": "待删除", "source_type": "manual"},
|
||||
)
|
||||
source_id = create_resp.json()["data"]["id"]
|
||||
|
||||
resp = client.delete(
|
||||
f"{self.DATA_ENDPOINT}/sources/{source_id}",
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "删除成功"
|
||||
|
||||
def test_delete_source_not_found(self, client: TestClient, db: Session):
|
||||
"""删除不存在的源"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.delete(
|
||||
f"{self.DATA_ENDPOINT}/sources/99999",
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestDataImport:
|
||||
"""Excel导入测试"""
|
||||
|
||||
def test_import_excel_success(self, client: TestClient, db: Session):
|
||||
"""导入Excel数据"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
# 先创建KPI
|
||||
create_test_kpi(db, kpi_code="F_REVENUE_IMP", kpi_name="导入测试KPI")
|
||||
|
||||
# 构造Excel文件
|
||||
df = pd.DataFrame({
|
||||
"kpi_code": ["F_REVENUE_IMP", "F_REVENUE_IMP"],
|
||||
"period": ["2026-06", "2026-07"],
|
||||
"actual_value": [100.0, 200.0],
|
||||
})
|
||||
buffer = io.BytesIO()
|
||||
df.to_excel(buffer, index=False)
|
||||
buffer.seek(0)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/cma/data/import-excel",
|
||||
headers=auth_header(token),
|
||||
files={"file": ("test.xlsx", buffer, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["message"] == "导入成功 2 条数据"
|
||||
assert "batch" in data
|
||||
|
||||
def test_import_excel_missing_columns(self, client: TestClient, db: Session):
|
||||
"""导入缺少必要列的Excel"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
df = pd.DataFrame({"name": ["test"], "value": [100]})
|
||||
buffer = io.BytesIO()
|
||||
df.to_excel(buffer, index=False)
|
||||
buffer.seek(0)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/cma/data/import-excel",
|
||||
headers=auth_header(token),
|
||||
files={"file": ("bad.xlsx", buffer, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_import_excel_no_match_kpi(self, client: TestClient, db: Session):
|
||||
"""导入时KPI不存在 → 跳过(不报错)"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
df = pd.DataFrame({
|
||||
"kpi_code": ["NON_EXISTENT_KPI"],
|
||||
"period": ["2026-06"],
|
||||
"actual_value": [999.0],
|
||||
})
|
||||
buffer = io.BytesIO()
|
||||
df.to_excel(buffer, index=False)
|
||||
buffer.seek(0)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/cma/data/import-excel",
|
||||
headers=auth_header(token),
|
||||
files={"file": ("test.xlsx", buffer, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "导入成功 0 条数据"
|
||||
|
||||
def test_import_template_download(self, client: TestClient, db: Session):
|
||||
"""下载导入模板"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.get(
|
||||
f"/api/cma/data/import-template",
|
||||
headers=auth_header(token),
|
||||
)
|
||||
# 如果模板文件存在则返回200,否则404(两种情况都算合理)
|
||||
assert resp.status_code in (200, 404)
|
||||
@@ -0,0 +1,183 @@
|
||||
"""通知配置模块测试 — 渠道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
|
||||
|
||||
|
||||
class TestNotificationChannels:
|
||||
"""通知渠道CRUD测试"""
|
||||
|
||||
BASE = "/api/cma/notifications"
|
||||
|
||||
def test_list_channels_empty(self, client: TestClient, db: Session):
|
||||
"""空列表"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.get(f"{self.BASE}/channels", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["data"] == []
|
||||
|
||||
def test_create_wecom_channel(self, client: TestClient, db: Session):
|
||||
"""创建企微机器人渠道"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.post(
|
||||
f"{self.BASE}/channels",
|
||||
headers=auth_header(token),
|
||||
json={
|
||||
"name": "告警群",
|
||||
"channel_type": "wecom",
|
||||
"config": {"webhook_url": "https://qyapi.weixin.qq.com/webhook/abc"},
|
||||
"enabled": True,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["name"] == "告警群"
|
||||
assert data["channel_type"] == "wecom"
|
||||
assert data["enabled"] is True
|
||||
|
||||
def test_create_mail_channel(self, client: TestClient, db: Session):
|
||||
"""创建邮件渠道"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.post(
|
||||
f"{self.BASE}/channels",
|
||||
headers=auth_header(token),
|
||||
json={
|
||||
"name": "管理邮箱",
|
||||
"channel_type": "mail",
|
||||
"config": {
|
||||
"host": "smtp.qq.com",
|
||||
"port": 465,
|
||||
"user": "admin@example.com",
|
||||
"password": "pass",
|
||||
"to": ["admin@example.com"],
|
||||
},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["channel_type"] == "mail"
|
||||
|
||||
def test_update_channel(self, client: TestClient, db: Session):
|
||||
"""更新渠道"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
create_resp = client.post(
|
||||
f"{self.BASE}/channels",
|
||||
headers=auth_header(token),
|
||||
json={"name": "旧名称", "channel_type": "wecom", "config": {}},
|
||||
)
|
||||
ch_id = create_resp.json()["id"]
|
||||
|
||||
resp = client.put(
|
||||
f"{self.BASE}/channels/{ch_id}",
|
||||
headers=auth_header(token),
|
||||
json={"name": "新名称", "enabled": False},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["name"] == "新名称"
|
||||
assert resp.json()["enabled"] is False
|
||||
|
||||
def test_delete_channel(self, client: TestClient, db: Session):
|
||||
"""删除渠道"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
create_resp = client.post(
|
||||
f"{self.BASE}/channels",
|
||||
headers=auth_header(token),
|
||||
json={"name": "待删除", "channel_type": "wecom", "config": {}},
|
||||
)
|
||||
ch_id = create_resp.json()["id"]
|
||||
|
||||
resp = client.delete(
|
||||
f"{self.BASE}/channels/{ch_id}",
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "已删除"
|
||||
|
||||
# 验证已删除
|
||||
get_resp = client.get(f"{self.BASE}/channels", headers=auth_header(token))
|
||||
assert len(get_resp.json()["data"]) == 0
|
||||
|
||||
def test_delete_channel_not_found(self, client: TestClient, db: Session):
|
||||
"""删除不存在的渠道"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.delete(f"{self.BASE}/channels/99999", headers=auth_header(token))
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_test_channel_network(self, client: TestClient, db: Session):
|
||||
"""测试推送(网络可能不通,但不应500)"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
create_resp = client.post(
|
||||
f"{self.BASE}/channels",
|
||||
headers=auth_header(token),
|
||||
json={"name": "测试", "channel_type": "wecom", "config": {"webhook_url": "http://invalid"}, "enabled": True},
|
||||
)
|
||||
ch_id = create_resp.json()["id"]
|
||||
|
||||
# 网络不通,但应该返回结果而不是500
|
||||
resp = client.post(
|
||||
f"{self.BASE}/channels/{ch_id}/test",
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "results" in resp.json()
|
||||
|
||||
|
||||
class TestNotificationLogs:
|
||||
"""通知历史测试"""
|
||||
|
||||
def test_logs_empty(self, client: TestClient, db: Session):
|
||||
"""空日志"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.get("/api/cma/notifications/logs", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total"] == 0
|
||||
|
||||
def test_logs_with_data(self, client: TestClient, db: Session):
|
||||
"""有了日志记录后可查询"""
|
||||
from app.models import NotificationLog
|
||||
from datetime import datetime
|
||||
|
||||
# 直接插入日志
|
||||
log = NotificationLog(
|
||||
alert_id=1, channel="wecom", recipient="测试群",
|
||||
title="测试通知", content="测试内容", status="sent", sent_at=datetime.now(),
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.get("/api/cma/notifications/logs", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total"] >= 1
|
||||
assert len(resp.json()["data"]) >= 1
|
||||
|
||||
|
||||
class TestAlertPush:
|
||||
"""手动推送测试"""
|
||||
|
||||
def test_push_alerts_no_channels(self, client: TestClient, db: Session):
|
||||
"""没有渠道 → 推送0条"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.post("/api/cma/notifications/alerts/push", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["pushed"] == 0
|
||||
@@ -0,0 +1,176 @@
|
||||
"""组织管理模块测试 — 组织节点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
|
||||
from app.models import OrgNode
|
||||
|
||||
|
||||
class TestOrgNodes:
|
||||
"""组织节点CRUD测试"""
|
||||
|
||||
BASE = "/api/cma/org"
|
||||
|
||||
def test_list_nodes_empty(self, client: TestClient, db: Session):
|
||||
"""空列表"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.get(f"{self.BASE}/nodes", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
# org_nodes may have seed data from init_db, just check response is valid
|
||||
assert "data" in resp.json()
|
||||
|
||||
def test_tree_empty(self, client: TestClient, db: Session):
|
||||
"""空树"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.get(f"{self.BASE}/tree", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert "data" in resp.json()
|
||||
|
||||
def test_create_root_node(self, client: TestClient, db: Session):
|
||||
"""创建根节点(集团)"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.post(
|
||||
f"{self.BASE}/nodes",
|
||||
headers=auth_header(token),
|
||||
json={"name": "集团公司", "code": "GROUP", "level": 1, "sort_order": 1},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["name"] == "集团公司"
|
||||
assert data["code"] == "GROUP"
|
||||
assert data["level"] == 1
|
||||
|
||||
def test_create_child_node(self, client: TestClient, db: Session):
|
||||
"""创建子节点"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
# 先创建父节点
|
||||
parent_resp = client.post(
|
||||
f"{self.BASE}/nodes",
|
||||
headers=auth_header(token),
|
||||
json={"name": "集团", "code": "G01", "level": 1},
|
||||
)
|
||||
parent_id = parent_resp.json()["id"]
|
||||
|
||||
# 创建子节点
|
||||
resp = client.post(
|
||||
f"{self.BASE}/nodes",
|
||||
headers=auth_header(token),
|
||||
json={"parent_id": parent_id, "name": "华东事业部", "code": "BU_EAST", "level": 2},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["name"] == "华东事业部"
|
||||
assert resp.json()["level"] == 2
|
||||
|
||||
def test_tree_structure(self, client: TestClient, db: Session):
|
||||
"""返回正确的树结构"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
# 创建层级数据
|
||||
r1 = client.post(
|
||||
f"{self.BASE}/nodes",
|
||||
headers=auth_header(token),
|
||||
json={"name": "集团", "code": "G01", "level": 1, "sort_order": 1},
|
||||
).json()
|
||||
p_id = r1["id"]
|
||||
|
||||
client.post(
|
||||
f"{self.BASE}/nodes",
|
||||
headers=auth_header(token),
|
||||
json={"parent_id": p_id, "name": "事业部A", "code": "BU_A", "level": 2},
|
||||
)
|
||||
client.post(
|
||||
f"{self.BASE}/nodes",
|
||||
headers=auth_header(token),
|
||||
json={"parent_id": p_id, "name": "事业部B", "code": "BU_B", "level": 2},
|
||||
)
|
||||
|
||||
resp = client.get(f"{self.BASE}/tree", headers=auth_header(token)).json()
|
||||
tree = resp["data"]
|
||||
# 至少有一个根节点,且我们创建的根节点在列表中
|
||||
assert len(tree) >= 1
|
||||
created_root = [t for t in tree if t["label"] == "集团"]
|
||||
assert len(created_root) == 1
|
||||
assert len(created_root[0]["children"]) == 2
|
||||
|
||||
def test_update_node(self, client: TestClient, db: Session):
|
||||
"""修改节点"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
node = OrgNode(name="旧名称", code="OLD", level=1, sort_order=1)
|
||||
db.add(node)
|
||||
db.commit()
|
||||
|
||||
resp = client.put(
|
||||
f"{self.BASE}/nodes/{node.id}",
|
||||
headers=auth_header(token),
|
||||
json={"name": "新名称", "code": "NEW"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["name"] == "新名称"
|
||||
assert resp.json()["code"] == "NEW"
|
||||
|
||||
def test_delete_leaf_node(self, client: TestClient, db: Session):
|
||||
"""删除叶子节点"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
node = OrgNode(name="待删除节点", code="DEL", level=3)
|
||||
db.add(node)
|
||||
db.commit()
|
||||
|
||||
resp = client.delete(f"{self.BASE}/nodes/{node.id}", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "已删除"
|
||||
|
||||
def test_delete_node_with_children_blocked(self, client: TestClient, db: Session):
|
||||
"""删除有子节点的节点被阻止"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
parent = OrgNode(name="父节点", code="PARENT", level=1)
|
||||
db.add(parent)
|
||||
db.flush()
|
||||
child = OrgNode(name="子节点", code="CHILD", level=2, parent_id=parent.id)
|
||||
db.add(child)
|
||||
db.commit()
|
||||
|
||||
resp = client.delete(f"{self.BASE}/nodes/{parent.id}", headers=auth_header(token))
|
||||
assert resp.status_code == 400
|
||||
assert "子节点" in resp.json()["detail"]
|
||||
|
||||
def test_toggle_node(self, client: TestClient, db: Session):
|
||||
"""切换启用/禁用"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
node = OrgNode(name="可切换节点", code="TOGGLE", level=2, enabled=1)
|
||||
db.add(node)
|
||||
db.commit()
|
||||
|
||||
# 禁用它
|
||||
resp = client.put(f"{self.BASE}/nodes/{node.id}/toggle", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["enabled"] == 0
|
||||
|
||||
# 再启用
|
||||
resp = client.put(f"{self.BASE}/nodes/{node.id}/toggle", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["enabled"] == 1
|
||||
|
||||
def test_delete_not_found(self, client: TestClient, db: Session):
|
||||
"""删除不存在的节点"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.delete(f"{self.BASE}/nodes/99999", headers=auth_header(token))
|
||||
assert resp.status_code == 404
|
||||
@@ -0,0 +1,108 @@
|
||||
"""角色权限模块测试 — 模块/动作/权限配置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
|
||||
|
||||
|
||||
class TestPermissionModules:
|
||||
"""模块和动作定义测试"""
|
||||
|
||||
BASE = "/api/cma/permissions"
|
||||
|
||||
def test_list_modules(self, client: TestClient, db: Session):
|
||||
"""获取模块和动作定义"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.get(f"{self.BASE}/modules", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "modules" in data
|
||||
assert "actions" in data
|
||||
assert "roles" in data
|
||||
assert len(data["modules"]) > 0
|
||||
assert len(data["actions"]) > 0
|
||||
# 检查是否包含核心模块
|
||||
module_keys = [m["key"] for m in data["modules"]]
|
||||
assert "dashboard" in module_keys
|
||||
assert "kpis" in module_keys
|
||||
assert "budget" in module_keys
|
||||
|
||||
|
||||
class TestPermissionConfig:
|
||||
"""权限配置CRUD测试"""
|
||||
|
||||
BASE = "/api/cma/permissions"
|
||||
|
||||
def test_get_config_creates_defaults(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
|
||||
data = resp.json()
|
||||
assert "route_permissions" in data
|
||||
assert "action_permissions" in data
|
||||
assert "ceo" in data["route_permissions"]
|
||||
assert "finance" in data["route_permissions"]
|
||||
|
||||
def test_update_route_permissions(self, client: TestClient, db: Session):
|
||||
"""更新路由权限"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
new_route = {
|
||||
"ceo": ["dashboard", "kpis", "maps"],
|
||||
"finance": ["dashboard", "kpis"],
|
||||
}
|
||||
resp = client.put(
|
||||
f"{self.BASE}/config",
|
||||
headers=auth_header(token),
|
||||
json={"route_permissions": new_route},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "权限配置已更新"
|
||||
|
||||
# 验证已更新
|
||||
get_resp = client.get(f"{self.BASE}/config", headers=auth_header(token))
|
||||
assert get_resp.json()["route_permissions"] == new_route
|
||||
|
||||
def test_update_action_permissions(self, client: TestClient, db: Session):
|
||||
"""更新动作权限"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
new_actions = {
|
||||
"ceo": ["read", "approve", "delete"],
|
||||
"it": ["read", "write", "admin"],
|
||||
}
|
||||
resp = client.put(
|
||||
f"{self.BASE}/config",
|
||||
headers=auth_header(token),
|
||||
json={"action_permissions": new_actions},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
get_resp = client.get(f"{self.BASE}/config", headers=auth_header(token))
|
||||
assert get_resp.json()["action_permissions"] == new_actions
|
||||
|
||||
def test_update_both_simultaneously(self, client: TestClient, db: Session):
|
||||
"""同时更新路由和动作权限"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
resp = client.put(
|
||||
f"{self.BASE}/config",
|
||||
headers=auth_header(token),
|
||||
json={
|
||||
"route_permissions": {"ceo": ["dashboard"]},
|
||||
"action_permissions": {"ceo": ["read"]},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
data = client.get(f"{self.BASE}/config", headers=auth_header(token)).json()
|
||||
assert data["route_permissions"] == {"ceo": ["dashboard"]}
|
||||
assert data["action_permissions"] == {"ceo": ["read"]}
|
||||
@@ -0,0 +1,141 @@
|
||||
"""用户管理模块测试 — CRUD"""
|
||||
import hashlib
|
||||
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 User
|
||||
|
||||
|
||||
class TestUsers:
|
||||
"""用户管理CRUD测试"""
|
||||
|
||||
BASE = "/api/cma/users"
|
||||
|
||||
def test_list_users_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()["data"]
|
||||
assert len(data) >= 1
|
||||
|
||||
def test_create_user(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={
|
||||
"username": "newuser001",
|
||||
"password": "test12345",
|
||||
"name": "新用户",
|
||||
"role": "finance",
|
||||
"phone": "13800138000",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["username"] == "newuser001"
|
||||
assert data["name"] == "新用户"
|
||||
assert data["role"] == "finance"
|
||||
assert data["phone"] == "13800138000"
|
||||
assert "password" not in data # 不返回密码
|
||||
|
||||
def test_create_duplicate_username(self, client: TestClient, db: Session):
|
||||
"""重复用户名被拒绝"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
# 同名用户
|
||||
client.post(
|
||||
self.BASE,
|
||||
headers=auth_header(token),
|
||||
json={"username": "dupuser", "password": "pass123", "name": "重复"},
|
||||
)
|
||||
resp = client.post(
|
||||
self.BASE,
|
||||
headers=auth_header(token),
|
||||
json={"username": "dupuser", "password": "pass456", "name": "重复"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "已存在" in resp.json()["detail"]
|
||||
|
||||
def test_update_user(self, client: TestClient, db: Session):
|
||||
"""更新用户信息"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
# 创建待更新用户
|
||||
create_resp = client.post(
|
||||
self.BASE,
|
||||
headers=auth_header(token),
|
||||
json={"username": "updatable", "password": "oldpass", "name": "旧名称"},
|
||||
)
|
||||
user_id = create_resp.json()["id"]
|
||||
|
||||
resp = client.put(
|
||||
f"{self.BASE}/{user_id}",
|
||||
headers=auth_header(token),
|
||||
json={"name": "新名称", "role": "business"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["name"] == "新名称"
|
||||
assert resp.json()["role"] == "business"
|
||||
|
||||
def test_update_user_password(self, client: TestClient, db: Session):
|
||||
"""更新用户密码"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
create_resp = client.post(
|
||||
self.BASE,
|
||||
headers=auth_header(token),
|
||||
json={"username": "passuser", "password": "oldpass", "name": "密码测试"},
|
||||
)
|
||||
user_id = create_resp.json()["id"]
|
||||
|
||||
client.put(
|
||||
f"{self.BASE}/{user_id}",
|
||||
headers=auth_header(token),
|
||||
json={"password": "newsecret"},
|
||||
)
|
||||
|
||||
# 验证可以用新密码登录
|
||||
login_resp = client.post("/api/cma/auth/login", json={
|
||||
"username": "passuser", "password": "newsecret",
|
||||
})
|
||||
assert login_resp.status_code == 200
|
||||
|
||||
def test_delete_user(self, client: TestClient, db: Session):
|
||||
"""删除用户"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
|
||||
create_resp = client.post(
|
||||
self.BASE,
|
||||
headers=auth_header(token),
|
||||
json={"username": "todelete", "password": "pass", "name": "待删除"},
|
||||
)
|
||||
user_id = create_resp.json()["id"]
|
||||
|
||||
resp = client.delete(f"{self.BASE}/{user_id}", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "已删除"
|
||||
|
||||
# 验证已删除
|
||||
list_resp = client.get(self.BASE, headers=auth_header(token))
|
||||
ids = [u["id"] for u in list_resp.json()["data"]]
|
||||
assert user_id not in ids
|
||||
|
||||
def test_delete_user_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
|
||||
@@ -0,0 +1,235 @@
|
||||
"""工具层测试:calc_engine 和 deviation_engine 核心函数"""
|
||||
import pytest
|
||||
from app.utils.deviation_engine import (
|
||||
calc_deviation,
|
||||
calc_period_deviation,
|
||||
calc_period_diff,
|
||||
check_trend_anomaly,
|
||||
run_deviation_check,
|
||||
)
|
||||
from app.utils.calc_engine import calculate_all, _get
|
||||
from app.models import KPIDefinition, KPIValue, BudgetPlan, KPIAlert
|
||||
|
||||
|
||||
class TestDeviationCalc:
|
||||
"""偏差计算核心函数测试"""
|
||||
|
||||
def test_calc_deviation_positive(self):
|
||||
"""实际 > 预算 → 正差异"""
|
||||
result = calc_deviation(120, 100)
|
||||
assert result["deviation_amount"] == 20
|
||||
assert result["deviation_rate"] == 20.0
|
||||
assert result["is_over_budget"] is True
|
||||
|
||||
def test_calc_deviation_negative(self):
|
||||
"""实际 < 预算 → 负差异"""
|
||||
result = calc_deviation(80, 100)
|
||||
assert result["deviation_amount"] == -20
|
||||
assert result["deviation_rate"] == -20.0
|
||||
assert result["is_over_budget"] is False
|
||||
|
||||
def test_calc_deviation_zero_budget(self):
|
||||
"""预算为0 → 差异为None"""
|
||||
result = calc_deviation(100, 0)
|
||||
assert result["deviation_amount"] is None
|
||||
assert result["deviation_rate"] is None
|
||||
assert result["is_over_budget"] is None
|
||||
|
||||
def test_calc_deviation_none_budget(self):
|
||||
"""预算为None → 差异为None"""
|
||||
result = calc_deviation(100, None)
|
||||
assert result["deviation_amount"] is None
|
||||
|
||||
def test_calc_deviation_exact_match(self):
|
||||
"""实际==预算 → 差异0"""
|
||||
result = calc_deviation(100, 100)
|
||||
assert result["deviation_amount"] == 0
|
||||
assert result["deviation_rate"] == 0.0
|
||||
assert result["is_over_budget"] is False
|
||||
|
||||
|
||||
class TestPeriodDeviation:
|
||||
"""单期差异计算(依赖数据库)"""
|
||||
|
||||
def test_calc_period_deviation_with_budget(self, db):
|
||||
"""有预算值和实际值 → 正确计算差异"""
|
||||
kpi = KPIDefinition(kpi_code="TEST_DEV_001", kpi_name="测试KPI", dimension="finance",
|
||||
target_value=1000, status="active")
|
||||
db.add(kpi)
|
||||
db.commit()
|
||||
|
||||
bp = BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=500.0,
|
||||
budget_year=2026, budget_month=6, status="active", version="v1.0")
|
||||
db.add(bp)
|
||||
kv = KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=600.0)
|
||||
db.add(kv)
|
||||
db.commit()
|
||||
|
||||
result = calc_period_deviation(db, kpi.id, "2026-06")
|
||||
assert result["kpi_code"] == "TEST_DEV_001"
|
||||
assert result["actual_value"] == 600.0
|
||||
assert result["budget_value"] == 500.0
|
||||
assert result["deviation_amount"] == 100.0
|
||||
assert result["deviation_rate"] == 20.0
|
||||
|
||||
def test_calc_period_deviation_no_budget_fallback_target(self, db):
|
||||
"""无预算值时回退到 target_value/12(monthly频率)"""
|
||||
kpi = KPIDefinition(kpi_code="TEST_DEV_002", kpi_name="测试KPI", dimension="finance",
|
||||
target_value=1200, frequency="monthly", status="active")
|
||||
db.add(kpi)
|
||||
db.flush()
|
||||
kv = KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=150.0)
|
||||
db.add(kv)
|
||||
db.commit()
|
||||
|
||||
result = calc_period_deviation(db, kpi.id, "2026-06")
|
||||
# target=1200, monthly budget = 1200/12 = 100
|
||||
assert result["budget_value"] == 100.0
|
||||
assert result["deviation_amount"] == 50.0
|
||||
|
||||
|
||||
class TestPeriodDiff:
|
||||
"""同比/环比差异测试"""
|
||||
|
||||
def test_yoy_diff(self, db):
|
||||
"""同比差异计算"""
|
||||
kpi = KPIDefinition(kpi_code="TEST_YOY", kpi_name="同比测试", dimension="finance", status="active")
|
||||
db.add(kpi)
|
||||
db.commit()
|
||||
# 上年同期值
|
||||
kv1 = KPIValue(kpi_id=kpi.id, period="2025-06", actual_value=100.0)
|
||||
# 本期值
|
||||
kv2 = KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=120.0)
|
||||
db.add_all([kv1, kv2])
|
||||
db.commit()
|
||||
|
||||
result = calc_period_diff(db, kpi.id, "2026-06", "yoy")
|
||||
assert result["label"] == "同比"
|
||||
assert result["current_value"] == 120.0
|
||||
assert result["prev_value"] == 100.0
|
||||
assert result["diff_amount"] == 20.0
|
||||
assert result["diff_rate"] == 20.0
|
||||
|
||||
def test_mom_diff_january(self, db):
|
||||
"""环比:跨年情况(1月→去年12月)"""
|
||||
kpi = KPIDefinition(kpi_code="TEST_MOM", kpi_name="环比测试", dimension="finance", status="active")
|
||||
db.add(kpi)
|
||||
db.commit()
|
||||
kv1 = KPIValue(kpi_id=kpi.id, period="2025-12", actual_value=200.0)
|
||||
kv2 = KPIValue(kpi_id=kpi.id, period="2026-01", actual_value=180.0)
|
||||
db.add_all([kv1, kv2])
|
||||
db.commit()
|
||||
|
||||
result = calc_period_diff(db, kpi.id, "2026-01", "mom")
|
||||
assert result["label"] == "环比"
|
||||
assert result["prev_period"] == "2025-12"
|
||||
assert result["diff_amount"] == -20.0
|
||||
assert result["diff_rate"] == -10.0
|
||||
|
||||
def test_diff_unknown_type(self, db):
|
||||
"""未知比较类型"""
|
||||
kpi = KPIDefinition(kpi_code="TEST_UNKNOWN", kpi_name="未知", dimension="finance", status="active")
|
||||
db.add(kpi)
|
||||
db.commit()
|
||||
result = calc_period_diff(db, kpi.id, "2026-06", "wow")
|
||||
assert "error" in result
|
||||
|
||||
|
||||
class TestTrendAnomaly:
|
||||
"""趋势异常检测"""
|
||||
|
||||
def test_continuous_decline(self, db):
|
||||
"""连续3期下滑 → 触发异常"""
|
||||
kpi = KPIDefinition(kpi_code="TEST_TREND_DEC", kpi_name="下滑趋势",
|
||||
dimension="finance", status="active")
|
||||
db.add(kpi)
|
||||
db.commit()
|
||||
for m, v in [(4, 150), (5, 120), (6, 90)]:
|
||||
db.add(KPIValue(kpi_id=kpi.id, period=f"2026-{m:02d}", actual_value=float(v)))
|
||||
db.commit()
|
||||
|
||||
result = check_trend_anomaly(db, kpi.id, "2026-06", consecutive=3)
|
||||
assert result["anomaly"] is True
|
||||
assert result["type"] == "continuous_decline"
|
||||
assert "下滑" in result["message"]
|
||||
|
||||
def test_continuous_rise(self, db):
|
||||
"""连续3期上升 → 触发异常"""
|
||||
kpi = KPIDefinition(kpi_code="TEST_TREND_RISE", kpi_name="上升趋势",
|
||||
dimension="finance", status="active")
|
||||
db.add(kpi)
|
||||
db.commit()
|
||||
for m, v in [(4, 50), (5, 80), (6, 110)]:
|
||||
db.add(KPIValue(kpi_id=kpi.id, period=f"2026-{m:02d}", actual_value=float(v)))
|
||||
db.commit()
|
||||
|
||||
result = check_trend_anomaly(db, kpi.id, "2026-06", consecutive=3)
|
||||
assert result["anomaly"] is True
|
||||
assert result["type"] == "continuous_rise"
|
||||
|
||||
def test_no_anomaly(self, db):
|
||||
"""不满足连续条件 → 无异常"""
|
||||
kpi = KPIDefinition(kpi_code="TEST_TREND_NORMAL", kpi_name="正常趋势",
|
||||
dimension="finance", status="active")
|
||||
db.add(kpi)
|
||||
db.commit()
|
||||
for m, v in [(4, 100), (5, 120), (6, 90)]:
|
||||
db.add(KPIValue(kpi_id=kpi.id, period=f"2026-{m:02d}", actual_value=float(v)))
|
||||
db.commit()
|
||||
|
||||
result = check_trend_anomaly(db, kpi.id, "2026-06", consecutive=3)
|
||||
assert result["anomaly"] is False
|
||||
|
||||
def test_insufficient_data(self, db):
|
||||
"""数据不足 → 无异常"""
|
||||
kpi = KPIDefinition(kpi_code="TEST_TREND_SHORT", kpi_name="数据不足",
|
||||
dimension="finance", status="active")
|
||||
db.add(kpi)
|
||||
db.flush()
|
||||
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=100.0))
|
||||
db.commit()
|
||||
|
||||
result = check_trend_anomaly(db, kpi.id, "2026-06", consecutive=3)
|
||||
assert result["anomaly"] is False
|
||||
assert "数据不足" in result.get("reason", "")
|
||||
|
||||
|
||||
class TestRunDeviationCheck:
|
||||
"""run_deviation_check 集成测试"""
|
||||
|
||||
def test_run_check_creates_alerts(self, db):
|
||||
"""偏差超阈值 → 创建预警"""
|
||||
kpi = KPIDefinition(kpi_code="SALES_TOTAL", kpi_name="销售总额",
|
||||
dimension="finance", target_value=100000, status="active")
|
||||
db.add(kpi)
|
||||
db.commit()
|
||||
|
||||
# 预算50000 vs 实际40000 → 差异率-20%(higher_better,低于预算触发)
|
||||
bp = BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=50000.0,
|
||||
budget_year=2026, budget_month=6, status="active", version="v1.0")
|
||||
kv = KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=40000.0)
|
||||
db.add_all([bp, kv])
|
||||
db.commit()
|
||||
|
||||
count = run_deviation_check(db, period="2026-06")
|
||||
assert count >= 1
|
||||
|
||||
alert = db.query(KPIAlert).filter(KPIAlert.kpi_id == kpi.id).first()
|
||||
assert alert is not None
|
||||
assert "差异预警" in alert.alert_message
|
||||
|
||||
def test_run_check_no_alert_when_on_budget(self, db):
|
||||
"""实际≈预算 → 不创建预警"""
|
||||
kpi = KPIDefinition(kpi_code="SALES_TOTAL", kpi_name="销售总额",
|
||||
dimension="finance", target_value=100000, status="active")
|
||||
db.add(kpi)
|
||||
db.flush()
|
||||
bp = BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=50000.0,
|
||||
budget_year=2026, budget_month=6, status="active", version="v1.0")
|
||||
kv = KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=50500.0)
|
||||
db.add_all([kpi, bp, kv])
|
||||
db.commit()
|
||||
|
||||
count = run_deviation_check(db, period="2026-06")
|
||||
# 差异率1% < 10%阈值,不触发
|
||||
assert count == 0
|
||||
@@ -0,0 +1,178 @@
|
||||
"""工具层测试:cost_engine, predict_engine, cache 核心函数"""
|
||||
import pytest
|
||||
from app.utils.cost_engine import (
|
||||
calc_variance,
|
||||
calc_efficiency_variance,
|
||||
)
|
||||
from app.utils.predict_engine import (
|
||||
cvp_analysis,
|
||||
npv,
|
||||
irr,
|
||||
sensitivity_analysis,
|
||||
scenario_analysis,
|
||||
)
|
||||
from app.utils.cache import _make_key, get, set, delete, clear_all
|
||||
|
||||
|
||||
class TestCostVariance:
|
||||
"""成本差异计算测试"""
|
||||
|
||||
def test_calc_variance_standard(self):
|
||||
"""标准差异计算"""
|
||||
result = calc_variance(10, 12, 5, 4.5)
|
||||
# 量差 = (12-10)*5 = 10
|
||||
# 价差 = (4.5-5)*12 = -6
|
||||
# 总差异 = 10 + (-6) = 4
|
||||
assert result["qty_variance"] == 10.0
|
||||
assert result["price_variance"] == -6.0
|
||||
assert result["total_variance"] == 4.0
|
||||
assert result["standard_cost"] == 50.0
|
||||
assert result["actual_cost"] == 54.0
|
||||
|
||||
def test_calc_variance_equal(self):
|
||||
"""实际与标准完全一致"""
|
||||
result = calc_variance(10, 10, 5, 5)
|
||||
assert result["qty_variance"] == 0
|
||||
assert result["price_variance"] == 0
|
||||
assert result["total_variance"] == 0
|
||||
|
||||
def test_calc_variance_no_qty_diff(self):
|
||||
"""只有价格差异"""
|
||||
result = calc_variance(10, 10, 5, 6)
|
||||
assert result["qty_variance"] == 0
|
||||
assert result["price_variance"] == 10.0 # (6-5)*10
|
||||
assert result["total_variance"] == 10.0
|
||||
|
||||
def test_calc_efficiency_variance(self):
|
||||
"""效率差异计算"""
|
||||
result = calc_efficiency_variance(100, 120, 50)
|
||||
# (120-100)*50 = 1000
|
||||
assert result["efficiency_variance"] == 1000.0
|
||||
|
||||
|
||||
class TestCvpAnalysis:
|
||||
"""CVP本量利分析测试"""
|
||||
|
||||
def test_basic_bep(self):
|
||||
"""基础盈亏平衡点"""
|
||||
result = cvp_analysis(unit_price=100, unit_variable_cost=60, fixed_cost=20000)
|
||||
assert result["contribution_margin"] == 40.0
|
||||
assert result["contribution_ratio"] == 40.0
|
||||
assert result["bep_units"] == 500.0
|
||||
assert result["bep_revenue"] == 50000.0
|
||||
|
||||
def test_with_actual_volume(self):
|
||||
"""带实际销量的安全边际计算"""
|
||||
result = cvp_analysis(unit_price=100, unit_variable_cost=60, fixed_cost=20000, actual_volume=800)
|
||||
assert result["safety_margin_units"] == 300.0 # 800-500
|
||||
assert result["safety_margin_ratio"] == 37.5 # 300/800*100
|
||||
assert result["actual_profit"] == 12000.0 # (100-60)*800 - 20000
|
||||
|
||||
def test_with_target_profit(self):
|
||||
"""带目标利润"""
|
||||
result = cvp_analysis(unit_price=100, unit_variable_cost=60, fixed_cost=20000, target_profit=10000)
|
||||
assert result["target_units"] == 750.0 # (20000+10000)/40
|
||||
assert result["target_revenue"] == 75000.0
|
||||
|
||||
def test_price_less_than_variable_cost(self):
|
||||
"""单价<=变动成本 → 错误"""
|
||||
result = cvp_analysis(unit_price=50, unit_variable_cost=60, fixed_cost=10000)
|
||||
assert "error" in result
|
||||
|
||||
|
||||
class TestNpvIrr:
|
||||
"""NPV/IRR投资决策测试"""
|
||||
|
||||
def test_npv_positive(self):
|
||||
"""正NPV → 可行"""
|
||||
result = npv(1000, [400, 500, 500], 10)
|
||||
assert result["is_viable"] is True
|
||||
assert result["npv"] > 0
|
||||
assert result["profitability_index"] > 1.0
|
||||
|
||||
def test_npv_negative(self):
|
||||
"""负NPV → 不可行"""
|
||||
result = npv(10000, [100, 100, 100], 10)
|
||||
assert result["is_viable"] is False
|
||||
assert result["npv"] < 0
|
||||
|
||||
def test_npv_empty_cashflows(self):
|
||||
"""空现金流 → 错误"""
|
||||
result = npv(1000, [], 10)
|
||||
assert "error" in result
|
||||
|
||||
def test_irr_basic(self):
|
||||
"""基本IRR计算"""
|
||||
result = irr(1000, [300, 400, 500, 200])
|
||||
assert result["payback_period"] is not None
|
||||
assert result["is_viable"] is True # IRR > 0
|
||||
|
||||
def test_irr_negative_case(self):
|
||||
"""现金流总和小于投资→可能负IRR"""
|
||||
result = irr(10000, [1000, 1000, 1000])
|
||||
assert result["payback_period"] is None # 无法回收
|
||||
|
||||
|
||||
class TestSensitivityAnalysis:
|
||||
"""敏感性分析测试"""
|
||||
|
||||
def test_basic_sensitivity(self):
|
||||
"""基础敏感性分析"""
|
||||
result = sensitivity_analysis(base_revenue=1000, base_cost=800, step=10, max_step=20)
|
||||
assert result["base_profit"] == 200.0
|
||||
assert len(result["factors"]) > 0
|
||||
# 0%变动时利润不变
|
||||
zero = [f for f in result["factors"] if f["change_pct"] == 0][0]
|
||||
assert zero["revenue_change_profit"] == 200.0
|
||||
assert zero["revenue_sensitivity"] == 0.0
|
||||
|
||||
|
||||
class TestScenarioAnalysis:
|
||||
"""情景模拟测试"""
|
||||
|
||||
def test_three_scenarios(self):
|
||||
"""三情景分析"""
|
||||
result = scenario_analysis(
|
||||
optimistic={"revenue": 130, "cost": 90},
|
||||
pessimistic={"revenue": 80, "cost": 110},
|
||||
base={"revenue": 100, "cost": 100},
|
||||
)
|
||||
assert len(result["scenarios"]) == 3
|
||||
assert result["scenarios"][0]["scenario"] == "乐观"
|
||||
assert result["scenarios"][1]["scenario"] == "中性"
|
||||
assert result["scenarios"][2]["scenario"] == "悲观"
|
||||
assert result["expected_profit"] == round((40 + 0 + (-30)) / 3, 2)
|
||||
|
||||
|
||||
class TestCacheUtils:
|
||||
"""缓存工具测试(不依赖Redis,测试逻辑)"""
|
||||
|
||||
def test_make_key_format(self):
|
||||
"""缓存key格式"""
|
||||
key = _make_key("test", "hello")
|
||||
assert key.startswith("cma:cache:test:")
|
||||
assert len(key) > len("cma:cache:test:")
|
||||
|
||||
def test_get_when_unavailable(self):
|
||||
"""Redis不可用 → get返回None"""
|
||||
# cache.py 在模块级别会尝试连接Redis,如果不可用则 _available=False
|
||||
# 此时 get 返回 None
|
||||
result = get("test", "key")
|
||||
# 可能返回 None(Redis不可用)或者正常获取
|
||||
assert result is None or isinstance(result, object)
|
||||
|
||||
def test_set_when_unavailable(self):
|
||||
"""Redis不可用 → set返回False"""
|
||||
result = set("test", "key", {"data": 123})
|
||||
# 可能返回 False(Redis不可用)或者 True
|
||||
assert result is False or result is True
|
||||
|
||||
def test_delete_when_unavailable(self):
|
||||
"""Redis不可用 → delete返回False"""
|
||||
result = delete("test", "key")
|
||||
assert result is False or result is True
|
||||
|
||||
def test_clear_all_when_unavailable(self):
|
||||
"""Redis不可用 → clear_all返回False"""
|
||||
result = clear_all()
|
||||
assert result is False or result is True
|
||||
@@ -0,0 +1,212 @@
|
||||
"""工具层测试:notifier 推送逻辑(mock外部HTTP)"""
|
||||
import json
|
||||
from unittest.mock import patch, MagicMock
|
||||
import pytest
|
||||
from app.utils.notifier import (
|
||||
send_wecom_robot,
|
||||
send_wecom_app,
|
||||
send_mail,
|
||||
push_alert,
|
||||
_build_content,
|
||||
push_pending_alerts,
|
||||
)
|
||||
|
||||
|
||||
class TestWecomRobot:
|
||||
"""企业微信群机器人推送测试(mock HTTP)"""
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_send_success(self, mock_urlopen):
|
||||
"""推送成功"""
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = json.dumps({"errcode": 0}).encode()
|
||||
mock_urlopen.return_value.__enter__.return_value = mock_resp
|
||||
|
||||
result = send_wecom_robot("https://qyapi.weixin.qq.com/webhook/test",
|
||||
"测试标题", "测试内容", "red")
|
||||
assert result["success"] is True
|
||||
assert "已推送" in result["message"]
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_send_api_error(self, mock_urlopen):
|
||||
"""企微API返回错误"""
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = json.dumps({"errcode": 40001, "errmsg": "invalid webhook"}).encode()
|
||||
mock_urlopen.return_value.__enter__.return_value = mock_resp
|
||||
|
||||
result = send_wecom_robot("https://qyapi.weixin.qq.com/webhook/test",
|
||||
"测试", "内容", "yellow")
|
||||
assert result["success"] is False
|
||||
assert "推送失败" in result["message"]
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_send_network_error(self, mock_urlopen):
|
||||
"""网络异常"""
|
||||
mock_urlopen.side_effect = Exception("Connection refused")
|
||||
|
||||
result = send_wecom_robot("https://qyapi.weixin.qq.com/webhook/test",
|
||||
"测试", "内容", "green")
|
||||
assert result["success"] is False
|
||||
assert "推送异常" in result["message"]
|
||||
|
||||
def test_build_content(self):
|
||||
"""_build_content 内容构建"""
|
||||
alert = {
|
||||
"kpi_name": "销售总额",
|
||||
"period": "2026-06",
|
||||
"actual_value": "800,000",
|
||||
"target_value": "1,000,000",
|
||||
"alert_level": "red",
|
||||
"resolution": "加强销售推广",
|
||||
}
|
||||
content = _build_content(alert)
|
||||
assert "销售总额" in content
|
||||
assert "2026-06" in content
|
||||
assert "800,000" in content
|
||||
assert "加强销售推广" in content
|
||||
assert "紧急" in content
|
||||
|
||||
|
||||
class TestPushAlert:
|
||||
"""主推送函数测试"""
|
||||
|
||||
@patch("app.utils.notifier.send_wecom_robot")
|
||||
def test_push_to_wecom_robot(self, mock_send):
|
||||
"""推送到企微机器人渠道"""
|
||||
mock_send.return_value = {"success": True, "message": "ok"}
|
||||
channels = [{
|
||||
"name": "测试群", "channel_type": "wecom", "enabled": True,
|
||||
"config": {"webhook_url": "https://qyapi.weixin.qq.com/webhook/abc"}
|
||||
}]
|
||||
results = push_alert({"alert_level": "yellow", "alert_message": "测试"}, channels)
|
||||
assert len(results) == 1
|
||||
assert results[0]["channel"] == "wecom"
|
||||
assert results[0]["success"] is True
|
||||
|
||||
@patch("app.utils.notifier.send_wecom_app")
|
||||
def test_push_to_wecom_app(self, mock_send):
|
||||
"""推送到企微应用消息"""
|
||||
mock_send.return_value = {"success": True, "message": "ok"}
|
||||
channels = [{
|
||||
"name": "应用通知", "channel_type": "wecom_app", "enabled": True,
|
||||
"config": {"corp_id": "xxx", "corp_secret": "yyy", "agent_id": "1000002", "touser": "@all"}
|
||||
}]
|
||||
results = push_alert({"alert_level": "yellow", "alert_message": "测试"}, channels)
|
||||
assert len(results) == 1
|
||||
assert results[0]["channel"] == "wecom_app"
|
||||
|
||||
@patch("app.utils.notifier.send_mail")
|
||||
def test_push_to_mail(self, mock_send):
|
||||
"""推送到邮件"""
|
||||
mock_send.return_value = {"success": True, "message": "ok"}
|
||||
channels = [{
|
||||
"name": "通知邮箱", "channel_type": "mail", "enabled": True,
|
||||
"config": {"to": ["admin@example.com"]}
|
||||
}]
|
||||
results = push_alert({"alert_level": "red", "alert_message": "测试"}, channels)
|
||||
assert len(results) == 1
|
||||
assert results[0]["channel"] == "mail"
|
||||
|
||||
def test_disabled_channel_skipped(self):
|
||||
"""已禁用的渠道跳过"""
|
||||
channels = [{
|
||||
"name": "禁用渠道", "channel_type": "wecom", "enabled": False,
|
||||
"config": {}
|
||||
}]
|
||||
results = push_alert({"alert_level": "yellow", "alert_message": "测试"}, channels)
|
||||
assert len(results) == 0
|
||||
|
||||
def test_empty_channels(self):
|
||||
"""无渠道 → 空结果"""
|
||||
results = push_alert({"alert_level": "yellow", "alert_message": "测试"}, [])
|
||||
assert results == []
|
||||
|
||||
|
||||
class TestSendWecomApp:
|
||||
"""企微应用消息测试"""
|
||||
|
||||
@patch("requests.get")
|
||||
@patch("requests.post")
|
||||
def test_app_send_success(self, mock_post, mock_get):
|
||||
"""企微应用消息发送成功"""
|
||||
mock_get.return_value.json.return_value = {"errcode": 0, "access_token": "fake_token"}
|
||||
mock_post.return_value.json.return_value = {"errcode": 0}
|
||||
|
||||
result = send_wecom_app("corp123", "secret456", "1000001",
|
||||
"@all", "标题", "内容", "yellow")
|
||||
assert result["success"] is True
|
||||
|
||||
@patch("requests.get")
|
||||
def test_app_token_fail(self, mock_get):
|
||||
"""获取token失败"""
|
||||
mock_get.return_value.json.return_value = {"errcode": 40013, "errmsg": "invalid corpid"}
|
||||
|
||||
result = send_wecom_app("bad_corp", "bad_secret", "1000001",
|
||||
"@all", "标题", "内容", "yellow")
|
||||
assert result["success"] is False
|
||||
assert "获取token失败" in result["message"]
|
||||
|
||||
|
||||
class TestSendMail:
|
||||
"""邮件推送测试(mock SMTP)"""
|
||||
|
||||
@patch("smtplib.SMTP_SSL")
|
||||
def test_mail_send_success(self, mock_smtp):
|
||||
"""邮件发送成功"""
|
||||
mock_server = MagicMock()
|
||||
mock_smtp.return_value = mock_server
|
||||
|
||||
config = {"host": "smtp.example.com", "port": 465, "user": "user@example.com",
|
||||
"password": "pass", "use_ssl": True, "from_addr": "user@example.com"}
|
||||
result = send_mail(config, ["admin@example.com"], "测试标题", "测试内容")
|
||||
assert result["success"] is True
|
||||
mock_server.login.assert_called_once()
|
||||
mock_server.sendmail.assert_called_once()
|
||||
|
||||
@patch("smtplib.SMTP_SSL")
|
||||
def test_mail_send_failure(self, mock_smtp):
|
||||
"""邮件发送异常"""
|
||||
mock_smtp.side_effect = Exception("SMTP server error")
|
||||
|
||||
config = {"host": "smtp.example.com", "port": 465, "user": "user@example.com",
|
||||
"password": "pass", "use_ssl": True, "from_addr": "user@example.com"}
|
||||
result = send_mail(config, ["admin@example.com"], "测试标题", "测试内容")
|
||||
assert result["success"] is False
|
||||
assert "邮件发送失败" in result["message"]
|
||||
|
||||
|
||||
class TestPushPendingAlerts:
|
||||
"""push_pending_alerts 测试"""
|
||||
|
||||
def test_no_channels(self, db):
|
||||
"""没有渠道 → 返回0"""
|
||||
count = push_pending_alerts(db)
|
||||
assert count == 0
|
||||
|
||||
def test_no_pending_alerts(self, db):
|
||||
"""没有待处理预警 → 返回0"""
|
||||
from app.models import NotificationChannel
|
||||
db.add(NotificationChannel(name="测试群", channel_type="wecom", config={}, enabled=True))
|
||||
db.commit()
|
||||
|
||||
count = push_pending_alerts(db)
|
||||
assert count == 0
|
||||
|
||||
@patch("app.utils.notifier.push_alert")
|
||||
def test_push_pending_with_data(self, mock_push, db):
|
||||
"""有渠道+待处理预警 → 尝试推送"""
|
||||
from app.models import NotificationChannel, KPIAlert, KPIDefinition
|
||||
kpi = KPIDefinition(kpi_code="TEST_PUSH", kpi_name="推送测试", dimension="finance", status="active")
|
||||
db.add(kpi)
|
||||
db.commit()
|
||||
|
||||
db.add(NotificationChannel(name="测试群", channel_type="wecom",
|
||||
config={"webhook_url": "http://test"}, enabled=True))
|
||||
db.add(KPIAlert(kpi_id=kpi.id, alert_level="red", alert_message="测试预警", status="pending"))
|
||||
db.commit()
|
||||
|
||||
mock_push.return_value = [{"channel": "wecom", "channel_name": "测试群", "success": True, "message": "ok"}]
|
||||
|
||||
count = push_pending_alerts(db)
|
||||
mock_push.assert_called_once()
|
||||
assert count == 1
|
||||
Reference in New Issue
Block a user