add backend source code

This commit is contained in:
2026-02-28 15:06:52 +08:00
parent 1bc330e20c
commit 2c37aa9064
67 changed files with 11654 additions and 0 deletions

View File

@@ -0,0 +1,296 @@
"""
科室类型BSC维度权重配置服务测试用例
测试覆盖:
1. 权重配置获取
2. 权重配置创建/更新
3. 维度加权得分计算
4. 默认权重初始化
"""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from decimal import Decimal
from app.services.dimension_weight_service import DimensionWeightService, DEFAULT_WEIGHTS
from app.models.models import DeptType, DeptTypeDimensionWeight
class TestGetDimensionWeights:
"""获取权重配置测试"""
def test_get_default_weights_surgical(self):
"""测试获取手术临床科室默认权重"""
weights = DimensionWeightService.get_dimension_weights(DeptType.CLINICAL_SURGICAL)
assert weights["financial"] == 0.60
assert weights["customer"] == 0.15
assert weights["internal_process"] == 0.20
assert weights["learning_growth"] == 0.05
def test_get_default_weights_medical_tech(self):
"""测试获取医技科室默认权重"""
weights = DimensionWeightService.get_dimension_weights(DeptType.MEDICAL_TECH)
assert weights["financial"] == 0.40
assert weights["customer"] == 0.25
assert weights["internal_process"] == 0.30
assert weights["learning_growth"] == 0.05
def test_get_default_weights_nursing(self):
"""测试获取护理单元默认权重"""
weights = DimensionWeightService.get_dimension_weights(DeptType.NURSING)
assert weights["financial"] == 0.20
assert weights["customer"] == 0.15
assert weights["internal_process"] == 0.50
assert weights["learning_growth"] == 0.15
def test_get_default_weights_unknown_type(self):
"""测试获取未知类型的默认权重"""
weights = DimensionWeightService.get_dimension_weights("unknown_type")
# 应返回通用默认值
assert "financial" in weights
assert "customer" in weights
@pytest.mark.asyncio
async def test_get_by_dept_type(self):
"""测试根据科室类型获取数据库配置"""
mock_db = AsyncMock()
# 创建模拟配置
config = MagicMock()
config.dept_type = DeptType.CLINICAL_SURGICAL
config.financial_weight = 0.60
config.customer_weight = 0.15
config.internal_process_weight = 0.20
config.learning_growth_weight = 0.05
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = config
with patch('app.services.dimension_weight_service.select') as mock_select:
mock_db.execute.return_value = mock_result
result = await DimensionWeightService.get_by_dept_type(
mock_db, DeptType.CLINICAL_SURGICAL
)
assert result is not None
class TestCreateUpdateWeights:
"""创建/更新权重配置测试"""
@pytest.mark.asyncio
async def test_create_weight_config(self):
"""测试创建权重配置"""
mock_db = AsyncMock()
# 模拟不存在现有配置
with patch.object(DimensionWeightService, 'get_by_dept_type', return_value=None):
result = await DimensionWeightService.create_or_update(
mock_db,
dept_type=DeptType.CLINICAL_SURGICAL,
financial_weight=0.60,
customer_weight=0.15,
internal_process_weight=0.20,
learning_growth_weight=0.05,
description="手术临床科室"
)
mock_db.add.assert_called_once()
@pytest.mark.asyncio
async def test_update_weight_config(self):
"""测试更新权重配置"""
mock_db = AsyncMock()
# 创建模拟现有配置
existing = MagicMock()
existing.dept_type = DeptType.CLINICAL_SURGICAL
existing.financial_weight = 0.50
existing.customer_weight = 0.20
with patch.object(DimensionWeightService, 'get_by_dept_type', return_value=existing):
result = await DimensionWeightService.create_or_update(
mock_db,
dept_type=DeptType.CLINICAL_SURGICAL,
financial_weight=0.60,
customer_weight=0.15,
internal_process_weight=0.20,
learning_growth_weight=0.05
)
assert existing.financial_weight == 0.60
assert existing.customer_weight == 0.15
@pytest.mark.asyncio
async def test_create_weight_config_invalid_total(self):
"""测试创建权重配置总和不为100%"""
mock_db = AsyncMock()
with patch.object(DimensionWeightService, 'get_by_dept_type', return_value=None):
with pytest.raises(ValueError, match="权重总和必须为100%"):
await DimensionWeightService.create_or_update(
mock_db,
dept_type=DeptType.CLINICAL_SURGICAL,
financial_weight=0.50, # 50%
customer_weight=0.30, # 30%
internal_process_weight=0.10, # 10%
learning_growth_weight=0.05 # 5%
# 总计: 95% != 100%
)
class TestCalculateWeightedScore:
"""维度加权得分计算测试"""
@pytest.mark.asyncio
async def test_calculate_dimension_weighted_score_surgical(self):
"""测试手术临床科室维度加权得分计算"""
mock_db = AsyncMock()
# 模拟数据库返回权重配置
config = MagicMock()
config.financial_weight = Decimal("0.60")
config.customer_weight = Decimal("0.15")
config.internal_process_weight = Decimal("0.20")
config.learning_growth_weight = Decimal("0.05")
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = config
with patch.object(DimensionWeightService, 'get_by_dept_type', return_value=config):
result = await DimensionWeightService.calculate_dimension_weighted_score(
mock_db,
dept_type=DeptType.CLINICAL_SURGICAL,
financial_score=90,
customer_score=85,
internal_process_score=88,
learning_growth_score=92
)
# 验证结果
assert "weights" in result
assert "weighted_scores" in result
assert "total_score" in result
# 计算总分
# 财务: 90 * 0.60 = 54
# 客户: 85 * 0.15 = 12.75
# 流程: 88 * 0.20 = 17.6
# 学习: 92 * 0.05 = 4.6
# 总分: 54 + 12.75 + 17.6 + 4.6 = 88.95
assert result["total_score"] == 88.95
@pytest.mark.asyncio
async def test_calculate_dimension_weighted_score_nursing(self):
"""测试护理单元维度加权得分计算"""
mock_db = AsyncMock()
config = MagicMock()
config.financial_weight = Decimal("0.20")
config.customer_weight = Decimal("0.15")
config.internal_process_weight = Decimal("0.50")
config.learning_growth_weight = Decimal("0.15")
with patch.object(DimensionWeightService, 'get_by_dept_type', return_value=config):
result = await DimensionWeightService.calculate_dimension_weighted_score(
mock_db,
dept_type=DeptType.NURSING,
financial_score=80,
customer_score=90,
internal_process_score=85,
learning_growth_score=88
)
# 护理单元的内部流程权重最高(50%)
assert result["weights"]["internal_process"] == 0.50
assert result["weighted_scores"]["internal_process"] == 42.5 # 85 * 0.50
class TestInitDefaultWeights:
"""默认权重初始化测试"""
def test_default_weights_completeness(self):
"""测试默认权重覆盖所有科室类型"""
expected_types = [
DeptType.CLINICAL_SURGICAL,
DeptType.CLINICAL_NONSURGICAL_WARD,
DeptType.CLINICAL_NONSURGICAL_NOWARD,
DeptType.MEDICAL_TECH,
DeptType.MEDICAL_AUXILIARY,
DeptType.NURSING,
DeptType.ADMIN,
DeptType.FINANCE,
DeptType.LOGISTICS,
]
for dept_type in expected_types:
assert dept_type in DEFAULT_WEIGHTS, f"缺少科室类型 {dept_type} 的默认权重"
def test_default_weights_sum_to_one(self):
"""测试所有默认权重总和为1"""
for dept_type, weights in DEFAULT_WEIGHTS.items():
total = (
weights["financial"] +
weights["customer"] +
weights["internal_process"] +
weights["learning_growth"]
)
assert abs(total - 1.0) < 0.01, f"{dept_type} 权重总和为 {total},不为 1.0"
@pytest.mark.asyncio
async def test_init_default_weights(self):
"""测试初始化默认权重"""
mock_db = AsyncMock()
# 模拟不存在现有配置
with patch.object(DimensionWeightService, 'get_by_dept_type', return_value=None):
with patch.object(DimensionWeightService, 'create_or_update') as mock_create:
mock_create.return_value = MagicMock()
result = await DimensionWeightService.init_default_weights(mock_db)
# 应该为每个科室类型创建配置
assert len(result) == len(DEFAULT_WEIGHTS)
class TestDeleteWeight:
"""删除权重配置测试"""
@pytest.mark.asyncio
async def test_delete_weight_config(self):
"""测试删除权重配置(软删除)"""
mock_db = AsyncMock()
config = MagicMock()
config.id = 1
config.is_active = True
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = config
with patch('app.services.dimension_weight_service.select') as mock_select:
mock_db.execute.return_value = mock_result
result = await DimensionWeightService.delete(mock_db, 1)
assert result is True
assert config.is_active is False
@pytest.mark.asyncio
async def test_delete_nonexistent_config(self):
"""测试删除不存在的配置"""
mock_db = AsyncMock()
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = None
with patch('app.services.dimension_weight_service.select') as mock_select:
mock_db.execute.return_value = mock_result
result = await DimensionWeightService.delete(mock_db, 999)
assert result is False

