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:
@@ -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:
|
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):
|
if any(_visit_code(visit) == "提前终止" and getattr(visit, "actual_date", None) for visit in visits):
|
||||||
return "DROPPED"
|
return "DROPPED"
|
||||||
|
|
||||||
@@ -116,7 +119,7 @@ async def create_subject(db: AsyncSession, study_id: uuid.UUID, subject_in: Subj
|
|||||||
study_id=study_id,
|
study_id=study_id,
|
||||||
site_id=subject_in.site_id,
|
site_id=subject_in.site_id,
|
||||||
subject_no=subject_in.subject_no,
|
subject_no=subject_in.subject_no,
|
||||||
status="SCREENING",
|
status="SCREENING" if subject_in.screening_date else "",
|
||||||
screening_date=subject_in.screening_date,
|
screening_date=subject_in.screening_date,
|
||||||
consent_date=subject_in.consent_date,
|
consent_date=subject_in.consent_date,
|
||||||
enrollment_date=subject_in.enrollment_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:
|
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 = update_data.get("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)
|
||||||
next_completion_date = update_data.get("completion_date", subject.completion_date)
|
next_completion_date = update_data.get("completion_date", subject.completion_date)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class SubjectCreate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class SubjectUpdate(BaseModel):
|
class SubjectUpdate(BaseModel):
|
||||||
status: Optional[str] = None
|
screening_date: Optional[date] = None
|
||||||
consent_date: Optional[date] = None
|
consent_date: Optional[date] = None
|
||||||
enrollment_date: Optional[date] = None
|
enrollment_date: Optional[date] = None
|
||||||
baseline_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
|
from types import SimpleNamespace
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
@@ -12,6 +12,16 @@ def test_subject_update_accepts_actual_medication_count():
|
|||||||
assert payload.actual_medication_count == 12
|
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():
|
def test_subject_read_includes_actual_medication_count():
|
||||||
subject = SimpleNamespace(
|
subject = SimpleNamespace(
|
||||||
id=uuid.UUID("00000000-0000-0000-0000-000000000001"),
|
id=uuid.UUID("00000000-0000-0000-0000-000000000001"),
|
||||||
|
|||||||
@@ -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)
|
subject = SimpleNamespace(screening_date=date(2026, 5, 1), enrollment_date=None)
|
||||||
|
|
||||||
assert derive_subject_status(subject, [], []) == "SCREENING"
|
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, [], []) == ""
|
||||||
|
|||||||
@@ -85,7 +85,7 @@
|
|||||||
<template #default="scope">{{ displayDate(scope.row.consent_date) }}</template>
|
<template #default="scope">{{ displayDate(scope.row.consent_date) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="status" :label="TEXT.common.fields.status">
|
<el-table-column prop="status" :label="TEXT.common.fields.status">
|
||||||
<template #default="scope">{{ displayEnum(TEXT.enums.subjectStatus, scope.row.status) }}</template>
|
<template #default="scope">{{ displaySubjectStatus(scope.row) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="enrollment_date" :label="TEXT.common.fields.enrollmentDate">
|
<el-table-column prop="enrollment_date" :label="TEXT.common.fields.enrollmentDate">
|
||||||
<template #default="scope">{{ displayDate(scope.row.enrollment_date) }}</template>
|
<template #default="scope">{{ displayDate(scope.row.enrollment_date) }}</template>
|
||||||
@@ -227,6 +227,10 @@ const getAeBadges = (row: any) => {
|
|||||||
}
|
}
|
||||||
return badges;
|
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 filteredItems = computed(() => {
|
||||||
const keyword = filters.value.keyword.trim().toLowerCase();
|
const keyword = filters.value.keyword.trim().toLowerCase();
|
||||||
return items.value.filter((item) => {
|
return items.value.filter((item) => {
|
||||||
|
|||||||
@@ -30,9 +30,18 @@
|
|||||||
<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">
|
||||||
<span>{{ displayEnum(TEXT.enums.subjectStatus, detail.status) }}</span>
|
<span>{{ displaySubjectStatus(detail) }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item :label="TEXT.common.fields.screeningDate">
|
||||||
|
<el-date-picker
|
||||||
|
v-if="subjectEditing"
|
||||||
|
v-model="subjectForm.screening_date"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<span v-else>{{ displayDate(detail.screening_date) }}</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.consentDate">
|
<el-descriptions-item :label="TEXT.common.fields.consentDate">
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
v-if="subjectEditing"
|
v-if="subjectEditing"
|
||||||
@@ -532,6 +541,7 @@ 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({
|
||||||
|
screening_date: "",
|
||||||
enrollment_date: "",
|
enrollment_date: "",
|
||||||
baseline_date: "",
|
baseline_date: "",
|
||||||
consent_date: "",
|
consent_date: "",
|
||||||
@@ -665,6 +675,11 @@ const getErrorMessage = (error: any, fallback: string) => {
|
|||||||
return fallback;
|
return fallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const displaySubjectStatus = (subject: any) => {
|
||||||
|
if (!subject?.screening_date) return TEXT.common.fallback;
|
||||||
|
return displayEnum(TEXT.enums.subjectStatus, subject.status);
|
||||||
|
};
|
||||||
|
|
||||||
const loadSites = async () => {
|
const loadSites = async () => {
|
||||||
if (!studyId) return;
|
if (!studyId) return;
|
||||||
try {
|
try {
|
||||||
@@ -702,6 +717,7 @@ const startSubjectEdit = () => {
|
|||||||
ElMessage.warning("中心已停用");
|
ElMessage.warning("中心已停用");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
subjectForm.screening_date = detail.screening_date || "";
|
||||||
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 || "";
|
||||||
@@ -723,6 +739,7 @@ const saveSubjectEdit = async () => {
|
|||||||
subjectSaving.value = true;
|
subjectSaving.value = true;
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
|
screening_date: subjectForm.screening_date || 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,
|
||||||
|
|||||||
Reference in New Issue
Block a user