import uuid from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from app.core.deps import get_current_user, get_db_session, require_study_not_locked, require_api_permission from app.crud import audit as audit_crud from app.crud import site as site_crud from app.crud import subject as subject_crud from app.crud import study as study_crud from app.crud import visit as visit_crud from app.schemas.subject import SubjectUpdate from app.schemas.visit import EarlyTerminationCreate, VisitCreate, VisitRead, VisitUpdate router = APIRouter() async def _ensure_subject(db: AsyncSession, study_id: uuid.UUID, subject_id: uuid.UUID): subject = await subject_crud.get_subject(db, subject_id) if not subject or subject.study_id != study_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="参与者不存在") return subject async def _ensure_subject_active(db: AsyncSession, subject) -> None: if not subject or not subject.site_id: return site = await site_crud.get_site(db, subject.site_id) if not site or not site.is_active: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="中心已停用") @router.get( "/", response_model=list[VisitRead], dependencies=[Depends(require_api_permission("visits:read"))], ) async def list_visits( study_id: uuid.UUID, subject_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), ) -> list[VisitRead]: await _ensure_subject(db, study_id, subject_id) await visit_crud.mark_overdue_as_lost(db, subject_id) visits = await visit_crud.list_visits(db, subject_id) return list(visits) @router.post( "/", response_model=VisitRead, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_api_permission("visits:create")), Depends(require_study_not_locked())], ) async def create_visit( study_id: uuid.UUID, subject_id: uuid.UUID, visit_in: VisitCreate, db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> VisitRead: subject = await _ensure_subject(db, study_id, subject_id) 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="首次新增访视必须填写计划访视日期") visit = await visit_crud.create_visit( db, study_id=study_id, visit_in=visit_in, subject=subject, visit_code=next_visit_code, planned_date=visit_in.planned_date, ) if next_visit_code == "V1" and visit_in.planned_date: study = await study_crud.get(db, study_id) if study: await visit_crud.create_scheduled_visits( db, study_id=study_id, subject=subject, base_date=visit_in.planned_date, visit_schedule=study.visit_schedule, ) subject = await subject_crud.sync_subject_status(db, subject) await audit_crud.log_action( db, study_id=study_id, entity_type="visit", entity_id=visit.id, action="CREATE_VISIT", detail=f"访视 {visit.visit_code} 已创建", operator_id=current_user.id, operator_role=current_user.role, ) return visit @router.post( "/early-termination", response_model=VisitRead, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_api_permission("visits:create")), Depends(require_study_not_locked())], ) async def create_early_termination( study_id: uuid.UUID, subject_id: uuid.UUID, termination_in: EarlyTerminationCreate, db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> VisitRead: subject = await _ensure_subject(db, study_id, subject_id) await _ensure_subject_active(db, subject) reason = termination_in.reason.strip() if not reason: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="提前终止原因不能为空") if subject.baseline_date and termination_in.termination_date < subject.baseline_date: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="提前终止日期不能早于基线/治疗日期") try: visit = await visit_crud.create_early_termination_visit( db, study_id=study_id, subject=subject, termination_date=termination_in.termination_date, reason=reason, notes=termination_in.notes, ) except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc await subject_crud.update_subject( db, subject, SubjectUpdate( completion_date=termination_in.termination_date, drop_reason=reason, ), ) await audit_crud.log_action( db, study_id=study_id, entity_type="visit", entity_id=visit.id, action="CREATE_EARLY_TERMINATION", detail=f"参与者 {subject.subject_no} 已提前终止", operator_id=current_user.id, operator_role=current_user.role, ) return visit @router.patch( "/{visit_id}", response_model=VisitRead, dependencies=[Depends(require_api_permission("visits:update")), Depends(require_study_not_locked())], ) async def update_visit( study_id: uuid.UUID, subject_id: uuid.UUID, visit_id: uuid.UUID, visit_in: VisitUpdate, db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> VisitRead: subject = await _ensure_subject(db, study_id, subject_id) await _ensure_subject_active(db, subject) visit = await visit_crud.get_visit(db, visit_id) if not visit or visit.subject_id != subject_id or visit.study_id != study_id: 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) await subject_crud.sync_subject_status(db, subject) detail = None if visit_in.status: detail = f"访视 {visit.visit_code} {visit.status} -> {visit_in.status}" await audit_crud.log_action( db, study_id=study_id, entity_type="visit", entity_id=visit_id, action="VISIT_STATUS_CHANGE" if detail else "UPDATE_VISIT", detail=detail or "访视已更新", operator_id=current_user.id, operator_role=current_user.role, ) return updated @router.delete( "/{visit_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_api_permission("visits:delete")), Depends(require_study_not_locked())], ) async def delete_visit( study_id: uuid.UUID, subject_id: uuid.UUID, visit_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> None: subject = await _ensure_subject(db, study_id, subject_id) await _ensure_subject_active(db, subject) visit = await visit_crud.get_visit(db, visit_id) if not visit or visit.subject_id != subject_id or visit.study_id != study_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="访视不存在") await visit_crud.delete_visit(db, visit) await subject_crud.sync_subject_status(db, subject) await audit_crud.log_action( db, study_id=study_id, entity_type="visit", entity_id=visit_id, action="DELETE_VISIT", detail=f"访视 {visit_id} 已删除", operator_id=current_user.id, operator_role=current_user.role, )