import uuid import re from datetime import date 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 ( 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, 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( 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 "无" 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, 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, ) _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, 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="项目编号已存在") 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 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 @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 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="配置保存失败") 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 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, )