diff --git a/backend/app/api/v1/subjects.py b/backend/app/api/v1/subjects.py index b282057f..0645ec2a 100644 --- a/backend/app/api/v1/subjects.py +++ b/backend/app/api/v1/subjects.py @@ -176,9 +176,10 @@ async def update_subject( # 基线/治疗日期是访视计划的唯一推算基准,不能用入组日期替代。 if updated.baseline_date: await subject_crud.sync_visits_from_baseline(db, updated) + updated = await subject_crud.sync_subject_status(db, updated) detail = None - if subject_in.status and subject_in.status != old_status: - detail = f"参与者 {updated.subject_no} 状态 {old_status} -> {subject_in.status}" + if updated.status != old_status: + detail = f"参与者 {updated.subject_no} 状态 {old_status} -> {updated.status}" await audit_crud.log_action( db, study_id=study_id, diff --git a/backend/app/api/v1/visits.py b/backend/app/api/v1/visits.py index a5571643..e6a561fd 100644 --- a/backend/app/api/v1/visits.py +++ b/backend/app/api/v1/visits.py @@ -98,6 +98,7 @@ async def create_visit( base_date=visit_in.planned_date, visit_schedule=study.visit_schedule, ) + subject = await subject_crud.sync_subject_status(db, subject) await audit_crud.log_action( db, study_id=study_id, @@ -147,7 +148,6 @@ async def create_early_termination( db, subject, SubjectUpdate( - status="DROPPED", completion_date=termination_in.termination_date, drop_reason=reason, ), @@ -192,6 +192,7 @@ async def update_visit( if visit_in.actual_date and visit.window_end and visit_in.actual_date > visit.window_end: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="实际访视日期不能晚于访视窗口结束日期") updated = await visit_crud.update_visit(db, visit, visit_in) + await subject_crud.sync_subject_status(db, subject) detail = None if visit_in.status: detail = f"访视 {visit.visit_code} {visit.status} -> {visit_in.status}" @@ -226,6 +227,7 @@ async def delete_visit( if not visit or visit.subject_id != subject_id or visit.study_id != study_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="访视不存在") await visit_crud.delete_visit(db, visit) + await subject_crud.sync_subject_status(db, subject) await audit_crud.log_action( db, study_id=study_id, diff --git a/backend/app/crud/subject.py b/backend/app/crud/subject.py index 67b21935..923ccf00 100644 --- a/backend/app/crud/subject.py +++ b/backend/app/crud/subject.py @@ -16,6 +16,55 @@ from app.models.subject import Subject from app.schemas.subject import SubjectCreate, SubjectUpdate +def _visit_code(visit) -> str: + return str(getattr(visit, "visit_code", "") or "").strip() + + +def _is_cancelled_visit(visit) -> bool: + return str(getattr(visit, "status", "") or "").strip().upper() == "CANCELLED" + + +def _standard_visit_codes(visit_schedule: list[dict] | None) -> set[str]: + return { + str(item.get("visit_code") or "").strip() + for item in visit_schedule or [] + if str(item.get("visit_code") or "").strip() + and str(item.get("visit_code") or "").strip() not in visit_crud.NON_TREATMENT_VISIT_CODES + } + + +def derive_subject_status(subject, visits: Sequence[Visit], visit_schedule: list[dict] | None = None) -> str: + if any(_visit_code(visit) == "提前终止" and getattr(visit, "actual_date", None) for visit in visits): + return "DROPPED" + + standard_codes = _standard_visit_codes(visit_schedule) + completion_visits = [ + visit + for visit in visits + if _visit_code(visit) not in visit_crud.NON_TREATMENT_VISIT_CODES + and not _is_cancelled_visit(visit) + and getattr(visit, "planned_date", None) is not None + ] + if standard_codes: + standard_visits = [visit for visit in completion_visits if _visit_code(visit) in standard_codes] + if standard_visits: + latest_planned_date = max(getattr(visit, "planned_date") for visit in completion_visits) + latest_visits = [ + visit for visit in completion_visits if getattr(visit, "planned_date", None) == latest_planned_date + ] + if latest_visits and all(getattr(visit, "actual_date", None) for visit in latest_visits): + return "COMPLETED" + elif completion_visits: + latest_planned_date = max(getattr(visit, "planned_date") for visit in completion_visits) + latest_visits = [visit for visit in completion_visits if getattr(visit, "planned_date", None) == latest_planned_date] + if latest_visits and all(getattr(visit, "actual_date", None) for visit in latest_visits): + return "COMPLETED" + + if getattr(subject, "enrollment_date", None): + return "ENROLLED" + return "SCREENING" + + def _validate_subject_date_chain( *, screening_date: date | None, @@ -80,6 +129,7 @@ async def create_subject(db: AsyncSession, study_id: uuid.UUID, subject_in: Subj await db.refresh(subject) await sync_visits_from_baseline(db, subject) + await sync_subject_status(db, subject) return subject @@ -121,12 +171,30 @@ async def sync_visits_from_baseline(db: AsyncSession, subject: Subject) -> None: ) +async def sync_subject_status(db: AsyncSession, subject: Subject) -> Subject: + visits_result = await db.execute(select(Visit).where(Visit.subject_id == subject.id)) + visits = list(visits_result.scalars().all()) + study_result = await db.execute(select(Study.visit_schedule).where(Study.id == subject.study_id)) + visit_schedule = study_result.scalar_one_or_none() or [] + next_status = derive_subject_status(subject, visits, visit_schedule) + if subject.status != next_status: + await db.execute( + sa_update(Subject) + .where(Subject.id == subject.id) + .values(status=next_status) + ) + await db.commit() + await db.refresh(subject) + return subject + + async def generate_default_visits(db: AsyncSession, subject: Subject) -> None: await sync_visits_from_baseline(db, subject) async def update_subject(db: AsyncSession, subject: Subject, subject_in: SubjectUpdate) -> Subject: update_data = subject_in.model_dump(exclude_unset=True) + update_data.pop("status", None) next_screening_date = subject.screening_date next_consent_date = update_data.get("consent_date", subject.consent_date) next_enrollment_date = update_data.get("enrollment_date", subject.enrollment_date) @@ -146,7 +214,7 @@ async def update_subject(db: AsyncSession, subject: Subject, subject_in: Subject ) await db.commit() await db.refresh(subject) - return subject + return await sync_subject_status(db, subject) def should_generate_visits_after_subject_update( diff --git a/backend/tests/test_visit_schedule.py b/backend/tests/test_visit_schedule.py index 3511bb45..9666c0c1 100644 --- a/backend/tests/test_visit_schedule.py +++ b/backend/tests/test_visit_schedule.py @@ -1,7 +1,7 @@ from datetime import date from types import SimpleNamespace -from app.crud.subject import should_generate_visits_after_subject_update +from app.crud.subject import derive_subject_status, should_generate_visits_after_subject_update from app.crud.visit import ( build_early_termination_visit_changes, build_visit_schedule_dates, @@ -227,3 +227,51 @@ def test_validate_early_termination_date_requires_date_before_last_visit_window_ assert "提前终止日期必须早于方案最后一个计划访视窗口开始日" in str(exc) else: raise AssertionError("same-day final visit window start should not be accepted as early termination") + + +def test_derive_subject_status_marks_dropped_when_early_termination_has_actual_date(): + subject = SimpleNamespace(screening_date=date(2026, 5, 1), enrollment_date=date(2026, 5, 3)) + visits = [ + SimpleNamespace(visit_code="V1", planned_date=date(2026, 5, 10), actual_date=date(2026, 5, 10), status="DONE", created_at=None), + SimpleNamespace(visit_code="提前终止", planned_date=None, actual_date=date(2026, 5, 12), status="DONE", created_at=None), + ] + + assert derive_subject_status(subject, visits, [{"visit_code": "V1"}]) == "DROPPED" + + +def test_derive_subject_status_marks_completed_when_last_standard_visit_has_actual_date(): + subject = SimpleNamespace(screening_date=date(2026, 5, 1), enrollment_date=date(2026, 5, 3)) + visits = [ + SimpleNamespace(visit_code="V1", planned_date=date(2026, 5, 10), actual_date=date(2026, 5, 10), status="DONE", created_at=None), + SimpleNamespace(visit_code="V2", planned_date=date(2026, 5, 17), actual_date=date(2026, 5, 17), status="DONE", created_at=None), + ] + + assert derive_subject_status(subject, visits, [{"visit_code": "V1"}, {"visit_code": "V2"}]) == "COMPLETED" + + +def test_derive_subject_status_rolls_back_to_enrolled_when_followup_after_standard_end_is_unfinished(): + subject = SimpleNamespace(screening_date=date(2026, 5, 1), enrollment_date=date(2026, 5, 3)) + visits = [ + SimpleNamespace(visit_code="V1", planned_date=date(2026, 5, 10), actual_date=date(2026, 5, 10), status="DONE", created_at=None), + SimpleNamespace(visit_code="V2", planned_date=date(2026, 5, 17), actual_date=date(2026, 5, 17), status="DONE", created_at=None), + SimpleNamespace(visit_code="安全性随访", planned_date=date(2026, 5, 24), actual_date=None, status="PLANNED", created_at=None), + ] + + assert derive_subject_status(subject, visits, [{"visit_code": "V1"}, {"visit_code": "V2"}]) == "ENROLLED" + + +def test_derive_subject_status_uses_completed_standard_visit_when_extra_visit_is_before_standard_end(): + subject = SimpleNamespace(screening_date=date(2026, 5, 1), enrollment_date=date(2026, 5, 3)) + visits = [ + SimpleNamespace(visit_code="V1", planned_date=date(2026, 5, 10), actual_date=date(2026, 5, 10), status="DONE", created_at=None), + SimpleNamespace(visit_code="安全性随访", planned_date=date(2026, 5, 12), actual_date=None, status="PLANNED", created_at=None), + SimpleNamespace(visit_code="V2", planned_date=date(2026, 5, 17), actual_date=date(2026, 5, 17), status="DONE", created_at=None), + ] + + assert derive_subject_status(subject, visits, [{"visit_code": "V1"}, {"visit_code": "V2"}]) == "COMPLETED" + + +def test_derive_subject_status_marks_screening_until_enrollment_date_is_filled(): + subject = SimpleNamespace(screening_date=date(2026, 5, 1), enrollment_date=None) + + assert derive_subject_status(subject, [], []) == "SCREENING" diff --git a/frontend/src/views/subjects/SubjectDetail.vue b/frontend/src/views/subjects/SubjectDetail.vue index 9cc4a4ed..f507caa1 100644 --- a/frontend/src/views/subjects/SubjectDetail.vue +++ b/frontend/src/views/subjects/SubjectDetail.vue @@ -30,13 +30,7 @@ {{ detail.subject_no || TEXT.common.fallback }} {{ siteMap[detail.site_id] || TEXT.common.fallback }} - - - - - - - {{ displayEnum(TEXT.enums.subjectStatus, detail.status) }} + {{ displayEnum(TEXT.enums.subjectStatus, detail.status) }} {{ displayDate(detail.screening_date) }} @@ -538,7 +532,6 @@ const detail = reactive({ const subjectEditing = ref(false); const subjectSaving = ref(false); const subjectForm = reactive({ - status: "SCREENING", enrollment_date: "", baseline_date: "", consent_date: "", @@ -709,7 +702,6 @@ const startSubjectEdit = () => { ElMessage.warning("中心已停用"); return; } - subjectForm.status = detail.status || "SCREENING"; subjectForm.consent_date = detail.consent_date || ""; subjectForm.enrollment_date = detail.enrollment_date || ""; subjectForm.baseline_date = detail.baseline_date || ""; @@ -731,7 +723,6 @@ const saveSubjectEdit = async () => { subjectSaving.value = true; try { const payload = { - status: subjectForm.status || null, consent_date: subjectForm.consent_date || null, enrollment_date: subjectForm.enrollment_date || null, baseline_date: subjectForm.baseline_date || null, diff --git a/frontend/src/views/subjects/SubjectForm.vue b/frontend/src/views/subjects/SubjectForm.vue index 53d2a974..bb8c082f 100644 --- a/frontend/src/views/subjects/SubjectForm.vue +++ b/frontend/src/views/subjects/SubjectForm.vue @@ -22,14 +22,6 @@ - - - - - - - - @@ -152,7 +144,6 @@ const form = reactive({ site_id: "", screening_date: "", consent_date: "", - status: "SCREENING", enrollment_date: "", baseline_date: "", completion_date: "", @@ -178,7 +169,6 @@ const load = async () => { site_id: data.site_id || "", screening_date: data.screening_date || "", consent_date: data.consent_date || "", - status: data.status || "SCREENING", enrollment_date: data.enrollment_date || "", baseline_date: data.baseline_date || "", completion_date: data.completion_date || "", @@ -199,7 +189,6 @@ const submit = async () => { try { if (isEdit.value && subjectId.value) { const payload = { - status: form.status || null, consent_date: form.consent_date || null, enrollment_date: form.enrollment_date || null, baseline_date: form.baseline_date || null,