diff --git a/backend/alembic/versions/20260228_02_add_setup_version_published_project_snapshot.py b/backend/alembic/versions/20260228_02_add_setup_version_published_project_snapshot.py new file mode 100644 index 00000000..e1c9432c --- /dev/null +++ b/backend/alembic/versions/20260228_02_add_setup_version_published_project_snapshot.py @@ -0,0 +1,44 @@ +"""add published project snapshot to study_setup_config_versions + +Revision ID: 20260228_02 +Revises: 20260228_01 +Create Date: 2026-02-28 18:20: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 = "20260228_02" +down_revision: Union[str, None] = "20260228_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) + tables = set(inspector.get_table_names()) + if "study_setup_config_versions" not in tables: + return + columns = {col["name"] for col in inspector.get_columns("study_setup_config_versions")} + if "published_project_snapshot" not in columns: + op.add_column( + "study_setup_config_versions", + sa.Column("published_project_snapshot", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + tables = set(inspector.get_table_names()) + if "study_setup_config_versions" not in tables: + return + columns = {col["name"] for col in inspector.get_columns("study_setup_config_versions")} + if "published_project_snapshot" in columns: + op.drop_column("study_setup_config_versions", "published_project_snapshot") diff --git a/backend/alembic/versions/20260302_01_add_setup_version_branching.py b/backend/alembic/versions/20260302_01_add_setup_version_branching.py new file mode 100644 index 00000000..5a4bf83e --- /dev/null +++ b/backend/alembic/versions/20260302_01_add_setup_version_branching.py @@ -0,0 +1,235 @@ +"""add setup config version branching metadata + +Revision ID: 20260302_01 +Revises: 20260228_02 +Create Date: 2026-03-02 16: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 = "20260302_01" +down_revision: Union[str, None] = "20260228_02" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _has_table(inspector: sa.Inspector, table_name: str) -> bool: + return table_name in set(inspector.get_table_names()) + + +def _has_column(inspector: sa.Inspector, table_name: str, column_name: str) -> bool: + return column_name in {col["name"] for col in inspector.get_columns(table_name)} + + +def _has_unique_constraint(inspector: sa.Inspector, table_name: str, constraint_name: str) -> bool: + return constraint_name in {item["name"] for item in inspector.get_unique_constraints(table_name)} + + +def _has_foreign_key(inspector: sa.Inspector, table_name: str, fk_name: str) -> bool: + return fk_name in {item["name"] for item in inspector.get_foreign_keys(table_name)} + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if _has_table(inspector, "study_setup_config_versions"): + if not _has_column(inspector, "study_setup_config_versions", "branch_name"): + op.add_column("study_setup_config_versions", sa.Column("branch_name", sa.String(), nullable=True)) + if not _has_column(inspector, "study_setup_config_versions", "branch_seq"): + op.add_column("study_setup_config_versions", sa.Column("branch_seq", sa.Integer(), nullable=True)) + if not _has_column(inspector, "study_setup_config_versions", "version_label"): + op.add_column("study_setup_config_versions", sa.Column("version_label", sa.String(), nullable=True)) + if not _has_column(inspector, "study_setup_config_versions", "parent_version_id"): + op.add_column("study_setup_config_versions", sa.Column("parent_version_id", postgresql.UUID(as_uuid=True), nullable=True)) + if not _has_column(inspector, "study_setup_config_versions", "merged_from_version_id"): + op.add_column( + "study_setup_config_versions", + sa.Column("merged_from_version_id", postgresql.UUID(as_uuid=True), nullable=True), + ) + + op.execute("UPDATE study_setup_config_versions SET branch_name = 'main' WHERE branch_name IS NULL") + op.execute( + "UPDATE study_setup_config_versions SET branch_seq = COALESCE(display_version, version, 1) WHERE branch_seq IS NULL" + ) + op.execute( + "UPDATE study_setup_config_versions SET version_label = ('v' || COALESCE(display_version, version, 1)::text) WHERE version_label IS NULL" + ) + + op.alter_column("study_setup_config_versions", "branch_name", nullable=False) + op.alter_column("study_setup_config_versions", "branch_seq", nullable=False) + op.alter_column("study_setup_config_versions", "version_label", nullable=False) + + inspector = sa.inspect(bind) + if not _has_foreign_key(inspector, "study_setup_config_versions", "fk_setup_config_versions_parent_version"): + op.create_foreign_key( + "fk_setup_config_versions_parent_version", + "study_setup_config_versions", + "study_setup_config_versions", + ["parent_version_id"], + ["id"], + ondelete="SET NULL", + ) + if not _has_foreign_key(inspector, "study_setup_config_versions", "fk_setup_config_versions_merged_from_version"): + op.create_foreign_key( + "fk_setup_config_versions_merged_from_version", + "study_setup_config_versions", + "study_setup_config_versions", + ["merged_from_version_id"], + ["id"], + ondelete="SET NULL", + ) + + if _has_unique_constraint(inspector, "study_setup_config_versions", "uq_setup_config_versions_study_display_version"): + op.drop_constraint( + "uq_setup_config_versions_study_display_version", + "study_setup_config_versions", + type_="unique", + ) + inspector = sa.inspect(bind) + if not _has_unique_constraint(inspector, "study_setup_config_versions", "uq_setup_config_versions_study_branch_seq"): + op.create_unique_constraint( + "uq_setup_config_versions_study_branch_seq", + "study_setup_config_versions", + ["study_id", "branch_name", "branch_seq"], + ) + if not _has_unique_constraint(inspector, "study_setup_config_versions", "uq_setup_config_versions_study_version_label"): + op.create_unique_constraint( + "uq_setup_config_versions_study_version_label", + "study_setup_config_versions", + ["study_id", "version_label"], + ) + + if _has_table(inspector, "study_setup_configs"): + if not _has_column(inspector, "study_setup_configs", "current_branch_name"): + op.add_column("study_setup_configs", sa.Column("current_branch_name", sa.String(), nullable=True)) + if not _has_column(inspector, "study_setup_configs", "active_branch_base_version_id"): + op.add_column( + "study_setup_configs", + sa.Column("active_branch_base_version_id", postgresql.UUID(as_uuid=True), nullable=True), + ) + if not _has_column(inspector, "study_setup_configs", "current_published_version_id"): + op.add_column( + "study_setup_configs", + sa.Column("current_published_version_id", postgresql.UUID(as_uuid=True), nullable=True), + ) + + op.execute("UPDATE study_setup_configs SET current_branch_name = 'main' WHERE current_branch_name IS NULL") + op.alter_column("study_setup_configs", "current_branch_name", nullable=False) + + if _has_table(inspector, "study_setup_config_versions"): + op.execute( + """ + UPDATE study_setup_configs + SET current_published_version_id = ( + SELECT v.id + FROM study_setup_config_versions AS v + WHERE v.study_id = study_setup_configs.study_id + AND study_setup_configs.published_config IS NOT NULL + AND v.config = study_setup_configs.published_config + ORDER BY + CASE + WHEN study_setup_configs.published_at IS NOT NULL + AND v.published_at = study_setup_configs.published_at THEN 0 + ELSE 1 + END, + v.version DESC + LIMIT 1 + ) + WHERE current_published_version_id IS NULL + """ + ) + + inspector = sa.inspect(bind) + if not _has_foreign_key(inspector, "study_setup_configs", "fk_setup_configs_active_branch_base_version"): + op.create_foreign_key( + "fk_setup_configs_active_branch_base_version", + "study_setup_configs", + "study_setup_config_versions", + ["active_branch_base_version_id"], + ["id"], + ondelete="SET NULL", + ) + if not _has_foreign_key(inspector, "study_setup_configs", "fk_setup_configs_current_published_version"): + op.create_foreign_key( + "fk_setup_configs_current_published_version", + "study_setup_configs", + "study_setup_config_versions", + ["current_published_version_id"], + ["id"], + ondelete="SET NULL", + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if _has_table(inspector, "study_setup_configs"): + if _has_foreign_key(inspector, "study_setup_configs", "fk_setup_configs_current_published_version"): + op.drop_constraint( + "fk_setup_configs_current_published_version", + "study_setup_configs", + type_="foreignkey", + ) + if _has_foreign_key(inspector, "study_setup_configs", "fk_setup_configs_active_branch_base_version"): + op.drop_constraint( + "fk_setup_configs_active_branch_base_version", + "study_setup_configs", + type_="foreignkey", + ) + if _has_column(inspector, "study_setup_configs", "current_published_version_id"): + op.drop_column("study_setup_configs", "current_published_version_id") + if _has_column(inspector, "study_setup_configs", "active_branch_base_version_id"): + op.drop_column("study_setup_configs", "active_branch_base_version_id") + if _has_column(inspector, "study_setup_configs", "current_branch_name"): + op.drop_column("study_setup_configs", "current_branch_name") + + inspector = sa.inspect(bind) + if _has_table(inspector, "study_setup_config_versions"): + if _has_unique_constraint(inspector, "study_setup_config_versions", "uq_setup_config_versions_study_version_label"): + op.drop_constraint( + "uq_setup_config_versions_study_version_label", + "study_setup_config_versions", + type_="unique", + ) + if _has_unique_constraint(inspector, "study_setup_config_versions", "uq_setup_config_versions_study_branch_seq"): + op.drop_constraint( + "uq_setup_config_versions_study_branch_seq", + "study_setup_config_versions", + type_="unique", + ) + if not _has_unique_constraint(inspector, "study_setup_config_versions", "uq_setup_config_versions_study_display_version"): + op.create_unique_constraint( + "uq_setup_config_versions_study_display_version", + "study_setup_config_versions", + ["study_id", "display_version"], + ) + if _has_foreign_key(inspector, "study_setup_config_versions", "fk_setup_config_versions_merged_from_version"): + op.drop_constraint( + "fk_setup_config_versions_merged_from_version", + "study_setup_config_versions", + type_="foreignkey", + ) + if _has_foreign_key(inspector, "study_setup_config_versions", "fk_setup_config_versions_parent_version"): + op.drop_constraint( + "fk_setup_config_versions_parent_version", + "study_setup_config_versions", + type_="foreignkey", + ) + if _has_column(inspector, "study_setup_config_versions", "merged_from_version_id"): + op.drop_column("study_setup_config_versions", "merged_from_version_id") + if _has_column(inspector, "study_setup_config_versions", "parent_version_id"): + op.drop_column("study_setup_config_versions", "parent_version_id") + if _has_column(inspector, "study_setup_config_versions", "version_label"): + op.drop_column("study_setup_config_versions", "version_label") + if _has_column(inspector, "study_setup_config_versions", "branch_seq"): + op.drop_column("study_setup_config_versions", "branch_seq") + if _has_column(inspector, "study_setup_config_versions", "branch_name"): + op.drop_column("study_setup_config_versions", "branch_name") diff --git a/backend/alembic/versions/20260302_02_drop_setup_version_label_unique.py b/backend/alembic/versions/20260302_02_drop_setup_version_label_unique.py new file mode 100644 index 00000000..efd621a3 --- /dev/null +++ b/backend/alembic/versions/20260302_02_drop_setup_version_label_unique.py @@ -0,0 +1,71 @@ +"""drop unique constraint on setup config version label + +Revision ID: 20260302_02 +Revises: 20260302_01 +Create Date: 2026-03-02 20:20:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "20260302_02" +down_revision: Union[str, None] = "20260302_01" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _has_table(inspector: sa.Inspector, table_name: str) -> bool: + return table_name in set(inspector.get_table_names()) + + +def _has_unique_constraint(inspector: sa.Inspector, table_name: str, constraint_name: str) -> bool: + return constraint_name in {item["name"] for item in inspector.get_unique_constraints(table_name)} + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + table_name = "study_setup_config_versions" + constraint_name = "uq_setup_config_versions_study_version_label" + if _has_table(inspector, table_name) and _has_unique_constraint(inspector, table_name, constraint_name): + op.drop_constraint(constraint_name, table_name, type_="unique") + if _has_table(inspector, table_name): + op.execute( + """ + WITH ranked AS ( + SELECT + id, + display_version, + ROW_NUMBER() OVER ( + PARTITION BY study_id, branch_name, display_version + ORDER BY published_at ASC, version ASC, created_at ASC + ) AS rev_seq + FROM study_setup_config_versions + WHERE branch_name = 'main' + ) + UPDATE study_setup_config_versions AS v + SET version_label = CASE + WHEN ranked.rev_seq <= 1 THEN ('v' || ranked.display_version::text) + ELSE ('v' || ranked.display_version::text || '-r' || ranked.rev_seq::text) + END + FROM ranked + WHERE v.id = ranked.id + """ + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + table_name = "study_setup_config_versions" + constraint_name = "uq_setup_config_versions_study_version_label" + if _has_table(inspector, table_name) and not _has_unique_constraint(inspector, table_name, constraint_name): + op.create_unique_constraint( + constraint_name, + table_name, + ["study_id", "version_label"], + ) diff --git a/backend/app/api/v1/studies.py b/backend/app/api/v1/studies.py index cd54c236..2c54569a 100644 --- a/backend/app/api/v1/studies.py +++ b/backend/app/api/v1/studies.py @@ -1,6 +1,6 @@ import uuid import re -from datetime import date +from datetime import date, datetime from collections import Counter from typing import Any @@ -27,7 +27,10 @@ from app.schemas.member import StudyMemberCreate from app.schemas.study_setup_config import ( ProjectPublishSnapshot, SetupProjectionSummary, + StudySetupConfigCheckoutBranch, StudySetupConfigData, + StudySetupConfigDraftAction, + StudySetupConfigMergeToMain, StudySetupConfigPublish, StudySetupConfigRead, StudySetupConfigRollback, @@ -295,6 +298,8 @@ def _to_setup_config_read( *, saved_by_name: str | None = None, published_by_name: str | None = None, + current_published_version_label: str | None = None, + active_branch_base_version_label: str | None = None, projection_status: str | None = None, projection_summary: SetupProjectionSummary | None = None, ) -> StudySetupConfigRead: @@ -302,6 +307,11 @@ def _to_setup_config_read( id=record.id, study_id=record.study_id, version=record.version, + current_branch_name=(getattr(record, "current_branch_name", None) or "main"), + current_published_version_id=getattr(record, "current_published_version_id", None), + current_published_version_label=current_published_version_label, + active_branch_base_version_id=getattr(record, "active_branch_base_version_id", None), + active_branch_base_version_label=active_branch_base_version_label, data=StudySetupConfigData.model_validate(record.config or {}), publish_status=record.publish_status or "DRAFT", published_data=StudySetupConfigData.model_validate(record.published_config) if record.published_config else None, @@ -363,15 +373,42 @@ def _ensure_study_timeline_valid( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="访视窗口开始偏移不能晚于结束偏移") -def _to_setup_config_version_read(record, *, published_by_name: str | None = None) -> StudySetupConfigVersionRead: +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) + branch_seq = int(getattr(record, "branch_seq", 1) or 1) + if branch_name == "main": + return getattr(record, "version_label", None) or f"v{display_version}" + return getattr(record, "version_label", None) or f"v{display_version}.{branch_seq}" + + +def _to_setup_config_version_read( + record, + *, + published_by_name: str | None = None, + is_current_published: bool = False, + is_active_draft_base: bool = False, +) -> StudySetupConfigVersionRead: return StudySetupConfigVersionRead( id=record.id, study_id=record.study_id, study_setup_config_id=record.study_setup_config_id, version=record.version, display_version=record.display_version, + branch_name=(record.branch_name or "main"), + branch_seq=record.branch_seq or 1, + version_label=_resolve_version_label(record), source_version=record.source_version, + parent_version_id=record.parent_version_id, + merged_from_version_id=record.merged_from_version_id, config=StudySetupConfigData.model_validate(record.config or {}), + published_project_snapshot=( + ProjectPublishSnapshot.model_validate(record.published_project_snapshot) + if record.published_project_snapshot + else None + ), + is_current_published=is_current_published, + is_active_draft_base=is_active_draft_base, published_by=record.published_by, published_by_name=published_by_name, published_at=record.published_at, @@ -379,6 +416,89 @@ def _to_setup_config_version_read(record, *, published_by_name: str | None = Non ) +def _resolve_current_published_snapshot_version( + versions: list[Any], + setup_record: Any | None, +) -> int | None: + if not setup_record: + return None + current_published_snapshot_id = getattr(setup_record, "current_published_version_id", None) + if current_published_snapshot_id: + matched = next((item for item in versions if item.id == current_published_snapshot_id), None) + if matched: + return matched.version + + published_config = setup_record.published_config or {} + if not published_config: + return None + + best_score = -1 + best_version: int | None = None + for item in versions: + if (item.config or {}) != published_config: + continue + score = 1 + if setup_record.published_at and item.published_at == setup_record.published_at: + score += 2 + if setup_record.published_by and item.published_by == setup_record.published_by: + score += 1 + if score > best_score or (score == best_score and (best_version is None or item.version > best_version)): + best_score = score + best_version = item.version + return best_version + + +def _resolve_current_published_snapshot_label( + versions: list[Any], + setup_record: Any | None, +) -> str | None: + if not setup_record: + return None + current_published_snapshot_id = getattr(setup_record, "current_published_version_id", None) + if current_published_snapshot_id: + matched = next((item for item in versions if item.id == current_published_snapshot_id), None) + if matched: + return _resolve_version_label(matched) + + version = _resolve_current_published_snapshot_version(versions, setup_record) + if version is None: + return None + matched = next((item for item in versions if item.version == version), None) + if not matched: + return None + return _resolve_version_label(matched) + + +def _resolve_active_branch_base_snapshot_version( + versions: list[Any], + setup_record: Any | None, +) -> int | None: + if not setup_record: + return None + base_snapshot_id = getattr(setup_record, "active_branch_base_version_id", None) + if not base_snapshot_id: + return None + matched = next((item for item in versions if item.id == base_snapshot_id), None) + if not matched: + return None + return matched.version + + +def _resolve_active_branch_base_snapshot_label( + versions: list[Any], + setup_record: Any | None, +) -> str | None: + if not setup_record: + return None + base_snapshot_id = getattr(setup_record, "active_branch_base_version_id", None) + if not base_snapshot_id: + return None + matched = next((item for item in versions if item.id == base_snapshot_id), None) + if not matched: + return None + return _resolve_version_label(matched) + + _SETUP_MODULE_KEYS = ( "projectMilestones", "enrollmentPlan", @@ -777,7 +897,14 @@ async def get_study_setup_config( if user: published_by_name = user.full_name or user.username or user.email - return _to_setup_config_read(record, saved_by_name=saved_by_name, published_by_name=published_by_name) + versions = await study_setup_config_crud.list_versions(db, study_id) + return _to_setup_config_read( + record, + saved_by_name=saved_by_name, + published_by_name=published_by_name, + current_published_version_label=_resolve_current_published_snapshot_label(versions, record), + active_branch_base_version_label=_resolve_active_branch_base_snapshot_label(versions, record), + ) @router.put( @@ -843,7 +970,14 @@ async def upsert_study_setup_config( user = await user_crud.get_by_id(db, record.published_by) if user: published_by_name = user.full_name or user.username or user.email - return _to_setup_config_read(record, saved_by_name=saved_by_name, published_by_name=published_by_name) + versions = await study_setup_config_crud.list_versions(db, study_id) + return _to_setup_config_read( + record, + saved_by_name=saved_by_name, + published_by_name=published_by_name, + current_published_version_label=_resolve_current_published_snapshot_label(versions, record), + active_branch_base_version_label=_resolve_active_branch_base_snapshot_label(versions, record), + ) @router.post( @@ -864,6 +998,10 @@ 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") + force_create_snapshot = bool( + (record.published_project_snapshot or {}) != current_project_snapshot_for_publish + ) 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} @@ -879,6 +1017,7 @@ async def publish_study_setup_config( study_id, expected_version=payload.expected_version, published_by=current_user.id, + force_create_snapshot=force_create_snapshot, auto_commit=False, ) if conflict: @@ -892,7 +1031,7 @@ async def publish_study_setup_config( projection = await apply_setup_projection_on_publish( db, study_id=study_id, - setup_data=StudySetupConfigData.model_validate(published.config or {}), + setup_data=StudySetupConfigData.model_validate(published.published_config or {}), operator_id=current_user.id, ) projection_summary = SetupProjectionSummary( @@ -907,7 +1046,14 @@ async def publish_study_setup_config( ) study_after_publish = await study_crud.get(db, study_id) if study_after_publish: - published.published_project_snapshot = _build_project_publish_snapshot(study_after_publish).model_dump(mode="json") + 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, + ) await audit_crud.log_action( db, study_id=study_id, @@ -926,6 +1072,10 @@ async def publish_study_setup_config( operator_role=current_user.role, auto_commit=False, ) + published.config = study_setup_config_crud.empty_draft_payload() + published.active_branch_base_version_id = None + published.saved_by = current_user.id + published.updated_at = datetime.utcnow() await db.commit() except Exception: await db.rollback() @@ -933,10 +1083,13 @@ async def publish_study_setup_config( operator_name = current_user.full_name or current_user.username or current_user.email await db.refresh(published) + versions = await study_setup_config_crud.list_versions(db, study_id) return _to_setup_config_read( published, saved_by_name=operator_name, published_by_name=operator_name, + current_published_version_label=_resolve_current_published_snapshot_label(versions, published), + active_branch_base_version_label=_resolve_active_branch_base_snapshot_label(versions, published), projection_status=projection.status, projection_summary=projection_summary, ) @@ -956,6 +1109,9 @@ async def list_study_setup_config_versions( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在") versions = await study_setup_config_crud.list_versions(db, study_id) + current_setup = await study_setup_config_crud.get_by_study(db, study_id) + current_published_version = _resolve_current_published_snapshot_version(versions, current_setup) + active_draft_base_version = _resolve_active_branch_base_snapshot_version(versions, current_setup) user_ids = {item.published_by for item in versions if item.published_by} name_map: dict[uuid.UUID, str] = {} for user_id in user_ids: @@ -963,7 +1119,12 @@ async def list_study_setup_config_versions( if user: name_map[user_id] = user.full_name or user.username or user.email return [ - _to_setup_config_version_read(item, published_by_name=name_map.get(item.published_by) if item.published_by else None) + _to_setup_config_version_read( + item, + published_by_name=name_map.get(item.published_by) if item.published_by else None, + is_current_published=item.version == current_published_version, + is_active_draft_base=item.version == active_draft_base_version, + ) for item in versions ] @@ -982,6 +1143,9 @@ async def rollback_study_setup_config( study = await study_crud.get(db, study_id) if not study: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在") + versions_before = await study_setup_config_crud.list_versions(db, study_id) + target_label_map = {item.version: (item.version_label or f"v{item.display_version}") for item in versions_before} + target_label = target_label_map.get(payload.target_version, f"v{payload.target_version}") record, conflict, target_missing = await study_setup_config_crud.rollback_to_version( db, @@ -1003,7 +1167,7 @@ async def rollback_study_setup_config( entity_type="study_setup_config", entity_id=record.id, action="ROLLBACK_SETUP_CONFIG", - detail=f"立项配置已回滚到发布版本 v{payload.target_version}", + detail=f"立项配置已回滚并替换当前发布为 {target_label},草稿分支已切换到对应分支基线", operator_id=current_user.id, operator_role=current_user.role, ) @@ -1013,7 +1177,273 @@ async def rollback_study_setup_config( user = await user_crud.get_by_id(db, record.published_by) if user: published_by_name = user.full_name or user.username or user.email - return _to_setup_config_read(record, saved_by_name=operator_name, published_by_name=published_by_name) + versions = await study_setup_config_crud.list_versions(db, study_id) + return _to_setup_config_read( + record, + saved_by_name=operator_name, + published_by_name=published_by_name, + current_published_version_label=_resolve_current_published_snapshot_label(versions, record), + active_branch_base_version_label=_resolve_active_branch_base_snapshot_label(versions, record), + ) + + +@router.post( + "/{study_id}/setup-config/draft/checkout-branch", + response_model=StudySetupConfigRead, + dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())], +) +async def checkout_study_setup_config_branch_draft( + study_id: uuid.UUID, + payload: StudySetupConfigCheckoutBranch, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> StudySetupConfigRead: + study = await study_crud.get(db, study_id) + if not study: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在") + + versions_before = await study_setup_config_crud.list_versions(db, study_id) + target_label_map = {item.version: (item.version_label or f"v{item.display_version}") for item in versions_before} + target_label = target_label_map.get(payload.target_version, f"v{payload.target_version}") + + record, conflict, target_missing = await study_setup_config_crud.checkout_branch_draft_from_version( + db, + study_id, + expected_version=payload.expected_version, + target_version=payload.target_version, + saved_by=current_user.id, + ) + if conflict: + _raise_conflict_error() + if target_missing: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="目标版本不存在") + if not record: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="切换分支草稿失败") + + await audit_crud.log_action( + db, + study_id=study_id, + entity_type="study_setup_config", + entity_id=record.id, + action="CHECKOUT_SETUP_CONFIG_BRANCH_DRAFT", + detail=f"立项配置草稿已切换到 {target_label} 对应分支", + operator_id=current_user.id, + operator_role=current_user.role, + ) + operator_name = current_user.full_name or current_user.username or current_user.email + published_by_name = None + if record.published_by: + user = await user_crud.get_by_id(db, record.published_by) + if user: + published_by_name = user.full_name or user.username or user.email + versions = await study_setup_config_crud.list_versions(db, study_id) + return _to_setup_config_read( + record, + saved_by_name=operator_name, + published_by_name=published_by_name, + current_published_version_label=_resolve_current_published_snapshot_label(versions, record), + active_branch_base_version_label=_resolve_active_branch_base_snapshot_label(versions, record), + ) + + +@router.post( + "/{study_id}/setup-config/draft/clear", + response_model=StudySetupConfigRead, + dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())], +) +async def clear_study_setup_config_draft( + study_id: uuid.UUID, + payload: StudySetupConfigDraftAction, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> StudySetupConfigRead: + study = await study_crud.get(db, study_id) + if not study: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在") + + record, conflict = await study_setup_config_crud.clear_draft( + db, + study_id, + expected_version=payload.expected_version, + saved_by=current_user.id, + ) + if conflict: + _raise_conflict_error() + if not record: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="清空草稿失败") + + await audit_crud.log_action( + db, + study_id=study_id, + entity_type="study_setup_config", + entity_id=record.id, + action="CLEAR_SETUP_CONFIG_DRAFT", + detail="立项配置草稿已清空", + operator_id=current_user.id, + operator_role=current_user.role, + ) + operator_name = current_user.full_name or current_user.username or current_user.email + published_by_name = None + if record.published_by: + user = await user_crud.get_by_id(db, record.published_by) + if user: + published_by_name = user.full_name or user.username or user.email + versions = await study_setup_config_crud.list_versions(db, study_id) + return _to_setup_config_read( + record, + saved_by_name=operator_name, + published_by_name=published_by_name, + current_published_version_label=_resolve_current_published_snapshot_label(versions, record), + active_branch_base_version_label=_resolve_active_branch_base_snapshot_label(versions, record), + ) + + +@router.post( + "/{study_id}/setup-config/draft/refill", + response_model=StudySetupConfigRead, + dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())], +) +async def refill_study_setup_config_draft( + study_id: uuid.UUID, + payload: StudySetupConfigDraftAction, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> StudySetupConfigRead: + study = await study_crud.get(db, study_id) + if not study: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在") + + record, conflict, no_published = await study_setup_config_crud.refill_draft_from_published( + db, + study_id, + expected_version=payload.expected_version, + saved_by=current_user.id, + ) + if conflict: + _raise_conflict_error() + if no_published: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前无已发布版本,无法回填草稿") + if not record: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="回填草稿失败") + + await audit_crud.log_action( + db, + study_id=study_id, + entity_type="study_setup_config", + entity_id=record.id, + action="REFILL_SETUP_CONFIG_DRAFT", + detail="立项配置草稿已从当前发布版本一键回填", + operator_id=current_user.id, + operator_role=current_user.role, + ) + operator_name = current_user.full_name or current_user.username or current_user.email + published_by_name = None + if record.published_by: + user = await user_crud.get_by_id(db, record.published_by) + if user: + published_by_name = user.full_name or user.username or user.email + versions = await study_setup_config_crud.list_versions(db, study_id) + return _to_setup_config_read( + record, + saved_by_name=operator_name, + published_by_name=published_by_name, + current_published_version_label=_resolve_current_published_snapshot_label(versions, record), + active_branch_base_version_label=_resolve_active_branch_base_snapshot_label(versions, record), + ) + + +@router.post( + "/{study_id}/setup-config/merge-main", + response_model=StudySetupConfigRead, + dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())], +) +async def merge_study_setup_config_to_main( + study_id: uuid.UUID, + payload: StudySetupConfigMergeToMain, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> StudySetupConfigRead: + study = await study_crud.get(db, study_id) + if not study: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在") + versions_before = await study_setup_config_crud.list_versions(db, study_id) + source_label_map = {item.version: (item.version_label or f"v{item.display_version}") for item in versions_before} + source_label = source_label_map.get(payload.source_version, f"v{payload.source_version}") + + merged, conflict, source_missing = await study_setup_config_crud.merge_to_main( + db, + study_id, + expected_version=payload.expected_version, + source_version=payload.source_version, + merged_by=current_user.id, + auto_commit=False, + ) + if conflict: + await db.rollback() + _raise_conflict_error() + if source_missing: + await db.rollback() + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="目标版本不存在") + if not merged: + await db.rollback() + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="合并主分支失败") + + try: + projection = await apply_setup_projection_on_publish( + db, + study_id=study_id, + setup_data=StudySetupConfigData.model_validate(merged.published_config or {}), + operator_id=current_user.id, + ) + projection_summary = SetupProjectionSummary( + study_updated=projection.study_updated, + site_updated_count=projection.site_updated_count, + site_skipped_count=projection.site_skipped_count, + 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, + ) + await audit_crud.log_action( + db, + study_id=study_id, + entity_type="study_setup_config", + entity_id=merged.id, + action="MERGE_SETUP_CONFIG_TO_MAIN", + detail=f"已将发布版本 {source_label} 合并到主分支并生成新主版本", + operator_id=current_user.id, + operator_role=current_user.role, + auto_commit=False, + ) + merged.config = study_setup_config_crud.empty_draft_payload() + merged.active_branch_base_version_id = None + merged.saved_by = current_user.id + merged.updated_at = datetime.utcnow() + await db.commit() + except Exception: + await db.rollback() + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="合并主分支失败:联动同步异常") + + operator_name = current_user.full_name or current_user.username or current_user.email + await db.refresh(merged) + versions = await study_setup_config_crud.list_versions(db, study_id) + return _to_setup_config_read( + merged, + saved_by_name=operator_name, + published_by_name=operator_name, + current_published_version_label=_resolve_current_published_snapshot_label(versions, merged), + active_branch_base_version_label=_resolve_active_branch_base_snapshot_label(versions, merged), + projection_status=projection.status, + projection_summary=projection_summary, + ) @router.delete( @@ -1034,9 +1464,12 @@ async def delete_study_setup_config_version( versions = await study_setup_config_crud.list_versions(db, study_id) if not versions: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="目标版本不存在") - latest_version = max(item.version for item in versions) - if target_version == latest_version: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="最新发布版本不能删除") + current_setup = await study_setup_config_crud.get_by_study(db, study_id) + current_published_version = _resolve_current_published_snapshot_version(versions, current_setup) + if current_published_version is None: + current_published_version = max(item.version for item in versions) + if target_version == current_published_version: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前发布版本不能删除") deleted = await study_setup_config_crud.delete_version( db, diff --git a/backend/app/crud/study_setup_config.py b/backend/app/crud/study_setup_config.py index 8f31a877..51be2369 100644 --- a/backend/app/crud/study_setup_config.py +++ b/backend/app/crud/study_setup_config.py @@ -2,7 +2,7 @@ import uuid from copy import deepcopy from datetime import datetime -from sqlalchemy import delete as sa_delete, select +from sqlalchemy import delete as sa_delete, select, update as sa_update from sqlalchemy import func from sqlalchemy.ext.asyncio import AsyncSession @@ -10,6 +10,8 @@ from app.models.study_setup_config import StudySetupConfig from app.models.study_setup_config_version import StudySetupConfigVersion from app.schemas.study_setup_config import StudySetupConfigData +MAIN_BRANCH = "main" + def _to_storage(data: StudySetupConfigData | dict) -> dict: if isinstance(data, StudySetupConfigData): @@ -17,6 +19,130 @@ def _to_storage(data: StudySetupConfigData | dict) -> dict: return deepcopy(data) +def empty_draft_payload() -> dict: + return StudySetupConfigData().model_dump(mode="json") + + +def _build_release_branch_name(base_display_version: int) -> str: + return f"release/v{base_display_version}" + + +def _parse_release_branch_base_display_version(branch_name: str) -> int | None: + prefix = "release/v" + if not branch_name.startswith(prefix): + return None + suffix = branch_name[len(prefix) :] + if not suffix.isdigit(): + return None + return int(suffix) + + +def _build_version_label(branch_name: str, *, display_version: int, branch_seq: int) -> str: + if branch_name == MAIN_BRANCH: + return f"v{display_version}" + return f"v{display_version}.{branch_seq}" + + +async def _get_snapshot_by_version( + db: AsyncSession, + *, + study_id: uuid.UUID, + version: int, +) -> StudySetupConfigVersion | None: + result = await db.execute( + select(StudySetupConfigVersion).where( + StudySetupConfigVersion.study_id == study_id, + StudySetupConfigVersion.version == version, + ) + ) + return result.scalar_one_or_none() + + +async def _get_snapshot_by_id( + db: AsyncSession, + *, + snapshot_id: uuid.UUID, +) -> StudySetupConfigVersion | None: + result = await db.execute(select(StudySetupConfigVersion).where(StudySetupConfigVersion.id == snapshot_id)) + return result.scalar_one_or_none() + + +async def _get_latest_snapshot_in_branch( + db: AsyncSession, + *, + study_id: uuid.UUID, + branch_name: str, +) -> StudySetupConfigVersion | None: + result = await db.execute( + select(StudySetupConfigVersion) + .where( + StudySetupConfigVersion.study_id == study_id, + StudySetupConfigVersion.branch_name == branch_name, + ) + .order_by( + StudySetupConfigVersion.branch_seq.desc(), + StudySetupConfigVersion.version.desc(), + StudySetupConfigVersion.published_at.desc(), + ) + .limit(1) + ) + return result.scalar_one_or_none() + + +async def _get_main_next_display_version(db: AsyncSession, *, study_id: uuid.UUID) -> int: + result = await db.execute( + select(func.max(StudySetupConfigVersion.display_version)).where( + StudySetupConfigVersion.study_id == study_id, + StudySetupConfigVersion.branch_name == MAIN_BRANCH, + ) + ) + current_max = result.scalar_one_or_none() or 0 + return current_max + 1 + + +async def _get_branch_next_seq( + db: AsyncSession, + *, + study_id: uuid.UUID, + branch_name: str, +) -> int: + result = await db.execute( + select(func.max(StudySetupConfigVersion.branch_seq)).where( + StudySetupConfigVersion.study_id == study_id, + StudySetupConfigVersion.branch_name == branch_name, + ) + ) + current_max = result.scalar_one_or_none() or 0 + return current_max + 1 + + +async def _resolve_current_published_snapshot_id( + db: AsyncSession, + *, + study_id: uuid.UUID, + published_config: dict | None, + published_by: uuid.UUID | None, + published_at: datetime | None, +) -> uuid.UUID | None: + if not published_config: + return None + rows = await list_versions(db, study_id) + best_score = -1 + best_id: uuid.UUID | None = None + for item in rows: + if (item.config or {}) != (published_config or {}): + continue + score = 1 + if published_at and item.published_at == published_at: + score += 2 + if published_by and item.published_by == published_by: + score += 1 + if score > best_score: + best_score = score + best_id = item.id + return best_id + + async def get_by_study(db: AsyncSession, study_id: uuid.UUID) -> StudySetupConfig | None: result = await db.execute(select(StudySetupConfig).where(StudySetupConfig.study_id == study_id)) return result.scalar_one_or_none() @@ -56,6 +182,7 @@ async def upsert( version=1, config=payload, publish_status="DRAFT", + current_branch_name=MAIN_BRANCH, saved_by=saved_by, ) db.add(record) @@ -70,6 +197,7 @@ async def publish( *, expected_version: int | None, published_by: uuid.UUID | None, + force_create_snapshot: bool = False, auto_commit: bool = True, ) -> tuple[StudySetupConfig | None, bool]: existing = await get_by_study(db, study_id) @@ -79,15 +207,15 @@ async def publish( return None, True publish_payload = _to_storage(existing.config or {}) next_version = existing.version + 1 - latest_snapshot_stmt = ( - select(StudySetupConfigVersion) - .where(StudySetupConfigVersion.study_id == study_id) - .order_by(StudySetupConfigVersion.version.desc(), StudySetupConfigVersion.published_at.desc()) - .limit(1) - ) - latest_snapshot_result = await db.execute(latest_snapshot_stmt) - latest_snapshot = latest_snapshot_result.scalar_one_or_none() - should_create_snapshot = not latest_snapshot or (latest_snapshot.config or {}) != publish_payload + branch_name = (existing.current_branch_name or MAIN_BRANCH).strip() or MAIN_BRANCH + current_published_snapshot: StudySetupConfigVersion | None = None + current_published_branch = "" + if existing.current_published_version_id: + current_published_snapshot = await _get_snapshot_by_id(db, snapshot_id=existing.current_published_version_id) + if current_published_snapshot: + current_published_branch = (current_published_snapshot.branch_name or MAIN_BRANCH).strip() or MAIN_BRANCH + branch_changed = bool(current_published_branch and current_published_branch != branch_name) + should_create_snapshot = force_create_snapshot or (existing.published_config or {}) != publish_payload or branch_changed existing.version = next_version existing.publish_status = "PUBLISHED" @@ -98,23 +226,58 @@ async def publish( existing.updated_at = datetime.utcnow() if should_create_snapshot: - next_display_version_stmt = select(func.max(StudySetupConfigVersion.display_version)).where( - StudySetupConfigVersion.study_id == study_id - ) - display_version_result = await db.execute(next_display_version_stmt) - current_max_display_version = display_version_result.scalar_one_or_none() or 0 - next_display_version = current_max_display_version + 1 + parent_snapshot_id = existing.active_branch_base_version_id + if parent_snapshot_id is None: + latest_branch_snapshot = await _get_latest_snapshot_in_branch(db, study_id=study_id, branch_name=branch_name) + parent_snapshot_id = latest_branch_snapshot.id if latest_branch_snapshot else None + + if branch_name == MAIN_BRANCH: + next_display_version = await _get_main_next_display_version(db, study_id=study_id) + next_branch_seq = await _get_branch_next_seq(db, study_id=study_id, branch_name=branch_name) + else: + next_branch_seq = await _get_branch_next_seq(db, study_id=study_id, branch_name=branch_name) + branch_base = _parse_release_branch_base_display_version(branch_name) + if branch_base is None: + # Fall back to parent/main latest when branch name is customized. + if parent_snapshot_id: + parent_result = await db.execute( + select(StudySetupConfigVersion.display_version).where(StudySetupConfigVersion.id == parent_snapshot_id) + ) + branch_base = parent_result.scalar_one_or_none() + if branch_base is None: + branch_base = await _get_main_next_display_version(db, study_id=study_id) - 1 + next_display_version = branch_base + snapshot = StudySetupConfigVersion( study_setup_config_id=existing.id, study_id=study_id, version=next_version, display_version=next_display_version, + branch_name=branch_name, + branch_seq=next_branch_seq, + version_label=_build_version_label( + branch_name, + display_version=next_display_version, + branch_seq=next_branch_seq, + ), source_version=next_version - 1, + parent_version_id=parent_snapshot_id, config=_to_storage(publish_payload), published_by=published_by, published_at=existing.published_at, ) db.add(snapshot) + existing.current_published_version_id = snapshot.id + existing.active_branch_base_version_id = snapshot.id + else: + if not existing.current_published_version_id: + existing.current_published_version_id = await _resolve_current_published_snapshot_id( + db, + study_id=study_id, + published_config=existing.published_config, + published_by=existing.published_by, + published_at=existing.published_at, + ) if auto_commit: await db.commit() @@ -133,6 +296,21 @@ async def list_versions(db: AsyncSession, study_id: uuid.UUID) -> list[StudySetu return list(result.scalars().all()) +async def set_version_project_snapshot( + db: AsyncSession, + study_id: uuid.UUID, + *, + version: int, + project_snapshot: dict | None, +) -> bool: + target = await _get_snapshot_by_version(db, study_id=study_id, version=version) + if not target: + return False + target.published_project_snapshot = _to_storage(project_snapshot) if project_snapshot else None + await db.flush() + return True + + async def rollback_to_version( db: AsyncSession, study_id: uuid.UUID, @@ -147,23 +325,200 @@ async def rollback_to_version( if expected_version is not None and expected_version != existing.version: return None, True, False - result = await db.execute( - select(StudySetupConfigVersion).where( - StudySetupConfigVersion.study_id == study_id, - StudySetupConfigVersion.version == target_version, - ) - ) - target = result.scalar_one_or_none() + target = await _get_snapshot_by_version(db, study_id=study_id, version=target_version) if not target: return None, False, True + target_display_version = target.display_version or 0 + target_branch = (target.branch_name or MAIN_BRANCH).strip() or MAIN_BRANCH + if target_branch == MAIN_BRANCH: + branch_name = _build_release_branch_name(target_display_version) + else: + branch_name = target_branch + + now = datetime.utcnow() + existing.version = existing.version + 1 + target_config = _to_storage(target.config or {}) + existing.config = target_config + existing.published_config = _to_storage(target.config or {}) + existing.published_project_snapshot = ( + _to_storage(target.published_project_snapshot) if target.published_project_snapshot else None + ) + existing.current_branch_name = branch_name + existing.active_branch_base_version_id = target.id + existing.current_published_version_id = target.id + existing.saved_by = saved_by + existing.published_by = saved_by + existing.published_at = now + existing.publish_status = "PUBLISHED" + existing.updated_at = now + await db.commit() + await db.refresh(existing) + return existing, False, False + + +async def checkout_branch_draft_from_version( + db: AsyncSession, + study_id: uuid.UUID, + *, + expected_version: int | None, + target_version: int, + saved_by: uuid.UUID | None, +) -> tuple[StudySetupConfig | None, bool, bool]: + existing = await get_by_study(db, study_id) + if not existing: + return None, False, False + if expected_version is not None and expected_version != existing.version: + return None, True, False + + target = await _get_snapshot_by_version(db, study_id=study_id, version=target_version) + if not target: + return None, False, True + + target_display_version = target.display_version or 0 + target_branch = (target.branch_name or MAIN_BRANCH).strip() or MAIN_BRANCH + if target_branch == MAIN_BRANCH: + checkout_branch = _build_release_branch_name(target_display_version) + else: + checkout_branch = target_branch + + now = datetime.utcnow() existing.version = existing.version + 1 existing.config = _to_storage(target.config or {}) + existing.current_branch_name = checkout_branch + existing.active_branch_base_version_id = target.id existing.saved_by = saved_by + existing.publish_status = "PUBLISHED" if (existing.published_config or {}) == (existing.config or {}) else "DRAFT" + existing.updated_at = now + await db.commit() + await db.refresh(existing) + return existing, False, False + + +async def clear_draft( + db: AsyncSession, + study_id: uuid.UUID, + *, + expected_version: int | None, + saved_by: uuid.UUID | None, +) -> tuple[StudySetupConfig | None, bool]: + existing = await get_by_study(db, study_id) + if not existing: + return None, False + if expected_version is not None and expected_version != existing.version: + return None, True + + existing.version = existing.version + 1 + existing.config = empty_draft_payload() + existing.saved_by = saved_by + existing.active_branch_base_version_id = None existing.publish_status = "DRAFT" existing.updated_at = datetime.utcnow() await db.commit() await db.refresh(existing) + return existing, False + + +async def refill_draft_from_published( + db: AsyncSession, + study_id: uuid.UUID, + *, + expected_version: int | None, + saved_by: uuid.UUID | None, +) -> tuple[StudySetupConfig | None, bool, bool]: + existing = await get_by_study(db, study_id) + if not existing: + return None, False, False + if expected_version is not None and expected_version != existing.version: + return None, True, False + if not existing.published_config: + return None, False, True + + existing.version = existing.version + 1 + existing.config = _to_storage(existing.published_config or {}) + existing.saved_by = saved_by + if existing.current_published_version_id: + snapshot = await _get_snapshot_by_id(db, snapshot_id=existing.current_published_version_id) + if snapshot: + if not (existing.current_branch_name or "").strip(): + existing.current_branch_name = snapshot.branch_name or MAIN_BRANCH + if existing.active_branch_base_version_id is None: + existing.active_branch_base_version_id = snapshot.id + existing.publish_status = "PUBLISHED" if (existing.published_config or {}) == (existing.config or {}) else "DRAFT" + existing.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(existing) + return existing, False, False + + +async def merge_to_main( + db: AsyncSession, + study_id: uuid.UUID, + *, + expected_version: int | None, + source_version: int, + merged_by: uuid.UUID | None, + auto_commit: bool = True, +) -> tuple[StudySetupConfig | None, bool, bool]: + existing = await get_by_study(db, study_id) + if not existing: + return None, False, False + if expected_version is not None and expected_version != existing.version: + return None, True, False + + source = await _get_snapshot_by_version(db, study_id=study_id, version=source_version) + if not source: + return None, False, True + + payload = _to_storage(source.config or {}) + next_version = existing.version + 1 + now = datetime.utcnow() + latest_main_snapshot = await _get_latest_snapshot_in_branch(db, study_id=study_id, branch_name=MAIN_BRANCH) + parent_snapshot_id = latest_main_snapshot.id if latest_main_snapshot else None + next_main_display_version = await _get_main_next_display_version(db, study_id=study_id) + next_main_branch_seq = await _get_branch_next_seq(db, study_id=study_id, branch_name=MAIN_BRANCH) + snapshot = StudySetupConfigVersion( + study_setup_config_id=existing.id, + study_id=study_id, + version=next_version, + display_version=next_main_display_version, + branch_name=MAIN_BRANCH, + branch_seq=next_main_branch_seq, + version_label=_build_version_label( + MAIN_BRANCH, + display_version=next_main_display_version, + branch_seq=next_main_branch_seq, + ), + source_version=source.version, + parent_version_id=parent_snapshot_id, + merged_from_version_id=source.id, + config=payload, + published_project_snapshot=_to_storage(source.published_project_snapshot) if source.published_project_snapshot else None, + published_by=merged_by, + published_at=now, + ) + db.add(snapshot) + + existing.version = next_version + existing.config = payload + existing.publish_status = "PUBLISHED" + existing.published_config = _to_storage(payload) + existing.published_project_snapshot = ( + _to_storage(source.published_project_snapshot) if source.published_project_snapshot else None + ) + existing.published_by = merged_by + existing.published_at = now + existing.saved_by = merged_by + existing.current_branch_name = MAIN_BRANCH + existing.active_branch_base_version_id = snapshot.id + existing.current_published_version_id = snapshot.id + existing.updated_at = now + + if auto_commit: + await db.commit() + await db.refresh(existing) + else: + await db.flush() return existing, False, False @@ -173,21 +528,25 @@ async def delete_version( *, target_version: int, ) -> bool: - result = await db.execute( - select(StudySetupConfigVersion).where( - StudySetupConfigVersion.study_id == study_id, - StudySetupConfigVersion.version == target_version, - ) - ) - target = result.scalar_one_or_none() + target = await _get_snapshot_by_version(db, study_id=study_id, version=target_version) if not target: return False + await db.execute( + sa_update(StudySetupConfig) + .where(StudySetupConfig.study_id == study_id, StudySetupConfig.active_branch_base_version_id == target.id) + .values(active_branch_base_version_id=None) + ) + await db.execute( + sa_update(StudySetupConfig) + .where(StudySetupConfig.study_id == study_id, StudySetupConfig.current_published_version_id == target.id) + .values(current_published_version_id=None) + ) await db.delete(target) await db.commit() return True async def delete_by_study(db: AsyncSession, study_id: uuid.UUID) -> None: - await db.execute(sa_delete(StudySetupConfigVersion).where(StudySetupConfigVersion.study_id == study_id)) await db.execute(sa_delete(StudySetupConfig).where(StudySetupConfig.study_id == study_id)) + await db.execute(sa_delete(StudySetupConfigVersion).where(StudySetupConfigVersion.study_id == study_id)) await db.commit() diff --git a/backend/app/models/study_setup_config.py b/backend/app/models/study_setup_config.py index 4dea6071..0773e39c 100644 --- a/backend/app/models/study_setup_config.py +++ b/backend/app/models/study_setup_config.py @@ -19,6 +19,13 @@ class StudySetupConfig(Base): publish_status: Mapped[str] = mapped_column(default="DRAFT", nullable=False) published_config: Mapped[dict | None] = mapped_column(JSONB, nullable=True) published_project_snapshot: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + current_branch_name: Mapped[str] = mapped_column(nullable=False, default="main") + active_branch_base_version_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("study_setup_config_versions.id", ondelete="SET NULL"), nullable=True + ) + current_published_version_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("study_setup_config_versions.id", ondelete="SET NULL"), nullable=True + ) published_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) saved_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) diff --git a/backend/app/models/study_setup_config_version.py b/backend/app/models/study_setup_config_version.py index 3ad6cf7a..1bcc33cd 100644 --- a/backend/app/models/study_setup_config_version.py +++ b/backend/app/models/study_setup_config_version.py @@ -12,7 +12,7 @@ class StudySetupConfigVersion(Base): __tablename__ = "study_setup_config_versions" __table_args__ = ( UniqueConstraint("study_id", "version", name="uq_setup_config_versions_study_version"), - UniqueConstraint("study_id", "display_version", name="uq_setup_config_versions_study_display_version"), + UniqueConstraint("study_id", "branch_name", "branch_seq", name="uq_setup_config_versions_study_branch_seq"), ) id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) @@ -22,8 +22,18 @@ class StudySetupConfigVersion(Base): study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=False, index=True) version: Mapped[int] = mapped_column(Integer, nullable=False) display_version: Mapped[int] = mapped_column(Integer, nullable=False) + branch_name: Mapped[str] = mapped_column(nullable=False, default="main") + branch_seq: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + version_label: Mapped[str] = mapped_column(nullable=False, default="") source_version: Mapped[int | None] = mapped_column(Integer, nullable=True) + parent_version_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("study_setup_config_versions.id", ondelete="SET NULL"), nullable=True + ) + merged_from_version_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("study_setup_config_versions.id", ondelete="SET NULL"), nullable=True + ) config: Mapped[dict] = mapped_column(JSONB, nullable=False) + published_project_snapshot: Mapped[dict | None] = mapped_column(JSONB, nullable=True) published_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) published_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) diff --git a/backend/app/schemas/study_setup_config.py b/backend/app/schemas/study_setup_config.py index ce719529..0183a4ba 100644 --- a/backend/app/schemas/study_setup_config.py +++ b/backend/app/schemas/study_setup_config.py @@ -92,14 +92,36 @@ class StudySetupConfigRollback(BaseModel): target_version: int +class StudySetupConfigCheckoutBranch(BaseModel): + expected_version: int | None = None + target_version: int + + +class StudySetupConfigDraftAction(BaseModel): + expected_version: int | None = None + + +class StudySetupConfigMergeToMain(BaseModel): + expected_version: int | None = None + source_version: int + + class StudySetupConfigVersionRead(BaseModel): id: uuid.UUID study_id: uuid.UUID study_setup_config_id: uuid.UUID version: int display_version: int + branch_name: str = "main" + branch_seq: int = 1 + version_label: str = "" source_version: int | None = None + parent_version_id: uuid.UUID | None = None + merged_from_version_id: uuid.UUID | None = None config: StudySetupConfigData + published_project_snapshot: "ProjectPublishSnapshot | None" = None + is_current_published: bool = False + is_active_draft_base: bool = False published_by: uuid.UUID | None = None published_by_name: str | None = None published_at: datetime @@ -153,6 +175,11 @@ class StudySetupConfigRead(BaseModel): id: uuid.UUID study_id: uuid.UUID version: int + current_branch_name: str = "main" + current_published_version_id: uuid.UUID | None = None + current_published_version_label: str | None = None + active_branch_base_version_id: uuid.UUID | None = None + active_branch_base_version_label: str | None = None data: StudySetupConfigData publish_status: str published_data: StudySetupConfigData | None = None diff --git a/backend/scripts/smoke_setup_config.py b/backend/scripts/smoke_setup_config.py index 1788aecc..857def60 100644 --- a/backend/scripts/smoke_setup_config.py +++ b/backend/scripts/smoke_setup_config.py @@ -370,7 +370,7 @@ def main() -> int: ) assert_or_exit(status == 200, f"回滚失败 status={status} body={rolled}") assert_or_exit( - rolled.get("publish_status") == "DRAFT" + rolled.get("publish_status") == "PUBLISHED" and (rolled.get("data") or {}).get("enrollmentPlan", {}).get("totalTarget") == payload["data"]["enrollmentPlan"]["totalTarget"], f"回滚结果异常 body={rolled}", ) diff --git a/docker-compose.yaml b/docker-compose.yaml index 4aee40f4..ba85f58a 100755 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -53,6 +53,7 @@ services: context: ./frontend dockerfile: Dockerfile container_name: ctms_frontend + restart: always # 开启 Host 模式以支持 Vite 热更新 command: npm run dev -- --host volumes: @@ -63,6 +64,7 @@ services: - "5173:5173" environment: VITE_API_BASE_URL: / + NODE_OPTIONS: --max-old-space-size=4096 depends_on: - backend networks: diff --git a/frontend/src/api/studies.ts b/frontend/src/api/studies.ts index 82472f4f..e1ef8b48 100644 --- a/frontend/src/api/studies.ts +++ b/frontend/src/api/studies.ts @@ -1,7 +1,10 @@ import { apiGet, apiPatch, apiPost, apiDelete, apiPut } from "./axios"; import type { ApiListResponse, Study } from "../types/api"; import type { + StudySetupConfigCheckoutBranchPayload, + StudySetupConfigDraftActionPayload, StudySetupConfigPublishPayload, + StudySetupConfigMergeMainPayload, StudySetupConfigResponse, StudySetupConfigRollbackPayload, StudySetupConfigUpsertPayload, @@ -42,3 +45,17 @@ export const deleteStudySetupConfigVersion = (studyId: string, targetVersion: nu export const rollbackStudySetupConfig = (studyId: string, payload: StudySetupConfigRollbackPayload) => apiPost(`/api/v1/studies/${studyId}/setup-config/rollback`, payload, { suppressErrorMessage: true }); + +export const checkoutStudySetupConfigBranchDraft = (studyId: string, payload: StudySetupConfigCheckoutBranchPayload) => + apiPost(`/api/v1/studies/${studyId}/setup-config/draft/checkout-branch`, payload, { + suppressErrorMessage: true, + }); + +export const clearStudySetupConfigDraft = (studyId: string, payload: StudySetupConfigDraftActionPayload) => + apiPost(`/api/v1/studies/${studyId}/setup-config/draft/clear`, payload, { suppressErrorMessage: true }); + +export const refillStudySetupConfigDraft = (studyId: string, payload: StudySetupConfigDraftActionPayload) => + apiPost(`/api/v1/studies/${studyId}/setup-config/draft/refill`, payload, { suppressErrorMessage: true }); + +export const mergeStudySetupConfigToMain = (studyId: string, payload: StudySetupConfigMergeMainPayload) => + apiPost(`/api/v1/studies/${studyId}/setup-config/merge-main`, payload, { suppressErrorMessage: true }); diff --git a/frontend/src/composables/useSetupConfig.ts b/frontend/src/composables/useSetupConfig.ts index e90b15fe..7a95b98f 100644 --- a/frontend/src/composables/useSetupConfig.ts +++ b/frontend/src/composables/useSetupConfig.ts @@ -1,12 +1,19 @@ import { + checkoutStudySetupConfigBranchDraft, + clearStudySetupConfigDraft, deleteStudySetupConfigVersion, fetchStudySetupConfig, fetchStudySetupConfigVersions, + mergeStudySetupConfigToMain, publishStudySetupConfig, + refillStudySetupConfigDraft, rollbackStudySetupConfig, saveStudySetupConfig, } from "../api/studies"; import type { + StudySetupConfigCheckoutBranchPayload, + StudySetupConfigDraftActionPayload, + StudySetupConfigMergeMainPayload, StudySetupConfigPublishPayload, StudySetupConfigRollbackPayload, StudySetupConfigUpsertPayload, @@ -16,16 +23,25 @@ export const useSetupConfig = () => { const getConfig = (studyId: string) => fetchStudySetupConfig(studyId); const saveConfig = (studyId: string, payload: StudySetupConfigUpsertPayload) => saveStudySetupConfig(studyId, payload); const publishConfig = (studyId: string, payload: StudySetupConfigPublishPayload) => publishStudySetupConfig(studyId, payload); + const mergeToMain = (studyId: string, payload: StudySetupConfigMergeMainPayload) => mergeStudySetupConfigToMain(studyId, payload); const listVersions = (studyId: string) => fetchStudySetupConfigVersions(studyId); const rollbackConfig = (studyId: string, payload: StudySetupConfigRollbackPayload) => rollbackStudySetupConfig(studyId, payload); + const checkoutBranchDraft = (studyId: string, payload: StudySetupConfigCheckoutBranchPayload) => + checkoutStudySetupConfigBranchDraft(studyId, payload); + const clearDraft = (studyId: string, payload: StudySetupConfigDraftActionPayload) => clearStudySetupConfigDraft(studyId, payload); + const refillDraft = (studyId: string, payload: StudySetupConfigDraftActionPayload) => refillStudySetupConfigDraft(studyId, payload); const deleteVersion = (studyId: string, targetVersion: number) => deleteStudySetupConfigVersion(studyId, targetVersion); return { getConfig, saveConfig, publishConfig, + mergeToMain, listVersions, rollbackConfig, + checkoutBranchDraft, + clearDraft, + refillDraft, deleteVersion, }; }; diff --git a/frontend/src/types/setupConfig.ts b/frontend/src/types/setupConfig.ts index 1ba42fa3..700a3996 100644 --- a/frontend/src/types/setupConfig.ts +++ b/frontend/src/types/setupConfig.ts @@ -98,6 +98,11 @@ export interface StudySetupConfigResponse { id: string; study_id: string; version: number; + current_branch_name?: string; + current_published_version_id?: string | null; + current_published_version_label?: string | null; + active_branch_base_version_id?: string | null; + active_branch_base_version_label?: string | null; data: SetupConfigDraft; publish_status: "DRAFT" | "PUBLISHED" | string; published_data?: SetupConfigDraft | null; @@ -134,14 +139,36 @@ export interface StudySetupConfigRollbackPayload { target_version: number; } +export interface StudySetupConfigCheckoutBranchPayload { + expected_version?: number | null; + target_version: number; +} + +export interface StudySetupConfigMergeMainPayload { + expected_version?: number | null; + source_version: number; +} + +export interface StudySetupConfigDraftActionPayload { + expected_version?: number | null; +} + export interface StudySetupConfigVersionItem { id: string; study_id: string; study_setup_config_id: string; version: number; display_version: number; + branch_name?: string; + branch_seq?: number; + version_label?: string; source_version?: number | null; + parent_version_id?: string | null; + merged_from_version_id?: string | null; config: SetupConfigDraft; + published_project_snapshot?: ProjectPublishSnapshot | null; + is_current_published?: boolean; + is_active_draft_base?: boolean; published_by?: string | null; published_by_name?: string | null; published_at: string; diff --git a/frontend/src/utils/setupDiffRows.ts b/frontend/src/utils/setupDiffRows.ts index 26ddfe89..28666ea3 100644 --- a/frontend/src/utils/setupDiffRows.ts +++ b/frontend/src/utils/setupDiffRows.ts @@ -107,6 +107,38 @@ const getArrayRowIdentity = (moduleKey: keyof SetupConfigDraft, value: unknown): return ""; }; +type ArrayRowEntry = { + key: string; + value: unknown; + rowIdentity: string; +}; + +const buildArrayRowEntry = (moduleKey: keyof SetupConfigDraft, value: unknown, index: number): ArrayRowEntry => { + const rowIdentity = getArrayRowIdentity(moduleKey, value); + if (value && typeof value === "object" && !Array.isArray(value)) { + const id = String((value as Record).id || "").trim(); + if (id) { + return { + key: `id:${id}`, + value, + rowIdentity, + }; + } + } + if (rowIdentity) { + return { + key: `identity:${rowIdentity}`, + value, + rowIdentity, + }; + } + return { + key: `index:${index}`, + value, + rowIdentity: "", + }; +}; + const formatReadablePath = (moduleKey: keyof SetupConfigDraft, path: string): string => { let normalized = path; if (path === moduleKey) { @@ -247,11 +279,47 @@ const collectDiffRows = ( if (Array.isArray(localValue) || Array.isArray(serverValue)) { const localArr = Array.isArray(localValue) ? localValue : []; const serverArr = Array.isArray(serverValue) ? serverValue : []; - const maxLen = Math.max(localArr.length, serverArr.length); - for (let i = 0; i < maxLen; i += 1) { - const rowIdentity = getArrayRowIdentity(moduleKey, localArr[i] ?? serverArr[i]); - const indexLabel = rowIdentity ? `${i}|${rowIdentity}` : `${i}`; - collectDiffRows(moduleKey, moduleLabel, localArr[i], serverArr[i], `${path}[${indexLabel}]`, rows); + + const localEntryMap = new Map(); + localArr.forEach((item, index) => { + const entry = buildArrayRowEntry(moduleKey, item, index); + const list = localEntryMap.get(entry.key); + if (list) { + list.push(entry); + } else { + localEntryMap.set(entry.key, [entry]); + } + }); + + const consumeLocalEntry = (key: string): ArrayRowEntry | null => { + const list = localEntryMap.get(key); + if (!list || list.length === 0) return null; + const entry = list.shift() || null; + if (list.length === 0) localEntryMap.delete(key); + return entry; + }; + + let pairIndex = 0; + serverArr.forEach((item, index) => { + const serverEntry = buildArrayRowEntry(moduleKey, item, index); + const localEntry = consumeLocalEntry(serverEntry.key); + const rowIdentity = (localEntry?.rowIdentity || serverEntry.rowIdentity || "").trim(); + const indexLabel = rowIdentity ? `${pairIndex}|${rowIdentity}` : `${pairIndex}`; + collectDiffRows(moduleKey, moduleLabel, localEntry?.value, serverEntry.value, `${path}[${indexLabel}]`, rows); + pairIndex += 1; + }); + + localEntryMap.forEach((list) => { + list.forEach((localEntry) => { + const rowIdentity = localEntry.rowIdentity.trim(); + const indexLabel = rowIdentity ? `${pairIndex}|${rowIdentity}` : `${pairIndex}`; + collectDiffRows(moduleKey, moduleLabel, localEntry.value, undefined, `${path}[${indexLabel}]`, rows); + pairIndex += 1; + }); + }); + if (localArr.length === 0 && serverArr.length === 0) { + // Keep behavior explicit for empty array pair to avoid extra recursion. + return; } return; } diff --git a/frontend/src/utils/setupPublishWorkflow.ts b/frontend/src/utils/setupPublishWorkflow.ts index adf19a1f..95175736 100644 --- a/frontend/src/utils/setupPublishWorkflow.ts +++ b/frontend/src/utils/setupPublishWorkflow.ts @@ -6,8 +6,8 @@ export type SetupWorkflowStatus = "UNSAVED_DRAFT" | "SAVED_DRAFT" | "PUBLISHED"; export type SetupWorkflowTagMeta = { label: string; type: "success" | "warning" | "info" }; export const SETUP_WORKFLOW_TAG_META: Record = { - UNSAVED_DRAFT: { label: "草稿未保存", type: "warning" }, - SAVED_DRAFT: { label: "草稿已保存(未发布)", type: "info" }, + UNSAVED_DRAFT: { label: "未保存", type: "warning" }, + SAVED_DRAFT: { label: "已保存", type: "info" }, PUBLISHED: { label: "已发布", type: "success" }, }; diff --git a/frontend/src/views/admin/ProjectDetail.vue b/frontend/src/views/admin/ProjectDetail.vue index 7866909b..e3591781 100644 --- a/frontend/src/views/admin/ProjectDetail.vue +++ b/frontend/src/views/admin/ProjectDetail.vue @@ -18,39 +18,54 @@
-
-
- - - 已锁定 - -
-
- {{ setupSaveMetaText }} - - {{ setupWorkflowTagLabel }} - - - 发布时间 {{ setupPublishedAt || "-" }} - -
+
草稿 - 预览 + 预览 + 已发布版
+ + + + 已锁定 + + +
+
+ 当前发布 + + {{ setupPublishedBranchName || "main" }} + {{ setupPublishedVersionText }} + + + + +
+ +
+ 草稿基线 + + {{ setupDraftBranchDisplay }} + {{ setupDraftBaseVersionDisplay }} + + {{ setupDraftStatusDisplay }} +
+
保存配置 + 一键回填 + 清空草稿 发布版本 - 回滚版本 + 版本管理 @@ -782,7 +797,7 @@
-
+
中心
-
- -
中心入组日期
@@ -1124,7 +1136,7 @@
当前中心暂未配置入组计划
确认发布 - - - - - - - - - - - + + + +
+ +
+
+ 分支轨道 +
+ + + {{ lane.branch }} + +
+
+
+ + +
+ + {{ formatAxisLaneLabel(lane.branch) }} + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+
+
+ {{ row.displayLabel }} + + + 当前发布 + + + + 草稿基线 + + + + 来源 {{ row.mergeFromLabel }} + +
+
+ + + {{ row.branch_name || "main" }} + + + + {{ row.published_by_name || "-" }} + + + + {{ row.published_at }} + +
+
+ + + + 回滚 + + + + + + 新建分支草稿 + + + + + + 合并主线(新版本) + + + + + + 下载 + + + + + + 删除 + + +
+
+
+
+
+
@@ -1761,10 +1956,10 @@