修复项目配置草稿原子保存逻辑
This commit is contained in:
@@ -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
|
||||||
@@ -242,6 +242,7 @@ def _build_default_setup_config_from_study(study, sites: list) -> StudySetupConf
|
|||||||
if total_target is None:
|
if total_target is None:
|
||||||
total_target = 0
|
total_target = 0
|
||||||
return StudySetupConfigData(
|
return StudySetupConfigData(
|
||||||
|
projectInfo=_build_project_publish_snapshot(study),
|
||||||
projectMilestones=[],
|
projectMilestones=[],
|
||||||
enrollmentPlan={
|
enrollmentPlan={
|
||||||
"totalTarget": total_target,
|
"totalTarget": total_target,
|
||||||
@@ -263,13 +264,6 @@ def _to_date_text(value: date | None) -> str:
|
|||||||
return value.isoformat()
|
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:
|
def _build_project_publish_snapshot(study) -> ProjectPublishSnapshot:
|
||||||
return ProjectPublishSnapshot(
|
return ProjectPublishSnapshot(
|
||||||
code=getattr(study, "code", None) or "",
|
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:
|
def _resolve_setup_project_snapshot(setup_data: StudySetupConfigData, study) -> ProjectPublishSnapshot:
|
||||||
if _project_snapshot_has_draft_values(setup_data.projectInfo):
|
|
||||||
return setup_data.projectInfo
|
return setup_data.projectInfo
|
||||||
return _build_project_publish_snapshot(study)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_optional_snapshot_date(value: str) -> date | None:
|
def _parse_optional_snapshot_date(value: str) -> date | None:
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ from types import SimpleNamespace
|
|||||||
|
|
||||||
from fastapi import HTTPException
|
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
|
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
|
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():
|
def test_apply_project_publish_snapshot_updates_formal_study_fields():
|
||||||
study = SimpleNamespace(
|
study = SimpleNamespace(
|
||||||
code="PRJ-001",
|
code="PRJ-001",
|
||||||
|
|||||||
@@ -118,7 +118,17 @@ EOF
|
|||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. 检查容器状态
|
### 4. 执行数据库迁移
|
||||||
|
|
||||||
|
已有数据库升级到新代码时,启动后执行一次迁移:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose run --rm backend python -m alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
首次空库安装也可以执行该命令;如果已是最新版本,Alembic 不会重复应用迁移。
|
||||||
|
|
||||||
|
### 5. 检查容器状态
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose ps
|
docker compose ps
|
||||||
@@ -126,7 +136,7 @@ docker compose ps
|
|||||||
|
|
||||||
确认 `ctms_db`、`ctms_backend`、`ctms_nginx` 都是 `Up`,且 `ctms_db` 为 `healthy`。
|
确认 `ctms_db`、`ctms_backend`、`ctms_nginx` 都是 `Up`,且 `ctms_db` 为 `healthy`。
|
||||||
|
|
||||||
### 5. 检查后端环境
|
### 6. 检查后端环境
|
||||||
|
|
||||||
```bash
|
```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)))"
|
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
|
LOGIN_RSA_PRIVATE_KEY_SET=False
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6. 检查接口
|
### 7. 检查接口
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -i http://127.0.0.1:8888/health
|
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`。
|
两个接口都应返回 `HTTP/1.1 200 OK`。
|
||||||
|
|
||||||
### 7. 打开系统
|
### 8. 打开系统
|
||||||
|
|
||||||
```text
|
```text
|
||||||
http://localhost:8888
|
http://localhost:8888
|
||||||
```
|
```
|
||||||
|
|
||||||
### 8. 运行验证
|
### 9. 运行验证
|
||||||
|
|
||||||
后端:
|
后端:
|
||||||
|
|
||||||
@@ -243,7 +253,17 @@ docker compose run --rm backend-init
|
|||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
### 7. 检查容器状态
|
### 7. 执行数据库迁移
|
||||||
|
|
||||||
|
已有数据库升级到新代码时,启动后执行一次迁移:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose run --rm backend python -m alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
`backend-init` 会在初始化时处理空库和已有库迁移;这里单独执行迁移用于确认部署代码已落到最新数据库版本。
|
||||||
|
|
||||||
|
### 8. 检查容器状态
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose ps
|
docker compose ps
|
||||||
@@ -251,7 +271,7 @@ docker compose ps
|
|||||||
|
|
||||||
确认 `ctms_db`、`ctms_backend`、`ctms_nginx` 都是 `Up`,且 `ctms_db` 为 `healthy`。
|
确认 `ctms_db`、`ctms_backend`、`ctms_nginx` 都是 `Up`,且 `ctms_db` 为 `healthy`。
|
||||||
|
|
||||||
### 8. 检查后端环境
|
### 9. 检查后端环境
|
||||||
|
|
||||||
```bash
|
```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)"
|
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
|
LOGIN_RSA_KEY_ID=main-YYYYMMDD
|
||||||
```
|
```
|
||||||
|
|
||||||
### 9. 检查接口
|
### 10. 检查接口
|
||||||
|
|
||||||
本机:
|
本机:
|
||||||
|
|
||||||
@@ -284,7 +304,7 @@ curl -i https://<main-domain>/api/v1/auth/login-key
|
|||||||
|
|
||||||
接口应返回 `HTTP/1.1 200 OK`。
|
接口应返回 `HTTP/1.1 200 OK`。
|
||||||
|
|
||||||
### 10. 打开系统
|
### 11. 打开系统
|
||||||
|
|
||||||
本机:
|
本机:
|
||||||
|
|
||||||
@@ -300,7 +320,7 @@ https://<main-domain>
|
|||||||
|
|
||||||
远程访问必须使用 HTTPS。
|
远程访问必须使用 HTTPS。
|
||||||
|
|
||||||
### 11. 运行验证
|
### 12. 运行验证
|
||||||
|
|
||||||
后端:
|
后端:
|
||||||
|
|
||||||
@@ -417,7 +437,17 @@ docker compose run --rm backend-init
|
|||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
### 8. 检查容器状态
|
### 8. 执行数据库迁移
|
||||||
|
|
||||||
|
已有数据库升级到新代码时,启动后执行一次迁移:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose run --rm backend python -m alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
`backend-init` 会在初始化时处理空库和已有库迁移;这里单独执行迁移用于确认部署代码已落到最新数据库版本。
|
||||||
|
|
||||||
|
### 9. 检查容器状态
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose ps
|
docker compose ps
|
||||||
@@ -425,7 +455,7 @@ docker compose ps
|
|||||||
|
|
||||||
确认 `ctms_db`、`ctms_backend`、`ctms_nginx` 都是 `Up`,且 `ctms_db` 为 `healthy`。
|
确认 `ctms_db`、`ctms_backend`、`ctms_nginx` 都是 `Up`,且 `ctms_db` 为 `healthy`。
|
||||||
|
|
||||||
### 9. 检查后端环境
|
### 10. 检查后端环境
|
||||||
|
|
||||||
```bash
|
```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)"
|
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
|
LOGIN_RSA_KEY_ID=release-YYYYMMDD
|
||||||
```
|
```
|
||||||
|
|
||||||
### 10. 检查生产接口
|
### 11. 检查生产接口
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -i https://<production-domain>/health
|
curl -i https://<production-domain>/health
|
||||||
@@ -449,7 +479,7 @@ curl -i https://<production-domain>/api/v1/auth/login-key
|
|||||||
|
|
||||||
两个接口都应返回 `HTTP/1.1 200 OK`。
|
两个接口都应返回 `HTTP/1.1 200 OK`。
|
||||||
|
|
||||||
### 11. 打开系统并登录
|
### 12. 打开系统并登录
|
||||||
|
|
||||||
```text
|
```text
|
||||||
https://<production-domain>
|
https://<production-domain>
|
||||||
@@ -463,7 +493,7 @@ admin@huapont.cn / admin123
|
|||||||
|
|
||||||
首次登录后立即修改初始密码。
|
首次登录后立即修改初始密码。
|
||||||
|
|
||||||
### 12. 发布后检查
|
### 13. 发布后检查
|
||||||
|
|
||||||
查看容器:
|
查看容器:
|
||||||
|
|
||||||
|
|||||||
@@ -50,15 +50,28 @@ describe("ProjectDetail setup draft publish workflow", () => {
|
|||||||
expect(source.indexOf("applySetupDraft(createDefaultSetupDraft(siteOptions.value));", functionIndex)).toBe(-1);
|
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 source = readProjectDetail();
|
||||||
const functionIndex = source.indexOf("const applySetupDraft = (draft: SetupConfigDraft) => {");
|
const functionIndex = source.indexOf("const applySetupDraft = (draft: SetupConfigDraft) => {");
|
||||||
const emptyGuardIndex = source.indexOf("!isProjectPublishSnapshotEmpty(projectInfo) || isSetupDraftEffectivelyEmpty(setupDraft)", functionIndex);
|
const nextFunctionIndex = source.indexOf("const loadLocalSetupDraft = ", functionIndex);
|
||||||
const fallbackIndex = source.indexOf("setupDraft.projectInfo = buildProjectPublishSnapshot();", emptyGuardIndex);
|
const body = source.slice(functionIndex, nextFunctionIndex);
|
||||||
|
|
||||||
expect(functionIndex).toBeGreaterThan(-1);
|
expect(functionIndex).toBeGreaterThan(-1);
|
||||||
expect(emptyGuardIndex).toBeGreaterThan(functionIndex);
|
expect(body).toContain("setupDraft.projectInfo = projectInfo;");
|
||||||
expect(fallbackIndex).toBeGreaterThan(emptyGuardIndex);
|
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", () => {
|
it("keeps early termination out of regular visit schedule options", () => {
|
||||||
|
|||||||
@@ -2672,6 +2672,7 @@ const setupWorkflowTagMeta = computed<SetupWorkflowTagMeta>(() => SETUP_WORKFLOW
|
|||||||
const setupWorkflowTagLabel = computed(() => setupWorkflowTagMeta.value.label);
|
const setupWorkflowTagLabel = computed(() => setupWorkflowTagMeta.value.label);
|
||||||
const setupWorkflowTagType = computed<"success" | "warning" | "info">(() => setupWorkflowTagMeta.value.type);
|
const setupWorkflowTagType = computed<"success" | "warning" | "info">(() => setupWorkflowTagMeta.value.type);
|
||||||
const isSetupDraftEffectivelyEmpty = (draft: SetupConfigDraft): boolean => {
|
const isSetupDraftEffectivelyEmpty = (draft: SetupConfigDraft): boolean => {
|
||||||
|
if (!isProjectPublishSnapshotEmpty(draft.projectInfo)) return false;
|
||||||
if (draft.projectMilestones.length > 0) return false;
|
if (draft.projectMilestones.length > 0) return false;
|
||||||
if (draft.siteMilestones.length > 0) return false;
|
if (draft.siteMilestones.length > 0) return false;
|
||||||
if (draft.siteEnrollmentPlans.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;
|
if ((plan.stageBreakdown || "").trim()) return false;
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
const isAtomicSetupDraft = (draft: SetupConfigDraft): boolean =>
|
||||||
|
!isProjectPublishSnapshotEmpty(draft.projectInfo) || isSetupDraftEffectivelyEmpty(draft);
|
||||||
const shouldShowEmptyDraftBaseInfo = computed(() => isSetupDraftEffectivelyEmpty(setupDraft));
|
const shouldShowEmptyDraftBaseInfo = computed(() => isSetupDraftEffectivelyEmpty(setupDraft));
|
||||||
const setupDraftBaseVersionText = computed(() => {
|
const setupDraftBaseVersionText = computed(() => {
|
||||||
const explicit = (setupActiveDraftBaseVersion.value || "").trim();
|
const explicit = (setupActiveDraftBaseVersion.value || "").trim();
|
||||||
@@ -3988,7 +3991,7 @@ const isSetupDraftShape = (value: unknown): value is SetupConfigDraft => {
|
|||||||
if (!value || typeof value !== "object") return false;
|
if (!value || typeof value !== "object") return false;
|
||||||
const data = value as Record<string, unknown>;
|
const data = value as Record<string, unknown>;
|
||||||
return (
|
return (
|
||||||
(!Object.prototype.hasOwnProperty.call(data, "projectInfo") || isProjectPublishSnapshotShape(data.projectInfo)) &&
|
isProjectPublishSnapshotShape(data.projectInfo) &&
|
||||||
Array.isArray(data.projectMilestones) &&
|
Array.isArray(data.projectMilestones) &&
|
||||||
!!data.enrollmentPlan &&
|
!!data.enrollmentPlan &&
|
||||||
Array.isArray(data.siteMilestones) &&
|
Array.isArray(data.siteMilestones) &&
|
||||||
@@ -4009,8 +4012,9 @@ const isProjectPublishSnapshotShape = (value: unknown): value is ProjectPublishS
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isProjectPublishSnapshotEmpty = (snapshot: ProjectPublishSnapshot): boolean =>
|
function isProjectPublishSnapshotEmpty(snapshot: ProjectPublishSnapshot): boolean {
|
||||||
JSON.stringify(snapshot) === JSON.stringify(emptyProjectInfoDraft());
|
return JSON.stringify(snapshot) === JSON.stringify(emptyProjectInfoDraft());
|
||||||
|
}
|
||||||
|
|
||||||
const applyProjectInfoDraft = (snapshot: ProjectPublishSnapshot) => {
|
const applyProjectInfoDraft = (snapshot: ProjectPublishSnapshot) => {
|
||||||
suppressEnrollmentFieldSync.value = true;
|
suppressEnrollmentFieldSync.value = true;
|
||||||
@@ -4047,11 +4051,7 @@ const applySetupDraft = (draft: SetupConfigDraft) => {
|
|||||||
setupDraft.siteEnrollmentPlans = normalizeSiteEnrollmentPlans(clone(draft.siteEnrollmentPlans));
|
setupDraft.siteEnrollmentPlans = normalizeSiteEnrollmentPlans(clone(draft.siteEnrollmentPlans));
|
||||||
setupDraft.monitoringStrategies = clone(draft.monitoringStrategies);
|
setupDraft.monitoringStrategies = clone(draft.monitoringStrategies);
|
||||||
setupDraft.centerConfirm = clone(draft.centerConfirm);
|
setupDraft.centerConfirm = clone(draft.centerConfirm);
|
||||||
if (!isProjectPublishSnapshotEmpty(projectInfo) || isSetupDraftEffectivelyEmpty(setupDraft)) {
|
|
||||||
applyProjectInfoDraft(projectInfo);
|
applyProjectInfoDraft(projectInfo);
|
||||||
} else {
|
|
||||||
setupDraft.projectInfo = buildProjectPublishSnapshot();
|
|
||||||
}
|
|
||||||
formBaselineSnapshot.value = serializeFormForCompare();
|
formBaselineSnapshot.value = serializeFormForCompare();
|
||||||
suppressDraftWatch.value = false;
|
suppressDraftWatch.value = false;
|
||||||
};
|
};
|
||||||
@@ -4160,6 +4160,10 @@ const loadSetupDraftFromServer = async (): Promise<SetupConfigDraft | null> => {
|
|||||||
try {
|
try {
|
||||||
const { data } = await fetchSetupConfig(project.value.id);
|
const { data } = await fetchSetupConfig(project.value.id);
|
||||||
if (!isSetupDraftShape(data?.data)) return null;
|
if (!isSetupDraftShape(data?.data)) return null;
|
||||||
|
const draft = {
|
||||||
|
...clone(data.data),
|
||||||
|
projectInfo: isProjectPublishSnapshotShape(data.data.projectInfo) ? clone(data.data.projectInfo) : buildProjectPublishSnapshot(),
|
||||||
|
};
|
||||||
applySetupResponseMeta(data);
|
applySetupResponseMeta(data);
|
||||||
updateLastServerSaveMeta(data.updated_at, data.saved_by_name || resolveSavedByDisplay(data.saved_by));
|
updateLastServerSaveMeta(data.updated_at, data.saved_by_name || resolveSavedByDisplay(data.saved_by));
|
||||||
setupSaveMeta.value = {
|
setupSaveMeta.value = {
|
||||||
@@ -4167,10 +4171,7 @@ const loadSetupDraftFromServer = async (): Promise<SetupConfigDraft | null> => {
|
|||||||
savedBy: data.saved_by_name || resolveSavedByDisplay(data.saved_by),
|
savedBy: data.saved_by_name || resolveSavedByDisplay(data.saved_by),
|
||||||
serverSynced: true,
|
serverSynced: true,
|
||||||
};
|
};
|
||||||
return {
|
return draft;
|
||||||
...clone(data.data),
|
|
||||||
projectInfo: isProjectPublishSnapshotShape(data.data.projectInfo) ? clone(data.data.projectInfo) : buildProjectPublishSnapshot(),
|
|
||||||
};
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -4512,6 +4513,9 @@ const initializeSetupDraft = async () => {
|
|||||||
|
|
||||||
const localDraft = loadLocalSetupDraft();
|
const localDraft = loadLocalSetupDraft();
|
||||||
if (localDraft) {
|
if (localDraft) {
|
||||||
|
if (!isAtomicSetupDraft(localDraft.data)) {
|
||||||
|
clearLocalSetupDraft();
|
||||||
|
} else {
|
||||||
const shouldRestore = await confirmRestoreLocalDraft("恢复立项配置草稿", localDraft.savedAt, {
|
const shouldRestore = await confirmRestoreLocalDraft("恢复立项配置草稿", localDraft.savedAt, {
|
||||||
expired: localDraft.expired,
|
expired: localDraft.expired,
|
||||||
conflict: localDraft.conflict,
|
conflict: localDraft.conflict,
|
||||||
@@ -4530,6 +4534,7 @@ const initializeSetupDraft = async () => {
|
|||||||
}
|
}
|
||||||
clearLocalSetupDraft();
|
clearLocalSetupDraft();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
applySetupDraft(createDefaultSetupDraft(siteOptions.value));
|
applySetupDraft(createDefaultSetupDraft(siteOptions.value));
|
||||||
setupServerSnapshot.value = "";
|
setupServerSnapshot.value = "";
|
||||||
|
|||||||
Reference in New Issue
Block a user