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