From 6f6a721973a97a1d8ebcadf87d1f14ac2b8683f9 Mon Sep 17 00:00:00 2001 From: CCS Admin Date: Fri, 27 Feb 2026 13:28:17 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20add=20Wochen=C3=BCbersicht=20export=20+?= =?UTF-8?q?=20ICD=20import=20auto-detect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New Excel export service for weekly DAK summary sheets (c2s / c2s_g_s variants) - New API endpoint GET /reports/wochenuebersicht (admin-only) - ICD import auto-detects format (coding template vs. Wochenübersicht KVNR-based) - New admin frontend page with download form - Route + sidebar navigation entry Co-Authored-By: Claude Opus 4.6 --- backend/app/api/reports.py | 76 +++++++ backend/app/services/icd_service.py | 100 ++++++++- .../app/services/wochenuebersicht_export.py | 193 ++++++++++++++++++ frontend/src/App.tsx | 2 + frontend/src/components/layout/Sidebar.tsx | 2 + frontend/src/pages/WochenuebersichtPage.tsx | 177 ++++++++++++++++ 6 files changed, 548 insertions(+), 2 deletions(-) create mode 100644 backend/app/services/wochenuebersicht_export.py create mode 100644 frontend/src/pages/WochenuebersichtPage.tsx diff --git a/backend/app/api/reports.py b/backend/app/api/reports.py index 50cc326..e94ec0a 100644 --- a/backend/app/api/reports.py +++ b/backend/app/api/reports.py @@ -2,6 +2,7 @@ import logging import os +from collections import defaultdict from datetime import date from io import BytesIO @@ -283,6 +284,81 @@ def delete_reports( return {"deleted": deleted} +@router.get("/wochenuebersicht") +def download_wochenuebersicht( + export_type: str = Query(...), + jahr: int = Query(...), + kw_von: int = Query(...), + kw_bis: int = Query(...), + db: Session = Depends(get_db), + user: User = Depends(require_admin), +): + """Generate and download a Wochenübersicht Excel file for DAK. + + Admin only. Returns an .xlsx file with weekly case summaries grouped by KW. + """ + from app.config import get_settings + from app.models.case import Case + from app.services.wochenuebersicht_export import ( + WOCHENUEBERSICHT_TYPES, + generate_wochenuebersicht_xlsx, + ) + + settings = get_settings() + + if export_type not in WOCHENUEBERSICHT_TYPES: + raise HTTPException(422, f"Unknown export_type: {export_type}") + if not (1 <= kw_von <= 53 and 1 <= kw_bis <= 53): + raise HTTPException(422, "kw_von and kw_bis must be between 1 and 53") + if kw_von > kw_bis: + raise HTTPException(422, "kw_von must be <= kw_bis") + + type_cfg = WOCHENUEBERSICHT_TYPES[export_type] + fallgruppen = type_cfg["fallgruppen"] + + cases = ( + db.query(Case) + .filter( + Case.jahr == jahr, + Case.kw >= kw_von, + Case.kw <= kw_bis, + Case.fallgruppe.in_(fallgruppen), + Case.versicherung == settings.VERSICHERUNG_FILTER, + ) + .order_by(Case.kw, Case.datum) + .all() + ) + + cases_by_kw: dict[int, list[Case]] = defaultdict(list) + for case in cases: + cases_by_kw[case.kw].append(case) + + xlsx_bytes = generate_wochenuebersicht_xlsx(cases_by_kw, export_type, jahr) + + infix = type_cfg["filename_infix"] + filename = f"Wochenübersicht {infix} KW{kw_bis:02d}{jahr % 100:02d}.xlsx" + + log_action( + db, + user_id=user.id, + action="wochenuebersicht_downloaded", + entity_type="report", + new_values={ + "export_type": export_type, + "jahr": jahr, + "kw_von": kw_von, + "kw_bis": kw_bis, + "case_count": len(cases), + }, + ) + + return StreamingResponse( + BytesIO(xlsx_bytes), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + @router.get("/list", response_model=ReportListResponse) def list_reports( db: Session = Depends(get_db), diff --git a/backend/app/services/icd_service.py b/backend/app/services/icd_service.py index 36b4708..721a71c 100644 --- a/backend/app/services/icd_service.py +++ b/backend/app/services/icd_service.py @@ -143,14 +143,50 @@ def generate_coding_template( def import_icd_from_xlsx(db: Session, content: bytes, user_id: int) -> dict: - """Import ICD codes from a filled-in coding template Excel file. + """Import ICD codes from an Excel file. + + Auto-detects the format: + - Coding template: Column A contains numeric Case_IDs + - Wochenübersicht: Column A contains KVNR strings (letter prefix) - Expects columns: Case_ID (col 1), ICD (last col — col 5 or col 7) Returns: {"updated": int, "errors": list[str]} """ wb = load_workbook(BytesIO(content), read_only=True) ws = wb.active + # Detect format by scanning for first data row + detected_format = _detect_import_format(ws) + + if detected_format == "wochenuebersicht": + return _import_icd_wochenuebersicht(db, ws, user_id) + + # Default: Coding template (Case_ID-based) + return _import_icd_coding_template(db, ws, user_id) + + +def _detect_import_format(ws) -> str: + """Detect whether the worksheet is a coding template or Wochenübersicht.""" + for row in ws.iter_rows(min_row=1, max_row=20, values_only=True): + if not row or not row[0]: + continue + val = str(row[0]).strip() + # Skip header rows + if val.lower() in ("case_id", "kvnr", "kalenderwoche:"): + continue + # If it's a pure integer, it's a coding template (Case_ID) + try: + int(val) + return "coding_template" + except (ValueError, TypeError): + pass + # If it starts with a letter, it's likely a KVNR → Wochenübersicht + if val and val[0].isalpha(): + return "wochenuebersicht" + return "coding_template" + + +def _import_icd_coding_template(db: Session, ws, user_id: int) -> dict: + """Import ICD codes from a coding template (Case_ID in column A).""" updated = 0 errors: list[str] = [] @@ -180,3 +216,63 @@ def import_icd_from_xlsx(db: Session, content: bytes, user_id: int) -> dict: errors.append(f"Case {case_id}: {e}") return {"updated": updated, "errors": errors} + + +def _import_icd_wochenuebersicht(db: Session, ws, user_id: int) -> dict: + """Import ICD codes from a Wochenübersicht file (KVNR in col A, ICD in col I).""" + updated = 0 + errors: list[str] = [] + + for row in ws.iter_rows(values_only=False): + # Skip rows without enough columns + if len(row) < 9: + continue + + col_a = row[0].value + if not col_a: + continue + + col_a_str = str(col_a).strip() + + # Skip header/label rows + if col_a_str.lower() in ("kvnr", ""): + continue + # Skip non-data rows (e.g. "Kalenderwoche:", instruction text) + if not col_a_str[0].isalpha() and not col_a_str[0].isdigit(): + continue + # Skip rows where col A looks like a header label + if col_a_str.lower().startswith(("bitte", "kalender")): + continue + + kvnr = col_a_str + + # ICD is in column I (index 8) + icd_cell = row[8].value + if not icd_cell: + continue + icd_value = str(icd_cell).strip() + if not icd_value or icd_value.lower() == "icd-10": + continue + + # Find case by KVNR — take the most recent one + case = ( + db.query(Case) + .filter( + Case.kvnr == kvnr, + Case.versicherung == settings.VERSICHERUNG_FILTER, + ) + .order_by(Case.datum.desc()) + .first() + ) + + if not case: + errors.append(f"KVNR {kvnr}: Kein Fall gefunden") + continue + + try: + save_icd_for_case(db, case.id, icd_value, user_id) + updated += 1 + except Exception as e: + errors.append(f"KVNR {kvnr} (Case {case.id}): {e}") + + return {"updated": updated, "errors": errors} diff --git a/backend/app/services/wochenuebersicht_export.py b/backend/app/services/wochenuebersicht_export.py new file mode 100644 index 0000000..5fe07b1 --- /dev/null +++ b/backend/app/services/wochenuebersicht_export.py @@ -0,0 +1,193 @@ +"""Wochenübersicht Excel export for DAK weekly summary sheets. + +Generates .xlsx files matching the format sent to DAK for ICD code entry. +Two variants exist: + - "c2s" → Fallgruppen onko + intensiv (columns ZMO / ZMI) + - "c2s_g_s" → Fallgruppen galle + sd (columns Galle / Schild) + +Each KW block has 4 header rows + data rows + 2 blank separator rows. +KWs are sorted descending (newest first). +""" + +from __future__ import annotations + +from datetime import date +from io import BytesIO +from typing import Any + +from openpyxl import Workbook +from openpyxl.styles import Alignment, Border, Font, PatternFill, Side + +from app.services.excel_export import HEADER_FILL, HEADER_FONT + +# --------------------------------------------------------------------------- +# Type configuration +# --------------------------------------------------------------------------- + +WOCHENUEBERSICHT_TYPES: dict[str, dict[str, Any]] = { + "c2s": { + "fallgruppen": ("onko", "intensiv"), + "fg_labels": ("ZMO", "ZMI"), + "filename_infix": "c2s", + }, + "c2s_g_s": { + "fallgruppen": ("galle", "sd"), + "fg_labels": ("Galle", "Schild"), + "filename_infix": "c2s_g_s", + }, +} + +# Column headers (row 4 of each KW block) +COL_HEADERS = [ + "KVNR", # A + "Datum", # B + "Erstgespräch", # C + "Zweitmeinung/Vorbereitung (bei Abbruch)", # D + "Zweitmeinung/Vorbereitung + Erteilung", # E + "Schriftliche Dokumentation (Gutachten)", # F + # G and H are dynamic (FG labels) + # I = ICD-10 +] + +THIN_BORDER = Border( + left=Side(style="thin"), + right=Side(style="thin"), + top=Side(style="thin"), + bottom=Side(style="thin"), +) + + +def _kw_date_range(jahr: int, kw: int) -> tuple[date, date]: + """Return (Monday, Sunday) for the given ISO year/week.""" + monday = date.fromisocalendar(jahr, kw, 1) + sunday = date.fromisocalendar(jahr, kw, 7) + return monday, sunday + + +def _format_date_range(monday: date, sunday: date) -> str: + """Format as 'DD.MM. - DD.MM.YYYY'.""" + return f"{monday.strftime('%d.%m.')} - {sunday.strftime('%d.%m.%Y')}" + + +def generate_wochenuebersicht_xlsx( + cases_by_kw: dict[int, list[Any]], + export_type: str, + jahr: int, +) -> bytes: + """Generate a Wochenübersicht Excel workbook. + + Args: + cases_by_kw: Cases grouped by calendar week number. + export_type: One of ``"c2s"`` or ``"c2s_g_s"``. + jahr: The report year. + + Returns: + The ``.xlsx`` file contents as bytes. + """ + type_cfg = WOCHENUEBERSICHT_TYPES[export_type] + fg1, fg2 = type_cfg["fallgruppen"] + fg1_label, fg2_label = type_cfg["fg_labels"] + + wb = Workbook() + ws = wb.active + ws.title = "Wochenübersicht" + + # Column widths + ws.column_dimensions["A"].width = 14 + ws.column_dimensions["B"].width = 12 + ws.column_dimensions["C"].width = 14 + ws.column_dimensions["D"].width = 18 + ws.column_dimensions["E"].width = 18 + ws.column_dimensions["F"].width = 16 + ws.column_dimensions["G"].width = 10 + ws.column_dimensions["H"].width = 10 + ws.column_dimensions["I"].width = 14 + + current_row = 1 + + # Sort KWs descending (newest first) + sorted_kws = sorted(cases_by_kw.keys(), reverse=True) + + for kw in sorted_kws: + cases = cases_by_kw[kw] + monday, sunday = _kw_date_range(jahr, kw) + + # --- Row 1: "Kalenderwoche:" label + number --- + ws.cell(row=current_row, column=5, value="Kalenderwoche:") + ws.cell(row=current_row, column=5).font = HEADER_FONT + ws.cell(row=current_row, column=6, value=kw) + ws.cell(row=current_row, column=6).font = HEADER_FONT + current_row += 1 + + # --- Row 2: Date range --- + ws.cell(row=current_row, column=5, value=_format_date_range(monday, sunday)) + ws.cell(row=current_row, column=5).font = Font(italic=True) + current_row += 1 + + # --- Row 3: Instruction --- + ws.cell( + row=current_row, + column=3, + value="Bitte entsprechendes Feld mit x kennzeichnen", + ) + ws.cell(row=current_row, column=3).font = Font(italic=True, color="FF808080") + current_row += 1 + + # --- Row 4: Column headers --- + headers = COL_HEADERS + [fg1_label, fg2_label, "ICD-10"] + for ci, header in enumerate(headers, start=1): + cell = ws.cell(row=current_row, column=ci, value=header) + cell.fill = HEADER_FILL + cell.font = HEADER_FONT + cell.border = THIN_BORDER + cell.alignment = Alignment(wrap_text=True, vertical="center") + current_row += 1 + + # --- Data rows --- + # Sort cases by datum ascending within each KW + sorted_cases = sorted(cases, key=lambda c: (c.datum or date.min)) + + for case in sorted_cases: + # A: KVNR + ws.cell(row=current_row, column=1, value=case.kvnr or "") + # B: Datum + ws.cell( + row=current_row, + column=2, + value=case.datum.strftime("%d.%m.%Y") if case.datum else "", + ) + # C: Erstgespräch — always x + ws.cell(row=current_row, column=3, value="x") + # D: Zweitmeinung/Vorbereitung (bei Abbruch) + if case.ablehnung or case.abbruch: + ws.cell(row=current_row, column=4, value="x") + # E: Zweitmeinung/Vorbereitung + Erteilung + if case.unterlagen and not case.ablehnung and not case.abbruch: + ws.cell(row=current_row, column=5, value="x") + # F: Schriftliche Dokumentation (Gutachten) + if case.gutachten: + ws.cell(row=current_row, column=6, value="x") + # G: FG1 (ZMO / Galle) + if case.fallgruppe == fg1: + ws.cell(row=current_row, column=7, value="x") + # H: FG2 (ZMI / Schild) + if case.fallgruppe == fg2: + ws.cell(row=current_row, column=8, value="x") + # I: ICD-10 + ws.cell(row=current_row, column=9, value=case.icd or "") + + # Apply thin borders to data row + for ci in range(1, 10): + ws.cell(row=current_row, column=ci).border = THIN_BORDER + ws.cell(row=current_row, column=ci).alignment = Alignment( + horizontal="center" if ci >= 3 else "left" + ) + + current_row += 1 + + # --- 2 blank separator rows --- + current_row += 2 + + buf = BytesIO() + wb.save(buf) + return buf.getvalue() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c9347b6..d10e539 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,6 +18,7 @@ import { DisclosuresPage } from '@/pages/DisclosuresPage' import { AccountPage } from '@/pages/AccountPage' import { GutachtenStatistikPage } from '@/pages/GutachtenStatistikPage' import { MyDisclosuresPage } from '@/pages/MyDisclosuresPage' +import { WochenuebersichtPage } from '@/pages/WochenuebersichtPage' const queryClient = new QueryClient({ defaultOptions: { @@ -45,6 +46,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index dec7964..f73c6cb 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -10,6 +10,7 @@ import { FileEdit, ClipboardCheck, FileBarChart, + FileOutput, Users, Mail, History, @@ -32,6 +33,7 @@ const mainNavItems: NavItem[] = [ { label: 'ICD-Eingabe', to: '/icd', icon: FileEdit, mitarbeiterOnly: true }, { label: 'Coding', to: '/coding', icon: ClipboardCheck, adminOnly: true }, { label: 'Berichte', to: '/reports', icon: FileBarChart }, + { label: 'Wochenübersicht', to: '/wochenuebersicht', icon: FileOutput, adminOnly: true }, ] const accountNavItems: NavItem[] = [ diff --git a/frontend/src/pages/WochenuebersichtPage.tsx b/frontend/src/pages/WochenuebersichtPage.tsx new file mode 100644 index 0000000..8e1e79a --- /dev/null +++ b/frontend/src/pages/WochenuebersichtPage.tsx @@ -0,0 +1,177 @@ +import { useState } from 'react' +import { Download, FileSpreadsheet, Loader2 } from 'lucide-react' +import api from '@/services/api' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from '@/components/ui/select' +import { Alert, AlertDescription } from '@/components/ui/alert' + +const EXPORT_TYPES = [ + { value: 'c2s', label: 'Onko-Intensiv (c2s)' }, + { value: 'c2s_g_s', label: 'Galle-Schild (c2s_g_s)' }, +] as const + +function getISOWeek(d: Date): number { + const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())) + const dayNum = date.getUTCDay() || 7 + date.setUTCDate(date.getUTCDate() + 4 - dayNum) + const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1)) + return Math.ceil(((date.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7) +} + +export function WochenuebersichtPage() { + const currentYear = new Date().getFullYear() + const currentKw = getISOWeek(new Date()) + + const [jahr, setJahr] = useState(currentYear) + const [kwVon, setKwVon] = useState(1) + const [kwBis, setKwBis] = useState(currentKw) + const [exportType, setExportType] = useState('c2s') + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [success, setSuccess] = useState('') + + const handleDownload = async () => { + setError('') + setSuccess('') + + if (kwVon > kwBis) { + setError('KW von darf nicht größer als KW bis sein.') + return + } + + setLoading(true) + try { + const res = await api.get('/reports/wochenuebersicht', { + params: { export_type: exportType, jahr, kw_von: kwVon, kw_bis: kwBis }, + responseType: 'blob', + }) + + const blob = new Blob([res.data as BlobPart], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }) + const blobUrl = window.URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = blobUrl + + const contentDisposition = res.headers['content-disposition'] + let filename = `Wochenuebersicht_${exportType}_KW${kwBis}_${jahr}.xlsx` + if (contentDisposition) { + const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/) + if (match) filename = match[1].replace(/['"]/g, '') + } + + link.download = filename + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + window.URL.revokeObjectURL(blobUrl) + setSuccess(`Wochenübersicht KW ${kwVon}–${kwBis}/${jahr} wurde heruntergeladen.`) + } catch { + setError('Fehler beim Herunterladen der Wochenübersicht.') + } finally { + setLoading(false) + } + } + + return ( +
+

Wochenübersicht

+ + + + + + Wochenübersicht für die DAK generieren + + + +

+ Erstellt eine Excel-Datei mit den Falldaten pro Kalenderwoche, + die an die DAK zur ICD-Code-Eingabe gesendet wird. +

+ +
+
+ + setJahr(Number(e.target.value))} + className="w-28" + min={2020} + max={2030} + /> +
+
+ + setKwVon(Number(e.target.value))} + className="w-24" + min={1} + max={53} + /> +
+
+ + setKwBis(Number(e.target.value))} + className="w-24" + min={1} + max={53} + /> +
+
+ + +
+ +
+ + {error && ( + + {error} + + )} + {success && ( + + {success} + + )} +
+
+
+ ) +}