参与者管理- PD模块内容补充
This commit is contained in:
@@ -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")
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
@@ -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: "进行中",
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
</div>
|
||||
<div class="setup-view-mode">
|
||||
<el-radio-group v-model="setupViewMode" size="small">
|
||||
<el-radio-button label="draft">草稿视图</el-radio-button>
|
||||
<el-radio-button label="published">发布预览</el-radio-button>
|
||||
<el-radio-button label="draft">草稿</el-radio-button>
|
||||
<el-radio-button label="published">预览</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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) {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="filter-container unified-action-bar">
|
||||
<el-form :inline="true" class="filter-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>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
@@ -34,29 +34,20 @@
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="unified-section table-section">
|
||||
<el-table :data="filteredItems" v-loading="loading" style="width: 100%">
|
||||
<el-table-column prop="subject_no" :label="TEXT.modules.subjectManagement.screeningNo" min-width="160" />
|
||||
<el-table-column :label="TEXT.common.fields.site" min-width="160">
|
||||
<el-table :data="filteredItems" v-loading="loading" style="width: 100%" table-layout="fixed">
|
||||
<el-table-column prop="pd_no" :label="TEXT.common.fields.pdNo" min-width="160" show-overflow-tooltip />
|
||||
<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>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" :label="TEXT.common.fields.status" width="120">
|
||||
<template #default="scope">{{ displayEnum(TEXT.enums.subjectStatus, scope.row.status) }}</template>
|
||||
<el-table-column prop="status" :label="TEXT.common.fields.status" min-width="160">
|
||||
<template #default="scope">{{ displayEnum(TEXT.enums.pdStatus, scope.row.status) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="enrollment_date" :label="TEXT.common.fields.enrollmentDate" width="140">
|
||||
<template #default="scope">{{ displayDate(scope.row.enrollment_date) }}</template>
|
||||
</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">
|
||||
<el-table-column prop="description" :label="TEXT.common.fields.pdDescription" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column :label="TEXT.common.labels.actions" min-width="160" align="center">
|
||||
<template #default="scope">
|
||||
<el-button link type="primary" size="small" @click="goSubject(scope.row.id)">
|
||||
{{ TEXT.modules.riskIssuePd.toSubject }}
|
||||
<el-button link type="primary" size="small" @click="goSubject(scope.row.subject_id)">
|
||||
{{ TEXT.common.actions.view }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -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;
|
||||
|
||||
@@ -253,7 +253,47 @@
|
||||
</el-tab-pane>
|
||||
|
||||
<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-tabs>
|
||||
</el-card>
|
||||
@@ -342,6 +382,36 @@
|
||||
<el-button type="primary" :disabled="isReadOnly" @click="saveAe">{{ TEXT.common.actions.save }}</el-button>
|
||||
</template>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -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<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) => {
|
||||
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();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user