立项配置页初步优化

This commit is contained in:
Cheng Zhou
2026-02-24 16:53:22 +08:00
parent 0693f09b1d
commit 8f3f717e48
124 changed files with 12519 additions and 2954 deletions
+775 -2
View File
@@ -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)