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

10
backend/.env.example Normal file
View File

@@ -0,0 +1,10 @@
# 环境变量配置
# 数据库配置 (PostgreSQL)
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/hospital_performance
# JWT 配置
SECRET_KEY=your-secret-key-change-in-production-min-32-characters
# 调试模式
DEBUG=True

43
backend/alembic.ini Normal file
View File

@@ -0,0 +1,43 @@
# Alembic Config file
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = sqlite+aiosqlite:///./hospital_performance.db
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname =
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

68
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,68 @@
"""
Alembic环境配置
"""
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from app.core.config import settings
from app.models.models import Base
config = context.config
# 使用 settings 中的数据库 URL
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""离线模式运行迁移"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""异步模式运行迁移"""
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""在线模式运行迁移"""
import asyncio
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,182 @@
"""${message}
Revision ID: initial
Revises:
Create Date: 2024-01-01 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'initial'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 创建科室表
op.create_table(
'departments',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('name', sa.String(length=100), nullable=False, comment='科室名称'),
sa.Column('code', sa.String(length=20), nullable=False, comment='科室编码'),
sa.Column('dept_type', sa.String(length=50), nullable=False, comment='科室类型'),
sa.Column('parent_id', sa.Integer(), nullable=True, comment='上级科室'),
sa.Column('level', sa.Integer(), nullable=True, comment='层级'),
sa.Column('sort_order', sa.Integer(), nullable=True, comment='排序'),
sa.Column('is_active', sa.Boolean(), nullable=True, comment='是否启用'),
sa.Column('description', sa.Text(), nullable=True, comment='描述'),
sa.Column('created_at', sa.DateTime(), nullable=True, comment='创建时间'),
sa.Column('updated_at', sa.DateTime(), nullable=True, comment='更新时间'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('code')
)
op.create_index('idx_dept_type', 'departments', ['dept_type'])
op.create_index('idx_dept_parent', 'departments', ['parent_id'])
# 创建员工表
op.create_table(
'staff',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('employee_id', sa.String(length=20), nullable=False, comment='工号'),
sa.Column('name', sa.String(length=50), nullable=False, comment='姓名'),
sa.Column('department_id', sa.Integer(), nullable=False, comment='所属科室'),
sa.Column('position', sa.String(length=50), nullable=False, comment='职位'),
sa.Column('title', sa.String(length=50), nullable=True, comment='职称'),
sa.Column('phone', sa.String(length=20), nullable=True, comment='联系电话'),
sa.Column('email', sa.String(length=100), nullable=True, comment='邮箱'),
sa.Column('base_salary', sa.Numeric(precision=10, scale=2), nullable=True, comment='基本工资'),
sa.Column('performance_ratio', sa.Numeric(precision=5, scale=2), nullable=True, comment='绩效系数'),
sa.Column('status', sa.String(length=50), nullable=True, comment='状态'),
sa.Column('hire_date', sa.DateTime(), nullable=True, comment='入职日期'),
sa.Column('created_at', sa.DateTime(), nullable=True, comment='创建时间'),
sa.Column('updated_at', sa.DateTime(), nullable=True, comment='更新时间'),
sa.ForeignKeyConstraint(['department_id'], ['departments.id']),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('employee_id')
)
op.create_index('idx_staff_dept', 'staff', ['department_id'])
op.create_index('idx_staff_status', 'staff', ['status'])
# 创建指标表
op.create_table(
'indicators',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('name', sa.String(length=100), nullable=False, comment='指标名称'),
sa.Column('code', sa.String(length=20), nullable=False, comment='指标编码'),
sa.Column('indicator_type', sa.String(length=50), nullable=False, comment='指标类型'),
sa.Column('weight', sa.Numeric(precision=5, scale=2), nullable=True, comment='权重'),
sa.Column('max_score', sa.Numeric(precision=5, scale=2), nullable=True, comment='最高分值'),
sa.Column('target_value', sa.Numeric(precision=10, scale=2), nullable=True, comment='目标值'),
sa.Column('unit', sa.String(length=20), nullable=True, comment='计量单位'),
sa.Column('calculation_method', sa.Text(), nullable=True, comment='计算方法'),
sa.Column('description', sa.Text(), nullable=True, comment='描述'),
sa.Column('is_active', sa.Boolean(), nullable=True, comment='是否启用'),
sa.Column('created_at', sa.DateTime(), nullable=True, comment='创建时间'),
sa.Column('updated_at', sa.DateTime(), nullable=True, comment='更新时间'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('code')
)
op.create_index('idx_indicator_type', 'indicators', ['indicator_type'])
# 创建考核记录表
op.create_table(
'assessments',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('staff_id', sa.Integer(), nullable=False, comment='员工ID'),
sa.Column('period_year', sa.Integer(), nullable=False, comment='考核年度'),
sa.Column('period_month', sa.Integer(), nullable=False, comment='考核月份'),
sa.Column('period_type', sa.String(length=20), nullable=True, comment='考核周期类型'),
sa.Column('total_score', sa.Numeric(precision=5, scale=2), nullable=True, comment='总分'),
sa.Column('weighted_score', sa.Numeric(precision=5, scale=2), nullable=True, comment='加权得分'),
sa.Column('status', sa.String(length=50), nullable=True, comment='状态'),
sa.Column('assessor_id', sa.Integer(), nullable=True, comment='考核人'),
sa.Column('reviewer_id', sa.Integer(), nullable=True, comment='审核人'),
sa.Column('submit_time', sa.DateTime(), nullable=True, comment='提交时间'),
sa.Column('review_time', sa.DateTime(), nullable=True, comment='审核时间'),
sa.Column('remark', sa.Text(), nullable=True, comment='备注'),
sa.Column('created_at', sa.DateTime(), nullable=True, comment='创建时间'),
sa.Column('updated_at', sa.DateTime(), nullable=True, comment='更新时间'),
sa.ForeignKeyConstraint(['staff_id'], ['staff.id']),
sa.ForeignKeyConstraint(['assessor_id'], ['staff.id']),
sa.ForeignKeyConstraint(['reviewer_id'], ['staff.id']),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_assessment_staff', 'assessments', ['staff_id'])
op.create_index('idx_assessment_period', 'assessments', ['period_year', 'period_month'])
op.create_index('idx_assessment_status', 'assessments', ['status'])
# 创建考核明细表
op.create_table(
'assessment_details',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('assessment_id', sa.Integer(), nullable=False, comment='考核记录ID'),
sa.Column('indicator_id', sa.Integer(), nullable=False, comment='指标ID'),
sa.Column('actual_value', sa.Numeric(precision=10, scale=2), nullable=True, comment='实际值'),
sa.Column('score', sa.Numeric(precision=5, scale=2), nullable=True, comment='得分'),
sa.Column('evidence', sa.Text(), nullable=True, comment='佐证材料'),
sa.Column('remark', sa.Text(), nullable=True, comment='备注'),
sa.Column('created_at', sa.DateTime(), nullable=True, comment='创建时间'),
sa.Column('updated_at', sa.DateTime(), nullable=True, comment='更新时间'),
sa.ForeignKeyConstraint(['assessment_id'], ['assessments.id']),
sa.ForeignKeyConstraint(['indicator_id'], ['indicators.id']),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_detail_assessment', 'assessment_details', ['assessment_id'])
op.create_index('idx_detail_indicator', 'assessment_details', ['indicator_id'])
# 创建工资记录表
op.create_table(
'salary_records',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('staff_id', sa.Integer(), nullable=False, comment='员工ID'),
sa.Column('period_year', sa.Integer(), nullable=False, comment='年度'),
sa.Column('period_month', sa.Integer(), nullable=False, comment='月份'),
sa.Column('base_salary', sa.Numeric(precision=10, scale=2), nullable=True, comment='基本工资'),
sa.Column('performance_score', sa.Numeric(precision=5, scale=2), nullable=True, comment='绩效得分'),
sa.Column('performance_bonus', sa.Numeric(precision=10, scale=2), nullable=True, comment='绩效奖金'),
sa.Column('deduction', sa.Numeric(precision=10, scale=2), nullable=True, comment='扣款'),
sa.Column('allowance', sa.Numeric(precision=10, scale=2), nullable=True, comment='补贴'),
sa.Column('total_salary', sa.Numeric(precision=10, scale=2), nullable=True, comment='应发工资'),
sa.Column('status', sa.String(length=50), nullable=True, comment='状态'),
sa.Column('remark', sa.Text(), nullable=True, comment='备注'),
sa.Column('created_at', sa.DateTime(), nullable=True, comment='创建时间'),
sa.Column('updated_at', sa.DateTime(), nullable=True, comment='更新时间'),
sa.ForeignKeyConstraint(['staff_id'], ['staff.id']),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_salary_staff', 'salary_records', ['staff_id'])
op.create_index('idx_salary_period', 'salary_records', ['period_year', 'period_month'])
# 创建用户表
op.create_table(
'users',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('username', sa.String(length=50), nullable=False, comment='用户名'),
sa.Column('password_hash', sa.String(length=255), nullable=False, comment='密码哈希'),
sa.Column('staff_id', sa.Integer(), nullable=True, comment='关联员工'),
sa.Column('role', sa.String(length=20), nullable=True, comment='角色'),
sa.Column('is_active', sa.Boolean(), nullable=True, comment='是否启用'),
sa.Column('last_login', sa.DateTime(), nullable=True, comment='最后登录'),
sa.Column('created_at', sa.DateTime(), nullable=True, comment='创建时间'),
sa.Column('updated_at', sa.DateTime(), nullable=True, comment='更新时间'),
sa.ForeignKeyConstraint(['staff_id'], ['staff.id']),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('username')
)
op.create_index('idx_user_username', 'users', ['username'])
def downgrade() -> None:
op.drop_table('users')
op.drop_table('salary_records')
op.drop_table('assessment_details')
op.drop_table('assessments')
op.drop_table('indicators')
op.drop_table('staff')
op.drop_table('departments')

View File

@@ -0,0 +1,95 @@
"""添加指标模板表
Revision ID: 002_template
Revises: initial
Create Date: 2024-01-02 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '002_template'
down_revision: Union[str, None] = 'initial'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 创建指标模板表
op.create_table(
'indicator_templates',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('template_name', sa.String(length=200), nullable=False, comment='模板名称'),
sa.Column('template_code', sa.String(length=50), nullable=False, comment='模板编码'),
sa.Column('template_type', sa.String(length=30), nullable=False, comment='模板类型'),
sa.Column('description', sa.Text(), nullable=True, comment='模板描述'),
sa.Column('dimension_weights', sa.Text(), nullable=True, comment='维度权重 (JSON)'),
sa.Column('assessment_cycle', sa.String(length=20), nullable=True, comment='考核周期'),
sa.Column('is_active', sa.Boolean(), nullable=True, comment='是否启用'),
sa.Column('created_at', sa.DateTime(), nullable=True, comment='创建时间'),
sa.Column('updated_at', sa.DateTime(), nullable=True, comment='更新时间'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('template_code')
)
op.create_index('idx_template_type', 'indicator_templates', ['template_type'])
op.create_index('idx_template_active', 'indicator_templates', ['is_active'])
# 创建模板指标关联表
op.create_table(
'template_indicators',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('template_id', sa.Integer(), nullable=False, comment='模板 ID'),
sa.Column('indicator_id', sa.Integer(), nullable=False, comment='指标 ID'),
sa.Column('category', sa.String(length=100), nullable=True, comment='指标分类'),
sa.Column('target_value', sa.Numeric(precision=10, scale=2), nullable=True, comment='目标值'),
sa.Column('target_unit', sa.String(length=50), nullable=True, comment='目标值单位'),
sa.Column('weight', sa.Numeric(precision=5, scale=2), nullable=True, comment='权重'),
sa.Column('scoring_method', sa.String(length=50), nullable=True, comment='评分方法'),
sa.Column('scoring_params', sa.Text(), nullable=True, comment='评分参数 (JSON)'),
sa.Column('sort_order', sa.Integer(), nullable=True, comment='排序'),
sa.Column('remark', sa.Text(), nullable=True, comment='备注'),
sa.Column('created_at', sa.DateTime(), nullable=True, comment='创建时间'),
sa.Column('updated_at', sa.DateTime(), nullable=True, comment='更新时间'),
sa.ForeignKeyConstraint(['template_id'], ['indicator_templates.id']),
sa.ForeignKeyConstraint(['indicator_id'], ['indicators.id']),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_ti_template', 'template_indicators', ['template_id'])
op.create_index('idx_ti_indicator', 'template_indicators', ['indicator_id'])
op.create_index('idx_ti_unique', 'template_indicators', ['template_id', 'indicator_id'], unique=True)
# 为指标表添加 BSC 维度字段(如果不存在)
# 注意:在 PostgreSQL 中,如果字段已存在会报错,需要检查
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = [col['name'] for col in inspector.get_columns('indicators')]
if 'bs_dimension' not in columns:
op.add_column('indicators', sa.Column('bs_dimension', sa.String(length=30), nullable=True, comment='平衡计分卡维度'))
if 'target_unit' not in columns:
op.add_column('indicators', sa.Column('target_unit', sa.String(length=50), nullable=True, comment='目标值单位'))
if 'assessment_method' not in columns:
op.add_column('indicators', sa.Column('assessment_method', sa.Text(), nullable=True, comment='考核方法'))
if 'deduction_standard' not in columns:
op.add_column('indicators', sa.Column('deduction_standard', sa.Text(), nullable=True, comment='扣分标准'))
if 'data_source' not in columns:
op.add_column('indicators', sa.Column('data_source', sa.String(length=100), nullable=True, comment='数据来源'))
if 'applicable_dept_types' not in columns:
op.add_column('indicators', sa.Column('applicable_dept_types', sa.Text(), nullable=True, comment='适用科室类型'))
if 'is_veto' not in columns:
op.add_column('indicators', sa.Column('is_veto', sa.Boolean(), nullable=True, default=False, comment='是否一票否决指标'))
def downgrade() -> None:
op.drop_table('template_indicators')
op.drop_table('indicator_templates')

View File

0
backend/app/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,17 @@
from fastapi import APIRouter
from app.api.v1 import departments, staff, indicators, assessments, salary, stats, auth, finance, performance_plans, menus, templates, surveys
api_router = APIRouter()
api_router.include_router(auth.router)
api_router.include_router(departments.router)
api_router.include_router(staff.router)
api_router.include_router(indicators.router)
api_router.include_router(assessments.router)
api_router.include_router(salary.router)
api_router.include_router(stats.router)
api_router.include_router(finance.router)
api_router.include_router(performance_plans.router)
api_router.include_router(menus.router)
api_router.include_router(templates.router)
api_router.include_router(surveys.router)

View File

@@ -0,0 +1,165 @@
"""
API路由 - 绩效考核管理
"""
from typing import Annotated, Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_active_user, get_current_manager_user
from app.schemas.schemas import (
AssessmentCreate, AssessmentUpdate, AssessmentResponse,
AssessmentListResponse, ResponseBase
)
from app.services.assessment_service import AssessmentService
from app.models.models import User
router = APIRouter(prefix="/assessments", tags=["绩效考核"])
@router.get("", summary="获取考核列表")
async def get_assessments(
staff_id: Optional[int] = Query(None, description="员工ID"),
department_id: Optional[int] = Query(None, description="科室ID"),
period_year: Optional[int] = Query(None, description="年度"),
period_month: Optional[int] = Query(None, description="月份"),
status: Optional[str] = Query(None, description="状态"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""获取考核列表"""
assessments, total = await AssessmentService.get_list(
db, staff_id, department_id, period_year, period_month, status, page, page_size
)
# 转换响应
result = []
for assessment in assessments:
item = AssessmentListResponse.model_validate(assessment).model_dump()
item["staff_name"] = assessment.staff.name if assessment.staff else None
item["department_name"] = assessment.staff.department.name if assessment.staff and assessment.staff.department else None
result.append(item)
return {
"code": 200,
"message": "success",
"data": result,
"total": total,
"page": page,
"page_size": page_size
}
@router.get("/{assessment_id}", summary="获取考核详情")
async def get_assessment(
assessment_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""获取考核详情"""
assessment = await AssessmentService.get_by_id(db, assessment_id)
if not assessment:
raise HTTPException(status_code=404, detail="考核记录不存在")
result = AssessmentResponse.model_validate(assessment).model_dump()
result["staff_name"] = assessment.staff.name if assessment.staff else None
result["department_name"] = assessment.staff.department.name if assessment.staff and assessment.staff.department else None
# 添加明细指标名称
for detail in result.get("details", []):
for d in assessment.details:
if d.id == detail["id"] and d.indicator:
detail["indicator_name"] = d.indicator.name
break
return {"code": 200, "message": "success", "data": result}
@router.post("", summary="创建考核记录")
async def create_assessment(
assessment_data: AssessmentCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""创建考核记录"""
assessment = await AssessmentService.create(db, assessment_data)
return {"code": 200, "message": "创建成功", "data": {"id": assessment.id}}
@router.put("/{assessment_id}", summary="更新考核记录")
async def update_assessment(
assessment_id: int,
assessment_data: AssessmentUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""更新考核记录"""
assessment = await AssessmentService.update(db, assessment_id, assessment_data)
if not assessment:
raise HTTPException(status_code=400, detail="无法更新,考核记录不存在或状态不允许")
return {"code": 200, "message": "更新成功", "data": {"id": assessment.id}}
@router.post("/{assessment_id}/submit", summary="提交考核")
async def submit_assessment(
assessment_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""提交考核"""
assessment = await AssessmentService.submit(db, assessment_id)
if not assessment:
raise HTTPException(status_code=400, detail="无法提交,考核记录不存在或状态不允许")
return {"code": 200, "message": "提交成功"}
@router.post("/{assessment_id}/review", summary="审核考核")
async def review_assessment(
assessment_id: int,
approved: bool = Query(..., description="是否通过"),
remark: Optional[str] = Query(None, description="审核意见"),
db: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_manager_user)] = None
):
"""审核考核(需要管理员或经理权限)"""
# 从当前用户获取审核人ID
reviewer_id = current_user.id
assessment = await AssessmentService.review(db, assessment_id, reviewer_id, approved, remark)
if not assessment:
raise HTTPException(status_code=400, detail="无法审核,考核记录不存在或状态不允许")
return {"code": 200, "message": "审核通过" if approved else "已驳回"}
@router.post("/{assessment_id}/finalize", summary="确认考核")
async def finalize_assessment(
assessment_id: int,
db: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_manager_user)] = None
):
"""确认考核(需要管理员或经理权限)"""
assessment = await AssessmentService.finalize(db, assessment_id)
if not assessment:
raise HTTPException(status_code=400, detail="无法确认,考核记录不存在或状态不允许")
return {"code": 200, "message": "确认成功"}
@router.post("/batch-create", summary="批量创建考核")
async def batch_create_assessments(
department_id: int = Query(..., description="科室ID"),
period_year: int = Query(..., description="年度"),
period_month: int = Query(..., description="月份"),
indicators: List[int] = Query(..., description="指标ID列表"),
db: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_manager_user)] = None
):
"""为科室批量创建考核(需要管理员或经理权限)"""
assessments = await AssessmentService.batch_create_for_department(
db, department_id, period_year, period_month, indicators
)
return {
"code": 200,
"message": f"成功创建 {len(assessments)} 条考核记录",
"data": {"count": len(assessments)}
}

135
backend/app/api/v1/auth.py Normal file
View File

@@ -0,0 +1,135 @@
"""
API路由 - 用户认证
"""
from typing import Annotated, List
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import get_db
from app.core.security import verify_password, create_access_token, get_current_active_user
from app.schemas.schemas import UserLogin, UserCreate, UserResponse, Token
from app.models.models import User
router = APIRouter(prefix="/auth", tags=["用户认证"])
@router.post("/login", response_model=Token, summary="用户登录")
async def login(
form_data: OAuth2PasswordRequestForm = Depends(),
db: AsyncSession = Depends(get_db)
):
"""用户登录"""
from sqlalchemy import select
result = await db.execute(
select(User).where(User.username == form_data.username)
)
user = result.scalar_one_or_none()
if not user or not verify_password(form_data.password, user.password_hash):
raise HTTPException(status_code=401, detail="用户名或密码错误")
if not user.is_active:
raise HTTPException(status_code=403, detail="账户已禁用")
access_token = create_access_token(user.id)
return {"access_token": access_token, "token_type": "bearer"}
@router.post("/login-json", summary="用户登录(JSON格式)")
async def login_json(
login_data: UserLogin,
db: AsyncSession = Depends(get_db)
):
"""用户登录(JSON格式)"""
result = await db.execute(
select(User).where(User.username == login_data.username)
)
user = result.scalar_one_or_none()
if not user or not verify_password(login_data.password, user.password_hash):
raise HTTPException(status_code=401, detail="用户名或密码错误")
if not user.is_active:
raise HTTPException(status_code=403, detail="账户已禁用")
access_token = create_access_token(user.id)
return {"code": 200, "message": "登录成功", "data": {"access_token": access_token, "token_type": "bearer"}}
@router.post("/register", summary="用户注册")
async def register(
user_data: UserCreate,
db: AsyncSession = Depends(get_db)
):
"""用户注册"""
from sqlalchemy import select
from app.core.security import get_password_hash
# 检查用户名是否已存在
result = await db.execute(
select(User).where(User.username == user_data.username)
)
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="用户名已存在")
user = User(
username=user_data.username,
password_hash=get_password_hash(user_data.password),
staff_id=user_data.staff_id,
role=user_data.role
)
db.add(user)
await db.flush()
return {"code": 200, "message": "注册成功", "data": {"id": user.id}}
@router.get("/me", response_model=UserResponse, summary="获取当前用户")
async def get_current_user_info(
current_user: Annotated[User, Depends(get_current_active_user)]
):
"""获取当前用户信息"""
return current_user
@router.get("/users", summary="获取用户列表")
async def get_users(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""获取用户列表(需要登录)"""
# 查询用户总数
count_result = await db.execute(select(User))
all_users = count_result.scalars().all()
total = len(all_users)
# 分页查询
offset = (page - 1) * page_size
result = await db.execute(
select(User).order_by(User.id.desc()).offset(offset).limit(page_size)
)
users = result.scalars().all()
return {
"code": 200,
"message": "success",
"data": [
{
"id": u.id,
"username": u.username,
"role": u.role,
"is_active": u.is_active,
"last_login": u.last_login,
"created_at": u.created_at
}
for u in users
],
"total": total,
"page": page,
"page_size": page_size
}

View File

@@ -0,0 +1,107 @@
"""
API路由 - 科室管理
"""
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_active_user, get_current_manager_user
from app.schemas.schemas import (
DepartmentCreate, DepartmentUpdate, DepartmentResponse,
ResponseBase, PaginatedResponse
)
from app.services.department_service import DepartmentService
from app.models.models import User
router = APIRouter(prefix="/departments", tags=["科室管理"])
@router.get("", summary="获取科室列表")
async def get_departments(
dept_type: Optional[str] = Query(None, description="科室类型"),
is_active: Optional[bool] = Query(None, description="是否启用"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""获取科室列表"""
departments, total = await DepartmentService.get_list(
db, dept_type, is_active, page, page_size
)
return {
"code": 200,
"message": "success",
"data": [DepartmentResponse.model_validate(d) for d in departments],
"total": total,
"page": page,
"page_size": page_size
}
@router.get("/tree", summary="获取科室树形结构")
async def get_department_tree(
dept_type: Optional[str] = Query(None, description="科室类型"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""获取科室树形结构"""
tree = await DepartmentService.get_tree(db, dept_type)
return {"code": 200, "message": "success", "data": tree}
@router.get("/{dept_id}", summary="获取科室详情")
async def get_department(
dept_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""获取科室详情"""
department = await DepartmentService.get_by_id(db, dept_id)
if not department:
raise HTTPException(status_code=404, detail="科室不存在")
return {"code": 200, "message": "success", "data": DepartmentResponse.model_validate(department)}
@router.post("", summary="创建科室")
async def create_department(
dept_data: DepartmentCreate,
db: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_manager_user)] = None
):
"""创建科室(需要管理员或经理权限)"""
# 检查编码是否已存在
existing = await DepartmentService.get_by_code(db, dept_data.code)
if existing:
raise HTTPException(status_code=400, detail="科室编码已存在")
department = await DepartmentService.create(db, dept_data)
return {"code": 200, "message": "创建成功", "data": DepartmentResponse.model_validate(department)}
@router.put("/{dept_id}", summary="更新科室")
async def update_department(
dept_id: int,
dept_data: DepartmentUpdate,
db: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_manager_user)] = None
):
"""更新科室(需要管理员或经理权限)"""
department = await DepartmentService.update(db, dept_id, dept_data)
if not department:
raise HTTPException(status_code=404, detail="科室不存在")
return {"code": 200, "message": "更新成功", "data": DepartmentResponse.model_validate(department)}
@router.delete("/{dept_id}", summary="删除科室")
async def delete_department(
dept_id: int,
db: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_manager_user)] = None
):
"""删除科室(需要管理员或经理权限)"""
success = await DepartmentService.delete(db, dept_id)
if not success:
raise HTTPException(status_code=400, detail="无法删除,科室下存在子科室")
return {"code": 200, "message": "删除成功"}

View File

@@ -0,0 +1,216 @@
"""
API路由 - 财务核算
"""
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_active_user, get_current_manager_user
from app.schemas.schemas import (
FinanceRecordCreate, FinanceRecordUpdate, FinanceRecordResponse,
DepartmentBalance, CategorySummary, ResponseBase
)
from app.services.finance_service import FinanceService
from app.models.finance import RevenueCategory, ExpenseCategory, FinanceType
from app.models.models import User
router = APIRouter(prefix="/finance", tags=["财务核算"])
@router.get("/revenue", summary="获取科室收入")
async def get_revenue(
department_id: Optional[int] = Query(None, description="科室ID"),
period_year: Optional[int] = Query(None, description="年度"),
period_month: Optional[int] = Query(None, description="月份"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""获取科室收入列表"""
data = await FinanceService.get_department_revenue(
db, department_id, period_year, period_month
)
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/expense", summary="获取科室支出")
async def get_expense(
department_id: Optional[int] = Query(None, description="科室ID"),
period_year: Optional[int] = Query(None, description="年度"),
period_month: Optional[int] = Query(None, description="月份"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""获取科室支出列表"""
data = await FinanceService.get_department_expense(
db, department_id, period_year, period_month
)
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/balance", summary="获取收支结余")
async def get_balance(
department_id: Optional[int] = Query(None, description="科室ID"),
period_year: Optional[int] = Query(None, description="年度"),
period_month: Optional[int] = Query(None, description="月份"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""获取科室收支结余"""
data = await FinanceService.get_department_balance(
db, department_id, period_year, period_month
)
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/revenue/by-category", summary="按类别统计收入")
async def get_revenue_by_category(
department_id: Optional[int] = Query(None, description="科室ID"),
period_year: Optional[int] = Query(None, description="年度"),
period_month: Optional[int] = Query(None, description="月份"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""按类别统计收入"""
data = await FinanceService.get_revenue_by_category(
db, department_id, period_year, period_month
)
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/expense/by-category", summary="按类别统计支出")
async def get_expense_by_category(
department_id: Optional[int] = Query(None, description="科室ID"),
period_year: Optional[int] = Query(None, description="年度"),
period_month: Optional[int] = Query(None, description="月份"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""按类别统计支出"""
data = await FinanceService.get_expense_by_category(
db, department_id, period_year, period_month
)
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/summary", summary="获取科室财务汇总")
async def get_department_summary(
period_year: int = Query(..., description="年度"),
period_month: int = Query(..., ge=1, le=12, description="月份"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""获取所有科室的财务汇总"""
data = await FinanceService.get_department_summary(
db, period_year, period_month
)
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/categories", summary="获取财务类别")
async def get_categories(
current_user: User = Depends(get_current_active_user)
):
"""获取收入和支出类别"""
revenue_categories = [
{"value": cat.value, "label": label}
for cat, label in FinanceService.REVENUE_LABELS.items()
]
expense_categories = [
{"value": cat.value, "label": label}
for cat, label in FinanceService.EXPENSE_LABELS.items()
]
return {
"code": 200,
"message": "success",
"data": {
"revenue": revenue_categories,
"expense": expense_categories
}
}
@router.post("", summary="创建财务记录")
async def create_finance_record(
record_data: FinanceRecordCreate,
db: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_manager_user)] = None
):
"""创建财务记录(需要管理员或经理权限)"""
# 验证类别
if record_data.finance_type == FinanceType.REVENUE:
valid_categories = [cat.value for cat in RevenueCategory]
else:
valid_categories = [cat.value for cat in ExpenseCategory]
if record_data.category not in valid_categories:
raise HTTPException(
status_code=400,
detail=f"无效的类别: {record_data.category}"
)
record = await FinanceService.create_finance_record(
db,
department_id=record_data.department_id,
finance_type=record_data.finance_type,
category=record_data.category,
amount=record_data.amount,
period_year=record_data.period_year,
period_month=record_data.period_month,
source=record_data.source,
remark=record_data.remark
)
return {"code": 200, "message": "创建成功", "data": {"id": record.id}}
@router.put("/{record_id}", summary="更新财务记录")
async def update_finance_record(
record_id: int,
record_data: FinanceRecordUpdate,
db: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_manager_user)] = None
):
"""更新财务记录(需要管理员或经理权限)"""
record = await FinanceService.update_finance_record(
db, record_id, **record_data.model_dump(exclude_unset=True)
)
if not record:
raise HTTPException(status_code=404, detail="财务记录不存在")
return {"code": 200, "message": "更新成功"}
@router.delete("/{record_id}", summary="删除财务记录")
async def delete_finance_record(
record_id: int,
db: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_manager_user)] = None
):
"""删除财务记录(需要管理员或经理权限)"""
success = await FinanceService.delete_finance_record(db, record_id)
if not success:
raise HTTPException(status_code=404, detail="财务记录不存在")
return {"code": 200, "message": "删除成功"}

View File

