pflegepilot_v3: Antragsstatus-Tracking + automatischer Widerspruchs-Vorschlag (T25)

Periodische Magic-Link-Erinnerungsmail ("ist der Bescheid da?"), bei
Ablehnung eines Pflegegrad-Antrags eine "Keine Sorge"-Seite mit
eingebettetem Mini-Widerspruch-Generator, sowie eine Fristeskalationsmail
kurz vor Ablauf der Widerspruchsfrist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Sonnet 5 committed 2026-08-02 09:41:13 +02:00
1 parent 552570e696
commit 55806bb962
6 files changed
+486

No files matched your search

@@ -0,0 +1,44 @@
"""Magic-Link-Token fuer die Antragsstatus-Erinnerungsmail (T25) - gleiches
Muster wie der Passwort-Reset-Token in routers/auth.py: ein Klartext-Token
geht nur in den E-Mail-Link, persistiert wird ausschliesslich der SHA-256-
Hash. Ein Token deckt alle drei Ergebnis-Links einer Mail ab (bewilligt/
abgelehnt/offen kommt als eigener Query-Parameter dazu) - Einmalgebrauch:
sobald ein Link angeklickt wurde, sind die anderen beiden ungueltig.
"""
import hashlib
import secrets
from datetime import datetime, timedelta, timezone
from sqlalchemy.orm import Session
from app.models import Antrag, AntragAktionsToken
TOKEN_GUELTIGKEIT = timedelta(days=14)
def erstelle_token(db: Session, antrag: Antrag) -> str:
token = secrets.token_urlsafe(32)
eintrag = AntragAktionsToken(
antrag_id=antrag.id,
token_hash=hashlib.sha256(token.encode()).hexdigest(),
laeuft_ab=datetime.now(timezone.utc) + TOKEN_GUELTIGKEIT,
)
db.add(eintrag)
db.flush()
return token
def token_einloesen(db: Session, token: str) -> Antrag | None:
token_hash = hashlib.sha256(token.encode()).hexdigest()
eintrag = (
db.query(AntragAktionsToken).filter(AntragAktionsToken.token_hash == token_hash).first()
)
if (
eintrag is None
or eintrag.eingeloest_am is not None
or eintrag.laeuft_ab < datetime.now(timezone.utc)
):
return None
eintrag.eingeloest_am = datetime.now(timezone.utc)
return eintrag.antrag
+1
View File
@@ -86,6 +86,7 @@ app.include_router(stammdaten.router)
app.include_router(stammdaten.rv_router)
app.include_router(katalog.router)
app.include_router(antraege.router)
app.include_router(antraege.router_public)
app.include_router(briefe.router)
app.include_router(admin.router)
app.include_router(abo.router)
@@ -0,0 +1,57 @@
"""Antragsstatus-Tracking (T25): je Antragstyp unterschiedliches Intervall,
nach dem erstmalig per E-Mail nachgefragt wird, ob inzwischen ein Bescheid
eingetroffen ist (siehe app/reminder_scheduler.py). Nur Typen, bei denen
tatsaechlich ein Bescheid einer Stelle erwartet wird - adressaenderung/
vollmacht/patientenverfuegung sind bewusst nicht enthalten (kein Tracking).
erstantrag/hoeherst orientieren sich an der 25-Arbeitstage-Regelfrist nach
§ 18 Abs. 3 SGB XI (plus Puffer fuer Postlaufzeit/Wochenenden), die anderen
Werte sind pragmatische Schaetzwerte ohne feste gesetzliche Bearbeitungsfrist.
"""
import datetime
ANTRAG_ERINNERUNGS_TAGE: dict[str, int] = {
"erstantrag": 35,
"hoeherst": 35,
"widerspruch": 56,
"eingliederung": 42,
"grundsicherung": 42,
"blindenhilfe": 42,
}
# Wiederholungsintervall, falls nach der ersten Erinnerung immer noch keine
# Antwort eingetragen wurde, sowie ein Cutoff (ab wann die Serie ganz
# abbricht, um niemanden endlos zu behelligen).
ERINNERUNG_WIEDERHOLUNG_TAGE = 21
ERINNERUNG_CUTOFF_TAGE = 180
# Freundliche deutsche Bezeichnung je Antragstyp fuer E-Mail-Texte (Pendant
# zum ANTRAG_TYP_LABEL im Frontend, das Dashboard/Patientenakte fuer die
# Anzeige nutzen - hier eine eigene, serverseitige Kopie fuer Mailtexte).
ANTRAG_TYP_LABEL_DE: dict[str, str] = {
"erstantrag": "Erstantrag Pflegegrad",
"hoeherst": "Höherstufung Pflegegrad",
"widerspruch": "Widerspruch",
"adressaenderung": "Adressänderung",
"vollmacht": "Vollmacht",
"patientenverfuegung": "Patientenverfügung",
"eingliederung": "Eingliederungshilfe",
"grundsicherung": "Grundsicherung",
"blindenhilfe": "Blindenhilfe",
}
def berechne_naechste_erinnerung(
typ: str, bezugsdatum: datetime.date | None = None
) -> datetime.date | None:
"""Faelligkeitsdatum der ersten "ist der Bescheid da?"-Mail fuer einen
neu angelegten Antrag - None fuer Typen ohne Tracking (siehe oben).
bezugsdatum ist das Antragsdatum, falls bekannt (z.B. beim manuellen
Nachtragen eines extern gestellten Antrags); ohne Angabe wird das
Erstellungsdatum in der App als Basis genommen."""
tage = ANTRAG_ERINNERUNGS_TAGE.get(typ)
if tage is None:
return None
basis = bezugsdatum or datetime.date.today()
return basis + datetime.timedelta(days=tage)
@@ -0,0 +1,51 @@
"""antragsstatus-tracking: bewilligter betrag, aktenzeichen, erinnerungen
Revision ID: a1c4e7f9b3d6
Revises: d7f1a3c5e8b2
Create Date: 2026-07-31 00:00:00.000000
Tester-Feedback T25: periodische "ist der Bescheid da?"-Erinnerung per
E-Mail (Magic-Link, Einmal-Token), Erfassung des bewilligten Betrags, sowie
eine Eskalationserinnerung kurz vor Ablauf der Widerspruchsfrist.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a1c4e7f9b3d6'
down_revision = 'd7f1a3c5e8b2'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('antraege', sa.Column('bewilligter_betrag', sa.Numeric(10, 2), nullable=True))
op.add_column('antraege', sa.Column('aktenzeichen', sa.String(100), nullable=True))
op.add_column('antraege', sa.Column('naechste_statuserinnerung_am', sa.Date(), nullable=True))
op.add_column(
'antraege',
sa.Column(
'widerspruch_eskalation_gesendet', sa.Boolean(), nullable=False, server_default=sa.false()
),
)
op.create_table(
'antrag_aktions_token',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column(
'antrag_id', sa.Integer(), sa.ForeignKey('antraege.id', ondelete='CASCADE'), nullable=False
),
sa.Column('token_hash', sa.String(64), nullable=False, unique=True),
sa.Column('laeuft_ab', sa.DateTime(timezone=True), nullable=False),
sa.Column('eingeloest_am', sa.DateTime(timezone=True), nullable=True),
sa.Column('erstellt_am', sa.DateTime(timezone=True), server_default=sa.func.now()),
)
def downgrade() -> None:
op.drop_table('antrag_aktions_token')
op.drop_column('antraege', 'widerspruch_eskalation_gesendet')
op.drop_column('antraege', 'naechste_statuserinnerung_am')
op.drop_column('antraege', 'aktenzeichen')
op.drop_column('antraege', 'bewilligter_betrag')
@@ -0,0 +1,260 @@
"use client";
import { useMemo, useState } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import {
useAntrag,
useAntragMutationen,
useWiderspruchGruende,
pdfUrl,
} from "@/lib/briefe";
import { usePatient } from "@/lib/patienten";
import { buttonStyles, accentForKey, getAccent } from "@/lib/theme";
const AUTOMATISIERTE_TYPEN = ["erstantrag", "hoeherst"];
const HAEUFIGE_GRUENDE = [
"Die Begutachtung war zu kurz oder fand nur telefonisch statt, statt bei einem persönlichen Hausbesuch.",
"Das Pflegetagebuch oder ärztliche Nachweise wurden bei der Begutachtung nicht berücksichtigt.",
"Der tatsächliche Unterstützungsbedarf – z. B. bei Mobilität, Selbstversorgung oder nachts – wurde unterschätzt.",
"Der Bescheid ist nicht ausreichend begründet worden, sodass unklar bleibt, worauf die Entscheidung beruht.",
];
function fristInfo(bescheidDatum: string | null, widerspruchFrist: string | null) {
const frist = widerspruchFrist
? new Date(widerspruchFrist)
: bescheidDatum
? new Date(new Date(bescheidDatum).getTime() + 31 * 24 * 60 * 60 * 1000)
: null;
if (!frist) return null;
const heute = new Date();
const tage = Math.ceil((frist.getTime() - heute.getTime()) / (24 * 60 * 60 * 1000));
const farbe = tage <= 3 ? "text-red-700 bg-red-50 border-red-200" : tage <= 14 ? "text-amber-800 bg-amber-50 border-amber-200" : "text-emerald-700 bg-emerald-50 border-emerald-200";
return { frist, tage, farbe };
}
export default function KeineSorgePage() {
const params = useParams();
const aid = Number(params.id);
const { data: antrag } = useAntrag(aid);
const { data: patient } = usePatient(antrag?.patient_id ?? 0);
const { data: gruendeData } = useWiderspruchGruende();
const { widerspruchErstellen } = useAntragMutationen(patient?.id ?? 0);
const [sollGrad, setSollGrad] = useState(3);
const [gewaehlteGruende, setGewaehlteGruende] = useState<Set<string>>(new Set());
const [beweismittel, setBeweismittel] = useState<Set<string>>(new Set());
const gruppenNorm = useMemo(() => {
if (!gruendeData) return [];
const seen = new Map<string, string>();
for (const g of gruendeData.gruende) if (!seen.has(g.norm)) seen.set(g.norm, g.group);
return [...seen.entries()];
}, [gruendeData]);
const toggleGrund = (id: string) => {
setGewaehlteGruende((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
const toggleBeweis = (label: string) => {
setBeweismittel((prev) => {
const next = new Set(prev);
next.has(label) ? next.delete(label) : next.add(label);
return next;
});
};
if (!antrag || !patient) {
return <div className="mx-auto max-w-3xl p-6 text-slate-500">Lädt…</div>;
}
const info = fristInfo(antrag.bescheid_datum, antrag.widerspruch_frist);
const automatisiert = AUTOMATISIERTE_TYPEN.includes(antrag.typ);
const ergebnisAccent = getAccent("emerald");
const patientName = `${patient.vorname} ${patient.nachname}`;
const adresse = [patient.strasse, [patient.plz, patient.ort].filter(Boolean).join(", ")]
.filter(Boolean)
.join(", ");
const vollformularUrl = () => {
// name/adresse/geburtsdatum/versichertennummer/krankenkasse muessen hier
// nicht mitgegeben werden - /widerspruch fuellt sie beim Waehlen von
// patientId ohnehin aus demselben Patientendatensatz (patientWaehlen()).
const p = new URLSearchParams();
p.set("patientId", String(patient.id));
if (antrag.bescheid_datum) p.set("bescheiddatum", antrag.bescheid_datum);
if (antrag.aktenzeichen) p.set("aktenzeichen", antrag.aktenzeichen);
if (antrag.zugeteilter_pflegegrad != null) p.set("istGrad", String(antrag.zugeteilter_pflegegrad));
p.set("sollGrad", String(sollGrad));
if (gewaehlteGruende.size > 0) p.set("grundIds", [...gewaehlteGruende].join(","));
if (beweismittel.size > 0) p.set("beweismittel", [...beweismittel].join(","));
return `/widerspruch?${p.toString()}`;
};
const erstellen = () => {
if (!antrag.bescheid_datum || gewaehlteGruende.size === 0) return;
widerspruchErstellen.mutate({
patient_id: patient.id,
name: patientName,
adresse,
geburtsdatum: patient.geburtsdatum,
versichertennummer: patient.versichertennummer,
krankenkasse: patient.krankenkasse,
bescheiddatum: antrag.bescheid_datum,
aktenzeichen: antrag.aktenzeichen,
ist_grad: antrag.zugeteilter_pflegegrad ?? 0,
soll_grad: sollGrad,
grund_ids: [...gewaehlteGruende],
beweismittel: [...beweismittel],
});
};
return (
<div className="mx-auto max-w-3xl p-6">
<h1 className="mb-2 text-2xl font-semibold text-slate-800">
Der Bescheid wurde abgelehnt – aber das ist nicht das Ende
</h1>
<p className="mb-4 text-sm text-slate-600">
Ablehnungen kommen häufig vor und liegen oft an nachvollziehbaren, angreifbaren Gründen – nicht
daran, dass tatsächlich kein Bedarf besteht. Ein Widerspruch kostet nichts und lohnt sich in
vielen Fällen. Häufige Gründe, warum Bescheide zu Recht angefochten werden:
</p>
<ul className="mb-4 flex flex-col gap-2">
{HAEUFIGE_GRUENDE.map((g) => (
<li key={g} className="flex gap-2 rounded-lg border border-slate-200 bg-slate-50 p-2.5 text-sm text-slate-700">
<span className="text-slate-400">•</span>
{g}
</li>
))}
</ul>
{info && (
<p className={`mb-6 rounded-lg border p-3 text-sm font-medium ${info.farbe}`}>
Widerspruchsfrist (§ 84 SGG): bis {info.frist.toLocaleDateString("de-DE")}
{info.tage >= 0 ? ` – noch ${info.tage} Tage Zeit` : " – die Frist ist bereits abgelaufen, ein Widerspruch kann trotzdem noch sinnvoll sein"}
.
</p>
)}
{!automatisiert ? (
<div className="rounded-2xl border border-slate-200 bg-white p-4">
<p className="mb-3 text-sm text-slate-600">
Für diese Art von Antrag steht noch kein automatisch vorausgefüllter Widerspruch zur
Verfügung. Nutzen Sie das allgemeine Widerspruchs-Formular.
</p>
<Link href={`/widerspruch?patientId=${patient.id}`} className={buttonStyles.primary}>
Zum allgemeinen Widerspruchs-Formular
</Link>
</div>
) : (
<div className="rounded-2xl border border-slate-200 bg-white p-4">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-semibold text-slate-700">Widerspruch vorbereiten</h2>
<Link href={vollformularUrl()} className={buttonStyles.small}>
Vollständiges Formular öffnen
</Link>
</div>
<p className="mb-3 text-xs text-slate-500">
Für: {patientName}
{patient.krankenkasse ? ` · ${patient.krankenkasse}` : ""}
{antrag.bescheid_datum ? ` · Bescheid vom ${new Date(antrag.bescheid_datum).toLocaleDateString("de-DE")}` : ""}
</p>
<div className="mb-3 flex gap-2">
<div className="flex-1">
<label className="mb-1 block text-xs font-medium text-slate-600">Zugeteilter Grad</label>
<p className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700">
{antrag.zugeteilter_pflegegrad ? `Grad ${antrag.zugeteilter_pflegegrad}` : "Kein Grad"}
</p>
</div>
<div className="flex-1">
<label className="mb-1 block text-xs font-medium text-slate-600">Erwarteter Grad</label>
<select
value={sollGrad}
onChange={(e) => setSollGrad(Number(e.target.value))}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
>
{[1, 2, 3, 4, 5].map((g) => (
<option key={g} value={g}>
Grad {g}
</option>
))}
</select>
</div>
</div>
<p className="mb-2 text-xs font-semibold text-slate-600">Widerspruchsgründe auswählen *</p>
<div className="mb-3 flex max-h-72 flex-col gap-3 overflow-y-auto">
{gruppenNorm.map(([norm, group]) => {
const accent = getAccent(accentForKey(norm));
return (
<div key={norm} className={`rounded-xl border p-2.5 ${accent.card}`}>
<p className={`mb-1.5 inline-block rounded-full px-2 py-0.5 text-xs font-semibold ${accent.badge}`}>
{norm} – {group}
</p>
<div className="flex flex-col gap-1">
{gruendeData!.gruende
.filter((g) => g.norm === norm)
.map((g) => (
<label key={g.id} className="flex items-start gap-2 text-xs text-slate-700">
<input
type="checkbox"
checked={gewaehlteGruende.has(g.id)}
onChange={() => toggleGrund(g.id)}
className="mt-0.5"
/>
{g.label}
</label>
))}
</div>
</div>
);
})}
</div>
<p className="mb-2 text-xs font-semibold text-slate-600">Beigefügte Beweismittel</p>
<div className="mb-4 grid grid-cols-2 gap-1">
{gruendeData?.beweismittel_optionen.map((b) => (
<label key={b} className="flex items-center gap-2 text-xs text-slate-700">
<input type="checkbox" checked={beweismittel.has(b)} onChange={() => toggleBeweis(b)} />
{b}
</label>
))}
</div>
{!widerspruchErstellen.data ? (
<>
<button
onClick={erstellen}
disabled={widerspruchErstellen.isPending || !antrag.bescheid_datum || gewaehlteGruende.size === 0}
className={buttonStyles.primary + " w-full"}
>
Widerspruch erstellen
</button>
{widerspruchErstellen.isError && (
<p className="mt-2 text-sm text-red-600">{(widerspruchErstellen.error as Error).message}</p>
)}
</>
) : (
<div className={`rounded-xl border p-3 ${ergebnisAccent.card}`}>
<p className={`mb-2 text-sm font-medium ${ergebnisAccent.heading}`}>Widerspruch erstellt ✓</p>
<a
href={pdfUrl(patient.id, widerspruchErstellen.data.id)}
target="_blank"
rel="noreferrer"
className={buttonStyles.primary}
>
PDF öffnen
</a>
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,73 @@
"use client";
import { Suspense, useEffect, useRef } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { useAntragAktionEinloesen, type AntragAktionErgebnis } from "@/lib/briefe";
import { ApiError } from "@/lib/api";
function AntragStatusInner() {
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get("token") ?? "";
const ergebnis = (searchParams.get("ergebnis") ?? "offen") as AntragAktionErgebnis;
const einloesen = useAntragAktionEinloesen();
const gestartet = useRef(false);
useEffect(() => {
if (!token || gestartet.current) return;
gestartet.current = true;
einloesen.mutate(
{ token, ergebnis },
{
onSuccess: (data) => {
if (data.ergebnis === "bewilligt") {
router.replace(`/patienten/${data.patient_id}?antragBetrag=${data.antrag_id}`);
} else if (data.ergebnis === "abgelehnt") {
router.replace(`/antraege/${data.antrag_id}/keine-sorge`);
} else {
router.replace(`/patienten/${data.patient_id}`);
}
},
}
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
if (!token) {
return (
<p className="text-sm text-red-600">
Dieser Link ist unvollständig. Bitte den Link direkt aus der E-Mail öffnen.
</p>
);
}
if (einloesen.isError) {
const message =
einloesen.error instanceof ApiError ? einloesen.error.message : "Unbekannter Fehler";
return (
<div className="flex flex-col gap-3">
<p className="text-sm text-red-600">Link ungültig oder abgelaufen: {message}</p>
<Link
href="/dashboard"
className="rounded-lg bg-slate-800 px-4 py-2 text-center text-sm font-medium text-white"
>
Zur Anmeldung
</Link>
</div>
);
}
return <p className="text-sm text-slate-500">Einen Moment, wir tragen den Status ein…</p>;
}
export default function AntragStatusPage() {
return (
<div className="mx-auto max-w-sm p-6 py-16">
<h1 className="mb-4 text-2xl font-semibold text-slate-800">Antragsstatus</h1>
<Suspense fallback={<p className="text-slate-500">Lädt…</p>}>
<AntragStatusInner />
</Suspense>
</div>
);
}