diff --git a/backend/alembic/versions/20260228_01_add_subject_pds.py b/backend/alembic/versions/20260228_01_add_subject_pds.py new file mode 100644 index 00000000..3bd7a386 --- /dev/null +++ b/backend/alembic/versions/20260228_01_add_subject_pds.py @@ -0,0 +1,75 @@ +"""add subject_pds table + +Revision ID: 20260228_01 +Revises: 20260227_06 +Create Date: 2026-02-28 10:30:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision: str = "20260228_01" +down_revision: Union[str, None] = "20260227_06" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + table_names = set(inspector.get_table_names()) + + if "subject_pds" not in table_names: + op.create_table( + "subject_pds", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("study_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("subject_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("pd_no", sa.String(length=64), nullable=False), + sa.Column("pd_type", sa.String(length=100), nullable=False), + sa.Column("pd_level", sa.String(length=20), nullable=False, server_default=sa.text("'GENERAL'")), + sa.Column("approval_status", sa.String(length=20), nullable=False, server_default=sa.text("'DRAFT'")), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'OPEN'")), + sa.Column("created_by", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + sa.ForeignKeyConstraint(["study_id"], ["studies.id"]), + sa.ForeignKeyConstraint(["subject_id"], ["subjects.id"]), + sa.ForeignKeyConstraint(["created_by"], ["users.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("study_id", "pd_no", name="uq_subject_pds_study_pd_no"), + ) + table_names.add("subject_pds") + + indexes = {idx["name"] for idx in inspector.get_indexes("subject_pds")} if "subject_pds" in table_names else set() + study_index_name = op.f("ix_subject_pds_study_id") + if study_index_name not in indexes: + op.create_index(study_index_name, "subject_pds", ["study_id"], unique=False) + + subject_index_name = op.f("ix_subject_pds_subject_id") + if subject_index_name not in indexes: + op.create_index(subject_index_name, "subject_pds", ["subject_id"], unique=False) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + table_names = set(inspector.get_table_names()) + + if "subject_pds" in table_names: + indexes = {idx["name"] for idx in inspector.get_indexes("subject_pds")} + study_index_name = op.f("ix_subject_pds_study_id") + if study_index_name in indexes: + op.drop_index(study_index_name, table_name="subject_pds") + + subject_index_name = op.f("ix_subject_pds_subject_id") + if subject_index_name in indexes: + op.drop_index(subject_index_name, table_name="subject_pds") + + op.drop_table("subject_pds") diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index e05e8fc0..e1fbb5fb 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.v1 import auth, users, admin_users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, finance_contracts, finance_specials, fees_contracts, fees_specials, fees_attachments, drug_shipments, material_equipments, project_milestones, startup, knowledge_notes, subject_histories, faq_categories, faqs, documents, overview, notifications, monitoring_visit_issues +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, material_equipments, project_milestones, startup, knowledge_notes, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, overview, notifications, monitoring_visit_issues api_router = APIRouter() @@ -32,6 +32,8 @@ api_router.include_router(startup.router, prefix="/studies/{study_id}/startup", api_router.include_router(knowledge_notes.router, prefix="/studies/{study_id}/knowledge", tags=["knowledge"]) api_router.include_router(monitoring_visit_issues.router, prefix="/studies/{study_id}/monitoring", tags=["monitoring-visit-issues"]) api_router.include_router(subject_histories.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-histories"]) +api_router.include_router(subject_pds.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-pds"]) +api_router.include_router(study_subject_pds.router, prefix="/studies/{study_id}", tags=["subject-pds"]) api_router.include_router(faq_categories.router, prefix="/faqs/categories", tags=["faq-categories"]) api_router.include_router(faqs.router, prefix="/faqs/items", tags=["faqs"]) api_router.include_router(documents.router, prefix="", tags=["documents"]) diff --git a/backend/app/api/v1/study_subject_pds.py b/backend/app/api/v1/study_subject_pds.py new file mode 100644 index 00000000..4f30fd46 --- /dev/null +++ b/backend/app/api/v1/study_subject_pds.py @@ -0,0 +1,50 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_member +from app.crud import study as study_crud +from app.crud import subject_pd as subject_pd_crud +from app.schemas.subject_pd import SubjectPdSummaryRead + +router = APIRouter() + + +async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID): + study = await study_crud.get(db, study_id) + if not study: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在") + return study + + +@router.get( + "/subject-pds", + response_model=list[SubjectPdSummaryRead], + dependencies=[Depends(require_study_member())], +) +async def list_study_subject_pds( + study_id: uuid.UUID, + skip: int = 0, + limit: int = 500, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> list[SubjectPdSummaryRead]: + await _ensure_study_exists(db, study_id) + cra_scope = await get_cra_site_scope(db, study_id, current_user) + site_ids = cra_scope[0] if cra_scope else None + rows = await subject_pd_crud.list_study_subject_pds(db, study_id, skip=skip, limit=limit, site_ids=site_ids) + return [ + SubjectPdSummaryRead( + id=item.id, + study_id=item.study_id, + subject_id=item.subject_id, + subject_no=subject_no, + site_id=site_id, + pd_no=item.pd_no, + status=item.status, + description=item.description, + updated_at=item.updated_at, + ) + for item, subject_no, site_id in rows + ] diff --git a/backend/app/api/v1/subject_pds.py b/backend/app/api/v1/subject_pds.py new file mode 100644 index 00000000..2529855d --- /dev/null +++ b/backend/app/api/v1/subject_pds.py @@ -0,0 +1,206 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import ( + get_current_user, + get_db_session, + require_study_member, + require_study_not_locked, + require_study_roles, +) +from app.crud import audit as audit_crud +from app.crud import site as site_crud +from app.crud import study as study_crud +from app.crud import subject as subject_crud +from app.crud import subject_pd as subject_pd_crud +from app.schemas.subject_pd import SubjectPdCreate, SubjectPdRead, SubjectPdUpdate + +router = APIRouter() + +ALLOWED_PD_LEVELS = {"GENERAL", "MAJOR", "CRITICAL"} +ALLOWED_APPROVAL_STATUS = {"DRAFT", "SUBMITTED", "APPROVED", "REJECTED"} +ALLOWED_PD_STATUS = {"OPEN", "CLOSED"} + + +async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID): + study = await study_crud.get(db, study_id) + if not study: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在") + return study + + +async def _ensure_subject(db: AsyncSession, study_id: uuid.UUID, subject_id: uuid.UUID): + subject = await subject_crud.get_subject(db, subject_id) + if not subject or subject.study_id != study_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="参与者不存在") + return subject + + +async def _ensure_subject_active(db: AsyncSession, subject) -> None: + if not subject or not subject.site_id: + return + site = await site_crud.get_site(db, subject.site_id) + if not site or not site.is_active: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="中心已停用") + + +def _normalize_pd_type(value: str) -> str: + normalized = str(value or "").strip() + if not normalized: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="PD类型不能为空") + return normalized + + +def _normalize_choice(value: str | None, allowed: set[str], field_name: str) -> str: + if value is None: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{field_name}不能为空") + normalized = str(value).strip().upper() + if normalized not in allowed: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{field_name}取值无效") + return normalized + + +@router.get( + "/pds", + response_model=list[SubjectPdRead], + dependencies=[Depends(require_study_member())], +) +async def list_subject_pds( + study_id: uuid.UUID, + subject_id: uuid.UUID, + skip: int = 0, + limit: int = 200, + db: AsyncSession = Depends(get_db_session), +) -> list[SubjectPdRead]: + await _ensure_study_exists(db, study_id) + await _ensure_subject(db, study_id, subject_id) + items = await subject_pd_crud.list_subject_pds(db, study_id, subject_id, skip=skip, limit=limit) + return [SubjectPdRead.model_validate(item) for item in items] + + +@router.post( + "/pds", + response_model=SubjectPdRead, + status_code=status.HTTP_201_CREATED, + dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())], +) +async def create_subject_pd( + study_id: uuid.UUID, + subject_id: uuid.UUID, + pd_in: SubjectPdCreate, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> SubjectPdRead: + await _ensure_study_exists(db, study_id) + subject = await _ensure_subject(db, study_id, subject_id) + await _ensure_subject_active(db, subject) + if pd_in.subject_id != subject_id: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="参与者不匹配") + + payload = pd_in.model_dump() + payload["pd_type"] = _normalize_pd_type(payload.get("pd_type") or "") + payload["pd_level"] = _normalize_choice(payload.get("pd_level") or "GENERAL", ALLOWED_PD_LEVELS, "PD分级") + payload["approval_status"] = _normalize_choice( + payload.get("approval_status") or "DRAFT", ALLOWED_APPROVAL_STATUS, "审批状态" + ) + payload["status"] = _normalize_choice(payload.get("status") or "OPEN", ALLOWED_PD_STATUS, "状态") + + item = await subject_pd_crud.create_subject_pd( + db, + study_id, + SubjectPdCreate(**payload), + created_by=current_user.id, + ) + await audit_crud.log_action( + db, + study_id=study_id, + entity_type="subject_pd", + entity_id=item.id, + action="CREATE_SUBJECT_PD", + detail=f"PD记录 {item.pd_no} 已创建", + operator_id=current_user.id, + operator_role=current_user.role, + ) + return SubjectPdRead.model_validate(item) + + +@router.patch( + "/pds/{pd_id}", + response_model=SubjectPdRead, + dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())], +) +async def update_subject_pd( + study_id: uuid.UUID, + subject_id: uuid.UUID, + pd_id: uuid.UUID, + pd_in: SubjectPdUpdate, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> SubjectPdRead: + await _ensure_study_exists(db, study_id) + subject = await _ensure_subject(db, study_id, subject_id) + await _ensure_subject_active(db, subject) + + item = await subject_pd_crud.get_subject_pd(db, pd_id) + if not item or item.study_id != study_id or item.subject_id != subject_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="PD记录不存在") + + update_payload = pd_in.model_dump(exclude_unset=True) + if "pd_type" in update_payload: + update_payload["pd_type"] = _normalize_pd_type(update_payload["pd_type"]) + if "pd_level" in update_payload: + update_payload["pd_level"] = _normalize_choice(update_payload["pd_level"], ALLOWED_PD_LEVELS, "PD分级") + if "approval_status" in update_payload: + update_payload["approval_status"] = _normalize_choice( + update_payload["approval_status"], ALLOWED_APPROVAL_STATUS, "审批状态" + ) + if "status" in update_payload: + update_payload["status"] = _normalize_choice(update_payload["status"], ALLOWED_PD_STATUS, "状态") + + updated = await subject_pd_crud.update_subject_pd(db, item, SubjectPdUpdate(**update_payload)) + await audit_crud.log_action( + db, + study_id=study_id, + entity_type="subject_pd", + entity_id=pd_id, + action="UPDATE_SUBJECT_PD", + detail=f"PD记录 {updated.pd_no} 已更新", + operator_id=current_user.id, + operator_role=current_user.role, + ) + return SubjectPdRead.model_validate(updated) + + +@router.delete( + "/pds/{pd_id}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())], +) +async def delete_subject_pd( + study_id: uuid.UUID, + subject_id: uuid.UUID, + pd_id: uuid.UUID, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> None: + await _ensure_study_exists(db, study_id) + subject = await _ensure_subject(db, study_id, subject_id) + await _ensure_subject_active(db, subject) + + item = await subject_pd_crud.get_subject_pd(db, pd_id) + if not item or item.study_id != study_id or item.subject_id != subject_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="PD记录不存在") + + await subject_pd_crud.delete_subject_pd(db, item) + await audit_crud.log_action( + db, + study_id=study_id, + entity_type="subject_pd", + entity_id=pd_id, + action="DELETE_SUBJECT_PD", + detail=f"PD记录 {item.pd_no} 已删除", + operator_id=current_user.id, + operator_role=current_user.role, + ) diff --git a/backend/app/crud/subject_pd.py b/backend/app/crud/subject_pd.py new file mode 100644 index 00000000..1fc37aa9 --- /dev/null +++ b/backend/app/crud/subject_pd.py @@ -0,0 +1,120 @@ +import re +import uuid +from typing import Sequence + +from sqlalchemy import select, update as sa_update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.subject import Subject +from app.models.subject_pd import SubjectPd +from app.schemas.subject_pd import SubjectPdCreate, SubjectPdUpdate + + +_PD_NO_PATTERN = re.compile(r"PD(\d+)$", re.IGNORECASE) + + +def _extract_pd_sequence(pd_no: str | None) -> int: + if not pd_no: + return 0 + matched = _PD_NO_PATTERN.search(str(pd_no).strip()) + if not matched: + return 0 + try: + return int(matched.group(1)) + except ValueError: + return 0 + + +async def _next_pd_no(db: AsyncSession, study_id: uuid.UUID) -> str: + rows = await db.execute(select(SubjectPd.pd_no).where(SubjectPd.study_id == study_id)) + max_seq = 0 + for (pd_no,) in rows.all(): + max_seq = max(max_seq, _extract_pd_sequence(pd_no)) + return f"PD{max_seq + 1:04d}" + + +async def list_subject_pds( + db: AsyncSession, + study_id: uuid.UUID, + subject_id: uuid.UUID, + *, + skip: int = 0, + limit: int = 200, +) -> Sequence[SubjectPd]: + stmt = ( + select(SubjectPd) + .where(SubjectPd.study_id == study_id, SubjectPd.subject_id == subject_id) + .order_by(SubjectPd.pd_no.asc(), SubjectPd.created_at.asc()) + .offset(skip) + .limit(limit) + ) + rows = await db.execute(stmt) + return rows.scalars().all() + + +async def list_study_subject_pds( + db: AsyncSession, + study_id: uuid.UUID, + *, + skip: int = 0, + limit: int = 500, + site_ids: set[uuid.UUID] | None = None, +) -> list[tuple[SubjectPd, str, uuid.UUID]]: + if site_ids is not None and not site_ids: + return [] + stmt = ( + select(SubjectPd, Subject.subject_no, Subject.site_id) + .join(Subject, Subject.id == SubjectPd.subject_id) + .where( + SubjectPd.study_id == study_id, + Subject.study_id == study_id, + ) + .order_by(SubjectPd.pd_no.asc(), SubjectPd.created_at.asc()) + .offset(skip) + .limit(limit) + ) + if site_ids is not None: + stmt = stmt.where(Subject.site_id.in_(site_ids)) + rows = await db.execute(stmt) + return [(row[0], row[1], row[2]) for row in rows.all()] + + +async def get_subject_pd(db: AsyncSession, pd_id: uuid.UUID) -> SubjectPd | None: + row = await db.execute(select(SubjectPd).where(SubjectPd.id == pd_id)) + return row.scalar_one_or_none() + + +async def create_subject_pd( + db: AsyncSession, + study_id: uuid.UUID, + pd_in: SubjectPdCreate, + *, + created_by: uuid.UUID | None = None, +) -> SubjectPd: + payload = pd_in.model_dump(exclude_unset=True) + # PD编号统一由后端按项目全局顺序生成,避免被外部覆盖。 + payload.pop("pd_no", None) + pd_no = await _next_pd_no(db, study_id) + item = SubjectPd(study_id=study_id, pd_no=pd_no, created_by=created_by, **payload) + db.add(item) + await db.commit() + await db.refresh(item) + return item + + +async def update_subject_pd(db: AsyncSession, item: SubjectPd, pd_in: SubjectPdUpdate) -> SubjectPd: + update_data = pd_in.model_dump(exclude_unset=True) + if update_data: + await db.execute( + sa_update(SubjectPd) + .where(SubjectPd.id == item.id) + .values(**update_data) + ) + await db.commit() + await db.refresh(item) + return item + + +async def delete_subject_pd(db: AsyncSession, item: SubjectPd) -> None: + await db.delete(item) + await db.commit() diff --git a/backend/app/db/base.py b/backend/app/db/base.py index 0a0c47c7..4b5068b2 100644 --- a/backend/app/db/base.py +++ b/backend/app/db/base.py @@ -13,6 +13,7 @@ from app.models.distribution import Distribution # noqa: F401 from app.models.acknowledgement import Acknowledgement # noqa: F401 from app.models.milestone import Milestone # noqa: F401 from app.models.subject import Subject # noqa: F401 +from app.models.subject_pd import SubjectPd # noqa: F401 from app.models.visit import Visit # noqa: F401 from app.models.ae import AdverseEvent # noqa: F401 from app.models.finance import FinanceItem # noqa: F401 diff --git a/backend/app/models/subject_pd.py b/backend/app/models/subject_pd.py new file mode 100644 index 00000000..1f5be440 --- /dev/null +++ b/backend/app/models/subject_pd.py @@ -0,0 +1,28 @@ +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, String, Text, UniqueConstraint, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base_class import Base + + +class SubjectPd(Base): + __tablename__ = "subject_pds" + __table_args__ = (UniqueConstraint("study_id", "pd_no", name="uq_subject_pds_study_pd_no"),) + + 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"), index=True, nullable=False) + subject_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("subjects.id"), index=True, nullable=False) + pd_no: Mapped[str] = mapped_column(String(64), nullable=False) + pd_type: Mapped[str] = mapped_column(String(100), nullable=False) + pd_level: Mapped[str] = mapped_column(String(20), nullable=False, server_default="GENERAL") + approval_status: Mapped[str] = mapped_column(String(20), nullable=False, server_default="DRAFT") + description: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[str] = mapped_column(String(20), nullable=False, server_default="OPEN") + 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() + ) diff --git a/backend/app/schemas/subject_pd.py b/backend/app/schemas/subject_pd.py new file mode 100644 index 00000000..b1ccf3b1 --- /dev/null +++ b/backend/app/schemas/subject_pd.py @@ -0,0 +1,52 @@ +import uuid +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict + + +class SubjectPdCreate(BaseModel): + subject_id: uuid.UUID + pd_no: Optional[str] = None + pd_type: str + pd_level: Optional[str] = "GENERAL" + approval_status: Optional[str] = "DRAFT" + description: Optional[str] = None + status: Optional[str] = "OPEN" + + +class SubjectPdUpdate(BaseModel): + pd_type: Optional[str] = None + pd_level: Optional[str] = None + approval_status: Optional[str] = None + description: Optional[str] = None + status: Optional[str] = None + + +class SubjectPdRead(BaseModel): + id: uuid.UUID + study_id: uuid.UUID + subject_id: uuid.UUID + pd_no: str + pd_type: str + pd_level: str + approval_status: str + description: Optional[str] + status: str + created_by: Optional[uuid.UUID] + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class SubjectPdSummaryRead(BaseModel): + id: uuid.UUID + study_id: uuid.UUID + subject_id: uuid.UUID + subject_no: str + site_id: uuid.UUID + pd_no: str + status: str + description: Optional[str] + updated_at: datetime diff --git a/database/init.sql b/database/init.sql index 6ef5993f..80d6ab26 100644 --- a/database/init.sql +++ b/database/init.sql @@ -135,6 +135,25 @@ CREATE TABLE IF NOT EXISTS public.subject_histories ( CONSTRAINT fk_subject_histories_created_by FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT ); +CREATE TABLE IF NOT EXISTS public.subject_pds ( + id uuid PRIMARY KEY, + study_id uuid NOT NULL, + subject_id uuid NOT NULL, + pd_no character varying(64) NOT NULL, + pd_type character varying(100) NOT NULL, + pd_level character varying(20) NOT NULL DEFAULT 'GENERAL', + approval_status character varying(20) NOT NULL DEFAULT 'DRAFT', + description text, + status character varying(20) NOT NULL DEFAULT 'OPEN', + created_by uuid, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT uq_subject_pds_study_pd_no UNIQUE (study_id, pd_no), + CONSTRAINT fk_subject_pds_study_id FOREIGN KEY (study_id) REFERENCES public.studies(id) ON DELETE RESTRICT, + CONSTRAINT fk_subject_pds_subject_id FOREIGN KEY (subject_id) REFERENCES public.subjects(id) ON DELETE RESTRICT, + CONSTRAINT fk_subject_pds_created_by FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT +); + CREATE TABLE IF NOT EXISTS public.visits ( id uuid PRIMARY KEY, study_id uuid NOT NULL, @@ -749,6 +768,61 @@ ON CONFLICT (study_id, subject_no) DO UPDATE SET drop_reason = EXCLUDED.drop_reason, updated_at = EXCLUDED.updated_at; +INSERT INTO public.subject_pds ( + id, study_id, subject_id, pd_no, pd_type, pd_level, approval_status, description, status, created_by, created_at, updated_at +) VALUES + ( + '77777777-aaaa-bbbb-cccc-111111111111', + (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), + '11111111-2222-3333-4444-555555555555', + 'PD0001', + '知情同意', + 'MAJOR', + 'DRAFT', + '知情记录不完整,缺少签署时间记录。', + 'OPEN', + (SELECT id FROM public.users WHERE email = 'cra@example.com'), + '2025-01-11 10:00:00+00', + '2025-01-11 10:00:00+00' + ), + ( + '77777777-aaaa-bbbb-cccc-222222222222', + (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), + '11111111-2222-3333-4444-555555555555', + 'PD0002', + '排除标准', + 'CRITICAL', + 'APPROVED', + '排除标准判定延期,已补充说明并审批通过。', + 'OPEN', + (SELECT id FROM public.users WHERE email = 'cra@example.com'), + '2025-01-18 10:00:00+00', + '2025-01-19 09:00:00+00' + ), + ( + '77777777-aaaa-bbbb-cccc-333333333333', + (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), + '33333333-4444-5555-6666-777777777777', + 'PD0003', + '访视流程', + 'GENERAL', + 'SUBMITTED', + '访视窗口外补录,已提交审批。', + 'CLOSED', + (SELECT id FROM public.users WHERE email = 'pm@example.com'), + '2025-02-06 10:00:00+00', + '2025-02-06 10:00:00+00' + ) +ON CONFLICT (study_id, pd_no) DO UPDATE SET + subject_id = EXCLUDED.subject_id, + pd_type = EXCLUDED.pd_type, + pd_level = EXCLUDED.pd_level, + approval_status = EXCLUDED.approval_status, + description = EXCLUDED.description, + status = EXCLUDED.status, + created_by = EXCLUDED.created_by, + updated_at = EXCLUDED.updated_at; + INSERT INTO public.subject_histories ( id, study_id, subject_id, record_date, content, created_by, created_at, updated_at ) VALUES diff --git a/frontend/src/api/subjectPds.ts b/frontend/src/api/subjectPds.ts new file mode 100644 index 00000000..a3c759a8 --- /dev/null +++ b/frontend/src/api/subjectPds.ts @@ -0,0 +1,20 @@ +import { apiDelete, apiGet, apiPatch, apiPost } from "./axios"; + +export const listStudySubjectPds = (studyId: string, params?: Record) => + apiGet(`/api/v1/studies/${studyId}/subject-pds`, { params }); + +export const listSubjectPds = (studyId: string, subjectId: string, params?: Record) => + apiGet(`/api/v1/studies/${studyId}/subjects/${subjectId}/pds`, { params }); + +export const createSubjectPd = (studyId: string, subjectId: string, payload: Record) => + apiPost(`/api/v1/studies/${studyId}/subjects/${subjectId}/pds`, payload); + +export const updateSubjectPd = ( + studyId: string, + subjectId: string, + pdId: string, + payload: Record +) => apiPatch(`/api/v1/studies/${studyId}/subjects/${subjectId}/pds/${pdId}`, payload); + +export const deleteSubjectPd = (studyId: string, subjectId: string, pdId: string) => + apiDelete(`/api/v1/studies/${studyId}/subjects/${subjectId}/pds/${pdId}`); diff --git a/frontend/src/locales/zh-CN.ts b/frontend/src/locales/zh-CN.ts index cf4653b6..10122f57 100644 --- a/frontend/src/locales/zh-CN.ts +++ b/frontend/src/locales/zh-CN.ts @@ -149,6 +149,7 @@ export const TEXT = { approvedDate: "批准日期", meetingDate: "会议日期", approvalNo: "批件号", + approvalStatus: "审批状态", projectNo: "项目编号", kickoffDate: "启动会日期", attendees: "参训人员", @@ -175,6 +176,10 @@ export const TEXT = { signedDate: "签署日期", currency: "币种", currencyDefault: "CNY", + pdNo: "PD 编号", + pdType: "PD 类型", + pdLevel: "PD 分级", + pdDescription: "PD 描述", remark: "备注", }, empty: { @@ -670,9 +675,12 @@ export const TEXT = { emptyVisits: "暂无访视记录", emptyAe: "暂无 AE 记录", emptyPd: "暂无 PD 记录", + newPd: "新增 PD", dialogHistory: "病史记录", dialogVisit: "访视记录", dialogAe: "AE 记录", + dialogPd: "PD 记录", + pdNoAuto: "保存后自动生成", }, riskIssues: { title: "风险问题", @@ -692,7 +700,7 @@ export const TEXT = { title: "PD", subtitle: "同步参与者 PD 相关信息列表", listTitle: "PD 列表", - emptyDescription: "暂无参与者记录", + emptyDescription: "暂无 PD 记录", syncStatus: "同步状态", synced: "已同步", toSubject: "查看参与者", @@ -1136,6 +1144,21 @@ export const TEXT = { PROCESSING: "处理中", RESOLVED: "已解决", }, + pdLevel: { + GENERAL: "一般", + MAJOR: "重大", + CRITICAL: "严重", + }, + pdApprovalStatus: { + DRAFT: "未提交", + SUBMITTED: "审批中", + APPROVED: "通过", + REJECTED: "驳回", + }, + pdStatus: { + OPEN: "开放", + CLOSED: "关闭", + }, generalStatus: { TODO: "待处理", DOING: "进行中", diff --git a/frontend/src/views/admin/ProjectDetail.vue b/frontend/src/views/admin/ProjectDetail.vue index e98d1af3..3cf6c942 100644 --- a/frontend/src/views/admin/ProjectDetail.vue +++ b/frontend/src/views/admin/ProjectDetail.vue @@ -36,8 +36,8 @@
- 草稿视图 - 发布预览 + 草稿 + 预览
@@ -3623,7 +3623,7 @@ const startEdit = () => { if (!project.value) return; if (activeStep.value !== 0 && activeStep.value !== 2 && activeStep.value !== 4) return; if (isPublishedView.value) { - ElMessage.info("当前为发布预览,请先切换到草稿视图后再编辑"); + ElMessage.info("当前为预览,请先切换到草稿后再编辑"); return; } if (!canManageSetup.value) { @@ -4128,7 +4128,7 @@ const addProjectMilestone = () => { const ensureStep2Editable = (): boolean => { if (!project.value) return false; if (isPublishedView.value) { - ElMessage.info("当前为发布预览,请先切换到草稿视图后再操作"); + ElMessage.info("当前为预览,请先切换到草稿后再操作"); return false; } if (!canManageSetup.value) { diff --git a/frontend/src/views/ia/RiskIssuePd.vue b/frontend/src/views/ia/RiskIssuePd.vue index 73df9fe9..a5e368fb 100644 --- a/frontend/src/views/ia/RiskIssuePd.vue +++ b/frontend/src/views/ia/RiskIssuePd.vue @@ -4,7 +4,7 @@
- + @@ -34,29 +34,20 @@
- - - + + + + - - + + - - - - - - - - - - + + @@ -73,9 +64,9 @@ import { useRouter } from "vue-router"; import { ElMessage } from "element-plus"; import { CircleCheck, Location, Search } from "@element-plus/icons-vue"; import { useStudyStore } from "../../store/study"; -import { fetchSubjects } from "../../api/subjects"; +import { listStudySubjectPds } from "../../api/subjectPds"; import { fetchSites } from "../../api/sites"; -import { displayDate, displayEnum } from "../../utils/display"; +import { displayEnum } from "../../utils/display"; import StateEmpty from "../../components/StateEmpty.vue"; import { TEXT } from "../../locales"; @@ -91,7 +82,7 @@ const filters = ref({ status: "", }); -const statusOptions = Object.entries(TEXT.enums.subjectStatus).map(([value, label]) => ({ value, label })); +const statusOptions = Object.entries(TEXT.enums.pdStatus).map(([value, label]) => ({ value, label })); const resetFilters = () => { filters.value.keyword = ""; @@ -120,10 +111,16 @@ const load = async () => { loading.value = true; try { await loadSites(studyId); - const { data } = await fetchSubjects(studyId); - items.value = (Array.isArray(data) ? data : data.items || []).sort((a: any, b: any) => - String(b.updated_at || "").localeCompare(String(a.updated_at || "")) - ); + const { data } = await listStudySubjectPds(studyId); + const list = Array.isArray(data) ? data : data.items || []; + const getPdSeq = (pdNo: string) => { + const text = String(pdNo || ""); + const matched = text.match(/^PD(\d+)$/i); + if (!matched) return Number.POSITIVE_INFINITY; + const num = Number(matched[1]); + return Number.isFinite(num) ? num : Number.POSITIVE_INFINITY; + }; + items.value = list.sort((a: any, b: any) => getPdSeq(a?.pd_no) - getPdSeq(b?.pd_no)); } catch (e: any) { ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed); } finally { @@ -134,8 +131,10 @@ const load = async () => { const filteredItems = computed(() => { const keyword = filters.value.keyword.trim().toLowerCase(); return items.value.filter((item) => { + const pdNo = String(item?.pd_no || "").toLowerCase(); const subjectNo = String(item?.subject_no || "").toLowerCase(); - if (keyword && !subjectNo.includes(keyword)) return false; + const description = String(item?.description || "").toLowerCase(); + if (keyword && !pdNo.includes(keyword) && !subjectNo.includes(keyword) && !description.includes(keyword)) return false; if (filters.value.siteId && item?.site_id !== filters.value.siteId) return false; if (filters.value.status && item?.status !== filters.value.status) return false; return true; diff --git a/frontend/src/views/subjects/SubjectDetail.vue b/frontend/src/views/subjects/SubjectDetail.vue index f21e22a6..a19169ac 100644 --- a/frontend/src/views/subjects/SubjectDetail.vue +++ b/frontend/src/views/subjects/SubjectDetail.vue @@ -253,7 +253,47 @@ - +
+ + {{ TEXT.modules.subjectDetail.newPd }} + +
+ + + + + + + + + + + + + + + + +
@@ -342,6 +382,36 @@ {{ TEXT.common.actions.save }} + + + + + + + + + + + + + + + + + + + + + + + + + + +
@@ -355,6 +425,7 @@ import { fetchSites } from "../../api/sites"; import { listSubjectHistories, createSubjectHistory, updateSubjectHistory, deleteSubjectHistory } from "../../api/subjectHistories"; import { fetchVisits, createVisit, updateVisit, deleteVisit } from "../../api/visits"; import { fetchAes, createAe, updateAe, deleteAe } from "../../api/aes"; +import { listSubjectPds, createSubjectPd, updateSubjectPd, deleteSubjectPd } from "../../api/subjectPds"; import { displayDate, displayEnum, displayText } from "../../utils/display"; import StateEmpty from "../../components/StateEmpty.vue"; import { TEXT } from "../../locales"; @@ -461,6 +532,22 @@ const aeSeriousnessOptions = [ { value: "IV", label: TEXT.enums.aeSeriousness.IV }, { value: "V", label: TEXT.enums.aeSeriousness.V }, ]; + +const pdItems = ref([]); +const loadingPds = ref(false); +const pdDialogVisible = ref(false); +const pdEditingId = ref(null); +const pdTypeOptions = ["知情同意", "排除标准", "药物使用", "入排标准", "访视流程", "检验检查", "其他"]; +const pdLevelOptions = Object.entries(TEXT.enums.pdLevel).map(([value, label]) => ({ value, label })); +const pdStatusOptions = Object.entries(TEXT.enums.pdStatus).map(([value, label]) => ({ value, label })); +const pdForm = reactive({ + pd_no: "", + pd_type: "", + pd_level: "GENERAL", + description: "", + status: "OPEN", +}); + const normalizeSeriousness = (value?: string | null) => { if (!value) return "I"; const normalized = String(value).trim().toUpperCase(); @@ -905,6 +992,88 @@ const removeAe = async (row: any) => { } }; +const loadPds = async () => { + if (!studyId || !subjectId) return; + loadingPds.value = true; + try { + const { data } = (await listSubjectPds(studyId, subjectId)) as any; + const list = Array.isArray(data) ? data : data.items || []; + const getPdSeq = (pdNo: string) => { + const text = String(pdNo || ""); + const matched = text.match(/^PD(\d+)$/i); + if (!matched) return Number.POSITIVE_INFINITY; + const num = Number(matched[1]); + return Number.isFinite(num) ? num : Number.POSITIVE_INFINITY; + }; + pdItems.value = list.sort((a: any, b: any) => getPdSeq(a?.pd_no) - getPdSeq(b?.pd_no)); + } catch (e: any) { + ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed); + } finally { + loadingPds.value = false; + } +}; + +const openPdDialog = (row?: any) => { + if (isReadOnly.value) { + ElMessage.warning("中心已停用"); + return; + } + pdEditingId.value = row?.id || null; + Object.assign(pdForm, { + pd_no: row?.pd_no || "", + pd_type: row?.pd_type || "", + pd_level: row?.pd_level || "GENERAL", + description: row?.description || "", + status: row?.status || "OPEN", + }); + pdDialogVisible.value = true; +}; + +const savePd = async () => { + if (isReadOnly.value) { + ElMessage.warning("中心已停用"); + return; + } + if (!studyId || !subjectId) return; + if (!pdForm.pd_type?.trim()) { + ElMessage.error(TEXT.common.messages.required); + return; + } + try { + const basePayload = { + pd_type: pdForm.pd_type.trim(), + pd_level: pdForm.pd_level, + description: pdForm.description?.trim() || null, + status: pdForm.status, + }; + if (pdEditingId.value) { + await updateSubjectPd(studyId, subjectId, pdEditingId.value, basePayload); + } else { + await createSubjectPd(studyId, subjectId, { subject_id: subjectId, ...basePayload }); + } + pdDialogVisible.value = false; + loadPds(); + } catch (e: any) { + ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed); + } +}; + +const removePd = async (row: any) => { + if (isReadOnly.value) { + ElMessage.warning("中心已停用"); + return; + } + if (!studyId || !subjectId) return; + const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null); + if (!ok) return; + try { + await deleteSubjectPd(studyId, subjectId, row.id); + loadPds(); + } catch (e: any) { + ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed); + } +}; + const goBack = () => router.push("/subjects"); watch( @@ -920,6 +1089,7 @@ onMounted(async () => { loadHistories(); loadVisits(); loadAes(); + loadPds(); });