@@ -0,0 +1,141 @@
"""
API路由 - 考核指标管理
"""
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_active_user, get_current_manager_user
from app.schemas.schemas import (
IndicatorCreate, IndicatorUpdate, IndicatorResponse,
ResponseBase
)
from app.services.indicator_service import IndicatorService
from app.models.models import User
router = APIRouter(prefix="/indicators", tags=["考核指标"])
@router.get("", summary="获取指标列表")
async def get_indicators(
indicator_type: Optional[str] = Query(None, description="指标类型"),
bs_dimension: Optional[str] = Query(None, description="BSC 维度"),
is_active: Optional[bool] = Query(None, description="是否启用"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""获取指标列表"""
indicators, total = await IndicatorService.get_list(
db, indicator_type, bs_dimension, is_active, page, page_size
)
return {
"code": 200,
"message": "success",
"data": [IndicatorResponse.model_validate(i, from_attributes=True) for i in indicators],
"total": total,
"page": page,
"page_size": page_size
}
@router.get("/active", summary="获取所有启用的指标")
async def get_active_indicators(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""获取所有启用的指标"""
indicators = await IndicatorService.get_active_indicators(db)
return {
"code": 200,
"message": "success",
"data": [IndicatorResponse.model_validate(i, from_attributes=True) for i in indicators]
}
@router.get("/{indicator_id}", summary="获取指标详情")
async def get_indicator(
indicator_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""获取指标详情"""
indicator = await IndicatorService.get_by_id(db, indicator_id)
if not indicator:
raise HTTPException(status_code=404, detail="指标不存在")
return {"code": 200, "message": "success", "data": IndicatorResponse.model_validate(indicator, from_attributes=True)}
@router.post("", summary="创建指标")
async def create_indicator(
indicator_data: IndicatorCreate,
db: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_manager_user)] = None
):
"""创建指标(需要管理员或经理权限)"""
# 检查编码是否已存在
existing = await IndicatorService.get_by_code(db, indicator_data.code)
if existing:
raise HTTPException(status_code=400, detail="指标编码已存在")
indicator = await IndicatorService.create(db, indicator_data)
return {"code": 200, "message": "创建成功", "data": IndicatorResponse.model_validate(indicator, from_attributes=True)}
@router.put("/{indicator_id}", summary="更新指标")
async def update_indicator(
indicator_id: int,
indicator_data: IndicatorUpdate,
db: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_manager_user)] = None
):
"""更新指标(需要管理员或经理权限)"""
indicator = await IndicatorService.update(db, indicator_id, indicator_data)
if not indicator:
raise HTTPException(status_code=404, detail="指标不存在")
return {"code": 200, "message": "更新成功", "data": IndicatorResponse.model_validate(indicator, from_attributes=True)}
@router.delete("/{indicator_id}", summary="删除指标")
async def delete_indicator(
indicator_id: int,
db: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_manager_user)] = None
):
"""删除指标(需要管理员或经理权限)"""
success = await IndicatorService.delete(db, indicator_id)
if not success:
raise HTTPException(status_code=404, detail="指标不存在")
return {"code": 200, "message": "删除成功"}
@router.get("/templates/list", summary="获取指标模板列表")
async def get_indicator_templates(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""获取指标模板列表"""
templates = await IndicatorService.get_templates()
return {
"code": 200,
"message": "success",
"data": templates
}
@router.post("/templates/import", summary="导入指标模板")
async def import_indicator_template(
template_data: dict,
overwrite: bool = Query(False, description="是否覆盖已存在的指标"),
db: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_manager_user)] = None
):
"""导入指标模板"""
count = await IndicatorService.import_template(db, template_data, overwrite)
return {
"code": 200,
"message": f"成功导入 {count} 个指标",
"data": {"created_count": count}
}

163
backend/app/api/v1/menus.py Normal file
View File

@@ -0,0 +1,163 @@
"""
菜单管理 API
"""
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_active_user, get_current_manager_user
from app.schemas.schemas import MenuCreate, MenuUpdate, MenuResponse, ResponseBase
from app.services.menu_service import MenuService
from app.models.models import User, Menu
router = APIRouter(prefix="/menus", tags=["菜单管理"])
@router.get("/tree", summary="获取菜单树")
async def get_menu_tree(
visible_only: bool = Query(True, description="是否只返回可见菜单"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""获取菜单树形结构"""
tree = await MenuService.get_tree(db, visible_only)
return {
"code": 200,
"message": "success",
"data": tree
}
@router.get("", summary="获取菜单列表")
async def get_menus(
menu_type: Optional[str] = Query(None, description="菜单类型"),
is_visible: Optional[bool] = Query(None, description="是否可见"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""获取菜单列表"""
menus = await MenuService.get_list(db, menu_type, is_visible)
menu_list = []
for menu in menus:
menu_dict = {
"id": menu.id,
"parent_id": menu.parent_id,
"menu_type": menu.menu_type,
"menu_name": menu.menu_name,
"menu_icon": menu.menu_icon,
"path": menu.path,
"component": menu.component,
"permission": menu.permission,
"sort_order": menu.sort_order,
"is_visible": menu.is_visible,
"is_active": menu.is_active,
"created_at": menu.created_at,
"updated_at": menu.updated_at
}
menu_list.append(menu_dict)
return {
"code": 200,
"message": "success",
"data": menu_list
}
@router.get("/{menu_id}", summary="获取菜单详情")
async def get_menu(
menu_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""获取菜单详情"""
menu = await MenuService.get_by_id(db, menu_id)
if not menu:
raise HTTPException(status_code=404, detail="菜单不存在")
return {
"code": 200,
"message": "success",
"data": {
"id": menu.id,
"parent_id": menu.parent_id,
"menu_type": menu.menu_type,
"menu_name": menu.menu_name,
"menu_icon": menu.menu_icon,
"path": menu.path,
"component": menu.component,
"permission": menu.permission,
"sort_order": menu.sort_order,
"is_visible": menu.is_visible,
"is_active": menu.is_active
}
}
@router.post("", summary="创建菜单")
async def create_menu(
menu_data: MenuCreate,
db: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_manager_user)] = None
):
"""创建菜单(需要管理员或经理权限)"""
# 如果指定了父菜单,检查父菜单是否存在
if menu_data.parent_id:
parent = await MenuService.get_by_id(db, menu_data.parent_id)
if not parent:
raise HTTPException(status_code=400, detail="父菜单不存在")
menu = await MenuService.create(db, menu_data.model_dump())
return {
"code": 200,
"message": "创建成功",
"data": {"id": menu.id}
}
@router.put("/{menu_id}", summary="更新菜单")
async def update_menu(
menu_id: int,
menu_data: MenuUpdate,
db: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_manager_user)] = None
):
"""更新菜单(需要管理员或经理权限)"""
menu = await MenuService.update(db, menu_id, menu_data.model_dump(exclude_unset=True))
if not menu:
raise HTTPException(status_code=404, detail="菜单不存在")
return {
"code": 200,
"message": "更新成功",
"data": {"id": menu.id}
}
@router.delete("/{menu_id}", summary="删除菜单")
async def delete_menu(
menu_id: int,
db: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_manager_user)] = None
):
"""删除菜单(需要管理员或经理权限)"""
success = await MenuService.delete(db, menu_id)
if not success:
raise HTTPException(status_code=400, detail="无法删除,菜单不存在或存在子菜单")
return {
"code": 200,
"message": "删除成功"
}
@router.post("/init", summary="初始化默认菜单")
async def init_default_menus(
db: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_manager_user)] = None
):
"""初始化默认菜单(需要管理员权限)"""
await MenuService.init_default_menus(db)
return {
"code": 200,
"message": "初始化成功"
}

View File

@@ -0,0 +1,309 @@
"""
绩效计划管理 API
"""
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_active_user, get_current_manager_user
from app.schemas.schemas import (
PerformancePlanCreate, PerformancePlanUpdate, PerformancePlanResponse,
PerformancePlanStats, PlanKpiRelationCreate, PlanKpiRelationUpdate,
ResponseBase
)
from app.services.performance_plan_service import PerformancePlanService
from app.models.models import User, PlanStatus
router = APIRouter(prefix="/plans", tags=["绩效计划管理"])
@router.get("", summary="获取绩效计划列表")
async def get_performance_plans(
plan_level: Optional[str] = Query(None, description="计划层级"),
plan_year: Optional[int] = Query(None, description="计划年度"),
department_id: Optional[int] = Query(None, description="科室 ID"),
status: Optional[str] = Query(None, description="状态"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""获取绩效计划列表"""
plans, total = await PerformancePlanService.get_list(
db, plan_level, plan_year, department_id, status, page, page_size
)
# 构建响应数据
plan_list = []
for plan in plans:
plan_dict = {
"id": plan.id,
"plan_name": plan.plan_name,
"plan_code": plan.plan_code,
"plan_level": plan.plan_level,
"plan_year": plan.plan_year,
"plan_month": plan.plan_month,
"status": plan.status,
"department_id": plan.department_id,
"department_name": plan.department.name if plan.department else None,
"staff_id": plan.staff_id,
"staff_name": plan.staff.name if plan.staff else None,
"description": plan.description,
"created_at": plan.created_at,
"updated_at": plan.updated_at
}
plan_list.append(plan_dict)
return {
"code": 200,
"message": "success",
"data": plan_list,
"total": total,
"page": page,
"page_size": page_size
}
@router.get("/tree", summary="获取绩效计划树")
async def get_performance_plan_tree(
plan_year: Optional[int] = Query(None, description="计划年度"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""获取绩效计划树形结构"""
tree = await PerformancePlanService.get_tree(db, plan_year)
return {
"code": 200,
"message": "success",
"data": tree
}
@router.get("/stats", summary="获取绩效计划统计")
async def get_performance_plan_stats(
plan_year: Optional[int] = Query(None, description="计划年度"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""获取绩效计划统计信息"""
stats = await PerformancePlanService.get_stats(db, plan_year)
return {
"code": 200,
"message": "success",
"data": stats
}
@router.get("/{plan_id}", summary="获取绩效计划详情")
async def get_performance_plan(
plan_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""获取绩效计划详情"""
plan = await PerformancePlanService.get_by_id(db, plan_id)
if not plan:
raise HTTPException(status_code=404, detail="绩效计划不存在")
# 构建响应数据
kpi_relations = []
for relation in plan.kpi_relations:
kpi_relations.append({
"id": relation.id,
"indicator_id": relation.indicator_id,
"indicator_name": relation.indicator.name if relation.indicator else None,
"indicator_code": relation.indicator.code if relation.indicator else None,
"target_value": relation.target_value,
"target_unit": relation.target_unit,
"weight": relation.weight,
"scoring_method": relation.scoring_method,
"scoring_params": relation.scoring_params,
"remark": relation.remark
})
plan_data = {
"id": plan.id,
"plan_name": plan.plan_name,
"plan_code": plan.plan_code,
"plan_level": plan.plan_level,
"plan_year": plan.plan_year,
"plan_month": plan.plan_month,
"plan_type": plan.plan_type,
"department_id": plan.department_id,
"department_name": plan.department.name if plan.department else None,
"staff_id": plan.staff_id,
"staff_name": plan.staff.name if plan.staff else None,
"parent_plan_id": plan.parent_plan_id,
"description": plan.description,
"strategic_goals": plan.strategic_goals,
"key_initiatives": plan.key_initiatives,
"status": plan.status,
"submitter_id": plan.submitter_id,
"submit_time": plan.submit_time,
"approver_id": plan.approver_id,
"approve_time": plan.approve_time,
"approve_remark": plan.approve_remark,
"version": plan.version,
"is_active": plan.is_active,
"created_at": plan.created_at,
"updated_at": plan.updated_at,
"kpi_relations": kpi_relations
}
return {
"code": 200,
"message": "success",
"data": plan_data
}
@router.post("", summary="创建绩效计划")
async def create_performance_plan(
plan_data: PerformancePlanCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""创建绩效计划"""
plan = await PerformancePlanService.create(db, plan_data, current_user.id)
return {
"code": 200,
"message": "创建成功",
"data": {"id": plan.id}
}
@router.put("/{plan_id}", summary="更新绩效计划")
async def update_performance_plan(
plan_id: int,
plan_data: PerformancePlanUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""更新绩效计划"""
plan = await PerformancePlanService.update(db, plan_id, plan_data)
if not plan:
raise HTTPException(status_code=404, detail="绩效计划不存在")
return {
"code": 200,
"message": "更新成功",
"data": {"id": plan.id}
}
@router.post("/{plan_id}/submit", summary="提交绩效计划")
async def submit_performance_plan(
plan_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""提交绩效计划"""
plan = await PerformancePlanService.submit(db, plan_id)
if not plan:
raise HTTPException(status_code=400, detail="无法提交,计划不存在或状态不允许")
return {
"code": 200,
"message": "提交成功"
}
@router.post("/{plan_id}/approve", summary="审批绩效计划")
async def approve_performance_plan(
plan_id: int,
approved: bool = Query(..., description="是否通过"),
remark: Optional[str] = Query(None, description="审批意见"),
db: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_manager_user)] = None
):
"""审批绩效计划(需要管理员或经理权限)"""
plan = await PerformancePlanService.approve(db, plan_id, current_user.id, approved, remark)
if not plan:
raise HTTPException(status_code=400, detail="无法审批,计划不存在或状态不允许")
return {
"code": 200,
"message": "审核通过" if approved else "已驳回"
}
@router.post("/{plan_id}/activate", summary="激活绩效计划")
async def activate_performance_plan(
plan_id: int,
db: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_manager_user)] = None
):
"""激活绩效计划(需要管理员或经理权限)"""
plan = await PerformancePlanService.activate(db, plan_id)
if not plan:
raise HTTPException(status_code=400, detail="无法激活,计划不存在或状态不允许")
return {
"code": 200,
"message": "激活成功"
}
@router.delete("/{plan_id}", summary="删除绩效计划")
async def delete_performance_plan(
plan_id: int,
db: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_manager_user)] = None
):
"""删除绩效计划(需要管理员或经理权限)"""
success = await PerformancePlanService.delete(db, plan_id)
if not success:
raise HTTPException(status_code=404, detail="绩效计划不存在")
return {
"code": 200,
"message": "删除成功"
}
@router.post("/{plan_id}/kpi-relations", summary="添加计划指标关联")
async def add_kpi_relation(
plan_id: int,
kpi_data: PlanKpiRelationCreate,
db: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_manager_user)] = None
):
"""添加计划指标关联(需要管理员或经理权限)"""
relation = await PerformancePlanService.add_kpi_relation(db, plan_id, kpi_data)
if not relation:
raise HTTPException(status_code=404, detail="绩效计划不存在")
return {
"code": 200,
"message": "添加成功",
"data": {"id": relation.id}
}
@router.put("/kpi-relations/{relation_id}", summary="更新计划指标关联")
async def update_kpi_relation(
relation_id: int,
kpi_data: PlanKpiRelationUpdate,
db: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_manager_user)] = None
):
"""更新计划指标关联(需要管理员或经理权限)"""
relation = await PerformancePlanService.update_kpi_relation(db, relation_id, kpi_data.model_dump())
if not relation:
raise HTTPException(status_code=404, detail="指标关联不存在")
return {
"code": 200,
"message": "更新成功",
"data": {"id": relation.id}
}
@router.delete("/kpi-relations/{relation_id}", summary="删除计划指标关联")
async def delete_kpi_relation(
relation_id: int,
db: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_manager_user)] = None
):
"""删除计划指标关联(需要管理员或经理权限)"""
success = await PerformancePlanService.delete_kpi_relation(db, relation_id)
if not success:
raise HTTPException(status_code=404, detail="指标关联不存在")
return {
"code": 200,
"message": "删除成功"
}

View File

@@ -0,0 +1,155 @@
"""
API路由 - 工资核算管理
"""
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_active_user, get_current_manager_user
from app.schemas.schemas import (
SalaryRecordCreate, SalaryRecordUpdate, SalaryRecordResponse,
ResponseBase
)
from app.services.salary_service import SalaryService
from app.models.models import User
router = APIRouter(prefix="/salary", tags=["工资核算"])
@router.get("", summary="获取工资记录列表")
async def get_salary_records(
staff_id: Optional[int] = Query(None, description="员工ID"),
department_id: Optional[int] = Query(None, description="科室ID"),
period_year: Optional[int] = Query(None, description="年度"),
period_month: Optional[int] = Query(None, description="月份"),
status: Optional[str] = Query(None, description="状态"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""获取工资记录列表"""
records, total = await SalaryService.get_list(
db, staff_id, department_id, period_year, period_month, status, page, page_size
)
result = []
for record in records:
item = SalaryRecordResponse.model_validate(record).model_dump()
item["staff_name"] = record.staff.name if record.staff else None
item["department_name"] = record.staff.department.name if record.staff and record.staff.department else None
result.append(item)
return {
"code": 200,
"message": "success",
"data": result,
"total": total,
"page": page,
"page_size": page_size
}
@router.get("/{record_id}", summary="获取工资记录详情")
async def get_salary_record(
record_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""获取工资记录详情"""
record = await SalaryService.get_by_id(db, record_id)
if not record:
raise HTTPException(status_code=404, detail="工资记录不存在")
result = SalaryRecordResponse.model_validate(record).model_dump()
result["staff_name"] = record.staff.name if record.staff else None
result["department_name"] = record.staff.department.name if record.staff and record.staff.department else None
return {"code": 200, "message": "success", "data": result}
@router.post("", summary="创建工资记录")
async def create_salary_record(
record_data: SalaryRecordCreate,
db: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_manager_user)] = None
):
"""创建工资记录(需要管理员或经理权限)"""
record = await SalaryService.create(db, record_data)
return {"code": 200, "message": "创建成功", "data": {"id": record.id}}
@router.put("/{record_id}", summary="更新工资记录")
async def update_salary_record(
record_id: int,
record_data: SalaryRecordUpdate,
db: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_manager_user)] = None
):
"""更新工资记录(需要管理员或经理权限)"""
record = await SalaryService.update(db, record_id, record_data)
if not record:
raise HTTPException(status_code=400, detail="无法更新,记录不存在或状态不允许")
return {"code": 200, "message": "更新成功", "data": {"id": record.id}}
@router.post("/generate", summary="根据考核生成工资")
async def generate_salary(
staff_id: int = Query(..., description="员工ID"),
period_year: int = Query(..., description="年度"),
period_month: int = Query(..., description="月份"),
db: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_manager_user)] = None
):
"""根据考核记录生成工资记录(需要管理员或经理权限)"""
record = await SalaryService.generate_from_assessment(
db, staff_id, period_year, period_month
)
if not record:
raise HTTPException(status_code=400, detail="无法生成,未找到已确认的考核记录或已存在工资记录")
return {"code": 200, "message": "生成成功", "data": {"id": record.id}}
@router.post("/batch-generate", summary="批量生成工资")
async def batch_generate_salary(
department_id: int = Query(..., description="科室ID"),
period_year: int = Query(..., description="年度"),
period_month: int = Query(..., description="月份"),
db: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_manager_user)] = None
):
"""为科室批量生成工资记录(需要管理员或经理权限)"""
records = await SalaryService.batch_generate_for_department(
db, department_id, period_year, period_month
)
return {
"code": 200,
"message": f"成功生成 {len(records)} 条工资记录",
"data": {"count": len(records)}
}
@router.post("/{record_id}/confirm", summary="确认工资")
async def confirm_salary(
record_id: int,
db: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_manager_user)] = None
):
"""确认工资(需要管理员或经理权限)"""
record = await SalaryService.confirm(db, record_id)
if not record:
raise HTTPException(status_code=400, detail="无法确认,记录不存在或状态不允许")
return {"code": 200, "message": "确认成功"}
@router.post("/batch-confirm", summary="批量确认工资")
async def batch_confirm_salary(
period_year: int = Query(..., description="年度"),
period_month: int = Query(..., description="月份"),
department_id: Optional[int] = Query(None, description="科室ID"),
db: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_manager_user)] = None
):
"""批量确认工资(需要管理员或经理权限)"""
count = await SalaryService.batch_confirm(db, period_year, period_month, department_id)
return {"code": 200, "message": f"成功确认 {count} 条工资记录", "data": {"count": count}}

123
backend/app/api/v1/staff.py Normal file
View File

