diff --git a/apps/api/prisma/migrations/20261002120000_kantine_datev_config/migration.sql b/apps/api/prisma/migrations/20261002120000_kantine_datev_config/migration.sql new file mode 100644 index 0000000..7669224 --- /dev/null +++ b/apps/api/prisma/migrations/20261002120000_kantine_datev_config/migration.sql @@ -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()); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 781a962..73eebbe 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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 diff --git a/apps/api/src/accounting/decode-csv-text.spec.ts b/apps/api/src/accounting/decode-csv-text.spec.ts new file mode 100644 index 0000000..5998c88 --- /dev/null +++ b/apps/api/src/accounting/decode-csv-text.spec.ts @@ -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€'); + }); +}); diff --git a/apps/api/src/accounting/decode-csv-text.ts b/apps/api/src/accounting/decode-csv-text.ts new file mode 100644 index 0000000..6d92347 --- /dev/null +++ b/apps/api/src/accounting/decode-csv-text.ts @@ -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); + } +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index a5c1145..44550ad 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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, ], diff --git a/apps/api/src/kantine-datev/dto/kantine-datev-settings.dto.ts b/apps/api/src/kantine-datev/dto/kantine-datev-settings.dto.ts new file mode 100644 index 0000000..a958563 --- /dev/null +++ b/apps/api/src/kantine-datev/dto/kantine-datev-settings.dto.ts @@ -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; +} diff --git a/apps/api/src/kantine-datev/kantine-csv.parser.spec.ts b/apps/api/src/kantine-datev/kantine-csv.parser.spec.ts new file mode 100644 index 0000000..b80cf11 --- /dev/null +++ b/apps/api/src/kantine-datev/kantine-csv.parser.spec.ts @@ -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'); + }); +}); diff --git a/apps/api/src/kantine-datev/kantine-csv.parser.ts b/apps/api/src/kantine-datev/kantine-csv.parser.ts new file mode 100644 index 0000000..4f142bc --- /dev/null +++ b/apps/api/src/kantine-datev/kantine-csv.parser.ts @@ -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 }; +} diff --git a/apps/api/src/kantine-datev/kantine-csv.validator.spec.ts b/apps/api/src/kantine-datev/kantine-csv.validator.spec.ts new file mode 100644 index 0000000..349676d --- /dev/null +++ b/apps/api/src/kantine-datev/kantine-csv.validator.spec.ts @@ -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 { + 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.', + ); + }); +}); diff --git a/apps/api/src/kantine-datev/kantine-csv.validator.ts b/apps/api/src/kantine-datev/kantine-csv.validator.ts new file mode 100644 index 0000000..2a40614 --- /dev/null +++ b/apps/api/src/kantine-datev/kantine-csv.validator.ts @@ -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(); + 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 }; +} diff --git a/apps/api/src/kantine-datev/kantine-datev.controller.spec.ts b/apps/api/src/kantine-datev/kantine-datev.controller.spec.ts new file mode 100644 index 0000000..fa48b0c --- /dev/null +++ b/apps/api/src/kantine-datev/kantine-datev.controller.spec.ts @@ -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); + }); +}); diff --git a/apps/api/src/kantine-datev/kantine-datev.controller.ts b/apps/api/src/kantine-datev/kantine-datev.controller.ts new file mode 100644 index 0000000..aa69836 --- /dev/null +++ b/apps/api/src/kantine-datev/kantine-datev.controller.ts @@ -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); + } +} diff --git a/apps/api/src/kantine-datev/kantine-datev.module.ts b/apps/api/src/kantine-datev/kantine-datev.module.ts new file mode 100644 index 0000000..31397a1 --- /dev/null +++ b/apps/api/src/kantine-datev/kantine-datev.module.ts @@ -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 { + 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); + } + } +} diff --git a/apps/api/src/kantine-datev/kantine-datev.pipeline.spec.ts b/apps/api/src/kantine-datev/kantine-datev.pipeline.spec.ts new file mode 100644 index 0000000..cf2c81a --- /dev/null +++ b/apps/api/src/kantine-datev/kantine-datev.pipeline.spec.ts @@ -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'); + } + }); +}); diff --git a/apps/api/src/kantine-datev/kantine-datev.pipeline.ts b/apps/api/src/kantine-datev/kantine-datev.pipeline.ts new file mode 100644 index 0000000..d420715 --- /dev/null +++ b/apps/api/src/kantine-datev/kantine-datev.pipeline.ts @@ -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', + }; +} diff --git a/apps/api/src/kantine-datev/kantine-datev.seed.ts b/apps/api/src/kantine-datev/kantine-datev.seed.ts new file mode 100644 index 0000000..3ca90f1 --- /dev/null +++ b/apps/api/src/kantine-datev/kantine-datev.seed.ts @@ -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 { + 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, + }); +} diff --git a/apps/api/src/kantine-datev/kantine-datev.service.spec.ts b/apps/api/src/kantine-datev/kantine-datev.service.spec.ts new file mode 100644 index 0000000..8c3413d --- /dev/null +++ b/apps/api/src/kantine-datev/kantine-datev.service.spec.ts @@ -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 | 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'); + }); +}); diff --git a/apps/api/src/kantine-datev/kantine-datev.service.ts b/apps/api/src/kantine-datev/kantine-datev.service.ts new file mode 100644 index 0000000..9e9372f --- /dev/null +++ b/apps/api/src/kantine-datev/kantine-datev.service.ts @@ -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 { + 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 { + 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 { + 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 { + return processKantineCsv(buffer, await this.loadConfigured(tenantId)); + } + + async export(tenantId: string, buffer: Buffer): Promise { + 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; + } + } +} diff --git a/apps/api/src/kantine-datev/kantine-datev.transformer.spec.ts b/apps/api/src/kantine-datev/kantine-datev.transformer.spec.ts new file mode 100644 index 0000000..515b9cd --- /dev/null +++ b/apps/api/src/kantine-datev/kantine-datev.transformer.spec.ts @@ -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____.sic', () => { + expect(buildKantineExportFilename(SETTINGS, '03/2026')).toBe('LuG_1234567_12345_03_2026.sic'); + }); +}); diff --git a/apps/api/src/kantine-datev/kantine-datev.transformer.ts b/apps/api/src/kantine-datev/kantine-datev.transformer.ts new file mode 100644 index 0000000..a0c68a6 --- /dev/null +++ b/apps/api/src/kantine-datev/kantine-datev.transformer.ts @@ -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____.sic */ +export function buildKantineExportFilename( + settings: KantineDatevSettings, + abrechnungsMonat: string, +): string { + const [monat, jahr] = abrechnungsMonat.split('/'); + return `LuG_${settings.beraterNr}_${settings.mandantNr}_${monat}_${jahr}.sic`; +} diff --git a/apps/api/src/kantine-datev/kantine-datev.types.ts b/apps/api/src/kantine-datev/kantine-datev.types.ts new file mode 100644 index 0000000..2878ce3 --- /dev/null +++ b/apps/api/src/kantine-datev/kantine-datev.types.ts @@ -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; +} diff --git a/apps/web/src/app/(portal)/modules/kantine-datev/kantine-datev.test.tsx b/apps/web/src/app/(portal)/modules/kantine-datev/kantine-datev.test.tsx new file mode 100644 index 0000000..371a76a --- /dev/null +++ b/apps/web/src/app/(portal)/modules/kantine-datev/kantine-datev.test.tsx @@ -0,0 +1,226 @@ +import { + cleanup, + fireEvent, + render as rtlRender, + screen, + waitFor, + within, +} from '@testing-library/react'; +import { NextIntlClientProvider } from 'next-intl'; +import type { ReactElement } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import de from '@/messages/de.json'; + +function render(ui: ReactElement) { + return rtlRender( + + {ui} + , + ); +} + +const mockGetSettings = vi.fn(); +const mockSaveSettings = vi.fn(); +const mockPreview = vi.fn(); +const mockExport = vi.fn(); + +vi.mock('@/lib/kantine-datev-api', () => ({ + getKantineSettings: (...a: unknown[]) => mockGetSettings(...a), + saveKantineSettings: (...a: unknown[]) => mockSaveSettings(...a), + previewKantineCsv: (...a: unknown[]) => mockPreview(...a), + exportKantineCsv: (...a: unknown[]) => mockExport(...a), + KantineRequestError: class extends Error { + constructor( + readonly status: number, + readonly code: string | null, + message: string, + ) { + super(message); + } + }, +})); + +const mockDownload = vi.fn(); +vi.mock('@/lib/download-base64', () => ({ + downloadBase64: (...a: unknown[]) => mockDownload(...a), +})); + +const mockAuthStore = vi.fn(); +vi.mock('@/lib/stores/auth-store', () => ({ + useAuthStore: (selector: (state: unknown) => unknown) => mockAuthStore(selector), +})); + +function mockUser(role: 'SUPER_ADMIN' | 'ADMIN' | 'USER') { + mockAuthStore.mockImplementation((selector: (s: { user: { role: string } }) => unknown) => + selector({ user: { role } }), + ); +} + +import KantineDatevPage from './page'; + +const CONFIGURED = { beraterNr: '1234567', mandantNr: '12345', lohnart: '1111', configured: true }; +const EMPTY = { beraterNr: null, mandantNr: null, lohnart: null, configured: false }; + +const GOOD_PREVIEW = { + rowCount: 3, + abrechnungsMonat: '03/2026', + totalCents: 12345, + errors: [], + warnings: [], + canExport: true, + blockedReason: null, +}; + +function csvFile() { + return new File(['x'], 'kantine.csv', { type: 'text/csv' }); +} + +async function upload(file = csvFile()) { + const input = (await screen.findByTestId('file-drop-input')) as HTMLInputElement; + fireEvent.change(input, { target: { files: [file] } }); +} + +beforeEach(() => { + mockUser('USER'); + mockGetSettings.mockResolvedValue(CONFIGURED); +}); + +afterEach(() => { + cleanup(); + for (const m of [ + mockGetSettings, + mockSaveSettings, + mockPreview, + mockExport, + mockDownload, + mockAuthStore, + ]) { + m.mockReset(); + } +}); + +describe('KantineDatevPage — nicht eingerichtet', () => { + it('Administrator sieht Hinweis mit Weg zu den Einstellungen und das Formular', async () => { + mockUser('ADMIN'); + mockGetSettings.mockResolvedValue(EMPTY); + render(); + expect(await screen.findByText(/noch nicht hinterlegt/)).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'Zu den Einstellungen' })); + const berater = (await screen.findByLabelText('Beraternummer')) as HTMLInputElement; + expect(berater.value).toBe(''); + expect(screen.getByLabelText('Mandantennummer')).toBeTruthy(); + expect(screen.getByLabelText('Lohnart')).toBeTruthy(); + }); + + it('normaler Benutzer sieht den Administrator-Hinweis und keinen Einstellungen-Reiter', async () => { + mockGetSettings.mockResolvedValue(EMPTY); + render(); + expect(await screen.findByText(/Ein Administrator muss zuerst/)).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Einstellungen' })).toBeNull(); + }); +}); + +describe('KantineDatevPage — Abrechnung', () => { + it('zeigt nach dem Hochladen Zeilen, Abrechnungsmonat und Gesamtbetrag', async () => { + mockPreview.mockResolvedValue(GOOD_PREVIEW); + render(); + await upload(); + expect(await screen.findByText('03/2026')).toBeTruthy(); + expect(screen.getByText('3')).toBeTruthy(); + expect(screen.getByText(/123,45/)).toBeTruthy(); + expect(screen.getByText('Die hochgeladenen Daten werden nicht gespeichert.')).toBeTruthy(); + }); + + it('listet Warnungen und Fehler mit Zeilennummer, Download gesperrt', async () => { + mockPreview.mockResolvedValue({ + ...GOOD_PREVIEW, + canExport: false, + blockedReason: 'errors', + warnings: [ + { + code: 'multipleMonths', + message: 'x', + params: { months: ['03/2026', '04/2026'] }, + }, + ], + errors: [{ row: 7, field: 'betrag', code: 'betragFormat', message: 'Betrag muss ...' }], + }); + render(); + await upload(); + const table = await screen.findByRole('table'); + const row = within(table).getByText('7').closest('tr') as HTMLElement; + expect(within(row).getByText('Betrag')).toBeTruthy(); + expect(within(row).getByText(/deutschen Zahlenformat/)).toBeTruthy(); + expect( + screen.getByText(/Verschiedene Abrechnungsmonate erkannt: 03\/2026, 04\/2026/), + ).toBeTruthy(); + expect( + (screen.getByRole('button', { name: 'DATEV-Datei herunterladen' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + }); + + it('sperrt den Download, solange nichts hinterlegt ist', async () => { + mockGetSettings.mockResolvedValue(EMPTY); + mockPreview.mockResolvedValue({ + ...GOOD_PREVIEW, + canExport: false, + blockedReason: 'settingsMissing', + }); + render(); + await upload(); + const button = (await screen.findByRole('button', { + name: 'DATEV-Datei herunterladen', + })) as HTMLButtonElement; + expect(button.disabled).toBe(true); + }); + + it('Klick auf Download ruft den Export mit derselben Datei und danach downloadBase64', async () => { + mockPreview.mockResolvedValue(GOOD_PREVIEW); + mockExport.mockResolvedValue({ + filename: 'LuG_1_2_03_2026.sic', + content: 'QUJD', + mimeType: 'text/plain', + }); + render(); + const file = csvFile(); + await upload(file); + const button = (await screen.findByRole('button', { + name: 'DATEV-Datei herunterladen', + })) as HTMLButtonElement; + await waitFor(() => expect(button.disabled).toBe(false)); + fireEvent.click(button); + await waitFor(() => + expect(mockDownload).toHaveBeenCalledWith('LuG_1_2_03_2026.sic', 'QUJD', 'text/plain'), + ); + expect(mockExport).toHaveBeenCalledWith(file); + expect(await screen.findByText(/LuG_1_2_03_2026\.sic/)).toBeTruthy(); + }); +}); + +describe('KantineDatevPage — Einstellungen speichern', () => { + it('lehnt Buchstaben vor dem Senden ab und speichert Ziffern', async () => { + mockUser('ADMIN'); + mockGetSettings.mockResolvedValue(EMPTY); + mockSaveSettings.mockResolvedValue(CONFIGURED); + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Einstellungen' })); + fireEvent.change(await screen.findByLabelText('Beraternummer'), { target: { value: '12a' } }); + fireEvent.change(screen.getByLabelText('Mandantennummer'), { target: { value: '12345' } }); + fireEvent.change(screen.getByLabelText('Lohnart'), { target: { value: '1111' } }); + fireEvent.click(screen.getByRole('button', { name: 'Speichern' })); + expect(await screen.findByText('Bitte nur Ziffern eingeben (1 bis 10 Stellen).')).toBeTruthy(); + expect(mockSaveSettings).not.toHaveBeenCalled(); + + fireEvent.change(screen.getByLabelText('Beraternummer'), { target: { value: '1234567' } }); + fireEvent.click(screen.getByRole('button', { name: 'Speichern' })); + await waitFor(() => + expect(mockSaveSettings).toHaveBeenCalledWith({ + beraterNr: '1234567', + mandantNr: '12345', + lohnart: '1111', + }), + ); + expect(await screen.findByText('Die Einstellungen wurden gespeichert.')).toBeTruthy(); + }); +}); diff --git a/apps/web/src/app/(portal)/modules/kantine-datev/layout.tsx b/apps/web/src/app/(portal)/modules/kantine-datev/layout.tsx new file mode 100644 index 0000000..e046c03 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/kantine-datev/layout.tsx @@ -0,0 +1,6 @@ +import type { ReactNode } from 'react'; +import { ModuleAccessGate } from '@/components/modules/module-access-gate'; + +export default function KantineDatevLayout({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/apps/web/src/app/(portal)/modules/kantine-datev/page.tsx b/apps/web/src/app/(portal)/modules/kantine-datev/page.tsx new file mode 100644 index 0000000..6dc1d1f --- /dev/null +++ b/apps/web/src/app/(portal)/modules/kantine-datev/page.tsx @@ -0,0 +1,364 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { useCallback, useEffect, useState } from 'react'; +import { FileDropArea } from '@/components/accounting/file-drop-area'; +import { TabBar } from '@/components/accounting/tab-bar'; +import { PageHeader } from '@/components/layout/page-header'; +import { downloadBase64 } from '@/lib/download-base64'; +import { + exportKantineCsv, + getKantineSettings, + type KantinePreview, + KantineRequestError, + type KantineSettings, + previewKantineCsv, + saveKantineSettings, +} from '@/lib/kantine-datev-api'; +import { useAuthStore } from '@/lib/stores/auth-store'; + +type TabId = 'billing' | 'settings'; + +const INPUT_CLASS = + 'w-full rounded border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring'; +const BUTTON_CLASS = + 'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:cursor-not-allowed disabled:opacity-50'; + +const euro = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }); + +/** + * Kantinenabrechnung (quick-261002-fm5): Kantinen-CSV hochladen, pruefen und + * als DATEV-Lohndatei herunterladen. Die Datei bleibt im Browser (State) und + * wird fuer Vorschau UND Export an die API geschickt — die API speichert nichts. + */ +export default function KantineDatevPage() { + const t = useTranslations('kantineDatev'); + const user = useAuthStore((s) => s.user); + const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN'; + + const [tab, setTab] = useState('billing'); + const [settings, setSettings] = useState(null); + const [settingsError, setSettingsError] = useState(false); + + useEffect(() => { + let cancelled = false; + getKantineSettings() + .then((s) => { + if (!cancelled) setSettings(s); + }) + .catch(() => { + if (!cancelled) setSettingsError(true); + }); + return () => { + cancelled = true; + }; + }, []); + + const tabs: { id: TabId; label: string }[] = [{ id: 'billing', label: t('tabs.billing') }]; + if (isAdmin) tabs.push({ id: 'settings', label: t('tabs.settings') }); + const activeTab = tab === 'settings' && !isAdmin ? 'billing' : tab; + + return ( +
+ + +
+ {activeTab === 'billing' ? ( + setTab('settings')} + /> + ) : ( + + )} +
+
+ ); +} + +function BillingTab({ + settings, + settingsError, + isAdmin, + onOpenSettings, +}: { + settings: KantineSettings | null; + settingsError: boolean; + isAdmin: boolean; + onOpenSettings: () => void; +}) { + const t = useTranslations('kantineDatev'); + const [file, setFile] = useState(null); + const [preview, setPreview] = useState(null); + const [checking, setChecking] = useState(false); + const [downloading, setDownloading] = useState(false); + const [message, setMessage] = useState<{ kind: 'error' | 'ok'; text: string } | null>(null); + + const requestError = useCallback( + (error: unknown): string => { + if (error instanceof KantineRequestError) { + if (error.code && t.has(`errors.${error.code}`)) return t(`errors.${error.code}`); + return error.message; + } + return t('errors.request'); + }, + [t], + ); + + const handleFile = async (selected: File) => { + setFile(selected); + setPreview(null); + setMessage(null); + setChecking(true); + try { + setPreview(await previewKantineCsv(selected)); + } catch (error) { + setMessage({ kind: 'error', text: requestError(error) }); + } finally { + setChecking(false); + } + }; + + const handleClear = () => { + setFile(null); + setPreview(null); + setMessage(null); + }; + + const handleDownload = async () => { + if (!file) return; + setDownloading(true); + setMessage(null); + try { + const result = await exportKantineCsv(file); + downloadBase64(result.filename, result.content, result.mimeType); + setMessage({ kind: 'ok', text: t('download.done', { filename: result.filename }) }); + } catch (error) { + setMessage({ kind: 'error', text: requestError(error) }); + } finally { + setDownloading(false); + } + }; + + const notConfigured = settings !== null && !settings.configured; + const canDownload = Boolean(preview?.canExport) && !downloading && !checking; + + return ( +
+ {settingsError &&

{t('errors.request')}

} + + {notConfigured && ( +
+

{isAdmin ? t('notConfigured.admin') : t('notConfigured.user')}

+ {isAdmin && ( + + )} +
+ )} + + +

{t('noStorage')}

+ + {checking &&

{t('checking')}

} + + {preview && ( +
+
+
+ + + +
+
+ + {preview.warnings.length > 0 && ( +
+

{t('warnings.title')}

+
    + {preview.warnings.map((w) => ( +
  • + {w.code === 'multipleMonths' && w.params?.months + ? t('warnings.multipleMonths', { months: w.params.months.join(', ') }) + : w.message} +
  • + ))} +
+
+ )} + + {preview.errors.length > 0 && ( +
+

{t('errors.title')}

+
+ + + + + + + + + + {preview.errors.map((e) => ( + + + + + + ))} + +
{t('errors.line')}{t('errors.field')}{t('errors.message')}
{e.row} + {t.has(`fields.${e.field}`) ? t(`fields.${e.field}`) : e.field} + + {t.has(`errors.${e.code}`) ? t(`errors.${e.code}`) : e.message} +
+
+
+ )} + + +
+ )} + + {message && ( +

+ {message.text} +

+ )} +
+ ); +} + +function SummaryItem({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function SettingsTab({ + settings, + onSaved, +}: { + settings: KantineSettings | null; + onSaved: (s: KantineSettings) => void; +}) { + const t = useTranslations('kantineDatev'); + const [beraterNr, setBeraterNr] = useState(''); + const [mandantNr, setMandantNr] = useState(''); + const [lohnart, setLohnart] = useState(''); + const [touched, setTouched] = useState(false); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState<{ kind: 'error' | 'ok'; text: string } | null>(null); + + // Gespeicherte Werte einmal ins Formular uebernehmen, sobald sie geladen sind. + useEffect(() => { + if (settings) { + setBeraterNr(settings.beraterNr ?? ''); + setMandantNr(settings.mandantNr ?? ''); + setLohnart(settings.lohnart ?? ''); + } + }, [settings]); + + const valid = (v: string) => /^\d{1,10}$/.test(v); + const allValid = valid(beraterNr) && valid(mandantNr) && valid(lohnart); + + const handleSave = async (event: React.FormEvent) => { + event.preventDefault(); + setTouched(true); + if (!allValid) return; + setSaving(true); + setMessage(null); + try { + const saved = await saveKantineSettings({ beraterNr, mandantNr, lohnart }); + onSaved(saved); + setMessage({ kind: 'ok', text: t('settings.saved') }); + } catch (error) { + setMessage({ + kind: 'error', + text: error instanceof KantineRequestError ? error.message : t('errors.request'), + }); + } finally { + setSaving(false); + } + }; + + const fields = [ + { id: 'beraterNr', label: t('settings.beraterNr'), value: beraterNr, set: setBeraterNr }, + { id: 'mandantNr', label: t('settings.mandantNr'), value: mandantNr, set: setMandantNr }, + { id: 'lohnart', label: t('settings.lohnart'), value: lohnart, set: setLohnart }, + ]; + + return ( +
+

{t('settings.intro')}

+ {fields.map((f) => { + const showError = touched && !valid(f.value); + return ( +
+ + f.set(e.target.value.trim())} + className={INPUT_CLASS} + /> + {showError &&

{t('settings.digitsOnly')}

} +
+ ); + })} + + {message && ( +

+ {message.text} +

+ )} +
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/module-layouts.test.tsx b/apps/web/src/app/(portal)/modules/module-layouts.test.tsx index 11641b3..dc1ed30 100644 --- a/apps/web/src/app/(portal)/modules/module-layouts.test.tsx +++ b/apps/web/src/app/(portal)/modules/module-layouts.test.tsx @@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest'; import CertManagerLayout from './cert-manager/layout'; import DkvFleetLayout from './dkv-fleet/layout'; import DomaincheckLayout from './domaincheck/layout'; +import KantineDatevLayout from './kantine-datev/layout'; import TenderRadarLayout from './tender-radar/layout'; /** @@ -38,6 +39,7 @@ describe('module layouts — ModuleAccessGate slug wiring (T-e8k-01, T-e8k-03)', ['dkv-fleet', DkvFleetLayout], ['domaincheck', DomaincheckLayout], ['tender-radar', TenderRadarLayout], + ['kantine-datev', KantineDatevLayout], ] as const)('%s/layout.tsx passes moduleSlug="%s" and forwards children', (expectedSlug, Layout) => { const element = Layout({ children: placeholderChild }); diff --git a/apps/web/src/components/accounting/file-drop-area.tsx b/apps/web/src/components/accounting/file-drop-area.tsx new file mode 100644 index 0000000..4c18881 --- /dev/null +++ b/apps/web/src/components/accounting/file-drop-area.tsx @@ -0,0 +1,97 @@ +'use client'; + +import { useRef, useState } from 'react'; + +interface FileDropAreaProps { + onFile: (file: File) => void; + onClear: () => void; + accept: string; + file: File | null; + /** Text in der leeren Flaeche. */ + placeholder: string; + /** Zweite Zeile (erlaubte Formate). */ + hint?: string; + /** Beschriftung des Entfernen-Knopfs (fuer Screenreader). */ + clearLabel: string; + disabled?: boolean; +} + +/** + * Ablageflaeche fuer eine einzelne Datei (Finanzbuchhaltungs-Module, + * quick-261002-fm5). Eigenstaendig, ohne Texte aus einem anderen Modul — + * Bauform wie die Ablage des Zertifikat-Managers: echte Schaltflaeche fuer + * Klick UND Ziehen, Entfernen als Geschwister daneben. + */ +export function FileDropArea({ + onFile, + onClear, + accept, + file, + placeholder, + hint, + clearLabel, + disabled = false, +}: FileDropAreaProps) { + const inputRef = useRef(null); + const [isDragOver, setIsDragOver] = useState(false); + + return ( +
+ { + const picked = e.target.files?.[0]; + if (picked) onFile(picked); + e.target.value = ''; + }} + /> +
+ + {file && ( + + )} +
+
+ ); +} diff --git a/apps/web/src/components/accounting/tab-bar.tsx b/apps/web/src/components/accounting/tab-bar.tsx new file mode 100644 index 0000000..e391867 --- /dev/null +++ b/apps/web/src/components/accounting/tab-bar.tsx @@ -0,0 +1,30 @@ +'use client'; + +interface TabBarProps { + tabs: { id: T; label: string }[]; + active: T; + onChange: (id: T) => void; +} + +/** Reiterleiste der Finanzbuchhaltungs-Module — Muster Zertifikat-Manager. */ +export function TabBar({ tabs, active, onChange }: TabBarProps) { + return ( + + ); +} diff --git a/apps/web/src/components/modules/module-tile.tsx b/apps/web/src/components/modules/module-tile.tsx index b551cbf..f05b7a5 100644 --- a/apps/web/src/components/modules/module-tile.tsx +++ b/apps/web/src/components/modules/module-tile.tsx @@ -43,6 +43,13 @@ const GLYPHS: Record = { ), + utensils: ( + <> + + + + + ), tile: ( <> diff --git a/apps/web/src/lib/accounting-request.ts b/apps/web/src/lib/accounting-request.ts new file mode 100644 index 0000000..c86eb67 --- /dev/null +++ b/apps/web/src/lib/accounting-request.ts @@ -0,0 +1,70 @@ +/** + * Gemeinsame Anfragehilfen der Finanzbuchhaltungs-Module (Kantinenabrechnung, + * Handelsware; quick-261002-fm5). `credentials: 'include'` fuer Cookie-Auth, + * `NEXT_PUBLIC_API_URL` als Basis — Muster `custom-modules-api.ts`. + */ + +export const ACCOUNTING_API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; + +/** Dateiantwort der API (Base64) — wie beim Zertifikat-Manager. */ +export interface FileResponse { + filename: string; + content: string; + mimeType: string; +} + +/** + * Fehler mit HTTP-Status, maschinenlesbarer Kennung (`code`, falls die API + * eine liefert) und deutscher Servermeldung. + */ +export class AccountingRequestError extends Error { + constructor( + readonly status: number, + readonly code: string | null, + message: string, + readonly details: unknown = null, + ) { + super(message); + this.name = 'AccountingRequestError'; + } +} + +async function failure(res: Response): Promise { + let message = `Request failed (${res.status})`; + let code: string | null = null; + let details: unknown = null; + try { + const body = await res.json(); + const raw = body?.message; + if (Array.isArray(raw)) message = raw.join(' '); + else if (typeof raw === 'string') message = raw; + if (typeof body?.code === 'string') code = body.code; + details = body?.errors ?? null; + } catch { + // Antwort ohne JSON-Koerper — Standardmeldung bleibt. + } + return new AccountingRequestError(res.status, code, message, details); +} + +export async function accountingRequest( + path: string, + init: { method?: string; json?: unknown; form?: FormData } = {}, +): Promise { + const headers: Record = {}; + let body: BodyInit | undefined; + if (init.form) { + body = init.form; + } else if (init.json !== undefined) { + headers['Content-Type'] = 'application/json'; + body = JSON.stringify(init.json); + } + const res = await fetch(`${ACCOUNTING_API_URL}${path}`, { + method: init.method ?? 'GET', + credentials: 'include', + headers, + body, + }); + if (!res.ok) throw await failure(res); + if (res.status === 204) return undefined as T; + return (await res.json()) as T; +} diff --git a/apps/web/src/lib/download-base64.ts b/apps/web/src/lib/download-base64.ts new file mode 100644 index 0000000..e81a0df --- /dev/null +++ b/apps/web/src/lib/download-base64.ts @@ -0,0 +1,22 @@ +/** + * Startet den Download einer Base64-kodierten Datei im Browser (quick-261002-fm5). + * + * Clientseitiger Blob-Download: Object-URL plus Anker mit `download`-Attribut. + * Genau dieser Weg wird vom Desktop-Client seit 1.9.2 als Datei gespeichert — + * deshalb keine Tauri-spezifische Sonderbehandlung. Gleicher Koerper wie + * `downloadBase64` im Zertifikat-Manager. + */ +export function downloadBase64(filename: string, content: string, mimeType: string): void { + const bytes = atob(content); + const byteArray = new Uint8Array(bytes.length); + for (let i = 0; i < bytes.length; i++) { + byteArray[i] = bytes.charCodeAt(i); + } + const blob = new Blob([byteArray], { type: mimeType }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + anchor.click(); + URL.revokeObjectURL(url); +} diff --git a/apps/web/src/lib/kantine-datev-api.ts b/apps/web/src/lib/kantine-datev-api.ts new file mode 100644 index 0000000..2a8b47f --- /dev/null +++ b/apps/web/src/lib/kantine-datev-api.ts @@ -0,0 +1,70 @@ +/** + * Kantinenabrechnung — API-Client (quick-261002-fm5). Konsumiert + * `/modules/kantine-datev`. Hochgeladene Dateien gehen als Multipart-Feld + * `file`; die API speichert sie nicht. + */ + +import { accountingRequest, type FileResponse } from '@/lib/accounting-request'; + +export type { FileResponse } from '@/lib/accounting-request'; +export { AccountingRequestError as KantineRequestError } from '@/lib/accounting-request'; + +const BASE = '/modules/kantine-datev'; + +export interface KantineSettings { + beraterNr: string | null; + mandantNr: string | null; + lohnart: string | null; + configured: boolean; +} + +export interface KantineSettingsInput { + beraterNr: string; + mandantNr: string; + lohnart: string; +} + +export interface KantineIssue { + row: number; + field: string; + code: string; + message: string; +} + +export interface KantineWarning { + code: string; + message: string; + params?: { months?: string[] }; +} + +export interface KantinePreview { + rowCount: number; + abrechnungsMonat: string | null; + totalCents: number; + errors: KantineIssue[]; + warnings: KantineWarning[]; + canExport: boolean; + blockedReason: 'settingsMissing' | 'errors' | null; +} + +function fileForm(file: File): FormData { + const form = new FormData(); + form.append('file', file); + return form; +} + +export function getKantineSettings(): Promise { + return accountingRequest(`${BASE}/settings`); +} + +export function saveKantineSettings(input: KantineSettingsInput): Promise { + return accountingRequest(`${BASE}/settings`, { method: 'PUT', json: input }); +} + +export function previewKantineCsv(file: File): Promise { + return accountingRequest(`${BASE}/preview`, { method: 'POST', form: fileForm(file) }); +} + +export function exportKantineCsv(file: File): Promise { + return accountingRequest(`${BASE}/export`, { method: 'POST', form: fileForm(file) }); +} diff --git a/apps/web/src/lib/module-identity.ts b/apps/web/src/lib/module-identity.ts index 3d53f7d..924b9ca 100644 --- a/apps/web/src/lib/module-identity.ts +++ b/apps/web/src/lib/module-identity.ts @@ -7,7 +7,7 @@ * (`--tile`, `--tile-foreground`, `--primary`, `--primary-foreground`). */ -export type ModuleIconId = 'radar' | 'fuel' | 'certificate' | 'globe' | 'server' | 'tile'; +export type ModuleIconId = 'radar' | 'fuel' | 'certificate' | 'globe' | 'server' | 'utensils' | 'tile'; const ICONS: Record = { 'tender-radar': 'radar', @@ -15,6 +15,7 @@ const ICONS: Record = { 'cert-manager': 'certificate', domaincheck: 'globe', proxmox: 'server', + 'kantine-datev': 'utensils', }; /** Symbol eines Moduls; unbekannte Module bekommen das allgemeine Kachel-Symbol. */ diff --git a/apps/web/src/lib/module-loader.ts b/apps/web/src/lib/module-loader.ts index fb03723..060b383 100644 --- a/apps/web/src/lib/module-loader.ts +++ b/apps/web/src/lib/module-loader.ts @@ -59,6 +59,12 @@ export const MODULE_REGISTRY: Record = { { ssr: false }, ), }, + 'kantine-datev': { + component: dynamic( + () => import('@/app/(portal)/modules/kantine-datev/page'), + { ssr: false }, + ), + }, }; /** diff --git a/apps/web/src/lib/stores/nav-store.ts b/apps/web/src/lib/stores/nav-store.ts index e8c44c7..fc1861b 100644 --- a/apps/web/src/lib/stores/nav-store.ts +++ b/apps/web/src/lib/stores/nav-store.ts @@ -29,6 +29,7 @@ const MODULE_TITLE_KEYS: Record = { 'cert-manager': 'certManager.title', 'dkv-fleet': 'dkvFleet.pageTitle', 'tender-radar': 'tenderRadar.page.title', + 'kantine-datev': 'kantineDatev.title', }; /** diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index 2553897..bf9890e 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -1618,12 +1618,93 @@ "loadError": "Die eigenen Module konnten nicht geladen werden." } }, + "kantineDatev": { + "title": "Kantinenabrechnung", + "description": "Kantinen-CSV prüfen und als DATEV-Lohndatei für die Gehaltsabrechnung herunterladen.", + "tabs": { + "billing": "Abrechnung", + "settings": "Einstellungen" + }, + "loading": "Wird geladen …", + "dropZone": { + "placeholder": "CSV-Datei hierher ziehen oder klicken", + "formats": ".csv, Semikolon-getrennt, UTF-8 oder Windows-1252", + "clear": "Datei entfernen" + }, + "noStorage": "Die hochgeladenen Daten werden nicht gespeichert.", + "checking": "Datei wird geprüft …", + "notConfigured": { + "admin": "Beraternummer, Mandantennummer und Lohnart sind noch nicht hinterlegt. Ohne diese Angaben kann keine DATEV-Datei erstellt werden.", + "adminAction": "Zu den Einstellungen", + "user": "Ein Administrator muss zuerst Beraternummer, Mandantennummer und Lohnart hinterlegen." + }, + "summary": { + "title": "Ergebnis der Prüfung", + "rows": "Zeilen", + "month": "Abrechnungsmonat", + "total": "Gesamtbetrag", + "noMonth": "nicht erkannt" + }, + "warnings": { + "title": "Hinweise", + "multipleMonths": "Verschiedene Abrechnungsmonate erkannt: {months}. Alle Zeilen sollten im selben Abrechnungsmonat liegen." + }, + "errors": { + "title": "Fehler in der Datei", + "line": "Zeile", + "field": "Feld", + "message": "Meldung", + "headerMissing": "Die Datei enthält keine Kopfzeile.", + "headerColumns": "Die Kopfzeile hat zu wenige Spalten. Ist das Trennzeichen korrekt (Semikolon)?", + "columnCount": "Die Zeile hat zu wenige Spalten.", + "personalNrMissing": "Personalnummer fehlt", + "personalNrNotNumeric": "Personalnummer muss numerisch sein", + "betragMissing": "Betrag fehlt", + "betragFormat": "Betrag muss im deutschen Zahlenformat vorliegen (Komma als Dezimaltrenner)", + "vonMissing": "Abrechnung von fehlt", + "vonFormat": "Abrechnung von muss im Format TT.MM.JJJJ vorliegen", + "bisMissing": "Abrechnung bis fehlt", + "bisFormat": "Abrechnung bis muss im Format TT.MM.JJJJ vorliegen", + "multiMonthRange": "Abrechnungszeitraum erstreckt sich über mehrere Monate", + "noRows": "Die Datei enthält keine Datenzeilen.", + "settingsMissing": "Beraternummer, Mandantennummer und Lohnart sind noch nicht hinterlegt.", + "hasErrors": "Die Datei enthält Fehler und kann nicht exportiert werden.", + "qualityCheckFailed": "Die erzeugte Datei hat die Qualitätsprüfung nicht bestanden.", + "request": "Die Anfrage ist fehlgeschlagen. Bitte versuchen Sie es erneut." + }, + "fields": { + "header": "Kopfzeile", + "zeile": "Zeile", + "datei": "Datei", + "personalNr": "Personalnummer", + "betrag": "Betrag", + "abrechnungVon": "Abrechnung von", + "abrechnungBis": "Abrechnung bis", + "abrechnungVon/abrechnungBis": "Abrechnungszeitraum" + }, + "download": { + "button": "DATEV-Datei herunterladen", + "busy": "Datei wird erstellt …", + "done": "Die Datei {filename} wurde heruntergeladen." + }, + "settings": { + "intro": "Diese Angaben stehen im Kopf und in den Zeilen der DATEV-Datei. Sie gelten für alle Benutzer Ihres Mandanten und werden nur einmal hinterlegt.", + "beraterNr": "Beraternummer", + "mandantNr": "Mandantennummer", + "lohnart": "Lohnart", + "digitsOnly": "Bitte nur Ziffern eingeben (1 bis 10 Stellen).", + "save": "Speichern", + "saving": "Wird gespeichert …", + "saved": "Die Einstellungen wurden gespeichert." + } + }, "moduleCategories": { "domain-tools": "Domains", "security-tools": "Sicherheit", "fleet": "Fuhrpark", "infrastructure": "Infrastruktur", "procurement": "Beschaffung", + "accounting": "Finanzbuchhaltung", "custom-modules": "Eigene Module" } } diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index e9f7a15..bb73a47 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -1618,12 +1618,93 @@ "loadError": "The custom modules could not be loaded." } }, + "kantineDatev": { + "title": "Canteen billing", + "description": "Check canteen CSV files and download them as a DATEV payroll file for the salary run.", + "tabs": { + "billing": "Billing", + "settings": "Settings" + }, + "loading": "Loading …", + "dropZone": { + "placeholder": "Drag a CSV file here or click", + "formats": ".csv, semicolon-separated, UTF-8 or Windows-1252", + "clear": "Remove file" + }, + "noStorage": "The uploaded data is not stored.", + "checking": "Checking file …", + "notConfigured": { + "admin": "Consultant number, client number and wage type have not been set yet. Without them no DATEV file can be created.", + "adminAction": "Go to settings", + "user": "An administrator has to set the consultant number, client number and wage type first." + }, + "summary": { + "title": "Check result", + "rows": "Rows", + "month": "Billing month", + "total": "Total amount", + "noMonth": "not detected" + }, + "warnings": { + "title": "Notes", + "multipleMonths": "Different billing months detected: {months}. All rows should be in the same billing month." + }, + "errors": { + "title": "Errors in the file", + "line": "Line", + "field": "Field", + "message": "Message", + "headerMissing": "The file has no header line.", + "headerColumns": "The header has too few columns. Is the separator correct (semicolon)?", + "columnCount": "The line has too few columns.", + "personalNrMissing": "Personnel number is missing", + "personalNrNotNumeric": "Personnel number must be numeric", + "betragMissing": "Amount is missing", + "betragFormat": "Amount must be in German number format (comma as decimal separator)", + "vonMissing": "Billing from is missing", + "vonFormat": "Billing from must be in the format DD.MM.YYYY", + "bisMissing": "Billing to is missing", + "bisFormat": "Billing to must be in the format DD.MM.YYYY", + "multiMonthRange": "The billing period spans several months", + "noRows": "The file contains no data rows.", + "settingsMissing": "Consultant number, client number and wage type have not been set yet.", + "hasErrors": "The file contains errors and cannot be exported.", + "qualityCheckFailed": "The generated file failed the quality check.", + "request": "The request failed. Please try again." + }, + "fields": { + "header": "Header", + "zeile": "Line", + "datei": "File", + "personalNr": "Personnel number", + "betrag": "Amount", + "abrechnungVon": "Billing from", + "abrechnungBis": "Billing to", + "abrechnungVon/abrechnungBis": "Billing period" + }, + "download": { + "button": "Download DATEV file", + "busy": "Creating file …", + "done": "The file {filename} has been downloaded." + }, + "settings": { + "intro": "These values appear in the header and the lines of the DATEV file. They apply to all users of your tenant and only have to be set once.", + "beraterNr": "Consultant number", + "mandantNr": "Client number", + "lohnart": "Wage type", + "digitsOnly": "Please enter digits only (1 to 10 digits).", + "save": "Save", + "saving": "Saving …", + "saved": "The settings have been saved." + } + }, "moduleCategories": { "domain-tools": "Domains", "security-tools": "Security", "fleet": "Fleet", "infrastructure": "Infrastructure", "procurement": "Procurement", + "accounting": "Financial accounting", "custom-modules": "Custom modules" } } diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md index c1bdb76..27fa83f 100644 --- a/docs/mandantentrennung-zugriffsklassifikation.md +++ b/docs/mandantentrennung-zugriffsklassifikation.md @@ -178,7 +178,8 @@ Spalten sind mit der Schleife aus dem Gate von 260914-eym nachgerechnet | proxmox | 0 | 11 | 1 | **quick-260923-dhh (Aufgabe 5, Endstand):** 7→11 gebunden — `updateServer` (`proxmoxServer.findUnique` UND `.update`) und `deleteServer` (`proxmoxServer.findUnique` UND `.delete`) bringen vier weitere gebundene Rohtreffer, je ein Klient je Methode. Nachgemessen mit der Gate-Schleife (`grep -c` ueber `tenantPrisma\.\(proxmoxServer\|proxmoxServerStatus\)\.` in `proxmox.service.ts`: 10 fuer `proxmoxServer`, 1 fuer `proxmoxServerStatus`). Vorher: **quick-260923-dhh (Aufgabe 4):** 4→7 gebunden, 0→1 System — `proxmox.service.ts` bringt drei weitere gebundene Rohtreffer (`pollServer` mit `include: { status: true }` bleibt EIN Klient, `testConnection`, `listActiveServerIdsForTenant`, `loadActiveServersForTenantScheduling` — vier neue Methoden, aber `pollServer`s zweiter Zugriff war schon gezaehlt, macht drei zusaetzliche) und einen System-Rohtreffer (`loadActiveServersForScheduler()`, der einzige `forSystem()`-Aufruf des Moduls, Erlaubnisliste in `rls-access-inventory.spec.ts`). Vorher: **quick-260923-dhh (Aufgabe 1):** neu, vier gebundene Rohtreffer: `createServer` (`proxmoxServer.create`), `listWithStatus` (`proxmoxServer.findMany`), `pollServer` (`proxmoxServer.findUnique` UND `proxmoxServerStatus.upsert`, DERSELBE Klient in derselben Methode) | | custom-modules | 0 | 6 | 0 | **Nachgemessen quick-260929-dzu:** 0/6/0 — persönliche Einträge je Benutzer: `create` trägt jetzt zwei Klienten in getrennten Zweigen (gemeinsam ohne Benutzer, persönlich mit Benutzer, je ein `tenantPrisma.customModule.create`), die gemeinsame Ladefunktion `loadVisible` trägt das einzige `findUnique` für `getOne`/`update`/`remove` (vorher je Methode eines): `list` 1, `create` 2, `loadVisible` 1, `update` 1, `remove` 1. Das Ergebnis ist ein Treffer weniger als bei quick-260929-9wc, obwohl der Zugriff strenger geworden ist. Vorher: **quick-260929-9wc:** neu, sieben gebundene Rohtreffer in `custom-modules.service.ts` (`list` 1, `getOne` 1, `create` 1, `update` 2, `remove` 2), nachgemessen mit der Gate-Schleife: 0/7/0. Kein ungebundener Zugriff, kein Systemkontext. | | reminders | 0 | 12 | 1 | **quick-260929-if2 (Aufgabe 3):** nachgemessen mit der Gate-Schleife: 0/12/1 — +5 gebunden, +1 System. `reminders.service.ts` +1 gebunden (`getEmailAvailability`: `user.findFirst` für die eigene E-Mail-Adresse, an Mandant und Benutzer gebunden). NEU `reminder-mail.scheduler.ts`: +4 gebunden je Kandidatenzeile (`reminder.updateMany` als Anspruch, `reminder.findFirst`, `user.findFirst` für die Adresse des Besitzers, `reminder.updateMany` als Freigabe bei Transportfehler; alle über `forTenant(prisma, c.tenantId)` ohne Benutzer) und +1 System (`systemPrisma.reminder.findMany`, die Kandidatenabfrage über alle Mandanten, nur skalarer Select). Vorher: **quick-260929-if2 (Aufgabe 2):** nachgemessen mit der Gate-Schleife: 0/7/0 — +4 gebunden: `update` (`update`), `snooze` (`update`), `remove` (`delete`) und die gemeinsame Besitzprüfung `loadOwn` (`findFirst`, ein Treffer für alle drei; fremde und unbekannte Kennungen sind dort ununterscheidbar 404, D-05). Vorher: **quick-260929-if2 (Aufgabe 1, Tracer):** neu, drei gebundene Rohtreffer in `reminders.service.ts`, nachgemessen mit der Gate-Schleife: 0/3/0 — `list` (`findMany`), `create` (`count` fuer die Grenze von 100 und `create`). Persönliche Erinnerungen je Benutzer, jede Methode bindet mit Mandant UND Benutzer (`forTenant(prisma, tenantId, userId)`). Kein ungebundener Zugriff, kein Systemkontext in diesem Bereich (der E-Mail-Planer folgt in Aufgabe 3). | -| **Summe** | **61** | **240** | **7** | **Willkommensmail-Vorlage:** Gebunden +5 = `user` (siehe dortige Zeile), Ungebunden und System unverändert: 61/240/7. Vorher 61/235/7 — **Nachgemessen quick-260929-if2 (Aufgabe 3):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/235/7. Gegenüber der bisherigen Zeile (61/230/6): Gebunden +5 und System +1 = `reminders` (siehe dortige Zeile), Ungebunden unverändert. Vorher: **Nachgemessen quick-260929-if2 (Aufgabe 2):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/230/6. Gegenüber der bisherigen Zeile (61/226/6): Gebunden +4 = `reminders` +4 (siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-if2 (Aufgabe 1):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/226/6. Gegenüber der bisherigen Zeile (61/223/6): Gebunden +3 = `reminders` +3 (neu, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-dzu:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/223/6. Gegenüber der bisherigen Zeile (61/224/6): Gebunden −1 = `custom-modules` −1 (7→6, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-9wc:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/224/6. Gegenueber der bisherigen Zeile (61/216/6): Gebunden +8 = `user` +1 (Drift aus quick-260928-ujj, siehe dortige Zeile; gemessen war schon vorher 61/217/6) und `custom-modules` +7 (neu, siehe dortige Zeile), Ungebunden/System unveraendert. Vorher: **quick-260925-bow:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/216/6. Gegenüber der bisherigen Zeile (61/213/6): Gebunden +3 = `user` +3 (die zwei Selbstbedienungswege des „Was ist neu“-Fensters in `user.controller.ts`, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260924-m4n:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/213/6. Gegenüber der bisherigen Zeile (61/208/7): Gebunden +5 = `favorites` +4 (Drift aus quick-260923-lrr nachgeholt) und `dashboard` +1 (Drift +3 nachgeholt, diese Änderung −2; siehe dortige Zeilen), System −1 (`dashboard`, Bootstrap-Umzug der Bilderrahmen-Bilder entfernt). Vorher: **quick-260923-dhh (Aufgabe 5, Endstand):** Gebunden 204→208 (`proxmox` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 4):** Gebunden 201→204 (`proxmox` +3, siehe dortige Zeile), System 6→7 (`proxmox` +1) — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft | +| kantine-datev | 0 | 2 | 0 | **quick-261002-fm5:** neu, zwei gebundene Rohtreffer in `kantine-datev.service.ts` (`getSettings` `findUnique`, `saveSettings` `upsert`), nachgemessen mit der Gate-Schleife: 0/2/0. Kein ungebundener Zugriff, kein Systemkontext. | +| **Summe** | **61** | **242** | **7** | **quick-261002-fm5 (Aufgabe 1):** Gebunden +2 = `kantine-datev` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/242/7, nachgemessen mit der Gate-Schleife. Vorher: **Willkommensmail-Vorlage:** Gebunden +5 = `user` (siehe dortige Zeile), Ungebunden und System unverändert: 61/240/7. Vorher 61/235/7 — **Nachgemessen quick-260929-if2 (Aufgabe 3):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/235/7. Gegenüber der bisherigen Zeile (61/230/6): Gebunden +5 und System +1 = `reminders` (siehe dortige Zeile), Ungebunden unverändert. Vorher: **Nachgemessen quick-260929-if2 (Aufgabe 2):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/230/6. Gegenüber der bisherigen Zeile (61/226/6): Gebunden +4 = `reminders` +4 (siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-if2 (Aufgabe 1):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/226/6. Gegenüber der bisherigen Zeile (61/223/6): Gebunden +3 = `reminders` +3 (neu, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-dzu:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/223/6. Gegenüber der bisherigen Zeile (61/224/6): Gebunden −1 = `custom-modules` −1 (7→6, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-9wc:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/224/6. Gegenueber der bisherigen Zeile (61/216/6): Gebunden +8 = `user` +1 (Drift aus quick-260928-ujj, siehe dortige Zeile; gemessen war schon vorher 61/217/6) und `custom-modules` +7 (neu, siehe dortige Zeile), Ungebunden/System unveraendert. Vorher: **quick-260925-bow:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/216/6. Gegenüber der bisherigen Zeile (61/213/6): Gebunden +3 = `user` +3 (die zwei Selbstbedienungswege des „Was ist neu“-Fensters in `user.controller.ts`, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260924-m4n:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/213/6. Gegenüber der bisherigen Zeile (61/208/7): Gebunden +5 = `favorites` +4 (Drift aus quick-260923-lrr nachgeholt) und `dashboard` +1 (Drift +3 nachgeholt, diese Änderung −2; siehe dortige Zeilen), System −1 (`dashboard`, Bootstrap-Umzug der Bilderrahmen-Bilder entfernt). Vorher: **quick-260923-dhh (Aufgabe 5, Endstand):** Gebunden 204→208 (`proxmox` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 4):** Gebunden 201→204 (`proxmox` +3, siehe dortige Zeile), System 6→7 (`proxmox` +1) — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft | ## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 89 Paare) @@ -385,6 +386,10 @@ Nachgezaehlt mit `grep -cE '^\| apps/api/src/'` gegen die Bestandsaufnahme: 89 P `muss-mandantengebunden`, 22 `keine-mandantengebundene-tabelle`, 16 `beides`, 2 `bewusst-uebergreifend`. +quick-261002-fm5 (Aufgabe 1): +1 `muss-mandantengebunden` (`kantine-datev.service.ts`/`kantineDatevConfig`, `gebunden`): +90 Paare, davon 50 `muss-mandantengebunden`, 22 `keine-mandantengebundene-tabelle`, 16 `beides`, +2 `bewusst-uebergreifend` — nachgezaehlt mit `grep -cE '^\| apps/api/src/'` gegen die Bestandsaufnahme. + ## Der Hintergrunddienst als Falle — sechs Fälle Ein Planer, der über alle Mandanten iteriert, liest zu Recht übergreifend — @@ -823,6 +828,7 @@ werden. | apps/api/src/reminders/reminders.service.ts | user | muss-mandantengebunden | gebunden | quick-260929-if2 (Aufgabe 3): `getEmailAvailability` liest die eigene E-Mail-Adresse des Aufrufers (`user.findFirst` mit `where: { id: userId, tenantId }`), gebunden mit Mandant UND Benutzer über denselben Klienten wie die übrigen Zugriffe der Methode. Grundlage für den Schalter „zusätzlich per E-Mail“ und die 400-Antwort bei `emailEnabled` ohne Adresse. | | apps/api/src/reminders/reminder-mail.scheduler.ts | reminder | beides | system-gebunden | quick-260929-if2 (Aufgabe 3): der E-Mail-Planer der Erinnerungen ist ein globales 30-Sekunden-Intervall über ALLE Mandanten (bewusst übergreifend, siehe Dateikopf). Die Kandidatenabfrage (`findMany`, nur skalarer Select, `take 200`) liest über `forSystem()` (`system_read_policy ... FOR SELECT`, Migration 20260929140000, nur lesend); Anspruch (`updateMany`), Laden (`findFirst`) und Freigabe (`updateMany`) laufen je Kandidatenzeile über `forTenant(prisma, c.tenantId)`. Der Anspruch ist atomar (`emailSentAt: null` und unveränderte `dueAt` in der Bedingung), damit mehrere Instanzen nie doppelt senden. | | apps/api/src/reminders/reminder-mail.scheduler.ts | user | beides | gebunden | quick-260929-if2 (Aufgabe 3): die Empfängeradresse wird je Kandidatenzeile gelesen (`user.findFirst` mit `where: { id, tenantId }`), gebunden an den Mandanten dieser Zeile — bewusst NICHT als Relation im Systemklienten der Kandidatenabfrage (sonst würde `User` zum Systemlese-Modell). | +| apps/api/src/kantine-datev/kantine-datev.service.ts | kantineDatevConfig | muss-mandantengebunden | gebunden | **quick-261002-fm5:** neu — die drei Nummern der Kantinenabrechnung (Beraternummer, Mandantennummer, Lohnart), eine Zeile je Mandant (Singleton, Vorbild `DkvModuleConfig`). `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20261002120000) — Einstellungen des Mandanten, nicht persönliche Daten eines Benutzers. Bewusst KEINE `system_read_policy`: es gibt keinen Hintergrunddienst, der diese Einstellungen über alle Mandanten liest. Zwei mandantengebundene Rohtreffer, je Methode ein eigener Klient (`const tenantPrisma = forTenant(this.prisma, tenantId)`): `getSettings` (`findUnique`), `saveSettings` (`upsert`). Die hochgeladene Kantinen-CSV (Namen, Personalnummern) berührt die Datenbank nie. | ## Was diese Etappe NICHT entscheidet diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index bd137ae..e2207f1 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -272,6 +272,7 @@ export const MODULE_CATEGORIES = [ "fleet", "infrastructure", "procurement", + "accounting", ] as const; export type ModuleCategory = (typeof MODULE_CATEGORIES)[number];