Files
betreuungs-cockpit/src/lib/validierung/sozialhilfe.ts
T
Florian Wilde 1ed19eb76e Zeilenenden der 24 verbliebenen Dateien normalisieren
Ausschliesslich Zeilenenden. Nachgewiesen mit
"git diff --cached --ignore-cr-at-eol": Der Diff ist leer, es hat sich
kein Zeichen Inhalt geaendert.

Deshalb ein eigener Commit: 7448 geaenderte Zeilen in einem Commit mit
echten Aenderungen zu verstecken, macht die Historie an dieser Stelle
unlesbar. So laesst er sich beim Durchsehen ueberspringen.

Refs #26
2026-08-23 23:37:02 +02:00

438 lines
14 KiB
TypeScript

import { z } from 'zod'
import { betragParsenWennMoeglich } from '@/lib/finanzen/betrag'
/**
* Eingabeprüfung für das Sozialhilfemodul.
*
* Geprüft wird immer serverseitig. Die Meldungen sagen, was zu tun ist -
* nicht, welche Regel verletzt wurde. Wer am Handy im Heim steht und eine
* Nachforderung erfasst, hat keine Lust auf "invalid input".
*/
const optionalerText = (max = 200) =>
z
.string()
.trim()
.max(max, `Bitte höchstens ${max} Zeichen.`)
.transform((wert) => (wert === '' ? null : wert))
.nullable()
.default(null)
const optionalesDatum = z
.string()
.trim()
.transform((wert) => (wert === '' ? null : wert))
.nullable()
.default(null)
.refine((wert) => wert === null || /^\d{4}-\d{2}-\d{2}$/.test(wert), {
message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.',
})
.transform((wert) => (wert === null ? null : new Date(`${wert}T00:00:00.000Z`)))
const pflichtDatum = z
.string()
.trim()
.min(1, 'Bitte ein Datum angeben.')
.refine((wert) => /^\d{4}-\d{2}-\d{2}$/.test(wert), {
message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.',
})
.transform((wert) => new Date(`${wert}T00:00:00.000Z`))
/**
* Geldbetrag aus einem Textfeld. Leer wird zu null, nicht zu 0 - "keine
* Angabe" und "null Euro" sind fachlich zweierlei.
*/
const optionalerBetrag = z
.string()
.trim()
.default('')
.transform((wert, ctx) => {
if (wert === '') return null
const cent = betragParsenWennMoeglich(wert)
if (cent === null) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Bitte einen Betrag wie 1.234,56 eingeben.',
})
return z.NEVER
}
return cent
})
/** Betrag, der in einer Gegenüberstellung stehen muss - leer heißt 0. */
const betragOderNull = z
.string()
.trim()
.default('')
.transform((wert, ctx) => {
if (wert === '') return 0n
const cent = betragParsenWennMoeglich(wert)
if (cent === null) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Bitte einen Betrag wie 1.234,56 eingeben.',
})
return z.NEVER
}
if (cent < 0n) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'In dieser Aufstellung stehen nur positive Beträge.',
})
return z.NEVER
}
return cent
})
// -----------------------------------------------------------------------------
// Antragsakte
// -----------------------------------------------------------------------------
export const AntragSchema = z
.object({
traeger: z
.string()
.trim()
.min(2, 'Bitte den Träger angeben, z.B. „Bezirk Oberfranken“.')
.max(150),
leistungsart: z
.string()
.trim()
.min(2, 'Bitte die Leistungsart angeben.')
.max(200),
aktenzeichen: optionalerText(80),
sachbearbeiter: optionalerText(150),
telefon: optionalerText(50),
email: z
.string()
.trim()
.max(200)
.transform((wert) => (wert === '' ? null : wert))
.nullable()
.default(null)
.refine((wert) => wert === null || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(wert), {
message: 'Bitte eine gültige E-Mail-Adresse angeben oder das Feld leer lassen.',
}),
antragAm: optionalesDatum,
eingangBeimTraegerAm: optionalesDatum,
bewilligtVon: optionalesDatum,
bewilligtBis: optionalesDatum,
notizen: optionalerText(4000),
})
.refine(
(daten) =>
!daten.bewilligtVon || !daten.bewilligtBis || daten.bewilligtVon <= daten.bewilligtBis,
{
message: 'Das Ende des Bewilligungszeitraums liegt vor dem Beginn.',
path: ['bewilligtBis'],
},
)
.refine(
(daten) =>
!daten.antragAm ||
!daten.eingangBeimTraegerAm ||
daten.antragAm <= daten.eingangBeimTraegerAm,
{
message: 'Der Eingang beim Träger kann nicht vor dem Antragsdatum liegen.',
path: ['eingangBeimTraegerAm'],
},
)
export type AntragEingabe = z.infer<typeof AntragSchema>
// -----------------------------------------------------------------------------
// Nachforderung
// -----------------------------------------------------------------------------
export const NACHFORDERUNG_STATUS_WERTE = [
'OFFEN',
'IN_BESCHAFFUNG',
'UEBERSANDT',
'ANERKANNT',
'NICHT_VORHANDEN',
] as const
export const NachforderungSchema = z
.object({
bezeichnung: z
.string()
.trim()
.min(2, 'Bitte benennen, welche Unterlage der Träger will.')
.max(200),
erlaeuterung: optionalerText(2000),
status: z.enum(NACHFORDERUNG_STATUS_WERTE).default('OFFEN'),
angefordertAm: optionalesDatum,
fristBis: optionalesDatum,
uebersandtAm: optionalesDatum,
begruendungNichtVorhanden: optionalerText(2000),
reihenfolge: z.coerce.number().int().min(0).max(10_000).default(0),
dokumentId: optionalerText(40),
/** Soll aus der Frist des Trägers eine Frist im Fristenmodul werden? */
fristAnlegen: z.coerce.boolean().default(false),
})
.refine(
(daten) =>
daten.status !== 'NICHT_VORHANDEN' ||
(daten.begruendungNichtVorhanden !== null &&
daten.begruendungNichtVorhanden.trim().length >= 5),
{
message:
'Bitte begründen, warum es die Unterlage nicht gibt. Die Begründung geht mit ins ' +
'Unterlagenpaket — ohne sie sieht der Träger nur eine Lücke.',
path: ['begruendungNichtVorhanden'],
},
)
.refine((daten) => daten.status !== 'UEBERSANDT' || daten.uebersandtAm !== null, {
message:
'Bitte das Übersendungsdatum angeben — es ist der Nachweis der Mitwirkung, wenn der ' +
'Träger später von fehlenden Unterlagen ausgeht.',
path: ['uebersandtAm'],
})
.refine((daten) => !daten.fristAnlegen || daten.fristBis !== null, {
message: 'Für eine Frist im Fristenmodul brauche ich ein Fristdatum.',
path: ['fristBis'],
})
export type NachforderungEingabe = z.infer<typeof NachforderungSchema>
export const NachforderungStatusSchema = z
.object({
nachforderungId: z.string().trim().min(1),
status: z.enum(NACHFORDERUNG_STATUS_WERTE),
begruendungNichtVorhanden: optionalerText(2000),
uebersandtAm: optionalesDatum,
})
.refine(
(daten) =>
daten.status !== 'NICHT_VORHANDEN' ||
(daten.begruendungNichtVorhanden !== null &&
daten.begruendungNichtVorhanden.trim().length >= 5),
{
message: 'Bitte begründen, warum es die Unterlage nicht gibt.',
path: ['begruendungNichtVorhanden'],
},
)
export const ChecklisteSchema = z.object({
antragId: z.string().trim().min(1),
schluessel: z.array(z.string().trim().min(1)).min(1, 'Bitte mindestens eine Position auswählen.'),
angefordertAm: optionalesDatum,
fristBis: optionalesDatum,
})
// -----------------------------------------------------------------------------
// Bescheid
// -----------------------------------------------------------------------------
export const BESCHEID_ART_WERTE = [
'BEWILLIGUNG',
'ABLEHNUNG',
'AENDERUNG',
'AUFHEBUNG',
'ERSTATTUNG',
'VORSCHUSS',
'WIDERSPRUCHSBESCHEID',
] as const
export const PRUEFSTATUS_WERTE = [
'UNGEPRUEFT',
'GEPRUEFT_OK',
'WIDERSPRUCH_EINGELEGT',
'WIDERSPRUCH_ERLEDIGT',
'FRIST_VERSTRICHEN',
] as const
export const BescheidSchema = z
.object({
art: z.enum(BESCHEID_ART_WERTE),
bescheidVom: pflichtDatum,
zugegangenAm: optionalesDatum,
zeitraumVon: optionalesDatum,
zeitraumBis: optionalesDatum,
betrag: optionalerBetrag,
/**
* 1 = Regelfall (§ 84 SGG), 12 = fehlende oder fehlerhafte
* Rechtsbehelfsbelehrung (§ 66 SGG). Wird ausgewählt, nie geraten.
*/
rechtsbehelfsfristMonate: z.coerce
.number()
.int()
.refine((wert) => wert === 1 || wert === 12, {
message: 'Es kommen nur ein Monat (Regelfall) oder ein Jahr (fehlende Belehrung) in Betracht.',
})
.default(1),
widerspruchEingelegtAm: optionalesDatum,
notizen: optionalerText(4000),
dokumentId: optionalerText(40),
/** Soll die Frist "Widerspruch möglich bis" angelegt werden? */
fristAnlegen: z.coerce.boolean().default(false),
})
.refine(
(daten) => !daten.zugegangenAm || daten.zugegangenAm >= daten.bescheidVom,
{
message:
'Der Bescheid kann nicht vor seinem Ausstellungsdatum zugegangen sein. Prüfe den ' +
'Poststempel.',
path: ['zugegangenAm'],
},
)
.refine(
(daten) => !daten.zeitraumVon || !daten.zeitraumBis || daten.zeitraumVon <= daten.zeitraumBis,
{ message: 'Das Ende des Zeitraums liegt vor dem Beginn.', path: ['zeitraumBis'] },
)
.refine((daten) => !daten.fristAnlegen || daten.zugegangenAm !== null, {
message:
'Ohne Zugangsdatum lässt sich die Widerspruchsfrist nicht berechnen — und damit auch ' +
'keine Frist anlegen.',
path: ['zugegangenAm'],
})
export type BescheidEingabe = z.infer<typeof BescheidSchema>
export const BescheidStatusSchema = z.object({
bescheidId: z.string().trim().min(1),
pruefStatus: z.enum(PRUEFSTATUS_WERTE),
widerspruchEingelegtAm: optionalesDatum,
})
// -----------------------------------------------------------------------------
// Vermögensbewertung
// -----------------------------------------------------------------------------
export const BEWERTUNG_WERTE = ['UNGEPRUEFT', 'VERWERTBAR', 'GESCHUETZT', 'STRITTIG'] as const
export const BewertungSchema = z
.object({
herkunft: z.enum(['VERMOEGEN', 'VERTRAG']),
id: z.string().trim().min(1),
bewertung: z.enum(BEWERTUNG_WERTE),
begruendung: optionalerText(6000),
})
.refine(
(daten) =>
daten.bewertung === 'UNGEPRUEFT' ||
(daten.begruendung !== null && daten.begruendung.trim().length >= 10),
{
message:
'Eine Bewertung ohne Begründung hilft im Widerspruchsverfahren nicht weiter. ' +
'Übernimm einen der Textbausteine und passe ihn an.',
path: ['begruendung'],
},
)
// -----------------------------------------------------------------------------
// Heimkosten
// -----------------------------------------------------------------------------
export const HeimkostenSchema = z.object({
jahr: z.coerce
.number()
.int()
.min(2000, 'Das Jahr liegt außerhalb des sinnvollen Bereichs.')
.max(2100, 'Das Jahr liegt außerhalb des sinnvollen Bereichs.'),
monat: z.coerce.number().int().min(1, 'Bitte einen Monat wählen.').max(12),
pflegeEigenanteil: betragOderNull,
unterkunftVerpflegung: betragOderNull,
investitionskosten: betragOderNull,
ausbildungsumlage: betragOderNull,
zusatzleistungen: betragOderNull,
pflegekasse: betragOderNull,
leistungszuschlagProzent: z.coerce
.number()
.int()
.min(0, 'Der Zuschlag liegt zwischen 0 und 100 Prozent.')
.max(100, 'Der Zuschlag liegt zwischen 0 und 100 Prozent.')
.default(0),
leistungszuschlag: betragOderNull,
rente: betragOderNull,
sonstigeEinkuenfte: betragOderNull,
sozialhilfe: betragOderNull,
eigenmittel: betragOderNull,
barbetrag: betragOderNull,
bekleidungspauschale: betragOderNull,
abwesenheitstage: z.coerce
.number()
.int()
.min(0, 'Abwesenheitstage können nicht negativ sein.')
.max(31, 'Ein Monat hat höchstens 31 Tage.')
.default(0),
bemerkung: optionalerText(2000),
})
export type HeimkostenEingabeFormular = z.infer<typeof HeimkostenSchema>
// -----------------------------------------------------------------------------
// Weitere Leistungen
// -----------------------------------------------------------------------------
export const LEISTUNG_STATUS_WERTE = [
'NICHT_GEPRUEFT',
'PRUEFUNG_LAEUFT',
'BEANTRAGT',
'BEWILLIGT',
'ABGELEHNT',
'LAEUFT',
'NICHT_ZUTREFFEND',
] as const
export const LeistungSchema = z
.object({
leistungId: optionalerText(40),
katalogSchluessel: optionalerText(80),
bezeichnung: z.string().trim().min(2, 'Bitte die Leistung benennen.').max(200),
traeger: optionalerText(200),
rechtsgrundlage: optionalerText(200),
status: z.enum(LEISTUNG_STATUS_WERTE).default('NICHT_GEPRUEFT'),
beantragtAm: optionalesDatum,
bewilligtAm: optionalesDatum,
gueltigBis: optionalesDatum,
betrag: optionalerBetrag,
jaehrlichNeuBeantragen: z.coerce.boolean().default(false),
notizen: optionalerText(2000),
})
.refine((daten) => !daten.bewilligtAm || !daten.beantragtAm || daten.bewilligtAm >= daten.beantragtAm, {
message: 'Die Bewilligung kann nicht vor dem Antrag liegen.',
path: ['bewilligtAm'],
})
export const KatalogUebernehmenSchema = z.object({
schluessel: z.array(z.string().trim().min(1)).min(1, 'Bitte mindestens eine Leistung auswählen.'),
})
// -----------------------------------------------------------------------------
// Formularhilfen
// -----------------------------------------------------------------------------
/** Kontrollkästchen liefern "on" oder gar nichts. */
export function kaestchen(formular: FormData, name: string): boolean {
const wert = formular.get(name)
return wert === 'on' || wert === 'true' || wert === '1'
}
/** Liest ein FormData flach in ein Objekt - ohne Mehrfachwerte. */
export function formularLesen(formular: FormData): Record<string, unknown> {
const roh: Record<string, unknown> = {}
for (const [name, wert] of formular.entries()) {
if (typeof wert === 'string') roh[name] = wert
}
return roh
}
/**
* Zod-Feldfehler in die flache Form bringen, die die Formulare erwarten.
*/
export function feldfehler(fehler: z.ZodError): Record<string, string> {
const flach = fehler.flatten().fieldErrors
const ergebnis: Record<string, string> = {}
for (const [feld, meldungen] of Object.entries(flach)) {
const erste = meldungen?.[0]
if (erste) ergebnis[feld] = erste
}
return ergebnis
}