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', }; }