Files
ctms/backend/app/services/setup_config_projection.py
T
2026-02-27 09:06:06 +08:00

389 lines
14 KiB
Python

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