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,81 @@
import { describe, expect, it } from 'vitest';
import { validateKantinenData } from './kantine-csv.validator';
import type { KantinenRawRow } from './kantine-datev.types';
function row(over: Partial<KantinenRawRow> = {}): KantinenRawRow {
return {
personalNr: '100',
name: 'Max Muster',
menge: '1',
ekPreis: '1,00',
netto: '1,00',
zuAbschlag: '0',
mwst: '0',
zuschuss: '0',
betrag: '7,94',
abrechnungVon: '01.03.2026',
abrechnungBis: '31.03.2026',
...over,
};
}
describe('validateKantinenData', () => {
it('akzeptiert eine gueltige Zeile und bestimmt den Monat aus "bis"', () => {
const result = validateKantinenData([row()]);
expect(result.isValid).toBe(true);
expect(result.abrechnungsMonat).toBe('03/2026');
});
it('meldet nicht numerische und fehlende Personalnummern', () => {
const r = validateKantinenData([row({ personalNr: 'A12' }), row({ personalNr: '' })]);
expect(r.errors.map((e) => e.code)).toEqual(['personalNrNotNumeric', 'personalNrMissing']);
expect(r.errors[0].message).toBe('Personalnummer muss numerisch sein');
});
it('lehnt Betraege mit Tausenderpunkt oder Minus ab (wie die Vorlage)', () => {
const r = validateKantinenData([
row({ betrag: '1.234,56' }),
row({ betrag: '-5,00' }),
row({ betrag: '' }),
]);
expect(r.errors.map((e) => e.code)).toEqual(['betragFormat', 'betragFormat', 'betragMissing']);
});
it('prueft das Datumsformat', () => {
const r = validateKantinenData([row({ abrechnungVon: '2026-03-01', abrechnungBis: '' })]);
expect(r.errors.map((e) => e.code)).toEqual(['vonFormat', 'bisMissing']);
});
it('meldet von/bis in verschiedenen Monaten mit Zeile = Index + 2', () => {
const r = validateKantinenData([row(), row({ abrechnungVon: '28.02.2026' })]);
expect(r.errors).toEqual([
expect.objectContaining({
row: 3,
code: 'multiMonthRange',
field: 'abrechnungVon/abrechnungBis',
}),
]);
});
it('nimmt die echte Dateizeile, wenn der Parser sie mitliefert', () => {
const r = validateKantinenData([row({ personalNr: 'x', line: 9 })]);
expect(r.errors[0].row).toBe(9);
});
it('warnt bei zwei Abrechnungsmonaten und behaelt den ersten', () => {
const r = validateKantinenData([
row(),
row({ abrechnungVon: '01.04.2026', abrechnungBis: '30.04.2026' }),
]);
expect(r.isValid).toBe(true);
expect(r.abrechnungsMonat).toBe('03/2026');
expect(r.warnings).toHaveLength(1);
expect(r.warnings[0]).toMatchObject({
code: 'multipleMonths',
params: { months: ['03/2026', '04/2026'] },
});
expect(r.warnings[0].message).toBe(
'Verschiedene Abrechnungsmonate erkannt: 03/2026, 04/2026. Alle Zeilen sollten im selben Abrechnungsmonat liegen.',
);
});
});