立项配置页初步优化

This commit is contained in:
Cheng Zhou
2026-02-24 16:53:22 +08:00
parent 0693f09b1d
commit 8f3f717e48
124 changed files with 12519 additions and 2954 deletions
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
+775 -2
View File
@@ -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)
+28 -6
View File
@@ -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
+20
View File
@@ -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:
+11 -3
View File
@@ -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:
+6 -2
View File
@@ -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
+5
View File
@@ -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))
+37 -19
View File
@@ -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()
+171
View File
@@ -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()
+37
View File
@@ -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)
+4
View File
@@ -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
+81
View File
@@ -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
+2
View File
@@ -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)
+1
View File
@@ -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(
+5 -3
View File
@@ -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())
+20 -2
View File
@@ -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)
@@ -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()
)
@@ -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()
)
+27
View File
@@ -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()
)
@@ -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())
+8
View File
@@ -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",
+10 -3
View File
@@ -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)
+59 -2
View File
@@ -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
+137
View File
@@ -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)
+2
View File
@@ -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)
+246
View File
@@ -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,
}
)
@@ -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
+1
View File
@@ -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
+481
View File
@@ -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())