feat(subject): 参与者状态依赖筛选日期并支持后续编辑

- 未填写筛选日期的参与者不再默认进入 SCREENING,状态显示为占位符。
- SubjectUpdate 不再开放 status 入参,改由后端依据日期推导,并新增
  screening_date 字段允许 PM/CRA 后续补录筛选日期。
- 参与者管理页与详情页同步:详情页支持编辑筛选日期,列表页与详情页
  在没有筛选日期时显示占位符而非旧 enum 值。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cheng Zhou
2026-05-25 12:36:33 +08:00
parent f40e979c53
commit a2add43fa5
6 changed files with 47 additions and 8 deletions
+5 -3
View File
@@ -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)
+1 -1
View File
@@ -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
@@ -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"),
+6
View File
@@ -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, [], []) == ""