@@ -0,0 +1,123 @@
"""
API路由 - 员工管理
"""
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_active_user, get_current_manager_user
from app.schemas.schemas import (
StaffCreate, StaffUpdate, StaffResponse,
ResponseBase
)
from app.services.staff_service import StaffService
from app.models.models import User
router = APIRouter(prefix="/staff", tags=["员工管理"])
@router.get("", summary="获取员工列表")
async def get_staff_list(
department_id: Optional[int] = Query(None, description="科室ID"),
status: Optional[str] = Query(None, description="状态"),
keyword: Optional[str] = Query(None, description="搜索关键词"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""获取员工列表"""
staff_list, total = await StaffService.get_list(
db, department_id, status, keyword, page, page_size
)
# 添加科室名称
result = []
for staff in staff_list:
staff_dict = StaffResponse.model_validate(staff).model_dump()
staff_dict["department_name"] = staff.department.name if staff.department else None
result.append(staff_dict)
return {
"code": 200,
"message": "success",
"data": result,
"total": total,
"page": page,
"page_size": page_size
}
@router.get("/{staff_id}", summary="获取员工详情")
async def get_staff(
staff_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""获取员工详情"""
staff = await StaffService.get_by_id(db, staff_id)
if not staff:
raise HTTPException(status_code=404, detail="员工不存在")
staff_dict = StaffResponse.model_validate(staff).model_dump()
staff_dict["department_name"] = staff.department.name if staff.department else None
return {"code": 200, "message": "success", "data": staff_dict}
@router.post("", summary="创建员工")
async def create_staff(
staff_data: StaffCreate,
db: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_manager_user)] = None
):
"""创建员工(需要管理员或经理权限)"""
# 检查工号是否已存在
existing = await StaffService.get_by_employee_id(db, staff_data.employee_id)
if existing:
raise HTTPException(status_code=400, detail="工号已存在")
staff = await StaffService.create(db, staff_data)
return {"code": 200, "message": "创建成功", "data": StaffResponse.model_validate(staff)}
@router.put("/{staff_id}", summary="更新员工")
async def update_staff(
staff_id: int,
staff_data: StaffUpdate,
db: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_manager_user)] = None
):
"""更新员工(需要管理员或经理权限)"""
staff = await StaffService.update(db, staff_id, staff_data)
if not staff:
raise HTTPException(status_code=404, detail="员工不存在")
return {"code": 200, "message": "更新成功", "data": StaffResponse.model_validate(staff)}
@router.delete("/{staff_id}", summary="删除员工")
async def delete_staff(
staff_id: int,
db: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_manager_user)] = None
):
"""删除员工(需要管理员或经理权限)"""
success = await StaffService.delete(db, staff_id)
if not success:
raise HTTPException(status_code=404, detail="员工不存在")
return {"code": 200, "message": "删除成功"}
@router.get("/department/{department_id}", summary="获取科室员工")
async def get_department_staff(
department_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""获取科室下所有员工"""
staff_list = await StaffService.get_by_department(db, department_id)
return {
"code": 200,
"message": "success",
"data": [StaffResponse.model_validate(s) for s in staff_list]
}

241
backend/app/api/v1/stats.py Normal file
View File

@@ -0,0 +1,241 @@
"""
统计报表 API
"""
from typing import Optional
from datetime import datetime
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_active_user
from app.models.models import User
from app.services.stats_service import StatsService
router = APIRouter(prefix="/stats", tags=["统计报表"])
@router.get("/bsc-dimension", summary="BSC 维度分析")
async def get_bsc_dimension_stats(
department_id: Optional[int] = Query(None, description="科室 ID"),
period_year: int = Query(..., description="年度"),
period_month: int = Query(..., description="月份"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""获取 BSC 四个维度的统计分析"""
result = await StatsService.get_bsc_dimension_stats(
db, department_id, period_year, period_month
)
return {
"code": 200,
"message": "success",
"data": result
}
@router.get("/department", summary="科室绩效统计")
async def get_department_stats(
period_year: int = Query(..., description="年度"),
period_month: int = Query(..., description="月份"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""获取各科室绩效统计"""
result = await StatsService.get_department_stats(db, period_year, period_month)
return {
"code": 200,
"message": "success",
"data": result
}
@router.get("/trend", summary="趋势分析")
async def get_trend_stats(
department_id: Optional[int] = Query(None, description="科室 ID"),
period_year: Optional[int] = Query(None, description="年度"),
months: Optional[int] = Query(6, ge=1, le=24, description="最近几个月"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""获取绩效趋势分析(月度)"""
# 如果没有指定年份,使用当前年份
if not period_year:
period_year = datetime.now().year
result = await StatsService.get_trend_stats(db, department_id, period_year, months)
return {
"code": 200,
"message": "success",
"data": result
}
@router.get("/alerts", summary="预警数据")
async def get_alerts(
limit: Optional[int] = Query(10, ge=1, le=100, description="返回数量"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""获取预警数据(考核到期、工资未发等)"""
# TODO: 从数据库实际查询预警数据
# 目前返回模拟数据用于演示
return {
"code": 200,
"message": "success",
"data": {
"lowScoreStaff": [], # 低分员工
"incompleteDepartments": [], # 未完成考核科室
"anomalyData": [] # 异常数据
}
}
@router.get("/period", summary="周期统计")
async def get_period_stats(
period_year: Optional[int] = Query(None, description="年度"),
period_month: Optional[int] = Query(None, description="月份"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""获取周期统计数据"""
# 如果没有指定年月,使用当前年月
if not period_year:
period_year = datetime.now().year
if not period_month:
period_month = datetime.now().month
# 获取该周期的考核统计
result = await StatsService.get_department_stats(db, period_year, period_month)
# 计算汇总数据
total_departments = len(result)
total_staff = sum(dept.get('staff_count', 0) for dept in result)
avg_score = sum(dept.get('avg_score', 0) for dept in result) / total_departments if total_departments > 0 else 0
return {
"code": 200,
"message": "success",
"data": {
"period": f"{period_year}{period_month}",
"total_departments": total_departments,
"total_staff": total_staff,
"avg_score": round(avg_score, 2),
"departments": result
}
}
@router.get("/kpi-gauges", summary="关键指标仪表盘")
async def get_kpi_gauges(
period_year: Optional[int] = Query(None, description="年度"),
period_month: Optional[int] = Query(None, description="月份"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""获取关键指标仪表盘数据"""
# 如果没有指定年月,使用当前年月
if not period_year:
period_year = datetime.now().year
if not period_month:
period_month = datetime.now().month
# TODO: 从数据库实际计算这些指标
# 目前返回模拟数据用于演示
return {
"code": 200,
"message": "success",
"data": {
"bed_usage_rate": 85.5, # 床位使用率 (%)
"drug_ratio": 32.8, # 药占比 (%)
"material_ratio": 18.5, # 材料占比 (%)
"satisfaction_rate": 92.3 # 患者满意度 (%)
}
}
@router.get("/finance-trend", summary="收支趋势")
async def get_finance_trend(
months: Optional[int] = Query(6, ge=1, le=24, description="最近几个月"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""获取收支趋势数据"""
# TODO: 从数据库实际查询收支数据
# 目前返回模拟数据用于演示
from datetime import datetime
current_month = datetime.now().month
data = []
for i in range(months, 0, -1):
month = current_month - i + 1
if month < 1:
month += 12
data.append({
"period": f"{month}",
"income": 1000000 + (months - i) * 50000,
"expense": 800000 + (months - i) * 30000,
"profit": 200000 + (months - i) * 20000
})
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/department-ranking", summary="科室绩效排名")
async def get_department_ranking(
period_year: Optional[int] = Query(None, description="年度"),
period_month: Optional[int] = Query(None, description="月份"),
limit: Optional[int] = Query(10, ge=1, le=100, description="返回数量"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""获取科室绩效排名"""
# 如果没有指定年月,使用当前年月
if not period_year:
period_year = datetime.now().year
if not period_month:
period_month = datetime.now().month
result = await StatsService.get_department_stats(db, period_year, period_month)
# 返回前 limit 个
return {
"code": 200,
"message": "success",
"data": result[:limit] if limit else result
}
@router.get("/ranking", summary="绩效排名")
async def get_ranking_stats(
period_year: int = Query(..., description="年度"),
period_month: int = Query(..., description="月份"),
limit: int = Query(10, ge=1, le=100, description="返回数量"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""获取绩效排名前 N 名"""
result = await StatsService.get_ranking_stats(db, period_year, period_month, limit)
return {
"code": 200,
"message": "success",
"data": result
}
@router.get("/completion", summary="指标完成度")
async def get_completion_stats(
indicator_id: Optional[int] = Query(None, description="指标 ID"),
period_year: int = Query(..., description="年度"),
period_month: int = Query(..., description="月份"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""获取指标完成度统计"""
result = await StatsService.get_completion_stats(db, indicator_id, period_year, period_month)
return {
"code": 200,
"message": "success",
"data": result
}

View File

@@ -0,0 +1,401 @@
"""
满意度调查 API 接口
"""
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel, Field, ConfigDict
from datetime import datetime
import json
from app.core.database import get_db
from app.models.models import SurveyStatus, SurveyType, QuestionType
from app.services.survey_service import SurveyService
router = APIRouter(prefix="/surveys", tags=["满意度调查"])
# ==================== Pydantic Schemas ====================
class QuestionCreate(BaseModel):
"""题目创建"""
question_text: str = Field(..., description="题目内容")
question_type: str = Field(..., description="题目类型")
options: Optional[List[dict]] = Field(None, description="选项列表")
score_max: int = Field(5, description="最高分值")
is_required: bool = Field(True, description="是否必答")
class QuestionResponse(BaseModel):
"""题目响应"""
model_config = ConfigDict(from_attributes=True)
id: int
question_text: str
question_type: str
options: Optional[str] = None
score_max: int
is_required: bool
sort_order: int
class SurveyCreate(BaseModel):
"""问卷创建"""
survey_name: str = Field(..., description="问卷名称")
survey_code: str = Field(..., description="问卷编码")
survey_type: str = Field(..., description="问卷类型")
description: Optional[str] = Field(None, description="描述")
target_departments: Optional[List[int]] = Field(None, description="目标科室")
is_anonymous: bool = Field(True, description="是否匿名")
questions: Optional[List[QuestionCreate]] = Field(None, description="题目列表")
class SurveyUpdate(BaseModel):
"""问卷更新"""
survey_name: Optional[str] = None
description: Optional[str] = None
target_departments: Optional[List[int]] = None
is_anonymous: Optional[bool] = None
class SurveyResponse(BaseModel):
"""问卷响应"""
model_config = ConfigDict(from_attributes=True)
id: int
survey_name: str
survey_code: str
survey_type: str
description: Optional[str] = None
target_departments: Optional[str] = None
total_questions: int
status: str
start_date: Optional[datetime] = None
end_date: Optional[datetime] = None
is_anonymous: bool
created_at: datetime
class SurveyDetailResponse(SurveyResponse):
"""问卷详情响应"""
questions: Optional[List[QuestionResponse]] = None
class AnswerSubmit(BaseModel):
"""回答提交"""
question_id: int
answer_value: str
class ResponseSubmit(BaseModel):
"""问卷回答提交"""
department_id: Optional[int] = None
respondent_type: str = "patient"
respondent_id: Optional[int] = None
respondent_phone: Optional[str] = None
answers: List[AnswerSubmit]
class ResponseResult(BaseModel):
"""回答结果"""
model_config = ConfigDict(from_attributes=True)
id: int
survey_id: int
department_id: Optional[int] = None
total_score: float
max_score: float
satisfaction_rate: float
submitted_at: datetime
# ==================== API Endpoints ====================
@router.get("", summary="获取问卷列表")
async def get_survey_list(
survey_type: Optional[str] = Query(None, description="问卷类型"),
status: Optional[str] = Query(None, description="状态"),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db)
):
"""获取问卷列表"""
type_filter = SurveyType(survey_type) if survey_type else None
status_filter = SurveyStatus(status) if status else None
surveys, total = await SurveyService.get_survey_list(
db, type_filter, status_filter, page, page_size
)
return {
"code": 200,
"message": "success",
"data": [
SurveyResponse.model_validate(s) for s in surveys
],
"total": total,
"page": page,
"page_size": page_size
}
@router.get("/{survey_id}", summary="获取问卷详情")
async def get_survey_detail(
survey_id: int,
db: AsyncSession = Depends(get_db)
):
"""获取问卷详情"""
survey = await SurveyService.get_survey_by_id(db, survey_id)
if not survey:
raise HTTPException(status_code=404, detail="问卷不存在")
detail = SurveyDetailResponse.model_validate(survey)
if survey.questions:
detail.questions = [QuestionResponse.model_validate(q) for q in survey.questions]
return {
"code": 200,
"message": "success",
"data": detail
}
@router.post("", summary="创建问卷")
async def create_survey(
survey_data: SurveyCreate,
db: AsyncSession = Depends(get_db)
):
"""创建问卷"""
try:
survey_type = SurveyType(survey_data.survey_type)
except ValueError:
raise HTTPException(status_code=400, detail="无效的问卷类型")
survey = await SurveyService.create_survey(
db,
survey_name=survey_data.survey_name,
survey_code=survey_data.survey_code,
survey_type=survey_type,
description=survey_data.description,
target_departments=survey_data.target_departments,
is_anonymous=survey_data.is_anonymous
)
# 添加题目
if survey_data.questions:
for question in survey_data.questions:
try:
question_type = QuestionType(question.question_type)
except ValueError:
continue
await SurveyService.add_question(
db,
survey_id=survey.id,
question_text=question.question_text,
question_type=question_type,
options=question.options,
score_max=question.score_max,
is_required=question.is_required
)
await db.commit()
return {
"code": 200,
"message": "创建成功",
"data": SurveyResponse.model_validate(survey)
}
@router.put("/{survey_id}", summary="更新问卷")
async def update_survey(
survey_id: int,
survey_data: SurveyUpdate,
db: AsyncSession = Depends(get_db)
):
"""更新问卷"""
try:
update_dict = survey_data.model_dump(exclude_unset=True)
survey = await SurveyService.update_survey(db, survey_id, **update_dict)
if not survey:
raise HTTPException(status_code=404, detail="问卷不存在")
await db.commit()
return {
"code": 200,
"message": "更新成功",
"data": SurveyResponse.model_validate(survey)
}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/{survey_id}/publish", summary="发布问卷")
async def publish_survey(
survey_id: int,
db: AsyncSession = Depends(get_db)
):
"""发布问卷"""
try:
survey = await SurveyService.publish_survey(db, survey_id)
if not survey:
raise HTTPException(status_code=404, detail="问卷不存在")
await db.commit()
return {
"code": 200,
"message": "发布成功",
"data": SurveyResponse.model_validate(survey)
}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/{survey_id}/close", summary="结束问卷")
async def close_survey(
survey_id: int,
db: AsyncSession = Depends(get_db)
):
"""结束问卷"""
try:
survey = await SurveyService.close_survey(db, survey_id)
if not survey:
raise HTTPException(status_code=404, detail="问卷不存在")
await db.commit()
return {
"code": 200,
"message": "问卷已结束",
"data": SurveyResponse.model_validate(survey)
}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.delete("/{survey_id}", summary="删除问卷")
async def delete_survey(
survey_id: int,
db: AsyncSession = Depends(get_db)
):
"""删除问卷"""
try:
success = await SurveyService.delete_survey(db, survey_id)
if not success:
raise HTTPException(status_code=404, detail="问卷不存在")
await db.commit()
return {
"code": 200,
"message": "删除成功"
}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/{survey_id}/submit", summary="提交问卷回答")
async def submit_response(
survey_id: int,
response_data: ResponseSubmit,
db: AsyncSession = Depends(get_db)
):
"""提交问卷回答"""
try:
answers = [a.model_dump() for a in response_data.answers]
response = await SurveyService.submit_response(
db,
survey_id=survey_id,
department_id=response_data.department_id,
answers=answers,
respondent_type=response_data.respondent_type,
respondent_id=response_data.respondent_id,
respondent_phone=response_data.respondent_phone
)
await db.commit()
return {
"code": 200,
"message": "提交成功",
"data": ResponseResult.model_validate(response)
}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/{survey_id}/stats", summary="获取问卷统计")
async def get_survey_stats(
survey_id: int,
db: AsyncSession = Depends(get_db)
):
"""获取问卷各题目统计"""
survey = await SurveyService.get_survey_by_id(db, survey_id)
if not survey:
raise HTTPException(status_code=404, detail="问卷不存在")
stats = await SurveyService.get_question_stats(db, survey_id)
return {
"code": 200,
"message": "success",
"data": {
"survey_id": survey_id,
"survey_name": survey.survey_name,
"total_responses": await _get_response_count(db, survey_id),
"question_stats": stats
}
}
@router.get("/stats/department", summary="获取科室满意度统计")
async def get_department_satisfaction(
survey_id: Optional[int] = Query(None, description="问卷ID"),
department_id: Optional[int] = Query(None, description="科室ID"),
period_year: Optional[int] = Query(None, description="年度"),
period_month: Optional[int] = Query(None, description="月份"),
db: AsyncSession = Depends(get_db)
):
"""获取科室满意度统计"""
stats = await SurveyService.get_department_satisfaction(
db, survey_id, department_id, period_year, period_month
)
return {
"code": 200,
"message": "success",
"data": stats
}
@router.get("/stats/trend/{department_id}", summary="获取满意度趋势")
async def get_satisfaction_trend(
department_id: int,
months: int = Query(6, ge=1, le=12, description="查询月数"),
db: AsyncSession = Depends(get_db)
):
"""获取满意度趋势"""
trend = await SurveyService.get_satisfaction_trend(db, department_id, months)
return {
"code": 200,
"message": "success",
"data": trend
}
async def _get_response_count(db: AsyncSession, survey_id: int) -> int:
"""获取问卷回答数"""
from sqlalchemy import select, func
from app.models.models import SurveyResponse
result = await db.execute(
select(func.count(SurveyResponse.id))
.where(SurveyResponse.survey_id == survey_id)
)
return result.scalar() or 0

View File

@@ -0,0 +1,271 @@
"""
API路由 - 指标模板管理
"""
from typing import Annotated, Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_active_user, get_current_manager_user
from app.schemas.schemas import (
IndicatorTemplateCreate, IndicatorTemplateUpdate,
IndicatorTemplateResponse, IndicatorTemplateListResponse,
TemplateIndicatorCreate, TemplateIndicatorUpdate, TemplateIndicatorResponse,
ResponseBase
)
from app.services.template_service import TemplateService
from app.models.models import User
router = APIRouter(prefix="/templates", tags=["指标模板"])
@router.get("", summary="获取模板列表")
async def get_templates(
template_type: Optional[str] = Query(None, description="模板类型"),
is_active: Optional[bool] = Query(None, description="是否启用"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""获取模板列表"""
templates, total = await TemplateService.get_list(
db, template_type, is_active, page, page_size
)
return {
"code": 200,
"message": "success",
"data": templates,
"total": total,
"page": page,
"page_size": page_size
}
@router.get("/types", summary="获取模板类型列表")
async def get_template_types(
current_user: User = Depends(get_current_active_user)
):
"""获取模板类型列表"""
types = [
{"value": "general", "label": "通用模板"},
{"value": "surgical", "label": "手术临床科室"},
{"value": "nonsurgical_ward", "label": "非手术有病房科室"},
{"value": "nonsurgical_noward", "label": "非手术无病房科室"},
{"value": "medical_tech", "label": "医技科室"},
{"value": "nursing", "label": "护理单元"},
{"value": "admin", "label": "行政科室"},
{"value": "logistics", "label": "后勤科室"}
]
return {"code": 200, "message": "success", "data": types}
@router.get("/dimensions", summary="获取BSC维度列表")
async def get_dimensions(
current_user: User = Depends(get_current_active_user)
):
"""获取BSC维度列表"""
dimensions = [
{"value": "financial", "label": "财务管理", "weight_range": "30%-40%"},
{"value": "customer", "label": "顾客服务", "weight_range": "25%-35%"},
{"value": "internal_process", "label": "内部流程", "weight_range": "20%-30%"},
{"value": "learning_growth", "label": "学习与成长", "weight_range": "5%-15%"}
]
return {"code": 200, "message": "success", "data": dimensions}
@router.get("/{template_id}", summary="获取模板详情")
async def get_template(
template_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""获取模板详情"""
template = await TemplateService.get_by_id(db, template_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
# 构建响应数据
indicators = []
for ti in template.indicators:
ind_dict = {
"id": ti.id,
"template_id": ti.template_id,
"indicator_id": ti.indicator_id,
"indicator_name": ti.indicator.name if ti.indicator else None,
"indicator_code": ti.indicator.code if ti.indicator else None,
"indicator_type": ti.indicator.indicator_type.value if ti.indicator else None,
"bs_dimension": ti.indicator.bs_dimension.value if ti.indicator else None,
"category": ti.category,
"target_value": float(ti.target_value) if ti.target_value else None,
"target_unit": ti.target_unit,
"weight": float(ti.weight),
"scoring_method": ti.scoring_method,
"scoring_params": ti.scoring_params,
"sort_order": ti.sort_order,
"remark": ti.remark,
"created_at": ti.created_at,
"updated_at": ti.updated_at
}
indicators.append(ind_dict)
response_data = {
"id": template.id,
"template_name": template.template_name,
"template_code": template.template_code,
"template_type": template.template_type.value,
"description": template.description,
"dimension_weights": template.dimension_weights,
"assessment_cycle": template.assessment_cycle,
"is_active": template.is_active,
"created_at": template.created_at,
"updated_at": template.updated_at,
"indicators": indicators
}
return {"code": 200, "message": "success", "data": response_data}
@router.post("", summary="创建模板")
async def create_template(
template_data: IndicatorTemplateCreate,
db: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_manager_user)] = None
):
"""创建模板(需要管理员或经理权限)"""
# 检查编码是否已存在
existing = await TemplateService.get_by_code(db, template_data.template_code)
if existing:
raise HTTPException(status_code=400, detail="模板编码已存在")
template = await TemplateService.create(db, template_data)
return {"code": 200, "message": "创建成功", "data": {"id": template.id}}
@router.put("/{template_id}", summary="更新模板")
async def update_template(
template_id: int,
template_data: IndicatorTemplateUpdate,
db: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_manager_user)] = None
):
"""更新模板(需要管理员或经理权限)"""
template = await TemplateService.update(db, template_id, template_data)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
return {"code": 200, "message": "更新成功"}
@router.delete("/{template_id}", summary="删除模板")
async def delete_template(
template_id: int,
db: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_manager_user)] = None
):
"""删除模板(需要管理员或经理权限)"""
success = await TemplateService.delete(db, template_id)
if not success:
raise HTTPException(status_code=404, detail="模板不存在")
return {"code": 200, "message": "删除成功"}
# ==================== 模板指标管理 ====================
@router.get("/{template_id}/indicators", summary="获取模板指标列表")
async def get_template_indicators(
template_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""获取模板指标列表"""
indicators = await TemplateService.get_template_indicators(db, template_id)
result = []
for ti in indicators:
ind_dict = {
"id": ti.id,
"template_id": ti.template_id,
"indicator_id": ti.indicator_id,
"indicator_name": ti.indicator.name if ti.indicator else None,
"indicator_code": ti.indicator.code if ti.indicator else None,
"indicator_type": ti.indicator.indicator_type.value if ti.indicator else None,
"bs_dimension": ti.indicator.bs_dimension.value if ti.indicator else None,
"category": ti.category,
"target_value": float(ti.target_value) if ti.target_value else None,
"target_unit": ti.target_unit,
"weight": float(ti.weight),
"scoring_method": ti.scoring_method,
"scoring_params": ti.scoring_params,
"sort_order": ti.sort_order,
"remark": ti.remark,
"created_at": ti.created_at,
"updated_at": ti.updated_at
}
result.append(ind_dict)
return {"code": 200, "message": "success", "data": result}
@router.post("/{template_id}/indicators", summary="添加模板指标")
async def add_template_indicator(
template_id: int,
indicator_data: TemplateIndicatorCreate,
db: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_manager_user)] = None
):
"""添加模板指标"""
ti = await TemplateService.add_indicator(db, template_id, indicator_data)
if not ti:
raise HTTPException(status_code=400, detail="添加失败,模板不存在或指标已存在")
return {"code": 200, "message": "添加成功", "data": {"id": ti.id}}
@router.put("/{template_id}/indicators/{indicator_id}", summary="更新模板指标")
async def update_template_indicator(
template_id: int,
indicator_id: int,
indicator_data: TemplateIndicatorUpdate,
db: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_manager_user)] = None
):
"""更新模板指标"""
ti = await TemplateService.update_indicator(db, template_id, indicator_id, indicator_data)
if not ti:
raise HTTPException(status_code=404, detail="模板指标不存在")
return {"code": 200, "message": "更新成功"}
@router.delete("/{template_id}/indicators/{indicator_id}", summary="移除模板指标")
async def remove_template_indicator(
template_id: int,
indicator_id: int,
db: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_manager_user)] = None
):
"""移除模板指标"""
success = await TemplateService.remove_indicator(db, template_id, indicator_id)
if not success:
raise HTTPException(status_code=404, detail="模板指标不存在")
return {"code": 200, "message": "移除成功"}
@router.post("/{template_id}/indicators/batch", summary="批量添加模板指标")
async def batch_add_template_indicators(
template_id: int,
indicators_data: List[TemplateIndicatorCreate],
db: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_manager_user)] = None
):
"""批量添加模板指标"""
added_count = 0
for idx, ind_data in enumerate(indicators_data):
ind_data.sort_order = ind_data.sort_order or idx
ti = await TemplateService.add_indicator(db, template_id, ind_data)
if ti:
added_count += 1
return {
"code": 200,
"message": f"成功添加 {added_count} 个指标",
"data": {"added_count": added_count}
}

View File

View File

@@ -0,0 +1,46 @@
"""
系统配置模块
"""
from pydantic_settings import BaseSettings
from typing import Optional
from functools import lru_cache
class Settings(BaseSettings):
"""系统配置"""
# 应用配置
APP_NAME: str = "医院绩效考核管理系统"
APP_VERSION: str = "1.0.0"
DEBUG: bool = True
API_PREFIX: str = "/api/v1"
# 数据库配置
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/hospital_performance"
DATABASE_POOL_SIZE: int = 20
DATABASE_MAX_OVERFLOW: int = 10
# JWT配置
SECRET_KEY: str = "your-secret-key-change-in-production-min-32-chars"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 8 # 8小时
# 跨域配置
CORS_ORIGINS: list[str] = ["http://localhost:3000", "http://localhost:5173"]
# 分页配置
DEFAULT_PAGE_SIZE: int = 20
MAX_PAGE_SIZE: int = 100
class Config:
env_file = ".env"
case_sensitive = True
@lru_cache()
def get_settings() -> Settings:
"""获取配置单例"""
return Settings()
settings = get_settings()

View File

@@ -0,0 +1,38 @@
"""
数据库连接模块
"""
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
# 创建异步引擎
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG,
)
# 创建异步会话工厂
async_session_maker = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
class Base(DeclarativeBase):
"""数据库模型基类"""
pass
async def get_db() -> AsyncSession:
"""获取数据库会话依赖"""
async with async_session_maker() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()

114
backend/app/core/init_db.py Normal file
View File

@@ -0,0 +1,114 @@
"""
数据库初始化脚本
创建初始管理员用户和示例数据
"""
import asyncio
from sqlalchemy import select
from app.core.database import async_session_maker
from app.core.security import get_password_hash
from app.models.models import User, Department, Staff, Indicator, DeptType, StaffStatus, IndicatorType
async def init_admin_user():
"""创建初始管理员用户"""
async with async_session_maker() as db:
# 检查是否已存在admin用户
result = await db.execute(select(User).where(User.username == "admin"))
if result.scalar_one_or_none():
print("管理员用户已存在")
return
# 创建admin用户
admin = User(
username="admin",
password_hash=get_password_hash("admin123"),
role="admin",
is_active=True
)
db.add(admin)
await db.flush()
print("创建管理员用户: admin / admin123")
async def init_departments():
"""创建示例科室"""
async with async_session_maker() as db:
result = await db.execute(select(Department))
if result.scalars().first():
print("科室数据已存在")
return
departments = [
Department(name="内科", code="NK001", dept_type=DeptType.CLINICAL, level=1, sort_order=1),
Department(name="外科", code="WK001", dept_type=DeptType.CLINICAL, level=1, sort_order=2),
Department(name="中医科", code="ZYK001", dept_type=DeptType.CLINICAL, level=1, sort_order=3),
Department(name="检验科", code="JYK001", dept_type=DeptType.MEDICAL_TECH, level=1, sort_order=4),
Department(name="放射科", code="FSK001", dept_type=DeptType.MEDICAL_TECH, level=1, sort_order=5),
Department(name="财务科", code="CWK001", dept_type=DeptType.ADMIN, level=1, sort_order=6),
Department(name="办公室", code="BGS001", dept_type=DeptType.ADMIN, level=1, sort_order=7),
]
for dept in departments:
db.add(dept)
await db.flush()
print(f"创建了 {len(departments)} 个科室")
async def init_indicators():
"""创建示例考核指标"""
async with async_session_maker() as db:
result = await db.execute(select(Indicator))
if result.scalars().first():
print("指标数据已存在")
return
indicators = [
Indicator(name="门诊量", code="MZL001", indicator_type=IndicatorType.QUANTITY, weight=1.5, max_score=100, unit="人次"),
Indicator(name="住院量", code="ZYL001", indicator_type=IndicatorType.QUANTITY, weight=1.2, max_score=100, unit="人次"),
Indicator(name="诊断准确率", code="ZDZQL001", indicator_type=IndicatorType.QUALITY, weight=2.0, max_score=100, unit="%"),
Indicator(name="患者满意度", code="HZMYD001", indicator_type=IndicatorType.SERVICE, weight=1.5, max_score=100, unit="%"),
Indicator(name="医疗成本控制", code="YLCBKZ001", indicator_type=IndicatorType.COST, weight=1.0, max_score=100, unit="%"),
Indicator(name="工作效率", code="GZXL001", indicator_type=IndicatorType.EFFICIENCY, weight=1.0, max_score=100, unit="%"),
]
for ind in indicators:
db.add(ind)
await db.flush()
print(f"创建了 {len(indicators)} 个考核指标")
async def init_sample_staff():
"""创建示例员工"""
async with async_session_maker() as db:
result = await db.execute(select(Staff))
if result.scalars().first():
print("员工数据已存在")
return
# 获取科室ID
dept_result = await db.execute(select(Department))
departments = {d.code: d.id for d in dept_result.scalars().all()}
staff_list = [
Staff(employee_id="EMP001", name="张三", department_id=departments.get("NK001"), position="主治医师", title="副主任医师", base_salary=8000, performance_ratio=1.2, status=StaffStatus.ACTIVE),
Staff(employee_id="EMP002", name="李四", department_id=departments.get("WK001"), position="住院医师", title="主治医师", base_salary=7000, performance_ratio=1.0, status=StaffStatus.ACTIVE),
Staff(employee_id="EMP003", name="王五", department_id=departments.get("ZYK001"), position="主任医师", title="主任医师", base_salary=10000, performance_ratio=1.5, status=StaffStatus.ACTIVE),
Staff(employee_id="EMP004", name="赵六", department_id=departments.get("JYK001"), position="检验师", title="主管检验师", base_salary=6000, performance_ratio=1.0, status=StaffStatus.ACTIVE),
Staff(employee_id="EMP005", name="钱七", department_id=departments.get("CWK001"), position="会计", title="会计师", base_salary=5000, performance_ratio=0.8, status=StaffStatus.ACTIVE),
]
for staff in staff_list:
db.add(staff)
await db.flush()
print(f"创建了 {len(staff_list)} 个员工")
async def main():
"""初始化所有数据"""
print("开始初始化数据库...")
await init_departments()
await init_indicators()
await init_sample_staff()
await init_admin_user()
print("数据库初始化完成!")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,64 @@
"""
Logging configuration module
"""
import logging
import sys
from pathlib import Path
from logging.handlers import RotatingFileHandler
from datetime import datetime
# Use absolute path - backend directory is parent of app/core
BACKEND_DIR = Path(__file__).resolve().parent.parent.parent
LOG_DIR = BACKEND_DIR / "logs"
# Ensure logs directory exists
LOG_DIR.mkdir(exist_ok=True)
# Log file paths
current_date = datetime.now().strftime('%Y%m%d')
LOG_FILE = LOG_DIR / f"app_{current_date}.log"
ERROR_LOG_FILE = LOG_DIR / f"error_{current_date}.log"
# Log format
LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
# Create logger
logger = logging.getLogger("hospital_performance")
logger.setLevel(logging.DEBUG)
# Clear existing handlers
logger.handlers.clear()
# Console handler (INFO level)
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(logging.INFO)
console_handler.setFormatter(logging.Formatter(LOG_FORMAT, DATE_FORMAT))
logger.addHandler(console_handler)
# File handler (DEBUG level, rotating)
file_handler = RotatingFileHandler(
LOG_FILE,
maxBytes=10*1024*1024, # 10MB
backupCount=7, # Keep 7 backups
encoding="utf-8"
)
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(logging.Formatter(LOG_FORMAT, DATE_FORMAT))
logger.addHandler(file_handler)
# Error file handler (ERROR level)
error_handler = RotatingFileHandler(
ERROR_LOG_FILE,
maxBytes=10*1024*1024,
backupCount=7,
encoding="utf-8"
)
error_handler.setLevel(logging.ERROR)
error_handler.setFormatter(logging.Formatter(LOG_FORMAT, DATE_FORMAT))
logger.addHandler(error_handler)
def get_logger(name: str) -> logging.Logger:
"""Get child logger"""
return logger.getChild(name)

View File

@@ -0,0 +1,109 @@
"""
安全认证模块
"""
from datetime import datetime, timedelta
from typing import Any, Optional, Annotated
from jose import jwt, JWTError
import bcrypt
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.config import settings
from app.core.database import get_db
from app.models.models import User
# 密码加密直接使用 bcrypt
# OAuth2 密码模式
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.API_PREFIX}/auth/login")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""验证密码"""
return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8'))
def get_password_hash(password: str) -> str:
"""生成密码哈希"""
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
def create_access_token(subject: str | Any, expires_delta: Optional[timedelta] = None) -> str:
"""创建访问令牌"""
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode = {"exp": expire, "sub": str(subject)}
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def decode_token(token: str) -> Optional[dict]:
"""解码令牌"""
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
return payload
except JWTError:
return None
async def get_current_user(
token: Annotated[str, Depends(oauth2_scheme)],
db: AsyncSession = Depends(get_db)
) -> User:
"""从JWT获取当前用户"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无法验证凭据",
headers={"WWW-Authenticate": "Bearer"},
)
payload = decode_token(token)
if payload is None:
raise credentials_exception
user_id: str = payload.get("sub")
if user_id is None:
raise credentials_exception
result = await db.execute(
select(User).where(User.id == int(user_id))
)
user = result.scalar_one_or_none()
if user is None:
raise credentials_exception
return user
async def get_current_active_user(
current_user: Annotated[User, Depends(get_current_user)]
) -> User:
"""验证当前用户是否激活"""
if not current_user.is_active:
raise HTTPException(status_code=400, detail="用户已被禁用")
return current_user
async def get_current_admin_user(
current_user: Annotated[User, Depends(get_current_active_user)]
) -> User:
"""验证当前用户是否为管理员"""
if current_user.role != "admin":
raise HTTPException(status_code=403, detail="需要管理员权限")
return current_user
async def get_current_manager_user(
current_user: Annotated[User, Depends(get_current_active_user)]
) -> User:
"""验证当前用户是否为管理员或经理"""
if current_user.role not in ("admin", "manager"):
raise HTTPException(status_code=403, detail="需要管理员或经理权限")
return current_user

91
backend/app/main.py Normal file
View File

@@ -0,0 +1,91 @@
"""
FastAPI Main Application
"""
import logging
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException
from app.core.config import settings
from app.core.logging_config import logger
from app.api.v1 import api_router
def create_app() -> FastAPI:
"""Create application instance"""
logger.info("Creating FastAPI application instance...")
app = FastAPI(
title=settings.APP_NAME,
version=settings.APP_VERSION,
description="""
## Hospital Performance Management System API
### Function Modules
- **Basic Data Management**: Department, Staff, Indicators
- **Performance Assessment**: Assessment records, review workflow
- **Data Analysis Reports**: Department statistics, trends, rankings
- **Salary Calculation**: Performance-based payroll
### Tech Stack
- FastAPI + SQLAlchemy 2.0
- PostgreSQL
- Async IO support
""",
openapi_url=f"{settings.API_PREFIX}/openapi.json",
docs_url=f"{settings.API_PREFIX}/docs",
redoc_url=f"{settings.API_PREFIX}/redoc",
)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Register router
app.include_router(api_router, prefix=settings.API_PREFIX)
# Health check
@app.get("/health", tags=["System"])
async def health_check():
return {"status": "healthy", "version": settings.APP_VERSION}
# HTTP exception handler
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request, exc):
logger.warning(f"HTTP Exception: {request.method} {request.url} - {exc.status_code} - {exc.detail}")
raise exc
# Request validation exception handler
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request, exc):
logger.error(f"Validation Error: {request.method} {request.url} - {exc}")
raise exc
# Global exception handler
@app.exception_handler(Exception)
async def global_exception_handler(request, exc):
logger.error(f"Global Exception: {request.method} {request.url} - {exc}", exc_info=True)
raise exc
logger.info("FastAPI application instance created successfully")
return app
app = create_app()
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"app.main:app",
host="0.0.0.0",
port=8000,
reload=True,
log_level="info"
)

View File

@@ -0,0 +1,2 @@
from app.models.models import * # noqa
from app.models.finance import * # noqa

View File

@@ -0,0 +1,78 @@
"""
财务核算模型模块
"""
from datetime import datetime
from typing import Optional, List
from sqlalchemy import (
String, Text, Integer, Numeric, Boolean, DateTime, ForeignKey, Enum as SQLEnum,
Index, CheckConstraint
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from enum import Enum
from app.core.database import Base
class RevenueCategory(str, Enum):
"""收入类别"""
EXAMINATION = "examination" # 检查费
LAB_TEST = "lab_test" # 检验费
RADIOLOGY = "radiology" # 放射费
BED = "bed" # 床位费
NURSING = "nursing" # 护理费
TREATMENT = "treatment" # 治疗费
SURGERY = "surgery" # 手术费
INJECTION = "injection" # 注射费
OXYGEN = "oxygen" # 吸氧费
OTHER = "other" # 其他
class ExpenseCategory(str, Enum):
"""支出类别"""
MATERIAL = "material" # 材料费
PERSONNEL = "personnel" # 人员支出
MAINTENANCE = "maintenance" # 维修费
UTILITY = "utility" # 水电费
OTHER = "other" # 其他
class FinanceType(str, Enum):
"""财务类型"""
REVENUE = "revenue" # 收入
EXPENSE = "expense" # 支出
class DepartmentFinance(Base):
"""科室财务记录"""
__tablename__ = "department_finances"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
department_id: Mapped[int] = mapped_column(ForeignKey("departments.id"), nullable=False, comment="科室ID")
period_year: Mapped[int] = mapped_column(Integer, nullable=False, comment="年度")
period_month: Mapped[int] = mapped_column(Integer, nullable=False, comment="月份")
finance_type: Mapped[FinanceType] = mapped_column(
SQLEnum(FinanceType, values_callable=lambda x: [e.value for e in x]),
nullable=False,
comment="财务类型"
)
category: Mapped[str] = mapped_column(String(50), nullable=False, comment="类别")
amount: Mapped[float] = mapped_column(Numeric(12, 2), default=0, comment="金额")
source: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, comment="数据来源")
remark: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="备注")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, comment="创建时间")
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间")
# 关系
department: Mapped["Department"] = relationship("Department", backref="finances")
__table_args__ = (
Index("idx_finance_dept", "department_id"),
Index("idx_finance_period", "period_year", "period_month"),
Index("idx_finance_type", "finance_type"),
Index("idx_finance_category", "category"),
CheckConstraint("amount >= 0", name="ck_finance_amount"),
)
# 为了避免循环导入在models.py中导入时使用
from app.models.models import Department # noqa

View File

@@ -0,0 +1,588 @@
"""
数据模型模块
"""
from datetime import datetime
from typing import Optional, List
from sqlalchemy import (
String, Text, Integer, Numeric, Boolean, DateTime, ForeignKey, Enum as SQLEnum,
Index, CheckConstraint
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from enum import Enum
from app.core.database import Base
class DeptType(Enum):
"""科室类型"""
CLINICAL_SURGICAL = "clinical_surgical" # 手术临床科室
CLINICAL_NONSURGICAL_WARD = "clinical_nonsurgical_ward" # 非手术有病房科室
CLINICAL_NONSURGICAL_NOWARD = "clinical_nonsurgical_noward" # 非手术无病房科室
MEDICAL_TECH = "medical_tech" # 医技科室
MEDICAL_AUXILIARY = "medical_auxiliary" # 医辅科室
NURSING = "nursing" # 护理单元
ADMIN = "admin" # 行政科室
FINANCE = "finance" # 财务科室
LOGISTICS = "logistics" # 后勤保障科室
class BSCDimension(Enum):
"""平衡计分卡维度"""
FINANCIAL = "financial" # 财务维度
CUSTOMER = "customer" # 客户维度
INTERNAL_PROCESS = "internal_process" # 内部流程维度
LEARNING_GROWTH = "learning_growth" # 学习与成长维度
class StaffStatus(Enum):
"""员工状态"""
ACTIVE = "active" # 在职
LEAVE = "leave" # 休假
RESIGNED = "resigned" # 离职
RETIRED = "retired" # 退休
class AssessmentStatus(Enum):
"""考核状态"""
DRAFT = "draft" # 草稿
SUBMITTED = "submitted" # 已提交
REVIEWED = "reviewed" # 已审核
FINALIZED = "finalized" # 已确认
REJECTED = "rejected" # 已驳回
class IndicatorType(Enum):
"""指标类型"""
QUALITY = "quality" # 质量指标
QUANTITY = "quantity" # 数量指标
EFFICIENCY = "efficiency" # 效率指标
SERVICE = "service" # 服务指标
COST = "cost" # 成本指标
class Department(Base):
"""科室信息表"""
__tablename__ = "departments"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), nullable=False, comment="科室名称")
code: Mapped[str] = mapped_column(String(20), unique=True, nullable=False, comment="科室编码")
dept_type: Mapped[DeptType] = mapped_column(SQLEnum(DeptType, values_callable=lambda x: [e.value for e in x]), nullable=False, comment="科室类型")
parent_id: Mapped[Optional[int]] = mapped_column(ForeignKey("departments.id"), nullable=True, comment="上级科室")
level: Mapped[int] = mapped_column(Integer, default=1, comment="层级")
sort_order: Mapped[int] = mapped_column(Integer, default=0, comment="排序")
is_active: Mapped[bool] = mapped_column(Boolean, default=True, comment="是否启用")
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="描述")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, comment="创建时间")
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间")
# 关系
parent: Mapped[Optional["Department"]] = relationship("Department", remote_side=[id], backref="children")
staff: Mapped[List["Staff"]] = relationship("Staff", back_populates="department")
__table_args__ = (
Index("idx_dept_type", "dept_type"),
Index("idx_dept_parent", "parent_id"),
)
class Staff(Base):
"""员工信息表"""
__tablename__ = "staff"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
employee_id: Mapped[str] = mapped_column(String(20), unique=True, nullable=False, comment="工号")
name: Mapped[str] = mapped_column(String(50), nullable=False, comment="姓名")
department_id: Mapped[int] = mapped_column(ForeignKey("departments.id"), nullable=False, comment="所属科室")
position: Mapped[str] = mapped_column(String(50), nullable=False, comment="职位")
title: Mapped[Optional[str]] = mapped_column(String(50), nullable=True, comment="职称")
phone: Mapped[Optional[str]] = mapped_column(String(20), nullable=True, comment="联系电话")
email: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, comment="邮箱")
base_salary: Mapped[float] = mapped_column(Numeric(10, 2), default=0, comment="基本工资")
performance_ratio: Mapped[float] = mapped_column(Numeric(5, 2), default=1.0, comment="绩效系数")
status: Mapped[StaffStatus] = mapped_column(SQLEnum(StaffStatus, values_callable=lambda x: [e.value for e in x]), default=StaffStatus.ACTIVE, comment="状态")
hire_date: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True, comment="入职日期")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, comment="创建时间")
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间")
# 关系
department: Mapped["Department"] = relationship("Department", back_populates="staff")
assessments: Mapped[List["Assessment"]] = relationship("Assessment", foreign_keys="Assessment.staff_id", back_populates="staff")
salary_records: Mapped[List["SalaryRecord"]] = relationship("SalaryRecord", back_populates="staff")
__table_args__ = (
Index("idx_staff_dept", "department_id"),
Index("idx_staff_status", "status"),
)
class Indicator(Base):
"""考核指标表"""
__tablename__ = "indicators"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), nullable=False, comment="指标名称")
code: Mapped[str] = mapped_column(String(20), unique=True, nullable=False, comment="指标编码")
indicator_type: Mapped[IndicatorType] = mapped_column(SQLEnum(IndicatorType, values_callable=lambda x: [e.value for e in x]), nullable=False, comment="指标类型")
bs_dimension: Mapped[BSCDimension] = mapped_column(SQLEnum(BSCDimension, values_callable=lambda x: [e.value for e in x]), nullable=False, comment="平衡计分卡维度")
weight: Mapped[float] = mapped_column(Numeric(5, 2), default=1.0, comment="权重")
max_score: Mapped[float] = mapped_column(Numeric(5, 2), default=100.0, comment="最高分值")
target_value: Mapped[Optional[float]] = mapped_column(Numeric(10, 2), nullable=True, comment="目标值")
target_unit: Mapped[Optional[str]] = mapped_column(String(50), nullable=True, comment="目标值单位")
calculation_method: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="计算方法/公式")
assessment_method: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="考核方法")
deduction_standard: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="扣分标准")
data_source: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, comment="数据来源")
applicable_dept_types: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="适用科室类型 (JSON 数组)")
is_veto: Mapped[bool] = mapped_column(Boolean, default=False, comment="是否一票否决指标")
is_active: Mapped[bool] = mapped_column(Boolean, default=True, comment="是否启用")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, comment="创建时间")
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间")
# 关系
assessment_details: Mapped[List["AssessmentDetail"]] = relationship("AssessmentDetail", back_populates="indicator")
__table_args__ = (
Index("idx_indicator_type", "indicator_type"),
CheckConstraint("weight > 0", name="ck_indicator_weight"),
)
class Assessment(Base):
"""考核记录表"""
__tablename__ = "assessments"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
staff_id: Mapped[int] = mapped_column(ForeignKey("staff.id"), nullable=False, comment="员工ID")
period_year: Mapped[int] = mapped_column(Integer, nullable=False, comment="考核年度")
period_month: Mapped[int] = mapped_column(Integer, nullable=False, comment="考核月份")
period_type: Mapped[str] = mapped_column(String(20), default="monthly", comment="考核周期类型")
total_score: Mapped[float] = mapped_column(Numeric(5, 2), default=0, comment="总分")
weighted_score: Mapped[float] = mapped_column(Numeric(5, 2), default=0, comment="加权得分")
status: Mapped[AssessmentStatus] = mapped_column(SQLEnum(AssessmentStatus, values_callable=lambda x: [e.value for e in x]), default=AssessmentStatus.DRAFT, comment="状态")
assessor_id: Mapped[Optional[int]] = mapped_column(ForeignKey("staff.id"), nullable=True, comment="考核人")
reviewer_id: Mapped[Optional[int]] = mapped_column(ForeignKey("staff.id"), nullable=True, comment="审核人")
submit_time: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True, comment="提交时间")
review_time: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True, comment="审核时间")
remark: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="备注")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, comment="创建时间")
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间")
# 关系
staff: Mapped["Staff"] = relationship("Staff", foreign_keys=[staff_id], back_populates="assessments")
assessor: Mapped[Optional["Staff"]] = relationship("Staff", foreign_keys=[assessor_id])
reviewer: Mapped[Optional["Staff"]] = relationship("Staff", foreign_keys=[reviewer_id])
details: Mapped[List["AssessmentDetail"]] = relationship("AssessmentDetail", back_populates="assessment", cascade="all, delete-orphan")
__table_args__ = (
Index("idx_assessment_staff", "staff_id"),
Index("idx_assessment_period", "period_year", "period_month"),
Index("idx_assessment_status", "status"),
)
class AssessmentDetail(Base):
"""考核明细表"""
__tablename__ = "assessment_details"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
assessment_id: Mapped[int] = mapped_column(ForeignKey("assessments.id"), nullable=False, comment="考核记录ID")
indicator_id: Mapped[int] = mapped_column(ForeignKey("indicators.id"), nullable=False, comment="指标ID")
actual_value: Mapped[Optional[float]] = mapped_column(Numeric(10, 2), nullable=True, comment="实际值")
score: Mapped[float] = mapped_column(Numeric(5, 2), default=0, comment="得分")
evidence: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="佐证材料")
remark: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="备注")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, comment="创建时间")
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间")
# 关系
assessment: Mapped["Assessment"] = relationship("Assessment", back_populates="details")
indicator: Mapped["Indicator"] = relationship("Indicator", back_populates="assessment_details")
__table_args__ = (
Index("idx_detail_assessment", "assessment_id"),
Index("idx_detail_indicator", "indicator_id"),
)
class SalaryRecord(Base):
"""工资核算记录表"""
__tablename__ = "salary_records"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
staff_id: Mapped[int] = mapped_column(ForeignKey("staff.id"), nullable=False, comment="员工ID")
period_year: Mapped[int] = mapped_column(Integer, nullable=False, comment="年度")
period_month: Mapped[int] = mapped_column(Integer, nullable=False, comment="月份")
base_salary: Mapped[float] = mapped_column(Numeric(10, 2), default=0, comment="基本工资")
performance_score: Mapped[float] = mapped_column(Numeric(5, 2), default=0, comment="绩效得分")
performance_bonus: Mapped[float] = mapped_column(Numeric(10, 2), default=0, comment="绩效奖金")
deduction: Mapped[float] = mapped_column(Numeric(10, 2), default=0, comment="扣款")
allowance: Mapped[float] = mapped_column(Numeric(10, 2), default=0, comment="补贴")
total_salary: Mapped[float] = mapped_column(Numeric(10, 2), default=0, comment="应发工资")
status: Mapped[str] = mapped_column(String(20), default="pending", comment="状态")
remark: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="备注")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, comment="创建时间")
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间")
# 关系
staff: Mapped["Staff"] = relationship("Staff", back_populates="salary_records")
__table_args__ = (
Index("idx_salary_staff", "staff_id"),
Index("idx_salary_period", "period_year", "period_month"),
)
class PlanStatus(Enum):
"""计划状态"""
DRAFT = "draft" # 草稿
PENDING = "pending" # 待审批
APPROVED = "approved" # 已批准
REJECTED = "rejected" # 已驳回
ACTIVE = "active" # 执行中
COMPLETED = "completed" # 已完成
CANCELLED = "cancelled" # 已取消
class User(Base):
"""系统用户表"""
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, comment="用户名")
password_hash: Mapped[str] = mapped_column(String(255), nullable=False, comment="密码哈希")
staff_id: Mapped[Optional[int]] = mapped_column(ForeignKey("staff.id"), nullable=True, comment="关联员工")
role: Mapped[str] = mapped_column(String(20), default="staff", comment="角色")
is_active: Mapped[bool] = mapped_column(Boolean, default=True, comment="是否启用")
last_login: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True, comment="最后登录")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, comment="创建时间")
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间")
__table_args__ = (
Index("idx_user_username", "username"),
)
class PlanLevel(Enum):
"""计划层级"""
HOSPITAL = "hospital" # 医院级
DEPARTMENT = "department" # 科室级
INDIVIDUAL = "individual" # 个人级
class PerformancePlan(Base):
"""绩效计划表"""
__tablename__ = "performance_plans"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
plan_name: Mapped[str] = mapped_column(String(200), nullable=False, comment="计划名称")
plan_code: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, comment="计划编码")
plan_level: Mapped[PlanLevel] = mapped_column(SQLEnum(PlanLevel, values_callable=lambda x: [e.value for e in x]), nullable=False, comment="计划层级")
plan_year: Mapped[int] = mapped_column(Integer, nullable=False, comment="计划年度")
plan_month: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, comment="计划月份 (月度计划)")
plan_type: Mapped[str] = mapped_column(String(20), default="annual", comment="计划类型 (annual/monthly)")
department_id: Mapped[Optional[int]] = mapped_column(ForeignKey("departments.id"), nullable=True, comment="所属科室 ID")
staff_id: Mapped[Optional[int]] = mapped_column(ForeignKey("staff.id"), nullable=True, comment="责任人 ID")
parent_plan_id: Mapped[Optional[int]] = mapped_column(ForeignKey("performance_plans.id"), nullable=True, comment="上级计划 ID")
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="计划描述")
strategic_goals: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="战略目标")
key_initiatives: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="关键举措")
status: Mapped[PlanStatus] = mapped_column(SQLEnum(PlanStatus, values_callable=lambda x: [e.value for e in x]), default=PlanStatus.DRAFT, comment="状态")
submitter_id: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), nullable=True, comment="提交人 ID")
submit_time: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True, comment="提交时间")
approver_id: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), nullable=True, comment="审批人 ID")
approve_time: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True, comment="审批时间")
approve_remark: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="审批意见")
version: Mapped[int] = mapped_column(Integer, default=1, comment="版本号")
is_active: Mapped[bool] = mapped_column(Boolean, default=True, comment="是否启用")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, comment="创建时间")
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间")
# 关系
department: Mapped[Optional["Department"]] = relationship("Department", backref="performance_plans")
staff: Mapped[Optional["Staff"]] = relationship("Staff", backref="performance_plans")
parent_plan: Mapped[Optional["PerformancePlan"]] = relationship("PerformancePlan", remote_side=[id], backref="child_plans")
submitter: Mapped[Optional["User"]] = relationship("User", foreign_keys=[submitter_id])
approver: Mapped[Optional["User"]] = relationship("User", foreign_keys=[approver_id])
kpi_relations: Mapped[List["PlanKpiRelation"]] = relationship("PlanKpiRelation", back_populates="plan", cascade="all, delete-orphan")
__table_args__ = (
Index("idx_plan_level", "plan_level"),
Index("idx_plan_year", "plan_year"),
Index("idx_plan_department", "department_id"),
Index("idx_plan_status", "status"),
)
class PlanKpiRelation(Base):
"""计划指标关联表"""
__tablename__ = "plan_kpi_relations"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
plan_id: Mapped[int] = mapped_column(ForeignKey("performance_plans.id"), nullable=False, comment="计划 ID")
indicator_id: Mapped[int] = mapped_column(ForeignKey("indicators.id"), nullable=False, comment="指标 ID")
target_value: Mapped[Optional[float]] = mapped_column(Numeric(10, 2), nullable=True, comment="目标值")
target_unit: Mapped[Optional[str]] = mapped_column(String(50), nullable=True, comment="目标值单位")
weight: Mapped[float] = mapped_column(Numeric(5, 2), default=1.0, comment="权重")
scoring_method: Mapped[Optional[str]] = mapped_column(String(50), nullable=True, comment="评分方法")
scoring_params: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="评分参数 (JSON)")
remark: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="备注")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, comment="创建时间")
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间")
# 关系
plan: Mapped["PerformancePlan"] = relationship("PerformancePlan", back_populates="kpi_relations")
indicator: Mapped["Indicator"] = relationship("Indicator", backref="plan_relations")
__table_args__ = (
Index("idx_relation_plan", "plan_id"),
Index("idx_relation_indicator", "indicator_id"),
Index("idx_relation_unique", "plan_id", "indicator_id", unique=True),
)
class MenuType(Enum):
"""菜单类型"""
MENU = "menu" # 菜单
BUTTON = "button" # 按钮
class Menu(Base):
"""系统菜单表"""
__tablename__ = "menus"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
parent_id: Mapped[Optional[int]] = mapped_column(ForeignKey("menus.id"), nullable=True, comment="父菜单 ID")
menu_type: Mapped[MenuType] = mapped_column(SQLEnum(MenuType, values_callable=lambda x: [e.value for e in x]), default=MenuType.MENU, comment="菜单类型")
menu_name: Mapped[str] = mapped_column(String(100), nullable=False, comment="菜单名称")
menu_icon: Mapped[Optional[str]] = mapped_column(String(50), nullable=True, comment="菜单图标")
path: Mapped[str] = mapped_column(String(200), nullable=False, comment="路由路径")
component: Mapped[Optional[str]] = mapped_column(String(200), nullable=True, comment="组件路径")
permission: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, comment="权限标识")
sort_order: Mapped[int] = mapped_column(Integer, default=0, comment="排序")
is_visible: Mapped[bool] = mapped_column(Boolean, default=True, comment="是否可见")
is_active: Mapped[bool] = mapped_column(Boolean, default=True, comment="是否启用")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, comment="创建时间")
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间")
# 关系
parent: Mapped[Optional["Menu"]] = relationship("Menu", remote_side=[id], backref="children")
__table_args__ = (
Index("idx_menu_parent", "parent_id"),
Index("idx_menu_type", "menu_type"),
Index("idx_menu_visible", "is_visible"),
)
class TemplateType(Enum):
"""模板类型"""
GENERAL = "general" # 通用模板
SURGICAL = "surgical" # 手术临床科室
NON_SURGICAL_WARD = "nonsurgical_ward" # 非手术有病房科室
NON_SURGICAL_NOWARD = "nonsurgical_noward" # 非手术无病房科室
MEDICAL_TECH = "medical_tech" # 医技科室
NURSING = "nursing" # 护理单元
ADMIN = "admin" # 行政科室
LOGISTICS = "logistics" # 后勤科室
class IndicatorTemplate(Base):
"""考核指标模板表"""
__tablename__ = "indicator_templates"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
template_name: Mapped[str] = mapped_column(String(200), nullable=False, comment="模板名称")
template_code: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, comment="模板编码")
template_type: Mapped[TemplateType] = mapped_column(SQLEnum(TemplateType, values_callable=lambda x: [e.value for e in x]), nullable=False, comment="模板类型")
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="模板描述")
dimension_weights: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="维度权重 (JSON)")
assessment_cycle: Mapped[str] = mapped_column(String(20), default="monthly", comment="考核周期")
is_active: Mapped[bool] = mapped_column(Boolean, default=True, comment="是否启用")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, comment="创建时间")
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间")
# 关系
indicators: Mapped[List["TemplateIndicator"]] = relationship("TemplateIndicator", back_populates="template", cascade="all, delete-orphan")
__table_args__ = (
Index("idx_template_type", "template_type"),
Index("idx_template_active", "is_active"),
)
class TemplateIndicator(Base):
"""模板指标关联表"""
__tablename__ = "template_indicators"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
template_id: Mapped[int] = mapped_column(ForeignKey("indicator_templates.id"), nullable=False, comment="模板 ID")
indicator_id: Mapped[int] = mapped_column(ForeignKey("indicators.id"), nullable=False, comment="指标 ID")
category: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, comment="指标分类 (二级指标)")
target_value: Mapped[Optional[float]] = mapped_column(Numeric(10, 2), nullable=True, comment="目标值")
target_unit: Mapped[Optional[str]] = mapped_column(String(50), nullable=True, comment="目标值单位")
weight: Mapped[float] = mapped_column(Numeric(5, 2), default=1.0, comment="权重")
scoring_method: Mapped[Optional[str]] = mapped_column(String(50), nullable=True, comment="评分方法")
scoring_params: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="评分参数 (JSON)")
sort_order: Mapped[int] = mapped_column(Integer, default=0, comment="排序")
remark: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="备注")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, comment="创建时间")
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间")
# 关系
template: Mapped["IndicatorTemplate"] = relationship("IndicatorTemplate", back_populates="indicators")
indicator: Mapped["Indicator"] = relationship("Indicator", backref="template_relations")
__table_args__ = (
Index("idx_ti_template", "template_id"),
Index("idx_ti_indicator", "indicator_id"),
Index("idx_ti_unique", "template_id", "indicator_id", unique=True),
)
class DeptTypeDimensionWeight(Base):
"""科室类型BSC维度权重配置表"""
__tablename__ = "dept_type_dimension_weights"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
dept_type: Mapped[DeptType] = mapped_column(SQLEnum(DeptType, values_callable=lambda x: [e.value for e in x]), nullable=False, comment="科室类型")
financial_weight: Mapped[float] = mapped_column(Numeric(5, 2), default=0.60, comment="财务维度权重")
customer_weight: Mapped[float] = mapped_column(Numeric(5, 2), default=0.15, comment="客户维度权重")
internal_process_weight: Mapped[float] = mapped_column(Numeric(5, 2), default=0.20, comment="内部流程维度权重")
learning_growth_weight: Mapped[float] = mapped_column(Numeric(5, 2), default=0.05, comment="学习成长维度权重")
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="描述说明")
is_active: Mapped[bool] = mapped_column(Boolean, default=True, comment="是否启用")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, comment="创建时间")
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间")
__table_args__ = (
Index("idx_weight_dept_type", "dept_type"),
Index("idx_weight_active", "is_active"),
)
# ==================== 满意度调查模块 ====================
class SurveyStatus(Enum):
"""调查状态"""
DRAFT = "draft" # 草稿
PUBLISHED = "published" # 已发布
CLOSED = "closed" # 已结束
ARCHIVED = "archived" # 已归档
class SurveyType(Enum):
"""调查类型"""
INPATIENT = "inpatient" # 住院患者满意度
OUTPATIENT = "outpatient" # 门诊患者满意度
INTERNAL = "internal" # 内部员工满意度
DEPARTMENT = "department" # 科室间满意度
class Survey(Base):
"""满意度调查问卷表"""
__tablename__ = "surveys"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
survey_name: Mapped[str] = mapped_column(String(200), nullable=False, comment="调查名称")
survey_code: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, comment="调查编码")
survey_type: Mapped[SurveyType] = mapped_column(SQLEnum(SurveyType, values_callable=lambda x: [e.value for e in x]), nullable=False, comment="调查类型")
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="调查描述")
target_departments: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="目标科室(JSON数组)")
total_questions: Mapped[int] = mapped_column(Integer, default=0, comment="题目总数")
status: Mapped[SurveyStatus] = mapped_column(SQLEnum(SurveyStatus, values_callable=lambda x: [e.value for e in x]), default=SurveyStatus.DRAFT, comment="状态")
start_date: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True, comment="开始日期")
end_date: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True, comment="结束日期")
is_anonymous: Mapped[bool] = mapped_column(Boolean, default=True, comment="是否匿名")
is_active: Mapped[bool] = mapped_column(Boolean, default=True, comment="是否启用")
created_by: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), nullable=True, comment="创建人")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, comment="创建时间")
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间")
# 关系
questions: Mapped[List["SurveyQuestion"]] = relationship("SurveyQuestion", back_populates="survey", cascade="all, delete-orphan")
responses: Mapped[List["SurveyResponse"]] = relationship("SurveyResponse", back_populates="survey", cascade="all, delete-orphan")
__table_args__ = (
Index("idx_survey_type", "survey_type"),
Index("idx_survey_status", "status"),
Index("idx_survey_dates", "start_date", "end_date"),
)
class QuestionType(Enum):
"""题目类型"""
SINGLE_CHOICE = "single_choice" # 单选题
MULTIPLE_CHOICE = "multiple_choice" # 多选题
SCORE = "score" # 评分题(1-5分)
TEXT = "text" # 文本题
class SurveyQuestion(Base):
"""调查问卷题目表"""
__tablename__ = "survey_questions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
survey_id: Mapped[int] = mapped_column(ForeignKey("surveys.id"), nullable=False, comment="调查ID")
question_text: Mapped[str] = mapped_column(Text, nullable=False, comment="题目内容")
question_type: Mapped[QuestionType] = mapped_column(SQLEnum(QuestionType, values_callable=lambda x: [e.value for e in x]), nullable=False, comment="题目类型")
options: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="选项(JSON数组)")
score_max: Mapped[int] = mapped_column(Integer, default=5, comment="最高分值(评分题)")
is_required: Mapped[bool] = mapped_column(Boolean, default=True, comment="是否必答")
sort_order: Mapped[int] = mapped_column(Integer, default=0, comment="排序")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, comment="创建时间")
# 关系
survey: Mapped["Survey"] = relationship("Survey", back_populates="questions")
answers: Mapped[List["SurveyAnswer"]] = relationship("SurveyAnswer", back_populates="question", cascade="all, delete-orphan")
__table_args__ = (
Index("idx_question_survey", "survey_id"),
)
class SurveyResponse(Base):
"""调查问卷回答记录表"""
__tablename__ = "survey_responses"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
survey_id: Mapped[int] = mapped_column(ForeignKey("surveys.id"), nullable=False, comment="调查ID")
department_id: Mapped[Optional[int]] = mapped_column(ForeignKey("departments.id"), nullable=True, comment="评价科室")
respondent_type: Mapped[str] = mapped_column(String(20), default="patient", comment="回答者类型")
respondent_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, comment="回答者ID(员工时)")
respondent_phone: Mapped[Optional[str]] = mapped_column(String(20), nullable=True, comment="回答者手机")
total_score: Mapped[float] = mapped_column(Numeric(5, 2), default=0, comment="总得分")
max_score: Mapped[float] = mapped_column(Numeric(5, 2), default=0, comment="最高可能得分")
satisfaction_rate: Mapped[float] = mapped_column(Numeric(5, 2), default=0, comment="满意度比例")
ip_address: Mapped[Optional[str]] = mapped_column(String(50), nullable=True, comment="IP地址")
user_agent: Mapped[Optional[str]] = mapped_column(String(500), nullable=True, comment="用户代理")
submitted_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, comment="提交时间")
# 关系
survey: Mapped["Survey"] = relationship("Survey", back_populates="responses")
department: Mapped[Optional["Department"]] = relationship("Department", backref="survey_responses")
answers: Mapped[List["SurveyAnswer"]] = relationship("SurveyAnswer", back_populates="response", cascade="all, delete-orphan")
__table_args__ = (
Index("idx_response_survey", "survey_id"),
Index("idx_response_dept", "department_id"),
Index("idx_response_time", "submitted_at"),
)
class SurveyAnswer(Base):
"""调查问卷回答明细表"""
__tablename__ = "survey_answers"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
response_id: Mapped[int] = mapped_column(ForeignKey("survey_responses.id"), nullable=False, comment="回答记录ID")
question_id: Mapped[int] = mapped_column(ForeignKey("survey_questions.id"), nullable=False, comment="题目ID")
answer_value: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="回答值")
score: Mapped[float] = mapped_column(Numeric(5, 2), default=0, comment="得分")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, comment="创建时间")
# 关系
response: Mapped["SurveyResponse"] = relationship("SurveyResponse", back_populates="answers")
question: Mapped["SurveyQuestion"] = relationship("SurveyQuestion", back_populates="answers")
__table_args__ = (
Index("idx_answer_response", "response_id"),
Index("idx_answer_question", "question_id"),
)

View File

@@ -0,0 +1 @@
from app.schemas.schemas import * # noqa

View File

@@ -0,0 +1,742 @@
"""
Pydantic数据模式
"""
from datetime import datetime
from typing import Optional, List
from pydantic import BaseModel, Field, ConfigDict
from enum import Enum
# ==================== 枚举类型 ====================
class DeptType(str, Enum):
CLINICAL_SURGICAL = "clinical_surgical"
CLINICAL_NONSURGICAL_WARD = "clinical_nonsurgical_ward"
CLINICAL_NONSURGICAL_NOWARD = "clinical_nonsurgical_noward"
MEDICAL_TECH = "medical_tech"
MEDICAL_AUXILIARY = "medical_auxiliary"
NURSING = "nursing"
ADMIN = "admin"
FINANCE = "finance"
LOGISTICS = "logistics"
class StaffStatus(str, Enum):
ACTIVE = "active"
LEAVE = "leave"
RESIGNED = "resigned"
RETIRED = "retired"
class AssessmentStatus(str, Enum):
DRAFT = "draft"
SUBMITTED = "submitted"
REVIEWED = "reviewed"
FINALIZED = "finalized"
REJECTED = "rejected"
class IndicatorType(str, Enum):
QUALITY = "quality"
QUANTITY = "quantity"
EFFICIENCY = "efficiency"
SERVICE = "service"
COST = "cost"
# ==================== 通用响应 ====================
class ResponseBase(BaseModel):
"""通用响应基类"""
code: int = Field(default=200, description="状态码")
message: str = Field(default="success", description="消息")
class PaginatedResponse(ResponseBase):
"""分页响应"""
total: int = Field(description="总数")
page: int = Field(description="当前页")
page_size: int = Field(description="每页数量")
# ==================== 科室相关 ====================
class DepartmentBase(BaseModel):
"""科室基础模式"""
name: str = Field(..., max_length=100, description="科室名称")
code: str = Field(..., max_length=20, description="科室编码")
dept_type: DeptType = Field(..., description="科室类型")
parent_id: Optional[int] = Field(None, description="上级科室ID")
level: int = Field(default=1, ge=1, le=5, description="层级")
sort_order: int = Field(default=0, description="排序")
description: Optional[str] = Field(None, description="描述")
class DepartmentCreate(DepartmentBase):
"""创建科室"""
pass
class DepartmentUpdate(BaseModel):
"""更新科室"""
name: Optional[str] = Field(None, max_length=100)
dept_type: Optional[DeptType] = None
parent_id: Optional[int] = None
sort_order: Optional[int] = None
is_active: Optional[bool] = None
description: Optional[str] = None
class DepartmentResponse(DepartmentBase):
"""科室响应"""
model_config = ConfigDict(from_attributes=True)
id: int
is_active: bool
created_at: datetime
updated_at: datetime
class DepartmentTree(DepartmentResponse):
"""科室树形结构"""
children: List["DepartmentTree"] = []
# ==================== 员工相关 ====================
class StaffBase(BaseModel):
"""员工基础模式"""
employee_id: str = Field(..., max_length=20, description="工号")
name: str = Field(..., max_length=50, description="姓名")
department_id: int = Field(..., description="所属科室ID")
position: str = Field(..., max_length=50, description="职位")
title: Optional[str] = Field(None, max_length=50, description="职称")
phone: Optional[str] = Field(None, max_length=20, description="电话")
email: Optional[str] = Field(None, max_length=100, description="邮箱")
base_salary: float = Field(default=0, ge=0, description="基本工资")
performance_ratio: float = Field(default=1.0, ge=0, le=5, description="绩效系数")
class StaffCreate(StaffBase):
"""创建员工"""
status: StaffStatus = Field(default=StaffStatus.ACTIVE)
hire_date: Optional[datetime] = None
class StaffUpdate(BaseModel):
"""更新员工"""
name: Optional[str] = Field(None, max_length=50)
department_id: Optional[int] = None
position: Optional[str] = Field(None, max_length=50)
title: Optional[str] = Field(None, max_length=50)
phone: Optional[str] = Field(None, max_length=20)
email: Optional[str] = Field(None, max_length=100)
base_salary: Optional[float] = Field(None, ge=0)
performance_ratio: Optional[float] = Field(None, ge=0, le=5)
status: Optional[StaffStatus] = None
class StaffResponse(StaffBase):
"""员工响应"""
model_config = ConfigDict(from_attributes=True)
id: int
status: StaffStatus
hire_date: Optional[datetime]
created_at: datetime
updated_at: datetime
department_name: Optional[str] = None
# ==================== 指标相关 ====================
class IndicatorBase(BaseModel):
"""指标基础模式"""
name: str = Field(..., max_length=100, description="指标名称")
code: str = Field(..., max_length=20, description="指标编码")
indicator_type: IndicatorType = Field(..., description="指标类型")
weight: float = Field(default=1.0, gt=0, le=20, description="权重")
max_score: float = Field(default=100.0, ge=0, le=1000, description="最高分值")
target_value: Optional[float] = Field(None, description="目标值")
unit: Optional[str] = Field(None, max_length=20, description="计量单位")
calculation_method: Optional[str] = Field(None, description="计算方法")
description: Optional[str] = Field(None, description="描述")
class IndicatorCreate(IndicatorBase):
"""创建指标"""
pass
class IndicatorUpdate(BaseModel):
"""更新指标"""
name: Optional[str] = Field(None, max_length=100)
indicator_type: Optional[IndicatorType] = None
weight: Optional[float] = Field(None, gt=0, le=20)
max_score: Optional[float] = Field(None, ge=0, le=1000)
target_value: Optional[float] = None
unit: Optional[str] = None
calculation_method: Optional[str] = None
description: Optional[str] = None
is_active: Optional[bool] = None
class IndicatorResponse(IndicatorBase):
"""指标响应"""
model_config = ConfigDict(from_attributes=True)
id: int
is_active: bool
created_at: datetime
updated_at: datetime
# ==================== 考核相关 ====================
class AssessmentDetailBase(BaseModel):
"""考核明细基础模式"""
indicator_id: int = Field(..., description="指标ID")
actual_value: Optional[float] = Field(None, description="实际值")
score: float = Field(default=0, ge=0, description="得分")
evidence: Optional[str] = Field(None, description="佐证材料")
remark: Optional[str] = Field(None, description="备注")
class AssessmentDetailCreate(AssessmentDetailBase):
"""创建考核明细"""
pass
class AssessmentDetailResponse(AssessmentDetailBase):
"""考核明细响应"""
model_config = ConfigDict(from_attributes=True)
id: int
assessment_id: int
indicator_name: Optional[str] = None
created_at: datetime
class AssessmentBase(BaseModel):
"""考核基础模式"""
staff_id: int = Field(..., description="员工ID")
period_year: int = Field(..., ge=2020, le=2100, description="年度")
period_month: int = Field(..., ge=1, le=12, description="月份")
period_type: str = Field(default="monthly", description="周期类型")
class AssessmentCreate(AssessmentBase):
"""创建考核"""
details: List[AssessmentDetailCreate] = Field(default_factory=list, description="考核明细")
class AssessmentUpdate(BaseModel):
"""更新考核"""
details: Optional[List[AssessmentDetailCreate]] = None
remark: Optional[str] = None
class AssessmentResponse(AssessmentBase):
"""考核响应"""
model_config = ConfigDict(from_attributes=True)
id: int
total_score: float
weighted_score: float
status: AssessmentStatus
assessor_id: Optional[int]
reviewer_id: Optional[int]
submit_time: Optional[datetime]
review_time: Optional[datetime]
remark: Optional[str]
created_at: datetime
updated_at: datetime
staff_name: Optional[str] = None
department_name: Optional[str] = None
details: List[AssessmentDetailResponse] = []
class AssessmentListResponse(AssessmentBase):
"""考核列表响应"""
model_config = ConfigDict(from_attributes=True)
id: int
total_score: float
weighted_score: float
status: AssessmentStatus
staff_name: Optional[str] = None
department_name: Optional[str] = None
created_at: datetime
# ==================== 工资相关 ====================
class SalaryRecordBase(BaseModel):
"""工资记录基础模式"""
staff_id: int = Field(..., description="员工ID")
period_year: int = Field(..., ge=2020, le=2100, description="年度")
period_month: int = Field(..., ge=1, le=12, description="月份")
base_salary: float = Field(default=0, ge=0, description="基本工资")
performance_score: float = Field(default=0, ge=0, description="绩效得分")
performance_bonus: float = Field(default=0, ge=0, description="绩效奖金")
deduction: float = Field(default=0, ge=0, description="扣款")
allowance: float = Field(default=0, ge=0, description="补贴")
class SalaryRecordCreate(SalaryRecordBase):
"""创建工资记录"""
remark: Optional[str] = None
class SalaryRecordUpdate(BaseModel):
"""更新工资记录"""
performance_bonus: Optional[float] = Field(None, ge=0)
deduction: Optional[float] = Field(None, ge=0)
allowance: Optional[float] = Field(None, ge=0)
remark: Optional[str] = None
class SalaryRecordResponse(SalaryRecordBase):
"""工资记录响应"""
model_config = ConfigDict(from_attributes=True)
id: int
total_salary: float
status: str
remark: Optional[str]
created_at: datetime
updated_at: datetime
staff_name: Optional[str] = None
department_name: Optional[str] = None
# ==================== 用户认证相关 ====================
class UserLogin(BaseModel):
"""用户登录"""
username: str = Field(..., min_length=3, max_length=50)
password: str = Field(..., min_length=6, max_length=100)
class UserCreate(BaseModel):
"""创建用户"""
username: str = Field(..., min_length=3, max_length=50)
password: str = Field(..., min_length=6, max_length=100)
staff_id: Optional[int] = None
role: str = Field(default="staff", pattern="^(admin|manager|staff)$")
class Token(BaseModel):
"""令牌响应"""
access_token: str
token_type: str = "bearer"
class UserResponse(BaseModel):
"""用户响应"""
model_config = ConfigDict(from_attributes=True)
id: int
username: str
role: str
is_active: bool
last_login: Optional[datetime]
created_at: datetime
# ==================== 统计报表相关 ====================
class DepartmentStats(BaseModel):
"""科室统计"""
department_id: int
department_name: str
staff_count: int
avg_score: float
total_bonus: float
class PeriodStats(BaseModel):
"""周期统计"""
period_year: int
period_month: int
total_staff: int
assessed_count: int
avg_score: float
total_bonus: float
score_distribution: dict
class TrendData(BaseModel):
"""趋势数据"""
period: str
avg_score: float
total_bonus: float
# ==================== 财务核算相关 ====================
class RevenueCategory(str, Enum):
"""收入类别"""
EXAMINATION = "examination" # 检查费
LAB_TEST = "lab_test" # 检验费
RADIOLOGY = "radiology" # 放射费
BED = "bed" # 床位费
NURSING = "nursing" # 护理费
TREATMENT = "treatment" # 治疗费
SURGERY = "surgery" # 手术费
INJECTION = "injection" # 注射费
OXYGEN = "oxygen" # 吸氧费
OTHER = "other" # 其他
class ExpenseCategory(str, Enum):
"""支出类别"""
MATERIAL = "material" # 材料费
PERSONNEL = "personnel" # 人员支出
MAINTENANCE = "maintenance" # 维修费
UTILITY = "utility" # 水电费
OTHER = "other" # 其他
class FinanceType(str, Enum):
"""财务类型"""
REVENUE = "revenue" # 收入
EXPENSE = "expense" # 支出
class FinanceRecordBase(BaseModel):
"""财务记录基础模式"""
department_id: int = Field(..., description="科室ID")
finance_type: FinanceType = Field(..., description="财务类型")
category: str = Field(..., max_length=50, description="类别")
amount: float = Field(..., ge=0, description="金额")
period_year: int = Field(..., ge=2020, le=2100, description="年度")
period_month: int = Field(..., ge=1, le=12, description="月份")
source: Optional[str] = Field(None, max_length=100, description="数据来源")
remark: Optional[str] = Field(None, description="备注")
class FinanceRecordCreate(FinanceRecordBase):
"""创建财务记录"""
pass
class FinanceRecordUpdate(BaseModel):
"""更新财务记录"""
category: Optional[str] = Field(None, max_length=50)
amount: Optional[float] = Field(None, ge=0)
source: Optional[str] = Field(None, max_length=100)
remark: Optional[str] = None
class FinanceRecordResponse(FinanceRecordBase):
"""财务记录响应"""
model_config = ConfigDict(from_attributes=True)
id: int
department_name: Optional[str] = None
category_label: Optional[str] = None
created_at: datetime
updated_at: datetime
class DepartmentBalance(BaseModel):
"""科室收支结余"""
department_id: Optional[int] = None
department_name: Optional[str] = None
period_year: Optional[int] = None
period_month: Optional[int] = None
total_revenue: float = Field(default=0, description="总收入")
total_expense: float = Field(default=0, description="总支出")
balance: float = Field(default=0, description="结余")
class CategorySummary(BaseModel):
"""类别汇总"""
category: str
category_label: str
amount: float
# ==================== 绩效计划相关 ====================
class PlanLevel(str, Enum):
"""计划层级"""
HOSPITAL = "hospital"
DEPARTMENT = "department"
INDIVIDUAL = "individual"
class PlanStatus(str, Enum):
"""计划状态"""
DRAFT = "draft"
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
ACTIVE = "active"
COMPLETED = "completed"
CANCELLED = "cancelled"
class PlanKpiRelationBase(BaseModel):
"""计划指标关联基础模式"""
indicator_id: int = Field(..., description="指标 ID")
target_value: Optional[float] = Field(None, description="目标值")
target_unit: Optional[str] = Field(None, max_length=50, description="目标值单位")
weight: float = Field(default=1.0, ge=0, le=100, description="权重")
scoring_method: Optional[str] = Field(None, max_length=50, description="评分方法")
scoring_params: Optional[str] = Field(None, description="评分参数 (JSON)")
remark: Optional[str] = Field(None, description="备注")
class PlanKpiRelationCreate(PlanKpiRelationBase):
"""创建计划指标关联"""
pass
class PlanKpiRelationUpdate(BaseModel):
"""更新计划指标关联"""
target_value: Optional[float] = Field(None, description="目标值")
target_unit: Optional[str] = Field(None, max_length=50, description="目标值单位")
weight: Optional[float] = Field(None, ge=0, le=100, description="权重")
scoring_method: Optional[str] = Field(None, max_length=50, description="评分方法")
scoring_params: Optional[str] = Field(None, description="评分参数 (JSON)")
remark: Optional[str] = Field(None, description="备注")
class PlanKpiRelationResponse(PlanKpiRelationBase):
"""计划指标关联响应"""
model_config = ConfigDict(from_attributes=True)
id: int
plan_id: int
indicator_name: Optional[str] = None
indicator_code: Optional[str] = None
created_at: datetime
updated_at: datetime
class PerformancePlanBase(BaseModel):
"""绩效计划基础模式"""
plan_name: str = Field(..., max_length=200, description="计划名称")
plan_code: str = Field(..., max_length=50, description="计划编码")
plan_level: PlanLevel = Field(..., description="计划层级")
plan_year: int = Field(..., ge=2000, le=2100, description="计划年度")
plan_month: Optional[int] = Field(None, ge=1, le=12, description="计划月份")
plan_type: str = Field(default="annual", max_length=20, description="计划类型")
department_id: Optional[int] = Field(None, description="所属科室 ID")
staff_id: Optional[int] = Field(None, description="责任人 ID")
parent_plan_id: Optional[int] = Field(None, description="上级计划 ID")
description: Optional[str] = Field(None, description="计划描述")
strategic_goals: Optional[str] = Field(None, description="战略目标")
key_initiatives: Optional[str] = Field(None, description="关键举措")
class PerformancePlanCreate(PerformancePlanBase):
"""创建绩效计划"""
kpi_relations: Optional[List[PlanKpiRelationCreate]] = Field(None, description="指标关联列表")
class PerformancePlanUpdate(BaseModel):
"""更新绩效计划"""
plan_name: Optional[str] = Field(None, max_length=200, description="计划名称")
plan_level: Optional[PlanLevel] = Field(None, description="计划层级")
department_id: Optional[int] = Field(None, description="所属科室 ID")
staff_id: Optional[int] = Field(None, description="责任人 ID")
description: Optional[str] = Field(None, description="计划描述")
strategic_goals: Optional[str] = Field(None, description="战略目标")
key_initiatives: Optional[str] = Field(None, description="关键举措")
status: Optional[PlanStatus] = Field(None, description="状态")
approve_remark: Optional[str] = Field(None, description="审批意见")
class PerformancePlanResponse(PerformancePlanBase):
"""绩效计划响应"""
model_config = ConfigDict(from_attributes=True)
id: int
status: PlanStatus
submitter_id: Optional[int] = None
submit_time: Optional[datetime] = None
approver_id: Optional[int] = None
approve_time: Optional[datetime] = None
version: int
is_active: bool
created_at: datetime
updated_at: datetime
department_name: Optional[str] = None
staff_name: Optional[str] = None
kpi_relations: Optional[List[PlanKpiRelationResponse]] = None
class PerformancePlanStats(BaseModel):
"""绩效计划统计"""
total_plans: int = Field(0, description="总计划数")
draft_count: int = Field(0, description="草稿数")
pending_count: int = Field(0, description="待审批数")
approved_count: int = Field(0, description="已批准数")
active_count: int = Field(0, description="执行中数")
completed_count: int = Field(0, description="已完成数")
# ==================== 菜单管理相关 ====================
class MenuType(str, Enum):
"""菜单类型"""
MENU = "menu"
BUTTON = "button"
class MenuBase(BaseModel):
"""菜单基础模式"""
parent_id: Optional[int] = Field(None, description="父菜单 ID")
menu_type: MenuType = Field(default=MenuType.MENU, description="菜单类型")
menu_name: str = Field(..., max_length=100, description="菜单名称")
menu_icon: Optional[str] = Field(None, max_length=50, description="菜单图标")
path: str = Field(..., max_length=200, description="路由路径")
component: Optional[str] = Field(None, max_length=200, description="组件路径")
permission: Optional[str] = Field(None, max_length=100, description="权限标识")
sort_order: int = Field(default=0, ge=0, description="排序")
is_visible: bool = Field(default=True, description="是否可见")
is_active: bool = Field(default=True, description="是否启用")
class MenuCreate(MenuBase):
"""创建菜单"""
pass
class MenuUpdate(BaseModel):
"""更新菜单"""
menu_name: Optional[str] = Field(None, max_length=100, description="菜单名称")
menu_icon: Optional[str] = Field(None, max_length=50, description="菜单图标")
path: Optional[str] = Field(None, max_length=200, description="路由路径")
component: Optional[str] = Field(None, max_length=200, description="组件路径")
permission: Optional[str] = Field(None, max_length=100, description="权限标识")
sort_order: Optional[int] = Field(None, ge=0, description="排序")
is_visible: Optional[bool] = Field(None, description="是否可见")
is_active: Optional[bool] = Field(None, description="是否启用")
class MenuResponse(MenuBase):
"""菜单响应"""
model_config = ConfigDict(from_attributes=True)
id: int
children: Optional[List["MenuResponse"]] = None
created_at: datetime
updated_at: datetime
class MenuTree(BaseModel):
"""菜单树节点"""
id: int
menu_name: str
menu_icon: Optional[str] = None
path: str
children: Optional[List["MenuTree"]] = None
# ==================== 指标模板相关 ====================
class TemplateType(str, Enum):
"""模板类型"""
GENERAL = "general" # 通用模板
SURGICAL = "surgical" # 手术临床科室
NON_SURGICAL_WARD = "nonsurgical_ward" # 非手术有病房科室
NON_SURGICAL_NOWARD = "nonsurgical_noward" # 非手术无病房科室
MEDICAL_TECH = "medical_tech" # 医技科室
NURSING = "nursing" # 护理单元
ADMIN = "admin" # 行政科室
LOGISTICS = "logistics" # 后勤科室
class TemplateIndicatorBase(BaseModel):
"""模板指标关联基础模式"""
indicator_id: int = Field(..., description="指标 ID")
category: Optional[str] = Field(None, max_length=100, description="指标分类")
target_value: Optional[float] = Field(None, description="目标值")
target_unit: Optional[str] = Field(None, max_length=50, description="目标值单位")
weight: float = Field(default=1.0, ge=0, le=100, description="权重")
scoring_method: Optional[str] = Field(None, max_length=50, description="评分方法")
scoring_params: Optional[str] = Field(None, description="评分参数 (JSON)")
sort_order: int = Field(default=0, ge=0, description="排序")
remark: Optional[str] = Field(None, description="备注")
class TemplateIndicatorCreate(TemplateIndicatorBase):
"""创建模板指标关联"""
pass
class TemplateIndicatorUpdate(BaseModel):
"""更新模板指标关联"""
category: Optional[str] = Field(None, max_length=100, description="指标分类")
target_value: Optional[float] = Field(None, description="目标值")
target_unit: Optional[str] = Field(None, max_length=50, description="目标值单位")
weight: Optional[float] = Field(None, ge=0, le=100, description="权重")
scoring_method: Optional[str] = Field(None, max_length=50, description="评分方法")
scoring_params: Optional[str] = Field(None, description="评分参数 (JSON)")
sort_order: Optional[int] = Field(None, ge=0, description="排序")
remark: Optional[str] = Field(None, description="备注")
class TemplateIndicatorResponse(TemplateIndicatorBase):
"""模板指标关联响应"""
model_config = ConfigDict(from_attributes=True)
id: int
template_id: int
indicator_name: Optional[str] = None
indicator_code: Optional[str] = None
indicator_type: Optional[str] = None
bs_dimension: Optional[str] = None
created_at: datetime
updated_at: datetime
class IndicatorTemplateBase(BaseModel):
"""指标模板基础模式"""
template_name: str = Field(..., max_length=200, description="模板名称")
template_code: str = Field(..., max_length=50, description="模板编码")
template_type: TemplateType = Field(..., description="模板类型")
description: Optional[str] = Field(None, description="模板描述")
dimension_weights: Optional[str] = Field(None, description="维度权重 (JSON)")
assessment_cycle: str = Field(default="monthly", max_length=20, description="考核周期")
class IndicatorTemplateCreate(IndicatorTemplateBase):
"""创建指标模板"""
indicators: Optional[List[TemplateIndicatorCreate]] = Field(None, description="指标列表")
class IndicatorTemplateUpdate(BaseModel):
"""更新指标模板"""
template_name: Optional[str] = Field(None, max_length=200, description="模板名称")
template_type: Optional[TemplateType] = None
description: Optional[str] = Field(None, description="模板描述")
dimension_weights: Optional[str] = Field(None, description="维度权重 (JSON)")
assessment_cycle: Optional[str] = Field(None, max_length=20, description="考核周期")
is_active: Optional[bool] = Field(None, description="是否启用")
class IndicatorTemplateResponse(IndicatorTemplateBase):
"""指标模板响应"""
model_config = ConfigDict(from_attributes=True)
id: int
is_active: bool
created_at: datetime
updated_at: datetime
indicators: Optional[List[TemplateIndicatorResponse]] = None
class IndicatorTemplateListResponse(IndicatorTemplateBase):
"""指标模板列表响应"""
model_config = ConfigDict(from_attributes=True)
id: int
is_active: bool
indicator_count: int = Field(default=0, description="指标数量")
created_at: datetime
updated_at: datetime

View File

View File

@@ -0,0 +1,275 @@
"""
初始化指标模板数据
根据考核指标模板文档初始化模板和指标数据
"""
import asyncio
import json
import sys
import io
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import select
# 设置标准输出编码
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
from app.core.config import settings
from app.models.models import (
Indicator, IndicatorTemplate, TemplateIndicator,
IndicatorType, BSCDimension, TemplateType
)
# 指标数据 - 基于文档中的模板
INDICATORS_DATA = [
# 财务管理维度
{"code": "FIN001", "name": "业务收支结余率", "indicator_type": IndicatorType.COST, "bs_dimension": BSCDimension.FINANCIAL, "weight": 1.0, "max_score": 100, "target_value": None, "target_unit": "%", "calculation_method": "(收入-支出)/收入×100%", "assessment_method": "区间法达标满分每低1%扣2分", "data_source": "财务科"},
{"code": "FIN002", "name": "百元医疗收入卫生材料消耗", "indicator_type": IndicatorType.COST, "bs_dimension": BSCDimension.FINANCIAL, "weight": 1.0, "max_score": 100, "target_value": 20, "target_unit": "", "calculation_method": "卫生材料消耗/医疗收入×100", "assessment_method": "目标参照法(≤目标值满分,超扣分)", "data_source": "物资科"},
{"code": "FIN003", "name": "可控成本占比", "indicator_type": IndicatorType.COST, "bs_dimension": BSCDimension.FINANCIAL, "weight": 1.0, "max_score": 100, "target_value": 30, "target_unit": "%", "calculation_method": "可控成本/总成本×100%", "assessment_method": "区间法", "data_source": "财务科"},
{"code": "FIN004", "name": "百元固定资产收入", "indicator_type": IndicatorType.EFFICIENCY, "bs_dimension": BSCDimension.FINANCIAL, "weight": 1.0, "max_score": 100, "target_value": 150, "target_unit": "", "calculation_method": "业务收入/固定资产原值×100", "assessment_method": "比较法(与去年同期/标杆比)", "data_source": "财务科、设备科"},
# 顾客服务维度
{"code": "CUS001", "name": "住院患者满意度得分", "indicator_type": IndicatorType.SERVICE, "bs_dimension": BSCDimension.CUSTOMER, "weight": 1.0, "max_score": 100, "target_value": 90, "target_unit": "", "calculation_method": "满意度调查问卷统计", "assessment_method": "区间法90-100满分每低1分扣2分", "data_source": "满意度调查"},
{"code": "CUS002", "name": "门诊患者满意度得分", "indicator_type": IndicatorType.SERVICE, "bs_dimension": BSCDimension.CUSTOMER, "weight": 1.0, "max_score": 100, "target_value": 85, "target_unit": "", "calculation_method": "满意度调查问卷统计", "assessment_method": "区间法", "data_source": "满意度调查"},
{"code": "CUS003", "name": "预约就诊率", "indicator_type": IndicatorType.SERVICE, "bs_dimension": BSCDimension.CUSTOMER, "weight": 1.0, "max_score": 100, "target_value": 60, "target_unit": "%", "calculation_method": "预约挂号人次/总挂号人次×100%", "assessment_method": "目标参照法", "data_source": "HIS系统"},
{"code": "CUS004", "name": "有效投诉次数", "indicator_type": IndicatorType.SERVICE, "bs_dimension": BSCDimension.CUSTOMER, "weight": 1.0, "max_score": 100, "target_value": 0, "target_unit": "", "calculation_method": "有效投诉统计", "assessment_method": "扣分法每发生1次扣5分", "data_source": "投诉办、党办"},
# 内部流程维度
{"code": "IPR001", "name": "出院患者平均住院日", "indicator_type": IndicatorType.EFFICIENCY, "bs_dimension": BSCDimension.INTERNAL_PROCESS, "weight": 1.0, "max_score": 100, "target_value": 9, "target_unit": "", "calculation_method": "出院患者占用总床日数/出院人次", "assessment_method": "区间法≤目标值满分每超1天扣2分", "data_source": "病案室"},
{"code": "IPR002", "name": "病历甲级率", "indicator_type": IndicatorType.QUALITY, "bs_dimension": BSCDimension.INTERNAL_PROCESS, "weight": 1.0, "max_score": 100, "target_value": 90, "target_unit": "%", "calculation_method": "甲级病历数/抽查病历数×100%", "assessment_method": "区间法", "data_source": "质控科"},
{"code": "IPR003", "name": "医疗事故/严重差错发生数", "indicator_type": IndicatorType.QUALITY, "bs_dimension": BSCDimension.INTERNAL_PROCESS, "weight": 1.0, "max_score": 100, "target_value": 0, "target_unit": "", "calculation_method": "医疗事故统计", "assessment_method": "一票否决/扣分法", "data_source": "医务科", "is_veto": True},
{"code": "IPR004", "name": "医院感染发生率", "indicator_type": IndicatorType.QUALITY, "bs_dimension": BSCDimension.INTERNAL_PROCESS, "weight": 1.0, "max_score": 100, "target_value": 5, "target_unit": "%", "calculation_method": "医院感染例数/出院人次×100%", "assessment_method": "目标参照法", "data_source": "院感科"},
{"code": "IPR005", "name": "抗菌药物使用强度", "indicator_type": IndicatorType.QUALITY, "bs_dimension": BSCDimension.INTERNAL_PROCESS, "weight": 1.0, "max_score": 100, "target_value": 40, "target_unit": "DDDs", "calculation_method": "抗菌药物累计DDD数/同期收治患者人天数×100", "assessment_method": "区间法", "data_source": "药剂科"},
# 学习与成长维度
{"code": "LRN001", "name": "发表论文数", "indicator_type": IndicatorType.QUANTITY, "bs_dimension": BSCDimension.LEARNING_GROWTH, "weight": 1.0, "max_score": 100, "target_value": 2, "target_unit": "篇/年", "calculation_method": "核心/统计源期刊发表论文数", "assessment_method": "加分法每篇加5分封顶20分", "data_source": "科教科"},
{"code": "LRN002", "name": "带教实习生/进修生人数", "indicator_type": IndicatorType.QUANTITY, "bs_dimension": BSCDimension.LEARNING_GROWTH, "weight": 1.0, "max_score": 100, "target_value": 5, "target_unit": "人/年", "calculation_method": "带教人数统计", "assessment_method": "目标参照法", "data_source": "科教科"},
{"code": "LRN003", "name": "参加院内外培训人次", "indicator_type": IndicatorType.QUANTITY, "bs_dimension": BSCDimension.LEARNING_GROWTH, "weight": 1.0, "max_score": 100, "target_value": 20, "target_unit": "人次/季", "calculation_method": "培训人次统计", "assessment_method": "区间法", "data_source": "人事科"},
{"code": "LRN004", "name": "科室内部业务学习次数", "indicator_type": IndicatorType.QUANTITY, "bs_dimension": BSCDimension.LEARNING_GROWTH, "weight": 1.0, "max_score": 100, "target_value": 4, "target_unit": "次/月", "calculation_method": "学习记录统计", "assessment_method": "核查法少1次扣2分", "data_source": "科室自查"},
# 手术科室专项指标
{"code": "SRG001", "name": "DRG组数", "indicator_type": IndicatorType.QUANTITY, "bs_dimension": BSCDimension.FINANCIAL, "weight": 1.0, "max_score": 100, "target_value": None, "target_unit": "", "calculation_method": "出院病例进入的DRG组数量", "assessment_method": "比较法", "data_source": "病案室、DRG分组器"},
{"code": "SRG002", "name": "CMI值", "indicator_type": IndicatorType.QUALITY, "bs_dimension": BSCDimension.FINANCIAL, "weight": 1.0, "max_score": 100, "target_value": 1.0, "target_unit": None, "calculation_method": "科室出院病例平均权重", "assessment_method": "比较法", "data_source": "病案室、DRG分组器"},
{"code": "SRG003", "name": "费用消耗指数", "indicator_type": IndicatorType.COST, "bs_dimension": BSCDimension.FINANCIAL, "weight": 1.0, "max_score": 100, "target_value": 1.0, "target_unit": None, "calculation_method": "本科室DRG平均费用/区域同级医院同DRG平均费用", "assessment_method": "目标参照法", "data_source": "医保办、财务科"},
{"code": "SRG004", "name": "时间消耗指数", "indicator_type": IndicatorType.EFFICIENCY, "bs_dimension": BSCDimension.FINANCIAL, "weight": 1.0, "max_score": 100, "target_value": 1.0, "target_unit": None, "calculation_method": "本科室DRG平均住院日/区域同级医院同DRG平均住院日", "assessment_method": "目标参照法", "data_source": "病案室"},
{"code": "SRG005", "name": "手术并发症发生率", "indicator_type": IndicatorType.QUALITY, "bs_dimension": BSCDimension.INTERNAL_PROCESS, "weight": 1.0, "max_score": 100, "target_value": 1, "target_unit": "%", "calculation_method": "手术并发症例数/手术人次×100%", "assessment_method": "区间法", "data_source": "医务科"},
{"code": "SRG006", "name": "非计划重返手术室率", "indicator_type": IndicatorType.QUALITY, "bs_dimension": BSCDimension.INTERNAL_PROCESS, "weight": 1.0, "max_score": 100, "target_value": 0.5, "target_unit": "%", "calculation_method": "非计划重返手术室人次/手术人次×100%", "assessment_method": "区间法", "data_source": "医务科"},
{"code": "SRG007", "name": "围手术期死亡率", "indicator_type": IndicatorType.QUALITY, "bs_dimension": BSCDimension.INTERNAL_PROCESS, "weight": 1.0, "max_score": 100, "target_value": 0, "target_unit": "%", "calculation_method": "围手术期死亡人次/手术人次×100%", "assessment_method": "一票否决", "data_source": "医务科", "is_veto": True},
# 医技科室专项指标
{"code": "MTT001", "name": "检验/检查报告准确率", "indicator_type": IndicatorType.QUALITY, "bs_dimension": BSCDimension.INTERNAL_PROCESS, "weight": 1.0, "max_score": 100, "target_value": 99.5, "target_unit": "%", "calculation_method": "准确报告数/总报告数×100%", "assessment_method": "每低0.1%扣2分", "data_source": "质控科、临床反馈"},
{"code": "MTT002", "name": "室内质控达标率", "indicator_type": IndicatorType.QUALITY, "bs_dimension": BSCDimension.INTERNAL_PROCESS, "weight": 1.0, "max_score": 100, "target_value": 100, "target_unit": "%", "calculation_method": "质控达标项次/总质控项次×100%", "assessment_method": "未达标项次扣分", "data_source": "科室自查记录"},
{"code": "MTT003", "name": "危急值及时报告率", "indicator_type": IndicatorType.QUALITY, "bs_dimension": BSCDimension.INTERNAL_PROCESS, "weight": 1.0, "max_score": 100, "target_value": 100, "target_unit": "%", "calculation_method": "及时报告危急值数/危急值总数×100%", "assessment_method": "每漏报/迟报1例扣5分", "data_source": "HIS系统追踪"},
{"code": "MTT004", "name": "门诊常规报告出具时间", "indicator_type": IndicatorType.EFFICIENCY, "bs_dimension": BSCDimension.INTERNAL_PROCESS, "weight": 1.0, "max_score": 100, "target_value": 2, "target_unit": "小时", "calculation_method": "报告出具时间-标本接收时间", "assessment_method": "超时率每超5%扣2分", "data_source": "LIS/RIS系统"},
{"code": "MTT005", "name": "急诊报告出具时间", "indicator_type": IndicatorType.EFFICIENCY, "bs_dimension": BSCDimension.INTERNAL_PROCESS, "weight": 1.0, "max_score": 100, "target_value": 30, "target_unit": "分钟", "calculation_method": "报告出具时间-标本接收时间", "assessment_method": "超时率每超5%扣2分", "data_source": "LIS/RIS系统"},
{"code": "MTT006", "name": "临床科室对医技服务满意度", "indicator_type": IndicatorType.SERVICE, "bs_dimension": BSCDimension.CUSTOMER, "weight": 1.0, "max_score": 100, "target_value": 90, "target_unit": "", "calculation_method": "内部满意度调查", "assessment_method": "区间法", "data_source": "内部满意度调查"},
# 行政科室专项指标
{"code": "ADM001", "name": "服务态度与响应及时性", "indicator_type": IndicatorType.SERVICE, "bs_dimension": BSCDimension.CUSTOMER, "weight": 1.0, "max_score": 100, "target_value": 90, "target_unit": "", "calculation_method": "临床科室满意度测评", "assessment_method": "问卷/投票评分", "data_source": "满意度调查"},
{"code": "ADM002", "name": "遵纪守法与廉洁自律", "indicator_type": IndicatorType.QUALITY, "bs_dimension": BSCDimension.INTERNAL_PROCESS, "weight": 1.0, "max_score": 100, "target_value": 100, "target_unit": "%", "calculation_method": "违规情况统计", "assessment_method": "扣分法(违规即扣)", "data_source": "党办/纪检监察室"},
{"code": "ADM003", "name": "工作计划与总结", "indicator_type": IndicatorType.QUALITY, "bs_dimension": BSCDimension.INTERNAL_PROCESS, "weight": 1.0, "max_score": 100, "target_value": 100, "target_unit": "%", "calculation_method": "按时提交率", "assessment_method": "目标参照法+检查扣分", "data_source": "院办"},
{"code": "ADM004", "name": "任务按时完成率", "indicator_type": IndicatorType.EFFICIENCY, "bs_dimension": BSCDimension.INTERNAL_PROCESS, "weight": 1.0, "max_score": 100, "target_value": 95, "target_unit": "%", "calculation_method": "按时完成任务数/总任务数×100%", "assessment_method": "核查法(任务逾期扣分)", "data_source": "院办"},
# 后勤科室专项指标
{"code": "LOG001", "name": "后勤保障及时率", "indicator_type": IndicatorType.EFFICIENCY, "bs_dimension": BSCDimension.INTERNAL_PROCESS, "weight": 1.0, "max_score": 100, "target_value": 95, "target_unit": "%", "calculation_method": "及时响应次数/总报修次数×100%", "assessment_method": "区间法", "data_source": "总务科"},
{"code": "LOG002", "name": "安全生产检查达标率", "indicator_type": IndicatorType.QUALITY, "bs_dimension": BSCDimension.INTERNAL_PROCESS, "weight": 1.0, "max_score": 100, "target_value": 100, "target_unit": "%", "calculation_method": "达标项次/检查项次×100%", "assessment_method": "扣分法", "data_source": "安全检查记录"},
{"code": "LOG003", "name": "设备完好率", "indicator_type": IndicatorType.QUALITY, "bs_dimension": BSCDimension.INTERNAL_PROCESS, "weight": 1.0, "max_score": 100, "target_value": 95, "target_unit": "%", "calculation_method": "完好设备数/设备总数×100%", "assessment_method": "区间法", "data_source": "设备科"},
]
# 模板数据
TEMPLATES_DATA = [
{
"template_code": "TPL_GENERAL",
"template_name": "平衡计分卡四维度通用考核方案",
"template_type": TemplateType.GENERAL,
"description": "基于平衡计分卡理论,整合财务、顾客、内部流程、学习成长四个维度,适用于全院各科室",
"dimension_weights": {"financial": 35, "customer": 30, "internal_process": 25, "learning_growth": 10},
"assessment_cycle": "monthly",
"indicators": [
{"code": "FIN001", "category": "收支管理", "weight": 10},
{"code": "FIN002", "category": "成本控制", "weight": 8},
{"code": "FIN003", "category": "成本控制", "weight": 7},
{"code": "FIN004", "category": "资产效率", "weight": 5},
{"code": "CUS001", "category": "患者满意度", "weight": 10},
{"code": "CUS002", "category": "患者满意度", "weight": 5},
{"code": "CUS003", "category": "服务可及性", "weight": 5},
{"code": "CUS004", "category": "投诉管理", "weight": 5},
{"code": "IPR001", "category": "医疗质量", "weight": 6},
{"code": "IPR002", "category": "医疗质量", "weight": 6},
{"code": "IPR003", "category": "医疗安全", "weight": 8},
{"code": "IPR004", "category": "院感控制", "weight": 5},
{"code": "IPR005", "category": "合理用药", "weight": 5},
{"code": "LRN001", "category": "科研教学", "weight": 4},
{"code": "LRN002", "category": "科研教学", "weight": 3},
{"code": "LRN003", "category": "人才培养", "weight": 3},
{"code": "LRN004", "category": "人才培养", "weight": 2},
]
},
{
"template_code": "TPL_SURGICAL",
"template_name": "临床手术科室RBRVS/DRG导向绩效方案",
"template_type": TemplateType.SURGICAL,
"description": "结合RBRVS和DRG理念体现技术难度、风险和工作量适用于外科、妇科、眼科等手术科室",
"dimension_weights": {"financial": 35, "customer": 20, "internal_process": 30, "learning_growth": 15},
"assessment_cycle": "monthly",
"indicators": [
{"code": "SRG001", "category": "财务与效率", "weight": 8},
{"code": "SRG002", "category": "财务与效率", "weight": 10},
{"code": "SRG003", "category": "财务与效率", "weight": 8},
{"code": "SRG004", "category": "财务与效率", "weight": 9},
{"code": "FIN001", "category": "财务与效率", "weight": 5},
{"code": "SRG005", "category": "质量与安全", "weight": 10},
{"code": "SRG006", "category": "质量与安全", "weight": 8},
{"code": "SRG007", "category": "质量与安全", "weight": 10},
{"code": "IPR002", "category": "质量与安全", "weight": 8},
{"code": "CUS001", "category": "患者与服务", "weight": 10},
{"code": "CUS004", "category": "患者与服务", "weight": 5},
{"code": "LRN001", "category": "学习与创新", "weight": 5},
{"code": "LRN002", "category": "学习与创新", "weight": 4},
]
},
{
"template_code": "TPL_MEDICAL_TECH",
"template_name": "医技科室质量效率双核心考核方案",
"template_type": TemplateType.MEDICAL_TECH,
"description": "以工作质量、报告准确性和内部服务效率为核心,兼顾成本控制,适用于检验科、放射科、超声科、药剂科等",
"dimension_weights": {"financial": 20, "customer": 30, "internal_process": 40, "learning_growth": 10},
"assessment_cycle": "monthly",
"indicators": [
{"code": "MTT001", "category": "工作质量与安全", "weight": 12},
{"code": "MTT002", "category": "工作质量与安全", "weight": 10},
{"code": "MTT003", "category": "工作质量与安全", "weight": 10},
{"code": "IPR003", "category": "工作质量与安全", "weight": 8},
{"code": "MTT004", "category": "内部服务效率", "weight": 8},
{"code": "MTT005", "category": "内部服务效率", "weight": 10},
{"code": "MTT006", "category": "内部服务效率", "weight": 8},
{"code": "FIN002", "category": "成本与资源管理", "weight": 10},
{"code": "LOG003", "category": "成本与资源管理", "weight": 8},
{"code": "LRN001", "category": "学科发展与服务", "weight": 5},
{"code": "LRN002", "category": "学科发展与服务", "weight": 5},
]
},
{
"template_code": "TPL_ADMIN",
"template_name": "行政后勤科室服务支持导向考核方案",
"template_type": TemplateType.ADMIN,
"description": "以保障临床、服务一线、管理效能为核心,侧重过程管理与内部客户满意度,适用于院办、党办、医务科、护理部、财务科等",
"dimension_weights": {"financial": 10, "customer": 40, "internal_process": 40, "learning_growth": 10},
"assessment_cycle": "quarterly",
"indicators": [
{"code": "ADM001", "category": "基本素质与服务质量", "weight": 20},
{"code": "ADM002", "category": "遵纪守法与廉洁自律", "weight": 15},
{"code": "ADM003", "category": "科室内部管理", "weight": 10},
{"code": "ADM004", "category": "制度建设与执行力", "weight": 15},
{"code": "CUS001", "category": "内部服务满意度", "weight": 20},
{"code": "LRN003", "category": "学习与成长", "weight": 10},
]
},
{
"template_code": "TPL_LOGISTICS",
"template_name": "后勤保障科室考核方案",
"template_type": TemplateType.LOGISTICS,
"description": "以后勤保障及时性、安全生产和设备完好率为核心,适用于总务科、设备科、基建科等",
"dimension_weights": {"financial": 20, "customer": 30, "internal_process": 40, "learning_growth": 10},
"assessment_cycle": "monthly",
"indicators": [
{"code": "LOG001", "category": "后勤保障", "weight": 20},
{"code": "LOG002", "category": "安全生产", "weight": 15},
{"code": "LOG003", "category": "设备管理", "weight": 15},
{"code": "ADM001", "category": "服务质量", "weight": 20},
{"code": "ADM004", "category": "任务执行", "weight": 15},
{"code": "LRN003", "category": "学习与成长", "weight": 10},
]
},
]
async def init_data():
"""初始化数据"""
engine = create_async_engine(settings.DATABASE_URL, echo=True)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
# 创建指标
print("正在创建指标...")
indicator_map = {} # code -> indicator object
for ind_data in INDICATORS_DATA:
result = await session.execute(
select(Indicator).where(Indicator.code == ind_data["code"])
)
existing = result.scalar_one_or_none()
if existing:
indicator_map[ind_data["code"]] = existing
continue
indicator = Indicator(
code=ind_data["code"],
name=ind_data["name"],
indicator_type=ind_data["indicator_type"],
bs_dimension=ind_data["bs_dimension"],
weight=ind_data["weight"],
max_score=ind_data["max_score"],
target_value=ind_data.get("target_value"),
target_unit=ind_data.get("target_unit"),
calculation_method=ind_data.get("calculation_method"),
assessment_method=ind_data.get("assessment_method"),
deduction_standard=ind_data.get("deduction_standard"),
data_source=ind_data.get("data_source"),
is_veto=ind_data.get("is_veto", False),
is_active=True
)
session.add(indicator)
indicator_map[ind_data["code"]] = indicator
await session.commit()
print(f"已创建 {len(indicator_map)} 个指标")
# 创建模板
print("正在创建模板...")
for tpl_data in TEMPLATES_DATA:
result = await session.execute(
select(IndicatorTemplate).where(IndicatorTemplate.template_code == tpl_data["template_code"])
)
existing = result.scalar_one_or_none()
if existing:
print(f"模板 {tpl_data['template_code']} 已存在,跳过")
continue
template = IndicatorTemplate(
template_code=tpl_data["template_code"],
template_name=tpl_data["template_name"],
template_type=tpl_data["template_type"],
description=tpl_data["description"],
dimension_weights=json.dumps(tpl_data["dimension_weights"]),
assessment_cycle=tpl_data["assessment_cycle"],
is_active=True
)
session.add(template)
await session.flush()
# 添加指标关联
for idx, ind_ref in enumerate(tpl_data["indicators"]):
indicator = indicator_map.get(ind_ref["code"])
if not indicator:
print(f"警告:指标 {ind_ref['code']} 不存在")
continue
ti = TemplateIndicator(
template_id=template.id,
indicator_id=indicator.id,
category=ind_ref.get("category"),
weight=ind_ref.get("weight", 1.0),
sort_order=idx
)
session.add(ti)
print(f"已创建模板: {tpl_data['template_name']}")
await session.commit()
print("初始化完成!")
if __name__ == "__main__":
asyncio.run(init_data())

View File

@@ -0,0 +1,15 @@
from app.services.department_service import DepartmentService
from app.services.staff_service import StaffService
from app.services.indicator_service import IndicatorService
from app.services.assessment_service import AssessmentService
from app.services.salary_service import SalaryService
from app.services.stats_service import StatsService
__all__ = [
"DepartmentService",
"StaffService",
"IndicatorService",
"AssessmentService",
"SalaryService",
"StatsService",
]

View File

@@ -0,0 +1,262 @@
"""
绩效考核服务层
"""
from typing import Optional, List
from datetime import datetime
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.models import Assessment, AssessmentDetail, Staff, Indicator, AssessmentStatus
from app.schemas.schemas import AssessmentCreate, AssessmentUpdate
class AssessmentService:
"""绩效考核服务"""
@staticmethod
async def get_list(
db: AsyncSession,
staff_id: Optional[int] = None,
department_id: Optional[int] = None,
period_year: Optional[int] = None,
period_month: Optional[int] = None,
status: Optional[str] = None,
page: int = 1,
page_size: int = 20
) -> tuple[List[Assessment], int]:
"""获取考核列表"""
query = select(Assessment).options(
selectinload(Assessment.staff).selectinload(Staff.department)
)
if staff_id:
query = query.where(Assessment.staff_id == staff_id)
if department_id:
query = query.join(Staff).where(Staff.department_id == department_id)
if period_year:
query = query.where(Assessment.period_year == period_year)
if period_month:
query = query.where(Assessment.period_month == period_month)
if status:
query = query.where(Assessment.status == status)
# 统计总数
count_query = select(func.count()).select_from(query.subquery())
total = await db.scalar(count_query)
# 分页
query = query.order_by(Assessment.period_year.desc(), Assessment.period_month.desc(), Assessment.id.desc())
query = query.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
assessments = result.scalars().all()
return assessments, total or 0
@staticmethod
async def get_by_id(db: AsyncSession, assessment_id: int) -> Optional[Assessment]:
"""根据ID获取考核详情"""
result = await db.execute(
select(Assessment)
.options(
selectinload(Assessment.staff).selectinload(Staff.department),
selectinload(Assessment.details).selectinload(AssessmentDetail.indicator)
)
.where(Assessment.id == assessment_id)
)
return result.scalar_one_or_none()
@staticmethod
async def create(db: AsyncSession, assessment_data: AssessmentCreate, assessor_id: Optional[int] = None) -> Assessment:
"""创建考核记录"""
# 计算总分
total_score = sum(d.score for d in assessment_data.details)
# 获取指标权重计算加权得分
weighted_score = 0.0
for detail in assessment_data.details:
indicator = await db.execute(
select(Indicator).where(Indicator.id == detail.indicator_id)
)
ind = indicator.scalar_one_or_none()
if ind:
weighted_score += detail.score * float(ind.weight)
assessment = Assessment(
staff_id=assessment_data.staff_id,
period_year=assessment_data.period_year,
period_month=assessment_data.period_month,
period_type=assessment_data.period_type,
total_score=total_score,
weighted_score=weighted_score,
assessor_id=assessor_id,
)
db.add(assessment)
await db.flush()
# 创建明细
for detail_data in assessment_data.details:
detail = AssessmentDetail(
assessment_id=assessment.id,
**detail_data.model_dump()
)
db.add(detail)
await db.flush()
await db.refresh(assessment)
return assessment
@staticmethod
async def update(db: AsyncSession, assessment_id: int, assessment_data: AssessmentUpdate) -> Optional[Assessment]:
"""更新考核记录"""
assessment = await AssessmentService.get_by_id(db, assessment_id)
if not assessment:
return None
if assessment.status not in ["draft", "rejected"]:
return None
if assessment_data.details is not None:
# 删除旧明细
await db.execute(
select(AssessmentDetail).where(AssessmentDetail.assessment_id == assessment_id)
)
for detail in assessment.details:
await db.delete(detail)
# 创建新明细
total_score = 0.0
weighted_score = 0.0
for detail_data in assessment_data.details:
detail = AssessmentDetail(
assessment_id=assessment_id,
**detail_data.model_dump()
)
db.add(detail)
total_score += detail_data.score
# 获取权重
indicator = await db.execute(
select(Indicator).where(Indicator.id == detail_data.indicator_id)
)
ind = indicator.scalar_one_or_none()
if ind:
weighted_score += detail_data.score * float(ind.weight)
assessment.total_score = total_score
assessment.weighted_score = weighted_score
if assessment_data.remark is not None:
assessment.remark = assessment_data.remark
await db.flush()
await db.refresh(assessment)
return assessment
@staticmethod
async def submit(db: AsyncSession, assessment_id: int) -> Optional[Assessment]:
"""提交考核"""
assessment = await AssessmentService.get_by_id(db, assessment_id)
if not assessment or assessment.status != AssessmentStatus.DRAFT:
return None
assessment.status = AssessmentStatus.SUBMITTED
assessment.submit_time = datetime.utcnow()
await db.flush()
await db.refresh(assessment)
return assessment
@staticmethod
async def review(db: AsyncSession, assessment_id: int, reviewer_id: int, approved: bool, remark: Optional[str] = None) -> Optional[Assessment]:
"""审核考核"""
assessment = await AssessmentService.get_by_id(db, assessment_id)
if not assessment or assessment.status != AssessmentStatus.SUBMITTED:
return None
assessment.reviewer_id = reviewer_id
assessment.review_time = datetime.utcnow()
if approved:
assessment.status = AssessmentStatus.REVIEWED
else:
assessment.status = AssessmentStatus.REJECTED
if remark:
assessment.remark = remark
await db.flush()
await db.refresh(assessment)
return assessment
@staticmethod
async def finalize(db: AsyncSession, assessment_id: int) -> Optional[Assessment]:
"""确认考核"""
assessment = await AssessmentService.get_by_id(db, assessment_id)
if not assessment or assessment.status != AssessmentStatus.REVIEWED:
return None
assessment.status = AssessmentStatus.FINALIZED
await db.flush()
await db.refresh(assessment)
return assessment
@staticmethod
async def batch_create_for_department(
db: AsyncSession,
department_id: int,
period_year: int,
period_month: int,
indicators: List[int]
) -> List[Assessment]:
"""为科室批量创建考核"""
# 获取科室所有在职员工
staff_result = await db.execute(
select(Staff).where(
Staff.department_id == department_id,
Staff.status == "active"
)
)
staff_list = staff_result.scalars().all()
assessments = []
for staff in staff_list:
# 检查是否已存在
existing = await db.execute(
select(Assessment).where(
Assessment.staff_id == staff.id,
Assessment.period_year == period_year,
Assessment.period_month == period_month
)
)
if existing.scalar_one_or_none():
continue
# 创建考核记录
assessment = Assessment(
staff_id=staff.id,
period_year=period_year,
period_month=period_month,
period_type="monthly",
total_score=0,
weighted_score=0,
)
db.add(assessment)
await db.flush()
# 创建明细
for indicator_id in indicators:
detail = AssessmentDetail(
assessment_id=assessment.id,
indicator_id=indicator_id,
score=0
)
db.add(detail)
assessments.append(assessment)
await db.flush()
return assessments

View File

@@ -0,0 +1,149 @@
"""
科室服务层
"""
from typing import Optional, List
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.models import Department
from app.schemas.schemas import DepartmentCreate, DepartmentUpdate, DepartmentTree
class DepartmentService:
"""科室服务"""
@staticmethod
async def get_list(
db: AsyncSession,
dept_type: Optional[str] = None,
is_active: Optional[bool] = None,
page: int = 1,
page_size: int = 20
) -> tuple[List[Department], int]:
"""获取科室列表"""
query = select(Department)
if dept_type:
query = query.where(Department.dept_type == dept_type)
if is_active is not None:
query = query.where(Department.is_active == is_active)
# 统计总数
count_query = select(func.count()).select_from(query.subquery())
total = await db.scalar(count_query)
# 分页
query = query.order_by(Department.sort_order, Department.id)
query = query.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
departments = result.scalars().all()
return departments, total or 0
@staticmethod
async def get_by_id(db: AsyncSession, dept_id: int) -> Optional[Department]:
"""根据ID获取科室"""
result = await db.execute(
select(Department).where(Department.id == dept_id)
)
return result.scalar_one_or_none()
@staticmethod
async def get_by_code(db: AsyncSession, code: str) -> Optional[Department]:
"""根据编码获取科室"""
result = await db.execute(
select(Department).where(Department.code == code)
)
return result.scalar_one_or_none()
@staticmethod
async def create(db: AsyncSession, dept_data: DepartmentCreate) -> Department:
"""创建科室"""
# 计算层级
level = 1
if dept_data.parent_id:
parent = await DepartmentService.get_by_id(db, dept_data.parent_id)
if parent:
level = parent.level + 1
department = Department(
**dept_data.model_dump(exclude={'level'}),
level=level
)
db.add(department)
await db.flush()
await db.refresh(department)
return department
@staticmethod
async def update(db: AsyncSession, dept_id: int, dept_data: DepartmentUpdate) -> Optional[Department]:
"""更新科室"""
department = await DepartmentService.get_by_id(db, dept_id)
if not department:
return None
update_data = dept_data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(department, key, value)
await db.flush()
await db.refresh(department)
return department
@staticmethod
async def delete(db: AsyncSession, dept_id: int) -> bool:
"""删除科室"""
department = await DepartmentService.get_by_id(db, dept_id)
if not department:
return False
# 检查是否有子科室
result = await db.execute(
select(func.count()).where(Department.parent_id == dept_id)
)
if result.scalar() > 0:
return False
await db.delete(department)
return True
@staticmethod
async def get_tree(db: AsyncSession, dept_type: Optional[str] = None) -> List[DepartmentTree]:
"""获取科室树形结构"""
query = select(Department).order_by(Department.sort_order, Department.id)
if dept_type:
query = query.where(Department.dept_type == dept_type)
result = await db.execute(query)
departments = result.scalars().all()
# 构建树形结构 - 手动构建避免懒加载问题
dept_map = {}
for d in departments:
dept_map[d.id] = DepartmentTree(
id=d.id,
name=d.name,
code=d.code,
dept_type=d.dept_type,
parent_id=d.parent_id,
level=d.level,
sort_order=d.sort_order,
is_active=d.is_active,
description=d.description,
created_at=d.created_at,
updated_at=d.updated_at,
children=[]
)
roots = []
for dept in departments:
tree_node = dept_map[dept.id]
if dept.parent_id and dept.parent_id in dept_map:
dept_map[dept.parent_id].children.append(tree_node)
else:
roots.append(tree_node)
return roots

View File

@@ -0,0 +1,265 @@
"""
科室类型BSC维度权重配置服务
根据详细设计文档中的考核维度权重总览:
| 科室类型 | 财务维度 | 顾客维度 | 内部流程 | 学习成长 | 合计 |
|----------|----------|----------|----------|----------|------|
| 手术临床科室 | 60% | 15% | 20% | 5% | 100% |
| 非手术有病房科室 | 60% | 15% | 20% | 5% | 100% |
| 非手术无病房科室 | 60% | 15% | 20% | 5% | 100% |
| 医技科室 | 40% | 25% | 30% | 5% | 100% |
| 医疗辅助/行政科室 | 40% | 25% | 30% | 5% | 100% |
| 护理单元 | 20% | 15% | 50% | 15% | 100% |
| 药学部门 | 30% | 15% | 55% | - | 100% |
"""
from typing import Optional, List, Dict, Any
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.models import DeptType, DeptTypeDimensionWeight
# 默认权重配置(根据详细设计文档)
DEFAULT_WEIGHTS = {
DeptType.CLINICAL_SURGICAL: {
"financial": 0.60,
"customer": 0.15,
"internal_process": 0.20,
"learning_growth": 0.05,
"description": "手术临床科室:外科、骨科、泌尿外科、心胸外科、神经外科等"
},
DeptType.CLINICAL_NONSURGICAL_WARD: {
"financial": 0.60,
"customer": 0.15,
"internal_process": 0.20,
"learning_growth": 0.05,
"description": "非手术有病房科室:内科、神经内科、呼吸内科、消化内科等"
},
DeptType.CLINICAL_NONSURGICAL_NOWARD: {
"financial": 0.60,
"customer": 0.15,
"internal_process": 0.20,
"learning_growth": 0.05,
"description": "非手术无病房科室:门诊科室、急诊科等"
},
DeptType.MEDICAL_TECH: {
"financial": 0.40,
"customer": 0.25,
"internal_process": 0.30,
"learning_growth": 0.05,
"description": "医技科室:放射科、检验科、超声科、病理科、功能检查科等"
},
DeptType.MEDICAL_AUXILIARY: {
"financial": 0.40,
"customer": 0.25,
"internal_process": 0.30,
"learning_growth": 0.05,
"description": "医疗辅助/行政科室:设备科、信息科、总务科、财务科、人事科、医务科等"
},
DeptType.NURSING: {
"financial": 0.20,
"customer": 0.15,
"internal_process": 0.50,
"learning_growth": 0.15,
"description": "护理单元:各病区护理单元"
},
DeptType.ADMIN: {
"financial": 0.40,
"customer": 0.25,
"internal_process": 0.30,
"learning_growth": 0.05,
"description": "行政科室"
},
DeptType.FINANCE: {
"financial": 0.40,
"customer": 0.25,
"internal_process": 0.30,
"learning_growth": 0.05,
"description": "财务科室"
},
DeptType.LOGISTICS: {
"financial": 0.40,
"customer": 0.25,
"internal_process": 0.30,
"learning_growth": 0.05,
"description": "后勤保障科室"
},
}
class DimensionWeightService:
"""科室类型BSC维度权重配置服务"""
@staticmethod
async def get_by_dept_type(db: AsyncSession, dept_type: DeptType) -> Optional[DeptTypeDimensionWeight]:
"""根据科室类型获取权重配置"""
result = await db.execute(
select(DeptTypeDimensionWeight)
.where(DeptTypeDimensionWeight.dept_type == dept_type)
.where(DeptTypeDimensionWeight.is_active == True)
)
return result.scalar_one_or_none()
@staticmethod
async def get_all(db: AsyncSession, active_only: bool = True) -> List[DeptTypeDimensionWeight]:
"""获取所有权重配置"""
query = select(DeptTypeDimensionWeight)
if active_only:
query = query.where(DeptTypeDimensionWeight.is_active == True)
query = query.order_by(DeptTypeDimensionWeight.dept_type)
result = await db.execute(query)
return result.scalars().all()
@staticmethod
async def create_or_update(
db: AsyncSession,
dept_type: DeptType,
financial_weight: float,
customer_weight: float,
internal_process_weight: float,
learning_growth_weight: float,
description: Optional[str] = None
) -> DeptTypeDimensionWeight:
"""创建或更新权重配置"""
# 验证权重总和为1
total = financial_weight + customer_weight + internal_process_weight + learning_growth_weight
if abs(total - 1.0) > 0.01:
raise ValueError(f"权重总和必须为100%,当前总和为{total * 100}%")
# 查找现有配置
existing = await DimensionWeightService.get_by_dept_type(db, dept_type)
if existing:
# 更新现有配置
existing.financial_weight = financial_weight
existing.customer_weight = customer_weight
existing.internal_process_weight = internal_process_weight
existing.learning_growth_weight = learning_growth_weight
if description:
existing.description = description
await db.flush()
await db.refresh(existing)
return existing
else:
# 创建新配置
config = DeptTypeDimensionWeight(
dept_type=dept_type,
financial_weight=financial_weight,
customer_weight=customer_weight,
internal_process_weight=internal_process_weight,
learning_growth_weight=learning_growth_weight,
description=description
)
db.add(config)
await db.flush()
await db.refresh(config)
return config
@staticmethod
async def init_default_weights(db: AsyncSession) -> List[DeptTypeDimensionWeight]:
"""初始化默认权重配置(根据详细设计文档)"""
configs = []
for dept_type, weights in DEFAULT_WEIGHTS.items():
config = await DimensionWeightService.create_or_update(
db,
dept_type=dept_type,
financial_weight=weights["financial"],
customer_weight=weights["customer"],
internal_process_weight=weights["internal_process"],
learning_growth_weight=weights["learning_growth"],
description=weights.get("description")
)
configs.append(config)
return configs
@staticmethod
def get_dimension_weights(dept_type: DeptType) -> Dict[str, float]:
"""
获取科室类型的维度权重(用于计算)
优先使用数据库配置,如果没有则使用默认配置
"""
default = DEFAULT_WEIGHTS.get(dept_type, {
"financial": 0.40,
"customer": 0.25,
"internal_process": 0.30,
"learning_growth": 0.05,
})
return {
"financial": default["financial"],
"customer": default["customer"],
"internal_process": default["internal_process"],
"learning_growth": default["learning_growth"],
}
@staticmethod
async def calculate_dimension_weighted_score(
db: AsyncSession,
dept_type: DeptType,
financial_score: float,
customer_score: float,
internal_process_score: float,
learning_growth_score: float
) -> Dict[str, Any]:
"""
根据科室类型计算维度加权得分
Args:
db: 数据库会话
dept_type: 科室类型
financial_score: 财务维度得分
customer_score: 客户维度得分
internal_process_score: 内部流程维度得分
learning_growth_score: 学习成长维度得分
Returns:
包含各维度加权得分和总分的字典
"""
# 获取权重配置
config = await DimensionWeightService.get_by_dept_type(db, dept_type)
if config:
weights = {
"financial": float(config.financial_weight),
"customer": float(config.customer_weight),
"internal_process": float(config.internal_process_weight),
"learning_growth": float(config.learning_growth_weight),
}
else:
weights = DimensionWeightService.get_dimension_weights(dept_type)
# 计算各维度加权得分
weighted_scores = {
"financial": financial_score * weights["financial"],
"customer": customer_score * weights["customer"],
"internal_process": internal_process_score * weights["internal_process"],
"learning_growth": learning_growth_score * weights["learning_growth"],
}
# 计算总分
total_score = sum(weighted_scores.values())
return {
"weights": weights,
"weighted_scores": weighted_scores,
"total_score": round(total_score, 2),
"raw_scores": {
"financial": financial_score,
"customer": customer_score,
"internal_process": internal_process_score,
"learning_growth": learning_growth_score,
}
}
@staticmethod
async def delete(db: AsyncSession, config_id: int) -> bool:
"""删除权重配置(软删除)"""
result = await db.execute(
select(DeptTypeDimensionWeight).where(DeptTypeDimensionWeight.id == config_id)
)
config = result.scalar_one_or_none()
if not config:
return False
config.is_active = False
await db.flush()
return True

View File

@@ -0,0 +1,367 @@
"""
财务核算服务层
"""
from typing import Optional, List, Dict, Any
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from decimal import Decimal
from datetime import datetime
from app.models.finance import (
DepartmentFinance, RevenueCategory, ExpenseCategory, FinanceType
)
from app.models.models import Department
class FinanceService:
"""财务核算服务"""
# 收入类别标签映射
REVENUE_LABELS = {
RevenueCategory.EXAMINATION: "检查费",
RevenueCategory.LAB_TEST: "检验费",
RevenueCategory.RADIOLOGY: "放射费",
RevenueCategory.BED: "床位费",
RevenueCategory.NURSING: "护理费",
RevenueCategory.TREATMENT: "治疗费",
RevenueCategory.SURGERY: "手术费",
RevenueCategory.INJECTION: "注射费",
RevenueCategory.OXYGEN: "吸氧费",
RevenueCategory.OTHER: "其他",
}
# 支出类别标签映射
EXPENSE_LABELS = {
ExpenseCategory.MATERIAL: "材料费",
ExpenseCategory.PERSONNEL: "人员支出",
ExpenseCategory.MAINTENANCE: "维修费",
ExpenseCategory.UTILITY: "水电费",
ExpenseCategory.OTHER: "其他",
}
@staticmethod
async def get_department_revenue(
db: AsyncSession,
department_id: Optional[int] = None,
period_year: Optional[int] = None,
period_month: Optional[int] = None
) -> List[Dict[str, Any]]:
"""获取科室收入"""
query = select(DepartmentFinance).options(
selectinload(DepartmentFinance.department)
).where(DepartmentFinance.finance_type == FinanceType.REVENUE)
if department_id:
query = query.where(DepartmentFinance.department_id == department_id)
if period_year:
query = query.where(DepartmentFinance.period_year == period_year)
if period_month:
query = query.where(DepartmentFinance.period_month == period_month)
query = query.order_by(
DepartmentFinance.period_year.desc(),
DepartmentFinance.period_month.desc(),
DepartmentFinance.id.desc()
)
result = await db.execute(query)
records = result.scalars().all()
# 转换为字典列表并添加额外信息
data = []
for record in records:
record_dict = {
"id": record.id,
"department_id": record.department_id,
"department_name": record.department.name if record.department else None,
"period_year": record.period_year,
"period_month": record.period_month,
"category": record.category,
"category_label": FinanceService.REVENUE_LABELS.get(
RevenueCategory(record.category), record.category
),
"amount": float(record.amount),
"source": record.source,
"remark": record.remark,
"created_at": record.created_at,
}
data.append(record_dict)
return data
@staticmethod
async def get_department_expense(
db: AsyncSession,
department_id: Optional[int] = None,
period_year: Optional[int] = None,
period_month: Optional[int] = None
) -> List[Dict[str, Any]]:
"""获取科室支出"""
query = select(DepartmentFinance).options(
selectinload(DepartmentFinance.department)
).where(DepartmentFinance.finance_type == FinanceType.EXPENSE)
if department_id:
query = query.where(DepartmentFinance.department_id == department_id)
if period_year:
query = query.where(DepartmentFinance.period_year == period_year)
if period_month:
query = query.where(DepartmentFinance.period_month == period_month)
query = query.order_by(
DepartmentFinance.period_year.desc(),
DepartmentFinance.period_month.desc(),
DepartmentFinance.id.desc()
)
result = await db.execute(query)
records = result.scalars().all()
# 转换为字典列表并添加额外信息
data = []
for record in records:
record_dict = {
"id": record.id,
"department_id": record.department_id,
"department_name": record.department.name if record.department else None,
"period_year": record.period_year,
"period_month": record.period_month,
"category": record.category,
"category_label": FinanceService.EXPENSE_LABELS.get(
ExpenseCategory(record.category), record.category
),
"amount": float(record.amount),
"source": record.source,
"remark": record.remark,
"created_at": record.created_at,
}
data.append(record_dict)
return data
@staticmethod
async def get_department_balance(
db: AsyncSession,
department_id: Optional[int] = None,
period_year: Optional[int] = None,
period_month: Optional[int] = None
) -> Dict[str, Any]:
"""获取收支结余"""
# 构建基础查询条件
conditions = []
if department_id:
conditions.append(DepartmentFinance.department_id == department_id)
if period_year:
conditions.append(DepartmentFinance.period_year == period_year)
if period_month:
conditions.append(DepartmentFinance.period_month == period_month)
# 查询总收入
revenue_query = select(func.coalesce(func.sum(DepartmentFinance.amount), 0)).where(
and_(
DepartmentFinance.finance_type == FinanceType.REVENUE,
*conditions
)
)
total_revenue = await db.scalar(revenue_query) or 0
# 查询总支出
expense_query = select(func.coalesce(func.sum(DepartmentFinance.amount), 0)).where(
and_(
DepartmentFinance.finance_type == FinanceType.EXPENSE,
*conditions
)
)
total_expense = await db.scalar(expense_query) or 0
# 计算结余
balance = float(total_revenue) - float(total_expense)
# 获取科室名称
department_name = None
if department_id:
dept_result = await db.execute(
select(Department).where(Department.id == department_id)
)
dept = dept_result.scalar_one_or_none()
department_name = dept.name if dept else None
return {
"department_id": department_id,
"department_name": department_name,
"period_year": period_year,
"period_month": period_month,
"total_revenue": float(total_revenue),
"total_expense": float(total_expense),
"balance": balance,
}
@staticmethod
async def get_revenue_by_category(
db: AsyncSession,
department_id: Optional[int] = None,
period_year: Optional[int] = None,
period_month: Optional[int] = None
) -> List[Dict[str, Any]]:
"""按类别统计收入"""
conditions = [DepartmentFinance.finance_type == FinanceType.REVENUE]
if department_id:
conditions.append(DepartmentFinance.department_id == department_id)
if period_year:
conditions.append(DepartmentFinance.period_year == period_year)
if period_month:
conditions.append(DepartmentFinance.period_month == period_month)
query = select(
DepartmentFinance.category,
func.sum(DepartmentFinance.amount).label("total_amount")
).where(and_(*conditions)).group_by(DepartmentFinance.category)
result = await db.execute(query)
rows = result.all()
data = []
for row in rows:
category = row.category
data.append({
"category": category,
"category_label": FinanceService.REVENUE_LABELS.get(
RevenueCategory(category), category
),
"amount": float(row.total_amount),
})
return data
@staticmethod
async def get_expense_by_category(
db: AsyncSession,
department_id: Optional[int] = None,
period_year: Optional[int] = None,
period_month: Optional[int] = None
) -> List[Dict[str, Any]]:
"""按类别统计支出"""
conditions = [DepartmentFinance.finance_type == FinanceType.EXPENSE]
if department_id:
conditions.append(DepartmentFinance.department_id == department_id)
if period_year:
conditions.append(DepartmentFinance.period_year == period_year)
if period_month:
conditions.append(DepartmentFinance.period_month == period_month)
query = select(
DepartmentFinance.category,
func.sum(DepartmentFinance.amount).label("total_amount")
).where(and_(*conditions)).group_by(DepartmentFinance.category)
result = await db.execute(query)
rows = result.all()
data = []
for row in rows:
category = row.category
data.append({
"category": category,
"category_label": FinanceService.EXPENSE_LABELS.get(
ExpenseCategory(category), category
),
"amount": float(row.total_amount),
})
return data
@staticmethod
async def create_finance_record(
db: AsyncSession,
department_id: int,
finance_type: FinanceType,
category: str,
amount: float,
period_year: int,
period_month: int,
source: Optional[str] = None,
remark: Optional[str] = None
) -> DepartmentFinance:
"""创建财务记录"""
record = DepartmentFinance(
department_id=department_id,
finance_type=finance_type,
category=category,
amount=amount,
period_year=period_year,
period_month=period_month,
source=source,
remark=remark
)
db.add(record)
await db.flush()
await db.refresh(record)
return record
@staticmethod
async def get_by_id(db: AsyncSession, record_id: int) -> Optional[DepartmentFinance]:
"""根据ID获取财务记录"""
result = await db.execute(
select(DepartmentFinance)
.options(selectinload(DepartmentFinance.department))
.where(DepartmentFinance.id == record_id)
)
return result.scalar_one_or_none()
@staticmethod
async def update_finance_record(
db: AsyncSession,
record_id: int,
**kwargs
) -> Optional[DepartmentFinance]:
"""更新财务记录"""
record = await FinanceService.get_by_id(db, record_id)
if not record:
return None
for key, value in kwargs.items():
if value is not None and hasattr(record, key):
setattr(record, key, value)
await db.flush()
await db.refresh(record)
return record
@staticmethod
async def delete_finance_record(db: AsyncSession, record_id: int) -> bool:
"""删除财务记录"""
record = await FinanceService.get_by_id(db, record_id)
if not record:
return False
await db.delete(record)
return True
@staticmethod
async def get_department_summary(
db: AsyncSession,
period_year: int,
period_month: int
) -> List[Dict[str, Any]]:
"""获取所有科室的财务汇总"""
# 获取所有科室
dept_result = await db.execute(
select(Department).where(Department.is_active == True).order_by(Department.name)
)
departments = dept_result.scalars().all()
summaries = []
for dept in departments:
balance_data = await FinanceService.get_department_balance(
db, dept.id, period_year, period_month
)
summaries.append({
"department_id": dept.id,
"department_name": dept.name,
"total_revenue": balance_data["total_revenue"],
"total_expense": balance_data["total_expense"],
"balance": balance_data["balance"],
})
return summaries

View File

@@ -0,0 +1,196 @@
"""
考核指标服务层
"""
import json
from typing import Optional, List, Dict, Any
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.models import Indicator, IndicatorType, BSCDimension
from app.schemas.schemas import IndicatorCreate, IndicatorUpdate
class IndicatorService:
"""考核指标服务"""
@staticmethod
async def get_list(
db: AsyncSession,
indicator_type: Optional[str] = None,
bs_dimension: Optional[str] = None,
is_active: Optional[bool] = None,
page: int = 1,
page_size: int = 20
) -> tuple[List[Indicator], int]:
"""获取指标列表"""
query = select(Indicator)
if indicator_type:
query = query.where(Indicator.indicator_type == indicator_type)
if bs_dimension:
query = query.where(Indicator.bs_dimension == bs_dimension)
if is_active is not None:
query = query.where(Indicator.is_active == is_active)
# 统计总数
count_query = select(func.count()).select_from(query.subquery())
total = await db.scalar(count_query)
# 分页
query = query.order_by(Indicator.indicator_type, Indicator.id)
query = query.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
indicators = result.scalars().all()
return indicators, total or 0
@staticmethod
async def get_by_id(db: AsyncSession, indicator_id: int) -> Optional[Indicator]:
"""根据 ID 获取指标"""
result = await db.execute(
select(Indicator).where(Indicator.id == indicator_id)
)
return result.scalar_one_or_none()
@staticmethod
async def get_active_indicators(db: AsyncSession) -> List[Indicator]:
"""获取所有启用的指标"""
result = await db.execute(
select(Indicator)
.where(Indicator.is_active == True)
.order_by(Indicator.indicator_type, Indicator.id)
)
return result.scalars().all()
@staticmethod
async def create(db: AsyncSession, indicator_data: IndicatorCreate) -> Indicator:
"""创建指标"""
indicator = Indicator(**indicator_data.model_dump())
db.add(indicator)
await db.commit()
await db.refresh(indicator)
return indicator
@staticmethod
async def update(
db: AsyncSession,
indicator_id: int,
indicator_data: IndicatorUpdate
) -> Optional[Indicator]:
"""更新指标"""
indicator = await IndicatorService.get_by_id(db, indicator_id)
if not indicator:
return None
update_data = indicator_data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(indicator, key, value)
await db.commit()
await db.refresh(indicator)
return indicator
@staticmethod
async def delete(db: AsyncSession, indicator_id: int) -> bool:
"""删除指标"""
indicator = await IndicatorService.get_by_id(db, indicator_id)
if not indicator:
return False
await db.delete(indicator)
await db.commit()
return True
@staticmethod
async def import_template(
db: AsyncSession,
template_data: Dict[str, Any],
overwrite: bool = False
) -> int:
"""导入指标模板"""
dept_type = template_data.get('dept_type')
indicators_data = template_data.get('indicators', [])
created_count = 0
for ind_data in indicators_data:
# 检查是否已存在
existing = await db.execute(
select(Indicator).where(Indicator.code == ind_data['code'])
)
if existing.scalar_one_or_none():
if overwrite:
# 更新现有指标
indicator = existing.scalar_one_or_none()
if indicator:
for key, value in ind_data.items():
if hasattr(indicator, key):
setattr(indicator, key, value)
continue
# 创建新指标
indicator = Indicator(
name=ind_data.get('name'),
code=ind_data.get('code'),
indicator_type=ind_data.get('indicator_type'),
bs_dimension=ind_data.get('bs_dimension'),
weight=ind_data.get('weight', 1.0),
max_score=ind_data.get('max_score', 100.0),
target_value=ind_data.get('target_value'),
target_unit=ind_data.get('target_unit'),
calculation_method=ind_data.get('calculation_method'),
assessment_method=ind_data.get('assessment_method'),
deduction_standard=ind_data.get('deduction_standard'),
data_source=ind_data.get('data_source'),
applicable_dept_types=json.dumps([dept_type]) if dept_type else None,
is_veto=ind_data.get('is_veto', False),
is_active=ind_data.get('is_active', True)
)
db.add(indicator)
created_count += 1
await db.commit()
return created_count
@staticmethod
async def get_templates() -> List[Dict[str, Any]]:
"""获取指标模板列表"""
return [
{
"name": "手术临床科室考核指标",
"dept_type": "clinical_surgical",
"description": "适用于外科系统各手术科室",
"indicator_count": 12
},
{
"name": "非手术有病房科室考核指标",
"dept_type": "clinical_nonsurgical_ward",
"description": "适用于内科系统等有病房科室",
"indicator_count": 10
},
{
"name": "非手术无病房科室考核指标",
"dept_type": "clinical_nonsurgical_noward",
"description": "适用于门诊科室",
"indicator_count": 8
},
{
"name": "医技科室考核指标",
"dept_type": "medical_tech",
"description": "适用于检验科、放射科等医技科室",
"indicator_count": 8
},
{
"name": "行政科室考核指标",
"dept_type": "admin",
"description": "适用于党办、财务科、医保办等行政科室",
"indicator_count": 6
},
{
"name": "后勤保障科室考核指标",
"dept_type": "logistics",
"description": "适用于总务科、采购科、基建科",
"indicator_count": 6
}
]

View File

@@ -0,0 +1,136 @@
"""
菜单服务层
"""
from typing import Optional, List, Dict, Any
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.models import Menu, MenuType
class MenuService:
"""菜单服务"""
@staticmethod
async def get_tree(db: AsyncSession, visible_only: bool = True) -> List[Dict[str, Any]]:
"""获取菜单树形结构"""
query = select(Menu).options(selectinload(Menu.children))
if visible_only:
query = query.where(Menu.is_visible == True, Menu.is_active == True)
query = query.where(Menu.parent_id.is_(None))
query = query.order_by(Menu.sort_order, Menu.id)
result = await db.execute(query)
menus = result.scalars().all()
return [MenuService._menu_to_dict(menu) for menu in menus]
@staticmethod
async def get_list(
db: AsyncSession,
menu_type: Optional[str] = None,
is_visible: Optional[bool] = None
) -> List[Menu]:
"""获取菜单列表"""
query = select(Menu).options(selectinload(Menu.children))
conditions = []
if menu_type:
conditions.append(Menu.menu_type == menu_type)
if is_visible is not None:
conditions.append(Menu.is_visible == is_visible)
if conditions:
query = query.where(and_(*conditions))
query = query.order_by(Menu.sort_order, Menu.id)
result = await db.execute(query)
return result.scalars().all()
@staticmethod
async def get_by_id(db: AsyncSession, menu_id: int) -> Optional[Menu]:
"""根据 ID 获取菜单"""
result = await db.execute(
select(Menu).where(Menu.id == menu_id)
)
return result.scalar_one_or_none()
@staticmethod
async def create(db: AsyncSession, menu_data: dict) -> Menu:
"""创建菜单"""
menu = Menu(**menu_data)
db.add(menu)
await db.commit()
await db.refresh(menu)
return menu
@staticmethod
async def update(db: AsyncSession, menu_id: int, menu_data: dict) -> Optional[Menu]:
"""更新菜单"""
menu = await MenuService.get_by_id(db, menu_id)
if not menu:
return None
for key, value in menu_data.items():
if value is not None and hasattr(menu, key):
setattr(menu, key, value)
await db.commit()
await db.refresh(menu)
return menu
@staticmethod
async def delete(db: AsyncSession, menu_id: int) -> bool:
"""删除菜单"""
menu = await MenuService.get_by_id(db, menu_id)
if not menu:
return False
# 检查是否有子菜单
if menu.children:
return False
await db.delete(menu)
await db.commit()
return True
@staticmethod
def _menu_to_dict(menu: Menu) -> Dict[str, Any]:
"""将菜单对象转换为字典"""
return {
"id": menu.id,
"menu_name": menu.menu_name,
"menu_icon": menu.menu_icon,
"path": menu.path,
"children": [MenuService._menu_to_dict(child) for child in menu.children]
}
@staticmethod
async def init_default_menus(db: AsyncSession) -> None:
"""初始化默认菜单"""
# 检查是否已有菜单
result = await db.execute(select(Menu))
if result.scalar_one_or_none():
return
# 默认菜单数据
default_menus = [
{"menu_name": "工作台", "menu_icon": "HomeFilled", "path": "/dashboard", "component": "Dashboard", "sort_order": 1},
{"menu_name": "科室管理", "menu_icon": "OfficeBuilding", "path": "/departments", "component": "Departments", "sort_order": 2},
{"menu_name": "员工管理", "menu_icon": "User", "path": "/staff", "component": "Staff", "sort_order": 3},
{"menu_name": "考核指标", "menu_icon": "DataAnalysis", "path": "/indicators", "component": "Indicators", "sort_order": 4},
{"menu_name": "考核管理", "menu_icon": "Document", "path": "/assessments", "component": "Assessments", "sort_order": 5},
{"menu_name": "绩效计划", "menu_icon": "Setting", "path": "/plans", "component": "Plans", "sort_order": 6},
{"menu_name": "工资核算", "menu_icon": "Money", "path": "/salary", "component": "Salary", "sort_order": 7},
{"menu_name": "经济核算", "menu_icon": "Coin", "path": "/finance", "component": "Finance", "sort_order": 8},
{"menu_name": "统计报表", "menu_icon": "TrendCharts", "path": "/reports", "component": "Reports", "sort_order": 9},
]
for menu_data in default_menus:
menu = Menu(**menu_data)
db.add(menu)
await db.commit()

View File

@@ -0,0 +1,341 @@
"""
绩效计划服务层
"""
import json
from typing import Optional, List, Dict, Any
from datetime import datetime
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.models import PerformancePlan, PlanKpiRelation, PlanStatus, PlanLevel, Indicator
from app.schemas.schemas import PerformancePlanCreate, PerformancePlanUpdate, PlanKpiRelationCreate
class PerformancePlanService:
"""绩效计划服务"""
@staticmethod
async def get_list(
db: AsyncSession,
plan_level: Optional[str] = None,
plan_year: Optional[int] = None,
department_id: Optional[int] = None,
status: Optional[str] = None,
page: int = 1,
page_size: int = 20
) -> tuple[List[PerformancePlan], int]:
"""获取绩效计划列表"""
query = select(PerformancePlan).options(
selectinload(PerformancePlan.department),
selectinload(PerformancePlan.staff)
)
# 构建查询条件
conditions = []
if plan_level:
conditions.append(PerformancePlan.plan_level == plan_level)
if plan_year:
conditions.append(PerformancePlan.plan_year == plan_year)
if department_id:
conditions.append(PerformancePlan.department_id == department_id)
if status:
conditions.append(PerformancePlan.status == status)
if conditions:
query = query.where(and_(*conditions))
# 统计总数
count_query = select(func.count()).select_from(query.subquery())
total = await db.scalar(count_query)
# 分页
query = query.order_by(PerformancePlan.created_at.desc())
query = query.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
plans = result.scalars().all()
return plans, total or 0
@staticmethod
async def get_by_id(db: AsyncSession, plan_id: int) -> Optional[PerformancePlan]:
"""根据 ID 获取绩效计划详情"""
result = await db.execute(
select(PerformancePlan)
.options(
selectinload(PerformancePlan.department),
selectinload(PerformancePlan.staff),
selectinload(PerformancePlan.kpi_relations).selectinload(PlanKpiRelation.indicator)
)
.where(PerformancePlan.id == plan_id)
)
return result.scalar_one_or_none()
@staticmethod
async def create(
db: AsyncSession,
plan_data: PerformancePlanCreate,
submitter_id: Optional[int] = None
) -> PerformancePlan:
"""创建绩效计划"""
# 创建计划
plan_dict = plan_data.model_dump(exclude={'kpi_relations'})
plan = PerformancePlan(**plan_dict)
plan.submitter_id = submitter_id
db.add(plan)
await db.flush()
await db.refresh(plan)
# 创建指标关联
if plan_data.kpi_relations:
for kpi_data in plan_data.kpi_relations:
kpi_relation = PlanKpiRelation(
plan_id=plan.id,
**kpi_data.model_dump()
)
db.add(kpi_relation)
await db.commit()
await db.refresh(plan)
return plan
@staticmethod
async def update(
db: AsyncSession,
plan_id: int,
plan_data: PerformancePlanUpdate
) -> Optional[PerformancePlan]:
"""更新绩效计划"""
plan = await PerformancePlanService.get_by_id(db, plan_id)
if not plan:
return None
update_data = plan_data.model_dump(exclude_unset=True)
for key, value in update_data.items():
if hasattr(plan, key):
setattr(plan, key, value)
# 处理审批相关
if plan_data.status == PlanStatus.APPROVED:
plan.approve_time = datetime.utcnow()
elif plan_data.status == PlanStatus.REJECTED:
plan.approve_time = datetime.utcnow()
await db.commit()
await db.refresh(plan)
return plan
@staticmethod
async def submit(db: AsyncSession, plan_id: int) -> Optional[PerformancePlan]:
"""提交绩效计划"""
plan = await PerformancePlanService.get_by_id(db, plan_id)
if not plan or plan.status != PlanStatus.DRAFT:
return None
plan.status = PlanStatus.PENDING
plan.submit_time = datetime.utcnow()
await db.commit()
await db.refresh(plan)
return plan
@staticmethod
async def approve(
db: AsyncSession,
plan_id: int,
approver_id: int,
approved: bool,
remark: Optional[str] = None
) -> Optional[PerformancePlan]:
"""审批绩效计划"""
plan = await PerformancePlanService.get_by_id(db, plan_id)
if not plan or plan.status != PlanStatus.PENDING:
return None
plan.approver_id = approver_id
plan.approve_time = datetime.utcnow()
plan.approve_remark = remark
if approved:
plan.status = PlanStatus.APPROVED
else:
plan.status = PlanStatus.REJECTED
await db.commit()
await db.refresh(plan)
return plan
@staticmethod
async def activate(db: AsyncSession, plan_id: int) -> Optional[PerformancePlan]:
"""激活绩效计划"""
plan = await PerformancePlanService.get_by_id(db, plan_id)
if not plan or plan.status != PlanStatus.APPROVED:
return None
plan.status = PlanStatus.ACTIVE
plan.is_active = True
await db.commit()
await db.refresh(plan)
return plan
@staticmethod
async def delete(db: AsyncSession, plan_id: int) -> bool:
"""删除绩效计划"""
plan = await PerformancePlanService.get_by_id(db, plan_id)
if not plan:
return False
await db.delete(plan)
await db.commit()
return True
@staticmethod
async def add_kpi_relation(
db: AsyncSession,
plan_id: int,
kpi_data: PlanKpiRelationCreate
) -> Optional[PlanKpiRelation]:
"""添加计划指标关联"""
plan = await PerformancePlanService.get_by_id(db, plan_id)
if not plan:
return None
kpi_relation = PlanKpiRelation(
plan_id=plan_id,
**kpi_data.model_dump()
)
db.add(kpi_relation)
await db.commit()
await db.refresh(kpi_relation)
return kpi_relation
@staticmethod
async def update_kpi_relation(
db: AsyncSession,
relation_id: int,
kpi_data: dict
) -> Optional[PlanKpiRelation]:
"""更新计划指标关联"""
result = await db.execute(
select(PlanKpiRelation).where(PlanKpiRelation.id == relation_id)
)
kpi_relation = result.scalar_one_or_none()
if not kpi_relation:
return None
for key, value in kpi_data.items():
if hasattr(kpi_relation, key) and value is not None:
setattr(kpi_relation, key, value)
await db.commit()
await db.refresh(kpi_relation)
return kpi_relation
@staticmethod
async def delete_kpi_relation(db: AsyncSession, relation_id: int) -> bool:
"""删除计划指标关联"""
result = await db.execute(
select(PlanKpiRelation).where(PlanKpiRelation.id == relation_id)
)
kpi_relation = result.scalar_one_or_none()
if not kpi_relation:
return False
await db.delete(kpi_relation)
await db.commit()
return True
@staticmethod
async def get_stats(
db: AsyncSession,
plan_year: Optional[int] = None
) -> Dict[str, int]:
"""获取绩效计划统计"""
conditions = []
if plan_year:
conditions.append(PerformancePlan.plan_year == plan_year)
query = select(
PerformancePlan.status,
func.count().label('count')
)
if conditions:
query = query.where(and_(*conditions))
query = query.group_by(PerformancePlan.status)
result = await db.execute(query)
rows = result.fetchall()
stats = {
'total_plans': 0,
'draft_count': 0,
'pending_count': 0,
'approved_count': 0,
'active_count': 0,
'completed_count': 0
}
for row in rows:
status = row.status
count = row.count
stats['total_plans'] += count
if status == PlanStatus.DRAFT:
stats['draft_count'] = count
elif status == PlanStatus.PENDING:
stats['pending_count'] = count
elif status == PlanStatus.APPROVED:
stats['approved_count'] = count
elif status == PlanStatus.ACTIVE:
stats['active_count'] = count
elif status == PlanStatus.COMPLETED:
stats['completed_count'] = count
return stats
@staticmethod
async def get_tree(
db: AsyncSession,
plan_year: Optional[int] = None
) -> List[Dict[str, Any]]:
"""获取绩效计划树形结构"""
conditions = []
if plan_year:
conditions.append(PerformancePlan.plan_year == plan_year)
query = select(PerformancePlan).options(
selectinload(PerformancePlan.department),
selectinload(PerformancePlan.staff)
)
if conditions:
query = query.where(and_(*conditions))
query = query.order_by(PerformancePlan.plan_level, PerformancePlan.id)
result = await db.execute(query)
plans = result.scalars().all()
# 构建树形结构
plan_dict = {}
root_plans = []
for plan in plans:
plan_dict[plan.id] = {
'id': plan.id,
'plan_name': plan.plan_name,
'plan_code': plan.plan_code,
'plan_level': plan.plan_level,
'status': plan.status,
'department_name': plan.department.name if plan.department else None,
'staff_name': plan.staff.name if plan.staff else None,
'children': []
}
if plan.parent_plan_id:
if plan.parent_plan_id in plan_dict:
plan_dict[plan.parent_plan_id]['children'].append(plan_dict[plan.id])
else:
root_plans.append(plan_dict[plan.id])
return root_plans

View File

@@ -0,0 +1,259 @@
"""
工资核算服务层
"""
from typing import Optional, List
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from decimal import Decimal
from app.models.models import SalaryRecord, Staff, Assessment
from app.schemas.schemas import SalaryRecordCreate, SalaryRecordUpdate
class SalaryService:
"""工资核算服务"""
# 绩效奖金基数(可根据医院实际情况调整)
PERFORMANCE_BASE = 3000.0
@staticmethod
async def get_list(
db: AsyncSession,
staff_id: Optional[int] = None,
department_id: Optional[int] = None,
period_year: Optional[int] = None,
period_month: Optional[int] = None,
status: Optional[str] = None,
page: int = 1,
page_size: int = 20
) -> tuple[List[SalaryRecord], int]:
"""获取工资记录列表"""
query = select(SalaryRecord).options(
selectinload(SalaryRecord.staff).selectinload(Staff.department)
)
if staff_id:
query = query.where(SalaryRecord.staff_id == staff_id)
if department_id:
query = query.join(Staff).where(Staff.department_id == department_id)
if period_year:
query = query.where(SalaryRecord.period_year == period_year)
if period_month:
query = query.where(SalaryRecord.period_month == period_month)
if status:
query = query.where(SalaryRecord.status == status)
# 统计总数
count_query = select(func.count()).select_from(query.subquery())
total = await db.scalar(count_query)
# 分页
query = query.order_by(SalaryRecord.period_year.desc(), SalaryRecord.period_month.desc(), SalaryRecord.id.desc())
query = query.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
records = result.scalars().all()
return records, total or 0
@staticmethod
async def get_by_id(db: AsyncSession, record_id: int) -> Optional[SalaryRecord]:
"""根据ID获取工资记录"""
result = await db.execute(
select(SalaryRecord)
.options(selectinload(SalaryRecord.staff).selectinload(Staff.department))
.where(SalaryRecord.id == record_id)
)
return result.scalar_one_or_none()
@staticmethod
async def calculate_performance_bonus(performance_score: float, performance_ratio: float) -> float:
"""计算绩效奖金"""
# 绩效奖金 = 绩效基数 × (绩效得分/100) × 绩效系数
return SalaryService.PERFORMANCE_BASE * (performance_score / 100) * performance_ratio
@staticmethod
async def create(db: AsyncSession, record_data: SalaryRecordCreate) -> SalaryRecord:
"""创建工资记录"""
# 获取员工信息
staff_result = await db.execute(
select(Staff).where(Staff.id == record_data.staff_id)
)
staff = staff_result.scalar_one_or_none()
# 计算总工资
total_salary = (
record_data.base_salary +
record_data.performance_bonus +
record_data.allowance -
record_data.deduction
)
record = SalaryRecord(
**record_data.model_dump(),
total_salary=total_salary,
status="pending"
)
db.add(record)
await db.flush()
await db.refresh(record)
return record
@staticmethod
async def update(db: AsyncSession, record_id: int, record_data: SalaryRecordUpdate) -> Optional[SalaryRecord]:
"""更新工资记录"""
record = await SalaryService.get_by_id(db, record_id)
if not record or record.status != "pending":
return None
update_data = record_data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(record, key, value)
# 重新计算总工资
record.total_salary = (
record.base_salary +
record.performance_bonus +
record.allowance -
record.deduction
)
await db.flush()
await db.refresh(record)
return record
@staticmethod
async def generate_from_assessment(
db: AsyncSession,
staff_id: int,
period_year: int,
period_month: int
) -> Optional[SalaryRecord]:
"""根据考核记录生成工资记录"""
# 获取员工信息
staff_result = await db.execute(
select(Staff).where(Staff.id == staff_id)
)
staff = staff_result.scalar_one_or_none()
if not staff:
return None
# 获取考核记录
assessment_result = await db.execute(
select(Assessment).where(
Assessment.staff_id == staff_id,
Assessment.period_year == period_year,
Assessment.period_month == period_month,
Assessment.status == "finalized"
)
)
assessment = assessment_result.scalar_one_or_none()
if not assessment:
return None
# 检查是否已存在工资记录
existing = await db.execute(
select(SalaryRecord).where(
SalaryRecord.staff_id == staff_id,
SalaryRecord.period_year == period_year,
SalaryRecord.period_month == period_month
)
)
if existing.scalar_one_or_none():
return None
# 计算绩效奖金
performance_bonus = await SalaryService.calculate_performance_bonus(
float(assessment.weighted_score),
float(staff.performance_ratio)
)
# 创建工资记录
total_salary = float(staff.base_salary) + performance_bonus
record = SalaryRecord(
staff_id=staff_id,
period_year=period_year,
period_month=period_month,
base_salary=float(staff.base_salary),
performance_score=float(assessment.weighted_score),
performance_bonus=performance_bonus,
deduction=0,
allowance=0,
total_salary=total_salary,
status="pending"
)
db.add(record)
await db.flush()
await db.refresh(record)
return record
@staticmethod
async def batch_generate_for_department(
db: AsyncSession,
department_id: int,
period_year: int,
period_month: int
) -> List[SalaryRecord]:
"""为科室批量生成工资记录"""
# 获取科室所有已确认考核的员工
result = await db.execute(
select(Assessment).join(Staff).where(
Staff.department_id == department_id,
Assessment.period_year == period_year,
Assessment.period_month == period_month,
Assessment.status == "finalized"
)
)
assessments = result.scalars().all()
records = []
for assessment in assessments:
record = await SalaryService.generate_from_assessment(
db, assessment.staff_id, period_year, period_month
)
if record:
records.append(record)
return records
@staticmethod
async def confirm(db: AsyncSession, record_id: int) -> Optional[SalaryRecord]:
"""确认工资"""
record = await SalaryService.get_by_id(db, record_id)
if not record or record.status != "pending":
return None
record.status = "confirmed"
await db.flush()
await db.refresh(record)
return record
@staticmethod
async def batch_confirm(
db: AsyncSession,
period_year: int,
period_month: int,
department_id: Optional[int] = None
) -> int:
"""批量确认工资"""
query = select(SalaryRecord).where(
SalaryRecord.period_year == period_year,
SalaryRecord.period_month == period_month,
SalaryRecord.status == "pending"
)
if department_id:
query = query.join(Staff).where(Staff.department_id == department_id)
result = await db.execute(query)
records = result.scalars().all()
count = 0
for record in records:
record.status = "confirmed"
count += 1
await db.flush()
return count

View File

@@ -0,0 +1,441 @@
"""
评分方法计算服务 - 实现详细设计文档中的评分方法
支持的评分方法:
1. 目标参照法 - 适用指标: 业务收支结余率等固定目标指标
2. 区间法 - 趋高指标、趋低指标、趋中指标
3. 扣分法 - 适用指标: 投诉、差错、事故、病历质量等
4. 加分法 - 适用指标: 科研、教学、论文、新项目等
"""
from typing import Optional, Dict, Any, List
from enum import Enum
from dataclasses import dataclass
from decimal import Decimal
class ScoringMethod(str, Enum):
"""评分方法类型"""
TARGET_REFERENCE = "target_reference" # 目标参照法
INTERVAL_HIGH = "interval_high" # 区间法-趋高指标
INTERVAL_LOW = "interval_low" # 区间法-趋低指标
INTERVAL_CENTER = "interval_center" # 区间法-趋中指标
DEDUCTION = "deduction" # 扣分法
BONUS = "bonus" # 加分法
@dataclass
class ScoringParams:
"""评分参数"""
weight: float = 1.0 # 权重分
max_score: float = 100.0 # 最高分值
target_value: Optional[float] = None # 目标值
baseline_value: Optional[float] = None # 基准值(区间法用)
best_value: Optional[float] = None # 最佳值(区间法用)
worst_value: Optional[float] = None # 最低值(区间法用)
allowed_deviation: Optional[float] = None # 允许偏差(趋中指标用)
deduction_per_unit: Optional[float] = None # 每单位扣分
bonus_per_unit: Optional[float] = None # 每单位加分
max_bonus_ratio: float = 0.5 # 最大加分比例默认权重分的50%
@dataclass
class ScoringResult:
"""评分结果"""
score: float # 得分
actual_value: float # 实际值
method: str # 使用的评分方法
details: Dict[str, Any] # 详细信息
class ScoringService:
"""评分计算服务"""
@staticmethod
def calculate(
method: ScoringMethod,
actual_value: float,
params: ScoringParams
) -> ScoringResult:
"""
根据评分方法计算得分
Args:
method: 评分方法
actual_value: 实际值
params: 评分参数
Returns:
ScoringResult: 评分结果
"""
method_handlers = {
ScoringMethod.TARGET_REFERENCE: ScoringService._target_reference,
ScoringMethod.INTERVAL_HIGH: ScoringService._interval_high,
ScoringMethod.INTERVAL_LOW: ScoringService._interval_low,
ScoringMethod.INTERVAL_CENTER: ScoringService._interval_center,
ScoringMethod.DEDUCTION: ScoringService._deduction,
ScoringMethod.BONUS: ScoringService._bonus,
}
handler = method_handlers.get(method)
if not handler:
raise ValueError(f"不支持的评分方法: {method}")
return handler(actual_value, params)
@staticmethod
def _target_reference(actual_value: float, params: ScoringParams) -> ScoringResult:
"""
目标参照法
计算公式: 得分 = 权重分 × (实际值 / 目标值)
适用指标: 业务收支结余率等固定目标指标
示例: 业务收支结余率权重 12.6 分,目标 15%,实际 18%
得分 = 12.6 × (18% / 15%) = 15.12 分(可超过满分)
"""
if params.target_value is None or params.target_value == 0:
return ScoringResult(
score=0,
actual_value=actual_value,
method="target_reference",
details={"error": "目标值未设置或为零"}
)
score = params.weight * (actual_value / params.target_value)
return ScoringResult(
score=round(score, 2),
actual_value=actual_value,
method="target_reference",
details={
"formula": "得分 = 权重分 × (实际值 / 目标值)",
"weight": params.weight,
"target_value": params.target_value,
"ratio": actual_value / params.target_value
}
)
@staticmethod
def _interval_high(actual_value: float, params: ScoringParams) -> ScoringResult:
"""
区间法 - 趋高指标(越高越好)
评分标准:
- 实际值 ≥ 最佳值:得满分
- 实际值 ≥ 基准值:得分 = 权重分 × [(实际值 - 最低值)/(最佳值 - 最低值)]
- 实际值 < 基准值:得分 = 权重分 × (实际值/基准值) × 0.8
适用指标: 人均收支结余、满意度、工作量等
"""
weight = params.weight
best = params.best_value
baseline = params.baseline_value
worst = params.worst_value or 0
# 参数校验
if best is None or baseline is None:
return ScoringResult(
score=0,
actual_value=actual_value,
method="interval_high",
details={"error": "最佳值或基准值未设置"}
)
if actual_value >= best:
# 达到最佳值,得满分
score = weight
details = {"status": "达到最佳值,得满分"}
elif actual_value >= baseline:
# 在基准和最佳之间,按比例计算
if best > worst:
ratio = (actual_value - worst) / (best - worst)
score = weight * ratio
else:
score = weight * (actual_value / baseline)
details = {"status": "基准值和最佳值之间"}
else:
# 低于基准值,打折扣
score = weight * (actual_value / baseline) * 0.8
details = {"status": "低于基准值打8折"}
return ScoringResult(
score=round(score, 2),
actual_value=actual_value,
method="interval_high",
details={
**details,
"weight": weight,
"best_value": best,
"baseline_value": baseline,
"worst_value": worst
}
)
@staticmethod
def _interval_low(actual_value: float, params: ScoringParams) -> ScoringResult:
"""
区间法 - 趋低指标(越低越好)
评分标准:
- 实际值 ≤ 目标值:得满分
- 实际值 > 目标值:得分 = 权重分 × (目标值/实际值)
适用指标: 耗材率、药品比例、费用控制率等
"""
weight = params.weight
target = params.target_value
if target is None:
return ScoringResult(
score=0,
actual_value=actual_value,
method="interval_low",
details={"error": "目标值未设置"}
)
if actual_value <= target:
# 达到目标值,得满分
score = weight
details = {"status": "达到目标值,得满分"}
else:
# 超过目标值,按比例扣分
score = weight * (target / actual_value)
details = {"status": "超过目标值,按比例扣分"}
return ScoringResult(
score=round(score, 2),
actual_value=actual_value,
method="interval_low",
details={
**details,
"weight": weight,
"target_value": target
}
)
@staticmethod
def _interval_center(actual_value: float, params: ScoringParams) -> ScoringResult:
"""
区间法 - 趋中指标(接近目标最好)
评分标准:
- |实际值 - 目标值| ≤ 允许偏差:得满分
- |实际值 - 目标值| > 允许偏差:得分 = 权重分 × [1 - (|实际值 - 目标值| - 允许偏差)/允许偏差]
适用指标: 平均住院日等
"""
weight = params.weight
target = params.target_value
deviation = params.allowed_deviation or 0
if target is None:
return ScoringResult(
score=0,
actual_value=actual_value,
method="interval_center",
details={"error": "目标值未设置"}
)
diff = abs(actual_value - target)
if diff <= deviation:
# 在允许偏差范围内,得满分
score = weight
details = {"status": "在允许偏差范围内,得满分"}
else:
# 超出允许偏差,按比例扣分
penalty_ratio = (diff - deviation) / deviation if deviation > 0 else 1
score = max(0, weight * (1 - penalty_ratio))
details = {"status": "超出允许偏差,按比例扣分"}
return ScoringResult(
score=round(score, 2),
actual_value=actual_value,
method="interval_center",
details={
**details,
"weight": weight,
"target_value": target,
"allowed_deviation": deviation,
"actual_deviation": diff
}
)
@staticmethod
def _deduction(actual_value: float, params: ScoringParams) -> ScoringResult:
"""
扣分法
评分标准:
- 得分 = 权重分 - 扣分数
- 扣完为止,不计负分
适用指标: 投诉、差错、事故、病历质量等
扣分细则示例:
- 门诊药品比例: 每超标准 1% 扣 10 分
- 住院药品比例: 每超标准 1% 扣 10 分
- 医保专项: 每超标准 1% 扣 10 分
- 乙级病历: 每份扣 5 分
- 丙级病历: 零发生(发生不得分)
- 投诉: 发生投诉事件不得分
- 差错: 发生差错事件不得分
- 事故与赔偿: 发生事故或赔偿事件不得分
"""
weight = params.weight
deduction_per_unit = params.deduction_per_unit or 0
# 计算扣分
deduction = actual_value * deduction_per_unit
# 扣完为止,不计负分
score = max(0, weight - deduction)
return ScoringResult(
score=round(score, 2),
actual_value=actual_value,
method="deduction",
details={
"weight": weight,
"deduction_per_unit": deduction_per_unit,
"total_deduction": deduction,
"occurrences": actual_value,
"status": "扣完为止" if score == 0 else "正常扣分"
}
)
@staticmethod
def _bonus(actual_value: float, params: ScoringParams) -> ScoringResult:
"""
加分法
评分标准:
- 得分 = 权重分 + 加分
- 加分不超过权重分的 50%(可配置)
适用指标: 科研、教学、论文、新项目等
加分细则示例:
- 开展新技术项目: 每项加 2 分,最高 10 分
- 科研项目立项: 市级加 5 分,省级加 10 分,国家级加 20 分
- 论文发表: 核心期刊加 5 分SCI 加 10 分
- 教学任务完成: 优秀加 5 分,良好加 3 分
"""
weight = params.weight
bonus_per_unit = params.bonus_per_unit or 0
max_bonus = weight * params.max_bonus_ratio
# 计算加分
bonus = actual_value * bonus_per_unit
# 加分不超过上限
bonus = min(bonus, max_bonus)
score = weight + bonus
return ScoringResult(
score=round(score, 2),
actual_value=actual_value,
method="bonus",
details={
"weight": weight,
"bonus_per_unit": bonus_per_unit,
"total_bonus": bonus,
"occurrences": actual_value,
"max_bonus": max_bonus,
"status": "达到加分上限" if bonus >= max_bonus else "正常加分"
}
)
@staticmethod
def calculate_assessment_score(
details: List[Dict[str, Any]]
) -> Dict[str, Any]:
"""
计算考核总分和各维度得分
Args:
details: 考核明细列表,每个明细包含:
- indicator_id: 指标ID
- indicator_name: 指标名称
- bs_dimension: BSC维度
- weight: 权重
- actual_value: 实际值
- scoring_method: 评分方法
- scoring_params: 评分参数
Returns:
包含总分、加权得分、各维度得分的字典
"""
total_score = 0.0
weighted_score = 0.0
dimensions = {
"financial": {"score": 0, "weight": 0, "details": []},
"customer": {"score": 0, "weight": 0, "details": []},
"internal_process": {"score": 0, "weight": 0, "details": []},
"learning_growth": {"score": 0, "weight": 0, "details": []},
}
for detail in details:
# 获取评分方法和参数
method = detail.get("scoring_method")
params_dict = detail.get("scoring_params", {})
actual_value = detail.get("actual_value", 0)
weight = detail.get("weight", 1.0)
dimension = detail.get("bs_dimension", "financial")
# 构建评分参数
params = ScoringParams(
weight=weight,
max_score=detail.get("max_score", 100),
target_value=params_dict.get("target_value"),
baseline_value=params_dict.get("baseline_value"),
best_value=params_dict.get("best_value"),
worst_value=params_dict.get("worst_value"),
allowed_deviation=params_dict.get("allowed_deviation"),
deduction_per_unit=params_dict.get("deduction_per_unit"),
bonus_per_unit=params_dict.get("bonus_per_unit"),
max_bonus_ratio=params_dict.get("max_bonus_ratio", 0.5)
)
# 计算得分
if method:
try:
scoring_method = ScoringMethod(method)
result = ScoringService.calculate(scoring_method, actual_value, params)
score = result.score
except ValueError:
# 未知的评分方法,使用直接得分
score = actual_value * weight
else:
# 没有指定评分方法,使用直接得分
score = actual_value * weight
# 累计总分
total_score += score
weighted_score += score * weight
# 维度得分
dim_key = dimension.value if hasattr(dimension, 'value') else dimension
if dim_key in dimensions:
dimensions[dim_key]["score"] += score
dimensions[dim_key]["weight"] += weight
dimensions[dim_key]["details"].append({
"indicator_id": detail.get("indicator_id"),
"indicator_name": detail.get("indicator_name"),
"score": score,
"weight": weight
})
# 计算维度平均分
for dim in dimensions.values():
if dim["weight"] > 0:
dim["average"] = dim["score"] / dim["weight"]
else:
dim["average"] = 0
return {
"total_score": round(total_score, 2),
"weighted_score": round(weighted_score, 2),
"dimensions": dimensions
}

View File

@@ -0,0 +1,111 @@
"""
员工服务层
"""
from typing import Optional, List
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.models import Staff, Department
from app.schemas.schemas import StaffCreate, StaffUpdate
class StaffService:
"""员工服务"""
@staticmethod
async def get_list(
db: AsyncSession,
department_id: Optional[int] = None,
status: Optional[str] = None,
keyword: Optional[str] = None,
page: int = 1,
page_size: int = 20
) -> tuple[List[Staff], int]:
"""获取员工列表"""
query = select(Staff).options(selectinload(Staff.department))
if department_id:
query = query.where(Staff.department_id == department_id)
if status:
query = query.where(Staff.status == status)
if keyword:
query = query.where(
(Staff.name.ilike(f"%{keyword}%")) |
(Staff.employee_id.ilike(f"%{keyword}%"))
)
# 统计总数
count_query = select(func.count()).select_from(query.subquery())
total = await db.scalar(count_query)
# 分页
query = query.order_by(Staff.id.desc())
query = query.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
staff_list = result.scalars().all()
return staff_list, total or 0
@staticmethod
async def get_by_id(db: AsyncSession, staff_id: int) -> Optional[Staff]:
"""根据ID获取员工"""
result = await db.execute(
select(Staff)
.options(selectinload(Staff.department))
.where(Staff.id == staff_id)
)
return result.scalar_one_or_none()
@staticmethod
async def get_by_employee_id(db: AsyncSession, employee_id: str) -> Optional[Staff]:
"""根据工号获取员工"""
result = await db.execute(
select(Staff).where(Staff.employee_id == employee_id)
)
return result.scalar_one_or_none()
@staticmethod
async def create(db: AsyncSession, staff_data: StaffCreate) -> Staff:
"""创建员工"""
staff = Staff(**staff_data.model_dump())
db.add(staff)
await db.flush()
await db.refresh(staff)
return staff
@staticmethod
async def update(db: AsyncSession, staff_id: int, staff_data: StaffUpdate) -> Optional[Staff]:
"""更新员工"""
staff = await StaffService.get_by_id(db, staff_id)
if not staff:
return None
update_data = staff_data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(staff, key, value)
await db.flush()
await db.refresh(staff)
return staff
@staticmethod
async def delete(db: AsyncSession, staff_id: int) -> bool:
"""删除员工"""
staff = await StaffService.get_by_id(db, staff_id)
if not staff:
return False
await db.delete(staff)
return True
@staticmethod
async def get_by_department(db: AsyncSession, department_id: int) -> List[Staff]:
"""获取科室下所有员工"""
result = await db.execute(
select(Staff)
.where(Staff.department_id == department_id, Staff.status == "active")
.order_by(Staff.name)
)
return list(result.scalars().all())

View File

@@ -0,0 +1,299 @@
"""
统计服务层 - BSC 维度分析、绩效统计
"""
from typing import Optional, List, Dict, Any
from datetime import datetime
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.models import (
Assessment, AssessmentDetail, Indicator, Department, Staff,
BSCDimension, AssessmentStatus
)
class StatsService:
"""统计服务"""
@staticmethod
async def get_bsc_dimension_stats(
db: AsyncSession,
department_id: Optional[int] = None,
period_year: int = None,
period_month: int = None
) -> Dict[str, Any]:
"""获取 BSC 维度统计"""
dimensions = {
BSCDimension.FINANCIAL: {'score': 0, 'weight': 0, 'indicators': 0},
BSCDimension.CUSTOMER: {'score': 0, 'weight': 0, 'indicators': 0},
BSCDimension.INTERNAL_PROCESS: {'score': 0, 'weight': 0, 'indicators': 0},
BSCDimension.LEARNING_GROWTH: {'score': 0, 'weight': 0, 'indicators': 0},
}
# 构建查询条件
conditions = [
Assessment.status == AssessmentStatus.FINALIZED
]
if period_year:
conditions.append(Assessment.period_year == period_year)
if period_month:
conditions.append(Assessment.period_month == period_month)
if department_id:
conditions.append(Assessment.staff_id.has(Staff.department_id == department_id))
# 查询各维度得分
result = await db.execute(
select(
Indicator.bs_dimension,
func.sum(AssessmentDetail.score * Indicator.weight).label('total_score'),
func.sum(Indicator.weight).label('total_weight'),
func.count(AssessmentDetail.id).label('indicator_count')
)
.join(AssessmentDetail, AssessmentDetail.indicator_id == Indicator.id)
.join(Assessment, Assessment.id == AssessmentDetail.assessment_id)
.where(and_(*conditions))
.group_by(Indicator.bs_dimension)
)
for row in result.fetchall():
dim = row.bs_dimension
if dim in dimensions:
dimensions[dim] = {
'score': float(row.total_score) if row.total_score else 0,
'weight': float(row.total_weight) if row.total_weight else 0,
'indicators': row.indicator_count,
'average': (float(row.total_score) / float(row.total_weight)) if row.total_weight else 0
}
return {
'dimensions': dimensions,
'period': f"{period_year}{period_month}" if period_year and period_month else "全部"
}
@staticmethod
async def get_department_stats(
db: AsyncSession,
period_year: int = None,
period_month: int = None
) -> List[Dict[str, Any]]:
"""获取科室绩效统计"""
conditions = [
Assessment.status == AssessmentStatus.FINALIZED
]
if period_year:
conditions.append(Assessment.period_year == period_year)
if period_month:
conditions.append(Assessment.period_month == period_month)
result = await db.execute(
select(
Department.id,
Department.name,
Department.dept_type,
Staff.id.label('staff_id'),
Staff.name.label('staff_name'),
Assessment.total_score,
Assessment.weighted_score
)
.join(Staff, Staff.department_id == Department.id)
.join(Assessment, Assessment.staff_id == Staff.id)
.where(and_(*conditions))
.order_by(Department.id, Assessment.weighted_score.desc())
)
# 按科室汇总
dept_stats = {}
for row in result.fetchall():
dept_id = row.id
if dept_id not in dept_stats:
dept_stats[dept_id] = {
'department_id': dept_id,
'department_name': row.name,
'dept_type': row.dept_type,
'staff_count': 0,
'total_score': 0,
'avg_score': 0,
'max_score': 0,
'min_score': None,
'staff_list': []
}
dept_stats[dept_id]['staff_count'] += 1
dept_stats[dept_id]['total_score'] += row.weighted_score or 0
dept_stats[dept_id]['staff_list'].append({
'staff_id': row.staff_id,
'staff_name': row.staff_name,
'score': row.weighted_score
})
if row.weighted_score:
if row.weighted_score > dept_stats[dept_id]['max_score']:
dept_stats[dept_id]['max_score'] = row.weighted_score
if dept_stats[dept_id]['min_score'] is None or row.weighted_score < dept_stats[dept_id]['min_score']:
dept_stats[dept_id]['min_score'] = row.weighted_score
# 计算平均分
result_list = []
for dept in dept_stats.values():
if dept['staff_count'] > 0:
dept['avg_score'] = dept['total_score'] / dept['staff_count']
result_list.append(dept)
# 按平均分排序
result_list.sort(key=lambda x: x['avg_score'], reverse=True)
return result_list
@staticmethod
async def get_trend_stats(
db: AsyncSession,
department_id: Optional[int] = None,
period_year: int = None,
months: int = 6
) -> List[Dict[str, Any]]:
"""获取趋势统计(月度)"""
conditions = [
Assessment.status == AssessmentStatus.FINALIZED
]
if period_year:
# 查询最近 months 个月的数据
from datetime import datetime
current_month = datetime.now().month
start_month = current_month - months + 1
if start_month < 1:
# 跨年份
conditions.append(
((Assessment.period_year == period_year - 1) & (Assessment.period_month >= start_month + 12)) |
((Assessment.period_year == period_year) & (Assessment.period_month <= current_month))
)
else:
conditions.append(Assessment.period_year == period_year)
conditions.append(Assessment.period_month >= start_month)
conditions.append(Assessment.period_month <= current_month)
if department_id:
conditions.append(Assessment.staff_id.has(Staff.department_id == department_id))
result = await db.execute(
select(
Assessment.period_month,
func.avg(Assessment.total_score).label('avg_score'),
func.avg(Assessment.weighted_score).label('avg_weighted_score'),
func.count(Assessment.id).label('count')
)
.join(Staff, Staff.id == Assessment.staff_id)
.where(and_(*conditions))
.group_by(Assessment.period_month)
.order_by(Assessment.period_month)
)
return [
{
'month': row.period_month,
'avg_score': float(row.avg_score) if row.avg_score else 0,
'avg_weighted_score': float(row.avg_weighted_score) if row.avg_weighted_score else 0,
'count': row.count
}
for row in result.fetchall()
]
@staticmethod
async def get_ranking_stats(
db: AsyncSession,
period_year: int = None,
period_month: int = None,
limit: int = 10
) -> List[Dict[str, Any]]:
"""获取绩效排名"""
conditions = [
Assessment.status == AssessmentStatus.FINALIZED
]
if period_year:
conditions.append(Assessment.period_year == period_year)
if period_month:
conditions.append(Assessment.period_month == period_month)
result = await db.execute(
select(
Staff.id,
Staff.name,
Staff.employee_id,
Department.name.label('dept_name'),
Assessment.total_score,
Assessment.weighted_score
)
.join(Department, Department.id == Staff.department_id)
.join(Assessment, Assessment.staff_id == Staff.id)
.where(and_(*conditions))
.order_by(Assessment.weighted_score.desc())
.limit(limit)
)
return [
{
'staff_id': row.id,
'staff_name': row.name,
'employee_id': row.employee_id,
'department': row.dept_name,
'total_score': float(row.total_score) if row.total_score else 0,
'weighted_score': float(row.weighted_score) if row.weighted_score else 0,
'rank': idx + 1
}
for idx, row in enumerate(result.fetchall())
]
@staticmethod
async def get_completion_stats(
db: AsyncSession,
indicator_id: Optional[int] = None,
period_year: int = None,
period_month: int = None
) -> Dict[str, Any]:
"""获取指标完成度统计"""
conditions = [
Assessment.status == AssessmentStatus.FINALIZED
]
if period_year:
conditions.append(Assessment.period_year == period_year)
if period_month:
conditions.append(Assessment.period_month == period_month)
query = select(
Indicator.id,
Indicator.name,
Indicator.code,
Indicator.target_value,
Indicator.max_score,
func.avg(AssessmentDetail.score).label('avg_score'),
func.max(AssessmentDetail.score).label('max_score'),
func.min(AssessmentDetail.score).label('min_score'),
func.count(AssessmentDetail.id).label('count')
).join(AssessmentDetail, AssessmentDetail.indicator_id == Indicator.id)
if indicator_id:
conditions.append(Indicator.id == indicator_id)
result = await db.execute(
query.where(and_(*conditions))
.group_by(Indicator.id, Indicator.name, Indicator.code, Indicator.target_value, Indicator.max_score)
)
indicators = []
for row in result.fetchall():
completion_rate = 0
if row.target_value and row.avg_score:
completion_rate = (float(row.avg_score) / float(row.target_value)) * 100 if row.target_value else 0
indicators.append({
'indicator_id': row.id,
'indicator_name': row.name,
'indicator_code': row.code,
'target_value': float(row.target_value) if row.target_value else None,
'max_score': float(row.max_score) if row.max_score else 0,
'avg_score': float(row.avg_score) if row.avg_score else 0,
'completion_rate': min(completion_rate, 100), # 最高 100%
'count': row.count
})
return {'indicators': indicators}

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

View File

@@ -0,0 +1,293 @@
"""
指标模板服务层
"""
import json
from typing import Optional, List, Dict, Any
from sqlalchemy import select, func, delete
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.models import (
IndicatorTemplate, TemplateIndicator, Indicator,
TemplateType, BSCDimension
)
from app.schemas.schemas import (
IndicatorTemplateCreate, IndicatorTemplateUpdate,
TemplateIndicatorCreate, TemplateIndicatorUpdate
)
class TemplateService:
"""指标模板服务"""
@staticmethod
async def get_list(
db: AsyncSession,
template_type: Optional[str] = None,
is_active: Optional[bool] = None,
page: int = 1,
page_size: int = 20
) -> tuple[List[Dict], int]:
"""获取模板列表"""
query = select(IndicatorTemplate)
if template_type:
query = query.where(IndicatorTemplate.template_type == template_type)
if is_active is not None:
query = query.where(IndicatorTemplate.is_active == is_active)
# 统计总数
count_query = select(func.count()).select_from(query.subquery())
total = await db.scalar(count_query)
# 分页
query = query.order_by(IndicatorTemplate.template_type, IndicatorTemplate.id)
query = query.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
templates = result.scalars().all()
# 获取每个模板的指标数量
template_list = []
for t in templates:
indicator_count = await db.scalar(
select(func.count()).where(TemplateIndicator.template_id == t.id)
)
template_dict = {
"id": t.id,
"template_name": t.template_name,
"template_code": t.template_code,
"template_type": t.template_type.value,
"description": t.description,
"dimension_weights": t.dimension_weights,
"assessment_cycle": t.assessment_cycle,
"is_active": t.is_active,
"indicator_count": indicator_count or 0,
"created_at": t.created_at,
"updated_at": t.updated_at
}
template_list.append(template_dict)
return template_list, total or 0
@staticmethod
async def get_by_id(db: AsyncSession, template_id: int) -> Optional[IndicatorTemplate]:
"""根据 ID 获取模板"""
result = await db.execute(
select(IndicatorTemplate)
.options(selectinload(IndicatorTemplate.indicators).selectinload(TemplateIndicator.indicator))
.where(IndicatorTemplate.id == template_id)
)
return result.scalar_one_or_none()
@staticmethod
async def get_by_code(db: AsyncSession, template_code: str) -> Optional[IndicatorTemplate]:
"""根据编码获取模板"""
result = await db.execute(
select(IndicatorTemplate).where(IndicatorTemplate.template_code == template_code)
)
return result.scalar_one_or_none()
@staticmethod
async def create(
db: AsyncSession,
template_data: IndicatorTemplateCreate
) -> IndicatorTemplate:
"""创建模板"""
# 创建模板
template = IndicatorTemplate(
template_name=template_data.template_name,
template_code=template_data.template_code,
template_type=template_data.template_type,
description=template_data.description,
dimension_weights=template_data.dimension_weights,
assessment_cycle=template_data.assessment_cycle
)
db.add(template)
await db.flush()
# 添加指标关联
if template_data.indicators:
for idx, ind_data in enumerate(template_data.indicators):
ti = TemplateIndicator(
template_id=template.id,
indicator_id=ind_data.indicator_id,
category=ind_data.category,
target_value=ind_data.target_value,
target_unit=ind_data.target_unit,
weight=ind_data.weight,
scoring_method=ind_data.scoring_method,
scoring_params=ind_data.scoring_params,
sort_order=ind_data.sort_order or idx,
remark=ind_data.remark
)
db.add(ti)
await db.commit()
await db.refresh(template)
return template
@staticmethod
async def update(
db: AsyncSession,
template_id: int,
template_data: IndicatorTemplateUpdate
) -> Optional[IndicatorTemplate]:
"""更新模板"""
template = await TemplateService.get_by_id(db, template_id)
if not template:
return None
update_data = template_data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(template, key, value)
await db.commit()
await db.refresh(template)
return template
@staticmethod
async def delete(db: AsyncSession, template_id: int) -> bool:
"""删除模板"""
template = await TemplateService.get_by_id(db, template_id)
if not template:
return False
await db.delete(template)
await db.commit()
return True
@staticmethod
async def add_indicator(
db: AsyncSession,
template_id: int,
indicator_data: TemplateIndicatorCreate
) -> Optional[TemplateIndicator]:
"""添加模板指标"""
# 检查模板是否存在
template = await db.execute(
select(IndicatorTemplate).where(IndicatorTemplate.id == template_id)
)
if not template.scalar_one_or_none():
return None
# 检查指标是否已存在
existing = await db.execute(
select(TemplateIndicator).where(
TemplateIndicator.template_id == template_id,
TemplateIndicator.indicator_id == indicator_data.indicator_id
)
)
if existing.scalar_one_or_none():
return None
# 获取最大排序
max_order = await db.scalar(
select(func.max(TemplateIndicator.sort_order)).where(
TemplateIndicator.template_id == template_id
)
)
ti = TemplateIndicator(
template_id=template_id,
indicator_id=indicator_data.indicator_id,
category=indicator_data.category,
target_value=indicator_data.target_value,
target_unit=indicator_data.target_unit,
weight=indicator_data.weight,
scoring_method=indicator_data.scoring_method,
scoring_params=indicator_data.scoring_params,
sort_order=indicator_data.sort_order or (max_order or 0) + 1,
remark=indicator_data.remark
)
db.add(ti)
await db.commit()
await db.refresh(ti)
return ti
@staticmethod
async def update_indicator(
db: AsyncSession,
template_id: int,
indicator_id: int,
indicator_data: TemplateIndicatorUpdate
) -> Optional[TemplateIndicator]:
"""更新模板指标"""
result = await db.execute(
select(TemplateIndicator).where(
TemplateIndicator.template_id == template_id,
TemplateIndicator.indicator_id == indicator_id
)
)
ti = result.scalar_one_or_none()
if not ti:
return None
update_data = indicator_data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(ti, key, value)
await db.commit()
await db.refresh(ti)
return ti
@staticmethod
async def remove_indicator(
db: AsyncSession,
template_id: int,
indicator_id: int
) -> bool:
"""移除模板指标"""
result = await db.execute(
select(TemplateIndicator).where(
TemplateIndicator.template_id == template_id,
TemplateIndicator.indicator_id == indicator_id
)
)
ti = result.scalar_one_or_none()
if not ti:
return False
await db.delete(ti)
await db.commit()
return True
@staticmethod
async def get_template_indicators(
db: AsyncSession,
template_id: int
) -> List[TemplateIndicator]:
"""获取模板的所有指标"""
result = await db.execute(
select(TemplateIndicator)
.options(selectinload(TemplateIndicator.indicator))
.where(TemplateIndicator.template_id == template_id)
.order_by(TemplateIndicator.sort_order)
)
return result.scalars().all()
@staticmethod
def get_template_type_label(template_type: str) -> str:
"""获取模板类型标签"""
type_map = {
"general": "通用模板",
"surgical": "手术临床科室",
"nonsurgical_ward": "非手术有病房科室",
"nonsurgical_noward": "非手术无病房科室",
"medical_tech": "医技科室",
"nursing": "护理单元",
"admin": "行政科室",
"logistics": "后勤科室"
}
return type_map.get(template_type, template_type)
@staticmethod
def get_dimension_label(dimension: str) -> str:
"""获取维度标签"""
dimension_map = {
"financial": "财务管理",
"customer": "顾客服务",
"internal_process": "内部流程",
"learning_growth": "学习与成长"
}
return dimension_map.get(dimension, dimension)

View File

@@ -0,0 +1,33 @@
"""
工具函数模块
"""
from datetime import datetime
from typing import Optional
def get_current_period() -> tuple[int, int]:
"""获取当前考核周期(年、月)"""
now = datetime.now()
return now.year, now.month
def format_period(year: int, month: int) -> str:
"""格式化考核周期"""
return f"{year}{month:02d}"
def calculate_score_level(score: float) -> str:
"""计算绩效等级"""
if score >= 90:
return "优秀"
elif score >= 80:
return "良好"
elif score >= 60:
return "合格"
else:
return "不合格"
def generate_employee_id(department_code: str, sequence: int) -> str:
"""生成员工工号"""
return f"{department_code}{sequence:04d}"

37
backend/check_enum.py Normal file
View File

@@ -0,0 +1,37 @@
"""检查数据库枚举类型"""
import asyncio
import asyncpg
async def main():
conn = await asyncpg.connect(
'postgresql://postgresql:Jchl1528@192.168.110.252:15432/hospital_performance'
)
try:
# 检查枚举类型定义
result = await conn.fetch("""
SELECT t.typname, e.enumlabel
FROM pg_type t
JOIN pg_enum e ON t.oid = e.enumtypid
WHERE t.typname = 'depttype'
ORDER BY e.enumsortorder
""")
print('DeptType 枚举值:')
for row in result:
print(f' {row["enumlabel"]} ({len(row["enumlabel"])} chars)')
# 检查 departments 表结构
result2 = await conn.fetch("""
SELECT column_name, data_type, character_maximum_length
FROM information_schema.columns
WHERE table_name = 'departments'
""")
print('\nDepartments 表结构:')
for row in result2:
print(f' {row["column_name"]}: {row["data_type"]}({row["character_maximum_length"]})')
finally:
conn.terminate()
if __name__ == '__main__':
asyncio.run(main())

View File

@@ -0,0 +1,26 @@
"""创建数据库脚本"""
import asyncio
import asyncpg
async def main():
conn = await asyncpg.connect(
'postgresql://postgresql:Jchl1528@192.168.110.252:15432/postgres'
)
try:
# 检查数据库是否存在
exists = await conn.fetchval(
"SELECT 1 FROM pg_database WHERE datname = 'hospital_performance'"
)
if exists:
print('数据库 hospital_performance 已存在')
else:
# 创建数据库
await conn.execute('CREATE DATABASE hospital_performance')
print('数据库 hospital_performance 创建成功')
finally:
conn.terminate()
if __name__ == '__main__':
asyncio.run(main())

View File

@@ -0,0 +1,26 @@
"""
创建菜单管理表并初始化默认菜单
"""
import asyncio
from app.core.database import engine, Base
from app.models.models import Menu
from app.services.menu_service import MenuService
async def create_tables_and_init():
"""创建表并初始化菜单"""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
print("数据库表创建成功!")
# 初始化默认菜单
async with async_session_maker() as session:
await MenuService.init_default_menus(session)
print("默认菜单初始化成功!")
if __name__ == "__main__":
from app.core.database import async_session_maker
asyncio.run(create_tables_and_init())

View File

@@ -0,0 +1,133 @@
"""
创建新功能所需的数据库表
包括:
1. 科室类型BSC维度权重配置表
2. 满意度调查模块相关表
"""
import asyncio
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import text
from app.core.database import engine, Base
from app.models.models import (
DeptTypeDimensionWeight, Survey, SurveyQuestion,
SurveyResponse, SurveyAnswer
)
async def create_tables():
"""创建新表"""
print("开始创建新功能数据库表...")
async with engine.begin() as conn:
# 创建科室类型维度权重配置表
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS dept_type_dimension_weights (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dept_type VARCHAR(50) NOT NULL,
financial_weight NUMERIC(5, 2) DEFAULT 0.60,
customer_weight NUMERIC(5, 2) DEFAULT 0.15,
internal_process_weight NUMERIC(5, 2) DEFAULT 0.20,
learning_growth_weight NUMERIC(5, 2) DEFAULT 0.05,
description TEXT,
is_active BOOLEAN DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
"""))
print(" ✓ 创建 dept_type_dimension_weights 表")
# 创建满意度调查问卷表
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS surveys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
survey_name VARCHAR(200) NOT NULL,
survey_code VARCHAR(50) NOT NULL UNIQUE,
survey_type VARCHAR(50) NOT NULL,
description TEXT,
target_departments TEXT,
total_questions INTEGER DEFAULT 0,
status VARCHAR(20) DEFAULT 'draft',
start_date DATETIME,
end_date DATETIME,
is_anonymous BOOLEAN DEFAULT 1,
is_active BOOLEAN DEFAULT 1,
created_by INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
"""))
print(" ✓ 创建 surveys 表")
# 创建调查问卷题目表
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS survey_questions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
survey_id INTEGER NOT NULL,
question_text TEXT NOT NULL,
question_type VARCHAR(50) NOT NULL,
options TEXT,
score_max INTEGER DEFAULT 5,
is_required BOOLEAN DEFAULT 1,
sort_order INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (survey_id) REFERENCES surveys(id)
)
"""))
print(" ✓ 创建 survey_questions 表")
# 创建调查问卷回答记录表
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS survey_responses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
survey_id INTEGER NOT NULL,
department_id INTEGER,
respondent_type VARCHAR(20) DEFAULT 'patient',
respondent_id INTEGER,
respondent_phone VARCHAR(20),
total_score NUMERIC(5, 2) DEFAULT 0,
max_score NUMERIC(5, 2) DEFAULT 0,
satisfaction_rate NUMERIC(5, 2) DEFAULT 0,
ip_address VARCHAR(50),
user_agent VARCHAR(500),
submitted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (survey_id) REFERENCES surveys(id),
FOREIGN KEY (department_id) REFERENCES departments(id)
)
"""))
print(" ✓ 创建 survey_responses 表")
# 创建调查问卷回答明细表
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS survey_answers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
response_id INTEGER NOT NULL,
question_id INTEGER NOT NULL,
answer_value TEXT,
score NUMERIC(5, 2) DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (response_id) REFERENCES survey_responses(id),
FOREIGN KEY (question_id) REFERENCES survey_questions(id)
)
"""))
print(" ✓ 创建 survey_answers 表")
# 创建索引
await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_weight_dept_type ON dept_type_dimension_weights(dept_type)"))
await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_survey_type ON surveys(survey_type)"))
await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_survey_status ON surveys(status)"))
await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_question_survey ON survey_questions(survey_id)"))
await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_response_survey ON survey_responses(survey_id)"))
await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_response_dept ON survey_responses(department_id)"))
print(" ✓ 创建索引")
print("数据库表创建完成!")
if __name__ == "__main__":
asyncio.run(create_tables())

