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,21 @@
import { describe, expect, it } from 'vitest';
import { decodeCsvText } from './decode-csv-text';
describe('decodeCsvText', () => {
it('liest gueltiges UTF-8 unveraendert', () => {
expect(decodeCsvText(Buffer.from('Müller;Straße', 'utf8'))).toBe('Müller;Straße');
});
it('entfernt ein UTF-8-BOM', () => {
const buf = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from('Name;Wert', 'utf8')]);
expect(decodeCsvText(buf)).toBe('Name;Wert');
});
it('faellt bei ungueltigem UTF-8 auf Windows-1252 zurueck (Umlaut)', () => {
expect(decodeCsvText(Buffer.from('Müller', 'latin1'))).toBe('Müller');
});
it('liest das Euro-Zeichen (0x80) in Windows-1252', () => {
expect(decodeCsvText(Buffer.from([0x31, 0x30, 0x80]))).toBe('10€');
});
});
@@ -0,0 +1,18 @@
/**
* Dekodiert hochgeladene CSV-Bytes zu Text (quick-261002-fm5).
*
* Excel und Warenwirtschaftssysteme liefern CSV entweder als UTF-8 (mit oder
* ohne Byte-Order-Mark) oder als Windows-1252. Zuerst wird streng als UTF-8
* gelesen: sind die Bytes kein gueltiges UTF-8 (typisch bei Umlauten in
* Windows-1252), faellt die Funktion auf Windows-1252 zurueck. `TextDecoder`
* verwirft ein fuehrendes BOM standardmaessig.
*
* Gemeinsam genutzt von Kantinenabrechnung und Handelsware.
*/
export function decodeCsvText(buffer: Buffer): string {
try {
return new TextDecoder('utf-8', { fatal: true }).decode(buffer);
} catch {
return new TextDecoder('windows-1252').decode(buffer);
}
}
+2
View File
@@ -26,6 +26,7 @@ import { TenantGuard } from './tenant/tenant.guard';
import { TenantModule } from './tenant/tenant.module';
import { TendersModule } from './tenders/tenders.module';
import { UserModule } from './user/user.module';
import { KantineDatevModule } from './kantine-datev/kantine-datev.module';
import { ProxmoxModule } from './proxmox/proxmox.module';
import { CustomModulesModule } from './custom-modules/custom-modules.module';
import { RemindersModule } from './reminders/reminders.module';
@@ -55,6 +56,7 @@ import { RemindersModule } from './reminders/reminders.module';
TendersModule,
BugReportsModule,
ProxmoxModule,
KantineDatevModule,
CustomModulesModule,
RemindersModule,
],
@@ -0,0 +1,24 @@
import { IsString, Matches } from 'class-validator';
/**
* Einstellungen der Kantinenabrechnung (quick-261002-fm5): drei Nummern, die
* der Administrator einmalig je Mandant hinterlegt. Nur Ziffern (1 bis 10),
* Text statt Zahl, damit fuehrende Nullen erhalten bleiben.
*/
export class KantineDatevSettingsDto {
@IsString({ message: 'Die Beraternummer muss angegeben werden' })
@Matches(/^\d{1,10}$/, {
message: 'Die Beraternummer darf nur Ziffern enthalten (1 bis 10 Stellen)',
})
beraterNr!: string;
@IsString({ message: 'Die Mandantennummer muss angegeben werden' })
@Matches(/^\d{1,10}$/, {
message: 'Die Mandantennummer darf nur Ziffern enthalten (1 bis 10 Stellen)',
})
mandantNr!: string;
@IsString({ message: 'Die Lohnart muss angegeben werden' })
@Matches(/^\d{1,10}$/, { message: 'Die Lohnart darf nur Ziffern enthalten (1 bis 10 Stellen)' })
lohnart!: string;
}
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { parseKantinenCsv } from './kantine-csv.parser';
const HEADER = 'PersNr;Name;Menge;EK;Netto;ZuAb;MwSt;Zuschuss;Betrag;Von;Bis';
const ROW = '100;Muster, Max;1;1,00;1,00;0;0;0;7,94;01.03.2026;31.03.2026';
describe('parseKantinenCsv', () => {
it('liefert bei CRLF und LF dieselben Zeilen', () => {
const lf = parseKantinenCsv([HEADER, ROW, ROW].join('\n'));
const crlf = parseKantinenCsv([HEADER, ROW, ROW].join('\r\n'));
expect(crlf.rows).toEqual(lf.rows);
expect(lf.rows).toHaveLength(2);
expect(lf.errors).toEqual([]);
});
it('ueberspringt leere Zeilen und merkt sich die echte Zeilennummer', () => {
const { rows } = parseKantinenCsv([HEADER, '', ROW, '', ROW, ''].join('\r\n'));
expect(rows.map((r) => r.line)).toEqual([3, 5]);
});
it('meldet einen Kopf mit zu wenigen Spalten mit Hinweis auf das Trennzeichen', () => {
const { rows, errors } = parseKantinenCsv('a,b,c\n1,2,3');
expect(rows).toEqual([]);
expect(errors).toHaveLength(1);
expect(errors[0]).toMatchObject({ row: 1, code: 'headerColumns' });
expect(errors[0].message).toContain('Ist das Trennzeichen korrekt (Semikolon)?');
});
it('meldet eine leere Datei als fehlenden Kopf', () => {
expect(parseKantinenCsv('').errors[0]).toMatchObject({ row: 1, code: 'headerMissing' });
});
it('meldet eine Datenzeile mit zu wenigen Spalten mit Zeilennummer und ueberspringt sie', () => {
const { rows, errors } = parseKantinenCsv([HEADER, ROW, '1;2;3', ROW].join('\n'));
expect(rows).toHaveLength(2);
expect(errors).toEqual([
expect.objectContaining({ row: 3, code: 'columnCount', field: 'zeile' }),
]);
});
it('trimmt Whitespace', () => {
const { rows } = parseKantinenCsv(
[HEADER, ` 100 ; Max ;1;1;1;0;0;0; 7,94 ;01.03.2026;31.03.2026`].join('\n'),
);
expect(rows[0].personalNr).toBe('100');
expect(rows[0].betrag).toBe('7,94');
});
});
@@ -0,0 +1,83 @@
import type { KantinenRawRow, ValidationError } from './kantine-datev.types';
/** Erwartete Anzahl der Spalten pro CSV-Zeile. */
const ERWARTETE_SPALTENANZAHL = 11;
/**
* Parst eine Kantinen-CSV (Semikolon-getrennt, bereits als Text dekodiert).
*
* - Erste Zeile ist der Kopf und wird uebersprungen
* - Leere Zeilen werden ignoriert
* - Whitespace wird getrimmt
* - Falsche Spaltenanzahl erzeugt einen Fehler mit Zeilennummer
*/
export function parseKantinenCsv(content: string): {
rows: KantinenRawRow[];
errors: ValidationError[];
} {
const rows: KantinenRawRow[] = [];
const errors: ValidationError[] = [];
// Unterstuetzt CRLF und LF.
const zeilen = content.split(/\r?\n/);
const headerZeile = zeilen[0]?.trim();
if (!headerZeile) {
errors.push({
row: 1,
field: 'header',
code: 'headerMissing',
message: 'Die Datei enthält keine Header-Zeile.',
});
return { rows, errors };
}
const headerSpalten = headerZeile.split(';').map((s) => s.trim());
if (headerSpalten.length < ERWARTETE_SPALTENANZAHL) {
errors.push({
row: 1,
field: 'header',
code: 'headerColumns',
message: `Header enthält nur ${headerSpalten.length} Spalten, erwartet werden ${ERWARTETE_SPALTENANZAHL}. Ist das Trennzeichen korrekt (Semikolon)?`,
});
return { rows, errors };
}
for (let i = 1; i < zeilen.length; i++) {
const zeile = zeilen[i]?.trim();
const zeilenNummer = i + 1;
if (!zeile) {
continue;
}
const spalten = zeile.split(';').map((s) => s.trim());
if (spalten.length < ERWARTETE_SPALTENANZAHL) {
errors.push({
row: zeilenNummer,
field: 'zeile',
code: 'columnCount',
message: `Zeile hat nur ${spalten.length} Spalten, erwartet werden ${ERWARTETE_SPALTENANZAHL}.`,
});
continue;
}
rows.push({
personalNr: spalten[0],
name: spalten[1],
menge: spalten[2],
ekPreis: spalten[3],
netto: spalten[4],
zuAbschlag: spalten[5],
mwst: spalten[6],
zuschuss: spalten[7],
betrag: spalten[8],
abrechnungVon: spalten[9],
abrechnungBis: spalten[10],
line: zeilenNummer,
});
}
return { rows, errors };
}
@@ -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.',
);
});
});
@@ -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 };
}
@@ -0,0 +1,85 @@
import 'reflect-metadata';
import { BadRequestException, ForbiddenException, ValidationPipe } from '@nestjs/common';
import { Role } from '@prisma/client';
import { describe, expect, it, vi } from 'vitest';
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { MODULE_SLUG_KEY } from '../module-registry/module.guard';
import { KantineDatevSettingsDto } from './dto/kantine-datev-settings.dto';
import { KantineDatevController } from './kantine-datev.controller';
const proto = KantineDatevController.prototype as any;
const req = (tenantId?: string) => ({ tenantId }) as any;
function makeService() {
return {
getSettings: vi.fn(async (..._a: unknown[]) => ({})),
saveSettings: vi.fn(async (..._a: unknown[]) => ({})),
preview: vi.fn(async (..._a: unknown[]) => ({})),
export: vi.fn(async (..._a: unknown[]) => ({})),
};
}
describe('KantineDatevController — Metadaten', () => {
it('haengt an modules/kantine-datev und traegt @UseModule', () => {
expect(Reflect.getMetadata('path', KantineDatevController)).toBe('modules/kantine-datev');
expect(Reflect.getMetadata(MODULE_SLUG_KEY, KantineDatevController)).toBe('kantine-datev');
});
it('PUT settings verlangt ADMIN/SUPER_ADMIN', () => {
expect(Reflect.getMetadata(ROLES_KEY, proto.saveSettings)).toEqual([
Role.ADMIN,
Role.SUPER_ADMIN,
]);
});
it.each(['getSettings', 'preview', 'export'])('%s traegt keine Routen-Rolle', (name) => {
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined();
});
});
describe('KantineDatevController — Verhalten', () => {
it('reicht req.tenantId und den Dateipuffer an den Dienst', async () => {
const service = makeService();
const c = new KantineDatevController(service as any);
const buffer = Buffer.from('x');
await c.getSettings(req('t1'));
await c.preview(req('t1'), { buffer } as any);
await c.export(req('t1'), { buffer } as any);
expect(service.getSettings).toHaveBeenCalledWith('t1');
expect(service.preview).toHaveBeenCalledWith('t1', buffer);
expect(service.export).toHaveBeenCalledWith('t1', buffer);
});
it('antwortet ohne Datei mit 400', async () => {
const c = new KantineDatevController(makeService() as any);
await expect(c.preview(req('t1'), undefined)).rejects.toThrow(BadRequestException);
await expect(c.export(req('t1'), undefined)).rejects.toThrow(BadRequestException);
});
it('antwortet ohne Mandantenkontext mit 403', async () => {
const c = new KantineDatevController(makeService() as any);
await expect(c.getSettings(req(undefined))).rejects.toThrow(ForbiddenException);
});
});
describe('KantineDatevSettingsDto', () => {
const pipe = new ValidationPipe({ whitelist: true, transform: true });
const run = (value: unknown) =>
pipe.transform(value, { type: 'body', metatype: KantineDatevSettingsDto });
it('akzeptiert Ziffernketten (auch mit fuehrender Null)', async () => {
await expect(
run({ beraterNr: '0123456', mandantNr: '12345', lohnart: '1111' }),
).resolves.toBeDefined();
});
it.each([
[{ beraterNr: '12a', mandantNr: '1', lohnart: '1' }],
[{ beraterNr: '1', mandantNr: '', lohnart: '1' }],
[{ beraterNr: '1', mandantNr: '1', lohnart: '12345678901' }],
[{ beraterNr: '1', mandantNr: '1' }],
[{ beraterNr: 1, mandantNr: '1', lohnart: '1' }],
])('lehnt %j ab', async (body) => {
await expect(run(body)).rejects.toThrow(BadRequestException);
});
});
@@ -0,0 +1,78 @@
import {
BadRequestException,
Body,
Controller,
ForbiddenException,
Get,
Post,
Put,
Req,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { Role } from '@prisma/client';
import { Roles } from '../auth/decorators/roles.decorator';
import type { AuthenticatedRequest, UploadedFileLike } from '../auth/types/auth-user';
import { UseModule } from '../module-registry/module.guard';
import { KantineDatevSettingsDto } from './dto/kantine-datev-settings.dto';
import { KantineDatevService } from './kantine-datev.service';
/**
* `@UseModule('kantine-datev')` auf Klassenebene — Aktivierung UND Freigabe.
* `tenantId` kommt ausschliesslich aus `req.tenantId` (TenantGuard). Lesen,
* Vorschau und Export stehen jedem Benutzer mit Modulzugriff offen; die
* Einstellungen aendern nur Administratoren (T-FM5-02). Hochgeladene Dateien
* bleiben im Arbeitsspeicher (multer-Standard), 5 MB Grenze (T-FM5-04).
* Keine `:id`-Routen in diesem Controller.
*/
@Controller('modules/kantine-datev')
@UseModule('kantine-datev')
export class KantineDatevController {
constructor(private readonly service: KantineDatevService) {}
private requireTenantId(req: AuthenticatedRequest): string {
const tenantId = req.tenantId;
if (!tenantId) {
throw new ForbiddenException('Kein Mandantenkontext');
}
return tenantId;
}
@Get('settings')
async getSettings(@Req() req: AuthenticatedRequest) {
return this.service.getSettings(this.requireTenantId(req));
}
@Put('settings')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async saveSettings(@Req() req: AuthenticatedRequest, @Body() dto: KantineDatevSettingsDto) {
return this.service.saveSettings(this.requireTenantId(req), dto);
}
@Post('preview')
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 5 * 1024 * 1024 } }))
async preview(
@Req() req: AuthenticatedRequest,
@UploadedFile() file: UploadedFileLike | undefined,
) {
const tenantId = this.requireTenantId(req);
if (!file) {
throw new BadRequestException('Keine Datei hochgeladen');
}
return this.service.preview(tenantId, file.buffer);
}
@Post('export')
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 5 * 1024 * 1024 } }))
async export(
@Req() req: AuthenticatedRequest,
@UploadedFile() file: UploadedFileLike | undefined,
) {
const tenantId = this.requireTenantId(req);
if (!file) {
throw new BadRequestException('Keine Datei hochgeladen');
}
return this.service.export(tenantId, file.buffer);
}
}
@@ -0,0 +1,31 @@
import { Logger, Module, OnModuleInit } from '@nestjs/common';
import { ModuleRegistryModule } from '../module-registry/module-registry.module';
import { ModuleRegistryService } from '../module-registry/module-registry.service';
import { KantineDatevController } from './kantine-datev.controller';
import { seedKantineDatevModule } from './kantine-datev.seed';
import { KantineDatevService } from './kantine-datev.service';
/**
* Kantinenabrechnung (quick-261002-fm5): Kantinen-CSV pruefen und als
* DATEV-Lohn-ASCII-Datei exportieren. Traegt sich beim Start in die
* Modulverwaltung ein; aktiviert wird per Marktplatz.
*/
@Module({
imports: [ModuleRegistryModule],
controllers: [KantineDatevController],
providers: [KantineDatevService],
})
export class KantineDatevModule implements OnModuleInit {
private readonly logger = new Logger(KantineDatevModule.name);
constructor(private readonly moduleRegistryService: ModuleRegistryService) {}
async onModuleInit(): Promise<void> {
try {
await seedKantineDatevModule(this.moduleRegistryService);
this.logger.log('Kantine-DATEV module seeded in registry');
} catch (error) {
this.logger.error('Failed to seed kantine-datev module', error);
}
}
}
@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest';
import {
buildKantineExport,
KantineExportError,
processKantineCsv,
} from './kantine-datev.pipeline';
const SETTINGS = { beraterNr: '1234567', mandantNr: '12345', lohnart: '1111' };
const HEADER = 'PersNr;Name;Menge;EK;Netto;ZuAb;MwSt;Zuschuss;Betrag;Von;Bis';
const ok = (nr: string, name: string, betrag: string, bis = '31.03.2026') =>
`${nr};${name};1;1,00;1,00;0;0;0;${betrag};01.03.2026;${bis}`;
function csv(lines: string[], eol = '\r\n'): string {
return [HEADER, ...lines].join(eol);
}
describe('processKantineCsv', () => {
it('liest Windows-1252 mit Umlauten und CRLF', () => {
const buf = Buffer.from(
csv([ok('100', 'Müller, Jürgen', '7,94'), ok('200', 'Köhler', '51,5')]),
'latin1',
);
const p = processKantineCsv(buf, SETTINGS);
expect(p.rowCount).toBe(2);
expect(p.abrechnungsMonat).toBe('03/2026');
expect(p.totalCents).toBe(794 + 5150);
expect(p.errors).toEqual([]);
expect(p.canExport).toBe(true);
expect(p.blockedReason).toBeNull();
});
it('liest UTF-8 mit BOM und LF gleich', () => {
const body = csv([ok('100', 'Müller', '7,94')], '\n');
const buf = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(body, 'utf8')]);
const p = processKantineCsv(buf, SETTINGS);
expect(p.rowCount).toBe(1);
expect(p.errors).toEqual([]);
});
it('zaehlt in der Summe nur Zeilen mit gueltigem Betrag', () => {
const p = processKantineCsv(
Buffer.from(csv([ok('100', 'A', '7,94'), ok('101', 'B', 'abc')])),
SETTINGS,
);
expect(p.totalCents).toBe(794);
expect(p.errors).toEqual([expect.objectContaining({ row: 3, code: 'betragFormat' })]);
expect(p.canExport).toBe(false);
expect(p.blockedReason).toBe('errors');
});
it('meldet zwei Monate als Warnung, nicht als Fehler', () => {
const p = processKantineCsv(
Buffer.from(csv([ok('100', 'A', '1,00'), `101;B;1;1;1;0;0;0;1,00;01.04.2026;30.04.2026`])),
SETTINGS,
);
expect(p.warnings).toHaveLength(1);
expect(p.errors).toEqual([]);
expect(p.abrechnungsMonat).toBe('03/2026');
});
it('meldet eine Datei ohne Datenzeilen mit noRows', () => {
const p = processKantineCsv(Buffer.from(csv([])), SETTINGS);
expect(p.rowCount).toBe(0);
expect(p.errors.map((e) => e.code)).toEqual(['noRows']);
expect(p.canExport).toBe(false);
});
it('sperrt ohne Einstellungen mit settingsMissing', () => {
const p = processKantineCsv(Buffer.from(csv([ok('100', 'A', '1,00')])), null);
expect(p.canExport).toBe(false);
expect(p.blockedReason).toBe('settingsMissing');
});
it('gibt keine Zeileninhalte in der Vorschau zurueck', () => {
const p = processKantineCsv(Buffer.from(csv([ok('100', 'Geheimname', 'x')])), SETTINGS);
expect(JSON.stringify(p)).not.toContain('Geheimname');
});
});
describe('buildKantineExport', () => {
it('erzeugt Dateiname und Base64-Inhalt', () => {
const r = buildKantineExport(Buffer.from(csv([ok('100', 'A', '7,94')])), SETTINGS);
expect(r.filename).toBe('LuG_1234567_12345_03_2026.sic');
expect(r.mimeType).toBe('text/plain');
expect(Buffer.from(r.content, 'base64').toString('utf8')).toBe(
'1234567\t12345\t03/2026\t\t\t\t\t\t\t\t\r\n\t100\t\t1111\t-7.94\t\t\t\t\t\t\r\n',
);
});
it('verweigert den Export bei Fehlern', () => {
expect(() => buildKantineExport(Buffer.from(csv([ok('x', 'A', '7,94')])), SETTINGS)).toThrow(
KantineExportError,
);
try {
buildKantineExport(Buffer.from(csv([ok('x', 'A', '7,94')])), SETTINGS);
} catch (e) {
expect((e as KantineExportError).code).toBe('hasErrors');
}
});
it('verweigert den Export ohne Einstellungen', () => {
try {
buildKantineExport(Buffer.from(csv([ok('100', 'A', '7,94')])), null);
expect.unreachable();
} catch (e) {
expect((e as KantineExportError).code).toBe('settingsMissing');
}
});
});
@@ -0,0 +1,134 @@
import { decodeCsvText } from '../accounting/decode-csv-text';
import { parseKantinenCsv } from './kantine-csv.parser';
import { isValidGermanNumber, validateKantinenData } from './kantine-csv.validator';
import {
buildKantineExportFilename,
generateDatevOutput,
qualityCheck,
transformToDatevRecords,
} from './kantine-datev.transformer';
import type {
FileResponse,
KantineDatevSettings,
KantinenRawRow,
KantinePreview,
ValidationError,
ValidationResult,
} from './kantine-datev.types';
/**
* Verarbeitungskette der Kantinenabrechnung (quick-261002-fm5):
* dekodieren -> parsen -> validieren -> Summe -> Vorschau bzw. Export.
*
* Reine Funktionen ohne Datenbank, Datei oder Protokollausgabe: hochgeladene
* Zeilen (Namen, Personalnummern) verlassen den Arbeitsspeicher nie.
*/
/** Grund, warum ein Export abgelehnt wurde. */
export type KantineExportErrorCode = 'settingsMissing' | 'hasErrors' | 'qualityCheckFailed';
export class KantineExportError extends Error {
constructor(
readonly code: KantineExportErrorCode,
message: string,
readonly errors: ValidationError[] = [],
) {
super(message);
this.name = 'KantineExportError';
}
}
interface Analysis {
rows: KantinenRawRow[];
errors: ValidationError[];
validation: ValidationResult;
}
function analyze(buffer: Buffer): Analysis {
const text = decodeCsvText(buffer);
const parsed = parseKantinenCsv(text);
const validation = validateKantinenData(parsed.rows);
const errors = [...parsed.errors, ...validation.errors].sort((a, b) => a.row - b.row);
if (parsed.rows.length === 0 && parsed.errors.length === 0) {
errors.push({
row: 1,
field: 'datei',
code: 'noRows',
message: 'Die Datei enthält keine Datenzeilen.',
});
}
return { rows: parsed.rows, errors, validation };
}
/** Summe der Betraege in Cent; nur Zeilen, deren Betrag das Format besteht. */
function sumCents(rows: KantinenRawRow[]): number {
let total = 0;
for (const row of rows) {
if (!isValidGermanNumber(row.betrag)) continue;
total += Math.round(Number(row.betrag.trim().replace(',', '.')) * 100);
}
return total;
}
export function processKantineCsv(
buffer: Buffer,
settings: KantineDatevSettings | null,
): KantinePreview {
const { rows, errors, validation } = analyze(buffer);
let blockedReason: KantinePreview['blockedReason'] = null;
if (!settings) {
blockedReason = 'settingsMissing';
} else if (errors.length > 0) {
blockedReason = 'errors';
}
return {
rowCount: rows.length,
abrechnungsMonat: validation.abrechnungsMonat,
totalCents: sumCents(rows),
errors,
warnings: validation.warnings,
canExport: blockedReason === null,
blockedReason,
};
}
export function buildKantineExport(
buffer: Buffer,
settings: KantineDatevSettings | null,
): FileResponse {
if (!settings) {
throw new KantineExportError(
'settingsMissing',
'Beraternummer, Mandantennummer und Lohnart sind noch nicht hinterlegt.',
);
}
const { rows, errors, validation } = analyze(buffer);
if (errors.length > 0 || !validation.abrechnungsMonat) {
throw new KantineExportError(
'hasErrors',
'Die Datei enthält Fehler und kann nicht exportiert werden.',
errors,
);
}
const records = transformToDatevRecords(rows);
const output = generateDatevOutput(records, validation.abrechnungsMonat, settings);
const check = qualityCheck(output);
if (!check.passed) {
throw new KantineExportError(
'qualityCheckFailed',
`Qualitätsprüfung fehlgeschlagen: ${check.errors.join('; ')}`,
);
}
return {
filename: buildKantineExportFilename(settings, validation.abrechnungsMonat),
content: Buffer.from(output, 'utf8').toString('base64'),
mimeType: 'text/plain',
};
}
@@ -0,0 +1,23 @@
import { ModuleRegistryService } from '../module-registry/module-registry.service';
/**
* Traegt das Modul "Kantinenabrechnung" in die Modulverwaltung ein
* (quick-261002-fm5). `isSystem: true` legt den Eintrag an, aktiviert ihn aber
* NICHT je Mandant — der Administrator aktiviert ueber den Marktplatz und
* erteilt die Freigabe.
*/
export async function seedKantineDatevModule(
moduleRegistryService: ModuleRegistryService,
): Promise<void> {
await moduleRegistryService.seedModule({
slug: 'kantine-datev',
name: 'Kantinenabrechnung',
version: '1.0.0',
category: 'accounting',
description: {
de: 'Kantinen-CSV prüfen und als DATEV-Lohndatei (ASCII) für die Gehaltsabrechnung exportieren',
en: 'Check canteen CSV files and export them as a DATEV payroll ASCII file',
},
isSystem: true,
});
}
@@ -0,0 +1,83 @@
import { BadRequestException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((p: unknown) => p),
}));
import { forTenant } from '../prisma/prisma-tenant.extension';
import { KantineDatevService } from './kantine-datev.service';
const HEADER = 'PersNr;Name;Menge;EK;Netto;ZuAb;MwSt;Zuschuss;Betrag;Von;Bis';
const GOOD = Buffer.from(
[HEADER, '100;Max;1;1,00;1,00;0;0;0;7,94;01.03.2026;31.03.2026'].join('\r\n'),
);
function setup(row: Record<string, string | null> | null = null) {
const kantineDatevConfig = {
findUnique: vi.fn(async () => row),
upsert: vi.fn(async ({ create }: any) => create),
};
return { prisma: { kantineDatevConfig }, kantineDatevConfig };
}
describe('KantineDatevService — Einstellungen', () => {
it('liefert ohne Zeile leere Werte und configured=false', async () => {
const { prisma } = setup(null);
const res = await new KantineDatevService(prisma as any).getSettings('t1');
expect(res).toEqual({ beraterNr: null, mandantNr: null, lohnart: null, configured: false });
expect(forTenant).toHaveBeenCalledWith(prisma, 't1');
});
it('configured=false, solange ein Feld fehlt', async () => {
const { prisma } = setup({ beraterNr: '1', mandantNr: null, lohnart: '3' });
expect((await new KantineDatevService(prisma as any).getSettings('t1')).configured).toBe(false);
});
it('speichert per upsert auf tenantId (aus dem Argument)', async () => {
const { prisma, kantineDatevConfig } = setup();
const res = await new KantineDatevService(prisma as any).saveSettings('t1', {
beraterNr: '1234567',
mandantNr: '12345',
lohnart: '1111',
});
expect(kantineDatevConfig.upsert).toHaveBeenCalledWith(
expect.objectContaining({ where: { tenantId: 't1' } }),
);
expect(res.configured).toBe(true);
});
});
describe('KantineDatevService — Vorschau und Export', () => {
it('Vorschau ohne Einstellungen sperrt mit settingsMissing', async () => {
const { prisma } = setup(null);
const p = await new KantineDatevService(prisma as any).preview('t1', GOOD);
expect(p.blockedReason).toBe('settingsMissing');
expect(p.rowCount).toBe(1);
});
it('Export ohne Einstellungen -> 400 mit code settingsMissing', async () => {
const { prisma } = setup(null);
const err: any = await new KantineDatevService(prisma as any)
.export('t1', GOOD)
.catch((e) => e);
expect(err).toBeInstanceOf(BadRequestException);
expect(err.getResponse().code).toBe('settingsMissing');
});
it('Export mit Fehlern -> 400 mit code hasErrors und Fehlerliste', async () => {
const { prisma } = setup({ beraterNr: '1', mandantNr: '2', lohnart: '3' });
const bad = Buffer.from(
[HEADER, 'x;Max;1;1,00;1,00;0;0;0;7,94;01.03.2026;31.03.2026'].join('\n'),
);
const err: any = await new KantineDatevService(prisma as any).export('t1', bad).catch((e) => e);
expect(err.getResponse().code).toBe('hasErrors');
expect(err.getResponse().errors).toHaveLength(1);
});
it('Export mit Einstellungen liefert Datei', async () => {
const { prisma } = setup({ beraterNr: '1234567', mandantNr: '12345', lohnart: '1111' });
const res = await new KantineDatevService(prisma as any).export('t1', GOOD);
expect(res.filename).toBe('LuG_1234567_12345_03_2026.sic');
});
});
@@ -0,0 +1,84 @@
import { BadRequestException, Injectable, UnprocessableEntityException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import type { KantineDatevSettingsDto } from './dto/kantine-datev-settings.dto';
import {
buildKantineExport,
KantineExportError,
processKantineCsv,
} from './kantine-datev.pipeline';
import type { FileResponse, KantineDatevSettings, KantinePreview } from './kantine-datev.types';
export interface KantineSettingsResponse {
beraterNr: string | null;
mandantNr: string | null;
lohnart: string | null;
configured: boolean;
}
/**
* Kantinenabrechnung (quick-261002-fm5). Die Einstellungen liegen je Mandant
* in `KantineDatevConfig` (mandantengebunden); die hochgeladene CSV wird nur
* im Arbeitsspeicher verarbeitet und weder gespeichert noch protokolliert.
*/
@Injectable()
export class KantineDatevService {
constructor(private readonly prisma: PrismaService) {}
async getSettings(tenantId: string): Promise<KantineSettingsResponse> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const row = await tenantPrisma.kantineDatevConfig.findUnique({ where: { tenantId } });
const beraterNr = row?.beraterNr ?? null;
const mandantNr = row?.mandantNr ?? null;
const lohnart = row?.lohnart ?? null;
return {
beraterNr,
mandantNr,
lohnart,
configured: Boolean(beraterNr && mandantNr && lohnart),
};
}
async saveSettings(
tenantId: string,
dto: KantineDatevSettingsDto,
): Promise<KantineSettingsResponse> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const data = { beraterNr: dto.beraterNr, mandantNr: dto.mandantNr, lohnart: dto.lohnart };
await tenantPrisma.kantineDatevConfig.upsert({
where: { tenantId },
create: { tenantId, ...data },
update: data,
});
return { ...data, configured: true };
}
private async loadConfigured(tenantId: string): Promise<KantineDatevSettings | null> {
const s = await this.getSettings(tenantId);
if (!s.configured || !s.beraterNr || !s.mandantNr || !s.lohnart) return null;
return { beraterNr: s.beraterNr, mandantNr: s.mandantNr, lohnart: s.lohnart };
}
async preview(tenantId: string, buffer: Buffer): Promise<KantinePreview> {
return processKantineCsv(buffer, await this.loadConfigured(tenantId));
}
async export(tenantId: string, buffer: Buffer): Promise<FileResponse> {
const settings = await this.loadConfigured(tenantId);
try {
return buildKantineExport(buffer, settings);
} catch (error) {
if (error instanceof KantineExportError) {
if (error.code === 'qualityCheckFailed') {
throw new UnprocessableEntityException({ code: error.code, message: error.message });
}
throw new BadRequestException({
code: error.code,
message: error.message,
errors: error.errors,
});
}
throw error;
}
}
}
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest';
import {
buildKantineExportFilename,
generateDatevOutput,
qualityCheck,
transformBetrag,
} from './kantine-datev.transformer';
const SETTINGS = { beraterNr: '1234567', mandantNr: '12345', lohnart: '1111' };
describe('transformBetrag', () => {
it('macht den Betrag negativ mit Punkt und zwei Nachkommastellen', () => {
expect(transformBetrag('7,94')).toBe('-7.94');
expect(transformBetrag('51,5')).toBe('-51.50');
expect(transformBetrag('0,00')).toBe('-0.00');
});
});
describe('generateDatevOutput', () => {
const out = generateDatevOutput(
[
{ personalNr: '100', betrag: '-7.94' },
{ personalNr: '200', betrag: '-51.50' },
],
'03/2026',
SETTINGS,
);
it('schreibt den Kopf aus den Einstellungen mit 8 leeren Spalten', () => {
const lines = out.split('\r\n');
expect(lines[0]).toBe('1234567\t12345\t03/2026\t\t\t\t\t\t\t\t');
});
it('schreibt Detailzeilen mit Lohnart und Betrag', () => {
const lines = out.split('\r\n');
expect(lines[1]).toBe('\t100\t\t1111\t-7.94\t\t\t\t\t\t');
expect(lines[2]).toBe('\t200\t\t1111\t-51.50\t\t\t\t\t\t');
});
it('hat in jeder Zeile 11 Spalten, CRLF und ein abschliessendes CRLF', () => {
expect(out.endsWith('\r\n')).toBe(true);
expect(out.replace(/\r\n/g, '')).not.toContain('\n');
for (const line of out.split('\r\n').slice(0, -1)) {
expect(line.split('\t')).toHaveLength(11);
}
});
it('besteht die Qualitaetspruefung', () => {
expect(qualityCheck(out)).toEqual({ passed: true, errors: [] });
});
});
describe('qualityCheck', () => {
it('lehnt eine Datei nur mit LF ab', () => {
const r = qualityCheck('a\tb\t\t\t\t\t\t\t\t\t\n');
expect(r.passed).toBe(false);
expect(r.errors).toContain('Datei endet nicht mit CRLF');
expect(r.errors).toContain('Datei enthaelt einzelne LF-Zeilenenden (nur CRLF erlaubt)');
});
it('lehnt eine Zeile mit 10 Spalten ab', () => {
const r = qualityCheck('a\t\t\t\t\t\t\t\t\t\r\n');
expect(r.passed).toBe(false);
expect(r.errors[0]).toBe('Zeile 1: 10 Spalten gefunden, 11 erwartet');
});
it('lehnt einen positiven Betrag ab', () => {
const r = qualityCheck(`k\t\t\t\t\t\t\t\t\t\t\r\n\t1\t\t1111\t7.94\t\t\t\t\t\t\r\n`);
expect(r.passed).toBe(false);
expect(r.errors[0]).toContain('Betrag "7.94" ist nicht im Format -X.XX');
});
});
describe('buildKantineExportFilename', () => {
it('folgt LuG_<Berater>_<Mandant>_<MM>_<YYYY>.sic', () => {
expect(buildKantineExportFilename(SETTINGS, '03/2026')).toBe('LuG_1234567_12345_03_2026.sic');
});
});
@@ -0,0 +1,111 @@
import type { DatevRecord, KantineDatevSettings, KantinenRawRow } from './kantine-datev.types';
const SPALTEN_ANZAHL = 11;
const TAB = '\t';
const CRLF = '\r\n';
/**
* Transformiert einen Betrag gemaess DATEV-Regel: Komma -> Punkt, immer
* negativ, exakt 2 Nachkommastellen. "7,94" -> "-7.94", "51,5" -> "-51.50".
*/
export function transformBetrag(betrag: string): string {
const cleaned = betrag.trim().replace(',', '.');
const num = parseFloat(cleaned);
const absValue = Math.abs(num);
return `-${absValue.toFixed(2)}`;
}
export function transformToDatevRecords(rows: KantinenRawRow[]): DatevRecord[] {
return rows.map((row) => ({
personalNr: row.personalNr.trim(),
betrag: transformBetrag(row.betrag),
}));
}
/**
* Generiert die komplette DATEV-Lohn-ASCII-Datei.
*
* Kopf: Beraternr TAB Mandantennr TAB MM/YYYY + 8 leere Spalten
* Detail: TAB PersonalNr TAB TAB Lohnart TAB -Betrag + 6 leere Spalten
*
* Exakt 11 Spalten je Zeile, CRLF-Zeilenenden, die Datei endet mit CRLF.
* Berater-, Mandantennummer und Lohnart kommen aus den Einstellungen des
* Mandanten, nicht aus festen Werten.
*/
export function generateDatevOutput(
records: DatevRecord[],
abrechnungsMonat: string,
settings: KantineDatevSettings,
): string {
const lines: string[] = [];
lines.push(
[settings.beraterNr, settings.mandantNr, abrechnungsMonat, '', '', '', '', '', '', '', ''].join(
TAB,
),
);
for (const record of records) {
lines.push(
['', record.personalNr, '', settings.lohnart, record.betrag, '', '', '', '', '', ''].join(
TAB,
),
);
}
return lines.join(CRLF) + CRLF;
}
/**
* Qualitaetspruefung der erzeugten Ausgabe: 11 Spalten je Zeile, nur CRLF,
* Datei endet mit CRLF, alle Betraege negativ mit 2 Nachkommastellen.
*/
export function qualityCheck(output: string): { passed: boolean; errors: string[] } {
const errors: string[] = [];
if (!output.endsWith(CRLF)) {
errors.push('Datei endet nicht mit CRLF');
}
const ohneCarriageReturn = output.replace(/\r\n/g, '');
if (ohneCarriageReturn.includes('\n')) {
errors.push('Datei enthaelt einzelne LF-Zeilenenden (nur CRLF erlaubt)');
}
const zeilen = output.split(CRLF);
const inhaltZeilen = zeilen.slice(0, -1);
if (inhaltZeilen.length === 0) {
errors.push('Datei enthaelt keine Zeilen');
return { passed: false, errors };
}
for (let i = 0; i < inhaltZeilen.length; i++) {
const spalten = inhaltZeilen[i].split(TAB);
if (spalten.length !== SPALTEN_ANZAHL) {
errors.push(`Zeile ${i + 1}: ${spalten.length} Spalten gefunden, ${SPALTEN_ANZAHL} erwartet`);
}
}
const betragRegex = /^-\d+\.\d{2}$/;
for (let i = 1; i < inhaltZeilen.length; i++) {
const spalten = inhaltZeilen[i].split(TAB);
const betrag = spalten[4];
if (betrag && !betragRegex.test(betrag)) {
errors.push(
`Zeile ${i + 1}: Betrag "${betrag}" ist nicht im Format -X.XX (negativ, 2 Nachkommastellen)`,
);
}
}
return { passed: errors.length === 0, errors };
}
/** Dateiname des Downloads: LuG_<Beraternr>_<Mandantennr>_<MM>_<YYYY>.sic */
export function buildKantineExportFilename(
settings: KantineDatevSettings,
abrechnungsMonat: string,
): string {
const [monat, jahr] = abrechnungsMonat.split('/');
return `LuG_${settings.beraterNr}_${settings.mandantNr}_${monat}_${jahr}.sic`;
}
@@ -0,0 +1,93 @@
/**
* Typen der Kantinenabrechnung (quick-261002-fm5). Portiert aus der
* Desktop-Vorlage; jeder Fehler und jede Warnung traegt zusaetzlich eine
* stabile Kennung (`code`), damit die Oberflaeche den Text uebersetzen kann,
* waehrend `message` den deutschen Originaltext behaelt.
*/
/** Rohe CSV-Zeile nach dem Parsen. */
export interface KantinenRawRow {
personalNr: string;
name: string;
menge: string;
ekPreis: string;
netto: string;
zuAbschlag: string;
mwst: string;
zuschuss: string;
betrag: string;
abrechnungVon: string;
abrechnungBis: string;
/** 1-basierte Zeilennummer in der Datei (fuer Fehlermeldungen). */
line?: number;
}
/** Validierter und transformierter Datensatz. */
export interface DatevRecord {
personalNr: string;
/** z. B. "-51.50" (immer negativ, Punkt, 2 Nachkommastellen) */
betrag: string;
}
export type KantineErrorCode =
| 'headerMissing'
| 'headerColumns'
| 'columnCount'
| 'personalNrMissing'
| 'personalNrNotNumeric'
| 'betragMissing'
| 'betragFormat'
| 'vonMissing'
| 'vonFormat'
| 'bisMissing'
| 'bisFormat'
| 'multiMonthRange'
| 'noRows';
export interface ValidationError {
row: number;
field: string;
code: KantineErrorCode;
message: string;
}
export interface ValidationWarning {
code: 'multipleMonths';
message: string;
params: { months: string[] };
}
export interface ValidationResult {
isValid: boolean;
errors: ValidationError[];
warnings: ValidationWarning[];
/** Format "MM/YYYY" */
abrechnungsMonat: string | null;
}
/** Nummern, die der Administrator je Mandant hinterlegt. */
export interface KantineDatevSettings {
beraterNr: string;
mandantNr: string;
lohnart: string;
}
export type KantineBlockedReason = 'settingsMissing' | 'errors';
export interface KantinePreview {
rowCount: number;
abrechnungsMonat: string | null;
/** Summe der gueltigen Betraege in Cent. */
totalCents: number;
errors: ValidationError[];
warnings: ValidationWarning[];
canExport: boolean;
blockedReason: KantineBlockedReason | null;
}
export interface FileResponse {
filename: string;
/** Base64 */
content: string;
mimeType: string;
}