diff --git a/.gitignore b/.gitignore index a70d0106..b36fb933 100644 --- a/.gitignore +++ b/.gitignore @@ -43,7 +43,7 @@ pyrightconfig.json # Node/Vite frontend frontend/node_modules/ -frontend/dist/ +#frontend/dist/ frontend/.vite/ npm-debug.log* yarn-debug.log* diff --git a/backend/alembic/versions/20260206_01_add_ae_sae_susar_flags.py b/backend/alembic/versions/20260206_01_add_ae_sae_susar_flags.py new file mode 100644 index 00000000..3809dfb2 --- /dev/null +++ b/backend/alembic/versions/20260206_01_add_ae_sae_susar_flags.py @@ -0,0 +1,36 @@ +"""add is_sae and is_susar flags to adverse events + +Revision ID: 20260206_01 +Revises: 20260116_08 +Create Date: 2026-02-06 10:20:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "20260206_01" +down_revision: Union[str, None] = "20260116_08" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "adverse_events", + sa.Column("is_sae", sa.Boolean(), nullable=False, server_default=sa.false()), + ) + op.add_column( + "adverse_events", + sa.Column("is_susar", sa.Boolean(), nullable=False, server_default=sa.false()), + ) + op.alter_column("adverse_events", "is_sae", server_default=None) + op.alter_column("adverse_events", "is_susar", server_default=None) + + +def downgrade() -> None: + op.drop_column("adverse_events", "is_susar") + op.drop_column("adverse_events", "is_sae") diff --git a/backend/alembic/versions/20260206_02_add_study_setup_configs.py b/backend/alembic/versions/20260206_02_add_study_setup_configs.py new file mode 100644 index 00000000..060b5386 --- /dev/null +++ b/backend/alembic/versions/20260206_02_add_study_setup_configs.py @@ -0,0 +1,56 @@ +"""add study_setup_configs table + +Revision ID: 20260206_02 +Revises: 20260206_01 +Create Date: 2026-02-06 19:10:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision: str = "20260206_02" +down_revision: Union[str, None] = "20260206_01" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if "study_setup_configs" not in inspector.get_table_names(): + op.create_table( + "study_setup_configs", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("study_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("version", sa.Integer(), nullable=False, server_default="1"), + sa.Column("config", postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column("saved_by", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["saved_by"], ["users.id"]), + sa.ForeignKeyConstraint(["study_id"], ["studies.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("study_id", name="uq_study_setup_config_study"), + ) + + indexes = {idx["name"] for idx in inspector.get_indexes("study_setup_configs")} + index_name = op.f("ix_study_setup_configs_study_id") + if index_name not in indexes: + op.create_index(index_name, "study_setup_configs", ["study_id"], unique=False) + + uniques = {uq["name"] for uq in inspector.get_unique_constraints("study_setup_configs") if uq.get("name")} + if "uq_study_setup_config_study" not in uniques: + op.create_unique_constraint("uq_study_setup_config_study", "study_setup_configs", ["study_id"]) + + op.execute("ALTER TABLE study_setup_configs ALTER COLUMN version DROP DEFAULT") + + +def downgrade() -> None: + op.drop_index(op.f("ix_study_setup_configs_study_id"), table_name="study_setup_configs") + op.drop_table("study_setup_configs") diff --git a/backend/alembic/versions/20260206_03_add_setup_config_publish_fields.py b/backend/alembic/versions/20260206_03_add_setup_config_publish_fields.py new file mode 100644 index 00000000..ed6e0ef5 --- /dev/null +++ b/backend/alembic/versions/20260206_03_add_setup_config_publish_fields.py @@ -0,0 +1,66 @@ +"""add setup config publish fields + +Revision ID: 20260206_03 +Revises: 20260206_02 +Create Date: 2026-02-06 19:40:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision: str = "20260206_03" +down_revision: Union[str, None] = "20260206_02" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {c["name"] for c in inspector.get_columns("study_setup_configs")} + + if "publish_status" not in columns: + op.add_column( + "study_setup_configs", + sa.Column("publish_status", sa.String(length=20), nullable=False, server_default="DRAFT"), + ) + op.alter_column("study_setup_configs", "publish_status", server_default=None) + + if "published_config" not in columns: + op.add_column( + "study_setup_configs", + sa.Column("published_config", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + ) + + if "published_by" not in columns: + op.add_column( + "study_setup_configs", + sa.Column("published_by", postgresql.UUID(as_uuid=True), nullable=True), + ) + op.create_foreign_key( + "study_setup_configs_published_by_fkey", + "study_setup_configs", + "users", + ["published_by"], + ["id"], + ) + + if "published_at" not in columns: + op.add_column( + "study_setup_configs", + sa.Column("published_at", sa.DateTime(timezone=True), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("study_setup_configs", "published_at") + op.drop_constraint("study_setup_configs_published_by_fkey", "study_setup_configs", type_="foreignkey") + op.drop_column("study_setup_configs", "published_by") + op.drop_column("study_setup_configs", "published_config") + op.drop_column("study_setup_configs", "publish_status") + diff --git a/backend/alembic/versions/20260211_01_add_study_setup_config_versions.py b/backend/alembic/versions/20260211_01_add_study_setup_config_versions.py new file mode 100644 index 00000000..f55ea7a7 --- /dev/null +++ b/backend/alembic/versions/20260211_01_add_study_setup_config_versions.py @@ -0,0 +1,75 @@ +"""add study setup config versions table + +Revision ID: 20260211_01 +Revises: 20260206_03 +Create Date: 2026-02-11 11:30:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision: str = "20260211_01" +down_revision: Union[str, None] = "20260206_03" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + tables = set(inspector.get_table_names()) + + if "study_setup_config_versions" not in tables: + op.create_table( + "study_setup_config_versions", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("study_setup_config_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("study_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column("source_version", sa.Integer(), nullable=True), + sa.Column("config", postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column("published_by", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("published_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + sa.ForeignKeyConstraint(["published_by"], ["users.id"]), + sa.ForeignKeyConstraint(["study_id"], ["studies.id"]), + sa.ForeignKeyConstraint(["study_setup_config_id"], ["study_setup_configs.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("study_id", "version", name="uq_setup_config_versions_study_version"), + ) + + existing_indexes = {idx["name"] for idx in inspector.get_indexes("study_setup_config_versions")} + if "ix_study_setup_config_versions_study_setup_config_id" not in existing_indexes: + op.create_index( + "ix_study_setup_config_versions_study_setup_config_id", + "study_setup_config_versions", + ["study_setup_config_id"], + unique=False, + ) + if "ix_study_setup_config_versions_study_id" not in existing_indexes: + op.create_index( + "ix_study_setup_config_versions_study_id", + "study_setup_config_versions", + ["study_id"], + unique=False, + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + tables = set(inspector.get_table_names()) + if "study_setup_config_versions" not in tables: + return + + indexes = {idx["name"] for idx in inspector.get_indexes("study_setup_config_versions")} + if "ix_study_setup_config_versions_study_setup_config_id" in indexes: + op.drop_index("ix_study_setup_config_versions_study_setup_config_id", table_name="study_setup_config_versions") + if "ix_study_setup_config_versions_study_id" in indexes: + op.drop_index("ix_study_setup_config_versions_study_id", table_name="study_setup_config_versions") + op.drop_table("study_setup_config_versions") diff --git a/backend/alembic/versions/20260211_02_add_study_extended_profile_fields.py b/backend/alembic/versions/20260211_02_add_study_extended_profile_fields.py new file mode 100644 index 00000000..6e00109c --- /dev/null +++ b/backend/alembic/versions/20260211_02_add_study_extended_profile_fields.py @@ -0,0 +1,88 @@ +"""add extended profile fields to studies + +Revision ID: 20260211_02 +Revises: 20260211_01 +Create Date: 2026-02-11 14:20:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "20260211_02" +down_revision: Union[str, None] = "20260211_01" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {col["name"] for col in inspector.get_columns("studies")} + + if "project_full_name" not in columns: + op.add_column("studies", sa.Column("project_full_name", sa.String(length=500), nullable=True)) + if "lead_unit" not in columns: + op.add_column("studies", sa.Column("lead_unit", sa.String(length=255), nullable=True)) + if "principal_investigator" not in columns: + op.add_column("studies", sa.Column("principal_investigator", sa.String(length=255), nullable=True)) + if "main_pm" not in columns: + op.add_column("studies", sa.Column("main_pm", sa.String(length=255), nullable=True)) + if "research_analysis" not in columns: + op.add_column("studies", sa.Column("research_analysis", sa.Text(), nullable=True)) + if "research_product" not in columns: + op.add_column("studies", sa.Column("research_product", sa.String(length=255), nullable=True)) + if "control_product" not in columns: + op.add_column("studies", sa.Column("control_product", sa.String(length=255), nullable=True)) + if "indication" not in columns: + op.add_column("studies", sa.Column("indication", sa.String(length=255), nullable=True)) + if "research_population" not in columns: + op.add_column("studies", sa.Column("research_population", sa.String(length=255), nullable=True)) + if "research_design" not in columns: + op.add_column("studies", sa.Column("research_design", sa.Text(), nullable=True)) + if "plan_start_date" not in columns: + op.add_column("studies", sa.Column("plan_start_date", sa.Date(), nullable=True)) + if "plan_end_date" not in columns: + op.add_column("studies", sa.Column("plan_end_date", sa.Date(), nullable=True)) + if "planned_site_count" not in columns: + op.add_column("studies", sa.Column("planned_site_count", sa.Integer(), nullable=True)) + if "planned_enrollment_count" not in columns: + op.add_column("studies", sa.Column("planned_enrollment_count", sa.Integer(), nullable=True)) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {col["name"] for col in inspector.get_columns("studies")} + + if "planned_enrollment_count" in columns: + op.drop_column("studies", "planned_enrollment_count") + if "planned_site_count" in columns: + op.drop_column("studies", "planned_site_count") + if "plan_end_date" in columns: + op.drop_column("studies", "plan_end_date") + if "plan_start_date" in columns: + op.drop_column("studies", "plan_start_date") + if "research_design" in columns: + op.drop_column("studies", "research_design") + if "research_population" in columns: + op.drop_column("studies", "research_population") + if "indication" in columns: + op.drop_column("studies", "indication") + if "control_product" in columns: + op.drop_column("studies", "control_product") + if "research_product" in columns: + op.drop_column("studies", "research_product") + if "research_analysis" in columns: + op.drop_column("studies", "research_analysis") + if "main_pm" in columns: + op.drop_column("studies", "main_pm") + if "principal_investigator" in columns: + op.drop_column("studies", "principal_investigator") + if "lead_unit" in columns: + op.drop_column("studies", "lead_unit") + if "project_full_name" in columns: + op.drop_column("studies", "project_full_name") diff --git a/backend/alembic/versions/20260213_01_add_display_version_to_setup_config_versions.py b/backend/alembic/versions/20260213_01_add_display_version_to_setup_config_versions.py new file mode 100644 index 00000000..7d478290 --- /dev/null +++ b/backend/alembic/versions/20260213_01_add_display_version_to_setup_config_versions.py @@ -0,0 +1,77 @@ +"""add display version to setup config versions + +Revision ID: 20260213_01 +Revises: 20260211_02 +Create Date: 2026-02-13 12:10:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "20260213_01" +down_revision: Union[str, None] = "20260211_02" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {col["name"] for col in inspector.get_columns("study_setup_config_versions")} + + if "display_version" not in columns: + op.add_column( + "study_setup_config_versions", + sa.Column("display_version", sa.Integer(), nullable=True), + ) + + op.execute( + sa.text( + """ + WITH ranked AS ( + SELECT + id, + ROW_NUMBER() OVER ( + PARTITION BY study_id + ORDER BY published_at ASC, version ASC + ) AS rn + FROM study_setup_config_versions + ) + UPDATE study_setup_config_versions v + SET display_version = ranked.rn + FROM ranked + WHERE v.id = ranked.id + AND v.display_version IS NULL + """ + ) + ) + + op.alter_column("study_setup_config_versions", "display_version", nullable=False) + + uniques = {item["name"] for item in inspector.get_unique_constraints("study_setup_config_versions")} + if "uq_setup_config_versions_study_display_version" not in uniques: + op.create_unique_constraint( + "uq_setup_config_versions_study_display_version", + "study_setup_config_versions", + ["study_id", "display_version"], + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + uniques = {item["name"] for item in inspector.get_unique_constraints("study_setup_config_versions")} + if "uq_setup_config_versions_study_display_version" in uniques: + op.drop_constraint( + "uq_setup_config_versions_study_display_version", + "study_setup_config_versions", + type_="unique", + ) + + columns = {col["name"] for col in inspector.get_columns("study_setup_config_versions")} + if "display_version" in columns: + op.drop_column("study_setup_config_versions", "display_version") diff --git a/backend/alembic/versions/20260213_02_add_setup_projection_fields.py b/backend/alembic/versions/20260213_02_add_setup_projection_fields.py new file mode 100644 index 00000000..2bb50941 --- /dev/null +++ b/backend/alembic/versions/20260213_02_add_setup_projection_fields.py @@ -0,0 +1,64 @@ +"""add setup projection fields to studies and sites + +Revision ID: 20260213_02 +Revises: 20260213_01 +Create Date: 2026-02-13 16:30:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "20260213_02" +down_revision: Union[str, None] = "20260213_01" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + study_columns = {col["name"] for col in inspector.get_columns("studies")} + if "enrollment_monthly_goal_note" not in study_columns: + op.add_column("studies", sa.Column("enrollment_monthly_goal_note", sa.Text(), nullable=True)) + if "enrollment_stage_breakdown" not in study_columns: + op.add_column("studies", sa.Column("enrollment_stage_breakdown", sa.Text(), nullable=True)) + if "summary_note" not in study_columns: + op.add_column("studies", sa.Column("summary_note", sa.Text(), nullable=True)) + if "objective_note" not in study_columns: + op.add_column("studies", sa.Column("objective_note", sa.Text(), nullable=True)) + + site_columns = {col["name"] for col in inspector.get_columns("sites")} + if "enrollment_plan_start_date" not in site_columns: + op.add_column("sites", sa.Column("enrollment_plan_start_date", sa.Date(), nullable=True)) + if "enrollment_plan_end_date" not in site_columns: + op.add_column("sites", sa.Column("enrollment_plan_end_date", sa.Date(), nullable=True)) + if "enrollment_plan_note" not in site_columns: + op.add_column("sites", sa.Column("enrollment_plan_note", sa.Text(), nullable=True)) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + site_columns = {col["name"] for col in inspector.get_columns("sites")} + if "enrollment_plan_note" in site_columns: + op.drop_column("sites", "enrollment_plan_note") + if "enrollment_plan_end_date" in site_columns: + op.drop_column("sites", "enrollment_plan_end_date") + if "enrollment_plan_start_date" in site_columns: + op.drop_column("sites", "enrollment_plan_start_date") + + study_columns = {col["name"] for col in inspector.get_columns("studies")} + if "objective_note" in study_columns: + op.drop_column("studies", "objective_note") + if "summary_note" in study_columns: + op.drop_column("studies", "summary_note") + if "enrollment_stage_breakdown" in study_columns: + op.drop_column("studies", "enrollment_stage_breakdown") + if "enrollment_monthly_goal_note" in study_columns: + op.drop_column("studies", "enrollment_monthly_goal_note") diff --git a/backend/alembic/versions/20260213_03_add_setup_projection_entities.py b/backend/alembic/versions/20260213_03_add_setup_projection_entities.py new file mode 100644 index 00000000..3809e380 --- /dev/null +++ b/backend/alembic/versions/20260213_03_add_setup_projection_entities.py @@ -0,0 +1,88 @@ +"""add setup projection entities for monitoring and center confirm + +Revision ID: 20260213_03 +Revises: 20260213_02 +Create Date: 2026-02-13 11:20:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision: str = "20260213_03" +down_revision: Union[str, None] = "20260213_02" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + milestone_columns = {col["name"] for col in inspector.get_columns("milestones")} + if "owner_name" not in milestone_columns: + op.add_column("milestones", sa.Column("owner_name", sa.String(length=255), nullable=True)) + + if "study_monitoring_strategies" not in inspector.get_table_names(): + op.create_table( + "study_monitoring_strategies", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("study_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("source_setup_item_id", sa.String(length=64), nullable=True), + sa.Column("strategy_type", sa.String(length=50), nullable=False), + sa.Column("detail", sa.Text(), nullable=False), + sa.Column("frequency", sa.String(length=50), nullable=False), + sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + sa.ForeignKeyConstraint(["study_id"], ["studies.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_study_monitoring_strategies_study_id", + "study_monitoring_strategies", + ["study_id"], + unique=False, + ) + + if "study_center_confirms" not in inspector.get_table_names(): + op.create_table( + "study_center_confirms", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("study_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("site_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("source_setup_item_id", sa.String(length=64), nullable=True), + sa.Column("confirmer", sa.String(length=255), nullable=True), + sa.Column("confirm_status", sa.String(length=20), nullable=False), + sa.Column("confirm_date", sa.Date(), nullable=True), + sa.Column("note", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + sa.ForeignKeyConstraint(["site_id"], ["sites.id"]), + sa.ForeignKeyConstraint(["study_id"], ["studies.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_study_center_confirms_site_id", "study_center_confirms", ["site_id"], unique=False) + op.create_index("ix_study_center_confirms_study_id", "study_center_confirms", ["study_id"], unique=False) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if "study_center_confirms" in inspector.get_table_names(): + op.drop_index("ix_study_center_confirms_study_id", table_name="study_center_confirms") + op.drop_index("ix_study_center_confirms_site_id", table_name="study_center_confirms") + op.drop_table("study_center_confirms") + + if "study_monitoring_strategies" in inspector.get_table_names(): + op.drop_index("ix_study_monitoring_strategies_study_id", table_name="study_monitoring_strategies") + op.drop_table("study_monitoring_strategies") + + milestone_columns = {col["name"] for col in inspector.get_columns("milestones")} + if "owner_name" in milestone_columns: + op.drop_column("milestones", "owner_name") diff --git a/backend/app/api/v1/studies.py b/backend/app/api/v1/studies.py index 678eab21..09158e43 100644 --- a/backend/app/api/v1/studies.py +++ b/backend/app/api/v1/studies.py @@ -1,19 +1,340 @@ import uuid +import re +from io import BytesIO +from datetime import date +from collections import Counter -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status +from fastapi.responses import StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession -from app.core.deps import get_current_user, get_db_session, require_roles, require_study_member, require_study_roles +from app.core.deps import ( + get_current_user, + get_db_session, + require_roles, + require_study_member, + require_study_not_locked, + require_study_roles, +) from app.crud import study as study_crud from app.crud import member as member_crud from app.crud import audit as audit_crud +from app.crud import site as site_crud +from app.crud import study_setup_config as study_setup_config_crud +from app.crud import user as user_crud +from app.services.setup_config_excel import export_setup_config_excel, import_setup_config_excel from app.schemas.common import PaginatedResponse from app.schemas.study import StudyCreate, StudyRead, StudyUpdate from app.schemas.member import StudyMemberCreate +from app.schemas.study_setup_config import ( + SetupProjectionSummary, + StudySetupConfigData, + StudySetupConfigPublish, + StudySetupConfigRead, + StudySetupConfigRollback, + StudySetupConfigUpsert, + StudySetupConfigVersionRead, +) +from app.services.setup_config_projection import apply_setup_projection_on_publish from app.utils.pagination import paginate router = APIRouter() +def _raise_validation_error(errors: list[dict[str, str]]) -> None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "code": "VALIDATION_ERROR", + "message": "校验失败", + "errors": errors, + }, + ) + + +def _raise_conflict_error() -> None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "code": "SETUP_CONFIG_VERSION_CONFLICT", + "message": "配置版本冲突,请刷新后重试", + }, + ) + + +def _parse_date_str(value: str, field: str, errors: list[dict[str, str]]) -> date | None: + if not value: + return None + try: + return date.fromisoformat(value) + except ValueError: + errors.append({"field": field, "message": "日期格式应为 YYYY-MM-DD"}) + return None + + +def _is_empty_row(values: list[str | None]) -> bool: + return all(not (item or "").strip() for item in values) + + +def _validate_setup_data( + payload: StudySetupConfigData, + study_sites: dict[str, str], + *, + project_plan_start: date | None = None, + project_plan_end: date | None = None, +) -> None: + errors: list[dict[str, str]] = [] + allowed_milestone_status = {"未开始", "进行中", "已完成", "延期"} + allowed_strategy_types = { + "启动访视", + "筛选访视", + "监查访视", + "协同访视", + "风险访视", + "末次访视", + } + allowed_confirm_status = {"待确认", "已确认", "退回"} + + for index, row in enumerate(payload.projectMilestones): + row_prefix = f"projectMilestones[{index}]" + if not row.id: + errors.append({"field": f"{row_prefix}.id", "message": "ID不能为空"}) + if _is_empty_row([row.name, row.owner, row.remark, row.planDate]): + continue + if not row.name.strip(): + errors.append({"field": f"{row_prefix}.name", "message": "里程碑名称不能为空"}) + _parse_date_str(row.planDate, f"{row_prefix}.planDate", errors) + if row.status not in allowed_milestone_status: + errors.append({"field": f"{row_prefix}.status", "message": "里程碑状态不合法"}) + + plan = payload.enrollmentPlan + if plan.totalTarget < 0: + errors.append({"field": "enrollmentPlan.totalTarget", "message": "计划总入组例数不能小于0"}) + enrollment_plan_start = _parse_date_str(plan.startDate, "enrollmentPlan.startDate", errors) + enrollment_plan_end = _parse_date_str(plan.endDate, "enrollmentPlan.endDate", errors) + if not plan.startDate: + errors.append({"field": "enrollmentPlan.startDate", "message": "请填写计划开始日期"}) + if not plan.endDate: + errors.append({"field": "enrollmentPlan.endDate", "message": "请填写计划结束日期"}) + if enrollment_plan_start and enrollment_plan_end and enrollment_plan_start > enrollment_plan_end: + errors.append({"field": "enrollmentPlan.endDate", "message": "结束日期不能早于开始日期"}) + if project_plan_start and enrollment_plan_start and enrollment_plan_start < project_plan_start: + errors.append({"field": "enrollmentPlan.startDate", "message": "项目入组计划开始日期不能早于项目计划开始日期"}) + if project_plan_end and enrollment_plan_start and enrollment_plan_start > project_plan_end: + errors.append({"field": "enrollmentPlan.startDate", "message": "项目入组计划开始日期不能晚于项目计划结束日期"}) + if project_plan_start and enrollment_plan_end and enrollment_plan_end < project_plan_start: + errors.append({"field": "enrollmentPlan.endDate", "message": "项目入组计划结束日期不能早于项目计划开始日期"}) + if project_plan_end and enrollment_plan_end and enrollment_plan_end > project_plan_end: + errors.append({"field": "enrollmentPlan.endDate", "message": "项目入组计划结束日期不能晚于项目计划结束日期"}) + + total_site_target = 0 + for index, row in enumerate(payload.siteMilestones): + row_prefix = f"siteMilestones[{index}]" + if not row.id: + errors.append({"field": f"{row_prefix}.id", "message": "ID不能为空"}) + if _is_empty_row([row.milestone, row.owner, row.remark, row.planDate]): + continue + if not row.milestone.strip(): + errors.append({"field": f"{row_prefix}.milestone", "message": "中心里程碑不能为空"}) + if row.status not in allowed_milestone_status: + errors.append({"field": f"{row_prefix}.status", "message": "里程碑状态不合法"}) + plan_date = _parse_date_str(row.planDate, f"{row_prefix}.planDate", errors) + if project_plan_start and plan_date and plan_date < project_plan_start: + errors.append({"field": f"{row_prefix}.planDate", "message": "中心里程碑日期不能早于项目计划开始日期"}) + if project_plan_end and plan_date and plan_date > project_plan_end: + errors.append({"field": f"{row_prefix}.planDate", "message": "中心里程碑日期不能晚于项目计划结束日期"}) + + seen_site_enrollment_site_ids: set[str] = set() + for index, row in enumerate(payload.siteEnrollmentPlans): + row_prefix = f"siteEnrollmentPlans[{index}]" + if not row.id: + errors.append({"field": f"{row_prefix}.id", "message": "ID不能为空"}) + if _is_empty_row([row.siteId, row.note, row.startDate, row.endDate]): + continue + site_id = (row.siteId or "").strip() + if not site_id: + errors.append({"field": f"{row_prefix}.siteId", "message": "请选择中心"}) + elif site_id not in study_sites: + errors.append({"field": f"{row_prefix}.siteId", "message": "中心不属于当前项目"}) + elif site_id in seen_site_enrollment_site_ids: + errors.append({"field": f"{row_prefix}.siteId", "message": "中心入组计划不允许重复配置同一中心"}) + else: + seen_site_enrollment_site_ids.add(site_id) + if row.target < 0: + errors.append({"field": f"{row_prefix}.target", "message": "目标入组人数不能小于0"}) + total_site_target += max(row.target, 0) + start = _parse_date_str(row.startDate, f"{row_prefix}.startDate", errors) + end = _parse_date_str(row.endDate, f"{row_prefix}.endDate", errors) + if not row.startDate: + errors.append({"field": f"{row_prefix}.startDate", "message": "请填写启动日期"}) + if not row.endDate: + errors.append({"field": f"{row_prefix}.endDate", "message": "请填写完成日期"}) + if start and end and start > end: + errors.append({"field": f"{row_prefix}.endDate", "message": "完成日期不能早于启动日期"}) + if project_plan_start and start and start < project_plan_start: + errors.append({"field": f"{row_prefix}.startDate", "message": "中心启动日期不能早于项目计划开始日期"}) + if project_plan_end and start and start > project_plan_end: + errors.append({"field": f"{row_prefix}.startDate", "message": "中心启动日期不能晚于项目计划结束日期"}) + if project_plan_start and end and end < project_plan_start: + errors.append({"field": f"{row_prefix}.endDate", "message": "中心完成日期不能早于项目计划开始日期"}) + if project_plan_end and end and end > project_plan_end: + errors.append({"field": f"{row_prefix}.endDate", "message": "中心完成日期不能晚于项目计划结束日期"}) + + if total_site_target > plan.totalTarget: + errors.append({"field": "siteEnrollmentPlans", "message": "中心计划总例数不能超过项目总入组例数"}) + + for index, row in enumerate(payload.monitoringStrategies): + row_prefix = f"monitoringStrategies[{index}]" + if not row.id: + errors.append({"field": f"{row_prefix}.id", "message": "ID不能为空"}) + if _is_empty_row([row.strategyType, row.detail, row.frequency]): + continue + if row.strategyType not in allowed_strategy_types: + errors.append({"field": f"{row_prefix}.strategyType", "message": "监查类型不合法"}) + if not row.detail.strip(): + errors.append({"field": f"{row_prefix}.detail", "message": "策略详情不能为空"}) + if row.frequency not in {"不限", "按触发", "每月1次"} and re.fullmatch(r"\d+次", row.frequency) is None: + errors.append({"field": f"{row_prefix}.frequency", "message": "监查次数格式应为“不限”/“按触发”/“每月1次”或“N次”"}) + + for index, row in enumerate(payload.centerConfirm): + row_prefix = f"centerConfirm[{index}]" + if not row.id: + errors.append({"field": f"{row_prefix}.id", "message": "ID不能为空"}) + if _is_empty_row([row.siteId, row.confirmer, row.note, row.confirmDate]): + continue + if not row.siteId: + errors.append({"field": f"{row_prefix}.siteId", "message": "请选择中心"}) + elif row.siteId not in study_sites: + errors.append({"field": f"{row_prefix}.siteId", "message": "中心不属于当前项目"}) + if row.confirmStatus not in allowed_confirm_status: + errors.append({"field": f"{row_prefix}.confirmStatus", "message": "确认状态不合法"}) + parsed_confirm_date = _parse_date_str(row.confirmDate, f"{row_prefix}.confirmDate", errors) + if row.confirmStatus == "已确认" and not parsed_confirm_date: + errors.append({"field": f"{row_prefix}.confirmDate", "message": "已确认时必须填写确认日期"}) + if row.confirmStatus == "退回" and not row.note.strip(): + errors.append({"field": f"{row_prefix}.note", "message": "退回时必须填写备注"}) + if project_plan_start and parsed_confirm_date and parsed_confirm_date < project_plan_start: + errors.append({"field": f"{row_prefix}.confirmDate", "message": "中心确认日期不能早于项目计划开始日期"}) + if project_plan_end and parsed_confirm_date and parsed_confirm_date > project_plan_end: + errors.append({"field": f"{row_prefix}.confirmDate", "message": "中心确认日期不能晚于项目计划结束日期"}) + + if errors: + _raise_validation_error(errors) + + +def _build_default_setup_config_from_study(study, sites: list) -> StudySetupConfigData: + plan_start = study.plan_start_date.isoformat() if getattr(study, "plan_start_date", None) else "" + plan_end = study.plan_end_date.isoformat() if getattr(study, "plan_end_date", None) else "" + total_target = getattr(study, "planned_enrollment_count", None) + if total_target is None: + total_target = 0 + return StudySetupConfigData( + projectMilestones=[], + enrollmentPlan={ + "totalTarget": total_target, + "startDate": plan_start, + "endDate": plan_end, + "monthlyGoalNote": getattr(study, "enrollment_monthly_goal_note", None) or "", + "stageBreakdown": getattr(study, "enrollment_stage_breakdown", None) or "", + }, + siteMilestones=[], + siteEnrollmentPlans=[], + monitoringStrategies=[], + centerConfirm=[], + ) + + +def _to_setup_config_read( + record, + *, + saved_by_name: str | None = None, + published_by_name: str | None = None, + projection_status: str | None = None, + projection_summary: SetupProjectionSummary | None = None, +) -> StudySetupConfigRead: + return StudySetupConfigRead( + id=record.id, + study_id=record.study_id, + version=record.version, + data=StudySetupConfigData.model_validate(record.config or {}), + publish_status=record.publish_status or "DRAFT", + published_data=StudySetupConfigData.model_validate(record.published_config) if record.published_config else None, + published_by=record.published_by, + published_by_name=published_by_name, + published_at=record.published_at, + saved_by=record.saved_by, + saved_by_name=saved_by_name, + projection_status=projection_status, + projection_summary=projection_summary, + created_at=record.created_at, + updated_at=record.updated_at, + ) + + +def _build_projection_audit_detail( + *, + projection_status: str, + study_updated: bool, + site_updated_count: int, + site_skipped_count: int, + warnings: list[str], + skipped_items: list[dict[str, str]], +) -> str: + warning_part = " | ".join(warnings[:5]) if warnings else "-" + reason_counter = Counter(item.get("reason") for item in skipped_items if item.get("reason")) + reason_summary = ", ".join(f"{reason}:{count}" for reason, count in sorted(reason_counter.items())) if reason_counter else "-" + return ( + "立项配置已发布" + f"; projection_status={projection_status}" + f"; study_updated={study_updated}" + f"; site_updated={site_updated_count}" + f"; site_skipped={site_skipped_count}" + f"; warnings={warning_part}" + f"; skipped_reason_counts={reason_summary}" + ) + + +def _ensure_study_timeline_valid( + *, + plan_start_date: date | None, + plan_end_date: date | None, + visit_window_start_offset: int | None, + visit_window_end_offset: int | None, +) -> None: + if plan_start_date and plan_end_date and plan_start_date > plan_end_date: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="项目计划结束日期不能早于开始日期") + if ( + visit_window_start_offset is not None + and visit_window_end_offset is not None + and visit_window_start_offset > visit_window_end_offset + ): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="访视窗口开始偏移不能晚于结束偏移") + + +def _to_setup_config_version_read(record, *, published_by_name: str | None = None) -> StudySetupConfigVersionRead: + return StudySetupConfigVersionRead( + id=record.id, + study_id=record.study_id, + study_setup_config_id=record.study_setup_config_id, + version=record.version, + display_version=record.display_version, + source_version=record.source_version, + config=StudySetupConfigData.model_validate(record.config or {}), + published_by=record.published_by, + published_by_name=published_by_name, + published_at=record.published_at, + created_at=record.created_at, + ) + + +def _top_level_diff_summary(old: dict | None, new: dict | None) -> str: + old = old or {} + new = new or {} + changed = [] + for key in ("projectMilestones", "enrollmentPlan", "siteMilestones", "siteEnrollmentPlans", "monitoringStrategies", "centerConfirm"): + if old.get(key) != new.get(key): + changed.append(key) + return "变更模块:" + (", ".join(changed) if changed else "无") + @router.post("/", response_model=StudyRead, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_roles(["ADMIN"]))]) async def create_study( @@ -21,6 +342,15 @@ async def create_study( db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> StudyRead: + _ensure_study_timeline_valid( + plan_start_date=study_in.plan_start_date, + plan_end_date=study_in.plan_end_date, + visit_window_start_offset=study_in.visit_window_start_offset, + visit_window_end_offset=study_in.visit_window_end_offset, + ) + study_in.code = study_in.code.strip() + if not study_in.code: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="项目编号不能为空") existing = await study_crud.get_by_code(db, study_in.code) if existing: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="项目编号已存在") @@ -33,6 +363,26 @@ async def create_study( is_active=True, ) await member_crud.add_member(db, study.id, member_in) + + # 初始化立项配置草稿,并回填可映射的项目信息字段 + default_setup_data = _build_default_setup_config_from_study(study, []) + setup_record, _ = await study_setup_config_crud.upsert( + db, + study.id, + expected_version=None, + data=default_setup_data, + saved_by=current_user.id, + ) + await audit_crud.log_action( + db, + study_id=study.id, + entity_type="study_setup_config", + entity_id=setup_record.id, + action="CREATE_SETUP_CONFIG", + detail="初始化立项配置(创建项目时回填基础信息)", + operator_id=current_user.id, + operator_role=current_user.role, + ) return study @@ -89,6 +439,25 @@ async def update_study( existing = await study_crud.get_by_code(db, study_in.code) if existing and existing.id != study.id: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="项目编号已存在") + + next_plan_start = study_in.plan_start_date if "plan_start_date" in study_in.model_fields_set else study.plan_start_date + next_plan_end = study_in.plan_end_date if "plan_end_date" in study_in.model_fields_set else study.plan_end_date + next_window_start = ( + study_in.visit_window_start_offset + if "visit_window_start_offset" in study_in.model_fields_set + else study.visit_window_start_offset + ) + next_window_end = ( + study_in.visit_window_end_offset + if "visit_window_end_offset" in study_in.model_fields_set + else study.visit_window_end_offset + ) + _ensure_study_timeline_valid( + plan_start_date=next_plan_start, + plan_end_date=next_plan_end, + visit_window_start_offset=next_window_start, + visit_window_end_offset=next_window_end, + ) updated = await study_crud.update(db, study, study_in) return updated @@ -175,3 +544,407 @@ async def unlock_study( ) return unlocked_study + + +@router.get( + "/{study_id}/setup-config", + response_model=StudySetupConfigRead, + dependencies=[Depends(require_study_member())], +) +async def get_study_setup_config( + study_id: uuid.UUID, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> StudySetupConfigRead: + study = await study_crud.get(db, study_id) + if not study: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在") + + record = await study_setup_config_crud.get_by_study(db, study_id) + if not record: + sites = await site_crud.list_by_study(db, study_id, skip=0, limit=500, include_inactive=True) + default_data = _build_default_setup_config_from_study(study, list(sites)) + record, _ = await study_setup_config_crud.upsert( + db, + study_id, + expected_version=None, + data=default_data, + saved_by=current_user.id, + ) + await audit_crud.log_action( + db, + study_id=study_id, + entity_type="study_setup_config", + entity_id=record.id, + action="CREATE_SETUP_CONFIG", + detail="初始化立项配置", + operator_id=current_user.id, + operator_role=current_user.role, + ) + + saved_by_name = None + published_by_name = None + if record.saved_by: + user = await user_crud.get_by_id(db, record.saved_by) + if user: + saved_by_name = user.full_name or user.username or user.email + if record.published_by: + user = await user_crud.get_by_id(db, record.published_by) + if user: + published_by_name = user.full_name or user.username or user.email + + return _to_setup_config_read(record, saved_by_name=saved_by_name, published_by_name=published_by_name) + + +@router.put( + "/{study_id}/setup-config", + response_model=StudySetupConfigRead, + dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())], +) +async def upsert_study_setup_config( + study_id: uuid.UUID, + payload: StudySetupConfigUpsert, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> StudySetupConfigRead: + study = await study_crud.get(db, study_id) + if not study: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在") + + sites = await site_crud.list_by_study(db, study_id, skip=0, limit=1000, include_inactive=True) + site_lookup = {str(site.id): site.name or "" for site in sites} + _validate_setup_data( + payload.data, + site_lookup, + project_plan_start=getattr(study, "plan_start_date", None), + project_plan_end=getattr(study, "plan_end_date", None), + ) + + existing = await study_setup_config_crud.get_by_study(db, study_id) + old_config = dict(existing.config or {}) if existing else {} + record, conflict = await study_setup_config_crud.upsert( + db, + study_id, + expected_version=payload.expected_version, + data=payload.data, + saved_by=current_user.id, + ) + if conflict: + _raise_conflict_error() + if not record: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="配置保存失败") + + await audit_crud.log_action( + db, + study_id=study_id, + entity_type="study_setup_config", + entity_id=record.id, + action="UPDATE_SETUP_CONFIG", + detail=_top_level_diff_summary(old_config, record.config), + operator_id=current_user.id, + operator_role=current_user.role, + ) + + saved_by_name = current_user.full_name or current_user.username or current_user.email + published_by_name = None + if record.published_by: + user = await user_crud.get_by_id(db, record.published_by) + if user: + published_by_name = user.full_name or user.username or user.email + return _to_setup_config_read(record, saved_by_name=saved_by_name, published_by_name=published_by_name) + + +@router.post( + "/{study_id}/setup-config/publish", + response_model=StudySetupConfigRead, + dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())], +) +async def publish_study_setup_config( + study_id: uuid.UUID, + payload: StudySetupConfigPublish, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> StudySetupConfigRead: + study = await study_crud.get(db, study_id) + if not study: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在") + + record = await study_setup_config_crud.get_by_study(db, study_id) + if not record: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="请先保存立项配置草稿") + + sites = await site_crud.list_by_study(db, study_id, skip=0, limit=1000, include_inactive=True) + site_lookup = {str(site.id): site.name or "" for site in sites} + _validate_setup_data( + StudySetupConfigData.model_validate(record.config or {}), + site_lookup, + project_plan_start=getattr(study, "plan_start_date", None), + project_plan_end=getattr(study, "plan_end_date", None), + ) + + published, conflict = await study_setup_config_crud.publish( + db, + study_id, + expected_version=payload.expected_version, + published_by=current_user.id, + auto_commit=False, + ) + if conflict: + await db.rollback() + _raise_conflict_error() + if not published: + await db.rollback() + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="配置发布失败") + + try: + projection = await apply_setup_projection_on_publish( + db, + study_id=study_id, + setup_data=StudySetupConfigData.model_validate(published.config or {}), + operator_id=current_user.id, + ) + projection_summary = SetupProjectionSummary( + study_updated=projection.study_updated, + site_updated_count=projection.site_updated_count, + site_skipped_count=projection.site_skipped_count, + warnings=projection.warnings, + skipped_items=[ + {"site_id": item.site_id, "reason": item.reason} + for item in projection.skipped_items + ], + ) + await audit_crud.log_action( + db, + study_id=study_id, + entity_type="study_setup_config", + entity_id=published.id, + action="PUBLISH_SETUP_CONFIG", + detail=_build_projection_audit_detail( + projection_status=projection.status, + study_updated=projection.study_updated, + site_updated_count=projection.site_updated_count, + site_skipped_count=projection.site_skipped_count, + warnings=projection.warnings, + skipped_items=[{"site_id": item.site_id, "reason": item.reason} for item in projection.skipped_items], + ), + operator_id=current_user.id, + operator_role=current_user.role, + auto_commit=False, + ) + await db.commit() + except Exception: + await db.rollback() + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="发布失败:联动同步异常") + + operator_name = current_user.full_name or current_user.username or current_user.email + await db.refresh(published) + return _to_setup_config_read( + published, + saved_by_name=operator_name, + published_by_name=operator_name, + projection_status=projection.status, + projection_summary=projection_summary, + ) + + +@router.get( + "/{study_id}/setup-config/versions", + response_model=list[StudySetupConfigVersionRead], + dependencies=[Depends(require_study_member())], +) +async def list_study_setup_config_versions( + study_id: uuid.UUID, + db: AsyncSession = Depends(get_db_session), +) -> list[StudySetupConfigVersionRead]: + study = await study_crud.get(db, study_id) + if not study: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在") + + versions = await study_setup_config_crud.list_versions(db, study_id) + user_ids = {item.published_by for item in versions if item.published_by} + name_map: dict[uuid.UUID, str] = {} + for user_id in user_ids: + user = await user_crud.get_by_id(db, user_id) + if user: + name_map[user_id] = user.full_name or user.username or user.email + return [ + _to_setup_config_version_read(item, published_by_name=name_map.get(item.published_by) if item.published_by else None) + for item in versions + ] + + +@router.post( + "/{study_id}/setup-config/rollback", + response_model=StudySetupConfigRead, + dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())], +) +async def rollback_study_setup_config( + study_id: uuid.UUID, + payload: StudySetupConfigRollback, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> StudySetupConfigRead: + study = await study_crud.get(db, study_id) + if not study: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在") + + record, conflict, target_missing = await study_setup_config_crud.rollback_to_version( + db, + study_id, + expected_version=payload.expected_version, + target_version=payload.target_version, + saved_by=current_user.id, + ) + if conflict: + _raise_conflict_error() + if target_missing: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="目标版本不存在") + if not record: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="版本回滚失败") + + await audit_crud.log_action( + db, + study_id=study_id, + entity_type="study_setup_config", + entity_id=record.id, + action="ROLLBACK_SETUP_CONFIG", + detail=f"立项配置已回滚到发布版本 v{payload.target_version}", + operator_id=current_user.id, + operator_role=current_user.role, + ) + operator_name = current_user.full_name or current_user.username or current_user.email + published_by_name = None + if record.published_by: + user = await user_crud.get_by_id(db, record.published_by) + if user: + published_by_name = user.full_name or user.username or user.email + return _to_setup_config_read(record, saved_by_name=operator_name, published_by_name=published_by_name) + + +@router.delete( + "/{study_id}/setup-config/versions/{target_version}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())], +) +async def delete_study_setup_config_version( + study_id: uuid.UUID, + target_version: int, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> None: + study = await study_crud.get(db, study_id) + if not study: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在") + + versions = await study_setup_config_crud.list_versions(db, study_id) + if not versions: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="目标版本不存在") + latest_version = max(item.version for item in versions) + if target_version == latest_version: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="最新发布版本不能删除") + + deleted = await study_setup_config_crud.delete_version( + db, + study_id, + target_version=target_version, + ) + if not deleted: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="目标版本不存在") + + await audit_crud.log_action( + db, + study_id=study_id, + entity_type="study_setup_config", + entity_id=study_id, + action="DELETE_SETUP_CONFIG_VERSION", + detail=f"删除发布版本快照 v{target_version}", + operator_id=current_user.id, + operator_role=current_user.role, + ) + + +@router.get( + "/{study_id}/setup-config/export-excel", + dependencies=[Depends(require_study_member())], +) +async def export_study_setup_config_excel( + study_id: uuid.UUID, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> StreamingResponse: + config = await get_study_setup_config(study_id=study_id, db=db, current_user=current_user) + study = await study_crud.get(db, study_id) + file_bytes = export_setup_config_excel(config.data) + filename = f"setup-config-{study.code if study else study_id}.xlsx" + return StreamingResponse( + BytesIO(file_bytes), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@router.post( + "/{study_id}/setup-config/import-excel", + response_model=StudySetupConfigRead, + dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())], +) +async def import_study_setup_config_excel( + study_id: uuid.UUID, + file: UploadFile = File(...), + expected_version: int | None = Form(default=None), + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> StudySetupConfigRead: + if not (file.filename or "").lower().endswith(".xlsx"): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请上传 .xlsx 格式文件") + + study = await study_crud.get(db, study_id) + if not study: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在") + + try: + payload_data = import_setup_config_excel(await file.read()) + except Exception: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Excel解析失败,请检查模板格式") + + sites = await site_crud.list_by_study(db, study_id, skip=0, limit=1000, include_inactive=True) + site_lookup = {str(site.id): site.name or "" for site in sites} + _validate_setup_data( + payload_data, + site_lookup, + project_plan_start=getattr(study, "plan_start_date", None), + project_plan_end=getattr(study, "plan_end_date", None), + ) + + existing = await study_setup_config_crud.get_by_study(db, study_id) + old_config = dict(existing.config or {}) if existing else {} + record, conflict = await study_setup_config_crud.upsert( + db, + study_id, + expected_version=expected_version, + data=payload_data, + saved_by=current_user.id, + ) + if conflict: + _raise_conflict_error() + if not record: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="配置导入失败") + + await audit_crud.log_action( + db, + study_id=study_id, + entity_type="study_setup_config", + entity_id=record.id, + action="IMPORT_SETUP_CONFIG_EXCEL", + detail=_top_level_diff_summary(old_config, record.config), + operator_id=current_user.id, + operator_role=current_user.role, + ) + + saved_by_name = current_user.full_name or current_user.username or current_user.email + published_by_name = None + if record.published_by: + user = await user_crud.get_by_id(db, record.published_by) + if user: + published_by_name = user.full_name or user.username or user.email + return _to_setup_config_read(record, saved_by_name=saved_by_name, published_by_name=published_by_name) diff --git a/backend/app/api/v1/subjects.py b/backend/app/api/v1/subjects.py index 2eaaf4d4..7370b693 100644 --- a/backend/app/api/v1/subjects.py +++ b/backend/app/api/v1/subjects.py @@ -85,16 +85,29 @@ async def list_subjects( return [] subject_ids = [subject.id for subject in subject_list] ae_rows = await db.execute( - select(AdverseEvent.subject_id).where( + select(AdverseEvent.subject_id, AdverseEvent.is_sae, AdverseEvent.is_susar).where( AdverseEvent.study_id == study_id, AdverseEvent.subject_id.in_(subject_ids), ) ) - ae_subject_ids = {row[0] for row in ae_rows.all() if row[0]} + ae_flags: dict[uuid.UUID, dict[str, bool]] = {} + for row in ae_rows.all(): + sid = row[0] + if not sid: + continue + if sid not in ae_flags: + ae_flags[sid] = {"has_ae": True, "has_sae": False, "has_susar": False} + if row[1]: + ae_flags[sid]["has_sae"] = True + if row[2]: + ae_flags[sid]["has_susar"] = True result: list[SubjectRead] = [] for subject in subject_list: data = SubjectRead.model_validate(subject) - data.has_ae = subject.id in ae_subject_ids + flags = ae_flags.get(subject.id, {"has_ae": False, "has_sae": False, "has_susar": False}) + data.has_ae = flags["has_ae"] + data.has_sae = flags["has_sae"] + data.has_susar = flags["has_susar"] result.append(data) return result @@ -120,12 +133,21 @@ async def get_subject( await _ensure_subject_active(db, subject) data = SubjectRead.model_validate(subject) ae_row = await db.execute( - select(AdverseEvent.id).where( + select(AdverseEvent.is_sae, AdverseEvent.is_susar).where( AdverseEvent.study_id == study_id, AdverseEvent.subject_id == subject.id, - ).limit(1) + ) ) - data.has_ae = ae_row.scalar_one_or_none() is not None + ae_flags = {"has_ae": False, "has_sae": False, "has_susar": False} + for row in ae_row.all(): + ae_flags["has_ae"] = True + if row[0]: + ae_flags["has_sae"] = True + if row[1]: + ae_flags["has_susar"] = True + data.has_ae = ae_flags["has_ae"] + data.has_sae = ae_flags["has_sae"] + data.has_susar = ae_flags["has_susar"] return data diff --git a/backend/app/api/v1/visits.py b/backend/app/api/v1/visits.py index 39fe9c60..29bf8da0 100644 --- a/backend/app/api/v1/visits.py +++ b/backend/app/api/v1/visits.py @@ -62,6 +62,20 @@ async def create_visit( await _ensure_subject_active(db, subject) if visit_in.subject_id != subject_id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="参与者不匹配") + if visit_in.window_start and visit_in.window_end and visit_in.window_start > visit_in.window_end: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="访视窗口结束日期不能早于开始日期") + if ( + visit_in.planned_date + and visit_in.window_start + and visit_in.planned_date < visit_in.window_start + ): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="计划访视日期不能早于访视窗口开始日期") + if ( + visit_in.planned_date + and visit_in.window_end + and visit_in.planned_date > visit_in.window_end + ): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="计划访视日期不能晚于访视窗口结束日期") next_visit_code = await visit_crud.get_next_visit_code(db, subject_id) if next_visit_code == "V1" and not visit_in.planned_date: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="首次新增访视必须填写计划访视日期") @@ -119,6 +133,12 @@ async def update_visit( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="访视不存在") if visit_in.status == "DONE" and not visit_in.actual_date: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="状态为已完成时必须填写实际日期") + if visit.window_start and visit.window_end and visit.window_start > visit.window_end: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前访视窗口配置异常,请先修复窗口日期") + if visit_in.actual_date and visit.window_start and visit_in.actual_date < visit.window_start: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="实际访视日期不能早于访视窗口开始日期") + if visit_in.actual_date and visit.window_end and visit_in.actual_date > visit.window_end: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="实际访视日期不能晚于访视窗口结束日期") updated = await visit_crud.update_visit(db, visit, visit_in) detail = None if visit_in.status: diff --git a/backend/app/crud/ae.py b/backend/app/crud/ae.py index c97e773d..ee7d3176 100644 --- a/backend/app/crud/ae.py +++ b/backend/app/crud/ae.py @@ -64,12 +64,14 @@ async def create_ae( term=ae_in.term, onset_date=ae_in.onset_date, resolution_date=None, - seriousness=str(ae_in.seriousness), - severity=str(ae_in.seriousness), + seriousness=ae_in.seriousness.value, + severity=ae_in.seriousness.value, causality=ae_in.causality, action_taken=None, outcome=ae_in.outcome, reported_to_sponsor=False, + is_sae=bool(ae_in.is_sae or ae_in.is_susar), + is_susar=bool(ae_in.is_susar), report_due_date=due_date, status="NEW", description=ae_in.description, @@ -125,8 +127,14 @@ async def list_ae( async def update_ae(db: AsyncSession, study_id: uuid.UUID, ae: AdverseEvent, ae_in: AEUpdate) -> AdverseEvent: update_data = ae_in.model_dump(exclude_unset=True) + if "is_susar" in update_data and update_data["is_susar"]: + update_data["is_sae"] = True + if "is_sae" in update_data and update_data["is_sae"] is False: + update_data["is_susar"] = False if "seriousness" in update_data: - update_data["seriousness"] = str(update_data["seriousness"]) + severity_value = update_data["seriousness"].value if hasattr(update_data["seriousness"], "value") else str(update_data["seriousness"]) + update_data["seriousness"] = severity_value + update_data["severity"] = severity_value if "subject_id" in update_data: await _validate_site_subject(db, study_id, None, update_data["subject_id"]) if "onset_date" in update_data or "seriousness" in update_data: diff --git a/backend/app/crud/audit.py b/backend/app/crud/audit.py index 47772be1..6f71dc02 100644 --- a/backend/app/crud/audit.py +++ b/backend/app/crud/audit.py @@ -17,6 +17,7 @@ async def log_action( detail: str | None, operator_id: uuid.UUID, operator_role: str, + auto_commit: bool = True, ) -> AuditLog: log = AuditLog( study_id=study_id, @@ -28,8 +29,11 @@ async def log_action( operator_role=operator_role, ) db.add(log) - await db.commit() - await db.refresh(log) + if auto_commit: + await db.commit() + await db.refresh(log) + else: + await db.flush() return log diff --git a/backend/app/crud/site.py b/backend/app/crud/site.py index 5799b421..178ae50e 100644 --- a/backend/app/crud/site.py +++ b/backend/app/crud/site.py @@ -25,6 +25,7 @@ from app.models.site import Site from app.models.special_expense import SpecialExpense from app.models.startup_ethics import StartupEthics from app.models.startup_feasibility import StartupFeasibility +from app.models.study_center_confirm import StudyCenterConfirm from app.models.subject import Subject from app.models.subject_history import SubjectHistory from app.models.training_authorization import TrainingAuthorization @@ -44,6 +45,9 @@ async def create_site(db: AsyncSession, study_id: uuid.UUID, site_in: SiteCreate pi_name=site_in.pi_name, contact=site_in.contact, is_active=site_in.is_active, + enrollment_plan_start_date=site_in.enrollment_plan_start_date, + enrollment_plan_end_date=site_in.enrollment_plan_end_date, + enrollment_plan_note=site_in.enrollment_plan_note, ) db.add(site) await db.commit() @@ -314,6 +318,7 @@ async def delete_site_and_related(db: AsyncSession, site: Site) -> None: await db.execute(delete(AdverseEvent).where(AdverseEvent.site_id == site_id)) await db.execute(delete(FinanceItem).where(FinanceItem.site_id == site_id)) await db.execute(delete(Subject).where(Subject.site_id == site_id)) + await db.execute(delete(StudyCenterConfirm).where(StudyCenterConfirm.site_id == site_id)) await db.execute(delete(Milestone).where(Milestone.site_id == site_id)) await db.execute(delete(KickoffMeeting).where(KickoffMeeting.site_id == site_id)) diff --git a/backend/app/crud/study.py b/backend/app/crud/study.py index 1d83030b..83cfe108 100644 --- a/backend/app/crud/study.py +++ b/backend/app/crud/study.py @@ -9,23 +9,29 @@ from app.schemas.study import StudyCreate, StudyUpdate async def create(db: AsyncSession, study_in: StudyCreate, *, created_by: uuid.UUID | None = None) -> Study: - import uuid as uuid_lib # Import uuid to generate unique code if needed - - code = study_in.code - if not code: - # Generate a unique code if not provided - # Use first 8 chars of a new UUID, uppercase - code = f"P-{str(uuid_lib.uuid4())[:8].upper()}" - study = Study( - code=code, + code=study_in.code.strip(), name=study_in.name, - # sponsor is removed from schema, but model has it. Set to None/empty or keep existing value if schema had it (which it doesn't now) - # If we removed it from schema, study_in.sponsor will fail if we try to access it via .sponsor attribute directly if it's not in the pydantic model anymore. - # Since we removed `sponsor` field from StudyCreate, `study_in` object won't have `sponsor` attribute unless we access dict or use default. - # However, the Study MODEL still has sponsor. We should set it to None. - sponsor=None, + project_full_name=study_in.project_full_name, + sponsor=study_in.sponsor, protocol_no=study_in.protocol_no, + lead_unit=study_in.lead_unit, + principal_investigator=study_in.principal_investigator, + main_pm=study_in.main_pm, + research_analysis=study_in.research_analysis, + research_product=study_in.research_product, + control_product=study_in.control_product, + indication=study_in.indication, + research_population=study_in.research_population, + research_design=study_in.research_design, + plan_start_date=study_in.plan_start_date, + plan_end_date=study_in.plan_end_date, + planned_site_count=study_in.planned_site_count, + planned_enrollment_count=study_in.planned_enrollment_count, + enrollment_monthly_goal_note=study_in.enrollment_monthly_goal_note, + enrollment_stage_breakdown=study_in.enrollment_stage_breakdown, + summary_note=study_in.summary_note, + objective_note=study_in.objective_note, phase=study_in.phase, status=study_in.status, visit_interval_days=study_in.visit_interval_days, @@ -114,6 +120,10 @@ async def delete(db: AsyncSession, study_id: uuid.UUID) -> None: from app.models.faq_category import FaqCategory from app.models.faq_item import FaqItem from app.models.faq_reply import FaqReply + from app.models.study_setup_config import StudySetupConfig + from app.models.study_setup_config_version import StudySetupConfigVersion + from app.models.study_monitoring_strategy import StudyMonitoringStrategy + from app.models.study_center_confirm import StudyCenterConfirm # 按依赖关系顺序删除关联数据 # 1. 删除审计日志 @@ -160,13 +170,21 @@ async def delete(db: AsyncSession, study_id: uuid.UUID) -> None: await db.execute(sa_delete(SubjectHistory).where(SubjectHistory.study_id == study_id)) await db.execute(sa_delete(Subject).where(Subject.study_id == study_id)) - # 13. 删除站点 + # 13. 删除立项联动实体(需先于站点删除,避免外键约束) + await db.execute(sa_delete(StudyMonitoringStrategy).where(StudyMonitoringStrategy.study_id == study_id)) + await db.execute(sa_delete(StudyCenterConfirm).where(StudyCenterConfirm.study_id == study_id)) + + # 14. 删除站点 await db.execute(sa_delete(Site).where(Site.study_id == study_id)) - - # 14. 删除成员 + + # 15. 删除成员 await db.execute(sa_delete(StudyMember).where(StudyMember.study_id == study_id)) - - # 15. 最后删除项目本身 + + # 16. 删除立项配置 + await db.execute(sa_delete(StudySetupConfigVersion).where(StudySetupConfigVersion.study_id == study_id)) + await db.execute(sa_delete(StudySetupConfig).where(StudySetupConfig.study_id == study_id)) + + # 17. 最后删除项目本身 await db.execute(sa_delete(Study).where(Study.id == study_id)) await db.commit() diff --git a/backend/app/crud/study_setup_config.py b/backend/app/crud/study_setup_config.py new file mode 100644 index 00000000..d2ccc341 --- /dev/null +++ b/backend/app/crud/study_setup_config.py @@ -0,0 +1,171 @@ +import uuid +from datetime import datetime + +from sqlalchemy import delete as sa_delete, select +from sqlalchemy import func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.study_setup_config import StudySetupConfig +from app.models.study_setup_config_version import StudySetupConfigVersion +from app.schemas.study_setup_config import StudySetupConfigData + + +def _to_storage(data: StudySetupConfigData | dict) -> dict: + if isinstance(data, StudySetupConfigData): + return data.model_dump(mode="json") + return data + + +async def get_by_study(db: AsyncSession, study_id: uuid.UUID) -> StudySetupConfig | None: + result = await db.execute(select(StudySetupConfig).where(StudySetupConfig.study_id == study_id)) + return result.scalar_one_or_none() + + +async def upsert( + db: AsyncSession, + study_id: uuid.UUID, + *, + expected_version: int | None, + data: StudySetupConfigData | dict, + saved_by: uuid.UUID | None, +) -> tuple[StudySetupConfig | None, bool]: + existing = await get_by_study(db, study_id) + payload = _to_storage(data) + if existing: + if expected_version is not None and expected_version != existing.version: + return None, True + existing.version = existing.version + 1 + existing.config = payload + existing.saved_by = saved_by + existing.publish_status = "PUBLISHED" if existing.published_config == payload else "DRAFT" + existing.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(existing) + return existing, False + + record = StudySetupConfig( + study_id=study_id, + version=1, + config=payload, + publish_status="DRAFT", + saved_by=saved_by, + ) + db.add(record) + await db.commit() + await db.refresh(record) + return record, False + + +async def publish( + db: AsyncSession, + study_id: uuid.UUID, + *, + expected_version: int | None, + published_by: uuid.UUID | None, + auto_commit: bool = True, +) -> tuple[StudySetupConfig | None, bool]: + existing = await get_by_study(db, study_id) + if not existing: + return None, False + if expected_version is not None and expected_version != existing.version: + return None, True + next_version = existing.version + 1 + next_display_version_stmt = select(func.max(StudySetupConfigVersion.display_version)).where( + StudySetupConfigVersion.study_id == study_id + ) + display_version_result = await db.execute(next_display_version_stmt) + current_max_display_version = display_version_result.scalar_one_or_none() or 0 + next_display_version = current_max_display_version + 1 + existing.version = next_version + existing.publish_status = "PUBLISHED" + existing.published_config = existing.config + existing.published_by = published_by + existing.published_at = datetime.utcnow() + existing.saved_by = published_by + existing.updated_at = datetime.utcnow() + snapshot = StudySetupConfigVersion( + study_setup_config_id=existing.id, + study_id=study_id, + version=next_version, + display_version=next_display_version, + source_version=next_version - 1, + config=existing.config, + published_by=published_by, + published_at=existing.published_at, + ) + db.add(snapshot) + if auto_commit: + await db.commit() + await db.refresh(existing) + else: + await db.flush() + return existing, False + + +async def list_versions(db: AsyncSession, study_id: uuid.UUID) -> list[StudySetupConfigVersion]: + result = await db.execute( + select(StudySetupConfigVersion) + .where(StudySetupConfigVersion.study_id == study_id) + .order_by(StudySetupConfigVersion.version.desc(), StudySetupConfigVersion.published_at.desc()) + ) + return list(result.scalars().all()) + + +async def rollback_to_version( + db: AsyncSession, + study_id: uuid.UUID, + *, + expected_version: int | None, + target_version: int, + saved_by: uuid.UUID | None, +) -> tuple[StudySetupConfig | None, bool, bool]: + existing = await get_by_study(db, study_id) + if not existing: + return None, False, False + if expected_version is not None and expected_version != existing.version: + return None, True, False + + result = await db.execute( + select(StudySetupConfigVersion).where( + StudySetupConfigVersion.study_id == study_id, + StudySetupConfigVersion.version == target_version, + ) + ) + target = result.scalar_one_or_none() + if not target: + return None, False, True + + existing.version = existing.version + 1 + existing.config = target.config + existing.saved_by = saved_by + existing.publish_status = "DRAFT" + existing.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(existing) + return existing, False, False + + +async def delete_version( + db: AsyncSession, + study_id: uuid.UUID, + *, + target_version: int, +) -> bool: + result = await db.execute( + select(StudySetupConfigVersion).where( + StudySetupConfigVersion.study_id == study_id, + StudySetupConfigVersion.version == target_version, + ) + ) + target = result.scalar_one_or_none() + if not target: + return False + await db.delete(target) + await db.commit() + return True + + +async def delete_by_study(db: AsyncSession, study_id: uuid.UUID) -> None: + await db.execute(sa_delete(StudySetupConfigVersion).where(StudySetupConfigVersion.study_id == study_id)) + await db.execute(sa_delete(StudySetupConfig).where(StudySetupConfig.study_id == study_id)) + await db.commit() diff --git a/backend/app/crud/subject.py b/backend/app/crud/subject.py index 5428df67..1f734832 100644 --- a/backend/app/crud/subject.py +++ b/backend/app/crud/subject.py @@ -16,6 +16,27 @@ from app.models.subject import Subject from app.schemas.subject import SubjectCreate, SubjectUpdate +def _validate_subject_date_chain( + *, + screening_date: date | None, + consent_date: date | None, + enrollment_date: date | None, + completion_date: date | None, +) -> None: + if screening_date and consent_date and consent_date < screening_date: + raise ValueError("知情同意日期不能早于筛选日期") + if screening_date and enrollment_date and enrollment_date < screening_date: + raise ValueError("入组日期不能早于筛选日期") + if consent_date and enrollment_date and enrollment_date < consent_date: + raise ValueError("入组日期不能早于知情同意日期") + if screening_date and completion_date and completion_date < screening_date: + raise ValueError("完成日期不能早于筛选日期") + if consent_date and completion_date and completion_date < consent_date: + raise ValueError("完成日期不能早于知情同意日期") + if enrollment_date and completion_date and completion_date < enrollment_date: + raise ValueError("完成日期不能早于入组日期") + + async def _validate_site(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UUID) -> None: result = await db.execute(select(Site).where(Site.id == site_id)) site = result.scalar_one_or_none() @@ -27,6 +48,12 @@ async def _validate_site(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UU async def create_subject(db: AsyncSession, study_id: uuid.UUID, subject_in: SubjectCreate) -> Subject: await _validate_site(db, study_id, subject_in.site_id) + _validate_subject_date_chain( + screening_date=subject_in.screening_date, + consent_date=subject_in.consent_date, + enrollment_date=None, + completion_date=None, + ) subject = Subject( study_id=study_id, site_id=subject_in.site_id, @@ -121,6 +148,16 @@ async def generate_default_visits(db: AsyncSession, subject: Subject) -> None: async def update_subject(db: AsyncSession, subject: Subject, subject_in: SubjectUpdate) -> Subject: update_data = subject_in.model_dump(exclude_unset=True) + next_screening_date = subject.screening_date + next_consent_date = update_data.get("consent_date", subject.consent_date) + next_enrollment_date = update_data.get("enrollment_date", subject.enrollment_date) + next_completion_date = update_data.get("completion_date", subject.completion_date) + _validate_subject_date_chain( + screening_date=next_screening_date, + consent_date=next_consent_date, + enrollment_date=next_enrollment_date, + completion_date=next_completion_date, + ) if update_data: await db.execute( sa_update(Subject) diff --git a/backend/app/db/base.py b/backend/app/db/base.py index c863ab39..b1e87b21 100644 --- a/backend/app/db/base.py +++ b/backend/app/db/base.py @@ -32,3 +32,7 @@ from app.models.subject_history import SubjectHistory # noqa: F401 from app.models.faq_category import FaqCategory # noqa: F401 from app.models.faq_item import FaqItem # noqa: F401 from app.models.faq_reply import FaqReply # noqa: F401 +from app.models.study_setup_config import StudySetupConfig # noqa: F401 +from app.models.study_setup_config_version import StudySetupConfigVersion # noqa: F401 +from app.models.study_monitoring_strategy import StudyMonitoringStrategy # noqa: F401 +from app.models.study_center_confirm import StudyCenterConfirm # noqa: F401 diff --git a/backend/app/main.py b/backend/app/main.py index d7010f32..118b273d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,5 +1,9 @@ import asyncio +import logging +import re +import time from contextlib import asynccontextmanager +from collections import defaultdict from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -14,6 +18,12 @@ from app.db.session import SessionLocal, engine from app.services.fee_seed import seed_demo_fees from app.services.visit_scheduler import run_daily_lost_visit_job +logger = logging.getLogger("ctms.setup_config") +UUID_RE = re.compile( + r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" +) +setup_config_stats: dict[str, int] = defaultdict(int) + @asynccontextmanager async def lifespan(_: FastAPI): @@ -100,6 +110,62 @@ def create_app() -> FastAPI: allow_methods=["*"], allow_headers=["*"], ) + + @app.middleware("http") + async def setup_config_monitoring_middleware(request, call_next): + path = request.url.path + is_setup_config_path = "/api/v1/studies/" in path and "/setup-config" in path + started_at = time.perf_counter() + try: + response = await call_next(request) + except Exception: + if is_setup_config_path: + normalized_path = UUID_RE.sub("{study_id}", path) + duration_ms = int((time.perf_counter() - started_at) * 1000) + key = f"{request.method} {normalized_path} 5xx" + setup_config_stats[key] += 1 + logger.exception( + "setup_config_request_error method=%s path=%s status=%s duration_ms=%s", + request.method, + normalized_path, + 500, + duration_ms, + ) + raise + + if is_setup_config_path: + normalized_path = UUID_RE.sub("{study_id}", path) + duration_ms = int((time.perf_counter() - started_at) * 1000) + status_code = int(response.status_code) + status_bucket = f"{status_code // 100}xx" + key = f"{request.method} {normalized_path} {status_bucket}" + setup_config_stats[key] += 1 + if status_code >= 500: + logger.error( + "setup_config_request method=%s path=%s status=%s duration_ms=%s", + request.method, + normalized_path, + status_code, + duration_ms, + ) + elif status_code >= 400: + logger.warning( + "setup_config_request method=%s path=%s status=%s duration_ms=%s", + request.method, + normalized_path, + status_code, + duration_ms, + ) + else: + logger.info( + "setup_config_request method=%s path=%s status=%s duration_ms=%s", + request.method, + normalized_path, + status_code, + duration_ms, + ) + return response + register_exception_handlers(app) @app.get( @@ -111,6 +177,21 @@ def create_app() -> FastAPI: async def health() -> dict[str, str]: return {"status": "ok"} + @app.get( + "/health/setup-config-stats", + tags=["health"], + summary="立项配置接口监控统计", + description="返回当前进程内立项配置接口访问计数(按接口+状态段汇总)。", + ) + async def setup_config_health_stats() -> dict[str, dict[str, int]]: + summary: dict[str, int] = dict(setup_config_stats) + totals = { + "2xx": sum(v for k, v in summary.items() if k.endswith(" 2xx")), + "4xx": sum(v for k, v in summary.items() if k.endswith(" 4xx")), + "5xx": sum(v for k, v in summary.items() if k.endswith(" 5xx")), + } + return {"totals": totals, "by_endpoint": summary} + app.include_router(api_router, prefix="/api/v1") return app diff --git a/backend/app/models/ae.py b/backend/app/models/ae.py index b4a04f34..0cb55c69 100644 --- a/backend/app/models/ae.py +++ b/backend/app/models/ae.py @@ -25,6 +25,8 @@ class AdverseEvent(Base): action_taken: Mapped[str | None] = mapped_column(Text, nullable=True) outcome: Mapped[str | None] = mapped_column(String(50), nullable=True) reported_to_sponsor: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + is_sae: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + is_susar: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) report_due_date: Mapped[date | None] = mapped_column(Date, nullable=True) status: Mapped[str] = mapped_column(String(20), nullable=False, default="NEW") description: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/backend/app/models/milestone.py b/backend/app/models/milestone.py index 20d5f1f2..e26eca4d 100644 --- a/backend/app/models/milestone.py +++ b/backend/app/models/milestone.py @@ -20,6 +20,7 @@ class Milestone(Base): status: Mapped[str] = mapped_column(String(20), nullable=False, default="NOT_STARTED") site_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), nullable=True) owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) + owner_name: Mapped[str | None] = mapped_column(String(255), nullable=True) notes: Mapped[str | None] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) updated_at: Mapped[datetime] = mapped_column( diff --git a/backend/app/models/site.py b/backend/app/models/site.py index b489167b..0744be43 100644 --- a/backend/app/models/site.py +++ b/backend/app/models/site.py @@ -1,7 +1,7 @@ import uuid -from datetime import datetime +from datetime import date, datetime -from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, func +from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, String, Text, func from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column @@ -19,5 +19,7 @@ class Site(Base): contact: Mapped[str | None] = mapped_column(String(100), nullable=True) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true") enrollment_target: Mapped[int | None] = mapped_column(Integer, nullable=True) + enrollment_plan_start_date: Mapped[date | None] = mapped_column(Date, nullable=True) + enrollment_plan_end_date: Mapped[date | None] = mapped_column(Date, nullable=True) + enrollment_plan_note: Mapped[str | None] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) - diff --git a/backend/app/models/study.py b/backend/app/models/study.py index a2bc4ee9..cafdfd6e 100644 --- a/backend/app/models/study.py +++ b/backend/app/models/study.py @@ -1,7 +1,7 @@ import uuid -from datetime import datetime +from datetime import date, datetime -from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, func +from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, String, Text, func from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column @@ -14,8 +14,26 @@ class Study(Base): id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) code: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True) name: Mapped[str] = mapped_column(String(200), nullable=False) + project_full_name: Mapped[str | None] = mapped_column(String(500), nullable=True) sponsor: Mapped[str | None] = mapped_column(String(200), nullable=True) protocol_no: Mapped[str | None] = mapped_column(String(100), nullable=True) + lead_unit: Mapped[str | None] = mapped_column(String(255), nullable=True) + principal_investigator: Mapped[str | None] = mapped_column(String(255), nullable=True) + main_pm: Mapped[str | None] = mapped_column(String(255), nullable=True) + research_analysis: Mapped[str | None] = mapped_column(Text, nullable=True) + research_product: Mapped[str | None] = mapped_column(String(255), nullable=True) + control_product: Mapped[str | None] = mapped_column(String(255), nullable=True) + indication: Mapped[str | None] = mapped_column(String(255), nullable=True) + research_population: Mapped[str | None] = mapped_column(String(255), nullable=True) + research_design: Mapped[str | None] = mapped_column(Text, nullable=True) + plan_start_date: Mapped[date | None] = mapped_column(Date, nullable=True) + plan_end_date: Mapped[date | None] = mapped_column(Date, nullable=True) + planned_site_count: Mapped[int | None] = mapped_column(Integer, nullable=True) + planned_enrollment_count: Mapped[int | None] = mapped_column(Integer, nullable=True) + enrollment_monthly_goal_note: Mapped[str | None] = mapped_column(Text, nullable=True) + enrollment_stage_breakdown: Mapped[str | None] = mapped_column(Text, nullable=True) + summary_note: Mapped[str | None] = mapped_column(Text, nullable=True) + objective_note: Mapped[str | None] = mapped_column(Text, nullable=True) phase: Mapped[str | None] = mapped_column(String(50), nullable=True) status: Mapped[str] = mapped_column(String(20), nullable=False, default="DRAFT") is_locked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) diff --git a/backend/app/models/study_center_confirm.py b/backend/app/models/study_center_confirm.py new file mode 100644 index 00000000..dd9dd247 --- /dev/null +++ b/backend/app/models/study_center_confirm.py @@ -0,0 +1,25 @@ +import uuid +from datetime import date, datetime + +from sqlalchemy import Date, DateTime, ForeignKey, String, Text, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base_class import Base + + +class StudyCenterConfirm(Base): + __tablename__ = "study_center_confirms" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False) + site_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=False) + source_setup_item_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + confirmer: Mapped[str | None] = mapped_column(String(255), nullable=True) + confirm_status: Mapped[str] = mapped_column(String(20), nullable=False) + confirm_date: Mapped[date | None] = mapped_column(Date, nullable=True) + note: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() + ) diff --git a/backend/app/models/study_monitoring_strategy.py b/backend/app/models/study_monitoring_strategy.py new file mode 100644 index 00000000..04697f39 --- /dev/null +++ b/backend/app/models/study_monitoring_strategy.py @@ -0,0 +1,24 @@ +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base_class import Base + + +class StudyMonitoringStrategy(Base): + __tablename__ = "study_monitoring_strategies" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False) + source_setup_item_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + strategy_type: Mapped[str] = mapped_column(String(50), nullable=False) + detail: Mapped[str] = mapped_column(Text, nullable=False) + frequency: Mapped[str] = mapped_column(String(50), nullable=False) + enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() + ) diff --git a/backend/app/models/study_setup_config.py b/backend/app/models/study_setup_config.py new file mode 100644 index 00000000..bedae373 --- /dev/null +++ b/backend/app/models/study_setup_config.py @@ -0,0 +1,27 @@ +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, UniqueConstraint, func +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base_class import Base + + +class StudySetupConfig(Base): + __tablename__ = "study_setup_configs" + __table_args__ = (UniqueConstraint("study_id", name="uq_study_setup_config_study"),) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=False, index=True) + version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + config: Mapped[dict] = mapped_column(JSONB, nullable=False) + publish_status: Mapped[str] = mapped_column(default="DRAFT", nullable=False) + published_config: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + published_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) + published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + saved_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() + ) diff --git a/backend/app/models/study_setup_config_version.py b/backend/app/models/study_setup_config_version.py new file mode 100644 index 00000000..3ad6cf7a --- /dev/null +++ b/backend/app/models/study_setup_config_version.py @@ -0,0 +1,29 @@ +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, UniqueConstraint, func +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base_class import Base + + +class StudySetupConfigVersion(Base): + __tablename__ = "study_setup_config_versions" + __table_args__ = ( + UniqueConstraint("study_id", "version", name="uq_setup_config_versions_study_version"), + UniqueConstraint("study_id", "display_version", name="uq_setup_config_versions_study_display_version"), + ) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + study_setup_config_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("study_setup_configs.id"), nullable=False, index=True + ) + study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=False, index=True) + version: Mapped[int] = mapped_column(Integer, nullable=False) + display_version: Mapped[int] = mapped_column(Integer, nullable=False) + source_version: Mapped[int | None] = mapped_column(Integer, nullable=True) + config: Mapped[dict] = mapped_column(JSONB, nullable=False) + published_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) + published_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) diff --git a/backend/app/schemas/ae.py b/backend/app/schemas/ae.py index fcf5945d..c84370f8 100644 --- a/backend/app/schemas/ae.py +++ b/backend/app/schemas/ae.py @@ -24,6 +24,8 @@ class AECreate(BaseModel): causality: Optional[str] = None description: Optional[str] = None outcome: Optional[str] = None + is_sae: bool = False + is_susar: bool = False class AEUpdate(BaseModel): @@ -36,6 +38,8 @@ class AEUpdate(BaseModel): action_taken: Optional[str] = None outcome: Optional[str] = None reported_to_sponsor: Optional[bool] = None + is_sae: Optional[bool] = None + is_susar: Optional[bool] = None status: Optional[str] = None description: Optional[str] = None @@ -54,6 +58,8 @@ class AERead(BaseModel): action_taken: Optional[str] outcome: Optional[str] reported_to_sponsor: bool + is_sae: bool + is_susar: bool report_due_date: Optional[date] status: str description: Optional[str] @@ -70,6 +76,8 @@ class AERead(BaseModel): if value is None: return AESeriousness.I normalized = str(value).strip().upper() + if normalized.startswith("AESERIOUSNESS."): + normalized = normalized.split(".", 1)[1] digit_map = { "1": "I", "2": "II", diff --git a/backend/app/schemas/site.py b/backend/app/schemas/site.py index 9344513f..25e3b52b 100644 --- a/backend/app/schemas/site.py +++ b/backend/app/schemas/site.py @@ -1,5 +1,5 @@ import uuid -from datetime import datetime +from datetime import date, datetime from typing import Optional from pydantic import BaseModel, ConfigDict, Field @@ -11,7 +11,9 @@ class SiteCreate(BaseModel): pi_name: Optional[str] = None contact: Optional[str] = None is_active: bool = True - enrollment_target: Optional[int] = None + enrollment_plan_start_date: Optional[date] = None + enrollment_plan_end_date: Optional[date] = None + enrollment_plan_note: Optional[str] = None class SiteUpdate(BaseModel): @@ -20,7 +22,9 @@ class SiteUpdate(BaseModel): pi_name: Optional[str] = None contact: Optional[str] = None is_active: Optional[bool] = None - enrollment_target: Optional[int] = None + enrollment_plan_start_date: Optional[date] = None + enrollment_plan_end_date: Optional[date] = None + enrollment_plan_note: Optional[str] = None class SiteRead(BaseModel): @@ -32,6 +36,9 @@ class SiteRead(BaseModel): contact: Optional[str] is_active: bool enrollment_target: Optional[int] + enrollment_plan_start_date: Optional[date] + enrollment_plan_end_date: Optional[date] + enrollment_plan_note: Optional[str] created_at: datetime model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/study.py b/backend/app/schemas/study.py index 1955bf86..87305f9a 100644 --- a/backend/app/schemas/study.py +++ b/backend/app/schemas/study.py @@ -1,5 +1,5 @@ import uuid -from datetime import datetime +from datetime import date, datetime from typing import Literal, Optional from pydantic import BaseModel, ConfigDict, Field @@ -9,8 +9,27 @@ StudyStatus = Literal["DRAFT", "ACTIVE", "CLOSED"] class StudyCreate(BaseModel): name: str = Field(min_length=1) - code: Optional[str] = None + code: str = Field(min_length=1) + project_full_name: Optional[str] = None + sponsor: Optional[str] = None protocol_no: Optional[str] = None + lead_unit: Optional[str] = None + principal_investigator: Optional[str] = None + main_pm: Optional[str] = None + research_analysis: Optional[str] = None + research_product: Optional[str] = None + control_product: Optional[str] = None + indication: Optional[str] = None + research_population: Optional[str] = None + research_design: Optional[str] = None + plan_start_date: Optional[date] = None + plan_end_date: Optional[date] = None + planned_site_count: Optional[int] = None + planned_enrollment_count: Optional[int] = None + enrollment_monthly_goal_note: Optional[str] = None + enrollment_stage_breakdown: Optional[str] = None + summary_note: Optional[str] = None + objective_note: Optional[str] = None phase: Optional[str] = None status: StudyStatus = "DRAFT" visit_interval_days: Optional[int] = None @@ -22,7 +41,26 @@ class StudyCreate(BaseModel): class StudyUpdate(BaseModel): code: Optional[str] = None name: Optional[str] = None + project_full_name: Optional[str] = None + sponsor: Optional[str] = None protocol_no: Optional[str] = None + lead_unit: Optional[str] = None + principal_investigator: Optional[str] = None + main_pm: Optional[str] = None + research_analysis: Optional[str] = None + research_product: Optional[str] = None + control_product: Optional[str] = None + indication: Optional[str] = None + research_population: Optional[str] = None + research_design: Optional[str] = None + plan_start_date: Optional[date] = None + plan_end_date: Optional[date] = None + planned_site_count: Optional[int] = None + planned_enrollment_count: Optional[int] = None + enrollment_monthly_goal_note: Optional[str] = None + enrollment_stage_breakdown: Optional[str] = None + summary_note: Optional[str] = None + objective_note: Optional[str] = None phase: Optional[str] = None status: Optional[StudyStatus] = None is_locked: Optional[bool] = None @@ -36,7 +74,26 @@ class StudyRead(BaseModel): id: uuid.UUID code: str name: str + project_full_name: Optional[str] + sponsor: Optional[str] protocol_no: Optional[str] + lead_unit: Optional[str] + principal_investigator: Optional[str] + main_pm: Optional[str] + research_analysis: Optional[str] + research_product: Optional[str] + control_product: Optional[str] + indication: Optional[str] + research_population: Optional[str] + research_design: Optional[str] + plan_start_date: Optional[date] + plan_end_date: Optional[date] + planned_site_count: Optional[int] + planned_enrollment_count: Optional[int] + enrollment_monthly_goal_note: Optional[str] + enrollment_stage_breakdown: Optional[str] + summary_note: Optional[str] + objective_note: Optional[str] phase: Optional[str] status: StudyStatus is_locked: bool diff --git a/backend/app/schemas/study_setup_config.py b/backend/app/schemas/study_setup_config.py new file mode 100644 index 00000000..3b3dab8f --- /dev/null +++ b/backend/app/schemas/study_setup_config.py @@ -0,0 +1,137 @@ +import uuid +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + + +ConfirmStatus = Literal["待确认", "已确认", "退回"] +MilestoneStatus = Literal["未开始", "进行中", "已完成", "延期"] + + +class ProjectMilestoneItem(BaseModel): + id: str + name: str = "" + planDate: str = "" + owner: str = "" + remark: str = "" + status: MilestoneStatus = "未开始" + + +class EnrollmentPlanItem(BaseModel): + totalTarget: int = 0 + startDate: str = "" + endDate: str = "" + monthlyGoalNote: str = "" + stageBreakdown: str = "" + + +class SiteMilestoneItem(BaseModel): + id: str + milestone: str = "" + planDate: str = "" + owner: str = "" + remark: str = "" + status: MilestoneStatus = "未开始" + + +class SiteEnrollmentPlanItem(BaseModel): + id: str + siteId: str = "" + siteName: str = "" + target: int = 0 + startDate: str = "" + endDate: str = "" + note: str = "" + stageBreakdown: str = "" + + +class MonitoringStrategyItem(BaseModel): + id: str + strategyType: str = "" + detail: str = "" + frequency: str = "" + updatedAt: str = "" + enabled: bool = True + + +class CenterConfirmItem(BaseModel): + id: str + siteId: str = "" + siteName: str = "" + confirmer: str = "" + confirmStatus: ConfirmStatus = "待确认" + confirmDate: str = "" + note: str = "" + + +class StudySetupConfigData(BaseModel): + projectMilestones: list[ProjectMilestoneItem] = Field(default_factory=list) + enrollmentPlan: EnrollmentPlanItem = Field(default_factory=EnrollmentPlanItem) + siteMilestones: list[SiteMilestoneItem] = Field(default_factory=list) + siteEnrollmentPlans: list[SiteEnrollmentPlanItem] = Field(default_factory=list) + monitoringStrategies: list[MonitoringStrategyItem] = Field(default_factory=list) + centerConfirm: list[CenterConfirmItem] = Field(default_factory=list) + + +class StudySetupConfigUpsert(BaseModel): + expected_version: int | None = None + data: StudySetupConfigData + + +class StudySetupConfigPublish(BaseModel): + expected_version: int | None = None + + +class StudySetupConfigRollback(BaseModel): + expected_version: int | None = None + target_version: int + + +class StudySetupConfigVersionRead(BaseModel): + id: uuid.UUID + study_id: uuid.UUID + study_setup_config_id: uuid.UUID + version: int + display_version: int + source_version: int | None = None + config: StudySetupConfigData + published_by: uuid.UUID | None = None + published_by_name: str | None = None + published_at: datetime + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class SetupProjectionSkippedItem(BaseModel): + site_id: str + reason: str + + +class SetupProjectionSummary(BaseModel): + study_updated: bool = False + site_updated_count: int = 0 + site_skipped_count: int = 0 + warnings: list[str] = Field(default_factory=list) + skipped_items: list[SetupProjectionSkippedItem] = Field(default_factory=list) + + +class StudySetupConfigRead(BaseModel): + id: uuid.UUID + study_id: uuid.UUID + version: int + data: StudySetupConfigData + publish_status: str + published_data: StudySetupConfigData | None = None + published_by: uuid.UUID | None = None + published_by_name: str | None = None + published_at: datetime | None = None + saved_by: uuid.UUID | None + saved_by_name: str | None = None + projection_status: str | None = None + projection_summary: SetupProjectionSummary | None = None + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/subject.py b/backend/app/schemas/subject.py index d194363a..11449ba4 100644 --- a/backend/app/schemas/subject.py +++ b/backend/app/schemas/subject.py @@ -34,5 +34,7 @@ class SubjectRead(BaseModel): created_at: datetime updated_at: datetime has_ae: Optional[bool] = None + has_sae: Optional[bool] = None + has_susar: Optional[bool] = None model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/services/setup_config_excel.py b/backend/app/services/setup_config_excel.py new file mode 100644 index 00000000..7aaaedd8 --- /dev/null +++ b/backend/app/services/setup_config_excel.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +from datetime import date, datetime +from io import BytesIO +import random +from typing import Any + +from openpyxl import Workbook, load_workbook + +from app.schemas.study_setup_config import StudySetupConfigData + + +def _make_row_id() -> str: + return f"{int(datetime.now().timestamp() * 1000)}_{random.randint(1000, 9999)}" + + +def _is_empty(*values: Any) -> bool: + return all(v is None or str(v).strip() == "" for v in values) + + +def _sheet_headers(sheet, headers: list[str]) -> None: + sheet.append(headers) + for idx, title in enumerate(headers, start=1): + cell = sheet.cell(row=1, column=idx) + cell.value = title + + +def export_setup_config_excel(data: StudySetupConfigData) -> bytes: + wb = Workbook() + + ws_guide = wb.active + ws_guide.title = "说明" + ws_guide.append(["立项配置Excel模板说明"]) + ws_guide.append(["1. 各业务Sheet首行是表头,不要修改列名。"]) + ws_guide.append(["2. 可在现有行基础上新增/删除行。"]) + ws_guide.append(["3. 日期格式建议:YYYY-MM-DD。"]) + ws_guide.append(["4. 导入后系统会自动校验并回填到立项配置草稿。"]) + + ws_pm = wb.create_sheet("项目里程碑") + _sheet_headers(ws_pm, ["ID", "里程碑", "计划日期", "负责人", "状态", "备注"]) + for row in data.projectMilestones: + ws_pm.append([row.id, row.name, row.planDate, row.owner, row.status, row.remark]) + + ws_plan = wb.create_sheet("项目入组计划") + _sheet_headers(ws_plan, ["计划总入组例数", "计划开始日期", "计划结束日期", "月度目标说明", "分阶段计划"]) + p = data.enrollmentPlan + ws_plan.append([p.totalTarget, p.startDate, p.endDate, p.monthlyGoalNote, p.stageBreakdown]) + + ws_sm = wb.create_sheet("中心里程碑") + _sheet_headers(ws_sm, ["ID", "里程碑", "计划日期", "负责人", "状态", "备注"]) + for row in data.siteMilestones: + ws_sm.append([row.id, row.milestone, row.planDate, row.owner, row.status, row.remark]) + + ws_sep = wb.create_sheet("中心入组计划") + _sheet_headers(ws_sep, ["ID", "中心ID", "中心名称", "计划例数", "启动日期", "完成日期", "备注", "分阶段计划"]) + for row in data.siteEnrollmentPlans: + ws_sep.append([row.id, row.siteId, row.siteName, row.target, row.startDate, row.endDate, row.note, row.stageBreakdown]) + + ws_ms = wb.create_sheet("监查计划策略") + _sheet_headers(ws_ms, ["ID", "监查类型", "策略详情", "监查次数", "更新时间", "是否启用"]) + for row in data.monitoringStrategies: + ws_ms.append([row.id, row.strategyType, row.detail, row.frequency, row.updatedAt, "是" if row.enabled else "否"]) + + ws_cc = wb.create_sheet("中心确认") + _sheet_headers(ws_cc, ["ID", "中心ID", "中心名称", "确认人", "确认状态", "确认日期", "备注"]) + for row in data.centerConfirm: + ws_cc.append([row.id, row.siteId, row.siteName, row.confirmer, row.confirmStatus, row.confirmDate, row.note]) + + bio = BytesIO() + wb.save(bio) + return bio.getvalue() + + +def _to_int(value: Any, default: int = 0) -> int: + if value is None or str(value).strip() == "": + return default + try: + return int(float(str(value))) + except (TypeError, ValueError): + return default + + +def _to_date_str(value: Any) -> str: + if value is None: + return "" + if isinstance(value, datetime): + return value.date().isoformat() + if isinstance(value, date): + return value.isoformat() + return str(value).strip() + + +def _to_datetime_str(value: Any) -> str: + if value is None: + return "" + if isinstance(value, datetime): + return value.strftime("%Y-%m-%d %H:%M:%S") + return str(value).strip() + + +def _to_bool(value: Any, default: bool = True) -> bool: + if value is None: + return default + v = str(value).strip().lower() + if v in {"1", "true", "yes", "y", "是", "启用"}: + return True + if v in {"0", "false", "no", "n", "否", "禁用"}: + return False + return default + + +def import_setup_config_excel(file_bytes: bytes) -> StudySetupConfigData: + wb = load_workbook(filename=BytesIO(file_bytes), data_only=True) + + project_milestones = [] + ws = wb["项目里程碑"] if "项目里程碑" in wb.sheetnames else None + if ws: + for row in ws.iter_rows(min_row=2, values_only=True): + rid, name, plan_date, owner, status, remark = row[:6] + if _is_empty(rid, name, plan_date, owner, status, remark): + continue + project_milestones.append( + { + "id": str(rid).strip() if rid else _make_row_id(), + "name": str(name or "").strip(), + "planDate": _to_date_str(plan_date), + "owner": str(owner or "").strip(), + "status": str(status or "未开始").strip() or "未开始", + "remark": str(remark or "").strip(), + } + ) + + enrollment_plan = { + "totalTarget": 0, + "startDate": "", + "endDate": "", + "monthlyGoalNote": "", + "stageBreakdown": "", + } + ws = wb["项目入组计划"] if "项目入组计划" in wb.sheetnames else None + if ws: + first = next(ws.iter_rows(min_row=2, values_only=True), None) + if first: + enrollment_plan = { + "totalTarget": _to_int(first[0], 0), + "startDate": _to_date_str(first[1]), + "endDate": _to_date_str(first[2]), + "monthlyGoalNote": str(first[3] or "").strip(), + "stageBreakdown": str(first[4] or "").strip(), + } + + site_milestones = [] + ws = wb["中心里程碑"] if "中心里程碑" in wb.sheetnames else None + if ws: + for row in ws.iter_rows(min_row=2, values_only=True): + raw_status = None + if len(row) >= 8: + rid, site_id, site_name, milestone, plan_date, owner, raw_status, remark = row[:8] + _ = (site_id, site_name) + elif len(row) == 7: + # Backward compatibility: old template has no status column and includes center columns. + rid, site_id, site_name, milestone, plan_date, owner, remark = row[:7] + _ = (site_id, site_name) + else: + rid, milestone, plan_date, owner, raw_status, remark = row[:6] + if _is_empty(rid, milestone, plan_date, owner, raw_status, remark): + continue + status = str(raw_status or "未开始").strip() or "未开始" + site_milestones.append( + { + "id": str(rid).strip() if rid else _make_row_id(), + "milestone": str(milestone or "").strip(), + "planDate": _to_date_str(plan_date), + "owner": str(owner or "").strip(), + "status": status, + "remark": str(remark or "").strip(), + } + ) + + site_enrollment_plans = [] + ws = wb["中心入组计划"] if "中心入组计划" in wb.sheetnames else None + if ws: + for row in ws.iter_rows(min_row=2, values_only=True): + rid, site_id, site_name, target, start_date, end_date, note, stage_breakdown = (list(row[:8]) + [None] * 8)[:8] + if _is_empty(rid, site_id, site_name, target, start_date, end_date, note, stage_breakdown): + continue + site_enrollment_plans.append( + { + "id": str(rid).strip() if rid else _make_row_id(), + "siteId": str(site_id or "").strip(), + "siteName": str(site_name or "").strip(), + "target": _to_int(target, 0), + "startDate": _to_date_str(start_date), + "endDate": _to_date_str(end_date), + "note": str(note or "").strip(), + "stageBreakdown": str(stage_breakdown or "").strip(), + } + ) + + monitoring_strategies = [] + ws = wb["监查计划策略"] if "监查计划策略" in wb.sheetnames else None + if ws: + for row in ws.iter_rows(min_row=2, values_only=True): + rid, strategy_type, detail, frequency, updated_at, enabled = row[:6] + if _is_empty(rid, strategy_type, detail, frequency, updated_at, enabled): + continue + monitoring_strategies.append( + { + "id": str(rid).strip() if rid else _make_row_id(), + "strategyType": str(strategy_type or "").strip(), + "detail": str(detail or "").strip(), + "frequency": str(frequency or "").strip(), + "updatedAt": _to_datetime_str(updated_at) or datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "enabled": _to_bool(enabled, True), + } + ) + + center_confirm = [] + ws = wb["中心确认"] if "中心确认" in wb.sheetnames else None + if ws: + for row in ws.iter_rows(min_row=2, values_only=True): + rid, site_id, site_name, confirmer, confirm_status, confirm_date, note = row[:7] + if _is_empty(rid, site_id, site_name, confirmer, confirm_status, confirm_date, note): + continue + center_confirm.append( + { + "id": str(rid).strip() if rid else _make_row_id(), + "siteId": str(site_id or "").strip(), + "siteName": str(site_name or "").strip(), + "confirmer": str(confirmer or "").strip(), + "confirmStatus": str(confirm_status or "待确认").strip() or "待确认", + "confirmDate": _to_date_str(confirm_date), + "note": str(note or "").strip(), + } + ) + + return StudySetupConfigData.model_validate( + { + "projectMilestones": project_milestones, + "enrollmentPlan": enrollment_plan, + "siteMilestones": site_milestones, + "siteEnrollmentPlans": site_enrollment_plans, + "monitoringStrategies": monitoring_strategies, + "centerConfirm": center_confirm, + } + ) diff --git a/backend/app/services/setup_config_projection.py b/backend/app/services/setup_config_projection.py new file mode 100644 index 00000000..45181fd9 --- /dev/null +++ b/backend/app/services/setup_config_projection.py @@ -0,0 +1,387 @@ +import uuid +from datetime import date +from typing import Literal + +from pydantic import BaseModel, Field +from sqlalchemy import delete, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.milestone import Milestone +from app.models.site import Site +from app.models.study import Study +from app.models.study_center_confirm import StudyCenterConfirm +from app.models.study_monitoring_strategy import StudyMonitoringStrategy +from app.models.user import User +from app.schemas.study_setup_config import StudySetupConfigData + +PROJECT_MILESTONE_TYPE = "SETUP_PROJECT_MILESTONE" +SITE_MILESTONE_TYPE = "SETUP_SITE_MILESTONE" +MILESTONE_STATUS_MAP = { + "未开始": "NOT_STARTED", + "进行中": "IN_PROGRESS", + "已完成": "DONE", + "延期": "BLOCKED", +} + + +def _map_milestone_status(raw_status: str | None) -> str | None: + normalized = (raw_status or "").strip() + if not normalized: + return "NOT_STARTED" + return MILESTONE_STATUS_MAP.get(normalized) + + +class SetupProjectionSkippedItem(BaseModel): + site_id: str + reason: str + + +class SetupProjectionResult(BaseModel): + status: Literal["success", "partial_success", "failed"] + study_updated: bool = False + site_updated_count: int = 0 + site_skipped_count: int = 0 + warnings: list[str] = Field(default_factory=list) + skipped_items: list[SetupProjectionSkippedItem] = Field(default_factory=list) + + +def _parse_date(value: str) -> date | None: + if not value: + return None + try: + return date.fromisoformat(value) + except ValueError: + return None + + +def _normalize_text(value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip() + return normalized or None + + +async def _build_owner_lookup( + db: AsyncSession, + *, + setup_data: StudySetupConfigData, + result: SetupProjectionResult, +) -> dict[str, uuid.UUID]: + owners: set[str] = set() + for row in setup_data.projectMilestones: + owner = _normalize_text(row.owner) + if owner: + owners.add(owner) + for row in setup_data.siteMilestones: + owner = _normalize_text(row.owner) + if owner: + owners.add(owner) + + if not owners: + return {} + + users_result = await db.execute( + select(User.id, User.full_name, User.email).where( + or_(User.full_name.in_(owners), User.email.in_(owners)) + ) + ) + rows = users_result.all() + + owner_candidates: dict[str, set[uuid.UUID]] = {} + for user_id, full_name, email in rows: + full_name_key = _normalize_text(full_name) + email_key = _normalize_text(email) + if full_name_key and full_name_key in owners: + owner_candidates.setdefault(full_name_key, set()).add(user_id) + if email_key and email_key in owners: + owner_candidates.setdefault(email_key, set()).add(user_id) + + resolved: dict[str, uuid.UUID] = {} + for owner in sorted(owners): + candidates = owner_candidates.get(owner, set()) + if len(candidates) == 1: + resolved[owner] = next(iter(candidates)) + elif len(candidates) > 1: + result.warnings.append(f"milestone_owner_ambiguous:{owner}") + return resolved + + +async def _replace_project_milestones( + db: AsyncSession, + *, + study_id: uuid.UUID, + setup_data: StudySetupConfigData, + owner_lookup: dict[str, uuid.UUID], + result: SetupProjectionResult, +) -> None: + await db.execute( + delete(Milestone).where( + Milestone.study_id == study_id, + Milestone.type == PROJECT_MILESTONE_TYPE, + ) + ) + for row in setup_data.projectMilestones: + if not any([(row.name or "").strip(), (row.owner or "").strip(), (row.remark or "").strip(), (row.planDate or "").strip()]): + continue + name = (row.name or "").strip() + if not name: + result.warnings.append(f"project_milestone_name_empty:{row.id}") + continue + planned_date = _parse_date(row.planDate) + if row.planDate and not planned_date: + result.warnings.append(f"project_milestone_plan_date_invalid:{row.id}") + raw_status = (row.status or "").strip() + mapped_status = MILESTONE_STATUS_MAP.get(raw_status, "NOT_STARTED") + if raw_status and raw_status not in MILESTONE_STATUS_MAP: + result.warnings.append(f"project_milestone_status_unknown:{row.id}") + owner_name = _normalize_text(row.owner) + owner_id = owner_lookup.get(owner_name, None) if owner_name else None + db.add( + Milestone( + study_id=study_id, + type=PROJECT_MILESTONE_TYPE, + name=name, + planned_date=planned_date, + status=mapped_status, + owner_id=owner_id, + owner_name=owner_name, + notes=_normalize_text(row.remark), + ) + ) + + +async def _replace_site_milestones( + db: AsyncSession, + *, + study_id: uuid.UUID, + setup_data: StudySetupConfigData, + owner_lookup: dict[str, uuid.UUID], + result: SetupProjectionResult, +) -> None: + await db.execute( + delete(Milestone).where( + Milestone.study_id == study_id, + Milestone.type == SITE_MILESTONE_TYPE, + ) + ) + for row in setup_data.siteMilestones: + if not any( + [ + (row.milestone or "").strip(), + (row.owner or "").strip(), + (row.remark or "").strip(), + (row.planDate or "").strip(), + ] + ): + continue + milestone_name = (row.milestone or "").strip() + if not milestone_name: + result.warnings.append(f"site_milestone_name_empty:{row.id}") + continue + planned_date = _parse_date(row.planDate) + if row.planDate and not planned_date: + result.warnings.append(f"site_milestone_plan_date_invalid:{row.id}") + mapped_status = _map_milestone_status(row.status) + if mapped_status is None: + result.warnings.append(f"site_milestone_status_unknown:{row.id}") + mapped_status = "NOT_STARTED" + owner_name = _normalize_text(row.owner) + owner_id = owner_lookup.get(owner_name, None) if owner_name else None + db.add( + Milestone( + study_id=study_id, + type=SITE_MILESTONE_TYPE, + name=milestone_name, + planned_date=planned_date, + status=mapped_status, + owner_id=owner_id, + owner_name=owner_name, + notes=_normalize_text(row.remark), + ) + ) + + +async def _replace_monitoring_strategies( + db: AsyncSession, + *, + study_id: uuid.UUID, + setup_data: StudySetupConfigData, + result: SetupProjectionResult, +) -> None: + await db.execute(delete(StudyMonitoringStrategy).where(StudyMonitoringStrategy.study_id == study_id)) + for row in setup_data.monitoringStrategies: + strategy_type = _normalize_text(row.strategyType) + detail = _normalize_text(row.detail) + frequency = _normalize_text(row.frequency) + if not any([strategy_type, detail, frequency]): + continue + if not strategy_type or not detail or not frequency: + result.warnings.append(f"monitoring_strategy_incomplete:{row.id}") + continue + db.add( + StudyMonitoringStrategy( + study_id=study_id, + source_setup_item_id=_normalize_text(row.id), + strategy_type=strategy_type, + detail=detail, + frequency=frequency, + enabled=bool(row.enabled), + ) + ) + + +async def _replace_center_confirms( + db: AsyncSession, + *, + study_id: uuid.UUID, + setup_data: StudySetupConfigData, + site_map: dict[str, Site], + result: SetupProjectionResult, +) -> None: + await db.execute(delete(StudyCenterConfirm).where(StudyCenterConfirm.study_id == study_id)) + for row in setup_data.centerConfirm: + site_id = (row.siteId or "").strip() + confirmer = _normalize_text(row.confirmer) + note = _normalize_text(row.note) + raw_confirm_date = row.confirmDate or "" + parsed_confirm_date = _parse_date(raw_confirm_date) + if not any([site_id, confirmer, note, raw_confirm_date]): + continue + if not site_id: + result.skipped_items.append(SetupProjectionSkippedItem(site_id="", reason="center_confirm_site_id_empty")) + continue + site = site_map.get(site_id) + if not site: + result.skipped_items.append(SetupProjectionSkippedItem(site_id=site_id, reason="center_confirm_site_not_found")) + continue + if not site.is_active: + result.skipped_items.append(SetupProjectionSkippedItem(site_id=site_id, reason="center_confirm_site_inactive")) + continue + if raw_confirm_date and not parsed_confirm_date: + result.skipped_items.append(SetupProjectionSkippedItem(site_id=site_id, reason="center_confirm_date_invalid")) + result.warnings.append(f"center_confirm_date_invalid:{site_id}") + continue + db.add( + StudyCenterConfirm( + study_id=study_id, + site_id=site.id, + source_setup_item_id=_normalize_text(row.id), + confirmer=confirmer, + confirm_status=_normalize_text(row.confirmStatus) or "待确认", + confirm_date=parsed_confirm_date, + note=note, + ) + ) + + +async def apply_setup_projection_on_publish( + db: AsyncSession, + *, + study_id: uuid.UUID, + setup_data: StudySetupConfigData, + operator_id: uuid.UUID | None, +) -> SetupProjectionResult: + _ = operator_id + result = SetupProjectionResult(status="success") + + study_result = await db.execute(select(Study).where(Study.id == study_id)) + study = study_result.scalar_one_or_none() + if not study: + return SetupProjectionResult( + status="failed", + warnings=["study_not_found"], + site_skipped_count=0, + ) + + if study.is_locked: + result.status = "partial_success" + result.warnings.append("study_locked_skipped") + result.skipped_items.append(SetupProjectionSkippedItem(site_id=str(study_id), reason="study_locked")) + result.site_skipped_count = 1 + return result + + plan = setup_data.enrollmentPlan + new_monthly_goal_note = _normalize_text(plan.monthlyGoalNote) + new_stage_breakdown = _normalize_text(plan.stageBreakdown) + study_changed = ( + study.planned_enrollment_count != plan.totalTarget + or study.enrollment_monthly_goal_note != new_monthly_goal_note + or study.enrollment_stage_breakdown != new_stage_breakdown + ) + study.planned_enrollment_count = plan.totalTarget + study.enrollment_monthly_goal_note = new_monthly_goal_note + study.enrollment_stage_breakdown = new_stage_breakdown + result.study_updated = study_changed + + site_result = await db.execute(select(Site).where(Site.study_id == study_id)) + site_map = {str(site.id): site for site in site_result.scalars().all()} + + deduped_plans: dict[str, tuple[int, str, str, str]] = {} + duplicate_ids: set[str] = set() + for row in setup_data.siteEnrollmentPlans: + site_id = (row.siteId or "").strip() + if not site_id: + result.skipped_items.append(SetupProjectionSkippedItem(site_id="", reason="site_id_empty")) + continue + if site_id in deduped_plans: + duplicate_ids.add(site_id) + deduped_plans[site_id] = ( + row.target, + row.startDate or "", + row.endDate or "", + row.note or "", + ) + + for site_id in sorted(duplicate_ids): + result.warnings.append(f"duplicate_site_enrollment_plan:{site_id}") + + for site_id, (target, raw_start_date, raw_end_date, raw_note) in deduped_plans.items(): + site = site_map.get(site_id) + if not site: + result.skipped_items.append(SetupProjectionSkippedItem(site_id=site_id, reason="site_not_found")) + continue + if not site.is_active: + result.skipped_items.append(SetupProjectionSkippedItem(site_id=site_id, reason="site_inactive")) + continue + if target < 0: + result.skipped_items.append(SetupProjectionSkippedItem(site_id=site_id, reason="target_invalid")) + result.warnings.append(f"target_invalid:{site_id}") + continue + start_date = _parse_date(raw_start_date) + end_date = _parse_date(raw_end_date) + if raw_start_date and not start_date: + result.skipped_items.append(SetupProjectionSkippedItem(site_id=site_id, reason="site_plan_start_date_invalid")) + result.warnings.append(f"site_plan_start_date_invalid:{site_id}") + continue + if raw_end_date and not end_date: + result.skipped_items.append(SetupProjectionSkippedItem(site_id=site_id, reason="site_plan_end_date_invalid")) + result.warnings.append(f"site_plan_end_date_invalid:{site_id}") + continue + site.enrollment_target = target + site.enrollment_plan_start_date = start_date + site.enrollment_plan_end_date = end_date + site.enrollment_plan_note = _normalize_text(raw_note) + result.site_updated_count += 1 + + owner_lookup = await _build_owner_lookup(db, setup_data=setup_data, result=result) + await _replace_project_milestones( + db, + study_id=study_id, + setup_data=setup_data, + owner_lookup=owner_lookup, + result=result, + ) + await _replace_site_milestones( + db, + study_id=study_id, + setup_data=setup_data, + owner_lookup=owner_lookup, + result=result, + ) + await _replace_monitoring_strategies(db, study_id=study_id, setup_data=setup_data, result=result) + await _replace_center_confirms(db, study_id=study_id, setup_data=setup_data, site_map=site_map, result=result) + + result.site_skipped_count = len(result.skipped_items) + if result.site_skipped_count > 0 and result.status != "failed": + result.status = "partial_success" + return result diff --git a/backend/requirements.txt b/backend/requirements.txt index a1f564d0..7e3e6a59 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -14,3 +14,4 @@ pytest-asyncio==0.23.5 httpx==0.25.2 email-validator==2.1.1 alembic==1.13.1 +openpyxl==3.1.5 diff --git a/backend/scripts/smoke_setup_config.py b/backend/scripts/smoke_setup_config.py new file mode 100644 index 00000000..ed2ee91a --- /dev/null +++ b/backend/scripts/smoke_setup_config.py @@ -0,0 +1,481 @@ +import asyncio +import json +import os +import sys +import tempfile +import urllib.error +import urllib.request +import uuid + +import asyncpg + + +BASE = os.getenv("BASE_URL", "http://localhost:8000") +ADMIN_EMAIL = os.getenv("EMAIL", "admin@example.com") +ADMIN_PASSWORD = os.getenv("PASSWORD", "admin123") +STUDY_ID = os.getenv("STUDY_ID", "").strip() +DATABASE_URL = (os.getenv("DATABASE_URL") or "postgresql://ctms_user:secret_password@db/ctms_db").replace("+asyncpg", "") + + +def request_json(path: str, *, method: str = "GET", token: str | None = None, payload: dict | None = None) -> tuple[int, dict]: + url = f"{BASE}{path}" + headers = {} + body = None + if token: + headers["Authorization"] = f"Bearer {token}" + if payload is not None: + headers["Content-Type"] = "application/json" + body = json.dumps(payload).encode() + req = urllib.request.Request(url, method=method, headers=headers, data=body) + try: + with urllib.request.urlopen(req, timeout=20) as resp: + text = resp.read().decode() or "{}" + return resp.getcode(), json.loads(text) + except urllib.error.HTTPError as exc: + text = exc.read().decode() or "{}" + try: + body_json = json.loads(text) + except Exception: + body_json = {"raw": text} + return exc.code, body_json + + +def request_bytes(path: str, *, method: str = "GET", token: str | None = None) -> tuple[int, bytes]: + url = f"{BASE}{path}" + headers = {} + if token: + headers["Authorization"] = f"Bearer {token}" + req = urllib.request.Request(url, method=method, headers=headers) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return resp.getcode(), resp.read() + except urllib.error.HTTPError as exc: + return exc.code, exc.read() + + +def request_multipart( + path: str, + *, + token: str, + file_field: str, + file_name: str, + file_content: bytes, + expected_version: int, +) -> tuple[int, dict]: + boundary = f"----ctms-boundary-{uuid.uuid4().hex}" + chunks: list[bytes] = [] + chunks.append( + ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="expected_version"\r\n\r\n' + f"{expected_version}\r\n" + ).encode("utf-8") + ) + chunks.append( + ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="{file_field}"; filename="{file_name}"\r\n' + f"Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\r\n\r\n" + ).encode("utf-8") + ) + chunks.append(file_content) + chunks.append(b"\r\n") + chunks.append(f"--{boundary}--\r\n".encode("utf-8")) + body = b"".join(chunks) + + url = f"{BASE}{path}" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": f"multipart/form-data; boundary={boundary}", + } + req = urllib.request.Request(url, method="POST", headers=headers, data=body) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + text = resp.read().decode() or "{}" + return resp.getcode(), json.loads(text) + except urllib.error.HTTPError as exc: + text = exc.read().decode() or "{}" + try: + body_json = json.loads(text) + except Exception: + body_json = {"raw": text} + return exc.code, body_json + + +def assert_or_exit(condition: bool, message: str) -> None: + if not condition: + print(f"[FAIL] {message}") + sys.exit(1) + + +def extract_items(payload: dict) -> list[dict]: + if isinstance(payload, list): + return payload + items = payload.get("items") + if isinstance(items, list): + return items + return [] + + +async def _db_fetch(sql: str, *args): + conn = await asyncpg.connect(DATABASE_URL) + try: + rows = await conn.fetch(sql, *args) + return [dict(row) for row in rows] + finally: + await conn.close() + + +def db_fetch(sql: str, *args) -> list[dict]: + return asyncio.run(_db_fetch(sql, *args)) + + +def main() -> int: + print(f"[config] BASE={BASE} EMAIL={ADMIN_EMAIL}") + status, login = request_json( + "/api/v1/auth/login", + method="POST", + payload={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD}, + ) + assert_or_exit(status == 200, f"登录失败 status={status} body={login}") + token = login["access_token"] + + if STUDY_ID: + study_id = STUDY_ID + else: + status, studies = request_json("/api/v1/studies/?skip=0&limit=1", token=token) + assert_or_exit(status == 200, f"获取项目列表失败 status={status} body={studies}") + items = studies.get("items") or [] + assert_or_exit(bool(items), "未找到可用项目,请设置环境变量 STUDY_ID") + study_id = items[0]["id"] + print(f"[1/12] study_id={study_id}") + + status, study_before = request_json(f"/api/v1/studies/{study_id}", token=token) + assert_or_exit(status == 200, f"获取项目详情失败 status={status} body={study_before}") + + status, sites_resp = request_json(f"/api/v1/studies/{study_id}/sites/?include_inactive=true&skip=0&limit=200", token=token) + assert_or_exit(status == 200, f"获取中心列表失败 status={status} body={sites_resp}") + sites = extract_items(sites_resp if isinstance(sites_resp, dict) else {"items": sites_resp}) + active_sites = [item for item in sites if item.get("is_active") is True] + assert_or_exit(len(active_sites) >= 1, "至少需要1个活跃中心以验证中心目标联动") + + status, cfg = request_json(f"/api/v1/studies/{study_id}/setup-config", token=token) + assert_or_exit(status == 200, f"获取配置失败 status={status} body={cfg}") + version = cfg["version"] + print(f"[2/12] get_ok version={version}") + + payload = {"expected_version": version, "data": cfg["data"]} + payload["data"]["projectMilestones"] = [ + { + "id": "smoke-project-ms-1", + "name": "立项启动", + "planDate": "2026-02-20", + "owner": ADMIN_EMAIL, + "remark": "project-ms-remark", + "status": "进行中", + } + ] + payload["data"]["enrollmentPlan"]["monthlyGoalNote"] = f"smoke-test-note-v{version}" + payload["data"]["enrollmentPlan"]["stageBreakdown"] = f"smoke-stage-v{version}" + payload["data"]["enrollmentPlan"]["totalTarget"] = max(1, int(study_before.get("planned_enrollment_count") or 0) + 7) + payload["data"]["enrollmentPlan"]["startDate"] = "2026-03-01" + payload["data"]["enrollmentPlan"]["endDate"] = "2026-09-30" + payload["data"]["siteMilestones"] = [ + { + "id": "smoke-site-ms-1", + "siteId": active_sites[0]["id"], + "siteName": active_sites[0].get("name") or "", + "milestone": "中心启动", + "planDate": "2026-03-05", + "owner": ADMIN_EMAIL, + "remark": "site-ms-remark", + } + ] + payload["data"]["siteEnrollmentPlans"] = [ + { + "id": f"smoke-plan-{idx+1}", + "siteId": site["id"], + "siteName": site.get("name") or "", + "target": 10 if idx == 0 else 8, + "startDate": "2026-03-01", + "endDate": "2026-09-30", + "note": "smoke-site-plan", + } + for idx, site in enumerate(active_sites[:2]) + ] + payload["data"]["monitoringStrategies"] = [ + { + "id": "smoke-monitoring-1", + "strategyType": "风险监查", + "detail": "基于关键风险触发", + "frequency": "按触发", + "updatedAt": "", + "enabled": True, + } + ] + payload["data"]["centerConfirm"] = [ + { + "id": "smoke-center-confirm-1", + "siteId": active_sites[0]["id"], + "siteName": active_sites[0].get("name") or "", + "confirmer": "System Admin", + "confirmStatus": "已确认", + "confirmDate": "2026-03-15", + "note": "smoke-center-confirm", + } + ] + + targeted_site_ids = {item["siteId"] for item in payload["data"]["siteEnrollmentPlans"]} + before_site_map = {str(item.get("id")): item for item in sites if str(item.get("id")) in targeted_site_ids} + + status, updated = request_json(f"/api/v1/studies/{study_id}/setup-config", token=token, method="PUT", payload=payload) + assert_or_exit(status == 200, f"保存草稿失败 status={status} body={updated}") + latest_version = updated["version"] + print(f"[3/12] put_ok version={latest_version}") + + status, study_after_save = request_json(f"/api/v1/studies/{study_id}", token=token) + assert_or_exit(status == 200, f"保存后获取项目详情失败 status={status} body={study_after_save}") + assert_or_exit( + study_after_save.get("planned_enrollment_count") == study_before.get("planned_enrollment_count") + and study_after_save.get("plan_start_date") == study_before.get("plan_start_date") + and study_after_save.get("plan_end_date") == study_before.get("plan_end_date") + and study_after_save.get("enrollment_monthly_goal_note") == study_before.get("enrollment_monthly_goal_note") + and study_after_save.get("enrollment_stage_breakdown") == study_before.get("enrollment_stage_breakdown"), + "保存草稿不应写入项目业务表字段", + ) + + status, sites_after_save_resp = request_json( + f"/api/v1/studies/{study_id}/sites/?include_inactive=true&skip=0&limit=200", token=token + ) + assert_or_exit(status == 200, f"保存后获取中心失败 status={status} body={sites_after_save_resp}") + sites_after_save = extract_items(sites_after_save_resp if isinstance(sites_after_save_resp, dict) else {"items": sites_after_save_resp}) + after_save_site_map = {str(item.get("id")): item for item in sites_after_save if str(item.get("id")) in targeted_site_ids} + for site_id in targeted_site_ids: + before_item = before_site_map.get(site_id) + after_item = after_save_site_map.get(site_id) + if not before_item or not after_item: + continue + assert_or_exit( + after_item.get("enrollment_target") == before_item.get("enrollment_target") + and after_item.get("enrollment_plan_start_date") == before_item.get("enrollment_plan_start_date") + and after_item.get("enrollment_plan_end_date") == before_item.get("enrollment_plan_end_date") + and after_item.get("enrollment_plan_note") == before_item.get("enrollment_plan_note"), + f"保存草稿不应写入中心业务表字段 site={site_id}", + ) + print("[4/12] save_no_projection_ok") + + status, stale_result = request_json( + f"/api/v1/studies/{study_id}/setup-config", + token=token, + method="PUT", + payload={"expected_version": version, "data": updated["data"]}, + ) + stale_code = (stale_result.get("detail") or {}).get("code") if isinstance(stale_result.get("detail"), dict) else "" + assert_or_exit(status == 409 and stale_code == "SETUP_CONFIG_VERSION_CONFLICT", f"冲突校验失败 status={status} body={stale_result}") + print("[5/12] conflict_409_ok") + + invalid_payload = json.loads(json.dumps(updated)) + invalid_payload["expected_version"] = latest_version + invalid_payload["data"]["enrollmentPlan"]["startDate"] = "2026-12-31" + invalid_payload["data"]["enrollmentPlan"]["endDate"] = "2026-01-01" + status, invalid_result = request_json( + f"/api/v1/studies/{study_id}/setup-config", + token=token, + method="PUT", + payload={"expected_version": invalid_payload["expected_version"], "data": invalid_payload["data"]}, + ) + detail = invalid_result.get("detail") if isinstance(invalid_result, dict) else None + err_code = detail.get("code") if isinstance(detail, dict) else "" + err_list = detail.get("errors") if isinstance(detail, dict) else [] + has_enrollment_date_error = any((item.get("field") == "enrollmentPlan.endDate") for item in (err_list or [])) + assert_or_exit(status == 422 and err_code == "VALIDATION_ERROR" and has_enrollment_date_error, f"422校验失败 status={status} body={invalid_result}") + print("[6/12] validation_422_ok") + + status, published = request_json( + f"/api/v1/studies/{study_id}/setup-config/publish", + token=token, + method="POST", + payload={"expected_version": latest_version}, + ) + assert_or_exit(status == 200 and published.get("publish_status") == "PUBLISHED", f"发布失败 status={status} body={published}") + projection_status = str(published.get("projection_status") or "").lower() + assert_or_exit( + projection_status in {"success", "partial_success"}, + f"发布联动状态异常 projection_status={published.get('projection_status')} body={published}", + ) + first_published_version = published["version"] + print(f"[7/12] first_publish_ok version={first_published_version}") + + status, study_detail = request_json(f"/api/v1/studies/{study_id}", token=token) + assert_or_exit(status == 200, f"获取项目详情失败 status={status} body={study_detail}") + assert_or_exit( + study_detail.get("planned_enrollment_count") == payload["data"]["enrollmentPlan"]["totalTarget"], + f"项目计划入组联动失败 expected={payload['data']['enrollmentPlan']['totalTarget']} actual={study_detail.get('planned_enrollment_count')}", + ) + assert_or_exit( + study_detail.get("plan_start_date") == payload["data"]["enrollmentPlan"]["startDate"] + and study_detail.get("plan_end_date") == payload["data"]["enrollmentPlan"]["endDate"], + f"项目计划日期联动失败 expected=({payload['data']['enrollmentPlan']['startDate']},{payload['data']['enrollmentPlan']['endDate']}) actual=({study_detail.get('plan_start_date')},{study_detail.get('plan_end_date')})", + ) + assert_or_exit( + study_detail.get("enrollment_monthly_goal_note") == payload["data"]["enrollmentPlan"]["monthlyGoalNote"] + and study_detail.get("enrollment_stage_breakdown") == payload["data"]["enrollmentPlan"]["stageBreakdown"], + "项目入组补充说明联动失败", + ) + + status, sites_after_resp = request_json(f"/api/v1/studies/{study_id}/sites/?include_inactive=true&skip=0&limit=200", token=token) + assert_or_exit(status == 200, f"发布后获取中心失败 status={status} body={sites_after_resp}") + sites_after = extract_items(sites_after_resp if isinstance(sites_after_resp, dict) else {"items": sites_after_resp}) + site_target_map = {str(item.get("id")): item.get("enrollment_target") for item in sites_after} + site_plan_start_map = {str(item.get("id")): item.get("enrollment_plan_start_date") for item in sites_after} + site_plan_end_map = {str(item.get("id")): item.get("enrollment_plan_end_date") for item in sites_after} + site_plan_note_map = {str(item.get("id")): item.get("enrollment_plan_note") for item in sites_after} + for plan in payload["data"]["siteEnrollmentPlans"]: + actual_target = site_target_map.get(str(plan["siteId"])) + assert_or_exit( + actual_target == plan["target"], + f"中心目标联动失败 site={plan['siteId']} expected={plan['target']} actual={actual_target}", + ) + assert_or_exit( + site_plan_start_map.get(str(plan["siteId"])) == plan["startDate"] + and site_plan_end_map.get(str(plan["siteId"])) == plan["endDate"] + and site_plan_note_map.get(str(plan["siteId"])) == plan["note"], + f"中心入组计划附加字段联动失败 site={plan['siteId']}", + ) + + monitoring_rows = db_fetch( + """ + SELECT strategy_type, detail, frequency, enabled + FROM study_monitoring_strategies + WHERE study_id = $1::uuid + """, + study_id, + ) + assert_or_exit(len(monitoring_rows) == 1, f"监查策略联动失败 rows={monitoring_rows}") + assert_or_exit( + monitoring_rows[0]["strategy_type"] == "风险监查" + and monitoring_rows[0]["detail"] == "基于关键风险触发" + and monitoring_rows[0]["frequency"] == "按触发" + and monitoring_rows[0]["enabled"] is True, + f"监查策略字段联动失败 rows={monitoring_rows}", + ) + + center_confirm_rows = db_fetch( + """ + SELECT site_id::text AS site_id, confirmer, confirm_status, confirm_date::text AS confirm_date, note + FROM study_center_confirms + WHERE study_id = $1::uuid + """, + study_id, + ) + assert_or_exit(len(center_confirm_rows) == 1, f"中心确认联动失败 rows={center_confirm_rows}") + assert_or_exit( + center_confirm_rows[0]["site_id"] == active_sites[0]["id"] + and center_confirm_rows[0]["confirm_status"] == "已确认" + and center_confirm_rows[0]["confirm_date"] == "2026-03-15" + and center_confirm_rows[0]["note"] == "smoke-center-confirm", + f"中心确认字段联动失败 rows={center_confirm_rows}", + ) + + milestone_rows = db_fetch( + """ + SELECT type, name, owner_id::text AS owner_id, owner_name, notes + FROM milestones + WHERE study_id = $1::uuid + AND type IN ('SETUP_PROJECT_MILESTONE', 'SETUP_SITE_MILESTONE') + ORDER BY type, name + """, + study_id, + ) + milestone_map = {(row["type"], row["name"]): row for row in milestone_rows} + project_row = milestone_map.get(("SETUP_PROJECT_MILESTONE", "立项启动")) + site_row = milestone_map.get(("SETUP_SITE_MILESTONE", "中心启动")) + assert_or_exit(project_row is not None and site_row is not None, f"里程碑联动失败 rows={milestone_rows}") + assert_or_exit( + project_row.get("owner_name") == ADMIN_EMAIL + and bool(project_row.get("owner_id")) + and project_row.get("notes") == "project-ms-remark", + f"项目里程碑owner结构化失败 row={project_row}", + ) + assert_or_exit( + site_row.get("owner_name") == ADMIN_EMAIL + and bool(site_row.get("owner_id")) + and site_row.get("notes") == "site-ms-remark", + f"中心里程碑owner结构化失败 row={site_row}", + ) + print("[8/12] publish_projection_db_assert_ok") + + payload_v2 = {"expected_version": published["version"], "data": published["data"]} + payload_v2["data"]["enrollmentPlan"]["totalTarget"] = payload["data"]["enrollmentPlan"]["totalTarget"] + 3 + payload_v2["data"]["enrollmentPlan"]["monthlyGoalNote"] = payload["data"]["enrollmentPlan"]["monthlyGoalNote"] + "-v2" + if payload_v2["data"]["siteEnrollmentPlans"]: + payload_v2["data"]["siteEnrollmentPlans"][0]["target"] = payload_v2["data"]["siteEnrollmentPlans"][0]["target"] + 2 + + status, updated_v2 = request_json( + f"/api/v1/studies/{study_id}/setup-config", + token=token, + method="PUT", + payload=payload_v2, + ) + assert_or_exit(status == 200, f"二次保存草稿失败 status={status} body={updated_v2}") + status, published_v2 = request_json( + f"/api/v1/studies/{study_id}/setup-config/publish", + token=token, + method="POST", + payload={"expected_version": updated_v2["version"]}, + ) + assert_or_exit(status == 200 and published_v2.get("publish_status") == "PUBLISHED", f"二次发布失败 status={status} body={published_v2}") + + status, rolled = request_json( + f"/api/v1/studies/{study_id}/setup-config/rollback", + token=token, + method="POST", + payload={"expected_version": published_v2["version"], "target_version": first_published_version}, + ) + assert_or_exit(status == 200, f"回滚失败 status={status} body={rolled}") + assert_or_exit( + rolled.get("publish_status") == "DRAFT" + and (rolled.get("data") or {}).get("enrollmentPlan", {}).get("totalTarget") == payload["data"]["enrollmentPlan"]["totalTarget"], + f"回滚结果异常 body={rolled}", + ) + print("[9/12] rollback_ok") + + status, exported_bytes = request_bytes(f"/api/v1/studies/{study_id}/setup-config/export-excel", token=token) + assert_or_exit(status == 200 and len(exported_bytes) > 0, f"导出Excel失败 status={status} bytes={len(exported_bytes)}") + with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp: + tmp.write(exported_bytes) + tmp_path = tmp.name + print(f"[10/12] export_excel_ok file={tmp_path}") + + status, imported = request_multipart( + f"/api/v1/studies/{study_id}/setup-config/import-excel", + token=token, + file_field="file", + file_name="setup-config-smoke.xlsx", + file_content=exported_bytes, + expected_version=rolled["version"], + ) + assert_or_exit(status == 200 and isinstance(imported.get("version"), int), f"导入失败 status={status} body={imported}") + print(f"[11/12] import_excel_ok version={imported['version']}") + + status, _ = request_json(f"/api/v1/studies/{study_id}/lock", token=token, method="PATCH", payload={}) + assert_or_exit(status == 200, f"锁定失败 status={status}") + try: + status, locked_result = request_json( + f"/api/v1/studies/{study_id}/setup-config", + token=token, + method="PUT", + payload={"expected_version": imported["version"], "data": imported["data"]}, + ) + assert_or_exit(status == 403, f"锁定后写入应403,实际 status={status} body={locked_result}") + print("[12/12] lock_403_check_ok") + finally: + unlock_status, unlock_body = request_json(f"/api/v1/studies/{study_id}/unlock", token=token, method="PATCH", payload={}) + assert_or_exit(unlock_status == 200, f"解锁失败 status={unlock_status} body={unlock_body}") + + print("setup-config smoke test passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/database/init.sql b/database/init.sql index 4a40f748..ac045573 100644 --- a/database/init.sql +++ b/database/init.sql @@ -163,6 +163,8 @@ CREATE TABLE IF NOT EXISTS public.adverse_events ( action_taken text, outcome character varying(50), reported_to_sponsor boolean NOT NULL DEFAULT false, + is_sae boolean NOT NULL DEFAULT false, + is_susar boolean NOT NULL DEFAULT false, report_due_date date, status character varying(20) NOT NULL DEFAULT 'NEW', description text, diff --git a/docker-compose.yaml b/docker-compose.yaml index 6460c7b3..4aee40f4 100755 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -104,6 +104,7 @@ services: volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ./nginx/certs:/etc/nginx/certs:ro + - ./frontend/public/favicon.ico:/usr/share/nginx/html/favicon.ico:ro depends_on: - backend - frontend diff --git a/docs/postman/local.postman_environment.json b/docs/postman/local.postman_environment.json new file mode 100644 index 00000000..1d109194 --- /dev/null +++ b/docs/postman/local.postman_environment.json @@ -0,0 +1,24 @@ +{ + "id": "9f9bb57a-6de8-4e31-a7a4-e0c0e7c0d119", + "name": "CTMS Local", + "values": [ + { "key": "base_url", "value": "http://localhost", "type": "default", "enabled": true }, + { "key": "email", "value": "admin@example.com", "type": "default", "enabled": true }, + { "key": "password", "value": "admin123", "type": "secret", "enabled": true }, + { "key": "study_id", "value": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "type": "default", "enabled": true }, + { "key": "token", "value": "", "type": "default", "enabled": true }, + { "key": "setup_version", "value": "1", "type": "default", "enabled": true }, + { "key": "target_total", "value": "123", "type": "default", "enabled": true }, + { "key": "plan_start_date", "value": "2026-02-01", "type": "default", "enabled": true }, + { "key": "plan_end_date", "value": "2026-12-31", "type": "default", "enabled": true }, + { + "key": "exported_data", + "value": "{\"projectMilestones\":[],\"enrollmentPlan\":{\"totalTarget\":0,\"startDate\":\"\",\"endDate\":\"\",\"monthlyGoalNote\":\"\",\"stageBreakdown\":\"\"},\"siteMilestones\":[],\"siteEnrollmentPlans\":[],\"monitoringStrategies\":[],\"centerConfirm\":[]}", + "type": "default", + "enabled": true + } + ], + "_postman_variable_scope": "environment", + "_postman_exported_at": "2026-02-09T00:00:00.000Z", + "_postman_exported_using": "Codex" +} diff --git a/docs/postman/setup-config.postman_collection.json b/docs/postman/setup-config.postman_collection.json new file mode 100644 index 00000000..d2a758ff --- /dev/null +++ b/docs/postman/setup-config.postman_collection.json @@ -0,0 +1,203 @@ +{ + "info": { + "name": "CTMS Setup Config API (Excel)", + "description": "立项配置接口联调集合(登录 -> 获取 -> 保存 -> 发布 -> 导出Excel -> 导入Excel)", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "1. 登录(获取 Token)", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"{{email}}\",\n \"password\": \"{{password}}\"\n}" + }, + "url": { + "raw": "{{base_url}}/api/v1/auth/login", + "host": ["{{base_url}}"], + "path": ["api", "v1", "auth", "login"] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', function () { pm.response.to.have.status(200); });", + "var json = pm.response.json();", + "pm.collectionVariables.set('token', json.access_token || '');" + ] + } + } + ] + }, + { + "name": "2. 获取立项配置", + "request": { + "method": "GET", + "header": [ + { "key": "Authorization", "value": "Bearer {{token}}" } + ], + "url": { + "raw": "{{base_url}}/api/v1/studies/{{study_id}}/setup-config", + "host": ["{{base_url}}"], + "path": ["api", "v1", "studies", "{{study_id}}", "setup-config"] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', function () { pm.response.to.have.status(200); });", + "var json = pm.response.json();", + "pm.collectionVariables.set('setup_version', String(json.version || 1));" + ] + } + } + ] + }, + { + "name": "3. 保存草稿", + "request": { + "method": "PUT", + "header": [ + { "key": "Authorization", "value": "Bearer {{token}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"expected_version\": {{setup_version}},\n \"data\": {\n \"projectMilestones\": [],\n \"enrollmentPlan\": {\n \"totalTarget\": {{target_total}},\n \"startDate\": \"{{plan_start_date}}\",\n \"endDate\": \"{{plan_end_date}}\",\n \"monthlyGoalNote\": \"Postman save draft\",\n \"stageBreakdown\": \"phase-1\"\n },\n \"siteMilestones\": [],\n \"siteEnrollmentPlans\": [],\n \"monitoringStrategies\": [],\n \"centerConfirm\": []\n }\n}" + }, + "url": { + "raw": "{{base_url}}/api/v1/studies/{{study_id}}/setup-config", + "host": ["{{base_url}}"], + "path": ["api", "v1", "studies", "{{study_id}}", "setup-config"] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', function () { pm.response.to.have.status(200); });", + "var json = pm.response.json();", + "pm.collectionVariables.set('setup_version', String(json.version || pm.collectionVariables.get('setup_version') || '1'));" + ] + } + } + ] + }, + { + "name": "4. 发布配置", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{token}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"expected_version\": {{setup_version}}\n}" + }, + "url": { + "raw": "{{base_url}}/api/v1/studies/{{study_id}}/setup-config/publish", + "host": ["{{base_url}}"], + "path": ["api", "v1", "studies", "{{study_id}}", "setup-config", "publish"] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', function () { pm.response.to.have.status(200); });", + "var json = pm.response.json();", + "pm.test('publish status is PUBLISHED', function () { pm.expect(json.publish_status).to.eql('PUBLISHED'); });", + "pm.collectionVariables.set('setup_version', String(json.version || pm.collectionVariables.get('setup_version') || '1'));" + ] + } + } + ] + }, + { + "name": "5. 导出配置 Excel", + "request": { + "method": "GET", + "header": [ + { "key": "Authorization", "value": "Bearer {{token}}" } + ], + "url": { + "raw": "{{base_url}}/api/v1/studies/{{study_id}}/setup-config/export-excel", + "host": ["{{base_url}}"], + "path": ["api", "v1", "studies", "{{study_id}}", "setup-config", "export-excel"] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', function () { pm.response.to.have.status(200); });" + ] + } + } + ] + }, + { + "name": "6. 导入配置 Excel", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{token}}" } + ], + "body": { + "mode": "formdata", + "formdata": [ + { "key": "expected_version", "value": "{{setup_version}}", "type": "text" }, + { "key": "file", "type": "file", "src": "{{excel_file_path}}" } + ] + }, + "url": { + "raw": "{{base_url}}/api/v1/studies/{{study_id}}/setup-config/import-excel", + "host": ["{{base_url}}"], + "path": ["api", "v1", "studies", "{{study_id}}", "setup-config", "import-excel"] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', function () { pm.response.to.have.status(200); });", + "var json = pm.response.json();", + "pm.collectionVariables.set('setup_version', String(json.version || pm.collectionVariables.get('setup_version') || '1'));" + ] + } + } + ] + } + ], + "variable": [ + { "key": "base_url", "value": "http://localhost" }, + { "key": "email", "value": "admin@example.com" }, + { "key": "password", "value": "admin123" }, + { "key": "study_id", "value": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" }, + { "key": "token", "value": "" }, + { "key": "setup_version", "value": "1" }, + { "key": "target_total", "value": "123" }, + { "key": "plan_start_date", "value": "2026-02-01" }, + { "key": "plan_end_date", "value": "2026-12-31" }, + { "key": "excel_file_path", "value": "" } + ] +} diff --git a/docs/release-checklist.md b/docs/release-checklist.md new file mode 100644 index 00000000..0c4ee221 --- /dev/null +++ b/docs/release-checklist.md @@ -0,0 +1,64 @@ +# Release Checklist(立项配置) + +## 1. 构建与迁移 +- [ ] `cd backend && docker compose run --rm backend python -m alembic upgrade head` +- [ ] `cd backend && python3 -m compileall app scripts` +- [ ] `cd frontend && npm run build` + +## 2. 接口与权限回归 +- [ ] ADMIN 账号可 `GET/PUT/PUBLISH/IMPORT-EXCEL/EXPORT-EXCEL` +- [ ] PM 账号可 `GET/PUT/PUBLISH/IMPORT-EXCEL/EXPORT-EXCEL` +- [ ] CRA 账号可 `GET/EXPORT-EXCEL`,`PUT/PUBLISH/IMPORT-EXCEL` 返回 `403` +- [ ] 非项目成员访问返回 `403` + +## 3. 并发冲突回归 +- [ ] 双开两个窗口同项目配置 +- [ ] A 保存后,B 再保存触发 `409` +- [ ] 前端显示冲突对比弹窗(可读 diff + JSON) +- [ ] 选择“使用服务器版本”后版本同步 +- [ ] 选择“用本地草稿覆盖”后保存成功且版本递增 +- [ ] `409` 响应结构包含 `detail.code=SETUP_CONFIG_VERSION_CONFLICT` + +## 3.1 业务校验回归(422) +- [ ] `enrollmentPlan.startDate > endDate` 返回 `422` +- [ ] 非本项目 `siteId` 返回 `422` +- [ ] `centerConfirm` 状态为“已确认”但无 `confirmDate` 返回 `422` +- [ ] `422` 响应包含 `detail.errors[].field` 与 `detail.errors[].message` + +## 4. 发布快照回归 +- [ ] 草稿保存后 `publish_status=DRAFT` +- [ ] 发布后 `publish_status=PUBLISHED` +- [ ] 发布后可切换“已发布快照”并只读 +- [ ] 再修改草稿后状态回到 `DRAFT` + +## 5. 导入导出回归 +- [ ] 导出 Excel 文件可打开且结构完整 +- [ ] 导入有效 Excel 成功,页面数据刷新 +- [ ] 导入非法 Excel 给出错误提示 +- [ ] 导入冲突时触发 `409` 提示并刷新版本 + +## 6. 锁定与审计回归 +- [ ] 项目锁定后 `PUT/PUBLISH/IMPORT-EXCEL` 返回 `403` +- [ ] 页面进入只读态,编辑按钮不可用 +- [ ] 审计日志可按对象类型筛选 `立项配置` +- [ ] 审计事件包含: + - [ ] `CREATE_SETUP_CONFIG` + - [ ] `UPDATE_SETUP_CONFIG` + - [ ] `PUBLISH_SETUP_CONFIG` + - [ ] `IMPORT_SETUP_CONFIG_EXCEL` + +## 7. 监控埋点回归 +- [ ] 访问 `GET /health/setup-config-stats` 可返回 JSON +- [ ] `by_endpoint` 中可看到 `setup-config` 各接口 `2xx/4xx/5xx` 计数 +- [ ] 人为制造一次冲突或锁定写入后,`4xx` 计数增加 + +## 8. 冒烟脚本 +- [ ] `cd backend && docker compose exec -T backend python scripts/smoke_setup_config.py` +- [ ] 使用 `BASE_URL/EMAIL/PASSWORD/STUDY_ID` 环境变量执行一次(覆盖指定项目) +- [ ] `BASE_URL=http://localhost EMAIL=... PASSWORD=... STUDY_ID=... bash docs/setup-config-curl-smoke.sh` +- [ ] 导入并执行 Postman 集合 `docs/postman/setup-config.postman_collection.json` + +## 9. 发布前确认 +- [ ] 文档已更新:`docs/setup-config-api.md` +- [ ] 本清单已全量勾选 +- [ ] 发布说明已包含新增接口与权限规则 diff --git a/docs/setup-config-api.md b/docs/setup-config-api.md new file mode 100644 index 00000000..77b2839c --- /dev/null +++ b/docs/setup-config-api.md @@ -0,0 +1,180 @@ +# 立项配置接口说明(Excel 导入导出版) + +## 1. 概览 +- 基础路径: `/api/v1/studies/{study_id}/setup-config` +- 数据作用域: 单项目唯一配置(`study_setup_configs.study_id` 唯一约束) +- 存储格式: JSONB(字段结构与前端 `SetupConfigDraft` 对齐) +- 并发控制: 乐观锁(`expected_version`) +- 发布机制: 草稿/已发布(`publish_status`) +- 前端视图语义: + - `草稿视图`: 当前草稿可编辑 + - `发布预览`: 当前草稿只读预览(不直接读取 `published_data`) + - `published_data`: 用于发布快照、差异比较与版本能力 + +## 2. 权限矩阵 +- `GET /setup-config` + - 允许: 项目成员、管理员 +- `PUT /setup-config` + - 允许: PM、ADMIN + - 限制: 项目锁定时返回 `403` +- `POST /setup-config/publish` + - 允许: PM、ADMIN + - 限制: 项目锁定时返回 `403` +- `GET /setup-config/export-excel` + - 允许: 项目成员、管理员 +- `POST /setup-config/import-excel` + - 允许: PM、ADMIN + - 限制: 项目锁定时返回 `403` + +## 3. 数据结构 + +### 3.1 请求体(保存) +```json +{ + "expected_version": 3, + "data": { + "projectMilestones": [ + { + "id": "m1", + "name": "首例入组", + "planDate": "2026-03-01", + "owner": "项目经理", + "remark": "", + "status": "未开始" + } + ], + "enrollmentPlan": { + "totalTarget": 100, + "startDate": "2026-02-01", + "endDate": "2026-12-31", + "monthlyGoalNote": "按月拆解", + "stageBreakdown": "阶段1/2/3" + }, + "siteMilestones": [], + "siteEnrollmentPlans": [], + "monitoringStrategies": [], + "centerConfirm": [] + } +} +``` + +### 3.2 响应体(GET/PUT/PUBLISH/IMPORT-EXCEL) +```json +{ + "id": "f0ac2fca-5fd3-470e-8e7e-2ded2b8f0f77", + "study_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "version": 6, + "publish_status": "PUBLISHED", + "data": { + "projectMilestones": [], + "enrollmentPlan": { + "totalTarget": 120, + "startDate": "2026-02-01", + "endDate": "2026-12-31", + "monthlyGoalNote": "", + "stageBreakdown": "" + }, + "siteMilestones": [], + "siteEnrollmentPlans": [], + "monitoringStrategies": [], + "centerConfirm": [] + }, + "published_data": null, + "saved_by": "11111111-1111-1111-1111-111111111111", + "saved_by_name": "System Admin", + "published_by": null, + "published_by_name": null, + "published_at": null, + "created_at": "2026-02-09T01:30:00.000000Z", + "updated_at": "2026-02-09T02:11:13.000000Z" +} +``` + +## 4. 接口定义 + +### 4.1 GET 获取配置 +```http +GET /api/v1/studies/{study_id}/setup-config +Authorization: Bearer +``` + +### 4.2 PUT 保存配置(草稿) +```http +PUT /api/v1/studies/{study_id}/setup-config +Authorization: Bearer +Content-Type: application/json +``` + +### 4.3 POST 发布配置 +```http +POST /api/v1/studies/{study_id}/setup-config/publish +Authorization: Bearer +Content-Type: application/json +``` + +### 4.4 GET 导出 Excel +```http +GET /api/v1/studies/{study_id}/setup-config/export-excel +Authorization: Bearer +``` +说明: +- 返回 `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` +- 文件可直接作为导入模板继续回灌 + +### 4.5 POST 导入 Excel +```http +POST /api/v1/studies/{study_id}/setup-config/import-excel +Authorization: Bearer +Content-Type: multipart/form-data +``` +表单字段: +- `file`: `.xlsx` 文件(必填) +- `expected_version`: 当前配置版本(可选,建议携带) + +说明: +- 导入会覆盖当前草稿并自增版本。 +- 写入审计事件: `IMPORT_SETUP_CONFIG_EXCEL`。 + +## 5. 错误码与典型场景 +- `400 BAD REQUEST` + - 上传文件非 `.xlsx` + - Excel 解析失败 +- `403 FORBIDDEN` + - 非 PM/ADMIN 导入/保存/发布 + - 项目已锁定 +- `404 NOT FOUND` + - study 不存在 +- `409 CONFLICT` + - `expected_version` 与服务端不一致 +- `422 UNPROCESSABLE ENTITY` + - Excel 数据通过解析后,业务字段校验失败 + +## 6. 联调脚本(curl) +- 脚本路径: `docs/setup-config-curl-smoke.sh` +- 使用方式: +```bash +chmod +x docs/setup-config-curl-smoke.sh +BASE_URL=http://localhost \ +EMAIL=admin@example.com \ +PASSWORD=admin123 \ +STUDY_ID=aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa \ +bash docs/setup-config-curl-smoke.sh +``` + +## 7. Postman 联调集合 +- Collection: `docs/postman/setup-config.postman_collection.json` +- Environment: `docs/postman/local.postman_environment.json` + +## 8. 后端烟雾测试(非 pytest) +```bash +docker compose up -d db backend +docker compose exec -T backend python scripts/smoke_setup_config.py +``` + +## 9. 监控埋点(4xx/5xx) +- 中间件统计以下接口: + - `GET /api/v1/studies/{study_id}/setup-config` + - `PUT /api/v1/studies/{study_id}/setup-config` + - `POST /api/v1/studies/{study_id}/setup-config/publish` + - `GET /api/v1/studies/{study_id}/setup-config/export-excel` + - `POST /api/v1/studies/{study_id}/setup-config/import-excel` diff --git a/docs/setup-config-code-audit.md b/docs/setup-config-code-audit.md new file mode 100644 index 00000000..16c27c25 --- /dev/null +++ b/docs/setup-config-code-audit.md @@ -0,0 +1,74 @@ +# 立项配置代码审计报告(2026-02) + +## 范围 +- 前端:`frontend/src/views/admin/ProjectDetail.vue`、`frontend/src/api/studies.ts`、`frontend/src/types/setupConfig.ts`、`frontend/src/utils/setupFieldLocator.ts` +- 后端:`backend/app/api/v1/studies.py`、`backend/app/crud/study_setup_config.py`、`backend/app/schemas/study_setup_config.py`、`backend/app/services/setup_config_excel.py` +- 文档:`docs/setup-config-api.md`、`docs/setup-config-curl-smoke.sh`、`docs/postman/setup-config.postman_collection.json`、`backend/scripts/smoke_setup_config.py` + +## 发现清单 + +### 严重(会导致错误或明显行为偏差) +- 无新增严重问题。 + +### 中等(会导致交互不一致或维护风险) +1. `ProjectDetail.vue` 中存在大量 `v-if="false"` 历史分支(Step4-7),增加阅读成本并容易引发误改。 +2. 步骤表格从“输入态表格”转“展示态 + 抽屉编辑”后,存在未清理的旧方法与旧样式残留,导致逻辑路径复杂。 +3. “发布预览”语义已切换为“当前草稿只读预览”,但文档未明确,容易与后端 `published_data` 概念混淆。 + +### 低(代码异味/重复/可读性) +1. `ProjectDetail.vue` 文件过长(约 3800+ 行),后续改动冲突概率高。 +2. 多个步骤编辑抽屉逻辑重复,适合后续抽象为通用 `RowEditorDrawer`。 +3. 表格列模板重复,适合后续改为列配置驱动。 + +## 本轮已处理 +1. 删除 Step4-7 全部 `v-if="false"` 模板分支。 +2. 删除上述分支遗留的无用方法: + - `handleSiteMilestoneSiteChange` + - `handleSiteEnrollmentSiteChange` + - `handleCenterConfirmSiteChange` + - `touchStrategy` + - `applyStrategy` +3. 清理无用样式: + - `.section-toolbar` + - `.setup-content.is-published-preview .section-toolbar` 相关规则 +4. 保持现有交互行为不变并完成构建验证。 +5. 修复步骤标题行按钮保护不一致问题: + - 第2/4/5/6/7步标题行功能键增加 `:disabled="!canEditSetup"`,避免锁定项目或无权限时误触发。 + - 对应新增/删除/抽屉打开与保存方法统一加守卫,防止外部调用绕过前端可见状态。 +6. 收敛重复的草稿可编辑判定,新增 `canMutateDraft()` 供步骤行编辑方法复用(不改变界面行为)。 +7. 抽取步骤行编辑通用控制器(`IndexedEditorController`)与通用函数: + - `openIndexedEditor` + - `getEditingIndex` + - `closeIndexedEditor` + 复用到 Step2/4/5/6/7 的单行抽屉编辑逻辑,减少重复代码。 +8. 合并重复 `onMounted` 注册逻辑,统一在单一入口完成 `loadProject` 与 `beforeunload` 监听初始化。 +9. 新增 `frontend/src/composables/useSetupConfig.ts`: + - 将 setup-config 相关 API 编排从页面中收口到 composable(获取/保存/发布/版本列表/回滚/版本删除/Excel 导入导出)。 + - `ProjectDetail.vue` 改为依赖 composable 暴露的方法,降低页面对 API 层的直接耦合。 +10. 新增 `frontend/src/utils/setupDiffRows.ts`: + - 将“步骤差异明细”计算与字段路径可读化逻辑从 `ProjectDetail.vue` 抽离为工具函数。 + - 页面保留项目主字段差异拼装,步骤差异统一由工具输出,降低页面认知负担。 +11. 将步骤标题行按钮显隐规则收敛为 `stepActionMode` 计算属性: + - 去除模板中多段 `activeStep === n && !isPublishedView` 分支。 + - 保持按钮行为与权限控制不变,降低后续步骤扩展的修改面。 + +## 验证结果 +1. `frontend npm run build` 通过。 +2. 后端 setup-config 核心文件通过 AST 语法解析检查。 +3. 当前环境 `npx tsc --noEmit` 失败主要来自项目既有依赖类型版本不匹配(`vue`/`element-plus`/`csstype`),非本轮立项配置改动引入。 + +## 一致性核对结果 +1. 前后端 setup-config 路由一致: + - `GET/PUT /setup-config` + - `POST /setup-config/publish` + - `GET /setup-config/versions` + - `POST /setup-config/rollback` + - `DELETE /setup-config/versions/{target_version}` + - `GET /setup-config/export-excel` + - `POST /setup-config/import-excel` +2. 前端 `setupConfig` 类型与后端 schema 字段一致(`published_data` 仍保留用于发布快照与差异比较)。 + +## 下一步建议(不改行为) +1. 拆分 `ProjectDetail.vue` 为步骤子组件(先拆 Step4-7)。 +2. 抽象通用行编辑抽屉组件,统一校验和保存流程。 +3. 使用列配置对象复用 `el-table-column` 渲染,降低重复模板。 diff --git a/docs/setup-config-curl-smoke.sh b/docs/setup-config-curl-smoke.sh new file mode 100755 index 00000000..e38fc104 --- /dev/null +++ b/docs/setup-config-curl-smoke.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="${BASE_URL:-http://localhost}" +EMAIL="${EMAIL:-admin@example.com}" +PASSWORD="${PASSWORD:-admin123}" +STUDY_ID="${STUDY_ID:-aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa}" + +echo "[1/6] login: $EMAIL" +TOKEN=$(curl -sS -X POST "$BASE_URL/api/v1/auth/login" \ + -H 'Content-Type: application/json' \ + -d "{\"email\":\"$EMAIL\",\"password\":\"$PASSWORD\"}" | \ + python3 -c 'import json,sys; print(json.load(sys.stdin).get("access_token",""))') + +if [[ -z "$TOKEN" ]]; then + echo "login failed: access_token empty" + exit 1 +fi + +AUTH=(-H "Authorization: Bearer $TOKEN") + +echo "[2/6] get setup-config" +GET_RESP=$(curl -sS "$BASE_URL/api/v1/studies/$STUDY_ID/setup-config" "${AUTH[@]}") +VERSION=$(echo "$GET_RESP" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("version",""))') +if [[ -z "$VERSION" ]]; then + echo "get setup-config failed" + echo "$GET_RESP" + exit 1 +fi +echo "version=$VERSION" + +echo "[3/6] save draft" +PUT_PAYLOAD=$(cat < + CTMS - - + +
diff --git a/frontend/index.html b/frontend/index.html index db5f5b13..a7b9c97b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,6 +3,7 @@ + CTMS diff --git a/frontend/src/api/axios.ts b/frontend/src/api/axios.ts index 7d5a29da..8624d845 100644 --- a/frontend/src/api/axios.ts +++ b/frontend/src/api/axios.ts @@ -16,6 +16,7 @@ const instance: AxiosInstance = axios.create({ export type ApiRequestConfig = AxiosRequestConfig & { ignoreIdle?: boolean; allowWhileLocked?: boolean; + suppressErrorMessage?: boolean; _retry?: boolean; }; @@ -52,7 +53,10 @@ instance.interceptors.response.use( (response: AxiosResponse) => response, async (error: AxiosError) => { if (error instanceof LockedError) { - ElMessage.warning(TEXT.common.messages.sessionLocked || "请解锁后继续操作"); + const lockedConfig = error.config as ApiRequestConfig | undefined; + if (!lockedConfig?.suppressErrorMessage) { + ElMessage.warning(TEXT.common.messages.sessionLocked || "请解锁后继续操作"); + } return Promise.reject(error); } const status = error.response?.status; @@ -93,10 +97,18 @@ instance.interceptors.response.use( } } else if (status === 403 && (data as any)?.detail === "账号已停用") { forceLogout(); - } else if (data?.message || (data as any)?.detail) { - ElMessage.error((data as any)?.message || (data as any)?.detail); } else { - ElMessage.error(TEXT.common.messages.requestFailed); + const suppressErrorMessage = (error.config as ApiRequestConfig | undefined)?.suppressErrorMessage; + if (!suppressErrorMessage) { + if (data?.message || (data as any)?.detail) { + const detail = (data as any)?.detail; + const detailMessage = + typeof detail === "string" ? detail : typeof detail?.message === "string" ? detail.message : undefined; + ElMessage.error((data as any)?.message || detailMessage || TEXT.common.messages.requestFailed); + } else { + ElMessage.error(TEXT.common.messages.requestFailed); + } + } } return Promise.reject(error); } @@ -107,6 +119,8 @@ export const apiPost = (url: string, data?: unknown, config?: ApiRe instance.post(url, data, config); export const apiPatch = (url: string, data?: unknown, config?: ApiRequestConfig) => instance.patch(url, data, config); +export const apiPut = (url: string, data?: unknown, config?: ApiRequestConfig) => + instance.put(url, data, config); export const apiDelete = (url: string, config?: ApiRequestConfig) => instance.delete(url, config); diff --git a/frontend/src/api/studies.ts b/frontend/src/api/studies.ts index 356daaec..7ef6f0cd 100644 --- a/frontend/src/api/studies.ts +++ b/frontend/src/api/studies.ts @@ -1,5 +1,12 @@ -import { apiGet, apiPatch, apiPost, apiDelete } from "./axios"; +import { apiGet, apiPatch, apiPost, apiDelete, apiPut } from "./axios"; import type { ApiListResponse, Study } from "../types/api"; +import type { + StudySetupConfigPublishPayload, + StudySetupConfigResponse, + StudySetupConfigRollbackPayload, + StudySetupConfigUpsertPayload, + StudySetupConfigVersionItem, +} from "../types/setupConfig"; // Backend expects trailing slash to avoid 307 redirect export const fetchStudies = () => apiGet>("/api/v1/studies/"); @@ -18,3 +25,26 @@ export const deleteStudy = (studyId: string) => export const lockStudy = (studyId: string) => apiPatch(`/api/v1/studies/${studyId}/lock`, {}); +export const fetchStudySetupConfig = (studyId: string) => + apiGet(`/api/v1/studies/${studyId}/setup-config`, { suppressErrorMessage: true }); + +export const saveStudySetupConfig = (studyId: string, payload: StudySetupConfigUpsertPayload) => + apiPut(`/api/v1/studies/${studyId}/setup-config`, payload, { suppressErrorMessage: true }); + +export const publishStudySetupConfig = (studyId: string, payload: StudySetupConfigPublishPayload) => + apiPost(`/api/v1/studies/${studyId}/setup-config/publish`, payload, { suppressErrorMessage: true }); + +export const fetchStudySetupConfigVersions = (studyId: string) => + apiGet(`/api/v1/studies/${studyId}/setup-config/versions`, { suppressErrorMessage: true }); + +export const deleteStudySetupConfigVersion = (studyId: string, targetVersion: number) => + apiDelete(`/api/v1/studies/${studyId}/setup-config/versions/${targetVersion}`, { suppressErrorMessage: true }); + +export const rollbackStudySetupConfig = (studyId: string, payload: StudySetupConfigRollbackPayload) => + apiPost(`/api/v1/studies/${studyId}/setup-config/rollback`, payload, { suppressErrorMessage: true }); + +export const exportStudySetupConfigExcel = (studyId: string) => + apiGet(`/api/v1/studies/${studyId}/setup-config/export-excel`, { responseType: "blob", suppressErrorMessage: true }); + +export const importStudySetupConfigExcel = (studyId: string, payload: FormData) => + apiPost(`/api/v1/studies/${studyId}/setup-config/import-excel`, payload, { suppressErrorMessage: true }); diff --git a/frontend/src/components/Layout.vue b/frontend/src/components/Layout.vue index def33883..1540465b 100644 --- a/frontend/src/components/Layout.vue +++ b/frontend/src/components/Layout.vue @@ -39,6 +39,10 @@ {{ TEXT.menu.projectOverview }} + + + {{ TEXT.menu.projectMilestones }} +