View File

@@ -0,0 +1,19 @@
"""
创建绩效计划管理表
"""
import asyncio
from app.core.database import engine, Base
from app.models.models import PerformancePlan, PlanKpiRelation
async def create_tables():
"""创建绩效计划管理相关表"""
async with engine.begin() as conn:
# 创建所有表
await conn.run_sync(Base.metadata.create_all)
print("绩效计划管理表创建成功!")
if __name__ == "__main__":
asyncio.run(create_tables())

82
backend/init_db.py Normal file
View File

@@ -0,0 +1,82 @@
"""
初始化数据库并创建测试数据
"""
import asyncio
from sqlalchemy import text
from app.core.database import engine, Base, async_session_maker
from app.models.models import Department, Staff, Indicator, User
from app.core.security import get_password_hash
async def init_db():
"""初始化数据库"""
# 创建所有表
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
print("Database tables created!")
# 创建测试数据
async with async_session_maker() as session:
# 检查是否已有数据
result = await session.execute(text("SELECT COUNT(*) FROM departments"))
if result.scalar() > 0:
print("Data exists, skipping...")
return
# 创建科室(使用新的 9 种科室类型)
departments = [
Department(code="KS001", name="内科", dept_type="clinical_nonsurgical_ward", level=1),
Department(code="KS002", name="外科", dept_type="clinical_surgical", level=1),
Department(code="KS003", name="妇产科", dept_type="clinical_nonsurgical_ward", level=1),
Department(code="KS004", name="儿科", dept_type="clinical_nonsurgical_ward", level=1),
Department(code="KS005", name="放射科", dept_type="medical_tech", level=1),
Department(code="KS006", name="检验科", dept_type="medical_tech", level=1),
Department(code="KS007", name="财务科", dept_type="finance", level=1),
Department(code="KS008", name="人事科", dept_type="admin", level=1),
]
session.add_all(departments)
await session.flush()
# 创建员工
staff_list = [
Staff(employee_id="E001", name="张三", department_id=1, position="主治医师", title="副主任医师", base_salary=8000, performance_ratio=1.2),
Staff(employee_id="E002", name="李四", department_id=1, position="住院医师", title="主治医师", base_salary=6000, performance_ratio=1.0),
Staff(employee_id="E003", name="王五", department_id=2, position="主治医师", title="副主任医师", base_salary=8500, performance_ratio=1.3),
Staff(employee_id="E004", name="赵六", department_id=2, position="住院医师", title="主治医师", base_salary=6500, performance_ratio=1.0),
Staff(employee_id="E005", name="钱七", department_id=3, position="主治医师", title="主任医师", base_salary=10000, performance_ratio=1.5),
Staff(employee_id="E006", name="孙八", department_id=4, position="住院医师", title="医师", base_salary=5000, performance_ratio=0.8),
Staff(employee_id="E007", name="周九", department_id=5, position="技师", title="主管技师", base_salary=7000, performance_ratio=1.1),
Staff(employee_id="E008", name="吴十", department_id=6, position="检验师", title="主管技师", base_salary=7000, performance_ratio=1.1),
]
session.add_all(staff_list)
await session.flush()
# 创建考核指标
indicators = [
Indicator(code="ZB001", name="门诊人次", indicator_type="quantity", bs_dimension="financial", weight=1.0, max_score=100, target_unit="人次"),
Indicator(code="ZB002", name="住院人次", indicator_type="quantity", bs_dimension="financial", weight=1.2, max_score=100, target_unit="人次"),
Indicator(code="ZB003", name="手术台次", indicator_type="quantity", bs_dimension="financial", weight=1.5, max_score=100, target_unit=""),
Indicator(code="ZB004", name="医疗质量合格率", indicator_type="quality", bs_dimension="internal_process", weight=2.0, max_score=100, target_unit="%"),
Indicator(code="ZB005", name="患者满意度", indicator_type="service", bs_dimension="customer", weight=1.5, max_score=100, target_unit="%"),
Indicator(code="ZB006", name="平均住院日", indicator_type="efficiency", bs_dimension="internal_process", weight=1.0, max_score=100, target_unit=""),
Indicator(code="ZB007", name="药占比控制", indicator_type="cost", bs_dimension="financial", weight=1.0, max_score=100, target_unit="%"),
Indicator(code="ZB008", name="病历书写合格率", indicator_type="quality", bs_dimension="internal_process", weight=1.2, max_score=100, target_unit="%"),
]
session.add_all(indicators)
await session.flush()
# 创建管理员用户
admin = User(
username="admin",
password_hash=get_password_hash("admin123"),
role="admin",
is_active=True
)
session.add(admin)
await session.commit()
print("Test data created!")
if __name__ == "__main__":
asyncio.run(init_db())

