立项配置页初步优化
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from io import BytesIO
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
from openpyxl import Workbook, load_workbook
|
||||
|
||||
from app.schemas.study_setup_config import StudySetupConfigData
|
||||
|
||||
|
||||
def _make_row_id() -> str:
|
||||
return f"{int(datetime.now().timestamp() * 1000)}_{random.randint(1000, 9999)}"
|
||||
|
||||
|
||||
def _is_empty(*values: Any) -> bool:
|
||||
return all(v is None or str(v).strip() == "" for v in values)
|
||||
|
||||
|
||||
def _sheet_headers(sheet, headers: list[str]) -> None:
|
||||
sheet.append(headers)
|
||||
for idx, title in enumerate(headers, start=1):
|
||||
cell = sheet.cell(row=1, column=idx)
|
||||
cell.value = title
|
||||
|
||||
|
||||
def export_setup_config_excel(data: StudySetupConfigData) -> bytes:
|
||||
wb = Workbook()
|
||||
|
||||
ws_guide = wb.active
|
||||
ws_guide.title = "说明"
|
||||
ws_guide.append(["立项配置Excel模板说明"])
|
||||
ws_guide.append(["1. 各业务Sheet首行是表头,不要修改列名。"])
|
||||
ws_guide.append(["2. 可在现有行基础上新增/删除行。"])
|
||||
ws_guide.append(["3. 日期格式建议:YYYY-MM-DD。"])
|
||||
ws_guide.append(["4. 导入后系统会自动校验并回填到立项配置草稿。"])
|
||||
|
||||
ws_pm = wb.create_sheet("项目里程碑")
|
||||
_sheet_headers(ws_pm, ["ID", "里程碑", "计划日期", "负责人", "状态", "备注"])
|
||||
for row in data.projectMilestones:
|
||||
ws_pm.append([row.id, row.name, row.planDate, row.owner, row.status, row.remark])
|
||||
|
||||
ws_plan = wb.create_sheet("项目入组计划")
|
||||
_sheet_headers(ws_plan, ["计划总入组例数", "计划开始日期", "计划结束日期", "月度目标说明", "分阶段计划"])
|
||||
p = data.enrollmentPlan
|
||||
ws_plan.append([p.totalTarget, p.startDate, p.endDate, p.monthlyGoalNote, p.stageBreakdown])
|
||||
|
||||
ws_sm = wb.create_sheet("中心里程碑")
|
||||
_sheet_headers(ws_sm, ["ID", "里程碑", "计划日期", "负责人", "状态", "备注"])
|
||||
for row in data.siteMilestones:
|
||||
ws_sm.append([row.id, row.milestone, row.planDate, row.owner, row.status, row.remark])
|
||||
|
||||
ws_sep = wb.create_sheet("中心入组计划")
|
||||
_sheet_headers(ws_sep, ["ID", "中心ID", "中心名称", "计划例数", "启动日期", "完成日期", "备注", "分阶段计划"])
|
||||
for row in data.siteEnrollmentPlans:
|
||||
ws_sep.append([row.id, row.siteId, row.siteName, row.target, row.startDate, row.endDate, row.note, row.stageBreakdown])
|
||||
|
||||
ws_ms = wb.create_sheet("监查计划策略")
|
||||
_sheet_headers(ws_ms, ["ID", "监查类型", "策略详情", "监查次数", "更新时间", "是否启用"])
|
||||
for row in data.monitoringStrategies:
|
||||
ws_ms.append([row.id, row.strategyType, row.detail, row.frequency, row.updatedAt, "是" if row.enabled else "否"])
|
||||
|
||||
ws_cc = wb.create_sheet("中心确认")
|
||||
_sheet_headers(ws_cc, ["ID", "中心ID", "中心名称", "确认人", "确认状态", "确认日期", "备注"])
|
||||
for row in data.centerConfirm:
|
||||
ws_cc.append([row.id, row.siteId, row.siteName, row.confirmer, row.confirmStatus, row.confirmDate, row.note])
|
||||
|
||||
bio = BytesIO()
|
||||
wb.save(bio)
|
||||
return bio.getvalue()
|
||||
|
||||
|
||||
def _to_int(value: Any, default: int = 0) -> int:
|
||||
if value is None or str(value).strip() == "":
|
||||
return default
|
||||
try:
|
||||
return int(float(str(value)))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _to_date_str(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, datetime):
|
||||
return value.date().isoformat()
|
||||
if isinstance(value, date):
|
||||
return value.isoformat()
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _to_datetime_str(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, datetime):
|
||||
return value.strftime("%Y-%m-%d %H:%M:%S")
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _to_bool(value: Any, default: bool = True) -> bool:
|
||||
if value is None:
|
||||
return default
|
||||
v = str(value).strip().lower()
|
||||
if v in {"1", "true", "yes", "y", "是", "启用"}:
|
||||
return True
|
||||
if v in {"0", "false", "no", "n", "否", "禁用"}:
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
def import_setup_config_excel(file_bytes: bytes) -> StudySetupConfigData:
|
||||
wb = load_workbook(filename=BytesIO(file_bytes), data_only=True)
|
||||
|
||||
project_milestones = []
|
||||
ws = wb["项目里程碑"] if "项目里程碑" in wb.sheetnames else None
|
||||
if ws:
|
||||
for row in ws.iter_rows(min_row=2, values_only=True):
|
||||
rid, name, plan_date, owner, status, remark = row[:6]
|
||||
if _is_empty(rid, name, plan_date, owner, status, remark):
|
||||
continue
|
||||
project_milestones.append(
|
||||
{
|
||||
"id": str(rid).strip() if rid else _make_row_id(),
|
||||
"name": str(name or "").strip(),
|
||||
"planDate": _to_date_str(plan_date),
|
||||
"owner": str(owner or "").strip(),
|
||||
"status": str(status or "未开始").strip() or "未开始",
|
||||
"remark": str(remark or "").strip(),
|
||||
}
|
||||
)
|
||||
|
||||
enrollment_plan = {
|
||||
"totalTarget": 0,
|
||||
"startDate": "",
|
||||
"endDate": "",
|
||||
"monthlyGoalNote": "",
|
||||
"stageBreakdown": "",
|
||||
}
|
||||
ws = wb["项目入组计划"] if "项目入组计划" in wb.sheetnames else None
|
||||
if ws:
|
||||
first = next(ws.iter_rows(min_row=2, values_only=True), None)
|
||||
if first:
|
||||
enrollment_plan = {
|
||||
"totalTarget": _to_int(first[0], 0),
|
||||
"startDate": _to_date_str(first[1]),
|
||||
"endDate": _to_date_str(first[2]),
|
||||
"monthlyGoalNote": str(first[3] or "").strip(),
|
||||
"stageBreakdown": str(first[4] or "").strip(),
|
||||
}
|
||||
|
||||
site_milestones = []
|
||||
ws = wb["中心里程碑"] if "中心里程碑" in wb.sheetnames else None
|
||||
if ws:
|
||||
for row in ws.iter_rows(min_row=2, values_only=True):
|
||||
raw_status = None
|
||||
if len(row) >= 8:
|
||||
rid, site_id, site_name, milestone, plan_date, owner, raw_status, remark = row[:8]
|
||||
_ = (site_id, site_name)
|
||||
elif len(row) == 7:
|
||||
# Backward compatibility: old template has no status column and includes center columns.
|
||||
rid, site_id, site_name, milestone, plan_date, owner, remark = row[:7]
|
||||
_ = (site_id, site_name)
|
||||
else:
|
||||
rid, milestone, plan_date, owner, raw_status, remark = row[:6]
|
||||
if _is_empty(rid, milestone, plan_date, owner, raw_status, remark):
|
||||
continue
|
||||
status = str(raw_status or "未开始").strip() or "未开始"
|
||||
site_milestones.append(
|
||||
{
|
||||
"id": str(rid).strip() if rid else _make_row_id(),
|
||||
"milestone": str(milestone or "").strip(),
|
||||
"planDate": _to_date_str(plan_date),
|
||||
"owner": str(owner or "").strip(),
|
||||
"status": status,
|
||||
"remark": str(remark or "").strip(),
|
||||
}
|
||||
)
|
||||
|
||||
site_enrollment_plans = []
|
||||
ws = wb["中心入组计划"] if "中心入组计划" in wb.sheetnames else None
|
||||
if ws:
|
||||
for row in ws.iter_rows(min_row=2, values_only=True):
|
||||
rid, site_id, site_name, target, start_date, end_date, note, stage_breakdown = (list(row[:8]) + [None] * 8)[:8]
|
||||
if _is_empty(rid, site_id, site_name, target, start_date, end_date, note, stage_breakdown):
|
||||
continue
|
||||
site_enrollment_plans.append(
|
||||
{
|
||||
"id": str(rid).strip() if rid else _make_row_id(),
|
||||
"siteId": str(site_id or "").strip(),
|
||||
"siteName": str(site_name or "").strip(),
|
||||
"target": _to_int(target, 0),
|
||||
"startDate": _to_date_str(start_date),
|
||||
"endDate": _to_date_str(end_date),
|
||||
"note": str(note or "").strip(),
|
||||
"stageBreakdown": str(stage_breakdown or "").strip(),
|
||||
}
|
||||
)
|
||||
|
||||
monitoring_strategies = []
|
||||
ws = wb["监查计划策略"] if "监查计划策略" in wb.sheetnames else None
|
||||
if ws:
|
||||
for row in ws.iter_rows(min_row=2, values_only=True):
|
||||
rid, strategy_type, detail, frequency, updated_at, enabled = row[:6]
|
||||
if _is_empty(rid, strategy_type, detail, frequency, updated_at, enabled):
|
||||
continue
|
||||
monitoring_strategies.append(
|
||||
{
|
||||
"id": str(rid).strip() if rid else _make_row_id(),
|
||||
"strategyType": str(strategy_type or "").strip(),
|
||||
"detail": str(detail or "").strip(),
|
||||
"frequency": str(frequency or "").strip(),
|
||||
"updatedAt": _to_datetime_str(updated_at) or datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"enabled": _to_bool(enabled, True),
|
||||
}
|
||||
)
|
||||
|
||||
center_confirm = []
|
||||
ws = wb["中心确认"] if "中心确认" in wb.sheetnames else None
|
||||
if ws:
|
||||
for row in ws.iter_rows(min_row=2, values_only=True):
|
||||
rid, site_id, site_name, confirmer, confirm_status, confirm_date, note = row[:7]
|
||||
if _is_empty(rid, site_id, site_name, confirmer, confirm_status, confirm_date, note):
|
||||
continue
|
||||
center_confirm.append(
|
||||
{
|
||||
"id": str(rid).strip() if rid else _make_row_id(),
|
||||
"siteId": str(site_id or "").strip(),
|
||||
"siteName": str(site_name or "").strip(),
|
||||
"confirmer": str(confirmer or "").strip(),
|
||||
"confirmStatus": str(confirm_status or "待确认").strip() or "待确认",
|
||||
"confirmDate": _to_date_str(confirm_date),
|
||||
"note": str(note or "").strip(),
|
||||
}
|
||||
)
|
||||
|
||||
return StudySetupConfigData.model_validate(
|
||||
{
|
||||
"projectMilestones": project_milestones,
|
||||
"enrollmentPlan": enrollment_plan,
|
||||
"siteMilestones": site_milestones,
|
||||
"siteEnrollmentPlans": site_enrollment_plans,
|
||||
"monitoringStrategies": monitoring_strategies,
|
||||
"centerConfirm": center_confirm,
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,387 @@
|
||||
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:
|
||||
if not any([(row.name or "").strip(), (row.owner or "").strip(), (row.remark or "").strip(), (row.planDate or "").strip()]):
|
||||
continue
|
||||
name = (row.name or "").strip()
|
||||
if not name:
|
||||
result.warnings.append(f"project_milestone_name_empty:{row.id}")
|
||||
continue
|
||||
planned_date = _parse_date(row.planDate)
|
||||
if row.planDate 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
|
||||
Reference in New Issue
Block a user