import uuid from datetime import date from typing import Literal from pydantic import BaseModel, Field from sqlalchemy import delete, or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.models.milestone import Milestone from app.models.site import Site from app.models.study import Study from app.models.study_center_confirm import StudyCenterConfirm from app.models.user import User from app.schemas.study_setup_config import StudySetupConfigData PROJECT_MILESTONE_TYPE = "SETUP_PROJECT_MILESTONE" SITE_MILESTONE_TYPE = "SETUP_SITE_MILESTONE" MILESTONE_STATUS_MAP = { "未开始": "NOT_STARTED", "进行中": "IN_PROGRESS", "已完成": "DONE", "延期": "BLOCKED", } def _map_milestone_status(raw_status: str | None) -> str | None: normalized = (raw_status or "").strip() if not normalized: return "NOT_STARTED" return MILESTONE_STATUS_MAP.get(normalized) class SetupProjectionSkippedItem(BaseModel): site_id: str reason: str class SetupProjectionResult(BaseModel): status: Literal["success", "partial_success", "failed"] study_updated: bool = False site_updated_count: int = 0 site_skipped_count: int = 0 warnings: list[str] = Field(default_factory=list) skipped_items: list[SetupProjectionSkippedItem] = Field(default_factory=list) def _parse_date(value: str) -> date | None: if not value: return None try: return date.fromisoformat(value) except ValueError: return None def _normalize_text(value: str | None) -> str | None: if value is None: return None normalized = value.strip() return normalized or None async def _build_owner_lookup( db: AsyncSession, *, setup_data: StudySetupConfigData, result: SetupProjectionResult, ) -> dict[str, uuid.UUID]: owners: set[str] = set() for row in setup_data.projectMilestones: owner = _normalize_text(row.owner) if owner: owners.add(owner) for row in setup_data.siteMilestones: owner = _normalize_text(row.owner) if owner: owners.add(owner) if not owners: return {} users_result = await db.execute( select(User.id, User.full_name, User.email).where( or_(User.full_name.in_(owners), User.email.in_(owners)) ) ) rows = users_result.all() owner_candidates: dict[str, set[uuid.UUID]] = {} for user_id, full_name, email in rows: full_name_key = _normalize_text(full_name) email_key = _normalize_text(email) if full_name_key and full_name_key in owners: owner_candidates.setdefault(full_name_key, set()).add(user_id) if email_key and email_key in owners: owner_candidates.setdefault(email_key, set()).add(user_id) resolved: dict[str, uuid.UUID] = {} for owner in sorted(owners): candidates = owner_candidates.get(owner, set()) if len(candidates) == 1: resolved[owner] = next(iter(candidates)) elif len(candidates) > 1: result.warnings.append(f"milestone_owner_ambiguous:{owner}") return resolved async def _replace_project_milestones( db: AsyncSession, *, study_id: uuid.UUID, setup_data: StudySetupConfigData, owner_lookup: dict[str, uuid.UUID], result: SetupProjectionResult, ) -> None: await db.execute( delete(Milestone).where( Milestone.study_id == study_id, Milestone.type == PROJECT_MILESTONE_TYPE, ) ) for row in setup_data.projectMilestones: start_text = (row.startDate or row.planDate or "").strip() if not any([(row.name or "").strip(), (row.owner or "").strip(), (row.remark or "").strip(), start_text]): continue name = (row.name or "").strip() if not name: result.warnings.append(f"project_milestone_name_empty:{row.id}") continue planned_date = _parse_date(start_text) if start_text and not planned_date: result.warnings.append(f"project_milestone_plan_date_invalid:{row.id}") raw_status = (row.status or "").strip() mapped_status = MILESTONE_STATUS_MAP.get(raw_status, "NOT_STARTED") if raw_status and raw_status not in MILESTONE_STATUS_MAP: result.warnings.append(f"project_milestone_status_unknown:{row.id}") owner_name = _normalize_text(row.owner) owner_id = owner_lookup.get(owner_name, None) if owner_name else None db.add( Milestone( study_id=study_id, type=PROJECT_MILESTONE_TYPE, name=name, planned_date=planned_date, adjusted_start_date=_parse_date((getattr(row, "adjustedStartDate", "") or "").strip()), adjusted_end_date=_parse_date((getattr(row, "adjustedEndDate", "") or "").strip()), actual_start_date=_parse_date((getattr(row, "actualStartDate", "") or "").strip()), actual_end_date=_parse_date((getattr(row, "actualEndDate", "") or "").strip()), actual_date=_parse_date((getattr(row, "actualEndDate", "") or "").strip()), status=mapped_status, owner_id=owner_id, owner_name=owner_name, notes=_normalize_text(row.remark), ) ) async def _replace_site_milestones( db: AsyncSession, *, study_id: uuid.UUID, setup_data: StudySetupConfigData, owner_lookup: dict[str, uuid.UUID], result: SetupProjectionResult, ) -> None: await db.execute( delete(Milestone).where( Milestone.study_id == study_id, Milestone.type == SITE_MILESTONE_TYPE, ) ) for row in setup_data.siteMilestones: if not any( [ (row.milestone or "").strip(), (row.owner or "").strip(), (row.remark or "").strip(), (row.planDate or "").strip(), ] ): continue milestone_name = (row.milestone or "").strip() if not milestone_name: result.warnings.append(f"site_milestone_name_empty:{row.id}") continue planned_date = _parse_date(row.planDate) if row.planDate and not planned_date: result.warnings.append(f"site_milestone_plan_date_invalid:{row.id}") mapped_status = _map_milestone_status(row.status) if mapped_status is None: result.warnings.append(f"site_milestone_status_unknown:{row.id}") mapped_status = "NOT_STARTED" owner_name = _normalize_text(row.owner) owner_id = owner_lookup.get(owner_name, None) if owner_name else None db.add( Milestone( study_id=study_id, type=SITE_MILESTONE_TYPE, name=milestone_name, planned_date=planned_date, status=mapped_status, owner_id=owner_id, owner_name=owner_name, notes=_normalize_text(row.remark), ) ) async def _replace_center_confirms( db: AsyncSession, *, study_id: uuid.UUID, setup_data: StudySetupConfigData, site_map: dict[str, Site], result: SetupProjectionResult, ) -> None: await db.execute(delete(StudyCenterConfirm).where(StudyCenterConfirm.study_id == study_id)) for row in setup_data.centerConfirm: site_id = (row.siteId or "").strip() confirmer = _normalize_text(row.confirmer) note = _normalize_text(row.note) raw_confirm_date = row.confirmDate or "" parsed_confirm_date = _parse_date(raw_confirm_date) if not any([site_id, confirmer, note, raw_confirm_date]): continue if not site_id: result.skipped_items.append(SetupProjectionSkippedItem(site_id="", reason="center_confirm_site_id_empty")) continue site = site_map.get(site_id) if not site: result.skipped_items.append(SetupProjectionSkippedItem(site_id=site_id, reason="center_confirm_site_not_found")) continue if not site.is_active: result.skipped_items.append(SetupProjectionSkippedItem(site_id=site_id, reason="center_confirm_site_inactive")) continue if raw_confirm_date and not parsed_confirm_date: result.skipped_items.append(SetupProjectionSkippedItem(site_id=site_id, reason="center_confirm_date_invalid")) result.warnings.append(f"center_confirm_date_invalid:{site_id}") continue db.add( StudyCenterConfirm( study_id=study_id, site_id=site.id, source_setup_item_id=_normalize_text(row.id), confirmer=confirmer, confirm_status=_normalize_text(row.confirmStatus) or "待确认", confirm_date=parsed_confirm_date, note=note, ) ) async def apply_setup_projection_on_publish( db: AsyncSession, *, study_id: uuid.UUID, setup_data: StudySetupConfigData, operator_id: uuid.UUID | None, ) -> SetupProjectionResult: _ = operator_id result = SetupProjectionResult(status="success") study_result = await db.execute(select(Study).where(Study.id == study_id)) study = study_result.scalar_one_or_none() if not study: return SetupProjectionResult( status="failed", warnings=["study_not_found"], site_skipped_count=0, ) if study.is_locked: result.status = "partial_success" result.warnings.append("study_locked_skipped") result.skipped_items.append(SetupProjectionSkippedItem(site_id=str(study_id), reason="study_locked")) result.site_skipped_count = 1 return result plan = setup_data.enrollmentPlan new_monthly_goal_note = _normalize_text(plan.monthlyGoalNote) new_stage_breakdown = _normalize_text(plan.stageBreakdown) study_changed = ( study.planned_enrollment_count != plan.totalTarget or study.enrollment_monthly_goal_note != new_monthly_goal_note or study.enrollment_stage_breakdown != new_stage_breakdown ) study.planned_enrollment_count = plan.totalTarget study.enrollment_monthly_goal_note = new_monthly_goal_note study.enrollment_stage_breakdown = new_stage_breakdown result.study_updated = study_changed site_result = await db.execute(select(Site).where(Site.study_id == study_id)) site_map = {str(site.id): site for site in site_result.scalars().all()} deduped_plans: dict[str, tuple[int, str, str, str]] = {} duplicate_ids: set[str] = set() for row in setup_data.siteEnrollmentPlans: site_id = (row.siteId or "").strip() if not site_id: result.skipped_items.append(SetupProjectionSkippedItem(site_id="", reason="site_id_empty")) continue if site_id in deduped_plans: duplicate_ids.add(site_id) deduped_plans[site_id] = ( row.target, row.startDate or "", row.endDate or "", row.note or "", ) for site_id in sorted(duplicate_ids): result.warnings.append(f"duplicate_site_enrollment_plan:{site_id}") for site_id, (target, raw_start_date, raw_end_date, raw_note) in deduped_plans.items(): site = site_map.get(site_id) if not site: result.skipped_items.append(SetupProjectionSkippedItem(site_id=site_id, reason="site_not_found")) continue if not site.is_active: result.skipped_items.append(SetupProjectionSkippedItem(site_id=site_id, reason="site_inactive")) continue if target < 0: result.skipped_items.append(SetupProjectionSkippedItem(site_id=site_id, reason="target_invalid")) result.warnings.append(f"target_invalid:{site_id}") continue start_date = _parse_date(raw_start_date) end_date = _parse_date(raw_end_date) if raw_start_date and not start_date: result.skipped_items.append(SetupProjectionSkippedItem(site_id=site_id, reason="site_plan_start_date_invalid")) result.warnings.append(f"site_plan_start_date_invalid:{site_id}") continue if raw_end_date and not end_date: result.skipped_items.append(SetupProjectionSkippedItem(site_id=site_id, reason="site_plan_end_date_invalid")) result.warnings.append(f"site_plan_end_date_invalid:{site_id}") continue site.enrollment_target = target site.enrollment_plan_start_date = start_date site.enrollment_plan_end_date = end_date site.enrollment_plan_note = _normalize_text(raw_note) result.site_updated_count += 1 owner_lookup = await _build_owner_lookup(db, setup_data=setup_data, result=result) await _replace_project_milestones( db, study_id=study_id, setup_data=setup_data, owner_lookup=owner_lookup, result=result, ) await _replace_site_milestones( db, study_id=study_id, setup_data=setup_data, owner_lookup=owner_lookup, result=result, ) await _replace_center_confirms(db, study_id=study_id, setup_data=setup_data, site_map=site_map, result=result) result.site_skipped_count = len(result.skipped_items) if result.site_skipped_count > 0 and result.status != "failed": result.status = "partial_success" return result