View File

@@ -0,0 +1,374 @@
"""
初始化绩效考核指标模板
根据参考文档创建各类科室的考核指标模板
"""
import asyncio
import json
from app.core.database import async_session_maker
from app.models.models import Indicator, IndicatorType, BSCDimension, DeptType
async def init_indicator_templates():
"""初始化指标模板数据"""
async with async_session_maker() as db:
# 检查是否已存在数据
from sqlalchemy import text
result = await db.execute(text("SELECT count(*) FROM indicators"))
count = result.scalar()
if count > 0:
print("指标数据已存在,跳过初始化")
return
# 临床手术科室指标模板
surgical_indicators = [
# 财务维度
Indicator(
name="业务收入增长率",
code="FIN001",
indicator_type=IndicatorType.QUANTITY,
bs_dimension=BSCDimension.FINANCIAL,
weight=1.0,
max_score=100.0,
target_value=10.0,
target_unit="%",
calculation_method="(本期收入 - 同期收入)/同期收入 × 100%",
assessment_method="统计报表",
deduction_standard="每降低 1% 扣 2 分",
data_source="HIS 系统",
applicable_dept_types=json.dumps(["clinical_surgical"]),
is_veto=False,
is_active=True
),
Indicator(
name="药品比例控制",
code="FIN002",
indicator_type=IndicatorType.COST,
bs_dimension=BSCDimension.FINANCIAL,
weight=1.2,
max_score=100.0,
target_value=40.0,
target_unit="%",
calculation_method="药品收入/总收入 × 100%",
assessment_method="药事统计",
deduction_standard="超 1% 扣 1 分",
data_source="HIS 系统",
applicable_dept_types=json.dumps(["clinical_surgical"]),
is_veto=False,
is_active=True
),
Indicator(
name="医保扣款控制",
code="FIN003",
indicator_type=IndicatorType.COST,
bs_dimension=BSCDimension.FINANCIAL,
weight=1.5,
max_score=100.0,
target_value=0.0,
target_unit="",
calculation_method="因违规导致的医保扣款金额",
assessment_method="医保办统计",
deduction_standard="每发生 1 例扣 5 分",
data_source="医保系统",
applicable_dept_types=json.dumps(["clinical_surgical"]),
is_veto=False,
is_active=True
),
# 客户维度
Indicator(
name="患者满意度",
code="CUS001",
indicator_type=IndicatorType.SERVICE,
bs_dimension=BSCDimension.CUSTOMER,
weight=1.5,
max_score=100.0,
target_value=90.0,
target_unit="%",
calculation_method="满意度调查表统计",
assessment_method="问卷调查",
deduction_standard="<90% 每降 1% 扣 1 分",
data_source="满意度调查系统",
applicable_dept_types=json.dumps(["clinical_surgical", "clinical_nonsurgical_ward", "clinical_nonsurgical_noward"]),
is_veto=False,
is_active=True
),
Indicator(
name="服务投诉处理率",
code="CUS002",
indicator_type=IndicatorType.SERVICE,
bs_dimension=BSCDimension.CUSTOMER,
weight=1.0,
max_score=100.0,
target_value=100.0,
target_unit="%",
calculation_method="已处理投诉/总投诉 × 100%",
assessment_method="投诉记录统计",
deduction_standard="每有 1 例未处理扣 5 分",
data_source="投诉管理系统",
applicable_dept_types=json.dumps(["clinical_surgical", "clinical_nonsurgical_ward", "clinical_nonsurgical_noward"]),
is_veto=False,
is_active=True
),
# 内部流程维度
Indicator(
name="病历书写合格率",
code="PRO001",
indicator_type=IndicatorType.QUALITY,
bs_dimension=BSCDimension.INTERNAL_PROCESS,
weight=2.0,
max_score=100.0,
target_value=95.0,
target_unit="%",
calculation_method="合格病历数/总病历数 × 100%",
assessment_method="每周抽查",
deduction_standard="不合格每份扣 50 元",
data_source="电子病历系统",
applicable_dept_types=json.dumps(["clinical_surgical", "clinical_nonsurgical_ward"]),
is_veto=False,
is_active=True
),
Indicator(
name="手术安全核对表执行率",
code="PRO002",
indicator_type=IndicatorType.QUALITY,
bs_dimension=BSCDimension.INTERNAL_PROCESS,
weight=1.5,
max_score=100.0,
target_value=100.0,
target_unit="%",
calculation_method="执行核对的手术数/总手术数 × 100%",
assessment_method="现场核查",
deduction_standard="未执行每次扣 1 分",
data_source="手术麻醉系统",
applicable_dept_types=json.dumps(["clinical_surgical"]),
is_veto=False,
is_active=True
),
Indicator(
name="手术分级管理制度执行",
code="PRO003",
indicator_type=IndicatorType.COMPLIANCE,
bs_dimension=BSCDimension.INTERNAL_PROCESS,
weight=1.5,
max_score=100.0,
target_value=100.0,
target_unit="%",
calculation_method="符合分级的手术数/总手术数 × 100%",
assessment_method="病历审查",
deduction_standard="越级手术每例扣 2 分",
data_source="手术麻醉系统",
applicable_dept_types=json.dumps(["clinical_surgical"]),
is_veto=False,
is_active=True
),
Indicator(
name="抗菌药物使用率",
code="PRO004",
indicator_type=IndicatorType.QUALITY,
bs_dimension=BSCDimension.INTERNAL_PROCESS,
weight=1.5,
max_score=100.0,
target_value=50.0,
target_unit="%",
calculation_method="使用抗菌药物病例数/总病例数 × 100%",
assessment_method="院感统计",
deduction_standard="超 1% 扣 1 分",
data_source="院感监测系统",
applicable_dept_types=json.dumps(["clinical_surgical", "clinical_nonsurgical_ward"]),
is_veto=False,
is_active=True
),
Indicator(
name="院感控制达标率",
code="PRO005",
indicator_type=IndicatorType.SAFETY,
bs_dimension=BSCDimension.INTERNAL_PROCESS,
weight=2.0,
max_score=100.0,
target_value=100.0,
target_unit="%",
calculation_method="院感监测指标达标情况",
assessment_method="现场抽查 + 数据监测",
deduction_standard="违规每项扣 3 分",
data_source="院感监测系统",
applicable_dept_types=json.dumps(["clinical_surgical", "clinical_nonsurgical_ward", "medical_tech"]),
is_veto=True,
is_active=True
),
# 学习与成长维度
Indicator(
name="培训参与率",
code="LRN001",
indicator_type=IndicatorType.LEARNING,
bs_dimension=BSCDimension.LEARNING_GROWTH,
weight=1.0,
max_score=100.0,
target_value=90.0,
target_unit="%",
calculation_method="实际参与人数/应参与人数 × 100%",
assessment_method="培训记录",
deduction_standard="每降 1% 扣 1 分",
data_source="科教科",
applicable_dept_types=json.dumps(["clinical_surgical", "clinical_nonsurgical_ward", "medical_tech"]),
is_veto=False,
is_active=True
),
Indicator(
name="继续教育学分达标率",
code="LRN002",
indicator_type=IndicatorType.LEARNING,
bs_dimension=BSCDimension.LEARNING_GROWTH,
weight=1.0,
max_score=100.0,
target_value=100.0,
target_unit="%",
calculation_method="达标人数/总人数 × 100%",
assessment_method="学分统计",
deduction_standard="每降 1% 扣 1 分",
data_source="科教科",
applicable_dept_types=json.dumps(["clinical_surgical", "clinical_nonsurgical_ward", "medical_tech"]),
is_veto=False,
is_active=True
),
]
# 行政科室指标模板
admin_indicators = [
# 工作业绩
Indicator(
name="工作计划完成率",
code="ADM001",
indicator_type=IndicatorType.QUANTITY,
bs_dimension=BSCDimension.INTERNAL_PROCESS,
weight=2.0,
max_score=100.0,
target_value=100.0,
target_unit="%",
calculation_method="实际完成工作数/计划工作数 × 100%",
assessment_method="年度考核",
deduction_standard="每项未完成扣 5 分",
data_source="院办",
applicable_dept_types=json.dumps(["admin"]),
is_veto=False,
is_active=True
),
Indicator(
name="制度建设健全率",
code="ADM002",
indicator_type=IndicatorType.COMPLIANCE,
bs_dimension=BSCDimension.INTERNAL_PROCESS,
weight=1.0,
max_score=100.0,
target_value=100.0,
target_unit="%",
calculation_method="已建立制度数/应建立制度数 × 100%",
assessment_method="查记录",
deduction_standard="每缺一项制度扣 2 分",
data_source="院办",
applicable_dept_types=json.dumps(["admin"]),
is_veto=False,
is_active=True
),
# 服务质量
Indicator(
name="临床对行政满意度",
code="ADM003",
indicator_type=IndicatorType.SERVICE,
bs_dimension=BSCDimension.CUSTOMER,
weight=1.5,
max_score=100.0,
target_value=90.0,
target_unit="%",
calculation_method="满意度调查统计",
assessment_method="季度测评",
deduction_standard="<90% 每降 1% 扣 1 分",
data_source="满意度调查",
applicable_dept_types=json.dumps(["admin"]),
is_veto=False,
is_active=True
),
# 内部管理
Indicator(
name="科务会召开率",
code="ADM004",
indicator_type=IndicatorType.COMPLIANCE,
bs_dimension=BSCDimension.INTERNAL_PROCESS,
weight=1.0,
max_score=100.0,
target_value=100.0,
target_unit="%",
calculation_method="实际召开次数/应召开次数 × 100%",
assessment_method="查会议记录",
deduction_standard="<2 次/月扣 2 分",
data_source="院办",
applicable_dept_types=json.dumps(["admin"]),
is_veto=False,
is_active=True
),
]
# 医技科室指标模板
tech_indicators = [
Indicator(
name="报告及时率",
code="TECH001",
indicator_type=IndicatorType.EFFICIENCY,
bs_dimension=BSCDimension.INTERNAL_PROCESS,
weight=1.5,
max_score=100.0,
target_value=100.0,
target_unit="%",
calculation_method="及时报告数/总报告数 × 100%",
assessment_method="系统统计",
deduction_standard="每降 1% 扣 1 分",
data_source="LIS/PACS 系统",
applicable_dept_types=json.dumps(["medical_tech"]),
is_veto=False,
is_active=True
),
Indicator(
name="检查阳性率",
code="TECH002",
indicator_type=IndicatorType.QUALITY,
bs_dimension=BSCDimension.INTERNAL_PROCESS,
weight=1.5,
max_score=100.0,
target_value=70.0,
target_unit="%",
calculation_method="阳性例数/总检查例数 × 100%",
assessment_method="科室统计",
deduction_standard="每降 1% 扣 1 分",
data_source="LIS/PACS 系统",
applicable_dept_types=json.dumps(["medical_tech"]),
is_veto=False,
is_active=True
),
Indicator(
name="设备完好率",
code="TECH003",
indicator_type=IndicatorType.QUANTITY,
bs_dimension=BSCDimension.INTERNAL_PROCESS,
weight=1.0,
max_score=100.0,
target_value=95.0,
target_unit="%",
calculation_method="完好设备数/总设备数 × 100%",
assessment_method="设备科统计",
deduction_standard="每降 1% 扣 1 分",
data_source="设备科",
applicable_dept_types=json.dumps(["medical_tech"]),
is_veto=False,
is_active=True
),
]
# 添加所有指标到数据库
all_indicators = surgical_indicators + admin_indicators + tech_indicators
for indicator in all_indicators:
db.add(indicator)
await db.commit()
print(f"成功初始化 {len(all_indicators)} 个考核指标模板")
if __name__ == "__main__":
asyncio.run(init_indicator_templates())

