Files
tessera-ctl/apps/api/src/kantine-datev/kantine-datev.transformer.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

112 lines
3.3 KiB
TypeScript

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`;
}