Files
tessera-ctl/apps/api/src/kantine-datev/kantine-datev.pipeline.ts
T
schalli 1f85277a3b 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>
2026-10-02 11:32:55 +02:00

135 lines
3.7 KiB
TypeScript

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