feat(kantine-datev): Kantinenabrechnung als Modul in neuer Gruppe Finanzbuchhaltung

- neue Seitenleisten-Kategorie accounting (Finanzbuchhaltung / Financial accounting)
- CSV (UTF-8, UTF-8 mit BOM, Windows-1252) pruefen, Vorschau mit Zeilenfehlern, DATEV-Lohn-ASCII-Export
- Beraternummer, Mandantennummer, Lohnart je Mandant als Admin-Einstellung (KantineDatevConfig mit Zeilenschutz), anfangs leer
- hochgeladene Daten werden nicht gespeichert; Download per Blob (auch im Desktop-Client)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-02 11:32:55 +02:00
parent 0edd6e9b1a
commit 1f85277a3b
38 changed files with 2460 additions and 2 deletions
@@ -0,0 +1,138 @@
import type {
KantinenRawRow,
ValidationError,
ValidationResult,
ValidationWarning,
} from './kantine-datev.types';
/** Zahl im deutschen Format (Komma als Dezimaltrenner), wie in der Vorlage. */
export function isValidGermanNumber(value: string): boolean {
return /^\d+([,]\d+)?$/.test(value.trim());
}
/** Datum im Format TT.MM.JJJJ. */
function isValidDate(value: string): boolean {
return /^\d{2}\.\d{2}\.\d{4}$/.test(value.trim());
}
function extractMonthYear(dateStr: string): { month: string; year: string } | null {
const match = dateStr.trim().match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
if (!match) return null;
return { month: match[2], year: match[3] };
}
/**
* Validiert die geparsten Kantinen-Zeilen (Regeln und Meldungen wie in der
* Desktop-Vorlage):
* 1. Personalnummer: vorhanden und numerisch
* 2. Betrag: vorhanden, deutsches Zahlenformat
* 3. Abrechnung von/bis: Format TT.MM.JJJJ
* 4. von und bis muessen im selben Monat liegen
* 5. Abrechnungsmonat kommt aus "Abrechnung bis" -> MM/YYYY
*/
export function validateKantinenData(rows: KantinenRawRow[]): ValidationResult {
const errors: ValidationError[] = [];
const warnings: ValidationWarning[] = [];
const detectedMonths = new Set<string>();
let abrechnungsMonat: string | null = null;
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
// Echte Dateizeile, falls bekannt; sonst 1-basiert + 1 fuer den Kopf.
const rowNum = row.line ?? i + 2;
if (!row.personalNr || row.personalNr.trim() === '') {
errors.push({
row: rowNum,
field: 'personalNr',
code: 'personalNrMissing',
message: 'Personalnummer fehlt',
});
} else if (!/^\d+$/.test(row.personalNr.trim())) {
errors.push({
row: rowNum,
field: 'personalNr',
code: 'personalNrNotNumeric',
message: 'Personalnummer muss numerisch sein',
});
}
if (!row.betrag || row.betrag.trim() === '') {
errors.push({ row: rowNum, field: 'betrag', code: 'betragMissing', message: 'Betrag fehlt' });
} else if (!isValidGermanNumber(row.betrag)) {
errors.push({
row: rowNum,
field: 'betrag',
code: 'betragFormat',
message: 'Betrag muss im deutschen Zahlenformat vorliegen (Komma als Dezimaltrenner)',
});
}
if (!row.abrechnungVon || row.abrechnungVon.trim() === '') {
errors.push({
row: rowNum,
field: 'abrechnungVon',
code: 'vonMissing',
message: 'Abrechnung von fehlt',
});
} else if (!isValidDate(row.abrechnungVon)) {
errors.push({
row: rowNum,
field: 'abrechnungVon',
code: 'vonFormat',
message: 'Abrechnung von muss im Format TT.MM.JJJJ vorliegen',
});
}
if (!row.abrechnungBis || row.abrechnungBis.trim() === '') {
errors.push({
row: rowNum,
field: 'abrechnungBis',
code: 'bisMissing',
message: 'Abrechnung bis fehlt',
});
} else if (!isValidDate(row.abrechnungBis)) {
errors.push({
row: rowNum,
field: 'abrechnungBis',
code: 'bisFormat',
message: 'Abrechnung bis muss im Format TT.MM.JJJJ vorliegen',
});
}
const vonParsed = extractMonthYear(row.abrechnungVon);
const bisParsed = extractMonthYear(row.abrechnungBis);
if (vonParsed && bisParsed) {
if (vonParsed.month !== bisParsed.month || vonParsed.year !== bisParsed.year) {
errors.push({
row: rowNum,
field: 'abrechnungVon/abrechnungBis',
code: 'multiMonthRange',
message: 'Abrechnungszeitraum erstreckt sich über mehrere Monate',
});
}
}
if (bisParsed) {
detectedMonths.add(`${bisParsed.month}/${bisParsed.year}`);
}
}
if (detectedMonths.size === 1) {
abrechnungsMonat = [...detectedMonths][0];
} else if (detectedMonths.size > 1) {
const months = [...detectedMonths];
warnings.push({
code: 'multipleMonths',
message:
`Verschiedene Abrechnungsmonate erkannt: ${months.join(', ')}. ` +
'Alle Zeilen sollten im selben Abrechnungsmonat liegen.',
params: { months },
});
// Fallback wie in der Vorlage: der erste erkannte Monat.
abrechnungsMonat = months[0];
}
return { isValid: errors.length === 0, errors, warnings, abrechnungsMonat };
}