merge: 同步项目配置草稿修复

This commit is contained in:
Cheng Zhou
2026-05-11 11:03:56 +08:00
48 changed files with 3930 additions and 737 deletions
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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
+135 -10
View File
@@ -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(
+149 -28
View File
@@ -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,
+56 -1
View File
@@ -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,
+40 -1
View File
@@ -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,
+6
View File
@@ -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(
+6
View File
@@ -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)
+145
View File
@@ -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
+8 -1
View File
@@ -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)
+2 -1
View File
@@ -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(
@@ -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",
)
+40 -51
View File
@@ -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
+2
View File
@@ -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
+6
View File
@@ -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
+76
View File
@@ -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()
+29 -7
View File
@@ -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()
+26
View File
@@ -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
@@ -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 == []
@@ -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"
@@ -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")
+86 -1
View File
@@ -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")
+446 -179
View File
@@ -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=<staging-strong-random-secret>
LOGIN_RSA_KEY_ID=staging-YYYYMMDD
LOGIN_RSA_PRIVATE_KEY=<staging-rsa-private-key-pem-with-\n-escaped-newlines>
```
生成密钥示例:
生成 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://<main-domain>/health
curl -i https://<main-domain>/api/v1/auth/login-key
```
### 6. 验收门禁
接口应返回 `HTTP/1.1 200 OK`
在把 `main` 推进到 `release` 前,至少执行:
### 11. 打开系统
本机:
```text
http://localhost:8888
```
远程访问:
```text
https://<main-domain>
```
远程访问必须使用 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=<production-strong-random-secret>
LOGIN_RSA_KEY_ID=prod-YYYYMMDD
LOGIN_RSA_PRIVATE_KEY=<production-rsa-private-key-pem-with-\n-escaped-newlines>
```text
https://<production-domain>
```
要求
如果服务器前面有反向代理,确认代理规则
- `.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://<production-domain>/health
curl -i https://<production-domain>/api/v1/auth/login-key
```
预期:
两个接口都应返回 `HTTP/1.1 200 OK`
### 12. 打开系统并登录
```text
https://<production-domain>
```
初始化管理员:
```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://<production-domain> EMAIL=<admin-email> PASSWORD=<admin-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://<domain>
```
将输出 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)"
```
+32
View File
@@ -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" } })
);
});
});
+2 -2
View File
@@ -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,
});
+8 -1
View File
@@ -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<string, any>) =>
apiPost(`/api/v1/studies/${studyId}/subjects/${subjectId}/visits/`, payload);
export const createEarlyTerminationVisit = (
studyId: string,
subjectId: string,
payload: Record<string, any>,
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<string, any>) =>
apiPatch(`/api/v1/studies/${studyId}/subjects/${subjectId}/visits/${visitId}`, payload);
@@ -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 {
@@ -45,7 +45,7 @@
</el-table>
</div>
<el-dialog append-to=".layout-main .content-wrapper" v-model="previewVisible" :title="previewTitle" width="720px" :close-on-click-modal="false">
<el-dialog v-if="previewVisible" append-to-body v-model="previewVisible" :title="previewTitle" width="720px" :close-on-click-modal="false">
<div v-if="previewError" class="preview-error">
{{ previewError }}
</div>
+1
View File
@@ -58,6 +58,7 @@ export interface CenterConfirmDraft {
}
export interface SetupConfigDraft {
projectInfo: ProjectPublishSnapshot;
projectMilestones: ProjectMilestoneDraft[];
enrollmentPlan: EnrollmentPlanDraft;
siteMilestones: SiteMilestoneDraft[];
+22
View File
@@ -32,6 +32,28 @@ const statusLabelMap: Record<string, string> = {
};
const setupFieldLabelMap: Record<keyof SetupConfigDraft, Record<string, string>> = {
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: "里程碑",
@@ -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([]);
});
});
+30 -2
View File
@@ -122,6 +122,34 @@ const PROJECT_FIELD_LABEL_MAP: Record<keyof ProjectPublishSnapshot, string> = {
visit_schedule: "访视计划",
};
const PROJECT_FIELD_MODULE_LABEL_MAP: Record<keyof ProjectPublishSnapshot, string> = {
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<keyof ProjectPublishSnapshot>).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]),
+48
View File
@@ -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");
});
});
+273 -2
View File
@@ -42,7 +42,8 @@
type="email"
placeholder="请输入邮箱"
size="large"
autocomplete="email"
name="username"
autocomplete="username"
class="login-input"
>
<template #prefix>
@@ -62,6 +63,7 @@
placeholder="请输入密码"
show-password
size="large"
name="password"
autocomplete="current-password"
class="login-input"
>
@@ -74,6 +76,21 @@
</el-input>
</el-form-item>
<div class="login-options">
<el-checkbox v-model="form.rememberPassword" class="remember-checkbox">
<span class="remember-text">记住密码</span>
</el-checkbox>
<el-checkbox v-model="form.agreeProtocol" class="protocol-checkbox">
<span class="protocol-text">
我已阅读并同意
<button type="button" class="protocol-link" @click.stop.prevent="openProtocolDialog">
用户协议
</button>
登录即代表同意当前协议
</span>
</el-checkbox>
</div>
<el-button type="primary" :loading="loading" @click="onSubmit" size="large" class="login-btn">
</el-button>
@@ -85,6 +102,27 @@
</p>
</div>
</div>
<el-dialog
v-model="protocolDialogVisible"
title="用户协议"
width="860px"
class="protocol-dialog"
append-to-body
align-center
>
<div class="protocol-content">
<section v-for="section in protocolSections" :key="section.title" class="protocol-section">
<h3>{{ section.title }}</h3>
<p v-for="paragraph in section.paragraphs" :key="paragraph">{{ paragraph }}</p>
</section>
</div>
<template #footer>
<el-button type="primary" size="large" class="protocol-confirm-btn" @click="confirmProtocol">
</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -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<FormInstance>();
const form = reactive({
email: "",
password: "",
rememberPassword: false,
agreeProtocol: false,
});
const rules: FormRules<typeof form> = {
@@ -115,22 +159,118 @@ const rules: FormRules<typeof form> = {
};
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;
}
}
</style>
@@ -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("提前终止不作为常规计划访视配置");
});
});
+187 -228
View File
@@ -1696,7 +1696,7 @@
<el-option label="常规访视(自动生成 Vn" :value="regularVisitOptionValue" />
<el-option label="安全性随访(自动生成 Vn" :value="safetyFollowUpVisitOptionValue" />
</el-option-group>
<el-option-group label="特殊访视">
<el-option-group label="计划特殊访视">
<el-option
v-for="option in specialVisitOptions"
:key="option"
@@ -1725,6 +1725,12 @@
class="visit-schedule-remove"
@click="removeVisitScheduleRow(index)"
/>
</div>
</div>
<div class="visit-special-rule">
<div class="visit-special-rule-title">特殊访视规则</div>
<div class="visit-special-rule-body">
提前终止不作为常规计划访视配置受试者发生提前终止时在访视页通过提前终止单独录入系统会自动关闭后续未完成计划访视
</div>
</div>
</el-form>
@@ -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<number | undefined>(undefined);
const setupDirtySinceLastPersist = ref(false);
const projectDirtySinceLastPersist = ref(false);
const suppressEnrollmentFieldSync = ref(false);
const enrollmentPlanCycle = ref<EnrollmentCycle>("month");
const enrollmentTargetsByCycle = ref<Record<EnrollmentCycle, Record<string, number>>>({
@@ -2358,9 +2362,7 @@ const publishedProjectSnapshot = ref<ProjectPublishSnapshot | null>(null);
const projectPublishCompareFallback = ref<ProjectPublishSnapshot | null>(null);
const projectSnapshotAtLoad = ref<ProjectPublishSnapshot | null>(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<SetupConfigDraft>({
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<DraftSyncStatus>(() => {
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<SetupWorkflowTagMeta>(() => 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<Study> | 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<string, unknown>;
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<Record<string, unknown>> | 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<string, unknown>);
});
} catch {
return null;
}
};
const applyProjectDraft = (draft: Record<string, unknown>) => {
const keys = Object.keys(form.value) as Array<keyof typeof form.value>;
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<SetupConfigDraft | null> => {
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<SetupConfigDraft | null> => {
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<ProjectPublishSnapshot | null>(() => {
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<Step1EditSection, "all">) => {
};
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<boolean> => {
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<boolean> => {
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<boolean> => {
const doPublishConfigNow = async (): Promise<boolean> => {
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<boolean> => {
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;
}
+9 -9
View File
@@ -11,7 +11,7 @@
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" label-position="top">
<h3 class="section-title">{{ TEXT.common.labels.basicInfo }}</h3>
<el-row :gutter="24">
<el-col :span="12">
<el-col :xs="24" :sm="12" :md="8" :xl="6">
<el-form-item :label="TEXT.common.fields.site" prop="center_id">
<el-select v-model="form.center_id" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
<el-option
@@ -24,7 +24,7 @@
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-col :xs="24" :sm="12" :md="8" :xl="6">
<el-form-item :label="TEXT.common.fields.direction" prop="direction">
<el-select v-model="form.direction" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
<el-option :label="TEXT.enums.shipmentDirection.SEND" value="SEND" />
@@ -32,7 +32,7 @@
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-col :xs="24" :sm="12" :md="8" :xl="6">
<el-form-item :label="TEXT.common.fields.status" prop="status">
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
<el-option :label="TEXT.enums.shipmentStatus.PENDING" value="PENDING" />
@@ -47,32 +47,32 @@
<h3 class="section-title">{{ TEXT.modules.drugShipments.recordLabel }}</h3>
<el-row :gutter="24">
<el-col :span="12">
<el-col :xs="24" :sm="12" :md="8" :xl="6">
<el-form-item :label="TEXT.common.fields.shipDate" prop="ship_date">
<el-date-picker v-model="form.ship_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-col :xs="24" :sm="12" :md="8" :xl="6">
<el-form-item :label="TEXT.common.fields.receiveDate" prop="receive_date">
<el-date-picker v-model="form.receive_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-col :xs="24" :sm="12" :md="8" :xl="6">
<el-form-item :label="TEXT.common.fields.quantity" prop="quantity">
<el-input-number v-model="form.quantity" :min="0" :controls="false" :placeholder="TEXT.common.placeholders.input" style="width: 100%" :disabled="isReadOnly" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-col :xs="24" :sm="12" :md="8" :xl="6">
<el-form-item :label="TEXT.common.fields.batchNo" prop="batch_no">
<el-input v-model="form.batch_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.batchNo" :disabled="isReadOnly" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-col :xs="24" :sm="12" :md="8" :xl="6">
<el-form-item :label="TEXT.common.fields.carrier" prop="carrier">
<el-input v-model="form.carrier" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.carrier" :disabled="isReadOnly" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-col :xs="24" :sm="12" :md="8" :xl="6">
<el-form-item :label="TEXT.common.fields.trackingNo" prop="tracking_no">
<el-input v-model="form.tracking_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.trackingNo" :disabled="isReadOnly" />
</el-form-item>
File diff suppressed because it is too large Load Diff
@@ -52,6 +52,10 @@ describe("route view overlays are mounted only when visible", () => {
'v-model="viewDialogVisible"',
],
},
{
file: "../components/attachments/AttachmentList.vue",
snippets: ['<el-dialog v-if="previewVisible"', 'append-to-body', 'v-model="previewVisible"'],
},
{
file: "./admin/ProjectDetail.vue",
snippets: [
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSubjectDetail = () => readFileSync(resolve(__dirname, "./SubjectDetail.vue"), "utf8");
describe("SubjectDetail medication adherence", () => {
it("adds a medication adherence tab with editable actual count", () => {
const source = readSubjectDetail();
expect(source).toContain('name="medicationAdherence"');
expect(source).toContain("actual_medication_count");
expect(source).toContain("保存用药次数");
expect(source).toContain("实际用药次数为试验期间实际药物暴露次数");
expect(source).toContain("75%~125% 范围内(包含边界值)");
});
it("uses baseline and termination visit actual dates for adherence calculation", () => {
const source = readSubjectDetail();
expect(source).toContain("const medicationStartDate = computed(() => detail.baseline_date || \"\");");
expect(source).toContain('new Set(["终止治疗", "提前终止"])');
expect(source).toContain("return Math.floor((end.getTime() - start.getTime()) / 86400000) + 1;");
});
it("marks adherence outside 75 to 125 percent as abnormal", () => {
const source = readSubjectDetail();
expect(source).toContain("if (rate < 75 || rate > 125) return \"danger\";");
expect(source).not.toContain("if (rate < 80 || rate > 120) return \"danger\";");
});
it("provides a dedicated early termination workflow", () => {
const source = readSubjectDetail();
expect(source).toContain("openEarlyTerminationDialog");
expect(source).toContain("createEarlyTerminationVisit");
expect(source).toContain("return \"不适用\";");
expect(source).toContain("getVisitStatusLabel");
expect(source).toContain("ElMessage.success(TEXT.common.messages.saveSuccess);");
expect(source).toContain("提前终止已保存,刷新数据失败,请手动刷新页面");
expect(source).toContain("提前终止表示受试者在方案最后一个计划访视窗口期前停止治疗");
expect(source).toContain("validateEarlyTerminationDateInput");
expect(source).toContain("提前终止日期必须早于最后计划访视窗口期开始日");
expect(source).toContain("getVisitPlannedDateLabel");
expect(source).toContain("getVisitWindowRangeLabel");
expect(source).toContain("");
expect(source).toContain("row.window_start === row.planned_date && row.window_end === row.planned_date");
expect(source).toContain("return \"不适用\";");
});
});
+312 -5
View File
@@ -88,9 +88,15 @@
<el-card class="unified-shell subject-shell subject-tabs-shell">
<div class="subject-tabs-action">
<el-button type="primary" :disabled="isReadOnly" @click="currentTabAction.onClick">
<el-button type="primary" :loading="currentTabAction.loading" :disabled="isReadOnly" @click="currentTabAction.onClick">
{{ currentTabAction.label }}
</el-button>
<el-button v-if="activeTab === 'visits'" type="warning" plain :disabled="isReadOnly" @click="openEarlyTerminationDialog">
提前终止
</el-button>
<el-button v-if="adherenceEditing && activeTab === 'medicationAdherence'" :disabled="isReadOnly" @click="adherenceEditing = false">
{{ TEXT.common.actions.cancel }}
</el-button>
</div>
<el-tabs v-model="activeTab">
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.history" name="history">
@@ -131,7 +137,14 @@
<el-table :data="visitItems" v-loading="loadingVisits" style="width: 100%" class="subject-detail-table" table-layout="fixed">
<el-table-column prop="visit_code" :label="TEXT.common.fields.visitCode" show-overflow-tooltip />
<el-table-column prop="planned_date" :label="TEXT.common.fields.plannedDate" show-overflow-tooltip>
<template #default="scope">{{ displayDate(scope.row.planned_date) }}</template>
<template #default="scope">
<div class="visit-planned-cell">
<span>{{ getVisitPlannedDateLabel(scope.row) }}</span>
<span v-if="getVisitWindowRangeLabel(scope.row)" class="visit-window-range">
{{ getVisitWindowRangeLabel(scope.row) }}
</span>
</div>
</template>
</el-table-column>
<el-table-column prop="actual_date" :label="TEXT.common.fields.actualDate" show-overflow-tooltip>
<template #default="scope">
@@ -151,7 +164,7 @@
:type="getVisitStatusTagType(getVisitStatus(scope.row, visitEditingRowId === scope.row.id ? visitRowDraft.actual_date : undefined))"
effect="light"
>
{{ displayEnum(TEXT.enums.visitStatus, getVisitStatus(scope.row, visitEditingRowId === scope.row.id ? visitRowDraft.actual_date : undefined)) }}
{{ getVisitStatusLabel(scope.row, visitEditingRowId === scope.row.id ? visitRowDraft.actual_date : undefined) }}
</el-tag>
</template>
</el-table-column>
@@ -205,6 +218,43 @@
</el-table>
</el-tab-pane>
<el-tab-pane label="用药依从性" name="medicationAdherence">
<div class="adherence-panel">
<el-descriptions :column="2" border>
<el-descriptions-item label="开始用药日期">
{{ displayDate(medicationStartDate) }}
</el-descriptions-item>
<el-descriptions-item label="结束用药日期">
{{ displayDate(medicationEndDate) }}
</el-descriptions-item>
<el-descriptions-item label="结束日期来源">
{{ medicationEndVisitLabel }}
</el-descriptions-item>
<el-descriptions-item label="理论用药次数">
{{ expectedMedicationCountText }}
</el-descriptions-item>
<el-descriptions-item label="实际用药次数">
<el-input-number
v-if="adherenceEditing"
v-model="adherenceForm.actual_medication_count"
:min="0"
:controls="false"
size="small"
class="adherence-count-input"
:disabled="isReadOnly"
/>
<span v-else>{{ actualMedicationCountText }}</span>
</el-descriptions-item>
<el-descriptions-item label="用药依从性">
<el-tag :type="adherenceRateTagType" effect="light">{{ medicationAdherenceRateText }}</el-tag>
</el-descriptions-item>
</el-descriptions>
<div class="adherence-hint">
用药依从性 = 实际用药次数 ÷ 理论用药次数 × 100%实际用药次数为试验期间实际药物暴露次数理论用药次数 =结束用药日期 - 开始用药日期 + 1× 1/试验期间的用药依从性原则上要求在 75%~125% 范围内包含边界值
</div>
</div>
</el-tab-pane>
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.ae" name="ae">
<el-table :data="aeItems" v-loading="loadingAes" style="width: 100%" class="subject-detail-table" table-layout="fixed">
<el-table-column prop="onset_date" :label="TEXT.common.fields.occurDate" show-overflow-tooltip>
@@ -347,6 +397,35 @@
</template>
</el-dialog>
<el-dialog
v-if="earlyTerminationDialogVisible"
append-to=".layout-main .content-wrapper"
v-model="earlyTerminationDialogVisible"
title="提前终止"
width="560px"
>
<el-form :model="earlyTerminationForm" label-width="110px">
<el-form-item label="提前终止日期" required>
<el-date-picker v-model="earlyTerminationForm.termination_date" type="date" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item label="提前终止原因" required>
<el-input v-model="earlyTerminationForm.reason" type="textarea" :rows="2" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.remark">
<el-input v-model="earlyTerminationForm.notes" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<div class="early-termination-hint">
提前终止表示受试者在方案最后一个计划访视窗口期前停止治疗日期进入最后访视窗口期后应录入正常终止治疗访视
</div>
<template #footer>
<el-button @click="earlyTerminationDialogVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="earlyTerminationSaving" :disabled="isReadOnly" @click="saveEarlyTermination">
{{ TEXT.common.actions.save }}
</el-button>
</template>
</el-dialog>
<el-dialog v-if="aeDialogVisible" append-to=".layout-main .content-wrapper" v-model="aeDialogVisible" :title="TEXT.modules.subjectDetail.dialogAe" width="560px">
<el-form :model="aeForm" label-width="100px">
<el-form-item :label="TEXT.common.fields.title" required>
@@ -426,7 +505,7 @@ import { useStudyStore } from "../../store/study";
import { getSubject, updateSubject } from "../../api/subjects";
import { fetchSites } from "../../api/sites";
import { listSubjectHistories, createSubjectHistory, updateSubjectHistory, deleteSubjectHistory } from "../../api/subjectHistories";
import { fetchVisits, createVisit, updateVisit, deleteVisit } from "../../api/visits";
import { fetchVisits, createEarlyTerminationVisit, createVisit, updateVisit, deleteVisit } from "../../api/visits";
import { fetchAes, createAe, updateAe, deleteAe } from "../../api/aes";
import { listSubjectPds, createSubjectPd, updateSubjectPd, deleteSubjectPd } from "../../api/subjectPds";
import { displayDate, displayEnum, displayText } from "../../utils/display";
@@ -439,7 +518,7 @@ const subjectId = route.params.subjectId as string;
const studyId = study.currentStudy?.id || "";
const resolveTabFromQuery = () => {
const tab = typeof route.query.tab === "string" ? route.query.tab : "";
return ["history", "visits", "ae", "pd"].includes(tab) ? tab : "history";
return ["history", "visits", "medicationAdherence", "ae", "pd"].includes(tab) ? tab : "history";
};
const loading = ref(false);
@@ -452,6 +531,7 @@ const detail = reactive<any>({
enrollment_date: "",
baseline_date: "",
completion_date: "",
actual_medication_count: null as number | null,
drop_reason: "",
});
@@ -483,6 +563,8 @@ const historyForm = reactive({
const visitItems = ref<any[]>([]);
const loadingVisits = ref(false);
const visitDialogVisible = ref(false);
const earlyTerminationDialogVisible = ref(false);
const earlyTerminationSaving = ref(false);
const visitEditingId = ref<string | null>(null);
const visitEditingRowId = ref<string | null>(null);
const visitRowDraft = reactive({
@@ -497,7 +579,17 @@ const visitForm = reactive<any>({
actual_date: "",
notes: "",
});
const earlyTerminationForm = reactive({
termination_date: "",
reason: "",
notes: "",
});
const isFirstVisit = computed(() => visitForm.visit_code === "V1");
const adherenceEditing = ref(false);
const adherenceSaving = ref(false);
const adherenceForm = reactive({
actual_medication_count: null as number | null,
});
const aeItems = ref<any[]>([]);
const loadingAes = ref(false);
@@ -572,6 +664,14 @@ const normalizeSeriousness = (value?: string | null) => {
return "I";
};
const getErrorMessage = (error: any, fallback: string) => {
const detail = error?.response?.data?.detail;
if (typeof error?.response?.data?.message === "string") return error.response.data.message;
if (typeof detail === "string") return detail;
if (typeof detail?.message === "string") return detail.message;
return fallback;
};
const loadSites = async () => {
if (!studyId) return;
try {
@@ -763,6 +863,17 @@ const openVisitDialog = (row?: any) => {
visitDialogVisible.value = true;
};
const openEarlyTerminationDialog = () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
earlyTerminationForm.termination_date = "";
earlyTerminationForm.reason = "";
earlyTerminationForm.notes = "";
earlyTerminationDialogVisible.value = true;
};
const startVisitRowEdit = (row: any) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
@@ -803,6 +914,84 @@ const parseDate = (value?: string | null) => {
return date;
};
const parseDateAtNoon = (value?: string | null) => {
if (!value) return null;
const date = new Date(`${value}T12:00:00`);
if (isNaN(date.getTime())) return null;
return date;
};
const getVisitWindowStartDateText = (row: any) => row?.window_start || row?.planned_date || "";
const getVisitPlannedDateLabel = (row: any) => {
if (String(row?.visit_code || "").trim() === "提前终止") return "不适用";
return displayDate(row?.planned_date);
};
const getVisitWindowRangeLabel = (row: any) => {
if (String(row?.visit_code || "").trim() === "提前终止") return "";
if (!row?.window_start || !row?.window_end) return "";
if (row.window_start === row.planned_date && row.window_end === row.planned_date) return "";
return `${displayDate(row.window_start)} ${displayDate(row.window_end)}`;
};
const lastPlannedVisitWindowStartText = computed(() => {
const excludedCodes = new Set(["筛选访视", "基线访视", "提前终止"]);
const candidates = visitItems.value
.filter((item) => !excludedCodes.has(String(item?.visit_code || "").trim()))
.map((item) => getVisitWindowStartDateText(item))
.filter(Boolean)
.sort();
return candidates[candidates.length - 1] || "";
});
const validateEarlyTerminationDateInput = () => {
const boundary = lastPlannedVisitWindowStartText.value;
if (!boundary || !earlyTerminationForm.termination_date) return true;
if (earlyTerminationForm.termination_date >= boundary) {
ElMessage.error(`提前终止日期必须早于最后计划访视窗口期开始日(${boundary}`);
return false;
}
return true;
};
const formatCount = (value: number | null | undefined) => (value === null || value === undefined ? TEXT.common.fallback : String(value));
const medicationStartDate = computed(() => detail.baseline_date || "");
const medicationEndVisit = computed(() => {
const targetLabels = new Set(["终止治疗", "提前终止"]);
return (
visitItems.value.find((item) => targetLabels.has(String(item?.visit_code || "").trim()) && item?.actual_date) || null
);
});
const medicationEndDate = computed(() => medicationEndVisit.value?.actual_date || "");
const medicationEndVisitLabel = computed(() => medicationEndVisit.value?.visit_code || "未记录终止治疗/提前终止实际日期");
const expectedMedicationCount = computed(() => {
const start = parseDateAtNoon(medicationStartDate.value);
const end = parseDateAtNoon(medicationEndDate.value);
if (!start || !end || end < start) return null;
return Math.floor((end.getTime() - start.getTime()) / 86400000) + 1;
});
const expectedMedicationCountText = computed(() => formatCount(expectedMedicationCount.value));
const actualMedicationCount = computed(() => detail.actual_medication_count ?? null);
const actualMedicationCountText = computed(() => formatCount(actualMedicationCount.value));
const medicationAdherenceRate = computed(() => {
const expected = expectedMedicationCount.value;
const actual = actualMedicationCount.value;
if (!expected || actual === null || actual === undefined) return null;
return (actual / expected) * 100;
});
const medicationAdherenceRateText = computed(() => {
const rate = medicationAdherenceRate.value;
if (rate === null) return "无法计算";
return `${rate.toFixed(1)}%`;
});
const adherenceRateTagType = computed(() => {
const rate = medicationAdherenceRate.value;
if (rate === null) return "info";
if (rate < 75 || rate > 125) return "danger";
return "success";
});
const getVisitStatus = (row: any, actualDateOverride?: string) => {
if (row?.status === "CANCELLED") return "CANCELLED";
const actualDate = parseDate(actualDateOverride ?? row?.actual_date);
@@ -840,6 +1029,14 @@ const getVisitStatusTagType = (status: string) => {
}
};
const getVisitStatusLabel = (row: any, actualDateOverride?: string) => {
const status = getVisitStatus(row, actualDateOverride);
if (status === "CANCELLED" && String(row?.notes || "").includes("提前终止")) {
return "不适用";
}
return displayEnum(TEXT.enums.visitStatus, status);
};
const getAeTypeKey = (row: any) => {
if (row?.is_susar) return "susar";
if (row?.is_sae) return "sae";
@@ -896,6 +1093,46 @@ const saveVisit = async () => {
}
};
const saveEarlyTermination = async () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!studyId || !subjectId) return;
if (!earlyTerminationForm.termination_date) {
ElMessage.error("请填写提前终止日期");
return;
}
if (!earlyTerminationForm.reason.trim()) {
ElMessage.error("请填写提前终止原因");
return;
}
if (!validateEarlyTerminationDateInput()) return;
earlyTerminationSaving.value = true;
try {
await createEarlyTerminationVisit(studyId, subjectId, {
termination_date: earlyTerminationForm.termination_date,
reason: earlyTerminationForm.reason.trim(),
notes: earlyTerminationForm.notes || null,
}, {
suppressErrorMessage: true,
});
earlyTerminationDialogVisible.value = false;
ElMessage.success(TEXT.common.messages.saveSuccess);
} catch (e: any) {
ElMessage.error(getErrorMessage(e, TEXT.common.messages.saveFailed));
earlyTerminationSaving.value = false;
return;
}
try {
await Promise.all([loadSubject(), loadVisits()]);
} catch {
ElMessage.warning("提前终止已保存,刷新数据失败,请手动刷新页面");
} finally {
earlyTerminationSaving.value = false;
}
};
const removeVisit = async (row: any) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
@@ -1032,6 +1269,11 @@ const currentTabAction = computed(() => {
switch (activeTab.value) {
case "visits":
return { label: TEXT.common.actions.newVisit, onClick: () => openVisitDialog() };
case "medicationAdherence":
if (adherenceEditing.value) {
return { label: "保存用药次数", loading: adherenceSaving.value, onClick: () => saveMedicationAdherence() };
}
return { label: "编辑", loading: false, onClick: () => startMedicationAdherenceEdit() };
case "ae":
return { label: TEXT.common.actions.newAe, onClick: () => openAeDialog() };
case "pd":
@@ -1042,6 +1284,36 @@ const currentTabAction = computed(() => {
}
});
const startMedicationAdherenceEdit = () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
adherenceForm.actual_medication_count = detail.actual_medication_count ?? null;
adherenceEditing.value = true;
};
const saveMedicationAdherence = async () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!studyId || !subjectId) return;
adherenceSaving.value = true;
try {
await updateSubject(studyId, subjectId, {
actual_medication_count: adherenceForm.actual_medication_count ?? null,
});
adherenceEditing.value = false;
ElMessage.success(TEXT.common.messages.saveSuccess);
await loadSubject();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
adherenceSaving.value = false;
}
};
const savePd = async () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
@@ -1168,12 +1440,47 @@ onMounted(async () => {
z-index: 2;
display: flex;
align-items: center;
gap: 8px;
}
.subject-overview-descriptions :deep(.el-descriptions__table) {
table-layout: fixed;
}
.visit-planned-cell {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
line-height: 1.35;
}
.visit-window-range {
color: #8a97ab;
font-size: 12px;
}
.adherence-panel {
padding: 16px 0 0;
}
.adherence-count-input {
width: 160px;
}
.adherence-hint {
margin-top: 12px;
color: #6b778c;
font-size: 13px;
}
.early-termination-hint {
margin-top: 10px;
color: #6b778c;
font-size: 13px;
line-height: 1.6;
}
.subject-overview-descriptions :deep(.el-descriptions__label) {
width: 140px;
min-width: 140px;
+11
View File
@@ -42,6 +42,16 @@ http {
add_header Cache-Control "public, max-age=86400" always;
}
location = /index.html {
try_files /index.html =404;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
}
location /assets/ {
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
location /api/ {
proxy_pass http://backend;
proxy_set_header Host $host;
@@ -60,6 +70,7 @@ http {
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
}
}
}