文件版本管理-初步优化

This commit is contained in:
Cheng Zhou
2026-01-14 11:35:37 +08:00
parent ef1e67218c
commit f9ef5c109f
46 changed files with 4603 additions and 30 deletions
+36
View File
@@ -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
+67
View File
@@ -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())
+24
View File
@@ -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"}
+1
View File
@@ -0,0 +1 @@
+214
View File
@@ -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)
+2 -1
View File
@@ -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"])
+45
View File
@@ -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)
+36
View File
@@ -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()
+25
View File
@@ -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()
+61
View File
@@ -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()
+76
View File
@@ -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()
+30
View File
@@ -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()
+11
View File
@@ -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
+26
View File
@@ -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()
+8
View File
@@ -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
+37
View File
@@ -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")
+2 -1
View File
@@ -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)
+50
View File
@@ -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",
)
+69
View File
@@ -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,
)
+61
View File
@@ -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")
+1
View File
@@ -15,6 +15,7 @@ class UserRole(str, enum.Enum):
CRA = "CRA"
PV = "PV"
IMP = "IMP"
QA = "QA"
class UserStatus(str, enum.Enum):
+47
View File
@@ -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")
+32
View File
@@ -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")
+23
View File
@@ -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")
+33
View File
@@ -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",
)
+29
View File
@@ -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)
+50
View File
@@ -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)
+70
View File
@@ -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)
+57
View File
@@ -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
+1 -1
View File
@@ -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):
+1 -1
View File
@@ -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"]
+29
View File
@@ -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)
+753
View File
@@ -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
+1
View File
@@ -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
+704 -8
View File
@@ -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;
+153
View File
@@ -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;
+56
View File
@@ -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<string, any>) =>
apiGet<ApiListResponse<DocumentSummary>>("/api/v1/documents", { params });
export const createDocument = (payload: Record<string, any>) =>
apiPost<DocumentSummary>("/api/v1/documents", payload);
export const deleteDocument = (documentId: string) =>
apiDelete<DocumentSummary>(`/api/v1/documents/${documentId}`);
export const fetchDocumentDetail = (documentId: string) =>
apiGet<DocumentDetail>(`/api/v1/documents/${documentId}`);
export const uploadDocumentVersion = (documentId: string, payload: FormData) =>
apiPost<DocumentVersion>(`/api/v1/documents/${documentId}/versions`, payload, {
headers: { "Content-Type": "multipart/form-data" },
});
export const submitVersion = (versionId: string, payload: { template_id: string; comment?: string }) =>
apiPost<DocumentVersion>(`/api/v1/versions/${versionId}/submit`, payload);
export const approveVersion = (versionId: string, payload: { comment?: string }) =>
apiPost<DocumentVersion>(`/api/v1/versions/${versionId}/approve`, payload);
export const rejectVersion = (versionId: string, payload: { comment?: string }) =>
apiPost<DocumentVersion>(`/api/v1/versions/${versionId}/reject`, payload);
export const makeVersionEffective = (versionId: string) =>
apiPost<DocumentVersion>(`/api/v1/versions/${versionId}/make-effective`);
export const downloadDocumentVersion = (versionId: string) =>
apiGet<Blob>(`/api/v1/versions/${versionId}/download`, {
responseType: "blob",
});
export const listWorkflowTemplates = (params?: { trial_id?: string }) =>
apiGet<WorkflowTemplate[]>(`/api/v1/workflow-templates`, { params });
export const listDistributions = (versionId: string) =>
apiGet<Distribution[]>(`/api/v1/versions/${versionId}/distributions`);
export const createDistributions = (versionId: string, payload: Record<string, any>) =>
apiPost<Distribution[]>(`/api/v1/versions/${versionId}/distributions`, payload);
export const createAcknowledgement = (distributionId: string, payload: { ack_type: string; evidence?: string }) =>
apiPost<Acknowledgement>(`/api/v1/distributions/${distributionId}/acknowledgements`, payload);
+1 -1
View File
@@ -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";
+135 -2
View File
@@ -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;
+14
View File
@@ -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",
+105 -7
View File
@@ -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;
}
+1 -1
View File
@@ -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 {
+126
View File
@@ -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;
}
@@ -0,0 +1,890 @@
<template>
<div class="ctms-page">
<div class="ctms-page-header">
<div>
<h1 class="ctms-page-title">
{{ detail.doc_no || TEXT.common.fallback }}
<span class="text-secondary mx-2">/</span>
{{ detail.title || TEXT.modules.fileVersionManagement.title }}
</h1>
<p class="ctms-page-subtitle">
<el-tag size="small" effect="plain" type="info" class="mr-2">{{ displayText(detail.doc_type, TEXT.enums.documentType) }}</el-tag>
<el-tag size="small" effect="plain" type="warning" class="mr-2">{{ displaySite(detail.site_id) }}</el-tag>
<span class="text-secondary text-sm">
{{ TEXT.modules.fileVersionManagement.labels.currentEffective }}
<span class="font-medium text-main">{{ detail.current_effective_version?.version_no || TEXT.common.fallback }}</span>
</span>
</p>
</div>
<div class="ctms-page-actions">
<el-button @click="goBack">
<el-icon class="el-icon--left"><ArrowLeft /></el-icon>
{{ TEXT.common.actions.back }}
</el-button>
</div>
</div>
<el-card class="ctms-section-card" v-loading="loading">
<el-descriptions :column="3" border class="ctms-descriptions">
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.columns.docNo">{{ detail.doc_no || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.fields.docType">{{ displayText(detail.doc_type, TEXT.enums.documentType) }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.fields.site">{{ displaySite(detail.site_id) }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.columns.status">
<el-tag :type="getStatusType(detail.status)" effect="light" size="small">{{ displayEnum(TEXT.enums.documentStatus, detail.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.labels.currentEffective">{{ detail.current_effective_version?.version_no || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.columns.effectiveAt">{{ formatDate(detail.current_effective_version?.effective_at) }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.columns.updatedAt">{{ formatDate(detail.updated_at) }}</el-descriptions-item>
</el-descriptions>
</el-card>
<el-tabs v-model="activeTab" @tab-change="handleTabChange" class="ctms-tabs">
<el-tab-pane :label="TEXT.modules.fileVersionManagement.tabs.versions" name="versions">
<div class="ctms-section-header">
<div class="ctms-section-title">{{ TEXT.modules.fileVersionManagement.labels.versionTimeline }}</div>
<div class="ctms-section-actions">
<el-button type="primary" @click="openUpload" v-if="canAction('create_version')">
<el-icon class="el-icon--left"><Upload /></el-icon>
{{ TEXT.modules.fileVersionManagement.actions.uploadVersion }}
</el-button>
</div>
</div>
<el-card class="ctms-table-card">
<el-table :data="detail.version_timeline" v-loading="versionLoading" class="ctms-table">
<el-table-column prop="version_no" :label="TEXT.modules.fileVersionManagement.fields.versionNo" width="120">
<template #default="{ row }">
<span class="font-medium">V{{ row.version_no }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.status" width="120">
<template #default="{ row }">
<el-tag :type="getVersionStatusType(row.status)" effect="light" size="small">{{ displayEnum(TEXT.enums.documentVersionStatus, row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="change_summary" :label="TEXT.modules.fileVersionManagement.fields.changeSummary" min-width="200" show-overflow-tooltip />
<el-table-column :label="TEXT.modules.fileVersionManagement.labels.createdBy" width="160">
<template #default="{ row }">
<span>{{ displayCreator(row.created_by) }}</span>
</template>
</el-table-column>
<el-table-column prop="created_at" :label="TEXT.modules.fileVersionManagement.labels.createdAt" width="180">
<template #default="{ row }">
<span class="text-secondary">{{ formatDate(row.created_at) }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.labels.approvalSummary" min-width="220">
<template #default="{ row }">
<div class="text-xs text-secondary lh-sm">
<div v-if="row.submitted_at">{{ TEXT.modules.fileVersionManagement.labels.submitAt }}{{ formatDate(row.submitted_at) }}</div>
<div v-if="row.approved_at" class="mt-1">
{{ TEXT.modules.fileVersionManagement.labels.approveAt }}{{ formatDate(row.approved_at) }}
</div>
</div>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.actions" width="280" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="downloadVersion(row)" v-if="canAction('view')">
{{ TEXT.modules.fileVersionManagement.actions.download }}
</el-button>
<el-button link type="primary" size="small" @click="openSubmit(row)" v-if="canAction('submit') && row.status === 'DRAFT'">
{{ TEXT.modules.fileVersionManagement.actions.submit }}
</el-button>
<el-button link type="success" size="small" @click="approve(row)" v-if="canAction('approve') && row.status === 'SUBMITTED'">
{{ TEXT.modules.fileVersionManagement.actions.approve }}
</el-button>
<el-button link type="danger" size="small" @click="reject(row)" v-if="canAction('approve') && row.status === 'SUBMITTED'">
{{ TEXT.modules.fileVersionManagement.actions.reject }}
</el-button>
<el-button link type="warning" size="small" @click="makeEffective(row)" v-if="canAction('approve') && row.status === 'APPROVED'">
{{ TEXT.modules.fileVersionManagement.actions.makeEffective }}
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</el-tab-pane>
<el-tab-pane :label="TEXT.modules.fileVersionManagement.tabs.distributions" name="distributions">
<div class="ctms-section-header">
<div class="ctms-section-title">{{ TEXT.modules.fileVersionManagement.labels.distributionList }}</div>
<div class="ctms-section-actions">
<el-button type="primary" @click="openDistribute" v-if="canAction('distribute')">
<el-icon class="el-icon--left"><Share /></el-icon>
{{ TEXT.modules.fileVersionManagement.actions.distribute }}
</el-button>
</div>
</div>
<el-card class="ctms-table-card">
<el-table :data="distributions" v-loading="distributionLoading" class="ctms-table">
<el-table-column :label="TEXT.modules.fileVersionManagement.fields.targetType" width="120">
<template #default="{ row }">
<el-tag effect="plain" type="info">{{ displayEnum(TEXT.enums.distributionTargetType, row.target_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.fields.target" min-width="200">
<template #default="{ row }">
<span class="font-medium">{{ displayTarget(row) }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.labels.ackStats" min-width="200">
<template #default="{ row }">
<span>{{ TEXT.modules.fileVersionManagement.actions.ackReceived }}: {{ row.stats?.received || 0 }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.labels.overdue" width="100">
<template #default="{ row }">
<el-tag v-if="row.stats?.overdue" type="danger" effect="dark" size="small">{{ TEXT.modules.fileVersionManagement.labels.overdue }}</el-tag>
<span v-else class="text-secondary">-</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.labels.dueAt" width="160">
<template #default="{ row }">
<span :class="{ 'text-danger': row.stats?.overdue }">{{ formatDate(row.due_at) }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.labels.ackAction" width="140" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="ack(row, 'RECEIVED')" v-if="canAction('ack')">
{{ TEXT.modules.fileVersionManagement.actions.ackReceived }}
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</el-tab-pane>
<el-tab-pane :label="TEXT.modules.fileVersionManagement.tabs.audit" name="audit">
<el-card class="ctms-table-card">
<el-table :data="auditLogs" v-loading="auditLoading" class="ctms-table">
<el-table-column prop="action" :label="TEXT.modules.fileVersionManagement.labels.auditAction" width="200">
<template #default="{ row }">
<span class="font-medium">{{ row.action }}</span>
</template>
</el-table-column>
<el-table-column prop="detail" :label="TEXT.modules.fileVersionManagement.labels.auditDetail" min-width="300" show-overflow-tooltip />
<el-table-column prop="operator_role" :label="TEXT.common.labels.role" width="120">
<template #default="{ row }">
<el-tag effect="plain" size="small">{{ row.operator_role }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" :label="TEXT.modules.fileVersionManagement.labels.auditTime" width="180">
<template #default="{ row }">
<span class="text-secondary">{{ formatDate(row.created_at) }}</span>
</template>
</el-table-column>
</el-table>
</el-card>
</el-tab-pane>
</el-tabs>
<el-dialog v-model="uploadVisible" :title="TEXT.modules.fileVersionManagement.dialog.uploadTitle" width="520px" destroy-on-close class="ctms-dialog">
<el-form ref="uploadFormRef" :model="uploadForm" :rules="uploadRules" label-width="110px" class="ctms-form">
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.versionNo" prop="version_no">
<el-input v-model="uploadForm.version_no" :placeholder="TEXT.modules.fileVersionManagement.placeholders.versionNo" />
</el-form-item>
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.changeSummary">
<el-input v-model="uploadForm.change_summary" type="textarea" :rows="3" />
</el-form-item>
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.parentVersion">
<el-select v-model="uploadForm.parent_version_id" clearable style="width: 100%">
<el-option
v-for="item in detail.version_timeline"
:key="item.id"
:label="item.version_no"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.file">
<input type="file" @change="onFileChange" class="ctms-file-input" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="uploadVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="uploading" @click="submitUpload">{{ TEXT.common.actions.confirm }}</el-button>
</template>
</el-dialog>
<el-dialog v-model="distributeVisible" :title="TEXT.modules.fileVersionManagement.dialog.distributeTitle" width="600px" destroy-on-close class="ctms-dialog">
<el-form :model="distributeForm" label-width="100px" class="ctms-form">
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.targetList">
<div class="target-list w-full">
<div class="target-row" v-for="(item, index) in distributeForm.targets" :key="item.uid">
<el-select
v-model="item.target_type"
:placeholder="TEXT.common.placeholders.select"
style="width: 120px"
@change="onTargetTypeChange(index)"
>
<el-option :label="TEXT.enums.distributionTargetType.ROLE" value="ROLE" />
<el-option :label="TEXT.enums.distributionTargetType.USER" value="USER" />
</el-select>
<el-select
v-if="item.target_type === 'ROLE'"
v-model="item.target_id"
filterable
clearable
:placeholder="TEXT.common.placeholders.select"
class="target-select"
>
<el-option v-for="role in roleOptions" :key="role.value" :label="role.label" :value="role.value" />
</el-select>
<el-select
v-else
v-model="item.target_id"
filterable
clearable
:placeholder="TEXT.common.placeholders.select"
class="target-select"
>
<el-option v-for="member in memberOptions" :key="member.value" :label="member.label" :value="member.value" />
</el-select>
<el-button link type="danger" @click="removeTarget(index)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
<el-button type="primary" plain @click="addTarget" class="w-full dashed-button">
<el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.modules.fileVersionManagement.actions.addTarget }}
</el-button>
</div>
</el-form-item>
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.dueAt">
<el-date-picker v-model="distributeForm.due_at" type="datetime" :placeholder="TEXT.common.placeholders.select" style="width: 100%" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="distributeVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="distributing" @click="submitDistribute">{{ TEXT.common.actions.confirm }}</el-button>
</template>
</el-dialog>
<el-dialog v-model="submitVisible" :title="TEXT.modules.fileVersionManagement.messages.submitTitle" width="480px" destroy-on-close class="ctms-dialog">
<el-form :model="submitForm" label-width="100px" class="ctms-form">
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.workflowTemplate">
<el-select
v-model="submitForm.template_id"
filterable
clearable
:placeholder="TEXT.modules.fileVersionManagement.placeholders.workflowTemplate"
style="width: 100%"
>
<el-option v-for="item in workflowTemplates" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="submitVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="submitLoading" @click="confirmSubmit">{{ TEXT.common.actions.confirm }}</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage, FormInstance, FormRules } from "element-plus";
import { ArrowLeft, Upload, Plus, Download, Check, Close, Link, Share, Delete } from "@element-plus/icons-vue";
import {
approveVersion,
createAcknowledgement,
createDistributions,
downloadDocumentVersion,
fetchDocumentDetail,
listDistributions,
listWorkflowTemplates,
makeVersionEffective,
rejectVersion,
submitVersion,
uploadDocumentVersion,
} from "../../api/documents";
import { fetchAuditLogs } from "../../api/auditLogs";
import { listMembers } from "../../api/members";
import { fetchSites } from "../../api/sites";
import { fetchUsers } from "../../api/users";
import type { Distribution, DocumentDetail, DocumentVersion, WorkflowTemplate } from "../../types/documents";
import type { Site, StudyMember } from "../../types/api";
import type { UserInfo } from "../../types/api";
import { TEXT } from "../../locales";
import { displayEnum, displayText, getUserDisplayName } from "../../utils/display";
const getStatusType = (status: string) => {
switch (status) {
case "ACTIVE":
return "success";
case "DRAFT":
return "info";
case "ARCHIVED":
return "info";
case "OBSOLETE":
return "danger";
default:
return "";
}
};
const getVersionStatusType = (status: string) => {
switch (status) {
case "APPROVED":
return "success";
case "DRAFT":
return "info";
case "SUBMITTED":
return "warning";
case "REJECTED":
return "danger";
default:
return "";
}
};
const route = useRoute();
const router = useRouter();
const documentId = computed(() => route.params.id as string);
const loading = ref(false);
const versionLoading = ref(false);
const distributionLoading = ref(false);
const auditLoading = ref(false);
const detail = reactive<DocumentDetail>({
id: "",
trial_id: "",
site_id: null,
doc_no: "",
doc_type: "",
title: "",
scope_type: "GLOBAL",
status: "ACTIVE",
current_effective_version_id: null,
current_effective_version: null,
created_at: "",
updated_at: "",
version_timeline: [],
distribution_stats: { total: 0, received: 0, read: 0, trained: 0, overdue: 0 },
});
const activeTab = ref("versions");
const distributions = ref<Distribution[]>([]);
const auditLogs = ref<any[]>([]);
const users = ref<UserInfo[]>([]);
const sites = ref<Site[]>([]);
const members = ref<StudyMember[]>([]);
const workflowTemplates = ref<WorkflowTemplate[]>([]);
const userMap = computed(() =>
users.value.reduce<Record<string, string>>((acc, user) => {
acc[user.id] = getUserDisplayName(user) || user.email || user.username || user.id;
return acc;
}, {})
);
const displayCreator = (userId?: string | null) => {
if (!userId) return TEXT.common.fallback;
return userMap.value[userId] || userId;
};
const displaySite = (siteId?: string | null) => {
if (!siteId) return TEXT.enums.scopeType.GLOBAL;
return sites.value.find((s) => s.id === siteId)?.name || siteId;
};
const uploadVisible = ref(false);
const uploading = ref(false);
const uploadFormRef = ref<FormInstance>();
const uploadForm = reactive({
version_no: "",
change_summary: "",
parent_version_id: "",
});
const uploadFile = ref<File | null>(null);
const distributeVisible = ref(false);
const distributing = ref(false);
const targetSeed = ref(0);
const distributeForm = reactive({
targets: [{ uid: 0, target_type: "ROLE", target_id: "" }],
due_at: "",
});
const submitVisible = ref(false);
const submitLoading = ref(false);
const submitForm = reactive({
template_id: "",
});
const submitTarget = ref<DocumentVersion | null>(null);
const uploadRules: FormRules = {
version_no: [
{ required: true, message: TEXT.common.messages.required, trigger: "blur" },
{
validator: (_rule, value, callback) => {
const valid = /^\d+\.\d+$/.test(value);
callback(valid ? undefined : new Error(TEXT.modules.fileVersionManagement.messages.versionNoInvalid));
},
trigger: "blur",
},
],
};
const roleOptions = computed(() => {
const fallbackRoles = ["CRA", "PM", "IMP", "PV"];
const roles = new Set(members.value.map((m) => m.role_in_study).filter(Boolean));
const values = roles.size ? Array.from(roles) : fallbackRoles;
return values.map((value) => ({
value,
label: displayEnum(TEXT.enums.userRole, value),
}));
});
const memberOptions = computed(() =>
members.value.map((m) => {
const name = getUserDisplayName((m as any).user) || (m as any).user?.email || m.user_id;
const roleLabel = displayEnum(TEXT.enums.userRole, m.role_in_study);
return {
value: m.user_id,
label: `${name}${roleLabel}`,
role: m.role_in_study,
};
})
);
const displayTarget = (row: Distribution) => {
if (row.target_type === "ROLE") {
return displayEnum(TEXT.enums.userRole, row.target_id);
}
if (row.target_type === "USER") {
const hit = memberOptions.value.find((m) => m.value === row.target_id);
return hit?.label || row.target_id;
}
return row.target_id;
};
const createTarget = () => ({
uid: ++targetSeed.value,
target_type: "ROLE",
target_id: "",
});
const canAction = (action: string) => {
const list = detail.can_actions || [];
if (list.length === 0) return true;
return list.includes(action);
};
const loadMembers = async () => {
if (!detail.trial_id) {
members.value = [];
return;
}
try {
const { data } = await listMembers(detail.trial_id, { limit: 500 });
members.value = Array.isArray(data) ? data : data.items || [];
} catch {
members.value = [];
}
};
const loadSites = async () => {
if (!detail.trial_id) {
sites.value = [];
return;
}
try {
const { data } = await fetchSites(detail.trial_id, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || [];
} catch {
sites.value = [];
}
};
const loadUsers = async () => {
try {
const { data } = await fetchUsers({ limit: 500 });
users.value = (data as any).items || data || [];
} catch {
users.value = [];
}
};
const loadDetail = async () => {
if (!documentId.value) return;
loading.value = true;
try {
const { data } = await fetchDocumentDetail(documentId.value);
Object.assign(detail, data);
if (!users.value.length) {
await loadUsers();
}
if (!members.value.length) {
await loadMembers();
}
if (!sites.value.length) {
await loadSites();
}
if (activeTab.value === "distributions") {
await loadDistributions();
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
loading.value = false;
}
};
const loadDistributions = async () => {
if (!detail.current_effective_version_id) {
distributions.value = [];
return;
}
distributionLoading.value = true;
try {
const { data } = await listDistributions(detail.current_effective_version_id);
distributions.value = data || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
distributionLoading.value = false;
}
};
const loadAuditLogs = async () => {
if (!detail.trial_id) return;
auditLoading.value = true;
try {
const { data } = await fetchAuditLogs(detail.trial_id, {
entity_type: "DOCUMENT",
entity_id: documentId.value,
limit: 20,
});
auditLogs.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
auditLoading.value = false;
}
};
const loadWorkflowTemplates = async () => {
try {
const params = detail.trial_id ? { trial_id: detail.trial_id } : undefined;
const { data } = await listWorkflowTemplates(params);
workflowTemplates.value = data || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
}
};
const handleTabChange = async (name: string) => {
if (name === "distributions") {
await loadDistributions();
}
if (name === "audit") {
await loadAuditLogs();
}
};
const openUpload = () => {
uploadForm.version_no = "";
uploadForm.change_summary = "";
uploadForm.parent_version_id = "";
uploadFile.value = null;
uploadVisible.value = true;
};
const onFileChange = (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files && target.files[0]) {
uploadFile.value = target.files[0];
}
};
const submitUpload = async () => {
if (!uploadFormRef.value) return;
await uploadFormRef.value.validate(async (valid) => {
if (!valid || !documentId.value || !uploadFile.value) {
if (!uploadFile.value) {
ElMessage.warning(TEXT.modules.fileVersionManagement.messages.missingFile);
}
return;
}
uploading.value = true;
try {
const formData = new FormData();
formData.append("version_no", uploadForm.version_no);
if (uploadForm.change_summary) {
formData.append("change_summary", uploadForm.change_summary);
}
if (uploadForm.parent_version_id) {
formData.append("parent_version_id", uploadForm.parent_version_id);
}
formData.append("file", uploadFile.value);
await uploadDocumentVersion(documentId.value, formData);
uploadVisible.value = false;
await loadDetail();
ElMessage.success(TEXT.modules.fileVersionManagement.messages.uploadSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
uploading.value = false;
}
});
};
const openSubmit = async (version: DocumentVersion) => {
submitTarget.value = version;
submitForm.template_id = "";
submitVisible.value = true;
if (!workflowTemplates.value.length) {
await loadWorkflowTemplates();
}
};
const confirmSubmit = async () => {
if (!submitTarget.value) return;
if (!submitForm.template_id) {
ElMessage.warning(TEXT.modules.fileVersionManagement.messages.workflowTemplateRequired);
return;
}
submitLoading.value = true;
try {
await submitVersion(submitTarget.value.id, { template_id: submitForm.template_id, comment: "" });
submitVisible.value = false;
await loadDetail();
ElMessage.success(TEXT.modules.fileVersionManagement.messages.submitSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
submitLoading.value = false;
}
};
const approve = async (version: DocumentVersion) => {
versionLoading.value = true;
try {
await approveVersion(version.id, { comment: "" });
await loadDetail();
ElMessage.success(TEXT.modules.fileVersionManagement.messages.approveSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
versionLoading.value = false;
}
};
const reject = async (version: DocumentVersion) => {
versionLoading.value = true;
try {
await rejectVersion(version.id, { comment: "" });
await loadDetail();
ElMessage.success(TEXT.modules.fileVersionManagement.messages.rejectSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
versionLoading.value = false;
}
};
const makeEffective = async (version: DocumentVersion) => {
versionLoading.value = true;
try {
await makeVersionEffective(version.id);
await loadDetail();
ElMessage.success(TEXT.modules.fileVersionManagement.messages.effectiveSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
versionLoading.value = false;
}
};
const getFilename = (header?: string | null) => {
if (!header) return null;
const match = /filename\*=UTF-8''([^;]+)|filename="?([^\";]+)"?/i.exec(header);
if (!match) return null;
return decodeURIComponent(match[1] || match[2] || "");
};
const downloadVersion = async (version: DocumentVersion) => {
try {
const response = await downloadDocumentVersion(version.id);
const contentType = response.headers?.["content-type"] || "application/octet-stream";
const filename =
getFilename(response.headers?.["content-disposition"]) ||
`document-${version.version_no || version.id}.bin`;
const blob = new Blob([response.data], { type: contentType });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.downloadFailed);
}
};
const openDistribute = () => {
distributeForm.targets = [createTarget()];
distributeForm.due_at = "";
distributeVisible.value = true;
if (!members.value.length) {
loadMembers();
}
};
const addTarget = () => {
distributeForm.targets.push(createTarget());
};
const removeTarget = (index: number) => {
distributeForm.targets.splice(index, 1);
};
const onTargetTypeChange = (index: number) => {
const target = distributeForm.targets[index];
if (!target) return;
target.target_id = "";
};
const submitDistribute = async () => {
if (!detail.current_effective_version_id) {
ElMessage.warning(TEXT.modules.fileVersionManagement.messages.noEffective);
return;
}
if (distributeForm.targets.some((t) => !t.target_id)) {
ElMessage.warning(TEXT.modules.fileVersionManagement.messages.missingTarget);
return;
}
distributing.value = true;
try {
const targets = distributeForm.targets.map((t) => ({
target_type: t.target_type,
target_id: t.target_id,
}));
await createDistributions(detail.current_effective_version_id, {
targets,
due_at: distributeForm.due_at || undefined,
});
distributeVisible.value = false;
await loadDistributions();
ElMessage.success(TEXT.modules.fileVersionManagement.messages.distributeSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
distributing.value = false;
}
};
const ack = async (distribution: Distribution, ackType: string) => {
try {
await createAcknowledgement(distribution.id, { ack_type: ackType });
await loadDistributions();
ElMessage.success(TEXT.modules.fileVersionManagement.messages.ackSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
}
};
const goBack = () => {
if (detail.trial_id) {
router.push(`/trial/${detail.trial_id}/documents`);
} else {
router.back();
}
};
const formatDate = (value?: string | null) => {
if (!value) return TEXT.common.fallback;
return value.replace("T", " ").replace("Z", "").split(".")[0];
};
onMounted(async () => {
await loadDetail();
await loadDistributions();
});
watch(
() => documentId.value,
async (value, oldValue) => {
if (value && value !== oldValue) {
await loadDetail();
await loadDistributions();
}
}
);
</script>
<style scoped>
.text-secondary {
color: var(--ctms-text-secondary);
}
.text-main {
color: var(--ctms-text-main);
}
.font-medium {
font-weight: 500;
}
.bg-primary-light {
background-color: var(--ctms-primary-light);
}
.text-primary {
color: var(--ctms-primary);
}
.text-xs {
font-size: 12px;
}
.text-sm {
font-size: 13px;
}
.text-danger {
color: var(--ctms-danger);
}
.lh-sm {
line-height: 1.4;
}
.dashed-button {
border-style: dashed;
}
.flex {
display: flex;
}
.items-center {
align-items: center;
}
.gap-2 {
gap: 8px;
}
.mr-2 {
margin-right: 8px;
}
.mt-1 {
margin-top: 4px;
}
.mx-2 {
margin-left: 8px;
margin-right: 8px;
}
.w-full {
width: 100%;
}
</style>
@@ -0,0 +1,388 @@
<template>
<div class="ctms-page">
<div class="ctms-page-header">
<div>
<h1 class="ctms-page-title">{{ TEXT.modules.fileVersionManagement.title }}</h1>
<p class="page-subtitle">{{ TEXT.modules.fileVersionManagement.subtitle }}</p>
</div>
<div class="ctms-page-actions">
<el-button type="primary" @click="openCreate">
<el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.modules.fileVersionManagement.newDocument }}
</el-button>
</div>
</div>
<el-card class="ctms-filter-card">
<div class="ctms-filter-row">
<div class="ctms-filter-item">
<span class="ctms-filter-label">{{ TEXT.modules.fileVersionManagement.filters.scope }}</span>
<el-select v-model="filters.scope_type" :placeholder="TEXT.modules.fileVersionManagement.filters.all" clearable>
<el-option v-for="item in scopeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</div>
<div class="ctms-filter-item">
<span class="ctms-filter-label">{{ TEXT.modules.fileVersionManagement.filters.site }}</span>
<el-select v-model="filters.site_id" :placeholder="TEXT.modules.fileVersionManagement.filters.all" clearable filterable>
<el-option v-for="item in siteOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</div>
<div class="ctms-filter-item">
<span class="ctms-filter-label">{{ TEXT.modules.fileVersionManagement.filters.docType }}</span>
<el-select v-model="filters.doc_type" :placeholder="TEXT.modules.fileVersionManagement.filters.all" clearable>
<el-option v-for="item in docTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</div>
<div class="ctms-filter-item">
<span class="ctms-filter-label">{{ TEXT.modules.fileVersionManagement.filters.status }}</span>
<el-select v-model="filters.status" :placeholder="TEXT.modules.fileVersionManagement.filters.all" clearable>
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</div>
<div class="ctms-filter-spacer"></div>
<div class="ctms-filter-actions">
<el-button type="primary" @click="load">{{ TEXT.common.actions.search }}</el-button>
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
</div>
</div>
</el-card>
<el-card class="ctms-table-card">
<el-table :data="items" v-loading="loading" @row-click="handleRowClick" row-class-name="clickable-row" class="ctms-table">
<el-table-column prop="doc_no" :label="TEXT.modules.fileVersionManagement.columns.docNo" width="160">
<template #default="{ row }">
<span class="font-mono font-medium">{{ row.doc_no }}</span>
</template>
</el-table-column>
<el-table-column prop="title" :label="TEXT.modules.fileVersionManagement.columns.title" min-width="200">
<template #default="{ row }">
<span class="font-semibold">{{ row.title }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.scope" width="120">
<template #default="{ row }">
<el-tag
effect="plain"
:type="row.scope_type === 'SITE' ? 'warning' : 'success'"
>
{{ displayEnum(TEXT.enums.scopeType, row.scope_type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.site" min-width="160">
<template #default="{ row }">
<span>{{ displaySite(row.site_id) }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.docType" width="140">
<template #default="{ row }">
<el-tag effect="plain" type="info">{{ displayText(row.doc_type, TEXT.enums.documentType) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.currentVersion" width="140">
<template #default="{ row }">
<span v-if="row.current_effective_version">{{ row.current_effective_version.version_no }}</span>
<span v-else class="text-secondary">-</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.status" width="120">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)" effect="light">
{{ displayEnum(TEXT.enums.documentStatus, row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.effectiveAt" width="180">
<template #default="{ row }">
<span>{{ formatDate(row.current_effective_version?.effective_at) }}</span>
</template>
</el-table-column>
<el-table-column prop="updated_at" :label="TEXT.modules.fileVersionManagement.columns.updatedAt" width="180">
<template #default="{ row }">
<span class="text-secondary">{{ formatDate(row.updated_at) }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.actions" width="100" fixed="right">
<template #default="{ row }">
<el-button link type="danger" @click.stop="confirmDelete(row)">
{{ TEXT.common.actions.delete }}
</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty :description="TEXT.modules.fileVersionManagement.emptyDescription" />
</template>
</el-table>
</el-card>
<el-dialog v-model="createVisible" :title="TEXT.modules.fileVersionManagement.dialog.createTitle" width="520px" destroy-on-close class="ctms-dialog">
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="100px" class="ctms-form">
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.docNo" prop="doc_no">
<el-input v-model="createForm.doc_no" placeholder="e.g. SOP-001" />
</el-form-item>
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.title" prop="title">
<el-input v-model="createForm.title" />
</el-form-item>
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.scope" prop="scope_type">
<el-select v-model="createForm.scope_type" style="width: 100%">
<el-option v-for="item in scopeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item v-if="createForm.scope_type === 'SITE'" :label="TEXT.modules.fileVersionManagement.fields.site" prop="site_id">
<el-select v-model="createForm.site_id" filterable style="width: 100%">
<el-option v-for="item in siteOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.docType" prop="doc_type">
<el-select v-model="createForm.doc_type" style="width: 100%">
<el-option v-for="item in docTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="creating" @click="submitCreate">{{ TEXT.common.actions.confirm }}</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref, computed, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
import { fetchDocuments, createDocument, deleteDocument } from "../../api/documents";
import { fetchSites } from "../../api/sites";
import { useStudyStore } from "../../store/study";
import type { DocumentSummary } from "../../types/documents";
import type { Site } from "../../types/api";
import { displayEnum, displayText } from "../../utils/display";
import { TEXT } from "../../locales";
const getStatusType = (status: string) => {
switch (status) {
case "ACTIVE":
return "success";
case "DRAFT":
return "info";
case "ARCHIVED":
return "info";
case "OBSOLETE":
return "danger";
default:
return "";
}
};
const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const loading = ref(false);
const items = ref<DocumentSummary[]>([]);
const sites = ref<Site[]>([]);
const createVisible = ref(false);
const creating = ref(false);
const createFormRef = ref<FormInstance>();
const trialId = computed(() => (route.params.trialId as string) || "");
const filters = reactive({
scope_type: "",
site_id: "",
doc_type: "",
status: "",
});
const docTypeOptions = Object.keys(TEXT.enums.documentType).map((value) => ({
value,
label: TEXT.enums.documentType[value as keyof typeof TEXT.enums.documentType],
}));
const statusOptions = Object.keys(TEXT.enums.documentStatus).map((value) => ({
value,
label: TEXT.enums.documentStatus[value as keyof typeof TEXT.enums.documentStatus],
}));
const scopeOptions = [
{ value: "GLOBAL", label: TEXT.enums.scopeType.GLOBAL },
{ value: "SITE", label: TEXT.enums.scopeType.SITE },
];
const siteOptions = computed(() =>
sites.value.map((site) => ({ value: site.id, label: site.name }))
);
const createForm = reactive({
doc_no: "",
title: "",
scope_type: "GLOBAL",
site_id: "",
doc_type: "",
});
const createRules: FormRules = {
doc_no: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }],
title: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }],
scope_type: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
site_id: [
{
validator: (_rule, value, callback) => {
if (createForm.scope_type === "SITE" && !value) {
callback(new Error(TEXT.common.messages.required));
return;
}
callback();
},
trigger: "change",
},
],
doc_type: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }],
};
const displaySite = (siteId?: string | null) => {
if (!siteId) return TEXT.common.fallback;
return sites.value.find((s) => s.id === siteId)?.name || siteId;
};
const ensureTrialRoute = () => {
if (trialId.value) return;
const currentStudy = study.currentStudy?.id;
if (currentStudy) {
router.replace(`/trial/${currentStudy}/documents`);
}
};
const loadSites = async () => {
if (!trialId.value) return;
try {
const { data } = await fetchSites(trialId.value, { limit: 500 });
const items = Array.isArray(data) ? data : data.items || [];
sites.value = items;
} catch {
sites.value = [];
}
};
const load = async () => {
if (!trialId.value) return;
loading.value = true;
try {
const { data } = await fetchDocuments({
trial_id: trialId.value,
scope_type: filters.scope_type || undefined,
site_id: filters.site_id || undefined,
doc_type: filters.doc_type || undefined,
status: filters.status || undefined,
});
items.value = data.items || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
loading.value = false;
}
};
const resetFilters = () => {
filters.scope_type = "";
filters.site_id = "";
filters.doc_type = "";
filters.status = "";
load();
};
const openCreate = () => {
createForm.doc_no = "";
createForm.title = "";
createForm.scope_type = "GLOBAL";
createForm.site_id = "";
createForm.doc_type = "";
createVisible.value = true;
};
const submitCreate = async () => {
if (!createFormRef.value) return;
await createFormRef.value.validate(async (valid) => {
if (!valid || !trialId.value) return;
creating.value = true;
try {
await createDocument({
trial_id: trialId.value,
site_id: createForm.scope_type === "SITE" ? createForm.site_id : undefined,
doc_no: createForm.doc_no,
title: createForm.title,
doc_type: createForm.doc_type,
scope_type: createForm.scope_type,
});
createVisible.value = false;
await load();
ElMessage.success(TEXT.modules.fileVersionManagement.messages.createSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
creating.value = false;
}
});
};
const goDetail = (id: string) => router.push(`/documents/${id}`);
const handleRowClick = (row: DocumentSummary) => {
if (!row?.id) return;
goDetail(row.id);
};
const confirmDelete = async (row: DocumentSummary) => {
if (!row?.id) return;
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return;
try {
await deleteDocument(row.id);
ElMessage.success(TEXT.common.messages.deleteSuccess);
await load();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
}
};
const formatDate = (value?: string | null) => {
if (!value) return TEXT.common.fallback;
return value.replace("T", " ").replace("Z", "").split(".")[0];
};
onMounted(() => {
ensureTrialRoute();
load();
loadSites();
});
watch(
() => trialId.value,
(value, oldValue) => {
if (value && value !== oldValue) {
load();
loadSites();
}
}
);
</script>
<style scoped>
.text-secondary {
color: var(--ctms-text-secondary);
}
.font-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.font-medium {
font-weight: 500;
}
.text-primary {
color: var(--ctms-primary);
}
.font-semibold {
font-weight: 600;
}
</style>
@@ -1,13 +1,28 @@
<template>
<ModulePlaceholder
:title="TEXT.modules.fileVersionManagement.title"
:subtitle="TEXT.modules.fileVersionManagement.subtitle"
:list-title="TEXT.modules.fileVersionManagement.listTitle"
:empty-description="TEXT.modules.fileVersionManagement.emptyDescription"
/>
<div class="redirecting">
<el-empty :description="`${TEXT.common.loading}...`" />
</div>
</template>
<script setup lang="ts">
import ModulePlaceholder from "../../components/ModulePlaceholder.vue";
import { onMounted } from "vue";
import { useRouter } from "vue-router";
import { useStudyStore } from "../../store/study";
import { TEXT } from "../../locales";
const router = useRouter();
const study = useStudyStore();
onMounted(() => {
const currentStudy = study.currentStudy?.id;
if (currentStudy) {
router.replace(`/trial/${currentStudy}/documents`);
}
});
</script>
<style scoped>
.redirecting {
padding: 24px;
}
</style>