feat: add Onko-Intensiv and Galle-Schild report types

Adds report_type support across the full stack:
- Backend: REPORT_TYPES mapping, fallgruppen filter in all 5 sheet
  calculations, dynamic Excel columns, report_type DB column with
  Alembic migration 007
- Frontend: report type dropdown in generation form, type column in
  reports table, dynamic fallgruppen in ReportViewer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
CCS Admin 2026-02-27 12:47:59 +00:00
parent 3bbf5bf51a
commit 48939f01dd
11 changed files with 235 additions and 84 deletions

View file

@ -0,0 +1,35 @@
"""Add report_type column to weekly_reports.
Revision ID: 007_add_report_type
Revises: 006_anonymize_fall_ids
"""
from alembic import op
import sqlalchemy as sa
revision = "007_add_report_type"
down_revision = "006_anonymize_fall_ids"
branch_labels = None
depends_on = None
def upgrade():
# Add report_type column with default 'gesamt'
op.add_column(
"weekly_reports",
sa.Column("report_type", sa.String(50), nullable=False, server_default="gesamt"),
)
# Drop old unique constraint (jahr, kw)
op.drop_constraint("uk_jahr_kw", "weekly_reports", type_="unique")
# Add new unique constraint (jahr, kw, report_type)
op.create_unique_constraint(
"uk_jahr_kw_type", "weekly_reports", ["jahr", "kw", "report_type"]
)
def downgrade():
op.drop_constraint("uk_jahr_kw_type", "weekly_reports", type_="unique")
op.drop_column("weekly_reports", "report_type")
op.create_unique_constraint("uk_jahr_kw", "weekly_reports", ["jahr", "kw"])

View file

