add backend source code
This commit is contained in:
10
backend/.env.example
Normal file
10
backend/.env.example
Normal 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
43
backend/alembic.ini
Normal 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
68
backend/alembic/env.py
Normal 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()
|
||||
182
backend/alembic/versions/001_initial.py
Normal file
182
backend/alembic/versions/001_initial.py
Normal 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')
|
||||
95
backend/alembic/versions/002_template.py
Normal file
95
backend/alembic/versions/002_template.py
Normal 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')
|
||||
0
backend/alembic/versions/__init__.py
Normal file
0
backend/alembic/versions/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
17
backend/app/api/v1/__init__.py
Normal file
17
backend/app/api/v1/__init__.py
Normal 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)
|
||||
165
backend/app/api/v1/assessments.py
Normal file
165
backend/app/api/v1/assessments.py
Normal 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
135
backend/app/api/v1/auth.py
Normal 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
|
||||
}
|
||||
107
backend/app/api/v1/departments.py
Normal file
107
backend/app/api/v1/departments.py
Normal 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": "删除成功"}
|
||||
216
backend/app/api/v1/finance.py
Normal file
216
backend/app/api/v1/finance.py
Normal 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": "删除成功"}
|
||||
141
backend/app/api/v1/indicators.py
Normal file
141
backend/app/api/v1/indicators.py
Normal 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
163
backend/app/api/v1/menus.py
Normal 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": "初始化成功"
|
||||
}
|
||||
309
backend/app/api/v1/performance_plans.py
Normal file
309
backend/app/api/v1/performance_plans.py
Normal 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": "删除成功"
|
||||
}
|
||||
155
backend/app/api/v1/salary.py
Normal file
155
backend/app/api/v1/salary.py
Normal 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
123
backend/app/api/v1/staff.py
Normal 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
241
backend/app/api/v1/stats.py
Normal 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
|
||||
}
|
||||
401
backend/app/api/v1/surveys.py
Normal file
401
backend/app/api/v1/surveys.py
Normal 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
|
||||
271
backend/app/api/v1/templates.py
Normal file
271
backend/app/api/v1/templates.py
Normal 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}
|
||||
}
|
||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
46
backend/app/core/config.py
Normal file
46
backend/app/core/config.py
Normal 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()
|
||||
38
backend/app/core/database.py
Normal file
38
backend/app/core/database.py
Normal 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
114
backend/app/core/init_db.py
Normal 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())
|
||||
64
backend/app/core/logging_config.py
Normal file
64
backend/app/core/logging_config.py
Normal 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)
|
||||
109
backend/app/core/security.py
Normal file
109
backend/app/core/security.py
Normal 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
91
backend/app/main.py
Normal 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"
|
||||
)
|
||||
2
backend/app/models/__init__.py
Normal file
2
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from app.models.models import * # noqa
|
||||
from app.models.finance import * # noqa
|
||||
78
backend/app/models/finance.py
Normal file
78
backend/app/models/finance.py
Normal 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
|
||||
588
backend/app/models/models.py
Normal file
588
backend/app/models/models.py
Normal 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"),
|
||||
)
|
||||
1
backend/app/schemas/__init__.py
Normal file
1
backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from app.schemas.schemas import * # noqa
|
||||
742
backend/app/schemas/schemas.py
Normal file
742
backend/app/schemas/schemas.py
Normal 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
|
||||
0
backend/app/scripts/__init__.py
Normal file
0
backend/app/scripts/__init__.py
Normal file
275
backend/app/scripts/init_templates.py
Normal file
275
backend/app/scripts/init_templates.py
Normal 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())
|
||||
15
backend/app/services/__init__.py
Normal file
15
backend/app/services/__init__.py
Normal 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",
|
||||
]
|
||||
262
backend/app/services/assessment_service.py
Normal file
262
backend/app/services/assessment_service.py
Normal 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
|
||||
149
backend/app/services/department_service.py
Normal file
149
backend/app/services/department_service.py
Normal 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
|
||||
265
backend/app/services/dimension_weight_service.py
Normal file
265
backend/app/services/dimension_weight_service.py
Normal 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
|
||||
367
backend/app/services/finance_service.py
Normal file
367
backend/app/services/finance_service.py
Normal 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
|
||||
196
backend/app/services/indicator_service.py
Normal file
196
backend/app/services/indicator_service.py
Normal 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
|
||||
}
|
||||
]
|
||||
136
backend/app/services/menu_service.py
Normal file
136
backend/app/services/menu_service.py
Normal 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()
|
||||
341
backend/app/services/performance_plan_service.py
Normal file
341
backend/app/services/performance_plan_service.py
Normal 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
|
||||
259
backend/app/services/salary_service.py
Normal file
259
backend/app/services/salary_service.py
Normal 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
|
||||
441
backend/app/services/scoring_service.py
Normal file
441
backend/app/services/scoring_service.py
Normal 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
|
||||
}
|
||||
111
backend/app/services/staff_service.py
Normal file
111
backend/app/services/staff_service.py
Normal 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())
|
||||
299
backend/app/services/stats_service.py
Normal file
299
backend/app/services/stats_service.py
Normal 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}
|
||||
520
backend/app/services/survey_service.py
Normal file
520
backend/app/services/survey_service.py
Normal 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
|
||||
293
backend/app/services/template_service.py
Normal file
293
backend/app/services/template_service.py
Normal 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)
|
||||
33
backend/app/utils/__init__.py
Normal file
33
backend/app/utils/__init__.py
Normal 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
37
backend/check_enum.py
Normal 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())
|
||||
26
backend/create_database.py
Normal file
26
backend/create_database.py
Normal 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())
|
||||
26
backend/create_menu_tables.py
Normal file
26
backend/create_menu_tables.py
Normal 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())
|
||||
133
backend/create_new_tables.py
Normal file
133
backend/create_new_tables.py
Normal 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())
|
||||
19
backend/create_plan_tables.py
Normal file
19
backend/create_plan_tables.py
Normal 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
82
backend/init_db.py
Normal 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())
|
||||
374
backend/init_indicator_templates.py
Normal file
374
backend/init_indicator_templates.py
Normal 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
564
backend/init_test_data.py
Normal 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())
|
||||
45
backend/migrate_indicators.py
Normal file
45
backend/migrate_indicators.py
Normal 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
31
backend/rebuild_db.py
Normal 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
16
backend/requirements.txt
Normal 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
32
backend/test_api.py
Normal 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
27
backend/test_pg.py
Normal 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)} 条')
|
||||
296
backend/tests/test_dimension_weight_service.py
Normal file
296
backend/tests/test_dimension_weight_service.py
Normal 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
535
backend/tests/test_e2e.py
Normal 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
|
||||
455
backend/tests/test_scoring_service.py
Normal file
455
backend/tests/test_scoring_service.py
Normal 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
|
||||
)
|
||||
279
backend/tests/test_survey_service.py
Normal file
279
backend/tests/test_survey_service.py
Normal 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
|
||||
Reference in New Issue
Block a user