feat: derive subject status from visits
This commit is contained in:
@@ -176,9 +176,10 @@ async def update_subject(
|
|||||||
# 基线/治疗日期是访视计划的唯一推算基准,不能用入组日期替代。
|
# 基线/治疗日期是访视计划的唯一推算基准,不能用入组日期替代。
|
||||||
if updated.baseline_date:
|
if updated.baseline_date:
|
||||||
await subject_crud.sync_visits_from_baseline(db, updated)
|
await subject_crud.sync_visits_from_baseline(db, updated)
|
||||||
|
updated = await subject_crud.sync_subject_status(db, updated)
|
||||||
detail = None
|
detail = None
|
||||||
if subject_in.status and subject_in.status != old_status:
|
if updated.status != old_status:
|
||||||
detail = f"参与者 {updated.subject_no} 状态 {old_status} -> {subject_in.status}"
|
detail = f"参与者 {updated.subject_no} 状态 {old_status} -> {updated.status}"
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
db,
|
db,
|
||||||
study_id=study_id,
|
study_id=study_id,
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ async def create_visit(
|
|||||||
base_date=visit_in.planned_date,
|
base_date=visit_in.planned_date,
|
||||||
visit_schedule=study.visit_schedule,
|
visit_schedule=study.visit_schedule,
|
||||||
)
|
)
|
||||||
|
subject = await subject_crud.sync_subject_status(db, subject)
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
db,
|
db,
|
||||||
study_id=study_id,
|
study_id=study_id,
|
||||||
@@ -147,7 +148,6 @@ async def create_early_termination(
|
|||||||
db,
|
db,
|
||||||
subject,
|
subject,
|
||||||
SubjectUpdate(
|
SubjectUpdate(
|
||||||
status="DROPPED",
|
|
||||||
completion_date=termination_in.termination_date,
|
completion_date=termination_in.termination_date,
|
||||||
drop_reason=reason,
|
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:
|
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="实际访视日期不能晚于访视窗口结束日期")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="实际访视日期不能晚于访视窗口结束日期")
|
||||||
updated = await visit_crud.update_visit(db, visit, visit_in)
|
updated = await visit_crud.update_visit(db, visit, visit_in)
|
||||||
|
await subject_crud.sync_subject_status(db, subject)
|
||||||
detail = None
|
detail = None
|
||||||
if visit_in.status:
|
if visit_in.status:
|
||||||
detail = f"访视 {visit.visit_code} {visit.status} -> {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:
|
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="访视不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="访视不存在")
|
||||||
await visit_crud.delete_visit(db, visit)
|
await visit_crud.delete_visit(db, visit)
|
||||||
|
await subject_crud.sync_subject_status(db, subject)
|
||||||
await audit_crud.log_action(
|
await audit_crud.log_action(
|
||||||
db,
|
db,
|
||||||
study_id=study_id,
|
study_id=study_id,
|
||||||
|
|||||||
@@ -16,6 +16,55 @@ from app.models.subject import Subject
|
|||||||
from app.schemas.subject import SubjectCreate, SubjectUpdate
|
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(
|
def _validate_subject_date_chain(
|
||||||
*,
|
*,
|
||||||
screening_date: date | None,
|
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 db.refresh(subject)
|
||||||
|
|
||||||
await sync_visits_from_baseline(db, subject)
|
await sync_visits_from_baseline(db, subject)
|
||||||
|
await sync_subject_status(db, subject)
|
||||||
return 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:
|
async def generate_default_visits(db: AsyncSession, subject: Subject) -> None:
|
||||||
await sync_visits_from_baseline(db, subject)
|
await sync_visits_from_baseline(db, subject)
|
||||||
|
|
||||||
|
|
||||||
async def update_subject(db: AsyncSession, subject: Subject, subject_in: SubjectUpdate) -> Subject:
|
async def update_subject(db: AsyncSession, subject: Subject, subject_in: SubjectUpdate) -> Subject:
|
||||||
update_data = subject_in.model_dump(exclude_unset=True)
|
update_data = subject_in.model_dump(exclude_unset=True)
|
||||||
|
update_data.pop("status", None)
|
||||||
next_screening_date = subject.screening_date
|
next_screening_date = subject.screening_date
|
||||||
next_consent_date = update_data.get("consent_date", subject.consent_date)
|
next_consent_date = update_data.get("consent_date", subject.consent_date)
|
||||||
next_enrollment_date = update_data.get("enrollment_date", subject.enrollment_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.commit()
|
||||||
await db.refresh(subject)
|
await db.refresh(subject)
|
||||||
return subject
|
return await sync_subject_status(db, subject)
|
||||||
|
|
||||||
|
|
||||||
def should_generate_visits_after_subject_update(
|
def should_generate_visits_after_subject_update(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
from types import SimpleNamespace
|
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 (
|
from app.crud.visit import (
|
||||||
build_early_termination_visit_changes,
|
build_early_termination_visit_changes,
|
||||||
build_visit_schedule_dates,
|
build_visit_schedule_dates,
|
||||||
@@ -227,3 +227,51 @@ def test_validate_early_termination_date_requires_date_before_last_visit_window_
|
|||||||
assert "提前终止日期必须早于方案最后一个计划访视窗口开始日" in str(exc)
|
assert "提前终止日期必须早于方案最后一个计划访视窗口开始日" in str(exc)
|
||||||
else:
|
else:
|
||||||
raise AssertionError("same-day final visit window start should not be accepted as early termination")
|
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"
|
||||||
|
|||||||
@@ -30,13 +30,7 @@
|
|||||||
<el-descriptions-item :label="TEXT.modules.subjectManagement.screeningNo">{{ detail.subject_no || TEXT.common.fallback }}</el-descriptions-item>
|
<el-descriptions-item :label="TEXT.modules.subjectManagement.screeningNo">{{ detail.subject_no || TEXT.common.fallback }}</el-descriptions-item>
|
||||||
<el-descriptions-item :label="TEXT.common.fields.site">{{ siteMap[detail.site_id] || TEXT.common.fallback }}</el-descriptions-item>
|
<el-descriptions-item :label="TEXT.common.fields.site">{{ siteMap[detail.site_id] || TEXT.common.fallback }}</el-descriptions-item>
|
||||||
<el-descriptions-item :label="TEXT.common.fields.status">
|
<el-descriptions-item :label="TEXT.common.fields.status">
|
||||||
<el-select v-if="subjectEditing" v-model="subjectForm.status" size="small">
|
<span>{{ displayEnum(TEXT.enums.subjectStatus, detail.status) }}</span>
|
||||||
<el-option :label="TEXT.enums.subjectStatus.SCREENING" value="SCREENING" />
|
|
||||||
<el-option :label="TEXT.enums.subjectStatus.ENROLLED" value="ENROLLED" />
|
|
||||||
<el-option :label="TEXT.enums.subjectStatus.COMPLETED" value="COMPLETED" />
|
|
||||||
<el-option :label="TEXT.enums.subjectStatus.DROPPED" value="DROPPED" />
|
|
||||||
</el-select>
|
|
||||||
<span v-else>{{ displayEnum(TEXT.enums.subjectStatus, detail.status) }}</span>
|
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item :label="TEXT.common.fields.screeningDate">{{ displayDate(detail.screening_date) }}</el-descriptions-item>
|
<el-descriptions-item :label="TEXT.common.fields.screeningDate">{{ displayDate(detail.screening_date) }}</el-descriptions-item>
|
||||||
<el-descriptions-item :label="TEXT.common.fields.consentDate">
|
<el-descriptions-item :label="TEXT.common.fields.consentDate">
|
||||||
@@ -538,7 +532,6 @@ const detail = reactive<any>({
|
|||||||
const subjectEditing = ref(false);
|
const subjectEditing = ref(false);
|
||||||
const subjectSaving = ref(false);
|
const subjectSaving = ref(false);
|
||||||
const subjectForm = reactive({
|
const subjectForm = reactive({
|
||||||
status: "SCREENING",
|
|
||||||
enrollment_date: "",
|
enrollment_date: "",
|
||||||
baseline_date: "",
|
baseline_date: "",
|
||||||
consent_date: "",
|
consent_date: "",
|
||||||
@@ -709,7 +702,6 @@ const startSubjectEdit = () => {
|
|||||||
ElMessage.warning("中心已停用");
|
ElMessage.warning("中心已停用");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
subjectForm.status = detail.status || "SCREENING";
|
|
||||||
subjectForm.consent_date = detail.consent_date || "";
|
subjectForm.consent_date = detail.consent_date || "";
|
||||||
subjectForm.enrollment_date = detail.enrollment_date || "";
|
subjectForm.enrollment_date = detail.enrollment_date || "";
|
||||||
subjectForm.baseline_date = detail.baseline_date || "";
|
subjectForm.baseline_date = detail.baseline_date || "";
|
||||||
@@ -731,7 +723,6 @@ const saveSubjectEdit = async () => {
|
|||||||
subjectSaving.value = true;
|
subjectSaving.value = true;
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
status: subjectForm.status || null,
|
|
||||||
consent_date: subjectForm.consent_date || null,
|
consent_date: subjectForm.consent_date || null,
|
||||||
enrollment_date: subjectForm.enrollment_date || null,
|
enrollment_date: subjectForm.enrollment_date || null,
|
||||||
baseline_date: subjectForm.baseline_date || null,
|
baseline_date: subjectForm.baseline_date || null,
|
||||||
|
|||||||
@@ -22,14 +22,6 @@
|
|||||||
<el-option v-for="site in sites" :key="site.id" :label="site.name" :value="site.id" :disabled="!site.is_active" />
|
<el-option v-for="site in sites" :key="site.id" :label="site.name" :value="site.id" :disabled="!site.is_active" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-if="isEdit" :label="TEXT.common.fields.status" class="field-span-2">
|
|
||||||
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select" :disabled="isReadOnly" size="large">
|
|
||||||
<el-option :label="TEXT.enums.subjectStatus.SCREENING" value="SCREENING" />
|
|
||||||
<el-option :label="TEXT.enums.subjectStatus.ENROLLED" value="ENROLLED" />
|
|
||||||
<el-option :label="TEXT.enums.subjectStatus.COMPLETED" value="COMPLETED" />
|
|
||||||
<el-option :label="TEXT.enums.subjectStatus.DROPPED" value="DROPPED" />
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -152,7 +144,6 @@ const form = reactive({
|
|||||||
site_id: "",
|
site_id: "",
|
||||||
screening_date: "",
|
screening_date: "",
|
||||||
consent_date: "",
|
consent_date: "",
|
||||||
status: "SCREENING",
|
|
||||||
enrollment_date: "",
|
enrollment_date: "",
|
||||||
baseline_date: "",
|
baseline_date: "",
|
||||||
completion_date: "",
|
completion_date: "",
|
||||||
@@ -178,7 +169,6 @@ const load = async () => {
|
|||||||
site_id: data.site_id || "",
|
site_id: data.site_id || "",
|
||||||
screening_date: data.screening_date || "",
|
screening_date: data.screening_date || "",
|
||||||
consent_date: data.consent_date || "",
|
consent_date: data.consent_date || "",
|
||||||
status: data.status || "SCREENING",
|
|
||||||
enrollment_date: data.enrollment_date || "",
|
enrollment_date: data.enrollment_date || "",
|
||||||
baseline_date: data.baseline_date || "",
|
baseline_date: data.baseline_date || "",
|
||||||
completion_date: data.completion_date || "",
|
completion_date: data.completion_date || "",
|
||||||
@@ -199,7 +189,6 @@ const submit = async () => {
|
|||||||
try {
|
try {
|
||||||
if (isEdit.value && subjectId.value) {
|
if (isEdit.value && subjectId.value) {
|
||||||
const payload = {
|
const payload = {
|
||||||
status: form.status || null,
|
|
||||||
consent_date: form.consent_date || null,
|
consent_date: form.consent_date || null,
|
||||||
enrollment_date: form.enrollment_date || null,
|
enrollment_date: form.enrollment_date || null,
|
||||||
baseline_date: form.baseline_date || null,
|
baseline_date: form.baseline_date || null,
|
||||||
|
|||||||
Reference in New Issue
Block a user