564
backend/init_test_data.py Normal file
View File

@@ -0,0 +1,564 @@
"""
测试数据初始化脚本
包含:
1. 科室类型BSC维度权重配置初始化
2. 满意度调查问卷示例数据
3. 评分方法示例数据
"""
import asyncio
import sys
import os
# 添加项目路径
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import select
from app.core.database import async_session_maker
from app.models.models import (
DeptType, DeptTypeDimensionWeight, BSCDimension,
Survey, SurveyQuestion, SurveyStatus, SurveyType, QuestionType,
Indicator, IndicatorType, IndicatorTemplate, TemplateIndicator, TemplateType,
Department, Staff
)
from app.services.dimension_weight_service import DEFAULT_WEIGHTS
import json
async def init_dimension_weights():
"""初始化科室类型BSC维度权重配置"""
print("=" * 50)
print("初始化科室类型BSC维度权重配置...")
print("=" * 50)
async with async_session_maker() as db:
for dept_type, weights in DEFAULT_WEIGHTS.items():
# 检查是否已存在
result = await db.execute(
select(DeptTypeDimensionWeight)
.where(DeptTypeDimensionWeight.dept_type == dept_type)
)
existing = result.scalar_one_or_none()
if existing:
print(f" {dept_type.value}: 已存在,跳过")
continue
config = DeptTypeDimensionWeight(
dept_type=dept_type,
financial_weight=weights["financial"],
customer_weight=weights["customer"],
internal_process_weight=weights["internal_process"],
learning_growth_weight=weights["learning_growth"],
description=weights.get("description", "")
)
db.add(config)
print(f" {dept_type.value}: 财务{weights['financial']*100}%, 客户{weights['customer']*100}%, 流程{weights['internal_process']*100}%, 学习{weights['learning_growth']*100}%")
await db.commit()
print("科室类型BSC维度权重配置初始化完成\n")
async def init_sample_surveys():
"""初始化满意度调查问卷示例数据"""
print("=" * 50)
print("初始化满意度调查问卷示例数据...")
print("=" * 50)
async with async_session_maker() as db:
# 检查是否已存在问卷
result = await db.execute(select(Survey))
if result.scalars().first():
print(" 已存在问卷数据,跳过初始化\n")
return
# 创建住院患者满意度问卷
survey1 = Survey(
survey_name="住院患者满意度调查问卷",
survey_code="INPATIENT_001",
survey_type=SurveyType.INPATIENT,
description="用于评估住院患者对医院服务的满意度",
status=SurveyStatus.PUBLISHED,
is_anonymous=True,
total_questions=10
)
db.add(survey1)
await db.flush()
# 添加问卷题目
questions1 = [
{
"question_text": "您对入院手续办理的便捷程度是否满意?",
"question_type": QuestionType.SCORE,
"score_max": 5,
"sort_order": 1
},
{
"question_text": "您对病房环境的整洁舒适程度是否满意?",
"question_type": QuestionType.SCORE,
"score_max": 5,
"sort_order": 2
},
{
"question_text": "您对医生的服务态度是否满意?",
"question_type": QuestionType.SCORE,
"score_max": 5,
"sort_order": 3
},
{
"question_text": "您对护士的护理服务是否满意?",
"question_type": QuestionType.SCORE,
"score_max": 5,
"sort_order": 4
},
{
"question_text": "您对医生的技术水平是否满意?",
"question_type": QuestionType.SCORE,
"score_max": 5,
"sort_order": 5
},
{
"question_text": "您对医院餐饮服务质量是否满意?",
"question_type": QuestionType.SCORE,
"score_max": 5,
"sort_order": 6
},
{
"question_text": "您对检查检验等候时间是否满意?",
"question_type": QuestionType.SCORE,
"score_max": 5,
"sort_order": 7
},
{
"question_text": "您对出院结算流程是否满意?",
"question_type": QuestionType.SCORE,
"score_max": 5,
"sort_order": 8
},
{
"question_text": "您对医院整体服务是否满意?",
"question_type": QuestionType.SINGLE_CHOICE,
"options": json.dumps([
{"label": "非常满意", "value": "5", "score": 5},
{"label": "满意", "value": "4", "score": 4},
{"label": "一般", "value": "3", "score": 3},
{"label": "不满意", "value": "2", "score": 2},
{"label": "非常不满意", "value": "1", "score": 1}
], ensure_ascii=False),
"score_max": 5,
"sort_order": 9
},
{
"question_text": "您对医院有什么意见或建议?",
"question_type": QuestionType.TEXT,
"is_required": False,
"sort_order": 10
}
]
for q in questions1:
question = SurveyQuestion(
survey_id=survey1.id,
question_text=q["question_text"],
question_type=q["question_type"],
options=q.get("options"),
score_max=q.get("score_max", 5),
is_required=q.get("is_required", True),
sort_order=q["sort_order"]
)
db.add(question)
print(f" 创建问卷: {survey1.survey_name}")
# 创建门诊患者满意度问卷
survey2 = Survey(
survey_name="门诊患者满意度调查问卷",
survey_code="OUTPATIENT_001",
survey_type=SurveyType.OUTPATIENT,
description="用于评估门诊患者对医院服务的满意度",
status=SurveyStatus.PUBLISHED,
is_anonymous=True,
total_questions=8
)
db.add(survey2)
await db.flush()
questions2 = [
{
"question_text": "您对挂号流程的便捷程度是否满意?",
"question_type": QuestionType.SCORE,
"score_max": 5,
"sort_order": 1
},
{
"question_text": "您对候诊时间是否满意?",
"question_type": QuestionType.SCORE,
"score_max": 5,
"sort_order": 2
},
{
"question_text": "您对医生的诊疗服务是否满意?",
"question_type": QuestionType.SCORE,
"score_max": 5,
"sort_order": 3
},
{
"question_text": "您对药房取药流程是否满意?",
"question_type": QuestionType.SCORE,
"score_max": 5,
"sort_order": 4
},
{
"question_text": "您对收费处服务是否满意?",
"question_type": QuestionType.SCORE,
"score_max": 5,
"sort_order": 5
},
{
"question_text": "您对门诊环境是否满意?",
"question_type": QuestionType.SCORE,
"score_max": 5,
"sort_order": 6
},
{
"question_text": "您对门诊整体服务是否满意?",
"question_type": QuestionType.SINGLE_CHOICE,
"options": json.dumps([
{"label": "非常满意", "value": "5", "score": 5},
{"label": "满意", "value": "4", "score": 4},
{"label": "一般", "value": "3", "score": 3},
{"label": "不满意", "value": "2", "score": 2},
{"label": "非常不满意", "value": "1", "score": 1}
], ensure_ascii=False),
"score_max": 5,
"sort_order": 7
},
{
"question_text": "您对医院有什么意见或建议?",
"question_type": QuestionType.TEXT,
"is_required": False,
"sort_order": 8
}
]
for q in questions2:
question = SurveyQuestion(
survey_id=survey2.id,
question_text=q["question_text"],
question_type=q["question_type"],
options=q.get("options"),
score_max=q.get("score_max", 5),
is_required=q.get("is_required", True),
sort_order=q["sort_order"]
)
db.add(question)
print(f" 创建问卷: {survey2.survey_name}")
# 创建科室间满意度问卷
survey3 = Survey(
survey_name="科室间协作满意度调查",
survey_code="INTERNAL_001",
survey_type=SurveyType.DEPARTMENT,
description="用于评估科室之间的协作配合满意度",
status=SurveyStatus.DRAFT,
is_anonymous=False,
total_questions=6
)
db.add(survey3)
await db.flush()
questions3 = [
{
"question_text": "您对该科室的工作响应速度是否满意?",
"question_type": QuestionType.SCORE,
"score_max": 5,
"sort_order": 1
},
{
"question_text": "您对该科室的工作配合度是否满意?",
"question_type": QuestionType.SCORE,
"score_max": 5,
"sort_order": 2
},
{
"question_text": "您对该科室的服务态度是否满意?",
"question_type": QuestionType.SCORE,
"score_max": 5,
"sort_order": 3
},
{
"question_text": "您对该科室的工作质量是否满意?",
"question_type": QuestionType.SCORE,
"score_max": 5,
"sort_order": 4
},
{
"question_text": "您对该科室整体协作是否满意?",
"question_type": QuestionType.SINGLE_CHOICE,
"options": json.dumps([
{"label": "非常满意", "value": "5", "score": 5},
{"label": "满意", "value": "4", "score": 4},
{"label": "一般", "value": "3", "score": 3},
{"label": "不满意", "value": "2", "score": 2},
{"label": "非常不满意", "value": "1", "score": 1}
], ensure_ascii=False),
"score_max": 5,
"sort_order": 5
},
{
"question_text": "您对该科室有什么建议?",
"question_type": QuestionType.TEXT,
"is_required": False,
"sort_order": 6
}
]
for q in questions3:
question = SurveyQuestion(
survey_id=survey3.id,
question_text=q["question_text"],
question_type=q["question_type"],
options=q.get("options"),
score_max=q.get("score_max", 5),
is_required=q.get("is_required", True),
sort_order=q["sort_order"]
)
db.add(question)
print(f" 创建问卷: {survey3.survey_name}")
await db.commit()
print("满意度调查问卷示例数据初始化完成!\n")
async def init_sample_survey_responses():
"""初始化满意度调查回答示例数据"""
print("=" * 50)
print("初始化满意度调查回答示例数据...")
print("=" * 50)
async with async_session_maker() as db:
# 获取问卷
result = await db.execute(
select(Survey).where(Survey.survey_code == "INPATIENT_001")
)
survey = result.scalar_one_or_none()
if not survey:
print(" 未找到住院患者满意度问卷,跳过\n")
return
# 获取科室列表
dept_result = await db.execute(select(Department))
departments = dept_result.scalars().all()
if not departments:
print(" 未找到科室数据,跳过\n")
return
# 获取问卷题目
q_result = await db.execute(
select(SurveyQuestion)
.where(SurveyQuestion.survey_id == survey.id)
.order_by(SurveyQuestion.sort_order)
)
questions = q_result.scalars().all()
# 为每个科室生成示例回答
from app.models.models import SurveyResponse, SurveyAnswer
import random
for dept in departments[:5]: # 只为前5个科室生成
# 生成5-10条回答
num_responses = random.randint(5, 10)
for _ in range(num_responses):
total_score = 0
max_score = 0
response = SurveyResponse(
survey_id=survey.id,
department_id=dept.id,
respondent_type="patient"
)
db.add(response)
await db.flush()
# 为每个题目生成回答
for q in questions:
if q.question_type == QuestionType.TEXT:
continue
# 随机生成评分(倾向高分)
score = random.choices(
[5, 4, 3, 2, 1],
weights=[50, 30, 15, 3, 2]
)[0]
answer = SurveyAnswer(
response_id=response.id,
question_id=q.id,
answer_value=str(score),
score=score
)
db.add(answer)
total_score += score
max_score += q.score_max
# 更新回答记录得分
response.total_score = total_score
response.max_score = max_score
response.satisfaction_rate = (total_score / max_score * 100) if max_score > 0 else 0
await db.commit()
print(f"{min(len(departments), 5)}个科室生成了满意度调查回答数据")
print("满意度调查回答示例数据初始化完成!\n")
async def init_scoring_method_indicators():
"""初始化评分方法示例指标"""
print("=" * 50)
print("初始化评分方法示例指标...")
print("=" * 50)
async with async_session_maker() as db:
# 检查是否已存在评分方法相关指标
result = await db.execute(
select(Indicator).where(Indicator.code.in_(["SCORE001", "SCORE002", "SCORE003", "SCORE004"]))
)
if result.scalars().first():
print(" 已存在评分方法示例指标,跳过初始化\n")
return
# 创建示例指标,展示各种评分方法
scoring_indicators = [
# 目标参照法示例
Indicator(
name="业务收支结余率",
code="SCORE001",
indicator_type=IndicatorType.QUANTITY,
bs_dimension=BSCDimension.FINANCIAL,
weight=12.6,
max_score=100,
target_value=15.0, # 目标值15%
target_unit="%",
calculation_method="(业务收入 - 业务支出)/业务收入 × 100%",
assessment_method="target_reference", # 目标参照法
data_source="财务系统",
applicable_dept_types=json.dumps(["clinical_surgical", "clinical_nonsurgical_ward"]),
is_active=True
),
# 区间法-趋高指标示例
Indicator(
name="人均收支结余",
code="SCORE002",
indicator_type=IndicatorType.QUANTITY,
bs_dimension=BSCDimension.FINANCIAL,
weight=16.8,
max_score=100,
target_value=5000, # 基准值
target_unit="",
calculation_method="业务收支结余/平均职工人数",
assessment_method="interval_high", # 区间法-趋高
data_source="财务系统",
applicable_dept_types=json.dumps(["clinical_surgical", "clinical_nonsurgical_ward"]),
is_active=True
),
# 区间法-趋低指标示例
Indicator(
name="百元收入耗材率",
code="SCORE003",
indicator_type=IndicatorType.COST,
bs_dimension=BSCDimension.FINANCIAL,
weight=12.6,
max_score=100,
target_value=25.0, # 目标值≤25元
target_unit="",
calculation_method="耗材支出/业务收入 × 100",
assessment_method="interval_low", # 区间法-趋低
data_source="物资系统",
applicable_dept_types=json.dumps(["clinical_surgical", "clinical_nonsurgical_ward"]),
is_active=True
),
# 扣分法示例
Indicator(
name="病历质量考核",
code="SCORE004",
indicator_type=IndicatorType.QUALITY,
bs_dimension=BSCDimension.INTERNAL_PROCESS,
weight=3.2,
max_score=100,
target_value=0, # 目标值0无乙级病历
calculation_method="乙级病历每份扣5分丙级病历不得分",
assessment_method="deduction", # 扣分法
deduction_standard="乙级病历每份扣5分",
data_source="病案质控",
applicable_dept_types=json.dumps(["clinical_surgical", "clinical_nonsurgical_ward"]),
is_active=True
),
# 加分法示例
Indicator(
name="科研教学工作",
code="SCORE005",
indicator_type=IndicatorType.QUANTITY, # 使用QUANTITY代替
bs_dimension=BSCDimension.LEARNING_GROWTH,
weight=4.5,
max_score=100,
target_value=0,
calculation_method="科研教学成果加分",
assessment_method="bonus", # 加分法
deduction_standard="科研项目立项市级加5分省级加10分国家级加20分\n论文发表核心期刊加5分SCI加10分",
data_source="科教科",
applicable_dept_types=json.dumps(["clinical_surgical", "clinical_nonsurgical_ward", "medical_tech"]),
is_active=True
),
# 区间法-趋中指标示例
Indicator(
name="平均住院日",
code="SCORE006",
indicator_type=IndicatorType.EFFICIENCY,
bs_dimension=BSCDimension.INTERNAL_PROCESS,
weight=2.0,
max_score=100,
target_value=10.0, # 目标值10天
target_unit="",
calculation_method="出院患者平均住院天数",
assessment_method="interval_center", # 区间法-趋中
data_source="病案统计",
applicable_dept_types=json.dumps(["clinical_surgical", "clinical_nonsurgical_ward"]),
is_active=True
),
]
for indicator in scoring_indicators:
db.add(indicator)
print(f" 创建指标: {indicator.name} ({indicator.assessment_method})")
await db.commit()
print("评分方法示例指标初始化完成!\n")
async def main():
"""主函数"""
print("\n" + "=" * 60)
print("开始初始化测试数据...")
print("=" * 60 + "\n")
# 初始化科室类型BSC维度权重配置
await init_dimension_weights()
# 初始化满意度调查问卷示例数据
await init_sample_surveys()
# 初始化满意度调查回答示例数据
await init_sample_survey_responses()
# 初始化评分方法示例指标
await init_scoring_method_indicators()
print("\n" + "=" * 60)
print("测试数据初始化完成!")
print("=" * 60 + "\n")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,45 @@
"""
添加指标表的 bs_dimension 字段
"""
import asyncio
from sqlalchemy import text
from app.core.database import engine
async def migrate():
"""添加 bs_dimension 字段到 indicators 表"""
async with engine.begin() as conn:
# 添加 bs_dimension 字段
await conn.execute(
text("ALTER TABLE indicators ADD COLUMN bs_dimension VARCHAR(50) NOT NULL DEFAULT 'internal_process'")
)
# 添加 target_unit 字段
await conn.execute(
text("ALTER TABLE indicators ADD COLUMN target_unit VARCHAR(50)")
)
# 添加 assessment_method 字段
await conn.execute(
text("ALTER TABLE indicators ADD COLUMN assessment_method TEXT")
)
# 添加 deduction_standard 字段
await conn.execute(
text("ALTER TABLE indicators ADD COLUMN deduction_standard TEXT")
)
# 添加 data_source 字段
await conn.execute(
text("ALTER TABLE indicators ADD COLUMN data_source VARCHAR(100)")
)
# 添加 applicable_dept_types 字段
await conn.execute(
text("ALTER TABLE indicators ADD COLUMN applicable_dept_types TEXT")
)
# 添加 is_veto 字段
await conn.execute(
text("ALTER TABLE indicators ADD COLUMN is_veto BOOLEAN NOT NULL DEFAULT 0")
)
print("指标表迁移完成!")
if __name__ == "__main__":
asyncio.run(migrate())

