From 2c37aa9064cca58353f9198ff9e90989b3b86b90 Mon Sep 17 00:00:00 2001 From: chenqi Date: Sat, 28 Feb 2026 15:06:52 +0800 Subject: [PATCH] add backend source code --- backend/.env.example | 10 + backend/alembic.ini | 43 + backend/alembic/env.py | 68 ++ backend/alembic/versions/001_initial.py | 182 +++++ backend/alembic/versions/002_template.py | 95 +++ backend/alembic/versions/__init__.py | 0 backend/app/__init__.py | 0 backend/app/api/__init__.py | 0 backend/app/api/v1/__init__.py | 17 + backend/app/api/v1/assessments.py | 165 ++++ backend/app/api/v1/auth.py | 135 ++++ backend/app/api/v1/departments.py | 107 +++ backend/app/api/v1/finance.py | 216 +++++ backend/app/api/v1/indicators.py | 141 ++++ backend/app/api/v1/menus.py | 163 ++++ backend/app/api/v1/performance_plans.py | 309 ++++++++ backend/app/api/v1/salary.py | 155 ++++ backend/app/api/v1/staff.py | 123 +++ backend/app/api/v1/stats.py | 241 ++++++ backend/app/api/v1/surveys.py | 401 ++++++++++ backend/app/api/v1/templates.py | 271 +++++++ backend/app/core/__init__.py | 0 backend/app/core/config.py | 46 ++ backend/app/core/database.py | 38 + backend/app/core/init_db.py | 114 +++ backend/app/core/logging_config.py | 64 ++ backend/app/core/security.py | 109 +++ backend/app/main.py | 91 +++ backend/app/models/__init__.py | 2 + backend/app/models/finance.py | 78 ++ backend/app/models/models.py | 588 ++++++++++++++ backend/app/schemas/__init__.py | 1 + backend/app/schemas/schemas.py | 742 ++++++++++++++++++ backend/app/scripts/__init__.py | 0 backend/app/scripts/init_templates.py | 275 +++++++ backend/app/services/__init__.py | 15 + backend/app/services/assessment_service.py | 262 +++++++ backend/app/services/department_service.py | 149 ++++ .../app/services/dimension_weight_service.py | 265 +++++++ backend/app/services/finance_service.py | 367 +++++++++ backend/app/services/indicator_service.py | 196 +++++ backend/app/services/menu_service.py | 136 ++++ .../app/services/performance_plan_service.py | 341 ++++++++ backend/app/services/salary_service.py | 259 ++++++ backend/app/services/scoring_service.py | 441 +++++++++++ backend/app/services/staff_service.py | 111 +++ backend/app/services/stats_service.py | 299 +++++++ backend/app/services/survey_service.py | 520 ++++++++++++ backend/app/services/template_service.py | 293 +++++++ backend/app/utils/__init__.py | 33 + backend/check_enum.py | 37 + backend/create_database.py | 26 + backend/create_menu_tables.py | 26 + backend/create_new_tables.py | 133 ++++ backend/create_plan_tables.py | 19 + backend/init_db.py | 82 ++ backend/init_indicator_templates.py | 374 +++++++++ backend/init_test_data.py | 564 +++++++++++++ backend/migrate_indicators.py | 45 ++ backend/rebuild_db.py | 31 + backend/requirements.txt | 16 + backend/test_api.py | 32 + backend/test_pg.py | 27 + .../tests/test_dimension_weight_service.py | 296 +++++++ backend/tests/test_e2e.py | 535 +++++++++++++ backend/tests/test_scoring_service.py | 455 +++++++++++ backend/tests/test_survey_service.py | 279 +++++++ 67 files changed, 11654 insertions(+) create mode 100644 backend/.env.example create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/versions/001_initial.py create mode 100644 backend/alembic/versions/002_template.py create mode 100644 backend/alembic/versions/__init__.py create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/v1/__init__.py create mode 100644 backend/app/api/v1/assessments.py create mode 100644 backend/app/api/v1/auth.py create mode 100644 backend/app/api/v1/departments.py create mode 100644 backend/app/api/v1/finance.py create mode 100644 backend/app/api/v1/indicators.py create mode 100644 backend/app/api/v1/menus.py create mode 100644 backend/app/api/v1/performance_plans.py create mode 100644 backend/app/api/v1/salary.py create mode 100644 backend/app/api/v1/staff.py create mode 100644 backend/app/api/v1/stats.py create mode 100644 backend/app/api/v1/surveys.py create mode 100644 backend/app/api/v1/templates.py create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/database.py create mode 100644 backend/app/core/init_db.py create mode 100644 backend/app/core/logging_config.py create mode 100644 backend/app/core/security.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/finance.py create mode 100644 backend/app/models/models.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/schemas.py create mode 100644 backend/app/scripts/__init__.py create mode 100644 backend/app/scripts/init_templates.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/assessment_service.py create mode 100644 backend/app/services/department_service.py create mode 100644 backend/app/services/dimension_weight_service.py create mode 100644 backend/app/services/finance_service.py create mode 100644 backend/app/services/indicator_service.py create mode 100644 backend/app/services/menu_service.py create mode 100644 backend/app/services/performance_plan_service.py create mode 100644 backend/app/services/salary_service.py create mode 100644 backend/app/services/scoring_service.py create mode 100644 backend/app/services/staff_service.py create mode 100644 backend/app/services/stats_service.py create mode 100644 backend/app/services/survey_service.py create mode 100644 backend/app/services/template_service.py create mode 100644 backend/app/utils/__init__.py create mode 100644 backend/check_enum.py create mode 100644 backend/create_database.py create mode 100644 backend/create_menu_tables.py create mode 100644 backend/create_new_tables.py create mode 100644 backend/create_plan_tables.py create mode 100644 backend/init_db.py create mode 100644 backend/init_indicator_templates.py create mode 100644 backend/init_test_data.py create mode 100644 backend/migrate_indicators.py create mode 100644 backend/rebuild_db.py create mode 100644 backend/requirements.txt create mode 100644 backend/test_api.py create mode 100644 backend/test_pg.py create mode 100644 backend/tests/test_dimension_weight_service.py create mode 100644 backend/tests/test_e2e.py create mode 100644 backend/tests/test_scoring_service.py create mode 100644 backend/tests/test_survey_service.py diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..0773c7e --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..cf14bd7 --- /dev/null +++ b/backend/alembic.ini @@ -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 diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..faff52d --- /dev/null +++ b/backend/alembic/env.py @@ -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() diff --git a/backend/alembic/versions/001_initial.py b/backend/alembic/versions/001_initial.py new file mode 100644 index 0000000..ebbb040 --- /dev/null +++ b/backend/alembic/versions/001_initial.py @@ -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') diff --git a/backend/alembic/versions/002_template.py b/backend/alembic/versions/002_template.py new file mode 100644 index 0000000..25ebfb3 --- /dev/null +++ b/backend/alembic/versions/002_template.py @@ -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') diff --git a/backend/alembic/versions/__init__.py b/backend/alembic/versions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py new file mode 100644 index 0000000..4c2941e --- /dev/null +++ b/backend/app/api/v1/__init__.py @@ -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) diff --git a/backend/app/api/v1/assessments.py b/backend/app/api/v1/assessments.py new file mode 100644 index 0000000..4e63bdf --- /dev/null +++ b/backend/app/api/v1/assessments.py @@ -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)} + } diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py new file mode 100644 index 0000000..74a9c06 --- /dev/null +++ b/backend/app/api/v1/auth.py @@ -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 + } diff --git a/backend/app/api/v1/departments.py b/backend/app/api/v1/departments.py new file mode 100644 index 0000000..0ba1ccd --- /dev/null +++ b/backend/app/api/v1/departments.py @@ -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": "删除成功"} diff --git a/backend/app/api/v1/finance.py b/backend/app/api/v1/finance.py new file mode 100644 index 0000000..4ca6989 --- /dev/null +++ b/backend/app/api/v1/finance.py @@ -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": "删除成功"} diff --git a/backend/app/api/v1/indicators.py b/backend/app/api/v1/indicators.py new file mode 100644 index 0000000..581dcc1 --- /dev/null +++ b/backend/app/api/v1/indicators.py @@ -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} + } diff --git a/backend/app/api/v1/menus.py b/backend/app/api/v1/menus.py new file mode 100644 index 0000000..f0c29c7 --- /dev/null +++ b/backend/app/api/v1/menus.py @@ -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": "初始化成功" + } diff --git a/backend/app/api/v1/performance_plans.py b/backend/app/api/v1/performance_plans.py new file mode 100644 index 0000000..b3b9c5a --- /dev/null +++ b/backend/app/api/v1/performance_plans.py @@ -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": "删除成功" + } diff --git a/backend/app/api/v1/salary.py b/backend/app/api/v1/salary.py new file mode 100644 index 0000000..e066c29 --- /dev/null +++ b/backend/app/api/v1/salary.py @@ -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}} diff --git a/backend/app/api/v1/staff.py b/backend/app/api/v1/staff.py new file mode 100644 index 0000000..f03932c --- /dev/null +++ b/backend/app/api/v1/staff.py @@ -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] + } diff --git a/backend/app/api/v1/stats.py b/backend/app/api/v1/stats.py new file mode 100644 index 0000000..642076e --- /dev/null +++ b/backend/app/api/v1/stats.py @@ -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 + } diff --git a/backend/app/api/v1/surveys.py b/backend/app/api/v1/surveys.py new file mode 100644 index 0000000..b1e72b0 --- /dev/null +++ b/backend/app/api/v1/surveys.py @@ -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 diff --git a/backend/app/api/v1/templates.py b/backend/app/api/v1/templates.py new file mode 100644 index 0000000..f7527a6 --- /dev/null +++ b/backend/app/api/v1/templates.py @@ -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} + } diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..1dce645 --- /dev/null +++ b/backend/app/core/config.py @@ -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() diff --git a/backend/app/core/database.py b/backend/app/core/database.py new file mode 100644 index 0000000..c63b22d --- /dev/null +++ b/backend/app/core/database.py @@ -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() diff --git a/backend/app/core/init_db.py b/backend/app/core/init_db.py new file mode 100644 index 0000000..5f46a86 --- /dev/null +++ b/backend/app/core/init_db.py @@ -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()) diff --git a/backend/app/core/logging_config.py b/backend/app/core/logging_config.py new file mode 100644 index 0000000..1038058 --- /dev/null +++ b/backend/app/core/logging_config.py @@ -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) diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..d593193 --- /dev/null +++ b/backend/app/core/security.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..7f91807 --- /dev/null +++ b/backend/app/main.py @@ -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" + ) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..13a4ade --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,2 @@ +from app.models.models import * # noqa +from app.models.finance import * # noqa diff --git a/backend/app/models/finance.py b/backend/app/models/finance.py new file mode 100644 index 0000000..e235376 --- /dev/null +++ b/backend/app/models/finance.py @@ -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 diff --git a/backend/app/models/models.py b/backend/app/models/models.py new file mode 100644 index 0000000..fa0b03e --- /dev/null +++ b/backend/app/models/models.py @@ -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"), + ) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..84efbe4 --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1 @@ +from app.schemas.schemas import * # noqa diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py new file mode 100644 index 0000000..9786b95 --- /dev/null +++ b/backend/app/schemas/schemas.py @@ -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 diff --git a/backend/app/scripts/__init__.py b/backend/app/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/scripts/init_templates.py b/backend/app/scripts/init_templates.py new file mode 100644 index 0000000..372a932 --- /dev/null +++ b/backend/app/scripts/init_templates.py @@ -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()) diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..743509a --- /dev/null +++ b/backend/app/services/__init__.py @@ -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", +] diff --git a/backend/app/services/assessment_service.py b/backend/app/services/assessment_service.py new file mode 100644 index 0000000..3466e10 --- /dev/null +++ b/backend/app/services/assessment_service.py @@ -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 diff --git a/backend/app/services/department_service.py b/backend/app/services/department_service.py new file mode 100644 index 0000000..af5657f --- /dev/null +++ b/backend/app/services/department_service.py @@ -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 diff --git a/backend/app/services/dimension_weight_service.py b/backend/app/services/dimension_weight_service.py new file mode 100644 index 0000000..591e3e0 --- /dev/null +++ b/backend/app/services/dimension_weight_service.py @@ -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 diff --git a/backend/app/services/finance_service.py b/backend/app/services/finance_service.py new file mode 100644 index 0000000..3fad728 --- /dev/null +++ b/backend/app/services/finance_service.py @@ -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 diff --git a/backend/app/services/indicator_service.py b/backend/app/services/indicator_service.py new file mode 100644 index 0000000..6753629 --- /dev/null +++ b/backend/app/services/indicator_service.py @@ -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 + } + ] diff --git a/backend/app/services/menu_service.py b/backend/app/services/menu_service.py new file mode 100644 index 0000000..61761a7 --- /dev/null +++ b/backend/app/services/menu_service.py @@ -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() diff --git a/backend/app/services/performance_plan_service.py b/backend/app/services/performance_plan_service.py new file mode 100644 index 0000000..e75af17 --- /dev/null +++ b/backend/app/services/performance_plan_service.py @@ -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 diff --git a/backend/app/services/salary_service.py b/backend/app/services/salary_service.py new file mode 100644 index 0000000..c5662d1 --- /dev/null +++ b/backend/app/services/salary_service.py @@ -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 diff --git a/backend/app/services/scoring_service.py b/backend/app/services/scoring_service.py new file mode 100644 index 0000000..8c14387 --- /dev/null +++ b/backend/app/services/scoring_service.py @@ -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 + } diff --git a/backend/app/services/staff_service.py b/backend/app/services/staff_service.py new file mode 100644 index 0000000..adfc979 --- /dev/null +++ b/backend/app/services/staff_service.py @@ -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()) diff --git a/backend/app/services/stats_service.py b/backend/app/services/stats_service.py new file mode 100644 index 0000000..e184686 --- /dev/null +++ b/backend/app/services/stats_service.py @@ -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} diff --git a/backend/app/services/survey_service.py b/backend/app/services/survey_service.py new file mode 100644 index 0000000..21bfb4b --- /dev/null +++ b/backend/app/services/survey_service.py @@ -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 diff --git a/backend/app/services/template_service.py b/backend/app/services/template_service.py new file mode 100644 index 0000000..ff54ebd --- /dev/null +++ b/backend/app/services/template_service.py @@ -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) \ No newline at end of file diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py new file mode 100644 index 0000000..2f6cf53 --- /dev/null +++ b/backend/app/utils/__init__.py @@ -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}" diff --git a/backend/check_enum.py b/backend/check_enum.py new file mode 100644 index 0000000..e8cd564 --- /dev/null +++ b/backend/check_enum.py @@ -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()) diff --git a/backend/create_database.py b/backend/create_database.py new file mode 100644 index 0000000..71f6e9e --- /dev/null +++ b/backend/create_database.py @@ -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()) diff --git a/backend/create_menu_tables.py b/backend/create_menu_tables.py new file mode 100644 index 0000000..b50ec27 --- /dev/null +++ b/backend/create_menu_tables.py @@ -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()) diff --git a/backend/create_new_tables.py b/backend/create_new_tables.py new file mode 100644 index 0000000..a99371a --- /dev/null +++ b/backend/create_new_tables.py @@ -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()) diff --git a/backend/create_plan_tables.py b/backend/create_plan_tables.py new file mode 100644 index 0000000..37c1dd6 --- /dev/null +++ b/backend/create_plan_tables.py @@ -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()) diff --git a/backend/init_db.py b/backend/init_db.py new file mode 100644 index 0000000..2a9dfc8 --- /dev/null +++ b/backend/init_db.py @@ -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()) diff --git a/backend/init_indicator_templates.py b/backend/init_indicator_templates.py new file mode 100644 index 0000000..af20d09 --- /dev/null +++ b/backend/init_indicator_templates.py @@ -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()) diff --git a/backend/init_test_data.py b/backend/init_test_data.py new file mode 100644 index 0000000..4410282 --- /dev/null +++ b/backend/init_test_data.py @@ -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()) diff --git a/backend/migrate_indicators.py b/backend/migrate_indicators.py new file mode 100644 index 0000000..3e7e18f --- /dev/null +++ b/backend/migrate_indicators.py @@ -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()) diff --git a/backend/rebuild_db.py b/backend/rebuild_db.py new file mode 100644 index 0000000..39a55fa --- /dev/null +++ b/backend/rebuild_db.py @@ -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()) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..129fddf --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/test_api.py b/backend/test_api.py new file mode 100644 index 0000000..7f007b7 --- /dev/null +++ b/backend/test_api.py @@ -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) diff --git a/backend/test_pg.py b/backend/test_pg.py new file mode 100644 index 0000000..8a9ff84 --- /dev/null +++ b/backend/test_pg.py @@ -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)} 条') diff --git a/backend/tests/test_dimension_weight_service.py b/backend/tests/test_dimension_weight_service.py new file mode 100644 index 0000000..e88e714 --- /dev/null +++ b/backend/tests/test_dimension_weight_service.py @@ -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 diff --git a/backend/tests/test_e2e.py b/backend/tests/test_e2e.py new file mode 100644 index 0000000..86a88b6 --- /dev/null +++ b/backend/tests/test_e2e.py @@ -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 diff --git a/backend/tests/test_scoring_service.py b/backend/tests/test_scoring_service.py new file mode 100644 index 0000000..824d5a2 --- /dev/null +++ b/backend/tests/test_scoring_service.py @@ -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 + ) diff --git a/backend/tests/test_survey_service.py b/backend/tests/test_survey_service.py new file mode 100644 index 0000000..7441b2e --- /dev/null +++ b/backend/tests/test_survey_service.py @@ -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