Files
ctms/backend/app/api/v1/monitoring_visit_issues.py
T

652 lines
22 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 study as study_crud
from app.schemas.monitoring_visit_issue import (
MonitoringVisitIssueCreate,
MonitoringVisitIssueImportSummary,
MonitoringVisitIssueRead,
MonitoringVisitIssueUpdate,
)
router = APIRouter()
_HEADER_ALIASES: dict[str, list[str]] = {
"issue_no": ["问题编号", "问题编码", "issue_no", "IssueNo"],
"open_duration_text": ["开放时长", "open_duration"],
"category": ["问题分类", "category"],
"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"],
"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, ...] = (
"issue_no",
"source",
"monitor_type",
"monitor_item",
"category",
"recommendation",
"subject_name",
"subject_code",
"description",
"action_taken",
"follow_up_progress",
"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
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 _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,
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,
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,
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, 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,
category: str | None = None,
status_value: str | None = Query(default=None, alias="status"),
overdue: bool | 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)
normalized_status = _normalize_status(status_value) if status_value else None
items = await issue_crud.list_issues(
db,
study_id,
category=(category or None),
status=normalized_status,
overdue=overdue,
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)
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,
category: str | None = None,
status_value: str | None = Query(default=None, alias="status"),
overdue: bool | None = None,
keyword: str | None = None,
db: AsyncSession = Depends(get_db_session),
) -> StreamingResponse:
await _ensure_study_exists(db, study_id)
normalized_status = _normalize_status(status_value) if status_value else None
items = await issue_crud.list_issues(
db,
study_id,
category=(category or None),
status=normalized_status,
overdue=overdue,
keyword=(keyword or None),
skip=0,
limit=50000,
)
wb = Workbook()
ws = wb.active
ws.title = "监查访视问题"
headers = [
"问题编号",
"问题来源",
"监查类型",
"监查项",
"问题分类",
"建议措施",
"受试者",
"受试者缩写号",
"问题描述",
"采取措施",
"跟进计划及进展",
"发现时间",
"预计解决日期",
"实际解决日期",
"关闭日期",
"责任人",
"进度",
"开放时长",
"创建者",
"创建时间",
]
ws.append(headers)
for item in items:
view = _to_read(item)
ws.append(
[
view.issue_no or "",
view.source or "",
view.monitor_type or "",
view.monitor_item or "",
view.category or "",
view.recommendation or "",
view.subject_name or "",
view.subject_code or "",
view.description 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="至少提供一个更新字段")
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] = []
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
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,
category=category,
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"),
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],
)