diff --git a/backend/app/crud/ae.py b/backend/app/crud/ae.py index ee7d3176..52f76e43 100644 --- a/backend/app/crud/ae.py +++ b/backend/app/crud/ae.py @@ -73,7 +73,7 @@ async def create_ae( is_sae=bool(ae_in.is_sae or ae_in.is_susar), is_susar=bool(ae_in.is_susar), report_due_date=due_date, - status="NEW", + status="FOLLOW_UP", description=ae_in.description, created_by=created_by, ) diff --git a/backend/app/crud/visit.py b/backend/app/crud/visit.py index 43f80713..2bd5b58d 100644 --- a/backend/app/crud/visit.py +++ b/backend/app/crud/visit.py @@ -111,6 +111,20 @@ def get_last_planned_visit_window_start_date(visits: Sequence[Visit]) -> date | return max(window_start_dates) if window_start_dates else None +def derive_visit_status_from_actual_date( + *, + actual_date: date | None, + window_start: date | None, + window_end: date | None, + default_status: str = "PLANNED", +) -> str: + if actual_date is None: + return default_status + if (window_start and actual_date < window_start) or (window_end and actual_date > window_end): + return "OVERDUE" + return "DONE" + + def validate_early_termination_date(termination_date: date, visits: Sequence[Visit]) -> None: last_window_start_date = get_last_planned_visit_window_start_date(visits) if last_window_start_date is not None and termination_date >= last_window_start_date: @@ -157,16 +171,24 @@ async def create_visit( actual_date: date | None = None, status: str = "PLANNED", ) -> Visit: + resolved_window_start = window_start if window_start is not None else (visit_in.window_start if visit_in else None) + resolved_window_end = window_end if window_end is not None else (visit_in.window_end if visit_in else None) + resolved_actual_date = actual_date if actual_date is not None else (visit_in.actual_date if visit_in else None) visit = Visit( study_id=study_id, subject_id=subject.id, visit_code=visit_code, planned_date=planned_date, - actual_date=actual_date, - status=status, - window_start=window_start if window_start is not None else (visit_in.window_start if visit_in else None), - window_end=window_end if window_end is not None else (visit_in.window_end if visit_in else None), - notes=None, + actual_date=resolved_actual_date, + status=derive_visit_status_from_actual_date( + actual_date=resolved_actual_date, + window_start=resolved_window_start, + window_end=resolved_window_end, + default_status=status, + ), + window_start=resolved_window_start, + window_end=resolved_window_end, + notes=visit_in.notes if visit_in else None, ) db.add(visit) await db.commit() @@ -240,11 +262,11 @@ async def get_visit(db: AsyncSession, visit_id: uuid.UUID) -> Visit | None: async def update_visit(db: AsyncSession, visit: Visit, visit_in: VisitUpdate) -> Visit: update_data = visit_in.model_dump(exclude_unset=True) if "actual_date" in update_data and update_data["actual_date"] is not None and "status" not in update_data: - actual_date = update_data["actual_date"] - if (visit.window_start and actual_date < visit.window_start) or (visit.window_end and actual_date > visit.window_end): - update_data["status"] = "OVERDUE" - else: - update_data["status"] = "DONE" + update_data["status"] = derive_visit_status_from_actual_date( + actual_date=update_data["actual_date"], + window_start=visit.window_start, + window_end=visit.window_end, + ) if update_data: await db.execute( sa_update(Visit) diff --git a/backend/app/models/ae.py b/backend/app/models/ae.py index de2904f1..bef679ca 100644 --- a/backend/app/models/ae.py +++ b/backend/app/models/ae.py @@ -31,7 +31,7 @@ class AdverseEvent(Base): is_sae: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) is_susar: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) report_due_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True) - status: Mapped[str] = mapped_column(String(20), nullable=False, default="NEW") + status: Mapped[str] = mapped_column(String(20), nullable=False, default="FOLLOW_UP") description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) diff --git a/backend/app/schemas/visit.py b/backend/app/schemas/visit.py index cd189c93..1bca5215 100644 --- a/backend/app/schemas/visit.py +++ b/backend/app/schemas/visit.py @@ -11,6 +11,8 @@ class VisitCreate(BaseModel): planned_date: Optional[date] = None window_start: Optional[date] = None window_end: Optional[date] = None + actual_date: Optional[date] = None + notes: Optional[str] = None class VisitUpdate(BaseModel): diff --git a/backend/tests/test_ae_status.py b/backend/tests/test_ae_status.py new file mode 100644 index 00000000..5f523551 --- /dev/null +++ b/backend/tests/test_ae_status.py @@ -0,0 +1,11 @@ +from pathlib import Path + + +def test_new_ae_records_default_to_follow_up_status(): + crud_source = Path("app/crud/ae.py").read_text(encoding="utf-8") + model_source = Path("app/models/ae.py").read_text(encoding="utf-8") + + assert 'status="FOLLOW_UP"' in crud_source + assert 'default="FOLLOW_UP"' in model_source + assert 'status="NEW"' not in crud_source + assert 'default="NEW"' not in model_source diff --git a/backend/tests/test_visit_schedule.py b/backend/tests/test_visit_schedule.py index 33341c73..0ff0f5cc 100644 --- a/backend/tests/test_visit_schedule.py +++ b/backend/tests/test_visit_schedule.py @@ -10,6 +10,7 @@ from app.crud.visit import ( validate_early_termination_date, ) from app.schemas.study import StudyUpdate +from app.schemas.visit import VisitCreate def test_build_visit_schedule_dates_uses_per_visit_windows(): @@ -105,6 +106,19 @@ def test_study_update_allows_multiple_visits_with_same_baseline_offset(): assert [item.visit_code for item in payload.visit_schedule or []] == ["筛选访视", "基线访视"] +def test_visit_create_accepts_actual_date_and_notes_for_shared_drawer_create_flow(): + payload = VisitCreate( + subject_id="00000000-0000-0000-0000-000000000001", + visit_code="V4", + planned_date=date(2026, 6, 1), + actual_date=date(2026, 6, 2), + notes="现场已完成", + ) + + assert payload.actual_date == date(2026, 6, 2) + assert payload.notes == "现场已完成" + + def test_sort_visits_for_display_follows_configured_visit_order_without_dates(): visits = [ SimpleNamespace(visit_code="V1", planned_date=None), diff --git a/frontend/src/views/ia/RiskIssueSae.test.ts b/frontend/src/views/ia/RiskIssueSae.test.ts new file mode 100644 index 00000000..65304e18 --- /dev/null +++ b/frontend/src/views/ia/RiskIssueSae.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const readRiskIssueSae = () => readFileSync(resolve(__dirname, "./RiskIssueSae.vue"), "utf8"); + +describe("RiskIssueSae status", () => { + it("only exposes follow-up and closed statuses in the risk issue status column", () => { + const source = readRiskIssueSae(); + + expect(source).toContain("const aeStatusOptions = ["); + expect(source).toContain('{ value: "FOLLOW_UP", label: TEXT.enums.aeStatus.FOLLOW_UP }'); + expect(source).toContain('{ value: "CLOSED", label: TEXT.enums.aeStatus.CLOSED }'); + expect(source).toContain("normalizeAeStatus"); + expect(source).not.toContain("Object.entries(TEXT.enums.aeStatus)"); + }); +}); diff --git a/frontend/src/views/ia/RiskIssueSae.vue b/frontend/src/views/ia/RiskIssueSae.vue index 453a153b..4b36ba61 100644 --- a/frontend/src/views/ia/RiskIssueSae.vue +++ b/frontend/src/views/ia/RiskIssueSae.vue @@ -1,89 +1,93 @@