917ab7ccf1
Add early termination visit workflow with ordering, non-applicable visit handling, visit window display, and medication adherence support. Extend monitoring visit issue template fields, site scoping, setup draft project info handling, login security UI, attachment behavior, and related tests/migrations.
777 lines
27 KiB
Python
777 lines
27 KiB
Python
import csv
|
|
import io
|
|
import json
|
|
import uuid
|
|
from datetime import date, datetime, timezone
|
|
from urllib.parse import quote
|
|
|
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
|
from fastapi.responses import StreamingResponse
|
|
from openpyxl import Workbook, load_workbook
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_not_locked, require_study_roles
|
|
from app.crud import audit as audit_crud
|
|
from app.crud import monitoring_visit_issue as issue_crud
|
|
from app.crud import site as site_crud
|
|
from app.crud import study as study_crud
|
|
from app.schemas.monitoring_visit_issue import (
|
|
MonitoringVisitIssueCreate,
|
|
MonitoringVisitIssueImportSummary,
|
|
MonitoringVisitIssueRead,
|
|
MonitoringVisitIssueUpdate,
|
|
)
|
|
|
|
router = APIRouter()
|
|
|
|
_HEADER_ALIASES: dict[str, list[str]] = {
|
|
"site_name": ["项目/中心", "中心", "中心名称", "site_name", "site"],
|
|
"issue_no": ["问题编号", "问题编码", "issue_no", "IssueNo"],
|
|
"open_duration_text": ["开放时长", "open_duration"],
|
|
"category": ["问题分类", "category"],
|
|
"severity": ["严重程度", "severity"],
|
|
"mark": ["标记", "mark"],
|
|
"visit_cycle": ["访视周期", "访视", "visit_cycle"],
|
|
"subject_code": ["受试者", "受试者编号", "subject_code"],
|
|
"subject_name": ["受试者姓名", "subject_name"],
|
|
"monitor_item": ["监查项", "monitor_item"],
|
|
"monitor_type": ["监查类型", "monitor_type"],
|
|
"status": ["进度", "状态", "status", "progress"],
|
|
"source": ["问题来源", "source"],
|
|
"creator_name": ["创建者", "creator", "creator_name"],
|
|
"created_at": ["创建时间", "created_at", "创建日期"],
|
|
"recommendation": ["建议措施", "recommendation"],
|
|
"description": ["问题描述", "description"],
|
|
"action_taken": ["采取措施", "action_taken"],
|
|
"follow_up_progress": ["跟进计划及进展", "follow_up_progress"],
|
|
"center_query": ["中心质疑", "center_query"],
|
|
"center_latest_reply": ["中心最新回复", "中心回复", "center_latest_reply"],
|
|
"rectification_completed": ["是否完成整改", "完成整改", "rectification_completed"],
|
|
"found_date": ["发现时间", "found_date"],
|
|
"due_at": ["截止时间", "截止日期", "超期截止时间", "due_at"],
|
|
"actual_resolve_date": ["实际解决日期", "actual_resolve_date"],
|
|
"closed_at": ["关闭日期", "closed_at"],
|
|
"responsible_name": ["责任人", "responsible_name"],
|
|
}
|
|
|
|
_AUDIT_FIELDS: tuple[str, ...] = (
|
|
"site_id",
|
|
"issue_no",
|
|
"source",
|
|
"monitor_type",
|
|
"monitor_item",
|
|
"category",
|
|
"severity",
|
|
"mark",
|
|
"visit_cycle",
|
|
"recommendation",
|
|
"subject_name",
|
|
"subject_code",
|
|
"description",
|
|
"action_taken",
|
|
"follow_up_progress",
|
|
"center_query",
|
|
"center_latest_reply",
|
|
"rectification_completed",
|
|
"found_date",
|
|
"due_at",
|
|
"actual_resolve_date",
|
|
"closed_at",
|
|
"responsible_name",
|
|
"status",
|
|
)
|
|
|
|
|
|
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
|
study = await study_crud.get(db, study_id)
|
|
if not study:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
|
return study
|
|
|
|
|
|
async def _ensure_site_belongs_to_study(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UUID | None) -> None:
|
|
if site_id is None:
|
|
return
|
|
site = await site_crud.get_site(db, site_id)
|
|
if not site or site.study_id != study_id:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或不属于当前项目")
|
|
|
|
|
|
def _normalize_status(value: str | None) -> str:
|
|
text = (value or "").strip().upper()
|
|
zh = {
|
|
"已关闭": "CLOSED",
|
|
"关闭": "CLOSED",
|
|
"开放中": "OPEN",
|
|
"开启": "OPEN",
|
|
"OPEN": "OPEN",
|
|
"CLOSED": "CLOSED",
|
|
}
|
|
mapped = zh.get(text)
|
|
if mapped:
|
|
return mapped
|
|
return "OPEN"
|
|
|
|
|
|
def _parse_bool(value: object | None) -> bool | None:
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, bool):
|
|
return value
|
|
text = str(value).strip().lower()
|
|
if not text:
|
|
return None
|
|
if text in {"1", "true", "yes", "y", "是", "已完成", "完成"}:
|
|
return True
|
|
if text in {"0", "false", "no", "n", "否", "未完成"}:
|
|
return False
|
|
return None
|
|
|
|
|
|
def _pick(row: dict[str, object], key: str) -> str | None:
|
|
for alias in _HEADER_ALIASES[key]:
|
|
if alias not in row:
|
|
continue
|
|
value = row.get(alias)
|
|
if value is None:
|
|
continue
|
|
text = str(value).strip()
|
|
if text:
|
|
return text
|
|
return None
|
|
|
|
|
|
def _parse_datetime(value: object | None) -> datetime | None:
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, datetime):
|
|
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
|
if isinstance(value, date):
|
|
return datetime(value.year, value.month, value.day, tzinfo=timezone.utc)
|
|
|
|
text = str(value).strip()
|
|
if not text:
|
|
return None
|
|
|
|
formats = [
|
|
"%Y-%m-%d %H:%M:%S",
|
|
"%Y/%m/%d %H:%M:%S",
|
|
"%Y-%m-%d %H:%M",
|
|
"%Y/%m/%d %H:%M",
|
|
"%Y-%m-%d",
|
|
"%Y/%m/%d",
|
|
]
|
|
for fmt in formats:
|
|
try:
|
|
parsed = datetime.strptime(text, fmt)
|
|
return parsed.replace(tzinfo=timezone.utc)
|
|
except ValueError:
|
|
continue
|
|
|
|
try:
|
|
parsed = datetime.fromisoformat(text)
|
|
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _parse_date(value: object | None) -> date | None:
|
|
parsed = _parse_datetime(value)
|
|
if parsed is None:
|
|
return None
|
|
return parsed.date()
|
|
|
|
|
|
def _calc_open_duration(created_at: datetime, status_text: str, closed_at: datetime | None, override_text: str | None) -> str:
|
|
if override_text:
|
|
return override_text
|
|
start = created_at if created_at.tzinfo else created_at.replace(tzinfo=timezone.utc)
|
|
if status_text == "CLOSED" and closed_at is not None:
|
|
end = closed_at if closed_at.tzinfo else closed_at.replace(tzinfo=timezone.utc)
|
|
else:
|
|
end = datetime.now(timezone.utc)
|
|
delta = end - start
|
|
if delta.total_seconds() < 0:
|
|
return "0天0小时"
|
|
days = delta.days
|
|
hours = (delta.seconds // 3600) % 24
|
|
return f"{days}天{hours}小时"
|
|
|
|
|
|
def _to_read(item) -> MonitoringVisitIssueRead:
|
|
now = datetime.now(timezone.utc)
|
|
overdue = item.status == "OPEN" and item.due_at is not None and item.due_at < now
|
|
return MonitoringVisitIssueRead(
|
|
id=item.id,
|
|
study_id=item.study_id,
|
|
site_id=item.site_id,
|
|
issue_no=item.issue_no,
|
|
open_duration=_calc_open_duration(item.created_at, item.status, item.closed_at, item.open_duration_text),
|
|
category=item.category,
|
|
severity=item.severity,
|
|
mark=item.mark,
|
|
visit_cycle=item.visit_cycle,
|
|
subject_code=item.subject_code,
|
|
monitor_item=item.monitor_item,
|
|
monitor_type=item.monitor_type,
|
|
progress="已关闭" if item.status == "CLOSED" else "开放中",
|
|
status=item.status,
|
|
source=item.source,
|
|
creator_name=item.creator_name,
|
|
recommendation=item.recommendation,
|
|
subject_name=item.subject_name,
|
|
created_at=item.created_at,
|
|
description=item.description,
|
|
action_taken=item.action_taken,
|
|
follow_up_progress=item.follow_up_progress,
|
|
center_query=item.center_query,
|
|
center_latest_reply=item.center_latest_reply,
|
|
rectification_completed=bool(item.rectification_completed),
|
|
found_date=item.found_date,
|
|
overdue=overdue,
|
|
due_at=item.due_at,
|
|
actual_resolve_date=item.actual_resolve_date,
|
|
closed_at=item.closed_at,
|
|
responsible_name=item.responsible_name,
|
|
)
|
|
|
|
|
|
def _format_date(value: date | None) -> str:
|
|
if not value:
|
|
return ""
|
|
return value.isoformat()
|
|
|
|
|
|
def _format_datetime(value: datetime | None) -> str:
|
|
if not value:
|
|
return ""
|
|
parsed = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
|
return parsed.strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
|
|
def _to_audit_value(value: object | None):
|
|
if isinstance(value, uuid.UUID):
|
|
return str(value)
|
|
if isinstance(value, datetime):
|
|
parsed = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
|
return parsed.isoformat()
|
|
if isinstance(value, date):
|
|
return value.isoformat()
|
|
return value
|
|
|
|
|
|
def _issue_audit_snapshot(item) -> dict[str, object | None]:
|
|
snapshot: dict[str, object | None] = {}
|
|
for field in _AUDIT_FIELDS:
|
|
snapshot[field] = _to_audit_value(getattr(item, field, None))
|
|
return snapshot
|
|
|
|
|
|
def _build_audit_diff(before: dict[str, object | None], after: dict[str, object | None]):
|
|
before_changed: dict[str, object | None] = {}
|
|
after_changed: dict[str, object | None] = {}
|
|
for field in _AUDIT_FIELDS:
|
|
prev = before.get(field)
|
|
next_value = after.get(field)
|
|
if prev == next_value:
|
|
continue
|
|
before_changed[field] = prev
|
|
after_changed[field] = next_value
|
|
return before_changed, after_changed
|
|
|
|
|
|
def _read_xlsx_rows(content: bytes) -> list[dict[str, object]]:
|
|
workbook = load_workbook(filename=io.BytesIO(content), data_only=True)
|
|
worksheet = workbook.active
|
|
rows_iter = worksheet.iter_rows(values_only=True)
|
|
headers_row = next(rows_iter, None)
|
|
if not headers_row:
|
|
return []
|
|
headers = [str(item).strip() if item is not None else "" for item in headers_row]
|
|
rows: list[dict[str, object]] = []
|
|
for values in rows_iter:
|
|
if values is None:
|
|
continue
|
|
row_dict: dict[str, object] = {}
|
|
for idx, value in enumerate(values):
|
|
if idx >= len(headers):
|
|
continue
|
|
header = headers[idx]
|
|
if not header:
|
|
continue
|
|
row_dict[header] = value
|
|
if row_dict:
|
|
rows.append(row_dict)
|
|
return rows
|
|
|
|
|
|
def _read_csv_rows(content: bytes) -> list[dict[str, object]]:
|
|
text = None
|
|
for encoding in ("utf-8-sig", "utf-8", "gbk"):
|
|
try:
|
|
text = content.decode(encoding)
|
|
break
|
|
except UnicodeDecodeError:
|
|
continue
|
|
if text is None:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="导入文件编码不支持")
|
|
reader = csv.DictReader(io.StringIO(text))
|
|
return [dict(row) for row in reader if row]
|
|
|
|
|
|
def _normalize_file_rows(filename: str, content: bytes) -> list[dict[str, object]]:
|
|
lower = filename.lower()
|
|
if lower.endswith(".xlsx"):
|
|
return _read_xlsx_rows(content)
|
|
if lower.endswith(".csv"):
|
|
return _read_csv_rows(content)
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅支持 .xlsx 或 .csv 文件")
|
|
|
|
|
|
@router.get(
|
|
"/issues",
|
|
response_model=list[MonitoringVisitIssueRead],
|
|
dependencies=[Depends(require_study_member())],
|
|
)
|
|
async def list_monitoring_visit_issues(
|
|
study_id: uuid.UUID,
|
|
site_id: uuid.UUID | None = None,
|
|
category: str | None = None,
|
|
severity: str | None = None,
|
|
mark: str | None = None,
|
|
visit_cycle: str | None = None,
|
|
status_value: str | None = Query(default=None, alias="status"),
|
|
overdue: bool | None = None,
|
|
rectification_completed: bool | None = None,
|
|
due_from: date | None = None,
|
|
due_to: date | None = None,
|
|
created_from: date | None = None,
|
|
created_to: date | None = None,
|
|
keyword: str | None = None,
|
|
skip: int = 0,
|
|
limit: int = 500,
|
|
db: AsyncSession = Depends(get_db_session),
|
|
) -> list[MonitoringVisitIssueRead]:
|
|
await _ensure_study_exists(db, study_id)
|
|
await _ensure_site_belongs_to_study(db, study_id, site_id)
|
|
normalized_status = _normalize_status(status_value) if status_value else None
|
|
items = await issue_crud.list_issues(
|
|
db,
|
|
study_id,
|
|
site_id=site_id,
|
|
category=(category or None),
|
|
severity=(severity or None),
|
|
mark=(mark or None),
|
|
visit_cycle=(visit_cycle or None),
|
|
status=normalized_status,
|
|
overdue=overdue,
|
|
rectification_completed=rectification_completed,
|
|
due_from=due_from,
|
|
due_to=due_to,
|
|
created_from=created_from,
|
|
created_to=created_to,
|
|
keyword=(keyword or None),
|
|
skip=skip,
|
|
limit=min(limit, 2000),
|
|
)
|
|
return [_to_read(item) for item in items]
|
|
|
|
|
|
@router.post(
|
|
"/issues",
|
|
response_model=MonitoringVisitIssueRead,
|
|
status_code=status.HTTP_201_CREATED,
|
|
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
|
|
)
|
|
async def create_monitoring_visit_issue(
|
|
study_id: uuid.UUID,
|
|
payload: MonitoringVisitIssueCreate,
|
|
db: AsyncSession = Depends(get_db_session),
|
|
current_user=Depends(get_current_user),
|
|
) -> MonitoringVisitIssueRead:
|
|
await _ensure_study_exists(db, study_id)
|
|
await _ensure_site_belongs_to_study(db, study_id, payload.site_id)
|
|
|
|
if payload.issue_no:
|
|
existing = await issue_crud.get_issue_by_issue_no(db, study_id, payload.issue_no)
|
|
if existing:
|
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="问题编号已存在")
|
|
|
|
creator_name = payload.creator_name or getattr(current_user, "full_name", None)
|
|
payload_to_save = payload.model_copy(update={"creator_name": creator_name})
|
|
try:
|
|
item = await issue_crud.create_issue(db, study_id, payload_to_save, created_by=current_user.id)
|
|
except IntegrityError:
|
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="问题编号已存在")
|
|
|
|
await audit_crud.log_action(
|
|
db,
|
|
study_id=study_id,
|
|
entity_type="monitoring_visit_issue",
|
|
entity_id=item.id,
|
|
action="CREATE_MONITORING_VISIT_ISSUE",
|
|
detail=f"监查访视问题 {item.issue_no} 已创建",
|
|
operator_id=current_user.id,
|
|
operator_role=current_user.role,
|
|
)
|
|
return _to_read(item)
|
|
|
|
|
|
@router.get(
|
|
"/issues/export",
|
|
response_class=StreamingResponse,
|
|
dependencies=[Depends(require_study_member())],
|
|
)
|
|
async def export_monitoring_visit_issues(
|
|
study_id: uuid.UUID,
|
|
site_id: uuid.UUID | None = None,
|
|
category: str | None = None,
|
|
severity: str | None = None,
|
|
mark: str | None = None,
|
|
visit_cycle: str | None = None,
|
|
status_value: str | None = Query(default=None, alias="status"),
|
|
overdue: bool | None = None,
|
|
rectification_completed: bool | None = None,
|
|
due_from: date | None = None,
|
|
due_to: date | None = None,
|
|
created_from: date | None = None,
|
|
created_to: date | None = None,
|
|
keyword: str | None = None,
|
|
db: AsyncSession = Depends(get_db_session),
|
|
) -> StreamingResponse:
|
|
await _ensure_study_exists(db, study_id)
|
|
await _ensure_site_belongs_to_study(db, study_id, site_id)
|
|
normalized_status = _normalize_status(status_value) if status_value else None
|
|
items = await issue_crud.list_issues(
|
|
db,
|
|
study_id,
|
|
site_id=site_id,
|
|
category=(category or None),
|
|
severity=(severity or None),
|
|
mark=(mark or None),
|
|
visit_cycle=(visit_cycle or None),
|
|
status=normalized_status,
|
|
overdue=overdue,
|
|
rectification_completed=rectification_completed,
|
|
due_from=due_from,
|
|
due_to=due_to,
|
|
created_from=created_from,
|
|
created_to=created_to,
|
|
keyword=(keyword or None),
|
|
skip=0,
|
|
limit=50000,
|
|
)
|
|
|
|
wb = Workbook()
|
|
ws = wb.active
|
|
ws.title = "监查访视问题"
|
|
headers = [
|
|
"项目/中心",
|
|
"问题编号",
|
|
"受试者筛选号",
|
|
"访视周期",
|
|
"问题分类",
|
|
"严重程度",
|
|
"建议措施",
|
|
"问题描述",
|
|
"中心质疑",
|
|
"中心最新回复",
|
|
"是否完成整改",
|
|
"标记",
|
|
"状态",
|
|
"是否超期",
|
|
"问题来源",
|
|
"监查类型",
|
|
"监查项",
|
|
"采取措施",
|
|
"跟进计划及进展",
|
|
"发现时间",
|
|
"预计解决日期",
|
|
"实际解决日期",
|
|
"关闭日期",
|
|
"责任人",
|
|
"进度",
|
|
"开放时长",
|
|
"创建者",
|
|
"创建时间",
|
|
]
|
|
ws.append(headers)
|
|
site_ids = {item.site_id for item in items if item.site_id}
|
|
site_map = await site_crud.get_sites_by_ids(db, site_ids)
|
|
|
|
for item in items:
|
|
view = _to_read(item)
|
|
site_name = site_map.get(view.site_id).name if view.site_id in site_map else ""
|
|
ws.append(
|
|
[
|
|
site_name,
|
|
view.issue_no or "",
|
|
view.subject_code or "",
|
|
view.visit_cycle or "",
|
|
view.category or "",
|
|
view.severity or "",
|
|
view.recommendation or "",
|
|
view.description or "",
|
|
view.center_query or "",
|
|
view.center_latest_reply or "",
|
|
"是" if view.rectification_completed else "否",
|
|
view.mark or "",
|
|
view.progress or "",
|
|
"是" if view.overdue else "否",
|
|
view.source or "",
|
|
view.monitor_type or "",
|
|
view.monitor_item or "",
|
|
view.action_taken or "",
|
|
view.follow_up_progress or "",
|
|
_format_date(view.found_date),
|
|
_format_datetime(view.due_at),
|
|
_format_date(view.actual_resolve_date),
|
|
_format_datetime(view.closed_at),
|
|
view.responsible_name or "",
|
|
view.progress or "",
|
|
view.open_duration or "",
|
|
view.creator_name or "",
|
|
_format_datetime(view.created_at),
|
|
]
|
|
)
|
|
|
|
stream = io.BytesIO()
|
|
wb.save(stream)
|
|
stream.seek(0)
|
|
filename = f"monitoring_visit_issues_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
|
fallback = "".join((ch if ord(ch) < 128 else "_") for ch in filename) or "download.xlsx"
|
|
quoted = quote(filename, safe="")
|
|
headers = {"Content-Disposition": f"attachment; filename=\"{fallback}\"; filename*=UTF-8''{quoted}"}
|
|
return StreamingResponse(
|
|
stream,
|
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
headers=headers,
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/issues/{issue_id}",
|
|
response_model=MonitoringVisitIssueRead,
|
|
dependencies=[Depends(require_study_member())],
|
|
)
|
|
async def get_monitoring_visit_issue(
|
|
study_id: uuid.UUID,
|
|
issue_id: uuid.UUID,
|
|
db: AsyncSession = Depends(get_db_session),
|
|
) -> MonitoringVisitIssueRead:
|
|
await _ensure_study_exists(db, study_id)
|
|
item = await issue_crud.get_issue(db, issue_id)
|
|
if not item or item.study_id != study_id:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="问题记录不存在")
|
|
return _to_read(item)
|
|
|
|
|
|
@router.patch(
|
|
"/issues/{issue_id}",
|
|
response_model=MonitoringVisitIssueRead,
|
|
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
|
|
)
|
|
async def update_monitoring_visit_issue(
|
|
study_id: uuid.UUID,
|
|
issue_id: uuid.UUID,
|
|
payload: MonitoringVisitIssueUpdate,
|
|
db: AsyncSession = Depends(get_db_session),
|
|
current_user=Depends(get_current_user),
|
|
) -> MonitoringVisitIssueRead:
|
|
await _ensure_study_exists(db, study_id)
|
|
item = await issue_crud.get_issue(db, issue_id)
|
|
if not item or item.study_id != study_id:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="问题记录不存在")
|
|
|
|
if payload.issue_no and payload.issue_no != item.issue_no:
|
|
existing = await issue_crud.get_issue_by_issue_no(db, study_id, payload.issue_no)
|
|
if existing and existing.id != item.id:
|
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="问题编号已存在")
|
|
|
|
if not payload.model_fields_set:
|
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="至少提供一个更新字段")
|
|
if "site_id" in payload.model_fields_set:
|
|
await _ensure_site_belongs_to_study(db, study_id, payload.site_id)
|
|
|
|
before_snapshot = _issue_audit_snapshot(item)
|
|
try:
|
|
updated = await issue_crud.update_issue(db, item, payload, created_by=current_user.id)
|
|
except IntegrityError:
|
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="问题编号已存在")
|
|
|
|
after_snapshot = _issue_audit_snapshot(updated)
|
|
before_changed, after_changed = _build_audit_diff(before_snapshot, after_snapshot)
|
|
detail_payload: dict[str, object] = {"targetName": updated.issue_no}
|
|
if before_changed or after_changed:
|
|
detail_payload["before"] = before_changed
|
|
detail_payload["after"] = after_changed
|
|
else:
|
|
detail_payload["description"] = f"监查访视问题 {updated.issue_no} 已更新"
|
|
|
|
await audit_crud.log_action(
|
|
db,
|
|
study_id=study_id,
|
|
entity_type="monitoring_visit_issue",
|
|
entity_id=item.id,
|
|
action="UPDATE_MONITORING_VISIT_ISSUE",
|
|
detail=json.dumps(detail_payload, ensure_ascii=False),
|
|
operator_id=current_user.id,
|
|
operator_role=current_user.role,
|
|
)
|
|
return _to_read(updated)
|
|
|
|
|
|
@router.delete(
|
|
"/issues/{issue_id}",
|
|
status_code=status.HTTP_204_NO_CONTENT,
|
|
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
|
|
)
|
|
async def delete_monitoring_visit_issue(
|
|
study_id: uuid.UUID,
|
|
issue_id: uuid.UUID,
|
|
db: AsyncSession = Depends(get_db_session),
|
|
current_user=Depends(get_current_user),
|
|
) -> None:
|
|
await _ensure_study_exists(db, study_id)
|
|
item = await issue_crud.get_issue(db, issue_id)
|
|
if not item or item.study_id != study_id:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="问题记录不存在")
|
|
|
|
issue_no = item.issue_no
|
|
await issue_crud.delete_issue(db, item)
|
|
await audit_crud.log_action(
|
|
db,
|
|
study_id=study_id,
|
|
entity_type="monitoring_visit_issue",
|
|
entity_id=issue_id,
|
|
action="DELETE_MONITORING_VISIT_ISSUE",
|
|
detail=f"监查访视问题 {issue_no} 已删除",
|
|
operator_id=current_user.id,
|
|
operator_role=current_user.role,
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/issues/import",
|
|
response_model=MonitoringVisitIssueImportSummary,
|
|
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
|
|
)
|
|
async def import_monitoring_visit_issues(
|
|
study_id: uuid.UUID,
|
|
file: UploadFile = File(...),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
current_user=Depends(get_current_user),
|
|
) -> MonitoringVisitIssueImportSummary:
|
|
await _ensure_study_exists(db, study_id)
|
|
if not file.filename:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文件名不能为空")
|
|
|
|
content = await file.read()
|
|
rows = _normalize_file_rows(file.filename, content)
|
|
if not rows:
|
|
return MonitoringVisitIssueImportSummary(created_count=0, updated_count=0, skipped_count=0, skipped_rows=[])
|
|
|
|
created_count = 0
|
|
updated_count = 0
|
|
skipped_rows: list[str] = []
|
|
sites = await site_crud.list_by_study(db, study_id, limit=5000, include_inactive=True)
|
|
site_by_name = {site.name.strip().lower(): site for site in sites if site.name}
|
|
|
|
for idx, row in enumerate(rows, start=2):
|
|
issue_no = _pick(row, "issue_no")
|
|
category = _pick(row, "category")
|
|
if not issue_no or not category:
|
|
skipped_rows.append(f"第{idx}行:问题编号或问题分类为空")
|
|
continue
|
|
|
|
site_id = None
|
|
site_name = _pick(row, "site_name")
|
|
if site_name:
|
|
site = site_by_name.get(site_name.strip().lower())
|
|
if not site:
|
|
skipped_rows.append(f"第{idx}行:中心「{site_name}」不存在")
|
|
continue
|
|
site_id = site.id
|
|
|
|
status_text = _normalize_status(_pick(row, "status"))
|
|
creator_name = _pick(row, "creator_name") or getattr(current_user, "full_name", None)
|
|
created_at = _parse_datetime(row.get("创建时间") or row.get("created_at") or _pick(row, "created_at"))
|
|
due_at = _parse_datetime(row.get("截止时间") or row.get("due_at") or _pick(row, "due_at"))
|
|
closed_at = _parse_datetime(row.get("关闭日期") or row.get("closed_at") or _pick(row, "closed_at"))
|
|
open_duration_text = _pick(row, "open_duration_text")
|
|
|
|
try:
|
|
payload = MonitoringVisitIssueCreate(
|
|
issue_no=issue_no,
|
|
site_id=site_id,
|
|
category=category,
|
|
severity=_pick(row, "severity"),
|
|
mark=_pick(row, "mark"),
|
|
visit_cycle=_pick(row, "visit_cycle"),
|
|
subject_code=_pick(row, "subject_code"),
|
|
subject_name=_pick(row, "subject_name"),
|
|
monitor_item=_pick(row, "monitor_item"),
|
|
monitor_type=_pick(row, "monitor_type") or "监查访视",
|
|
status=status_text,
|
|
source=_pick(row, "source") or "监查",
|
|
creator_name=creator_name,
|
|
recommendation=_pick(row, "recommendation"),
|
|
description=_pick(row, "description"),
|
|
action_taken=_pick(row, "action_taken"),
|
|
follow_up_progress=_pick(row, "follow_up_progress"),
|
|
center_query=_pick(row, "center_query"),
|
|
center_latest_reply=_pick(row, "center_latest_reply"),
|
|
rectification_completed=_parse_bool(_pick(row, "rectification_completed")) or False,
|
|
found_date=_parse_date(row.get("发现时间") or row.get("found_date") or _pick(row, "found_date")),
|
|
due_at=due_at,
|
|
actual_resolve_date=_parse_date(
|
|
row.get("实际解决日期") or row.get("actual_resolve_date") or _pick(row, "actual_resolve_date")
|
|
),
|
|
closed_at=closed_at,
|
|
responsible_name=_pick(row, "responsible_name"),
|
|
)
|
|
except Exception as exc:
|
|
skipped_rows.append(f"第{idx}行:{exc}")
|
|
continue
|
|
|
|
existing = await issue_crud.get_issue_by_issue_no(db, study_id, payload.issue_no)
|
|
if existing:
|
|
await issue_crud.update_issue(
|
|
db,
|
|
existing,
|
|
MonitoringVisitIssueUpdate(**payload.model_dump()),
|
|
created_by=current_user.id,
|
|
created_at=created_at,
|
|
open_duration_text=open_duration_text,
|
|
)
|
|
updated_count += 1
|
|
else:
|
|
await issue_crud.create_issue(
|
|
db,
|
|
study_id,
|
|
payload,
|
|
created_by=current_user.id,
|
|
created_at=created_at,
|
|
open_duration_text=open_duration_text,
|
|
)
|
|
created_count += 1
|
|
|
|
await audit_crud.log_action(
|
|
db,
|
|
study_id=study_id,
|
|
entity_type="monitoring_visit_issue",
|
|
entity_id=None,
|
|
action="IMPORT_MONITORING_VISIT_ISSUE",
|
|
detail=f"导入监查访视问题:新增 {created_count} 条,更新 {updated_count} 条,跳过 {len(skipped_rows)} 条",
|
|
operator_id=current_user.id,
|
|
operator_role=current_user.role,
|
|
)
|
|
|
|
return MonitoringVisitIssueImportSummary(
|
|
created_count=created_count,
|
|
updated_count=updated_count,
|
|
skipped_count=len(skipped_rows),
|
|
skipped_rows=skipped_rows[:20],
|
|
)
|