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,520 @@
"""
满意度调查服务
功能:
1. 调查问卷管理 - 问卷CRUD、题目管理
2. 调查响应处理 - 提交回答、计算得分
3. 满意度统计 - 科室满意度、趋势分析
"""
from typing import Optional, List, Dict, Any
from datetime import datetime
from sqlalchemy import select, func, and_, or_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
import json
from app.models.models import (
Survey, SurveyQuestion, SurveyResponse, SurveyAnswer,
SurveyStatus, SurveyType, QuestionType,
Department
)
class SurveyService:
"""满意度调查服务"""
# ==================== 问卷管理 ====================
@staticmethod
async def get_survey_list(
db: AsyncSession,
survey_type: Optional[SurveyType] = None,
status: Optional[SurveyStatus] = None,
page: int = 1,
page_size: int = 20
) -> tuple[List[Survey], int]:
"""获取问卷列表"""
query = select(Survey).options(
selectinload(Survey.questions)
)
if survey_type:
query = query.where(Survey.survey_type == survey_type)
if status:
query = query.where(Survey.status == status)
# 统计总数
count_query = select(func.count()).select_from(query.subquery())
total = await db.scalar(count_query)
# 分页
query = query.order_by(Survey.created_at.desc())
query = query.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
surveys = result.scalars().all()
return list(surveys), total or 0
@staticmethod
async def get_survey_by_id(db: AsyncSession, survey_id: int) -> Optional[Survey]:
"""获取问卷详情"""
result = await db.execute(
select(Survey)
.options(
selectinload(Survey.questions)
)
.where(Survey.id == survey_id)
)
return result.scalar_one_or_none()
@staticmethod
async def create_survey(
db: AsyncSession,
survey_name: str,
survey_code: str,
survey_type: SurveyType,
description: Optional[str] = None,
target_departments: Optional[List[int]] = None,
is_anonymous: bool = True,
created_by: Optional[int] = None
) -> Survey:
"""创建问卷"""
survey = Survey(
survey_name=survey_name,
survey_code=survey_code,
survey_type=survey_type,
description=description,
target_departments=json.dumps(target_departments) if target_departments else None,
is_anonymous=is_anonymous,
created_by=created_by,
status=SurveyStatus.DRAFT
)
db.add(survey)
await db.flush()
await db.refresh(survey)
return survey
@staticmethod
async def update_survey(
db: AsyncSession,
survey_id: int,
**kwargs
) -> Optional[Survey]:
"""更新问卷"""
survey = await SurveyService.get_survey_by_id(db, survey_id)
if not survey:
return None
# 只有草稿状态可以修改
if survey.status != SurveyStatus.DRAFT:
raise ValueError("只有草稿状态的问卷可以修改")
for key, value in kwargs.items():
if hasattr(survey, key):
if key == "target_departments" and isinstance(value, list):
value = json.dumps(value)
setattr(survey, key, value)
await db.flush()
await db.refresh(survey)
return survey
@staticmethod
async def publish_survey(db: AsyncSession, survey_id: int) -> Optional[Survey]:
"""发布问卷"""
survey = await SurveyService.get_survey_by_id(db, survey_id)
if not survey:
return None
if survey.status != SurveyStatus.DRAFT:
raise ValueError("只有草稿状态的问卷可以发布")
# 检查是否有题目
if survey.total_questions == 0:
raise ValueError("问卷没有题目,无法发布")
survey.status = SurveyStatus.PUBLISHED
survey.start_date = datetime.utcnow()
await db.flush()
await db.refresh(survey)
return survey
@staticmethod
async def close_survey(db: AsyncSession, survey_id: int) -> Optional[Survey]:
"""结束问卷"""
survey = await SurveyService.get_survey_by_id(db, survey_id)
if not survey:
return None
if survey.status != SurveyStatus.PUBLISHED:
raise ValueError("只有已发布的问卷可以结束")
survey.status = SurveyStatus.CLOSED
survey.end_date = datetime.utcnow()
await db.flush()
await db.refresh(survey)
return survey
@staticmethod
async def delete_survey(db: AsyncSession, survey_id: int) -> bool:
"""删除问卷"""
survey = await SurveyService.get_survey_by_id(db, survey_id)
if not survey:
return False
if survey.status == SurveyStatus.PUBLISHED:
raise ValueError("发布中的问卷无法删除")
await db.delete(survey)
await db.flush()
return True
# ==================== 题目管理 ====================
@staticmethod
async def add_question(
db: AsyncSession,
survey_id: int,
question_text: str,
question_type: QuestionType,
options: Optional[List[Dict]] = None,
score_max: int = 5,
is_required: bool = True
) -> SurveyQuestion:
"""添加题目"""
survey = await SurveyService.get_survey_by_id(db, survey_id)
if not survey:
raise ValueError("问卷不存在")
if survey.status != SurveyStatus.DRAFT:
raise ValueError("只有草稿状态的问卷可以添加题目")
# 获取最大排序号
result = await db.execute(
select(func.max(SurveyQuestion.sort_order))
.where(SurveyQuestion.survey_id == survey_id)
)
max_order = result.scalar() or 0
question = SurveyQuestion(
survey_id=survey_id,
question_text=question_text,
question_type=question_type,
options=json.dumps(options, ensure_ascii=False) if options else None,
score_max=score_max,
is_required=is_required,
sort_order=max_order + 1
)
db.add(question)
# 更新问卷题目数
survey.total_questions += 1
await db.flush()
await db.refresh(question)
return question
@staticmethod
async def update_question(
db: AsyncSession,
question_id: int,
**kwargs
) -> Optional[SurveyQuestion]:
"""更新题目"""
result = await db.execute(
select(SurveyQuestion).where(SurveyQuestion.id == question_id)
)
question = result.scalar_one_or_none()
if not question:
return None
# 检查问卷状态
survey = await SurveyService.get_survey_by_id(db, question.survey_id)
if survey and survey.status != SurveyStatus.DRAFT:
raise ValueError("只有草稿状态的问卷题目可以修改")
for key, value in kwargs.items():
if hasattr(question, key):
if key == "options" and isinstance(value, list):
value = json.dumps(value, ensure_ascii=False)
setattr(question, key, value)
await db.flush()
await db.refresh(question)
return question
@staticmethod
async def delete_question(db: AsyncSession, question_id: int) -> bool:
"""删除题目"""
result = await db.execute(
select(SurveyQuestion).where(SurveyQuestion.id == question_id)
)
question = result.scalar_one_or_none()
if not question:
return False
# 检查问卷状态
survey = await SurveyService.get_survey_by_id(db, question.survey_id)
if survey and survey.status != SurveyStatus.DRAFT:
raise ValueError("只有草稿状态的问卷题目可以删除")
# 更新问卷题目数
if survey:
survey.total_questions -= 1
await db.delete(question)
await db.flush()
return True
# ==================== 提交回答 ====================
@staticmethod
async def submit_response(
db: AsyncSession,
survey_id: int,
department_id: Optional[int],
answers: List[Dict[str, Any]],
respondent_type: str = "patient",
respondent_id: Optional[int] = None,
respondent_phone: Optional[str] = None,
ip_address: Optional[str] = None,
user_agent: Optional[str] = None
) -> SurveyResponse:
"""提交问卷回答"""
survey = await SurveyService.get_survey_by_id(db, survey_id)
if not survey:
raise ValueError("问卷不存在")
if survey.status != SurveyStatus.PUBLISHED:
raise ValueError("问卷未发布或已结束")
# 计算得分
total_score = 0.0
max_score = 0.0
# 创建回答记录
response = SurveyResponse(
survey_id=survey_id,
department_id=department_id,
respondent_type=respondent_type,
respondent_id=respondent_id,
respondent_phone=respondent_phone,
ip_address=ip_address,
user_agent=user_agent
)
db.add(response)
await db.flush()
# 处理每个回答
for answer_data in answers:
question_id = answer_data.get("question_id")
answer_value = answer_data.get("answer_value")
# 获取题目
q_result = await db.execute(
select(SurveyQuestion).where(SurveyQuestion.id == question_id)
)
question = q_result.scalar_one_or_none()
if not question:
continue
# 计算得分
score = 0.0
if question.question_type == QuestionType.SCORE:
# 评分题:直接取分值
try:
score = float(answer_value)
except (ValueError, TypeError):
score = 0
max_score += question.score_max
elif question.question_type == QuestionType.SINGLE_CHOICE:
# 单选题:根据选项得分
if question.options:
options = json.loads(question.options)
for opt in options:
if opt.get("value") == answer_value:
score = opt.get("score", 0)
break
max_score += 5 # 假设单选题最高5分
elif question.question_type == QuestionType.MULTIPLE_CHOICE:
# 多选题:累加选中选项得分
if question.options:
options = json.loads(question.options)
selected = answer_value.split(",") if answer_value else []
for opt in options:
if opt.get("value") in selected:
score += opt.get("score", 0)
max_score += 5
total_score += score
# 创建回答明细
answer = SurveyAnswer(
response_id=response.id,
question_id=question_id,
answer_value=str(answer_value) if answer_value else None,
score=score
)
db.add(answer)
# 更新回答记录得分
response.total_score = total_score
response.max_score = max_score if max_score > 0 else 1
response.satisfaction_rate = (total_score / response.max_score * 100) if response.max_score > 0 else 0
await db.flush()
await db.refresh(response)
return response
# ==================== 满意度统计 ====================
@staticmethod
async def get_department_satisfaction(
db: AsyncSession,
survey_id: Optional[int] = None,
department_id: Optional[int] = None,
period_year: Optional[int] = None,
period_month: Optional[int] = None
) -> List[Dict[str, Any]]:
"""获取科室满意度统计"""
query = select(
Department.id.label("department_id"),
Department.name.label("department_name"),
func.count(SurveyResponse.id).label("response_count"),
func.avg(SurveyResponse.satisfaction_rate).label("avg_satisfaction"),
func.sum(SurveyResponse.total_score).label("total_score"),
func.sum(SurveyResponse.max_score).label("max_score")
).join(
SurveyResponse, SurveyResponse.department_id == Department.id
)
conditions = []
if survey_id:
conditions.append(SurveyResponse.survey_id == survey_id)
if department_id:
conditions.append(Department.id == department_id)
if period_year and period_month:
from sqlalchemy import extract
conditions.append(extract('year', SurveyResponse.submitted_at) == period_year)
conditions.append(extract('month', SurveyResponse.submitted_at) == period_month)
if conditions:
query = query.where(and_(*conditions))
query = query.group_by(Department.id, Department.name)
query = query.order_by(func.avg(SurveyResponse.satisfaction_rate).desc())
result = await db.execute(query)
return [
{
"department_id": row.department_id,
"department_name": row.department_name,
"response_count": row.response_count,
"avg_satisfaction": round(float(row.avg_satisfaction), 2) if row.avg_satisfaction else 0,
"total_score": float(row.total_score) if row.total_score else 0,
"max_score": float(row.max_score) if row.max_score else 0
}
for row in result.fetchall()
]
@staticmethod
async def get_satisfaction_trend(
db: AsyncSession,
department_id: int,
months: int = 6
) -> List[Dict[str, Any]]:
"""获取满意度趋势"""
from sqlalchemy import extract
from datetime import datetime
current_date = datetime.now()
current_year = current_date.year
current_month = current_date.month
query = select(
extract('year', SurveyResponse.submitted_at).label("year"),
extract('month', SurveyResponse.submitted_at).label("month"),
func.count(SurveyResponse.id).label("response_count"),
func.avg(SurveyResponse.satisfaction_rate).label("avg_satisfaction")
).where(
SurveyResponse.department_id == department_id
).group_by(
extract('year', SurveyResponse.submitted_at),
extract('month', SurveyResponse.submitted_at)
).order_by(
extract('year', SurveyResponse.submitted_at),
extract('month', SurveyResponse.submitted_at)
)
result = await db.execute(query)
return [
{
"year": int(row.year),
"month": int(row.month),
"period": f"{int(row.year)}{int(row.month)}",
"response_count": row.response_count,
"avg_satisfaction": round(float(row.avg_satisfaction), 2) if row.avg_satisfaction else 0
}
for row in result.fetchall()
]
@staticmethod
async def get_question_stats(db: AsyncSession, survey_id: int) -> List[Dict[str, Any]]:
"""获取问卷各题目统计"""
# 获取问卷题目
questions_result = await db.execute(
select(SurveyQuestion)
.where(SurveyQuestion.survey_id == survey_id)
.order_by(SurveyQuestion.sort_order)
)
questions = questions_result.scalars().all()
stats = []
for question in questions:
# 统计该题目的回答
answer_result = await db.execute(
select(
func.count(SurveyAnswer.id).label("count"),
func.avg(SurveyAnswer.score).label("avg_score"),
func.sum(SurveyAnswer.score).label("total_score")
).where(SurveyAnswer.question_id == question.id)
)
row = answer_result.fetchone()
question_stat = {
"question_id": question.id,
"question_text": question.question_text,
"question_type": question.question_type.value,
"response_count": row.count if row else 0,
"avg_score": round(float(row.avg_score), 2) if row and row.avg_score else 0,
"total_score": float(row.total_score) if row and row.total_score else 0,
"max_possible_score": question.score_max
}
# 如果是选择题,统计各选项占比
if question.question_type in [QuestionType.SINGLE_CHOICE, QuestionType.MULTIPLE_CHOICE]:
if question.options:
options = json.loads(question.options)
option_stats = []
for opt in options:
count_result = await db.execute(
select(func.count(SurveyAnswer.id))
.where(SurveyAnswer.question_id == question.id)
.where(SurveyAnswer.answer_value.contains(opt.get("value")))
)
opt_count = count_result.scalar() or 0
option_stats.append({
"option": opt.get("label"),
"value": opt.get("value"),
"count": opt_count
})
question_stat["option_stats"] = option_stats
stats.append(question_stat)
return stats