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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user