From f9ef5c109f82250154deee6cf69b157152042652 Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Wed, 14 Jan 2026 11:35:37 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=87=E4=BB=B6=E7=89=88=E6=9C=AC=E7=AE=A1?= =?UTF-8?q?=E7=90=86-=E5=88=9D=E6=AD=A5=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/alembic.ini | 36 + backend/alembic/env.py | 67 ++ backend/alembic/script.py.mako | 24 + backend/alembic/versions/.gitkeep | 1 + backend/app/api/v1/documents.py | 214 +++++ backend/app/api/v1/router.py | 3 +- backend/app/core/rbac.py | 45 + backend/app/crud/acknowledgement.py | 36 + backend/app/crud/distribution.py | 25 + backend/app/crud/document.py | 61 ++ backend/app/crud/document_version.py | 76 ++ backend/app/crud/version_workflow.py | 30 + backend/app/crud/workflow_action.py | 11 + backend/app/crud/workflow_template.py | 26 + backend/app/db/base.py | 8 + backend/app/models/acknowledgement.py | 37 + backend/app/models/audit_log.py | 3 +- backend/app/models/distribution.py | 50 + backend/app/models/document.py | 69 ++ backend/app/models/document_version.py | 61 ++ backend/app/models/user.py | 1 + backend/app/models/version_workflow.py | 47 + backend/app/models/workflow_action.py | 32 + backend/app/models/workflow_node.py | 23 + backend/app/models/workflow_template.py | 33 + backend/app/schemas/acknowledgement.py | 29 + backend/app/schemas/distribution.py | 50 + backend/app/schemas/document.py | 70 ++ backend/app/schemas/document_version.py | 57 ++ backend/app/schemas/member.py | 2 +- backend/app/schemas/user.py | 2 +- backend/app/schemas/workflow_template.py | 29 + backend/app/services/document_service.py | 753 +++++++++++++++ backend/requirements.txt | 1 + database/init.sql | 712 +++++++++++++- database/seed_documents.sql | 153 +++ frontend/src/api/documents.ts | 56 ++ frontend/src/components/Layout.vue | 2 +- frontend/src/locales/zh-CN.ts | 137 ++- frontend/src/router/index.ts | 14 + frontend/src/styles/main.css | 112 ++- frontend/src/types/api.ts | 2 +- frontend/src/types/documents.ts | 126 +++ .../src/views/documents/DocumentDetail.vue | 890 ++++++++++++++++++ frontend/src/views/documents/DocumentList.vue | 388 ++++++++ .../src/views/ia/FileVersionManagement.vue | 29 +- 46 files changed, 4603 insertions(+), 30 deletions(-) create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/.gitkeep create mode 100644 backend/app/api/v1/documents.py create mode 100644 backend/app/core/rbac.py create mode 100644 backend/app/crud/acknowledgement.py create mode 100644 backend/app/crud/distribution.py create mode 100644 backend/app/crud/document.py create mode 100644 backend/app/crud/document_version.py create mode 100644 backend/app/crud/version_workflow.py create mode 100644 backend/app/crud/workflow_action.py create mode 100644 backend/app/crud/workflow_template.py create mode 100644 backend/app/models/acknowledgement.py create mode 100644 backend/app/models/distribution.py create mode 100644 backend/app/models/document.py create mode 100644 backend/app/models/document_version.py create mode 100644 backend/app/models/version_workflow.py create mode 100644 backend/app/models/workflow_action.py create mode 100644 backend/app/models/workflow_node.py create mode 100644 backend/app/models/workflow_template.py create mode 100644 backend/app/schemas/acknowledgement.py create mode 100644 backend/app/schemas/distribution.py create mode 100644 backend/app/schemas/document.py create mode 100644 backend/app/schemas/document_version.py create mode 100644 backend/app/schemas/workflow_template.py create mode 100644 backend/app/services/document_service.py create mode 100644 database/seed_documents.sql create mode 100644 frontend/src/api/documents.ts create mode 100644 frontend/src/types/documents.ts create mode 100644 frontend/src/views/documents/DocumentDetail.vue create mode 100644 frontend/src/views/documents/DocumentList.vue diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 00000000..a54181d5 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,36 @@ +[alembic] +script_location = alembic +sqlalchemy.url = driver://user:pass@localhost/dbname + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 00000000..4f3a2106 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,67 @@ +from __future__ import with_statement + +import asyncio +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.ext.asyncio import async_engine_from_config + +from app.core.config import settings +from app.db.base import Base + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def get_database_url() -> str: + return settings.DATABASE_URL + + +def run_migrations_offline() -> None: + url = get_database_url() + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + compare_type=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection) -> None: + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online() -> None: + configuration = config.get_section(config.config_ini_section, {}) + configuration["sqlalchemy.url"] = get_database_url() + connectable = async_engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + asyncio.run(run_migrations_online()) diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 00000000..e400c02c --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa + +${imports if imports else ""} + +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/.gitkeep b/backend/alembic/versions/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/backend/alembic/versions/.gitkeep @@ -0,0 +1 @@ + diff --git a/backend/app/api/v1/documents.py b/backend/app/api/v1/documents.py new file mode 100644 index 00000000..d5d190b0 --- /dev/null +++ b/backend/app/api/v1/documents.py @@ -0,0 +1,214 @@ +import uuid +from typing import Optional + +from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status +from fastapi.responses import FileResponse +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_current_user, get_db_session +from app.crud import document_version as version_crud +from app.schemas.acknowledgement import AcknowledgementCreate, AcknowledgementRead +from app.schemas.common import PaginatedResponse +from app.schemas.distribution import DistributionCreate, DistributionRead +from app.schemas.document import DocumentCreate, DocumentDetail, DocumentSummary +from app.schemas.document_version import DocumentVersionRead, VersionActionRequest, VersionSubmitRequest +from app.schemas.workflow_template import WorkflowTemplateRead +from app.services import document_service +from app.utils.pagination import paginate + +router = APIRouter() + + +@router.post( + "/documents", + response_model=DocumentSummary, + status_code=status.HTTP_201_CREATED, +) +async def create_document( + payload: DocumentCreate, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> DocumentSummary: + doc = await document_service.create_document(db, payload, current_user) + return DocumentSummary.model_validate(doc) + + +@router.get("/documents", response_model=PaginatedResponse[DocumentSummary]) +async def list_documents( + trial_id: uuid.UUID = Query(...), + site_id: Optional[uuid.UUID] = Query(None), + doc_type: Optional[str] = Query(None), + scope_type: Optional[str] = Query(None), + status_value: Optional[str] = Query(None, alias="status"), + skip: int = 0, + limit: int = 100, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> PaginatedResponse[DocumentSummary]: + items = await document_service.list_documents( + db, + trial_id=trial_id, + site_id=site_id, + doc_type=doc_type, + status=status_value, + scope_type=scope_type, + skip=skip, + limit=limit, + current_user=current_user, + ) + return paginate(items, total=len(items)) + + +@router.get("/documents/{document_id}", response_model=DocumentDetail) +async def get_document_detail( + document_id: uuid.UUID, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> DocumentDetail: + return await document_service.get_document_detail(db, document_id, current_user) + + +@router.delete("/documents/{document_id}", response_model=DocumentSummary) +async def delete_document( + document_id: uuid.UUID, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> DocumentSummary: + doc = await document_service.delete_document(db, document_id, current_user) + return DocumentSummary.model_validate(doc) + + +@router.post( + "/documents/{document_id}/versions", + response_model=DocumentVersionRead, + status_code=status.HTTP_201_CREATED, +) +async def create_version( + document_id: uuid.UUID, + version_no: str = Form(...), + change_summary: Optional[str] = Form(None), + parent_version_id: Optional[uuid.UUID] = Form(None), + file: UploadFile = File(...), + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> DocumentVersionRead: + version = await document_service.create_version( + db, + document_id, + version_no=version_no, + change_summary=change_summary, + parent_version_id=parent_version_id, + file=file, + current_user=current_user, + ) + return DocumentVersionRead.model_validate(version) + + +@router.post("/versions/{version_id}/submit", response_model=DocumentVersionRead) +async def submit_version( + version_id: uuid.UUID, + payload: VersionSubmitRequest, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> DocumentVersionRead: + await document_service.submit_version(db, version_id, payload.template_id, payload.comment, current_user) + version = await version_crud.get(db, version_id) + if not version: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="版本不存在") + return DocumentVersionRead.model_validate(version) + + +@router.post("/versions/{version_id}/approve", response_model=DocumentVersionRead) +async def approve_version( + version_id: uuid.UUID, + payload: VersionActionRequest, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> DocumentVersionRead: + await document_service.approve_version(db, version_id, payload.comment, current_user) + version = await version_crud.get(db, version_id) + if not version: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="版本不存在") + return DocumentVersionRead.model_validate(version) + + +@router.post("/versions/{version_id}/reject", response_model=DocumentVersionRead) +async def reject_version( + version_id: uuid.UUID, + payload: VersionActionRequest, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> DocumentVersionRead: + await document_service.reject_version(db, version_id, payload.comment, current_user) + version = await version_crud.get(db, version_id) + if not version: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="版本不存在") + return DocumentVersionRead.model_validate(version) + + +@router.post("/versions/{version_id}/make-effective", response_model=DocumentVersionRead) +async def make_version_effective( + version_id: uuid.UUID, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> DocumentVersionRead: + version = await document_service.make_version_effective(db, version_id, current_user) + return DocumentVersionRead.model_validate(version) + + +@router.get("/versions/{version_id}/download", response_class=FileResponse) +async def download_version( + version_id: uuid.UUID, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +): + return await document_service.get_version_download_response(db, version_id, current_user) + + +@router.get("/workflow-templates", response_model=list[WorkflowTemplateRead]) +async def list_workflow_templates( + trial_id: uuid.UUID | None = None, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> list[WorkflowTemplateRead]: + templates = await document_service.list_workflow_templates(db, trial_id, current_user) + return [WorkflowTemplateRead.model_validate(t) for t in templates] + + +@router.post( + "/versions/{version_id}/distributions", + response_model=list[DistributionRead], + status_code=status.HTTP_201_CREATED, +) +async def create_distributions( + version_id: uuid.UUID, + payload: DistributionCreate, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> list[DistributionRead]: + distributions = await document_service.create_distributions(db, version_id, payload, current_user) + return [DistributionRead.model_validate(d) for d in distributions] + + +@router.get("/versions/{version_id}/distributions", response_model=list[DistributionRead]) +async def list_distributions( + version_id: uuid.UUID, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> list[DistributionRead]: + return await document_service.list_distributions(db, version_id, current_user) + + +@router.post( + "/distributions/{distribution_id}/acknowledgements", + response_model=AcknowledgementRead, + status_code=status.HTTP_201_CREATED, +) +async def create_acknowledgement( + distribution_id: uuid.UUID, + payload: AcknowledgementCreate, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> AcknowledgementRead: + ack = await document_service.create_acknowledgement(db, distribution_id, payload, current_user) + return AcknowledgementRead.model_validate(ack) diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 7f50eccb..37b1a0be 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.v1 import auth, users, admin_users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, finance_contracts, finance_specials, fees_contracts, fees_specials, fees_attachments, drug_shipments, startup, knowledge_notes, subject_histories, faq_categories, faqs +from app.api.v1 import auth, users, admin_users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, finance_contracts, finance_specials, fees_contracts, fees_specials, fees_attachments, drug_shipments, startup, knowledge_notes, subject_histories, faq_categories, faqs, documents api_router = APIRouter() api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) @@ -28,3 +28,4 @@ api_router.include_router(knowledge_notes.router, prefix="/studies/{study_id}/kn api_router.include_router(subject_histories.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-histories"]) api_router.include_router(faq_categories.router, prefix="/faqs/categories", tags=["faq-categories"]) api_router.include_router(faqs.router, prefix="/faqs/items", tags=["faqs"]) +api_router.include_router(documents.router, prefix="", tags=["documents"]) diff --git a/backend/app/core/rbac.py b/backend/app/core/rbac.py new file mode 100644 index 00000000..2d51d6e7 --- /dev/null +++ b/backend/app/core/rbac.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable + + +@dataclass(frozen=True) +class Scope: + trial_id: str | None = None + site_id: str | None = None + doc_type: str | None = None + + +ROLE_ACTIONS: dict[str, set[str]] = { + "ADMIN": { + "view", + "create_document", + "create_version", + "submit", + "approve", + "distribute", + "ack", + "delete_document", + }, + "QA": {"view", "create_document", "create_version", "submit", "approve", "distribute", "ack"}, + "PM": {"view", "create_document", "create_version", "submit", "approve", "distribute", "ack", "delete_document"}, + "CRA": {"view", "create_version", "submit", "ack"}, + "PV": {"view", "create_version", "submit", "ack"}, + "IMP": {"view", "create_version", "submit", "ack"}, +} + + +def is_allowed(action: str, user, membership) -> bool: + role_value = user.role.value if hasattr(user.role, "value") else str(user.role) + allowed = ROLE_ACTIONS.get(role_value, set()) + if action in allowed: + return True + member_role = getattr(membership, "role_in_study", None) + if member_role: + return action in ROLE_ACTIONS.get(member_role, set()) + return False + + +def is_scope_allowed(action: str, scope: Scope, user, membership) -> bool: + return is_allowed(action, user, membership) diff --git a/backend/app/crud/acknowledgement.py b/backend/app/crud/acknowledgement.py new file mode 100644 index 00000000..9921afee --- /dev/null +++ b/backend/app/crud/acknowledgement.py @@ -0,0 +1,36 @@ +import uuid +from typing import Sequence + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.acknowledgement import Acknowledgement, AcknowledgementType + + +async def create(db: AsyncSession, ack: Acknowledgement, *, commit: bool = True) -> Acknowledgement: + db.add(ack) + if commit: + await db.commit() + await db.refresh(ack) + return ack + + +async def get_existing( + db: AsyncSession, + distribution_id: uuid.UUID, + user_id: uuid.UUID, + ack_type: AcknowledgementType, +) -> Acknowledgement | None: + result = await db.execute( + select(Acknowledgement).where( + Acknowledgement.distribution_id == distribution_id, + Acknowledgement.user_id == user_id, + Acknowledgement.ack_type == ack_type, + ) + ) + return result.scalar_one_or_none() + + +async def list_by_distribution(db: AsyncSession, distribution_id: uuid.UUID) -> Sequence[Acknowledgement]: + result = await db.execute(select(Acknowledgement).where(Acknowledgement.distribution_id == distribution_id)) + return result.scalars().all() diff --git a/backend/app/crud/distribution.py b/backend/app/crud/distribution.py new file mode 100644 index 00000000..f98cc32d --- /dev/null +++ b/backend/app/crud/distribution.py @@ -0,0 +1,25 @@ +import uuid +from typing import Sequence + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.distribution import Distribution + + +async def create(db: AsyncSession, distribution: Distribution, *, commit: bool = True) -> Distribution: + db.add(distribution) + if commit: + await db.commit() + await db.refresh(distribution) + return distribution + + +async def list_by_version(db: AsyncSession, version_id: uuid.UUID) -> Sequence[Distribution]: + result = await db.execute(select(Distribution).where(Distribution.version_id == version_id)) + return result.scalars().all() + + +async def get(db: AsyncSession, distribution_id: uuid.UUID) -> Distribution | None: + result = await db.execute(select(Distribution).where(Distribution.id == distribution_id)) + return result.scalar_one_or_none() diff --git a/backend/app/crud/document.py b/backend/app/crud/document.py new file mode 100644 index 00000000..05acac56 --- /dev/null +++ b/backend/app/crud/document.py @@ -0,0 +1,61 @@ +import uuid +from typing import Sequence + +from sqlalchemy import select, update as sa_update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.document import Document + + +async def create(db: AsyncSession, doc: Document, *, commit: bool = True) -> Document: + db.add(doc) + if commit: + await db.commit() + await db.refresh(doc) + return doc + + +async def get(db: AsyncSession, document_id: uuid.UUID) -> Document | None: + result = await db.execute(select(Document).where(Document.id == document_id)) + return result.scalar_one_or_none() + + +async def get_by_trial_doc_no(db: AsyncSession, trial_id: uuid.UUID, doc_no: str) -> Document | None: + result = await db.execute(select(Document).where(Document.trial_id == trial_id, Document.doc_no == doc_no)) + return result.scalar_one_or_none() + + +async def list_documents( + db: AsyncSession, + *, + trial_id: uuid.UUID, + site_id: uuid.UUID | None = None, + doc_type: str | None = None, + status: str | None = None, + scope_type: str | None = None, + skip: int = 0, + limit: int = 100, +) -> Sequence[Document]: + stmt = select(Document).where(Document.trial_id == trial_id) + if scope_type: + stmt = stmt.where(Document.scope_type == scope_type) + if doc_type: + stmt = stmt.where(Document.doc_type == doc_type) + if status: + stmt = stmt.where(Document.status == status) + if site_id: + stmt = stmt.where(Document.site_id == site_id) + stmt = stmt.order_by(Document.updated_at.desc()).offset(skip).limit(limit) + result = await db.execute(stmt) + return result.scalars().all() + + +async def update(db: AsyncSession, document_id: uuid.UUID, values: dict, *, commit: bool = True) -> None: + if values: + await db.execute( + sa_update(Document) + .where(Document.id == document_id) + .values(**values) + ) + if commit: + await db.commit() diff --git a/backend/app/crud/document_version.py b/backend/app/crud/document_version.py new file mode 100644 index 00000000..9c52dd70 --- /dev/null +++ b/backend/app/crud/document_version.py @@ -0,0 +1,76 @@ +import uuid +from typing import Sequence + +from sqlalchemy import select, update as sa_update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.document_version import DocumentVersion, DocumentVersionStatus + + +async def create(db: AsyncSession, version: DocumentVersion, *, commit: bool = True) -> DocumentVersion: + db.add(version) + if commit: + await db.commit() + await db.refresh(version) + return version + + +async def get(db: AsyncSession, version_id: uuid.UUID) -> DocumentVersion | None: + result = await db.execute(select(DocumentVersion).where(DocumentVersion.id == version_id)) + return result.scalar_one_or_none() + + +async def get_by_document_version_no( + db: AsyncSession, + document_id: uuid.UUID, + version_no: str, +) -> DocumentVersion | None: + result = await db.execute( + select(DocumentVersion).where( + DocumentVersion.document_id == document_id, + DocumentVersion.version_no == version_no, + ) + ) + return result.scalar_one_or_none() + + +async def list_by_document( + db: AsyncSession, + document_id: uuid.UUID, + skip: int = 0, + limit: int = 200, +) -> Sequence[DocumentVersion]: + result = await db.execute( + select(DocumentVersion) + .where(DocumentVersion.document_id == document_id) + .order_by(DocumentVersion.created_at.desc()) + .offset(skip) + .limit(limit) + ) + return result.scalars().all() + + +async def list_pending_versions(db: AsyncSession, document_id: uuid.UUID) -> Sequence[DocumentVersion]: + result = await db.execute( + select(DocumentVersion).where( + DocumentVersion.document_id == document_id, + DocumentVersion.status.in_( + [ + DocumentVersionStatus.SUBMITTED, + DocumentVersionStatus.APPROVED, + ] + ), + ) + ) + return result.scalars().all() + + +async def update(db: AsyncSession, version_id: uuid.UUID, values: dict, *, commit: bool = True) -> None: + if values: + await db.execute( + sa_update(DocumentVersion) + .where(DocumentVersion.id == version_id) + .values(**values) + ) + if commit: + await db.commit() diff --git a/backend/app/crud/version_workflow.py b/backend/app/crud/version_workflow.py new file mode 100644 index 00000000..8a84c811 --- /dev/null +++ b/backend/app/crud/version_workflow.py @@ -0,0 +1,30 @@ +import uuid +from typing import Sequence + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.version_workflow import VersionWorkflow + + +async def create(db: AsyncSession, workflow: VersionWorkflow, *, commit: bool = True) -> VersionWorkflow: + db.add(workflow) + if commit: + await db.commit() + await db.refresh(workflow) + return workflow + + +async def get(db: AsyncSession, workflow_id: uuid.UUID) -> VersionWorkflow | None: + result = await db.execute(select(VersionWorkflow).where(VersionWorkflow.id == workflow_id)) + return result.scalar_one_or_none() + + +async def get_by_version(db: AsyncSession, version_id: uuid.UUID) -> VersionWorkflow | None: + result = await db.execute(select(VersionWorkflow).where(VersionWorkflow.version_id == version_id)) + return result.scalar_one_or_none() + + +async def list_by_document(db: AsyncSession, document_id: uuid.UUID) -> Sequence[VersionWorkflow]: + result = await db.execute(select(VersionWorkflow).where(VersionWorkflow.document_id == document_id)) + return result.scalars().all() diff --git a/backend/app/crud/workflow_action.py b/backend/app/crud/workflow_action.py new file mode 100644 index 00000000..22ba790c --- /dev/null +++ b/backend/app/crud/workflow_action.py @@ -0,0 +1,11 @@ +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.workflow_action import WorkflowAction + + +async def create(db: AsyncSession, action: WorkflowAction, *, commit: bool = True) -> WorkflowAction: + db.add(action) + if commit: + await db.commit() + await db.refresh(action) + return action diff --git a/backend/app/crud/workflow_template.py b/backend/app/crud/workflow_template.py new file mode 100644 index 00000000..c19cfd06 --- /dev/null +++ b/backend/app/crud/workflow_template.py @@ -0,0 +1,26 @@ +import uuid +from typing import Sequence + +from sqlalchemy import select +from sqlalchemy.orm import selectinload + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.workflow_template import WorkflowTemplate + + +async def get(db: AsyncSession, template_id: uuid.UUID) -> WorkflowTemplate | None: + result = await db.execute(select(WorkflowTemplate).where(WorkflowTemplate.id == template_id)) + return result.scalar_one_or_none() + + +async def list_active(db: AsyncSession, trial_id: uuid.UUID | None = None) -> Sequence[WorkflowTemplate]: + stmt = ( + select(WorkflowTemplate) + .where(WorkflowTemplate.is_active.is_(True)) + .options(selectinload(WorkflowTemplate.nodes)) + ) + if trial_id: + stmt = stmt.where(WorkflowTemplate.trial_id == trial_id) + result = await db.execute(stmt) + return result.scalars().all() diff --git a/backend/app/db/base.py b/backend/app/db/base.py index bea62225..7ffc366d 100644 --- a/backend/app/db/base.py +++ b/backend/app/db/base.py @@ -7,6 +7,14 @@ from app.models.site import Site # noqa: F401 from app.models.study_member import StudyMember # noqa: F401 from app.models.attachment import Attachment # noqa: F401 from app.models.audit_log import AuditLog # noqa: F401 +from app.models.document import Document # noqa: F401 +from app.models.document_version import DocumentVersion # noqa: F401 +from app.models.workflow_template import WorkflowTemplate # noqa: F401 +from app.models.workflow_node import WorkflowNode # noqa: F401 +from app.models.version_workflow import VersionWorkflow # noqa: F401 +from app.models.workflow_action import WorkflowAction # noqa: F401 +from app.models.distribution import Distribution # noqa: F401 +from app.models.acknowledgement import Acknowledgement # noqa: F401 from app.models.milestone import Milestone # noqa: F401 from app.models.subject import Subject # noqa: F401 from app.models.visit import Visit # noqa: F401 diff --git a/backend/app/models/acknowledgement.py b/backend/app/models/acknowledgement.py new file mode 100644 index 00000000..6edbc869 --- /dev/null +++ b/backend/app/models/acknowledgement.py @@ -0,0 +1,37 @@ +import enum +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Enum, ForeignKey, Text, UniqueConstraint, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base_class import Base + + +class AcknowledgementType(str, enum.Enum): + RECEIVED = "RECEIVED" + READ = "READ" + TRAINED = "TRAINED" + + +class Acknowledgement(Base): + __tablename__ = "acknowledgements" + __table_args__ = ( + UniqueConstraint("distribution_id", "user_id", "ack_type", name="uq_ack_distribution_user_type"), + ) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + distribution_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("distributions.id"), nullable=False) + user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + site_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), nullable=True) + ack_type: Mapped[AcknowledgementType] = mapped_column( + Enum(AcknowledgementType, name="acknowledgement_type"), + nullable=False, + ) + due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + acked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + note: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + + distribution = relationship("Distribution", back_populates="acknowledgements") diff --git a/backend/app/models/audit_log.py b/backend/app/models/audit_log.py index 63903eca..49628bd9 100644 --- a/backend/app/models/audit_log.py +++ b/backend/app/models/audit_log.py @@ -1,7 +1,7 @@ import uuid from datetime import datetime -from sqlalchemy import DateTime, ForeignKey, String, Text, func +from sqlalchemy import DateTime, ForeignKey, Index, String, Text, func from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column @@ -10,6 +10,7 @@ from app.db.base_class import Base class AuditLog(Base): __tablename__ = "audit_logs" + __table_args__ = (Index("ix_audit_logs_entity_at", "entity_type", "entity_id", "created_at"),) id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) study_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=True) diff --git a/backend/app/models/distribution.py b/backend/app/models/distribution.py new file mode 100644 index 00000000..992bebc3 --- /dev/null +++ b/backend/app/models/distribution.py @@ -0,0 +1,50 @@ +import enum +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Enum, ForeignKey, String, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base_class import Base + + +class DistributionTargetType(str, enum.Enum): + SITE = "SITE" + ROLE = "ROLE" + USER = "USER" + + +class DistributionStatus(str, enum.Enum): + ACTIVE = "ACTIVE" + CLOSED = "CLOSED" + + +class Distribution(Base): + __tablename__ = "distributions" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + document_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("documents.id"), nullable=False) + version_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("document_versions.id"), nullable=False) + target_type: Mapped[DistributionTargetType] = mapped_column( + Enum(DistributionTargetType, name="distribution_target_type"), + nullable=False, + ) + target_id: Mapped[str] = mapped_column(String(50), nullable=False) + status: Mapped[DistributionStatus] = mapped_column( + Enum(DistributionStatus, name="distribution_status"), + nullable=False, + server_default=DistributionStatus.ACTIVE.value, + ) + due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + + document = relationship("Document") + version = relationship("DocumentVersion", back_populates="distributions") + acknowledgements = relationship( + "Acknowledgement", + back_populates="distribution", + cascade="all, delete-orphan", + ) diff --git a/backend/app/models/document.py b/backend/app/models/document.py new file mode 100644 index 00000000..13dc78fb --- /dev/null +++ b/backend/app/models/document.py @@ -0,0 +1,69 @@ +import enum +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Enum, ForeignKey, String, Text, UniqueConstraint, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base_class import Base + + +class DocumentScopeType(str, enum.Enum): + GLOBAL = "GLOBAL" + SITE = "SITE" + DERIVED = "DERIVED" + + +class DocumentStatus(str, enum.Enum): + DRAFT = "DRAFT" + ACTIVE = "ACTIVE" + ARCHIVED = "ARCHIVED" + + +class Document(Base): + __tablename__ = "documents" + __table_args__ = (UniqueConstraint("trial_id", "doc_no", name="uq_documents_trial_doc_no"),) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + trial_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=False) + site_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), nullable=True) + doc_no: Mapped[str] = mapped_column(String(50), nullable=False) + doc_type: Mapped[str] = mapped_column(String(50), nullable=False) + title: Mapped[str] = mapped_column(String(200), nullable=False) + scope_type: Mapped[DocumentScopeType] = mapped_column( + Enum(DocumentScopeType, name="document_scope_type"), + nullable=False, + server_default=DocumentScopeType.GLOBAL.value, + ) + owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) + status: Mapped[DocumentStatus] = mapped_column( + Enum(DocumentStatus, name="document_status"), + nullable=False, + server_default=DocumentStatus.ACTIVE.value, + ) + current_effective_version_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("document_versions.id"), + nullable=True, + ) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + onupdate=func.now(), + ) + + versions = relationship( + "DocumentVersion", + back_populates="document", + cascade="all, delete-orphan", + foreign_keys="DocumentVersion.document_id", + ) + current_effective_version = relationship( + "DocumentVersion", + foreign_keys=[current_effective_version_id], + post_update=True, + ) diff --git a/backend/app/models/document_version.py b/backend/app/models/document_version.py new file mode 100644 index 00000000..91d7e796 --- /dev/null +++ b/backend/app/models/document_version.py @@ -0,0 +1,61 @@ +import enum +import uuid +from datetime import datetime + +from sqlalchemy import BigInteger, DateTime, Enum, ForeignKey, String, Text, UniqueConstraint, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base_class import Base + + +class DocumentVersionStatus(str, enum.Enum): + DRAFT = "DRAFT" + SUBMITTED = "SUBMITTED" + APPROVED = "APPROVED" + EFFECTIVE = "EFFECTIVE" + SUPERSEDED = "SUPERSEDED" + ARCHIVED = "ARCHIVED" + WITHDRAWN = "WITHDRAWN" + + +class DocumentVersion(Base): + __tablename__ = "document_versions" + __table_args__ = (UniqueConstraint("document_id", "version_no", name="uq_document_versions_doc_no"),) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + document_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("documents.id"), nullable=False) + version_no: Mapped[str] = mapped_column(String(50), nullable=False) + parent_version_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("document_versions.id"), + nullable=True, + ) + derived_from_version_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("document_versions.id"), + nullable=True, + ) + status: Mapped[DocumentVersionStatus] = mapped_column( + Enum(DocumentVersionStatus, name="document_version_status"), + nullable=False, + server_default=DocumentVersionStatus.DRAFT.value, + ) + effective_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + superseded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + file_uri: Mapped[str] = mapped_column(String(500), nullable=False) + file_hash: Mapped[str] = mapped_column(String(128), nullable=False) + file_size: Mapped[int] = mapped_column(BigInteger, nullable=False) + mime_type: Mapped[str | None] = mapped_column(String(100), nullable=True) + change_summary: Mapped[str | None] = mapped_column(Text, nullable=True) + created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) + submitted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + withdrawn_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + + document = relationship("Document", back_populates="versions", foreign_keys=[document_id]) + parent_version = relationship("DocumentVersion", remote_side=[id], foreign_keys=[parent_version_id]) + derived_from_version = relationship("DocumentVersion", remote_side=[id], foreign_keys=[derived_from_version_id]) + workflow_instance = relationship("VersionWorkflow", back_populates="version", uselist=False) + distributions = relationship("Distribution", back_populates="version") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index ece5f1e9..f45f61bb 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -15,6 +15,7 @@ class UserRole(str, enum.Enum): CRA = "CRA" PV = "PV" IMP = "IMP" + QA = "QA" class UserStatus(str, enum.Enum): diff --git a/backend/app/models/version_workflow.py b/backend/app/models/version_workflow.py new file mode 100644 index 00000000..cff0cd1a --- /dev/null +++ b/backend/app/models/version_workflow.py @@ -0,0 +1,47 @@ +import enum +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Enum, ForeignKey, Integer, UniqueConstraint, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base_class import Base + + +class WorkflowStatus(str, enum.Enum): + PENDING = "PENDING" + APPROVED = "APPROVED" + REJECTED = "REJECTED" + CANCELED = "CANCELED" + + +class VersionWorkflow(Base): + __tablename__ = "version_workflows" + __table_args__ = (UniqueConstraint("version_id", name="uq_version_workflow_version"),) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + document_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("documents.id"), nullable=False) + version_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("document_versions.id"), nullable=False) + template_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("workflow_templates.id"), nullable=False) + status: Mapped[WorkflowStatus] = mapped_column( + Enum(WorkflowStatus, name="workflow_status"), + nullable=False, + server_default=WorkflowStatus.PENDING.value, + ) + current_node: Mapped[int | None] = mapped_column(Integer, nullable=True) + submitted_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) + submitted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + onupdate=func.now(), + ) + + template = relationship("WorkflowTemplate") + version = relationship("DocumentVersion", back_populates="workflow_instance") + document = relationship("Document") + actions = relationship("WorkflowAction", back_populates="workflow", cascade="all, delete-orphan") diff --git a/backend/app/models/workflow_action.py b/backend/app/models/workflow_action.py new file mode 100644 index 00000000..cb7e5030 --- /dev/null +++ b/backend/app/models/workflow_action.py @@ -0,0 +1,32 @@ +import enum +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Enum, ForeignKey, Integer, Text, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base_class import Base + + +class WorkflowActionType(str, enum.Enum): + SUBMIT = "SUBMIT" + APPROVE = "APPROVE" + REJECT = "REJECT" + + +class WorkflowAction(Base): + __tablename__ = "workflow_actions" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + workflow_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("version_workflows.id"), nullable=False) + node_order: Mapped[int | None] = mapped_column(Integer, nullable=True) + action: Mapped[WorkflowActionType] = mapped_column( + Enum(WorkflowActionType, name="workflow_action_type"), + nullable=False, + ) + actor_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) + comment: Mapped[str | None] = mapped_column(Text, nullable=True) + acted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + + workflow = relationship("VersionWorkflow", back_populates="actions") diff --git a/backend/app/models/workflow_node.py b/backend/app/models/workflow_node.py new file mode 100644 index 00000000..5ad1f221 --- /dev/null +++ b/backend/app/models/workflow_node.py @@ -0,0 +1,23 @@ +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, UniqueConstraint, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base_class import Base + + +class WorkflowNode(Base): + __tablename__ = "workflow_nodes" + __table_args__ = (UniqueConstraint("template_id", "node_order", name="uq_workflow_nodes_order"),) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + template_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("workflow_templates.id"), nullable=False) + node_order: Mapped[int] = mapped_column(Integer, nullable=False) + role: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str | None] = mapped_column(String(100), nullable=True) + is_required: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + + template = relationship("WorkflowTemplate", back_populates="nodes") diff --git a/backend/app/models/workflow_template.py b/backend/app/models/workflow_template.py new file mode 100644 index 00000000..94d353df --- /dev/null +++ b/backend/app/models/workflow_template.py @@ -0,0 +1,33 @@ +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base_class import Base + + +class WorkflowTemplate(Base): + __tablename__ = "workflow_templates" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + trial_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=True) + name: Mapped[str] = mapped_column(String(200), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true") + created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + onupdate=func.now(), + ) + + nodes = relationship( + "WorkflowNode", + back_populates="template", + cascade="all, delete-orphan", + order_by="WorkflowNode.node_order", + ) diff --git a/backend/app/schemas/acknowledgement.py b/backend/app/schemas/acknowledgement.py new file mode 100644 index 00000000..e2b1ce64 --- /dev/null +++ b/backend/app/schemas/acknowledgement.py @@ -0,0 +1,29 @@ +import enum +import uuid +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict + + +class AcknowledgementType(str, enum.Enum): + RECEIVED = "RECEIVED" + + +class AcknowledgementCreate(BaseModel): + ack_type: AcknowledgementType + evidence: Optional[str] = None + + +class AcknowledgementRead(BaseModel): + id: uuid.UUID + distribution_id: uuid.UUID + user_id: uuid.UUID + site_id: Optional[uuid.UUID] = None + ack_type: AcknowledgementType + due_at: Optional[datetime] = None + acked_at: Optional[datetime] = None + note: Optional[str] = None + created_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/distribution.py b/backend/app/schemas/distribution.py new file mode 100644 index 00000000..04a7adb0 --- /dev/null +++ b/backend/app/schemas/distribution.py @@ -0,0 +1,50 @@ +import enum +import uuid +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class DistributionTargetType(str, enum.Enum): + SITE = "SITE" + ROLE = "ROLE" + USER = "USER" + + +class DistributionStatus(str, enum.Enum): + ACTIVE = "ACTIVE" + CLOSED = "CLOSED" + + +class DistributionTarget(BaseModel): + target_type: DistributionTargetType + target_id: str + + +class DistributionCreate(BaseModel): + targets: list[DistributionTarget] + due_at: Optional[datetime] = None + + +class DistributionStats(BaseModel): + total: int = 0 + received: int = 0 + read: int = 0 + trained: int = 0 + overdue: int = 0 + + +class DistributionRead(BaseModel): + id: uuid.UUID + document_id: uuid.UUID + version_id: uuid.UUID + target_type: DistributionTargetType + target_id: str + status: DistributionStatus + due_at: Optional[datetime] = None + created_by: Optional[uuid.UUID] = None + created_at: datetime + stats: DistributionStats = Field(default_factory=DistributionStats) + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/document.py b/backend/app/schemas/document.py new file mode 100644 index 00000000..a3f910fd --- /dev/null +++ b/backend/app/schemas/document.py @@ -0,0 +1,70 @@ +import enum +import uuid +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + +from app.schemas.document_version import DocumentVersionRead, DocumentVersionSummary +from app.schemas.distribution import DistributionStats +from app.schemas.user import UserDisplay + + +class DocumentScopeType(str, enum.Enum): + GLOBAL = "GLOBAL" + SITE = "SITE" + DERIVED = "DERIVED" + + +class DocumentStatus(str, enum.Enum): + DRAFT = "DRAFT" + ACTIVE = "ACTIVE" + ARCHIVED = "ARCHIVED" + + +class DocumentCreate(BaseModel): + trial_id: uuid.UUID + site_id: Optional[uuid.UUID] = None + doc_no: str + doc_type: str + title: str + scope_type: DocumentScopeType = DocumentScopeType.GLOBAL + owner_id: Optional[uuid.UUID] = None + description: Optional[str] = None + + +class DocumentUpdate(BaseModel): + title: Optional[str] = None + doc_type: Optional[str] = None + scope_type: Optional[DocumentScopeType] = None + owner_id: Optional[uuid.UUID] = None + site_id: Optional[uuid.UUID] = None + status: Optional[DocumentStatus] = None + description: Optional[str] = None + + +class DocumentSummary(BaseModel): + id: uuid.UUID + trial_id: uuid.UUID + site_id: Optional[uuid.UUID] = None + doc_no: str + doc_type: str + title: str + scope_type: DocumentScopeType + owner_id: Optional[uuid.UUID] + status: DocumentStatus + current_effective_version_id: Optional[uuid.UUID] + current_effective_version: Optional[DocumentVersionSummary] = None + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class DocumentDetail(DocumentSummary): + description: Optional[str] = None + owner: Optional[UserDisplay] = None + version_timeline: list[DocumentVersionRead] = Field(default_factory=list) + distribution_stats: DistributionStats = Field(default_factory=DistributionStats) + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/document_version.py b/backend/app/schemas/document_version.py new file mode 100644 index 00000000..6225919e --- /dev/null +++ b/backend/app/schemas/document_version.py @@ -0,0 +1,57 @@ +import enum +import uuid +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict + + +class DocumentVersionStatus(str, enum.Enum): + DRAFT = "DRAFT" + SUBMITTED = "SUBMITTED" + APPROVED = "APPROVED" + EFFECTIVE = "EFFECTIVE" + SUPERSEDED = "SUPERSEDED" + ARCHIVED = "ARCHIVED" + WITHDRAWN = "WITHDRAWN" + + +class DocumentVersionSummary(BaseModel): + id: uuid.UUID + version_no: str + status: DocumentVersionStatus + effective_at: Optional[datetime] = None + + model_config = ConfigDict(from_attributes=True) + + +class DocumentVersionRead(BaseModel): + id: uuid.UUID + document_id: uuid.UUID + version_no: str + parent_version_id: Optional[uuid.UUID] = None + derived_from_version_id: Optional[uuid.UUID] = None + status: DocumentVersionStatus + effective_at: Optional[datetime] = None + superseded_at: Optional[datetime] = None + file_uri: str + file_hash: str + file_size: int + mime_type: Optional[str] = None + change_summary: Optional[str] = None + created_by: Optional[uuid.UUID] = None + submitted_at: Optional[datetime] = None + approved_at: Optional[datetime] = None + withdrawn_at: Optional[datetime] = None + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class VersionSubmitRequest(BaseModel): + template_id: uuid.UUID + comment: Optional[str] = None + + +class VersionActionRequest(BaseModel): + comment: Optional[str] = None diff --git a/backend/app/schemas/member.py b/backend/app/schemas/member.py index 37d94523..63242f4b 100644 --- a/backend/app/schemas/member.py +++ b/backend/app/schemas/member.py @@ -5,7 +5,7 @@ from typing import Literal, Optional from pydantic import BaseModel, ConfigDict, Field from app.schemas.user import UserDisplay -StudyRole = Literal["PM", "CRA", "PV", "IMP", "ADMIN"] +StudyRole = Literal["PM", "CRA", "PV", "IMP", "QA", "ADMIN"] class StudyMemberCreate(BaseModel): diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 064555d5..bb9e5b8f 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -5,7 +5,7 @@ from typing import Literal, Optional from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator -UserRole = Literal["PM", "CRA", "PV", "IMP", "ADMIN"] +UserRole = Literal["PM", "CRA", "PV", "IMP", "QA", "ADMIN"] RegisterRole = Literal["PM", "CRA", "PV", "IMP"] UserStatus = Literal["PENDING", "ACTIVE", "REJECTED", "DISABLED"] diff --git a/backend/app/schemas/workflow_template.py b/backend/app/schemas/workflow_template.py new file mode 100644 index 00000000..88274acd --- /dev/null +++ b/backend/app/schemas/workflow_template.py @@ -0,0 +1,29 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class WorkflowNodeRead(BaseModel): + id: uuid.UUID + node_order: int + role: str + name: str | None = None + is_required: bool + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class WorkflowTemplateRead(BaseModel): + id: uuid.UUID + trial_id: uuid.UUID | None = None + name: str + description: str | None = None + is_active: bool + created_by: uuid.UUID | None = None + created_at: datetime + updated_at: datetime + nodes: list[WorkflowNodeRead] = [] + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/services/document_service.py b/backend/app/services/document_service.py new file mode 100644 index 00000000..8c96f260 --- /dev/null +++ b/backend/app/services/document_service.py @@ -0,0 +1,753 @@ +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 diff --git a/backend/requirements.txt b/backend/requirements.txt index 49b58b76..a1f564d0 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -13,3 +13,4 @@ pytest==8.1.1 pytest-asyncio==0.23.5 httpx==0.25.2 email-validator==2.1.1 +alembic==1.13.1 diff --git a/database/init.sql b/database/init.sql index a300698b..c7e5a5fc 100644 --- a/database/init.sql +++ b/database/init.sql @@ -2,7 +2,14 @@ DO $$ BEGIN - CREATE TYPE public.user_role AS ENUM ('ADMIN', 'PM', 'CRA', 'PV', 'IMP'); + CREATE TYPE public.user_role AS ENUM ('ADMIN', 'PM', 'CRA', 'PV', 'IMP', 'QA'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +DO $$ +BEGIN + ALTER TYPE public.user_role ADD VALUE IF NOT EXISTS 'QA'; EXCEPTION WHEN duplicate_object THEN null; END $$; @@ -517,7 +524,10 @@ INSERT INTO public.users ( ) VALUES ('11111111-1111-1111-1111-111111111111', 'admin@example.com', '$2y$12$FMfyBt7YfZWeh/x8WMEIA.S6VUTIMryk2NxWHyOOuJvd6YrDwHAdu', 'System Admin', 'ADMIN', 'SYSTEM', 'ACTIVE', '2025-01-05 08:00:00+00', '2025-01-05 08:00:00+00'), ('22222222-2222-2222-2222-222222222222', 'pm@example.com', '$2y$12$P9UDKu48ru84uVrlquqhXufmw/CfMEzQosC0X8eT2EmY/PgM/03j2', '项目经理', 'PM', 'PMO', 'ACTIVE', '2025-01-05 08:10:00+00', '2025-01-05 08:10:00+00'), - ('33333333-3333-3333-3333-333333333333', 'cra@example.com', '$2y$12$D/.ZeDrUByPZFPnAl9MnKuzKn2G4ctKQ9IAS/CqS2sfMdE/5NaKAK', 'CRA 用户', 'CRA', 'CRA', 'ACTIVE', '2025-01-05 08:20:00+00', '2025-01-05 08:20:00+00') + ('33333333-3333-3333-3333-333333333333', 'cra@example.com', '$2y$12$D/.ZeDrUByPZFPnAl9MnKuzKn2G4ctKQ9IAS/CqS2sfMdE/5NaKAK', 'CRA 用户', 'CRA', 'CRA', 'ACTIVE', '2025-01-05 08:20:00+00', '2025-01-05 08:20:00+00'), + ('44444444-4444-4444-4444-444444444444', 'qa@example.com', '$2y$12$P9UDKu48ru84uVrlquqhXufmw/CfMEzQosC0X8eT2EmY/PgM/03j2', 'QA 用户', 'QA', 'QA', 'ACTIVE', '2025-01-05 08:30:00+00', '2025-01-05 08:30:00+00'), + ('55555555-5555-5555-5555-555555555555', 'pv@example.com', '$2y$12$P9UDKu48ru84uVrlquqhXufmw/CfMEzQosC0X8eT2EmY/PgM/03j2', 'PV 用户', 'PV', 'PV', 'ACTIVE', '2025-01-05 08:40:00+00', '2025-01-05 08:40:00+00'), + ('66666666-6666-6666-6666-666666666666', 'imp@example.com', '$2y$12$P9UDKu48ru84uVrlquqhXufmw/CfMEzQosC0X8eT2EmY/PgM/03j2', '药品管理员', 'IMP', 'IMP', 'ACTIVE', '2025-01-05 08:50:00+00', '2025-01-05 08:50:00+00') ON CONFLICT (email) DO UPDATE SET password_hash = EXCLUDED.password_hash, full_name = EXCLUDED.full_name, @@ -566,7 +576,10 @@ INSERT INTO public.study_members ( ) VALUES ('dddddddd-dddd-dddd-dddd-dddddddddddd', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), (SELECT id FROM public.users WHERE email = 'admin@example.com'), 'ADMIN', true, '2025-01-06 10:00:00+00'), ('eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), (SELECT id FROM public.users WHERE email = 'pm@example.com'), 'PM', true, '2025-01-06 10:05:00+00'), - ('ffffffff-ffff-ffff-ffff-ffffffffffff', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), (SELECT id FROM public.users WHERE email = 'cra@example.com'), 'CRA', true, '2025-01-06 10:10:00+00') + ('ffffffff-ffff-ffff-ffff-ffffffffffff', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), (SELECT id FROM public.users WHERE email = 'cra@example.com'), 'CRA', true, '2025-01-06 10:10:00+00'), + ('11111111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), (SELECT id FROM public.users WHERE email = 'qa@example.com'), 'QA', true, '2025-01-06 10:15:00+00'), + ('22222222-3333-4444-5555-666666666666', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), (SELECT id FROM public.users WHERE email = 'pv@example.com'), 'PV', true, '2025-01-06 10:20:00+00'), + ('33333333-4444-5555-6666-777777777777', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), (SELECT id FROM public.users WHERE email = 'imp@example.com'), 'IMP', true, '2025-01-06 10:25:00+00') ON CONFLICT (study_id, user_id) DO UPDATE SET role_in_study = EXCLUDED.role_in_study, is_active = EXCLUDED.is_active; @@ -601,12 +614,12 @@ INSERT INTO public.finance_items ( ON CONFLICT (id) DO NOTHING; INSERT INTO public.drug_shipments ( - id, study_id, direction, site_name, drug_desc, ship_date, carrier, tracking_no, from_party, to_party, status, remark, created_by, created_at, updated_at + id, study_id, direction, center_id, site_name, ship_date, receive_date, quantity, batch_no, carrier, tracking_no, status, remark, created_by, created_at, updated_at ) VALUES - ('aaaa1111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '寄送', '北京协和医院', 'DP-001 片剂 50mg × 200 盒', '2025-01-12', '顺丰', 'SF100001', '中央库房', '北京协和医院药房', '运输中', '首批寄送', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-01-12 11:00:00+00', '2025-01-12 11:00:00+00'), - ('bbbb1111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '寄送', '上海瑞金医院', 'DP-001 片剂 50mg × 150 盒', '2025-01-13', '京东物流', 'JD200002', '中央库房', '上海瑞金医院药房', '已签收', '分中心补货', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-01-13 11:00:00+00', '2025-01-14 09:00:00+00'), - ('cccc1111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '回收', '北京协和医院', '空药盒及剩余药品回收', '2025-02-05', '顺丰', 'SF100003', '北京协和医院药房', '中央库房', '已回收', '首批回收', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-02-05 14:00:00+00', '2025-02-06 09:00:00+00'), - ('dddd1111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '回收', '上海瑞金医院', '冷链箱回收', '2025-02-08', '德邦', 'DB300004', '上海瑞金医院药房', '中央库房', '运输中', '回收途中', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-02-08 16:00:00+00', '2025-02-08 16:00:00+00') + ('aaaa1111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '寄送', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '北京协和医院', '2025-01-12', NULL, 200, 'DP-001', '顺丰', 'SF100001', '运输中', '首批寄送:DP-001 片剂 50mg × 200 盒', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-01-12 11:00:00+00', '2025-01-12 11:00:00+00'), + ('bbbb1111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '寄送', 'cccccccc-cccc-cccc-cccc-cccccccccccc', '上海瑞金医院', '2025-01-13', '2025-01-14', 150, 'DP-001', '京东物流', 'JD200002', '已签收', '分中心补货:DP-001 片剂 50mg × 150 盒', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-01-13 11:00:00+00', '2025-01-14 09:00:00+00'), + ('cccc1111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '回收', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '北京协和医院', '2025-02-05', '2025-02-06', NULL, 'DP-001', '顺丰', 'SF100003', '已回收', '首批回收:空药盒及剩余药品', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-02-05 14:00:00+00', '2025-02-06 09:00:00+00'), + ('dddd1111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '回收', 'cccccccc-cccc-cccc-cccc-cccccccccccc', '上海瑞金医院', '2025-02-08', NULL, NULL, 'DP-001', '德邦', 'DB300004', '运输中', '回收途中:冷链箱回收', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-02-08 16:00:00+00', '2025-02-08 16:00:00+00') ON CONFLICT (id) DO NOTHING; INSERT INTO public.startup_feasibility ( @@ -739,3 +752,686 @@ INSERT INTO public.attachments ( ('cccc0000-1111-2222-3333-444455556666', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'startup_ethics', '1111aaaa-2222-3333-4444-555555555555', 'ethics_beijing.pdf', '/code/app/uploads/study_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/startup_ethics_1111aaaa-2222-3333-4444-555555555555/ethics_beijing.pdf', 1536, 'application/pdf', (SELECT id FROM public.users WHERE email = 'pm@example.com'), '2025-01-20 12:00:00+00', false), ('dddd0000-1111-2222-3333-444455556666', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'knowledge_note', '4444aaaa-2222-3333-4444-555555555555', 'note_sample.txt', '/code/app/uploads/study_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/knowledge_note_4444aaaa-2222-3333-4444-555555555555/note_sample.txt', 512, 'text/plain', (SELECT id FROM public.users WHERE email = 'pm@example.com'), '2025-01-12 19:00:00+00', false) ON CONFLICT (id) DO NOTHING; + +-- ========================================= +-- Document version management module +-- ========================================= + +DO $$ +BEGIN + CREATE TYPE public.document_scope_type AS ENUM ('GLOBAL', 'SITE', 'DERIVED'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +DO $$ +BEGIN + CREATE TYPE public.document_status AS ENUM ('DRAFT', 'ACTIVE', 'ARCHIVED'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +DO $$ +BEGIN + CREATE TYPE public.document_version_status AS ENUM ( + 'DRAFT', 'SUBMITTED', 'APPROVED', 'EFFECTIVE', 'SUPERSEDED', 'ARCHIVED', 'WITHDRAWN' + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +DO $$ +BEGIN + CREATE TYPE public.workflow_status AS ENUM ('PENDING', 'APPROVED', 'REJECTED', 'CANCELED'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +DO $$ +BEGIN + CREATE TYPE public.workflow_action_type AS ENUM ('SUBMIT', 'APPROVE', 'REJECT'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +DO $$ +BEGIN + CREATE TYPE public.distribution_target_type AS ENUM ('SITE', 'ROLE', 'USER'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +DO $$ +BEGIN + CREATE TYPE public.distribution_status AS ENUM ('ACTIVE', 'CLOSED'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +DO $$ +BEGIN + CREATE TYPE public.acknowledgement_type AS ENUM ('RECEIVED', 'READ', 'TRAINED'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +CREATE TABLE IF NOT EXISTS public.documents ( + id uuid PRIMARY KEY, + trial_id uuid NOT NULL, + site_id uuid, + doc_no character varying(50) NOT NULL, + doc_type character varying(50) NOT NULL, + title character varying(200) NOT NULL, + scope_type public.document_scope_type NOT NULL DEFAULT 'GLOBAL', + owner_id uuid, + status public.document_status NOT NULL DEFAULT 'ACTIVE', + current_effective_version_id uuid, + description text, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT uq_documents_trial_doc_no UNIQUE (trial_id, doc_no), + CONSTRAINT fk_documents_trial_id FOREIGN KEY (trial_id) REFERENCES public.studies(id) ON DELETE RESTRICT, + CONSTRAINT fk_documents_site_id FOREIGN KEY (site_id) REFERENCES public.sites(id) ON DELETE SET NULL, + CONSTRAINT fk_documents_owner_id FOREIGN KEY (owner_id) REFERENCES public.users(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS public.workflow_templates ( + id uuid PRIMARY KEY, + trial_id uuid, + name character varying(200) NOT NULL, + description text, + is_active boolean NOT NULL DEFAULT true, + created_by uuid, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT fk_workflow_templates_trial_id FOREIGN KEY (trial_id) REFERENCES public.studies(id) ON DELETE RESTRICT, + CONSTRAINT fk_workflow_templates_created_by FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS public.workflow_nodes ( + id uuid PRIMARY KEY, + template_id uuid NOT NULL, + node_order integer NOT NULL, + role character varying(50) NOT NULL, + name character varying(100), + is_required boolean NOT NULL DEFAULT true, + created_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT uq_workflow_nodes_order UNIQUE (template_id, node_order), + CONSTRAINT fk_workflow_nodes_template_id FOREIGN KEY (template_id) REFERENCES public.workflow_templates(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS public.document_versions ( + id uuid PRIMARY KEY, + document_id uuid NOT NULL, + version_no character varying(50) NOT NULL, + parent_version_id uuid, + derived_from_version_id uuid, + status public.document_version_status NOT NULL DEFAULT 'DRAFT', + effective_at timestamp with time zone, + superseded_at timestamp with time zone, + file_uri character varying(500) NOT NULL, + file_hash character varying(128) NOT NULL, + file_size bigint NOT NULL, + mime_type character varying(100), + change_summary text, + created_by uuid, + submitted_at timestamp with time zone, + approved_at timestamp with time zone, + withdrawn_at timestamp with time zone, + created_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT uq_document_versions_doc_no UNIQUE (document_id, version_no), + CONSTRAINT fk_document_versions_document_id FOREIGN KEY (document_id) REFERENCES public.documents(id) ON DELETE CASCADE, + CONSTRAINT fk_document_versions_parent_id FOREIGN KEY (parent_version_id) REFERENCES public.document_versions(id) ON DELETE SET NULL, + CONSTRAINT fk_document_versions_derived_id FOREIGN KEY (derived_from_version_id) REFERENCES public.document_versions(id) ON DELETE SET NULL, + CONSTRAINT fk_document_versions_created_by FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE SET NULL +); + +DO $$ +BEGIN + ALTER TABLE public.documents + ADD CONSTRAINT fk_documents_current_effective_version_id + FOREIGN KEY (current_effective_version_id) + REFERENCES public.document_versions(id) + ON DELETE SET NULL; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +CREATE TABLE IF NOT EXISTS public.version_workflows ( + id uuid PRIMARY KEY, + document_id uuid NOT NULL, + version_id uuid NOT NULL, + template_id uuid NOT NULL, + status public.workflow_status NOT NULL DEFAULT 'PENDING', + current_node integer, + submitted_by uuid, + submitted_at timestamp with time zone, + completed_at timestamp with time zone, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT uq_version_workflow_version UNIQUE (version_id), + CONSTRAINT fk_version_workflows_document_id FOREIGN KEY (document_id) REFERENCES public.documents(id) ON DELETE CASCADE, + CONSTRAINT fk_version_workflows_version_id FOREIGN KEY (version_id) REFERENCES public.document_versions(id) ON DELETE CASCADE, + CONSTRAINT fk_version_workflows_template_id FOREIGN KEY (template_id) REFERENCES public.workflow_templates(id) ON DELETE RESTRICT, + CONSTRAINT fk_version_workflows_submitted_by FOREIGN KEY (submitted_by) REFERENCES public.users(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS public.workflow_actions ( + id uuid PRIMARY KEY, + workflow_id uuid NOT NULL, + node_order integer, + action public.workflow_action_type NOT NULL, + actor_id uuid, + comment text, + acted_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT fk_workflow_actions_workflow_id FOREIGN KEY (workflow_id) REFERENCES public.version_workflows(id) ON DELETE CASCADE, + CONSTRAINT fk_workflow_actions_actor_id FOREIGN KEY (actor_id) REFERENCES public.users(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS public.distributions ( + id uuid PRIMARY KEY, + document_id uuid NOT NULL, + version_id uuid NOT NULL, + target_type public.distribution_target_type NOT NULL, + target_id character varying(50) NOT NULL, + status public.distribution_status NOT NULL DEFAULT 'ACTIVE', + due_at timestamp with time zone, + closed_at timestamp with time zone, + created_by uuid, + created_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT fk_distributions_document_id FOREIGN KEY (document_id) REFERENCES public.documents(id) ON DELETE CASCADE, + CONSTRAINT fk_distributions_version_id FOREIGN KEY (version_id) REFERENCES public.document_versions(id) ON DELETE CASCADE, + CONSTRAINT fk_distributions_created_by FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS public.acknowledgements ( + id uuid PRIMARY KEY, + distribution_id uuid NOT NULL, + user_id uuid NOT NULL, + site_id uuid, + ack_type public.acknowledgement_type NOT NULL, + due_at timestamp with time zone, + acked_at timestamp with time zone, + note text, + created_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT uq_ack_distribution_user_type UNIQUE (distribution_id, user_id, ack_type), + CONSTRAINT fk_acknowledgements_distribution_id FOREIGN KEY (distribution_id) REFERENCES public.distributions(id) ON DELETE CASCADE, + CONSTRAINT fk_acknowledgements_user_id FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE RESTRICT, + CONSTRAINT fk_acknowledgements_site_id FOREIGN KEY (site_id) REFERENCES public.sites(id) ON DELETE SET NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS ix_documents_trial_doc_no ON public.documents (trial_id, doc_no); +CREATE INDEX IF NOT EXISTS ix_documents_site_id ON public.documents (site_id); +CREATE UNIQUE INDEX IF NOT EXISTS ix_document_versions_doc_no ON public.document_versions (document_id, version_no); +CREATE INDEX IF NOT EXISTS ix_document_versions_status ON public.document_versions (document_id, status); +CREATE UNIQUE INDEX IF NOT EXISTS uq_document_versions_pending ON public.document_versions (document_id) + WHERE status IN ('SUBMITTED', 'APPROVED'); +CREATE UNIQUE INDEX IF NOT EXISTS uq_document_pending_workflow ON public.version_workflows (document_id) + WHERE status IN ('PENDING', 'APPROVED'); +CREATE INDEX IF NOT EXISTS ix_distributions_version_target ON public.distributions (version_id, target_type, target_id); +CREATE INDEX IF NOT EXISTS ix_acknowledgements_distribution_type ON public.acknowledgements (distribution_id, ack_type); +CREATE INDEX IF NOT EXISTS ix_audit_logs_entity_at ON public.audit_logs (entity_type, entity_id, created_at); + +-- ========================================= +-- Demo data for document version management +-- ========================================= + +INSERT INTO public.workflow_templates ( + id, trial_id, name, description, is_active, created_by, created_at, updated_at +) VALUES + ( + '11111111-aaaa-bbbb-cccc-111111111111', + (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), + '标准审批(PM 单签)', + 'PM 单节点审批,适用于一般文件', + true, + (SELECT id FROM public.users WHERE email = 'pm@example.com'), + '2025-01-07 08:00:00+00', + '2025-01-07 08:00:00+00' + ), + ( + '11111111-aaaa-bbbb-cccc-111111111112', + (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), + '严格审批(CRA→PM→QA)', + '三节点审批,适用于关键文件', + true, + (SELECT id FROM public.users WHERE email = 'pm@example.com'), + '2025-01-07 08:20:00+00', + '2025-01-07 08:20:00+00' + ) +ON CONFLICT (id) DO UPDATE +SET name = EXCLUDED.name, + description = EXCLUDED.description, + is_active = EXCLUDED.is_active, + trial_id = EXCLUDED.trial_id, + created_by = EXCLUDED.created_by, + updated_at = EXCLUDED.updated_at; + +INSERT INTO public.workflow_nodes ( + id, template_id, node_order, role, name, is_required, created_at +) VALUES + ( + '22222222-aaaa-bbbb-cccc-222222222222', + '11111111-aaaa-bbbb-cccc-111111111111', + 1, + 'PM', + '项目经理审批', + true, + '2025-01-07 08:10:00+00' + ), + ( + '22222222-aaaa-bbbb-cccc-222222222223', + '11111111-aaaa-bbbb-cccc-111111111112', + 1, + 'CRA', + 'CRA 初审', + true, + '2025-01-07 08:30:00+00' + ), + ( + '22222222-aaaa-bbbb-cccc-222222222224', + '11111111-aaaa-bbbb-cccc-111111111112', + 2, + 'PM', + '项目经理复审', + true, + '2025-01-07 08:40:00+00' + ), + ( + '22222222-aaaa-bbbb-cccc-222222222225', + '11111111-aaaa-bbbb-cccc-111111111112', + 3, + 'QA', + 'QA 终审', + true, + '2025-01-07 08:50:00+00' + ) +ON CONFLICT (id) DO UPDATE +SET template_id = EXCLUDED.template_id, + node_order = EXCLUDED.node_order, + role = EXCLUDED.role, + name = EXCLUDED.name, + is_required = EXCLUDED.is_required; + +INSERT INTO public.documents ( + id, trial_id, site_id, doc_no, doc_type, title, scope_type, owner_id, status, current_effective_version_id, description, created_at, updated_at +) VALUES + ( + '33333333-aaaa-bbbb-cccc-333333333333', + (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), + NULL, + 'SOP-001', + 'SOP', + '中心文件管理 SOP', + 'GLOBAL', + (SELECT id FROM public.users WHERE email = 'pm@example.com'), + 'ACTIVE', + NULL, + 'Demo SOP document', + '2025-01-08 09:00:00+00', + '2025-01-12 09:00:00+00' + ), + ( + '44444444-aaaa-bbbb-cccc-444444444444', + (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), + NULL, + 'TRN-101', + 'Training', + '中心培训材料', + 'GLOBAL', + (SELECT id FROM public.users WHERE email = 'pm@example.com'), + 'ACTIVE', + NULL, + 'Training deck', + '2025-01-08 10:00:00+00', + '2025-01-10 10:00:00+00' + ), + ( + '55555555-aaaa-bbbb-cccc-555555555555', + (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), + (SELECT id FROM public.sites WHERE name = '北京协和医院' AND study_id = (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS')), + 'SITE-SOP-001', + 'SOP', + '北京协和中心 SOP', + 'SITE', + (SELECT id FROM public.users WHERE email = 'cra@example.com'), + 'ACTIVE', + NULL, + 'Beijing site SOP', + '2025-01-09 09:00:00+00', + '2025-01-12 09:00:00+00' + ) +ON CONFLICT (trial_id, doc_no) DO NOTHING; + +INSERT INTO public.document_versions ( + id, document_id, version_no, parent_version_id, derived_from_version_id, status, effective_at, superseded_at, + file_uri, file_hash, file_size, mime_type, change_summary, created_by, submitted_at, approved_at, withdrawn_at, created_at +) VALUES + ( + '55555555-aaaa-bbbb-cccc-555555555555', + '33333333-aaaa-bbbb-cccc-333333333333', + '1.0', + NULL, + NULL, + 'EFFECTIVE', + '2025-01-10 09:00:00+00', + NULL, + '/code/app/uploads/documents/demo/SOP-001-v1.pdf', + 'b7c52b35f0b8e4a3c6d5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3', + 2048, + 'application/pdf', + '首次发布', + (SELECT id FROM public.users WHERE email = 'pm@example.com'), + '2025-01-09 09:00:00+00', + '2025-01-10 08:30:00+00', + NULL, + '2025-01-08 09:30:00+00' + ), + ( + '66666666-aaaa-bbbb-cccc-666666666666', + '33333333-aaaa-bbbb-cccc-333333333333', + '1.1', + '55555555-aaaa-bbbb-cccc-555555555555', + NULL, + 'APPROVED', + NULL, + NULL, + '/code/app/uploads/documents/demo/SOP-001-v1.1.pdf', + 'c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4', + 3072, + 'application/pdf', + '小幅更新', + (SELECT id FROM public.users WHERE email = 'pm@example.com'), + '2025-01-12 10:00:00+00', + '2025-01-12 11:00:00+00', + NULL, + '2025-01-12 09:30:00+00' + ), + ( + '77777777-aaaa-bbbb-cccc-777777777777', + '44444444-aaaa-bbbb-cccc-444444444444', + '1.0', + NULL, + NULL, + 'DRAFT', + NULL, + NULL, + '/code/app/uploads/documents/demo/TRN-101-v1.pdf', + 'd2c1b0a9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b2a1f0e9d8c7b6a5f4e3d2c1', + 4096, + 'application/pdf', + '培训材料草稿', + (SELECT id FROM public.users WHERE email = 'cra@example.com'), + NULL, + NULL, + NULL, + '2025-01-10 10:30:00+00' + ), + ( + '88888888-aaaa-bbbb-cccc-888888888888', + '55555555-aaaa-bbbb-cccc-555555555555', + '1.0', + NULL, + NULL, + 'EFFECTIVE', + '2025-01-10 09:00:00+00', + NULL, + '/code/app/uploads/documents/demo/SITE-SOP-001-v1.pdf', + 'a3b2c1d0e9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b2a1f0e9d8c7b6a5f4e3d2', + 2048, + 'application/pdf', + '分中心 SOP 首版', + (SELECT id FROM public.users WHERE email = 'cra@example.com'), + '2025-01-09 09:30:00+00', + '2025-01-10 08:30:00+00', + NULL, + '2025-01-09 09:00:00+00' + ) +ON CONFLICT (document_id, version_no) DO NOTHING; + +UPDATE public.documents +SET current_effective_version_id = '55555555-aaaa-bbbb-cccc-555555555555', + updated_at = '2025-01-12 09:00:00+00' +WHERE id = '33333333-aaaa-bbbb-cccc-333333333333' + AND current_effective_version_id IS NULL; + +UPDATE public.documents +SET current_effective_version_id = '88888888-aaaa-bbbb-cccc-888888888888', + updated_at = '2025-01-10 09:10:00+00' +WHERE id = '55555555-aaaa-bbbb-cccc-555555555555' + AND current_effective_version_id IS NULL; + +INSERT INTO public.version_workflows ( + id, document_id, version_id, template_id, status, current_node, submitted_by, submitted_at, completed_at, created_at, updated_at +) VALUES + ( + '88888888-aaaa-bbbb-cccc-888888888888', + '33333333-aaaa-bbbb-cccc-333333333333', + '66666666-aaaa-bbbb-cccc-666666666666', + '11111111-aaaa-bbbb-cccc-111111111111', + 'APPROVED', + 1, + (SELECT id FROM public.users WHERE email = 'pm@example.com'), + '2025-01-12 10:00:00+00', + '2025-01-12 11:00:00+00', + '2025-01-12 10:00:00+00', + '2025-01-12 11:00:00+00' + ) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.workflow_actions ( + id, workflow_id, node_order, action, actor_id, comment, acted_at +) VALUES + ( + '99999999-aaaa-bbbb-cccc-999999999999', + '88888888-aaaa-bbbb-cccc-888888888888', + 1, + 'SUBMIT', + (SELECT id FROM public.users WHERE email = 'pm@example.com'), + '提交审批', + '2025-01-12 10:00:00+00' + ), + ( + 'aaaa0000-bbbb-cccc-dddd-111111111111', + '88888888-aaaa-bbbb-cccc-888888888888', + 1, + 'APPROVE', + (SELECT id FROM public.users WHERE email = 'pm@example.com'), + '批准通过', + '2025-01-12 11:00:00+00' + ) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.distributions ( + id, document_id, version_id, target_type, target_id, status, due_at, closed_at, created_by, created_at +) VALUES + ( + 'bbbb0000-1111-2222-3333-444444444444', + '33333333-aaaa-bbbb-cccc-333333333333', + '55555555-aaaa-bbbb-cccc-555555555555', + 'SITE', + 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', + 'ACTIVE', + '2025-01-20 18:00:00+00', + NULL, + (SELECT id FROM public.users WHERE email = 'pm@example.com'), + '2025-01-12 12:00:00+00' + ), + ( + 'cccc0000-1111-2222-3333-555555555555', + '33333333-aaaa-bbbb-cccc-333333333333', + '55555555-aaaa-bbbb-cccc-555555555555', + 'SITE', + 'cccccccc-cccc-cccc-cccc-cccccccccccc', + 'ACTIVE', + '2025-01-20 18:00:00+00', + NULL, + (SELECT id FROM public.users WHERE email = 'pm@example.com'), + '2025-01-12 12:05:00+00' + ), + ( + 'dddd0000-1111-2222-3333-666666666666', + '33333333-aaaa-bbbb-cccc-333333333333', + '55555555-aaaa-bbbb-cccc-555555555555', + 'USER', + (SELECT id FROM public.users WHERE email = 'pm@example.com'), + 'ACTIVE', + '2025-01-20 18:00:00+00', + NULL, + (SELECT id FROM public.users WHERE email = 'pm@example.com'), + '2025-01-12 12:10:00+00' + ), + ( + 'eeee0000-1111-2222-3333-777777777777', + '33333333-aaaa-bbbb-cccc-333333333333', + '55555555-aaaa-bbbb-cccc-555555555555', + 'USER', + (SELECT id FROM public.users WHERE email = 'cra@example.com'), + 'ACTIVE', + '2025-01-20 18:00:00+00', + NULL, + (SELECT id FROM public.users WHERE email = 'pm@example.com'), + '2025-01-12 12:15:00+00' + ) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.acknowledgements ( + id, distribution_id, user_id, site_id, ack_type, due_at, acked_at, note, created_at +) VALUES + ( + 'ffff0000-1111-2222-3333-888888888888', + 'dddd0000-1111-2222-3333-666666666666', + (SELECT id FROM public.users WHERE email = 'pm@example.com'), + NULL, + 'RECEIVED', + '2025-01-20 18:00:00+00', + '2025-01-12 13:00:00+00', + '已收到', + '2025-01-12 13:00:00+00' + ), + ( + '11110000-2222-3333-4444-999999999999', + 'eeee0000-1111-2222-3333-777777777777', + (SELECT id FROM public.users WHERE email = 'cra@example.com'), + NULL, + 'READ', + '2025-01-20 18:00:00+00', + '2025-01-12 14:00:00+00', + '已阅读', + '2025-01-12 14:00:00+00' + ) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.audit_logs ( + id, study_id, entity_type, entity_id, action, detail, operator_id, operator_role, created_at +) VALUES + ( + '22220000-3333-4444-5555-aaaaaaaaaaaa', + (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), + 'DOCUMENT', + '33333333-aaaa-bbbb-cccc-333333333333', + 'DOCUMENT_CREATED', + '{"before":null,"after":{"doc_no":"SOP-001","status":"ACTIVE"}}', + (SELECT id FROM public.users WHERE email = 'pm@example.com'), + 'PM', + '2025-01-08 09:00:00+00' + ), + ( + '33330000-4444-5555-6666-bbbbbbbbbbbb', + (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), + 'DOCUMENT_VERSION', + '55555555-aaaa-bbbb-cccc-555555555555', + 'VERSION_EFFECTIVE', + '{"before":{"status":"APPROVED"},"after":{"status":"EFFECTIVE"}}', + (SELECT id FROM public.users WHERE email = 'pm@example.com'), + 'PM', + '2025-01-10 09:00:00+00' + ), + ( + '44440000-5555-6666-7777-cccccccccccc', + (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), + 'DISTRIBUTION', + 'bbbb0000-1111-2222-3333-444444444444', + 'DISTRIBUTION_CREATED', + '{"version_id":"55555555-aaaa-bbbb-cccc-555555555555","target_id":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"}', + (SELECT id FROM public.users WHERE email = 'pm@example.com'), + 'PM', + '2025-01-12 12:00:00+00' + ), + ( + '55550000-6666-7777-8888-dddddddddddd', + (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), + 'ACKNOWLEDGEMENT', + 'ffff0000-1111-2222-3333-888888888888', + 'ACK_CREATED', + '{"distribution_id":"dddd0000-1111-2222-3333-666666666666","ack_type":"RECEIVED"}', + (SELECT id FROM public.users WHERE email = 'pm@example.com'), + 'PM', + '2025-01-12 13:00:00+00' + ) +ON CONFLICT (id) DO NOTHING; + +-- Demo transition: v1.1 effective, v1.0 superseded +UPDATE public.document_versions +SET status = 'SUPERSEDED', + superseded_at = '2025-01-15 09:00:00+00' +WHERE id = '55555555-aaaa-bbbb-cccc-555555555555' + AND status = 'EFFECTIVE'; + +UPDATE public.document_versions +SET status = 'EFFECTIVE', + effective_at = '2025-01-15 09:00:00+00' +WHERE id = '66666666-aaaa-bbbb-cccc-666666666666' + AND status = 'APPROVED'; + +UPDATE public.documents +SET current_effective_version_id = '66666666-aaaa-bbbb-cccc-666666666666', + updated_at = '2025-01-15 09:00:00+00' +WHERE id = '33333333-aaaa-bbbb-cccc-333333333333'; + +-- Lightweight demo: one extra draft document +INSERT INTO public.documents ( + id, trial_id, site_id, doc_no, doc_type, title, scope_type, owner_id, status, current_effective_version_id, description, created_at, updated_at +) VALUES + ( + '99999999-eeee-ffff-aaaa-000000000001', + (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), + NULL, + 'QRG-001', + 'Guidance', + '研究快速指引', + 'GLOBAL', + (SELECT id FROM public.users WHERE email = 'pm@example.com'), + 'ACTIVE', + NULL, + 'Quick reference guide (draft)', + '2025-01-16 09:00:00+00', + '2025-01-16 09:00:00+00' + ) +ON CONFLICT (trial_id, doc_no) DO NOTHING; + +INSERT INTO public.document_versions ( + id, document_id, version_no, parent_version_id, derived_from_version_id, status, effective_at, superseded_at, + file_uri, file_hash, file_size, mime_type, change_summary, created_by, submitted_at, approved_at, withdrawn_at, created_at +) VALUES + ( + '99999999-eeee-ffff-aaaa-000000000002', + '99999999-eeee-ffff-aaaa-000000000001', + '0.1', + NULL, + NULL, + 'DRAFT', + NULL, + NULL, + '/code/app/uploads/documents/demo/QRG-001-v0.1.pdf', + 'a1b2c3d4e5f60718293a4b5c6d7e8f90123456789abcdef0123456789abcdef0', + 1536, + 'application/pdf', + '初稿', + (SELECT id FROM public.users WHERE email = 'pm@example.com'), + NULL, + NULL, + NULL, + '2025-01-16 09:10:00+00' + ) +ON CONFLICT (document_id, version_no) DO NOTHING; diff --git a/database/seed_documents.sql b/database/seed_documents.sql new file mode 100644 index 00000000..04f9155b --- /dev/null +++ b/database/seed_documents.sql @@ -0,0 +1,153 @@ +-- Optional seed data for document version management module + +INSERT INTO public.studies ( + id, code, name, status, created_at +) VALUES ( + 'dddddddd-1111-2222-3333-444444444444', 'DOC-DEMO', 'Document Demo Study', 'DRAFT', now() +) +ON CONFLICT (code) DO NOTHING; + +INSERT INTO public.workflow_templates ( + id, trial_id, name, description, is_active, created_by, created_at, updated_at +) VALUES + ( + '11111111-aaaa-bbbb-cccc-111111111211', + (SELECT id FROM public.studies WHERE code = 'DOC-DEMO'), + '标准审批(PM 单签)', + 'PM 单节点审批,适用于一般文件', + true, + (SELECT id FROM public.users WHERE email = 'pm@example.com'), + now(), + now() + ), + ( + '11111111-aaaa-bbbb-cccc-111111111212', + (SELECT id FROM public.studies WHERE code = 'DOC-DEMO'), + '严格审批(CRA→PM→QA)', + '三节点审批,适用于关键文件', + true, + (SELECT id FROM public.users WHERE email = 'pm@example.com'), + now(), + now() + ) +ON CONFLICT (id) DO UPDATE +SET name = EXCLUDED.name, + description = EXCLUDED.description, + is_active = EXCLUDED.is_active, + trial_id = EXCLUDED.trial_id, + created_by = EXCLUDED.created_by, + updated_at = EXCLUDED.updated_at; + +INSERT INTO public.workflow_nodes ( + id, template_id, node_order, role, name, is_required, created_at +) VALUES + ( + '22222222-aaaa-bbbb-cccc-222222222311', + '11111111-aaaa-bbbb-cccc-111111111211', + 1, + 'PM', + '项目经理审批', + true, + now() + ), + ( + '22222222-aaaa-bbbb-cccc-222222222312', + '11111111-aaaa-bbbb-cccc-111111111212', + 1, + 'CRA', + 'CRA 初审', + true, + now() + ), + ( + '22222222-aaaa-bbbb-cccc-222222222313', + '11111111-aaaa-bbbb-cccc-111111111212', + 2, + 'PM', + '项目经理复审', + true, + now() + ), + ( + '22222222-aaaa-bbbb-cccc-222222222314', + '11111111-aaaa-bbbb-cccc-111111111212', + 3, + 'QA', + 'QA 终审', + true, + now() + ) +ON CONFLICT (id) DO UPDATE +SET template_id = EXCLUDED.template_id, + node_order = EXCLUDED.node_order, + role = EXCLUDED.role, + name = EXCLUDED.name, + is_required = EXCLUDED.is_required; + +INSERT INTO public.documents ( + id, trial_id, site_id, doc_no, doc_type, title, scope_type, owner_id, status, created_at, updated_at +) VALUES ( + 'eeeeeeee-1111-2222-3333-444444444444', + (SELECT id FROM public.studies WHERE code = 'DOC-DEMO'), + NULL, + 'SOP-001', + 'SOP', + 'Sample SOP for Versioning', + 'GLOBAL', + (SELECT id FROM public.users WHERE email = 'pm@example.com'), + 'ACTIVE', + now(), + now() +) +ON CONFLICT (trial_id, doc_no) DO NOTHING; + +INSERT INTO public.documents ( + id, trial_id, site_id, doc_no, doc_type, title, scope_type, owner_id, status, created_at, updated_at +) VALUES ( + 'eeeeeeee-1111-2222-3333-444444444445', + (SELECT id FROM public.studies WHERE code = 'DOC-DEMO'), + (SELECT id FROM public.sites WHERE study_id = (SELECT id FROM public.studies WHERE code = 'DOC-DEMO') LIMIT 1), + 'SITE-SOP-001', + 'SOP', + 'DOC-DEMO 分中心 SOP', + 'SITE', + (SELECT id FROM public.users WHERE email = 'pm@example.com'), + 'ACTIVE', + now(), + now() +) +ON CONFLICT (trial_id, doc_no) DO NOTHING; + +INSERT INTO public.document_versions ( + id, document_id, version_no, status, file_uri, file_hash, file_size, mime_type, change_summary, created_by, created_at +) VALUES ( + 'ffffffff-1111-2222-3333-444444444444', + (SELECT id FROM public.documents WHERE doc_no = 'SOP-001' AND trial_id = (SELECT id FROM public.studies WHERE code = 'DOC-DEMO')), + 'V1.0', + 'DRAFT', + '/uploads/docs/SOP-001/V1.0/sample.pdf', + '9f2b4c6d8e0f1234567890abcdef1234567890abcdef1234567890abcdef1234', + 2048, + 'application/pdf', + '初稿', + (SELECT id FROM public.users WHERE email = 'pm@example.com'), + now() +) +ON CONFLICT (document_id, version_no) DO NOTHING; + +INSERT INTO public.document_versions ( + id, document_id, version_no, status, file_uri, file_hash, file_size, mime_type, change_summary, created_by, created_at +) VALUES ( + 'ffffffff-1111-2222-3333-444444444445', + (SELECT id FROM public.documents WHERE doc_no = 'SITE-SOP-001' AND trial_id = (SELECT id FROM public.studies WHERE code = 'DOC-DEMO')), + '1.0', + 'EFFECTIVE', + '/code/app/uploads/documents/demo/SITE-SOP-001-v1.pdf', + 'a3b2c1d0e9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b2a1f0e9d8c7b6a5f4e3d2', + 2048, + 'application/pdf', + '分中心 SOP 首版', + (SELECT id FROM public.users WHERE email = 'pm@example.com'), + now() +) +ON CONFLICT (document_id, version_no) DO NOTHING; diff --git a/frontend/src/api/documents.ts b/frontend/src/api/documents.ts new file mode 100644 index 00000000..732ed1d8 --- /dev/null +++ b/frontend/src/api/documents.ts @@ -0,0 +1,56 @@ +import { apiDelete, apiGet, apiPost } from "./axios"; +import type { ApiListResponse } from "../types/api"; +import type { + Acknowledgement, + Distribution, + DocumentDetail, + DocumentSummary, + DocumentVersion, + WorkflowTemplate, +} from "../types/documents"; + +export const fetchDocuments = (params: Record) => + apiGet>("/api/v1/documents", { params }); + +export const createDocument = (payload: Record) => + apiPost("/api/v1/documents", payload); + +export const deleteDocument = (documentId: string) => + apiDelete(`/api/v1/documents/${documentId}`); + +export const fetchDocumentDetail = (documentId: string) => + apiGet(`/api/v1/documents/${documentId}`); + +export const uploadDocumentVersion = (documentId: string, payload: FormData) => + apiPost(`/api/v1/documents/${documentId}/versions`, payload, { + headers: { "Content-Type": "multipart/form-data" }, + }); + +export const submitVersion = (versionId: string, payload: { template_id: string; comment?: string }) => + apiPost(`/api/v1/versions/${versionId}/submit`, payload); + +export const approveVersion = (versionId: string, payload: { comment?: string }) => + apiPost(`/api/v1/versions/${versionId}/approve`, payload); + +export const rejectVersion = (versionId: string, payload: { comment?: string }) => + apiPost(`/api/v1/versions/${versionId}/reject`, payload); + +export const makeVersionEffective = (versionId: string) => + apiPost(`/api/v1/versions/${versionId}/make-effective`); + +export const downloadDocumentVersion = (versionId: string) => + apiGet(`/api/v1/versions/${versionId}/download`, { + responseType: "blob", + }); + +export const listWorkflowTemplates = (params?: { trial_id?: string }) => + apiGet(`/api/v1/workflow-templates`, { params }); + +export const listDistributions = (versionId: string) => + apiGet(`/api/v1/versions/${versionId}/distributions`); + +export const createDistributions = (versionId: string, payload: Record) => + apiPost(`/api/v1/versions/${versionId}/distributions`, payload); + +export const createAcknowledgement = (distributionId: string, payload: { ack_type: string; evidence?: string }) => + apiPost(`/api/v1/distributions/${distributionId}/acknowledgements`, payload); diff --git a/frontend/src/components/Layout.vue b/frontend/src/components/Layout.vue index 8a8c884d..9e1dd792 100644 --- a/frontend/src/components/Layout.vue +++ b/frontend/src/components/Layout.vue @@ -172,7 +172,7 @@ const activeMenu = computed(() => { if (path.startsWith("/finance/contracts")) return "/fees/contracts"; if (path.startsWith("/finance/special")) return "/fees/special"; if (path.startsWith("/drug/shipments")) return "/drug/shipments"; - if (path.startsWith("/file-versions")) return "/file-versions"; + if (path.startsWith("/file-versions") || path.startsWith("/trial/") || path.startsWith("/documents/")) return "/file-versions"; if (path.startsWith("/startup/feasibility") || path.startsWith("/startup/ethics")) return "/startup/feasibility-ethics"; if (path.startsWith("/startup/meeting-auth") || path.startsWith("/startup/kickoff") || path.startsWith("/startup/training")) { return "/startup/meeting-auth"; diff --git a/frontend/src/locales/zh-CN.ts b/frontend/src/locales/zh-CN.ts index d44cd693..0b9f77f8 100644 --- a/frontend/src/locales/zh-CN.ts +++ b/frontend/src/locales/zh-CN.ts @@ -46,6 +46,7 @@ export const TEXT = { deleteFailed: "删除失败", uploadSuccess: "上传成功", uploadFailed: "上传失败", + downloadFailed: "下载失败", actionFailed: "操作失败", noPermission: "无权限执行该操作", networkError: "网络错误,请稍后重试", @@ -436,9 +437,108 @@ export const TEXT = { }, fileVersionManagement: { title: "文件版本管理", - subtitle: "功能建设中", + subtitle: "管理试验文档与版本审批", listTitle: "文件版本列表", - emptyDescription: "文件版本管理正在搭建基础能力,敬请期待。", + emptyDescription: "暂无文档记录", + newDocument: "新增文档", + filters: { + scope: "范围", + site: "分中心", + docType: "类型", + status: "状态", + all: "全部", + }, + columns: { + docNo: "编号", + title: "标题", + scope: "范围", + site: "分中心", + docType: "类型", + currentVersion: "当前版本", + status: "状态", + effectiveAt: "生效时间", + updatedAt: "更新", + actions: "操作", + }, + actions: { + detail: "详情", + uploadVersion: "上传新版本", + submit: "提交审批", + approve: "通过", + reject: "驳回", + makeEffective: "设为生效", + download: "下载", + distribute: "新建抄送", + ackReceived: "已接收", + ackRead: "已阅读", + ackTrained: "已培训", + addTarget: "新增目标", + }, + dialog: { + createTitle: "新建文档", + uploadTitle: "上传新版本", + distributeTitle: "新建抄送", + }, + fields: { + docNo: "编号", + title: "标题", + docType: "类型", + scope: "范围", + site: "分中心", + versionNo: "版本号", + changeSummary: "变更摘要", + parentVersion: "父版本", + file: "文件", + workflowTemplate: "审批模板", + targetType: "目标类型", + target: "目标", + targetList: "目标列表", + dueAt: "截止日期", + }, + tabs: { + versions: "版本时间线", + distributions: "抄送", + audit: "审计", + }, + labels: { + currentEffective: "当前生效版本", + versionTimeline: "版本时间线", + distributionList: "抄送列表", + approvalSummary: "审批摘要", + createdBy: "创建者", + createdAt: "创建时间", + submitAt: "提交", + approveAt: "审批", + overdue: "超期", + ackStats: "回执统计", + ackAction: "回执", + dueAt: "截止日期", + auditAction: "动作", + auditDetail: "详情", + auditTime: "时间", + }, + placeholders: { + workflowTemplate: "审批模板 ID", + versionNo: "如 1.0", + target: "目标 ID/角色", + }, + messages: { + createSuccess: "文档已创建", + uploadSuccess: "版本已上传", + submitSuccess: "已提交审批", + approveSuccess: "审批通过", + rejectSuccess: "已驳回", + effectiveSuccess: "已设为生效版本", + distributeSuccess: "抄送已创建", + ackSuccess: "回执已提交", + missingFile: "请选择文件", + missingTarget: "请填写目标", + noEffective: "暂无生效版本", + versionNoInvalid: "版本号格式应为 1.0", + workflowTemplateRequired: "请选择审批模板", + submitPrompt: "请输入审批模板 ID", + submitTitle: "提交审批", + }, }, startupFeasibilityEthics: { title: "立项与伦理", @@ -812,6 +912,7 @@ export const TEXT = { CRA: "CRA", PV: "PV", IMP: "药品管理员", + QA: "QA", }, userStatus: { PENDING: "待审核", @@ -916,5 +1017,37 @@ export const TEXT = { true: "是", false: "否", }, + documentType: { + SOP: "SOP", + ICF: "知情同意书", + Training: "培训材料", + Manual: "手册", + Guidance: "研究快速指引", + }, + documentStatus: { + DRAFT: "草稿", + ACTIVE: "已生效", + ARCHIVED: "已归档", + }, + documentVersionStatus: { + DRAFT: "草稿", + SUBMITTED: "已提交", + APPROVED: "已审批", + EFFECTIVE: "已生效", + SUPERSEDED: "已废止", + ARCHIVED: "已归档", + WITHDRAWN: "已撤回", + REJECTED: "已驳回", + }, + distributionTargetType: { + SITE: "中心", + ROLE: "角色", + USER: "人员", + }, + scopeType: { + GLOBAL: "全局", + SITE: "中心", + DERIVED: "派生", + }, }, } as const; diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 7d01b8f5..4be6a157 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -27,6 +27,8 @@ import FeeSpecialForm from "../views/fees/SpecialExpenseForm.vue"; import FeeSpecialDetail from "../views/fees/SpecialExpenseDetail.vue"; import DrugShipments from "../views/ia/DrugShipments.vue"; import FileVersionManagement from "../views/ia/FileVersionManagement.vue"; +import DocumentList from "../views/documents/DocumentList.vue"; +import DocumentDetail from "../views/documents/DocumentDetail.vue"; import StartupFeasibilityEthics from "../views/ia/StartupFeasibilityEthics.vue"; import StartupMeetingAuth from "../views/ia/StartupMeetingAuth.vue"; import SubjectManagement from "../views/ia/SubjectManagement.vue"; @@ -224,6 +226,18 @@ const routes: RouteRecordRaw[] = [ component: FileVersionManagement, meta: { title: TEXT.menu.fileVersionManagement, requiresStudy: true }, }, + { + path: "trial/:trialId/documents", + name: "DocumentList", + component: DocumentList, + meta: { title: TEXT.menu.fileVersionManagement, requiresStudy: true }, + }, + { + path: "documents/:id", + name: "DocumentDetail", + component: DocumentDetail, + meta: { title: TEXT.menu.fileVersionManagement, requiresStudy: true }, + }, { path: "startup/feasibility-ethics", name: "StartupFeasibilityEthics", diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 86393601..ad5f56b6 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -43,12 +43,12 @@ --ctms-radius: 10px; --ctms-radius-lg: 12px; --ctms-radius-round: 50%; - + --ctms-shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04); --ctms-shadow: 0 8px 20px rgba(15, 23, 42, 0.06); --ctms-shadow-md: 0 12px 26px rgba(15, 23, 42, 0.08); --ctms-shadow-lg: 0 20px 40px rgba(15, 23, 42, 0.1); - + /* 动画 */ --ctms-transition: all 0.25s cubic-bezier(0.22, 1, 0.36, 1); @@ -186,7 +186,7 @@ body { padding: 10px 0; } -.el-table--enable-row-hover .el-table__row:hover > td.el-table__cell { +.el-table--enable-row-hover .el-table__row:hover>td.el-table__cell { background-color: #f2f5f8; } @@ -239,10 +239,28 @@ body { border: none; } -.el-tag--info { background-color: #f5f5f5; color: var(--ctms-text-secondary); } -.el-tag--success { background-color: rgba(63, 143, 107, 0.12); color: var(--ctms-success); border: 1px solid rgba(63, 143, 107, 0.2); } -.el-tag--warning { background-color: rgba(197, 139, 42, 0.12); color: var(--ctms-warning); border: 1px solid rgba(197, 139, 42, 0.2); } -.el-tag--danger { background-color: rgba(194, 75, 75, 0.12); color: var(--ctms-danger); border: 1px solid rgba(194, 75, 75, 0.2); } +.el-tag--info { + background-color: #f5f5f5; + color: var(--ctms-text-secondary); +} + +.el-tag--success { + background-color: rgba(63, 143, 107, 0.12); + color: var(--ctms-success); + border: 1px solid rgba(63, 143, 107, 0.2); +} + +.el-tag--warning { + background-color: rgba(197, 139, 42, 0.12); + color: var(--ctms-warning); + border: 1px solid rgba(197, 139, 42, 0.2); +} + +.el-tag--danger { + background-color: rgba(194, 75, 75, 0.12); + color: var(--ctms-danger); + border: 1px solid rgba(194, 75, 75, 0.2); +} .el-alert { border: 1px solid var(--ctms-border-color); @@ -359,3 +377,83 @@ body { font-weight: 600; color: var(--ctms-text-main); } + +.ctms-section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.ctms-section-actions { + display: flex; + gap: 8px; +} + +.ctms-section-card { + margin-bottom: 16px; +} + +.ctms-page-subtitle { + margin: 6px 0 0; + font-size: 13px; + color: var(--ctms-text-secondary); +} + +.ctms-descriptions { + margin-top: 0; +} + +.ctms-descriptions :deep(.el-descriptions__label) { + font-weight: 500; + color: var(--ctms-text-regular); + background-color: #f8fafc; +} + +.ctms-tabs :deep(.el-tabs__nav-wrap::after) { + height: 1px; + background-color: var(--ctms-border-color); +} + +.ctms-tabs :deep(.el-tabs__item) { + font-weight: 500; + color: var(--ctms-text-regular); +} + +.ctms-tabs :deep(.el-tabs__item.is-active) { + color: var(--ctms-primary); + font-weight: 600; +} + +/* Tool classes */ +.text-main { + color: var(--ctms-text-main); +} + +.text-secondary { + color: var(--ctms-text-secondary); +} + +.text-danger { + color: var(--ctms-danger); +} + +.text-primary { + color: var(--ctms-primary); +} + +.font-medium { + font-weight: 500; +} + +.font-mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; +} + +.text-xs { + font-size: 12px; +} + +.text-sm { + font-size: 13px; +} \ No newline at end of file diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 7ded5984..45628e05 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -24,7 +24,7 @@ export interface LoginResponse { token_type: string; } -export type UserRole = "PM" | "CRA" | "PV" | "IMP" | "ADMIN"; +export type UserRole = "PM" | "CRA" | "PV" | "IMP" | "QA" | "ADMIN"; export type UserStatus = "PENDING" | "ACTIVE" | "REJECTED" | "DISABLED"; export interface UserInfo { diff --git a/frontend/src/types/documents.ts b/frontend/src/types/documents.ts new file mode 100644 index 00000000..845a436c --- /dev/null +++ b/frontend/src/types/documents.ts @@ -0,0 +1,126 @@ +export type DocumentStatus = "DRAFT" | "ACTIVE" | "ARCHIVED"; +export type DocumentScopeType = "GLOBAL" | "SITE" | "DERIVED"; +export type DocumentVersionStatus = + | "DRAFT" + | "SUBMITTED" + | "APPROVED" + | "EFFECTIVE" + | "SUPERSEDED" + | "ARCHIVED" + | "WITHDRAWN"; + +export interface DocumentVersionSummary { + id: string; + version_no: string; + status: DocumentVersionStatus; + effective_at?: string | null; +} + +export interface DocumentSummary { + id: string; + trial_id: string; + site_id?: string | null; + doc_no: string; + doc_type: string; + title: string; + scope_type: DocumentScopeType; + owner_id?: string | null; + status: DocumentStatus; + current_effective_version_id?: string | null; + current_effective_version?: DocumentVersionSummary | null; + created_at: string; + updated_at: string; + can_actions?: string[]; +} + +export interface DocumentVersion { + id: string; + document_id: string; + version_no: string; + parent_version_id?: string | null; + derived_from_version_id?: string | null; + status: DocumentVersionStatus; + effective_at?: string | null; + superseded_at?: string | null; + file_uri: string; + file_hash: string; + file_size: number; + mime_type?: string | null; + change_summary?: string | null; + created_by?: string | null; + submitted_at?: string | null; + approved_at?: string | null; + withdrawn_at?: string | null; + created_at: string; + can_actions?: string[]; +} + +export interface WorkflowNode { + id: string; + node_order: number; + role: string; + name?: string | null; + is_required: boolean; + created_at: string; +} + +export interface WorkflowTemplate { + id: string; + trial_id?: string | null; + name: string; + description?: string | null; + is_active: boolean; + created_by?: string | null; + created_at: string; + updated_at: string; + nodes?: WorkflowNode[]; +} + +export interface DistributionStats { + total: number; + received: number; + read: number; + trained: number; + overdue: number; +} + +export type DistributionTargetType = "SITE" | "ROLE" | "USER"; +export type DistributionStatus = "ACTIVE" | "CLOSED"; + +export interface Distribution { + id: string; + document_id: string; + version_id: string; + target_type: DistributionTargetType; + target_id: string; + status: DistributionStatus; + due_at?: string | null; + created_by?: string | null; + created_at: string; + stats?: DistributionStats; +} + +export interface Acknowledgement { + id: string; + distribution_id: string; + user_id: string; + site_id?: string | null; + ack_type: "RECEIVED" | "READ" | "TRAINED"; + due_at?: string | null; + acked_at?: string | null; + note?: string | null; + created_at: string; +} + +export interface DocumentDetail extends DocumentSummary { + description?: string | null; + version_timeline: DocumentVersion[]; + distribution_stats: DistributionStats; + owner?: { + id: string; + email: string; + full_name: string; + role: string; + avatar_url?: string | null; + } | null; +} diff --git a/frontend/src/views/documents/DocumentDetail.vue b/frontend/src/views/documents/DocumentDetail.vue new file mode 100644 index 00000000..fbe1c526 --- /dev/null +++ b/frontend/src/views/documents/DocumentDetail.vue @@ -0,0 +1,890 @@ + + + + + diff --git a/frontend/src/views/documents/DocumentList.vue b/frontend/src/views/documents/DocumentList.vue new file mode 100644 index 00000000..89a49bcb --- /dev/null +++ b/frontend/src/views/documents/DocumentList.vue @@ -0,0 +1,388 @@ + + + + + diff --git a/frontend/src/views/ia/FileVersionManagement.vue b/frontend/src/views/ia/FileVersionManagement.vue index 4a7ca56a..ebbcf51d 100644 --- a/frontend/src/views/ia/FileVersionManagement.vue +++ b/frontend/src/views/ia/FileVersionManagement.vue @@ -1,13 +1,28 @@ + +