feat: refine subject visits and project workflows
Add early termination visit workflow with ordering, non-applicable visit handling, visit window display, and medication adherence support. Extend monitoring visit issue template fields, site scoping, setup draft project info handling, login security UI, attachment behavior, and related tests/migrations.
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
"""add template fields to monitoring_visit_issues
|
||||
|
||||
Revision ID: 20260509_01
|
||||
Revises: 20260508_03
|
||||
Create Date: 2026-05-09 13:45:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "20260509_01"
|
||||
down_revision: Union[str, None] = "20260508_03"
|
||||
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)
|
||||
columns = {col["name"] for col in inspector.get_columns("monitoring_visit_issues")}
|
||||
|
||||
if "severity" not in columns:
|
||||
op.add_column("monitoring_visit_issues", sa.Column("severity", sa.String(length=64), nullable=True))
|
||||
if "mark" not in columns:
|
||||
op.add_column("monitoring_visit_issues", sa.Column("mark", sa.String(length=100), nullable=True))
|
||||
if "visit_cycle" not in columns:
|
||||
op.add_column("monitoring_visit_issues", sa.Column("visit_cycle", sa.String(length=100), nullable=True))
|
||||
if "center_query" not in columns:
|
||||
op.add_column("monitoring_visit_issues", sa.Column("center_query", sa.Text(), nullable=True))
|
||||
if "center_latest_reply" not in columns:
|
||||
op.add_column("monitoring_visit_issues", sa.Column("center_latest_reply", sa.Text(), nullable=True))
|
||||
if "rectification_completed" not in columns:
|
||||
op.add_column(
|
||||
"monitoring_visit_issues",
|
||||
sa.Column("rectification_completed", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
columns = {col["name"] for col in inspector.get_columns("monitoring_visit_issues")}
|
||||
|
||||
if "rectification_completed" in columns:
|
||||
op.drop_column("monitoring_visit_issues", "rectification_completed")
|
||||
if "center_latest_reply" in columns:
|
||||
op.drop_column("monitoring_visit_issues", "center_latest_reply")
|
||||
if "center_query" in columns:
|
||||
op.drop_column("monitoring_visit_issues", "center_query")
|
||||
if "visit_cycle" in columns:
|
||||
op.drop_column("monitoring_visit_issues", "visit_cycle")
|
||||
if "mark" in columns:
|
||||
op.drop_column("monitoring_visit_issues", "mark")
|
||||
if "severity" in columns:
|
||||
op.drop_column("monitoring_visit_issues", "severity")
|
||||
@@ -0,0 +1,55 @@
|
||||
"""add site_id to monitoring_visit_issues
|
||||
|
||||
Revision ID: 20260509_02
|
||||
Revises: 20260509_01
|
||||
Create Date: 2026-05-09 14: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 = "20260509_02"
|
||||
down_revision: Union[str, None] = "20260509_01"
|
||||
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)
|
||||
columns = {col["name"] for col in inspector.get_columns("monitoring_visit_issues")}
|
||||
fks = {fk["name"] for fk in inspector.get_foreign_keys("monitoring_visit_issues")}
|
||||
indexes = {idx["name"] for idx in inspector.get_indexes("monitoring_visit_issues")}
|
||||
|
||||
if "site_id" not in columns:
|
||||
op.add_column("monitoring_visit_issues", sa.Column("site_id", postgresql.UUID(as_uuid=True), nullable=True))
|
||||
if "fk_monitoring_visit_issues_site_id" not in fks:
|
||||
op.create_foreign_key(
|
||||
"fk_monitoring_visit_issues_site_id",
|
||||
"monitoring_visit_issues",
|
||||
"sites",
|
||||
["site_id"],
|
||||
["id"],
|
||||
)
|
||||
if "ix_monitoring_visit_issues_site_id" not in indexes:
|
||||
op.create_index("ix_monitoring_visit_issues_site_id", "monitoring_visit_issues", ["site_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
columns = {col["name"] for col in inspector.get_columns("monitoring_visit_issues")}
|
||||
fks = {fk["name"] for fk in inspector.get_foreign_keys("monitoring_visit_issues")}
|
||||
indexes = {idx["name"] for idx in inspector.get_indexes("monitoring_visit_issues")}
|
||||
|
||||
if "ix_monitoring_visit_issues_site_id" in indexes:
|
||||
op.drop_index("ix_monitoring_visit_issues_site_id", table_name="monitoring_visit_issues")
|
||||
if "fk_monitoring_visit_issues_site_id" in fks:
|
||||
op.drop_constraint("fk_monitoring_visit_issues_site_id", "monitoring_visit_issues", type_="foreignkey")
|
||||
if "site_id" in columns:
|
||||
op.drop_column("monitoring_visit_issues", "site_id")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""add subject actual medication count
|
||||
|
||||
Revision ID: 20260509_03
|
||||
Revises: 20260509_02
|
||||
Create Date: 2026-05-09 14:58:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260509_03"
|
||||
down_revision: Union[str, None] = "20260509_02"
|
||||
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)
|
||||
columns = {col["name"] for col in inspector.get_columns("subjects")}
|
||||
if "actual_medication_count" not in columns:
|
||||
op.add_column("subjects", sa.Column("actual_medication_count", sa.Integer(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
columns = {col["name"] for col in inspector.get_columns("subjects")}
|
||||
if "actual_medication_count" in columns:
|
||||
op.drop_column("subjects", "actual_medication_count")
|
||||
@@ -14,6 +14,7 @@ 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 monitoring_visit_issue as issue_crud
|
||||
from app.crud import site as site_crud
|
||||
from app.crud import study as study_crud
|
||||
from app.schemas.monitoring_visit_issue import (
|
||||
MonitoringVisitIssueCreate,
|
||||
@@ -25,9 +26,13 @@ from app.schemas.monitoring_visit_issue import (
|
||||
router = APIRouter()
|
||||
|
||||
_HEADER_ALIASES: dict[str, list[str]] = {
|
||||
"site_name": ["项目/中心", "中心", "中心名称", "site_name", "site"],
|
||||
"issue_no": ["问题编号", "问题编码", "issue_no", "IssueNo"],
|
||||
"open_duration_text": ["开放时长", "open_duration"],
|
||||
"category": ["问题分类", "category"],
|
||||
"severity": ["严重程度", "severity"],
|
||||
"mark": ["标记", "mark"],
|
||||
"visit_cycle": ["访视周期", "访视", "visit_cycle"],
|
||||
"subject_code": ["受试者", "受试者编号", "subject_code"],
|
||||
"subject_name": ["受试者姓名", "subject_name"],
|
||||
"monitor_item": ["监查项", "monitor_item"],
|
||||
@@ -40,6 +45,9 @@ _HEADER_ALIASES: dict[str, list[str]] = {
|
||||
"description": ["问题描述", "description"],
|
||||
"action_taken": ["采取措施", "action_taken"],
|
||||
"follow_up_progress": ["跟进计划及进展", "follow_up_progress"],
|
||||
"center_query": ["中心质疑", "center_query"],
|
||||
"center_latest_reply": ["中心最新回复", "中心回复", "center_latest_reply"],
|
||||
"rectification_completed": ["是否完成整改", "完成整改", "rectification_completed"],
|
||||
"found_date": ["发现时间", "found_date"],
|
||||
"due_at": ["截止时间", "截止日期", "超期截止时间", "due_at"],
|
||||
"actual_resolve_date": ["实际解决日期", "actual_resolve_date"],
|
||||
@@ -48,17 +56,24 @@ _HEADER_ALIASES: dict[str, list[str]] = {
|
||||
}
|
||||
|
||||
_AUDIT_FIELDS: tuple[str, ...] = (
|
||||
"site_id",
|
||||
"issue_no",
|
||||
"source",
|
||||
"monitor_type",
|
||||
"monitor_item",
|
||||
"category",
|
||||
"severity",
|
||||
"mark",
|
||||
"visit_cycle",
|
||||
"recommendation",
|
||||
"subject_name",
|
||||
"subject_code",
|
||||
"description",
|
||||
"action_taken",
|
||||
"follow_up_progress",
|
||||
"center_query",
|
||||
"center_latest_reply",
|
||||
"rectification_completed",
|
||||
"found_date",
|
||||
"due_at",
|
||||
"actual_resolve_date",
|
||||
@@ -75,6 +90,14 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
||||
return study
|
||||
|
||||
|
||||
async def _ensure_site_belongs_to_study(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UUID | None) -> None:
|
||||
if site_id is None:
|
||||
return
|
||||
site = await site_crud.get_site(db, site_id)
|
||||
if not site or site.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或不属于当前项目")
|
||||
|
||||
|
||||
def _normalize_status(value: str | None) -> str:
|
||||
text = (value or "").strip().upper()
|
||||
zh = {
|
||||
@@ -91,6 +114,21 @@ def _normalize_status(value: str | None) -> str:
|
||||
return "OPEN"
|
||||
|
||||
|
||||
def _parse_bool(value: object | None) -> bool | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
text = str(value).strip().lower()
|
||||
if not text:
|
||||
return None
|
||||
if text in {"1", "true", "yes", "y", "是", "已完成", "完成"}:
|
||||
return True
|
||||
if text in {"0", "false", "no", "n", "否", "未完成"}:
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def _pick(row: dict[str, object], key: str) -> str | None:
|
||||
for alias in _HEADER_ALIASES[key]:
|
||||
if alias not in row:
|
||||
@@ -167,9 +205,13 @@ def _to_read(item) -> MonitoringVisitIssueRead:
|
||||
return MonitoringVisitIssueRead(
|
||||
id=item.id,
|
||||
study_id=item.study_id,
|
||||
site_id=item.site_id,
|
||||
issue_no=item.issue_no,
|
||||
open_duration=_calc_open_duration(item.created_at, item.status, item.closed_at, item.open_duration_text),
|
||||
category=item.category,
|
||||
severity=item.severity,
|
||||
mark=item.mark,
|
||||
visit_cycle=item.visit_cycle,
|
||||
subject_code=item.subject_code,
|
||||
monitor_item=item.monitor_item,
|
||||
monitor_type=item.monitor_type,
|
||||
@@ -183,6 +225,9 @@ def _to_read(item) -> MonitoringVisitIssueRead:
|
||||
description=item.description,
|
||||
action_taken=item.action_taken,
|
||||
follow_up_progress=item.follow_up_progress,
|
||||
center_query=item.center_query,
|
||||
center_latest_reply=item.center_latest_reply,
|
||||
rectification_completed=bool(item.rectification_completed),
|
||||
found_date=item.found_date,
|
||||
overdue=overdue,
|
||||
due_at=item.due_at,
|
||||
@@ -206,6 +251,8 @@ def _format_datetime(value: datetime | None) -> str:
|
||||
|
||||
|
||||
def _to_audit_value(value: object | None):
|
||||
if isinstance(value, uuid.UUID):
|
||||
return str(value)
|
||||
if isinstance(value, datetime):
|
||||
parsed = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||
return parsed.isoformat()
|
||||
@@ -289,22 +336,41 @@ def _normalize_file_rows(filename: str, content: bytes) -> list[dict[str, object
|
||||
)
|
||||
async def list_monitoring_visit_issues(
|
||||
study_id: uuid.UUID,
|
||||
site_id: uuid.UUID | None = None,
|
||||
category: str | None = None,
|
||||
severity: str | None = None,
|
||||
mark: str | None = None,
|
||||
visit_cycle: str | None = None,
|
||||
status_value: str | None = Query(default=None, alias="status"),
|
||||
overdue: bool | None = None,
|
||||
rectification_completed: bool | None = None,
|
||||
due_from: date | None = None,
|
||||
due_to: date | None = None,
|
||||
created_from: date | None = None,
|
||||
created_to: date | None = None,
|
||||
keyword: str | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 500,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> list[MonitoringVisitIssueRead]:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
await _ensure_site_belongs_to_study(db, study_id, site_id)
|
||||
normalized_status = _normalize_status(status_value) if status_value else None
|
||||
items = await issue_crud.list_issues(
|
||||
db,
|
||||
study_id,
|
||||
site_id=site_id,
|
||||
category=(category or None),
|
||||
severity=(severity or None),
|
||||
mark=(mark or None),
|
||||
visit_cycle=(visit_cycle or None),
|
||||
status=normalized_status,
|
||||
overdue=overdue,
|
||||
rectification_completed=rectification_completed,
|
||||
due_from=due_from,
|
||||
due_to=due_to,
|
||||
created_from=created_from,
|
||||
created_to=created_to,
|
||||
keyword=(keyword or None),
|
||||
skip=skip,
|
||||
limit=min(limit, 2000),
|
||||
@@ -325,6 +391,7 @@ async def create_monitoring_visit_issue(
|
||||
current_user=Depends(get_current_user),
|
||||
) -> MonitoringVisitIssueRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
await _ensure_site_belongs_to_study(db, study_id, payload.site_id)
|
||||
|
||||
if payload.issue_no:
|
||||
existing = await issue_crud.get_issue_by_issue_no(db, study_id, payload.issue_no)
|
||||
@@ -358,20 +425,39 @@ async def create_monitoring_visit_issue(
|
||||
)
|
||||
async def export_monitoring_visit_issues(
|
||||
study_id: uuid.UUID,
|
||||
site_id: uuid.UUID | None = None,
|
||||
category: str | None = None,
|
||||
severity: str | None = None,
|
||||
mark: str | None = None,
|
||||
visit_cycle: str | None = None,
|
||||
status_value: str | None = Query(default=None, alias="status"),
|
||||
overdue: bool | None = None,
|
||||
rectification_completed: bool | None = None,
|
||||
due_from: date | None = None,
|
||||
due_to: date | None = None,
|
||||
created_from: date | None = None,
|
||||
created_to: date | None = None,
|
||||
keyword: str | None = None,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> StreamingResponse:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
await _ensure_site_belongs_to_study(db, study_id, site_id)
|
||||
normalized_status = _normalize_status(status_value) if status_value else None
|
||||
items = await issue_crud.list_issues(
|
||||
db,
|
||||
study_id,
|
||||
site_id=site_id,
|
||||
category=(category or None),
|
||||
severity=(severity or None),
|
||||
mark=(mark or None),
|
||||
visit_cycle=(visit_cycle or None),
|
||||
status=normalized_status,
|
||||
overdue=overdue,
|
||||
rectification_completed=rectification_completed,
|
||||
due_from=due_from,
|
||||
due_to=due_to,
|
||||
created_from=created_from,
|
||||
created_to=created_to,
|
||||
keyword=(keyword or None),
|
||||
skip=0,
|
||||
limit=50000,
|
||||
@@ -381,15 +467,23 @@ async def export_monitoring_visit_issues(
|
||||
ws = wb.active
|
||||
ws.title = "监查访视问题"
|
||||
headers = [
|
||||
"项目/中心",
|
||||
"问题编号",
|
||||
"受试者筛选号",
|
||||
"访视周期",
|
||||
"问题分类",
|
||||
"严重程度",
|
||||
"建议措施",
|
||||
"问题描述",
|
||||
"中心质疑",
|
||||
"中心最新回复",
|
||||
"是否完成整改",
|
||||
"标记",
|
||||
"状态",
|
||||
"是否超期",
|
||||
"问题来源",
|
||||
"监查类型",
|
||||
"监查项",
|
||||
"问题分类",
|
||||
"建议措施",
|
||||
"受试者",
|
||||
"受试者缩写号",
|
||||
"问题描述",
|
||||
"采取措施",
|
||||
"跟进计划及进展",
|
||||
"发现时间",
|
||||
@@ -403,20 +497,31 @@ async def export_monitoring_visit_issues(
|
||||
"创建时间",
|
||||
]
|
||||
ws.append(headers)
|
||||
site_ids = {item.site_id for item in items if item.site_id}
|
||||
site_map = await site_crud.get_sites_by_ids(db, site_ids)
|
||||
|
||||
for item in items:
|
||||
view = _to_read(item)
|
||||
site_name = site_map.get(view.site_id).name if view.site_id in site_map else ""
|
||||
ws.append(
|
||||
[
|
||||
site_name,
|
||||
view.issue_no or "",
|
||||
view.subject_code or "",
|
||||
view.visit_cycle or "",
|
||||
view.category or "",
|
||||
view.severity or "",
|
||||
view.recommendation or "",
|
||||
view.description or "",
|
||||
view.center_query or "",
|
||||
view.center_latest_reply or "",
|
||||
"是" if view.rectification_completed else "否",
|
||||
view.mark or "",
|
||||
view.progress or "",
|
||||
"是" if view.overdue else "否",
|
||||
view.source or "",
|
||||
view.monitor_type or "",
|
||||
view.monitor_item or "",
|
||||
view.category or "",
|
||||
view.recommendation or "",
|
||||
view.subject_name or "",
|
||||
view.subject_code or "",
|
||||
view.description or "",
|
||||
view.action_taken or "",
|
||||
view.follow_up_progress or "",
|
||||
_format_date(view.found_date),
|
||||
@@ -486,6 +591,8 @@ async def update_monitoring_visit_issue(
|
||||
|
||||
if not payload.model_fields_set:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="至少提供一个更新字段")
|
||||
if "site_id" in payload.model_fields_set:
|
||||
await _ensure_site_belongs_to_study(db, study_id, payload.site_id)
|
||||
|
||||
before_snapshot = _issue_audit_snapshot(item)
|
||||
try:
|
||||
@@ -568,6 +675,8 @@ async def import_monitoring_visit_issues(
|
||||
created_count = 0
|
||||
updated_count = 0
|
||||
skipped_rows: list[str] = []
|
||||
sites = await site_crud.list_by_study(db, study_id, limit=5000, include_inactive=True)
|
||||
site_by_name = {site.name.strip().lower(): site for site in sites if site.name}
|
||||
|
||||
for idx, row in enumerate(rows, start=2):
|
||||
issue_no = _pick(row, "issue_no")
|
||||
@@ -576,6 +685,15 @@ async def import_monitoring_visit_issues(
|
||||
skipped_rows.append(f"第{idx}行:问题编号或问题分类为空")
|
||||
continue
|
||||
|
||||
site_id = None
|
||||
site_name = _pick(row, "site_name")
|
||||
if site_name:
|
||||
site = site_by_name.get(site_name.strip().lower())
|
||||
if not site:
|
||||
skipped_rows.append(f"第{idx}行:中心「{site_name}」不存在")
|
||||
continue
|
||||
site_id = site.id
|
||||
|
||||
status_text = _normalize_status(_pick(row, "status"))
|
||||
creator_name = _pick(row, "creator_name") or getattr(current_user, "full_name", None)
|
||||
created_at = _parse_datetime(row.get("创建时间") or row.get("created_at") or _pick(row, "created_at"))
|
||||
@@ -586,7 +704,11 @@ async def import_monitoring_visit_issues(
|
||||
try:
|
||||
payload = MonitoringVisitIssueCreate(
|
||||
issue_no=issue_no,
|
||||
site_id=site_id,
|
||||
category=category,
|
||||
severity=_pick(row, "severity"),
|
||||
mark=_pick(row, "mark"),
|
||||
visit_cycle=_pick(row, "visit_cycle"),
|
||||
subject_code=_pick(row, "subject_code"),
|
||||
subject_name=_pick(row, "subject_name"),
|
||||
monitor_item=_pick(row, "monitor_item"),
|
||||
@@ -598,6 +720,9 @@ async def import_monitoring_visit_issues(
|
||||
description=_pick(row, "description"),
|
||||
action_taken=_pick(row, "action_taken"),
|
||||
follow_up_progress=_pick(row, "follow_up_progress"),
|
||||
center_query=_pick(row, "center_query"),
|
||||
center_latest_reply=_pick(row, "center_latest_reply"),
|
||||
rectification_completed=_parse_bool(_pick(row, "rectification_completed")) or False,
|
||||
found_date=_parse_date(row.get("发现时间") or row.get("found_date") or _pick(row, "found_date")),
|
||||
due_at=due_at,
|
||||
actual_resolve_date=_parse_date(
|
||||
|
||||
+157
-28
@@ -263,6 +263,13 @@ def _to_date_text(value: date | None) -> str:
|
||||
return value.isoformat()
|
||||
|
||||
|
||||
def _project_snapshot_has_draft_values(snapshot: ProjectPublishSnapshot | None) -> bool:
|
||||
if not snapshot:
|
||||
return False
|
||||
data = snapshot.model_dump(mode="json")
|
||||
return any(value not in ("", None, []) for value in data.values())
|
||||
|
||||
|
||||
def _build_project_publish_snapshot(study) -> ProjectPublishSnapshot:
|
||||
return ProjectPublishSnapshot(
|
||||
code=getattr(study, "code", None) or "",
|
||||
@@ -288,6 +295,61 @@ def _build_project_publish_snapshot(study) -> ProjectPublishSnapshot:
|
||||
)
|
||||
|
||||
|
||||
def _resolve_setup_project_snapshot(setup_data: StudySetupConfigData, study) -> ProjectPublishSnapshot:
|
||||
if _project_snapshot_has_draft_values(setup_data.projectInfo):
|
||||
return setup_data.projectInfo
|
||||
return _build_project_publish_snapshot(study)
|
||||
|
||||
|
||||
def _parse_optional_snapshot_date(value: str) -> date | None:
|
||||
text = (value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
return date.fromisoformat(text)
|
||||
|
||||
|
||||
def _normalize_optional_snapshot_text(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = value.strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _snapshot_visit_schedule(snapshot: ProjectPublishSnapshot) -> list[dict[str, int | str]]:
|
||||
return [item.model_dump(mode="json") for item in snapshot.visit_schedule]
|
||||
|
||||
|
||||
def _apply_project_publish_snapshot_to_study(study, snapshot: ProjectPublishSnapshot) -> bool:
|
||||
next_values = {
|
||||
"code": snapshot.code.strip(),
|
||||
"name": snapshot.name.strip(),
|
||||
"project_full_name": _normalize_optional_snapshot_text(snapshot.project_full_name),
|
||||
"sponsor": _normalize_optional_snapshot_text(snapshot.sponsor),
|
||||
"protocol_no": _normalize_optional_snapshot_text(snapshot.protocol_no),
|
||||
"lead_unit": _normalize_optional_snapshot_text(snapshot.lead_unit),
|
||||
"principal_investigator": _normalize_optional_snapshot_text(snapshot.principal_investigator),
|
||||
"main_pm": _normalize_optional_snapshot_text(snapshot.main_pm),
|
||||
"research_analysis": _normalize_optional_snapshot_text(snapshot.research_analysis),
|
||||
"research_product": _normalize_optional_snapshot_text(snapshot.research_product),
|
||||
"control_product": _normalize_optional_snapshot_text(snapshot.control_product),
|
||||
"indication": _normalize_optional_snapshot_text(snapshot.indication),
|
||||
"research_population": _normalize_optional_snapshot_text(snapshot.research_population),
|
||||
"research_design": _normalize_optional_snapshot_text(snapshot.research_design),
|
||||
"plan_start_date": _parse_optional_snapshot_date(snapshot.plan_start_date),
|
||||
"plan_end_date": _parse_optional_snapshot_date(snapshot.plan_end_date),
|
||||
"planned_site_count": snapshot.planned_site_count,
|
||||
"planned_enrollment_count": snapshot.planned_enrollment_count,
|
||||
"status": snapshot.status or "DRAFT",
|
||||
"visit_schedule": _snapshot_visit_schedule(snapshot),
|
||||
}
|
||||
changed = False
|
||||
for field, value in next_values.items():
|
||||
if getattr(study, field, None) != value:
|
||||
setattr(study, field, value)
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def _to_setup_config_read(
|
||||
record,
|
||||
*,
|
||||
@@ -360,6 +422,63 @@ def _ensure_study_timeline_valid(
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="项目计划结束日期不能早于开始日期")
|
||||
|
||||
|
||||
async def _validate_setup_project_snapshot(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
study_id: uuid.UUID,
|
||||
snapshot: ProjectPublishSnapshot,
|
||||
strict_required: bool = True,
|
||||
) -> None:
|
||||
errors: list[dict[str, str]] = []
|
||||
code = snapshot.code.strip()
|
||||
name = snapshot.name.strip()
|
||||
if strict_required and not code:
|
||||
errors.append({"field": "projectInfo.code", "message": "项目编号不能为空"})
|
||||
if strict_required and not name:
|
||||
errors.append({"field": "projectInfo.name", "message": "项目名称不能为空"})
|
||||
try:
|
||||
plan_start = _parse_optional_snapshot_date(snapshot.plan_start_date)
|
||||
plan_end = _parse_optional_snapshot_date(snapshot.plan_end_date)
|
||||
except ValueError:
|
||||
errors.append({"field": "projectInfo.plan_start_date", "message": "项目计划日期格式应为 YYYY-MM-DD"})
|
||||
plan_start = None
|
||||
plan_end = None
|
||||
if plan_start and plan_end and plan_start > plan_end:
|
||||
errors.append({"field": "projectInfo.plan_end_date", "message": "项目计划结束日期不能早于开始日期"})
|
||||
|
||||
seen_visit_codes: set[str] = set()
|
||||
for index, item in enumerate(snapshot.visit_schedule):
|
||||
row_prefix = f"projectInfo.visit_schedule[{index}]"
|
||||
visit_code = item.visit_code.strip()
|
||||
has_visit_values = bool(visit_code) or any(
|
||||
value != 0
|
||||
for value in (item.baseline_offset_days, item.window_before_days, item.window_after_days)
|
||||
)
|
||||
if strict_required and not visit_code:
|
||||
errors.append({"field": f"{row_prefix}.visit_code", "message": "访视编号不能为空"})
|
||||
if not has_visit_values:
|
||||
continue
|
||||
if visit_code:
|
||||
if len(visit_code) > 50:
|
||||
errors.append({"field": f"{row_prefix}.visit_code", "message": "访视编号不能超过50个字符"})
|
||||
if visit_code in seen_visit_codes:
|
||||
errors.append({"field": f"{row_prefix}.visit_code", "message": f"访视编号重复:{visit_code}"})
|
||||
seen_visit_codes.add(visit_code)
|
||||
if item.baseline_offset_days < 0 or item.baseline_offset_days > 3650:
|
||||
errors.append({"field": f"{row_prefix}.baseline_offset_days", "message": "基线偏移天数应在0到3650之间"})
|
||||
if item.window_before_days < 0 or item.window_before_days > 365:
|
||||
errors.append({"field": f"{row_prefix}.window_before_days", "message": "窗口前天数应在0到365之间"})
|
||||
if item.window_after_days < 0 or item.window_after_days > 365:
|
||||
errors.append({"field": f"{row_prefix}.window_after_days", "message": "窗口后天数应在0到365之间"})
|
||||
|
||||
if errors:
|
||||
_raise_validation_error(errors)
|
||||
if code:
|
||||
existing = await study_crud.get_by_code(db, code)
|
||||
if existing and existing.id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="项目编号已存在")
|
||||
|
||||
|
||||
def _resolve_version_label(record: Any) -> str:
|
||||
branch_name = (getattr(record, "branch_name", None) or "main").strip() or "main"
|
||||
display_version = int(getattr(record, "display_version", 0) or 0)
|
||||
@@ -897,18 +1016,23 @@ async def upsert_study_setup_config(
|
||||
|
||||
sites = await site_crud.list_by_study(db, study_id, skip=0, limit=1000, include_inactive=True)
|
||||
site_lookup = {str(site.id): site.name or "" for site in sites}
|
||||
setup_project_snapshot = _resolve_setup_project_snapshot(payload.data, study)
|
||||
await _validate_setup_project_snapshot(db, study_id=study_id, snapshot=setup_project_snapshot, strict_required=False)
|
||||
setup_plan_start = _parse_optional_snapshot_date(setup_project_snapshot.plan_start_date)
|
||||
setup_plan_end = _parse_optional_snapshot_date(setup_project_snapshot.plan_end_date)
|
||||
_validate_setup_data(
|
||||
payload.data,
|
||||
site_lookup,
|
||||
project_plan_start=getattr(study, "plan_start_date", None),
|
||||
project_plan_end=getattr(study, "plan_end_date", None),
|
||||
project_plan_start=setup_plan_start,
|
||||
project_plan_end=setup_plan_end,
|
||||
strict_required=False,
|
||||
)
|
||||
|
||||
existing = await study_setup_config_crud.get_by_study(db, study_id)
|
||||
old_config = dict(existing.config or {}) if existing else {}
|
||||
force_draft = bool(payload.force_draft)
|
||||
if existing and existing.publish_status == "PUBLISHED" and existing.published_project_snapshot:
|
||||
current_project_snapshot = _build_project_publish_snapshot(study).model_dump(mode="json")
|
||||
current_project_snapshot = setup_project_snapshot.model_dump(mode="json")
|
||||
if current_project_snapshot != dict(existing.published_project_snapshot or {}):
|
||||
force_draft = True
|
||||
record, conflict = await study_setup_config_crud.upsert(
|
||||
@@ -971,7 +1095,12 @@ async def publish_study_setup_config(
|
||||
record = await study_setup_config_crud.get_by_study(db, study_id)
|
||||
if not record:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="请先保存立项配置草稿")
|
||||
current_project_snapshot_for_publish = _build_project_publish_snapshot(study).model_dump(mode="json")
|
||||
setup_data = StudySetupConfigData.model_validate(record.config or {})
|
||||
project_snapshot_for_publish = _resolve_setup_project_snapshot(setup_data, study)
|
||||
await _validate_setup_project_snapshot(db, study_id=study_id, snapshot=project_snapshot_for_publish)
|
||||
setup_plan_start = _parse_optional_snapshot_date(project_snapshot_for_publish.plan_start_date)
|
||||
setup_plan_end = _parse_optional_snapshot_date(project_snapshot_for_publish.plan_end_date)
|
||||
current_project_snapshot_for_publish = project_snapshot_for_publish.model_dump(mode="json")
|
||||
force_create_snapshot = bool(
|
||||
(record.published_project_snapshot or {}) != current_project_snapshot_for_publish
|
||||
)
|
||||
@@ -979,10 +1108,10 @@ async def publish_study_setup_config(
|
||||
sites = await site_crud.list_by_study(db, study_id, skip=0, limit=1000, include_inactive=True)
|
||||
site_lookup = {str(site.id): site.name or "" for site in sites}
|
||||
_validate_setup_data(
|
||||
StudySetupConfigData.model_validate(record.config or {}),
|
||||
setup_data,
|
||||
site_lookup,
|
||||
project_plan_start=getattr(study, "plan_start_date", None),
|
||||
project_plan_end=getattr(study, "plan_end_date", None),
|
||||
project_plan_start=setup_plan_start,
|
||||
project_plan_end=setup_plan_end,
|
||||
)
|
||||
|
||||
published, conflict = await study_setup_config_crud.publish(
|
||||
@@ -1001,6 +1130,7 @@ async def publish_study_setup_config(
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="配置发布失败")
|
||||
|
||||
try:
|
||||
_apply_project_publish_snapshot_to_study(study, project_snapshot_for_publish)
|
||||
projection = await apply_setup_projection_on_publish(
|
||||
db,
|
||||
study_id=study_id,
|
||||
@@ -1017,16 +1147,14 @@ async def publish_study_setup_config(
|
||||
for item in projection.skipped_items
|
||||
],
|
||||
)
|
||||
study_after_publish = await study_crud.get(db, study_id)
|
||||
if study_after_publish:
|
||||
project_snapshot = _build_project_publish_snapshot(study_after_publish).model_dump(mode="json")
|
||||
published.published_project_snapshot = project_snapshot
|
||||
await study_setup_config_crud.set_version_project_snapshot(
|
||||
db,
|
||||
study_id,
|
||||
version=published.version,
|
||||
project_snapshot=project_snapshot,
|
||||
)
|
||||
project_snapshot = project_snapshot_for_publish.model_dump(mode="json")
|
||||
published.published_project_snapshot = project_snapshot
|
||||
await study_setup_config_crud.set_version_project_snapshot(
|
||||
db,
|
||||
study_id,
|
||||
version=published.version,
|
||||
project_snapshot=project_snapshot,
|
||||
)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
@@ -1362,10 +1490,13 @@ async def merge_study_setup_config_to_main(
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="合并主分支失败")
|
||||
|
||||
try:
|
||||
merged_setup_data = StudySetupConfigData.model_validate(merged.published_config or {})
|
||||
merged_project_snapshot = _resolve_setup_project_snapshot(merged_setup_data, study)
|
||||
_apply_project_publish_snapshot_to_study(study, merged_project_snapshot)
|
||||
projection = await apply_setup_projection_on_publish(
|
||||
db,
|
||||
study_id=study_id,
|
||||
setup_data=StudySetupConfigData.model_validate(merged.published_config or {}),
|
||||
setup_data=merged_setup_data,
|
||||
operator_id=current_user.id,
|
||||
)
|
||||
projection_summary = SetupProjectionSummary(
|
||||
@@ -1375,16 +1506,14 @@ async def merge_study_setup_config_to_main(
|
||||
warnings=projection.warnings,
|
||||
skipped_items=[{"site_id": item.site_id, "reason": item.reason} for item in projection.skipped_items],
|
||||
)
|
||||
study_after_publish = await study_crud.get(db, study_id)
|
||||
if study_after_publish:
|
||||
project_snapshot = _build_project_publish_snapshot(study_after_publish).model_dump(mode="json")
|
||||
merged.published_project_snapshot = project_snapshot
|
||||
await study_setup_config_crud.set_version_project_snapshot(
|
||||
db,
|
||||
study_id,
|
||||
version=merged.version,
|
||||
project_snapshot=project_snapshot,
|
||||
)
|
||||
project_snapshot = merged_project_snapshot.model_dump(mode="json")
|
||||
merged.published_project_snapshot = project_snapshot
|
||||
await study_setup_config_crud.set_version_project_snapshot(
|
||||
db,
|
||||
study_id,
|
||||
version=merged.version,
|
||||
project_snapshot=project_snapshot,
|
||||
)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
|
||||
@@ -9,7 +9,8 @@ from app.crud import site as site_crud
|
||||
from app.crud import subject as subject_crud
|
||||
from app.crud import study as study_crud
|
||||
from app.crud import visit as visit_crud
|
||||
from app.schemas.visit import VisitCreate, VisitRead, VisitUpdate
|
||||
from app.schemas.subject import SubjectUpdate
|
||||
from app.schemas.visit import EarlyTerminationCreate, VisitCreate, VisitRead, VisitUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -110,6 +111,60 @@ async def create_visit(
|
||||
return visit
|
||||
|
||||
|
||||
@router.post(
|
||||
"/early-termination",
|
||||
response_model=VisitRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
|
||||
)
|
||||
async def create_early_termination(
|
||||
study_id: uuid.UUID,
|
||||
subject_id: uuid.UUID,
|
||||
termination_in: EarlyTerminationCreate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> VisitRead:
|
||||
subject = await _ensure_subject(db, study_id, subject_id)
|
||||
await _ensure_subject_active(db, subject)
|
||||
reason = termination_in.reason.strip()
|
||||
if not reason:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="提前终止原因不能为空")
|
||||
if subject.baseline_date and termination_in.termination_date < subject.baseline_date:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="提前终止日期不能早于基线/治疗日期")
|
||||
|
||||
try:
|
||||
visit = await visit_crud.create_early_termination_visit(
|
||||
db,
|
||||
study_id=study_id,
|
||||
subject=subject,
|
||||
termination_date=termination_in.termination_date,
|
||||
reason=reason,
|
||||
notes=termination_in.notes,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
await subject_crud.update_subject(
|
||||
db,
|
||||
subject,
|
||||
SubjectUpdate(
|
||||
status="DROPPED",
|
||||
completion_date=termination_in.termination_date,
|
||||
drop_reason=reason,
|
||||
),
|
||||
)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="visit",
|
||||
entity_id=visit.id,
|
||||
action="CREATE_EARLY_TERMINATION",
|
||||
detail=f"参与者 {subject.subject_no} 已提前终止",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return visit
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{visit_id}",
|
||||
response_model=VisitRead,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import and_, or_, select
|
||||
@@ -46,25 +46,55 @@ async def list_issues(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID,
|
||||
*,
|
||||
site_id: uuid.UUID | None = None,
|
||||
category: str | None = None,
|
||||
severity: str | None = None,
|
||||
mark: str | None = None,
|
||||
visit_cycle: str | None = None,
|
||||
status: str | None = None,
|
||||
overdue: bool | None = None,
|
||||
rectification_completed: bool | None = None,
|
||||
due_from: date | None = None,
|
||||
due_to: date | None = None,
|
||||
created_from: date | None = None,
|
||||
created_to: date | None = None,
|
||||
keyword: str | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 500,
|
||||
) -> Sequence[MonitoringVisitIssue]:
|
||||
stmt = select(MonitoringVisitIssue).where(MonitoringVisitIssue.study_id == study_id)
|
||||
|
||||
if site_id:
|
||||
stmt = stmt.where(MonitoringVisitIssue.site_id == site_id)
|
||||
if category:
|
||||
stmt = stmt.where(MonitoringVisitIssue.category == category)
|
||||
if severity:
|
||||
stmt = stmt.where(MonitoringVisitIssue.severity == severity)
|
||||
if mark:
|
||||
stmt = stmt.where(MonitoringVisitIssue.mark.ilike(f"%{mark}%"))
|
||||
if visit_cycle:
|
||||
stmt = stmt.where(MonitoringVisitIssue.visit_cycle == visit_cycle)
|
||||
if status:
|
||||
stmt = stmt.where(MonitoringVisitIssue.status == status)
|
||||
if rectification_completed is not None:
|
||||
stmt = stmt.where(MonitoringVisitIssue.rectification_completed == rectification_completed)
|
||||
if due_from:
|
||||
stmt = stmt.where(MonitoringVisitIssue.due_at >= datetime.combine(due_from, time.min, tzinfo=timezone.utc))
|
||||
if due_to:
|
||||
stmt = stmt.where(MonitoringVisitIssue.due_at < datetime.combine(due_to + timedelta(days=1), time.min, tzinfo=timezone.utc))
|
||||
if created_from:
|
||||
stmt = stmt.where(MonitoringVisitIssue.created_at >= datetime.combine(created_from, time.min, tzinfo=timezone.utc))
|
||||
if created_to:
|
||||
stmt = stmt.where(MonitoringVisitIssue.created_at < datetime.combine(created_to + timedelta(days=1), time.min, tzinfo=timezone.utc))
|
||||
if keyword:
|
||||
term = f"%{keyword}%"
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
MonitoringVisitIssue.issue_no.ilike(term),
|
||||
MonitoringVisitIssue.category.ilike(term),
|
||||
MonitoringVisitIssue.severity.ilike(term),
|
||||
MonitoringVisitIssue.mark.ilike(term),
|
||||
MonitoringVisitIssue.visit_cycle.ilike(term),
|
||||
MonitoringVisitIssue.subject_code.ilike(term),
|
||||
MonitoringVisitIssue.subject_name.ilike(term),
|
||||
MonitoringVisitIssue.monitor_item.ilike(term),
|
||||
@@ -72,6 +102,8 @@ async def list_issues(
|
||||
MonitoringVisitIssue.recommendation.ilike(term),
|
||||
MonitoringVisitIssue.action_taken.ilike(term),
|
||||
MonitoringVisitIssue.follow_up_progress.ilike(term),
|
||||
MonitoringVisitIssue.center_query.ilike(term),
|
||||
MonitoringVisitIssue.center_latest_reply.ilike(term),
|
||||
MonitoringVisitIssue.responsible_name.ilike(term),
|
||||
)
|
||||
)
|
||||
@@ -107,8 +139,12 @@ async def create_issue(
|
||||
closed_at = datetime.now(timezone.utc)
|
||||
item = MonitoringVisitIssue(
|
||||
study_id=study_id,
|
||||
site_id=issue_in.site_id,
|
||||
issue_no=issue_no,
|
||||
category=issue_in.category,
|
||||
severity=issue_in.severity,
|
||||
mark=issue_in.mark,
|
||||
visit_cycle=issue_in.visit_cycle,
|
||||
subject_code=issue_in.subject_code,
|
||||
monitor_item=issue_in.monitor_item,
|
||||
monitor_type=issue_in.monitor_type,
|
||||
@@ -120,6 +156,9 @@ async def create_issue(
|
||||
description=issue_in.description,
|
||||
action_taken=issue_in.action_taken,
|
||||
follow_up_progress=issue_in.follow_up_progress,
|
||||
center_query=issue_in.center_query,
|
||||
center_latest_reply=issue_in.center_latest_reply,
|
||||
rectification_completed=issue_in.rectification_completed,
|
||||
found_date=issue_in.found_date,
|
||||
due_at=issue_in.due_at,
|
||||
actual_resolve_date=issue_in.actual_resolve_date,
|
||||
|
||||
@@ -21,6 +21,7 @@ from app.models.finance_special import FinanceSpecial
|
||||
from app.models.kickoff_meeting import KickoffMeeting
|
||||
from app.models.knowledge_note import KnowledgeNote
|
||||
from app.models.milestone import Milestone
|
||||
from app.models.monitoring_visit_issue import MonitoringVisitIssue
|
||||
from app.models.site import Site
|
||||
from app.models.special_expense import SpecialExpense
|
||||
from app.models.startup_ethics import StartupEthics
|
||||
@@ -328,6 +329,11 @@ async def delete_site_and_related(db: AsyncSession, site: Site) -> None:
|
||||
await db.execute(delete(ContractFee).where(ContractFee.center_id == site_id))
|
||||
await db.execute(delete(SpecialExpense).where(SpecialExpense.center_id == site_id))
|
||||
await db.execute(delete(DrugShipment).where(DrugShipment.center_id == site_id))
|
||||
await db.execute(
|
||||
update(MonitoringVisitIssue)
|
||||
.where(MonitoringVisitIssue.site_id == site_id)
|
||||
.values(site_id=None)
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
delete(TrainingAuthorization).where(
|
||||
|
||||
@@ -37,6 +37,11 @@ def _validate_subject_date_chain(
|
||||
raise ValueError("完成日期不能早于入组日期")
|
||||
|
||||
|
||||
def _validate_actual_medication_count(value: int | None) -> None:
|
||||
if value is not None and value < 0:
|
||||
raise ValueError("实际用药次数不能小于0")
|
||||
|
||||
|
||||
def _should_sync_visits(previous_baseline_date: date | None, next_baseline_date: date | None) -> bool:
|
||||
return next_baseline_date is not None and previous_baseline_date != next_baseline_date
|
||||
|
||||
@@ -132,6 +137,7 @@ async def update_subject(db: AsyncSession, subject: Subject, subject_in: Subject
|
||||
enrollment_date=next_enrollment_date,
|
||||
completion_date=next_completion_date,
|
||||
)
|
||||
_validate_actual_medication_count(update_data.get("actual_medication_count", subject.actual_medication_count))
|
||||
if update_data:
|
||||
await db.execute(
|
||||
sa_update(Subject)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, timedelta
|
||||
from typing import Sequence
|
||||
|
||||
@@ -11,6 +12,17 @@ from app.models.visit import Visit
|
||||
from app.schemas.visit import VisitCreate, VisitUpdate
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EarlyTerminationVisitChanges:
|
||||
event_visit_code: str
|
||||
event_actual_date: date
|
||||
event_notes: str
|
||||
visits_to_cancel: list[Visit]
|
||||
|
||||
|
||||
NON_TREATMENT_VISIT_CODES = {"筛选访视", "基线访视", "提前终止"}
|
||||
|
||||
|
||||
def _visit_schedule_order_map(visit_schedule: list[dict] | None) -> dict[str, int]:
|
||||
order_map: dict[str, int] = {}
|
||||
for index, item in enumerate(visit_schedule or []):
|
||||
@@ -23,6 +35,34 @@ def _visit_schedule_order_map(visit_schedule: list[dict] | None) -> dict[str, in
|
||||
def sort_visits_for_display(visits: Sequence[Visit], visit_schedule: list[dict] | None = None) -> list[Visit]:
|
||||
order_map = _visit_schedule_order_map(visit_schedule)
|
||||
fallback_start = len(order_map)
|
||||
early_termination = next((visit for visit in visits if (visit.visit_code or "").strip() == "提前终止"), None)
|
||||
if early_termination and early_termination.actual_date:
|
||||
last_actual_index = max(
|
||||
(
|
||||
order_map.get((visit.visit_code or "").strip(), fallback_start)
|
||||
for visit in visits
|
||||
if visit is not early_termination and visit.actual_date is not None
|
||||
),
|
||||
default=-1,
|
||||
)
|
||||
sorted_visits: list[Visit] = []
|
||||
inserted = False
|
||||
for visit in sorted(
|
||||
[visit for visit in visits if visit is not early_termination],
|
||||
key=lambda visit: (
|
||||
order_map.get((visit.visit_code or "").strip(), fallback_start),
|
||||
visit.planned_date is None,
|
||||
visit.planned_date or date.max,
|
||||
visit.visit_code or "",
|
||||
),
|
||||
):
|
||||
sorted_visits.append(visit)
|
||||
if not inserted and order_map.get((visit.visit_code or "").strip(), fallback_start) == last_actual_index:
|
||||
sorted_visits.append(early_termination)
|
||||
inserted = True
|
||||
if not inserted:
|
||||
sorted_visits.insert(0, early_termination)
|
||||
return sorted_visits
|
||||
return sorted(
|
||||
visits,
|
||||
key=lambda visit: (
|
||||
@@ -58,6 +98,52 @@ def build_visit_schedule_dates(visit_schedule: list[dict] | None, base_date: dat
|
||||
return rows
|
||||
|
||||
|
||||
def get_visit_window_start_date(visit: Visit) -> date | None:
|
||||
return visit.window_start or visit.planned_date
|
||||
|
||||
|
||||
def get_last_planned_visit_window_start_date(visits: Sequence[Visit]) -> date | None:
|
||||
window_start_dates = [
|
||||
get_visit_window_start_date(visit)
|
||||
for visit in visits
|
||||
if get_visit_window_start_date(visit) is not None and (visit.visit_code or "").strip() not in NON_TREATMENT_VISIT_CODES
|
||||
]
|
||||
return max(window_start_dates) if window_start_dates else None
|
||||
|
||||
|
||||
def validate_early_termination_date(termination_date: date, visits: Sequence[Visit]) -> None:
|
||||
last_window_start_date = get_last_planned_visit_window_start_date(visits)
|
||||
if last_window_start_date is not None and termination_date >= last_window_start_date:
|
||||
raise ValueError(f"提前终止日期必须早于方案最后一个计划访视窗口开始日({last_window_start_date.isoformat()})")
|
||||
|
||||
|
||||
def build_early_termination_visit_changes(
|
||||
visits: Sequence[Visit],
|
||||
*,
|
||||
termination_date: date,
|
||||
reason: str,
|
||||
notes: str | None = None,
|
||||
) -> EarlyTerminationVisitChanges:
|
||||
event_notes = reason.strip()
|
||||
extra_notes = (notes or "").strip()
|
||||
if extra_notes:
|
||||
event_notes = f"{event_notes}\n{extra_notes}"
|
||||
|
||||
visits_to_cancel = [
|
||||
visit
|
||||
for visit in visits
|
||||
if visit.actual_date is None
|
||||
and visit.status not in ["DONE", "CANCELLED", "LOST"]
|
||||
and (visit.visit_code or "").strip() not in NON_TREATMENT_VISIT_CODES
|
||||
]
|
||||
return EarlyTerminationVisitChanges(
|
||||
event_visit_code="提前终止",
|
||||
event_actual_date=termination_date,
|
||||
event_notes=event_notes,
|
||||
visits_to_cancel=visits_to_cancel,
|
||||
)
|
||||
|
||||
|
||||
async def create_visit(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
@@ -264,3 +350,62 @@ async def create_scheduled_visits(
|
||||
)
|
||||
)
|
||||
return created
|
||||
|
||||
|
||||
async def create_early_termination_visit(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
study_id: uuid.UUID,
|
||||
subject: Subject,
|
||||
termination_date: date,
|
||||
reason: str,
|
||||
notes: str | None = None,
|
||||
) -> Visit:
|
||||
result = await db.execute(select(Visit).where(Visit.subject_id == subject.id))
|
||||
existing_visits = list(result.scalars().all())
|
||||
validate_early_termination_date(termination_date, existing_visits)
|
||||
changes = build_early_termination_visit_changes(
|
||||
existing_visits,
|
||||
termination_date=termination_date,
|
||||
reason=reason,
|
||||
notes=notes,
|
||||
)
|
||||
existing_event = next(
|
||||
(visit for visit in existing_visits if visit.visit_code == changes.event_visit_code),
|
||||
None,
|
||||
)
|
||||
if existing_event:
|
||||
await db.execute(
|
||||
sa_update(Visit)
|
||||
.where(Visit.id == existing_event.id)
|
||||
.values(
|
||||
actual_date=changes.event_actual_date,
|
||||
status="DONE",
|
||||
notes=changes.event_notes,
|
||||
)
|
||||
)
|
||||
event_visit = existing_event
|
||||
else:
|
||||
event_visit = Visit(
|
||||
study_id=study_id,
|
||||
subject_id=subject.id,
|
||||
visit_code=changes.event_visit_code,
|
||||
planned_date=None,
|
||||
actual_date=changes.event_actual_date,
|
||||
status="DONE",
|
||||
window_start=None,
|
||||
window_end=None,
|
||||
notes=changes.event_notes,
|
||||
)
|
||||
db.add(event_visit)
|
||||
|
||||
for visit in changes.visits_to_cancel:
|
||||
await db.execute(
|
||||
sa_update(Visit)
|
||||
.where(Visit.id == visit.id)
|
||||
.values(status="CANCELLED", notes="提前终止后不再适用")
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(event_visit)
|
||||
return event_visit
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -14,8 +14,12 @@ class MonitoringVisitIssue(Base):
|
||||
|
||||
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)
|
||||
site_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=True)
|
||||
issue_no: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
category: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
severity: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
mark: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
visit_cycle: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
subject_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
monitor_item: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
monitor_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
@@ -27,6 +31,9 @@ class MonitoringVisitIssue(Base):
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
action_taken: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
follow_up_progress: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
center_query: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
center_latest_reply: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
rectification_completed: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false", default=False)
|
||||
found_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
actual_resolve_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime, date
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -22,6 +22,7 @@ class Subject(Base):
|
||||
enrollment_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
baseline_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
completion_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
actual_medication_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
drop_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
|
||||
@@ -6,7 +6,11 @@ from pydantic import BaseModel, ConfigDict, field_validator
|
||||
|
||||
class MonitoringVisitIssueCreate(BaseModel):
|
||||
issue_no: str | None = None
|
||||
site_id: uuid.UUID | None = None
|
||||
category: str
|
||||
severity: str | None = None
|
||||
mark: str | None = None
|
||||
visit_cycle: str | None = None
|
||||
subject_code: str | None = None
|
||||
monitor_item: str | None = None
|
||||
monitor_type: str | None = None
|
||||
@@ -18,6 +22,9 @@ class MonitoringVisitIssueCreate(BaseModel):
|
||||
description: str | None = None
|
||||
action_taken: str | None = None
|
||||
follow_up_progress: str | None = None
|
||||
center_query: str | None = None
|
||||
center_latest_reply: str | None = None
|
||||
rectification_completed: bool = False
|
||||
found_date: date | None = None
|
||||
due_at: datetime | None = None
|
||||
actual_resolve_date: date | None = None
|
||||
@@ -42,6 +49,9 @@ class MonitoringVisitIssueCreate(BaseModel):
|
||||
|
||||
@field_validator(
|
||||
"subject_code",
|
||||
"severity",
|
||||
"mark",
|
||||
"visit_cycle",
|
||||
"monitor_item",
|
||||
"monitor_type",
|
||||
"source",
|
||||
@@ -51,6 +61,8 @@ class MonitoringVisitIssueCreate(BaseModel):
|
||||
"description",
|
||||
"action_taken",
|
||||
"follow_up_progress",
|
||||
"center_query",
|
||||
"center_latest_reply",
|
||||
"responsible_name",
|
||||
mode="before",
|
||||
)
|
||||
@@ -80,9 +92,13 @@ class MonitoringVisitIssueCreate(BaseModel):
|
||||
class MonitoringVisitIssueRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
study_id: uuid.UUID
|
||||
site_id: uuid.UUID | None
|
||||
issue_no: str
|
||||
open_duration: str
|
||||
category: str
|
||||
severity: str | None
|
||||
mark: str | None
|
||||
visit_cycle: str | None
|
||||
subject_code: str | None
|
||||
monitor_item: str | None
|
||||
monitor_type: str | None
|
||||
@@ -96,6 +112,9 @@ class MonitoringVisitIssueRead(BaseModel):
|
||||
description: str | None
|
||||
action_taken: str | None
|
||||
follow_up_progress: str | None
|
||||
center_query: str | None
|
||||
center_latest_reply: str | None
|
||||
rectification_completed: bool
|
||||
found_date: date | None
|
||||
overdue: bool
|
||||
due_at: datetime | None
|
||||
@@ -115,7 +134,11 @@ class MonitoringVisitIssueImportSummary(BaseModel):
|
||||
|
||||
class MonitoringVisitIssueUpdate(BaseModel):
|
||||
issue_no: str | None = None
|
||||
site_id: uuid.UUID | None = None
|
||||
category: str | None = None
|
||||
severity: str | None = None
|
||||
mark: str | None = None
|
||||
visit_cycle: str | None = None
|
||||
subject_code: str | None = None
|
||||
monitor_item: str | None = None
|
||||
monitor_type: str | None = None
|
||||
@@ -127,6 +150,9 @@ class MonitoringVisitIssueUpdate(BaseModel):
|
||||
description: str | None = None
|
||||
action_taken: str | None = None
|
||||
follow_up_progress: str | None = None
|
||||
center_query: str | None = None
|
||||
center_latest_reply: str | None = None
|
||||
rectification_completed: bool | None = None
|
||||
found_date: date | None = None
|
||||
due_at: datetime | None = None
|
||||
actual_resolve_date: date | None = None
|
||||
@@ -145,6 +171,9 @@ class MonitoringVisitIssueUpdate(BaseModel):
|
||||
|
||||
@field_validator(
|
||||
"subject_code",
|
||||
"severity",
|
||||
"mark",
|
||||
"visit_cycle",
|
||||
"monitor_item",
|
||||
"monitor_type",
|
||||
"source",
|
||||
@@ -154,6 +183,8 @@ class MonitoringVisitIssueUpdate(BaseModel):
|
||||
"description",
|
||||
"action_taken",
|
||||
"follow_up_progress",
|
||||
"center_query",
|
||||
"center_latest_reply",
|
||||
"responsible_name",
|
||||
mode="before",
|
||||
)
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
ConfirmStatus = Literal["待确认", "已确认", "退回"]
|
||||
MilestoneStatus = Literal["未开始", "进行中", "已完成", "延期"]
|
||||
|
||||
|
||||
class ProjectMilestoneItem(BaseModel):
|
||||
id: str
|
||||
name: str = ""
|
||||
@@ -18,7 +13,7 @@ class ProjectMilestoneItem(BaseModel):
|
||||
durationDays: int = 1
|
||||
owner: str = ""
|
||||
remark: str = ""
|
||||
status: MilestoneStatus = "未开始"
|
||||
status: str = "未开始"
|
||||
|
||||
|
||||
class EnrollmentPlanItem(BaseModel):
|
||||
@@ -35,7 +30,7 @@ class SiteMilestoneItem(BaseModel):
|
||||
planDate: str = ""
|
||||
owner: str = ""
|
||||
remark: str = ""
|
||||
status: MilestoneStatus = "未开始"
|
||||
status: str = "未开始"
|
||||
|
||||
|
||||
class SiteEnrollmentPlanItem(BaseModel):
|
||||
@@ -63,12 +58,49 @@ class CenterConfirmItem(BaseModel):
|
||||
siteId: str = ""
|
||||
siteName: str = ""
|
||||
confirmer: str = ""
|
||||
confirmStatus: ConfirmStatus = "待确认"
|
||||
confirmStatus: str = "待确认"
|
||||
confirmDate: str = ""
|
||||
note: str = ""
|
||||
|
||||
|
||||
class VisitScheduleItem(BaseModel):
|
||||
visit_code: str = ""
|
||||
baseline_offset_days: int = 0
|
||||
window_before_days: int = 0
|
||||
window_after_days: int = 0
|
||||
|
||||
|
||||
class ProjectPublishSnapshot(BaseModel):
|
||||
code: str = ""
|
||||
name: str = ""
|
||||
project_full_name: str = ""
|
||||
sponsor: str = ""
|
||||
protocol_no: str = ""
|
||||
lead_unit: str = ""
|
||||
principal_investigator: str = ""
|
||||
main_pm: str = ""
|
||||
research_analysis: str = ""
|
||||
research_product: str = ""
|
||||
control_product: str = ""
|
||||
indication: str = ""
|
||||
research_population: str = ""
|
||||
research_design: str = ""
|
||||
plan_start_date: str = ""
|
||||
plan_end_date: str = ""
|
||||
planned_site_count: int | None = None
|
||||
planned_enrollment_count: int | None = None
|
||||
status: str = ""
|
||||
visit_schedule: list[VisitScheduleItem] = Field(default_factory=list)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def normalize_visit_schedule(self):
|
||||
for item in self.visit_schedule:
|
||||
item.visit_code = item.visit_code.strip()
|
||||
return self
|
||||
|
||||
|
||||
class StudySetupConfigData(BaseModel):
|
||||
projectInfo: ProjectPublishSnapshot = Field(default_factory=ProjectPublishSnapshot)
|
||||
projectMilestones: list[ProjectMilestoneItem] = Field(default_factory=list)
|
||||
enrollmentPlan: EnrollmentPlanItem = Field(default_factory=EnrollmentPlanItem)
|
||||
siteMilestones: list[SiteMilestoneItem] = Field(default_factory=list)
|
||||
@@ -143,49 +175,6 @@ class SetupProjectionSummary(BaseModel):
|
||||
skipped_items: list[SetupProjectionSkippedItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class VisitScheduleItem(BaseModel):
|
||||
visit_code: str = Field(min_length=1, max_length=50)
|
||||
baseline_offset_days: int = Field(ge=0, le=3650)
|
||||
window_before_days: int = Field(ge=0, le=365)
|
||||
window_after_days: int = Field(ge=0, le=365)
|
||||
|
||||
|
||||
class ProjectPublishSnapshot(BaseModel):
|
||||
code: str = ""
|
||||
name: str = ""
|
||||
project_full_name: str = ""
|
||||
sponsor: str = ""
|
||||
protocol_no: str = ""
|
||||
lead_unit: str = ""
|
||||
principal_investigator: str = ""
|
||||
main_pm: str = ""
|
||||
research_analysis: str = ""
|
||||
research_product: str = ""
|
||||
control_product: str = ""
|
||||
indication: str = ""
|
||||
research_population: str = ""
|
||||
research_design: str = ""
|
||||
plan_start_date: str = ""
|
||||
plan_end_date: str = ""
|
||||
planned_site_count: int | None = None
|
||||
planned_enrollment_count: int | None = None
|
||||
status: str = ""
|
||||
visit_schedule: list[VisitScheduleItem] = Field(default_factory=list)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_visit_schedule(self):
|
||||
codes: set[str] = set()
|
||||
for index, item in enumerate(self.visit_schedule):
|
||||
code = item.visit_code.strip()
|
||||
if not code:
|
||||
raise ValueError(f"第 {index + 1} 行访视编号不能为空")
|
||||
if code in codes:
|
||||
raise ValueError(f"访视编号重复:{code}")
|
||||
codes.add(code)
|
||||
item.visit_code = code
|
||||
return self
|
||||
|
||||
|
||||
class StudySetupConfigRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
study_id: uuid.UUID
|
||||
|
||||
@@ -20,6 +20,7 @@ class SubjectUpdate(BaseModel):
|
||||
enrollment_date: Optional[date] = None
|
||||
baseline_date: Optional[date] = None
|
||||
completion_date: Optional[date] = None
|
||||
actual_medication_count: Optional[int] = None
|
||||
drop_reason: Optional[str] = None
|
||||
|
||||
|
||||
@@ -34,6 +35,7 @@ class SubjectRead(BaseModel):
|
||||
enrollment_date: Optional[date]
|
||||
baseline_date: Optional[date]
|
||||
completion_date: Optional[date]
|
||||
actual_medication_count: Optional[int]
|
||||
drop_reason: Optional[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -19,6 +19,12 @@ class VisitUpdate(BaseModel):
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class EarlyTerminationCreate(BaseModel):
|
||||
termination_date: date
|
||||
reason: str
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class VisitRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
study_id: uuid.UUID
|
||||
|
||||
@@ -3,35 +3,57 @@ import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from app.core.config import PROTECTED_ADMIN_EMAIL # noqa: E402
|
||||
from app.crud.user import ensure_admin_exists # noqa: E402
|
||||
from app.db.session import SessionLocal # noqa: E402
|
||||
from app.db.base import Base # noqa: E402
|
||||
from app.db.session import SessionLocal, engine # noqa: E402
|
||||
|
||||
|
||||
def run_migrations() -> None:
|
||||
def run_alembic_command(*args: str) -> None:
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "alembic", "upgrade", "head"],
|
||||
[sys.executable, "-m", "alembic", *args],
|
||||
check=True,
|
||||
cwd=PROJECT_ROOT,
|
||||
)
|
||||
|
||||
|
||||
def _list_user_tables(sync_conn) -> list[str]:
|
||||
inspector = inspect(sync_conn)
|
||||
return [table for table in inspector.get_table_names(schema="public") if table != "alembic_version"]
|
||||
|
||||
|
||||
async def initialize_schema() -> None:
|
||||
async with engine.begin() as conn:
|
||||
user_tables = await conn.run_sync(_list_user_tables)
|
||||
if user_tables:
|
||||
run_alembic_command("upgrade", "head")
|
||||
return
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
run_alembic_command("stamp", "head")
|
||||
|
||||
|
||||
async def seed_protected_admin() -> None:
|
||||
async with SessionLocal() as session:
|
||||
await ensure_admin_exists(session)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("Running Alembic migrations...")
|
||||
run_migrations()
|
||||
async def async_main() -> None:
|
||||
print("Initializing database schema...")
|
||||
await initialize_schema()
|
||||
print(f"Ensuring protected admin exists: {PROTECTED_ADMIN_EMAIL}")
|
||||
asyncio.run(seed_protected_admin())
|
||||
await seed_protected_admin()
|
||||
print("Production initialization complete.")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
asyncio.run(async_main())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.crud import monitoring_visit_issue as issue_crud
|
||||
from app.models.monitoring_visit_issue import MonitoringVisitIssue
|
||||
from app.schemas.monitoring_visit_issue import MonitoringVisitIssueCreate
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_monitoring_visit_issue_template_fields_can_be_filtered(tmp_path):
|
||||
db_path = tmp_path / "monitoring-issues.db"
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", future=True)
|
||||
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(MonitoringVisitIssue.__table__.create)
|
||||
|
||||
study_id = uuid.UUID("11111111-1111-1111-1111-111111111111")
|
||||
site_id = uuid.UUID("22222222-2222-2222-2222-222222222222")
|
||||
async with SessionLocal() as session:
|
||||
created = await issue_crud.create_issue(
|
||||
session,
|
||||
study_id,
|
||||
MonitoringVisitIssueCreate(
|
||||
issue_no="MV-001",
|
||||
site_id=site_id,
|
||||
category="原始记录",
|
||||
subject_code="SUBJ-001",
|
||||
status="OPEN",
|
||||
severity="严重",
|
||||
mark="SDV",
|
||||
visit_cycle="V1",
|
||||
center_query="请补充签名日期",
|
||||
center_latest_reply="待中心回复",
|
||||
rectification_completed=False,
|
||||
due_at=datetime(2026, 5, 1, tzinfo=timezone.utc),
|
||||
),
|
||||
created_by=None,
|
||||
)
|
||||
|
||||
assert created.site_id == site_id
|
||||
assert created.severity == "严重"
|
||||
assert created.mark == "SDV"
|
||||
assert created.visit_cycle == "V1"
|
||||
assert created.center_query == "请补充签名日期"
|
||||
assert created.center_latest_reply == "待中心回复"
|
||||
assert created.rectification_completed is False
|
||||
|
||||
matched = await issue_crud.list_issues(
|
||||
session,
|
||||
study_id,
|
||||
site_id=site_id,
|
||||
severity="严重",
|
||||
mark="SDV",
|
||||
visit_cycle="V1",
|
||||
rectification_completed=False,
|
||||
due_from=datetime(2026, 5, 1, tzinfo=timezone.utc).date(),
|
||||
due_to=datetime(2026, 5, 1, tzinfo=timezone.utc).date(),
|
||||
)
|
||||
unmatched = await issue_crud.list_issues(
|
||||
session,
|
||||
study_id,
|
||||
site_id=uuid.UUID("33333333-3333-3333-3333-333333333333"),
|
||||
)
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
assert [item.issue_no for item in matched] == ["MV-001"]
|
||||
assert unmatched == []
|
||||
@@ -0,0 +1,230 @@
|
||||
from datetime import date
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.api.v1.studies import _apply_project_publish_snapshot_to_study, _validate_setup_data
|
||||
from app.schemas.study_setup_config import ProjectPublishSnapshot, StudySetupConfigData
|
||||
|
||||
|
||||
def test_setup_config_data_keeps_project_info_draft():
|
||||
draft = StudySetupConfigData.model_validate(
|
||||
{
|
||||
"projectInfo": {
|
||||
"code": "PRJ-002",
|
||||
"name": "草稿项目",
|
||||
"plan_start_date": "2026-06-01",
|
||||
"plan_end_date": "2026-12-31",
|
||||
"planned_enrollment_count": 120,
|
||||
"status": "ACTIVE",
|
||||
"visit_schedule": [
|
||||
{
|
||||
"visit_code": "V1",
|
||||
"baseline_offset_days": 7,
|
||||
"window_before_days": 1,
|
||||
"window_after_days": 2,
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
assert draft.projectInfo.code == "PRJ-002"
|
||||
assert draft.projectInfo.name == "草稿项目"
|
||||
assert draft.projectInfo.planned_enrollment_count == 120
|
||||
assert draft.projectInfo.visit_schedule[0].visit_code == "V1"
|
||||
|
||||
|
||||
def test_draft_schema_accepts_incomplete_project_snapshot_rows():
|
||||
draft = StudySetupConfigData.model_validate(
|
||||
{
|
||||
"projectInfo": {
|
||||
"code": "PRJ-002",
|
||||
"name": "草稿项目",
|
||||
"visit_schedule": [
|
||||
{
|
||||
"visit_code": "",
|
||||
"baseline_offset_days": 0,
|
||||
"window_before_days": 0,
|
||||
"window_after_days": 0,
|
||||
}
|
||||
],
|
||||
},
|
||||
"projectMilestones": [
|
||||
{
|
||||
"id": "row-1",
|
||||
"name": "",
|
||||
"planDate": "",
|
||||
"startDate": "",
|
||||
"endDate": "",
|
||||
"durationDays": 1,
|
||||
"owner": "",
|
||||
"remark": "",
|
||||
"status": "",
|
||||
}
|
||||
],
|
||||
"centerConfirm": [
|
||||
{
|
||||
"id": "row-1",
|
||||
"siteId": "",
|
||||
"siteName": "",
|
||||
"confirmer": "",
|
||||
"confirmStatus": "",
|
||||
"confirmDate": "",
|
||||
"note": "",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert draft.projectInfo.visit_schedule[0].visit_code == ""
|
||||
assert draft.projectMilestones[0].status == ""
|
||||
assert draft.centerConfirm[0].confirmStatus == ""
|
||||
|
||||
|
||||
def test_draft_save_allows_incomplete_setup_steps():
|
||||
draft = StudySetupConfigData.model_validate(
|
||||
{
|
||||
"projectInfo": {
|
||||
"code": "PRJ-002",
|
||||
"name": "草稿项目",
|
||||
"plan_start_date": "2026-05-01",
|
||||
"plan_end_date": "2027-05-31",
|
||||
},
|
||||
"enrollmentPlan": {
|
||||
"totalTarget": 180,
|
||||
"startDate": "",
|
||||
"endDate": "",
|
||||
"monthlyGoalNote": "",
|
||||
"stageBreakdown": "",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
_validate_setup_data(
|
||||
draft,
|
||||
{},
|
||||
project_plan_start=date(2026, 5, 1),
|
||||
project_plan_end=date(2027, 5, 31),
|
||||
strict_required=False,
|
||||
)
|
||||
|
||||
|
||||
def test_publish_validation_still_requires_complete_setup_steps():
|
||||
draft = StudySetupConfigData.model_validate(
|
||||
{
|
||||
"projectInfo": {
|
||||
"code": "PRJ-002",
|
||||
"name": "草稿项目",
|
||||
"plan_start_date": "2026-05-01",
|
||||
"plan_end_date": "2027-05-31",
|
||||
},
|
||||
"enrollmentPlan": {
|
||||
"totalTarget": 180,
|
||||
"startDate": "",
|
||||
"endDate": "",
|
||||
"monthlyGoalNote": "",
|
||||
"stageBreakdown": "",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
_validate_setup_data(
|
||||
draft,
|
||||
{},
|
||||
project_plan_start=date(2026, 5, 1),
|
||||
project_plan_end=date(2027, 5, 31),
|
||||
)
|
||||
except HTTPException as exc:
|
||||
assert exc.status_code == 422
|
||||
else:
|
||||
raise AssertionError("publish validation should reject incomplete setup steps")
|
||||
|
||||
|
||||
def test_project_info_is_part_of_setup_config_payload():
|
||||
draft = StudySetupConfigData.model_validate(
|
||||
{
|
||||
"projectInfo": {
|
||||
"code": "PRJ-VERSION",
|
||||
"name": "版本内项目信息",
|
||||
"project_full_name": "版本内项目全称",
|
||||
"plan_start_date": "2026-06-01",
|
||||
"plan_end_date": "2026-12-31",
|
||||
"planned_site_count": 8,
|
||||
"planned_enrollment_count": 120,
|
||||
"status": "ACTIVE",
|
||||
},
|
||||
"enrollmentPlan": {
|
||||
"totalTarget": 120,
|
||||
"startDate": "2026-06-01",
|
||||
"endDate": "2026-12-31",
|
||||
"monthlyGoalNote": "",
|
||||
"stageBreakdown": "",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
payload = draft.model_dump(mode="json")
|
||||
|
||||
assert payload["projectInfo"]["code"] == "PRJ-VERSION"
|
||||
assert payload["projectInfo"]["planned_enrollment_count"] == 120
|
||||
assert payload["enrollmentPlan"]["totalTarget"] == 120
|
||||
|
||||
|
||||
def test_apply_project_publish_snapshot_updates_formal_study_fields():
|
||||
study = SimpleNamespace(
|
||||
code="PRJ-001",
|
||||
name="原项目",
|
||||
project_full_name=None,
|
||||
sponsor=None,
|
||||
protocol_no=None,
|
||||
lead_unit=None,
|
||||
principal_investigator=None,
|
||||
main_pm=None,
|
||||
research_analysis=None,
|
||||
research_product=None,
|
||||
control_product=None,
|
||||
indication=None,
|
||||
research_population=None,
|
||||
research_design=None,
|
||||
plan_start_date=None,
|
||||
plan_end_date=None,
|
||||
planned_site_count=None,
|
||||
planned_enrollment_count=10,
|
||||
status="DRAFT",
|
||||
visit_schedule=[],
|
||||
)
|
||||
snapshot = ProjectPublishSnapshot(
|
||||
code="PRJ-002",
|
||||
name="发布项目",
|
||||
project_full_name="发布项目全称",
|
||||
sponsor="申办方",
|
||||
plan_start_date="2026-06-01",
|
||||
plan_end_date="2026-12-31",
|
||||
planned_site_count=8,
|
||||
planned_enrollment_count=120,
|
||||
status="ACTIVE",
|
||||
visit_schedule=[
|
||||
{
|
||||
"visit_code": "V1",
|
||||
"baseline_offset_days": 7,
|
||||
"window_before_days": 1,
|
||||
"window_after_days": 2,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
changed = _apply_project_publish_snapshot_to_study(study, snapshot)
|
||||
|
||||
assert changed is True
|
||||
assert study.code == "PRJ-002"
|
||||
assert study.name == "发布项目"
|
||||
assert study.project_full_name == "发布项目全称"
|
||||
assert study.sponsor == "申办方"
|
||||
assert study.plan_start_date == date(2026, 6, 1)
|
||||
assert study.plan_end_date == date(2026, 12, 31)
|
||||
assert study.planned_site_count == 8
|
||||
assert study.planned_enrollment_count == 120
|
||||
assert study.status == "ACTIVE"
|
||||
assert study.visit_schedule[0]["visit_code"] == "V1"
|
||||
@@ -0,0 +1,44 @@
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
import uuid
|
||||
|
||||
from app.crud.subject import _validate_actual_medication_count
|
||||
from app.schemas.subject import SubjectRead, SubjectUpdate
|
||||
|
||||
|
||||
def test_subject_update_accepts_actual_medication_count():
|
||||
payload = SubjectUpdate(actual_medication_count=12)
|
||||
|
||||
assert payload.actual_medication_count == 12
|
||||
|
||||
|
||||
def test_subject_read_includes_actual_medication_count():
|
||||
subject = SimpleNamespace(
|
||||
id=uuid.UUID("00000000-0000-0000-0000-000000000001"),
|
||||
study_id=uuid.UUID("00000000-0000-0000-0000-000000000002"),
|
||||
site_id=uuid.UUID("00000000-0000-0000-0000-000000000003"),
|
||||
subject_no="S001",
|
||||
status="ENROLLED",
|
||||
screening_date=None,
|
||||
consent_date=None,
|
||||
enrollment_date=None,
|
||||
baseline_date=None,
|
||||
completion_date=None,
|
||||
actual_medication_count=10,
|
||||
drop_reason=None,
|
||||
created_at=datetime(2026, 5, 9, 0, 0, 0),
|
||||
updated_at=datetime(2026, 5, 9, 0, 0, 0),
|
||||
)
|
||||
|
||||
data = SubjectRead.model_validate(subject)
|
||||
|
||||
assert data.actual_medication_count == 10
|
||||
|
||||
|
||||
def test_actual_medication_count_cannot_be_negative():
|
||||
try:
|
||||
_validate_actual_medication_count(-1)
|
||||
except ValueError as exc:
|
||||
assert "实际用药次数不能小于0" in str(exc)
|
||||
else:
|
||||
raise AssertionError("negative actual medication count should be rejected")
|
||||
@@ -2,7 +2,13 @@ from datetime import date
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.crud.subject import should_generate_visits_after_subject_update
|
||||
from app.crud.visit import build_visit_schedule_dates, sort_visits_for_display
|
||||
from app.crud.visit import (
|
||||
build_early_termination_visit_changes,
|
||||
build_visit_schedule_dates,
|
||||
get_last_planned_visit_window_start_date,
|
||||
sort_visits_for_display,
|
||||
validate_early_termination_date,
|
||||
)
|
||||
from app.schemas.study import StudyUpdate
|
||||
|
||||
|
||||
@@ -129,6 +135,30 @@ def test_sort_visits_for_display_does_not_infer_business_order():
|
||||
assert [visit.visit_code for visit in sort_visits_for_display(visits, visit_schedule)] == ["V1", "V2", "基线访视"]
|
||||
|
||||
|
||||
def test_sort_visits_places_early_termination_after_last_actual_visit():
|
||||
visits = [
|
||||
SimpleNamespace(visit_code="筛选访视", planned_date=date(2026, 5, 3), actual_date=date(2026, 5, 3)),
|
||||
SimpleNamespace(visit_code="基线访视", planned_date=date(2026, 5, 3), actual_date=date(2026, 5, 3)),
|
||||
SimpleNamespace(visit_code="V1", planned_date=date(2026, 5, 10), actual_date=None),
|
||||
SimpleNamespace(visit_code="V2", planned_date=date(2026, 5, 17), actual_date=None),
|
||||
SimpleNamespace(visit_code="提前终止", planned_date=None, actual_date=date(2026, 5, 13)),
|
||||
]
|
||||
visit_schedule = [
|
||||
{"visit_code": "筛选访视"},
|
||||
{"visit_code": "基线访视"},
|
||||
{"visit_code": "V1"},
|
||||
{"visit_code": "V2"},
|
||||
]
|
||||
|
||||
assert [visit.visit_code for visit in sort_visits_for_display(visits, visit_schedule)] == [
|
||||
"筛选访视",
|
||||
"基线访视",
|
||||
"提前终止",
|
||||
"V1",
|
||||
"V2",
|
||||
]
|
||||
|
||||
|
||||
def test_should_generate_visits_when_baseline_date_is_set_or_changed():
|
||||
assert should_generate_visits_after_subject_update(
|
||||
previous_baseline_date=None,
|
||||
@@ -142,3 +172,58 @@ def test_should_generate_visits_when_baseline_date_is_set_or_changed():
|
||||
previous_baseline_date=None,
|
||||
next_baseline_date=None,
|
||||
)
|
||||
|
||||
|
||||
def test_build_early_termination_visit_changes_adds_event_and_cancels_future_planned_visits():
|
||||
visits = [
|
||||
SimpleNamespace(visit_code="筛选访视", planned_date=date(2026, 5, 1), actual_date=date(2026, 5, 1), status="DONE"),
|
||||
SimpleNamespace(visit_code="基线访视", planned_date=date(2026, 5, 1), actual_date=date(2026, 5, 1), status="DONE"),
|
||||
SimpleNamespace(visit_code="V0", planned_date=date(2026, 5, 5), actual_date=None, status="LOST"),
|
||||
SimpleNamespace(
|
||||
visit_code="V1",
|
||||
planned_date=date(2026, 5, 6),
|
||||
window_start=date(2026, 5, 4),
|
||||
window_end=date(2026, 5, 8),
|
||||
actual_date=None,
|
||||
status="PLANNED",
|
||||
),
|
||||
SimpleNamespace(
|
||||
visit_code="V2",
|
||||
planned_date=date(2026, 5, 15),
|
||||
window_start=date(2026, 5, 12),
|
||||
window_end=date(2026, 5, 18),
|
||||
actual_date=None,
|
||||
status="PLANNED",
|
||||
),
|
||||
]
|
||||
|
||||
changes = build_early_termination_visit_changes(
|
||||
visits,
|
||||
termination_date=date(2026, 5, 6),
|
||||
reason="不良事件退出",
|
||||
)
|
||||
|
||||
assert changes.event_visit_code == "提前终止"
|
||||
assert changes.event_actual_date == date(2026, 5, 6)
|
||||
assert changes.event_notes == "不良事件退出"
|
||||
assert [visit.visit_code for visit in changes.visits_to_cancel] == ["V1", "V2"]
|
||||
|
||||
|
||||
def test_validate_early_termination_date_requires_date_before_last_visit_window_start():
|
||||
visits = [
|
||||
SimpleNamespace(visit_code="筛选访视", planned_date=date(2026, 5, 1), window_start=date(2026, 5, 1)),
|
||||
SimpleNamespace(visit_code="基线访视", planned_date=date(2026, 5, 1), window_start=date(2026, 5, 1)),
|
||||
SimpleNamespace(visit_code="V1", planned_date=date(2026, 5, 8), window_start=date(2026, 5, 6)),
|
||||
SimpleNamespace(visit_code="V2", planned_date=date(2026, 5, 15), window_start=date(2026, 5, 12)),
|
||||
SimpleNamespace(visit_code="提前终止", planned_date=None, window_start=None),
|
||||
]
|
||||
|
||||
assert get_last_planned_visit_window_start_date(visits) == date(2026, 5, 12)
|
||||
validate_early_termination_date(date(2026, 5, 11), visits)
|
||||
|
||||
try:
|
||||
validate_early_termination_date(date(2026, 5, 12), visits)
|
||||
except ValueError as exc:
|
||||
assert "提前终止日期必须早于方案最后一个计划访视窗口开始日" in str(exc)
|
||||
else:
|
||||
raise AssertionError("same-day final visit window start should not be accepted as early termination")
|
||||
|
||||
Reference in New Issue
Block a user