777 lines
28 KiB
Python
777 lines
28 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_operator_role_label, get_current_user, get_db_session, require_study_not_locked, require_api_permission
|
|
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_api_permission("monitoring_issues:list"))],
|
|
)
|
|
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_api_permission("monitoring_issues:create")), 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=await get_operator_role_label(db, study_id, current_user),
|
|
)
|
|
return _to_read(item)
|
|
|
|
|
|
@router.get(
|
|
"/issues/export",
|
|
response_class=StreamingResponse,
|
|
dependencies=[Depends(require_api_permission("monitoring_issues:list"))],
|
|
)
|
|
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_api_permission("monitoring_issues:read"))],
|
|
)
|
|
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_api_permission("monitoring_issues:update")), 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=await get_operator_role_label(db, study_id, current_user),
|
|
)
|
|
return _to_read(updated)
|
|
|
|
|
|
@router.delete(
|
|
"/issues/{issue_id}",
|
|
status_code=status.HTTP_204_NO_CONTENT,
|
|
dependencies=[Depends(require_api_permission("monitoring_issues:delete")), 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=await get_operator_role_label(db, study_id, current_user),
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/issues/import",
|
|
response_model=MonitoringVisitIssueImportSummary,
|
|
dependencies=[Depends(require_api_permission("monitoring_issues:create")), 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=await get_operator_role_label(db, study_id, current_user),
|
|
)
|
|
|
|
return MonitoringVisitIssueImportSummary(
|
|
created_count=created_count,
|
|
updated_count=updated_count,
|
|
skipped_count=len(skipped_rows),
|
|
skipped_rows=skipped_rows[:20],
|
|
)
|