From a2add43fa50c438634f17c951a994abd4939db3b Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Mon, 25 May 2026 12:36:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(subject):=20=E5=8F=82=E4=B8=8E=E8=80=85?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E4=BE=9D=E8=B5=96=E7=AD=9B=E9=80=89=E6=97=A5?= =?UTF-8?q?=E6=9C=9F=E5=B9=B6=E6=94=AF=E6=8C=81=E5=90=8E=E7=BB=AD=E7=BC=96?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 未填写筛选日期的参与者不再默认进入 SCREENING,状态显示为占位符。 - SubjectUpdate 不再开放 status 入参,改由后端依据日期推导,并新增 screening_date 字段允许 PM/CRA 后续补录筛选日期。 - 参与者管理页与详情页同步:详情页支持编辑筛选日期,列表页与详情页 在没有筛选日期时显示占位符而非旧 enum 值。 Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/crud/subject.py | 8 ++++--- backend/app/schemas/subject.py | 2 +- .../test_subject_medication_adherence.py | 12 ++++++++++- backend/tests/test_visit_schedule.py | 6 ++++++ frontend/src/views/ia/SubjectManagement.vue | 6 +++++- frontend/src/views/subjects/SubjectDetail.vue | 21 +++++++++++++++++-- 6 files changed, 47 insertions(+), 8 deletions(-) diff --git a/backend/app/crud/subject.py b/backend/app/crud/subject.py index 923ccf00..a972bf45 100644 --- a/backend/app/crud/subject.py +++ b/backend/app/crud/subject.py @@ -34,6 +34,9 @@ def _standard_visit_codes(visit_schedule: list[dict] | None) -> set[str]: def derive_subject_status(subject, visits: Sequence[Visit], visit_schedule: list[dict] | None = None) -> str: + if not getattr(subject, "screening_date", None): + return "" + if any(_visit_code(visit) == "提前终止" and getattr(visit, "actual_date", None) for visit in visits): return "DROPPED" @@ -116,7 +119,7 @@ async def create_subject(db: AsyncSession, study_id: uuid.UUID, subject_in: Subj study_id=study_id, site_id=subject_in.site_id, subject_no=subject_in.subject_no, - status="SCREENING", + status="SCREENING" if subject_in.screening_date else "", screening_date=subject_in.screening_date, consent_date=subject_in.consent_date, enrollment_date=subject_in.enrollment_date, @@ -194,8 +197,7 @@ async def generate_default_visits(db: AsyncSession, subject: Subject) -> None: 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_screening_date = update_data.get("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) next_completion_date = update_data.get("completion_date", subject.completion_date) diff --git a/backend/app/schemas/subject.py b/backend/app/schemas/subject.py index 0fed17cf..669e24e1 100644 --- a/backend/app/schemas/subject.py +++ b/backend/app/schemas/subject.py @@ -15,7 +15,7 @@ class SubjectCreate(BaseModel): class SubjectUpdate(BaseModel): - status: Optional[str] = None + screening_date: Optional[date] = None consent_date: Optional[date] = None enrollment_date: Optional[date] = None baseline_date: Optional[date] = None diff --git a/backend/tests/test_subject_medication_adherence.py b/backend/tests/test_subject_medication_adherence.py index b134f6e2..bb03083e 100644 --- a/backend/tests/test_subject_medication_adherence.py +++ b/backend/tests/test_subject_medication_adherence.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import date, datetime from types import SimpleNamespace import uuid @@ -12,6 +12,16 @@ def test_subject_update_accepts_actual_medication_count(): assert payload.actual_medication_count == 12 +def test_subject_update_accepts_screening_date(): + payload = SubjectUpdate(screening_date=date(2026, 5, 25)) + + assert payload.screening_date == date(2026, 5, 25) + + +def test_subject_update_does_not_expose_status_input(): + assert "status" not in SubjectUpdate.model_fields + + def test_subject_read_includes_actual_medication_count(): subject = SimpleNamespace( id=uuid.UUID("00000000-0000-0000-0000-000000000001"), diff --git a/backend/tests/test_visit_schedule.py b/backend/tests/test_visit_schedule.py index 9666c0c1..33341c73 100644 --- a/backend/tests/test_visit_schedule.py +++ b/backend/tests/test_visit_schedule.py @@ -275,3 +275,9 @@ 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" + + +def test_derive_subject_status_is_empty_before_screening_date_is_filled(): + subject = SimpleNamespace(screening_date=None, enrollment_date=None) + + assert derive_subject_status(subject, [], []) == "" diff --git a/frontend/src/views/ia/SubjectManagement.vue b/frontend/src/views/ia/SubjectManagement.vue index f097d7f2..4318785d 100644 --- a/frontend/src/views/ia/SubjectManagement.vue +++ b/frontend/src/views/ia/SubjectManagement.vue @@ -85,7 +85,7 @@ - + @@ -227,6 +227,10 @@ const getAeBadges = (row: any) => { } return badges; }; +const displaySubjectStatus = (subject: any) => { + if (!subject?.screening_date) return TEXT.common.fallback; + return displayEnum(TEXT.enums.subjectStatus, subject.status); +}; const filteredItems = computed(() => { const keyword = filters.value.keyword.trim().toLowerCase(); return items.value.filter((item) => { diff --git a/frontend/src/views/subjects/SubjectDetail.vue b/frontend/src/views/subjects/SubjectDetail.vue index f507caa1..fb884f81 100644 --- a/frontend/src/views/subjects/SubjectDetail.vue +++ b/frontend/src/views/subjects/SubjectDetail.vue @@ -30,9 +30,18 @@ {{ detail.subject_no || TEXT.common.fallback }} {{ siteMap[detail.site_id] || TEXT.common.fallback }} - {{ displayEnum(TEXT.enums.subjectStatus, detail.status) }} + {{ displaySubjectStatus(detail) }} + + + + {{ displayDate(detail.screening_date) }} - {{ displayDate(detail.screening_date) }} ({ const subjectEditing = ref(false); const subjectSaving = ref(false); const subjectForm = reactive({ + screening_date: "", enrollment_date: "", baseline_date: "", consent_date: "", @@ -665,6 +675,11 @@ const getErrorMessage = (error: any, fallback: string) => { return fallback; }; +const displaySubjectStatus = (subject: any) => { + if (!subject?.screening_date) return TEXT.common.fallback; + return displayEnum(TEXT.enums.subjectStatus, subject.status); +}; + const loadSites = async () => { if (!studyId) return; try { @@ -702,6 +717,7 @@ const startSubjectEdit = () => { ElMessage.warning("中心已停用"); return; } + subjectForm.screening_date = detail.screening_date || ""; subjectForm.consent_date = detail.consent_date || ""; subjectForm.enrollment_date = detail.enrollment_date || ""; subjectForm.baseline_date = detail.baseline_date || ""; @@ -723,6 +739,7 @@ const saveSubjectEdit = async () => { subjectSaving.value = true; try { const payload = { + screening_date: subjectForm.screening_date || null, consent_date: subjectForm.consent_date || null, enrollment_date: subjectForm.enrollment_date || null, baseline_date: subjectForm.baseline_date || null,