立项配置页初步优化
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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())
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user