Files
ctms/backend/app/api/v1/studies.py
T
2026-05-11 11:03:06 +08:00

1586 lines
66 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import uuid
import re
from datetime import date, datetime
from collections import Counter
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
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.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 (
ProjectPublishSnapshot,
SetupProjectionSummary,
StudySetupConfigCheckoutBranch,
StudySetupConfigData,
StudySetupConfigDraftAction,
StudySetupConfigMergeToMain,
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,
strict_required: bool = True,
) -> 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}]"
start_text = (row.startDate or row.planDate or "").strip()
end_text = (row.endDate or row.planDate or "").strip()
if not row.id:
errors.append({"field": f"{row_prefix}.id", "message": "ID不能为空"})
if _is_empty_row([row.name, row.owner, row.remark, start_text, end_text]):
continue
if not row.name.strip():
errors.append({"field": f"{row_prefix}.name", "message": "里程碑名称不能为空"})
start = _parse_date_str(start_text, f"{row_prefix}.startDate", errors)
end = _parse_date_str(end_text, f"{row_prefix}.endDate", errors)
if not start_text:
errors.append({"field": f"{row_prefix}.startDate", "message": "请填写开始日期"})
if not end_text:
errors.append({"field": f"{row_prefix}.endDate", "message": "请填写结束日期"})
if start and end and start > end:
errors.append({"field": f"{row_prefix}.endDate", "message": "结束日期不能早于开始日期"})
if row.durationDays < 1:
errors.append({"field": f"{row_prefix}.durationDays", "message": "耗时天数不能小于1"})
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 strict_required and not plan.startDate:
errors.append({"field": "enrollmentPlan.startDate", "message": "请填写计划开始日期"})
if strict_required and 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 strict_required and not row.startDate:
errors.append({"field": f"{row_prefix}.startDate", "message": "请填写启动日期"})
if strict_required and 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(
projectInfo=_build_project_publish_snapshot(study),
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_date_text(value: date | None) -> str:
if not value:
return ""
return value.isoformat()
def _build_project_publish_snapshot(study) -> ProjectPublishSnapshot:
return ProjectPublishSnapshot(
code=getattr(study, "code", None) or "",
name=getattr(study, "name", None) or "",
project_full_name=getattr(study, "project_full_name", None) or "",
sponsor=getattr(study, "sponsor", None) or "",
protocol_no=getattr(study, "protocol_no", None) or "",
lead_unit=getattr(study, "lead_unit", None) or "",
principal_investigator=getattr(study, "principal_investigator", None) or "",
main_pm=getattr(study, "main_pm", None) or "",
research_analysis=getattr(study, "research_analysis", None) or "",
research_product=getattr(study, "research_product", None) or "",
control_product=getattr(study, "control_product", None) or "",
indication=getattr(study, "indication", None) or "",
research_population=getattr(study, "research_population", None) or "",
research_design=getattr(study, "research_design", None) or "",
plan_start_date=_to_date_text(getattr(study, "plan_start_date", None)),
plan_end_date=_to_date_text(getattr(study, "plan_end_date", None)),
planned_site_count=getattr(study, "planned_site_count", None),
planned_enrollment_count=getattr(study, "planned_enrollment_count", None),
status=getattr(study, "status", None) or "",
visit_schedule=getattr(study, "visit_schedule", None) or [],
)
def _resolve_setup_project_snapshot(setup_data: StudySetupConfigData, study) -> ProjectPublishSnapshot:
return setup_data.projectInfo
def _parse_optional_snapshot_date(value: str) -> date | None:
text = (value or "").strip()
if not text:
return None
return date.fromisoformat(text)
def _normalize_optional_snapshot_text(value: str | None) -> str | None:
if value is None:
return None
text = value.strip()
return text or None
def _snapshot_visit_schedule(snapshot: ProjectPublishSnapshot) -> list[dict[str, int | str]]:
return [item.model_dump(mode="json") for item in snapshot.visit_schedule]
def _apply_project_publish_snapshot_to_study(study, snapshot: ProjectPublishSnapshot) -> bool:
next_values = {
"code": snapshot.code.strip(),
"name": snapshot.name.strip(),
"project_full_name": _normalize_optional_snapshot_text(snapshot.project_full_name),
"sponsor": _normalize_optional_snapshot_text(snapshot.sponsor),
"protocol_no": _normalize_optional_snapshot_text(snapshot.protocol_no),
"lead_unit": _normalize_optional_snapshot_text(snapshot.lead_unit),
"principal_investigator": _normalize_optional_snapshot_text(snapshot.principal_investigator),
"main_pm": _normalize_optional_snapshot_text(snapshot.main_pm),
"research_analysis": _normalize_optional_snapshot_text(snapshot.research_analysis),
"research_product": _normalize_optional_snapshot_text(snapshot.research_product),
"control_product": _normalize_optional_snapshot_text(snapshot.control_product),
"indication": _normalize_optional_snapshot_text(snapshot.indication),
"research_population": _normalize_optional_snapshot_text(snapshot.research_population),
"research_design": _normalize_optional_snapshot_text(snapshot.research_design),
"plan_start_date": _parse_optional_snapshot_date(snapshot.plan_start_date),
"plan_end_date": _parse_optional_snapshot_date(snapshot.plan_end_date),
"planned_site_count": snapshot.planned_site_count,
"planned_enrollment_count": snapshot.planned_enrollment_count,
"status": snapshot.status or "DRAFT",
"visit_schedule": _snapshot_visit_schedule(snapshot),
}
changed = False
for field, value in next_values.items():
if getattr(study, field, None) != value:
setattr(study, field, value)
changed = True
return changed
def _to_setup_config_read(
record,
*,
saved_by_name: str | None = None,
published_by_name: str | None = None,
current_published_version_label: str | None = None,
active_branch_base_version_label: 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,
current_branch_name=(getattr(record, "current_branch_name", None) or "main"),
current_published_version_id=getattr(record, "current_published_version_id", None),
current_published_version_label=current_published_version_label,
active_branch_base_version_id=getattr(record, "active_branch_base_version_id", None),
active_branch_base_version_label=active_branch_base_version_label,
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_project_snapshot=(
ProjectPublishSnapshot.model_validate(record.published_project_snapshot)
if record.published_project_snapshot
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 "无"
projection_status_label = "成功" if projection_status == "success" else "失败" if projection_status == "failed" else projection_status
return (
"立项配置已发布"
f"; 发布同步结果:{projection_status_label}"
f"; 已同步项目主信息:{'是' if study_updated else '否'}"
f"; 已同步中心数:{site_updated_count}"
f"; 未同步中心数:{site_skipped_count}"
f"; 同步告警:{warning_part}"
f"; 未同步原因统计:{reason_summary}"
)
def _ensure_study_timeline_valid(
*,
plan_start_date: date | None,
plan_end_date: date | 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="项目计划结束日期不能早于开始日期")
async def _validate_setup_project_snapshot(
db: AsyncSession,
*,
study_id: uuid.UUID,
snapshot: ProjectPublishSnapshot,
strict_required: bool = True,
) -> None:
errors: list[dict[str, str]] = []
code = snapshot.code.strip()
name = snapshot.name.strip()
if strict_required and not code:
errors.append({"field": "projectInfo.code", "message": "项目编号不能为空"})
if strict_required and not name:
errors.append({"field": "projectInfo.name", "message": "项目名称不能为空"})
try:
plan_start = _parse_optional_snapshot_date(snapshot.plan_start_date)
plan_end = _parse_optional_snapshot_date(snapshot.plan_end_date)
except ValueError:
errors.append({"field": "projectInfo.plan_start_date", "message": "项目计划日期格式应为 YYYY-MM-DD"})
plan_start = None
plan_end = None
if plan_start and plan_end and plan_start > plan_end:
errors.append({"field": "projectInfo.plan_end_date", "message": "项目计划结束日期不能早于开始日期"})
seen_visit_codes: set[str] = set()
for index, item in enumerate(snapshot.visit_schedule):
row_prefix = f"projectInfo.visit_schedule[{index}]"
visit_code = item.visit_code.strip()
has_visit_values = bool(visit_code) or any(
value != 0
for value in (item.baseline_offset_days, item.window_before_days, item.window_after_days)
)
if strict_required and not visit_code:
errors.append({"field": f"{row_prefix}.visit_code", "message": "访视编号不能为空"})
if not has_visit_values:
continue
if visit_code:
if len(visit_code) > 50:
errors.append({"field": f"{row_prefix}.visit_code", "message": "访视编号不能超过50个字符"})
if visit_code in seen_visit_codes:
errors.append({"field": f"{row_prefix}.visit_code", "message": f"访视编号重复:{visit_code}"})
seen_visit_codes.add(visit_code)
if item.baseline_offset_days < 0 or item.baseline_offset_days > 3650:
errors.append({"field": f"{row_prefix}.baseline_offset_days", "message": "基线偏移天数应在0到3650之间"})
if item.window_before_days < 0 or item.window_before_days > 365:
errors.append({"field": f"{row_prefix}.window_before_days", "message": "窗口前天数应在0到365之间"})
if item.window_after_days < 0 or item.window_after_days > 365:
errors.append({"field": f"{row_prefix}.window_after_days", "message": "窗口后天数应在0到365之间"})
if errors:
_raise_validation_error(errors)
if code:
existing = await study_crud.get_by_code(db, code)
if existing and existing.id != study_id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="项目编号已存在")
def _resolve_version_label(record: Any) -> str:
branch_name = (getattr(record, "branch_name", None) or "main").strip() or "main"
display_version = int(getattr(record, "display_version", 0) or 0)
branch_seq = int(getattr(record, "branch_seq", 1) or 1)
if branch_name == "main":
return getattr(record, "version_label", None) or f"v{display_version}"
return getattr(record, "version_label", None) or f"v{display_version}.{branch_seq}"
def _to_setup_config_version_read(
record,
*,
published_by_name: str | None = None,
is_current_published: bool = False,
is_active_draft_base: bool = False,
) -> 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,
branch_name=(record.branch_name or "main"),
branch_seq=record.branch_seq or 1,
version_label=_resolve_version_label(record),
source_version=record.source_version,
parent_version_id=record.parent_version_id,
merged_from_version_id=record.merged_from_version_id,
config=StudySetupConfigData.model_validate(record.config or {}),
published_project_snapshot=(
ProjectPublishSnapshot.model_validate(record.published_project_snapshot)
if record.published_project_snapshot
else None
),
is_current_published=is_current_published,
is_active_draft_base=is_active_draft_base,
published_by=record.published_by,
published_by_name=published_by_name,
published_at=record.published_at,
created_at=record.created_at,
)
def _resolve_current_published_snapshot_version(
versions: list[Any],
setup_record: Any | None,
) -> int | None:
if not setup_record:
return None
current_published_snapshot_id = getattr(setup_record, "current_published_version_id", None)
if current_published_snapshot_id:
matched = next((item for item in versions if item.id == current_published_snapshot_id), None)
if matched:
return matched.version
published_config = setup_record.published_config or {}
if not published_config:
return None
best_score = -1
best_version: int | None = None
for item in versions:
if (item.config or {}) != published_config:
continue
score = 1
if setup_record.published_at and item.published_at == setup_record.published_at:
score += 2
if setup_record.published_by and item.published_by == setup_record.published_by:
score += 1
if score > best_score or (score == best_score and (best_version is None or item.version > best_version)):
best_score = score
best_version = item.version
return best_version
def _resolve_current_published_snapshot_label(
versions: list[Any],
setup_record: Any | None,
) -> str | None:
if not setup_record:
return None
current_published_snapshot_id = getattr(setup_record, "current_published_version_id", None)
if current_published_snapshot_id:
matched = next((item for item in versions if item.id == current_published_snapshot_id), None)
if matched:
return _resolve_version_label(matched)
version = _resolve_current_published_snapshot_version(versions, setup_record)
if version is None:
return None
matched = next((item for item in versions if item.version == version), None)
if not matched:
return None
return _resolve_version_label(matched)
def _resolve_active_branch_base_snapshot_version(
versions: list[Any],
setup_record: Any | None,
) -> int | None:
if not setup_record:
return None
base_snapshot_id = getattr(setup_record, "active_branch_base_version_id", None)
if not base_snapshot_id:
return None
matched = next((item for item in versions if item.id == base_snapshot_id), None)
if not matched:
return None
return matched.version
def _resolve_active_branch_base_snapshot_label(
versions: list[Any],
setup_record: Any | None,
) -> str | None:
if not setup_record:
return None
base_snapshot_id = getattr(setup_record, "active_branch_base_version_id", None)
if not base_snapshot_id:
return None
matched = next((item for item in versions if item.id == base_snapshot_id), None)
if not matched:
return None
return _resolve_version_label(matched)
_SETUP_MODULE_KEYS = (
"projectMilestones",
"enrollmentPlan",
"siteMilestones",
"siteEnrollmentPlans",
"monitoringStrategies",
"centerConfirm",
)
_SETUP_MODULE_LABELS = {
"projectMilestones": "项目里程碑",
"enrollmentPlan": "项目入组计划",
"siteMilestones": "中心里程碑",
"siteEnrollmentPlans": "中心入组计划",
"monitoringStrategies": "监查策略",
"centerConfirm": "中心确认",
}
_SETUP_FIELD_LABELS = {
"id": "ID",
"name": "名称",
"milestone": "里程碑",
"planDate": "计划日期",
"startDate": "开始日期",
"endDate": "结束日期",
"durationDays": "耗时(天)",
"owner": "负责人",
"remark": "备注",
"status": "状态",
"totalTarget": "计划总入组例数",
"startDate": "开始日期",
"endDate": "结束日期",
"monthlyGoalNote": "月度目标说明",
"stageBreakdown": "分阶段计划",
"siteId": "中心ID",
"siteName": "中心名称",
"target": "计划例数",
"note": "备注",
"strategyType": "监查类型",
"detail": "策略详情",
"frequency": "监查频次",
"updatedAt": "更新时间",
"enabled": "启用状态",
"confirmer": "确认人",
"confirmStatus": "确认状态",
"confirmDate": "确认日期",
}
def _setup_value_text(value: Any) -> str:
if value is None:
return "未填写"
if isinstance(value, bool):
return "是" if value else "否"
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, str):
text = value.strip()
if not text:
return "未填写"
if len(text) > 80:
return f"{text[:80]}..."
return text
if isinstance(value, list):
return f"已配置列表({len(value)}项)"
if isinstance(value, dict):
return "已配置内容"
return str(value)
def _setup_row_identity(module_key: str, row: Any, index: int) -> str:
if not isinstance(row, dict):
return f"第{index + 1}行"
if module_key == "projectMilestones":
return str(row.get("name") or "").strip() or f"第{index + 1}行"
if module_key == "siteMilestones":
return str(row.get("milestone") or "").strip() or f"第{index + 1}行"
if module_key in {"siteEnrollmentPlans", "centerConfirm"}:
return str(row.get("siteName") or row.get("siteId") or "").strip() or f"第{index + 1}行"
if module_key == "monitoringStrategies":
return str(row.get("strategyType") or "").strip() or f"第{index + 1}行"
return f"第{index + 1}行"
def _setup_collect_diff_lines(
module_key: str,
old_value: Any,
new_value: Any,
path: list[str],
lines: list[str],
) -> None:
if isinstance(old_value, list) or isinstance(new_value, list):
old_list = old_value if isinstance(old_value, list) else []
new_list = new_value if isinstance(new_value, list) else []
for index in range(max(len(old_list), len(new_list))):
old_item = old_list[index] if index < len(old_list) else None
new_item = new_list[index] if index < len(new_list) else None
identity = _setup_row_identity(module_key, new_item if new_item is not None else old_item, index)
_setup_collect_diff_lines(module_key, old_item, new_item, [*path, identity], lines)
return
if isinstance(old_value, dict) or isinstance(new_value, dict):
old_dict = old_value if isinstance(old_value, dict) else {}
new_dict = new_value if isinstance(new_value, dict) else {}
keys = sorted(set(old_dict.keys()) | set(new_dict.keys()))
for key in keys:
_setup_collect_diff_lines(module_key, old_dict.get(key), new_dict.get(key), [*path, key], lines)
return
if old_value == new_value:
return
module_label = _SETUP_MODULE_LABELS.get(module_key, module_key)
readable_path = " / ".join(_SETUP_FIELD_LABELS.get(part, part) for part in path if part)
field_text = f"{module_label} / {readable_path}" if readable_path else module_label
lines.append(f"{field_text}{_setup_value_text(old_value)} -> {_setup_value_text(new_value)}")
def _top_level_diff_summary(old: dict | None, new: dict | None) -> str:
old = old or {}
new = new or {}
changed_modules: list[str] = []
detail_lines: list[str] = []
for module_key in _SETUP_MODULE_KEYS:
if old.get(module_key) == new.get(module_key):
continue
changed_modules.append(_SETUP_MODULE_LABELS.get(module_key, module_key))
_setup_collect_diff_lines(module_key, old.get(module_key), new.get(module_key), [], detail_lines)
module_summary = "变更模块:" + ("、".join(changed_modules) if changed_modules else "无")
if not detail_lines:
return module_summary
max_lines = 20
visible_lines = detail_lines[:max_lines]
if len(detail_lines) > max_lines:
visible_lines.append(f"其余 {len(detail_lines) - max_lines} 项变更已省略")
return f"{module_summary}; 变更明细:" + "".join(visible_lines)
@router.post("/", response_model=StudyRead, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_roles(["ADMIN"]))])
async def create_study(
study_in: StudyCreate,
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,
)
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="项目编号已存在")
study = await study_crud.create(db, study_in, created_by=current_user.id)
# 自动将创建者添加为项目成员,角色为PM
member_in = StudyMemberCreate(
user_id=current_user.id,
role_in_study="PM",
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
@router.get("/", response_model=PaginatedResponse[StudyRead])
async def list_studies(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> PaginatedResponse[StudyRead]:
if current_user.role == "ADMIN":
studies = await study_crud.list_studies(db, skip=skip, limit=limit)
total = await study_crud.list_studies(db, skip=0, limit=10_000_000)
else:
studies = await study_crud.list_studies_for_user(db, current_user.id, skip=skip, limit=limit)
total = await study_crud.list_studies_for_user(db, current_user.id, skip=0, limit=10_000_000)
return paginate(list(studies), total=len(total))
@router.get("/{study_id}", response_model=StudyRead, dependencies=[Depends(require_study_member())])
async def get_study(
study_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
) -> StudyRead:
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
return study
@router.patch(
"/{study_id}",
response_model=StudyRead,
dependencies=[Depends(require_study_roles(["PM"]))],
)
async def update_study(
study_id: uuid.UUID,
study_in: StudyUpdate,
db: AsyncSession = Depends(get_db_session),
) -> StudyRead:
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
# 检查项目是否已锁定(除非是解锁操作)
if study.is_locked and not (study_in.is_locked is False):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="项目已锁定,无法编辑。请先解锁项目。"
)
if study_in.code and study_in.code != study.code:
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
_ensure_study_timeline_valid(
plan_start_date=next_plan_start,
plan_end_date=next_plan_end,
)
updated = await study_crud.update(db, study, study_in)
return updated
@router.delete(
"/{study_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_roles(["ADMIN"]))],
)
async def delete_study(
study_id: uuid.UUID,
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="项目不存在")
# 删除项目及其所有关联数据(包括审计日志)
await study_crud.delete(db, study_id)
@router.patch(
"/{study_id}/lock",
response_model=StudyRead,
dependencies=[Depends(require_roles(["ADMIN"]))],
)
async def lock_study(
study_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> StudyRead:
"""锁定项目(仅管理员)"""
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
locked_study = await study_crud.lock(db, study_id)
# 记录审计日志
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="study",
entity_id=study_id,
action="LOCK_STUDY",
detail=f"项目已锁定:{study.name} ({study.code})",
operator_id=current_user.id,
operator_role=current_user.role,
)
return locked_study
@router.patch(
"/{study_id}/unlock",
response_model=StudyRead,
dependencies=[Depends(require_roles(["ADMIN"]))],
)
async def unlock_study(
study_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> StudyRead:
"""解锁项目(仅管理员)"""
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
unlocked_study = await study_crud.unlock(db, study_id)
# 记录审计日志
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="study",
entity_id=study_id,
action="UNLOCK_STUDY",
detail=f"项目已解锁:{study.name} ({study.code})",
operator_id=current_user.id,
operator_role=current_user.role,
)
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
versions = await study_setup_config_crud.list_versions(db, study_id)
return _to_setup_config_read(
record,
saved_by_name=saved_by_name,
published_by_name=published_by_name,
current_published_version_label=_resolve_current_published_snapshot_label(versions, record),
active_branch_base_version_label=_resolve_active_branch_base_snapshot_label(versions, record),
)
@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}
setup_project_snapshot = _resolve_setup_project_snapshot(payload.data, study)
await _validate_setup_project_snapshot(db, study_id=study_id, snapshot=setup_project_snapshot, strict_required=False)
setup_plan_start = _parse_optional_snapshot_date(setup_project_snapshot.plan_start_date)
setup_plan_end = _parse_optional_snapshot_date(setup_project_snapshot.plan_end_date)
_validate_setup_data(
payload.data,
site_lookup,
project_plan_start=setup_plan_start,
project_plan_end=setup_plan_end,
strict_required=False,
)
existing = await study_setup_config_crud.get_by_study(db, study_id)
old_config = dict(existing.config or {}) if existing else {}
force_draft = bool(payload.force_draft)
if existing and existing.publish_status == "PUBLISHED" and existing.published_project_snapshot:
current_project_snapshot = setup_project_snapshot.model_dump(mode="json")
if current_project_snapshot != dict(existing.published_project_snapshot or {}):
force_draft = True
record, conflict = await study_setup_config_crud.upsert(
db,
study_id,
expected_version=payload.expected_version,
data=payload.data,
saved_by=current_user.id,
force_draft=force_draft,
)
if conflict:
_raise_conflict_error()
if not record:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="配置保存失败")
has_setup_diff = old_config != (record.config or {})
if has_setup_diff:
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
versions = await study_setup_config_crud.list_versions(db, study_id)
return _to_setup_config_read(
record,
saved_by_name=saved_by_name,
published_by_name=published_by_name,
current_published_version_label=_resolve_current_published_snapshot_label(versions, record),
active_branch_base_version_label=_resolve_active_branch_base_snapshot_label(versions, record),
)
@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="请先保存立项配置草稿")
setup_data = StudySetupConfigData.model_validate(record.config or {})
project_snapshot_for_publish = _resolve_setup_project_snapshot(setup_data, study)
await _validate_setup_project_snapshot(db, study_id=study_id, snapshot=project_snapshot_for_publish)
setup_plan_start = _parse_optional_snapshot_date(project_snapshot_for_publish.plan_start_date)
setup_plan_end = _parse_optional_snapshot_date(project_snapshot_for_publish.plan_end_date)
current_project_snapshot_for_publish = project_snapshot_for_publish.model_dump(mode="json")
force_create_snapshot = bool(
(record.published_project_snapshot or {}) != current_project_snapshot_for_publish
)
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(
setup_data,
site_lookup,
project_plan_start=setup_plan_start,
project_plan_end=setup_plan_end,
)
published, conflict = await study_setup_config_crud.publish(
db,
study_id,
expected_version=payload.expected_version,
published_by=current_user.id,
force_create_snapshot=force_create_snapshot,
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:
_apply_project_publish_snapshot_to_study(study, project_snapshot_for_publish)
projection = await apply_setup_projection_on_publish(
db,
study_id=study_id,
setup_data=StudySetupConfigData.model_validate(published.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
],
)
project_snapshot = project_snapshot_for_publish.model_dump(mode="json")
published.published_project_snapshot = project_snapshot
await study_setup_config_crud.set_version_project_snapshot(
db,
study_id,
version=published.version,
project_snapshot=project_snapshot,
)
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,
)
published.config = study_setup_config_crud.empty_draft_payload()
published.active_branch_base_version_id = None
published.saved_by = current_user.id
published.updated_at = datetime.utcnow()
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)
versions = await study_setup_config_crud.list_versions(db, study_id)
return _to_setup_config_read(
published,
saved_by_name=operator_name,
published_by_name=operator_name,
current_published_version_label=_resolve_current_published_snapshot_label(versions, published),
active_branch_base_version_label=_resolve_active_branch_base_snapshot_label(versions, published),
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)
current_setup = await study_setup_config_crud.get_by_study(db, study_id)
current_published_version = _resolve_current_published_snapshot_version(versions, current_setup)
active_draft_base_version = _resolve_active_branch_base_snapshot_version(versions, current_setup)
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,
is_current_published=item.version == current_published_version,
is_active_draft_base=item.version == active_draft_base_version,
)
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="项目不存在")
versions_before = await study_setup_config_crud.list_versions(db, study_id)
target_label_map = {item.version: (item.version_label or f"v{item.display_version}") for item in versions_before}
target_label = target_label_map.get(payload.target_version, f"v{payload.target_version}")
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"立项配置已回滚并替换当前发布为 {target_label},草稿分支已切换到对应分支基线",
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
versions = await study_setup_config_crud.list_versions(db, study_id)
return _to_setup_config_read(
record,
saved_by_name=operator_name,
published_by_name=published_by_name,
current_published_version_label=_resolve_current_published_snapshot_label(versions, record),
active_branch_base_version_label=_resolve_active_branch_base_snapshot_label(versions, record),
)
@router.post(
"/{study_id}/setup-config/draft/checkout-branch",
response_model=StudySetupConfigRead,
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
)
async def checkout_study_setup_config_branch_draft(
study_id: uuid.UUID,
payload: StudySetupConfigCheckoutBranch,
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="项目不存在")
versions_before = await study_setup_config_crud.list_versions(db, study_id)
target_label_map = {item.version: (item.version_label or f"v{item.display_version}") for item in versions_before}
target_label = target_label_map.get(payload.target_version, f"v{payload.target_version}")
record, conflict, target_missing = await study_setup_config_crud.checkout_branch_draft_from_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="CHECKOUT_SETUP_CONFIG_BRANCH_DRAFT",
detail=f"立项配置草稿已切换到 {target_label} 对应分支",
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
versions = await study_setup_config_crud.list_versions(db, study_id)
return _to_setup_config_read(
record,
saved_by_name=operator_name,
published_by_name=published_by_name,
current_published_version_label=_resolve_current_published_snapshot_label(versions, record),
active_branch_base_version_label=_resolve_active_branch_base_snapshot_label(versions, record),
)
@router.post(
"/{study_id}/setup-config/draft/clear",
response_model=StudySetupConfigRead,
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
)
async def clear_study_setup_config_draft(
study_id: uuid.UUID,
payload: StudySetupConfigDraftAction,
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 = await study_setup_config_crud.clear_draft(
db,
study_id,
expected_version=payload.expected_version,
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="CLEAR_SETUP_CONFIG_DRAFT",
detail="立项配置草稿已清空",
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
versions = await study_setup_config_crud.list_versions(db, study_id)
return _to_setup_config_read(
record,
saved_by_name=operator_name,
published_by_name=published_by_name,
current_published_version_label=_resolve_current_published_snapshot_label(versions, record),
active_branch_base_version_label=_resolve_active_branch_base_snapshot_label(versions, record),
)
@router.post(
"/{study_id}/setup-config/draft/refill",
response_model=StudySetupConfigRead,
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
)
async def refill_study_setup_config_draft(
study_id: uuid.UUID,
payload: StudySetupConfigDraftAction,
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, no_published = await study_setup_config_crud.refill_draft_from_published(
db,
study_id,
expected_version=payload.expected_version,
saved_by=current_user.id,
)
if conflict:
_raise_conflict_error()
if no_published:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, 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="REFILL_SETUP_CONFIG_DRAFT",
detail="立项配置草稿已从当前发布版本一键回填",
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
versions = await study_setup_config_crud.list_versions(db, study_id)
return _to_setup_config_read(
record,
saved_by_name=operator_name,
published_by_name=published_by_name,
current_published_version_label=_resolve_current_published_snapshot_label(versions, record),
active_branch_base_version_label=_resolve_active_branch_base_snapshot_label(versions, record),
)
@router.post(
"/{study_id}/setup-config/merge-main",
response_model=StudySetupConfigRead,
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
)
async def merge_study_setup_config_to_main(
study_id: uuid.UUID,
payload: StudySetupConfigMergeToMain,
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="项目不存在")
versions_before = await study_setup_config_crud.list_versions(db, study_id)
source_label_map = {item.version: (item.version_label or f"v{item.display_version}") for item in versions_before}
source_label = source_label_map.get(payload.source_version, f"v{payload.source_version}")
merged, conflict, source_missing = await study_setup_config_crud.merge_to_main(
db,
study_id,
expected_version=payload.expected_version,
source_version=payload.source_version,
merged_by=current_user.id,
auto_commit=False,
)
if conflict:
await db.rollback()
_raise_conflict_error()
if source_missing:
await db.rollback()
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="目标版本不存在")
if not merged:
await db.rollback()
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="合并主分支失败")
try:
merged_setup_data = StudySetupConfigData.model_validate(merged.published_config or {})
merged_project_snapshot = _resolve_setup_project_snapshot(merged_setup_data, study)
_apply_project_publish_snapshot_to_study(study, merged_project_snapshot)
projection = await apply_setup_projection_on_publish(
db,
study_id=study_id,
setup_data=merged_setup_data,
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],
)
project_snapshot = merged_project_snapshot.model_dump(mode="json")
merged.published_project_snapshot = project_snapshot
await study_setup_config_crud.set_version_project_snapshot(
db,
study_id,
version=merged.version,
project_snapshot=project_snapshot,
)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="study_setup_config",
entity_id=merged.id,
action="MERGE_SETUP_CONFIG_TO_MAIN",
detail=f"已将发布版本 {source_label} 合并到主分支并生成新主版本",
operator_id=current_user.id,
operator_role=current_user.role,
auto_commit=False,
)
merged.config = study_setup_config_crud.empty_draft_payload()
merged.active_branch_base_version_id = None
merged.saved_by = current_user.id
merged.updated_at = datetime.utcnow()
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(merged)
versions = await study_setup_config_crud.list_versions(db, study_id)
return _to_setup_config_read(
merged,
saved_by_name=operator_name,
published_by_name=operator_name,
current_published_version_label=_resolve_current_published_snapshot_label(versions, merged),
active_branch_base_version_label=_resolve_active_branch_base_snapshot_label(versions, merged),
projection_status=projection.status,
projection_summary=projection_summary,
)
@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="目标版本不存在")
current_setup = await study_setup_config_crud.get_by_study(db, study_id)
current_published_version = _resolve_current_published_snapshot_version(versions, current_setup)
if current_published_version is None:
current_published_version = max(item.version for item in versions)
if target_version == current_published_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,
)