31
backend/rebuild_db.py Normal file
View File

@@ -0,0 +1,31 @@
"""重建数据库"""
import asyncio
import asyncpg
async def main():
# 连接到 postgres 数据库
conn = await asyncpg.connect(
'postgresql://postgresql:Jchl1528@192.168.110.252:15432/postgres'
)
try:
# 终止所有连接
await conn.execute("""
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = 'hospital_performance' AND pid <> pg_backend_pid()
""")
# 删除数据库
await conn.execute('DROP DATABASE IF EXISTS hospital_performance')
print('数据库已删除')
# 重新创建
await conn.execute('CREATE DATABASE hospital_performance')
print('数据库已创建')
finally:
conn.terminate()
if __name__ == '__main__':
asyncio.run(main())

16
backend/requirements.txt Normal file
View File

@@ -0,0 +1,16 @@
fastapi>=0.115.0
uvicorn[standard]>=0.32.0
python-multipart>=0.0.9
sqlalchemy>=2.0.36
aiosqlite>=0.19.0
asyncpg>=0.30.0
alembic>=1.14.0
pydantic>=2.10.0
pydantic-settings>=2.6.0
email-validator>=2.2.0
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
python-dotenv>=1.0.0
httpx>=0.28.0
pytest>=8.0.0
pytest-asyncio>=0.24.0

