951 lines
39 KiB
Python
951 lines
39 KiB
Python
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)
|