diff --git a/backend/alembic/versions/20260116_01_add_is_locked_to_studies.py b/backend/alembic/versions/20260116_01_add_is_locked_to_studies.py index 13b0e724..275d7bec 100644 --- a/backend/alembic/versions/20260116_01_add_is_locked_to_studies.py +++ b/backend/alembic/versions/20260116_01_add_is_locked_to_studies.py @@ -19,10 +19,18 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - # 添加 is_locked 字段到 studies 表 - op.add_column('studies', sa.Column('is_locked', sa.Boolean(), nullable=False, server_default='false')) + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {col["name"] for col in inspector.get_columns("studies")} + + if "is_locked" not in columns: + op.add_column("studies", sa.Column("is_locked", sa.Boolean(), nullable=False, server_default="false")) def downgrade() -> None: - # 删除 is_locked 字段 - op.drop_column('studies', 'is_locked') + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {col["name"] for col in inspector.get_columns("studies")} + + if "is_locked" in columns: + op.drop_column("studies", "is_locked") diff --git a/backend/alembic/versions/20260116_02_add_enrollment_target_to_sites.py b/backend/alembic/versions/20260116_02_add_enrollment_target_to_sites.py index d8987dad..1a5cf900 100644 --- a/backend/alembic/versions/20260116_02_add_enrollment_target_to_sites.py +++ b/backend/alembic/versions/20260116_02_add_enrollment_target_to_sites.py @@ -19,10 +19,18 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - # 添加 enrollment_target 字段到 sites 表 - op.add_column('sites', sa.Column('enrollment_target', sa.Integer(), nullable=True)) + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {col["name"] for col in inspector.get_columns("sites")} + + if "enrollment_target" not in columns: + op.add_column("sites", sa.Column("enrollment_target", sa.Integer(), nullable=True)) def downgrade() -> None: - # 删除 enrollment_target 字段 - op.drop_column('sites', 'enrollment_target') + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {col["name"] for col in inspector.get_columns("sites")} + + if "enrollment_target" in columns: + op.drop_column("sites", "enrollment_target") diff --git a/backend/alembic/versions/20260116_05_remove_document_workflows.py b/backend/alembic/versions/20260116_05_remove_document_workflows.py index 9c62459f..395beb14 100644 --- a/backend/alembic/versions/20260116_05_remove_document_workflows.py +++ b/backend/alembic/versions/20260116_05_remove_document_workflows.py @@ -20,10 +20,18 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - op.drop_table("workflow_actions") - op.drop_table("version_workflows") - op.drop_table("workflow_nodes") - op.drop_table("workflow_templates") + bind = op.get_bind() + inspector = sa.inspect(bind) + tables = set(inspector.get_table_names()) + + if "workflow_actions" in tables: + op.drop_table("workflow_actions") + if "version_workflows" in tables: + op.drop_table("version_workflows") + if "workflow_nodes" in tables: + op.drop_table("workflow_nodes") + if "workflow_templates" in tables: + op.drop_table("workflow_templates") op.execute("DROP TYPE IF EXISTS workflow_action_type") op.execute("DROP TYPE IF EXISTS workflow_status") diff --git a/backend/alembic/versions/20260206_01_add_ae_sae_susar_flags.py b/backend/alembic/versions/20260206_01_add_ae_sae_susar_flags.py index 3809dfb2..33b486ff 100644 --- a/backend/alembic/versions/20260206_01_add_ae_sae_susar_flags.py +++ b/backend/alembic/versions/20260206_01_add_ae_sae_susar_flags.py @@ -19,18 +19,31 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - op.add_column( - "adverse_events", - sa.Column("is_sae", sa.Boolean(), nullable=False, server_default=sa.false()), - ) - op.add_column( - "adverse_events", - sa.Column("is_susar", sa.Boolean(), nullable=False, server_default=sa.false()), - ) - op.alter_column("adverse_events", "is_sae", server_default=None) - op.alter_column("adverse_events", "is_susar", server_default=None) + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {col["name"] for col in inspector.get_columns("adverse_events")} + + if "is_sae" not in columns: + op.add_column( + "adverse_events", + sa.Column("is_sae", sa.Boolean(), nullable=False, server_default=sa.false()), + ) + if "is_susar" not in columns: + op.add_column( + "adverse_events", + sa.Column("is_susar", sa.Boolean(), nullable=False, server_default=sa.false()), + ) + if "is_sae" in columns or "is_susar" in columns: + op.alter_column("adverse_events", "is_sae", server_default=None) + op.alter_column("adverse_events", "is_susar", server_default=None) def downgrade() -> None: - op.drop_column("adverse_events", "is_susar") - op.drop_column("adverse_events", "is_sae") + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {col["name"] for col in inspector.get_columns("adverse_events")} + + if "is_susar" in columns: + op.drop_column("adverse_events", "is_susar") + if "is_sae" in columns: + op.drop_column("adverse_events", "is_sae") diff --git a/backend/alembic/versions/20260509_01_add_monitoring_issue_template_fields.py b/backend/alembic/versions/20260509_01_add_monitoring_issue_template_fields.py new file mode 100644 index 00000000..50f1f8a1 --- /dev/null +++ b/backend/alembic/versions/20260509_01_add_monitoring_issue_template_fields.py @@ -0,0 +1,59 @@ +"""add template fields to monitoring_visit_issues + +Revision ID: 20260509_01 +Revises: 20260508_03 +Create Date: 2026-05-09 13:45:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "20260509_01" +down_revision: Union[str, None] = "20260508_03" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {col["name"] for col in inspector.get_columns("monitoring_visit_issues")} + + if "severity" not in columns: + op.add_column("monitoring_visit_issues", sa.Column("severity", sa.String(length=64), nullable=True)) + if "mark" not in columns: + op.add_column("monitoring_visit_issues", sa.Column("mark", sa.String(length=100), nullable=True)) + if "visit_cycle" not in columns: + op.add_column("monitoring_visit_issues", sa.Column("visit_cycle", sa.String(length=100), nullable=True)) + if "center_query" not in columns: + op.add_column("monitoring_visit_issues", sa.Column("center_query", sa.Text(), nullable=True)) + if "center_latest_reply" not in columns: + op.add_column("monitoring_visit_issues", sa.Column("center_latest_reply", sa.Text(), nullable=True)) + if "rectification_completed" not in columns: + op.add_column( + "monitoring_visit_issues", + sa.Column("rectification_completed", sa.Boolean(), nullable=False, server_default=sa.text("false")), + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {col["name"] for col in inspector.get_columns("monitoring_visit_issues")} + + if "rectification_completed" in columns: + op.drop_column("monitoring_visit_issues", "rectification_completed") + if "center_latest_reply" in columns: + op.drop_column("monitoring_visit_issues", "center_latest_reply") + if "center_query" in columns: + op.drop_column("monitoring_visit_issues", "center_query") + if "visit_cycle" in columns: + op.drop_column("monitoring_visit_issues", "visit_cycle") + if "mark" in columns: + op.drop_column("monitoring_visit_issues", "mark") + if "severity" in columns: + op.drop_column("monitoring_visit_issues", "severity") diff --git a/backend/alembic/versions/20260509_02_add_site_id_to_monitoring_visit_issues.py b/backend/alembic/versions/20260509_02_add_site_id_to_monitoring_visit_issues.py new file mode 100644 index 00000000..47612c40 --- /dev/null +++ b/backend/alembic/versions/20260509_02_add_site_id_to_monitoring_visit_issues.py @@ -0,0 +1,55 @@ +"""add site_id to monitoring_visit_issues + +Revision ID: 20260509_02 +Revises: 20260509_01 +Create Date: 2026-05-09 14:30:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision: str = "20260509_02" +down_revision: Union[str, None] = "20260509_01" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {col["name"] for col in inspector.get_columns("monitoring_visit_issues")} + fks = {fk["name"] for fk in inspector.get_foreign_keys("monitoring_visit_issues")} + indexes = {idx["name"] for idx in inspector.get_indexes("monitoring_visit_issues")} + + if "site_id" not in columns: + op.add_column("monitoring_visit_issues", sa.Column("site_id", postgresql.UUID(as_uuid=True), nullable=True)) + if "fk_monitoring_visit_issues_site_id" not in fks: + op.create_foreign_key( + "fk_monitoring_visit_issues_site_id", + "monitoring_visit_issues", + "sites", + ["site_id"], + ["id"], + ) + if "ix_monitoring_visit_issues_site_id" not in indexes: + op.create_index("ix_monitoring_visit_issues_site_id", "monitoring_visit_issues", ["site_id"]) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {col["name"] for col in inspector.get_columns("monitoring_visit_issues")} + fks = {fk["name"] for fk in inspector.get_foreign_keys("monitoring_visit_issues")} + indexes = {idx["name"] for idx in inspector.get_indexes("monitoring_visit_issues")} + + if "ix_monitoring_visit_issues_site_id" in indexes: + op.drop_index("ix_monitoring_visit_issues_site_id", table_name="monitoring_visit_issues") + if "fk_monitoring_visit_issues_site_id" in fks: + op.drop_constraint("fk_monitoring_visit_issues_site_id", "monitoring_visit_issues", type_="foreignkey") + if "site_id" in columns: + op.drop_column("monitoring_visit_issues", "site_id") diff --git a/backend/alembic/versions/20260509_03_add_subject_actual_medication_count.py b/backend/alembic/versions/20260509_03_add_subject_actual_medication_count.py new file mode 100644 index 00000000..e4ea1739 --- /dev/null +++ b/backend/alembic/versions/20260509_03_add_subject_actual_medication_count.py @@ -0,0 +1,34 @@ +"""add subject actual medication count + +Revision ID: 20260509_03 +Revises: 20260509_02 +Create Date: 2026-05-09 14:58:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260509_03" +down_revision: Union[str, None] = "20260509_02" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {col["name"] for col in inspector.get_columns("subjects")} + if "actual_medication_count" not in columns: + op.add_column("subjects", sa.Column("actual_medication_count", sa.Integer(), nullable=True)) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {col["name"] for col in inspector.get_columns("subjects")} + if "actual_medication_count" in columns: + op.drop_column("subjects", "actual_medication_count") diff --git a/backend/alembic/versions/20260511_01_backfill_setup_project_info.py b/backend/alembic/versions/20260511_01_backfill_setup_project_info.py new file mode 100644 index 00000000..69137ae1 --- /dev/null +++ b/backend/alembic/versions/20260511_01_backfill_setup_project_info.py @@ -0,0 +1,157 @@ +"""backfill project info in setup drafts + +Revision ID: 20260511_01 +Revises: 20260509_03 +Create Date: 2026-05-11 10:30:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260511_01" +down_revision: Union[str, None] = "20260509_03" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +EMPTY_PROJECT_INFO_JSON = """{ + "code": "", + "name": "", + "project_full_name": "", + "sponsor": "", + "protocol_no": "", + "lead_unit": "", + "principal_investigator": "", + "main_pm": "", + "research_analysis": "", + "research_product": "", + "control_product": "", + "indication": "", + "research_population": "", + "research_design": "", + "plan_start_date": "", + "plan_end_date": "", + "planned_site_count": null, + "planned_enrollment_count": null, + "status": "", + "visit_schedule": [] +}""" + + +def _has_table(inspector: sa.Inspector, table_name: str) -> bool: + return table_name in set(inspector.get_table_names()) + + +def _has_columns(inspector: sa.Inspector, table_name: str, column_names: set[str]) -> bool: + existing = {col["name"] for col in inspector.get_columns(table_name)} + return column_names.issubset(existing) + + +def _backfill_config_column(table_name: str, config_column: str, *, require_existing_setup_content: bool) -> None: + content_filter = "" + if require_existing_setup_content: + content_filter = f""" + AND ( + jsonb_array_length(COALESCE(setup.{config_column}->'projectMilestones', '[]'::jsonb)) > 0 + OR COALESCE(setup.{config_column}->'enrollmentPlan', '{{}}'::jsonb) <> '{{"totalTarget": 0, "startDate": "", "endDate": "", "monthlyGoalNote": "", "stageBreakdown": ""}}'::jsonb + OR jsonb_array_length(COALESCE(setup.{config_column}->'siteMilestones', '[]'::jsonb)) > 0 + OR jsonb_array_length(COALESCE(setup.{config_column}->'siteEnrollmentPlans', '[]'::jsonb)) > 0 + OR jsonb_array_length(COALESCE(setup.{config_column}->'monitoringStrategies', '[]'::jsonb)) > 0 + OR jsonb_array_length(COALESCE(setup.{config_column}->'centerConfirm', '[]'::jsonb)) > 0 + ) + """ + op.execute( + sa.text( + f""" + UPDATE {table_name} AS setup + SET {config_column} = jsonb_set( + setup.{config_column}, + '{{projectInfo}}', + jsonb_build_object( + 'code', COALESCE(studies.code, ''), + 'name', COALESCE(studies.name, ''), + 'project_full_name', COALESCE(studies.project_full_name, ''), + 'sponsor', COALESCE(studies.sponsor, ''), + 'protocol_no', COALESCE(studies.protocol_no, ''), + 'lead_unit', COALESCE(studies.lead_unit, ''), + 'principal_investigator', COALESCE(studies.principal_investigator, ''), + 'main_pm', COALESCE(studies.main_pm, ''), + 'research_analysis', COALESCE(studies.research_analysis, ''), + 'research_product', COALESCE(studies.research_product, ''), + 'control_product', COALESCE(studies.control_product, ''), + 'indication', COALESCE(studies.indication, ''), + 'research_population', COALESCE(studies.research_population, ''), + 'research_design', COALESCE(studies.research_design, ''), + 'plan_start_date', COALESCE(to_char(studies.plan_start_date, 'YYYY-MM-DD'), ''), + 'plan_end_date', COALESCE(to_char(studies.plan_end_date, 'YYYY-MM-DD'), ''), + 'planned_site_count', to_jsonb(studies.planned_site_count), + 'planned_enrollment_count', to_jsonb(studies.planned_enrollment_count), + 'status', COALESCE(studies.status, ''), + 'visit_schedule', COALESCE(studies.visit_schedule::jsonb, '[]'::jsonb) + ), + true + ) + FROM studies + WHERE setup.study_id = studies.id + AND setup.{config_column} IS NOT NULL + AND ( + NOT setup.{config_column} ? 'projectInfo' + OR setup.{config_column}->'projectInfo' = CAST(:empty_project_info AS jsonb) + ) + {content_filter} + """ + ).bindparams(empty_project_info=EMPTY_PROJECT_INFO_JSON) + ) + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + required_study_columns = { + "id", + "code", + "name", + "project_full_name", + "sponsor", + "protocol_no", + "lead_unit", + "principal_investigator", + "main_pm", + "research_analysis", + "research_product", + "control_product", + "indication", + "research_population", + "research_design", + "plan_start_date", + "plan_end_date", + "planned_site_count", + "planned_enrollment_count", + "status", + "visit_schedule", + } + if not _has_table(inspector, "studies") or not _has_columns(inspector, "studies", required_study_columns): + return + + if _has_table(inspector, "study_setup_configs") and _has_columns( + inspector, "study_setup_configs", {"study_id", "config"} + ): + _backfill_config_column("study_setup_configs", "config", require_existing_setup_content=True) + + if _has_table(inspector, "study_setup_configs") and _has_columns( + inspector, "study_setup_configs", {"study_id", "published_config"} + ): + _backfill_config_column("study_setup_configs", "published_config", require_existing_setup_content=False) + + if _has_table(inspector, "study_setup_config_versions") and _has_columns( + inspector, "study_setup_config_versions", {"study_id", "config"} + ): + _backfill_config_column("study_setup_config_versions", "config", require_existing_setup_content=False) + + +def downgrade() -> None: + pass diff --git a/backend/app/api/v1/monitoring_visit_issues.py b/backend/app/api/v1/monitoring_visit_issues.py index 5d3dd929..710fc65e 100644 --- a/backend/app/api/v1/monitoring_visit_issues.py +++ b/backend/app/api/v1/monitoring_visit_issues.py @@ -14,6 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_not_locked, require_study_roles from app.crud import audit as audit_crud from app.crud import monitoring_visit_issue as issue_crud +from app.crud import site as site_crud from app.crud import study as study_crud from app.schemas.monitoring_visit_issue import ( MonitoringVisitIssueCreate, @@ -25,9 +26,13 @@ from app.schemas.monitoring_visit_issue import ( router = APIRouter() _HEADER_ALIASES: dict[str, list[str]] = { + "site_name": ["项目/中心", "中心", "中心名称", "site_name", "site"], "issue_no": ["问题编号", "问题编码", "issue_no", "IssueNo"], "open_duration_text": ["开放时长", "open_duration"], "category": ["问题分类", "category"], + "severity": ["严重程度", "severity"], + "mark": ["标记", "mark"], + "visit_cycle": ["访视周期", "访视", "visit_cycle"], "subject_code": ["受试者", "受试者编号", "subject_code"], "subject_name": ["受试者姓名", "subject_name"], "monitor_item": ["监查项", "monitor_item"], @@ -40,6 +45,9 @@ _HEADER_ALIASES: dict[str, list[str]] = { "description": ["问题描述", "description"], "action_taken": ["采取措施", "action_taken"], "follow_up_progress": ["跟进计划及进展", "follow_up_progress"], + "center_query": ["中心质疑", "center_query"], + "center_latest_reply": ["中心最新回复", "中心回复", "center_latest_reply"], + "rectification_completed": ["是否完成整改", "完成整改", "rectification_completed"], "found_date": ["发现时间", "found_date"], "due_at": ["截止时间", "截止日期", "超期截止时间", "due_at"], "actual_resolve_date": ["实际解决日期", "actual_resolve_date"], @@ -48,17 +56,24 @@ _HEADER_ALIASES: dict[str, list[str]] = { } _AUDIT_FIELDS: tuple[str, ...] = ( + "site_id", "issue_no", "source", "monitor_type", "monitor_item", "category", + "severity", + "mark", + "visit_cycle", "recommendation", "subject_name", "subject_code", "description", "action_taken", "follow_up_progress", + "center_query", + "center_latest_reply", + "rectification_completed", "found_date", "due_at", "actual_resolve_date", @@ -75,6 +90,14 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID): return study +async def _ensure_site_belongs_to_study(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UUID | None) -> None: + if site_id is None: + return + site = await site_crud.get_site(db, site_id) + if not site or site.study_id != study_id: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或不属于当前项目") + + def _normalize_status(value: str | None) -> str: text = (value or "").strip().upper() zh = { @@ -91,6 +114,21 @@ def _normalize_status(value: str | None) -> str: return "OPEN" +def _parse_bool(value: object | None) -> bool | None: + if value is None: + return None + if isinstance(value, bool): + return value + text = str(value).strip().lower() + if not text: + return None + if text in {"1", "true", "yes", "y", "是", "已完成", "完成"}: + return True + if text in {"0", "false", "no", "n", "否", "未完成"}: + return False + return None + + def _pick(row: dict[str, object], key: str) -> str | None: for alias in _HEADER_ALIASES[key]: if alias not in row: @@ -167,9 +205,13 @@ def _to_read(item) -> MonitoringVisitIssueRead: return MonitoringVisitIssueRead( id=item.id, study_id=item.study_id, + site_id=item.site_id, issue_no=item.issue_no, open_duration=_calc_open_duration(item.created_at, item.status, item.closed_at, item.open_duration_text), category=item.category, + severity=item.severity, + mark=item.mark, + visit_cycle=item.visit_cycle, subject_code=item.subject_code, monitor_item=item.monitor_item, monitor_type=item.monitor_type, @@ -183,6 +225,9 @@ def _to_read(item) -> MonitoringVisitIssueRead: description=item.description, action_taken=item.action_taken, follow_up_progress=item.follow_up_progress, + center_query=item.center_query, + center_latest_reply=item.center_latest_reply, + rectification_completed=bool(item.rectification_completed), found_date=item.found_date, overdue=overdue, due_at=item.due_at, @@ -206,6 +251,8 @@ def _format_datetime(value: datetime | None) -> str: def _to_audit_value(value: object | None): + if isinstance(value, uuid.UUID): + return str(value) if isinstance(value, datetime): parsed = value if value.tzinfo else value.replace(tzinfo=timezone.utc) return parsed.isoformat() @@ -289,22 +336,41 @@ def _normalize_file_rows(filename: str, content: bytes) -> list[dict[str, object ) async def list_monitoring_visit_issues( study_id: uuid.UUID, + site_id: uuid.UUID | None = None, category: str | None = None, + severity: str | None = None, + mark: str | None = None, + visit_cycle: str | None = None, status_value: str | None = Query(default=None, alias="status"), overdue: bool | None = None, + rectification_completed: bool | None = None, + due_from: date | None = None, + due_to: date | None = None, + created_from: date | None = None, + created_to: date | None = None, keyword: str | None = None, skip: int = 0, limit: int = 500, db: AsyncSession = Depends(get_db_session), ) -> list[MonitoringVisitIssueRead]: await _ensure_study_exists(db, study_id) + await _ensure_site_belongs_to_study(db, study_id, site_id) normalized_status = _normalize_status(status_value) if status_value else None items = await issue_crud.list_issues( db, study_id, + site_id=site_id, category=(category or None), + severity=(severity or None), + mark=(mark or None), + visit_cycle=(visit_cycle or None), status=normalized_status, overdue=overdue, + rectification_completed=rectification_completed, + due_from=due_from, + due_to=due_to, + created_from=created_from, + created_to=created_to, keyword=(keyword or None), skip=skip, limit=min(limit, 2000), @@ -325,6 +391,7 @@ async def create_monitoring_visit_issue( current_user=Depends(get_current_user), ) -> MonitoringVisitIssueRead: await _ensure_study_exists(db, study_id) + await _ensure_site_belongs_to_study(db, study_id, payload.site_id) if payload.issue_no: existing = await issue_crud.get_issue_by_issue_no(db, study_id, payload.issue_no) @@ -358,20 +425,39 @@ async def create_monitoring_visit_issue( ) async def export_monitoring_visit_issues( study_id: uuid.UUID, + site_id: uuid.UUID | None = None, category: str | None = None, + severity: str | None = None, + mark: str | None = None, + visit_cycle: str | None = None, status_value: str | None = Query(default=None, alias="status"), overdue: bool | None = None, + rectification_completed: bool | None = None, + due_from: date | None = None, + due_to: date | None = None, + created_from: date | None = None, + created_to: date | None = None, keyword: str | None = None, db: AsyncSession = Depends(get_db_session), ) -> StreamingResponse: await _ensure_study_exists(db, study_id) + await _ensure_site_belongs_to_study(db, study_id, site_id) normalized_status = _normalize_status(status_value) if status_value else None items = await issue_crud.list_issues( db, study_id, + site_id=site_id, category=(category or None), + severity=(severity or None), + mark=(mark or None), + visit_cycle=(visit_cycle or None), status=normalized_status, overdue=overdue, + rectification_completed=rectification_completed, + due_from=due_from, + due_to=due_to, + created_from=created_from, + created_to=created_to, keyword=(keyword or None), skip=0, limit=50000, @@ -381,15 +467,23 @@ async def export_monitoring_visit_issues( ws = wb.active ws.title = "监查访视问题" headers = [ + "项目/中心", "问题编号", + "受试者筛选号", + "访视周期", + "问题分类", + "严重程度", + "建议措施", + "问题描述", + "中心质疑", + "中心最新回复", + "是否完成整改", + "标记", + "状态", + "是否超期", "问题来源", "监查类型", "监查项", - "问题分类", - "建议措施", - "受试者", - "受试者缩写号", - "问题描述", "采取措施", "跟进计划及进展", "发现时间", @@ -403,20 +497,31 @@ async def export_monitoring_visit_issues( "创建时间", ] ws.append(headers) + site_ids = {item.site_id for item in items if item.site_id} + site_map = await site_crud.get_sites_by_ids(db, site_ids) for item in items: view = _to_read(item) + site_name = site_map.get(view.site_id).name if view.site_id in site_map else "" ws.append( [ + site_name, view.issue_no or "", + view.subject_code or "", + view.visit_cycle or "", + view.category or "", + view.severity or "", + view.recommendation or "", + view.description or "", + view.center_query or "", + view.center_latest_reply or "", + "是" if view.rectification_completed else "否", + view.mark or "", + view.progress or "", + "是" if view.overdue else "否", view.source or "", view.monitor_type or "", view.monitor_item or "", - view.category or "", - view.recommendation or "", - view.subject_name or "", - view.subject_code or "", - view.description or "", view.action_taken or "", view.follow_up_progress or "", _format_date(view.found_date), @@ -486,6 +591,8 @@ async def update_monitoring_visit_issue( if not payload.model_fields_set: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="至少提供一个更新字段") + if "site_id" in payload.model_fields_set: + await _ensure_site_belongs_to_study(db, study_id, payload.site_id) before_snapshot = _issue_audit_snapshot(item) try: @@ -568,6 +675,8 @@ async def import_monitoring_visit_issues( created_count = 0 updated_count = 0 skipped_rows: list[str] = [] + sites = await site_crud.list_by_study(db, study_id, limit=5000, include_inactive=True) + site_by_name = {site.name.strip().lower(): site for site in sites if site.name} for idx, row in enumerate(rows, start=2): issue_no = _pick(row, "issue_no") @@ -576,6 +685,15 @@ async def import_monitoring_visit_issues( skipped_rows.append(f"第{idx}行:问题编号或问题分类为空") continue + site_id = None + site_name = _pick(row, "site_name") + if site_name: + site = site_by_name.get(site_name.strip().lower()) + if not site: + skipped_rows.append(f"第{idx}行:中心「{site_name}」不存在") + continue + site_id = site.id + status_text = _normalize_status(_pick(row, "status")) creator_name = _pick(row, "creator_name") or getattr(current_user, "full_name", None) created_at = _parse_datetime(row.get("创建时间") or row.get("created_at") or _pick(row, "created_at")) @@ -586,7 +704,11 @@ async def import_monitoring_visit_issues( try: payload = MonitoringVisitIssueCreate( issue_no=issue_no, + site_id=site_id, category=category, + severity=_pick(row, "severity"), + mark=_pick(row, "mark"), + visit_cycle=_pick(row, "visit_cycle"), subject_code=_pick(row, "subject_code"), subject_name=_pick(row, "subject_name"), monitor_item=_pick(row, "monitor_item"), @@ -598,6 +720,9 @@ async def import_monitoring_visit_issues( description=_pick(row, "description"), action_taken=_pick(row, "action_taken"), follow_up_progress=_pick(row, "follow_up_progress"), + center_query=_pick(row, "center_query"), + center_latest_reply=_pick(row, "center_latest_reply"), + rectification_completed=_parse_bool(_pick(row, "rectification_completed")) or False, found_date=_parse_date(row.get("发现时间") or row.get("found_date") or _pick(row, "found_date")), due_at=due_at, actual_resolve_date=_parse_date( diff --git a/backend/app/api/v1/studies.py b/backend/app/api/v1/studies.py index 0875004f..879b732e 100644 --- a/backend/app/api/v1/studies.py +++ b/backend/app/api/v1/studies.py @@ -242,6 +242,7 @@ def _build_default_setup_config_from_study(study, sites: list) -> StudySetupConf if total_target is None: total_target = 0 return StudySetupConfigData( + projectInfo=_build_project_publish_snapshot(study), projectMilestones=[], enrollmentPlan={ "totalTarget": total_target, @@ -288,6 +289,59 @@ def _build_project_publish_snapshot(study) -> ProjectPublishSnapshot: ) +def _resolve_setup_project_snapshot(setup_data: StudySetupConfigData, study) -> ProjectPublishSnapshot: + return setup_data.projectInfo + + +def _parse_optional_snapshot_date(value: str) -> date | None: + text = (value or "").strip() + if not text: + return None + return date.fromisoformat(text) + + +def _normalize_optional_snapshot_text(value: str | None) -> str | None: + if value is None: + return None + text = value.strip() + return text or None + + +def _snapshot_visit_schedule(snapshot: ProjectPublishSnapshot) -> list[dict[str, int | str]]: + return [item.model_dump(mode="json") for item in snapshot.visit_schedule] + + +def _apply_project_publish_snapshot_to_study(study, snapshot: ProjectPublishSnapshot) -> bool: + next_values = { + "code": snapshot.code.strip(), + "name": snapshot.name.strip(), + "project_full_name": _normalize_optional_snapshot_text(snapshot.project_full_name), + "sponsor": _normalize_optional_snapshot_text(snapshot.sponsor), + "protocol_no": _normalize_optional_snapshot_text(snapshot.protocol_no), + "lead_unit": _normalize_optional_snapshot_text(snapshot.lead_unit), + "principal_investigator": _normalize_optional_snapshot_text(snapshot.principal_investigator), + "main_pm": _normalize_optional_snapshot_text(snapshot.main_pm), + "research_analysis": _normalize_optional_snapshot_text(snapshot.research_analysis), + "research_product": _normalize_optional_snapshot_text(snapshot.research_product), + "control_product": _normalize_optional_snapshot_text(snapshot.control_product), + "indication": _normalize_optional_snapshot_text(snapshot.indication), + "research_population": _normalize_optional_snapshot_text(snapshot.research_population), + "research_design": _normalize_optional_snapshot_text(snapshot.research_design), + "plan_start_date": _parse_optional_snapshot_date(snapshot.plan_start_date), + "plan_end_date": _parse_optional_snapshot_date(snapshot.plan_end_date), + "planned_site_count": snapshot.planned_site_count, + "planned_enrollment_count": snapshot.planned_enrollment_count, + "status": snapshot.status or "DRAFT", + "visit_schedule": _snapshot_visit_schedule(snapshot), + } + changed = False + for field, value in next_values.items(): + if getattr(study, field, None) != value: + setattr(study, field, value) + changed = True + return changed + + def _to_setup_config_read( record, *, @@ -360,6 +414,63 @@ def _ensure_study_timeline_valid( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="项目计划结束日期不能早于开始日期") +async def _validate_setup_project_snapshot( + db: AsyncSession, + *, + study_id: uuid.UUID, + snapshot: ProjectPublishSnapshot, + strict_required: bool = True, +) -> None: + errors: list[dict[str, str]] = [] + code = snapshot.code.strip() + name = snapshot.name.strip() + if strict_required and not code: + errors.append({"field": "projectInfo.code", "message": "项目编号不能为空"}) + if strict_required and not name: + errors.append({"field": "projectInfo.name", "message": "项目名称不能为空"}) + try: + plan_start = _parse_optional_snapshot_date(snapshot.plan_start_date) + plan_end = _parse_optional_snapshot_date(snapshot.plan_end_date) + except ValueError: + errors.append({"field": "projectInfo.plan_start_date", "message": "项目计划日期格式应为 YYYY-MM-DD"}) + plan_start = None + plan_end = None + if plan_start and plan_end and plan_start > plan_end: + errors.append({"field": "projectInfo.plan_end_date", "message": "项目计划结束日期不能早于开始日期"}) + + seen_visit_codes: set[str] = set() + for index, item in enumerate(snapshot.visit_schedule): + row_prefix = f"projectInfo.visit_schedule[{index}]" + visit_code = item.visit_code.strip() + has_visit_values = bool(visit_code) or any( + value != 0 + for value in (item.baseline_offset_days, item.window_before_days, item.window_after_days) + ) + if strict_required and not visit_code: + errors.append({"field": f"{row_prefix}.visit_code", "message": "访视编号不能为空"}) + if not has_visit_values: + continue + if visit_code: + if len(visit_code) > 50: + errors.append({"field": f"{row_prefix}.visit_code", "message": "访视编号不能超过50个字符"}) + if visit_code in seen_visit_codes: + errors.append({"field": f"{row_prefix}.visit_code", "message": f"访视编号重复:{visit_code}"}) + seen_visit_codes.add(visit_code) + if item.baseline_offset_days < 0 or item.baseline_offset_days > 3650: + errors.append({"field": f"{row_prefix}.baseline_offset_days", "message": "基线偏移天数应在0到3650之间"}) + if item.window_before_days < 0 or item.window_before_days > 365: + errors.append({"field": f"{row_prefix}.window_before_days", "message": "窗口前天数应在0到365之间"}) + if item.window_after_days < 0 or item.window_after_days > 365: + errors.append({"field": f"{row_prefix}.window_after_days", "message": "窗口后天数应在0到365之间"}) + + if errors: + _raise_validation_error(errors) + if code: + existing = await study_crud.get_by_code(db, code) + if existing and existing.id != study_id: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="项目编号已存在") + + def _resolve_version_label(record: Any) -> str: branch_name = (getattr(record, "branch_name", None) or "main").strip() or "main" display_version = int(getattr(record, "display_version", 0) or 0) @@ -897,18 +1008,23 @@ async def upsert_study_setup_config( sites = await site_crud.list_by_study(db, study_id, skip=0, limit=1000, include_inactive=True) site_lookup = {str(site.id): site.name or "" for site in sites} + setup_project_snapshot = _resolve_setup_project_snapshot(payload.data, study) + await _validate_setup_project_snapshot(db, study_id=study_id, snapshot=setup_project_snapshot, strict_required=False) + setup_plan_start = _parse_optional_snapshot_date(setup_project_snapshot.plan_start_date) + setup_plan_end = _parse_optional_snapshot_date(setup_project_snapshot.plan_end_date) _validate_setup_data( payload.data, site_lookup, - project_plan_start=getattr(study, "plan_start_date", None), - project_plan_end=getattr(study, "plan_end_date", None), + project_plan_start=setup_plan_start, + project_plan_end=setup_plan_end, + strict_required=False, ) existing = await study_setup_config_crud.get_by_study(db, study_id) old_config = dict(existing.config or {}) if existing else {} force_draft = bool(payload.force_draft) if existing and existing.publish_status == "PUBLISHED" and existing.published_project_snapshot: - current_project_snapshot = _build_project_publish_snapshot(study).model_dump(mode="json") + current_project_snapshot = setup_project_snapshot.model_dump(mode="json") if current_project_snapshot != dict(existing.published_project_snapshot or {}): force_draft = True record, conflict = await study_setup_config_crud.upsert( @@ -971,7 +1087,12 @@ async def publish_study_setup_config( record = await study_setup_config_crud.get_by_study(db, study_id) if not record: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="请先保存立项配置草稿") - current_project_snapshot_for_publish = _build_project_publish_snapshot(study).model_dump(mode="json") + setup_data = StudySetupConfigData.model_validate(record.config or {}) + project_snapshot_for_publish = _resolve_setup_project_snapshot(setup_data, study) + await _validate_setup_project_snapshot(db, study_id=study_id, snapshot=project_snapshot_for_publish) + setup_plan_start = _parse_optional_snapshot_date(project_snapshot_for_publish.plan_start_date) + setup_plan_end = _parse_optional_snapshot_date(project_snapshot_for_publish.plan_end_date) + current_project_snapshot_for_publish = project_snapshot_for_publish.model_dump(mode="json") force_create_snapshot = bool( (record.published_project_snapshot or {}) != current_project_snapshot_for_publish ) @@ -979,10 +1100,10 @@ async def publish_study_setup_config( sites = await site_crud.list_by_study(db, study_id, skip=0, limit=1000, include_inactive=True) site_lookup = {str(site.id): site.name or "" for site in sites} _validate_setup_data( - StudySetupConfigData.model_validate(record.config or {}), + setup_data, site_lookup, - project_plan_start=getattr(study, "plan_start_date", None), - project_plan_end=getattr(study, "plan_end_date", None), + project_plan_start=setup_plan_start, + project_plan_end=setup_plan_end, ) published, conflict = await study_setup_config_crud.publish( @@ -1001,6 +1122,7 @@ async def publish_study_setup_config( raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="配置发布失败") try: + _apply_project_publish_snapshot_to_study(study, project_snapshot_for_publish) projection = await apply_setup_projection_on_publish( db, study_id=study_id, @@ -1017,16 +1139,14 @@ async def publish_study_setup_config( for item in projection.skipped_items ], ) - study_after_publish = await study_crud.get(db, study_id) - if study_after_publish: - project_snapshot = _build_project_publish_snapshot(study_after_publish).model_dump(mode="json") - published.published_project_snapshot = project_snapshot - await study_setup_config_crud.set_version_project_snapshot( - db, - study_id, - version=published.version, - project_snapshot=project_snapshot, - ) + project_snapshot = project_snapshot_for_publish.model_dump(mode="json") + published.published_project_snapshot = project_snapshot + await study_setup_config_crud.set_version_project_snapshot( + db, + study_id, + version=published.version, + project_snapshot=project_snapshot, + ) await audit_crud.log_action( db, study_id=study_id, @@ -1362,10 +1482,13 @@ async def merge_study_setup_config_to_main( raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="合并主分支失败") try: + merged_setup_data = StudySetupConfigData.model_validate(merged.published_config or {}) + merged_project_snapshot = _resolve_setup_project_snapshot(merged_setup_data, study) + _apply_project_publish_snapshot_to_study(study, merged_project_snapshot) projection = await apply_setup_projection_on_publish( db, study_id=study_id, - setup_data=StudySetupConfigData.model_validate(merged.published_config or {}), + setup_data=merged_setup_data, operator_id=current_user.id, ) projection_summary = SetupProjectionSummary( @@ -1375,16 +1498,14 @@ async def merge_study_setup_config_to_main( warnings=projection.warnings, skipped_items=[{"site_id": item.site_id, "reason": item.reason} for item in projection.skipped_items], ) - study_after_publish = await study_crud.get(db, study_id) - if study_after_publish: - project_snapshot = _build_project_publish_snapshot(study_after_publish).model_dump(mode="json") - merged.published_project_snapshot = project_snapshot - await study_setup_config_crud.set_version_project_snapshot( - db, - study_id, - version=merged.version, - project_snapshot=project_snapshot, - ) + project_snapshot = merged_project_snapshot.model_dump(mode="json") + merged.published_project_snapshot = project_snapshot + await study_setup_config_crud.set_version_project_snapshot( + db, + study_id, + version=merged.version, + project_snapshot=project_snapshot, + ) await audit_crud.log_action( db, study_id=study_id, diff --git a/backend/app/api/v1/visits.py b/backend/app/api/v1/visits.py index abd695e6..0de029b4 100644 --- a/backend/app/api/v1/visits.py +++ b/backend/app/api/v1/visits.py @@ -9,7 +9,8 @@ from app.crud import site as site_crud from app.crud import subject as subject_crud from app.crud import study as study_crud from app.crud import visit as visit_crud -from app.schemas.visit import VisitCreate, VisitRead, VisitUpdate +from app.schemas.subject import SubjectUpdate +from app.schemas.visit import EarlyTerminationCreate, VisitCreate, VisitRead, VisitUpdate router = APIRouter() @@ -110,6 +111,60 @@ async def create_visit( return visit +@router.post( + "/early-termination", + response_model=VisitRead, + status_code=status.HTTP_201_CREATED, + dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())], +) +async def create_early_termination( + study_id: uuid.UUID, + subject_id: uuid.UUID, + termination_in: EarlyTerminationCreate, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> VisitRead: + subject = await _ensure_subject(db, study_id, subject_id) + await _ensure_subject_active(db, subject) + reason = termination_in.reason.strip() + if not reason: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="提前终止原因不能为空") + if subject.baseline_date and termination_in.termination_date < subject.baseline_date: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="提前终止日期不能早于基线/治疗日期") + + try: + visit = await visit_crud.create_early_termination_visit( + db, + study_id=study_id, + subject=subject, + termination_date=termination_in.termination_date, + reason=reason, + notes=termination_in.notes, + ) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + await subject_crud.update_subject( + db, + subject, + SubjectUpdate( + status="DROPPED", + completion_date=termination_in.termination_date, + drop_reason=reason, + ), + ) + await audit_crud.log_action( + db, + study_id=study_id, + entity_type="visit", + entity_id=visit.id, + action="CREATE_EARLY_TERMINATION", + detail=f"参与者 {subject.subject_no} 已提前终止", + operator_id=current_user.id, + operator_role=current_user.role, + ) + return visit + + @router.patch( "/{visit_id}", response_model=VisitRead, diff --git a/backend/app/crud/monitoring_visit_issue.py b/backend/app/crud/monitoring_visit_issue.py index 6df2bebc..40359302 100644 --- a/backend/app/crud/monitoring_visit_issue.py +++ b/backend/app/crud/monitoring_visit_issue.py @@ -1,5 +1,5 @@ import uuid -from datetime import datetime, timezone +from datetime import date, datetime, time, timedelta, timezone from typing import Sequence from sqlalchemy import and_, or_, select @@ -46,25 +46,55 @@ async def list_issues( db: AsyncSession, study_id: uuid.UUID, *, + site_id: uuid.UUID | None = None, category: str | None = None, + severity: str | None = None, + mark: str | None = None, + visit_cycle: str | None = None, status: str | None = None, overdue: bool | None = None, + rectification_completed: bool | None = None, + due_from: date | None = None, + due_to: date | None = None, + created_from: date | None = None, + created_to: date | None = None, keyword: str | None = None, skip: int = 0, limit: int = 500, ) -> Sequence[MonitoringVisitIssue]: stmt = select(MonitoringVisitIssue).where(MonitoringVisitIssue.study_id == study_id) + if site_id: + stmt = stmt.where(MonitoringVisitIssue.site_id == site_id) if category: stmt = stmt.where(MonitoringVisitIssue.category == category) + if severity: + stmt = stmt.where(MonitoringVisitIssue.severity == severity) + if mark: + stmt = stmt.where(MonitoringVisitIssue.mark.ilike(f"%{mark}%")) + if visit_cycle: + stmt = stmt.where(MonitoringVisitIssue.visit_cycle == visit_cycle) if status: stmt = stmt.where(MonitoringVisitIssue.status == status) + if rectification_completed is not None: + stmt = stmt.where(MonitoringVisitIssue.rectification_completed == rectification_completed) + if due_from: + stmt = stmt.where(MonitoringVisitIssue.due_at >= datetime.combine(due_from, time.min, tzinfo=timezone.utc)) + if due_to: + stmt = stmt.where(MonitoringVisitIssue.due_at < datetime.combine(due_to + timedelta(days=1), time.min, tzinfo=timezone.utc)) + if created_from: + stmt = stmt.where(MonitoringVisitIssue.created_at >= datetime.combine(created_from, time.min, tzinfo=timezone.utc)) + if created_to: + stmt = stmt.where(MonitoringVisitIssue.created_at < datetime.combine(created_to + timedelta(days=1), time.min, tzinfo=timezone.utc)) if keyword: term = f"%{keyword}%" stmt = stmt.where( or_( MonitoringVisitIssue.issue_no.ilike(term), MonitoringVisitIssue.category.ilike(term), + MonitoringVisitIssue.severity.ilike(term), + MonitoringVisitIssue.mark.ilike(term), + MonitoringVisitIssue.visit_cycle.ilike(term), MonitoringVisitIssue.subject_code.ilike(term), MonitoringVisitIssue.subject_name.ilike(term), MonitoringVisitIssue.monitor_item.ilike(term), @@ -72,6 +102,8 @@ async def list_issues( MonitoringVisitIssue.recommendation.ilike(term), MonitoringVisitIssue.action_taken.ilike(term), MonitoringVisitIssue.follow_up_progress.ilike(term), + MonitoringVisitIssue.center_query.ilike(term), + MonitoringVisitIssue.center_latest_reply.ilike(term), MonitoringVisitIssue.responsible_name.ilike(term), ) ) @@ -107,8 +139,12 @@ async def create_issue( closed_at = datetime.now(timezone.utc) item = MonitoringVisitIssue( study_id=study_id, + site_id=issue_in.site_id, issue_no=issue_no, category=issue_in.category, + severity=issue_in.severity, + mark=issue_in.mark, + visit_cycle=issue_in.visit_cycle, subject_code=issue_in.subject_code, monitor_item=issue_in.monitor_item, monitor_type=issue_in.monitor_type, @@ -120,6 +156,9 @@ async def create_issue( description=issue_in.description, action_taken=issue_in.action_taken, follow_up_progress=issue_in.follow_up_progress, + center_query=issue_in.center_query, + center_latest_reply=issue_in.center_latest_reply, + rectification_completed=issue_in.rectification_completed, found_date=issue_in.found_date, due_at=issue_in.due_at, actual_resolve_date=issue_in.actual_resolve_date, diff --git a/backend/app/crud/site.py b/backend/app/crud/site.py index 178ae50e..c1464d2c 100644 --- a/backend/app/crud/site.py +++ b/backend/app/crud/site.py @@ -21,6 +21,7 @@ from app.models.finance_special import FinanceSpecial from app.models.kickoff_meeting import KickoffMeeting from app.models.knowledge_note import KnowledgeNote from app.models.milestone import Milestone +from app.models.monitoring_visit_issue import MonitoringVisitIssue from app.models.site import Site from app.models.special_expense import SpecialExpense from app.models.startup_ethics import StartupEthics @@ -328,6 +329,11 @@ async def delete_site_and_related(db: AsyncSession, site: Site) -> None: await db.execute(delete(ContractFee).where(ContractFee.center_id == site_id)) await db.execute(delete(SpecialExpense).where(SpecialExpense.center_id == site_id)) await db.execute(delete(DrugShipment).where(DrugShipment.center_id == site_id)) + await db.execute( + update(MonitoringVisitIssue) + .where(MonitoringVisitIssue.site_id == site_id) + .values(site_id=None) + ) await db.execute( delete(TrainingAuthorization).where( diff --git a/backend/app/crud/subject.py b/backend/app/crud/subject.py index 0e29168b..67b21935 100644 --- a/backend/app/crud/subject.py +++ b/backend/app/crud/subject.py @@ -37,6 +37,11 @@ def _validate_subject_date_chain( raise ValueError("完成日期不能早于入组日期") +def _validate_actual_medication_count(value: int | None) -> None: + if value is not None and value < 0: + raise ValueError("实际用药次数不能小于0") + + def _should_sync_visits(previous_baseline_date: date | None, next_baseline_date: date | None) -> bool: return next_baseline_date is not None and previous_baseline_date != next_baseline_date @@ -132,6 +137,7 @@ async def update_subject(db: AsyncSession, subject: Subject, subject_in: Subject enrollment_date=next_enrollment_date, completion_date=next_completion_date, ) + _validate_actual_medication_count(update_data.get("actual_medication_count", subject.actual_medication_count)) if update_data: await db.execute( sa_update(Subject) diff --git a/backend/app/crud/visit.py b/backend/app/crud/visit.py index 7d7e418f..43f80713 100644 --- a/backend/app/crud/visit.py +++ b/backend/app/crud/visit.py @@ -1,4 +1,5 @@ import uuid +from dataclasses import dataclass from datetime import date, timedelta from typing import Sequence @@ -11,6 +12,17 @@ from app.models.visit import Visit from app.schemas.visit import VisitCreate, VisitUpdate +@dataclass(frozen=True) +class EarlyTerminationVisitChanges: + event_visit_code: str + event_actual_date: date + event_notes: str + visits_to_cancel: list[Visit] + + +NON_TREATMENT_VISIT_CODES = {"筛选访视", "基线访视", "提前终止"} + + def _visit_schedule_order_map(visit_schedule: list[dict] | None) -> dict[str, int]: order_map: dict[str, int] = {} for index, item in enumerate(visit_schedule or []): @@ -23,6 +35,34 @@ def _visit_schedule_order_map(visit_schedule: list[dict] | None) -> dict[str, in def sort_visits_for_display(visits: Sequence[Visit], visit_schedule: list[dict] | None = None) -> list[Visit]: order_map = _visit_schedule_order_map(visit_schedule) fallback_start = len(order_map) + early_termination = next((visit for visit in visits if (visit.visit_code or "").strip() == "提前终止"), None) + if early_termination and early_termination.actual_date: + last_actual_index = max( + ( + order_map.get((visit.visit_code or "").strip(), fallback_start) + for visit in visits + if visit is not early_termination and visit.actual_date is not None + ), + default=-1, + ) + sorted_visits: list[Visit] = [] + inserted = False + for visit in sorted( + [visit for visit in visits if visit is not early_termination], + key=lambda visit: ( + order_map.get((visit.visit_code or "").strip(), fallback_start), + visit.planned_date is None, + visit.planned_date or date.max, + visit.visit_code or "", + ), + ): + sorted_visits.append(visit) + if not inserted and order_map.get((visit.visit_code or "").strip(), fallback_start) == last_actual_index: + sorted_visits.append(early_termination) + inserted = True + if not inserted: + sorted_visits.insert(0, early_termination) + return sorted_visits return sorted( visits, key=lambda visit: ( @@ -58,6 +98,52 @@ def build_visit_schedule_dates(visit_schedule: list[dict] | None, base_date: dat return rows +def get_visit_window_start_date(visit: Visit) -> date | None: + return visit.window_start or visit.planned_date + + +def get_last_planned_visit_window_start_date(visits: Sequence[Visit]) -> date | None: + window_start_dates = [ + get_visit_window_start_date(visit) + for visit in visits + if get_visit_window_start_date(visit) is not None and (visit.visit_code or "").strip() not in NON_TREATMENT_VISIT_CODES + ] + return max(window_start_dates) if window_start_dates else None + + +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: + raise ValueError(f"提前终止日期必须早于方案最后一个计划访视窗口开始日({last_window_start_date.isoformat()})") + + +def build_early_termination_visit_changes( + visits: Sequence[Visit], + *, + termination_date: date, + reason: str, + notes: str | None = None, +) -> EarlyTerminationVisitChanges: + event_notes = reason.strip() + extra_notes = (notes or "").strip() + if extra_notes: + event_notes = f"{event_notes}\n{extra_notes}" + + visits_to_cancel = [ + visit + for visit in visits + if visit.actual_date is None + and visit.status not in ["DONE", "CANCELLED", "LOST"] + and (visit.visit_code or "").strip() not in NON_TREATMENT_VISIT_CODES + ] + return EarlyTerminationVisitChanges( + event_visit_code="提前终止", + event_actual_date=termination_date, + event_notes=event_notes, + visits_to_cancel=visits_to_cancel, + ) + + async def create_visit( db: AsyncSession, *, @@ -264,3 +350,62 @@ async def create_scheduled_visits( ) ) return created + + +async def create_early_termination_visit( + db: AsyncSession, + *, + study_id: uuid.UUID, + subject: Subject, + termination_date: date, + reason: str, + notes: str | None = None, +) -> Visit: + result = await db.execute(select(Visit).where(Visit.subject_id == subject.id)) + existing_visits = list(result.scalars().all()) + validate_early_termination_date(termination_date, existing_visits) + changes = build_early_termination_visit_changes( + existing_visits, + termination_date=termination_date, + reason=reason, + notes=notes, + ) + existing_event = next( + (visit for visit in existing_visits if visit.visit_code == changes.event_visit_code), + None, + ) + if existing_event: + await db.execute( + sa_update(Visit) + .where(Visit.id == existing_event.id) + .values( + actual_date=changes.event_actual_date, + status="DONE", + notes=changes.event_notes, + ) + ) + event_visit = existing_event + else: + event_visit = Visit( + study_id=study_id, + subject_id=subject.id, + visit_code=changes.event_visit_code, + planned_date=None, + actual_date=changes.event_actual_date, + status="DONE", + window_start=None, + window_end=None, + notes=changes.event_notes, + ) + db.add(event_visit) + + for visit in changes.visits_to_cancel: + await db.execute( + sa_update(Visit) + .where(Visit.id == visit.id) + .values(status="CANCELLED", notes="提前终止后不再适用") + ) + + await db.commit() + await db.refresh(event_visit) + return event_visit diff --git a/backend/app/models/monitoring_visit_issue.py b/backend/app/models/monitoring_visit_issue.py index baf4ea24..0ed7ccc2 100644 --- a/backend/app/models/monitoring_visit_issue.py +++ b/backend/app/models/monitoring_visit_issue.py @@ -1,7 +1,7 @@ import uuid from datetime import date, datetime -from sqlalchemy import Date, DateTime, ForeignKey, String, Text, UniqueConstraint, func +from sqlalchemy import Boolean, Date, DateTime, ForeignKey, String, Text, UniqueConstraint, func from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column @@ -14,8 +14,12 @@ class MonitoringVisitIssue(Base): id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False) + site_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=True) issue_no: Mapped[str] = mapped_column(String(64), nullable=False) category: Mapped[str] = mapped_column(String(255), nullable=False) + severity: Mapped[str | None] = mapped_column(String(64), nullable=True) + mark: Mapped[str | None] = mapped_column(String(100), nullable=True) + visit_cycle: Mapped[str | None] = mapped_column(String(100), nullable=True) subject_code: Mapped[str | None] = mapped_column(String(64), nullable=True) monitor_item: Mapped[str | None] = mapped_column(Text, nullable=True) monitor_type: Mapped[str | None] = mapped_column(String(64), nullable=True) @@ -27,6 +31,9 @@ class MonitoringVisitIssue(Base): description: Mapped[str | None] = mapped_column(Text, nullable=True) action_taken: Mapped[str | None] = mapped_column(Text, nullable=True) follow_up_progress: Mapped[str | None] = mapped_column(Text, nullable=True) + center_query: Mapped[str | None] = mapped_column(Text, nullable=True) + center_latest_reply: Mapped[str | None] = mapped_column(Text, nullable=True) + rectification_completed: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false", default=False) found_date: Mapped[date | None] = mapped_column(Date, nullable=True) due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) actual_resolve_date: Mapped[date | None] = mapped_column(Date, nullable=True) diff --git a/backend/app/models/subject.py b/backend/app/models/subject.py index c14acc76..4884c13c 100644 --- a/backend/app/models/subject.py +++ b/backend/app/models/subject.py @@ -1,7 +1,7 @@ import uuid from datetime import datetime, date -from sqlalchemy import Date, DateTime, ForeignKey, String, Text, UniqueConstraint, func +from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column @@ -22,6 +22,7 @@ class Subject(Base): enrollment_date: Mapped[date | None] = mapped_column(Date, nullable=True) baseline_date: Mapped[date | None] = mapped_column(Date, nullable=True) completion_date: Mapped[date | None] = mapped_column(Date, nullable=True) + actual_medication_count: Mapped[int | None] = mapped_column(Integer, nullable=True) drop_reason: Mapped[str | None] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) updated_at: Mapped[datetime] = mapped_column( diff --git a/backend/app/schemas/monitoring_visit_issue.py b/backend/app/schemas/monitoring_visit_issue.py index 417ebb62..1ccca03a 100644 --- a/backend/app/schemas/monitoring_visit_issue.py +++ b/backend/app/schemas/monitoring_visit_issue.py @@ -6,7 +6,11 @@ from pydantic import BaseModel, ConfigDict, field_validator class MonitoringVisitIssueCreate(BaseModel): issue_no: str | None = None + site_id: uuid.UUID | None = None category: str + severity: str | None = None + mark: str | None = None + visit_cycle: str | None = None subject_code: str | None = None monitor_item: str | None = None monitor_type: str | None = None @@ -18,6 +22,9 @@ class MonitoringVisitIssueCreate(BaseModel): description: str | None = None action_taken: str | None = None follow_up_progress: str | None = None + center_query: str | None = None + center_latest_reply: str | None = None + rectification_completed: bool = False found_date: date | None = None due_at: datetime | None = None actual_resolve_date: date | None = None @@ -42,6 +49,9 @@ class MonitoringVisitIssueCreate(BaseModel): @field_validator( "subject_code", + "severity", + "mark", + "visit_cycle", "monitor_item", "monitor_type", "source", @@ -51,6 +61,8 @@ class MonitoringVisitIssueCreate(BaseModel): "description", "action_taken", "follow_up_progress", + "center_query", + "center_latest_reply", "responsible_name", mode="before", ) @@ -80,9 +92,13 @@ class MonitoringVisitIssueCreate(BaseModel): class MonitoringVisitIssueRead(BaseModel): id: uuid.UUID study_id: uuid.UUID + site_id: uuid.UUID | None issue_no: str open_duration: str category: str + severity: str | None + mark: str | None + visit_cycle: str | None subject_code: str | None monitor_item: str | None monitor_type: str | None @@ -96,6 +112,9 @@ class MonitoringVisitIssueRead(BaseModel): description: str | None action_taken: str | None follow_up_progress: str | None + center_query: str | None + center_latest_reply: str | None + rectification_completed: bool found_date: date | None overdue: bool due_at: datetime | None @@ -115,7 +134,11 @@ class MonitoringVisitIssueImportSummary(BaseModel): class MonitoringVisitIssueUpdate(BaseModel): issue_no: str | None = None + site_id: uuid.UUID | None = None category: str | None = None + severity: str | None = None + mark: str | None = None + visit_cycle: str | None = None subject_code: str | None = None monitor_item: str | None = None monitor_type: str | None = None @@ -127,6 +150,9 @@ class MonitoringVisitIssueUpdate(BaseModel): description: str | None = None action_taken: str | None = None follow_up_progress: str | None = None + center_query: str | None = None + center_latest_reply: str | None = None + rectification_completed: bool | None = None found_date: date | None = None due_at: datetime | None = None actual_resolve_date: date | None = None @@ -145,6 +171,9 @@ class MonitoringVisitIssueUpdate(BaseModel): @field_validator( "subject_code", + "severity", + "mark", + "visit_cycle", "monitor_item", "monitor_type", "source", @@ -154,6 +183,8 @@ class MonitoringVisitIssueUpdate(BaseModel): "description", "action_taken", "follow_up_progress", + "center_query", + "center_latest_reply", "responsible_name", mode="before", ) diff --git a/backend/app/schemas/study_setup_config.py b/backend/app/schemas/study_setup_config.py index 5d456d72..de538093 100644 --- a/backend/app/schemas/study_setup_config.py +++ b/backend/app/schemas/study_setup_config.py @@ -1,14 +1,9 @@ import uuid from datetime import datetime -from typing import Literal from pydantic import BaseModel, ConfigDict, Field, model_validator -ConfirmStatus = Literal["待确认", "已确认", "退回"] -MilestoneStatus = Literal["未开始", "进行中", "已完成", "延期"] - - class ProjectMilestoneItem(BaseModel): id: str name: str = "" @@ -18,7 +13,7 @@ class ProjectMilestoneItem(BaseModel): durationDays: int = 1 owner: str = "" remark: str = "" - status: MilestoneStatus = "未开始" + status: str = "未开始" class EnrollmentPlanItem(BaseModel): @@ -35,7 +30,7 @@ class SiteMilestoneItem(BaseModel): planDate: str = "" owner: str = "" remark: str = "" - status: MilestoneStatus = "未开始" + status: str = "未开始" class SiteEnrollmentPlanItem(BaseModel): @@ -63,12 +58,49 @@ class CenterConfirmItem(BaseModel): siteId: str = "" siteName: str = "" confirmer: str = "" - confirmStatus: ConfirmStatus = "待确认" + confirmStatus: str = "待确认" confirmDate: str = "" note: str = "" +class VisitScheduleItem(BaseModel): + visit_code: str = "" + baseline_offset_days: int = 0 + window_before_days: int = 0 + window_after_days: int = 0 + + +class ProjectPublishSnapshot(BaseModel): + code: str = "" + name: str = "" + project_full_name: str = "" + sponsor: str = "" + protocol_no: str = "" + lead_unit: str = "" + principal_investigator: str = "" + main_pm: str = "" + research_analysis: str = "" + research_product: str = "" + control_product: str = "" + indication: str = "" + research_population: str = "" + research_design: str = "" + plan_start_date: str = "" + plan_end_date: str = "" + planned_site_count: int | None = None + planned_enrollment_count: int | None = None + status: str = "" + visit_schedule: list[VisitScheduleItem] = Field(default_factory=list) + + @model_validator(mode="after") + def normalize_visit_schedule(self): + for item in self.visit_schedule: + item.visit_code = item.visit_code.strip() + return self + + class StudySetupConfigData(BaseModel): + projectInfo: ProjectPublishSnapshot = Field(default_factory=ProjectPublishSnapshot) projectMilestones: list[ProjectMilestoneItem] = Field(default_factory=list) enrollmentPlan: EnrollmentPlanItem = Field(default_factory=EnrollmentPlanItem) siteMilestones: list[SiteMilestoneItem] = Field(default_factory=list) @@ -143,49 +175,6 @@ class SetupProjectionSummary(BaseModel): skipped_items: list[SetupProjectionSkippedItem] = Field(default_factory=list) -class VisitScheduleItem(BaseModel): - visit_code: str = Field(min_length=1, max_length=50) - baseline_offset_days: int = Field(ge=0, le=3650) - window_before_days: int = Field(ge=0, le=365) - window_after_days: int = Field(ge=0, le=365) - - -class ProjectPublishSnapshot(BaseModel): - code: str = "" - name: str = "" - project_full_name: str = "" - sponsor: str = "" - protocol_no: str = "" - lead_unit: str = "" - principal_investigator: str = "" - main_pm: str = "" - research_analysis: str = "" - research_product: str = "" - control_product: str = "" - indication: str = "" - research_population: str = "" - research_design: str = "" - plan_start_date: str = "" - plan_end_date: str = "" - planned_site_count: int | None = None - planned_enrollment_count: int | None = None - status: str = "" - visit_schedule: list[VisitScheduleItem] = Field(default_factory=list) - - @model_validator(mode="after") - def validate_visit_schedule(self): - codes: set[str] = set() - for index, item in enumerate(self.visit_schedule): - code = item.visit_code.strip() - if not code: - raise ValueError(f"第 {index + 1} 行访视编号不能为空") - if code in codes: - raise ValueError(f"访视编号重复:{code}") - codes.add(code) - item.visit_code = code - return self - - class StudySetupConfigRead(BaseModel): id: uuid.UUID study_id: uuid.UUID diff --git a/backend/app/schemas/subject.py b/backend/app/schemas/subject.py index fcaa32d9..0fed17cf 100644 --- a/backend/app/schemas/subject.py +++ b/backend/app/schemas/subject.py @@ -20,6 +20,7 @@ class SubjectUpdate(BaseModel): enrollment_date: Optional[date] = None baseline_date: Optional[date] = None completion_date: Optional[date] = None + actual_medication_count: Optional[int] = None drop_reason: Optional[str] = None @@ -34,6 +35,7 @@ class SubjectRead(BaseModel): enrollment_date: Optional[date] baseline_date: Optional[date] completion_date: Optional[date] + actual_medication_count: Optional[int] drop_reason: Optional[str] created_at: datetime updated_at: datetime diff --git a/backend/app/schemas/visit.py b/backend/app/schemas/visit.py index 43362b84..cd189c93 100644 --- a/backend/app/schemas/visit.py +++ b/backend/app/schemas/visit.py @@ -19,6 +19,12 @@ class VisitUpdate(BaseModel): notes: Optional[str] = None +class EarlyTerminationCreate(BaseModel): + termination_date: date + reason: str + notes: Optional[str] = None + + class VisitRead(BaseModel): id: uuid.UUID study_id: uuid.UUID diff --git a/backend/scripts/check_migration_state.py b/backend/scripts/check_migration_state.py new file mode 100644 index 00000000..9aa431f4 --- /dev/null +++ b/backend/scripts/check_migration_state.py @@ -0,0 +1,76 @@ +import asyncio +from typing import Iterable + +from sqlalchemy import inspect, text +from sqlalchemy.ext.asyncio import create_async_engine + +from app.core.config import settings +from app.db.base import Base + + +CRITICAL_TABLE_COLUMNS: dict[str, set[str]] = { + "studies": { + "is_locked", + "visit_schedule", + "enrollment_monthly_goal_note", + "enrollment_stage_breakdown", + }, + "subjects": { + "baseline_date", + "actual_medication_count", + }, + "monitoring_visit_issues": { + "site_id", + "severity", + "mark", + "visit_cycle", + "center_query", + "center_latest_reply", + "rectification_completed", + }, +} + + +async def check() -> int: + engine = create_async_engine(settings.DATABASE_URL) + try: + async with engine.connect() as conn: + def inspect_schema(sync_conn): + inspector = inspect(sync_conn) + errors: list[str] = [] + + if not inspector.has_table("alembic_version"): + errors.append("missing alembic_version table") + else: + version_rows = sync_conn.execute(text("select version_num from alembic_version")).fetchall() + if not version_rows: + errors.append("alembic_version table is empty") + + for table_name, expected_columns in CRITICAL_TABLE_COLUMNS.items(): + if not inspector.has_table(table_name): + errors.append(f"missing table: {table_name}") + continue + existing = {col["name"] for col in inspector.get_columns(table_name)} + missing = sorted(expected_columns - existing) + if missing: + errors.append(f"{table_name} missing columns: {', '.join(missing)}") + + return errors + + errors = await conn.run_sync(inspect_schema) + if errors: + for item in errors: + print(f"ERROR: {item}") + return 1 + print("migration state OK") + return 0 + finally: + await engine.dispose() + + +def main() -> None: + raise SystemExit(asyncio.run(check())) + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/init_production.py b/backend/scripts/init_production.py index 8b065169..38b96ed8 100644 --- a/backend/scripts/init_production.py +++ b/backend/scripts/init_production.py @@ -3,35 +3,57 @@ import subprocess import sys from pathlib import Path +from sqlalchemy import inspect + PROJECT_ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(PROJECT_ROOT)) from app.core.config import PROTECTED_ADMIN_EMAIL # noqa: E402 from app.crud.user import ensure_admin_exists # noqa: E402 -from app.db.session import SessionLocal # noqa: E402 +from app.db.base import Base # noqa: E402 +from app.db.session import SessionLocal, engine # noqa: E402 -def run_migrations() -> None: +def run_alembic_command(*args: str) -> None: subprocess.run( - [sys.executable, "-m", "alembic", "upgrade", "head"], + [sys.executable, "-m", "alembic", *args], check=True, cwd=PROJECT_ROOT, ) +def _list_user_tables(sync_conn) -> list[str]: + inspector = inspect(sync_conn) + return [table for table in inspector.get_table_names(schema="public") if table != "alembic_version"] + + +async def initialize_schema() -> None: + async with engine.begin() as conn: + user_tables = await conn.run_sync(_list_user_tables) + if user_tables: + run_alembic_command("upgrade", "head") + return + await conn.run_sync(Base.metadata.create_all) + run_alembic_command("stamp", "head") + + async def seed_protected_admin() -> None: async with SessionLocal() as session: await ensure_admin_exists(session) -def main() -> None: - print("Running Alembic migrations...") - run_migrations() +async def async_main() -> None: + print("Initializing database schema...") + await initialize_schema() print(f"Ensuring protected admin exists: {PROTECTED_ADMIN_EMAIL}") - asyncio.run(seed_protected_admin()) + await seed_protected_admin() print("Production initialization complete.") +def main() -> None: + asyncio.run(async_main()) + + if __name__ == "__main__": main() diff --git a/backend/tests/test_migration_safety.py b/backend/tests/test_migration_safety.py new file mode 100644 index 00000000..66a08db2 --- /dev/null +++ b/backend/tests/test_migration_safety.py @@ -0,0 +1,26 @@ +from pathlib import Path + + +def test_is_locked_migration_is_idempotent(): + source = Path("alembic/versions/20260116_01_add_is_locked_to_studies.py").read_text(encoding="utf-8") + + assert 'if "is_locked" not in columns:' in source + assert 'if "is_locked" in columns:' in source + + +def test_removed_workflow_tables_are_dropped_conditionally(): + source = Path("alembic/versions/20260116_05_remove_document_workflows.py").read_text(encoding="utf-8") + + assert 'if "workflow_actions" in tables:' in source + assert 'if "version_workflows" in tables:' in source + assert 'if "workflow_nodes" in tables:' in source + assert 'if "workflow_templates" in tables:' in source + + +def test_migration_state_check_script_exists(): + source = Path("scripts/check_migration_state.py").read_text(encoding="utf-8") + + assert "missing alembic_version table" in source + assert "studies" in source + assert "subjects" in source + assert "monitoring_visit_issues" in source diff --git a/backend/tests/test_monitoring_visit_issue_template_fields.py b/backend/tests/test_monitoring_visit_issue_template_fields.py new file mode 100644 index 00000000..211b6011 --- /dev/null +++ b/backend/tests/test_monitoring_visit_issue_template_fields.py @@ -0,0 +1,72 @@ +import uuid +from datetime import datetime, timezone + +import pytest +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from app.crud import monitoring_visit_issue as issue_crud +from app.models.monitoring_visit_issue import MonitoringVisitIssue +from app.schemas.monitoring_visit_issue import MonitoringVisitIssueCreate + + +@pytest.mark.asyncio +async def test_monitoring_visit_issue_template_fields_can_be_filtered(tmp_path): + db_path = tmp_path / "monitoring-issues.db" + engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", future=True) + SessionLocal = async_sessionmaker(engine, expire_on_commit=False) + + async with engine.begin() as conn: + await conn.run_sync(MonitoringVisitIssue.__table__.create) + + study_id = uuid.UUID("11111111-1111-1111-1111-111111111111") + site_id = uuid.UUID("22222222-2222-2222-2222-222222222222") + async with SessionLocal() as session: + created = await issue_crud.create_issue( + session, + study_id, + MonitoringVisitIssueCreate( + issue_no="MV-001", + site_id=site_id, + category="原始记录", + subject_code="SUBJ-001", + status="OPEN", + severity="严重", + mark="SDV", + visit_cycle="V1", + center_query="请补充签名日期", + center_latest_reply="待中心回复", + rectification_completed=False, + due_at=datetime(2026, 5, 1, tzinfo=timezone.utc), + ), + created_by=None, + ) + + assert created.site_id == site_id + assert created.severity == "严重" + assert created.mark == "SDV" + assert created.visit_cycle == "V1" + assert created.center_query == "请补充签名日期" + assert created.center_latest_reply == "待中心回复" + assert created.rectification_completed is False + + matched = await issue_crud.list_issues( + session, + study_id, + site_id=site_id, + severity="严重", + mark="SDV", + visit_cycle="V1", + rectification_completed=False, + due_from=datetime(2026, 5, 1, tzinfo=timezone.utc).date(), + due_to=datetime(2026, 5, 1, tzinfo=timezone.utc).date(), + ) + unmatched = await issue_crud.list_issues( + session, + study_id, + site_id=uuid.UUID("33333333-3333-3333-3333-333333333333"), + ) + + await engine.dispose() + + assert [item.issue_no for item in matched] == ["MV-001"] + assert unmatched == [] diff --git a/backend/tests/test_setup_config_project_draft.py b/backend/tests/test_setup_config_project_draft.py new file mode 100644 index 00000000..d226e34f --- /dev/null +++ b/backend/tests/test_setup_config_project_draft.py @@ -0,0 +1,307 @@ +from datetime import date +from types import SimpleNamespace + +from fastapi import HTTPException + +from app.api.v1.studies import ( + _apply_project_publish_snapshot_to_study, + _build_default_setup_config_from_study, + _resolve_setup_project_snapshot, + _validate_setup_data, +) +from app.schemas.study_setup_config import ProjectPublishSnapshot, StudySetupConfigData + + +def test_setup_config_data_keeps_project_info_draft(): + draft = StudySetupConfigData.model_validate( + { + "projectInfo": { + "code": "PRJ-002", + "name": "草稿项目", + "plan_start_date": "2026-06-01", + "plan_end_date": "2026-12-31", + "planned_enrollment_count": 120, + "status": "ACTIVE", + "visit_schedule": [ + { + "visit_code": "V1", + "baseline_offset_days": 7, + "window_before_days": 1, + "window_after_days": 2, + } + ], + } + } + ) + + assert draft.projectInfo.code == "PRJ-002" + assert draft.projectInfo.name == "草稿项目" + assert draft.projectInfo.planned_enrollment_count == 120 + assert draft.projectInfo.visit_schedule[0].visit_code == "V1" + + +def test_draft_schema_accepts_incomplete_project_snapshot_rows(): + draft = StudySetupConfigData.model_validate( + { + "projectInfo": { + "code": "PRJ-002", + "name": "草稿项目", + "visit_schedule": [ + { + "visit_code": "", + "baseline_offset_days": 0, + "window_before_days": 0, + "window_after_days": 0, + } + ], + }, + "projectMilestones": [ + { + "id": "row-1", + "name": "", + "planDate": "", + "startDate": "", + "endDate": "", + "durationDays": 1, + "owner": "", + "remark": "", + "status": "", + } + ], + "centerConfirm": [ + { + "id": "row-1", + "siteId": "", + "siteName": "", + "confirmer": "", + "confirmStatus": "", + "confirmDate": "", + "note": "", + } + ], + } + ) + + assert draft.projectInfo.visit_schedule[0].visit_code == "" + assert draft.projectMilestones[0].status == "" + assert draft.centerConfirm[0].confirmStatus == "" + + +def test_draft_save_allows_incomplete_setup_steps(): + draft = StudySetupConfigData.model_validate( + { + "projectInfo": { + "code": "PRJ-002", + "name": "草稿项目", + "plan_start_date": "2026-05-01", + "plan_end_date": "2027-05-31", + }, + "enrollmentPlan": { + "totalTarget": 180, + "startDate": "", + "endDate": "", + "monthlyGoalNote": "", + "stageBreakdown": "", + }, + } + ) + + _validate_setup_data( + draft, + {}, + project_plan_start=date(2026, 5, 1), + project_plan_end=date(2027, 5, 31), + strict_required=False, + ) + + +def test_publish_validation_still_requires_complete_setup_steps(): + draft = StudySetupConfigData.model_validate( + { + "projectInfo": { + "code": "PRJ-002", + "name": "草稿项目", + "plan_start_date": "2026-05-01", + "plan_end_date": "2027-05-31", + }, + "enrollmentPlan": { + "totalTarget": 180, + "startDate": "", + "endDate": "", + "monthlyGoalNote": "", + "stageBreakdown": "", + }, + } + ) + + try: + _validate_setup_data( + draft, + {}, + project_plan_start=date(2026, 5, 1), + project_plan_end=date(2027, 5, 31), + ) + except HTTPException as exc: + assert exc.status_code == 422 + else: + raise AssertionError("publish validation should reject incomplete setup steps") + + +def test_project_info_is_part_of_setup_config_payload(): + draft = StudySetupConfigData.model_validate( + { + "projectInfo": { + "code": "PRJ-VERSION", + "name": "版本内项目信息", + "project_full_name": "版本内项目全称", + "plan_start_date": "2026-06-01", + "plan_end_date": "2026-12-31", + "planned_site_count": 8, + "planned_enrollment_count": 120, + "status": "ACTIVE", + }, + "enrollmentPlan": { + "totalTarget": 120, + "startDate": "2026-06-01", + "endDate": "2026-12-31", + "monthlyGoalNote": "", + "stageBreakdown": "", + }, + } + ) + + payload = draft.model_dump(mode="json") + + assert payload["projectInfo"]["code"] == "PRJ-VERSION" + assert payload["projectInfo"]["planned_enrollment_count"] == 120 + assert payload["enrollmentPlan"]["totalTarget"] == 120 + + +def test_default_setup_config_includes_project_info_snapshot(): + study = SimpleNamespace( + code="PRJ-DEFAULT", + name="默认项目", + project_full_name="默认项目全称", + sponsor="申办方", + protocol_no="PROTO-DEFAULT", + lead_unit="组长单位", + principal_investigator="PI", + main_pm="PM", + research_analysis="III期", + research_product="研究药物", + control_product="对照药物", + indication="适应症", + research_population="研究人群", + research_design="随机双盲", + plan_start_date=date(2026, 6, 1), + plan_end_date=date(2026, 12, 31), + planned_site_count=8, + planned_enrollment_count=120, + enrollment_monthly_goal_note="", + enrollment_stage_breakdown="", + status="ACTIVE", + visit_schedule=[ + { + "visit_code": "V1", + "baseline_offset_days": 7, + "window_before_days": 1, + "window_after_days": 2, + } + ], + ) + + draft = _build_default_setup_config_from_study(study, []) + + assert draft.projectInfo.code == "PRJ-DEFAULT" + assert draft.projectInfo.name == "默认项目" + assert draft.projectInfo.planned_enrollment_count == 120 + assert draft.projectInfo.visit_schedule[0].visit_code == "V1" + + +def test_empty_project_info_does_not_fallback_to_study_snapshot(): + study = SimpleNamespace( + code="PRJ-MASTER", + name="主表项目", + project_full_name="", + sponsor="", + protocol_no="", + lead_unit="", + principal_investigator="", + main_pm="", + research_analysis="", + research_product="", + control_product="", + indication="", + research_population="", + research_design="", + plan_start_date=None, + plan_end_date=None, + planned_site_count=None, + planned_enrollment_count=None, + status="DRAFT", + visit_schedule=[], + ) + draft = StudySetupConfigData() + + snapshot = _resolve_setup_project_snapshot(draft, study) + + assert snapshot.code == "" + assert snapshot.name == "" + + +def test_apply_project_publish_snapshot_updates_formal_study_fields(): + study = SimpleNamespace( + code="PRJ-001", + name="原项目", + project_full_name=None, + sponsor=None, + protocol_no=None, + lead_unit=None, + principal_investigator=None, + main_pm=None, + research_analysis=None, + research_product=None, + control_product=None, + indication=None, + research_population=None, + research_design=None, + plan_start_date=None, + plan_end_date=None, + planned_site_count=None, + planned_enrollment_count=10, + status="DRAFT", + visit_schedule=[], + ) + snapshot = ProjectPublishSnapshot( + code="PRJ-002", + name="发布项目", + project_full_name="发布项目全称", + sponsor="申办方", + plan_start_date="2026-06-01", + plan_end_date="2026-12-31", + planned_site_count=8, + planned_enrollment_count=120, + status="ACTIVE", + visit_schedule=[ + { + "visit_code": "V1", + "baseline_offset_days": 7, + "window_before_days": 1, + "window_after_days": 2, + } + ], + ) + + changed = _apply_project_publish_snapshot_to_study(study, snapshot) + + assert changed is True + assert study.code == "PRJ-002" + assert study.name == "发布项目" + assert study.project_full_name == "发布项目全称" + assert study.sponsor == "申办方" + assert study.plan_start_date == date(2026, 6, 1) + assert study.plan_end_date == date(2026, 12, 31) + assert study.planned_site_count == 8 + assert study.planned_enrollment_count == 120 + assert study.status == "ACTIVE" + assert study.visit_schedule[0]["visit_code"] == "V1" diff --git a/backend/tests/test_subject_medication_adherence.py b/backend/tests/test_subject_medication_adherence.py new file mode 100644 index 00000000..b134f6e2 --- /dev/null +++ b/backend/tests/test_subject_medication_adherence.py @@ -0,0 +1,44 @@ +from datetime import datetime +from types import SimpleNamespace +import uuid + +from app.crud.subject import _validate_actual_medication_count +from app.schemas.subject import SubjectRead, SubjectUpdate + + +def test_subject_update_accepts_actual_medication_count(): + payload = SubjectUpdate(actual_medication_count=12) + + assert payload.actual_medication_count == 12 + + +def test_subject_read_includes_actual_medication_count(): + subject = SimpleNamespace( + id=uuid.UUID("00000000-0000-0000-0000-000000000001"), + study_id=uuid.UUID("00000000-0000-0000-0000-000000000002"), + site_id=uuid.UUID("00000000-0000-0000-0000-000000000003"), + subject_no="S001", + status="ENROLLED", + screening_date=None, + consent_date=None, + enrollment_date=None, + baseline_date=None, + completion_date=None, + actual_medication_count=10, + drop_reason=None, + created_at=datetime(2026, 5, 9, 0, 0, 0), + updated_at=datetime(2026, 5, 9, 0, 0, 0), + ) + + data = SubjectRead.model_validate(subject) + + assert data.actual_medication_count == 10 + + +def test_actual_medication_count_cannot_be_negative(): + try: + _validate_actual_medication_count(-1) + except ValueError as exc: + assert "实际用药次数不能小于0" in str(exc) + else: + raise AssertionError("negative actual medication count should be rejected") diff --git a/backend/tests/test_visit_schedule.py b/backend/tests/test_visit_schedule.py index c16c0f14..3511bb45 100644 --- a/backend/tests/test_visit_schedule.py +++ b/backend/tests/test_visit_schedule.py @@ -2,7 +2,13 @@ from datetime import date from types import SimpleNamespace from app.crud.subject import should_generate_visits_after_subject_update -from app.crud.visit import build_visit_schedule_dates, sort_visits_for_display +from app.crud.visit import ( + build_early_termination_visit_changes, + build_visit_schedule_dates, + get_last_planned_visit_window_start_date, + sort_visits_for_display, + validate_early_termination_date, +) from app.schemas.study import StudyUpdate @@ -129,6 +135,30 @@ def test_sort_visits_for_display_does_not_infer_business_order(): assert [visit.visit_code for visit in sort_visits_for_display(visits, visit_schedule)] == ["V1", "V2", "基线访视"] +def test_sort_visits_places_early_termination_after_last_actual_visit(): + visits = [ + SimpleNamespace(visit_code="筛选访视", planned_date=date(2026, 5, 3), actual_date=date(2026, 5, 3)), + SimpleNamespace(visit_code="基线访视", planned_date=date(2026, 5, 3), actual_date=date(2026, 5, 3)), + SimpleNamespace(visit_code="V1", planned_date=date(2026, 5, 10), actual_date=None), + SimpleNamespace(visit_code="V2", planned_date=date(2026, 5, 17), actual_date=None), + SimpleNamespace(visit_code="提前终止", planned_date=None, actual_date=date(2026, 5, 13)), + ] + visit_schedule = [ + {"visit_code": "筛选访视"}, + {"visit_code": "基线访视"}, + {"visit_code": "V1"}, + {"visit_code": "V2"}, + ] + + assert [visit.visit_code for visit in sort_visits_for_display(visits, visit_schedule)] == [ + "筛选访视", + "基线访视", + "提前终止", + "V1", + "V2", + ] + + def test_should_generate_visits_when_baseline_date_is_set_or_changed(): assert should_generate_visits_after_subject_update( previous_baseline_date=None, @@ -142,3 +172,58 @@ def test_should_generate_visits_when_baseline_date_is_set_or_changed(): previous_baseline_date=None, next_baseline_date=None, ) + + +def test_build_early_termination_visit_changes_adds_event_and_cancels_future_planned_visits(): + visits = [ + SimpleNamespace(visit_code="筛选访视", planned_date=date(2026, 5, 1), actual_date=date(2026, 5, 1), status="DONE"), + SimpleNamespace(visit_code="基线访视", planned_date=date(2026, 5, 1), actual_date=date(2026, 5, 1), status="DONE"), + SimpleNamespace(visit_code="V0", planned_date=date(2026, 5, 5), actual_date=None, status="LOST"), + SimpleNamespace( + visit_code="V1", + planned_date=date(2026, 5, 6), + window_start=date(2026, 5, 4), + window_end=date(2026, 5, 8), + actual_date=None, + status="PLANNED", + ), + SimpleNamespace( + visit_code="V2", + planned_date=date(2026, 5, 15), + window_start=date(2026, 5, 12), + window_end=date(2026, 5, 18), + actual_date=None, + status="PLANNED", + ), + ] + + changes = build_early_termination_visit_changes( + visits, + termination_date=date(2026, 5, 6), + reason="不良事件退出", + ) + + assert changes.event_visit_code == "提前终止" + assert changes.event_actual_date == date(2026, 5, 6) + assert changes.event_notes == "不良事件退出" + assert [visit.visit_code for visit in changes.visits_to_cancel] == ["V1", "V2"] + + +def test_validate_early_termination_date_requires_date_before_last_visit_window_start(): + visits = [ + SimpleNamespace(visit_code="筛选访视", planned_date=date(2026, 5, 1), window_start=date(2026, 5, 1)), + SimpleNamespace(visit_code="基线访视", planned_date=date(2026, 5, 1), window_start=date(2026, 5, 1)), + SimpleNamespace(visit_code="V1", planned_date=date(2026, 5, 8), window_start=date(2026, 5, 6)), + SimpleNamespace(visit_code="V2", planned_date=date(2026, 5, 15), window_start=date(2026, 5, 12)), + SimpleNamespace(visit_code="提前终止", planned_date=None, window_start=None), + ] + + assert get_last_planned_visit_window_start_date(visits) == date(2026, 5, 12) + validate_early_termination_date(date(2026, 5, 11), visits) + + try: + validate_early_termination_date(date(2026, 5, 12), visits) + except ValueError as exc: + assert "提前终止日期必须早于方案最后一个计划访视窗口开始日" in str(exc) + else: + raise AssertionError("same-day final visit window start should not be accepted as early termination") diff --git a/docs/guides/branch-environment-installation.md b/docs/guides/branch-environment-installation.md index ef5326d9..7ebb6f6b 100644 --- a/docs/guides/branch-environment-installation.md +++ b/docs/guides/branch-environment-installation.md @@ -1,48 +1,95 @@ -# CTMS 分支环境安装配置指南 +# CTMS 安装步骤向导 -本文定义 `dev`、`main`、`release` 三类分支对应的推荐部署环境、配置文件、初始化步骤与验证命令。分支本身不会自动切换运行模式,实际模式由后端环境变量 `ENV` 决定。 +按目标分支选择对应章节执行: -## 分支与环境映射 +- [dev 分支安装](#dev-分支安装) +- [main 分支安装](#main-分支安装) +- [release 分支安装](#release-分支安装) -| 分支 | 分支定位 | 推荐环境 | 后端 `ENV` | 用途 | -| --- | --- | --- | --- | --- | -| `dev` | 日常开发与集成 | 开发环境 | `development` | 功能开发、联调、自测、内部集成 | -| `main` | 下一版本候选 | 预发布 / 验收环境 | `production` | 回归测试、验收、发布候选验证 | -| `release` | 当前稳定生产线 | 生产环境 | `production` | 正式生产部署、生产 hotfix | +## 安装前准备 -推广路径保持为: +三个分支都先完成本节。 -```text -feature/* -> dev -> main -> release +### 1. 准备安装目录 + +```bash +mkdir -p /opt/ctms +cd /opt/ctms ``` -不要把 `release` 当作日常开发分支使用。`main` 应只接收已经准备进入候选版本范围的变更。 +### 2. 安装 Docker -## 公共前置条件 +Linux 可使用 Docker Engine 或 Docker Desktop。安装完成后确认 Docker Compose v2 可用: -所有环境都需要: - -- Docker 与 Docker Compose -- 可写的 `pg_data/` 数据目录 -- 端口 `8888`、`8000`、`5432` 未被占用 -- 后端镜像可安装 `backend/requirements.txt` 中的依赖 -- 前端镜像可执行 `npm ci` 与 `npm run build` - -当前 compose 服务拓扑: - -```text -nginx -> backend -> db +```bash +docker --version +docker compose version ``` -对外入口: +如果当前用户不能执行 Docker 命令,将用户加入 `docker` 组后重新登录: -- 前端:`http://localhost:8888` -- 后端 API:同域 `/api/v1/*` -- 健康检查:`http://localhost:8888/health` +```bash +sudo usermod -aG docker "$USER" +``` -## dev 分支:开发环境 +macOS 安装 Docker Desktop 后执行: -`dev` 默认用于本地开发和内部集成,推荐使用 `ENV=development`。 +```bash +docker --version +docker compose version +``` + +### 3. 安装基础工具 + +Linux: + +```bash +sudo apt-get update +sudo apt-get install -y git openssl curl lsof +``` + +macOS: + +```bash +brew install git openssl curl +``` + +### 4. 检查端口 + +```bash +lsof -nP -iTCP:8888 -sTCP:LISTEN +lsof -nP -iTCP:8000 -sTCP:LISTEN +lsof -nP -iTCP:5432 -sTCP:LISTEN +``` + +如果命令有输出,先停止占用这些端口的服务,或调整 `docker-compose.yaml` 中的端口映射。 + +### 5. 拉取代码 + +首次安装: + +```bash +git clone https://github.com/chengchengzhou7/CTMS.git +cd CTMS +``` + +已有仓库: + +```bash +cd /opt/ctms/CTMS +git fetch --prune origin +``` + +### 6. 确认本地配置不会提交 + +```bash +grep -n "^\\.env$" .gitignore +grep -n "^\\.env\\.\\*$" .gitignore +``` + +预期能看到 `.env` 和 `.env.*` 已被忽略。 + +## dev 分支安装 ### 1. 切换分支 @@ -51,68 +98,93 @@ git checkout dev git pull --rebase origin dev ``` -### 2. 配置 `.env` +### 2. 写入环境配置 -根目录 `.env` 不提交到仓库。开发环境推荐: +在仓库根目录创建或覆盖 `.env`: -```env +```bash +cat > .env <<'EOF' COMPOSE_PROJECT_NAME=ctms_dev ENV=development JWT_SECRET_KEY=dev-secret LOGIN_RSA_KEY_ID=default LOGIN_RSA_PRIVATE_KEY= +EOF ``` -说明: - -- `ENV=development` 会启用开发行为。 -- 未配置 `LOGIN_RSA_PRIVATE_KEY` 时,后端启动后会生成临时 RSA 私钥。 -- 后端重启后临时公钥会变化,已有登录页应刷新后重新登录。 -- `JWT_SECRET_KEY=dev-secret` 只允许本地开发使用。 - -### 3. 启动 - -```bash -docker compose up -d --build backend nginx -``` - -如果需要首次启动完整栈: +### 3. 构建镜像并启动 ```bash docker compose up -d --build ``` -### 4. 开发模式行为 +### 4. 执行数据库迁移 -`development` 模式下: +已有数据库升级到新代码时,启动后执行一次迁移: -- FastAPI `debug=True` -- 应用启动时会执行 `Base.metadata.create_all` -- 应用启动时会确保默认管理员存在 -- 未配置 RSA 私钥时允许临时生成 +```bash +docker compose run --rm backend python -m alembic upgrade head +``` -这些行为只适合开发,不适合生产。 +首次空库安装也可以执行该命令;如果已是最新版本,Alembic 不会重复应用迁移。 -### 5. 验证 +### 5. 检查容器状态 ```bash docker compose ps -docker compose exec backend python -c "from app.core.config import settings; print(settings.ENV)" +``` + +确认 `ctms_db`、`ctms_backend`、`ctms_nginx` 都是 `Up`,且 `ctms_db` 为 `healthy`。 + +### 6. 检查后端环境 + +```bash +docker compose exec backend python -c "from app.core.config import settings; print('ENV=' + settings.ENV); print('JWT_SECRET_IS_DEV=' + str(settings.JWT_SECRET_KEY == 'dev-secret')); print('LOGIN_RSA_PRIVATE_KEY_SET=' + str(bool(settings.LOGIN_RSA_PRIVATE_KEY)))" +``` + +预期输出: + +```text +ENV=development +JWT_SECRET_IS_DEV=True +LOGIN_RSA_PRIVATE_KEY_SET=False +``` + +### 7. 检查接口 + +```bash curl -i http://127.0.0.1:8888/health curl -i http://127.0.0.1:8888/api/v1/auth/login-key ``` -预期: +两个接口都应返回 `HTTP/1.1 200 OK`。 + +### 8. 打开系统 ```text -ENV=development -GET /health -> 200 -GET /api/v1/auth/login-key -> 200, key_id=default +http://localhost:8888 ``` -## main 分支:预发布 / 验收环境 +### 9. 运行验证 -`main` 是下一正式版本候选分支,应按生产模式运行,但不直接承载正式生产流量。 +后端: + +```bash +docker compose run --rm -v "$PWD/backend/tests:/code/tests:ro" backend python -m pytest +``` + +前端: + +```bash +cd frontend +npm install +npm run test:unit +npm run type-check +npm run build +cd .. +``` + +## main 分支安装 ### 1. 切换分支 @@ -121,81 +193,153 @@ git checkout main git pull --rebase origin main ``` -### 2. 配置 `.env` +### 2. 生成密钥 -预发布环境应使用 `ENV=production`,并配置独立于生产的密钥: - -```env -COMPOSE_PROJECT_NAME=ctms_staging -ENV=production -JWT_SECRET_KEY= -LOGIN_RSA_KEY_ID=staging-YYYYMMDD -LOGIN_RSA_PRIVATE_KEY= -``` - -生成密钥示例: +生成 JWT 密钥: ```bash openssl rand -hex 32 -openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 ``` -要求: +生成 RSA 私钥文件: -- `JWT_SECRET_KEY` 不得使用 `dev-secret`。 -- `LOGIN_RSA_PRIVATE_KEY` 必须固定保存,不能每次部署重新生成。 -- staging 私钥不得复用 production 私钥。 +```bash +openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out login_rsa_main.pem +``` -### 3. 初始化数据库 +生成可写入 `.env` 的 RSA 私钥单行文本: -生产模式不会自动建表或自动补管理员。首次部署或迁移前执行: +```bash +awk '{printf "%s\\n", $0}' login_rsa_main.pem +``` + +### 3. 写入环境配置 + +在仓库根目录创建或覆盖 `.env`,将占位符替换为上一步生成的值: + +```bash +cat > .env <<'EOF' +COMPOSE_PROJECT_NAME=ctms_main +ENV=production +JWT_SECRET_KEY=<替换为 JWT 密钥> +LOGIN_RSA_KEY_ID=main-YYYYMMDD +LOGIN_RSA_PRIVATE_KEY=<替换为 RSA 私钥单行文本> +EOF +``` + +### 4. 检查 Compose 配置 + +```bash +docker compose config +``` + +确认输出中包含: + +```text +ENV: production +LOGIN_RSA_KEY_ID: main-YYYYMMDD +LOGIN_RSA_PRIVATE_KEY: '-----BEGIN PRIVATE KEY-----...' +``` + +### 5. 初始化数据库 ```bash docker compose run --rm backend-init ``` -该步骤应运行 Alembic migration,并确保固定管理员账号存在。 - -### 4. 启动 +### 6. 构建镜像并启动 ```bash docker compose up -d --build ``` -### 5. 验证 +### 7. 执行数据库迁移 + +已有数据库升级到新代码时,启动后执行一次迁移: + +```bash +docker compose run --rm backend python -m alembic upgrade head +``` + +`backend-init` 会在初始化时处理空库和已有库迁移;这里单独执行迁移用于确认部署代码已落到最新数据库版本。 + +### 8. 检查容器状态 ```bash -docker compose config docker compose ps -docker compose exec backend python -c "from app.core.config import settings; print(settings.ENV); print(settings.JWT_SECRET_KEY == 'dev-secret'); print(bool(settings.LOGIN_RSA_PRIVATE_KEY))" +``` + +确认 `ctms_db`、`ctms_backend`、`ctms_nginx` 都是 `Up`,且 `ctms_db` 为 `healthy`。 + +### 9. 检查后端环境 + +```bash +docker compose exec backend python -c "from app.core.config import settings; print('ENV=' + settings.ENV); print('JWT_SECRET_IS_DEV=' + str(settings.JWT_SECRET_KEY == 'dev-secret')); print('LOGIN_RSA_PRIVATE_KEY_SET=' + str(bool(settings.LOGIN_RSA_PRIVATE_KEY))); print('LOGIN_RSA_KEY_ID=' + settings.LOGIN_RSA_KEY_ID)" +``` + +预期输出: + +```text +ENV=production +JWT_SECRET_IS_DEV=False +LOGIN_RSA_PRIVATE_KEY_SET=True +LOGIN_RSA_KEY_ID=main-YYYYMMDD +``` + +### 10. 检查接口 + +本机: + +```bash curl -i http://127.0.0.1:8888/health curl -i http://127.0.0.1:8888/api/v1/auth/login-key ``` -预期: +远程域名: -```text -ENV=production -JWT_SECRET_KEY == dev-secret -> False -LOGIN_RSA_PRIVATE_KEY_SET -> True -GET /health -> 200 -GET /api/v1/auth/login-key -> 200 +```bash +curl -i https:///health +curl -i https:///api/v1/auth/login-key ``` -### 6. 验收门禁 +接口应返回 `HTTP/1.1 200 OK`。 -在把 `main` 推进到 `release` 前,至少执行: +### 11. 打开系统 + +本机: + +```text +http://localhost:8888 +``` + +远程访问: + +```text +https:// +``` + +远程访问必须使用 HTTPS。 + +### 12. 运行验证 + +后端: ```bash docker compose run --rm -v "$PWD/backend/tests:/code/tests:ro" backend python -m pytest -cd frontend && npm run test:unit -cd frontend && npm run type-check -cd frontend && npm run build ``` -## release 分支:生产环境 +前端: -`release` 是正式生产稳定分支。只有正式发布和生产 hotfix 应进入该分支。 +```bash +cd frontend +npm install +npm run test:unit +npm run type-check +npm run build +cd .. +``` + +## release 分支安装 ### 1. 切换分支 @@ -204,125 +348,248 @@ git checkout release git pull --rebase origin release ``` -### 2. 配置 `.env` +### 2. 准备 HTTPS 域名 -生产环境必须使用生产专用配置: +正式访问地址必须是: -```env -COMPOSE_PROJECT_NAME=ctms_prod -ENV=production -JWT_SECRET_KEY= -LOGIN_RSA_KEY_ID=prod-YYYYMMDD -LOGIN_RSA_PRIVATE_KEY= +```text +https:// ``` -要求: +如果服务器前面有反向代理,确认代理规则: -- `.env` 必须只保存在部署机器或密钥管理系统中。 -- 不得提交 `.env`、私钥、JWT 密钥。 -- `JWT_SECRET_KEY` 轮换会使既有 token 失效,应安排维护窗口。 -- `LOGIN_RSA_PRIVATE_KEY` 轮换会影响新登录密钥获取,应同步更新 `LOGIN_RSA_KEY_ID`。 +```text +/ -> CTMS nginx 8888 +/api/* -> CTMS nginx 8888 +/health -> CTMS nginx 8888 +``` -### 3. HTTPS 要求 +### 3. 生成密钥 -登录加密依赖浏览器 WebCrypto。生产访问必须使用 HTTPS: +生成 JWT 密钥: -- `https://正式域名` -- 本地 `localhost` 是浏览器安全上下文例外,但不能代表生产可用性 +```bash +openssl rand -hex 32 +``` -如果生产仍通过 `http://服务器IP:8888` 访问,前端会拒绝执行登录加密。 +生成 RSA 私钥文件: -### 4. 初始化与迁移 +```bash +openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out login_rsa_release.pem +``` -首次部署或每次包含 migration 的发布: +生成可写入 `.env` 的 RSA 私钥单行文本: + +```bash +awk '{printf "%s\\n", $0}' login_rsa_release.pem +``` + +将 `login_rsa_release.pem` 保存到服务器安全目录或密钥管理系统。 + +### 4. 写入环境配置 + +在仓库根目录创建或覆盖 `.env`,将占位符替换为生产值: + +```bash +cat > .env <<'EOF' +COMPOSE_PROJECT_NAME=ctms_release +ENV=production +JWT_SECRET_KEY=<替换为生产 JWT 密钥> +LOGIN_RSA_KEY_ID=release-YYYYMMDD +LOGIN_RSA_PRIVATE_KEY=<替换为生产 RSA 私钥单行文本> +EOF +``` + +确认 `.env` 没有进入 Git 暂存区: + +```bash +git status --short .env +``` + +预期无输出。 + +### 5. 检查 Compose 配置 + +```bash +docker compose config +``` + +确认输出中包含: + +```text +ENV: production +JWT_SECRET_KEY: <不是 dev-secret> +LOGIN_RSA_KEY_ID: release-YYYYMMDD +LOGIN_RSA_PRIVATE_KEY: '-----BEGIN PRIVATE KEY-----...' +``` + +### 6. 初始化数据库 ```bash docker compose run --rm backend-init ``` -确认 migration 成功后再启动或滚动重启服务。 +确认命令成功后再启动服务。 -### 5. 启动 +### 7. 构建镜像并启动 ```bash docker compose up -d --build ``` -### 6. 生产验证 +### 8. 执行数据库迁移 + +已有数据库升级到新代码时,启动后执行一次迁移: + +```bash +docker compose run --rm backend python -m alembic upgrade head +``` + +`backend-init` 会在初始化时处理空库和已有库迁移;这里单独执行迁移用于确认部署代码已落到最新数据库版本。 + +### 9. 检查容器状态 ```bash docker compose ps -docker compose exec backend python -c "from app.core.config import settings; print(settings.ENV); print(settings.JWT_SECRET_KEY == 'dev-secret'); print(bool(settings.LOGIN_RSA_PRIVATE_KEY)); print(settings.LOGIN_RSA_KEY_ID)" +``` + +确认 `ctms_db`、`ctms_backend`、`ctms_nginx` 都是 `Up`,且 `ctms_db` 为 `healthy`。 + +### 10. 检查后端环境 + +```bash +docker compose exec backend python -c "from app.core.config import settings; print('ENV=' + settings.ENV); print('JWT_SECRET_IS_DEV=' + str(settings.JWT_SECRET_KEY == 'dev-secret')); print('LOGIN_RSA_PRIVATE_KEY_SET=' + str(bool(settings.LOGIN_RSA_PRIVATE_KEY))); print('LOGIN_RSA_KEY_ID=' + settings.LOGIN_RSA_KEY_ID)" +``` + +预期输出: + +```text +ENV=production +JWT_SECRET_IS_DEV=False +LOGIN_RSA_PRIVATE_KEY_SET=True +LOGIN_RSA_KEY_ID=release-YYYYMMDD +``` + +### 11. 检查生产接口 + +```bash curl -i https:///health curl -i https:///api/v1/auth/login-key ``` -预期: +两个接口都应返回 `HTTP/1.1 200 OK`。 + +### 12. 打开系统并登录 + +```text +https:// +``` + +初始化管理员: + +```text +admin@huapont.cn / admin123 +``` + +首次登录后立即修改初始密码。 + +### 13. 发布后检查 + +查看容器: + +```bash +docker compose ps +``` + +查看后端日志: + +```bash +docker compose logs --tail=200 backend +``` + +查看 nginx 日志: + +```bash +docker compose logs --tail=200 nginx +``` + +执行 smoke: + +```bash +BASE_URL=https:// EMAIL= PASSWORD= bash docs/setup-config-curl-smoke.sh +``` + +## 常见安装问题 + +### 端口被占用 + +检查: + +```bash +lsof -nP -iTCP:8888 -sTCP:LISTEN +lsof -nP -iTCP:8000 -sTCP:LISTEN +lsof -nP -iTCP:5432 -sTCP:LISTEN +``` + +处理:停止占用进程,或调整 `docker-compose.yaml` 端口映射。 + +### 容器名冲突 + +检查: + +```bash +docker ps -a --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" +``` + +处理: + +```bash +docker compose down +``` + +如果同一台机器要同时运行多套 CTMS,需为每套环境调整 `docker-compose.yaml` 中的 `container_name` 和端口。 + +### production 启动失败 + +检查: + +```bash +grep -n "ENV\\|JWT_SECRET_KEY\\|LOGIN_RSA" .env +``` + +确认: ```text ENV=production -JWT_SECRET_KEY == dev-secret -> False -LOGIN_RSA_PRIVATE_KEY_SET -> True -GET /health -> 200 -GET /api/v1/auth/login-key -> 200 +JWT_SECRET_KEY 不是 dev-secret +LOGIN_RSA_PRIVATE_KEY 不是空值 +LOGIN_RSA_PRIVATE_KEY 包含 -----BEGIN PRIVATE KEY----- ``` -### 7. 多实例约束 +### 远程访问无法登录 -当前登录 challenge 默认保存在后端进程内: +检查当前浏览器地址。如果不是 `https://`,改用 HTTPS 域名访问: -- 单实例、单 worker:可直接使用 -- 多实例或多 worker:必须满足以下至少一项 - - 使用共享缓存保存 challenge,例如 Redis - - 启用粘性会话,确保 `/login-key` 与 `/login` 命中同一后端进程 - - 将后端限制为单 worker 单副本 - -如果不满足,上线后可能出现偶发登录失败。 - -## 环境变量说明 - -| 变量 | dev | main/staging | release/production | 说明 | -| --- | --- | --- | --- | --- | -| `COMPOSE_PROJECT_NAME` | `ctms_dev` | `ctms_staging` | `ctms_prod` | 防止不同环境容器名、网络名冲突 | -| `ENV` | `development` | `production` | `production` | 后端运行模式 | -| `JWT_SECRET_KEY` | `dev-secret` | 强随机 | 强随机 | JWT 签名密钥 | -| `LOGIN_RSA_KEY_ID` | `default` | `staging-YYYYMMDD` | `prod-YYYYMMDD` | 登录 RSA 密钥版本 | -| `LOGIN_RSA_PRIVATE_KEY` | 空 | staging 私钥 | production 私钥 | RSA 私钥,生产模式必填 | - -## 常见问题 - -### 登录页提示当前浏览器环境不支持安全登录加密 - -原因:非 HTTPS、非 localhost 的访问环境不满足 WebCrypto 安全上下文要求。 - -处理: - -- 本地使用 `http://localhost:8888` -- staging/production 使用 HTTPS 域名 - -### production 模式启动失败,提示必须配置 LOGIN_RSA_PRIVATE_KEY - -原因:`ENV=production` 时必须提供固定 RSA 私钥。 - -处理: - -```bash -openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 +```text +https:// ``` -将输出 PEM 写入 `.env` 的 `LOGIN_RSA_PRIVATE_KEY`,换行使用 `\n` 转义。 +本机 `dev` 可使用: -### 切换分支后环境不符合预期 - -分支不会自动修改 `.env`。切换分支后应重新确认: - -```bash -docker compose config -docker compose exec backend python -c "from app.core.config import settings; print(settings.ENV)" +```text +http://localhost:8888 ``` -必要时修改 `.env` 并重启: +### 切换分支后环境不正确 + +重新执行对应分支的 `.env` 配置,然后重建: ```bash docker compose up -d --build backend nginx ``` + +再检查: + +```bash +docker compose exec backend python -c "from app.core.config import settings; print(settings.ENV)" +``` diff --git a/frontend/src/api/attachments.test.ts b/frontend/src/api/attachments.test.ts new file mode 100644 index 00000000..204d890e --- /dev/null +++ b/frontend/src/api/attachments.test.ts @@ -0,0 +1,32 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const apiGet = vi.fn(); +const apiPost = vi.fn(); +const apiDelete = vi.fn(); + +vi.mock("./axios", () => ({ + apiGet, + apiPost, + apiDelete, +})); + +describe("attachments api", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("uses canonical collection URLs with trailing slash", async () => { + const { fetchAttachments, uploadAttachment } = await import("./attachments"); + const file = new File(["x"], "x.txt", { type: "text/plain" }); + + fetchAttachments("study-1", "startup_feasibility", "entity-1"); + uploadAttachment("study-1", "startup_feasibility", "entity-1", file); + + expect(apiGet).toHaveBeenCalledWith("/api/v1/studies/study-1/startup_feasibility/entity-1/attachments/"); + expect(apiPost).toHaveBeenCalledWith( + "/api/v1/studies/study-1/startup_feasibility/entity-1/attachments/", + expect.any(FormData), + expect.objectContaining({ headers: { "Content-Type": "multipart/form-data" } }) + ); + }); +}); diff --git a/frontend/src/api/attachments.ts b/frontend/src/api/attachments.ts index 876d8b21..4c1c8b6a 100644 --- a/frontend/src/api/attachments.ts +++ b/frontend/src/api/attachments.ts @@ -1,7 +1,7 @@ import { apiGet, apiPost, apiDelete } from "./axios"; export const fetchAttachments = (studyId: string, entityType: string, entityId: string) => - apiGet(`/api/v1/studies/${studyId}/${entityType}/${entityId}/attachments`); + apiGet(`/api/v1/studies/${studyId}/${entityType}/${entityId}/attachments/`); export const uploadAttachment = ( studyId: string, @@ -12,7 +12,7 @@ export const uploadAttachment = ( ) => { const formData = new FormData(); formData.append("file", file); - return apiPost(`/api/v1/studies/${studyId}/${entityType}/${entityId}/attachments`, formData, { + return apiPost(`/api/v1/studies/${studyId}/${entityType}/${entityId}/attachments/`, formData, { headers: { "Content-Type": "multipart/form-data" }, ...options, }); diff --git a/frontend/src/api/visits.ts b/frontend/src/api/visits.ts index c5700b15..0c18c143 100644 --- a/frontend/src/api/visits.ts +++ b/frontend/src/api/visits.ts @@ -1,4 +1,4 @@ -import { apiDelete, apiGet, apiPatch, apiPost } from "./axios"; +import { apiDelete, apiGet, apiPatch, apiPost, type ApiRequestConfig } from "./axios"; export const fetchVisits = (studyId: string, subjectId: string) => apiGet(`/api/v1/studies/${studyId}/subjects/${subjectId}/visits/`); @@ -6,6 +6,13 @@ export const fetchVisits = (studyId: string, subjectId: string) => export const createVisit = (studyId: string, subjectId: string, payload: Record) => apiPost(`/api/v1/studies/${studyId}/subjects/${subjectId}/visits/`, payload); +export const createEarlyTerminationVisit = ( + studyId: string, + subjectId: string, + payload: Record, + config?: ApiRequestConfig +) => apiPost(`/api/v1/studies/${studyId}/subjects/${subjectId}/visits/early-termination`, payload, config); + export const updateVisit = (studyId: string, subjectId: string, visitId: string, payload: Record) => apiPatch(`/api/v1/studies/${studyId}/subjects/${subjectId}/visits/${visitId}`, payload); diff --git a/frontend/src/components/FaqCategoryForm.vue b/frontend/src/components/FaqCategoryForm.vue index 5ce298e5..dcb562f5 100644 --- a/frontend/src/components/FaqCategoryForm.vue +++ b/frontend/src/components/FaqCategoryForm.vue @@ -101,9 +101,6 @@ const onSubmit = async () => { sort_order: form.sort_order, is_active: form.is_active, }; - console.log('[FaqCategoryForm] payload:', payload); - console.log('[FaqCategoryForm] isEdit:', isEdit.value); - console.log('[FaqCategoryForm] study.currentStudy:', study.currentStudy); if (isEdit.value && props.category) { await updateFaqCategory(props.category.id, payload); } else { diff --git a/frontend/src/components/attachments/AttachmentList.vue b/frontend/src/components/attachments/AttachmentList.vue index 45772d5d..99be3de2 100644 --- a/frontend/src/components/attachments/AttachmentList.vue +++ b/frontend/src/components/attachments/AttachmentList.vue @@ -45,7 +45,7 @@ - +
{{ previewError }}
diff --git a/frontend/src/types/setupConfig.ts b/frontend/src/types/setupConfig.ts index c03b2748..6d51eb0e 100644 --- a/frontend/src/types/setupConfig.ts +++ b/frontend/src/types/setupConfig.ts @@ -58,6 +58,7 @@ export interface CenterConfirmDraft { } export interface SetupConfigDraft { + projectInfo: ProjectPublishSnapshot; projectMilestones: ProjectMilestoneDraft[]; enrollmentPlan: EnrollmentPlanDraft; siteMilestones: SiteMilestoneDraft[]; diff --git a/frontend/src/utils/setupDiffRows.ts b/frontend/src/utils/setupDiffRows.ts index 28666ea3..f82d9f98 100644 --- a/frontend/src/utils/setupDiffRows.ts +++ b/frontend/src/utils/setupDiffRows.ts @@ -32,6 +32,28 @@ const statusLabelMap: Record = { }; const setupFieldLabelMap: Record> = { + projectInfo: { + code: "项目编号", + name: "项目简称", + project_full_name: "项目全称", + sponsor: "申办方", + protocol_no: "方案号", + lead_unit: "组长单位", + principal_investigator: "主要研究者", + main_pm: "主PM", + research_analysis: "研究分期", + research_product: "研究产品", + control_product: "对照产品", + indication: "适应症", + research_population: "研究人群", + research_design: "研究设计", + status: "项目状态", + plan_start_date: "计划开始日期", + plan_end_date: "计划结束日期", + planned_site_count: "计划中心数", + planned_enrollment_count: "计划入组例数", + visit_schedule: "访视计划", + }, projectMilestones: { id: "ID", name: "里程碑", diff --git a/frontend/src/utils/setupPublishWorkflow.test.ts b/frontend/src/utils/setupPublishWorkflow.test.ts new file mode 100644 index 00000000..8823c535 --- /dev/null +++ b/frontend/src/utils/setupPublishWorkflow.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import type { ProjectPublishSnapshot } from "../types/setupConfig"; +import { buildProjectDiffRows } from "./setupPublishWorkflow"; +import { serializeDiffValue } from "./setupDiffRows"; + +const emptyProjectSnapshot = (): ProjectPublishSnapshot => ({ + code: "", + name: "", + project_full_name: "", + sponsor: "", + protocol_no: "", + lead_unit: "", + principal_investigator: "", + main_pm: "", + research_analysis: "", + research_product: "", + control_product: "", + indication: "", + research_population: "", + research_design: "", + plan_start_date: "", + plan_end_date: "", + planned_site_count: null, + planned_enrollment_count: null, + status: "", + visit_schedule: [], +}); + +describe("setup publish workflow project diff rows", () => { + it("shows visit schedule changes under the summary module", () => { + const current = emptyProjectSnapshot(); + current.visit_schedule = [ + { + visit_code: "V1", + baseline_offset_days: 0, + window_before_days: 0, + window_after_days: 3, + }, + ]; + + const rows = buildProjectDiffRows(current, emptyProjectSnapshot(), serializeDiffValue); + + expect(rows).toEqual([ + { + moduleLabel: "方案摘要", + path: "访视计划", + changeType: "修改", + localValue: "已配置列表(1项)", + serverValue: "已配置列表(0项)", + }, + ]); + }); + + it("does not treat equivalent visit schedule arrays as changed", () => { + const current = emptyProjectSnapshot(); + const base = emptyProjectSnapshot(); + current.visit_schedule = [ + { + visit_code: "V1", + baseline_offset_days: 0, + window_before_days: 0, + window_after_days: 3, + }, + ]; + base.visit_schedule = [ + { + visit_code: "V1", + baseline_offset_days: 0, + window_before_days: 0, + window_after_days: 3, + }, + ]; + + expect(buildProjectDiffRows(current, base, serializeDiffValue)).toEqual([]); + }); +}); diff --git a/frontend/src/utils/setupPublishWorkflow.ts b/frontend/src/utils/setupPublishWorkflow.ts index 7aff9934..8956a7ae 100644 --- a/frontend/src/utils/setupPublishWorkflow.ts +++ b/frontend/src/utils/setupPublishWorkflow.ts @@ -122,6 +122,34 @@ const PROJECT_FIELD_LABEL_MAP: Record = { visit_schedule: "访视计划", }; +const PROJECT_FIELD_MODULE_LABEL_MAP: Record = { + code: "项目信息", + name: "项目信息", + project_full_name: "项目信息", + sponsor: "项目信息", + protocol_no: "项目信息", + lead_unit: "项目信息", + principal_investigator: "项目信息", + main_pm: "项目信息", + research_analysis: "研究信息", + research_product: "研究信息", + control_product: "研究信息", + indication: "研究信息", + research_population: "研究信息", + research_design: "研究信息", + plan_start_date: "执行信息", + plan_end_date: "执行信息", + planned_site_count: "执行信息", + planned_enrollment_count: "执行信息", + status: "执行信息", + visit_schedule: "方案摘要", +}; + +const isProjectFieldValueEqual = (currentValue: unknown, baseValue: unknown): boolean => { + if (currentValue === baseValue) return true; + return JSON.stringify(currentValue ?? null) === JSON.stringify(baseValue ?? null); +}; + export const buildProjectDiffRows = ( currentSnapshot: ProjectPublishSnapshot, baseSnapshot: ProjectPublishSnapshot | null, @@ -130,9 +158,9 @@ export const buildProjectDiffRows = ( if (!baseSnapshot) return []; const rows: SetupDiffRow[] = []; (Object.keys(PROJECT_FIELD_LABEL_MAP) as Array).forEach((key) => { - if (currentSnapshot[key] === baseSnapshot[key]) return; + if (isProjectFieldValueEqual(currentSnapshot[key], baseSnapshot[key])) return; rows.push({ - moduleLabel: "项目信息", + moduleLabel: PROJECT_FIELD_MODULE_LABEL_MAP[key], path: PROJECT_FIELD_LABEL_MAP[key], changeType: "修改", localValue: serializeValue(currentSnapshot[key]), diff --git a/frontend/src/views/Login.test.ts b/frontend/src/views/Login.test.ts new file mode 100644 index 00000000..4e92f0ef --- /dev/null +++ b/frontend/src/views/Login.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const readLoginView = () => readFileSync(resolve(__dirname, "./Login.vue"), "utf8"); + +describe("Login protocol agreement", () => { + it("requires protocol agreement before calling login", () => { + const source = readLoginView(); + const protocolGuardIndex = source.indexOf("!form.agreeProtocol"); + const loginCallIndex = source.indexOf("auth.login(form.email, form.password)"); + + expect(source).toContain('v-model="form.agreeProtocol"'); + expect(source).toContain("我已阅读并同意"); + expect(source).toContain("请先阅读并同意用户协议"); + expect(protocolGuardIndex).toBeGreaterThan(-1); + expect(loginCallIndex).toBeGreaterThan(-1); + expect(protocolGuardIndex).toBeLessThan(loginCallIndex); + }); + + it("offers remember password through browser credentials without local password storage", () => { + const source = readLoginView(); + + expect(source).toContain('v-model="form.rememberPassword"'); + expect(source).toContain("记住密码"); + expect(source).toContain('autocomplete="username"'); + expect(source).toContain('name="username"'); + expect(source).toContain('name="password"'); + expect(source).toContain("tryLoadBrowserCredential"); + expect(source).toContain("tryStoreBrowserCredential"); + expect(source).toContain("navigator.credentials"); + expect(source).toContain("password: true"); + expect(source).not.toContain('localStorage.setItem("ctms_saved_password"'); + expect(source).not.toContain("localStorage.setItem('ctms_saved_password'"); + }); + + it("opens a CTMS-specific protocol dialog from the protocol text", () => { + const source = readLoginView(); + + expect(source).toContain('v-model="protocolDialogVisible"'); + expect(source).toContain('@click.stop.prevent="openProtocolDialog"'); + expect(source).toContain("protocolSections"); + expect(source).toContain("临床试验数据与合规"); + expect(source).toContain("项目、中心、受试者、访视、AE/SAE、PD"); + expect(source).toContain("权限与审计"); + expect(source).toContain("confirmProtocol"); + }); +}); diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index 9ec09681..2cd2a09b 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -42,7 +42,8 @@ type="email" placeholder="请输入邮箱" size="large" - autocomplete="email" + name="username" + autocomplete="username" class="login-input" > @@ -99,11 +137,17 @@ import { consumeLogoutReason, LOGOUT_REASON_TIMEOUT } from "../session/sessionMa const auth = useAuthStore(); const router = useRouter(); +const REMEMBER_PASSWORD_KEY = "ctms_remember_password"; + +type PasswordCredentialConstructor = new (data: { id: string; password: string; name?: string }) => Credential; +type StoredPasswordCredential = Credential & { id?: string; password?: string }; const formRef = ref(); const form = reactive({ email: "", password: "", + rememberPassword: false, + agreeProtocol: false, }); const rules: FormRules = { @@ -115,22 +159,118 @@ const rules: FormRules = { }; const loading = ref(false); +const protocolDialogVisible = ref(false); -onMounted(() => { +const protocolSections = [ + { + title: "1. 服务条款", + paragraphs: [ + "本系统为 CTMS 临床试验管理系统,用于支持临床试验项目的账号治理、项目与中心管理、项目里程碑、立项与伦理、启动会与培训授权、受试者管理、访视、AE/SAE、PD、风险问题、费用、药品物资、文件版本、知识库与审计日志等业务协同。", + "用户应在所属机构授权范围内使用本系统。系统中的流程提醒、状态跟踪和数据汇总用于辅助项目管理,不替代申办方、研究中心、CRA、PM、PV、IMP 或管理员按照法规、方案、SOP 和合同约定应履行的专业判断与职责。", + ], + }, + { + title: "2. 账号安全", + paragraphs: [ + "用户应妥善保管登录账号、密码和浏览器会话,不得转让、出借、共享账号,不得使用他人账号访问项目数据。因账号保管不当造成的数据泄露、误操作或审计异常,由账号所属用户及其机构承担相应责任。", + "系统支持邮箱密码登录、账号审核、启用停用、项目内角色授权和操作级权限控制。用户发现账号异常、权限错误、设备遗失或疑似未授权访问时,应立即联系管理员处理。", + ], + }, + { + title: "3. 临床试验数据与合规", + paragraphs: [ + "用户在系统中录入、上传、维护或导出的项目、中心、受试者、访视、AE/SAE、PD、伦理、培训、合同费用、特殊费用、药品流向、设备台账、文件版本和知识库资料,应确保来源合法、内容真实、准确、完整、及时,并符合适用的 GCP、研究方案、伦理批件、SOP、数据保护和保密要求。", + "涉及受试者、研究者、中心联系人、医学事件、财务附件、合同文件或其他敏感信息时,用户应遵循最小必要原则,不得上传与当前项目管理无关的数据,不得通过截图、导出、复制或外部工具进行未授权传播。", + ], + }, + { + title: "4. 权限与审计", + paragraphs: [ + "系统按照管理员、PM、CRA、PV、IMP 及普通成员等角色提供不同操作能力。同一账号在不同项目中的角色可能不同,用户仅可在被授权项目和中心范围内执行查看、新增、编辑、审批、关闭、导出等操作。", + "系统会记录关键业务操作和管理操作,包括但不限于账号、项目、成员、中心、AE、费用、文件、审计导出等事件。用户理解并同意这些日志可用于安全追踪、合规核查、问题复盘和内部管理。", + ], + }, + { + title: "5. 隐私与数据保护", + paragraphs: [ + "平台将按照业务需要处理用户账号信息、项目角色、操作记录以及临床试验管理过程中产生的业务数据,并采取访问控制、登录加密、会话管理和审计追踪等措施保护数据安全。", + "除法律法规、监管要求、机构授权或项目管理必要场景外,平台不会主动向无关第三方披露业务数据。用户应自行确认其录入和上传的数据已取得必要授权或具备合法处理依据。", + ], + }, + { + title: "6. 免责声明", + paragraphs: [ + "因网络故障、浏览器兼容性、用户设备异常、第三方服务中断、不可抗力或用户误操作导致的服务中断、数据延迟、展示异常或操作失败,平台将在合理范围内协助排查,但不承担超出适用法律和合同约定范围的责任。", + "用户继续登录和使用系统,即表示已阅读、理解并同意本协议。若不同意本协议内容,应停止登录并联系管理员或所属机构负责人处理。", + ], + }, +]; + +onMounted(async () => { const logoutReason = consumeLogoutReason(); if (logoutReason === LOGOUT_REASON_TIMEOUT) { ElMessage.warning("长时间未操作,已自动退出登录,请重新登录"); } form.email = localStorage.getItem("ctms_last_login_email") || ""; + form.rememberPassword = localStorage.getItem(REMEMBER_PASSWORD_KEY) === "true"; + await tryLoadBrowserCredential(); }); +const tryLoadBrowserCredential = async () => { + if (!form.rememberPassword || !navigator.credentials?.get) return; + + try { + const credential = (await navigator.credentials.get({ + password: true, + mediation: "optional", + } as CredentialRequestOptions)) as StoredPasswordCredential | null; + if (!credential?.password) return; + form.email = credential.id || form.email; + form.password = credential.password; + } catch { + // 浏览器可能不支持读取密码凭据,保留用户手动输入流程。 + } +}; + +const tryStoreBrowserCredential = async (email: string, password: string) => { + if (!form.rememberPassword) { + localStorage.removeItem(REMEMBER_PASSWORD_KEY); + return; + } + + localStorage.setItem(REMEMBER_PASSWORD_KEY, "true"); + const PasswordCredential = (window as typeof window & { PasswordCredential?: PasswordCredentialConstructor }) + .PasswordCredential; + if (!navigator.credentials?.store || !PasswordCredential) return; + + try { + await navigator.credentials.store(new PasswordCredential({ id: email, password, name: email })); + } catch { + // 浏览器或用户可能拒绝保存凭据,不影响登录主流程。 + } +}; + +const openProtocolDialog = () => { + protocolDialogVisible.value = true; +}; + +const confirmProtocol = () => { + form.agreeProtocol = true; + protocolDialogVisible.value = false; +}; + const onSubmit = async () => { if (!formRef.value) return; const valid = await formRef.value.validate(); if (!valid) return; + if (!form.agreeProtocol) { + ElMessage.warning("请先阅读并同意用户协议"); + return; + } loading.value = true; try { await auth.login(form.email, form.password); + await tryStoreBrowserCredential(form.email, form.password); const studyStore = useStudyStore(); if (!studyStore.currentStudy) { if (auth.user?.role === "ADMIN") { @@ -309,6 +449,129 @@ const onSubmit = async () => { transition: background-color 5000s ease-in-out 0s; } +.login-options { + display: flex; + flex-direction: column; + gap: 12px; + margin: -4px 0 26px; +} + +.remember-checkbox, +.protocol-checkbox { + display: flex; + align-items: flex-start; + width: 100%; + min-height: 24px; +} + +.remember-checkbox :deep(.el-checkbox__input), +.protocol-checkbox :deep(.el-checkbox__input) { + margin-top: 2px; +} + +.remember-checkbox :deep(.el-checkbox__label), +.protocol-checkbox :deep(.el-checkbox__label) { + display: block; + padding-left: 10px; + line-height: 20px; + white-space: normal; +} + +.remember-text { + color: #52657b; + font-size: 12px; + font-weight: 700; + line-height: 18px; +} + +.protocol-text { + color: #475569; + font-size: 12px; + font-weight: 700; + line-height: 18px; +} + +.protocol-link { + appearance: none; + padding: 0; + border: 0; + background: transparent; + color: #4da3ff; + cursor: pointer; + font: inherit; + font-weight: 800; +} + +.protocol-link:hover { + color: #2f8fff; + text-decoration: underline; +} + +.protocol-content { + max-height: 58vh; + overflow-y: auto; + padding: 2px 8px 2px 0; +} + +.protocol-section { + margin-bottom: 26px; +} + +.protocol-section:last-child { + margin-bottom: 0; +} + +.protocol-section h3 { + margin: 0 0 14px; + color: #2f3946; + font-size: 18px; + font-weight: 800; + line-height: 1.4; +} + +.protocol-section p { + margin: 0 0 10px; + color: #5f6b7a; + font-size: 15px; + font-weight: 600; + line-height: 1.8; +} + +.protocol-section p:last-child { + margin-bottom: 0; +} + +.protocol-confirm-btn { + min-width: 112px; + height: 44px; + border-radius: 22px; + font-size: 16px; + font-weight: 700; + letter-spacing: 2px; + background: linear-gradient(135deg, #3f5d75, #4a6d87); + border: none; + box-shadow: 0 8px 16px -4px rgba(63, 93, 117, 0.3); +} + +:global(.protocol-dialog .el-dialog__header) { + padding: 24px 28px 12px; + margin-right: 0; +} + +:global(.protocol-dialog .el-dialog__title) { + color: #2f3946; + font-size: 20px; + font-weight: 800; +} + +:global(.protocol-dialog .el-dialog__body) { + padding: 22px 36px 10px; +} + +:global(.protocol-dialog .el-dialog__footer) { + padding: 18px 28px 24px; +} + .login-btn { width: 100%; height: 48px; @@ -359,5 +622,13 @@ const onSubmit = async () => { .sys-title { font-size: 22px; } + + :global(.protocol-dialog) { + width: calc(100vw - 32px) !important; + } + + :global(.protocol-dialog .el-dialog__body) { + padding: 18px 22px 8px; + } } diff --git a/frontend/src/views/admin/ProjectDetail.test.ts b/frontend/src/views/admin/ProjectDetail.test.ts new file mode 100644 index 00000000..2b4430f7 --- /dev/null +++ b/frontend/src/views/admin/ProjectDetail.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const readProjectDetail = () => readFileSync(resolve(__dirname, "./ProjectDetail.vue"), "utf8"); + +describe("ProjectDetail setup draft publish workflow", () => { + it("marks the draft server-synced after a successful server draft save", () => { + const source = readProjectDetail(); + const functionIndex = source.indexOf("const persistSetupDraft = async"); + const successIndex = source.indexOf("applySetupResponseMeta(data);", functionIndex); + const syncedIndex = source.indexOf("markSetupServerSynced();", successIndex); + + expect(functionIndex).toBeGreaterThan(-1); + expect(successIndex).toBeGreaterThan(-1); + expect(syncedIndex).toBeGreaterThan(successIndex); + }); + + it("reconciles stale dirty flags before opening the publish dialog", () => { + const source = readProjectDetail(); + const functionIndex = source.indexOf("const publishConfigNow = async"); + const reconcileIndex = source.indexOf("reconcileDraftSyncStateBeforePublish();", functionIndex); + const unsavedGuardIndex = source.indexOf("if (hasSetupDraftUnsavedChanges.value)", functionIndex); + + expect(functionIndex).toBeGreaterThan(-1); + expect(reconcileIndex).toBeGreaterThan(functionIndex); + expect(unsavedGuardIndex).toBeGreaterThan(reconcileIndex); + }); + + it("uses an empty project snapshot as the first publish diff baseline", () => { + const source = readProjectDetail(); + const computedIndex = source.indexOf("const projectPublishCompareBase = computed"); + const emptyBaselineIndex = source.indexOf("return isFirstPublish.value ? emptyProjectInfoDraft() : null;", computedIndex); + + expect(computedIndex).toBeGreaterThan(-1); + expect(emptyBaselineIndex).toBeGreaterThan(computedIndex); + }); + + it("clears the full setup draft including project info", () => { + const source = readProjectDetail(); + const functionIndex = source.indexOf("const handleClearDraft = async"); + const confirmTextIndex = source.indexOf("确认清空当前立项配置草稿的所有数据?该操作不会影响已发布版本。", functionIndex); + const applyServerDataIndex = source.indexOf("applySetupDraft(clone(data.data));", functionIndex); + const clearProjectDraftIndex = source.indexOf("clearLocalProjectDraft();", applyServerDataIndex); + + expect(functionIndex).toBeGreaterThan(-1); + expect(confirmTextIndex).toBeGreaterThan(functionIndex); + expect(applyServerDataIndex).toBeGreaterThan(confirmTextIndex); + expect(clearProjectDraftIndex).toBeGreaterThan(applyServerDataIndex); + expect(source.indexOf("applySetupDraft(createDefaultSetupDraft(siteOptions.value));", functionIndex)).toBe(-1); + }); + + it("applies setup drafts atomically including empty project info", () => { + const source = readProjectDetail(); + const functionIndex = source.indexOf("const applySetupDraft = (draft: SetupConfigDraft) => {"); + const nextFunctionIndex = source.indexOf("const loadLocalSetupDraft = ", functionIndex); + const body = source.slice(functionIndex, nextFunctionIndex); + + expect(functionIndex).toBeGreaterThan(-1); + expect(body).toContain("setupDraft.projectInfo = projectInfo;"); + expect(body).toContain("applyProjectInfoDraft(projectInfo);"); + expect(body).not.toContain("shouldApplyProjectInfo"); + expect(body).not.toContain("isSetupDraftEffectivelyEmpty(setupDraft)"); + }); + + it("requires projectInfo in setup draft shape", () => { + const source = readProjectDetail(); + const functionIndex = source.indexOf("const isSetupDraftShape = (value: unknown): value is SetupConfigDraft => {"); + const nextFunctionIndex = source.indexOf("const isProjectPublishSnapshotShape = ", functionIndex); + const body = source.slice(functionIndex, nextFunctionIndex); + + expect(functionIndex).toBeGreaterThan(-1); + expect(body).toContain("isProjectPublishSnapshotShape(data.projectInfo)"); + expect(body).not.toContain("!Object.prototype.hasOwnProperty.call(data, \"projectInfo\")"); + }); + + it("keeps early termination out of regular visit schedule options", () => { + const source = readProjectDetail(); + + expect(source).toContain('const specialVisitOptions = ["筛选访视", "基线访视", "终止治疗"];'); + expect(source).toContain("提前终止不作为常规计划访视配置"); + }); +}); diff --git a/frontend/src/views/admin/ProjectDetail.vue b/frontend/src/views/admin/ProjectDetail.vue index 7671737b..8ec5fa79 100644 --- a/frontend/src/views/admin/ProjectDetail.vue +++ b/frontend/src/views/admin/ProjectDetail.vue @@ -1696,7 +1696,7 @@ - + + + +
+
特殊访视规则
+
+ 提前终止不作为常规计划访视配置;受试者发生提前终止时,在访视页通过“提前终止”单独录入,系统会自动关闭后续未完成计划访视。
@@ -2044,7 +2050,6 @@ import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "elem import { ArrowLeft, ArrowRight, Promotion, User, OfficeBuilding, Lock, SuccessFilled, ArrowDown, Close, List, EditPen, Connection, FolderOpened, Clock, RefreshLeft, DocumentCopy, Download, Delete } from "@element-plus/icons-vue"; import { fetchStudyDetail, - updateStudy, } from "../../api/studies"; import { listMembers } from "../../api/members"; import { fetchSites } from "../../api/sites"; @@ -2240,7 +2245,6 @@ const draftReady = ref(false); const suppressDraftWatch = ref(false); const autoSaveTimer = ref(undefined); const setupDirtySinceLastPersist = ref(false); -const projectDirtySinceLastPersist = ref(false); const suppressEnrollmentFieldSync = ref(false); const enrollmentPlanCycle = ref("month"); const enrollmentTargetsByCycle = ref>>({ @@ -2358,9 +2362,7 @@ const publishedProjectSnapshot = ref(null); const projectPublishCompareFallback = ref(null); const projectSnapshotAtLoad = ref(null); const setupServerSnapshot = ref(""); -const projectServerSnapshot = ref(""); const setupLocalDraftSavedAt = ref(""); -const projectLocalDraftSavedAt = ref(""); const setupSaveMeta = ref<{ updatedAt: string; savedBy: string | null; serverSynced: boolean }>({ updatedAt: "", savedBy: null, @@ -2400,7 +2402,7 @@ const form = ref({ const formBaselineSnapshot = ref(""); const regularVisitOptionValue = "__REGULAR_VISIT__"; const safetyFollowUpVisitOptionValue = "__SAFETY_FOLLOW_UP_VISIT__"; -const specialVisitOptions = ["筛选访视", "基线访视", "终止治疗", "提前终止"]; +const specialVisitOptions = ["筛选访视", "基线访视", "终止治疗"]; const getNextRegularVisitCode = (excludeIndex?: number): string => { const maxRegularIndex = form.value.visit_schedule.reduce((maxIndex, row, rowIndex) => { @@ -2538,7 +2540,31 @@ const formatVisitSchedule = (rows: VisitScheduleItem[] | undefined | null): stri const formatVisitWindow = (row: VisitScheduleItem): string => `-${row.window_before_days} / +${row.window_after_days} 天`; +const emptyProjectInfoDraft = (): ProjectPublishSnapshot => ({ + code: "", + name: "", + project_full_name: "", + sponsor: "", + protocol_no: "", + lead_unit: "", + principal_investigator: "", + main_pm: "", + research_analysis: "", + research_product: "", + control_product: "", + indication: "", + research_population: "", + research_design: "", + plan_start_date: "", + plan_end_date: "", + planned_site_count: null, + planned_enrollment_count: null, + status: "", + visit_schedule: [], +}); + const setupDraft = reactive({ + projectInfo: emptyProjectInfoDraft(), projectMilestones: [], enrollmentPlan: { totalTarget: 0, @@ -2628,10 +2654,10 @@ const currentStepTitle = computed(() => `第${activeStep.value + 1}步:${steps const setupPublishedVersionText = computed(() => { return setupPublishedVersion.value || "v0"; }); -const hasProjectPendingChanges = computed(() => hasFormUnsavedChanges.value || projectDirtySinceLastPersist.value); +const hasProjectPendingChanges = computed(() => hasFormUnsavedChanges.value); const draftSyncStatus = computed(() => { const formDirty = Boolean(formBaselineSnapshot.value && serializeFormForCompare() !== formBaselineSnapshot.value); - if (formDirty || setupDirtySinceLastPersist.value || projectDirtySinceLastPersist.value || Boolean(autoSaveTimer.value)) { + if (formDirty || setupDirtySinceLastPersist.value || Boolean(autoSaveTimer.value)) { return "DIRTY_UNSAVED"; } return setupSaveMeta.value.serverSynced ? "SYNCED" : "LOCAL_ONLY"; @@ -2646,6 +2672,7 @@ const setupWorkflowTagMeta = computed(() => SETUP_WORKFLOW const setupWorkflowTagLabel = computed(() => setupWorkflowTagMeta.value.label); const setupWorkflowTagType = computed<"success" | "warning" | "info">(() => setupWorkflowTagMeta.value.type); const isSetupDraftEffectivelyEmpty = (draft: SetupConfigDraft): boolean => { + if (!isProjectPublishSnapshotEmpty(draft.projectInfo)) return false; if (draft.projectMilestones.length > 0) return false; if (draft.siteMilestones.length > 0) return false; if (draft.siteEnrollmentPlans.length > 0) return false; @@ -2660,6 +2687,8 @@ const isSetupDraftEffectivelyEmpty = (draft: SetupConfigDraft): boolean => { if ((plan.stageBreakdown || "").trim()) return false; return true; }; +const isAtomicSetupDraft = (draft: SetupConfigDraft): boolean => + !isProjectPublishSnapshotEmpty(draft.projectInfo) || isSetupDraftEffectivelyEmpty(draft); const shouldShowEmptyDraftBaseInfo = computed(() => isSetupDraftEffectivelyEmpty(setupDraft)); const setupDraftBaseVersionText = computed(() => { const explicit = (setupActiveDraftBaseVersion.value || "").trim(); @@ -2702,7 +2731,14 @@ const serializeFormForCompare = (): string => status: form.value.status || "", visit_schedule: normalizeVisitSchedule(form.value.visit_schedule), }); -const serializeSetupForCompare = (): string => JSON.stringify(clone(setupDraft)); +const setupDraftForPersistence = (): SetupConfigDraft => ({ + ...clone(setupDraft), + projectInfo: buildProjectPublishSnapshot(), +}); +const syncProjectInfoIntoSetupDraft = () => { + setupDraft.projectInfo = buildProjectPublishSnapshot(); +}; +const serializeSetupForCompare = (): string => JSON.stringify(setupDraftForPersistence()); const buildProjectPublishSnapshotFromStudy = (study: Partial | null | undefined): ProjectPublishSnapshot => ({ code: study?.code || "", name: study?.name || "", @@ -2750,8 +2786,6 @@ const buildProjectPublishSnapshot = (): ProjectPublishSnapshot => }); const serializeProjectForPublishCompare = (): string => JSON.stringify(buildProjectPublishSnapshot()); const hasFormUnsavedChanges = computed(() => Boolean(formBaselineSnapshot.value && serializeFormForCompare() !== formBaselineSnapshot.value)); -const hasProjectDiffFromServer = (): boolean => - Boolean(projectServerSnapshot.value && serializeFormForCompare() !== projectServerSnapshot.value); const hasSetupDiffFromServer = (): boolean => Boolean(setupServerSnapshot.value && serializeSetupForCompare() !== setupServerSnapshot.value); const clearPendingAutoSave = () => { @@ -2759,24 +2793,30 @@ const clearPendingAutoSave = () => { window.clearTimeout(autoSaveTimer.value); autoSaveTimer.value = undefined; }; -const refreshProjectDirtyState = (): boolean => { - projectDirtySinceLastPersist.value = hasProjectDiffFromServer(); - return projectDirtySinceLastPersist.value; -}; const refreshSetupDirtyState = (): boolean => { setupDirtySinceLastPersist.value = hasSetupDiffFromServer(); return setupDirtySinceLastPersist.value; }; const markServerSyncedIfNoLocalChanges = () => { - if (projectDirtySinceLastPersist.value || setupDirtySinceLastPersist.value || autoSaveTimer.value) return; + if (setupDirtySinceLastPersist.value || autoSaveTimer.value) return; if (setupSaveMeta.value.serverSynced) return; setupSaveMeta.value = { ...setupSaveMeta.value, serverSynced: true, }; }; +const reconcileDraftSyncStateBeforePublish = () => { + const changed = refreshSetupDirtyState(); + if (changed) return; + clearPendingAutoSave(); + markSetupSynced(); + if (setupSaveMeta.value.serverSynced) { + clearLocalSetupDraft(); + clearLocalProjectDraft(); + } +}; const hasUnsavedChanges = computed( - () => hasProjectPendingChanges.value || setupDirtySinceLastPersist.value || Boolean(autoSaveTimer.value) + () => hasFormUnsavedChanges.value || setupDirtySinceLastPersist.value || Boolean(autoSaveTimer.value) ); const hasSetupDraftUnsavedChanges = computed(() => setupDirtySinceLastPersist.value || Boolean(autoSaveTimer.value)); const hasLeaveGuardChanges = computed( @@ -2999,6 +3039,13 @@ const clearSetupValidationErrors = () => { const markSetupSynced = () => { setupDirtySinceLastPersist.value = false; }; +const markSetupServerSynced = () => { + clearPendingAutoSave(); + setupServerSnapshot.value = serializeSetupForCompare(); + markSetupSynced(); + clearLocalSetupDraft(); + clearLocalProjectDraft(); +}; const updateLastServerSaveMeta = (updatedAt?: string | null, savedBy?: string | null) => { lastServerSaveMeta.value = { updatedAt: formatDisplayTime(updatedAt) || lastServerSaveMeta.value.updatedAt || "-", @@ -3918,6 +3965,7 @@ watch( const createDefaultSetupDraft = (_sites: Site[]): SetupConfigDraft => { return { + projectInfo: buildProjectPublishSnapshot(), projectMilestones: [], enrollmentPlan: { totalTarget: 0, @@ -3943,6 +3991,7 @@ const isSetupDraftShape = (value: unknown): value is SetupConfigDraft => { if (!value || typeof value !== "object") return false; const data = value as Record; return ( + isProjectPublishSnapshotShape(data.projectInfo) && Array.isArray(data.projectMilestones) && !!data.enrollmentPlan && Array.isArray(data.siteMilestones) && @@ -3963,14 +4012,47 @@ const isProjectPublishSnapshotShape = (value: unknown): value is ProjectPublishS ); }; +function isProjectPublishSnapshotEmpty(snapshot: ProjectPublishSnapshot): boolean { + return JSON.stringify(snapshot) === JSON.stringify(emptyProjectInfoDraft()); +} + +const applyProjectInfoDraft = (snapshot: ProjectPublishSnapshot) => { + suppressEnrollmentFieldSync.value = true; + form.value.code = snapshot.code || ""; + form.value.name = snapshot.name || ""; + form.value.project_full_name = snapshot.project_full_name || ""; + form.value.sponsor = snapshot.sponsor || ""; + form.value.protocol_no = snapshot.protocol_no || ""; + form.value.lead_unit = snapshot.lead_unit || ""; + form.value.principal_investigator = snapshot.principal_investigator || ""; + form.value.main_pm = snapshot.main_pm || ""; + form.value.research_analysis = snapshot.research_analysis || ""; + form.value.research_product = snapshot.research_product || ""; + form.value.control_product = snapshot.control_product || ""; + form.value.indication = snapshot.indication || ""; + form.value.research_population = snapshot.research_population || ""; + form.value.research_design = snapshot.research_design || ""; + form.value.plan_start_date = snapshot.plan_start_date || ""; + form.value.plan_end_date = snapshot.plan_end_date || ""; + form.value.planned_site_count = snapshot.planned_site_count ?? null; + form.value.planned_enrollment_count = snapshot.planned_enrollment_count ?? null; + form.value.status = snapshot.status || "DRAFT"; + form.value.visit_schedule = normalizeVisitSchedule(snapshot.visit_schedule); + suppressEnrollmentFieldSync.value = false; +}; + const applySetupDraft = (draft: SetupConfigDraft) => { suppressDraftWatch.value = true; + const projectInfo = isProjectPublishSnapshotShape(draft.projectInfo) ? clone(draft.projectInfo) : buildProjectPublishSnapshot(); + setupDraft.projectInfo = projectInfo; setupDraft.projectMilestones = normalizeProjectMilestoneRows(clone(draft.projectMilestones)); setupDraft.enrollmentPlan = clone(draft.enrollmentPlan); setupDraft.siteMilestones = clone(draft.siteMilestones); setupDraft.siteEnrollmentPlans = normalizeSiteEnrollmentPlans(clone(draft.siteEnrollmentPlans)); setupDraft.monitoringStrategies = clone(draft.monitoringStrategies); setupDraft.centerConfirm = clone(draft.centerConfirm); + applyProjectInfoDraft(projectInfo); + formBaselineSnapshot.value = serializeFormForCompare(); suppressDraftWatch.value = false; }; @@ -3995,7 +4077,7 @@ const persistSetupDraftToLocal = () => { storageKey.value, JSON.stringify({ version: DRAFT_VERSION, - data: clone(setupDraft), + data: setupDraftForPersistence(), savedAt, savedAtMs: nowMs(), ttlMs: LOCAL_DRAFT_TTL_MS, @@ -4020,7 +4102,7 @@ const cacheSetupDraftToLocal = () => { storageKey.value, JSON.stringify({ version: DRAFT_VERSION, - data: clone(setupDraft), + data: setupDraftForPersistence(), savedAt, savedAtMs: nowMs(), ttlMs: LOCAL_DRAFT_TTL_MS, @@ -4033,78 +4115,9 @@ const cacheSetupDraftToLocal = () => { } }; -const loadLocalProjectDraft = (): LocalDraftLoadResult> | null => { - try { - const raw = localStorage.getItem(projectDraftStorageKey.value); - if (!raw) return null; - return parseLocalDraftEnvelope(raw, (candidate) => { - if (!candidate || typeof candidate !== "object") return null; - return clone(candidate as Record); - }); - } catch { - return null; - } -}; - -const applyProjectDraft = (draft: Record) => { - const keys = Object.keys(form.value) as Array; - keys.forEach((key) => { - if (Object.prototype.hasOwnProperty.call(draft, key)) { - (form.value[key] as any) = draft[key as string] as any; - } - }); -}; - -const persistProjectDraftToLocal = () => { - try { - const savedAt = nowString(); - localStorage.setItem( - projectDraftStorageKey.value, - JSON.stringify({ - version: DRAFT_VERSION, - data: clone(form.value), - savedAt, - savedAtMs: nowMs(), - ttlMs: LOCAL_DRAFT_TTL_MS, - }) - ); - projectLocalDraftSavedAt.value = savedAt; - projectDirtySinceLastPersist.value = Boolean( - projectServerSnapshot.value && serializeFormForCompare() !== projectServerSnapshot.value - ); - setupSaveMeta.value = { - updatedAt: savedAt, - savedBy: authStore.user?.full_name || authStore.user?.username || authStore.user?.email || "当前用户", - serverSynced: false, - }; - } catch { - // ignore local storage errors - } -}; - -const cacheProjectDraftToLocal = () => { - try { - const savedAt = nowString(); - localStorage.setItem( - projectDraftStorageKey.value, - JSON.stringify({ - version: DRAFT_VERSION, - data: clone(form.value), - savedAt, - savedAtMs: nowMs(), - ttlMs: LOCAL_DRAFT_TTL_MS, - }) - ); - projectLocalDraftSavedAt.value = savedAt; - } catch { - // ignore local storage errors - } -}; - const clearLocalProjectDraft = () => { try { localStorage.removeItem(projectDraftStorageKey.value); - projectLocalDraftSavedAt.value = ""; } catch { // ignore local storage errors } @@ -4147,6 +4160,10 @@ const loadSetupDraftFromServer = async (): Promise => { try { const { data } = await fetchSetupConfig(project.value.id); if (!isSetupDraftShape(data?.data)) return null; + const draft = { + ...clone(data.data), + projectInfo: isProjectPublishSnapshotShape(data.data.projectInfo) ? clone(data.data.projectInfo) : buildProjectPublishSnapshot(), + }; applySetupResponseMeta(data); updateLastServerSaveMeta(data.updated_at, data.saved_by_name || resolveSavedByDisplay(data.saved_by)); setupSaveMeta.value = { @@ -4154,7 +4171,7 @@ const loadSetupDraftFromServer = async (): Promise => { savedBy: data.saved_by_name || resolveSavedByDisplay(data.saved_by), serverSynced: true, }; - return clone(data.data); + return draft; } catch { return null; } @@ -4177,6 +4194,7 @@ const setupModuleLabels: Array<{ key: keyof SetupConfigDraft; label: string }> = { key: "centerConfirm", label: "中心确认" }, ]; const emptySetupDraft: SetupConfigDraft = { + projectInfo: emptyProjectInfoDraft(), projectMilestones: [], enrollmentPlan: { totalTarget: 0, @@ -4199,7 +4217,8 @@ const hasBranchPublishDiff = computed(() => { }); const projectPublishCompareBase = computed(() => { const base = resolveProjectPublishCompareBase(publishedProjectSnapshot.value, projectPublishCompareFallback.value); - return base ? clone(base) : null; + if (base) return clone(base); + return isFirstPublish.value ? emptyProjectInfoDraft() : null; }); const hasProjectPublishDiff = computed(() => { return hasProjectSnapshotDiff(buildProjectPublishSnapshot(), projectPublishCompareBase.value); @@ -4278,7 +4297,7 @@ const refreshSetupDraftFromServer = async (options?: { silent?: boolean }) => { if (!latest) return false; applySetupDraft(latest); clearSetupValidationErrors(); - markSetupSynced(); + markSetupServerSynced(); if (!options?.silent) { ElMessage.info("已刷新服务端最新配置"); } @@ -4292,26 +4311,20 @@ const persistSetupDraft = async ( if (!project.value) return { serverSynced: false, retryable: false }; const payload: StudySetupConfigUpsertPayload = { expected_version: setupRevision.value, - data: clone(setupDraft), + data: setupDraftForPersistence(), force_draft: Boolean(options?.forceDraft), }; try { const { data } = await upsertSetupConfig(project.value.id, payload); applySetupResponseMeta(data); clearSetupValidationErrors(); - markSetupSynced(); - setupServerSnapshot.value = serializeSetupForCompare(); - clearLocalSetupDraft(); + markSetupServerSynced(); setupSaveMeta.value = { updatedAt: formatDisplayTime(data.updated_at), savedBy: data.saved_by_name || resolveSavedByDisplay(data.saved_by), serverSynced: true, }; updateLastServerSaveMeta(data.updated_at, data.saved_by_name || resolveSavedByDisplay(data.saved_by)); - if (autoSaveTimer.value) { - window.clearTimeout(autoSaveTimer.value); - autoSaveTimer.value = undefined; - } return { serverSynced: true, retryable: false }; } catch (e: any) { if (handleValidationFailure(e, "配置保存失败,请修正后重试")) { @@ -4500,23 +4513,27 @@ const initializeSetupDraft = async () => { const localDraft = loadLocalSetupDraft(); if (localDraft) { - const shouldRestore = await confirmRestoreLocalDraft("恢复立项配置草稿", localDraft.savedAt, { - expired: localDraft.expired, - conflict: localDraft.conflict, - }); - if (shouldRestore) { - applySetupDraft(localDraft.data); - setupDirtySinceLastPersist.value = true; - setupLocalDraftSavedAt.value = localDraft.savedAt; - setupSaveMeta.value = { - updatedAt: localDraft.savedAt || nowString(), - savedBy: authStore.user?.full_name || authStore.user?.username || authStore.user?.email || "当前用户", - serverSynced: false, - }; - draftReady.value = true; - return; + if (!isAtomicSetupDraft(localDraft.data)) { + clearLocalSetupDraft(); + } else { + const shouldRestore = await confirmRestoreLocalDraft("恢复立项配置草稿", localDraft.savedAt, { + expired: localDraft.expired, + conflict: localDraft.conflict, + }); + if (shouldRestore) { + applySetupDraft(localDraft.data); + setupDirtySinceLastPersist.value = true; + setupLocalDraftSavedAt.value = localDraft.savedAt; + setupSaveMeta.value = { + updatedAt: localDraft.savedAt || nowString(), + savedBy: authStore.user?.full_name || authStore.user?.username || authStore.user?.email || "当前用户", + serverSynced: false, + }; + draftReady.value = true; + return; + } + clearLocalSetupDraft(); } - clearLocalSetupDraft(); } applySetupDraft(createDefaultSetupDraft(siteOptions.value)); @@ -4582,37 +4599,9 @@ const loadProject = async () => { project.value = data as Study; syncFormFromProjectWithoutSetupLink(project.value); projectSnapshotAtLoad.value = buildProjectPublishSnapshot(); - projectServerSnapshot.value = serializeFormForCompare(); - const serverProjectSnapshot = serializeFormForCompare(); - const localProjectDraft = loadLocalProjectDraft(); - if (localProjectDraft) { - const shouldRestore = await confirmRestoreLocalDraft("恢复项目基础信息草稿", localProjectDraft.savedAt, { - expired: localProjectDraft.expired, - }); - if (shouldRestore) { - applyProjectDraft(localProjectDraft.data); - const localSnapshot = serializeFormForCompare(); - if (localSnapshot !== serverProjectSnapshot) { - projectDirtySinceLastPersist.value = true; - projectLocalDraftSavedAt.value = localProjectDraft.savedAt; - setupSaveMeta.value = { - updatedAt: localProjectDraft.savedAt || nowString(), - savedBy: authStore.user?.full_name || authStore.user?.username || authStore.user?.email || "当前用户", - serverSynced: false, - }; - } else { - clearLocalProjectDraft(); - projectDirtySinceLastPersist.value = false; - } - } else { - clearLocalProjectDraft(); - projectDirtySinceLastPersist.value = false; - } - } else { - projectDirtySinceLastPersist.value = false; - } + clearLocalProjectDraft(); formBaselineSnapshot.value = serializeFormForCompare(); - if (setupSaveMeta.value.serverSynced && !projectDirtySinceLastPersist.value) { + if (setupSaveMeta.value.serverSynced) { setupDirtySinceLastPersist.value = false; if (autoSaveTimer.value) { window.clearTimeout(autoSaveTimer.value); @@ -4638,7 +4627,6 @@ const refreshProjectDisplayAfterPublish = async () => { const { data } = await fetchStudyDetail(project.value.id); project.value = data as Study; syncFormFromProjectWithoutSetupLink(project.value); - projectServerSnapshot.value = serializeFormForCompare(); formBaselineSnapshot.value = serializeFormForCompare(); if (studyStore.currentStudy?.id === project.value.id) { studyStore.setCurrentStudy({ ...(studyStore.currentStudy as Study), ...project.value } as Study); @@ -4693,14 +4681,7 @@ const startStep1SectionEdit = (section: Exclude) => { }; const cancelEdit = () => { - syncFormFromProjectWithoutSetupLink(project.value); - if (projectDirtySinceLastPersist.value) { - const localProjectDraft = loadLocalProjectDraft(); - if (localProjectDraft) { - applyProjectDraft(localProjectDraft.data); - formBaselineSnapshot.value = serializeFormForCompare(); - } - } + applyProjectInfoDraft(setupDraft.projectInfo); isEditing.value = false; step1EditSection.value = "all"; visitScheduleDialogVisible.value = false; @@ -4753,13 +4734,16 @@ const saveVisitScheduleDialogEdit = async () => { if (!canEditSetup.value) return; drawerConfirmLoading.value = true; try { - const projectChanged = refreshProjectDirtyState(); - if (projectChanged) { - persistProjectDraftToLocal(); + syncProjectInfoIntoSetupDraft(); + const setupChanged = refreshSetupDirtyState(); + if (setupChanged) { + persistSetupDraftToLocal(); } else { - clearLocalProjectDraft(); + clearLocalSetupDraft(); + clearPendingAutoSave(); markServerSyncedIfNoLocalChanges(); } + clearLocalProjectDraft(); formBaselineSnapshot.value = serializeFormForCompare(); visitScheduleDialogVisible.value = false; isEditing.value = false; @@ -4780,13 +4764,16 @@ const handleDrawerConfirm = async () => { if (formRef.value) { await formRef.value.validate(); } - const projectChanged = refreshProjectDirtyState(); - if (projectChanged) { - persistProjectDraftToLocal(); + syncProjectInfoIntoSetupDraft(); + const setupChanged = refreshSetupDirtyState(); + if (setupChanged) { + persistSetupDraftToLocal(); } else { - clearLocalProjectDraft(); + clearLocalSetupDraft(); + clearPendingAutoSave(); markServerSyncedIfNoLocalChanges(); } + clearLocalProjectDraft(); formBaselineSnapshot.value = serializeFormForCompare(); saved = true; } else { @@ -4808,62 +4795,10 @@ const handleDrawerConfirm = async () => { } }; -const saveEdit = async (closeAfterSave = true, showSuccessMessage = true): Promise => { - if (!project.value) return false; - if (formRef.value) { - await formRef.value.validate(); - } - submitting.value = true; - try { - const { data } = await updateStudy(project.value.id, { - code: form.value.code.trim(), - name: form.value.name.trim(), - project_full_name: form.value.project_full_name?.trim() || null, - sponsor: form.value.sponsor?.trim() || null, - protocol_no: form.value.protocol_no?.trim() || null, - lead_unit: form.value.lead_unit?.trim() || null, - principal_investigator: form.value.principal_investigator?.trim() || null, - main_pm: form.value.main_pm?.trim() || null, - research_analysis: form.value.research_analysis?.trim() || null, - research_product: form.value.research_product?.trim() || null, - control_product: form.value.control_product?.trim() || null, - indication: form.value.indication?.trim() || null, - research_population: form.value.research_population?.trim() || null, - research_design: form.value.research_design?.trim() || null, - plan_start_date: form.value.plan_start_date || null, - plan_end_date: form.value.plan_end_date || null, - planned_site_count: form.value.planned_site_count, - planned_enrollment_count: form.value.planned_enrollment_count, - phase: form.value.phase?.trim() || null, - status: form.value.status, - visit_schedule: normalizeVisitSchedule(form.value.visit_schedule), - }); - project.value = data as Study; - syncFormFromProjectWithoutSetupLink(project.value); - projectServerSnapshot.value = serializeFormForCompare(); - projectDirtySinceLastPersist.value = false; - clearLocalProjectDraft(); - formBaselineSnapshot.value = serializeFormForCompare(); - if (closeAfterSave) { - isEditing.value = false; - step1EditSection.value = "all"; - } - if (showSuccessMessage) { - ElMessage.success("项目信息已保存"); - } - return true; - } catch (e: any) { - ElMessage.error(getApiErrorMessage(e, TEXT.common.messages.saveFailed)); - return false; - } finally { - submitting.value = false; - } -}; - const captureProjectCompareFallbackBeforeSave = (): ProjectPublishSnapshot | null => { if ( !shouldCaptureProjectCompareFallback({ - hasProjectPendingChanges: hasProjectPendingChanges.value, + hasProjectPendingChanges: hasFormUnsavedChanges.value, hasPublishedProjectSnapshot: Boolean(publishedProjectSnapshot.value), isFirstPublish: isFirstPublish.value, }) @@ -4880,11 +4815,11 @@ const applyCapturedProjectCompareFallback = (snapshot: ProjectPublishSnapshot | const saveConfigNow = async (): Promise => { if (!canSaveDraftAction.value) return false; const projectSnapshotBeforeSave = captureProjectCompareFallbackBeforeSave(); - if (hasProjectPendingChanges.value) { - const basicSaved = await saveEdit(false, false); - if (!basicSaved) return false; + if (hasFormUnsavedChanges.value) { + syncProjectInfoIntoSetupDraft(); + setupDirtySinceLastPersist.value = true; } - const result = await persistSetupDraftWithRetry(false, { forceDraft: hasProjectPendingChanges.value }); + const result = await persistSetupDraftWithRetry(false, { forceDraft: true }); if (!result.serverSynced) return false; applyCapturedProjectCompareFallback(projectSnapshotBeforeSave); formBaselineSnapshot.value = serializeFormForCompare(); @@ -4894,9 +4829,13 @@ const saveConfigNow = async (): Promise => { const doPublishConfigNow = async (): Promise => { if (!project.value || !canPublishAction.value) return false; try { - if (hasProjectPendingChanges.value) { - const basicSaved = await saveEdit(false, false); - if (!basicSaved) return false; + if (hasFormUnsavedChanges.value) { + syncProjectInfoIntoSetupDraft(); + setupDirtySinceLastPersist.value = true; + } + if (setupDirtySinceLastPersist.value || Boolean(autoSaveTimer.value)) { + const draftSaved = await persistSetupDraftWithRetry(false, { forceDraft: true }); + if (!draftSaved.serverSynced) return false; } const { data } = await publishConfig(project.value.id, { expected_version: setupRevision.value }); applySetupResponseMeta(data); @@ -4950,6 +4889,7 @@ const doPublishConfigNow = async (): Promise => { const publishConfigNow = async () => { if (!project.value || !canManageSetup.value) return; + reconcileDraftSyncStateBeforePublish(); if (hasSetupDraftUnsavedChanges.value) { ElMessage.warning("当前存在未上传的本地草稿,请先点击“保存配置”"); return; @@ -5734,7 +5674,7 @@ const handleClearDraft = async () => { if (!project.value || !canManageSetup.value) return; try { await ElMessageBox.confirm( - "确认清空当前立项配置草稿(第2-7步)?该操作不会影响已发布版本,也不会影响第1步项目信息主数据。", + "确认清空当前立项配置草稿的所有数据?该操作不会影响已发布版本。", "清空草稿确认", { type: "warning", @@ -5751,10 +5691,11 @@ const handleClearDraft = async () => { expected_version: setupRevision.value, }); applySetupResponseMeta(data); - applySetupDraft(createDefaultSetupDraft(siteOptions.value)); + applySetupDraft(clone(data.data)); clearSetupValidationErrors(); markSetupSynced(); clearLocalSetupDraft(); + clearLocalProjectDraft(); setupViewMode.value = "draft"; setupSaveMeta.value = { updatedAt: formatDisplayTime(data.updated_at), @@ -5764,10 +5705,7 @@ const handleClearDraft = async () => { updateLastServerSaveMeta(data.updated_at, data.saved_by_name || resolveSavedByDisplay(data.saved_by)); await refreshSetupDraftFromServer({ silent: true }); await loadSetupVersionHistory(); - if (activeStep.value === 0) { - activeStep.value = 1; - } - ElMessage.success(`草稿已清空(第2-7步已重置,当前发布仍为 ${setupPublishedVersionText.value})`); + ElMessage.success(`草稿已清空(当前发布仍为 ${setupPublishedVersionText.value})`); } catch (e: any) { if (e?.response?.status === 409) { ElMessage.warning("清空失败:版本冲突,正在刷新最新配置"); @@ -8407,6 +8345,27 @@ onBeforeUnmount(() => { margin-top: 0; } +.visit-special-rule { + margin-top: 14px; + padding: 12px 14px; + border: 1px solid #dce6f2; + border-radius: 8px; + background: #f8fbff; +} + +.visit-special-rule-title { + color: #0f172a; + font-size: 13px; + font-weight: 800; +} + +.visit-special-rule-body { + margin-top: 6px; + color: #475569; + font-size: 13px; + line-height: 1.6; +} + .visit-schedule-dialog :deep(.el-dialog) { border-radius: 10px; } diff --git a/frontend/src/views/drug/ShipmentForm.vue b/frontend/src/views/drug/ShipmentForm.vue index edcf1c79..eaee7b6c 100644 --- a/frontend/src/views/drug/ShipmentForm.vue +++ b/frontend/src/views/drug/ShipmentForm.vue @@ -11,7 +11,7 @@

{{ TEXT.common.labels.basicInfo }}

- + - + @@ -32,7 +32,7 @@ - + @@ -47,32 +47,32 @@

{{ TEXT.modules.drugShipments.recordLabel }}

- + - + - + - + - + - + diff --git a/frontend/src/views/ia/RiskIssueMonitoringVisits.vue b/frontend/src/views/ia/RiskIssueMonitoringVisits.vue index e33b2dc4..06edad10 100644 --- a/frontend/src/views/ia/RiskIssueMonitoringVisits.vue +++ b/frontend/src/views/ia/RiskIssueMonitoringVisits.vue @@ -1,56 +1,154 @@