diff --git a/.gitignore b/.gitignore index b36fb933..a70d0106 100644 --- a/.gitignore +++ b/.gitignore @@ -43,7 +43,7 @@ pyrightconfig.json # Node/Vite frontend frontend/node_modules/ -#frontend/dist/ +frontend/dist/ frontend/.vite/ npm-debug.log* yarn-debug.log* diff --git a/backend/app/api/v1/studies.py b/backend/app/api/v1/studies.py index 09158e43..6dbfac26 100644 --- a/backend/app/api/v1/studies.py +++ b/backend/app/api/v1/studies.py @@ -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) diff --git a/backend/app/schemas/study_setup_config.py b/backend/app/schemas/study_setup_config.py index 3b3dab8f..bf99f023 100644 --- a/backend/app/schemas/study_setup_config.py +++ b/backend/app/schemas/study_setup_config.py @@ -13,6 +13,9 @@ class ProjectMilestoneItem(BaseModel): id: str name: str = "" planDate: str = "" + startDate: str = "" + endDate: str = "" + durationDays: int = 1 owner: str = "" remark: str = "" status: MilestoneStatus = "未开始" diff --git a/backend/app/services/setup_config_excel.py b/backend/app/services/setup_config_excel.py deleted file mode 100644 index 7aaaedd8..00000000 --- a/backend/app/services/setup_config_excel.py +++ /dev/null @@ -1,246 +0,0 @@ -from __future__ import annotations - -from datetime import date, datetime -from io import BytesIO -import random -from typing import Any - -from openpyxl import Workbook, load_workbook - -from app.schemas.study_setup_config import StudySetupConfigData - - -def _make_row_id() -> str: - return f"{int(datetime.now().timestamp() * 1000)}_{random.randint(1000, 9999)}" - - -def _is_empty(*values: Any) -> bool: - return all(v is None or str(v).strip() == "" for v in values) - - -def _sheet_headers(sheet, headers: list[str]) -> None: - sheet.append(headers) - for idx, title in enumerate(headers, start=1): - cell = sheet.cell(row=1, column=idx) - cell.value = title - - -def export_setup_config_excel(data: StudySetupConfigData) -> bytes: - wb = Workbook() - - ws_guide = wb.active - ws_guide.title = "说明" - ws_guide.append(["立项配置Excel模板说明"]) - ws_guide.append(["1. 各业务Sheet首行是表头,不要修改列名。"]) - ws_guide.append(["2. 可在现有行基础上新增/删除行。"]) - ws_guide.append(["3. 日期格式建议:YYYY-MM-DD。"]) - ws_guide.append(["4. 导入后系统会自动校验并回填到立项配置草稿。"]) - - ws_pm = wb.create_sheet("项目里程碑") - _sheet_headers(ws_pm, ["ID", "里程碑", "计划日期", "负责人", "状态", "备注"]) - for row in data.projectMilestones: - ws_pm.append([row.id, row.name, row.planDate, row.owner, row.status, row.remark]) - - ws_plan = wb.create_sheet("项目入组计划") - _sheet_headers(ws_plan, ["计划总入组例数", "计划开始日期", "计划结束日期", "月度目标说明", "分阶段计划"]) - p = data.enrollmentPlan - ws_plan.append([p.totalTarget, p.startDate, p.endDate, p.monthlyGoalNote, p.stageBreakdown]) - - ws_sm = wb.create_sheet("中心里程碑") - _sheet_headers(ws_sm, ["ID", "里程碑", "计划日期", "负责人", "状态", "备注"]) - for row in data.siteMilestones: - ws_sm.append([row.id, row.milestone, row.planDate, row.owner, row.status, row.remark]) - - ws_sep = wb.create_sheet("中心入组计划") - _sheet_headers(ws_sep, ["ID", "中心ID", "中心名称", "计划例数", "启动日期", "完成日期", "备注", "分阶段计划"]) - for row in data.siteEnrollmentPlans: - ws_sep.append([row.id, row.siteId, row.siteName, row.target, row.startDate, row.endDate, row.note, row.stageBreakdown]) - - ws_ms = wb.create_sheet("监查计划策略") - _sheet_headers(ws_ms, ["ID", "监查类型", "策略详情", "监查次数", "更新时间", "是否启用"]) - for row in data.monitoringStrategies: - ws_ms.append([row.id, row.strategyType, row.detail, row.frequency, row.updatedAt, "是" if row.enabled else "否"]) - - ws_cc = wb.create_sheet("中心确认") - _sheet_headers(ws_cc, ["ID", "中心ID", "中心名称", "确认人", "确认状态", "确认日期", "备注"]) - for row in data.centerConfirm: - ws_cc.append([row.id, row.siteId, row.siteName, row.confirmer, row.confirmStatus, row.confirmDate, row.note]) - - bio = BytesIO() - wb.save(bio) - return bio.getvalue() - - -def _to_int(value: Any, default: int = 0) -> int: - if value is None or str(value).strip() == "": - return default - try: - return int(float(str(value))) - except (TypeError, ValueError): - return default - - -def _to_date_str(value: Any) -> str: - if value is None: - return "" - if isinstance(value, datetime): - return value.date().isoformat() - if isinstance(value, date): - return value.isoformat() - return str(value).strip() - - -def _to_datetime_str(value: Any) -> str: - if value is None: - return "" - if isinstance(value, datetime): - return value.strftime("%Y-%m-%d %H:%M:%S") - return str(value).strip() - - -def _to_bool(value: Any, default: bool = True) -> bool: - if value is None: - return default - v = str(value).strip().lower() - if v in {"1", "true", "yes", "y", "是", "启用"}: - return True - if v in {"0", "false", "no", "n", "否", "禁用"}: - return False - return default - - -def import_setup_config_excel(file_bytes: bytes) -> StudySetupConfigData: - wb = load_workbook(filename=BytesIO(file_bytes), data_only=True) - - project_milestones = [] - ws = wb["项目里程碑"] if "项目里程碑" in wb.sheetnames else None - if ws: - for row in ws.iter_rows(min_row=2, values_only=True): - rid, name, plan_date, owner, status, remark = row[:6] - if _is_empty(rid, name, plan_date, owner, status, remark): - continue - project_milestones.append( - { - "id": str(rid).strip() if rid else _make_row_id(), - "name": str(name or "").strip(), - "planDate": _to_date_str(plan_date), - "owner": str(owner or "").strip(), - "status": str(status or "未开始").strip() or "未开始", - "remark": str(remark or "").strip(), - } - ) - - enrollment_plan = { - "totalTarget": 0, - "startDate": "", - "endDate": "", - "monthlyGoalNote": "", - "stageBreakdown": "", - } - ws = wb["项目入组计划"] if "项目入组计划" in wb.sheetnames else None - if ws: - first = next(ws.iter_rows(min_row=2, values_only=True), None) - if first: - enrollment_plan = { - "totalTarget": _to_int(first[0], 0), - "startDate": _to_date_str(first[1]), - "endDate": _to_date_str(first[2]), - "monthlyGoalNote": str(first[3] or "").strip(), - "stageBreakdown": str(first[4] or "").strip(), - } - - site_milestones = [] - ws = wb["中心里程碑"] if "中心里程碑" in wb.sheetnames else None - if ws: - for row in ws.iter_rows(min_row=2, values_only=True): - raw_status = None - if len(row) >= 8: - rid, site_id, site_name, milestone, plan_date, owner, raw_status, remark = row[:8] - _ = (site_id, site_name) - elif len(row) == 7: - # Backward compatibility: old template has no status column and includes center columns. - rid, site_id, site_name, milestone, plan_date, owner, remark = row[:7] - _ = (site_id, site_name) - else: - rid, milestone, plan_date, owner, raw_status, remark = row[:6] - if _is_empty(rid, milestone, plan_date, owner, raw_status, remark): - continue - status = str(raw_status or "未开始").strip() or "未开始" - site_milestones.append( - { - "id": str(rid).strip() if rid else _make_row_id(), - "milestone": str(milestone or "").strip(), - "planDate": _to_date_str(plan_date), - "owner": str(owner or "").strip(), - "status": status, - "remark": str(remark or "").strip(), - } - ) - - site_enrollment_plans = [] - ws = wb["中心入组计划"] if "中心入组计划" in wb.sheetnames else None - if ws: - for row in ws.iter_rows(min_row=2, values_only=True): - rid, site_id, site_name, target, start_date, end_date, note, stage_breakdown = (list(row[:8]) + [None] * 8)[:8] - if _is_empty(rid, site_id, site_name, target, start_date, end_date, note, stage_breakdown): - continue - site_enrollment_plans.append( - { - "id": str(rid).strip() if rid else _make_row_id(), - "siteId": str(site_id or "").strip(), - "siteName": str(site_name or "").strip(), - "target": _to_int(target, 0), - "startDate": _to_date_str(start_date), - "endDate": _to_date_str(end_date), - "note": str(note or "").strip(), - "stageBreakdown": str(stage_breakdown or "").strip(), - } - ) - - monitoring_strategies = [] - ws = wb["监查计划策略"] if "监查计划策略" in wb.sheetnames else None - if ws: - for row in ws.iter_rows(min_row=2, values_only=True): - rid, strategy_type, detail, frequency, updated_at, enabled = row[:6] - if _is_empty(rid, strategy_type, detail, frequency, updated_at, enabled): - continue - monitoring_strategies.append( - { - "id": str(rid).strip() if rid else _make_row_id(), - "strategyType": str(strategy_type or "").strip(), - "detail": str(detail or "").strip(), - "frequency": str(frequency or "").strip(), - "updatedAt": _to_datetime_str(updated_at) or datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - "enabled": _to_bool(enabled, True), - } - ) - - center_confirm = [] - ws = wb["中心确认"] if "中心确认" in wb.sheetnames else None - if ws: - for row in ws.iter_rows(min_row=2, values_only=True): - rid, site_id, site_name, confirmer, confirm_status, confirm_date, note = row[:7] - if _is_empty(rid, site_id, site_name, confirmer, confirm_status, confirm_date, note): - continue - center_confirm.append( - { - "id": str(rid).strip() if rid else _make_row_id(), - "siteId": str(site_id or "").strip(), - "siteName": str(site_name or "").strip(), - "confirmer": str(confirmer or "").strip(), - "confirmStatus": str(confirm_status or "待确认").strip() or "待确认", - "confirmDate": _to_date_str(confirm_date), - "note": str(note or "").strip(), - } - ) - - return StudySetupConfigData.model_validate( - { - "projectMilestones": project_milestones, - "enrollmentPlan": enrollment_plan, - "siteMilestones": site_milestones, - "siteEnrollmentPlans": site_enrollment_plans, - "monitoringStrategies": monitoring_strategies, - "centerConfirm": center_confirm, - } - ) diff --git a/backend/app/services/setup_config_projection.py b/backend/app/services/setup_config_projection.py index 45181fd9..7cdc3565 100644 --- a/backend/app/services/setup_config_projection.py +++ b/backend/app/services/setup_config_projection.py @@ -121,14 +121,15 @@ async def _replace_project_milestones( ) ) for row in setup_data.projectMilestones: - if not any([(row.name or "").strip(), (row.owner or "").strip(), (row.remark or "").strip(), (row.planDate or "").strip()]): + start_text = (row.startDate or row.planDate or "").strip() + if not any([(row.name or "").strip(), (row.owner or "").strip(), (row.remark or "").strip(), start_text]): continue name = (row.name or "").strip() if not name: result.warnings.append(f"project_milestone_name_empty:{row.id}") continue - planned_date = _parse_date(row.planDate) - if row.planDate and not planned_date: + planned_date = _parse_date(start_text) + if start_text and not planned_date: result.warnings.append(f"project_milestone_plan_date_invalid:{row.id}") raw_status = (row.status or "").strip() mapped_status = MILESTONE_STATUS_MAP.get(raw_status, "NOT_STARTED") diff --git a/backend/scripts/smoke_setup_config.py b/backend/scripts/smoke_setup_config.py index ed2ee91a..1788aecc 100644 --- a/backend/scripts/smoke_setup_config.py +++ b/backend/scripts/smoke_setup_config.py @@ -2,10 +2,8 @@ import asyncio import json import os import sys -import tempfile import urllib.error import urllib.request -import uuid import asyncpg @@ -40,68 +38,6 @@ def request_json(path: str, *, method: str = "GET", token: str | None = None, pa return exc.code, body_json -def request_bytes(path: str, *, method: str = "GET", token: str | None = None) -> tuple[int, bytes]: - url = f"{BASE}{path}" - headers = {} - if token: - headers["Authorization"] = f"Bearer {token}" - req = urllib.request.Request(url, method=method, headers=headers) - try: - with urllib.request.urlopen(req, timeout=30) as resp: - return resp.getcode(), resp.read() - except urllib.error.HTTPError as exc: - return exc.code, exc.read() - - -def request_multipart( - path: str, - *, - token: str, - file_field: str, - file_name: str, - file_content: bytes, - expected_version: int, -) -> tuple[int, dict]: - boundary = f"----ctms-boundary-{uuid.uuid4().hex}" - chunks: list[bytes] = [] - chunks.append( - ( - f"--{boundary}\r\n" - f'Content-Disposition: form-data; name="expected_version"\r\n\r\n' - f"{expected_version}\r\n" - ).encode("utf-8") - ) - chunks.append( - ( - f"--{boundary}\r\n" - f'Content-Disposition: form-data; name="{file_field}"; filename="{file_name}"\r\n' - f"Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\r\n\r\n" - ).encode("utf-8") - ) - chunks.append(file_content) - chunks.append(b"\r\n") - chunks.append(f"--{boundary}--\r\n".encode("utf-8")) - body = b"".join(chunks) - - url = f"{BASE}{path}" - headers = { - "Authorization": f"Bearer {token}", - "Content-Type": f"multipart/form-data; boundary={boundary}", - } - req = urllib.request.Request(url, method="POST", headers=headers, data=body) - try: - with urllib.request.urlopen(req, timeout=30) as resp: - text = resp.read().decode() or "{}" - return resp.getcode(), json.loads(text) - except urllib.error.HTTPError as exc: - text = exc.read().decode() or "{}" - try: - body_json = json.loads(text) - except Exception: - body_json = {"raw": text} - return exc.code, body_json - - def assert_or_exit(condition: bool, message: str) -> None: if not condition: print(f"[FAIL] {message}") @@ -440,24 +376,6 @@ def main() -> int: ) print("[9/12] rollback_ok") - status, exported_bytes = request_bytes(f"/api/v1/studies/{study_id}/setup-config/export-excel", token=token) - assert_or_exit(status == 200 and len(exported_bytes) > 0, f"导出Excel失败 status={status} bytes={len(exported_bytes)}") - with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp: - tmp.write(exported_bytes) - tmp_path = tmp.name - print(f"[10/12] export_excel_ok file={tmp_path}") - - status, imported = request_multipart( - f"/api/v1/studies/{study_id}/setup-config/import-excel", - token=token, - file_field="file", - file_name="setup-config-smoke.xlsx", - file_content=exported_bytes, - expected_version=rolled["version"], - ) - assert_or_exit(status == 200 and isinstance(imported.get("version"), int), f"导入失败 status={status} body={imported}") - print(f"[11/12] import_excel_ok version={imported['version']}") - status, _ = request_json(f"/api/v1/studies/{study_id}/lock", token=token, method="PATCH", payload={}) assert_or_exit(status == 200, f"锁定失败 status={status}") try: @@ -465,10 +383,10 @@ def main() -> int: f"/api/v1/studies/{study_id}/setup-config", token=token, method="PUT", - payload={"expected_version": imported["version"], "data": imported["data"]}, + payload={"expected_version": rolled["version"], "data": rolled["data"]}, ) assert_or_exit(status == 403, f"锁定后写入应403,实际 status={status} body={locked_result}") - print("[12/12] lock_403_check_ok") + print("[10/10] lock_403_check_ok") finally: unlock_status, unlock_body = request_json(f"/api/v1/studies/{study_id}/unlock", token=token, method="PATCH", payload={}) assert_or_exit(unlock_status == 200, f"解锁失败 status={unlock_status} body={unlock_body}") diff --git a/frontend/dist/index.html b/frontend/dist/index.html deleted file mode 100644 index fed5c75c..00000000 --- a/frontend/dist/index.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - CTMS - - - - -
- - diff --git a/frontend/index.html b/frontend/index.html index a7b9c97b..00fa07c8 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,13 +1,16 @@ - - - - - CTMS - - -
- - - + + + + + + CTMS + + + +
+ + + + \ No newline at end of file diff --git a/frontend/src/api/axios.ts b/frontend/src/api/axios.ts index 8624d845..e669ed03 100644 --- a/frontend/src/api/axios.ts +++ b/frontend/src/api/axios.ts @@ -1,4 +1,4 @@ -import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios"; +import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from "axios"; import { ElMessage } from "element-plus"; import router from "../router"; import { getToken } from "../utils/auth"; @@ -27,7 +27,7 @@ class LockedError extends Error { } } -instance.interceptors.request.use((config: ApiRequestConfig) => { +instance.interceptors.request.use((config: InternalAxiosRequestConfig & ApiRequestConfig) => { const session = useSessionStore(); const reqUrl = config.url || ""; const allowWhileLocked = @@ -41,7 +41,7 @@ instance.interceptors.request.use((config: ApiRequestConfig) => { const token = getToken(); if (token) { config.headers = config.headers || {}; - config.headers.Authorization = `Bearer ${token}`; + (config.headers as Record).Authorization = `Bearer ${token}`; } if (!config.ignoreIdle) { markNetworkActive(); @@ -114,14 +114,14 @@ instance.interceptors.response.use( } ); -export const apiGet = (url: string, config?: ApiRequestConfig) => instance.get(url, config); -export const apiPost = (url: string, data?: unknown, config?: ApiRequestConfig) => +export const apiGet = (url: string, config?: ApiRequestConfig) => instance.get(url, config); +export const apiPost = (url: string, data?: unknown, config?: ApiRequestConfig) => instance.post(url, data, config); -export const apiPatch = (url: string, data?: unknown, config?: ApiRequestConfig) => +export const apiPatch = (url: string, data?: unknown, config?: ApiRequestConfig) => instance.patch(url, data, config); -export const apiPut = (url: string, data?: unknown, config?: ApiRequestConfig) => +export const apiPut = (url: string, data?: unknown, config?: ApiRequestConfig) => instance.put(url, data, config); -export const apiDelete = (url: string, config?: ApiRequestConfig) => +export const apiDelete = (url: string, config?: ApiRequestConfig) => instance.delete(url, config); export default instance; diff --git a/frontend/src/api/studies.ts b/frontend/src/api/studies.ts index 7ef6f0cd..82472f4f 100644 --- a/frontend/src/api/studies.ts +++ b/frontend/src/api/studies.ts @@ -42,9 +42,3 @@ 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 exportStudySetupConfigExcel = (studyId: string) => - apiGet(`/api/v1/studies/${studyId}/setup-config/export-excel`, { responseType: "blob", suppressErrorMessage: true }); - -export const importStudySetupConfigExcel = (studyId: string, payload: FormData) => - apiPost(`/api/v1/studies/${studyId}/setup-config/import-excel`, payload, { suppressErrorMessage: true }); diff --git a/frontend/src/audit/index.ts b/frontend/src/audit/index.ts index b98f8d8a..12adecff 100644 --- a/frontend/src/audit/index.ts +++ b/frontend/src/audit/index.ts @@ -25,27 +25,184 @@ export interface AuditEvent { timestamp: string; } -const buildDiffText = (before?: Record, after?: Record) => { - if (!before || !after) return []; - const lines: string[] = []; - Object.keys({ ...before, ...after }).forEach((key) => { - const prev = before[key]; - const next = after[key]; - if (prev === next) return; +const entityTypeLabelMap: Record = { + study_setup_config: "立项配置", + study: "项目", + subject: "参与者", + visit: "访视", + ae: "不良事件", + finance_contract: "费用合同", + finance_special: "费用特殊费用", + contract_fee: "合同费用条目", + contract_fee_payment: "合同费用回款", + special_expense: "特殊费用条目", + drug_shipment: "药品流向", + startup_feasibility: "立项可行性", + startup_ethics: "立项伦理", + startup_kickoff: "启动会", + training_authorization: "培训授权", + knowledge_note: "注意事项", + subject_history: "参与者历史", + faq_category: "医学咨询分类", + faq_item: "医学咨询问题", + faq_reply: "医学咨询回复", + DOCUMENT: "文档", + DOCUMENT_VERSION: "文档版本", + DISTRIBUTION: "文档分发", + ACKNOWLEDGEMENT: "文档签收", +}; + +const setupModuleLabelMap: Record = { + projectMilestones: "项目里程碑", + enrollmentPlan: "项目入组计划", + siteMilestones: "中心里程碑", + siteEnrollmentPlans: "中心入组计划", + monitoringStrategies: "监查策略", + centerConfirm: "中心确认", +}; + +const toReadableEntityType = (value?: string): string => { + const key = String(value || "").trim(); + if (!key) return TEXT.audit.targetFallback; + return entityTypeLabelMap[key] || key; +}; + +const normalizeSetupModuleText = (raw: string): string => { + const text = String(raw || "").trim(); + if (!text) return "无"; + return text + .split(/[,,]/) + .map((item) => item.trim()) + .filter(Boolean) + .map((item) => setupModuleLabelMap[item] || item) + .join("、") || "无"; +}; + +const normalizeLegacyDetailLine = (line: string): string => { + const text = String(line || "").trim(); + if (!text) return ""; + const projectionStatusMatch = text.match(/^projection_status=(.+)$/i); + if (projectionStatusMatch) { + const status = projectionStatusMatch[1].trim().toLowerCase(); + const statusLabel = status === "success" ? "成功" : status === "failed" ? "失败" : status; + return `发布同步结果:${statusLabel}`; + } + const studyUpdatedMatch = text.match(/^study_updated=(.+)$/i); + if (studyUpdatedMatch) { + const rawValue = studyUpdatedMatch[1].trim().toLowerCase(); + return `已同步项目主信息:${rawValue === "true" ? "是" : rawValue === "false" ? "否" : rawValue}`; + } + const siteUpdatedMatch = text.match(/^site_updated=(\d+)$/i); + if (siteUpdatedMatch) return `已同步中心数:${siteUpdatedMatch[1]}`; + const siteSkippedMatch = text.match(/^site_skipped=(\d+)$/i); + if (siteSkippedMatch) return `未同步中心数:${siteSkippedMatch[1]}`; + const warningsMatch = text.match(/^warnings=(.*)$/i); + if (warningsMatch) { + const value = warningsMatch[1].trim(); + return `同步告警:${value === "-" ? "无" : value}`; + } + const skippedReasonMatch = text.match(/^skipped_reason_counts=(.*)$/i); + if (skippedReasonMatch) { + const value = skippedReasonMatch[1].trim(); + return `未同步原因统计:${value === "-" ? "无" : value}`; + } + const setupModuleMatch = text.match(/^变更模块[::]\s*(.*)$/); + if (setupModuleMatch) { + return `变更模块:${normalizeSetupModuleText(setupModuleMatch[1])}`; + } + const detailMatch = text.match(/^变更明细[::]\s*(.*)$/); + if (detailMatch) { + const detailText = detailMatch[1].trim(); + return `变更明细:${detailText || "无"}`; + } + if (text === "立项配置已发布") return "立项配置已发布"; + if (text.includes("siteEnrollmentPlans")) return text.replaceAll("siteEnrollmentPlans", "中心入组计划"); + if (text.includes("monitoringStrategies")) return text.replaceAll("monitoringStrategies", "监查策略"); + return text; +}; + +const splitAuditDetailSegments = (line: string): string[] => { + const text = String(line || "").trim(); + if (!text) return []; + return text + .split(/[;;\n]/) + .map((item) => item.trim()) + .filter(Boolean); +}; + +const auditFieldLabelMap: Record = { + status: "状态", + is_active: "启用状态", + role: "角色", + role_in_study: "项目角色", + full_name: "姓名", + username: "账号", + department: "部门", + email: "邮箱", + name: "名称", + code: "编号", + site_name: "中心名称", + pi_name: "PI", + city: "城市", + contact: "负责人", + phone: "电话", + plan_start_date: "计划开始日期", + plan_end_date: "计划结束日期", +}; + +const toReadableFieldLabel = (key: string): string => { + const normalized = String(key || "").trim(); + if (!normalized) return "字段"; + if (auditFieldLabelMap[normalized]) return auditFieldLabelMap[normalized]; + return `字段(${normalized})`; +}; + +const toReadableValue = (key: string, value: any): string => { + if (value === null || value === undefined || value === "") return "未填写"; + if (typeof value === "boolean") { + if (key === "is_active") return value ? "启用" : "停用"; + return value ? "是" : "否"; + } + if (typeof value === "number") return String(value); + if (typeof value === "string") { + const text = value.trim(); + if (!text) return "未填写"; if (key.toLowerCase().includes("status")) { - lines.push(`${TEXT.audit.statusLabel}${getDictLabel(statusDict, prev)} → ${getDictLabel(statusDict, next)}`); - } else { - lines.push(`${key}:${prev ?? TEXT.audit.emptyValue} → ${next ?? TEXT.audit.emptyValue}`); + return getDictLabel(statusDict, text) || text; } + return text; + } + if (Array.isArray(value)) return `共${value.length}项`; + if (typeof value === "object") { + if (typeof value.name === "string" && value.name.trim()) return value.name.trim(); + if (typeof value.label === "string" && value.label.trim()) return value.label.trim(); + return "已配置"; + } + return String(value); +}; + +const buildDiffText = (before?: Record, after?: Record) => { + if (!before && !after) return []; + const lines: string[] = []; + const prevObj = before || {}; + const nextObj = after || {}; + Object.keys({ ...prevObj, ...nextObj }).forEach((key) => { + const prev = prevObj[key]; + const next = nextObj[key]; + if (prev === next) return; + const label = toReadableFieldLabel(key); + const prevText = toReadableValue(key, prev); + const nextText = toReadableValue(key, next); + lines.push(`${label}:${prevText} -> ${nextText}`); }); return lines; }; export const normalizeAuditEvent = (raw: any, userMap: Record): AuditEvent => { const dict = auditDict[raw.action] || { - label: TEXT.audit.eventFallback, - actionText: TEXT.audit.actionFallback, - targetLabel: raw.entity_type || TEXT.audit.targetFallback, + label: raw.action ? `${TEXT.audit.eventFallback}(${raw.action})` : TEXT.audit.eventFallback, + actionText: raw.action ? `执行了${raw.action}` : TEXT.audit.actionFallback, + targetLabel: toReadableEntityType(raw.entity_type), }; let detailObj: any = {}; if (raw.detail) { @@ -57,7 +214,18 @@ export const normalizeAuditEvent = (raw: any, userMap: Record): } const before = detailObj.before; const after = detailObj.after; - const diffText = detailObj.diffText || buildDiffText(before, after); + const fallbackDescription = typeof detailObj.description === "string" ? detailObj.description.trim() : ""; + let diffText = detailObj.diffText || buildDiffText(before, after); + if ((!Array.isArray(diffText) || diffText.length === 0) && fallbackDescription) { + diffText = fallbackDescription + .split(/[;;\n]/) + .map((item: string) => item.trim()) + .filter(Boolean); + } + const readableDiffText = (Array.isArray(diffText) ? diffText : diffText ? [String(diffText)] : []) + .flatMap((line) => splitAuditDetailSegments(String(line))) + .map((line) => normalizeLegacyDetailLine(line)) + .filter(Boolean); return { eventType: raw.action, eventLabel: dict.label, @@ -68,11 +236,11 @@ export const normalizeAuditEvent = (raw: any, userMap: Record): targetType: raw.entity_type || "", targetTypeLabel: dict.targetLabel, targetId: raw.entity_id || "", - targetName: detailObj.targetName, + targetName: detailObj.targetName || raw.entity_id || "", actionText: dict.actionText, before, after, - diffText: Array.isArray(diffText) ? diffText : diffText ? [diffText] : [], + diffText: readableDiffText, reason: detailObj.reason, result: detailObj.result || "SUCCESS", resultLabel: detailObj.result === "FAIL" ? TEXT.audit.resultFail : TEXT.audit.resultSuccess, diff --git a/frontend/src/components/FaqCategoryForm.vue b/frontend/src/components/FaqCategoryForm.vue index 20add4de..5ce298e5 100644 --- a/frontend/src/components/FaqCategoryForm.vue +++ b/frontend/src/components/FaqCategoryForm.vue @@ -1,5 +1,5 @@