文件版本管理-初步优化
This commit is contained in:
@@ -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
|
||||||
@@ -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())
|
||||||
@@ -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"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter
|
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 = APIRouter()
|
||||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
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(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(faq_categories.router, prefix="/faqs/categories", tags=["faq-categories"])
|
||||||
api_router.include_router(faqs.router, prefix="/faqs/items", tags=["faqs"])
|
api_router.include_router(faqs.router, prefix="/faqs/items", tags=["faqs"])
|
||||||
|
api_router.include_router(documents.router, prefix="", tags=["documents"])
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -7,6 +7,14 @@ from app.models.site import Site # noqa: F401
|
|||||||
from app.models.study_member import StudyMember # noqa: F401
|
from app.models.study_member import StudyMember # noqa: F401
|
||||||
from app.models.attachment import Attachment # noqa: F401
|
from app.models.attachment import Attachment # noqa: F401
|
||||||
from app.models.audit_log import AuditLog # 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.milestone import Milestone # noqa: F401
|
||||||
from app.models.subject import Subject # noqa: F401
|
from app.models.subject import Subject # noqa: F401
|
||||||
from app.models.visit import Visit # noqa: F401
|
from app.models.visit import Visit # noqa: F401
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
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.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ from app.db.base_class import Base
|
|||||||
|
|
||||||
class AuditLog(Base):
|
class AuditLog(Base):
|
||||||
__tablename__ = "audit_logs"
|
__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)
|
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)
|
study_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=True)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
@@ -15,6 +15,7 @@ class UserRole(str, enum.Enum):
|
|||||||
CRA = "CRA"
|
CRA = "CRA"
|
||||||
PV = "PV"
|
PV = "PV"
|
||||||
IMP = "IMP"
|
IMP = "IMP"
|
||||||
|
QA = "QA"
|
||||||
|
|
||||||
|
|
||||||
class UserStatus(str, enum.Enum):
|
class UserStatus(str, enum.Enum):
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -5,7 +5,7 @@ from typing import Literal, Optional
|
|||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
from app.schemas.user import UserDisplay
|
from app.schemas.user import UserDisplay
|
||||||
|
|
||||||
StudyRole = Literal["PM", "CRA", "PV", "IMP", "ADMIN"]
|
StudyRole = Literal["PM", "CRA", "PV", "IMP", "QA", "ADMIN"]
|
||||||
|
|
||||||
|
|
||||||
class StudyMemberCreate(BaseModel):
|
class StudyMemberCreate(BaseModel):
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from typing import Literal, Optional
|
|||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
|
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"]
|
RegisterRole = Literal["PM", "CRA", "PV", "IMP"]
|
||||||
UserStatus = Literal["PENDING", "ACTIVE", "REJECTED", "DISABLED"]
|
UserStatus = Literal["PENDING", "ACTIVE", "REJECTED", "DISABLED"]
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -13,3 +13,4 @@ pytest==8.1.1
|
|||||||
pytest-asyncio==0.23.5
|
pytest-asyncio==0.23.5
|
||||||
httpx==0.25.2
|
httpx==0.25.2
|
||||||
email-validator==2.1.1
|
email-validator==2.1.1
|
||||||
|
alembic==1.13.1
|
||||||
|
|||||||
+704
-8
@@ -2,7 +2,14 @@
|
|||||||
|
|
||||||
DO $$
|
DO $$
|
||||||
BEGIN
|
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
|
EXCEPTION
|
||||||
WHEN duplicate_object THEN null;
|
WHEN duplicate_object THEN null;
|
||||||
END $$;
|
END $$;
|
||||||
@@ -517,7 +524,10 @@ INSERT INTO public.users (
|
|||||||
) VALUES
|
) 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'),
|
('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'),
|
('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
|
ON CONFLICT (email) DO UPDATE SET
|
||||||
password_hash = EXCLUDED.password_hash,
|
password_hash = EXCLUDED.password_hash,
|
||||||
full_name = EXCLUDED.full_name,
|
full_name = EXCLUDED.full_name,
|
||||||
@@ -566,7 +576,10 @@ INSERT INTO public.study_members (
|
|||||||
) VALUES
|
) 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'),
|
('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'),
|
('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
|
ON CONFLICT (study_id, user_id) DO UPDATE SET
|
||||||
role_in_study = EXCLUDED.role_in_study,
|
role_in_study = EXCLUDED.role_in_study,
|
||||||
is_active = EXCLUDED.is_active;
|
is_active = EXCLUDED.is_active;
|
||||||
@@ -601,12 +614,12 @@ INSERT INTO public.finance_items (
|
|||||||
ON CONFLICT (id) DO NOTHING;
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
INSERT INTO public.drug_shipments (
|
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
|
) 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'),
|
('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'), '寄送', '上海瑞金医院', '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'),
|
('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'), '回收', '北京协和医院', '空药盒及剩余药品回收', '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'),
|
('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'), '回收', '上海瑞金医院', '冷链箱回收', '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')
|
('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;
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
INSERT INTO public.startup_feasibility (
|
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),
|
('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)
|
('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;
|
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;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
@@ -172,7 +172,7 @@ const activeMenu = computed(() => {
|
|||||||
if (path.startsWith("/finance/contracts")) return "/fees/contracts";
|
if (path.startsWith("/finance/contracts")) return "/fees/contracts";
|
||||||
if (path.startsWith("/finance/special")) return "/fees/special";
|
if (path.startsWith("/finance/special")) return "/fees/special";
|
||||||
if (path.startsWith("/drug/shipments")) return "/drug/shipments";
|
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/feasibility") || path.startsWith("/startup/ethics")) return "/startup/feasibility-ethics";
|
||||||
if (path.startsWith("/startup/meeting-auth") || path.startsWith("/startup/kickoff") || path.startsWith("/startup/training")) {
|
if (path.startsWith("/startup/meeting-auth") || path.startsWith("/startup/kickoff") || path.startsWith("/startup/training")) {
|
||||||
return "/startup/meeting-auth";
|
return "/startup/meeting-auth";
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export const TEXT = {
|
|||||||
deleteFailed: "删除失败",
|
deleteFailed: "删除失败",
|
||||||
uploadSuccess: "上传成功",
|
uploadSuccess: "上传成功",
|
||||||
uploadFailed: "上传失败",
|
uploadFailed: "上传失败",
|
||||||
|
downloadFailed: "下载失败",
|
||||||
actionFailed: "操作失败",
|
actionFailed: "操作失败",
|
||||||
noPermission: "无权限执行该操作",
|
noPermission: "无权限执行该操作",
|
||||||
networkError: "网络错误,请稍后重试",
|
networkError: "网络错误,请稍后重试",
|
||||||
@@ -436,9 +437,108 @@ export const TEXT = {
|
|||||||
},
|
},
|
||||||
fileVersionManagement: {
|
fileVersionManagement: {
|
||||||
title: "文件版本管理",
|
title: "文件版本管理",
|
||||||
subtitle: "功能建设中",
|
subtitle: "管理试验文档与版本审批",
|
||||||
listTitle: "文件版本列表",
|
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: {
|
startupFeasibilityEthics: {
|
||||||
title: "立项与伦理",
|
title: "立项与伦理",
|
||||||
@@ -812,6 +912,7 @@ export const TEXT = {
|
|||||||
CRA: "CRA",
|
CRA: "CRA",
|
||||||
PV: "PV",
|
PV: "PV",
|
||||||
IMP: "药品管理员",
|
IMP: "药品管理员",
|
||||||
|
QA: "QA",
|
||||||
},
|
},
|
||||||
userStatus: {
|
userStatus: {
|
||||||
PENDING: "待审核",
|
PENDING: "待审核",
|
||||||
@@ -916,5 +1017,37 @@ export const TEXT = {
|
|||||||
true: "是",
|
true: "是",
|
||||||
false: "否",
|
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;
|
} as const;
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import FeeSpecialForm from "../views/fees/SpecialExpenseForm.vue";
|
|||||||
import FeeSpecialDetail from "../views/fees/SpecialExpenseDetail.vue";
|
import FeeSpecialDetail from "../views/fees/SpecialExpenseDetail.vue";
|
||||||
import DrugShipments from "../views/ia/DrugShipments.vue";
|
import DrugShipments from "../views/ia/DrugShipments.vue";
|
||||||
import FileVersionManagement from "../views/ia/FileVersionManagement.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 StartupFeasibilityEthics from "../views/ia/StartupFeasibilityEthics.vue";
|
||||||
import StartupMeetingAuth from "../views/ia/StartupMeetingAuth.vue";
|
import StartupMeetingAuth from "../views/ia/StartupMeetingAuth.vue";
|
||||||
import SubjectManagement from "../views/ia/SubjectManagement.vue";
|
import SubjectManagement from "../views/ia/SubjectManagement.vue";
|
||||||
@@ -224,6 +226,18 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: FileVersionManagement,
|
component: FileVersionManagement,
|
||||||
meta: { title: TEXT.menu.fileVersionManagement, requiresStudy: true },
|
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",
|
path: "startup/feasibility-ethics",
|
||||||
name: "StartupFeasibilityEthics",
|
name: "StartupFeasibilityEthics",
|
||||||
|
|||||||
@@ -43,12 +43,12 @@
|
|||||||
--ctms-radius: 10px;
|
--ctms-radius: 10px;
|
||||||
--ctms-radius-lg: 12px;
|
--ctms-radius-lg: 12px;
|
||||||
--ctms-radius-round: 50%;
|
--ctms-radius-round: 50%;
|
||||||
|
|
||||||
--ctms-shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04);
|
--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: 0 8px 20px rgba(15, 23, 42, 0.06);
|
||||||
--ctms-shadow-md: 0 12px 26px rgba(15, 23, 42, 0.08);
|
--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-shadow-lg: 0 20px 40px rgba(15, 23, 42, 0.1);
|
||||||
|
|
||||||
/* 动画 */
|
/* 动画 */
|
||||||
--ctms-transition: all 0.25s cubic-bezier(0.22, 1, 0.36, 1);
|
--ctms-transition: all 0.25s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
|
||||||
@@ -186,7 +186,7 @@ body {
|
|||||||
padding: 10px 0;
|
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;
|
background-color: #f2f5f8;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,10 +239,28 @@ body {
|
|||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-tag--info { background-color: #f5f5f5; color: var(--ctms-text-secondary); }
|
.el-tag--info {
|
||||||
.el-tag--success { background-color: rgba(63, 143, 107, 0.12); color: var(--ctms-success); border: 1px solid rgba(63, 143, 107, 0.2); }
|
background-color: #f5f5f5;
|
||||||
.el-tag--warning { background-color: rgba(197, 139, 42, 0.12); color: var(--ctms-warning); border: 1px solid rgba(197, 139, 42, 0.2); }
|
color: var(--ctms-text-secondary);
|
||||||
.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--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 {
|
.el-alert {
|
||||||
border: 1px solid var(--ctms-border-color);
|
border: 1px solid var(--ctms-border-color);
|
||||||
@@ -359,3 +377,83 @@ body {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--ctms-text-main);
|
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;
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ export interface LoginResponse {
|
|||||||
token_type: string;
|
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 type UserStatus = "PENDING" | "ACTIVE" | "REJECTED" | "DISABLED";
|
||||||
|
|
||||||
export interface UserInfo {
|
export interface UserInfo {
|
||||||
|
|||||||
@@ -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>
|
<template>
|
||||||
<ModulePlaceholder
|
<div class="redirecting">
|
||||||
:title="TEXT.modules.fileVersionManagement.title"
|
<el-empty :description="`${TEXT.common.loading}...`" />
|
||||||
:subtitle="TEXT.modules.fileVersionManagement.subtitle"
|
</div>
|
||||||
:list-title="TEXT.modules.fileVersionManagement.listTitle"
|
|
||||||
:empty-description="TEXT.modules.fileVersionManagement.emptyDescription"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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";
|
import { TEXT } from "../../locales";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const study = useStudyStore();
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const currentStudy = study.currentStudy?.id;
|
||||||
|
if (currentStudy) {
|
||||||
|
router.replace(`/trial/${currentStudy}/documents`);
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.redirecting {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user