项目里程碑初步优化
This commit is contained in:
+177
-120
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user