feat: add Wochenübersicht export + ICD import auto-detect

- 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 <noreply@anthropic.com>
This commit is contained in:
CCS Admin 2026-02-27 13:28:17 +00:00
parent 48939f01dd
commit 6f6a721973
6 changed files with 548 additions and 2 deletions

View file

@ -2,6 +2,7 @@
import logging import logging
import os import os
from collections import defaultdict
from datetime import date from datetime import date
from io import BytesIO from io import BytesIO
@ -283,6 +284,81 @@ def delete_reports(
return {"deleted": deleted} 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) @router.get("/list", response_model=ReportListResponse)
def list_reports( def list_reports(
db: Session = Depends(get_db), db: Session = Depends(get_db),

View file

@ -143,14 +143,50 @@ def generate_coding_template(
def import_icd_from_xlsx(db: Session, content: bytes, user_id: int) -> dict: 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]} Returns: {"updated": int, "errors": list[str]}
""" """
wb = load_workbook(BytesIO(content), read_only=True) wb = load_workbook(BytesIO(content), read_only=True)
ws = wb.active 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 updated = 0
errors: list[str] = [] 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}") errors.append(f"Case {case_id}: {e}")
return {"updated": updated, "errors": errors} 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}

View file

@ -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()

View file

@ -18,6 +18,7 @@ import { DisclosuresPage } from '@/pages/DisclosuresPage'
import { AccountPage } from '@/pages/AccountPage' import { AccountPage } from '@/pages/AccountPage'
import { GutachtenStatistikPage } from '@/pages/GutachtenStatistikPage' import { GutachtenStatistikPage } from '@/pages/GutachtenStatistikPage'
import { MyDisclosuresPage } from '@/pages/MyDisclosuresPage' import { MyDisclosuresPage } from '@/pages/MyDisclosuresPage'
import { WochenuebersichtPage } from '@/pages/WochenuebersichtPage'
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@ -45,6 +46,7 @@ function App() {
<Route path="icd" element={<IcdPage />} /> <Route path="icd" element={<IcdPage />} />
<Route path="coding" element={<ProtectedRoute requireAdmin><CodingPage /></ProtectedRoute>} /> <Route path="coding" element={<ProtectedRoute requireAdmin><CodingPage /></ProtectedRoute>} />
<Route path="reports" element={<ReportsPage />} /> <Route path="reports" element={<ReportsPage />} />
<Route path="wochenuebersicht" element={<ProtectedRoute requireAdmin><WochenuebersichtPage /></ProtectedRoute>} />
<Route path="gutachten-statistik" element={<GutachtenStatistikPage />} /> <Route path="gutachten-statistik" element={<GutachtenStatistikPage />} />
<Route path="account" element={<AccountPage />} /> <Route path="account" element={<AccountPage />} />
<Route path="disclosures" element={<MyDisclosuresPage />} /> <Route path="disclosures" element={<MyDisclosuresPage />} />

View file

@ -10,6 +10,7 @@ import {
FileEdit, FileEdit,
ClipboardCheck, ClipboardCheck,
FileBarChart, FileBarChart,
FileOutput,
Users, Users,
Mail, Mail,
History, History,
@ -32,6 +33,7 @@ const mainNavItems: NavItem[] = [
{ label: 'ICD-Eingabe', to: '/icd', icon: FileEdit, mitarbeiterOnly: true }, { label: 'ICD-Eingabe', to: '/icd', icon: FileEdit, mitarbeiterOnly: true },
{ label: 'Coding', to: '/coding', icon: ClipboardCheck, adminOnly: true }, { label: 'Coding', to: '/coding', icon: ClipboardCheck, adminOnly: true },
{ label: 'Berichte', to: '/reports', icon: FileBarChart }, { label: 'Berichte', to: '/reports', icon: FileBarChart },
{ label: 'Wochenübersicht', to: '/wochenuebersicht', icon: FileOutput, adminOnly: true },
] ]
const accountNavItems: NavItem[] = [ const accountNavItems: NavItem[] = [

View file

@ -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 (
<div className="p-6 space-y-6">
<h1 className="text-2xl font-bold">Wochenübersicht</h1>
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<FileSpreadsheet className="h-5 w-5" />
Wochenübersicht für die DAK generieren
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Erstellt eine Excel-Datei mit den Falldaten pro Kalenderwoche,
die an die DAK zur ICD-Code-Eingabe gesendet wird.
</p>
<div className="flex flex-wrap items-end gap-4">
<div className="space-y-1">
<Label htmlFor="wu-jahr">Jahr</Label>
<Input
id="wu-jahr"
type="number"
value={jahr}
onChange={(e) => setJahr(Number(e.target.value))}
className="w-28"
min={2020}
max={2030}
/>
</div>
<div className="space-y-1">
<Label htmlFor="wu-kw-von">KW von</Label>
<Input
id="wu-kw-von"
type="number"
value={kwVon}
onChange={(e) => setKwVon(Number(e.target.value))}
className="w-24"
min={1}
max={53}
/>
</div>
<div className="space-y-1">
<Label htmlFor="wu-kw-bis">KW bis</Label>
<Input
id="wu-kw-bis"
type="number"
value={kwBis}
onChange={(e) => setKwBis(Number(e.target.value))}
className="w-24"
min={1}
max={53}
/>
</div>
<div className="space-y-1">
<Label>Exporttyp</Label>
<Select value={exportType} onValueChange={setExportType}>
<SelectTrigger className="w-52">
<SelectValue />
</SelectTrigger>
<SelectContent>
{EXPORT_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button onClick={handleDownload} disabled={loading}>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Wird generiert...
</>
) : (
<>
<Download className="mr-2 h-4 w-4" />
Herunterladen
</>
)}
</Button>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{success && (
<Alert>
<AlertDescription>{success}</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
</div>
)
}