diff --git a/backend/alembic/versions/20260527_09_add_etmf_nodes.py b/backend/alembic/versions/20260527_09_add_etmf_nodes.py new file mode 100644 index 00000000..24c9693f --- /dev/null +++ b/backend/alembic/versions/20260527_09_add_etmf_nodes.py @@ -0,0 +1,88 @@ +"""add etmf nodes + +Revision ID: 20260527_09 +Revises: 20260527_08_precautions +Create Date: 2026-05-27 20:30:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +revision: str = "20260527_09" +down_revision: Union[str, None] = "20260527_08_precautions" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _table_exists(inspector: sa.Inspector, table_name: str) -> bool: + return table_name in inspector.get_table_names() + + +def _column_exists(inspector: sa.Inspector, table_name: str, column_name: str) -> bool: + return column_name in {column["name"] for column in inspector.get_columns(table_name)} + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + uuid_type = postgresql.UUID(as_uuid=True) + scope_type = postgresql.ENUM( + "GLOBAL", + "SITE", + "DERIVED", + name="document_scope_type", + create_type=False, + ) + + if not _table_exists(inspector, "etmf_nodes"): + op.create_table( + "etmf_nodes", + sa.Column("id", uuid_type, nullable=False), + sa.Column("study_id", uuid_type, nullable=False), + sa.Column("parent_id", uuid_type, nullable=True), + sa.Column("code", sa.String(length=50), nullable=False), + sa.Column("name", sa.String(length=200), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("scope_type", scope_type, server_default="GLOBAL", nullable=False), + sa.Column("required", sa.Boolean(), server_default=sa.text("false"), nullable=False), + sa.Column("expected_doc_type", sa.String(length=50), nullable=True), + sa.Column("sort_order", sa.Integer(), server_default="0", nullable=False), + sa.Column("is_active", sa.Boolean(), server_default=sa.text("true"), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["parent_id"], ["etmf_nodes.id"]), + sa.ForeignKeyConstraint(["study_id"], ["studies.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("study_id", "parent_id", "code", name="uq_etmf_nodes_study_parent_code"), + ) + op.create_index("ix_etmf_nodes_study_id", "etmf_nodes", ["study_id"]) + op.create_index("ix_etmf_nodes_parent_id", "etmf_nodes", ["parent_id"]) + op.create_index("ix_etmf_nodes_scope_type", "etmf_nodes", ["scope_type"]) + op.create_index("ix_etmf_nodes_is_active", "etmf_nodes", ["is_active"]) + + if _table_exists(inspector, "documents") and not _column_exists(inspector, "documents", "etmf_node_id"): + op.add_column("documents", sa.Column("etmf_node_id", uuid_type, nullable=True)) + op.create_index("ix_documents_etmf_node_id", "documents", ["etmf_node_id"]) + op.create_foreign_key("fk_documents_etmf_node_id", "documents", "etmf_nodes", ["etmf_node_id"], ["id"]) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if _table_exists(inspector, "documents") and _column_exists(inspector, "documents", "etmf_node_id"): + op.drop_constraint("fk_documents_etmf_node_id", "documents", type_="foreignkey") + op.drop_index("ix_documents_etmf_node_id", table_name="documents") + op.drop_column("documents", "etmf_node_id") + + if _table_exists(inspector, "etmf_nodes"): + op.drop_index("ix_etmf_nodes_is_active", table_name="etmf_nodes") + op.drop_index("ix_etmf_nodes_scope_type", table_name="etmf_nodes") + op.drop_index("ix_etmf_nodes_parent_id", table_name="etmf_nodes") + op.drop_index("ix_etmf_nodes_study_id", table_name="etmf_nodes") + op.drop_table("etmf_nodes") diff --git a/backend/app/api/v1/etmf.py b/backend/app/api/v1/etmf.py new file mode 100644 index 00000000..5e9483e8 --- /dev/null +++ b/backend/app/api/v1/etmf.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import uuid + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_current_user, get_db_session +from app.schemas.document import DocumentCreate, DocumentSummary +from app.schemas.etmf import EtmfNodeCreate, EtmfNodeRead, EtmfNodeUpdate, EtmfTreeNode +from app.services import etmf_service + +router = APIRouter() + + +@router.get("/tree", response_model=list[EtmfTreeNode]) +async def get_etmf_tree( + study_id: uuid.UUID = Query(...), + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> list[EtmfTreeNode]: + return await etmf_service.list_etmf_tree(db, study_id=study_id, current_user=current_user) + + +@router.post("/nodes", response_model=EtmfNodeRead, status_code=status.HTTP_201_CREATED) +async def create_etmf_node( + payload: EtmfNodeCreate, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> EtmfNodeRead: + node = await etmf_service.create_node(db, payload, current_user) + return EtmfNodeRead.model_validate(node) + + +@router.patch("/nodes/{node_id}", response_model=EtmfNodeRead) +async def update_etmf_node( + node_id: uuid.UUID, + payload: EtmfNodeUpdate, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> EtmfNodeRead: + node = await etmf_service.update_node(db, node_id, payload, current_user) + return EtmfNodeRead.model_validate(node) + + +@router.get("/nodes/{node_id}/documents", response_model=list[DocumentSummary]) +async def list_etmf_node_documents( + node_id: uuid.UUID, + site_id: uuid.UUID | None = Query(None), + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> list[DocumentSummary]: + return await etmf_service.list_node_documents(db, node_id=node_id, site_id=site_id, current_user=current_user) + + +@router.post("/nodes/{node_id}/documents", response_model=DocumentSummary, status_code=status.HTTP_201_CREATED) +async def create_etmf_node_document( + node_id: uuid.UUID, + payload: DocumentCreate, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> DocumentSummary: + return await etmf_service.create_node_document(db, node_id=node_id, payload=payload, current_user=current_user) diff --git a/backend/app/crud/document.py b/backend/app/crud/document.py index 9ae372e9..1202910a 100644 --- a/backend/app/crud/document.py +++ b/backend/app/crud/document.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import uuid from typing import Sequence @@ -33,6 +35,7 @@ async def list_documents( doc_type: str | None = None, status: str | None = None, scope_type: str | None = None, + etmf_node_id: uuid.UUID | None = None, cra_site_ids: set[uuid.UUID] | None = None, skip: int = 0, limit: int = 100, @@ -51,6 +54,8 @@ async def list_documents( stmt = stmt.where(Document.status == status) if site_id: stmt = stmt.where(Document.site_id == site_id) + if etmf_node_id: + stmt = stmt.where(Document.etmf_node_id == etmf_node_id) stmt = stmt.order_by(Document.updated_at.desc()).offset(skip).limit(limit) result = await db.execute(stmt) return result.scalars().all() diff --git a/backend/app/crud/etmf.py b/backend/app/crud/etmf.py new file mode 100644 index 00000000..fceb295c --- /dev/null +++ b/backend/app/crud/etmf.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import uuid +from typing import Sequence + +from sqlalchemy import select, update as sa_update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.etmf import EtmfNode + + +async def get_node(db: AsyncSession, node_id: uuid.UUID) -> EtmfNode | None: + result = await db.execute(select(EtmfNode).where(EtmfNode.id == node_id)) + return result.scalar_one_or_none() + + +async def list_nodes_by_study(db: AsyncSession, study_id: uuid.UUID, *, include_inactive: bool = True) -> Sequence[EtmfNode]: + stmt = select(EtmfNode).where(EtmfNode.study_id == study_id) + if not include_inactive: + stmt = stmt.where(EtmfNode.is_active.is_(True)) + stmt = stmt.order_by(EtmfNode.sort_order.asc(), EtmfNode.code.asc(), EtmfNode.name.asc()) + result = await db.execute(stmt) + return result.scalars().all() + + +async def create_node(db: AsyncSession, node: EtmfNode, *, commit: bool = True) -> EtmfNode: + db.add(node) + if commit: + await db.commit() + await db.refresh(node) + return node + + +async def update_node(db: AsyncSession, node_id: uuid.UUID, values: dict, *, commit: bool = True) -> None: + if values: + await db.execute(sa_update(EtmfNode).where(EtmfNode.id == node_id).values(**values)) + if commit: + await db.commit() diff --git a/backend/app/models/document.py b/backend/app/models/document.py index e6e19b27..4b492e07 100644 --- a/backend/app/models/document.py +++ b/backend/app/models/document.py @@ -31,6 +31,7 @@ class Document(Base): 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[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), nullable=True) + etmf_node_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("etmf_nodes.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) diff --git a/backend/app/models/etmf.py b/backend/app/models/etmf.py new file mode 100644 index 00000000..df878039 --- /dev/null +++ b/backend/app/models/etmf.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Optional + +from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, Integer, 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 +from app.models.document import DocumentScopeType + + +class EtmfNode(Base): + __tablename__ = "etmf_nodes" + __table_args__ = ( + UniqueConstraint("study_id", "parent_id", "code", name="uq_etmf_nodes_study_parent_code"), + ) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=False) + parent_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("etmf_nodes.id"), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(200), nullable=False) + description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + scope_type: Mapped[DocumentScopeType] = mapped_column( + Enum(DocumentScopeType, name="document_scope_type"), + nullable=False, + server_default=DocumentScopeType.GLOBAL.value, + ) + required: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false") + expected_doc_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="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(), + ) + + parent = relationship("EtmfNode", remote_side=[id], foreign_keys=[parent_id]) diff --git a/backend/app/schemas/document.py b/backend/app/schemas/document.py index dabfa826..7c51aae1 100644 --- a/backend/app/schemas/document.py +++ b/backend/app/schemas/document.py @@ -25,6 +25,7 @@ class DocumentStatus(str, enum.Enum): class DocumentCreate(BaseModel): trial_id: uuid.UUID site_id: Optional[uuid.UUID] = None + etmf_node_id: Optional[uuid.UUID] = None doc_no: str doc_type: str title: str @@ -39,6 +40,7 @@ class DocumentUpdate(BaseModel): scope_type: Optional[DocumentScopeType] = None owner_id: Optional[uuid.UUID] = None site_id: Optional[uuid.UUID] = None + etmf_node_id: Optional[uuid.UUID] = None status: Optional[DocumentStatus] = None description: Optional[str] = None @@ -47,6 +49,7 @@ class DocumentSummary(BaseModel): id: uuid.UUID trial_id: uuid.UUID site_id: Optional[uuid.UUID] = None + etmf_node_id: Optional[uuid.UUID] = None doc_type: str title: str scope_type: DocumentScopeType diff --git a/backend/app/schemas/etmf.py b/backend/app/schemas/etmf.py new file mode 100644 index 00000000..8d4558ab --- /dev/null +++ b/backend/app/schemas/etmf.py @@ -0,0 +1,72 @@ +import enum +import uuid +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + +from app.schemas.document import DocumentScopeType, DocumentSummary + + +class EtmfNodeStatus(str, enum.Enum): + NOT_REQUIRED = "NOT_REQUIRED" + MISSING = "MISSING" + UPLOADED = "UPLOADED" + EFFECTIVE = "EFFECTIVE" + INACTIVE = "INACTIVE" + + +class EtmfNodeBase(BaseModel): + parent_id: Optional[uuid.UUID] = None + code: str + name: str + description: Optional[str] = None + scope_type: DocumentScopeType = DocumentScopeType.GLOBAL + required: bool = False + expected_doc_type: Optional[str] = None + sort_order: int = 0 + is_active: bool = True + + +class EtmfNodeCreate(EtmfNodeBase): + study_id: uuid.UUID + + +class EtmfNodeUpdate(BaseModel): + parent_id: Optional[uuid.UUID] = None + code: Optional[str] = None + name: Optional[str] = None + description: Optional[str] = None + scope_type: Optional[DocumentScopeType] = None + required: Optional[bool] = None + expected_doc_type: Optional[str] = None + sort_order: Optional[int] = None + is_active: Optional[bool] = None + + +class EtmfNodeRead(EtmfNodeBase): + id: uuid.UUID + study_id: uuid.UUID + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class EtmfNodeStatusSummary(BaseModel): + status: EtmfNodeStatus + document_count: int = 0 + effective_document_count: int = 0 + missing_count: int = 0 + + +class EtmfTreeNode(EtmfNodeRead): + status: EtmfNodeStatus = EtmfNodeStatus.NOT_REQUIRED + document_count: int = 0 + effective_document_count: int = 0 + children: list["EtmfTreeNode"] = Field(default_factory=list) + + +class EtmfNodeDocuments(BaseModel): + node: EtmfNodeRead + documents: list[DocumentSummary] = Field(default_factory=list) diff --git a/backend/app/services/document_service.py b/backend/app/services/document_service.py index e825f795..76f306a6 100644 --- a/backend/app/services/document_service.py +++ b/backend/app/services/document_service.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import hashlib import json import uuid @@ -16,6 +18,7 @@ from app.core.project_permissions import role_has_api_permission 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 etmf as etmf_crud from app.crud import document_version as version_crud from app.crud import member as member_crud from app.crud import site as site_crud @@ -58,6 +61,7 @@ def _doc_snapshot(doc: Document) -> dict: "id": str(doc.id), "trial_id": str(doc.trial_id), "site_id": str(doc.site_id) if doc.site_id else None, + "etmf_node_id": str(doc.etmf_node_id) if doc.etmf_node_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, @@ -108,9 +112,20 @@ async def create_document( 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: + etmf_node = None + if doc_in.etmf_node_id: + etmf_node = await etmf_crud.get_node(db, doc_in.etmf_node_id) + if not etmf_node: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="eTMF目录不存在") + if etmf_node.study_id != doc_in.trial_id: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="eTMF目录不属于当前项目") + if etmf_node.scope_type == DocumentScopeType.SITE and not doc_in.site_id: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="中心级eTMF目录需指定中心") + + scope_type = etmf_node.scope_type if etmf_node else doc_in.scope_type + if 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 + site_id = doc_in.site_id if scope_type == DocumentScopeType.SITE else None if site_id: site = await site_crud.get_site(db, site_id) if not site or not site.is_active: @@ -122,10 +137,11 @@ async def create_document( doc = Document( trial_id=doc_in.trial_id, site_id=site_id, + etmf_node_id=doc_in.etmf_node_id, doc_no=doc_in.doc_no, doc_type=doc_in.doc_type, title=doc_in.title, - scope_type=doc_in.scope_type, + scope_type=scope_type, owner_id=doc_in.owner_id, description=doc_in.description, ) @@ -154,6 +170,7 @@ async def list_documents( doc_type: str | None, status: str | None, scope_type: str | None, + etmf_node_id: uuid.UUID | None = None, skip: int, limit: int, current_user, @@ -168,6 +185,7 @@ async def list_documents( doc_type=doc_type, status=status, scope_type=scope_type, + etmf_node_id=etmf_node_id, cra_site_ids=cra_site_ids, skip=skip, limit=limit, @@ -185,6 +203,7 @@ async def list_documents( id=doc.id, trial_id=doc.trial_id, site_id=doc.site_id, + etmf_node_id=doc.etmf_node_id, doc_type=doc.doc_type, title=doc.title, scope_type=doc.scope_type, @@ -228,6 +247,7 @@ async def get_document_detail( id=doc.id, trial_id=doc.trial_id, site_id=doc.site_id, + etmf_node_id=doc.etmf_node_id, doc_type=doc.doc_type, title=doc.title, scope_type=doc.scope_type, diff --git a/backend/app/services/etmf_service.py b/backend/app/services/etmf_service.py new file mode 100644 index 00000000..1fa6fdeb --- /dev/null +++ b/backend/app/services/etmf_service.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +import uuid +from collections import defaultdict +from typing import Iterable + +from fastapi import HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.crud import document as document_crud +from app.crud import etmf as etmf_crud +from app.models.document import Document, DocumentStatus +from app.models.etmf import EtmfNode +from app.models.audit_log import AuditLog +from app.schemas.document import DocumentCreate, DocumentSummary +from app.schemas.etmf import EtmfNodeCreate, EtmfNodeRead, EtmfNodeStatus, EtmfTreeNode, EtmfNodeUpdate + + +def _role_value(user) -> str: + if user is None: + return "SYSTEM" + return user.role.value if hasattr(user.role, "value") else str(user.role) + + +def calculate_node_status(node: EtmfNode, documents: Iterable[Document]) -> EtmfNodeStatus: + docs = list(documents) + if not node.is_active: + return EtmfNodeStatus.INACTIVE + if any(doc.current_effective_version_id for doc in docs): + return EtmfNodeStatus.EFFECTIVE + if docs: + return EtmfNodeStatus.UPLOADED + if node.required: + return EtmfNodeStatus.MISSING + return EtmfNodeStatus.NOT_REQUIRED + + +def _tree_node(node: EtmfNode, documents: list[Document], children: list[EtmfTreeNode]) -> EtmfTreeNode: + return EtmfTreeNode( + id=node.id, + study_id=node.study_id, + parent_id=node.parent_id, + code=node.code, + name=node.name, + description=node.description, + scope_type=node.scope_type, + required=node.required, + expected_doc_type=node.expected_doc_type, + sort_order=node.sort_order, + is_active=node.is_active, + created_at=node.created_at, + updated_at=node.updated_at, + status=calculate_node_status(node, documents), + document_count=len(documents), + effective_document_count=sum(1 for doc in documents if doc.current_effective_version_id), + children=children, + ) + + +async def _documents_by_node(db: AsyncSession, study_id: uuid.UUID) -> dict[uuid.UUID, list[Document]]: + result = await db.execute( + select(Document).where( + Document.trial_id == study_id, + Document.etmf_node_id.is_not(None), + Document.status != DocumentStatus.ARCHIVED, + ) + ) + grouped: dict[uuid.UUID, list[Document]] = defaultdict(list) + for document in result.scalars().all(): + if document.etmf_node_id: + grouped[document.etmf_node_id].append(document) + return grouped + + +async def list_etmf_tree(db: AsyncSession, *, study_id: uuid.UUID, current_user) -> list[EtmfTreeNode]: + if current_user is not None: + from app.services import document_service + + await document_service._ensure_study_access(db, study_id, current_user, action="view") + nodes = list(await etmf_crud.list_nodes_by_study(db, study_id)) + documents = await _documents_by_node(db, study_id) + children_by_parent: dict[uuid.UUID | None, list[EtmfNode]] = defaultdict(list) + for node in nodes: + children_by_parent[node.parent_id].append(node) + + def build(node: EtmfNode) -> EtmfTreeNode: + children = [build(child) for child in children_by_parent.get(node.id, [])] + return _tree_node(node, documents.get(node.id, []), children) + + return [build(node) for node in children_by_parent.get(None, [])] + + +async def create_node(db: AsyncSession, payload: EtmfNodeCreate, current_user) -> EtmfNode: + from app.services import document_service + + await document_service._ensure_study_access(db, payload.study_id, current_user, action="create_document") + if payload.parent_id: + parent = await etmf_crud.get_node(db, payload.parent_id) + if not parent or parent.study_id != payload.study_id: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="父级eTMF目录不存在或不属于当前项目") + node = EtmfNode(**payload.model_dump()) + db.add(node) + db.add( + AuditLog( + study_id=payload.study_id, + entity_type="ETMF_NODE", + entity_id=node.id, + action="ETMF_NODE_CREATED", + detail=f'{{"code":"{node.code}","name":"{node.name}"}}', + operator_id=current_user.id, + operator_role=_role_value(current_user), + ) + ) + await db.commit() + await db.refresh(node) + return node + + +async def update_node(db: AsyncSession, node_id: uuid.UUID, payload: EtmfNodeUpdate, current_user) -> EtmfNode: + from app.services import document_service + + node = await etmf_crud.get_node(db, node_id) + if not node: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="eTMF目录不存在") + await document_service._ensure_study_access(db, node.study_id, current_user, action="create_version") + values = payload.model_dump(exclude_unset=True) + if "parent_id" in values and values["parent_id"]: + parent = await etmf_crud.get_node(db, values["parent_id"]) + if not parent or parent.study_id != node.study_id or parent.id == node.id: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="父级eTMF目录不合法") + await etmf_crud.update_node(db, node_id, values, commit=False) + db.add( + AuditLog( + study_id=node.study_id, + entity_type="ETMF_NODE", + entity_id=node.id, + action="ETMF_NODE_UPDATED", + detail="{}", + operator_id=current_user.id, + operator_role=_role_value(current_user), + ) + ) + await db.commit() + refreshed = await etmf_crud.get_node(db, node_id) + if not refreshed: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="eTMF目录不存在") + return refreshed + + +async def list_node_documents( + db: AsyncSession, + *, + node_id: uuid.UUID, + site_id: uuid.UUID | None, + current_user, +) -> list[DocumentSummary]: + from app.services import document_service + + node = await etmf_crud.get_node(db, node_id) + if not node: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="eTMF目录不存在") + return await document_service.list_documents( + db, + trial_id=node.study_id, + site_id=site_id, + doc_type=None, + status=None, + scope_type=None, + etmf_node_id=node_id, + skip=0, + limit=500, + current_user=current_user, + ) + + +async def create_node_document( + db: AsyncSession, + *, + node_id: uuid.UUID, + payload: DocumentCreate, + current_user, +) -> DocumentSummary: + from app.services import document_service + + node = await etmf_crud.get_node(db, node_id) + if not node: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="eTMF目录不存在") + doc_payload = payload.model_copy(update={"trial_id": node.study_id, "etmf_node_id": node.id, "scope_type": node.scope_type}) + document = await document_service.create_document(db, doc_payload, current_user) + return DocumentSummary.model_validate(document) diff --git a/backend/tests/test_etmf_api.py b/backend/tests/test_etmf_api.py new file mode 100644 index 00000000..a2616b32 --- /dev/null +++ b/backend/tests/test_etmf_api.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import uuid +from types import SimpleNamespace + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.document import Document, DocumentScopeType +from app.models.document_version import DocumentVersion # noqa: F401 +from app.models.distribution import Distribution # noqa: F401 +from app.models.acknowledgement import Acknowledgement # noqa: F401 +from app.models.etmf import EtmfNode +from app.services.etmf_service import calculate_node_status, list_etmf_tree + + +def test_required_node_without_documents_is_missing() -> None: + node = SimpleNamespace(is_active=True, required=True) + + assert calculate_node_status(node, []) == "MISSING" + + +def test_node_with_document_without_effective_version_is_uploaded() -> None: + node = SimpleNamespace(is_active=True, required=True) + document = SimpleNamespace(current_effective_version_id=None) + + assert calculate_node_status(node, [document]) == "UPLOADED" + + +def test_node_with_current_effective_version_is_effective() -> None: + node = SimpleNamespace(is_active=True, required=True) + document = SimpleNamespace(current_effective_version_id=uuid.uuid4()) + + assert calculate_node_status(node, [document]) == "EFFECTIVE" + + +@pytest.mark.asyncio +async def test_etmf_tree_returns_hierarchy_and_status(db_session: AsyncSession) -> None: + study_id = uuid.uuid4() + parent = EtmfNode( + id=uuid.uuid4(), + study_id=study_id, + code="01", + name="项目管理", + scope_type=DocumentScopeType.GLOBAL, + required=False, + sort_order=1, + ) + child = EtmfNode( + id=uuid.uuid4(), + study_id=study_id, + parent_id=parent.id, + code="01.01", + name="方案与版本", + scope_type=DocumentScopeType.GLOBAL, + required=True, + sort_order=1, + ) + db_session.add_all([parent, child]) + await db_session.commit() + + tree = await list_etmf_tree(db_session, study_id=study_id, current_user=None) + + assert len(tree) == 1 + assert tree[0].code == "01" + assert tree[0].children[0].code == "01.01" + assert tree[0].children[0].status == "MISSING" diff --git a/backend/tests/test_etmf_nodes.py b/backend/tests/test_etmf_nodes.py new file mode 100644 index 00000000..b9e81554 --- /dev/null +++ b/backend/tests/test_etmf_nodes.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import uuid + +import pytest +from fastapi import HTTPException +from sqlalchemy import select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.base_class import Base +from app.models.document import Document, DocumentScopeType +from app.models.document_version import DocumentVersion # noqa: F401 +from app.models.distribution import Distribution # noqa: F401 +from app.models.acknowledgement import Acknowledgement # noqa: F401 +from app.models.user import UserRole, UserStatus + + +class UserStub: + def __init__(self) -> None: + self.id = uuid.uuid4() + self.role = UserRole.ADMIN + self.status = UserStatus.ACTIVE + + +def test_etmf_node_table_is_registered() -> None: + import app.db.base # noqa: F401 + + assert "etmf_nodes" in Base.metadata.tables + + +@pytest.mark.asyncio +async def test_document_create_persists_etmf_node_id(db_session: AsyncSession) -> None: + from app.models.etmf import EtmfNode + from app.schemas.document import DocumentCreate + from app.services import document_service + + study_id = ( + await db_session.execute(text("SELECT id FROM studies ORDER BY created_at DESC LIMIT 1")) + ).scalar_one() + node = EtmfNode( + study_id=study_id, + code="01.01", + name="方案与版本", + scope_type=DocumentScopeType.GLOBAL, + required=True, + sort_order=1, + ) + db_session.add(node) + await db_session.commit() + await db_session.refresh(node) + + doc = await document_service.create_document( + db_session, + DocumentCreate( + trial_id=study_id, + doc_no="PROTOCOL-001", + doc_type="Protocol", + title="研究方案", + scope_type=DocumentScopeType.GLOBAL, + etmf_node_id=node.id, + ), + UserStub(), + ) + + assert doc.etmf_node_id == node.id + stored = ( + await db_session.execute(select(Document).where(Document.id == doc.id)) + ).scalar_one() + assert stored.etmf_node_id == node.id + + +@pytest.mark.asyncio +async def test_site_scoped_etmf_node_requires_site_document(db_session: AsyncSession) -> None: + from app.models.etmf import EtmfNode + from app.schemas.document import DocumentCreate + from app.services import document_service + + study_id = ( + await db_session.execute(text("SELECT id FROM studies ORDER BY created_at DESC LIMIT 1")) + ).scalar_one() + node = EtmfNode( + study_id=study_id, + code="03.01", + name="中心启动文件", + scope_type=DocumentScopeType.SITE, + required=True, + sort_order=1, + ) + db_session.add(node) + await db_session.commit() + await db_session.refresh(node) + + with pytest.raises(HTTPException) as exc_info: + await document_service.create_document( + db_session, + DocumentCreate( + trial_id=study_id, + doc_no="SITE-STARTUP-001", + doc_type="Startup", + title="中心启动文件", + scope_type=DocumentScopeType.GLOBAL, + etmf_node_id=node.id, + ), + UserStub(), + ) + + assert exc_info.value.status_code == 422 + assert "中心" in str(exc_info.value.detail) diff --git a/docs/plans/2026-05-27-etmf-design.md b/docs/plans/2026-05-27-etmf-design.md new file mode 100644 index 00000000..7e0ad1cf --- /dev/null +++ b/docs/plans/2026-05-27-etmf-design.md @@ -0,0 +1,151 @@ +# eTMF Module Design + +## 背景 + +当前系统已有文件版本管理能力,包括 `documents`、`document_versions`、文件上传下载、分发、回执、权限和审计。`/etmf` 路由目前是占位页,并已复用 `documents:read` 权限。 + +eTMF 第一阶段需要同时满足两个目标: + +- 合规归档视图:按 TMF 标准目录检查项目级和中心级文件归档状态。 +- 文件管理入口:项目成员可以按目录上传、查找、下载、维护版本。 + +## 推荐方案 + +采用“TMF 目录树模板 + 文档元数据挂载”模型。前端呈现为目录结构,但后端不把 eTMF 当作真实文件夹系统,而是用目录节点表达归档标准,用现有文档实体承载文件和版本。 + +该方案保留文件夹式使用体验,同时支持合规检查、缺失项统计、中心维度对比和后续报表扩展。 + +## 核心模型 + +### eTMF 目录节点 + +新增 `etmf_nodes` 表,表达项目内 eTMF 目录树。 + +建议字段: + +- `id` +- `study_id` +- `parent_id` +- `code` +- `name` +- `description` +- `scope_type`: `GLOBAL` 或 `SITE` +- `required` +- `expected_doc_type` +- `sort_order` +- `is_active` +- `created_at` +- `updated_at` + +约束: + +- 同一项目、同一父节点下 `code` 唯一。 +- 节点软停用,不物理删除,避免历史文档失去归档上下文。 +- 第一阶段不做自定义模板版本管理,只提供项目内节点树。 + +### 文档挂载 + +复用现有 `documents` 表,新增 `etmf_node_id` 外键。 + +规则: + +- `documents.etmf_node_id` 为空表示普通文件版本管理文档。 +- `documents.etmf_node_id` 非空表示该文档归档到 eTMF 节点。 +- 项目级节点对应 `documents.site_id = null`。 +- 中心级节点对应 `documents.site_id` 有值。 + +第一阶段不新增独立 eTMF 文件表,避免重复存储文件元数据和版本逻辑。 + +## 状态计算 + +eTMF 状态优先通过查询计算,不在第一阶段持久化冗余状态。 + +节点状态: + +- `NOT_REQUIRED`: 非必需节点且无文档。 +- `MISSING`: 必需节点没有匹配文档。 +- `UPLOADED`: 有文档但没有生效版本。 +- `EFFECTIVE`: 至少一个匹配文档存在当前生效版本。 +- `INACTIVE`: 节点停用。 + +中心级节点按中心分别计算状态。项目级节点只计算项目维度。 + +## 后端接口 + +新增 eTMF API,挂载在现有 v1 router 下。 + +建议接口: + +- `GET /api/v1/etmf/tree?study_id=...` + 返回节点树、每个节点汇总状态和文档数量。 +- `GET /api/v1/etmf/nodes/{node_id}/documents?site_id=...` + 返回节点下文档列表,复用 `DocumentSummary`。 +- `POST /api/v1/etmf/nodes` + 创建节点。 +- `PATCH /api/v1/etmf/nodes/{node_id}` + 更新节点名称、排序、必需性、适用范围。 +- `POST /api/v1/etmf/nodes/{node_id}/documents` + 在节点下创建文档,本质调用现有 `create_document`,自动带入 `etmf_node_id`。 + +第一阶段不做节点移动和批量导入,除非后续确认需要。 + +## 前端体验 + +替换当前 `EtmfPlaceholder.vue`。 + +页面结构: + +- 顶部工具栏:中心筛选、状态筛选、刷新、新增目录、新增文档。 +- 左侧:eTMF 目录树,显示节点名称、编码、状态标记、缺失提示。 +- 中间:当前节点文档列表,展示标题、类型、范围、中心、当前版本、更新时间、操作。 +- 右侧:节点详情,展示必需性、适用范围、完成状态、缺失说明、中心完成度摘要。 + +交互规则: + +- 点击目录节点加载该节点文档。 +- 创建文档时默认带入当前节点和当前中心筛选。 +- 文档详情和版本维护复用现有文档详情页。 +- 中心级节点在选择中心后展示该中心归档状态;未选中心时展示中心完成度汇总。 + +## 权限和审计 + +第一阶段复用现有文档权限: + +- 查看:`documents:read` +- 新建文档或目录:`documents:create` +- 更新文档版本或节点:`documents:update` +- 删除或停用:`documents:delete` + +审计: + +- 文档创建、版本上传、下载、删除继续复用现有文档审计。 +- 节点创建、更新、停用新增 `ETMF_NODE_CREATED`、`ETMF_NODE_UPDATED`、`ETMF_NODE_DISABLED` 审计动作。 + +## 测试策略 + +后端: + +- 节点树接口按项目隔离。 +- 必需节点缺失状态计算正确。 +- 中心级节点按 `site_id` 计算状态。 +- CRA 只能看到自身中心范围内的中心级文件。 +- 节点下创建文档会写入 `etmf_node_id`。 + +前端: + +- `/etmf` 不再展示占位页。 +- 节点树加载、节点选择、中心筛选、状态展示。 +- 新建文档时携带当前节点。 +- 路由权限继续使用 `documents:read`。 + +## 取舍 + +第一阶段明确不做: + +- 电子签名。 +- 复杂审批流。 +- 模板版本发布。 +- 物理文件夹移动。 +- 批量导入 TMF 标准目录。 + +这些能力可以在基础目录树和文档挂载稳定后追加。 diff --git a/docs/plans/2026-05-27-etmf-implementation.md b/docs/plans/2026-05-27-etmf-implementation.md new file mode 100644 index 00000000..d88cd94d --- /dev/null +++ b/docs/plans/2026-05-27-etmf-implementation.md @@ -0,0 +1,386 @@ +# eTMF Module Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build an eTMF module that combines a compliant TMF directory tree with reusable document version management. + +**Architecture:** Add eTMF directory nodes as metadata, then mount existing `documents` records onto those nodes with `etmf_node_id`. Keep file storage, versions, permissions, downloads, distribution, and audit behavior in the existing document module. + +**Tech Stack:** FastAPI, SQLAlchemy async ORM, Alembic, PostgreSQL, Vue 3, Element Plus, Vitest, Pytest. + +--- + +## Implementation Notes + +The repository instruction says not to plan or execute git commits unless explicitly requested. This plan intentionally omits commit steps. + +Follow TDD for each backend behavior before implementation. Keep the first release small: no e-signature, no workflow redesign, no template versioning, no bulk import. + +### Task 1: Backend eTMF Node Model + +**Files:** + +- Create: `backend/app/models/etmf.py` +- Create: `backend/app/schemas/etmf.py` +- Modify: `backend/app/db/base.py` +- Create: `backend/alembic/versions/20260527_07_add_etmf_nodes.py` +- Test: `backend/tests/test_etmf_nodes.py` + +**Step 1: Write failing model/import test** + +Add a test that imports `EtmfNode` through `app.db.base` metadata and verifies the table name is registered. + +Run: + +```bash +cd backend +pytest tests/test_etmf_nodes.py -q +``` + +Expected: fail because `app.models.etmf` does not exist. + +**Step 2: Add model** + +Create `EtmfNode` with these columns: + +- `id` +- `study_id` +- `parent_id` +- `code` +- `name` +- `description` +- `scope_type` +- `required` +- `expected_doc_type` +- `sort_order` +- `is_active` +- `created_at` +- `updated_at` + +Use existing enum values from document scope where practical. Keep node-specific logic out of the model. + +**Step 3: Add schemas** + +Create request and response schemas: + +- `EtmfNodeCreate` +- `EtmfNodeUpdate` +- `EtmfNodeRead` +- `EtmfTreeNode` +- `EtmfNodeStatusSummary` + +**Step 4: Add migration** + +Create `etmf_nodes` with: + +- Foreign key to `studies.id` +- Self foreign key `parent_id` +- Unique constraint on `(study_id, parent_id, code)` +- Indexes on `study_id`, `parent_id`, `scope_type`, `is_active` + +**Step 5: Verify** + +Run: + +```bash +cd backend +pytest tests/test_etmf_nodes.py -q +``` + +Expected: pass. + +### Task 2: Link Documents To eTMF Nodes + +**Files:** + +- Modify: `backend/app/models/document.py` +- Modify: `backend/app/schemas/document.py` +- Modify: `backend/app/crud/document.py` +- Modify: `backend/app/services/document_service.py` +- Modify: `frontend/src/types/documents.ts` +- Modify: `backend/alembic/versions/20260527_07_add_etmf_nodes.py` +- Test: `backend/tests/test_etmf_nodes.py` + +**Step 1: Write failing service test** + +Test that creating a document with `etmf_node_id` persists the relationship and list/detail responses include it. + +Expected: fail because schema and model do not include `etmf_node_id`. + +**Step 2: Add model column** + +Add nullable `Document.etmf_node_id` with foreign key to `etmf_nodes.id`. + +**Step 3: Add schema fields** + +Add `etmf_node_id` to: + +- `DocumentCreate` +- `DocumentUpdate` +- `DocumentSummary` +- `DocumentDetail` + +**Step 4: Validate node scope** + +In `document_service.create_document`, if `etmf_node_id` is provided: + +- Verify node exists. +- Verify node belongs to the same study. +- If node scope is `SITE`, require `site_id`. +- If node scope is `GLOBAL`, normalize `site_id` to null unless the product decision changes. + +**Step 5: Verify** + +Run: + +```bash +cd backend +pytest tests/test_etmf_nodes.py -q +``` + +Expected: pass. + +### Task 3: eTMF Service And Tree API + +**Files:** + +- Create: `backend/app/crud/etmf.py` +- Create: `backend/app/services/etmf_service.py` +- Create: `backend/app/api/v1/etmf.py` +- Modify: `backend/app/api/v1/router.py` +- Test: `backend/tests/test_etmf_api.py` + +**Step 1: Write failing API tests** + +Cover: + +- `GET /api/v1/etmf/tree?study_id=...` +- Required empty node returns `MISSING`. +- Node with document but no effective version returns `UPLOADED`. +- Node with current effective version returns `EFFECTIVE`. +- Site-scoped node can return per-site status. + +Expected: fail because API does not exist. + +**Step 2: Implement CRUD** + +Add small CRUD functions: + +- `get_node` +- `list_nodes_by_study` +- `create_node` +- `update_node` + +Avoid broad generic repository abstractions. + +**Step 3: Implement service** + +Add tree assembly and status calculation: + +- Build parent-child tree in memory. +- Query documents for target study and group by `etmf_node_id`. +- Calculate node status from required flag and document effective version presence. +- Keep status calculation deterministic and isolated for unit tests. + +**Step 4: Implement routes** + +Add: + +- `GET /tree` +- `GET /nodes/{node_id}/documents` +- `POST /nodes` +- `PATCH /nodes/{node_id}` +- `POST /nodes/{node_id}/documents` + +Use existing project permission checks through the document permission model. + +**Step 5: Verify** + +Run: + +```bash +cd backend +pytest tests/test_etmf_api.py -q +``` + +Expected: pass. + +### Task 4: Frontend API And Types + +**Files:** + +- Create: `frontend/src/types/etmf.ts` +- Create: `frontend/src/api/etmf.ts` +- Test: `frontend/src/api/etmf.test.ts` + +**Step 1: Write failing API test** + +Test that each frontend API helper calls the expected URL and HTTP method. + +Expected: fail because `src/api/etmf.ts` does not exist. + +**Step 2: Add types** + +Add types matching backend schemas: + +- `EtmfNodeStatus` +- `EtmfTreeNode` +- `EtmfNodeCreatePayload` +- `EtmfNodeUpdatePayload` + +**Step 3: Add API helpers** + +Add: + +- `fetchEtmfTree` +- `fetchEtmfNodeDocuments` +- `createEtmfNode` +- `updateEtmfNode` +- `createEtmfDocument` + +**Step 4: Verify** + +Run: + +```bash +cd frontend +npm test -- src/api/etmf.test.ts +``` + +Expected: pass. + +### Task 5: eTMF Page + +**Files:** + +- Replace: `frontend/src/views/ia/EtmfPlaceholder.vue` +- Modify: `frontend/src/locales/zh-CN.ts` +- Test: `frontend/src/views/ia/EtmfPlaceholder.test.ts` + +**Step 1: Write failing page test** + +Test that `/etmf` page: + +- Renders tree panel. +- Loads eTMF tree for current study. +- Selecting a node loads its documents. +- Shows missing/effective status labels. + +Expected: fail because the page is still a placeholder. + +**Step 2: Build layout** + +Use existing CTMS shell classes: + +- Top action bar. +- Left tree panel. +- Main document table. +- Right node detail panel. + +Do not introduce a marketing-style page or nested card layout. + +**Step 3: Add interactions** + +Implement: + +- Center filter. +- Node selection. +- Refresh. +- Create node dialog. +- Create document dialog that posts to eTMF node document API. + +Reuse existing display helpers and document enums. + +**Step 4: Verify** + +Run: + +```bash +cd frontend +npm test -- src/views/ia/EtmfPlaceholder.test.ts +``` + +Expected: pass. + +### Task 6: Route And Permission Regression + +**Files:** + +- Modify: `frontend/src/utils/projectRoutePermissions.ts` +- Modify: `frontend/src/utils/projectRoutePermissions.test.ts` +- Modify: `backend/tests/test_api_permissions.py` + +**Step 1: Write failing regression tests** + +Ensure: + +- `/etmf` still requires `documents:read`. +- eTMF write endpoints are covered by document permissions. +- Existing document permission tests remain stable. + +**Step 2: Implement minimal permission wiring** + +Keep first-stage eTMF permissions mapped to existing document operation keys. + +**Step 3: Verify** + +Run: + +```bash +cd backend +pytest tests/test_api_permissions.py -q +cd ../frontend +npm test -- src/utils/projectRoutePermissions.test.ts +``` + +Expected: pass. + +### Task 7: End-To-End Verification + +**Files:** + +- No source changes expected unless verification finds a bug. + +**Step 1: Run backend focused tests** + +```bash +cd backend +pytest tests/test_etmf_nodes.py tests/test_etmf_api.py tests/test_api_permissions.py -q +``` + +Expected: pass. + +**Step 2: Run frontend focused tests** + +```bash +cd frontend +npm test -- src/api/etmf.test.ts src/views/ia/EtmfPlaceholder.test.ts src/utils/projectRoutePermissions.test.ts +``` + +Expected: pass. + +**Step 3: Run UI contract check if available** + +```bash +cd frontend +npm run verify-ui-contract +``` + +Expected: pass. + +**Step 4: Manual browser check** + +Start the dev server and open `/etmf` with an active study: + +```bash +cd frontend +npm run dev +``` + +Expected: + +- eTMF page is not blank. +- Directory tree is visible. +- Selecting a node updates the document list. +- Center filter does not break layout. +- Text does not overlap at desktop and mobile widths. diff --git a/frontend/src/api/etmf.test.ts b/frontend/src/api/etmf.test.ts new file mode 100644 index 00000000..bda6da49 --- /dev/null +++ b/frontend/src/api/etmf.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const apiGet = vi.fn(); +const apiPost = vi.fn(); +const apiPatch = vi.fn(); + +vi.mock("./axios", () => ({ + apiGet, + apiPost, + apiPatch, +})); + +describe("etmf api", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("uses canonical eTMF URLs", async () => { + const { fetchEtmfTree, fetchEtmfNodeDocuments, createEtmfNode, updateEtmfNode, createEtmfDocument } = await import("./etmf"); + + fetchEtmfTree("study-1"); + fetchEtmfNodeDocuments("node-1", { site_id: "site-1" }); + createEtmfNode({ study_id: "study-1", code: "01", name: "项目管理" }); + updateEtmfNode("node-1", { name: "项目管理文件" }); + createEtmfDocument("node-1", { + trial_id: "study-1", + doc_no: "DOC-001", + doc_type: "Protocol", + title: "研究方案", + scope_type: "GLOBAL", + }); + + expect(apiGet).toHaveBeenCalledWith("/api/v1/etmf/tree", { params: { study_id: "study-1" } }); + expect(apiGet).toHaveBeenCalledWith("/api/v1/etmf/nodes/node-1/documents", { params: { site_id: "site-1" } }); + expect(apiPost).toHaveBeenCalledWith("/api/v1/etmf/nodes", { study_id: "study-1", code: "01", name: "项目管理" }); + expect(apiPatch).toHaveBeenCalledWith("/api/v1/etmf/nodes/node-1", { name: "项目管理文件" }); + expect(apiPost).toHaveBeenCalledWith( + "/api/v1/etmf/nodes/node-1/documents", + expect.objectContaining({ doc_no: "DOC-001" }) + ); + }); +}); diff --git a/frontend/src/api/etmf.ts b/frontend/src/api/etmf.ts new file mode 100644 index 00000000..6c0c09f4 --- /dev/null +++ b/frontend/src/api/etmf.ts @@ -0,0 +1,18 @@ +import { apiGet, apiPatch, apiPost } from "./axios"; +import type { DocumentSummary } from "../types/documents"; +import type { EtmfDocumentCreatePayload, EtmfNodeCreatePayload, EtmfNodeUpdatePayload, EtmfTreeNode } from "../types/etmf"; + +export const fetchEtmfTree = (studyId: string) => + apiGet("/api/v1/etmf/tree", { params: { study_id: studyId } }); + +export const fetchEtmfNodeDocuments = (nodeId: string, params: Record = {}) => + apiGet(`/api/v1/etmf/nodes/${nodeId}/documents`, { params }); + +export const createEtmfNode = (payload: EtmfNodeCreatePayload) => + apiPost("/api/v1/etmf/nodes", payload); + +export const updateEtmfNode = (nodeId: string, payload: EtmfNodeUpdatePayload) => + apiPatch(`/api/v1/etmf/nodes/${nodeId}`, payload); + +export const createEtmfDocument = (nodeId: string, payload: EtmfDocumentCreatePayload) => + apiPost(`/api/v1/etmf/nodes/${nodeId}/documents`, payload); diff --git a/frontend/src/types/documents.ts b/frontend/src/types/documents.ts index 96776f48..d8c6bb1f 100644 --- a/frontend/src/types/documents.ts +++ b/frontend/src/types/documents.ts @@ -18,6 +18,7 @@ export interface DocumentSummary { id: string; trial_id: string; site_id?: string | null; + etmf_node_id?: string | null; doc_type: string; title: string; scope_type: DocumentScopeType; diff --git a/frontend/src/types/etmf.ts b/frontend/src/types/etmf.ts new file mode 100644 index 00000000..3c14dd45 --- /dev/null +++ b/frontend/src/types/etmf.ts @@ -0,0 +1,63 @@ +import type { DocumentScopeType, DocumentSummary } from "./documents"; + +export type EtmfNodeStatus = "NOT_REQUIRED" | "MISSING" | "UPLOADED" | "EFFECTIVE" | "INACTIVE"; + +export interface EtmfTreeNode { + id: string; + study_id: string; + parent_id?: string | null; + code: string; + name: string; + description?: string | null; + scope_type: DocumentScopeType; + required: boolean; + expected_doc_type?: string | null; + sort_order: number; + is_active: boolean; + status: EtmfNodeStatus; + document_count: number; + effective_document_count: number; + created_at: string; + updated_at: string; + children: EtmfTreeNode[]; +} + +export interface EtmfNodeCreatePayload { + study_id: string; + parent_id?: string | null; + code: string; + name: string; + description?: string | null; + scope_type?: DocumentScopeType; + required?: boolean; + expected_doc_type?: string | null; + sort_order?: number; + is_active?: boolean; +} + +export interface EtmfNodeUpdatePayload { + parent_id?: string | null; + code?: string; + name?: string; + description?: string | null; + scope_type?: DocumentScopeType; + required?: boolean; + expected_doc_type?: string | null; + sort_order?: number; + is_active?: boolean; +} + +export type EtmfDocumentCreatePayload = { + trial_id: string; + site_id?: string | null; + doc_no: string; + doc_type: string; + title: string; + scope_type: DocumentScopeType; + owner_id?: string | null; + description?: string | null; +}; + +export interface EtmfNodeDocumentList { + documents: DocumentSummary[]; +} diff --git a/frontend/src/views/ia/EtmfPlaceholder.test.ts b/frontend/src/views/ia/EtmfPlaceholder.test.ts new file mode 100644 index 00000000..ee3d45c8 --- /dev/null +++ b/frontend/src/views/ia/EtmfPlaceholder.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const readSource = () => readFileSync(resolve(__dirname, "./EtmfPlaceholder.vue"), "utf8"); + +describe("EtmfPlaceholder", () => { + it("renders a functional eTMF workspace instead of the generic placeholder", () => { + const source = readSource(); + + expect(source).toContain("fetchEtmfTree"); + expect(source).toContain("fetchEtmfNodeDocuments"); + expect(source).toContain("createEtmfDocument"); + expect(source).toContain("etmf-tree-panel"); + expect(source).toContain("etmf-document-table"); + expect(source).toContain("etmf-node-detail"); + expect(source).toContain("selectedNode"); + expect(source).toContain("statusType"); + expect(source).not.toContain("ModulePlaceholder"); + }); +}); diff --git a/frontend/src/views/ia/EtmfPlaceholder.vue b/frontend/src/views/ia/EtmfPlaceholder.vue index 0f1fdf93..08cd8b04 100644 --- a/frontend/src/views/ia/EtmfPlaceholder.vue +++ b/frontend/src/views/ia/EtmfPlaceholder.vue @@ -1,13 +1,847 @@ + +