import uuid import re from io import BytesIO from datetime import date from collections import Counter 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_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( 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="配置保存失败") 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)