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,46 @@
|
||||
-- 261002-fm5 — Finanzbuchhaltung: Modul "Kantinenabrechnung" (kantine-datev).
|
||||
--
|
||||
-- Zweck: eine neue Tabelle `KantineDatevConfig` mit den drei Nummern, die der
|
||||
-- Administrator einmalig je Mandant hinterlegt (Beraternummer, Mandantennummer,
|
||||
-- Lohnart). Eine Zeile je Mandant (Singleton, Vorbild `DkvModuleConfig`). Die
|
||||
-- Felder sind Text, damit fuehrende Nullen erhalten bleiben, und haben
|
||||
-- ABSICHTLICH keinen Standardwert: solange sie leer sind, sperrt das Modul die
|
||||
-- Verarbeitung. Die hochgeladene Kantinen-CSV wird nicht gespeichert.
|
||||
--
|
||||
-- Von Hand geschrieben (Vorbild 20260923140000_proxmox_server), von Hand
|
||||
-- gepflegter Kopfkommentar Pflicht bei jeder RLS-Migration in diesem Projekt.
|
||||
--
|
||||
-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): die Tabelle
|
||||
-- traegt `tenantId` und `tenant_isolation_policy` OHNE Benutzerdimension
|
||||
-- (`USING ("tenantId" = current_tenant_id())`, Form aus `DkvModuleConfig`) —
|
||||
-- Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines Benutzers.
|
||||
-- Keine `system_read_policy`: es gibt keinen Hintergrunddienst, der diese
|
||||
-- Einstellungen ueber alle Mandanten liest.
|
||||
--
|
||||
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
|
||||
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
|
||||
-- tun.
|
||||
--
|
||||
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
|
||||
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
|
||||
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
|
||||
|
||||
CREATE TABLE "KantineDatevConfig" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"beraterNr" TEXT,
|
||||
"mandantNr" TEXT,
|
||||
"lohnart" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "KantineDatevConfig_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "KantineDatevConfig_tenantId_key" ON "KantineDatevConfig"("tenantId");
|
||||
CREATE INDEX "KantineDatevConfig_tenantId_idx" ON "KantineDatevConfig"("tenantId");
|
||||
|
||||
ALTER TABLE "KantineDatevConfig" ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "KantineDatevConfig" FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation_policy ON "KantineDatevConfig"
|
||||
USING ("tenantId" = current_tenant_id());
|
||||
@@ -344,6 +344,23 @@ model DkvModuleConfig {
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
||||
// quick-261002-fm5: Kantinenabrechnung (Modul kantine-datev). Eine Zeile je
|
||||
// Mandant (Singleton wie DkvModuleConfig). Die drei Nummern stehen als Text,
|
||||
// damit fuehrende Nullen erhalten bleiben; sie haben bewusst KEINEN
|
||||
// Standardwert — der Administrator hinterlegt sie einmalig, bis dahin ist die
|
||||
// Verarbeitung gesperrt. Die hochgeladene CSV selbst wird nie gespeichert.
|
||||
model KantineDatevConfig {
|
||||
id String @id @default(uuid())
|
||||
tenantId String @unique
|
||||
beraterNr String?
|
||||
mandantNr String?
|
||||
lohnart String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
||||
// Phase 14, Plan 03 (INGEST-05, CONFIG-02, D-06/D-07) — per-tenant portal-
|
||||
// alert mailbox config, mirroring DkvModuleConfig's shape/pattern exactly
|
||||
// (own tenantId @unique row, own encrypted creds — D-03: each module keeps
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { decodeCsvText } from './decode-csv-text';
|
||||
|
||||
describe('decodeCsvText', () => {
|
||||
it('liest gueltiges UTF-8 unveraendert', () => {
|
||||
expect(decodeCsvText(Buffer.from('Müller;Straße', 'utf8'))).toBe('Müller;Straße');
|
||||
});
|
||||
|
||||
it('entfernt ein UTF-8-BOM', () => {
|
||||
const buf = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from('Name;Wert', 'utf8')]);
|
||||
expect(decodeCsvText(buf)).toBe('Name;Wert');
|
||||
});
|
||||
|
||||
it('faellt bei ungueltigem UTF-8 auf Windows-1252 zurueck (Umlaut)', () => {
|
||||
expect(decodeCsvText(Buffer.from('Müller', 'latin1'))).toBe('Müller');
|
||||
});
|
||||
|
||||
it('liest das Euro-Zeichen (0x80) in Windows-1252', () => {
|
||||
expect(decodeCsvText(Buffer.from([0x31, 0x30, 0x80]))).toBe('10€');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Dekodiert hochgeladene CSV-Bytes zu Text (quick-261002-fm5).
|
||||
*
|
||||
* Excel und Warenwirtschaftssysteme liefern CSV entweder als UTF-8 (mit oder
|
||||
* ohne Byte-Order-Mark) oder als Windows-1252. Zuerst wird streng als UTF-8
|
||||
* gelesen: sind die Bytes kein gueltiges UTF-8 (typisch bei Umlauten in
|
||||
* Windows-1252), faellt die Funktion auf Windows-1252 zurueck. `TextDecoder`
|
||||
* verwirft ein fuehrendes BOM standardmaessig.
|
||||
*
|
||||
* Gemeinsam genutzt von Kantinenabrechnung und Handelsware.
|
||||
*/
|
||||
export function decodeCsvText(buffer: Buffer): string {
|
||||
try {
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(buffer);
|
||||
} catch {
|
||||
return new TextDecoder('windows-1252').decode(buffer);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import { TenantGuard } from './tenant/tenant.guard';
|
||||
import { TenantModule } from './tenant/tenant.module';
|
||||
import { TendersModule } from './tenders/tenders.module';
|
||||
import { UserModule } from './user/user.module';
|
||||
import { KantineDatevModule } from './kantine-datev/kantine-datev.module';
|
||||
import { ProxmoxModule } from './proxmox/proxmox.module';
|
||||
import { CustomModulesModule } from './custom-modules/custom-modules.module';
|
||||
import { RemindersModule } from './reminders/reminders.module';
|
||||
@@ -55,6 +56,7 @@ import { RemindersModule } from './reminders/reminders.module';
|
||||
TendersModule,
|
||||
BugReportsModule,
|
||||
ProxmoxModule,
|
||||
KantineDatevModule,
|
||||
CustomModulesModule,
|
||||
RemindersModule,
|
||||
],
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { IsString, Matches } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Einstellungen der Kantinenabrechnung (quick-261002-fm5): drei Nummern, die
|
||||
* der Administrator einmalig je Mandant hinterlegt. Nur Ziffern (1 bis 10),
|
||||
* Text statt Zahl, damit fuehrende Nullen erhalten bleiben.
|
||||
*/
|
||||
export class KantineDatevSettingsDto {
|
||||
@IsString({ message: 'Die Beraternummer muss angegeben werden' })
|
||||
@Matches(/^\d{1,10}$/, {
|
||||
message: 'Die Beraternummer darf nur Ziffern enthalten (1 bis 10 Stellen)',
|
||||
})
|
||||
beraterNr!: string;
|
||||
|
||||
@IsString({ message: 'Die Mandantennummer muss angegeben werden' })
|
||||
@Matches(/^\d{1,10}$/, {
|
||||
message: 'Die Mandantennummer darf nur Ziffern enthalten (1 bis 10 Stellen)',
|
||||
})
|
||||
mandantNr!: string;
|
||||
|
||||
@IsString({ message: 'Die Lohnart muss angegeben werden' })
|
||||
@Matches(/^\d{1,10}$/, { message: 'Die Lohnart darf nur Ziffern enthalten (1 bis 10 Stellen)' })
|
||||
lohnart!: string;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseKantinenCsv } from './kantine-csv.parser';
|
||||
|
||||
const HEADER = 'PersNr;Name;Menge;EK;Netto;ZuAb;MwSt;Zuschuss;Betrag;Von;Bis';
|
||||
const ROW = '100;Muster, Max;1;1,00;1,00;0;0;0;7,94;01.03.2026;31.03.2026';
|
||||
|
||||
describe('parseKantinenCsv', () => {
|
||||
it('liefert bei CRLF und LF dieselben Zeilen', () => {
|
||||
const lf = parseKantinenCsv([HEADER, ROW, ROW].join('\n'));
|
||||
const crlf = parseKantinenCsv([HEADER, ROW, ROW].join('\r\n'));
|
||||
expect(crlf.rows).toEqual(lf.rows);
|
||||
expect(lf.rows).toHaveLength(2);
|
||||
expect(lf.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('ueberspringt leere Zeilen und merkt sich die echte Zeilennummer', () => {
|
||||
const { rows } = parseKantinenCsv([HEADER, '', ROW, '', ROW, ''].join('\r\n'));
|
||||
expect(rows.map((r) => r.line)).toEqual([3, 5]);
|
||||
});
|
||||
|
||||
it('meldet einen Kopf mit zu wenigen Spalten mit Hinweis auf das Trennzeichen', () => {
|
||||
const { rows, errors } = parseKantinenCsv('a,b,c\n1,2,3');
|
||||
expect(rows).toEqual([]);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toMatchObject({ row: 1, code: 'headerColumns' });
|
||||
expect(errors[0].message).toContain('Ist das Trennzeichen korrekt (Semikolon)?');
|
||||
});
|
||||
|
||||
it('meldet eine leere Datei als fehlenden Kopf', () => {
|
||||
expect(parseKantinenCsv('').errors[0]).toMatchObject({ row: 1, code: 'headerMissing' });
|
||||
});
|
||||
|
||||
it('meldet eine Datenzeile mit zu wenigen Spalten mit Zeilennummer und ueberspringt sie', () => {
|
||||
const { rows, errors } = parseKantinenCsv([HEADER, ROW, '1;2;3', ROW].join('\n'));
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(errors).toEqual([
|
||||
expect.objectContaining({ row: 3, code: 'columnCount', field: 'zeile' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('trimmt Whitespace', () => {
|
||||
const { rows } = parseKantinenCsv(
|
||||
[HEADER, ` 100 ; Max ;1;1;1;0;0;0; 7,94 ;01.03.2026;31.03.2026`].join('\n'),
|
||||
);
|
||||
expect(rows[0].personalNr).toBe('100');
|
||||
expect(rows[0].betrag).toBe('7,94');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { KantinenRawRow, ValidationError } from './kantine-datev.types';
|
||||
|
||||
/** Erwartete Anzahl der Spalten pro CSV-Zeile. */
|
||||
const ERWARTETE_SPALTENANZAHL = 11;
|
||||
|
||||
/**
|
||||
* Parst eine Kantinen-CSV (Semikolon-getrennt, bereits als Text dekodiert).
|
||||
*
|
||||
* - Erste Zeile ist der Kopf und wird uebersprungen
|
||||
* - Leere Zeilen werden ignoriert
|
||||
* - Whitespace wird getrimmt
|
||||
* - Falsche Spaltenanzahl erzeugt einen Fehler mit Zeilennummer
|
||||
*/
|
||||
export function parseKantinenCsv(content: string): {
|
||||
rows: KantinenRawRow[];
|
||||
errors: ValidationError[];
|
||||
} {
|
||||
const rows: KantinenRawRow[] = [];
|
||||
const errors: ValidationError[] = [];
|
||||
|
||||
// Unterstuetzt CRLF und LF.
|
||||
const zeilen = content.split(/\r?\n/);
|
||||
|
||||
const headerZeile = zeilen[0]?.trim();
|
||||
if (!headerZeile) {
|
||||
errors.push({
|
||||
row: 1,
|
||||
field: 'header',
|
||||
code: 'headerMissing',
|
||||
message: 'Die Datei enthält keine Header-Zeile.',
|
||||
});
|
||||
return { rows, errors };
|
||||
}
|
||||
|
||||
const headerSpalten = headerZeile.split(';').map((s) => s.trim());
|
||||
if (headerSpalten.length < ERWARTETE_SPALTENANZAHL) {
|
||||
errors.push({
|
||||
row: 1,
|
||||
field: 'header',
|
||||
code: 'headerColumns',
|
||||
message: `Header enthält nur ${headerSpalten.length} Spalten, erwartet werden ${ERWARTETE_SPALTENANZAHL}. Ist das Trennzeichen korrekt (Semikolon)?`,
|
||||
});
|
||||
return { rows, errors };
|
||||
}
|
||||
|
||||
for (let i = 1; i < zeilen.length; i++) {
|
||||
const zeile = zeilen[i]?.trim();
|
||||
const zeilenNummer = i + 1;
|
||||
|
||||
if (!zeile) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const spalten = zeile.split(';').map((s) => s.trim());
|
||||
|
||||
if (spalten.length < ERWARTETE_SPALTENANZAHL) {
|
||||
errors.push({
|
||||
row: zeilenNummer,
|
||||
field: 'zeile',
|
||||
code: 'columnCount',
|
||||
message: `Zeile hat nur ${spalten.length} Spalten, erwartet werden ${ERWARTETE_SPALTENANZAHL}.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.push({
|
||||
personalNr: spalten[0],
|
||||
name: spalten[1],
|
||||
menge: spalten[2],
|
||||
ekPreis: spalten[3],
|
||||
netto: spalten[4],
|
||||
zuAbschlag: spalten[5],
|
||||
mwst: spalten[6],
|
||||
zuschuss: spalten[7],
|
||||
betrag: spalten[8],
|
||||
abrechnungVon: spalten[9],
|
||||
abrechnungBis: spalten[10],
|
||||
line: zeilenNummer,
|
||||
});
|
||||
}
|
||||
|
||||
return { rows, errors };
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { validateKantinenData } from './kantine-csv.validator';
|
||||
import type { KantinenRawRow } from './kantine-datev.types';
|
||||
|
||||
function row(over: Partial<KantinenRawRow> = {}): KantinenRawRow {
|
||||
return {
|
||||
personalNr: '100',
|
||||
name: 'Max Muster',
|
||||
menge: '1',
|
||||
ekPreis: '1,00',
|
||||
netto: '1,00',
|
||||
zuAbschlag: '0',
|
||||
mwst: '0',
|
||||
zuschuss: '0',
|
||||
betrag: '7,94',
|
||||
abrechnungVon: '01.03.2026',
|
||||
abrechnungBis: '31.03.2026',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('validateKantinenData', () => {
|
||||
it('akzeptiert eine gueltige Zeile und bestimmt den Monat aus "bis"', () => {
|
||||
const result = validateKantinenData([row()]);
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.abrechnungsMonat).toBe('03/2026');
|
||||
});
|
||||
|
||||
it('meldet nicht numerische und fehlende Personalnummern', () => {
|
||||
const r = validateKantinenData([row({ personalNr: 'A12' }), row({ personalNr: '' })]);
|
||||
expect(r.errors.map((e) => e.code)).toEqual(['personalNrNotNumeric', 'personalNrMissing']);
|
||||
expect(r.errors[0].message).toBe('Personalnummer muss numerisch sein');
|
||||
});
|
||||
|
||||
it('lehnt Betraege mit Tausenderpunkt oder Minus ab (wie die Vorlage)', () => {
|
||||
const r = validateKantinenData([
|
||||
row({ betrag: '1.234,56' }),
|
||||
row({ betrag: '-5,00' }),
|
||||
row({ betrag: '' }),
|
||||
]);
|
||||
expect(r.errors.map((e) => e.code)).toEqual(['betragFormat', 'betragFormat', 'betragMissing']);
|
||||
});
|
||||
|
||||
it('prueft das Datumsformat', () => {
|
||||
const r = validateKantinenData([row({ abrechnungVon: '2026-03-01', abrechnungBis: '' })]);
|
||||
expect(r.errors.map((e) => e.code)).toEqual(['vonFormat', 'bisMissing']);
|
||||
});
|
||||
|
||||
it('meldet von/bis in verschiedenen Monaten mit Zeile = Index + 2', () => {
|
||||
const r = validateKantinenData([row(), row({ abrechnungVon: '28.02.2026' })]);
|
||||
expect(r.errors).toEqual([
|
||||
expect.objectContaining({
|
||||
row: 3,
|
||||
code: 'multiMonthRange',
|
||||
field: 'abrechnungVon/abrechnungBis',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('nimmt die echte Dateizeile, wenn der Parser sie mitliefert', () => {
|
||||
const r = validateKantinenData([row({ personalNr: 'x', line: 9 })]);
|
||||
expect(r.errors[0].row).toBe(9);
|
||||
});
|
||||
|
||||
it('warnt bei zwei Abrechnungsmonaten und behaelt den ersten', () => {
|
||||
const r = validateKantinenData([
|
||||
row(),
|
||||
row({ abrechnungVon: '01.04.2026', abrechnungBis: '30.04.2026' }),
|
||||
]);
|
||||
expect(r.isValid).toBe(true);
|
||||
expect(r.abrechnungsMonat).toBe('03/2026');
|
||||
expect(r.warnings).toHaveLength(1);
|
||||
expect(r.warnings[0]).toMatchObject({
|
||||
code: 'multipleMonths',
|
||||
params: { months: ['03/2026', '04/2026'] },
|
||||
});
|
||||
expect(r.warnings[0].message).toBe(
|
||||
'Verschiedene Abrechnungsmonate erkannt: 03/2026, 04/2026. Alle Zeilen sollten im selben Abrechnungsmonat liegen.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import type {
|
||||
KantinenRawRow,
|
||||
ValidationError,
|
||||
ValidationResult,
|
||||
ValidationWarning,
|
||||
} from './kantine-datev.types';
|
||||
|
||||
/** Zahl im deutschen Format (Komma als Dezimaltrenner), wie in der Vorlage. */
|
||||
export function isValidGermanNumber(value: string): boolean {
|
||||
return /^\d+([,]\d+)?$/.test(value.trim());
|
||||
}
|
||||
|
||||
/** Datum im Format TT.MM.JJJJ. */
|
||||
function isValidDate(value: string): boolean {
|
||||
return /^\d{2}\.\d{2}\.\d{4}$/.test(value.trim());
|
||||
}
|
||||
|
||||
function extractMonthYear(dateStr: string): { month: string; year: string } | null {
|
||||
const match = dateStr.trim().match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
|
||||
if (!match) return null;
|
||||
return { month: match[2], year: match[3] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validiert die geparsten Kantinen-Zeilen (Regeln und Meldungen wie in der
|
||||
* Desktop-Vorlage):
|
||||
* 1. Personalnummer: vorhanden und numerisch
|
||||
* 2. Betrag: vorhanden, deutsches Zahlenformat
|
||||
* 3. Abrechnung von/bis: Format TT.MM.JJJJ
|
||||
* 4. von und bis muessen im selben Monat liegen
|
||||
* 5. Abrechnungsmonat kommt aus "Abrechnung bis" -> MM/YYYY
|
||||
*/
|
||||
export function validateKantinenData(rows: KantinenRawRow[]): ValidationResult {
|
||||
const errors: ValidationError[] = [];
|
||||
const warnings: ValidationWarning[] = [];
|
||||
const detectedMonths = new Set<string>();
|
||||
let abrechnungsMonat: string | null = null;
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
// Echte Dateizeile, falls bekannt; sonst 1-basiert + 1 fuer den Kopf.
|
||||
const rowNum = row.line ?? i + 2;
|
||||
|
||||
if (!row.personalNr || row.personalNr.trim() === '') {
|
||||
errors.push({
|
||||
row: rowNum,
|
||||
field: 'personalNr',
|
||||
code: 'personalNrMissing',
|
||||
message: 'Personalnummer fehlt',
|
||||
});
|
||||
} else if (!/^\d+$/.test(row.personalNr.trim())) {
|
||||
errors.push({
|
||||
row: rowNum,
|
||||
field: 'personalNr',
|
||||
code: 'personalNrNotNumeric',
|
||||
message: 'Personalnummer muss numerisch sein',
|
||||
});
|
||||
}
|
||||
|
||||
if (!row.betrag || row.betrag.trim() === '') {
|
||||
errors.push({ row: rowNum, field: 'betrag', code: 'betragMissing', message: 'Betrag fehlt' });
|
||||
} else if (!isValidGermanNumber(row.betrag)) {
|
||||
errors.push({
|
||||
row: rowNum,
|
||||
field: 'betrag',
|
||||
code: 'betragFormat',
|
||||
message: 'Betrag muss im deutschen Zahlenformat vorliegen (Komma als Dezimaltrenner)',
|
||||
});
|
||||
}
|
||||
|
||||
if (!row.abrechnungVon || row.abrechnungVon.trim() === '') {
|
||||
errors.push({
|
||||
row: rowNum,
|
||||
field: 'abrechnungVon',
|
||||
code: 'vonMissing',
|
||||
message: 'Abrechnung von fehlt',
|
||||
});
|
||||
} else if (!isValidDate(row.abrechnungVon)) {
|
||||
errors.push({
|
||||
row: rowNum,
|
||||
field: 'abrechnungVon',
|
||||
code: 'vonFormat',
|
||||
message: 'Abrechnung von muss im Format TT.MM.JJJJ vorliegen',
|
||||
});
|
||||
}
|
||||
|
||||
if (!row.abrechnungBis || row.abrechnungBis.trim() === '') {
|
||||
errors.push({
|
||||
row: rowNum,
|
||||
field: 'abrechnungBis',
|
||||
code: 'bisMissing',
|
||||
message: 'Abrechnung bis fehlt',
|
||||
});
|
||||
} else if (!isValidDate(row.abrechnungBis)) {
|
||||
errors.push({
|
||||
row: rowNum,
|
||||
field: 'abrechnungBis',
|
||||
code: 'bisFormat',
|
||||
message: 'Abrechnung bis muss im Format TT.MM.JJJJ vorliegen',
|
||||
});
|
||||
}
|
||||
|
||||
const vonParsed = extractMonthYear(row.abrechnungVon);
|
||||
const bisParsed = extractMonthYear(row.abrechnungBis);
|
||||
|
||||
if (vonParsed && bisParsed) {
|
||||
if (vonParsed.month !== bisParsed.month || vonParsed.year !== bisParsed.year) {
|
||||
errors.push({
|
||||
row: rowNum,
|
||||
field: 'abrechnungVon/abrechnungBis',
|
||||
code: 'multiMonthRange',
|
||||
message: 'Abrechnungszeitraum erstreckt sich über mehrere Monate',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (bisParsed) {
|
||||
detectedMonths.add(`${bisParsed.month}/${bisParsed.year}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (detectedMonths.size === 1) {
|
||||
abrechnungsMonat = [...detectedMonths][0];
|
||||
} else if (detectedMonths.size > 1) {
|
||||
const months = [...detectedMonths];
|
||||
warnings.push({
|
||||
code: 'multipleMonths',
|
||||
message:
|
||||
`Verschiedene Abrechnungsmonate erkannt: ${months.join(', ')}. ` +
|
||||
'Alle Zeilen sollten im selben Abrechnungsmonat liegen.',
|
||||
params: { months },
|
||||
});
|
||||
// Fallback wie in der Vorlage: der erste erkannte Monat.
|
||||
abrechnungsMonat = months[0];
|
||||
}
|
||||
|
||||
return { isValid: errors.length === 0, errors, warnings, abrechnungsMonat };
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import 'reflect-metadata';
|
||||
import { BadRequestException, ForbiddenException, ValidationPipe } from '@nestjs/common';
|
||||
import { Role } from '@prisma/client';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
|
||||
import { MODULE_SLUG_KEY } from '../module-registry/module.guard';
|
||||
import { KantineDatevSettingsDto } from './dto/kantine-datev-settings.dto';
|
||||
import { KantineDatevController } from './kantine-datev.controller';
|
||||
|
||||
const proto = KantineDatevController.prototype as any;
|
||||
const req = (tenantId?: string) => ({ tenantId }) as any;
|
||||
|
||||
function makeService() {
|
||||
return {
|
||||
getSettings: vi.fn(async (..._a: unknown[]) => ({})),
|
||||
saveSettings: vi.fn(async (..._a: unknown[]) => ({})),
|
||||
preview: vi.fn(async (..._a: unknown[]) => ({})),
|
||||
export: vi.fn(async (..._a: unknown[]) => ({})),
|
||||
};
|
||||
}
|
||||
|
||||
describe('KantineDatevController — Metadaten', () => {
|
||||
it('haengt an modules/kantine-datev und traegt @UseModule', () => {
|
||||
expect(Reflect.getMetadata('path', KantineDatevController)).toBe('modules/kantine-datev');
|
||||
expect(Reflect.getMetadata(MODULE_SLUG_KEY, KantineDatevController)).toBe('kantine-datev');
|
||||
});
|
||||
|
||||
it('PUT settings verlangt ADMIN/SUPER_ADMIN', () => {
|
||||
expect(Reflect.getMetadata(ROLES_KEY, proto.saveSettings)).toEqual([
|
||||
Role.ADMIN,
|
||||
Role.SUPER_ADMIN,
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(['getSettings', 'preview', 'export'])('%s traegt keine Routen-Rolle', (name) => {
|
||||
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('KantineDatevController — Verhalten', () => {
|
||||
it('reicht req.tenantId und den Dateipuffer an den Dienst', async () => {
|
||||
const service = makeService();
|
||||
const c = new KantineDatevController(service as any);
|
||||
const buffer = Buffer.from('x');
|
||||
await c.getSettings(req('t1'));
|
||||
await c.preview(req('t1'), { buffer } as any);
|
||||
await c.export(req('t1'), { buffer } as any);
|
||||
expect(service.getSettings).toHaveBeenCalledWith('t1');
|
||||
expect(service.preview).toHaveBeenCalledWith('t1', buffer);
|
||||
expect(service.export).toHaveBeenCalledWith('t1', buffer);
|
||||
});
|
||||
|
||||
it('antwortet ohne Datei mit 400', async () => {
|
||||
const c = new KantineDatevController(makeService() as any);
|
||||
await expect(c.preview(req('t1'), undefined)).rejects.toThrow(BadRequestException);
|
||||
await expect(c.export(req('t1'), undefined)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('antwortet ohne Mandantenkontext mit 403', async () => {
|
||||
const c = new KantineDatevController(makeService() as any);
|
||||
await expect(c.getSettings(req(undefined))).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('KantineDatevSettingsDto', () => {
|
||||
const pipe = new ValidationPipe({ whitelist: true, transform: true });
|
||||
const run = (value: unknown) =>
|
||||
pipe.transform(value, { type: 'body', metatype: KantineDatevSettingsDto });
|
||||
|
||||
it('akzeptiert Ziffernketten (auch mit fuehrender Null)', async () => {
|
||||
await expect(
|
||||
run({ beraterNr: '0123456', mandantNr: '12345', lohnart: '1111' }),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ beraterNr: '12a', mandantNr: '1', lohnart: '1' }],
|
||||
[{ beraterNr: '1', mandantNr: '', lohnart: '1' }],
|
||||
[{ beraterNr: '1', mandantNr: '1', lohnart: '12345678901' }],
|
||||
[{ beraterNr: '1', mandantNr: '1' }],
|
||||
[{ beraterNr: 1, mandantNr: '1', lohnart: '1' }],
|
||||
])('lehnt %j ab', async (body) => {
|
||||
await expect(run(body)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Req,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { Role } from '@prisma/client';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import type { AuthenticatedRequest, UploadedFileLike } from '../auth/types/auth-user';
|
||||
import { UseModule } from '../module-registry/module.guard';
|
||||
import { KantineDatevSettingsDto } from './dto/kantine-datev-settings.dto';
|
||||
import { KantineDatevService } from './kantine-datev.service';
|
||||
|
||||
/**
|
||||
* `@UseModule('kantine-datev')` auf Klassenebene — Aktivierung UND Freigabe.
|
||||
* `tenantId` kommt ausschliesslich aus `req.tenantId` (TenantGuard). Lesen,
|
||||
* Vorschau und Export stehen jedem Benutzer mit Modulzugriff offen; die
|
||||
* Einstellungen aendern nur Administratoren (T-FM5-02). Hochgeladene Dateien
|
||||
* bleiben im Arbeitsspeicher (multer-Standard), 5 MB Grenze (T-FM5-04).
|
||||
* Keine `:id`-Routen in diesem Controller.
|
||||
*/
|
||||
@Controller('modules/kantine-datev')
|
||||
@UseModule('kantine-datev')
|
||||
export class KantineDatevController {
|
||||
constructor(private readonly service: KantineDatevService) {}
|
||||
|
||||
private requireTenantId(req: AuthenticatedRequest): string {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new ForbiddenException('Kein Mandantenkontext');
|
||||
}
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
@Get('settings')
|
||||
async getSettings(@Req() req: AuthenticatedRequest) {
|
||||
return this.service.getSettings(this.requireTenantId(req));
|
||||
}
|
||||
|
||||
@Put('settings')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async saveSettings(@Req() req: AuthenticatedRequest, @Body() dto: KantineDatevSettingsDto) {
|
||||
return this.service.saveSettings(this.requireTenantId(req), dto);
|
||||
}
|
||||
|
||||
@Post('preview')
|
||||
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 5 * 1024 * 1024 } }))
|
||||
async preview(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@UploadedFile() file: UploadedFileLike | undefined,
|
||||
) {
|
||||
const tenantId = this.requireTenantId(req);
|
||||
if (!file) {
|
||||
throw new BadRequestException('Keine Datei hochgeladen');
|
||||
}
|
||||
return this.service.preview(tenantId, file.buffer);
|
||||
}
|
||||
|
||||
@Post('export')
|
||||
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 5 * 1024 * 1024 } }))
|
||||
async export(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@UploadedFile() file: UploadedFileLike | undefined,
|
||||
) {
|
||||
const tenantId = this.requireTenantId(req);
|
||||
if (!file) {
|
||||
throw new BadRequestException('Keine Datei hochgeladen');
|
||||
}
|
||||
return this.service.export(tenantId, file.buffer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Logger, Module, OnModuleInit } from '@nestjs/common';
|
||||
import { ModuleRegistryModule } from '../module-registry/module-registry.module';
|
||||
import { ModuleRegistryService } from '../module-registry/module-registry.service';
|
||||
import { KantineDatevController } from './kantine-datev.controller';
|
||||
import { seedKantineDatevModule } from './kantine-datev.seed';
|
||||
import { KantineDatevService } from './kantine-datev.service';
|
||||
|
||||
/**
|
||||
* Kantinenabrechnung (quick-261002-fm5): Kantinen-CSV pruefen und als
|
||||
* DATEV-Lohn-ASCII-Datei exportieren. Traegt sich beim Start in die
|
||||
* Modulverwaltung ein; aktiviert wird per Marktplatz.
|
||||
*/
|
||||
@Module({
|
||||
imports: [ModuleRegistryModule],
|
||||
controllers: [KantineDatevController],
|
||||
providers: [KantineDatevService],
|
||||
})
|
||||
export class KantineDatevModule implements OnModuleInit {
|
||||
private readonly logger = new Logger(KantineDatevModule.name);
|
||||
|
||||
constructor(private readonly moduleRegistryService: ModuleRegistryService) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
try {
|
||||
await seedKantineDatevModule(this.moduleRegistryService);
|
||||
this.logger.log('Kantine-DATEV module seeded in registry');
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to seed kantine-datev module', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildKantineExport,
|
||||
KantineExportError,
|
||||
processKantineCsv,
|
||||
} from './kantine-datev.pipeline';
|
||||
|
||||
const SETTINGS = { beraterNr: '1234567', mandantNr: '12345', lohnart: '1111' };
|
||||
const HEADER = 'PersNr;Name;Menge;EK;Netto;ZuAb;MwSt;Zuschuss;Betrag;Von;Bis';
|
||||
const ok = (nr: string, name: string, betrag: string, bis = '31.03.2026') =>
|
||||
`${nr};${name};1;1,00;1,00;0;0;0;${betrag};01.03.2026;${bis}`;
|
||||
|
||||
function csv(lines: string[], eol = '\r\n'): string {
|
||||
return [HEADER, ...lines].join(eol);
|
||||
}
|
||||
|
||||
describe('processKantineCsv', () => {
|
||||
it('liest Windows-1252 mit Umlauten und CRLF', () => {
|
||||
const buf = Buffer.from(
|
||||
csv([ok('100', 'Müller, Jürgen', '7,94'), ok('200', 'Köhler', '51,5')]),
|
||||
'latin1',
|
||||
);
|
||||
const p = processKantineCsv(buf, SETTINGS);
|
||||
expect(p.rowCount).toBe(2);
|
||||
expect(p.abrechnungsMonat).toBe('03/2026');
|
||||
expect(p.totalCents).toBe(794 + 5150);
|
||||
expect(p.errors).toEqual([]);
|
||||
expect(p.canExport).toBe(true);
|
||||
expect(p.blockedReason).toBeNull();
|
||||
});
|
||||
|
||||
it('liest UTF-8 mit BOM und LF gleich', () => {
|
||||
const body = csv([ok('100', 'Müller', '7,94')], '\n');
|
||||
const buf = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(body, 'utf8')]);
|
||||
const p = processKantineCsv(buf, SETTINGS);
|
||||
expect(p.rowCount).toBe(1);
|
||||
expect(p.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('zaehlt in der Summe nur Zeilen mit gueltigem Betrag', () => {
|
||||
const p = processKantineCsv(
|
||||
Buffer.from(csv([ok('100', 'A', '7,94'), ok('101', 'B', 'abc')])),
|
||||
SETTINGS,
|
||||
);
|
||||
expect(p.totalCents).toBe(794);
|
||||
expect(p.errors).toEqual([expect.objectContaining({ row: 3, code: 'betragFormat' })]);
|
||||
expect(p.canExport).toBe(false);
|
||||
expect(p.blockedReason).toBe('errors');
|
||||
});
|
||||
|
||||
it('meldet zwei Monate als Warnung, nicht als Fehler', () => {
|
||||
const p = processKantineCsv(
|
||||
Buffer.from(csv([ok('100', 'A', '1,00'), `101;B;1;1;1;0;0;0;1,00;01.04.2026;30.04.2026`])),
|
||||
SETTINGS,
|
||||
);
|
||||
expect(p.warnings).toHaveLength(1);
|
||||
expect(p.errors).toEqual([]);
|
||||
expect(p.abrechnungsMonat).toBe('03/2026');
|
||||
});
|
||||
|
||||
it('meldet eine Datei ohne Datenzeilen mit noRows', () => {
|
||||
const p = processKantineCsv(Buffer.from(csv([])), SETTINGS);
|
||||
expect(p.rowCount).toBe(0);
|
||||
expect(p.errors.map((e) => e.code)).toEqual(['noRows']);
|
||||
expect(p.canExport).toBe(false);
|
||||
});
|
||||
|
||||
it('sperrt ohne Einstellungen mit settingsMissing', () => {
|
||||
const p = processKantineCsv(Buffer.from(csv([ok('100', 'A', '1,00')])), null);
|
||||
expect(p.canExport).toBe(false);
|
||||
expect(p.blockedReason).toBe('settingsMissing');
|
||||
});
|
||||
|
||||
it('gibt keine Zeileninhalte in der Vorschau zurueck', () => {
|
||||
const p = processKantineCsv(Buffer.from(csv([ok('100', 'Geheimname', 'x')])), SETTINGS);
|
||||
expect(JSON.stringify(p)).not.toContain('Geheimname');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildKantineExport', () => {
|
||||
it('erzeugt Dateiname und Base64-Inhalt', () => {
|
||||
const r = buildKantineExport(Buffer.from(csv([ok('100', 'A', '7,94')])), SETTINGS);
|
||||
expect(r.filename).toBe('LuG_1234567_12345_03_2026.sic');
|
||||
expect(r.mimeType).toBe('text/plain');
|
||||
expect(Buffer.from(r.content, 'base64').toString('utf8')).toBe(
|
||||
'1234567\t12345\t03/2026\t\t\t\t\t\t\t\t\r\n\t100\t\t1111\t-7.94\t\t\t\t\t\t\r\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('verweigert den Export bei Fehlern', () => {
|
||||
expect(() => buildKantineExport(Buffer.from(csv([ok('x', 'A', '7,94')])), SETTINGS)).toThrow(
|
||||
KantineExportError,
|
||||
);
|
||||
try {
|
||||
buildKantineExport(Buffer.from(csv([ok('x', 'A', '7,94')])), SETTINGS);
|
||||
} catch (e) {
|
||||
expect((e as KantineExportError).code).toBe('hasErrors');
|
||||
}
|
||||
});
|
||||
|
||||
it('verweigert den Export ohne Einstellungen', () => {
|
||||
try {
|
||||
buildKantineExport(Buffer.from(csv([ok('100', 'A', '7,94')])), null);
|
||||
expect.unreachable();
|
||||
} catch (e) {
|
||||
expect((e as KantineExportError).code).toBe('settingsMissing');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ModuleRegistryService } from '../module-registry/module-registry.service';
|
||||
|
||||
/**
|
||||
* Traegt das Modul "Kantinenabrechnung" in die Modulverwaltung ein
|
||||
* (quick-261002-fm5). `isSystem: true` legt den Eintrag an, aktiviert ihn aber
|
||||
* NICHT je Mandant — der Administrator aktiviert ueber den Marktplatz und
|
||||
* erteilt die Freigabe.
|
||||
*/
|
||||
export async function seedKantineDatevModule(
|
||||
moduleRegistryService: ModuleRegistryService,
|
||||
): Promise<void> {
|
||||
await moduleRegistryService.seedModule({
|
||||
slug: 'kantine-datev',
|
||||
name: 'Kantinenabrechnung',
|
||||
version: '1.0.0',
|
||||
category: 'accounting',
|
||||
description: {
|
||||
de: 'Kantinen-CSV prüfen und als DATEV-Lohndatei (ASCII) für die Gehaltsabrechnung exportieren',
|
||||
en: 'Check canteen CSV files and export them as a DATEV payroll ASCII file',
|
||||
},
|
||||
isSystem: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((p: unknown) => p),
|
||||
}));
|
||||
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { KantineDatevService } from './kantine-datev.service';
|
||||
|
||||
const HEADER = 'PersNr;Name;Menge;EK;Netto;ZuAb;MwSt;Zuschuss;Betrag;Von;Bis';
|
||||
const GOOD = Buffer.from(
|
||||
[HEADER, '100;Max;1;1,00;1,00;0;0;0;7,94;01.03.2026;31.03.2026'].join('\r\n'),
|
||||
);
|
||||
|
||||
function setup(row: Record<string, string | null> | null = null) {
|
||||
const kantineDatevConfig = {
|
||||
findUnique: vi.fn(async () => row),
|
||||
upsert: vi.fn(async ({ create }: any) => create),
|
||||
};
|
||||
return { prisma: { kantineDatevConfig }, kantineDatevConfig };
|
||||
}
|
||||
|
||||
describe('KantineDatevService — Einstellungen', () => {
|
||||
it('liefert ohne Zeile leere Werte und configured=false', async () => {
|
||||
const { prisma } = setup(null);
|
||||
const res = await new KantineDatevService(prisma as any).getSettings('t1');
|
||||
expect(res).toEqual({ beraterNr: null, mandantNr: null, lohnart: null, configured: false });
|
||||
expect(forTenant).toHaveBeenCalledWith(prisma, 't1');
|
||||
});
|
||||
|
||||
it('configured=false, solange ein Feld fehlt', async () => {
|
||||
const { prisma } = setup({ beraterNr: '1', mandantNr: null, lohnart: '3' });
|
||||
expect((await new KantineDatevService(prisma as any).getSettings('t1')).configured).toBe(false);
|
||||
});
|
||||
|
||||
it('speichert per upsert auf tenantId (aus dem Argument)', async () => {
|
||||
const { prisma, kantineDatevConfig } = setup();
|
||||
const res = await new KantineDatevService(prisma as any).saveSettings('t1', {
|
||||
beraterNr: '1234567',
|
||||
mandantNr: '12345',
|
||||
lohnart: '1111',
|
||||
});
|
||||
expect(kantineDatevConfig.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { tenantId: 't1' } }),
|
||||
);
|
||||
expect(res.configured).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('KantineDatevService — Vorschau und Export', () => {
|
||||
it('Vorschau ohne Einstellungen sperrt mit settingsMissing', async () => {
|
||||
const { prisma } = setup(null);
|
||||
const p = await new KantineDatevService(prisma as any).preview('t1', GOOD);
|
||||
expect(p.blockedReason).toBe('settingsMissing');
|
||||
expect(p.rowCount).toBe(1);
|
||||
});
|
||||
|
||||
it('Export ohne Einstellungen -> 400 mit code settingsMissing', async () => {
|
||||
const { prisma } = setup(null);
|
||||
const err: any = await new KantineDatevService(prisma as any)
|
||||
.export('t1', GOOD)
|
||||
.catch((e) => e);
|
||||
expect(err).toBeInstanceOf(BadRequestException);
|
||||
expect(err.getResponse().code).toBe('settingsMissing');
|
||||
});
|
||||
|
||||
it('Export mit Fehlern -> 400 mit code hasErrors und Fehlerliste', async () => {
|
||||
const { prisma } = setup({ beraterNr: '1', mandantNr: '2', lohnart: '3' });
|
||||
const bad = Buffer.from(
|
||||
[HEADER, 'x;Max;1;1,00;1,00;0;0;0;7,94;01.03.2026;31.03.2026'].join('\n'),
|
||||
);
|
||||
const err: any = await new KantineDatevService(prisma as any).export('t1', bad).catch((e) => e);
|
||||
expect(err.getResponse().code).toBe('hasErrors');
|
||||
expect(err.getResponse().errors).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('Export mit Einstellungen liefert Datei', async () => {
|
||||
const { prisma } = setup({ beraterNr: '1234567', mandantNr: '12345', lohnart: '1111' });
|
||||
const res = await new KantineDatevService(prisma as any).export('t1', GOOD);
|
||||
expect(res.filename).toBe('LuG_1234567_12345_03_2026.sic');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { BadRequestException, Injectable, UnprocessableEntityException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import type { KantineDatevSettingsDto } from './dto/kantine-datev-settings.dto';
|
||||
import {
|
||||
buildKantineExport,
|
||||
KantineExportError,
|
||||
processKantineCsv,
|
||||
} from './kantine-datev.pipeline';
|
||||
import type { FileResponse, KantineDatevSettings, KantinePreview } from './kantine-datev.types';
|
||||
|
||||
export interface KantineSettingsResponse {
|
||||
beraterNr: string | null;
|
||||
mandantNr: string | null;
|
||||
lohnart: string | null;
|
||||
configured: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kantinenabrechnung (quick-261002-fm5). Die Einstellungen liegen je Mandant
|
||||
* in `KantineDatevConfig` (mandantengebunden); die hochgeladene CSV wird nur
|
||||
* im Arbeitsspeicher verarbeitet und weder gespeichert noch protokolliert.
|
||||
*/
|
||||
@Injectable()
|
||||
export class KantineDatevService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getSettings(tenantId: string): Promise<KantineSettingsResponse> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const row = await tenantPrisma.kantineDatevConfig.findUnique({ where: { tenantId } });
|
||||
const beraterNr = row?.beraterNr ?? null;
|
||||
const mandantNr = row?.mandantNr ?? null;
|
||||
const lohnart = row?.lohnart ?? null;
|
||||
return {
|
||||
beraterNr,
|
||||
mandantNr,
|
||||
lohnart,
|
||||
configured: Boolean(beraterNr && mandantNr && lohnart),
|
||||
};
|
||||
}
|
||||
|
||||
async saveSettings(
|
||||
tenantId: string,
|
||||
dto: KantineDatevSettingsDto,
|
||||
): Promise<KantineSettingsResponse> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const data = { beraterNr: dto.beraterNr, mandantNr: dto.mandantNr, lohnart: dto.lohnart };
|
||||
await tenantPrisma.kantineDatevConfig.upsert({
|
||||
where: { tenantId },
|
||||
create: { tenantId, ...data },
|
||||
update: data,
|
||||
});
|
||||
return { ...data, configured: true };
|
||||
}
|
||||
|
||||
private async loadConfigured(tenantId: string): Promise<KantineDatevSettings | null> {
|
||||
const s = await this.getSettings(tenantId);
|
||||
if (!s.configured || !s.beraterNr || !s.mandantNr || !s.lohnart) return null;
|
||||
return { beraterNr: s.beraterNr, mandantNr: s.mandantNr, lohnart: s.lohnart };
|
||||
}
|
||||
|
||||
async preview(tenantId: string, buffer: Buffer): Promise<KantinePreview> {
|
||||
return processKantineCsv(buffer, await this.loadConfigured(tenantId));
|
||||
}
|
||||
|
||||
async export(tenantId: string, buffer: Buffer): Promise<FileResponse> {
|
||||
const settings = await this.loadConfigured(tenantId);
|
||||
try {
|
||||
return buildKantineExport(buffer, settings);
|
||||
} catch (error) {
|
||||
if (error instanceof KantineExportError) {
|
||||
if (error.code === 'qualityCheckFailed') {
|
||||
throw new UnprocessableEntityException({ code: error.code, message: error.message });
|
||||
}
|
||||
throw new BadRequestException({
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
errors: error.errors,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildKantineExportFilename,
|
||||
generateDatevOutput,
|
||||
qualityCheck,
|
||||
transformBetrag,
|
||||
} from './kantine-datev.transformer';
|
||||
|
||||
const SETTINGS = { beraterNr: '1234567', mandantNr: '12345', lohnart: '1111' };
|
||||
|
||||
describe('transformBetrag', () => {
|
||||
it('macht den Betrag negativ mit Punkt und zwei Nachkommastellen', () => {
|
||||
expect(transformBetrag('7,94')).toBe('-7.94');
|
||||
expect(transformBetrag('51,5')).toBe('-51.50');
|
||||
expect(transformBetrag('0,00')).toBe('-0.00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateDatevOutput', () => {
|
||||
const out = generateDatevOutput(
|
||||
[
|
||||
{ personalNr: '100', betrag: '-7.94' },
|
||||
{ personalNr: '200', betrag: '-51.50' },
|
||||
],
|
||||
'03/2026',
|
||||
SETTINGS,
|
||||
);
|
||||
|
||||
it('schreibt den Kopf aus den Einstellungen mit 8 leeren Spalten', () => {
|
||||
const lines = out.split('\r\n');
|
||||
expect(lines[0]).toBe('1234567\t12345\t03/2026\t\t\t\t\t\t\t\t');
|
||||
});
|
||||
|
||||
it('schreibt Detailzeilen mit Lohnart und Betrag', () => {
|
||||
const lines = out.split('\r\n');
|
||||
expect(lines[1]).toBe('\t100\t\t1111\t-7.94\t\t\t\t\t\t');
|
||||
expect(lines[2]).toBe('\t200\t\t1111\t-51.50\t\t\t\t\t\t');
|
||||
});
|
||||
|
||||
it('hat in jeder Zeile 11 Spalten, CRLF und ein abschliessendes CRLF', () => {
|
||||
expect(out.endsWith('\r\n')).toBe(true);
|
||||
expect(out.replace(/\r\n/g, '')).not.toContain('\n');
|
||||
for (const line of out.split('\r\n').slice(0, -1)) {
|
||||
expect(line.split('\t')).toHaveLength(11);
|
||||
}
|
||||
});
|
||||
|
||||
it('besteht die Qualitaetspruefung', () => {
|
||||
expect(qualityCheck(out)).toEqual({ passed: true, errors: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('qualityCheck', () => {
|
||||
it('lehnt eine Datei nur mit LF ab', () => {
|
||||
const r = qualityCheck('a\tb\t\t\t\t\t\t\t\t\t\n');
|
||||
expect(r.passed).toBe(false);
|
||||
expect(r.errors).toContain('Datei endet nicht mit CRLF');
|
||||
expect(r.errors).toContain('Datei enthaelt einzelne LF-Zeilenenden (nur CRLF erlaubt)');
|
||||
});
|
||||
|
||||
it('lehnt eine Zeile mit 10 Spalten ab', () => {
|
||||
const r = qualityCheck('a\t\t\t\t\t\t\t\t\t\r\n');
|
||||
expect(r.passed).toBe(false);
|
||||
expect(r.errors[0]).toBe('Zeile 1: 10 Spalten gefunden, 11 erwartet');
|
||||
});
|
||||
|
||||
it('lehnt einen positiven Betrag ab', () => {
|
||||
const r = qualityCheck(`k\t\t\t\t\t\t\t\t\t\t\r\n\t1\t\t1111\t7.94\t\t\t\t\t\t\r\n`);
|
||||
expect(r.passed).toBe(false);
|
||||
expect(r.errors[0]).toContain('Betrag "7.94" ist nicht im Format -X.XX');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildKantineExportFilename', () => {
|
||||
it('folgt LuG_<Berater>_<Mandant>_<MM>_<YYYY>.sic', () => {
|
||||
expect(buildKantineExportFilename(SETTINGS, '03/2026')).toBe('LuG_1234567_12345_03_2026.sic');
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Typen der Kantinenabrechnung (quick-261002-fm5). Portiert aus der
|
||||
* Desktop-Vorlage; jeder Fehler und jede Warnung traegt zusaetzlich eine
|
||||
* stabile Kennung (`code`), damit die Oberflaeche den Text uebersetzen kann,
|
||||
* waehrend `message` den deutschen Originaltext behaelt.
|
||||
*/
|
||||
|
||||
/** Rohe CSV-Zeile nach dem Parsen. */
|
||||
export interface KantinenRawRow {
|
||||
personalNr: string;
|
||||
name: string;
|
||||
menge: string;
|
||||
ekPreis: string;
|
||||
netto: string;
|
||||
zuAbschlag: string;
|
||||
mwst: string;
|
||||
zuschuss: string;
|
||||
betrag: string;
|
||||
abrechnungVon: string;
|
||||
abrechnungBis: string;
|
||||
/** 1-basierte Zeilennummer in der Datei (fuer Fehlermeldungen). */
|
||||
line?: number;
|
||||
}
|
||||
|
||||
/** Validierter und transformierter Datensatz. */
|
||||
export interface DatevRecord {
|
||||
personalNr: string;
|
||||
/** z. B. "-51.50" (immer negativ, Punkt, 2 Nachkommastellen) */
|
||||
betrag: string;
|
||||
}
|
||||
|
||||
export type KantineErrorCode =
|
||||
| 'headerMissing'
|
||||
| 'headerColumns'
|
||||
| 'columnCount'
|
||||
| 'personalNrMissing'
|
||||
| 'personalNrNotNumeric'
|
||||
| 'betragMissing'
|
||||
| 'betragFormat'
|
||||
| 'vonMissing'
|
||||
| 'vonFormat'
|
||||
| 'bisMissing'
|
||||
| 'bisFormat'
|
||||
| 'multiMonthRange'
|
||||
| 'noRows';
|
||||
|
||||
export interface ValidationError {
|
||||
row: number;
|
||||
field: string;
|
||||
code: KantineErrorCode;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ValidationWarning {
|
||||
code: 'multipleMonths';
|
||||
message: string;
|
||||
params: { months: string[] };
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
isValid: boolean;
|
||||
errors: ValidationError[];
|
||||
warnings: ValidationWarning[];
|
||||
/** Format "MM/YYYY" */
|
||||
abrechnungsMonat: string | null;
|
||||
}
|
||||
|
||||
/** Nummern, die der Administrator je Mandant hinterlegt. */
|
||||
export interface KantineDatevSettings {
|
||||
beraterNr: string;
|
||||
mandantNr: string;
|
||||
lohnart: string;
|
||||
}
|
||||
|
||||
export type KantineBlockedReason = 'settingsMissing' | 'errors';
|
||||
|
||||
export interface KantinePreview {
|
||||
rowCount: number;
|
||||
abrechnungsMonat: string | null;
|
||||
/** Summe der gueltigen Betraege in Cent. */
|
||||
totalCents: number;
|
||||
errors: ValidationError[];
|
||||
warnings: ValidationWarning[];
|
||||
canExport: boolean;
|
||||
blockedReason: KantineBlockedReason | null;
|
||||
}
|
||||
|
||||
export interface FileResponse {
|
||||
filename: string;
|
||||
/** Base64 */
|
||||
content: string;
|
||||
mimeType: string;
|
||||
}
|
||||
Reference in New Issue
Block a user