1f85277a3b
- 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>
135 lines
3.7 KiB
TypeScript
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',
|
|
};
|
|
}
|