diff --git a/backend/alembic/versions/20260511_01_backfill_setup_project_info.py b/backend/alembic/versions/20260511_01_backfill_setup_project_info.py new file mode 100644 index 00000000..69137ae1 --- /dev/null +++ b/backend/alembic/versions/20260511_01_backfill_setup_project_info.py @@ -0,0 +1,157 @@ +"""backfill project info in setup drafts + +Revision ID: 20260511_01 +Revises: 20260509_03 +Create Date: 2026-05-11 10:30:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260511_01" +down_revision: Union[str, None] = "20260509_03" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +EMPTY_PROJECT_INFO_JSON = """{ + "code": "", + "name": "", + "project_full_name": "", + "sponsor": "", + "protocol_no": "", + "lead_unit": "", + "principal_investigator": "", + "main_pm": "", + "research_analysis": "", + "research_product": "", + "control_product": "", + "indication": "", + "research_population": "", + "research_design": "", + "plan_start_date": "", + "plan_end_date": "", + "planned_site_count": null, + "planned_enrollment_count": null, + "status": "", + "visit_schedule": [] +}""" + + +def _has_table(inspector: sa.Inspector, table_name: str) -> bool: + return table_name in set(inspector.get_table_names()) + + +def _has_columns(inspector: sa.Inspector, table_name: str, column_names: set[str]) -> bool: + existing = {col["name"] for col in inspector.get_columns(table_name)} + return column_names.issubset(existing) + + +def _backfill_config_column(table_name: str, config_column: str, *, require_existing_setup_content: bool) -> None: + content_filter = "" + if require_existing_setup_content: + content_filter = f""" + AND ( + jsonb_array_length(COALESCE(setup.{config_column}->'projectMilestones', '[]'::jsonb)) > 0 + OR COALESCE(setup.{config_column}->'enrollmentPlan', '{{}}'::jsonb) <> '{{"totalTarget": 0, "startDate": "", "endDate": "", "monthlyGoalNote": "", "stageBreakdown": ""}}'::jsonb + OR jsonb_array_length(COALESCE(setup.{config_column}->'siteMilestones', '[]'::jsonb)) > 0 + OR jsonb_array_length(COALESCE(setup.{config_column}->'siteEnrollmentPlans', '[]'::jsonb)) > 0 + OR jsonb_array_length(COALESCE(setup.{config_column}->'monitoringStrategies', '[]'::jsonb)) > 0 + OR jsonb_array_length(COALESCE(setup.{config_column}->'centerConfirm', '[]'::jsonb)) > 0 + ) + """ + op.execute( + sa.text( + f""" + UPDATE {table_name} AS setup + SET {config_column} = jsonb_set( + setup.{config_column}, + '{{projectInfo}}', + jsonb_build_object( + 'code', COALESCE(studies.code, ''), + 'name', COALESCE(studies.name, ''), + 'project_full_name', COALESCE(studies.project_full_name, ''), + 'sponsor', COALESCE(studies.sponsor, ''), + 'protocol_no', COALESCE(studies.protocol_no, ''), + 'lead_unit', COALESCE(studies.lead_unit, ''), + 'principal_investigator', COALESCE(studies.principal_investigator, ''), + 'main_pm', COALESCE(studies.main_pm, ''), + 'research_analysis', COALESCE(studies.research_analysis, ''), + 'research_product', COALESCE(studies.research_product, ''), + 'control_product', COALESCE(studies.control_product, ''), + 'indication', COALESCE(studies.indication, ''), + 'research_population', COALESCE(studies.research_population, ''), + 'research_design', COALESCE(studies.research_design, ''), + 'plan_start_date', COALESCE(to_char(studies.plan_start_date, 'YYYY-MM-DD'), ''), + 'plan_end_date', COALESCE(to_char(studies.plan_end_date, 'YYYY-MM-DD'), ''), + 'planned_site_count', to_jsonb(studies.planned_site_count), + 'planned_enrollment_count', to_jsonb(studies.planned_enrollment_count), + 'status', COALESCE(studies.status, ''), + 'visit_schedule', COALESCE(studies.visit_schedule::jsonb, '[]'::jsonb) + ), + true + ) + FROM studies + WHERE setup.study_id = studies.id + AND setup.{config_column} IS NOT NULL + AND ( + NOT setup.{config_column} ? 'projectInfo' + OR setup.{config_column}->'projectInfo' = CAST(:empty_project_info AS jsonb) + ) + {content_filter} + """ + ).bindparams(empty_project_info=EMPTY_PROJECT_INFO_JSON) + ) + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + required_study_columns = { + "id", + "code", + "name", + "project_full_name", + "sponsor", + "protocol_no", + "lead_unit", + "principal_investigator", + "main_pm", + "research_analysis", + "research_product", + "control_product", + "indication", + "research_population", + "research_design", + "plan_start_date", + "plan_end_date", + "planned_site_count", + "planned_enrollment_count", + "status", + "visit_schedule", + } + if not _has_table(inspector, "studies") or not _has_columns(inspector, "studies", required_study_columns): + return + + if _has_table(inspector, "study_setup_configs") and _has_columns( + inspector, "study_setup_configs", {"study_id", "config"} + ): + _backfill_config_column("study_setup_configs", "config", require_existing_setup_content=True) + + if _has_table(inspector, "study_setup_configs") and _has_columns( + inspector, "study_setup_configs", {"study_id", "published_config"} + ): + _backfill_config_column("study_setup_configs", "published_config", require_existing_setup_content=False) + + if _has_table(inspector, "study_setup_config_versions") and _has_columns( + inspector, "study_setup_config_versions", {"study_id", "config"} + ): + _backfill_config_column("study_setup_config_versions", "config", require_existing_setup_content=False) + + +def downgrade() -> None: + pass diff --git a/backend/app/api/v1/studies.py b/backend/app/api/v1/studies.py index 66f6fd6f..879b732e 100644 --- a/backend/app/api/v1/studies.py +++ b/backend/app/api/v1/studies.py @@ -242,6 +242,7 @@ def _build_default_setup_config_from_study(study, sites: list) -> StudySetupConf if total_target is None: total_target = 0 return StudySetupConfigData( + projectInfo=_build_project_publish_snapshot(study), projectMilestones=[], enrollmentPlan={ "totalTarget": total_target, @@ -263,13 +264,6 @@ def _to_date_text(value: date | None) -> str: return value.isoformat() -def _project_snapshot_has_draft_values(snapshot: ProjectPublishSnapshot | None) -> bool: - if not snapshot: - return False - data = snapshot.model_dump(mode="json") - return any(value not in ("", None, []) for value in data.values()) - - def _build_project_publish_snapshot(study) -> ProjectPublishSnapshot: return ProjectPublishSnapshot( code=getattr(study, "code", None) or "", @@ -296,9 +290,7 @@ def _build_project_publish_snapshot(study) -> ProjectPublishSnapshot: def _resolve_setup_project_snapshot(setup_data: StudySetupConfigData, study) -> ProjectPublishSnapshot: - if _project_snapshot_has_draft_values(setup_data.projectInfo): - return setup_data.projectInfo - return _build_project_publish_snapshot(study) + return setup_data.projectInfo def _parse_optional_snapshot_date(value: str) -> date | None: diff --git a/backend/tests/test_setup_config_project_draft.py b/backend/tests/test_setup_config_project_draft.py index 5ce84753..d226e34f 100644 --- a/backend/tests/test_setup_config_project_draft.py +++ b/backend/tests/test_setup_config_project_draft.py @@ -3,7 +3,12 @@ from types import SimpleNamespace from fastapi import HTTPException -from app.api.v1.studies import _apply_project_publish_snapshot_to_study, _validate_setup_data +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 @@ -172,6 +177,78 @@ def test_project_info_is_part_of_setup_config_payload(): 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", diff --git a/docs/guides/branch-environment-installation.md b/docs/guides/branch-environment-installation.md index cbc284aa..7ebb6f6b 100644 --- a/docs/guides/branch-environment-installation.md +++ b/docs/guides/branch-environment-installation.md @@ -118,7 +118,17 @@ EOF docker compose up -d --build ``` -### 4. 检查容器状态 +### 4. 执行数据库迁移 + +已有数据库升级到新代码时,启动后执行一次迁移: + +```bash +docker compose run --rm backend python -m alembic upgrade head +``` + +首次空库安装也可以执行该命令;如果已是最新版本,Alembic 不会重复应用迁移。 + +### 5. 检查容器状态 ```bash docker compose ps @@ -126,7 +136,7 @@ docker compose ps 确认 `ctms_db`、`ctms_backend`、`ctms_nginx` 都是 `Up`,且 `ctms_db` 为 `healthy`。 -### 5. 检查后端环境 +### 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)))" @@ -140,7 +150,7 @@ JWT_SECRET_IS_DEV=True LOGIN_RSA_PRIVATE_KEY_SET=False ``` -### 6. 检查接口 +### 7. 检查接口 ```bash curl -i http://127.0.0.1:8888/health @@ -149,13 +159,13 @@ curl -i http://127.0.0.1:8888/api/v1/auth/login-key 两个接口都应返回 `HTTP/1.1 200 OK`。 -### 7. 打开系统 +### 8. 打开系统 ```text http://localhost:8888 ``` -### 8. 运行验证 +### 9. 运行验证 后端: @@ -243,7 +253,17 @@ docker compose run --rm backend-init docker compose up -d --build ``` -### 7. 检查容器状态 +### 7. 执行数据库迁移 + +已有数据库升级到新代码时,启动后执行一次迁移: + +```bash +docker compose run --rm backend python -m alembic upgrade head +``` + +`backend-init` 会在初始化时处理空库和已有库迁移;这里单独执行迁移用于确认部署代码已落到最新数据库版本。 + +### 8. 检查容器状态 ```bash docker compose ps @@ -251,7 +271,7 @@ docker compose ps 确认 `ctms_db`、`ctms_backend`、`ctms_nginx` 都是 `Up`,且 `ctms_db` 为 `healthy`。 -### 8. 检查后端环境 +### 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)" @@ -266,7 +286,7 @@ LOGIN_RSA_PRIVATE_KEY_SET=True LOGIN_RSA_KEY_ID=main-YYYYMMDD ``` -### 9. 检查接口 +### 10. 检查接口 本机: @@ -284,7 +304,7 @@ curl -i https:///api/v1/auth/login-key 接口应返回 `HTTP/1.1 200 OK`。 -### 10. 打开系统 +### 11. 打开系统 本机: @@ -300,7 +320,7 @@ https:// 远程访问必须使用 HTTPS。 -### 11. 运行验证 +### 12. 运行验证 后端: @@ -417,7 +437,17 @@ docker compose run --rm backend-init docker compose up -d --build ``` -### 8. 检查容器状态 +### 8. 执行数据库迁移 + +已有数据库升级到新代码时,启动后执行一次迁移: + +```bash +docker compose run --rm backend python -m alembic upgrade head +``` + +`backend-init` 会在初始化时处理空库和已有库迁移;这里单独执行迁移用于确认部署代码已落到最新数据库版本。 + +### 9. 检查容器状态 ```bash docker compose ps @@ -425,7 +455,7 @@ docker compose ps 确认 `ctms_db`、`ctms_backend`、`ctms_nginx` 都是 `Up`,且 `ctms_db` 为 `healthy`。 -### 9. 检查后端环境 +### 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)" @@ -440,7 +470,7 @@ LOGIN_RSA_PRIVATE_KEY_SET=True LOGIN_RSA_KEY_ID=release-YYYYMMDD ``` -### 10. 检查生产接口 +### 11. 检查生产接口 ```bash curl -i https:///health @@ -449,7 +479,7 @@ curl -i https:///api/v1/auth/login-key 两个接口都应返回 `HTTP/1.1 200 OK`。 -### 11. 打开系统并登录 +### 12. 打开系统并登录 ```text https:// @@ -463,7 +493,7 @@ admin@huapont.cn / admin123 首次登录后立即修改初始密码。 -### 12. 发布后检查 +### 13. 发布后检查 查看容器: diff --git a/frontend/src/views/admin/ProjectDetail.test.ts b/frontend/src/views/admin/ProjectDetail.test.ts index c40f5e6c..2b4430f7 100644 --- a/frontend/src/views/admin/ProjectDetail.test.ts +++ b/frontend/src/views/admin/ProjectDetail.test.ts @@ -50,15 +50,28 @@ describe("ProjectDetail setup draft publish workflow", () => { expect(source.indexOf("applySetupDraft(createDefaultSetupDraft(siteOptions.value));", functionIndex)).toBe(-1); }); - it("does not let legacy empty projectInfo overwrite project master data", () => { + it("applies setup drafts atomically including empty project info", () => { const source = readProjectDetail(); const functionIndex = source.indexOf("const applySetupDraft = (draft: SetupConfigDraft) => {"); - const emptyGuardIndex = source.indexOf("!isProjectPublishSnapshotEmpty(projectInfo) || isSetupDraftEffectivelyEmpty(setupDraft)", functionIndex); - const fallbackIndex = source.indexOf("setupDraft.projectInfo = buildProjectPublishSnapshot();", emptyGuardIndex); + const nextFunctionIndex = source.indexOf("const loadLocalSetupDraft = ", functionIndex); + const body = source.slice(functionIndex, nextFunctionIndex); expect(functionIndex).toBeGreaterThan(-1); - expect(emptyGuardIndex).toBeGreaterThan(functionIndex); - expect(fallbackIndex).toBeGreaterThan(emptyGuardIndex); + 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", () => { diff --git a/frontend/src/views/admin/ProjectDetail.vue b/frontend/src/views/admin/ProjectDetail.vue index 643fa6fb..8ec5fa79 100644 --- a/frontend/src/views/admin/ProjectDetail.vue +++ b/frontend/src/views/admin/ProjectDetail.vue @@ -2672,6 +2672,7 @@ const setupWorkflowTagMeta = computed(() => SETUP_WORKFLOW const setupWorkflowTagLabel = computed(() => setupWorkflowTagMeta.value.label); const setupWorkflowTagType = computed<"success" | "warning" | "info">(() => setupWorkflowTagMeta.value.type); const isSetupDraftEffectivelyEmpty = (draft: SetupConfigDraft): boolean => { + if (!isProjectPublishSnapshotEmpty(draft.projectInfo)) return false; if (draft.projectMilestones.length > 0) return false; if (draft.siteMilestones.length > 0) return false; if (draft.siteEnrollmentPlans.length > 0) return false; @@ -2686,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(); @@ -3988,7 +3991,7 @@ const isSetupDraftShape = (value: unknown): value is SetupConfigDraft => { if (!value || typeof value !== "object") return false; const data = value as Record; return ( - (!Object.prototype.hasOwnProperty.call(data, "projectInfo") || isProjectPublishSnapshotShape(data.projectInfo)) && + isProjectPublishSnapshotShape(data.projectInfo) && Array.isArray(data.projectMilestones) && !!data.enrollmentPlan && Array.isArray(data.siteMilestones) && @@ -4009,8 +4012,9 @@ const isProjectPublishSnapshotShape = (value: unknown): value is ProjectPublishS ); }; -const isProjectPublishSnapshotEmpty = (snapshot: ProjectPublishSnapshot): boolean => - JSON.stringify(snapshot) === JSON.stringify(emptyProjectInfoDraft()); +function isProjectPublishSnapshotEmpty(snapshot: ProjectPublishSnapshot): boolean { + return JSON.stringify(snapshot) === JSON.stringify(emptyProjectInfoDraft()); +} const applyProjectInfoDraft = (snapshot: ProjectPublishSnapshot) => { suppressEnrollmentFieldSync.value = true; @@ -4047,11 +4051,7 @@ const applySetupDraft = (draft: SetupConfigDraft) => { setupDraft.siteEnrollmentPlans = normalizeSiteEnrollmentPlans(clone(draft.siteEnrollmentPlans)); setupDraft.monitoringStrategies = clone(draft.monitoringStrategies); setupDraft.centerConfirm = clone(draft.centerConfirm); - if (!isProjectPublishSnapshotEmpty(projectInfo) || isSetupDraftEffectivelyEmpty(setupDraft)) { - applyProjectInfoDraft(projectInfo); - } else { - setupDraft.projectInfo = buildProjectPublishSnapshot(); - } + applyProjectInfoDraft(projectInfo); formBaselineSnapshot.value = serializeFormForCompare(); suppressDraftWatch.value = false; }; @@ -4160,6 +4160,10 @@ const loadSetupDraftFromServer = async (): Promise => { try { const { data } = await fetchSetupConfig(project.value.id); if (!isSetupDraftShape(data?.data)) return null; + const draft = { + ...clone(data.data), + projectInfo: isProjectPublishSnapshotShape(data.data.projectInfo) ? clone(data.data.projectInfo) : buildProjectPublishSnapshot(), + }; applySetupResponseMeta(data); updateLastServerSaveMeta(data.updated_at, data.saved_by_name || resolveSavedByDisplay(data.saved_by)); setupSaveMeta.value = { @@ -4167,10 +4171,7 @@ const loadSetupDraftFromServer = async (): Promise => { savedBy: data.saved_by_name || resolveSavedByDisplay(data.saved_by), serverSynced: true, }; - return { - ...clone(data.data), - projectInfo: isProjectPublishSnapshotShape(data.data.projectInfo) ? clone(data.data.projectInfo) : buildProjectPublishSnapshot(), - }; + return draft; } catch { return null; } @@ -4512,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));