import hashlib import json import uuid from datetime import datetime, timezone from pathlib import Path from typing import Iterable import aiofiles from fastapi import HTTPException, UploadFile, status from fastapi.responses import FileResponse from sqlalchemy import update as sa_update from sqlalchemy.ext.asyncio import AsyncSession from app.core import rbac from app.crud import acknowledgement as acknowledgement_crud from app.crud import distribution as distribution_crud from app.crud import document as document_crud from app.crud import document_version as version_crud from app.crud import member as member_crud from app.crud import study as study_crud from app.crud import user as user_crud from app.crud import version_workflow as workflow_crud from app.crud import workflow_template as template_crud from app.models.acknowledgement import Acknowledgement, AcknowledgementType from app.models.audit_log import AuditLog from app.models.distribution import Distribution, DistributionTargetType from app.models.document import Document, DocumentScopeType, DocumentStatus from app.models.document_version import DocumentVersion, DocumentVersionStatus from app.models.version_workflow import VersionWorkflow, WorkflowStatus from app.models.workflow_action import WorkflowAction, WorkflowActionType from app.models.workflow_template import WorkflowTemplate from app.schemas.acknowledgement import AcknowledgementCreate from app.schemas.distribution import DistributionCreate, DistributionRead, DistributionStats from app.schemas.document import DocumentCreate, DocumentDetail, DocumentSummary from app.schemas.document_version import DocumentVersionRead, DocumentVersionSummary from app.schemas.user import UserDisplay UPLOAD_ROOT = Path(__file__).resolve().parent.parent / "uploads" / "documents" def _role_value(user) -> str: return user.role.value if hasattr(user.role, "value") else str(user.role) def _audit_detail(before: dict | None, after: dict | None) -> str: payload = {"before": before, "after": after} return json.dumps(payload, ensure_ascii=True) def _doc_snapshot(doc: Document) -> dict: return { "id": str(doc.id), "trial_id": str(doc.trial_id), "site_id": str(doc.site_id) if doc.site_id else None, "doc_no": doc.doc_no, "status": str(doc.status), "current_effective_version_id": str(doc.current_effective_version_id) if doc.current_effective_version_id else None, } def _version_snapshot(version: DocumentVersion) -> dict: return { "id": str(version.id), "document_id": str(version.document_id), "version_no": version.version_no, "status": str(version.status), "file_hash": version.file_hash, } async def _ensure_study_access(db: AsyncSession, trial_id: uuid.UUID, current_user, action: str): study = await study_crud.get(db, trial_id) if not study: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在") if current_user.role == "ADMIN": return None membership = await member_crud.get_member(db, trial_id, current_user.id) if not membership or not membership.is_active: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员") if not rbac.is_allowed(action, current_user, membership): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") return membership async def create_document( db: AsyncSession, doc_in: DocumentCreate, current_user, ) -> Document: await _ensure_study_access(db, doc_in.trial_id, current_user, action="create_document") if doc_in.scope_type == DocumentScopeType.SITE and not doc_in.site_id: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="分中心文件需指定分中心") site_id = doc_in.site_id if doc_in.scope_type == DocumentScopeType.SITE else None existing = await document_crud.get_by_trial_doc_no(db, doc_in.trial_id, doc_in.doc_no) if existing: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="文档编号已存在") doc = Document( trial_id=doc_in.trial_id, site_id=site_id, doc_no=doc_in.doc_no, doc_type=doc_in.doc_type, title=doc_in.title, scope_type=doc_in.scope_type, owner_id=doc_in.owner_id, description=doc_in.description, ) db.add(doc) db.add( AuditLog( study_id=doc_in.trial_id, entity_type="DOCUMENT", entity_id=doc.id, action="DOCUMENT_CREATED", detail=_audit_detail(None, _doc_snapshot(doc)), operator_id=current_user.id, operator_role=_role_value(current_user), ) ) await db.commit() await db.refresh(doc) return doc async def list_documents( db: AsyncSession, *, trial_id: uuid.UUID, site_id: uuid.UUID | None, doc_type: str | None, status: str | None, scope_type: str | None, skip: int, limit: int, current_user, ) -> list[DocumentSummary]: await _ensure_study_access(db, trial_id, current_user, action="view") docs = await document_crud.list_documents( db, trial_id=trial_id, site_id=site_id, doc_type=doc_type, status=status, scope_type=scope_type, skip=skip, limit=limit, ) summaries: list[DocumentSummary] = [] for doc in docs: current_version = None if doc.current_effective_version_id: version = await version_crud.get(db, doc.current_effective_version_id) if version: current_version = DocumentVersionSummary.model_validate(version) summaries.append( DocumentSummary( id=doc.id, trial_id=doc.trial_id, site_id=doc.site_id, doc_no=doc.doc_no, doc_type=doc.doc_type, title=doc.title, scope_type=doc.scope_type, owner_id=doc.owner_id, status=doc.status, current_effective_version_id=doc.current_effective_version_id, current_effective_version=current_version, created_at=doc.created_at, updated_at=doc.updated_at, ) ) return summaries async def get_document_detail( db: AsyncSession, document_id: uuid.UUID, current_user, ) -> DocumentDetail: doc = await document_crud.get(db, document_id) if not doc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在") await _ensure_study_access(db, doc.trial_id, current_user, action="view") versions = await version_crud.list_by_document(db, document_id) current_version = None if doc.current_effective_version_id: version = await version_crud.get(db, doc.current_effective_version_id) if version: current_version = DocumentVersionSummary.model_validate(version) distribution_stats = await _aggregate_distribution_stats(db, doc.current_effective_version_id) owner = None if doc.owner_id: owner_obj = await user_crud.get_by_id(db, doc.owner_id) if owner_obj: owner = UserDisplay.model_validate(owner_obj) return DocumentDetail( id=doc.id, trial_id=doc.trial_id, site_id=doc.site_id, doc_no=doc.doc_no, doc_type=doc.doc_type, title=doc.title, scope_type=doc.scope_type, owner_id=doc.owner_id, status=doc.status, current_effective_version_id=doc.current_effective_version_id, current_effective_version=current_version, description=doc.description, created_at=doc.created_at, updated_at=doc.updated_at, owner=owner, version_timeline=[DocumentVersionRead.model_validate(v) for v in versions], distribution_stats=distribution_stats, ) async def create_version( db: AsyncSession, document_id: uuid.UUID, *, version_no: str, change_summary: str | None, parent_version_id: uuid.UUID | None, file: UploadFile, current_user, ) -> DocumentVersion: doc = await document_crud.get(db, document_id) if not doc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在") await _ensure_study_access(db, doc.trial_id, current_user, action="create_version") existing = await version_crud.get_by_document_version_no(db, document_id, version_no) if existing: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="版本号已存在") if parent_version_id: parent = await version_crud.get(db, parent_version_id) if not parent or parent.document_id != document_id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="父版本不存在或不属于该文档") content = await file.read() file_hash = hashlib.sha256(content).hexdigest() dest_dir = UPLOAD_ROOT / str(document_id) dest_dir.mkdir(parents=True, exist_ok=True) unique_name = f"{uuid.uuid4()}{Path(file.filename).suffix}" dest_path = dest_dir / unique_name async with aiofiles.open(dest_path, "wb") as out_file: await out_file.write(content) version = DocumentVersion( document_id=document_id, version_no=version_no, parent_version_id=parent_version_id, status=DocumentVersionStatus.DRAFT, file_uri=str(dest_path), file_hash=file_hash, file_size=len(content), mime_type=file.content_type, change_summary=change_summary, created_by=current_user.id, ) db.add(version) db.add( AuditLog( study_id=doc.trial_id, entity_type="DOCUMENT_VERSION", entity_id=version.id, action="VERSION_CREATED", detail=_audit_detail(None, _version_snapshot(version)), operator_id=current_user.id, operator_role=_role_value(current_user), ) ) await db.commit() await db.refresh(version) return version async def submit_version( db: AsyncSession, version_id: uuid.UUID, template_id: uuid.UUID, comment: str | None, current_user, ) -> VersionWorkflow: version = await version_crud.get(db, version_id) if not version: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="版本不存在") doc = await document_crud.get(db, version.document_id) if not doc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在") await _ensure_study_access(db, doc.trial_id, current_user, action="submit") if version.status != DocumentVersionStatus.DRAFT: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="版本状态不允许提交") pending = await version_crud.list_pending_versions(db, version.document_id) if any(v.id != version.id for v in pending): raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="存在待审批版本") template = await template_crud.get(db, template_id) if not template: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="审批模板不存在") before = _version_snapshot(version) version.status = DocumentVersionStatus.SUBMITTED version.submitted_at = datetime.now(timezone.utc) workflow = VersionWorkflow( document_id=version.document_id, version_id=version.id, template_id=template_id, status=WorkflowStatus.PENDING, current_node=1, submitted_by=current_user.id, submitted_at=version.submitted_at, ) action = WorkflowAction( workflow=workflow, node_order=workflow.current_node, action=WorkflowActionType.SUBMIT, actor_id=current_user.id, comment=comment, ) db.add(version) db.add(workflow) db.add(action) db.add( AuditLog( study_id=doc.trial_id, entity_type="DOCUMENT_VERSION", entity_id=version.id, action="VERSION_SUBMITTED", detail=_audit_detail(before, _version_snapshot(version)), operator_id=current_user.id, operator_role=_role_value(current_user), ) ) await db.commit() await db.refresh(workflow) return workflow async def approve_version( db: AsyncSession, version_id: uuid.UUID, comment: str | None, current_user, ) -> VersionWorkflow: version = await version_crud.get(db, version_id) if not version: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="版本不存在") doc = await document_crud.get(db, version.document_id) if not doc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在") await _ensure_study_access(db, doc.trial_id, current_user, action="approve") workflow = await workflow_crud.get_by_version(db, version_id) if not workflow or workflow.status != WorkflowStatus.PENDING: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="审批流程不可用") before = _version_snapshot(version) version.status = DocumentVersionStatus.APPROVED version.approved_at = datetime.now(timezone.utc) workflow.status = WorkflowStatus.APPROVED workflow.completed_at = version.approved_at db.add(version) db.add(workflow) db.add( WorkflowAction( workflow_id=workflow.id, node_order=workflow.current_node, action=WorkflowActionType.APPROVE, actor_id=current_user.id, comment=comment, ) ) db.add( AuditLog( study_id=doc.trial_id, entity_type="DOCUMENT_VERSION", entity_id=version.id, action="VERSION_APPROVED", detail=_audit_detail(before, _version_snapshot(version)), operator_id=current_user.id, operator_role=_role_value(current_user), ) ) await db.commit() await db.refresh(workflow) return workflow async def reject_version( db: AsyncSession, version_id: uuid.UUID, comment: str | None, current_user, ) -> VersionWorkflow: version = await version_crud.get(db, version_id) if not version: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="版本不存在") doc = await document_crud.get(db, version.document_id) if not doc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在") await _ensure_study_access(db, doc.trial_id, current_user, action="approve") workflow = await workflow_crud.get_by_version(db, version_id) if not workflow or workflow.status != WorkflowStatus.PENDING: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="审批流程不可用") before = _version_snapshot(version) version.status = DocumentVersionStatus.WITHDRAWN version.withdrawn_at = datetime.now(timezone.utc) workflow.status = WorkflowStatus.REJECTED workflow.completed_at = version.withdrawn_at db.add(version) db.add(workflow) db.add( WorkflowAction( workflow_id=workflow.id, node_order=workflow.current_node, action=WorkflowActionType.REJECT, actor_id=current_user.id, comment=comment, ) ) db.add( AuditLog( study_id=doc.trial_id, entity_type="DOCUMENT_VERSION", entity_id=version.id, action="VERSION_REJECTED", detail=_audit_detail(before, _version_snapshot(version)), operator_id=current_user.id, operator_role=_role_value(current_user), ) ) await db.commit() await db.refresh(workflow) return workflow async def make_version_effective( db: AsyncSession, version_id: uuid.UUID, current_user, ) -> DocumentVersion: version = await version_crud.get(db, version_id) if not version: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="版本不存在") doc = await document_crud.get(db, version.document_id) if not doc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在") await _ensure_study_access(db, doc.trial_id, current_user, action="approve") if version.status != DocumentVersionStatus.APPROVED: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="版本未审批通过") now = datetime.now(timezone.utc) before = _version_snapshot(version) version.status = DocumentVersionStatus.EFFECTIVE version.effective_at = now await db.execute( sa_update(DocumentVersion) .where( DocumentVersion.document_id == version.document_id, DocumentVersion.status == DocumentVersionStatus.EFFECTIVE, DocumentVersion.id != version.id, ) .values(status=DocumentVersionStatus.SUPERSEDED, superseded_at=now) ) await db.execute( sa_update(Document) .where(Document.id == version.document_id) .values(current_effective_version_id=version.id) ) db.add(version) db.add( AuditLog( study_id=doc.trial_id, entity_type="DOCUMENT_VERSION", entity_id=version.id, action="VERSION_EFFECTIVE", detail=_audit_detail(before, _version_snapshot(version)), operator_id=current_user.id, operator_role=_role_value(current_user), ) ) await db.commit() await db.refresh(version) return version async def delete_document( db: AsyncSession, document_id: uuid.UUID, current_user, ) -> Document: doc = await document_crud.get(db, document_id) if not doc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在") await _ensure_study_access(db, doc.trial_id, current_user, action="delete_document") if doc.status == DocumentStatus.ARCHIVED: return doc before = _doc_snapshot(doc) doc.status = DocumentStatus.ARCHIVED db.add(doc) db.add( AuditLog( study_id=doc.trial_id, entity_type="DOCUMENT", entity_id=doc.id, action="DOCUMENT_ARCHIVED", detail=_audit_detail(before, _doc_snapshot(doc)), operator_id=current_user.id, operator_role=_role_value(current_user), ) ) await db.commit() await db.refresh(doc) return doc async def get_version_download_response( db: AsyncSession, version_id: uuid.UUID, current_user, ) -> FileResponse: version = await version_crud.get(db, version_id) if not version: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="版本不存在") doc = await document_crud.get(db, version.document_id) if not doc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在") await _ensure_study_access(db, doc.trial_id, current_user, action="view") file_path = Path(version.file_uri) if not file_path.exists(): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文件不存在") filename = file_path.name return FileResponse( path=str(file_path), filename=filename, media_type=version.mime_type or "application/octet-stream", headers={"Content-Disposition": f'inline; filename="{filename}"'}, ) async def list_workflow_templates( db: AsyncSession, trial_id: uuid.UUID | None, current_user, ) -> list[WorkflowTemplate]: if trial_id: await _ensure_study_access(db, trial_id, current_user, action="view") elif current_user.role != "ADMIN": raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") templates = await template_crud.list_active(db, trial_id) return list(templates) async def create_distributions( db: AsyncSession, version_id: uuid.UUID, payload: DistributionCreate, current_user, ) -> list[Distribution]: version = await version_crud.get(db, version_id) if not version: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="版本不存在") doc = await document_crud.get(db, version.document_id) if not doc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在") await _ensure_study_access(db, doc.trial_id, current_user, action="distribute") if version.status != DocumentVersionStatus.EFFECTIVE: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="只有生效版本可分发") created: list[Distribution] = [] for target in payload.targets: distribution = Distribution( document_id=version.document_id, version_id=version.id, target_type=target.target_type, target_id=target.target_id, due_at=payload.due_at, created_by=current_user.id, ) db.add(distribution) created.append(distribution) db.add( AuditLog( study_id=doc.trial_id, entity_type="DISTRIBUTION", entity_id=distribution.id, action="DISTRIBUTION_CREATED", detail=_audit_detail(None, {"version_id": str(version.id), "target_id": target.target_id}), operator_id=current_user.id, operator_role=_role_value(current_user), ) ) await db.commit() for distribution in created: await db.refresh(distribution) return created async def list_distributions( db: AsyncSession, version_id: uuid.UUID, current_user, ) -> list[DistributionRead]: version = await version_crud.get(db, version_id) if not version: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="版本不存在") doc = await document_crud.get(db, version.document_id) if not doc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在") await _ensure_study_access(db, doc.trial_id, current_user, action="view") distributions = await distribution_crud.list_by_version(db, version_id) result: list[DistributionRead] = [] for distribution in distributions: stats = await _stats_for_distribution(db, distribution.id, distribution.due_at) result.append( DistributionRead( id=distribution.id, document_id=distribution.document_id, version_id=distribution.version_id, target_type=distribution.target_type, target_id=distribution.target_id, status=distribution.status, due_at=distribution.due_at, created_by=distribution.created_by, created_at=distribution.created_at, stats=stats, ) ) return result async def create_acknowledgement( db: AsyncSession, distribution_id: uuid.UUID, payload: AcknowledgementCreate, current_user, ) -> Acknowledgement: distribution = await distribution_crud.get(db, distribution_id) if not distribution: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分发不存在") version = await version_crud.get(db, distribution.version_id) if not version: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="版本不存在") doc = await document_crud.get(db, version.document_id) if not doc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在") membership = await _ensure_study_access(db, doc.trial_id, current_user, action="ack") if distribution.target_type == DistributionTargetType.USER and distribution.target_id != str(current_user.id): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不在分发范围内") if distribution.target_type == DistributionTargetType.ROLE: role_value = _role_value(current_user) if distribution.target_id not in (role_value, getattr(membership, "role_in_study", "")): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不在分发范围内") if payload.ack_type != AcknowledgementType.RECEIVED: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="仅支持已接收回执") existing = await acknowledgement_crud.get_existing( db, distribution_id=distribution_id, user_id=current_user.id, ack_type=payload.ack_type, ) if existing: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="回执已存在") ack = Acknowledgement( distribution_id=distribution_id, user_id=current_user.id, site_id=None, ack_type=payload.ack_type, due_at=distribution.due_at, acked_at=datetime.now(timezone.utc), note=payload.evidence, ) db.add(ack) db.add( AuditLog( study_id=doc.trial_id, entity_type="ACKNOWLEDGEMENT", entity_id=ack.id, action="ACK_CREATED", detail=_audit_detail(None, {"distribution_id": str(distribution.id), "ack_type": payload.ack_type}), operator_id=current_user.id, operator_role=_role_value(current_user), ) ) await db.commit() await db.refresh(ack) return ack async def _stats_for_distribution( db: AsyncSession, distribution_id: uuid.UUID, due_at: datetime | None, ) -> DistributionStats: acks = await acknowledgement_crud.list_by_distribution(db, distribution_id) return _aggregate_ack_stats(acks, due_at, target_count=1) async def _aggregate_distribution_stats( db: AsyncSession, version_id: uuid.UUID | None, ) -> DistributionStats: if not version_id: return DistributionStats() distributions = await distribution_crud.list_by_version(db, version_id) stats = DistributionStats() for distribution in distributions: acks = await acknowledgement_crud.list_by_distribution(db, distribution.id) dist_stats = _aggregate_ack_stats(acks, distribution.due_at, target_count=1) stats.total += dist_stats.total stats.received += dist_stats.received # only RECEIVED is supported stats.overdue += dist_stats.overdue return stats def _aggregate_ack_stats( acks: Iterable[Acknowledgement], due_at: datetime | None, target_count: int, ) -> DistributionStats: stats = DistributionStats() stats.total = target_count for ack in acks: if ack.ack_type == AcknowledgementType.RECEIVED: stats.received += 1 if due_at: now = datetime.now(timezone.utc) if due_at < now and not acks: stats.overdue = target_count return stats