立项配置页初步优化
This commit is contained in:
@@ -1,19 +1,340 @@
|
||||
import uuid
|
||||
import re
|
||||
from io import BytesIO
|
||||
from datetime import date
|
||||
from collections import Counter
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
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_roles
|
||||
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(
|
||||
@@ -21,6 +342,15 @@ async def create_study(
|
||||
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="项目编号已存在")
|
||||
@@ -33,6 +363,26 @@ async def create_study(
|
||||
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
|
||||
|
||||
@@ -89,6 +439,25 @@ async def update_study(
|
||||
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
|
||||
@@ -175,3 +544,407 @@ async def unlock_study(
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
@@ -85,16 +85,29 @@ async def list_subjects(
|
||||
return []
|
||||
subject_ids = [subject.id for subject in subject_list]
|
||||
ae_rows = await db.execute(
|
||||
select(AdverseEvent.subject_id).where(
|
||||
select(AdverseEvent.subject_id, AdverseEvent.is_sae, AdverseEvent.is_susar).where(
|
||||
AdverseEvent.study_id == study_id,
|
||||
AdverseEvent.subject_id.in_(subject_ids),
|
||||
)
|
||||
)
|
||||
ae_subject_ids = {row[0] for row in ae_rows.all() if row[0]}
|
||||
ae_flags: dict[uuid.UUID, dict[str, bool]] = {}
|
||||
for row in ae_rows.all():
|
||||
sid = row[0]
|
||||
if not sid:
|
||||
continue
|
||||
if sid not in ae_flags:
|
||||
ae_flags[sid] = {"has_ae": True, "has_sae": False, "has_susar": False}
|
||||
if row[1]:
|
||||
ae_flags[sid]["has_sae"] = True
|
||||
if row[2]:
|
||||
ae_flags[sid]["has_susar"] = True
|
||||
result: list[SubjectRead] = []
|
||||
for subject in subject_list:
|
||||
data = SubjectRead.model_validate(subject)
|
||||
data.has_ae = subject.id in ae_subject_ids
|
||||
flags = ae_flags.get(subject.id, {"has_ae": False, "has_sae": False, "has_susar": False})
|
||||
data.has_ae = flags["has_ae"]
|
||||
data.has_sae = flags["has_sae"]
|
||||
data.has_susar = flags["has_susar"]
|
||||
result.append(data)
|
||||
return result
|
||||
|
||||
@@ -120,12 +133,21 @@ async def get_subject(
|
||||
await _ensure_subject_active(db, subject)
|
||||
data = SubjectRead.model_validate(subject)
|
||||
ae_row = await db.execute(
|
||||
select(AdverseEvent.id).where(
|
||||
select(AdverseEvent.is_sae, AdverseEvent.is_susar).where(
|
||||
AdverseEvent.study_id == study_id,
|
||||
AdverseEvent.subject_id == subject.id,
|
||||
).limit(1)
|
||||
)
|
||||
)
|
||||
data.has_ae = ae_row.scalar_one_or_none() is not None
|
||||
ae_flags = {"has_ae": False, "has_sae": False, "has_susar": False}
|
||||
for row in ae_row.all():
|
||||
ae_flags["has_ae"] = True
|
||||
if row[0]:
|
||||
ae_flags["has_sae"] = True
|
||||
if row[1]:
|
||||
ae_flags["has_susar"] = True
|
||||
data.has_ae = ae_flags["has_ae"]
|
||||
data.has_sae = ae_flags["has_sae"]
|
||||
data.has_susar = ae_flags["has_susar"]
|
||||
return data
|
||||
|
||||
|
||||
|
||||
@@ -62,6 +62,20 @@ async def create_visit(
|
||||
await _ensure_subject_active(db, subject)
|
||||
if visit_in.subject_id != subject_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="参与者不匹配")
|
||||
if visit_in.window_start and visit_in.window_end and visit_in.window_start > visit_in.window_end:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="访视窗口结束日期不能早于开始日期")
|
||||
if (
|
||||
visit_in.planned_date
|
||||
and visit_in.window_start
|
||||
and visit_in.planned_date < visit_in.window_start
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="计划访视日期不能早于访视窗口开始日期")
|
||||
if (
|
||||
visit_in.planned_date
|
||||
and visit_in.window_end
|
||||
and visit_in.planned_date > visit_in.window_end
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="计划访视日期不能晚于访视窗口结束日期")
|
||||
next_visit_code = await visit_crud.get_next_visit_code(db, subject_id)
|
||||
if next_visit_code == "V1" and not visit_in.planned_date:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="首次新增访视必须填写计划访视日期")
|
||||
@@ -119,6 +133,12 @@ async def update_visit(
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="访视不存在")
|
||||
if visit_in.status == "DONE" and not visit_in.actual_date:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="状态为已完成时必须填写实际日期")
|
||||
if visit.window_start and visit.window_end and visit.window_start > visit.window_end:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前访视窗口配置异常,请先修复窗口日期")
|
||||
if visit_in.actual_date and visit.window_start and visit_in.actual_date < visit.window_start:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="实际访视日期不能早于访视窗口开始日期")
|
||||
if visit_in.actual_date and visit.window_end and visit_in.actual_date > visit.window_end:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="实际访视日期不能晚于访视窗口结束日期")
|
||||
updated = await visit_crud.update_visit(db, visit, visit_in)
|
||||
detail = None
|
||||
if visit_in.status:
|
||||
|
||||
Reference in New Issue
Block a user