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:
Cheng Zhou
2026-05-09 17:10:34 +08:00
parent 74feca4467
commit 917ab7ccf1
41 changed files with 3463 additions and 701 deletions
+40 -1
View File
@@ -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,
+6
View File
@@ -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(
+6
View File
@@ -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)
+145
View File
@@ -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