1059 lines
44 KiB
Python
1059 lines
44 KiB
Python
import uuid
|
||
import re
|
||
from datetime import date
|
||
from collections import Counter
|
||
from typing import Any
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, status
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.core.deps import (
|
||
get_current_user,
|
||
get_db_session,
|
||
require_roles,
|
||
require_study_member,
|
||
require_study_not_locked,
|
||
require_study_roles,
|
||
)
|
||
from app.crud import study as study_crud
|
||
from app.crud import member as member_crud
|
||
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.schemas.common import PaginatedResponse
|
||
from app.schemas.study import StudyCreate, StudyRead, StudyUpdate
|
||
from app.schemas.member import StudyMemberCreate
|
||
from app.schemas.study_setup_config import (
|
||
ProjectPublishSnapshot,
|
||
SetupProjectionSummary,
|
||
StudySetupConfigData,
|
||
StudySetupConfigPublish,
|
||
StudySetupConfigRead,
|
||
StudySetupConfigRollback,
|
||
StudySetupConfigUpsert,
|
||
StudySetupConfigVersionRead,
|
||
)
|
||
from app.services.setup_config_projection import apply_setup_projection_on_publish
|
||
from app.utils.pagination import paginate
|
||
|
||
router = APIRouter()
|
||
|
||
def _raise_validation_error(errors: list[dict[str, str]]) -> None:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||
detail={
|
||
"code": "VALIDATION_ERROR",
|
||
"message": "校验失败",
|
||
"errors": errors,
|
||
},
|
||
)
|
||
|
||
|
||
def _raise_conflict_error() -> None:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_409_CONFLICT,
|
||
detail={
|
||
"code": "SETUP_CONFIG_VERSION_CONFLICT",
|
||
"message": "配置版本冲突,请刷新后重试",
|
||
},
|
||
)
|
||
|
||
|
||
def _parse_date_str(value: str, field: str, errors: list[dict[str, str]]) -> date | None:
|
||
if not value:
|
||
return None
|
||
try:
|
||
return date.fromisoformat(value)
|
||
except ValueError:
|
||
errors.append({"field": field, "message": "日期格式应为 YYYY-MM-DD"})
|
||
return None
|
||
|
||
|
||
def _is_empty_row(values: list[str | None]) -> bool:
|
||
return all(not (item or "").strip() for item in values)
|
||
|
||
|
||
def _validate_setup_data(
|
||
payload: StudySetupConfigData,
|
||
study_sites: dict[str, str],
|
||
*,
|
||
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 = {"未开始", "进行中", "已完成", "延期"}
|
||
allowed_strategy_types = {
|
||
"启动访视",
|
||
"筛选访视",
|
||
"监查访视",
|
||
"协同访视",
|
||
"风险访视",
|
||
"末次访视",
|
||
}
|
||
allowed_confirm_status = {"待确认", "已确认", "退回"}
|
||
|
||
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, start_text, end_text]):
|
||
continue
|
||
if not row.name.strip():
|
||
errors.append({"field": f"{row_prefix}.name", "message": "里程碑名称不能为空"})
|
||
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": "里程碑状态不合法"})
|
||
|
||
plan = payload.enrollmentPlan
|
||
if plan.totalTarget < 0:
|
||
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 strict_required and not plan.startDate:
|
||
errors.append({"field": "enrollmentPlan.startDate", "message": "请填写计划开始日期"})
|
||
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": "结束日期不能早于开始日期"})
|
||
if project_plan_start and enrollment_plan_start and enrollment_plan_start < project_plan_start:
|
||
errors.append({"field": "enrollmentPlan.startDate", "message": "项目入组计划开始日期不能早于项目计划开始日期"})
|
||
if project_plan_end and enrollment_plan_start and enrollment_plan_start > project_plan_end:
|
||
errors.append({"field": "enrollmentPlan.startDate", "message": "项目入组计划开始日期不能晚于项目计划结束日期"})
|
||
if project_plan_start and enrollment_plan_end and enrollment_plan_end < project_plan_start:
|
||
errors.append({"field": "enrollmentPlan.endDate", "message": "项目入组计划结束日期不能早于项目计划开始日期"})
|
||
if project_plan_end and enrollment_plan_end and enrollment_plan_end > project_plan_end:
|
||
errors.append({"field": "enrollmentPlan.endDate", "message": "项目入组计划结束日期不能晚于项目计划结束日期"})
|
||
|
||
total_site_target = 0
|
||
for index, row in enumerate(payload.siteMilestones):
|
||
row_prefix = f"siteMilestones[{index}]"
|
||
if not row.id:
|
||
errors.append({"field": f"{row_prefix}.id", "message": "ID不能为空"})
|
||
if _is_empty_row([row.milestone, row.owner, row.remark, row.planDate]):
|
||
continue
|
||
if not row.milestone.strip():
|
||
errors.append({"field": f"{row_prefix}.milestone", "message": "中心里程碑不能为空"})
|
||
if row.status not in allowed_milestone_status:
|
||
errors.append({"field": f"{row_prefix}.status", "message": "里程碑状态不合法"})
|
||
plan_date = _parse_date_str(row.planDate, f"{row_prefix}.planDate", errors)
|
||
if project_plan_start and plan_date and plan_date < project_plan_start:
|
||
errors.append({"field": f"{row_prefix}.planDate", "message": "中心里程碑日期不能早于项目计划开始日期"})
|
||
if project_plan_end and plan_date and plan_date > project_plan_end:
|
||
errors.append({"field": f"{row_prefix}.planDate", "message": "中心里程碑日期不能晚于项目计划结束日期"})
|
||
|
||
seen_site_enrollment_site_ids: set[str] = set()
|
||
for index, row in enumerate(payload.siteEnrollmentPlans):
|
||
row_prefix = f"siteEnrollmentPlans[{index}]"
|
||
if not row.id:
|
||
errors.append({"field": f"{row_prefix}.id", "message": "ID不能为空"})
|
||
if _is_empty_row([row.siteId, row.note, row.startDate, row.endDate]):
|
||
continue
|
||
site_id = (row.siteId or "").strip()
|
||
if not site_id:
|
||
errors.append({"field": f"{row_prefix}.siteId", "message": "请选择中心"})
|
||
elif site_id not in study_sites:
|
||
errors.append({"field": f"{row_prefix}.siteId", "message": "中心不属于当前项目"})
|
||
elif site_id in seen_site_enrollment_site_ids:
|
||
errors.append({"field": f"{row_prefix}.siteId", "message": "中心入组计划不允许重复配置同一中心"})
|
||
else:
|
||
seen_site_enrollment_site_ids.add(site_id)
|
||
if row.target < 0:
|
||
errors.append({"field": f"{row_prefix}.target", "message": "目标入组人数不能小于0"})
|
||
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 strict_required and not row.startDate:
|
||
errors.append({"field": f"{row_prefix}.startDate", "message": "请填写启动日期"})
|
||
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": "完成日期不能早于启动日期"})
|
||
if project_plan_start and start and start < project_plan_start:
|
||
errors.append({"field": f"{row_prefix}.startDate", "message": "中心启动日期不能早于项目计划开始日期"})
|
||
if project_plan_end and start and start > project_plan_end:
|
||
errors.append({"field": f"{row_prefix}.startDate", "message": "中心启动日期不能晚于项目计划结束日期"})
|
||
if project_plan_start and end and end < project_plan_start:
|
||
errors.append({"field": f"{row_prefix}.endDate", "message": "中心完成日期不能早于项目计划开始日期"})
|
||
if project_plan_end and end and end > project_plan_end:
|
||
errors.append({"field": f"{row_prefix}.endDate", "message": "中心完成日期不能晚于项目计划结束日期"})
|
||
|
||
if total_site_target > plan.totalTarget:
|
||
errors.append({"field": "siteEnrollmentPlans", "message": "中心计划总例数不能超过项目总入组例数"})
|
||
|
||
for index, row in enumerate(payload.monitoringStrategies):
|
||
row_prefix = f"monitoringStrategies[{index}]"
|
||
if not row.id:
|
||
errors.append({"field": f"{row_prefix}.id", "message": "ID不能为空"})
|
||
if _is_empty_row([row.strategyType, row.detail, row.frequency]):
|
||
continue
|
||
if row.strategyType not in allowed_strategy_types:
|
||
errors.append({"field": f"{row_prefix}.strategyType", "message": "监查类型不合法"})
|
||
if not row.detail.strip():
|
||
errors.append({"field": f"{row_prefix}.detail", "message": "策略详情不能为空"})
|
||
if row.frequency not in {"不限", "按触发", "每月1次"} and re.fullmatch(r"\d+次", row.frequency) is None:
|
||
errors.append({"field": f"{row_prefix}.frequency", "message": "监查次数格式应为“不限”/“按触发”/“每月1次”或“N次”"})
|
||
|
||
for index, row in enumerate(payload.centerConfirm):
|
||
row_prefix = f"centerConfirm[{index}]"
|
||
if not row.id:
|
||
errors.append({"field": f"{row_prefix}.id", "message": "ID不能为空"})
|
||
if _is_empty_row([row.siteId, row.confirmer, row.note, row.confirmDate]):
|
||
continue
|
||
if not row.siteId:
|
||
errors.append({"field": f"{row_prefix}.siteId", "message": "请选择中心"})
|
||
elif row.siteId not in study_sites:
|
||
errors.append({"field": f"{row_prefix}.siteId", "message": "中心不属于当前项目"})
|
||
if row.confirmStatus not in allowed_confirm_status:
|
||
errors.append({"field": f"{row_prefix}.confirmStatus", "message": "确认状态不合法"})
|
||
parsed_confirm_date = _parse_date_str(row.confirmDate, f"{row_prefix}.confirmDate", errors)
|
||
if row.confirmStatus == "已确认" and not parsed_confirm_date:
|
||
errors.append({"field": f"{row_prefix}.confirmDate", "message": "已确认时必须填写确认日期"})
|
||
if row.confirmStatus == "退回" and not row.note.strip():
|
||
errors.append({"field": f"{row_prefix}.note", "message": "退回时必须填写备注"})
|
||
if project_plan_start and parsed_confirm_date and parsed_confirm_date < project_plan_start:
|
||
errors.append({"field": f"{row_prefix}.confirmDate", "message": "中心确认日期不能早于项目计划开始日期"})
|
||
if project_plan_end and parsed_confirm_date and parsed_confirm_date > project_plan_end:
|
||
errors.append({"field": f"{row_prefix}.confirmDate", "message": "中心确认日期不能晚于项目计划结束日期"})
|
||
|
||
if errors:
|
||
_raise_validation_error(errors)
|
||
|
||
|
||
def _build_default_setup_config_from_study(study, sites: list) -> StudySetupConfigData:
|
||
plan_start = study.plan_start_date.isoformat() if getattr(study, "plan_start_date", None) else ""
|
||
plan_end = study.plan_end_date.isoformat() if getattr(study, "plan_end_date", None) else ""
|
||
total_target = getattr(study, "planned_enrollment_count", None)
|
||
if total_target is None:
|
||
total_target = 0
|
||
return StudySetupConfigData(
|
||
projectMilestones=[],
|
||
enrollmentPlan={
|
||
"totalTarget": total_target,
|
||
"startDate": plan_start,
|
||
"endDate": plan_end,
|
||
"monthlyGoalNote": getattr(study, "enrollment_monthly_goal_note", None) or "",
|
||
"stageBreakdown": getattr(study, "enrollment_stage_breakdown", None) or "",
|
||
},
|
||
siteMilestones=[],
|
||
siteEnrollmentPlans=[],
|
||
monitoringStrategies=[],
|
||
centerConfirm=[],
|
||
)
|
||
|
||
|
||
def _to_date_text(value: date | None) -> str:
|
||
if not value:
|
||
return ""
|
||
return value.isoformat()
|
||
|
||
|
||
def _build_project_publish_snapshot(study) -> ProjectPublishSnapshot:
|
||
return ProjectPublishSnapshot(
|
||
code=getattr(study, "code", None) or "",
|
||
name=getattr(study, "name", None) or "",
|
||
project_full_name=getattr(study, "project_full_name", None) or "",
|
||
sponsor=getattr(study, "sponsor", None) or "",
|
||
protocol_no=getattr(study, "protocol_no", None) or "",
|
||
lead_unit=getattr(study, "lead_unit", None) or "",
|
||
principal_investigator=getattr(study, "principal_investigator", None) or "",
|
||
main_pm=getattr(study, "main_pm", None) or "",
|
||
research_analysis=getattr(study, "research_analysis", None) or "",
|
||
research_product=getattr(study, "research_product", None) or "",
|
||
control_product=getattr(study, "control_product", None) or "",
|
||
indication=getattr(study, "indication", None) or "",
|
||
research_population=getattr(study, "research_population", None) or "",
|
||
research_design=getattr(study, "research_design", None) or "",
|
||
plan_start_date=_to_date_text(getattr(study, "plan_start_date", None)),
|
||
plan_end_date=_to_date_text(getattr(study, "plan_end_date", None)),
|
||
planned_site_count=getattr(study, "planned_site_count", None),
|
||
planned_enrollment_count=getattr(study, "planned_enrollment_count", None),
|
||
summary_note=getattr(study, "summary_note", None) or "",
|
||
objective_note=getattr(study, "objective_note", None) or "",
|
||
status=getattr(study, "status", None) or "",
|
||
visit_interval_days=getattr(study, "visit_interval_days", None),
|
||
visit_total=getattr(study, "visit_total", None),
|
||
visit_window_start_offset=getattr(study, "visit_window_start_offset", None),
|
||
visit_window_end_offset=getattr(study, "visit_window_end_offset", None),
|
||
)
|
||
|
||
|
||
def _to_setup_config_read(
|
||
record,
|
||
*,
|
||
saved_by_name: str | None = None,
|
||
published_by_name: str | None = None,
|
||
projection_status: str | None = None,
|
||
projection_summary: SetupProjectionSummary | None = None,
|
||
) -> StudySetupConfigRead:
|
||
return StudySetupConfigRead(
|
||
id=record.id,
|
||
study_id=record.study_id,
|
||
version=record.version,
|
||
data=StudySetupConfigData.model_validate(record.config or {}),
|
||
publish_status=record.publish_status or "DRAFT",
|
||
published_data=StudySetupConfigData.model_validate(record.published_config) if record.published_config else None,
|
||
published_project_snapshot=(
|
||
ProjectPublishSnapshot.model_validate(record.published_project_snapshot)
|
||
if record.published_project_snapshot
|
||
else None
|
||
),
|
||
published_by=record.published_by,
|
||
published_by_name=published_by_name,
|
||
published_at=record.published_at,
|
||
saved_by=record.saved_by,
|
||
saved_by_name=saved_by_name,
|
||
projection_status=projection_status,
|
||
projection_summary=projection_summary,
|
||
created_at=record.created_at,
|
||
updated_at=record.updated_at,
|
||
)
|
||
|
||
|
||
def _build_projection_audit_detail(
|
||
*,
|
||
projection_status: str,
|
||
study_updated: bool,
|
||
site_updated_count: int,
|
||
site_skipped_count: int,
|
||
warnings: list[str],
|
||
skipped_items: list[dict[str, str]],
|
||
) -> str:
|
||
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 "无"
|
||
projection_status_label = "成功" if projection_status == "success" else "失败" if projection_status == "failed" else projection_status
|
||
return (
|
||
"立项配置已发布"
|
||
f"; 发布同步结果:{projection_status_label}"
|
||
f"; 已同步项目主信息:{'是' if study_updated else '否'}"
|
||
f"; 已同步中心数:{site_updated_count}"
|
||
f"; 未同步中心数:{site_skipped_count}"
|
||
f"; 同步告警:{warning_part}"
|
||
f"; 未同步原因统计:{reason_summary}"
|
||
)
|
||
|
||
|
||
def _ensure_study_timeline_valid(
|
||
*,
|
||
plan_start_date: date | None,
|
||
plan_end_date: date | None,
|
||
visit_window_start_offset: int | None,
|
||
visit_window_end_offset: int | None,
|
||
) -> None:
|
||
if plan_start_date and plan_end_date and plan_start_date > plan_end_date:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="项目计划结束日期不能早于开始日期")
|
||
if (
|
||
visit_window_start_offset is not None
|
||
and visit_window_end_offset is not None
|
||
and visit_window_start_offset > visit_window_end_offset
|
||
):
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="访视窗口开始偏移不能晚于结束偏移")
|
||
|
||
|
||
def _to_setup_config_version_read(record, *, published_by_name: str | None = None) -> StudySetupConfigVersionRead:
|
||
return StudySetupConfigVersionRead(
|
||
id=record.id,
|
||
study_id=record.study_id,
|
||
study_setup_config_id=record.study_setup_config_id,
|
||
version=record.version,
|
||
display_version=record.display_version,
|
||
source_version=record.source_version,
|
||
config=StudySetupConfigData.model_validate(record.config or {}),
|
||
published_by=record.published_by,
|
||
published_by_name=published_by_name,
|
||
published_at=record.published_at,
|
||
created_at=record.created_at,
|
||
)
|
||
|
||
|
||
_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_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"]))])
|
||
async def create_study(
|
||
study_in: StudyCreate,
|
||
db: AsyncSession = Depends(get_db_session),
|
||
current_user=Depends(get_current_user),
|
||
) -> StudyRead:
|
||
_ensure_study_timeline_valid(
|
||
plan_start_date=study_in.plan_start_date,
|
||
plan_end_date=study_in.plan_end_date,
|
||
visit_window_start_offset=study_in.visit_window_start_offset,
|
||
visit_window_end_offset=study_in.visit_window_end_offset,
|
||
)
|
||
study_in.code = study_in.code.strip()
|
||
if not study_in.code:
|
||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="项目编号不能为空")
|
||
existing = await study_crud.get_by_code(db, study_in.code)
|
||
if existing:
|
||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="项目编号已存在")
|
||
study = await study_crud.create(db, study_in, created_by=current_user.id)
|
||
|
||
# 自动将创建者添加为项目成员,角色为PM
|
||
member_in = StudyMemberCreate(
|
||
user_id=current_user.id,
|
||
role_in_study="PM",
|
||
is_active=True,
|
||
)
|
||
await member_crud.add_member(db, study.id, member_in)
|
||
|
||
# 初始化立项配置草稿,并回填可映射的项目信息字段
|
||
default_setup_data = _build_default_setup_config_from_study(study, [])
|
||
setup_record, _ = await study_setup_config_crud.upsert(
|
||
db,
|
||
study.id,
|
||
expected_version=None,
|
||
data=default_setup_data,
|
||
saved_by=current_user.id,
|
||
)
|
||
await audit_crud.log_action(
|
||
db,
|
||
study_id=study.id,
|
||
entity_type="study_setup_config",
|
||
entity_id=setup_record.id,
|
||
action="CREATE_SETUP_CONFIG",
|
||
detail="初始化立项配置(创建项目时回填基础信息)",
|
||
operator_id=current_user.id,
|
||
operator_role=current_user.role,
|
||
)
|
||
|
||
return study
|
||
|
||
|
||
@router.get("/", response_model=PaginatedResponse[StudyRead])
|
||
async def list_studies(
|
||
skip: int = 0,
|
||
limit: int = 100,
|
||
db: AsyncSession = Depends(get_db_session),
|
||
current_user=Depends(get_current_user),
|
||
) -> PaginatedResponse[StudyRead]:
|
||
if current_user.role == "ADMIN":
|
||
studies = await study_crud.list_studies(db, skip=skip, limit=limit)
|
||
total = await study_crud.list_studies(db, skip=0, limit=10_000_000)
|
||
else:
|
||
studies = await study_crud.list_studies_for_user(db, current_user.id, skip=skip, limit=limit)
|
||
total = await study_crud.list_studies_for_user(db, current_user.id, skip=0, limit=10_000_000)
|
||
return paginate(list(studies), total=len(total))
|
||
|
||
|
||
@router.get("/{study_id}", response_model=StudyRead, dependencies=[Depends(require_study_member())])
|
||
async def get_study(
|
||
study_id: uuid.UUID,
|
||
db: AsyncSession = Depends(get_db_session),
|
||
) -> StudyRead:
|
||
study = await study_crud.get(db, study_id)
|
||
if not study:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||
return study
|
||
|
||
|
||
@router.patch(
|
||
"/{study_id}",
|
||
response_model=StudyRead,
|
||
dependencies=[Depends(require_study_roles(["PM"]))],
|
||
)
|
||
async def update_study(
|
||
study_id: uuid.UUID,
|
||
study_in: StudyUpdate,
|
||
db: AsyncSession = Depends(get_db_session),
|
||
) -> StudyRead:
|
||
study = await study_crud.get(db, study_id)
|
||
if not study:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||
|
||
# 检查项目是否已锁定(除非是解锁操作)
|
||
if study.is_locked and not (study_in.is_locked is False):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_403_FORBIDDEN,
|
||
detail="项目已锁定,无法编辑。请先解锁项目。"
|
||
)
|
||
|
||
if study_in.code and study_in.code != study.code:
|
||
existing = await study_crud.get_by_code(db, study_in.code)
|
||
if existing and existing.id != study.id:
|
||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="项目编号已存在")
|
||
|
||
next_plan_start = study_in.plan_start_date if "plan_start_date" in study_in.model_fields_set else study.plan_start_date
|
||
next_plan_end = study_in.plan_end_date if "plan_end_date" in study_in.model_fields_set else study.plan_end_date
|
||
next_window_start = (
|
||
study_in.visit_window_start_offset
|
||
if "visit_window_start_offset" in study_in.model_fields_set
|
||
else study.visit_window_start_offset
|
||
)
|
||
next_window_end = (
|
||
study_in.visit_window_end_offset
|
||
if "visit_window_end_offset" in study_in.model_fields_set
|
||
else study.visit_window_end_offset
|
||
)
|
||
_ensure_study_timeline_valid(
|
||
plan_start_date=next_plan_start,
|
||
plan_end_date=next_plan_end,
|
||
visit_window_start_offset=next_window_start,
|
||
visit_window_end_offset=next_window_end,
|
||
)
|
||
|
||
updated = await study_crud.update(db, study, study_in)
|
||
return updated
|
||
|
||
|
||
@router.delete(
|
||
"/{study_id}",
|
||
status_code=status.HTTP_204_NO_CONTENT,
|
||
dependencies=[Depends(require_roles(["ADMIN"]))],
|
||
)
|
||
async def delete_study(
|
||
study_id: uuid.UUID,
|
||
db: AsyncSession = Depends(get_db_session),
|
||
current_user=Depends(get_current_user),
|
||
) -> None:
|
||
"""硬删除项目及其所有关联数据(不可逆操作)"""
|
||
study = await study_crud.get(db, study_id)
|
||
if not study:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||
|
||
# 删除项目及其所有关联数据(包括审计日志)
|
||
await study_crud.delete(db, study_id)
|
||
|
||
|
||
@router.patch(
|
||
"/{study_id}/lock",
|
||
response_model=StudyRead,
|
||
dependencies=[Depends(require_roles(["ADMIN"]))],
|
||
)
|
||
async def lock_study(
|
||
study_id: uuid.UUID,
|
||
db: AsyncSession = Depends(get_db_session),
|
||
current_user=Depends(get_current_user),
|
||
) -> StudyRead:
|
||
"""锁定项目(仅管理员)"""
|
||
study = await study_crud.get(db, study_id)
|
||
if not study:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||
|
||
locked_study = await study_crud.lock(db, study_id)
|
||
|
||
# 记录审计日志
|
||
await audit_crud.log_action(
|
||
db,
|
||
study_id=study_id,
|
||
entity_type="study",
|
||
entity_id=study_id,
|
||
action="LOCK_STUDY",
|
||
detail=f"项目已锁定:{study.name} ({study.code})",
|
||
operator_id=current_user.id,
|
||
operator_role=current_user.role,
|
||
)
|
||
|
||
return locked_study
|
||
|
||
|
||
@router.patch(
|
||
"/{study_id}/unlock",
|
||
response_model=StudyRead,
|
||
dependencies=[Depends(require_roles(["ADMIN"]))],
|
||
)
|
||
async def unlock_study(
|
||
study_id: uuid.UUID,
|
||
db: AsyncSession = Depends(get_db_session),
|
||
current_user=Depends(get_current_user),
|
||
) -> StudyRead:
|
||
"""解锁项目(仅管理员)"""
|
||
study = await study_crud.get(db, study_id)
|
||
if not study:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||
|
||
unlocked_study = await study_crud.unlock(db, study_id)
|
||
|
||
# 记录审计日志
|
||
await audit_crud.log_action(
|
||
db,
|
||
study_id=study_id,
|
||
entity_type="study",
|
||
entity_id=study_id,
|
||
action="UNLOCK_STUDY",
|
||
detail=f"项目已解锁:{study.name} ({study.code})",
|
||
operator_id=current_user.id,
|
||
operator_role=current_user.role,
|
||
)
|
||
|
||
return unlocked_study
|
||
|
||
|
||
@router.get(
|
||
"/{study_id}/setup-config",
|
||
response_model=StudySetupConfigRead,
|
||
dependencies=[Depends(require_study_member())],
|
||
)
|
||
async def get_study_setup_config(
|
||
study_id: uuid.UUID,
|
||
db: AsyncSession = Depends(get_db_session),
|
||
current_user=Depends(get_current_user),
|
||
) -> StudySetupConfigRead:
|
||
study = await study_crud.get(db, study_id)
|
||
if not study:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||
|
||
record = await study_setup_config_crud.get_by_study(db, study_id)
|
||
if not record:
|
||
sites = await site_crud.list_by_study(db, study_id, skip=0, limit=500, include_inactive=True)
|
||
default_data = _build_default_setup_config_from_study(study, list(sites))
|
||
record, _ = await study_setup_config_crud.upsert(
|
||
db,
|
||
study_id,
|
||
expected_version=None,
|
||
data=default_data,
|
||
saved_by=current_user.id,
|
||
)
|
||
await audit_crud.log_action(
|
||
db,
|
||
study_id=study_id,
|
||
entity_type="study_setup_config",
|
||
entity_id=record.id,
|
||
action="CREATE_SETUP_CONFIG",
|
||
detail="初始化立项配置",
|
||
operator_id=current_user.id,
|
||
operator_role=current_user.role,
|
||
)
|
||
|
||
saved_by_name = None
|
||
published_by_name = None
|
||
if record.saved_by:
|
||
user = await user_crud.get_by_id(db, record.saved_by)
|
||
if user:
|
||
saved_by_name = user.full_name or user.username or user.email
|
||
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)
|
||
|
||
|
||
@router.put(
|
||
"/{study_id}/setup-config",
|
||
response_model=StudySetupConfigRead,
|
||
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
|
||
)
|
||
async def upsert_study_setup_config(
|
||
study_id: uuid.UUID,
|
||
payload: StudySetupConfigUpsert,
|
||
db: AsyncSession = Depends(get_db_session),
|
||
current_user=Depends(get_current_user),
|
||
) -> StudySetupConfigRead:
|
||
study = await study_crud.get(db, study_id)
|
||
if not study:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||
|
||
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 {}
|
||
force_draft = bool(payload.force_draft)
|
||
if existing and existing.publish_status == "PUBLISHED" and existing.published_project_snapshot:
|
||
current_project_snapshot = _build_project_publish_snapshot(study).model_dump(mode="json")
|
||
if current_project_snapshot != dict(existing.published_project_snapshot or {}):
|
||
force_draft = True
|
||
record, conflict = await study_setup_config_crud.upsert(
|
||
db,
|
||
study_id,
|
||
expected_version=payload.expected_version,
|
||
data=payload.data,
|
||
saved_by=current_user.id,
|
||
force_draft=force_draft,
|
||
)
|
||
if conflict:
|
||
_raise_conflict_error()
|
||
if not record:
|
||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="配置保存失败")
|
||
|
||
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
|
||
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)
|
||
|
||
|
||
@router.post(
|
||
"/{study_id}/setup-config/publish",
|
||
response_model=StudySetupConfigRead,
|
||
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
|
||
)
|
||
async def publish_study_setup_config(
|
||
study_id: uuid.UUID,
|
||
payload: StudySetupConfigPublish,
|
||
db: AsyncSession = Depends(get_db_session),
|
||
current_user=Depends(get_current_user),
|
||
) -> StudySetupConfigRead:
|
||
study = await study_crud.get(db, study_id)
|
||
if not study:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||
|
||
record = await study_setup_config_crud.get_by_study(db, study_id)
|
||
if not record:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="请先保存立项配置草稿")
|
||
|
||
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(
|
||
StudySetupConfigData.model_validate(record.config or {}),
|
||
site_lookup,
|
||
project_plan_start=getattr(study, "plan_start_date", None),
|
||
project_plan_end=getattr(study, "plan_end_date", None),
|
||
)
|
||
|
||
published, conflict = await study_setup_config_crud.publish(
|
||
db,
|
||
study_id,
|
||
expected_version=payload.expected_version,
|
||
published_by=current_user.id,
|
||
auto_commit=False,
|
||
)
|
||
if conflict:
|
||
await db.rollback()
|
||
_raise_conflict_error()
|
||
if not published:
|
||
await db.rollback()
|
||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="配置发布失败")
|
||
|
||
try:
|
||
projection = await apply_setup_projection_on_publish(
|
||
db,
|
||
study_id=study_id,
|
||
setup_data=StudySetupConfigData.model_validate(published.config or {}),
|
||
operator_id=current_user.id,
|
||
)
|
||
projection_summary = SetupProjectionSummary(
|
||
study_updated=projection.study_updated,
|
||
site_updated_count=projection.site_updated_count,
|
||
site_skipped_count=projection.site_skipped_count,
|
||
warnings=projection.warnings,
|
||
skipped_items=[
|
||
{"site_id": item.site_id, "reason": item.reason}
|
||
for item in projection.skipped_items
|
||
],
|
||
)
|
||
study_after_publish = await study_crud.get(db, study_id)
|
||
if study_after_publish:
|
||
published.published_project_snapshot = _build_project_publish_snapshot(study_after_publish).model_dump(mode="json")
|
||
await audit_crud.log_action(
|
||
db,
|
||
study_id=study_id,
|
||
entity_type="study_setup_config",
|
||
entity_id=published.id,
|
||
action="PUBLISH_SETUP_CONFIG",
|
||
detail=_build_projection_audit_detail(
|
||
projection_status=projection.status,
|
||
study_updated=projection.study_updated,
|
||
site_updated_count=projection.site_updated_count,
|
||
site_skipped_count=projection.site_skipped_count,
|
||
warnings=projection.warnings,
|
||
skipped_items=[{"site_id": item.site_id, "reason": item.reason} for item in projection.skipped_items],
|
||
),
|
||
operator_id=current_user.id,
|
||
operator_role=current_user.role,
|
||
auto_commit=False,
|
||
)
|
||
await db.commit()
|
||
except Exception:
|
||
await db.rollback()
|
||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="发布失败:联动同步异常")
|
||
|
||
operator_name = current_user.full_name or current_user.username or current_user.email
|
||
await db.refresh(published)
|
||
return _to_setup_config_read(
|
||
published,
|
||
saved_by_name=operator_name,
|
||
published_by_name=operator_name,
|
||
projection_status=projection.status,
|
||
projection_summary=projection_summary,
|
||
)
|
||
|
||
|
||
@router.get(
|
||
"/{study_id}/setup-config/versions",
|
||
response_model=list[StudySetupConfigVersionRead],
|
||
dependencies=[Depends(require_study_member())],
|
||
)
|
||
async def list_study_setup_config_versions(
|
||
study_id: uuid.UUID,
|
||
db: AsyncSession = Depends(get_db_session),
|
||
) -> list[StudySetupConfigVersionRead]:
|
||
study = await study_crud.get(db, study_id)
|
||
if not study:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||
|
||
versions = await study_setup_config_crud.list_versions(db, study_id)
|
||
user_ids = {item.published_by for item in versions if item.published_by}
|
||
name_map: dict[uuid.UUID, str] = {}
|
||
for user_id in user_ids:
|
||
user = await user_crud.get_by_id(db, user_id)
|
||
if user:
|
||
name_map[user_id] = user.full_name or user.username or user.email
|
||
return [
|
||
_to_setup_config_version_read(item, published_by_name=name_map.get(item.published_by) if item.published_by else None)
|
||
for item in versions
|
||
]
|
||
|
||
|
||
@router.post(
|
||
"/{study_id}/setup-config/rollback",
|
||
response_model=StudySetupConfigRead,
|
||
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
|
||
)
|
||
async def rollback_study_setup_config(
|
||
study_id: uuid.UUID,
|
||
payload: StudySetupConfigRollback,
|
||
db: AsyncSession = Depends(get_db_session),
|
||
current_user=Depends(get_current_user),
|
||
) -> StudySetupConfigRead:
|
||
study = await study_crud.get(db, study_id)
|
||
if not study:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||
|
||
record, conflict, target_missing = await study_setup_config_crud.rollback_to_version(
|
||
db,
|
||
study_id,
|
||
expected_version=payload.expected_version,
|
||
target_version=payload.target_version,
|
||
saved_by=current_user.id,
|
||
)
|
||
if conflict:
|
||
_raise_conflict_error()
|
||
if target_missing:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="目标版本不存在")
|
||
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="ROLLBACK_SETUP_CONFIG",
|
||
detail=f"立项配置已回滚到发布版本 v{payload.target_version}",
|
||
operator_id=current_user.id,
|
||
operator_role=current_user.role,
|
||
)
|
||
operator_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=operator_name, published_by_name=published_by_name)
|
||
|
||
|
||
@router.delete(
|
||
"/{study_id}/setup-config/versions/{target_version}",
|
||
status_code=status.HTTP_204_NO_CONTENT,
|
||
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
|
||
)
|
||
async def delete_study_setup_config_version(
|
||
study_id: uuid.UUID,
|
||
target_version: int,
|
||
db: AsyncSession = Depends(get_db_session),
|
||
current_user=Depends(get_current_user),
|
||
) -> None:
|
||
study = await study_crud.get(db, study_id)
|
||
if not study:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||
|
||
versions = await study_setup_config_crud.list_versions(db, study_id)
|
||
if not versions:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="目标版本不存在")
|
||
latest_version = max(item.version for item in versions)
|
||
if target_version == latest_version:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="最新发布版本不能删除")
|
||
|
||
deleted = await study_setup_config_crud.delete_version(
|
||
db,
|
||
study_id,
|
||
target_version=target_version,
|
||
)
|
||
if not deleted:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="目标版本不存在")
|
||
|
||
await audit_crud.log_action(
|
||
db,
|
||
study_id=study_id,
|
||
entity_type="study_setup_config",
|
||
entity_id=study_id,
|
||
action="DELETE_SETUP_CONFIG_VERSION",
|
||
detail=f"删除发布版本快照 v{target_version}",
|
||
operator_id=current_user.id,
|
||
operator_role=current_user.role,
|
||
)
|