@ -80,14 +80,21 @@ def weekly_report(
def generate_report(
jahr: int | None = Query(None),
kw: int | None = Query(None),
report_type: str = Query("gesamt"),
db: Session = Depends(get_db),
user: User = Depends(require_admin),
):
"""Generate a full Berichtswesen Excel report and persist it to disk + DB.
Admin only. Defaults to the current ISO year/week if not specified.
Depends on report_service, excel_export, and vorjahr_service (parallel tasks).
Accepts *report_type* to generate filtered reports (``onko_intensiv``,
``galle_schild``, or the default ``gesamt``).
"""
from app.services.report_service import REPORT_TYPES, REPORT_TYPE_LABELS
if report_type not in REPORT_TYPES:
raise HTTPException(422, f"Unknown report_type: {report_type}")
if not jahr:
from app.utils.kw_utils import date_to_jahr, date_to_kw
@ -105,9 +112,14 @@ def generate_report(
from app.services.report_service import generate_full_report
from app.services.vorjahr_service import get_vorjahr_summary
report_data = generate_full_report(db, jahr, kw)
vorjahr = get_vorjahr_summary(db, jahr)
xlsx_bytes = generate_berichtswesen_xlsx(report_data, jahr, vorjahr)
fallgruppen = list(REPORT_TYPES[report_type])
report_data = generate_full_report(db, jahr, kw, report_type=report_type)
# Vorjahr data: use cached summary for gesamt, live calculation for filtered
vorjahr = get_vorjahr_summary(db, jahr) if report_type == "gesamt" else None
xlsx_bytes = generate_berichtswesen_xlsx(
report_data, jahr, vorjahr, fallgruppen=fallgruppen,
)
# Persist Excel file to disk
reports_dir = os.path.join(
@ -115,15 +127,23 @@ def generate_report(
"reports",
)
os.makedirs(reports_dir, exist_ok=True)
filename = f"Berichtswesen_{jahr}_KW{kw:02d}.xlsx"
type_label = REPORT_TYPE_LABELS.get(report_type, report_type)
if report_type != "gesamt":
filename = f"Berichtswesen_{type_label}_{jahr}_KW{kw:02d}.xlsx"
else:
filename = f"Berichtswesen_{jahr}_KW{kw:02d}.xlsx"
filepath = os.path.join(reports_dir, filename)
with open(filepath, "wb") as f:
f.write(xlsx_bytes)
# Upsert report metadata (replace if same jahr/kw exists)
# Upsert report metadata (replace if same jahr/kw/report_type exists)
report = (
db.query(WeeklyReport)
.filter(WeeklyReport.jahr == jahr, WeeklyReport.kw == kw)
.filter(
WeeklyReport.jahr == jahr,
WeeklyReport.kw == kw,
WeeklyReport.report_type == report_type,
)
.first()
)
if report:
@ -135,6 +155,7 @@ def generate_report(
report = WeeklyReport(
jahr=jahr,
kw=kw,
report_type=report_type,
report_date=date.today(),
report_data=report_data,
generated_by=user.id,
@ -151,7 +172,7 @@ def generate_report(
action="report_generated",
entity_type="report",
entity_id=report.id,
new_values={"jahr": jahr, "kw": kw, "filename": filename},
new_values={"jahr": jahr, "kw": kw, "report_type": report_type, "filename": filename},
)
return ReportMeta.model_validate(report)

View file

@ -28,6 +28,9 @@ class WeeklyReport(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
jahr: Mapped[int] = mapped_column(SmallInteger, nullable=False)
kw: Mapped[int] = mapped_column(SmallInteger, nullable=False)
report_type: Mapped[str] = mapped_column(
String(50), nullable=False, server_default="gesamt"
)
report_date: Mapped[dt.date] = mapped_column(Date, nullable=False)
report_file_path: Mapped[Optional[str]] = mapped_column(
String(500), nullable=True
@ -46,7 +49,7 @@ class WeeklyReport(Base):
)
__table_args__ = (
UniqueConstraint("jahr", "kw", name="uk_jahr_kw"),
UniqueConstraint("jahr", "kw", "report_type", name="uk_jahr_kw_type"),
)

View file

@ -40,6 +40,7 @@ class ReportMeta(BaseModel):
id: int
jahr: int
kw: int
report_type: str = "gesamt"
report_date: date
generated_at: datetime
generated_by: Optional[int] = None

View file

@ -49,6 +49,7 @@ def generate_berichtswesen_xlsx(
report_data: dict[str, Any],
jahr: int,
vorjahr_data: dict[str, Any] | None = None,
fallgruppen: list[str] | None = None,
) -> bytes:
"""Generate a Berichtswesen Excel file.
@ -59,19 +60,23 @@ def generate_berichtswesen_xlsx(
vorjahr_data: Previous-year summary data for the year-over-year
comparison block on Sheet 1. Structure same as *report_data*
``sheet1`` (i.e. containing ``totals`` and ``weeks``).
fallgruppen: Subset of Fallgruppen for filtered reports.
If *None*, all 5 Fallgruppen are used.
Returns:
The ``.xlsx`` file contents as *bytes*.
"""
fgs = fallgruppen or FALLGRUPPEN
wb = Workbook()
_write_sheet1_kw_gesamt(wb, report_data.get("sheet1", {}), jahr, vorjahr_data)
_write_sheet2_fachgebiete(wb, report_data.get("sheet2", {}), jahr)
_write_sheet3_gutachten(wb, report_data.get("sheet3", {}), jahr)
# ICD codes live inside sheet5
sheet5 = report_data.get("sheet5", {})
icd_codes = sheet5.get("icd_codes", []) if isinstance(sheet5, dict) else []
_write_sheet4_icd_onko(wb, icd_codes, jahr)
_write_sheet2_fachgebiete(wb, report_data.get("sheet2", {}), jahr, fgs)
_write_sheet3_gutachten(wb, report_data.get("sheet3", {}), jahr, fgs)
# ICD codes live inside sheet5 — only relevant if onko is included
if "onko" in fgs:
sheet5 = report_data.get("sheet5", {})
icd_codes = sheet5.get("icd_codes", []) if isinstance(sheet5, dict) else []
_write_sheet4_icd_onko(wb, icd_codes, jahr)
# Remove the default empty sheet created by Workbook()
if "Sheet" in wb.sheetnames:
@ -251,16 +256,18 @@ def _write_sheet2_fachgebiete(
wb: Workbook,
data: dict[str, Any],
jahr: int,
fallgruppen: list[str] | None = None,
) -> None:
"""Write the 'Auswertung nach Fachgebieten' sheet.
Layout:
Row 1: A1 = "Uebersicht nach Fallgruppen"
Row 3: Merged group headers (B3:D3, E3:G3, H3:J3, K3:M3, N3:P3)
Row 4: Sub-headers: KW | Anzahl | Gutachten | Keine RM/Ablehnung (x5)
Row 3: Merged group headers (dynamic based on fallgruppen)
Row 4: Sub-headers: KW | Anzahl | Gutachten | Keine RM/Ablehnung (per fg)
Row 5-56: KW 1-52 data
Row 57: Summe row
"""
fgs = fallgruppen or FALLGRUPPEN
ws = wb.create_sheet(title="Auswertung nach Fachgebieten")
weeks = _weeks_lookup(data.get("weekly", []))
@ -270,8 +277,9 @@ def _write_sheet2_fachgebiete(
ws["A1"].font = TITLE_FONT
# --- Group headers (row 3) with merged cells ---
group_start_cols = [2, 5, 8, 11, 14] # B, E, H, K, N
for fg_key, start_col in zip(FALLGRUPPEN, group_start_cols):
group_start_cols = [2 + i * 3 for i in range(len(fgs))]
max_col = group_start_cols[-1] + 2 if group_start_cols else 1
for fg_key, start_col in zip(fgs, group_start_cols):
label = FALLGRUPPEN_LABELS[fg_key]
cell = ws.cell(row=3, column=start_col, value=label)
cell.fill = HEADER_FILL
@ -293,17 +301,17 @@ def _write_sheet2_fachgebiete(
ws.cell(row=4, column=start_col, value="Anzahl")
ws.cell(row=4, column=start_col + 1, value="Gutachten")
ws.cell(row=4, column=start_col + 2, value="Keine R\u00fcckmeldung/Ablehnung")
_apply_header_style(ws, 4, 1, 16)
_apply_header_style(ws, 4, 1, max_col)
# --- Weekly data (rows 5-56) ---
sums = {fg: {"anzahl": 0, "gutachten": 0, "keine_rm": 0} for fg in FALLGRUPPEN}
sums = {fg: {"anzahl": 0, "gutachten": 0, "keine_rm": 0} for fg in fgs}
for kw in range(1, MAX_KW + 1):
row = 4 + kw # row 5 = KW 1
w = weeks.get(kw, {})
ws.cell(row=row, column=1, value=kw)
for fg_key, start_col in zip(FALLGRUPPEN, group_start_cols):
for fg_key, start_col in zip(fgs, group_start_cols):
fg_data = w.get(fg_key, {})
anz = _safe(fg_data.get("anzahl"))
ga = _safe(fg_data.get("gutachten"))
@ -319,11 +327,11 @@ def _write_sheet2_fachgebiete(
# --- Summe row (row 57) ---
summe_row = 4 + MAX_KW + 1 # 57
for fg_key, start_col in zip(FALLGRUPPEN, group_start_cols):
for fg_key, start_col in zip(fgs, group_start_cols):
ws.cell(row=summe_row, column=start_col, value=sums[fg_key]["anzahl"])
ws.cell(row=summe_row, column=start_col + 1, value=sums[fg_key]["gutachten"])
ws.cell(row=summe_row, column=start_col + 2, value=sums[fg_key]["keine_rm"])
_apply_header_style(ws, summe_row, 1, 16)
_apply_header_style(ws, summe_row, 1, max_col)
# --- Column widths ---
ws.column_dimensions["A"].width = 6
@ -341,16 +349,18 @@ def _write_sheet3_gutachten(
wb: Workbook,
data: dict[str, Any],
jahr: int,
fallgruppen: list[str] | None = None,
) -> None:
"""Write the 'Auswertung Gutachten' sheet.
Layout:
Row 1: A1 = "Uebersicht nach Fallgruppen"
Row 3: Group headers: Gesamt (B3) + 5 Fallgruppen
Row 4: Sub-headers: KW | Gutachten | Alternative | Bestaetigung (x6)
Row 3: Group headers: Gesamt (B3) + Fallgruppen (dynamic)
Row 4: Sub-headers: KW | Gutachten | Alternative | Bestaetigung (per group)
Row 5-56: KW 1-52 data
Row 57: Summe row
"""
fgs = fallgruppen or FALLGRUPPEN
ws = wb.create_sheet(title="Auswertung Gutachten")
weeks = _weeks_lookup(data.get("weekly", []))
@ -360,22 +370,21 @@ def _write_sheet3_gutachten(
ws["A1"].font = TITLE_FONT
# --- Group headers (row 3) ---
# Gesamt: B3 (no merge since it's a single-column-start group header,
# but in the reference the Gesamt label sits in B3 without a merge)
# Gesamt: B3
cell = ws.cell(row=3, column=2, value="Gesamt")
cell.fill = HEADER_FILL
cell.font = HEADER_FONT
cell.alignment = Alignment(horizontal="center")
# Fallgruppen start at columns E, H, K, N, Q (each 3 cols wide)
fg_start_cols = [5, 8, 11, 14, 17]
for fg_key, start_col in zip(FALLGRUPPEN, fg_start_cols):
# Fallgruppen start at column 5, each 3 cols wide
fg_start_cols = [5 + i * 3 for i in range(len(fgs))]
max_col = fg_start_cols[-1] + 2 if fg_start_cols else 4
for fg_key, start_col in zip(fgs, fg_start_cols):
label = FALLGRUPPEN_LABELS[fg_key]
cell = ws.cell(row=3, column=start_col, value=label)
cell.fill = HEADER_FILL
cell.font = HEADER_FONT
cell.alignment = Alignment(horizontal="center")
# Merge: the reference merges first 2 of 3 columns (E3:F3, H3:I3, etc.)
end_col = start_col + 1
ws.merge_cells(
start_row=3, start_column=start_col,
@ -396,11 +405,11 @@ def _write_sheet3_gutachten(
ws.cell(row=4, column=start_col, value="Gutachten")
ws.cell(row=4, column=start_col + 1, value="Alternative")
ws.cell(row=4, column=start_col + 2, value="Best\u00e4tigung")
_apply_header_style(ws, 4, 1, 19)
_apply_header_style(ws, 4, 1, max_col)
# --- Weekly data (rows 5-56) ---
sums_gesamt = {"gutachten": 0, "alternative": 0, "bestaetigung": 0}
sums_fg = {fg: {"gutachten": 0, "alternative": 0, "bestaetigung": 0} for fg in FALLGRUPPEN}
sums_fg = {fg: {"gutachten": 0, "alternative": 0, "bestaetigung": 0} for fg in fgs}
for kw in range(1, MAX_KW + 1):
row = 4 + kw
@ -420,7 +429,7 @@ def _write_sheet3_gutachten(
sums_gesamt["bestaetigung"] += g_best
# Per Fallgruppe
for fg_key, start_col in zip(FALLGRUPPEN, fg_start_cols):
for fg_key, start_col in zip(fgs, fg_start_cols):
fg_data = w.get(fg_key, {})
ga = _safe(fg_data.get("gutachten"))
alt = _safe(fg_data.get("alternative"))
@ -437,15 +446,15 @@ def _write_sheet3_gutachten(
ws.cell(row=summe_row, column=2, value=sums_gesamt["gutachten"])
ws.cell(row=summe_row, column=3, value=sums_gesamt["alternative"])
ws.cell(row=summe_row, column=4, value=sums_gesamt["bestaetigung"])
for fg_key, start_col in zip(FALLGRUPPEN, fg_start_cols):
for fg_key, start_col in zip(fgs, fg_start_cols):
ws.cell(row=summe_row, column=start_col, value=sums_fg[fg_key]["gutachten"])
ws.cell(row=summe_row, column=start_col + 1, value=sums_fg[fg_key]["alternative"])
ws.cell(row=summe_row, column=start_col + 2, value=sums_fg[fg_key]["bestaetigung"])
_apply_header_style(ws, summe_row, 1, 19)
_apply_header_style(ws, summe_row, 1, max_col)
# --- Column widths ---
ws.column_dimensions["A"].width = 6
for col in range(2, 20):
for col in range(2, max_col + 1):
ws.column_dimensions[get_column_letter(col)].width = 12

View file

@ -27,6 +27,19 @@ logger = logging.getLogger(__name__)
# Canonical Fallgruppen in display order
FALLGRUPPEN = ("onko", "kardio", "intensiv", "galle", "sd")
# Report type definitions: each maps to a subset of Fallgruppen
REPORT_TYPES: dict[str, tuple[str, ...]] = {
"gesamt": FALLGRUPPEN,
"onko_intensiv": ("onko", "intensiv"),
"galle_schild": ("galle", "sd"),
}
REPORT_TYPE_LABELS: dict[str, str] = {
"gesamt": "Gesamt",
"onko_intensiv": "Onko-Intensiv",
"galle_schild": "Galle-Schild",
}
# Number of calendar weeks to include (ISO weeks 1..52; 53 is rare)
MAX_KW = 52
@ -61,18 +74,18 @@ def _empty_weekly_row(kw: int) -> dict:
}
def _empty_fg_weekly_row(kw: int) -> dict:
def _empty_fg_weekly_row(kw: int, fallgruppen: tuple[str, ...] = FALLGRUPPEN) -> dict:
"""Return a zeroed-out weekly row template for Sheet 2."""
row: dict[str, Any] = {"kw": kw}
for fg in FALLGRUPPEN:
for fg in fallgruppen:
row[fg] = {"anzahl": 0, "gutachten": 0, "keine_rm": 0}
return row
def _empty_gutachten_weekly_row(kw: int) -> dict:
def _empty_gutachten_weekly_row(kw: int, fallgruppen: tuple[str, ...] = FALLGRUPPEN) -> dict:
"""Return a zeroed-out weekly row template for Sheet 3."""
row: dict[str, Any] = {"kw": kw}
for group in ("gesamt",) + FALLGRUPPEN:
for group in ("gesamt",) + fallgruppen:
row[group] = {"gutachten": 0, "alternative": 0, "bestaetigung": 0}
return row
@ -94,7 +107,7 @@ def _empty_ta_weekly_row(kw: int) -> dict:
# Sheet 1: Auswertung KW gesamt
# ---------------------------------------------------------------------------
def calculate_sheet1_data(db: Session, jahr: int, max_kw: int | None = None) -> dict:
def calculate_sheet1_data(db: Session, jahr: int, max_kw: int | None = None, fallgruppen: tuple[str, ...] | None = None) -> dict:
"""Calculate *Auswertung KW gesamt*.
Returns::
@ -126,6 +139,8 @@ def calculate_sheet1_data(db: Session, jahr: int, max_kw: int | None = None) ->
"""
# One query: group by kw, count the four flags
filters = [Case.versicherung == settings.VERSICHERUNG_FILTER, Case.jahr == jahr]
if fallgruppen is not None:
filters.append(Case.fallgruppe.in_(fallgruppen))
if max_kw is not None:
filters.append(Case.kw <= max_kw)
rows = (
@ -183,7 +198,7 @@ def calculate_sheet1_data(db: Session, jahr: int, max_kw: int | None = None) ->
# Sheet 2: Auswertung nach Fachgebieten
# ---------------------------------------------------------------------------
def calculate_sheet2_data(db: Session, jahr: int, max_kw: int | None = None) -> dict:
def calculate_sheet2_data(db: Session, jahr: int, max_kw: int | None = None, fallgruppen: tuple[str, ...] | None = None) -> dict:
"""Calculate *Auswertung nach Fachgebieten*.
Per KW, per Fallgruppe: Anzahl, Gutachten, Keine RM/Ablehnung.
@ -207,8 +222,12 @@ def calculate_sheet2_data(db: Session, jahr: int, max_kw: int | None = None) ->
Keine RM/Ablehnung = Anzahl - Gutachten (per the Excel formula).
If *max_kw* is given, only data up to and including that KW is included.
If *fallgruppen* is given, only those Fallgruppen are included.
"""
fgs = fallgruppen or FALLGRUPPEN
filters = [Case.versicherung == settings.VERSICHERUNG_FILTER, Case.jahr == jahr]
if fallgruppen is not None:
filters.append(Case.fallgruppe.in_(fallgruppen))
if max_kw is not None:
filters.append(Case.kw <= max_kw)
rows = (
@ -228,11 +247,11 @@ def calculate_sheet2_data(db: Session, jahr: int, max_kw: int | None = None) ->
for row in rows:
kw = _int(row.kw)
fg = row.fallgruppe
if fg not in FALLGRUPPEN:
if fg not in fgs:
logger.warning("Unknown fallgruppe '%s' in case data, skipping", fg)
continue
if kw not in kw_map:
kw_map[kw] = _empty_fg_weekly_row(kw)
kw_map[kw] = _empty_fg_weekly_row(kw, fgs)
anzahl = _int(row.anzahl)
gutachten = _int(row.gutachten)
kw_map[kw][fg] = {
@ -243,7 +262,7 @@ def calculate_sheet2_data(db: Session, jahr: int, max_kw: int | None = None) ->
weekly = []
for kw in range(1, MAX_KW + 1):
weekly.append(kw_map.get(kw, _empty_fg_weekly_row(kw)))
weekly.append(kw_map.get(kw, _empty_fg_weekly_row(kw, fgs)))
return {"weekly": weekly}
@ -252,7 +271,7 @@ def calculate_sheet2_data(db: Session, jahr: int, max_kw: int | None = None) ->
# Sheet 3: Auswertung Gutachten
# ---------------------------------------------------------------------------
def calculate_sheet3_data(db: Session, jahr: int, max_kw: int | None = None) -> dict:
def calculate_sheet3_data(db: Session, jahr: int, max_kw: int | None = None, fallgruppen: tuple[str, ...] | None = None) -> dict:
"""Calculate *Auswertung Gutachten*.
Per KW, per group (gesamt + 5 Fallgruppen):
@ -281,12 +300,16 @@ def calculate_sheet3_data(db: Session, jahr: int, max_kw: int | None = None) ->
- Gesamt = sum across all Fallgruppen
If *max_kw* is given, only data up to and including that KW is included.
If *fallgruppen* is given, only those Fallgruppen are included.
"""
fgs = fallgruppen or FALLGRUPPEN
filters = [
Case.versicherung == settings.VERSICHERUNG_FILTER,
Case.jahr == jahr,
Case.gutachten == True, # noqa: E712
]
if fallgruppen is not None:
filters.append(Case.fallgruppe.in_(fallgruppen))
if max_kw is not None:
filters.append(Case.kw <= max_kw)
rows = (
@ -307,10 +330,10 @@ def calculate_sheet3_data(db: Session, jahr: int, max_kw: int | None = None) ->
for row in rows:
kw = _int(row.kw)
fg = row.fallgruppe
if fg not in FALLGRUPPEN:
if fg not in fgs:
continue
if kw not in kw_map:
kw_map[kw] = _empty_gutachten_weekly_row(kw)
kw_map[kw] = _empty_gutachten_weekly_row(kw, fgs)
gutachten = _int(row.gutachten)
alternative = _int(row.alternative)
@ -320,10 +343,10 @@ def calculate_sheet3_data(db: Session, jahr: int, max_kw: int | None = None) ->
"bestaetigung": gutachten - alternative,
}
# Compute gesamt (sum of all Fallgruppen per KW)
# Compute gesamt (sum of included Fallgruppen per KW)
for kw_data in kw_map.values():
total_g = sum(kw_data[fg]["gutachten"] for fg in FALLGRUPPEN)
total_a = sum(kw_data[fg]["alternative"] for fg in FALLGRUPPEN)
total_g = sum(kw_data[fg]["gutachten"] for fg in fgs)
total_a = sum(kw_data[fg]["alternative"] for fg in fgs)
kw_data["gesamt"] = {
"gutachten": total_g,
"alternative": total_a,
@ -332,7 +355,7 @@ def calculate_sheet3_data(db: Session, jahr: int, max_kw: int | None = None) ->
weekly = []
for kw in range(1, MAX_KW + 1):
weekly.append(kw_map.get(kw, _empty_gutachten_weekly_row(kw)))
weekly.append(kw_map.get(kw, _empty_gutachten_weekly_row(kw, fgs)))
return {"weekly": weekly}
@ -341,7 +364,7 @@ def calculate_sheet3_data(db: Session, jahr: int, max_kw: int | None = None) ->
# Sheet 4: Auswertung Therapieaenderungen
# ---------------------------------------------------------------------------
def calculate_sheet4_data(db: Session, jahr: int, max_kw: int | None = None) -> dict:
def calculate_sheet4_data(db: Session, jahr: int, max_kw: int | None = None, fallgruppen: tuple[str, ...] | None = None) -> dict:
"""Calculate *Auswertung Therapieaenderungen*.
Per KW: Gutachten count, TA Ja, TA Nein, Diagnosekorrektur,
@ -365,12 +388,15 @@ def calculate_sheet4_data(db: Session, jahr: int, max_kw: int | None = None) ->
}
If *max_kw* is given, only data up to and including that KW is included.
If *fallgruppen* is given, only those Fallgruppen are included.
"""
filters = [
Case.versicherung == settings.VERSICHERUNG_FILTER,
Case.jahr == jahr,
Case.gutachten == True, # noqa: E712
]
if fallgruppen is not None:
filters.append(Case.fallgruppe.in_(fallgruppen))
if max_kw is not None:
filters.append(Case.kw <= max_kw)
rows = (
@ -416,7 +442,7 @@ def calculate_sheet4_data(db: Session, jahr: int, max_kw: int | None = None) ->
# Sheet 5: Auswertung ICD onko
# ---------------------------------------------------------------------------
def calculate_sheet5_data(db: Session, jahr: int, max_kw: int | None = None) -> dict:
def calculate_sheet5_data(db: Session, jahr: int, max_kw: int | None = None, fallgruppen: tuple[str, ...] | None = None) -> dict:
"""Calculate *Auswertung ICD onko*.
Returns sorted list of ICD codes from onko cases with counts.
@ -437,7 +463,13 @@ def calculate_sheet5_data(db: Session, jahr: int, max_kw: int | None = None) ->
}
If *max_kw* is given, only data up to and including that KW is included.
If *fallgruppen* is given and ``'onko'`` is not in the set, returns an
empty list (ICD codes are only relevant for onko cases).
"""
fgs = fallgruppen or FALLGRUPPEN
if "onko" not in fgs:
return {"icd_codes": []}
filter_conditions = [
Case.versicherung == settings.VERSICHERUNG_FILTER,
Case.fallgruppe == "onko",
@ -578,18 +610,23 @@ def calculate_dashboard_kpis(db: Session, jahr: int) -> dict:
# Full report generation (all 5 sheets)
# ---------------------------------------------------------------------------
def generate_full_report(db: Session, jahr: int, kw: int | None = None) -> dict:
def generate_full_report(db: Session, jahr: int, kw: int | None = None, report_type: str = "gesamt") -> dict:
"""Generate complete report data for all 5 sheets.
If *kw* is given, only data up to and including that calendar week is
included in the report. This allows generating historical reports
that reflect the state at a specific point in the year.
If *report_type* is given, only the Fallgruppen belonging to that type
are included (e.g. ``"onko_intensiv"`` only onko + intensiv).
Returns::
{
"jahr": int,
"kw": int | None,
"report_type": str,
"fallgruppen": list[str],
"sheet1": {...},
"sheet2": {...},
"sheet3": {...},
@ -597,14 +634,18 @@ def generate_full_report(db: Session, jahr: int, kw: int | None = None) -> dict:
"sheet5": {...},
}
"""
logger.info("Generating full report for jahr=%d, kw=%s", jahr, kw)
fallgruppen = REPORT_TYPES.get(report_type, FALLGRUPPEN)
fg_arg = fallgruppen if report_type != "gesamt" else None
logger.info("Generating full report for jahr=%d, kw=%s, report_type=%s", jahr, kw, report_type)
return {
"jahr": jahr,
"kw": kw,
"sheet1": calculate_sheet1_data(db, jahr, max_kw=kw),
"sheet2": calculate_sheet2_data(db, jahr, max_kw=kw),
"sheet3": calculate_sheet3_data(db, jahr, max_kw=kw),
"sheet4": calculate_sheet4_data(db, jahr, max_kw=kw),
"sheet5": calculate_sheet5_data(db, jahr, max_kw=kw),
"report_type": report_type,
"fallgruppen": list(fallgruppen),
"sheet1": calculate_sheet1_data(db, jahr, max_kw=kw, fallgruppen=fg_arg),
"sheet2": calculate_sheet2_data(db, jahr, max_kw=kw, fallgruppen=fg_arg),
"sheet3": calculate_sheet3_data(db, jahr, max_kw=kw, fallgruppen=fg_arg),
"sheet4": calculate_sheet4_data(db, jahr, max_kw=kw, fallgruppen=fg_arg),
"sheet5": calculate_sheet5_data(db, jahr, max_kw=kw, fallgruppen=fg_arg),
}

View file

@ -90,7 +90,7 @@ function Sheet1({ data }: { data: any }) {
// ---------------------------------------------------------------------------
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function Sheet2({ data }: { data: any }) {
function Sheet2({ data, fallgruppen }: { data: any; fallgruppen: readonly string[] }) {
if (!data?.weekly?.length) return <NoData />
const SUB_COLS = ['anzahl', 'gutachten', 'keine_rm'] as const
@ -102,14 +102,14 @@ function Sheet2({ data }: { data: any }) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const isRowEmpty = (row: any) =>
FALLGRUPPEN_KEYS.every((fg) =>
fallgruppen.every((fg) =>
SUB_COLS.every((sc) => (row[fg]?.[sc] ?? 0) === 0),
)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rows = (data.weekly as any[]).filter((row) => !isRowEmpty(row))
const totals = FALLGRUPPEN_KEYS.reduce(
const totals = fallgruppen.reduce(
(acc, fg) => {
acc[fg] = SUB_COLS.reduce(
(sub, sc) => {
@ -129,14 +129,14 @@ function Sheet2({ data }: { data: any }) {
<TableHeader>
<TableRow>
<TableHead rowSpan={2} className="align-bottom">KW</TableHead>
{FALLGRUPPEN_KEYS.map((fg) => (
{fallgruppen.map((fg) => (
<TableHead key={fg} colSpan={SUB_COLS.length} className="text-center border-l">
{FALLGRUPPEN_LABELS[fg]}
{FALLGRUPPEN_LABELS[fg] ?? fg}
</TableHead>
))}
</TableRow>
<TableRow>
{FALLGRUPPEN_KEYS.map((fg) =>
{fallgruppen.map((fg) =>
SUB_COLS.map((sc) => (
<TableHead key={`${fg}-${sc}`} className="text-right border-l first:border-l">
{SUB_LABELS[sc]}
@ -149,7 +149,7 @@ function Sheet2({ data }: { data: any }) {
{rows.map((row) => (
<TableRow key={row.kw}>
<TableCell>KW {row.kw}</TableCell>
{FALLGRUPPEN_KEYS.map((fg) =>
{fallgruppen.map((fg) =>
SUB_COLS.map((sc) => (
<TableCell key={`${fg}-${sc}`} className="text-right border-l">
{fmt(row[fg]?.[sc] ?? 0)}
@ -162,7 +162,7 @@ function Sheet2({ data }: { data: any }) {
<TableFooter>
<TableRow>
<TableCell className="font-bold">Gesamt</TableCell>
{FALLGRUPPEN_KEYS.map((fg) =>
{fallgruppen.map((fg) =>
SUB_COLS.map((sc) => (
<TableCell key={`${fg}-${sc}`} className="text-right font-bold border-l">
{fmt(totals[fg][sc])}
@ -180,7 +180,7 @@ function Sheet2({ data }: { data: any }) {
// ---------------------------------------------------------------------------
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function Sheet3({ data }: { data: any }) {
function Sheet3({ data, fallgruppen }: { data: any; fallgruppen: readonly string[] }) {
if (!data?.weekly?.length) return <NoData />
const SUB_COLS = ['gutachten', 'alternative', 'bestaetigung'] as const
@ -190,7 +190,7 @@ function Sheet3({ data }: { data: any }) {
bestaetigung: 'Best\u00e4tigung',
}
const GROUP_KEYS = ['gesamt', ...FALLGRUPPEN_KEYS] as const
const GROUP_KEYS = ['gesamt', ...fallgruppen] as const
const GROUP_LABELS: Record<string, string> = {
gesamt: 'Gesamt',
@ -228,7 +228,7 @@ function Sheet3({ data }: { data: any }) {
<TableHead rowSpan={2} className="align-bottom">KW</TableHead>
{GROUP_KEYS.map((grp) => (
<TableHead key={grp} colSpan={SUB_COLS.length} className="text-center border-l">
{GROUP_LABELS[grp]}
{GROUP_LABELS[grp] ?? grp}
</TableHead>
))}
</TableRow>
@ -379,6 +379,9 @@ function Sheet5({ data }: { data: any }) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function ReportViewer({ data }: { data: Record<string, any> }) {
const fallgruppen: readonly string[] = data?.fallgruppen ?? FALLGRUPPEN_KEYS
const hasOnko = fallgruppen.includes('onko')
return (
<Tabs defaultValue="sheet1" className="w-full">
<TabsList className="w-full justify-start">
@ -386,7 +389,7 @@ export function ReportViewer({ data }: { data: Record<string, any> }) {
<TabsTrigger value="sheet2">Fachgebiete</TabsTrigger>
<TabsTrigger value="sheet3">Gutachten</TabsTrigger>
<TabsTrigger value="sheet4">Therapie\u00e4nderungen</TabsTrigger>
<TabsTrigger value="sheet5">ICD onko</TabsTrigger>
{hasOnko && <TabsTrigger value="sheet5">ICD onko</TabsTrigger>}
</TabsList>
<TabsContent value="sheet1">
@ -394,20 +397,22 @@ export function ReportViewer({ data }: { data: Record<string, any> }) {
</TabsContent>
<TabsContent value="sheet2">
<Sheet2 data={data?.sheet2} />
<Sheet2 data={data?.sheet2} fallgruppen={fallgruppen} />
</TabsContent>
<TabsContent value="sheet3">
<Sheet3 data={data?.sheet3} />
<Sheet3 data={data?.sheet3} fallgruppen={fallgruppen} />
</TabsContent>
<TabsContent value="sheet4">
<Sheet4 data={data?.sheet4} />
</TabsContent>
<TabsContent value="sheet5">
<Sheet5 data={data?.sheet5} />
</TabsContent>
{hasOnko && (
<TabsContent value="sheet5">
<Sheet5 data={data?.sheet5} />
</TabsContent>
)}
</Tabs>
)
}

View file

@ -18,7 +18,7 @@ export function useReports() {
export function useGenerateReport() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (params: { jahr: number; kw: number }) =>
mutationFn: (params: { jahr: number; kw: number; report_type?: string }) =>
api.post<ReportMeta>('/reports/generate', null, { params }).then(r => r.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['reports'] })

View file

@ -12,9 +12,24 @@ import { Label } from '@/components/ui/label'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription } from '@/components/ui/alert'
const REPORT_TYPES = [
{ value: 'gesamt', label: 'Gesamt' },
{ value: 'onko_intensiv', label: 'Onko-Intensiv' },
{ value: 'galle_schild', label: 'Galle-Schild' },
] as const
const REPORT_TYPE_LABELS: Record<string, string> = {
gesamt: 'Gesamt',
onko_intensiv: 'Onko-Intensiv',
galle_schild: 'Galle-Schild',
}
export function ReportsPage() {
const { isAdmin } = useAuth()
const currentYear = new Date().getFullYear()
@ -31,6 +46,7 @@ export function ReportsPage() {
// Report generation state
const [genJahr, setGenJahr] = useState(currentYear)
const [genKw, setGenKw] = useState(currentKw)
const [genReportType, setGenReportType] = useState('gesamt')
const [genError, setGenError] = useState('')
const [genSuccess, setGenSuccess] = useState('')
@ -45,8 +61,11 @@ export function ReportsPage() {
setGenError('')
setGenSuccess('')
try {
const result = await generateMutation.mutateAsync({ jahr: genJahr, kw: genKw })
setGenSuccess(`Bericht für KW ${result.kw}/${result.jahr} wurde generiert.`)
const result = await generateMutation.mutateAsync({
jahr: genJahr, kw: genKw, report_type: genReportType,
})
const typeLabel = REPORT_TYPE_LABELS[result.report_type] ?? result.report_type
setGenSuccess(`Bericht "${typeLabel}" für KW ${result.kw}/${result.jahr} wurde generiert.`)
} catch {
setGenError('Fehler beim Generieren des Berichts.')
}
@ -150,6 +169,19 @@ export function ReportsPage() {
max={53}
/>
</div>
<div className="space-y-1">
<Label>Berichtstyp</Label>
<Select value={genReportType} onValueChange={setGenReportType}>
<SelectTrigger className="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
{REPORT_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button onClick={generateReport} disabled={generateMutation.isPending}>
{generateMutation.isPending ? (
<>
@ -224,6 +256,7 @@ export function ReportsPage() {
<TableHead>Berichtsdatum</TableHead>
<TableHead>Jahr</TableHead>
<TableHead>KW</TableHead>
<TableHead>Typ</TableHead>
<TableHead>Erstellt am</TableHead>
<TableHead className="text-right">Aktion</TableHead>
</TableRow>
@ -231,7 +264,7 @@ export function ReportsPage() {
<TableBody>
{reports.map((r) => {
const isExpanded = expandedId === r.id
const colCount = isAdmin ? 6 : 5
const colCount = isAdmin ? 7 : 6
return (
<Fragment key={r.id}>
<TableRow
@ -256,6 +289,7 @@ export function ReportsPage() {
</TableCell>
<TableCell>{r.jahr}</TableCell>
<TableCell>KW {r.kw}</TableCell>
<TableCell>{REPORT_TYPE_LABELS[r.report_type] ?? r.report_type}</TableCell>
<TableCell>{formatDateTime(r.generated_at)}</TableCell>
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
<Button

View file

@ -211,6 +211,7 @@ export const mockReportMeta: ReportMeta = {
id: 1,
jahr: 2026,
kw: 6,
report_type: 'gesamt',
report_date: '2026-02-09',
generated_at: '2026-02-10T08:30:00Z',
}

View file

@ -154,6 +154,7 @@ export interface ReportMeta {
id: number
jahr: number
kw: number
report_type: string
report_date: string
generated_at: string
}