32
backend/test_api.py Normal file
View File

@@ -0,0 +1,32 @@
import requests
import json
# Login
login_resp = requests.post('http://localhost:8001/api/v1/auth/login', json={'username': 'admin', 'password': 'admin123'})
print('Login:', login_resp.status_code)
if login_resp.status_code == 200:
token = login_resp.json()['access_token']
headers = {'Authorization': f'Bearer {token}'}
# Get templates
resp = requests.get('http://localhost:8001/api/v1/templates', headers=headers)
print('Templates:', resp.status_code)
data = resp.json()
print('Total templates:', data.get('total', 0))
for t in data.get('data', []):
print(f" - {t['template_name']} ({t['template_code']}) - {t['indicator_count']} indicators")
# Get template types
resp2 = requests.get('http://localhost:8001/api/v1/templates/types', headers=headers)
print('Template types:', resp2.status_code)
# Get first template detail
if data.get('data'):
tid = data['data'][0]['id']
resp3 = requests.get(f'http://localhost:8001/api/v1/templates/{tid}', headers=headers)
print('Template detail:', resp3.status_code)
detail = resp3.json()['data']
print(f" Name: {detail['template_name']}")
print(f" Indicators: {len(detail.get('indicators', []))}")
else:
print('Login failed:', login_resp.text)

27
backend/test_pg.py Normal file
View File

@@ -0,0 +1,27 @@
"""测试 PostgreSQL 连接"""
import httpx
# 测试登录
resp = httpx.post('http://localhost:8000/api/v1/auth/login',
data={'username': 'admin', 'password': 'admin123'})
print(f'登录: {resp.status_code}')
token = resp.json().get('access_token', '')[:50]
print(f'Token: {token}...')
token = resp.json().get('access_token')
headers = {'Authorization': f'Bearer {token}'}
# 测试科室
resp = httpx.get('http://localhost:8000/api/v1/departments', headers=headers)
data = resp.json().get('data', [])
print(f'科室: {resp.status_code} - {len(data)}')
# 测试员工
resp = httpx.get('http://localhost:8000/api/v1/staff', headers=headers)
data = resp.json().get('data', [])
print(f'员工: {resp.status_code} - {len(data)}')
# 测试指标
resp = httpx.get('http://localhost:8000/api/v1/indicators', headers=headers)
data = resp.json().get('data', [])
print(f'指标: {resp.status_code} - {len(data)}')

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