项目里程碑初步优化

This commit is contained in:
Cheng Zhou
2026-02-27 09:06:06 +08:00
parent 8f3f717e48
commit fd7e3fc948
47 changed files with 2029 additions and 783 deletions
+177 -120
View File
@@ -1,11 +1,10 @@
import uuid
import re
from io import BytesIO
from datetime import date
from collections import Counter
from typing import Any
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from fastapi.responses import StreamingResponse
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import (
@@ -22,7 +21,6 @@ from app.crud import audit as audit_crud
from app.crud import site as site_crud
from app.crud import study_setup_config as study_setup_config_crud
from app.crud import user as user_crud
from app.services.setup_config_excel import export_setup_config_excel, import_setup_config_excel
from app.schemas.common import PaginatedResponse
from app.schemas.study import StudyCreate, StudyRead, StudyUpdate
from app.schemas.member import StudyMemberCreate
@@ -81,6 +79,7 @@ def _validate_setup_data(
*,
project_plan_start: date | None = None,
project_plan_end: date | None = None,
strict_required: bool = True,
) -> None:
errors: list[dict[str, str]] = []
allowed_milestone_status = {"未开始", "进行中", "已完成", "延期"}
@@ -96,13 +95,24 @@ def _validate_setup_data(
for index, row in enumerate(payload.projectMilestones):
row_prefix = f"projectMilestones[{index}]"
start_text = (row.startDate or row.planDate or "").strip()
end_text = (row.endDate or row.planDate or "").strip()
if not row.id:
errors.append({"field": f"{row_prefix}.id", "message": "ID不能为空"})
if _is_empty_row([row.name, row.owner, row.remark, row.planDate]):
if _is_empty_row([row.name, row.owner, row.remark, start_text, end_text]):
continue
if not row.name.strip():
errors.append({"field": f"{row_prefix}.name", "message": "里程碑名称不能为空"})
_parse_date_str(row.planDate, f"{row_prefix}.planDate", errors)
start = _parse_date_str(start_text, f"{row_prefix}.startDate", errors)
end = _parse_date_str(end_text, f"{row_prefix}.endDate", errors)
if not start_text:
errors.append({"field": f"{row_prefix}.startDate", "message": "请填写开始日期"})
if not end_text:
errors.append({"field": f"{row_prefix}.endDate", "message": "请填写结束日期"})
if start and end and start > end:
errors.append({"field": f"{row_prefix}.endDate", "message": "结束日期不能早于开始日期"})
if row.durationDays < 1:
errors.append({"field": f"{row_prefix}.durationDays", "message": "耗时天数不能小于1"})
if row.status not in allowed_milestone_status:
errors.append({"field": f"{row_prefix}.status", "message": "里程碑状态不合法"})
@@ -111,9 +121,9 @@ def _validate_setup_data(
errors.append({"field": "enrollmentPlan.totalTarget", "message": "计划总入组例数不能小于0"})
enrollment_plan_start = _parse_date_str(plan.startDate, "enrollmentPlan.startDate", errors)
enrollment_plan_end = _parse_date_str(plan.endDate, "enrollmentPlan.endDate", errors)
if not plan.startDate:
if strict_required and not plan.startDate:
errors.append({"field": "enrollmentPlan.startDate", "message": "请填写计划开始日期"})
if not plan.endDate:
if strict_required and not plan.endDate:
errors.append({"field": "enrollmentPlan.endDate", "message": "请填写计划结束日期"})
if enrollment_plan_start and enrollment_plan_end and enrollment_plan_start > enrollment_plan_end:
errors.append({"field": "enrollmentPlan.endDate", "message": "结束日期不能早于开始日期"})
@@ -164,9 +174,9 @@ def _validate_setup_data(
total_site_target += max(row.target, 0)
start = _parse_date_str(row.startDate, f"{row_prefix}.startDate", errors)
end = _parse_date_str(row.endDate, f"{row_prefix}.endDate", errors)
if not row.startDate:
if strict_required and not row.startDate:
errors.append({"field": f"{row_prefix}.startDate", "message": "请填写启动日期"})
if not row.endDate:
if strict_required and not row.endDate:
errors.append({"field": f"{row_prefix}.endDate", "message": "请填写完成日期"})
if start and end and start > end:
errors.append({"field": f"{row_prefix}.endDate", "message": "完成日期不能早于启动日期"})
@@ -279,17 +289,18 @@ def _build_projection_audit_detail(
warnings: list[str],
skipped_items: list[dict[str, str]],
) -> str:
warning_part = " | ".join(warnings[:5]) if warnings else "-"
warning_part = "".join(warnings[:5]) if warnings else ""
reason_counter = Counter(item.get("reason") for item in skipped_items if item.get("reason"))
reason_summary = ", ".join(f"{reason}:{count}" for reason, count in sorted(reason_counter.items())) if reason_counter else "-"
reason_summary = "".join(f"{reason} {count}" for reason, count in sorted(reason_counter.items())) if reason_counter else ""
projection_status_label = "成功" if projection_status == "success" else "失败" if projection_status == "failed" else projection_status
return (
"立项配置已发布"
f"; projection_status={projection_status}"
f"; study_updated={study_updated}"
f"; site_updated={site_updated_count}"
f"; site_skipped={site_skipped_count}"
f"; warnings={warning_part}"
f"; skipped_reason_counts={reason_summary}"
f"; 发布同步结果:{projection_status_label}"
f"; 已同步项目主信息:{'' if study_updated else ''}"
f"; 已同步中心数:{site_updated_count}"
f"; 未同步中心数:{site_skipped_count}"
f"; 同步告警:{warning_part}"
f"; 未同步原因统计:{reason_summary}"
)
@@ -326,14 +337,145 @@ def _to_setup_config_version_read(record, *, published_by_name: str | None = Non
)
_SETUP_MODULE_KEYS = (
"projectMilestones",
"enrollmentPlan",
"siteMilestones",
"siteEnrollmentPlans",
"monitoringStrategies",
"centerConfirm",
)
_SETUP_MODULE_LABELS = {
"projectMilestones": "项目里程碑",
"enrollmentPlan": "项目入组计划",
"siteMilestones": "中心里程碑",
"siteEnrollmentPlans": "中心入组计划",
"monitoringStrategies": "监查策略",
"centerConfirm": "中心确认",
}
_SETUP_FIELD_LABELS = {
"id": "ID",
"name": "名称",
"milestone": "里程碑",
"planDate": "计划日期",
"startDate": "开始日期",
"endDate": "结束日期",
"durationDays": "耗时(天)",
"owner": "负责人",
"remark": "备注",
"status": "状态",
"totalTarget": "计划总入组例数",
"startDate": "开始日期",
"endDate": "结束日期",
"monthlyGoalNote": "月度目标说明",
"stageBreakdown": "分阶段计划",
"siteId": "中心ID",
"siteName": "中心名称",
"target": "计划例数",
"note": "备注",
"strategyType": "监查类型",
"detail": "策略详情",
"frequency": "监查频次",
"updatedAt": "更新时间",
"enabled": "启用状态",
"confirmer": "确认人",
"confirmStatus": "确认状态",
"confirmDate": "确认日期",
}
def _setup_value_text(value: Any) -> str:
if value is None:
return "未填写"
if isinstance(value, bool):
return "" if value else ""
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, str):
text = value.strip()
if not text:
return "未填写"
if len(text) > 80:
return f"{text[:80]}..."
return text
if isinstance(value, list):
return f"已配置列表({len(value)}项)"
if isinstance(value, dict):
return "已配置内容"
return str(value)
def _setup_row_identity(module_key: str, row: Any, index: int) -> str:
if not isinstance(row, dict):
return f"{index + 1}"
if module_key == "projectMilestones":
return str(row.get("name") or "").strip() or f"{index + 1}"
if module_key == "siteMilestones":
return str(row.get("milestone") or "").strip() or f"{index + 1}"
if module_key in {"siteEnrollmentPlans", "centerConfirm"}:
return str(row.get("siteName") or row.get("siteId") or "").strip() or f"{index + 1}"
if module_key == "monitoringStrategies":
return str(row.get("strategyType") or "").strip() or f"{index + 1}"
return f"{index + 1}"
def _setup_collect_diff_lines(
module_key: str,
old_value: Any,
new_value: Any,
path: list[str],
lines: list[str],
) -> None:
if isinstance(old_value, list) or isinstance(new_value, list):
old_list = old_value if isinstance(old_value, list) else []
new_list = new_value if isinstance(new_value, list) else []
for index in range(max(len(old_list), len(new_list))):
old_item = old_list[index] if index < len(old_list) else None
new_item = new_list[index] if index < len(new_list) else None
identity = _setup_row_identity(module_key, new_item if new_item is not None else old_item, index)
_setup_collect_diff_lines(module_key, old_item, new_item, [*path, identity], lines)
return
if isinstance(old_value, dict) or isinstance(new_value, dict):
old_dict = old_value if isinstance(old_value, dict) else {}
new_dict = new_value if isinstance(new_value, dict) else {}
keys = sorted(set(old_dict.keys()) | set(new_dict.keys()))
for key in keys:
_setup_collect_diff_lines(module_key, old_dict.get(key), new_dict.get(key), [*path, key], lines)
return
if old_value == new_value:
return
module_label = _SETUP_MODULE_LABELS.get(module_key, module_key)
readable_path = " / ".join(_SETUP_FIELD_LABELS.get(part, part) for part in path if part)
field_text = f"{module_label} / {readable_path}" if readable_path else module_label
lines.append(f"{field_text}{_setup_value_text(old_value)} -> {_setup_value_text(new_value)}")
def _top_level_diff_summary(old: dict | None, new: dict | None) -> str:
old = old or {}
new = new or {}
changed = []
for key in ("projectMilestones", "enrollmentPlan", "siteMilestones", "siteEnrollmentPlans", "monitoringStrategies", "centerConfirm"):
if old.get(key) != new.get(key):
changed.append(key)
return "变更模块:" + (", ".join(changed) if changed else "")
changed_modules: list[str] = []
detail_lines: list[str] = []
for module_key in _SETUP_MODULE_KEYS:
if old.get(module_key) == new.get(module_key):
continue
changed_modules.append(_SETUP_MODULE_LABELS.get(module_key, module_key))
_setup_collect_diff_lines(module_key, old.get(module_key), new.get(module_key), [], detail_lines)
module_summary = "变更模块:" + ("".join(changed_modules) if changed_modules else "")
if not detail_lines:
return module_summary
max_lines = 20
visible_lines = detail_lines[:max_lines]
if len(detail_lines) > max_lines:
visible_lines.append(f"其余 {len(detail_lines) - max_lines} 项变更已省略")
return f"{module_summary}; 变更明细:" + "".join(visible_lines)
@router.post("/", response_model=StudyRead, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_roles(["ADMIN"]))])
@@ -634,16 +776,18 @@ async def upsert_study_setup_config(
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="UPDATE_SETUP_CONFIG",
detail=_top_level_diff_summary(old_config, record.config),
operator_id=current_user.id,
operator_role=current_user.role,
)
has_setup_diff = old_config != (record.config or {})
if has_setup_diff:
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="study_setup_config",
entity_id=record.id,
action="UPDATE_SETUP_CONFIG",
detail=_top_level_diff_summary(old_config, record.config),
operator_id=current_user.id,
operator_role=current_user.role,
)
saved_by_name = current_user.full_name or current_user.username or current_user.email
published_by_name = None
@@ -861,90 +1005,3 @@ async def delete_study_setup_config_version(
operator_id=current_user.id,
operator_role=current_user.role,
)
@router.get(
"/{study_id}/setup-config/export-excel",
dependencies=[Depends(require_study_member())],
)
async def export_study_setup_config_excel(
study_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> StreamingResponse:
config = await get_study_setup_config(study_id=study_id, db=db, current_user=current_user)
study = await study_crud.get(db, study_id)
file_bytes = export_setup_config_excel(config.data)
filename = f"setup-config-{study.code if study else study_id}.xlsx"
return StreamingResponse(
BytesIO(file_bytes),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post(
"/{study_id}/setup-config/import-excel",
response_model=StudySetupConfigRead,
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
)
async def import_study_setup_config_excel(
study_id: uuid.UUID,
file: UploadFile = File(...),
expected_version: int | None = Form(default=None),
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> StudySetupConfigRead:
if not (file.filename or "").lower().endswith(".xlsx"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请上传 .xlsx 格式文件")
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
try:
payload_data = import_setup_config_excel(await file.read())
except Exception:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Excel解析失败,请检查模板格式")
sites = await site_crud.list_by_study(db, study_id, skip=0, limit=1000, include_inactive=True)
site_lookup = {str(site.id): site.name or "" for site in sites}
_validate_setup_data(
payload_data,
site_lookup,
project_plan_start=getattr(study, "plan_start_date", None),
project_plan_end=getattr(study, "plan_end_date", None),
)
existing = await study_setup_config_crud.get_by_study(db, study_id)
old_config = dict(existing.config or {}) if existing else {}
record, conflict = await study_setup_config_crud.upsert(
db,
study_id,
expected_version=expected_version,
data=payload_data,
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="IMPORT_SETUP_CONFIG_EXCEL",
detail=_top_level_diff_summary(old_config, record.config),
operator_id=current_user.id,
operator_role=current_user.role,
)
saved_by_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
return _to_setup_config_read(record, saved_by_name=saved_by_name, published_by_name=published_by_name)