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