535
backend/tests/test_e2e.py Normal file
View File

@@ -0,0 +1,535 @@
"""
完整闭环测试用例 - End-to-End Tests
测试覆盖完整的业务流程:
1. 用户登录 -> 获取Token
2. 科室管理 -> CRUD操作
3. 员工管理 -> CRUD操作
4. 考核指标管理 -> CRUD操作
5. 考核流程 -> 创建、提交、审核、确认
6. 满意度调查 -> 创建问卷、提交回答、统计
7. 评分方法计算 -> 各种评分方法验证
8. BSC维度权重 -> 配置和计算
"""
import pytest
import httpx
import asyncio
from typing import Optional
# API基础URL
BASE_URL = "http://localhost:8000/api/v1"
class TestAuth:
"""认证测试"""
@pytest.mark.asyncio
async def test_login_success(self):
"""测试登录成功"""
async with httpx.AsyncClient() as client:
response = await client.post(
f"{BASE_URL}/auth/login-json",
json={"username": "admin", "password": "admin123"}
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 200
assert "access_token" in data["data"]
return data["data"]["access_token"]
@pytest.mark.asyncio
async def test_login_failure(self):
"""测试登录失败"""
async with httpx.AsyncClient() as client:
response = await client.post(
f"{BASE_URL}/auth/login-json",
json={"username": "admin", "password": "wrong_password"}
)
assert response.status_code == 401
class TestDepartmentManagement:
"""科室管理闭环测试"""
@pytest.fixture
async def auth_token(self):
"""获取认证Token"""
async with httpx.AsyncClient() as client:
response = await client.post(
f"{BASE_URL}/auth/login-json",
json={"username": "admin", "password": "admin123"}
)
return response.json()["data"]["access_token"]
@pytest.mark.asyncio
async def test_get_department_list(self, auth_token):
"""测试获取科室列表"""
async with httpx.AsyncClient() as client:
response = await client.get(
f"{BASE_URL}/departments",
headers={"Authorization": f"Bearer {auth_token}"}
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 200
assert isinstance(data["data"], list)
@pytest.mark.asyncio
async def test_create_and_delete_department(self, auth_token):
"""测试创建和删除科室"""
async with httpx.AsyncClient() as client:
headers = {"Authorization": f"Bearer {auth_token}"}
# 创建科室
create_response = await client.post(
f"{BASE_URL}/departments",
headers=headers,
json={
"name": "测试科室E2E",
"code": "TEST_E2E_001",
"dept_type": "clinical_surgical",
"description": "E2E测试用科室"
}
)
assert create_response.status_code == 200
created = create_response.json()
assert created["code"] == 200
dept_id = created["data"]["id"]
# 获取科室详情
get_response = await client.get(
f"{BASE_URL}/departments/{dept_id}",
headers=headers
)
assert get_response.status_code == 200
# 删除科室
delete_response = await client.delete(
f"{BASE_URL}/departments/{dept_id}",
headers=headers
)
assert delete_response.status_code == 200
class TestStaffManagement:
"""员工管理闭环测试"""
@pytest.fixture
async def auth_token(self):
async with httpx.AsyncClient() as client:
response = await client.post(
f"{BASE_URL}/auth/login",
json={"username": "admin", "password": "admin123"}
)
return response.json()["data"]["access_token"]
@pytest.fixture
async def department_id(self, auth_token):
"""获取一个科室ID用于测试"""
async with httpx.AsyncClient() as client:
response = await client.get(
f"{BASE_URL}/departments",
headers={"Authorization": f"Bearer {auth_token}"}
)
departments = response.json()["data"]
if departments:
return departments[0]["id"]
return None
@pytest.mark.asyncio
async def test_get_staff_list(self, auth_token):
"""测试获取员工列表"""
async with httpx.AsyncClient() as client:
response = await client.get(
f"{BASE_URL}/staff",
headers={"Authorization": f"Bearer {auth_token}"}
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 200
class TestIndicatorManagement:
"""考核指标管理闭环测试"""
@pytest.fixture
async def auth_token(self):
async with httpx.AsyncClient() as client:
response = await client.post(
f"{BASE_URL}/auth/login",
json={"username": "admin", "password": "admin123"}
)
return response.json()["data"]["access_token"]
@pytest.mark.asyncio
async def test_get_indicator_list(self, auth_token):
"""测试获取指标列表"""
async with httpx.AsyncClient() as client:
response = await client.get(
f"{BASE_URL}/indicators",
headers={"Authorization": f"Bearer {auth_token}"}
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 200
@pytest.mark.asyncio
async def test_create_indicator(self, auth_token):
"""测试创建指标"""
async with httpx.AsyncClient() as client:
response = await client.post(
f"{BASE_URL}/indicators",
headers={"Authorization": f"Bearer {auth_token}"},
json={
"name": "E2E测试指标",
"code": "E2E_TEST_001",
"indicator_type": "quality",
"bs_dimension": "internal_process",
"weight": 1.0,
"max_score": 100,
"target_value": 95,
"target_unit": "%",
"calculation_method": "测试计算方法",
"assessment_method": "interval_high"
}
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 200
# 清理:删除创建的指标
indicator_id = data["data"]["id"]
async with httpx.AsyncClient() as client:
await client.delete(
f"{BASE_URL}/indicators/{indicator_id}",
headers={"Authorization": f"Bearer {auth_token}"}
)
class TestAssessmentWorkflow:
"""考核流程闭环测试"""
@pytest.fixture
async def auth_token(self):
async with httpx.AsyncClient() as client:
response = await client.post(
f"{BASE_URL}/auth/login",
json={"username": "admin", "password": "admin123"}
)
return response.json()["data"]["access_token"]
@pytest.mark.asyncio
async def test_get_assessment_list(self, auth_token):
"""测试获取考核列表"""
async with httpx.AsyncClient() as client:
response = await client.get(
f"{BASE_URL}/assessments",
headers={"Authorization": f"Bearer {auth_token}"}
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 200
class TestSurveyManagement:
"""满意度调查闭环测试"""
@pytest.fixture
async def auth_token(self):
async with httpx.AsyncClient() as client:
response = await client.post(
f"{BASE_URL}/auth/login",
json={"username": "admin", "password": "admin123"}
)
return response.json()["data"]["access_token"]
@pytest.mark.asyncio
async def test_get_survey_list(self, auth_token):
"""测试获取问卷列表"""
async with httpx.AsyncClient() as client:
response = await client.get(
f"{BASE_URL}/surveys",
headers={"Authorization": f"Bearer {auth_token}"}
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 200
assert isinstance(data["data"], list)
@pytest.mark.asyncio
async def test_get_survey_detail(self, auth_token):
"""测试获取问卷详情"""
# 先获取问卷列表
async with httpx.AsyncClient() as client:
list_response = await client.get(
f"{BASE_URL}/surveys",
headers={"Authorization": f"Bearer {auth_token}"}
)
surveys = list_response.json()["data"]
if surveys:
survey_id = surveys[0]["id"]
detail_response = await client.get(
f"{BASE_URL}/surveys/{survey_id}",
headers={"Authorization": f"Bearer {auth_token}"}
)
assert detail_response.status_code == 200
data = detail_response.json()
assert data["code"] == 200
assert data["data"]["id"] == survey_id
@pytest.mark.asyncio
async def test_create_and_close_survey(self, auth_token):
"""测试创建和关闭问卷"""
async with httpx.AsyncClient() as client:
headers = {"Authorization": f"Bearer {auth_token}"}
# 创建问卷
create_response = await client.post(
f"{BASE_URL}/surveys",
headers=headers,
json={
"survey_name": "E2E测试问卷",
"survey_code": "E2E_TEST_SURVEY_001",
"survey_type": "inpatient",
"description": "E2E测试用问卷",
"is_anonymous": True,
"questions": [
{
"question_text": "您对服务是否满意?",
"question_type": "score",
"score_max": 5,
"is_required": True
}
]
}
)
assert create_response.status_code == 200
created = create_response.json()
assert created["code"] == 200
survey_id = created["data"]["id"]
# 发布问卷
publish_response = await client.post(
f"{BASE_URL}/surveys/{survey_id}/publish",
headers=headers
)
assert publish_response.status_code == 200
# 关闭问卷
close_response = await client.post(
f"{BASE_URL}/surveys/{survey_id}/close",
headers=headers
)
assert close_response.status_code == 200
@pytest.mark.asyncio
async def test_get_department_satisfaction(self, auth_token):
"""测试获取科室满意度统计"""
async with httpx.AsyncClient() as client:
response = await client.get(
f"{BASE_URL}/surveys/stats/department",
headers={"Authorization": f"Bearer {auth_token}"}
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 200
class TestScoringMethodIntegration:
"""评分方法集成测试"""
@pytest.mark.asyncio
async def test_target_reference_method(self):
"""测试目标参照法(单元测试集成)"""
from app.services.scoring_service import ScoringService, ScoringMethod, ScoringParams
params = ScoringParams(weight=12.6, target_value=15.0)
result = ScoringService.calculate(
ScoringMethod.TARGET_REFERENCE,
actual_value=18.0,
params=params
)
assert result.score == 15.12 # 12.6 * (18/15)
@pytest.mark.asyncio
async def test_interval_high_method(self):
"""测试区间法-趋高指标"""
from app.services.scoring_service import ScoringService, ScoringMethod, ScoringParams
params = ScoringParams(
weight=12.6,
best_value=100,
baseline_value=80,
worst_value=0
)
result = ScoringService.calculate(
ScoringMethod.INTERVAL_HIGH,
actual_value=100,
params=params
)
assert result.score == 12.6 # 达到最佳值,满分
@pytest.mark.asyncio
async def test_interval_low_method(self):
"""测试区间法-趋低指标"""
from app.services.scoring_service import ScoringService, ScoringMethod, ScoringParams
params = ScoringParams(weight=12.6, target_value=25.0)
result = ScoringService.calculate(
ScoringMethod.INTERVAL_LOW,
actual_value=30.0,
params=params
)
# 12.6 * (25/30) = 10.5
assert result.score == 10.5
@pytest.mark.asyncio
async def test_deduction_method(self):
"""测试扣分法"""
from app.services.scoring_service import ScoringService, ScoringMethod, ScoringParams
params = ScoringParams(weight=3.2, deduction_per_unit=5.0)
result = ScoringService.calculate(
ScoringMethod.DEDUCTION,
actual_value=1, # 1次违规
params=params
)
assert result.score == 0 # 3.2 - 5 = -1.8 -> 0 (不计负分)
@pytest.mark.asyncio
async def test_bonus_method(self):
"""测试加分法"""
from app.services.scoring_service import ScoringService, ScoringMethod, ScoringParams
params = ScoringParams(
weight=4.5,
bonus_per_unit=2.0,
max_bonus_ratio=0.5
)
result = ScoringService.calculate(
ScoringMethod.BONUS,
actual_value=2, # 2项加分
params=params
)
assert result.score == 8.5 # 4.5 + 2*2 = 8.5
class TestDimensionWeight:
"""BSC维度权重测试"""
@pytest.mark.asyncio
async def test_default_weights(self):
"""测试默认权重配置"""
from app.services.dimension_weight_service import DimensionWeightService, DEFAULT_WEIGHTS
from app.models.models import DeptType
# 测试手术临床科室权重
weights = DimensionWeightService.get_dimension_weights(DeptType.CLINICAL_SURGICAL)
assert weights["financial"] == 0.60
assert weights["customer"] == 0.15
assert weights["internal_process"] == 0.20
assert weights["learning_growth"] == 0.05
# 测试护理单元权重
weights = DimensionWeightService.get_dimension_weights(DeptType.NURSING)
assert weights["financial"] == 0.20
assert weights["internal_process"] == 0.50 # 护理单元内部流程权重最高
@pytest.mark.asyncio
async def test_weight_sum_to_one(self):
"""测试所有权重总和为1"""
from app.services.dimension_weight_service import DEFAULT_WEIGHTS
for dept_type, weights in DEFAULT_WEIGHTS.items():
total = (
weights["financial"] +
weights["customer"] +
weights["internal_process"] +
weights["learning_growth"]
)
assert abs(total - 1.0) < 0.01, f"{dept_type} 权重总和不为1"
class TestStatsAPI:
"""统计报表API测试"""
@pytest.fixture
async def auth_token(self):
async with httpx.AsyncClient() as client:
response = await client.post(
f"{BASE_URL}/auth/login",
json={"username": "admin", "password": "admin123"}
)
return response.json()["data"]["access_token"]
@pytest.mark.asyncio
async def test_get_bsc_dimension_stats(self, auth_token):
"""测试获取BSC维度统计"""
async with httpx.AsyncClient() as client:
response = await client.get(
f"{BASE_URL}/stats/bsc-dimension",
headers={"Authorization": f"Bearer {auth_token}"}
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_get_department_stats(self, auth_token):
"""测试获取科室统计"""
async with httpx.AsyncClient() as client:
response = await client.get(
f"{BASE_URL}/stats/department",
headers={"Authorization": f"Bearer {auth_token}"}
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_get_ranking_stats(self, auth_token):
"""测试获取排名统计"""
async with httpx.AsyncClient() as client:
response = await client.get(
f"{BASE_URL}/stats/ranking",
headers={"Authorization": f"Bearer {auth_token}"}
)
assert response.status_code == 200
class TestHealthCheck:
"""健康检查测试"""
@pytest.mark.asyncio
async def test_health_endpoint(self):
"""测试健康检查端点"""
async with httpx.AsyncClient() as client:
response = await client.get("http://localhost:8000/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "healthy"
@pytest.mark.asyncio
async def test_api_docs_available(self):
"""测试API文档可访问"""
async with httpx.AsyncClient() as client:
response = await client.get(f"{BASE_URL}/docs")
assert response.status_code == 200

View File

@@ -0,0 +1,455 @@
"""
评分方法计算服务测试用例
测试覆盖:
1. 目标参照法计算
2. 区间法-趋高指标计算
3. 区间法-趋低指标计算
4. 区间法-趋中指标计算
5. 扣分法计算
6. 加分法计算
"""
import pytest
from app.services.scoring_service import (
ScoringService, ScoringMethod, ScoringParams, ScoringResult
)
class TestTargetReferenceMethod:
"""目标参照法测试"""
def test_target_reference_above_target(self):
"""测试实际值超过目标值"""
params = ScoringParams(
weight=12.6,
target_value=15.0
)
result = ScoringService.calculate(
ScoringMethod.TARGET_REFERENCE,
actual_value=18.0,
params=params
)
assert result.score == 15.12 # 12.6 * (18/15)
assert result.actual_value == 18.0
assert result.method == "target_reference"
def test_target_reference_below_target(self):
"""测试实际值低于目标值"""
params = ScoringParams(
weight=12.6,
target_value=15.0
)
result = ScoringService.calculate(
ScoringMethod.TARGET_REFERENCE,
actual_value=12.0,
params=params
)
assert result.score == 10.08 # 12.6 * (12/15)
def test_target_reference_zero_target(self):
"""测试目标值为零的情况"""
params = ScoringParams(
weight=12.6,
target_value=0
)
result = ScoringService.calculate(
ScoringMethod.TARGET_REFERENCE,
actual_value=18.0,
params=params
)
assert result.score == 0
assert "error" in result.details
class TestIntervalHighMethod:
"""区间法-趋高指标测试"""
def test_interval_high_at_best(self):
"""测试达到最佳值"""
params = ScoringParams(
weight=12.6,
best_value=100,
baseline_value=80,
worst_value=0
)
result = ScoringService.calculate(
ScoringMethod.INTERVAL_HIGH,
actual_value=100,
params=params
)
assert result.score == 12.6 # 满分
def test_interval_high_above_best(self):
"""测试超过最佳值"""
params = ScoringParams(
weight=12.6,
best_value=100,
baseline_value=80,
worst_value=0
)
result = ScoringService.calculate(
ScoringMethod.INTERVAL_HIGH,
actual_value=120,
params=params
)
assert result.score == 12.6 # 满分
def test_interval_high_between_baseline_and_best(self):
"""测试在基准值和最佳值之间"""
params = ScoringParams(
weight=12.6,
best_value=100,
baseline_value=80,
worst_value=0
)
result = ScoringService.calculate(
ScoringMethod.INTERVAL_HIGH,
actual_value=90,
params=params
)
# (90-0)/(100-0) * 12.6 = 11.34
assert result.score == 11.34
def test_interval_high_below_baseline(self):
"""测试低于基准值"""
params = ScoringParams(
weight=12.6,
best_value=100,
baseline_value=80,
worst_value=0
)
result = ScoringService.calculate(
ScoringMethod.INTERVAL_HIGH,
actual_value=60,
params=params
)
# 12.6 * (60/80) * 0.8 = 7.56
assert result.score == 7.56
def test_interval_high_missing_params(self):
"""测试缺少参数"""
params = ScoringParams(
weight=12.6,
best_value=None,
baseline_value=80
)
result = ScoringService.calculate(
ScoringMethod.INTERVAL_HIGH,
actual_value=90,
params=params
)
assert result.score == 0
assert "error" in result.details
class TestIntervalLowMethod:
"""区间法-趋低指标测试"""
def test_interval_low_at_target(self):
"""测试达到目标值"""
params = ScoringParams(
weight=12.6,
target_value=25.0
)
result = ScoringService.calculate(
ScoringMethod.INTERVAL_LOW,
actual_value=25.0,
params=params
)
assert result.score == 12.6 # 满分
def test_interval_low_below_target(self):
"""测试低于目标值"""
params = ScoringParams(
weight=12.6,
target_value=25.0
)
result = ScoringService.calculate(
ScoringMethod.INTERVAL_LOW,
actual_value=20.0,
params=params
)
assert result.score == 12.6 # 满分
def test_interval_low_above_target(self):
"""测试超过目标值"""
params = ScoringParams(
weight=12.6,
target_value=25.0
)
result = ScoringService.calculate(
ScoringMethod.INTERVAL_LOW,
actual_value=30.0,
params=params
)
# 12.6 * (25/30) = 10.5
assert result.score == 10.5
def test_interval_low_significantly_above_target(self):
"""测试远超目标值"""
params = ScoringParams(
weight=12.6,
target_value=25.0
)
result = ScoringService.calculate(
ScoringMethod.INTERVAL_LOW,
actual_value=50.0,
params=params
)
# 12.6 * (25/50) = 6.3
assert result.score == 6.3
class TestIntervalCenterMethod:
"""区间法-趋中指标测试"""
def test_interval_center_within_deviation(self):
"""测试在允许偏差范围内"""
params = ScoringParams(
weight=12.6,
target_value=10.0,
allowed_deviation=2.0
)
result = ScoringService.calculate(
ScoringMethod.INTERVAL_CENTER,
actual_value=11.0,
params=params
)
assert result.score == 12.6 # 满分
def test_interval_center_outside_deviation(self):
"""测试超出允许偏差"""
params = ScoringParams(
weight=12.6,
target_value=10.0,
allowed_deviation=2.0
)
result = ScoringService.calculate(
ScoringMethod.INTERVAL_CENTER,
actual_value=14.0,
params=params
)
# 偏差 = 14-10 = 4, 超出 = 4-2 = 2
# 扣分比例 = 2/2 = 1
# 得分 = 12.6 * (1-1) = 0
assert result.score == 0
def test_interval_center_partial_deviation(self):
"""测试部分超出允许偏差"""
params = ScoringParams(
weight=12.6,
target_value=10.0,
allowed_deviation=2.0
)
result = ScoringService.calculate(
ScoringMethod.INTERVAL_CENTER,
actual_value=12.5,
params=params
)
# 偏差 = 12.5-10 = 2.5, 超出 = 2.5-2 = 0.5
# 扣分比例 = 0.5/2 = 0.25
# 得分 = 12.6 * (1-0.25) = 9.45
assert result.score == 9.45
class TestDeductionMethod:
"""扣分法测试"""
def test_deduction_no_occurrence(self):
"""测试无违规情况"""
params = ScoringParams(
weight=12.6,
deduction_per_unit=5.0
)
result = ScoringService.calculate(
ScoringMethod.DEDUCTION,
actual_value=0,
params=params
)
assert result.score == 12.6 # 满分
def test_deduction_one_occurrence(self):
"""测试一次违规"""
params = ScoringParams(
weight=12.6,
deduction_per_unit=5.0
)
result = ScoringService.calculate(
ScoringMethod.DEDUCTION,
actual_value=1,
params=params
)
assert result.score == 7.6 # 12.6 - 5
def test_deduction_multiple_occurrences(self):
"""测试多次违规"""
params = ScoringParams(
weight=12.6,
deduction_per_unit=5.0
)
result = ScoringService.calculate(
ScoringMethod.DEDUCTION,
actual_value=2,
params=params
)
assert result.score == 2.6 # 12.6 - 10
def test_deduction_exhausted(self):
"""测试扣分超过满分"""
params = ScoringParams(
weight=12.6,
deduction_per_unit=5.0
)
result = ScoringService.calculate(
ScoringMethod.DEDUCTION,
actual_value=3,
params=params
)
assert result.score == 0 # 不计负分
class TestBonusMethod:
"""加分法测试"""
def test_bonus_no_bonus(self):
"""测试无加分项"""
params = ScoringParams(
weight=4.5,
bonus_per_unit=2.0,
max_bonus_ratio=0.5
)
result = ScoringService.calculate(
ScoringMethod.BONUS,
actual_value=0,
params=params
)
assert result.score == 4.5 # 基础分
def test_bonus_one_item(self):
"""测试一项加分"""
params = ScoringParams(
weight=4.5,
bonus_per_unit=2.0,
max_bonus_ratio=0.5
)
result = ScoringService.calculate(
ScoringMethod.BONUS,
actual_value=1,
params=params
)
assert result.score == 6.5 # 4.5 + 2
def test_bonus_multiple_items(self):
"""测试多项加分"""
params = ScoringParams(
weight=4.5,
bonus_per_unit=2.0,
max_bonus_ratio=0.5
)
result = ScoringService.calculate(
ScoringMethod.BONUS,
actual_value=3,
params=params
)
# 3项 * 2分 = 6分但最大加分为权重的50% = 2.25
# 所以总分 = 4.5 + 2.25 = 6.75
assert result.score == 6.75
assert result.details["status"] == "达到加分上限"
def test_bonus_exceeds_max(self):
"""测试加分超过上限"""
params = ScoringParams(
weight=4.5,
bonus_per_unit=2.0,
max_bonus_ratio=0.5
)
result = ScoringService.calculate(
ScoringMethod.BONUS,
actual_value=10,
params=params
)
# 最大加分为权重的50% = 2.25
# 总分 = 4.5 + 2.25 = 6.75
assert result.score == 6.75
assert result.details["status"] == "达到加分上限"
class TestCalculateAssessmentScore:
"""考核得分计算测试"""
def test_calculate_assessment_score(self):
"""测试综合考核得分计算"""
details = [
{
"indicator_id": 1,
"indicator_name": "业务收支结余率",
"bs_dimension": "financial",
"weight": 12.6,
"actual_value": 18.0,
"scoring_method": "target_reference",
"scoring_params": {"target_value": 15.0}
},
{
"indicator_id": 2,
"indicator_name": "百元收入耗材率",
"bs_dimension": "financial",
"weight": 12.6,
"actual_value": 23.0,
"scoring_method": "interval_low",
"scoring_params": {"target_value": 25.0}
},
{
"indicator_id": 3,
"indicator_name": "病历质量考核",
"bs_dimension": "internal_process",
"weight": 3.2,
"actual_value": 0,
"scoring_method": "deduction",
"scoring_params": {"deduction_per_unit": 5.0}
}
]
result = ScoringService.calculate_assessment_score(details)
assert "total_score" in result
assert "weighted_score" in result
assert "dimensions" in result
assert result["dimensions"]["financial"]["score"] > 0
class TestInvalidMethod:
"""无效方法测试"""
def test_invalid_method(self):
"""测试无效的评分方法"""
params = ScoringParams(weight=10.0)
with pytest.raises(ValueError, match="不支持的评分方法"):
ScoringService.calculate(
"invalid_method",
actual_value=50.0,
params=params
)

View File

@@ -0,0 +1,279 @@
"""
满意度调查服务测试用例
测试覆盖:
1. 问卷管理功能
2. 题目管理功能
3. 回答提交功能
4. 统计分析功能
"""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from datetime import datetime
from app.services.survey_service import SurveyService
from app.models.models import (
Survey, SurveyQuestion, SurveyResponse, SurveyAnswer,
SurveyStatus, SurveyType, QuestionType
)
class TestSurveyManagement:
"""问卷管理测试"""
@pytest.mark.asyncio
async def test_create_survey(self):
"""测试创建问卷"""
mock_db = AsyncMock()
# 模拟数据库操作
survey = MagicMock()
survey.id = 1
survey.survey_name = "测试问卷"
survey.survey_code = "TEST_001"
survey.survey_type = SurveyType.INPATIENT
survey.status = SurveyStatus.DRAFT
with patch.object(SurveyService, 'get_survey_by_id', return_value=None):
with patch.object(SurveyService, 'get_survey_list', return_value=([], 0)):
result = await SurveyService.create_survey(
mock_db,
survey_name="测试问卷",
survey_code="TEST_001",
survey_type=SurveyType.INPATIENT
)
mock_db.add.assert_called_once()
@pytest.mark.asyncio
async def test_publish_survey(self):
"""测试发布问卷"""
mock_db = AsyncMock()
# 创建模拟问卷
survey = MagicMock()
survey.id = 1
survey.status = SurveyStatus.DRAFT
survey.total_questions = 5
with patch.object(SurveyService, 'get_survey_by_id', return_value=survey):
result = await SurveyService.publish_survey(mock_db, 1)
assert survey.status == SurveyStatus.PUBLISHED
mock_db.flush.assert_called()
@pytest.mark.asyncio
async def test_publish_survey_without_questions(self):
"""测试发布无题目的问卷(应失败)"""
mock_db = AsyncMock()
survey = MagicMock()
survey.id = 1
survey.status = SurveyStatus.DRAFT
survey.total_questions = 0
with patch.object(SurveyService, 'get_survey_by_id', return_value=survey):
with pytest.raises(ValueError, match="问卷没有题目"):
await SurveyService.publish_survey(mock_db, 1)
@pytest.mark.asyncio
async def test_close_survey(self):
"""测试结束问卷"""
mock_db = AsyncMock()
survey = MagicMock()
survey.id = 1
survey.status = SurveyStatus.PUBLISHED
with patch.object(SurveyService, 'get_survey_by_id', return_value=survey):
result = await SurveyService.close_survey(mock_db, 1)
assert survey.status == SurveyStatus.CLOSED
class TestQuestionManagement:
"""题目管理测试"""
@pytest.mark.asyncio
async def test_add_question(self):
"""测试添加题目"""
mock_db = AsyncMock()
survey = MagicMock()
survey.id = 1
survey.status = SurveyStatus.DRAFT
survey.total_questions = 0
with patch.object(SurveyService, 'get_survey_by_id', return_value=survey):
# 模拟查询最大排序号
mock_result = MagicMock()
mock_result.scalar.return_value = None
with patch('app.services.survey_service.select') as mock_select:
mock_db.execute.return_value = mock_result
result = await SurveyService.add_question(
mock_db,
survey_id=1,
question_text="您对服务是否满意?",
question_type=QuestionType.SCORE
)
mock_db.add.assert_called()
@pytest.mark.asyncio
async def test_add_question_to_published_survey(self):
"""测试向已发布问卷添加题目(应失败)"""
mock_db = AsyncMock()
survey = MagicMock()
survey.id = 1
survey.status = SurveyStatus.PUBLISHED
with patch.object(SurveyService, 'get_survey_by_id', return_value=survey):
with pytest.raises(ValueError, match="只有草稿状态的问卷可以添加题目"):
await SurveyService.add_question(
mock_db,
survey_id=1,
question_text="测试题目",
question_type=QuestionType.SCORE
)
class TestResponseSubmission:
"""回答提交测试"""
@pytest.mark.asyncio
async def test_submit_response(self):
"""测试提交问卷回答"""
mock_db = AsyncMock()
# 创建模拟问卷
survey = MagicMock()
survey.id = 1
survey.status = SurveyStatus.PUBLISHED
# 创建模拟题目
question = MagicMock()
question.id = 1
question.question_type = QuestionType.SCORE
question.score_max = 5
question.options = None
# 设置mock返回值
mock_q_result = MagicMock()
mock_q_result.scalar_one_or_none.return_value = question
with patch.object(SurveyService, 'get_survey_by_id', return_value=survey):
with patch('app.services.survey_service.select') as mock_select:
mock_db.execute.return_value = mock_q_result
result = await SurveyService.submit_response(
mock_db,
survey_id=1,
department_id=1,
answers=[
{"question_id": 1, "answer_value": "4"}
]
)
mock_db.add.assert_called()
@pytest.mark.asyncio
async def test_submit_response_to_closed_survey(self):
"""测试向已结束问卷提交回答(应失败)"""
mock_db = AsyncMock()
survey = MagicMock()
survey.id = 1
survey.status = SurveyStatus.CLOSED
with patch.object(SurveyService, 'get_survey_by_id', return_value=survey):
with pytest.raises(ValueError, match="问卷未发布或已结束"):
await SurveyService.submit_response(
mock_db,
survey_id=1,
department_id=1,
answers=[]
)
class TestSatisfactionStats:
"""满意度统计测试"""
@pytest.mark.asyncio
async def test_get_department_satisfaction(self):
"""测试获取科室满意度统计"""
mock_db = AsyncMock()
# 模拟查询结果
mock_result = MagicMock()
mock_row = MagicMock()
mock_row.department_id = 1
mock_row.department_name = "内科"
mock_row.response_count = 10
mock_row.avg_satisfaction = 85.5
mock_row.total_score = 850.0
mock_row.max_score = 1000.0
mock_result.fetchall.return_value = [mock_row]
with patch('app.services.survey_service.select') as mock_select:
mock_db.execute.return_value = mock_result
result = await SurveyService.get_department_satisfaction(
mock_db,
survey_id=1
)
assert len(result) == 1
assert result[0]["department_name"] == "内科"
assert result[0]["avg_satisfaction"] == 85.5
class TestQuestionTypes:
"""不同题型测试"""
def test_score_question_options(self):
"""测试评分题选项"""
question = MagicMock()
question.question_type = QuestionType.SCORE
question.score_max = 5
question.options = None # 评分题不需要选项
# 评分题不需要选项
assert question.options is None
def test_single_choice_options(self):
"""测试单选题选项"""
import json
options = [
{"label": "非常满意", "value": "5", "score": 5},
{"label": "满意", "value": "4", "score": 4},
{"label": "一般", "value": "3", "score": 3},
{"label": "不满意", "value": "2", "score": 2}
]
question = MagicMock()
question.question_type = QuestionType.SINGLE_CHOICE
question.options = json.dumps(options, ensure_ascii=False)
parsed_options = json.loads(question.options)
assert len(parsed_options) == 4
def test_multiple_choice_options(self):
"""测试多选题选项"""
import json
options = [
{"label": "选项A", "value": "a", "score": 1},
{"label": "选项B", "value": "b", "score": 1},
{"label": "选项C", "value": "c", "score": 1}
]
question = MagicMock()
question.question_type = QuestionType.MULTIPLE_CHOICE
question.options = json.dumps(options, ensure_ascii=False)
parsed_options = json.loads(question.options)
assert len(parsed_options) == 3