参与者管理- 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 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"])
|
||||||
|
|||||||
@@ -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.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
|
||||||
|
|||||||
@@ -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
|
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
|
||||||
|
|||||||
@@ -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: "批准日期",
|
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: "进行中",
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user