diff --git a/backend/app/api/cases.py b/backend/app/api/cases.py new file mode 100644 index 0000000..607f31f --- /dev/null +++ b/backend/app/api/cases.py @@ -0,0 +1,403 @@ +"""Cases API routes — list, detail, update, ICD entry, coding, template download.""" + +import logging +from datetime import datetime, timezone +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from fastapi.responses import StreamingResponse +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from app.core.dependencies import get_current_user, require_admin +from app.database import get_db +from app.models.case import Case +from app.models.user import User +from app.schemas.case import ( + CaseListResponse, + CaseResponse, + CaseUpdate, + CodingUpdate, + ICDUpdate, +) +from app.services.audit_service import log_action +from app.services.icd_service import generate_coding_template, get_pending_icd_cases, save_icd_for_case + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +# --------------------------------------------------------------------------- +# Static routes MUST be defined BEFORE the parameterised /{case_id} routes +# to avoid FastAPI interpreting "pending-icd" etc. as a case_id. +# --------------------------------------------------------------------------- + + +@router.get("/pending-icd", response_model=CaseListResponse) +def list_pending_icd( + page: int = Query(1, ge=1), + per_page: int = Query(50, ge=1, le=200), + jahr: Optional[int] = Query(None), + fallgruppe: Optional[str] = Query(None), + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + """Return cases that still need ICD codes (icd IS NULL). + + Accessible to both admin and dak_mitarbeiter users. + """ + cases, total = get_pending_icd_cases( + db, jahr=jahr, fallgruppe=fallgruppe, page=page, per_page=per_page + ) + return CaseListResponse( + items=cases, + total=total, + page=page, + per_page=per_page, + ) + + +@router.get("/pending-coding", response_model=CaseListResponse) +def list_pending_coding( + page: int = Query(1, ge=1), + per_page: int = Query(50, ge=1, le=200), + jahr: Optional[int] = Query(None), + fallgruppe: Optional[str] = Query(None), + db: Session = Depends(get_db), + user: User = Depends(require_admin), +): + """Return cases with gutachten=True but no gutachten_typ yet (admin only). + + These cases have received a Gutachten but still need classification + (Bestätigung/Alternative) and therapy-change coding. + """ + query = db.query(Case).filter( + Case.gutachten == True, # noqa: E712 + Case.gutachten_typ == None, # noqa: E711 + ) + + if jahr: + query = query.filter(Case.jahr == jahr) + if fallgruppe: + query = query.filter(Case.fallgruppe == fallgruppe) + + total = query.count() + cases = ( + query.order_by(Case.datum.desc()) + .offset((page - 1) * per_page) + .limit(per_page) + .all() + ) + + return CaseListResponse( + items=cases, + total=total, + page=page, + per_page=per_page, + ) + + +@router.get("/coding-template") +def download_coding_template( + jahr: Optional[int] = Query(None), + fallgruppe: Optional[str] = Query(None), + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + """Download an Excel template for ICD coding. + + The template contains all cases that still need ICD codes, with an + empty ICD column for DAK users to fill in. + """ + xlsx_bytes = generate_coding_template(db, jahr=jahr, fallgruppe=fallgruppe) + + from io import BytesIO + + return StreamingResponse( + BytesIO(xlsx_bytes), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={ + "Content-Disposition": 'attachment; filename="ICD_Coding_Template.xlsx"' + }, + ) + + +# --------------------------------------------------------------------------- +# Paginated case list +# --------------------------------------------------------------------------- + + +@router.get("/", response_model=CaseListResponse) +def list_cases( + page: int = Query(1, ge=1), + per_page: int = Query(50, ge=1, le=200), + jahr: Optional[int] = Query(None), + kw: Optional[int] = Query(None), + fallgruppe: Optional[str] = Query(None), + has_icd: Optional[bool] = Query(None), + has_coding: Optional[bool] = Query(None), + search: Optional[str] = Query(None, min_length=1, max_length=200), + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + """Return a paginated, filterable list of cases. + + Accessible to both admin and dak_mitarbeiter users. + + Filters: + - ``jahr``: calendar year + - ``kw``: calendar week + - ``fallgruppe``: one of onko, kardio, intensiv, galle, sd + - ``has_icd``: True = only cases WITH icd; False = only cases WITHOUT + - ``has_coding``: True = only with gutachten_typ; False = only without + - ``search``: free-text search across nachname, vorname, fall_id, kvnr + """ + query = db.query(Case) + + if jahr is not None: + query = query.filter(Case.jahr == jahr) + if kw is not None: + query = query.filter(Case.kw == kw) + if fallgruppe is not None: + query = query.filter(Case.fallgruppe == fallgruppe) + if has_icd is True: + query = query.filter(Case.icd != None) # noqa: E711 + elif has_icd is False: + query = query.filter(Case.icd == None) # noqa: E711 + if has_coding is True: + query = query.filter(Case.gutachten_typ != None) # noqa: E711 + elif has_coding is False: + query = query.filter(Case.gutachten_typ == None) # noqa: E711 + if search: + like_pattern = f"%{search}%" + query = query.filter( + or_( + Case.nachname.ilike(like_pattern), + Case.vorname.ilike(like_pattern), + Case.fall_id.ilike(like_pattern), + Case.kvnr.ilike(like_pattern), + ) + ) + + total = query.count() + cases = ( + query.order_by(Case.datum.desc(), Case.id.desc()) + .offset((page - 1) * per_page) + .limit(per_page) + .all() + ) + + return CaseListResponse( + items=cases, + total=total, + page=page, + per_page=per_page, + ) + + +# --------------------------------------------------------------------------- +# Single case detail +# --------------------------------------------------------------------------- + + +@router.get("/{case_id}", response_model=CaseResponse) +def get_case( + case_id: int, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + """Return a single case by its database ID.""" + case = db.query(Case).filter(Case.id == case_id).first() + if not case: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Case not found", + ) + return case + + +# --------------------------------------------------------------------------- +# Update case (admin only) +# --------------------------------------------------------------------------- + + +@router.put("/{case_id}", response_model=CaseResponse) +def update_case( + case_id: int, + payload: CaseUpdate, + request: Request, + db: Session = Depends(get_db), + user: User = Depends(require_admin), +): + """Update arbitrary case fields (admin only). + + Only fields included in the request body are applied. Changes are + recorded in the audit log. + """ + case = db.query(Case).filter(Case.id == case_id).first() + if not case: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Case not found", + ) + + update_data = payload.model_dump(exclude_unset=True) + if not update_data: + return case # Nothing to update + + old_values: dict = {} + new_values: dict = {} + + for field, value in update_data.items(): + current = getattr(case, field) + # Convert date objects to strings for JSON-safe audit log + current_cmp = current + value_cmp = value + if current_cmp != value_cmp: + old_values[field] = str(current) if current is not None else None + new_values[field] = str(value) if value is not None else None + setattr(case, field, value) + + if new_values: + case.updated_by = user.id + db.commit() + db.refresh(case) + + log_action( + db, + user_id=user.id, + action="case_updated", + entity_type="case", + entity_id=case.id, + old_values=old_values, + new_values=new_values, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + + return case + + +# --------------------------------------------------------------------------- +# ICD entry +# --------------------------------------------------------------------------- + + +@router.put("/{case_id}/icd", response_model=CaseResponse) +def set_case_icd( + case_id: int, + payload: ICDUpdate, + request: Request, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + """Set ICD codes for a case. Accessible to both admin and dak_mitarbeiter. + + Replaces any existing ICD codes. The raw ICD string is validated, + normalised, and split into individual ``CaseICDCode`` entries. + """ + try: + case = save_icd_for_case(db, case_id, payload.icd, user.id) + except HTTPException: + raise + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(exc), + ) + + log_action( + db, + user_id=user.id, + action="icd_entered", + entity_type="case", + entity_id=case.id, + new_values={"icd": payload.icd}, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + + return case + + +# --------------------------------------------------------------------------- +# Coding (Gutachten classification) +# --------------------------------------------------------------------------- + + +@router.put("/{case_id}/coding", response_model=CaseResponse) +def set_case_coding( + case_id: int, + payload: CodingUpdate, + request: Request, + db: Session = Depends(get_db), + user: User = Depends(require_admin), +): + """Set coding fields on a case (admin only). + + Records the Gutachten classification (Bestätigung/Alternative) and + therapy-change flags. + """ + case = db.query(Case).filter(Case.id == case_id).first() + if not case: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Case not found", + ) + + # Validate gutachten_typ + if payload.gutachten_typ not in ("Bestätigung", "Alternative"): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="gutachten_typ must be 'Bestätigung' or 'Alternative'", + ) + + # Validate therapieaenderung + if payload.therapieaenderung not in ("Ja", "Nein"): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="therapieaenderung must be 'Ja' or 'Nein'", + ) + + old_values = { + "gutachten_typ": case.gutachten_typ, + "therapieaenderung": case.therapieaenderung, + "ta_diagnosekorrektur": case.ta_diagnosekorrektur, + "ta_unterversorgung": case.ta_unterversorgung, + "ta_uebertherapie": case.ta_uebertherapie, + } + + case.gutachten_typ = payload.gutachten_typ + case.therapieaenderung = payload.therapieaenderung + case.ta_diagnosekorrektur = payload.ta_diagnosekorrektur + case.ta_unterversorgung = payload.ta_unterversorgung + case.ta_uebertherapie = payload.ta_uebertherapie + case.coding_completed_by = user.id + case.coding_completed_at = datetime.now(timezone.utc) + case.updated_by = user.id + + db.commit() + db.refresh(case) + + new_values = { + "gutachten_typ": case.gutachten_typ, + "therapieaenderung": case.therapieaenderung, + "ta_diagnosekorrektur": case.ta_diagnosekorrektur, + "ta_unterversorgung": case.ta_unterversorgung, + "ta_uebertherapie": case.ta_uebertherapie, + } + + log_action( + db, + user_id=user.id, + action="coding_completed", + entity_type="case", + entity_id=case.id, + old_values=old_values, + new_values=new_values, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + + return case diff --git a/backend/app/api/import_router.py b/backend/app/api/import_router.py new file mode 100644 index 0000000..ff52ee3 --- /dev/null +++ b/backend/app/api/import_router.py @@ -0,0 +1,268 @@ +"""Import API routes — CSV upload/preview/confirm, ICD xlsx upload, import log.""" + +import logging +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.core.dependencies import get_current_user, require_admin +from app.database import get_db +from app.models.audit import ImportLog +from app.models.user import User +from app.schemas.import_schemas import ImportPreview, ImportResult +from app.services.csv_parser import parse_csv +from app.services.icd_service import import_icd_from_xlsx +from app.services.import_service import confirm_import, preview_import + +logger = logging.getLogger(__name__) +settings = get_settings() + +router = APIRouter() + + +# --------------------------------------------------------------------------- +# Response schemas (co-located for simplicity) +# --------------------------------------------------------------------------- + + +class ICDImportResponse(BaseModel): + """Result of importing ICD codes from an xlsx file.""" + + updated: int + errors: list[str] = [] + + +class ImportLogResponse(BaseModel): + """Single import-log entry.""" + + id: int + filename: str + import_type: str + cases_imported: int + cases_skipped: int + cases_updated: int + errors: Optional[str] = None + imported_by: Optional[int] = None + imported_at: datetime + + model_config = {"from_attributes": True} + + +class ImportLogListResponse(BaseModel): + """Paginated import-log response.""" + + items: list[ImportLogResponse] + total: int + page: int + per_page: int + + +# --------------------------------------------------------------------------- +# CSV Import — preview (dry-run) +# --------------------------------------------------------------------------- + + +@router.post("/csv", response_model=ImportPreview) +async def upload_csv_preview( + file: UploadFile = File(...), + db: Session = Depends(get_db), + user: User = Depends(require_admin), +): + """Upload a CRM CSV file and preview what will be imported. + + Returns a list of rows with duplicate detection. No data is written + to the database — the caller must confirm via ``POST /csv/confirm``. + """ + if not file.filename or not file.filename.lower().endswith(".csv"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="File must be a .csv file", + ) + + content = await file.read() + if len(content) > settings.MAX_UPLOAD_SIZE: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail=f"File exceeds maximum size of {settings.MAX_UPLOAD_SIZE // (1024 * 1024)}MB", + ) + + try: + parsed = parse_csv(content, file.filename) + except Exception as exc: + logger.exception("CSV parsing failed for %s", file.filename) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"CSV parsing failed: {exc}", + ) + + return preview_import(db, parsed, file.filename) + + +# --------------------------------------------------------------------------- +# CSV Import — confirm (write to DB) +# --------------------------------------------------------------------------- + + +@router.post("/csv/confirm", response_model=ImportResult) +async def confirm_csv_import( + file: UploadFile = File(...), + db: Session = Depends(get_db), + user: User = Depends(require_admin), +): + """Re-upload the same CSV file to confirm the import. + + The file is re-parsed and non-duplicate cases are inserted into the + database. An entry is written to the ``import_log`` table. + """ + if not file.filename or not file.filename.lower().endswith(".csv"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="File must be a .csv file", + ) + + content = await file.read() + if len(content) > settings.MAX_UPLOAD_SIZE: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail=f"File exceeds maximum size of {settings.MAX_UPLOAD_SIZE // (1024 * 1024)}MB", + ) + + try: + parsed = parse_csv(content, file.filename) + except Exception as exc: + logger.exception("CSV parsing failed for %s", file.filename) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"CSV parsing failed: {exc}", + ) + + return confirm_import(db, parsed, file.filename, user.id) + + +# --------------------------------------------------------------------------- +# ICD Excel upload +# --------------------------------------------------------------------------- + + +@router.post("/icd-xlsx", response_model=ICDImportResponse) +async def upload_icd_xlsx( + file: UploadFile = File(...), + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + """Upload a filled-in ICD coding Excel template. + + Expects columns: Case_ID (col 1), ICD (col 7). Accessible to both + admin and dak_mitarbeiter users. + """ + if not file.filename or not file.filename.lower().endswith((".xlsx", ".xls")): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="File must be an Excel file (.xlsx)", + ) + + content = await file.read() + if len(content) > settings.MAX_UPLOAD_SIZE: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail=f"File exceeds maximum size of {settings.MAX_UPLOAD_SIZE // (1024 * 1024)}MB", + ) + + try: + result = import_icd_from_xlsx(db, content, user.id) + except Exception as exc: + logger.exception("ICD xlsx import failed for %s", file.filename) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"ICD import failed: {exc}", + ) + + # Log the import + log = ImportLog( + filename=file.filename, + import_type="icd_xlsx", + cases_imported=0, + cases_skipped=0, + cases_updated=result["updated"], + errors="; ".join(result["errors"]) if result["errors"] else None, + imported_by=user.id, + ) + db.add(log) + db.commit() + + return ICDImportResponse(**result) + + +# --------------------------------------------------------------------------- +# Historical Excel upload (placeholder — full logic in Task 12) +# --------------------------------------------------------------------------- + + +@router.post("/historical", response_model=ImportResult) +async def upload_historical_excel( + file: UploadFile = File(...), + db: Session = Depends(get_db), + user: User = Depends(require_admin), +): + """Upload the Abrechnung_DAK.xlsx for historical data import (admin only). + + This is a placeholder endpoint. The full import logic will be + implemented in Task 12. + """ + if not file.filename or not file.filename.lower().endswith((".xlsx", ".xls")): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="File must be an Excel file (.xlsx)", + ) + + content = await file.read() + if len(content) > settings.MAX_UPLOAD_SIZE: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail=f"File exceeds maximum size of {settings.MAX_UPLOAD_SIZE // (1024 * 1024)}MB", + ) + + # TODO: Implement historical import in Task 12 + raise HTTPException( + status_code=status.HTTP_501_NOT_IMPLEMENTED, + detail="Historical Excel import is not yet implemented", + ) + + +# --------------------------------------------------------------------------- +# Import log (history) +# --------------------------------------------------------------------------- + + +@router.get("/log", response_model=ImportLogListResponse) +def get_import_log( + page: int = Query(1, ge=1), + per_page: int = Query(20, ge=1, le=100), + import_type: Optional[str] = Query(None), + db: Session = Depends(get_db), + user: User = Depends(require_admin), +): + """Return a paginated list of past imports (admin only).""" + query = db.query(ImportLog) + + if import_type: + query = query.filter(ImportLog.import_type == import_type) + + total = query.count() + items = ( + query.order_by(ImportLog.imported_at.desc()) + .offset((page - 1) * per_page) + .limit(per_page) + .all() + ) + + return ImportLogListResponse( + items=items, + total=total, + page=page, + per_page=per_page, + ) diff --git a/backend/app/schemas/case.py b/backend/app/schemas/case.py new file mode 100644 index 0000000..8cec581 --- /dev/null +++ b/backend/app/schemas/case.py @@ -0,0 +1,125 @@ +"""Pydantic schemas for case list, detail, update, ICD, and coding endpoints.""" + +from datetime import date, datetime +from typing import Optional + +from pydantic import BaseModel + + +class CaseResponse(BaseModel): + """Full representation of a case returned by list and detail endpoints.""" + + id: int + fall_id: Optional[str] = None + crm_ticket_id: Optional[str] = None + jahr: int + kw: int + datum: date + anrede: Optional[str] = None + vorname: Optional[str] = None + nachname: str + geburtsdatum: Optional[date] = None + kvnr: Optional[str] = None + versicherung: str + icd: Optional[str] = None + fallgruppe: str + strasse: Optional[str] = None + plz: Optional[str] = None + ort: Optional[str] = None + email: Optional[str] = None + ansprechpartner: Optional[str] = None + telefonnummer: Optional[str] = None + mobiltelefon: Optional[str] = None + unterlagen: bool + unterlagen_verschickt: Optional[date] = None + erhalten: Optional[bool] = None + unterlagen_erhalten: Optional[date] = None + unterlagen_an_gutachter: Optional[date] = None + gutachten: bool + gutachter: Optional[str] = None + gutachten_erstellt: Optional[date] = None + gutachten_versendet: Optional[date] = None + schweigepflicht: bool + ablehnung: bool + abbruch: bool + abbruch_datum: Optional[date] = None + gutachten_typ: Optional[str] = None + therapieaenderung: Optional[str] = None + ta_diagnosekorrektur: bool + ta_unterversorgung: bool + ta_uebertherapie: bool + kurzbeschreibung: Optional[str] = None + fragestellung: Optional[str] = None + kommentar: Optional[str] = None + sonstiges: Optional[str] = None + abgerechnet: bool + abrechnung_datum: Optional[date] = None + import_source: Optional[str] = None + imported_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class CaseListResponse(BaseModel): + """Paginated case list response.""" + + items: list[CaseResponse] + total: int + page: int + per_page: int + + +class CaseUpdate(BaseModel): + """Partial update for case fields (admin only). + + All fields are optional; only provided fields are applied. + """ + + anrede: Optional[str] = None + vorname: Optional[str] = None + nachname: Optional[str] = None + geburtsdatum: Optional[date] = None + kvnr: Optional[str] = None + strasse: Optional[str] = None + plz: Optional[str] = None + ort: Optional[str] = None + email: Optional[str] = None + ansprechpartner: Optional[str] = None + telefonnummer: Optional[str] = None + mobiltelefon: Optional[str] = None + unterlagen: Optional[bool] = None + unterlagen_verschickt: Optional[date] = None + erhalten: Optional[bool] = None + unterlagen_erhalten: Optional[date] = None + unterlagen_an_gutachter: Optional[date] = None + gutachten: Optional[bool] = None + gutachter: Optional[str] = None + gutachten_erstellt: Optional[date] = None + gutachten_versendet: Optional[date] = None + schweigepflicht: Optional[bool] = None + ablehnung: Optional[bool] = None + abbruch: Optional[bool] = None + abbruch_datum: Optional[date] = None + kurzbeschreibung: Optional[str] = None + fragestellung: Optional[str] = None + kommentar: Optional[str] = None + sonstiges: Optional[str] = None + abgerechnet: Optional[bool] = None + abrechnung_datum: Optional[date] = None + + +class ICDUpdate(BaseModel): + """Payload for setting ICD codes on a case.""" + + icd: str + + +class CodingUpdate(BaseModel): + """Payload for setting coding/gutachten classification on a case.""" + + gutachten_typ: str # "Bestätigung" or "Alternative" + therapieaenderung: str # "Ja" or "Nein" + ta_diagnosekorrektur: bool = False + ta_unterversorgung: bool = False + ta_uebertherapie: bool = False