参与者管理- PD模块内容补充

This commit is contained in:
Cheng Zhou
2026-02-28 14:33:57 +08:00
parent af0c1b4b7d
commit 9b36a42e6f
14 changed files with 855 additions and 35 deletions
@@ -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")
+3 -1
View File
@@ -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, 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() 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(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(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_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(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"]) api_router.include_router(documents.router, prefix="", tags=["documents"])
+50
View File
@@ -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
]
+206
View File
@@ -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,
)
+120
View File
@@ -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()
+1
View File
@@ -13,6 +13,7 @@ from app.models.distribution import Distribution # noqa: F401
from app.models.acknowledgement import Acknowledgement # 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.subject_pd import SubjectPd # noqa: F401
from app.models.visit import Visit # noqa: F401 from app.models.visit import Visit # noqa: F401
from app.models.ae import AdverseEvent # noqa: F401 from app.models.ae import AdverseEvent # noqa: F401
from app.models.finance import FinanceItem # noqa: F401 from app.models.finance import FinanceItem # noqa: F401
+28
View File
@@ -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()
)
+52
View File
@@ -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
+74
View File
@@ -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 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 ( CREATE TABLE IF NOT EXISTS public.visits (
id uuid PRIMARY KEY, id uuid PRIMARY KEY,
study_id uuid NOT NULL, study_id uuid NOT NULL,
@@ -749,6 +768,61 @@ ON CONFLICT (study_id, subject_no) DO UPDATE SET
drop_reason = EXCLUDED.drop_reason, drop_reason = EXCLUDED.drop_reason,
updated_at = EXCLUDED.updated_at; 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 ( INSERT INTO public.subject_histories (
id, study_id, subject_id, record_date, content, created_by, created_at, updated_at id, study_id, subject_id, record_date, content, created_by, created_at, updated_at
) VALUES ) VALUES
+20
View File
@@ -0,0 +1,20 @@
import { apiDelete, apiGet, apiPatch, apiPost } from "./axios";
export const listStudySubjectPds = (studyId: string, params?: Record<string, any>) =>
apiGet(`/api/v1/studies/${studyId}/subject-pds`, { params });
export const listSubjectPds = (studyId: string, subjectId: string, params?: Record<string, any>) =>
apiGet(`/api/v1/studies/${studyId}/subjects/${subjectId}/pds`, { params });
export const createSubjectPd = (studyId: string, subjectId: string, payload: Record<string, any>) =>
apiPost(`/api/v1/studies/${studyId}/subjects/${subjectId}/pds`, payload);
export const updateSubjectPd = (
studyId: string,
subjectId: string,
pdId: string,
payload: Record<string, any>
) => 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}`);
+24 -1
View File
@@ -149,6 +149,7 @@ export const TEXT = {
approvedDate: "批准日期", approvedDate: "批准日期",
meetingDate: "会议日期", meetingDate: "会议日期",
approvalNo: "批件号", approvalNo: "批件号",
approvalStatus: "审批状态",
projectNo: "项目编号", projectNo: "项目编号",
kickoffDate: "启动会日期", kickoffDate: "启动会日期",
attendees: "参训人员", attendees: "参训人员",
@@ -175,6 +176,10 @@ export const TEXT = {
signedDate: "签署日期", signedDate: "签署日期",
currency: "币种", currency: "币种",
currencyDefault: "CNY", currencyDefault: "CNY",
pdNo: "PD 编号",
pdType: "PD 类型",
pdLevel: "PD 分级",
pdDescription: "PD 描述",
remark: "备注", remark: "备注",
}, },
empty: { empty: {
@@ -670,9 +675,12 @@ export const TEXT = {
emptyVisits: "暂无访视记录", emptyVisits: "暂无访视记录",
emptyAe: "暂无 AE 记录", emptyAe: "暂无 AE 记录",
emptyPd: "暂无 PD 记录", emptyPd: "暂无 PD 记录",
newPd: "新增 PD",
dialogHistory: "病史记录", dialogHistory: "病史记录",
dialogVisit: "访视记录", dialogVisit: "访视记录",
dialogAe: "AE 记录", dialogAe: "AE 记录",
dialogPd: "PD 记录",
pdNoAuto: "保存后自动生成",
}, },
riskIssues: { riskIssues: {
title: "风险问题", title: "风险问题",
@@ -692,7 +700,7 @@ export const TEXT = {
title: "PD", title: "PD",
subtitle: "同步参与者 PD 相关信息列表", subtitle: "同步参与者 PD 相关信息列表",
listTitle: "PD 列表", listTitle: "PD 列表",
emptyDescription: "暂无参与者记录", emptyDescription: "暂无 PD 记录",
syncStatus: "同步状态", syncStatus: "同步状态",
synced: "已同步", synced: "已同步",
toSubject: "查看参与者", toSubject: "查看参与者",
@@ -1136,6 +1144,21 @@ export const TEXT = {
PROCESSING: "处理中", PROCESSING: "处理中",
RESOLVED: "已解决", RESOLVED: "已解决",
}, },
pdLevel: {
GENERAL: "一般",
MAJOR: "重大",
CRITICAL: "严重",
},
pdApprovalStatus: {
DRAFT: "未提交",
SUBMITTED: "审批中",
APPROVED: "通过",
REJECTED: "驳回",
},
pdStatus: {
OPEN: "开放",
CLOSED: "关闭",
},
generalStatus: { generalStatus: {
TODO: "待处理", TODO: "待处理",
DOING: "进行中", DOING: "进行中",
+4 -4
View File
@@ -36,8 +36,8 @@
</div> </div>
<div class="setup-view-mode"> <div class="setup-view-mode">
<el-radio-group v-model="setupViewMode" size="small"> <el-radio-group v-model="setupViewMode" size="small">
<el-radio-button label="draft">草稿视图</el-radio-button> <el-radio-button label="draft">草稿</el-radio-button>
<el-radio-button label="published">发布预览</el-radio-button> <el-radio-button label="published">预览</el-radio-button>
</el-radio-group> </el-radio-group>
</div> </div>
</div> </div>
@@ -3623,7 +3623,7 @@ const startEdit = () => {
if (!project.value) return; if (!project.value) return;
if (activeStep.value !== 0 && activeStep.value !== 2 && activeStep.value !== 4) return; if (activeStep.value !== 0 && activeStep.value !== 2 && activeStep.value !== 4) return;
if (isPublishedView.value) { if (isPublishedView.value) {
ElMessage.info("当前为发布预览,请先切换到草稿视图后再编辑"); ElMessage.info("当前为预览,请先切换到草稿后再编辑");
return; return;
} }
if (!canManageSetup.value) { if (!canManageSetup.value) {
@@ -4128,7 +4128,7 @@ const addProjectMilestone = () => {
const ensureStep2Editable = (): boolean => { const ensureStep2Editable = (): boolean => {
if (!project.value) return false; if (!project.value) return false;
if (isPublishedView.value) { if (isPublishedView.value) {
ElMessage.info("当前为发布预览,请先切换到草稿视图后再操作"); ElMessage.info("当前为预览,请先切换到草稿后再操作");
return false; return false;
} }
if (!canManageSetup.value) { if (!canManageSetup.value) {
+27 -28
View File
@@ -4,7 +4,7 @@
<div class="filter-container unified-action-bar"> <div class="filter-container unified-action-bar">
<el-form :inline="true" class="filter-form"> <el-form :inline="true" class="filter-form">
<el-form-item class="filter-item-form"> <el-form-item class="filter-item-form">
<el-input v-model="filters.keyword" :placeholder="TEXT.modules.subjectManagement.screeningNo" clearable class="filter-input-comp"> <el-input v-model="filters.keyword" :placeholder="TEXT.common.fields.keyword" clearable class="filter-input-comp">
<template #prefix> <template #prefix>
<el-icon><Search /></el-icon> <el-icon><Search /></el-icon>
</template> </template>
@@ -34,29 +34,20 @@
</el-form> </el-form>
</div> </div>
<div class="unified-section table-section"> <div class="unified-section table-section">
<el-table :data="filteredItems" v-loading="loading" style="width: 100%"> <el-table :data="filteredItems" v-loading="loading" style="width: 100%" table-layout="fixed">
<el-table-column prop="subject_no" :label="TEXT.modules.subjectManagement.screeningNo" min-width="160" /> <el-table-column prop="pd_no" :label="TEXT.common.fields.pdNo" min-width="160" show-overflow-tooltip />
<el-table-column :label="TEXT.common.fields.site" min-width="160"> <el-table-column prop="subject_no" :label="TEXT.modules.subjectManagement.screeningNo" min-width="160" show-overflow-tooltip />
<el-table-column :label="TEXT.common.fields.site" min-width="160" show-overflow-tooltip>
<template #default="scope">{{ siteMap[scope.row.site_id] || TEXT.common.fallback }}</template> <template #default="scope">{{ siteMap[scope.row.site_id] || TEXT.common.fallback }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="status" :label="TEXT.common.fields.status" width="120"> <el-table-column prop="status" :label="TEXT.common.fields.status" min-width="160">
<template #default="scope">{{ displayEnum(TEXT.enums.subjectStatus, scope.row.status) }}</template> <template #default="scope">{{ displayEnum(TEXT.enums.pdStatus, scope.row.status) }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="enrollment_date" :label="TEXT.common.fields.enrollmentDate" width="140"> <el-table-column prop="description" :label="TEXT.common.fields.pdDescription" min-width="160" show-overflow-tooltip />
<template #default="scope">{{ displayDate(scope.row.enrollment_date) }}</template> <el-table-column :label="TEXT.common.labels.actions" min-width="160" align="center">
</el-table-column>
<el-table-column prop="updated_at" :label="TEXT.common.labels.updatedAt" min-width="180">
<template #default="scope">{{ displayDate(scope.row.updated_at) }}</template>
</el-table-column>
<el-table-column :label="TEXT.modules.riskIssuePd.syncStatus" width="120">
<template #default>
<el-tag size="small" type="success" effect="plain">{{ TEXT.modules.riskIssuePd.synced }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120">
<template #default="scope"> <template #default="scope">
<el-button link type="primary" size="small" @click="goSubject(scope.row.id)"> <el-button link type="primary" size="small" @click="goSubject(scope.row.subject_id)">
{{ TEXT.modules.riskIssuePd.toSubject }} {{ TEXT.common.actions.view }}
</el-button> </el-button>
</template> </template>
</el-table-column> </el-table-column>
@@ -73,9 +64,9 @@ import { useRouter } from "vue-router";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { CircleCheck, Location, Search } from "@element-plus/icons-vue"; import { CircleCheck, Location, Search } from "@element-plus/icons-vue";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { fetchSubjects } from "../../api/subjects"; import { listStudySubjectPds } from "../../api/subjectPds";
import { fetchSites } from "../../api/sites"; import { fetchSites } from "../../api/sites";
import { displayDate, displayEnum } from "../../utils/display"; import { displayEnum } from "../../utils/display";
import StateEmpty from "../../components/StateEmpty.vue"; import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
@@ -91,7 +82,7 @@ const filters = ref({
status: "", 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 = () => { const resetFilters = () => {
filters.value.keyword = ""; filters.value.keyword = "";
@@ -120,10 +111,16 @@ const load = async () => {
loading.value = true; loading.value = true;
try { try {
await loadSites(studyId); await loadSites(studyId);
const { data } = await fetchSubjects(studyId); const { data } = await listStudySubjectPds(studyId);
items.value = (Array.isArray(data) ? data : data.items || []).sort((a: any, b: any) => const list = Array.isArray(data) ? data : data.items || [];
String(b.updated_at || "").localeCompare(String(a.updated_at || "")) 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) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed); ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally { } finally {
@@ -134,8 +131,10 @@ const load = async () => {
const filteredItems = computed(() => { const filteredItems = computed(() => {
const keyword = filters.value.keyword.trim().toLowerCase(); const keyword = filters.value.keyword.trim().toLowerCase();
return items.value.filter((item) => { return items.value.filter((item) => {
const pdNo = String(item?.pd_no || "").toLowerCase();
const subjectNo = String(item?.subject_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.siteId && item?.site_id !== filters.value.siteId) return false;
if (filters.value.status && item?.status !== filters.value.status) return false; if (filters.value.status && item?.status !== filters.value.status) return false;
return true; return true;
+171 -1
View File
@@ -253,7 +253,47 @@
</el-tab-pane> </el-tab-pane>
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.pd" name="pd"> <el-tab-pane :label="TEXT.modules.subjectDetail.tabs.pd" name="pd">
<StateEmpty :description="TEXT.modules.subjectDetail.emptyPd" /> <div class="tab-actions">
<el-button type="primary" :disabled="isReadOnly" @click="openPdDialog()">
{{ TEXT.modules.subjectDetail.newPd }}
</el-button>
</div>
<el-table :data="pdItems" v-loading="loadingPds" style="width: 100%">
<el-table-column prop="pd_no" :label="TEXT.common.fields.pdNo" width="130" />
<el-table-column prop="pd_type" :label="TEXT.common.fields.pdType" min-width="140" />
<el-table-column prop="pd_level" :label="TEXT.common.fields.pdLevel" min-width="120">
<template #default="scope">{{ displayEnum(TEXT.enums.pdLevel, scope.row.pd_level) }}</template>
</el-table-column>
<el-table-column prop="description" :label="TEXT.common.fields.pdDescription" min-width="240">
<template #default="scope">{{ scope.row.description || TEXT.common.fallback }}</template>
</el-table-column>
<el-table-column prop="status" :label="TEXT.common.fields.status" min-width="100">
<template #default="scope">{{ displayEnum(TEXT.enums.pdStatus, scope.row.status) }}</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="150">
<template #default="scope">
<el-button
link
type="primary"
size="small"
:disabled="isReadOnly"
@click="openPdDialog(scope.row)"
>
{{ TEXT.common.actions.edit }}
</el-button>
<el-button
link
type="danger"
size="small"
:disabled="isReadOnly"
@click="removePd(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</template>
</el-table-column>
</el-table>
<StateEmpty v-if="!loadingPds && pdItems.length === 0" :description="TEXT.modules.subjectDetail.emptyPd" />
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
</el-card> </el-card>
@@ -342,6 +382,36 @@
<el-button type="primary" :disabled="isReadOnly" @click="saveAe">{{ TEXT.common.actions.save }}</el-button> <el-button type="primary" :disabled="isReadOnly" @click="saveAe">{{ TEXT.common.actions.save }}</el-button>
</template> </template>
</el-dialog> </el-dialog>
<el-dialog append-to=".layout-main .content-wrapper" v-model="pdDialogVisible" :title="TEXT.modules.subjectDetail.dialogPd" width="560px">
<el-form :model="pdForm" label-width="100px">
<el-form-item :label="TEXT.common.fields.pdNo">
<el-input v-model="pdForm.pd_no" disabled :placeholder="TEXT.modules.subjectDetail.pdNoAuto" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.pdType" required>
<el-select v-model="pdForm.pd_type" filterable allow-create default-first-option clearable>
<el-option v-for="item in pdTypeOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
<el-form-item :label="TEXT.common.fields.pdLevel" required>
<el-select v-model="pdForm.pd_level">
<el-option v-for="option in pdLevelOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</el-form-item>
<el-form-item :label="TEXT.common.fields.status" required>
<el-select v-model="pdForm.status">
<el-option v-for="option in pdStatusOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</el-form-item>
<el-form-item :label="TEXT.common.fields.pdDescription">
<el-input v-model="pdForm.description" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="pdDialogVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :disabled="isReadOnly" @click="savePd">{{ TEXT.common.actions.save }}</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>
@@ -355,6 +425,7 @@ import { fetchSites } from "../../api/sites";
import { listSubjectHistories, createSubjectHistory, updateSubjectHistory, deleteSubjectHistory } from "../../api/subjectHistories"; import { listSubjectHistories, createSubjectHistory, updateSubjectHistory, deleteSubjectHistory } from "../../api/subjectHistories";
import { fetchVisits, createVisit, updateVisit, deleteVisit } from "../../api/visits"; import { fetchVisits, createVisit, updateVisit, deleteVisit } from "../../api/visits";
import { fetchAes, createAe, updateAe, deleteAe } from "../../api/aes"; import { fetchAes, createAe, updateAe, deleteAe } from "../../api/aes";
import { listSubjectPds, createSubjectPd, updateSubjectPd, deleteSubjectPd } from "../../api/subjectPds";
import { displayDate, displayEnum, displayText } from "../../utils/display"; import { displayDate, displayEnum, displayText } from "../../utils/display";
import StateEmpty from "../../components/StateEmpty.vue"; import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
@@ -461,6 +532,22 @@ const aeSeriousnessOptions = [
{ value: "IV", label: TEXT.enums.aeSeriousness.IV }, { value: "IV", label: TEXT.enums.aeSeriousness.IV },
{ value: "V", label: TEXT.enums.aeSeriousness.V }, { value: "V", label: TEXT.enums.aeSeriousness.V },
]; ];
const pdItems = ref<any[]>([]);
const loadingPds = ref(false);
const pdDialogVisible = ref(false);
const pdEditingId = ref<string | null>(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) => { const normalizeSeriousness = (value?: string | null) => {
if (!value) return "I"; if (!value) return "I";
const normalized = String(value).trim().toUpperCase(); 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"); const goBack = () => router.push("/subjects");
watch( watch(
@@ -920,6 +1089,7 @@ onMounted(async () => {
loadHistories(); loadHistories();
loadVisits(); loadVisits();
loadAes(); loadAes();
loadPds();
}); });
</script> </script>