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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user