feat(kantine-datev): Kantinenabrechnung als Modul in neuer Gruppe Finanzbuchhaltung
- neue Seitenleisten-Kategorie accounting (Finanzbuchhaltung / Financial accounting) - CSV (UTF-8, UTF-8 mit BOM, Windows-1252) pruefen, Vorschau mit Zeilenfehlern, DATEV-Lohn-ASCII-Export - Beraternummer, Mandantennummer, Lohnart je Mandant als Admin-Einstellung (KantineDatevConfig mit Zeilenschutz), anfangs leer - hochgeladene Daten werden nicht gespeichert; Download per Blob (auch im Desktop-Client) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
-- 261002-fm5 — Finanzbuchhaltung: Modul "Kantinenabrechnung" (kantine-datev).
|
||||
--
|
||||
-- Zweck: eine neue Tabelle `KantineDatevConfig` mit den drei Nummern, die der
|
||||
-- Administrator einmalig je Mandant hinterlegt (Beraternummer, Mandantennummer,
|
||||
-- Lohnart). Eine Zeile je Mandant (Singleton, Vorbild `DkvModuleConfig`). Die
|
||||
-- Felder sind Text, damit fuehrende Nullen erhalten bleiben, und haben
|
||||
-- ABSICHTLICH keinen Standardwert: solange sie leer sind, sperrt das Modul die
|
||||
-- Verarbeitung. Die hochgeladene Kantinen-CSV wird nicht gespeichert.
|
||||
--
|
||||
-- Von Hand geschrieben (Vorbild 20260923140000_proxmox_server), von Hand
|
||||
-- gepflegter Kopfkommentar Pflicht bei jeder RLS-Migration in diesem Projekt.
|
||||
--
|
||||
-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): die Tabelle
|
||||
-- traegt `tenantId` und `tenant_isolation_policy` OHNE Benutzerdimension
|
||||
-- (`USING ("tenantId" = current_tenant_id())`, Form aus `DkvModuleConfig`) —
|
||||
-- Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines Benutzers.
|
||||
-- Keine `system_read_policy`: es gibt keinen Hintergrunddienst, der diese
|
||||
-- Einstellungen ueber alle Mandanten liest.
|
||||
--
|
||||
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
|
||||
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
|
||||
-- tun.
|
||||
--
|
||||
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
|
||||
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
|
||||
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
|
||||
|
||||
CREATE TABLE "KantineDatevConfig" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"beraterNr" TEXT,
|
||||
"mandantNr" TEXT,
|
||||
"lohnart" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "KantineDatevConfig_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "KantineDatevConfig_tenantId_key" ON "KantineDatevConfig"("tenantId");
|
||||
CREATE INDEX "KantineDatevConfig_tenantId_idx" ON "KantineDatevConfig"("tenantId");
|
||||
|
||||
ALTER TABLE "KantineDatevConfig" ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "KantineDatevConfig" FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation_policy ON "KantineDatevConfig"
|
||||
USING ("tenantId" = current_tenant_id());
|
||||
@@ -344,6 +344,23 @@ model DkvModuleConfig {
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
||||
// quick-261002-fm5: Kantinenabrechnung (Modul kantine-datev). Eine Zeile je
|
||||
// Mandant (Singleton wie DkvModuleConfig). Die drei Nummern stehen als Text,
|
||||
// damit fuehrende Nullen erhalten bleiben; sie haben bewusst KEINEN
|
||||
// Standardwert — der Administrator hinterlegt sie einmalig, bis dahin ist die
|
||||
// Verarbeitung gesperrt. Die hochgeladene CSV selbst wird nie gespeichert.
|
||||
model KantineDatevConfig {
|
||||
id String @id @default(uuid())
|
||||
tenantId String @unique
|
||||
beraterNr String?
|
||||
mandantNr String?
|
||||
lohnart String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
||||
// Phase 14, Plan 03 (INGEST-05, CONFIG-02, D-06/D-07) — per-tenant portal-
|
||||
// alert mailbox config, mirroring DkvModuleConfig's shape/pattern exactly
|
||||
// (own tenantId @unique row, own encrypted creds — D-03: each module keeps
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { decodeCsvText } from './decode-csv-text';
|
||||
|
||||
describe('decodeCsvText', () => {
|
||||
it('liest gueltiges UTF-8 unveraendert', () => {
|
||||
expect(decodeCsvText(Buffer.from('Müller;Straße', 'utf8'))).toBe('Müller;Straße');
|
||||
});
|
||||
|
||||
it('entfernt ein UTF-8-BOM', () => {
|
||||
const buf = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from('Name;Wert', 'utf8')]);
|
||||
expect(decodeCsvText(buf)).toBe('Name;Wert');
|
||||
});
|
||||
|
||||
it('faellt bei ungueltigem UTF-8 auf Windows-1252 zurueck (Umlaut)', () => {
|
||||
expect(decodeCsvText(Buffer.from('Müller', 'latin1'))).toBe('Müller');
|
||||
});
|
||||
|
||||
it('liest das Euro-Zeichen (0x80) in Windows-1252', () => {
|
||||
expect(decodeCsvText(Buffer.from([0x31, 0x30, 0x80]))).toBe('10€');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Dekodiert hochgeladene CSV-Bytes zu Text (quick-261002-fm5).
|
||||
*
|
||||
* Excel und Warenwirtschaftssysteme liefern CSV entweder als UTF-8 (mit oder
|
||||
* ohne Byte-Order-Mark) oder als Windows-1252. Zuerst wird streng als UTF-8
|
||||
* gelesen: sind die Bytes kein gueltiges UTF-8 (typisch bei Umlauten in
|
||||
* Windows-1252), faellt die Funktion auf Windows-1252 zurueck. `TextDecoder`
|
||||
* verwirft ein fuehrendes BOM standardmaessig.
|
||||
*
|
||||
* Gemeinsam genutzt von Kantinenabrechnung und Handelsware.
|
||||
*/
|
||||
export function decodeCsvText(buffer: Buffer): string {
|
||||
try {
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(buffer);
|
||||
} catch {
|
||||
return new TextDecoder('windows-1252').decode(buffer);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import { TenantGuard } from './tenant/tenant.guard';
|
||||
import { TenantModule } from './tenant/tenant.module';
|
||||
import { TendersModule } from './tenders/tenders.module';
|
||||
import { UserModule } from './user/user.module';
|
||||
import { KantineDatevModule } from './kantine-datev/kantine-datev.module';
|
||||
import { ProxmoxModule } from './proxmox/proxmox.module';
|
||||
import { CustomModulesModule } from './custom-modules/custom-modules.module';
|
||||
import { RemindersModule } from './reminders/reminders.module';
|
||||
@@ -55,6 +56,7 @@ import { RemindersModule } from './reminders/reminders.module';
|
||||
TendersModule,
|
||||
BugReportsModule,
|
||||
ProxmoxModule,
|
||||
KantineDatevModule,
|
||||
CustomModulesModule,
|
||||
RemindersModule,
|
||||
],
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { IsString, Matches } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Einstellungen der Kantinenabrechnung (quick-261002-fm5): drei Nummern, die
|
||||
* der Administrator einmalig je Mandant hinterlegt. Nur Ziffern (1 bis 10),
|
||||
* Text statt Zahl, damit fuehrende Nullen erhalten bleiben.
|
||||
*/
|
||||
export class KantineDatevSettingsDto {
|
||||
@IsString({ message: 'Die Beraternummer muss angegeben werden' })
|
||||
@Matches(/^\d{1,10}$/, {
|
||||
message: 'Die Beraternummer darf nur Ziffern enthalten (1 bis 10 Stellen)',
|
||||
})
|
||||
beraterNr!: string;
|
||||
|
||||
@IsString({ message: 'Die Mandantennummer muss angegeben werden' })
|
||||
@Matches(/^\d{1,10}$/, {
|
||||
message: 'Die Mandantennummer darf nur Ziffern enthalten (1 bis 10 Stellen)',
|
||||
})
|
||||
mandantNr!: string;
|
||||
|
||||
@IsString({ message: 'Die Lohnart muss angegeben werden' })
|
||||
@Matches(/^\d{1,10}$/, { message: 'Die Lohnart darf nur Ziffern enthalten (1 bis 10 Stellen)' })
|
||||
lohnart!: string;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseKantinenCsv } from './kantine-csv.parser';
|
||||
|
||||
const HEADER = 'PersNr;Name;Menge;EK;Netto;ZuAb;MwSt;Zuschuss;Betrag;Von;Bis';
|
||||
const ROW = '100;Muster, Max;1;1,00;1,00;0;0;0;7,94;01.03.2026;31.03.2026';
|
||||
|
||||
describe('parseKantinenCsv', () => {
|
||||
it('liefert bei CRLF und LF dieselben Zeilen', () => {
|
||||
const lf = parseKantinenCsv([HEADER, ROW, ROW].join('\n'));
|
||||
const crlf = parseKantinenCsv([HEADER, ROW, ROW].join('\r\n'));
|
||||
expect(crlf.rows).toEqual(lf.rows);
|
||||
expect(lf.rows).toHaveLength(2);
|
||||
expect(lf.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('ueberspringt leere Zeilen und merkt sich die echte Zeilennummer', () => {
|
||||
const { rows } = parseKantinenCsv([HEADER, '', ROW, '', ROW, ''].join('\r\n'));
|
||||
expect(rows.map((r) => r.line)).toEqual([3, 5]);
|
||||
});
|
||||
|
||||
it('meldet einen Kopf mit zu wenigen Spalten mit Hinweis auf das Trennzeichen', () => {
|
||||
const { rows, errors } = parseKantinenCsv('a,b,c\n1,2,3');
|
||||
expect(rows).toEqual([]);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toMatchObject({ row: 1, code: 'headerColumns' });
|
||||
expect(errors[0].message).toContain('Ist das Trennzeichen korrekt (Semikolon)?');
|
||||
});
|
||||
|
||||
it('meldet eine leere Datei als fehlenden Kopf', () => {
|
||||
expect(parseKantinenCsv('').errors[0]).toMatchObject({ row: 1, code: 'headerMissing' });
|
||||
});
|
||||
|
||||
it('meldet eine Datenzeile mit zu wenigen Spalten mit Zeilennummer und ueberspringt sie', () => {
|
||||
const { rows, errors } = parseKantinenCsv([HEADER, ROW, '1;2;3', ROW].join('\n'));
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(errors).toEqual([
|
||||
expect.objectContaining({ row: 3, code: 'columnCount', field: 'zeile' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('trimmt Whitespace', () => {
|
||||
const { rows } = parseKantinenCsv(
|
||||
[HEADER, ` 100 ; Max ;1;1;1;0;0;0; 7,94 ;01.03.2026;31.03.2026`].join('\n'),
|
||||
);
|
||||
expect(rows[0].personalNr).toBe('100');
|
||||
expect(rows[0].betrag).toBe('7,94');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { KantinenRawRow, ValidationError } from './kantine-datev.types';
|
||||
|
||||
/** Erwartete Anzahl der Spalten pro CSV-Zeile. */
|
||||
const ERWARTETE_SPALTENANZAHL = 11;
|
||||
|
||||
/**
|
||||
* Parst eine Kantinen-CSV (Semikolon-getrennt, bereits als Text dekodiert).
|
||||
*
|
||||
* - Erste Zeile ist der Kopf und wird uebersprungen
|
||||
* - Leere Zeilen werden ignoriert
|
||||
* - Whitespace wird getrimmt
|
||||
* - Falsche Spaltenanzahl erzeugt einen Fehler mit Zeilennummer
|
||||
*/
|
||||
export function parseKantinenCsv(content: string): {
|
||||
rows: KantinenRawRow[];
|
||||
errors: ValidationError[];
|
||||
} {
|
||||
const rows: KantinenRawRow[] = [];
|
||||
const errors: ValidationError[] = [];
|
||||
|
||||
// Unterstuetzt CRLF und LF.
|
||||
const zeilen = content.split(/\r?\n/);
|
||||
|
||||
const headerZeile = zeilen[0]?.trim();
|
||||
if (!headerZeile) {
|
||||
errors.push({
|
||||
row: 1,
|
||||
field: 'header',
|
||||
code: 'headerMissing',
|
||||
message: 'Die Datei enthält keine Header-Zeile.',
|
||||
});
|
||||
return { rows, errors };
|
||||
}
|
||||
|
||||
const headerSpalten = headerZeile.split(';').map((s) => s.trim());
|
||||
if (headerSpalten.length < ERWARTETE_SPALTENANZAHL) {
|
||||
errors.push({
|
||||
row: 1,
|
||||
field: 'header',
|
||||
code: 'headerColumns',
|
||||
message: `Header enthält nur ${headerSpalten.length} Spalten, erwartet werden ${ERWARTETE_SPALTENANZAHL}. Ist das Trennzeichen korrekt (Semikolon)?`,
|
||||
});
|
||||
return { rows, errors };
|
||||
}
|
||||
|
||||
for (let i = 1; i < zeilen.length; i++) {
|
||||
const zeile = zeilen[i]?.trim();
|
||||
const zeilenNummer = i + 1;
|
||||
|
||||
if (!zeile) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const spalten = zeile.split(';').map((s) => s.trim());
|
||||
|
||||
if (spalten.length < ERWARTETE_SPALTENANZAHL) {
|
||||
errors.push({
|
||||
row: zeilenNummer,
|
||||
field: 'zeile',
|
||||
code: 'columnCount',
|
||||
message: `Zeile hat nur ${spalten.length} Spalten, erwartet werden ${ERWARTETE_SPALTENANZAHL}.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.push({
|
||||
personalNr: spalten[0],
|
||||
name: spalten[1],
|
||||
menge: spalten[2],
|
||||
ekPreis: spalten[3],
|
||||
netto: spalten[4],
|
||||
zuAbschlag: spalten[5],
|
||||
mwst: spalten[6],
|
||||
zuschuss: spalten[7],
|
||||
betrag: spalten[8],
|
||||
abrechnungVon: spalten[9],
|
||||
abrechnungBis: spalten[10],
|
||||
line: zeilenNummer,
|
||||
});
|
||||
}
|
||||
|
||||
return { rows, errors };
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { validateKantinenData } from './kantine-csv.validator';
|
||||
import type { KantinenRawRow } from './kantine-datev.types';
|
||||
|
||||
function row(over: Partial<KantinenRawRow> = {}): KantinenRawRow {
|
||||
return {
|
||||
personalNr: '100',
|
||||
name: 'Max Muster',
|
||||
menge: '1',
|
||||
ekPreis: '1,00',
|
||||
netto: '1,00',
|
||||
zuAbschlag: '0',
|
||||
mwst: '0',
|
||||
zuschuss: '0',
|
||||
betrag: '7,94',
|
||||
abrechnungVon: '01.03.2026',
|
||||
abrechnungBis: '31.03.2026',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('validateKantinenData', () => {
|
||||
it('akzeptiert eine gueltige Zeile und bestimmt den Monat aus "bis"', () => {
|
||||
const result = validateKantinenData([row()]);
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.abrechnungsMonat).toBe('03/2026');
|
||||
});
|
||||
|
||||
it('meldet nicht numerische und fehlende Personalnummern', () => {
|
||||
const r = validateKantinenData([row({ personalNr: 'A12' }), row({ personalNr: '' })]);
|
||||
expect(r.errors.map((e) => e.code)).toEqual(['personalNrNotNumeric', 'personalNrMissing']);
|
||||
expect(r.errors[0].message).toBe('Personalnummer muss numerisch sein');
|
||||
});
|
||||
|
||||
it('lehnt Betraege mit Tausenderpunkt oder Minus ab (wie die Vorlage)', () => {
|
||||
const r = validateKantinenData([
|
||||
row({ betrag: '1.234,56' }),
|
||||
row({ betrag: '-5,00' }),
|
||||
row({ betrag: '' }),
|
||||
]);
|
||||
expect(r.errors.map((e) => e.code)).toEqual(['betragFormat', 'betragFormat', 'betragMissing']);
|
||||
});
|
||||
|
||||
it('prueft das Datumsformat', () => {
|
||||
const r = validateKantinenData([row({ abrechnungVon: '2026-03-01', abrechnungBis: '' })]);
|
||||
expect(r.errors.map((e) => e.code)).toEqual(['vonFormat', 'bisMissing']);
|
||||
});
|
||||
|
||||
it('meldet von/bis in verschiedenen Monaten mit Zeile = Index + 2', () => {
|
||||
const r = validateKantinenData([row(), row({ abrechnungVon: '28.02.2026' })]);
|
||||
expect(r.errors).toEqual([
|
||||
expect.objectContaining({
|
||||
row: 3,
|
||||
code: 'multiMonthRange',
|
||||
field: 'abrechnungVon/abrechnungBis',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('nimmt die echte Dateizeile, wenn der Parser sie mitliefert', () => {
|
||||
const r = validateKantinenData([row({ personalNr: 'x', line: 9 })]);
|
||||
expect(r.errors[0].row).toBe(9);
|
||||
});
|
||||
|
||||
it('warnt bei zwei Abrechnungsmonaten und behaelt den ersten', () => {
|
||||
const r = validateKantinenData([
|
||||
row(),
|
||||
row({ abrechnungVon: '01.04.2026', abrechnungBis: '30.04.2026' }),
|
||||
]);
|
||||
expect(r.isValid).toBe(true);
|
||||
expect(r.abrechnungsMonat).toBe('03/2026');
|
||||
expect(r.warnings).toHaveLength(1);
|
||||
expect(r.warnings[0]).toMatchObject({
|
||||
code: 'multipleMonths',
|
||||
params: { months: ['03/2026', '04/2026'] },
|
||||
});
|
||||
expect(r.warnings[0].message).toBe(
|
||||
'Verschiedene Abrechnungsmonate erkannt: 03/2026, 04/2026. Alle Zeilen sollten im selben Abrechnungsmonat liegen.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import type {
|
||||
KantinenRawRow,
|
||||
ValidationError,
|
||||
ValidationResult,
|
||||
ValidationWarning,
|
||||
} from './kantine-datev.types';
|
||||
|
||||
/** Zahl im deutschen Format (Komma als Dezimaltrenner), wie in der Vorlage. */
|
||||
export function isValidGermanNumber(value: string): boolean {
|
||||
return /^\d+([,]\d+)?$/.test(value.trim());
|
||||
}
|
||||
|
||||
/** Datum im Format TT.MM.JJJJ. */
|
||||
function isValidDate(value: string): boolean {
|
||||
return /^\d{2}\.\d{2}\.\d{4}$/.test(value.trim());
|
||||
}
|
||||
|
||||
function extractMonthYear(dateStr: string): { month: string; year: string } | null {
|
||||
const match = dateStr.trim().match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
|
||||
if (!match) return null;
|
||||
return { month: match[2], year: match[3] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validiert die geparsten Kantinen-Zeilen (Regeln und Meldungen wie in der
|
||||
* Desktop-Vorlage):
|
||||
* 1. Personalnummer: vorhanden und numerisch
|
||||
* 2. Betrag: vorhanden, deutsches Zahlenformat
|
||||
* 3. Abrechnung von/bis: Format TT.MM.JJJJ
|
||||
* 4. von und bis muessen im selben Monat liegen
|
||||
* 5. Abrechnungsmonat kommt aus "Abrechnung bis" -> MM/YYYY
|
||||
*/
|
||||
export function validateKantinenData(rows: KantinenRawRow[]): ValidationResult {
|
||||
const errors: ValidationError[] = [];
|
||||
const warnings: ValidationWarning[] = [];
|
||||
const detectedMonths = new Set<string>();
|
||||
let abrechnungsMonat: string | null = null;
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
// Echte Dateizeile, falls bekannt; sonst 1-basiert + 1 fuer den Kopf.
|
||||
const rowNum = row.line ?? i + 2;
|
||||
|
||||
if (!row.personalNr || row.personalNr.trim() === '') {
|
||||
errors.push({
|
||||
row: rowNum,
|
||||
field: 'personalNr',
|
||||
code: 'personalNrMissing',
|
||||
message: 'Personalnummer fehlt',
|
||||
});
|
||||
} else if (!/^\d+$/.test(row.personalNr.trim())) {
|
||||
errors.push({
|
||||
row: rowNum,
|
||||
field: 'personalNr',
|
||||
code: 'personalNrNotNumeric',
|
||||
message: 'Personalnummer muss numerisch sein',
|
||||
});
|
||||
}
|
||||
|
||||
if (!row.betrag || row.betrag.trim() === '') {
|
||||
errors.push({ row: rowNum, field: 'betrag', code: 'betragMissing', message: 'Betrag fehlt' });
|
||||
} else if (!isValidGermanNumber(row.betrag)) {
|
||||
errors.push({
|
||||
row: rowNum,
|
||||
field: 'betrag',
|
||||
code: 'betragFormat',
|
||||
message: 'Betrag muss im deutschen Zahlenformat vorliegen (Komma als Dezimaltrenner)',
|
||||
});
|
||||
}
|
||||
|
||||
if (!row.abrechnungVon || row.abrechnungVon.trim() === '') {
|
||||
errors.push({
|
||||
row: rowNum,
|
||||
field: 'abrechnungVon',
|
||||
code: 'vonMissing',
|
||||
message: 'Abrechnung von fehlt',
|
||||
});
|
||||
} else if (!isValidDate(row.abrechnungVon)) {
|
||||
errors.push({
|
||||
row: rowNum,
|
||||
field: 'abrechnungVon',
|
||||
code: 'vonFormat',
|
||||
message: 'Abrechnung von muss im Format TT.MM.JJJJ vorliegen',
|
||||
});
|
||||
}
|
||||
|
||||
if (!row.abrechnungBis || row.abrechnungBis.trim() === '') {
|
||||
errors.push({
|
||||
row: rowNum,
|
||||
field: 'abrechnungBis',
|
||||
code: 'bisMissing',
|
||||
message: 'Abrechnung bis fehlt',
|
||||
});
|
||||
} else if (!isValidDate(row.abrechnungBis)) {
|
||||
errors.push({
|
||||
row: rowNum,
|
||||
field: 'abrechnungBis',
|
||||
code: 'bisFormat',
|
||||
message: 'Abrechnung bis muss im Format TT.MM.JJJJ vorliegen',
|
||||
});
|
||||
}
|
||||
|
||||
const vonParsed = extractMonthYear(row.abrechnungVon);
|
||||
const bisParsed = extractMonthYear(row.abrechnungBis);
|
||||
|
||||
if (vonParsed && bisParsed) {
|
||||
if (vonParsed.month !== bisParsed.month || vonParsed.year !== bisParsed.year) {
|
||||
errors.push({
|
||||
row: rowNum,
|
||||
field: 'abrechnungVon/abrechnungBis',
|
||||
code: 'multiMonthRange',
|
||||
message: 'Abrechnungszeitraum erstreckt sich über mehrere Monate',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (bisParsed) {
|
||||
detectedMonths.add(`${bisParsed.month}/${bisParsed.year}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (detectedMonths.size === 1) {
|
||||
abrechnungsMonat = [...detectedMonths][0];
|
||||
} else if (detectedMonths.size > 1) {
|
||||
const months = [...detectedMonths];
|
||||
warnings.push({
|
||||
code: 'multipleMonths',
|
||||
message:
|
||||
`Verschiedene Abrechnungsmonate erkannt: ${months.join(', ')}. ` +
|
||||
'Alle Zeilen sollten im selben Abrechnungsmonat liegen.',
|
||||
params: { months },
|
||||
});
|
||||
// Fallback wie in der Vorlage: der erste erkannte Monat.
|
||||
abrechnungsMonat = months[0];
|
||||
}
|
||||
|
||||
return { isValid: errors.length === 0, errors, warnings, abrechnungsMonat };
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import 'reflect-metadata';
|
||||
import { BadRequestException, ForbiddenException, ValidationPipe } from '@nestjs/common';
|
||||
import { Role } from '@prisma/client';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
|
||||
import { MODULE_SLUG_KEY } from '../module-registry/module.guard';
|
||||
import { KantineDatevSettingsDto } from './dto/kantine-datev-settings.dto';
|
||||
import { KantineDatevController } from './kantine-datev.controller';
|
||||
|
||||
const proto = KantineDatevController.prototype as any;
|
||||
const req = (tenantId?: string) => ({ tenantId }) as any;
|
||||
|
||||
function makeService() {
|
||||
return {
|
||||
getSettings: vi.fn(async (..._a: unknown[]) => ({})),
|
||||
saveSettings: vi.fn(async (..._a: unknown[]) => ({})),
|
||||
preview: vi.fn(async (..._a: unknown[]) => ({})),
|
||||
export: vi.fn(async (..._a: unknown[]) => ({})),
|
||||
};
|
||||
}
|
||||
|
||||
describe('KantineDatevController — Metadaten', () => {
|
||||
it('haengt an modules/kantine-datev und traegt @UseModule', () => {
|
||||
expect(Reflect.getMetadata('path', KantineDatevController)).toBe('modules/kantine-datev');
|
||||
expect(Reflect.getMetadata(MODULE_SLUG_KEY, KantineDatevController)).toBe('kantine-datev');
|
||||
});
|
||||
|
||||
it('PUT settings verlangt ADMIN/SUPER_ADMIN', () => {
|
||||
expect(Reflect.getMetadata(ROLES_KEY, proto.saveSettings)).toEqual([
|
||||
Role.ADMIN,
|
||||
Role.SUPER_ADMIN,
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(['getSettings', 'preview', 'export'])('%s traegt keine Routen-Rolle', (name) => {
|
||||
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('KantineDatevController — Verhalten', () => {
|
||||
it('reicht req.tenantId und den Dateipuffer an den Dienst', async () => {
|
||||
const service = makeService();
|
||||
const c = new KantineDatevController(service as any);
|
||||
const buffer = Buffer.from('x');
|
||||
await c.getSettings(req('t1'));
|
||||
await c.preview(req('t1'), { buffer } as any);
|
||||
await c.export(req('t1'), { buffer } as any);
|
||||
expect(service.getSettings).toHaveBeenCalledWith('t1');
|
||||
expect(service.preview).toHaveBeenCalledWith('t1', buffer);
|
||||
expect(service.export).toHaveBeenCalledWith('t1', buffer);
|
||||
});
|
||||
|
||||
it('antwortet ohne Datei mit 400', async () => {
|
||||
const c = new KantineDatevController(makeService() as any);
|
||||
await expect(c.preview(req('t1'), undefined)).rejects.toThrow(BadRequestException);
|
||||
await expect(c.export(req('t1'), undefined)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('antwortet ohne Mandantenkontext mit 403', async () => {
|
||||
const c = new KantineDatevController(makeService() as any);
|
||||
await expect(c.getSettings(req(undefined))).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('KantineDatevSettingsDto', () => {
|
||||
const pipe = new ValidationPipe({ whitelist: true, transform: true });
|
||||
const run = (value: unknown) =>
|
||||
pipe.transform(value, { type: 'body', metatype: KantineDatevSettingsDto });
|
||||
|
||||
it('akzeptiert Ziffernketten (auch mit fuehrender Null)', async () => {
|
||||
await expect(
|
||||
run({ beraterNr: '0123456', mandantNr: '12345', lohnart: '1111' }),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ beraterNr: '12a', mandantNr: '1', lohnart: '1' }],
|
||||
[{ beraterNr: '1', mandantNr: '', lohnart: '1' }],
|
||||
[{ beraterNr: '1', mandantNr: '1', lohnart: '12345678901' }],
|
||||
[{ beraterNr: '1', mandantNr: '1' }],
|
||||
[{ beraterNr: 1, mandantNr: '1', lohnart: '1' }],
|
||||
])('lehnt %j ab', async (body) => {
|
||||
await expect(run(body)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Req,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { Role } from '@prisma/client';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import type { AuthenticatedRequest, UploadedFileLike } from '../auth/types/auth-user';
|
||||
import { UseModule } from '../module-registry/module.guard';
|
||||
import { KantineDatevSettingsDto } from './dto/kantine-datev-settings.dto';
|
||||
import { KantineDatevService } from './kantine-datev.service';
|
||||
|
||||
/**
|
||||
* `@UseModule('kantine-datev')` auf Klassenebene — Aktivierung UND Freigabe.
|
||||
* `tenantId` kommt ausschliesslich aus `req.tenantId` (TenantGuard). Lesen,
|
||||
* Vorschau und Export stehen jedem Benutzer mit Modulzugriff offen; die
|
||||
* Einstellungen aendern nur Administratoren (T-FM5-02). Hochgeladene Dateien
|
||||
* bleiben im Arbeitsspeicher (multer-Standard), 5 MB Grenze (T-FM5-04).
|
||||
* Keine `:id`-Routen in diesem Controller.
|
||||
*/
|
||||
@Controller('modules/kantine-datev')
|
||||
@UseModule('kantine-datev')
|
||||
export class KantineDatevController {
|
||||
constructor(private readonly service: KantineDatevService) {}
|
||||
|
||||
private requireTenantId(req: AuthenticatedRequest): string {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new ForbiddenException('Kein Mandantenkontext');
|
||||
}
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
@Get('settings')
|
||||
async getSettings(@Req() req: AuthenticatedRequest) {
|
||||
return this.service.getSettings(this.requireTenantId(req));
|
||||
}
|
||||
|
||||
@Put('settings')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async saveSettings(@Req() req: AuthenticatedRequest, @Body() dto: KantineDatevSettingsDto) {
|
||||
return this.service.saveSettings(this.requireTenantId(req), dto);
|
||||
}
|
||||
|
||||
@Post('preview')
|
||||
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 5 * 1024 * 1024 } }))
|
||||
async preview(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@UploadedFile() file: UploadedFileLike | undefined,
|
||||
) {
|
||||
const tenantId = this.requireTenantId(req);
|
||||
if (!file) {
|
||||
throw new BadRequestException('Keine Datei hochgeladen');
|
||||
}
|
||||
return this.service.preview(tenantId, file.buffer);
|
||||
}
|
||||
|
||||
@Post('export')
|
||||
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 5 * 1024 * 1024 } }))
|
||||
async export(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@UploadedFile() file: UploadedFileLike | undefined,
|
||||
) {
|
||||
const tenantId = this.requireTenantId(req);
|
||||
if (!file) {
|
||||
throw new BadRequestException('Keine Datei hochgeladen');
|
||||
}
|
||||
return this.service.export(tenantId, file.buffer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Logger, Module, OnModuleInit } from '@nestjs/common';
|
||||
import { ModuleRegistryModule } from '../module-registry/module-registry.module';
|
||||
import { ModuleRegistryService } from '../module-registry/module-registry.service';
|
||||
import { KantineDatevController } from './kantine-datev.controller';
|
||||
import { seedKantineDatevModule } from './kantine-datev.seed';
|
||||
import { KantineDatevService } from './kantine-datev.service';
|
||||
|
||||
/**
|
||||
* Kantinenabrechnung (quick-261002-fm5): Kantinen-CSV pruefen und als
|
||||
* DATEV-Lohn-ASCII-Datei exportieren. Traegt sich beim Start in die
|
||||
* Modulverwaltung ein; aktiviert wird per Marktplatz.
|
||||
*/
|
||||
@Module({
|
||||
imports: [ModuleRegistryModule],
|
||||
controllers: [KantineDatevController],
|
||||
providers: [KantineDatevService],
|
||||
})
|
||||
export class KantineDatevModule implements OnModuleInit {
|
||||
private readonly logger = new Logger(KantineDatevModule.name);
|
||||
|
||||
constructor(private readonly moduleRegistryService: ModuleRegistryService) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
try {
|
||||
await seedKantineDatevModule(this.moduleRegistryService);
|
||||
this.logger.log('Kantine-DATEV module seeded in registry');
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to seed kantine-datev module', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildKantineExport,
|
||||
KantineExportError,
|
||||
processKantineCsv,
|
||||
} from './kantine-datev.pipeline';
|
||||
|
||||
const SETTINGS = { beraterNr: '1234567', mandantNr: '12345', lohnart: '1111' };
|
||||
const HEADER = 'PersNr;Name;Menge;EK;Netto;ZuAb;MwSt;Zuschuss;Betrag;Von;Bis';
|
||||
const ok = (nr: string, name: string, betrag: string, bis = '31.03.2026') =>
|
||||
`${nr};${name};1;1,00;1,00;0;0;0;${betrag};01.03.2026;${bis}`;
|
||||
|
||||
function csv(lines: string[], eol = '\r\n'): string {
|
||||
return [HEADER, ...lines].join(eol);
|
||||
}
|
||||
|
||||
describe('processKantineCsv', () => {
|
||||
it('liest Windows-1252 mit Umlauten und CRLF', () => {
|
||||
const buf = Buffer.from(
|
||||
csv([ok('100', 'Müller, Jürgen', '7,94'), ok('200', 'Köhler', '51,5')]),
|
||||
'latin1',
|
||||
);
|
||||
const p = processKantineCsv(buf, SETTINGS);
|
||||
expect(p.rowCount).toBe(2);
|
||||
expect(p.abrechnungsMonat).toBe('03/2026');
|
||||
expect(p.totalCents).toBe(794 + 5150);
|
||||
expect(p.errors).toEqual([]);
|
||||
expect(p.canExport).toBe(true);
|
||||
expect(p.blockedReason).toBeNull();
|
||||
});
|
||||
|
||||
it('liest UTF-8 mit BOM und LF gleich', () => {
|
||||
const body = csv([ok('100', 'Müller', '7,94')], '\n');
|
||||
const buf = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(body, 'utf8')]);
|
||||
const p = processKantineCsv(buf, SETTINGS);
|
||||
expect(p.rowCount).toBe(1);
|
||||
expect(p.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('zaehlt in der Summe nur Zeilen mit gueltigem Betrag', () => {
|
||||
const p = processKantineCsv(
|
||||
Buffer.from(csv([ok('100', 'A', '7,94'), ok('101', 'B', 'abc')])),
|
||||
SETTINGS,
|
||||
);
|
||||
expect(p.totalCents).toBe(794);
|
||||
expect(p.errors).toEqual([expect.objectContaining({ row: 3, code: 'betragFormat' })]);
|
||||
expect(p.canExport).toBe(false);
|
||||
expect(p.blockedReason).toBe('errors');
|
||||
});
|
||||
|
||||
it('meldet zwei Monate als Warnung, nicht als Fehler', () => {
|
||||
const p = processKantineCsv(
|
||||
Buffer.from(csv([ok('100', 'A', '1,00'), `101;B;1;1;1;0;0;0;1,00;01.04.2026;30.04.2026`])),
|
||||
SETTINGS,
|
||||
);
|
||||
expect(p.warnings).toHaveLength(1);
|
||||
expect(p.errors).toEqual([]);
|
||||
expect(p.abrechnungsMonat).toBe('03/2026');
|
||||
});
|
||||
|
||||
it('meldet eine Datei ohne Datenzeilen mit noRows', () => {
|
||||
const p = processKantineCsv(Buffer.from(csv([])), SETTINGS);
|
||||
expect(p.rowCount).toBe(0);
|
||||
expect(p.errors.map((e) => e.code)).toEqual(['noRows']);
|
||||
expect(p.canExport).toBe(false);
|
||||
});
|
||||
|
||||
it('sperrt ohne Einstellungen mit settingsMissing', () => {
|
||||
const p = processKantineCsv(Buffer.from(csv([ok('100', 'A', '1,00')])), null);
|
||||
expect(p.canExport).toBe(false);
|
||||
expect(p.blockedReason).toBe('settingsMissing');
|
||||
});
|
||||
|
||||
it('gibt keine Zeileninhalte in der Vorschau zurueck', () => {
|
||||
const p = processKantineCsv(Buffer.from(csv([ok('100', 'Geheimname', 'x')])), SETTINGS);
|
||||
expect(JSON.stringify(p)).not.toContain('Geheimname');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildKantineExport', () => {
|
||||
it('erzeugt Dateiname und Base64-Inhalt', () => {
|
||||
const r = buildKantineExport(Buffer.from(csv([ok('100', 'A', '7,94')])), SETTINGS);
|
||||
expect(r.filename).toBe('LuG_1234567_12345_03_2026.sic');
|
||||
expect(r.mimeType).toBe('text/plain');
|
||||
expect(Buffer.from(r.content, 'base64').toString('utf8')).toBe(
|
||||
'1234567\t12345\t03/2026\t\t\t\t\t\t\t\t\r\n\t100\t\t1111\t-7.94\t\t\t\t\t\t\r\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('verweigert den Export bei Fehlern', () => {
|
||||
expect(() => buildKantineExport(Buffer.from(csv([ok('x', 'A', '7,94')])), SETTINGS)).toThrow(
|
||||
KantineExportError,
|
||||
);
|
||||
try {
|
||||
buildKantineExport(Buffer.from(csv([ok('x', 'A', '7,94')])), SETTINGS);
|
||||
} catch (e) {
|
||||
expect((e as KantineExportError).code).toBe('hasErrors');
|
||||
}
|
||||
});
|
||||
|
||||
it('verweigert den Export ohne Einstellungen', () => {
|
||||
try {
|
||||
buildKantineExport(Buffer.from(csv([ok('100', 'A', '7,94')])), null);
|
||||
expect.unreachable();
|
||||
} catch (e) {
|
||||
expect((e as KantineExportError).code).toBe('settingsMissing');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { decodeCsvText } from '../accounting/decode-csv-text';
|
||||
import { parseKantinenCsv } from './kantine-csv.parser';
|
||||
import { isValidGermanNumber, validateKantinenData } from './kantine-csv.validator';
|
||||
import {
|
||||
buildKantineExportFilename,
|
||||
generateDatevOutput,
|
||||
qualityCheck,
|
||||
transformToDatevRecords,
|
||||
} from './kantine-datev.transformer';
|
||||
import type {
|
||||
FileResponse,
|
||||
KantineDatevSettings,
|
||||
KantinenRawRow,
|
||||
KantinePreview,
|
||||
ValidationError,
|
||||
ValidationResult,
|
||||
} from './kantine-datev.types';
|
||||
|
||||
/**
|
||||
* Verarbeitungskette der Kantinenabrechnung (quick-261002-fm5):
|
||||
* dekodieren -> parsen -> validieren -> Summe -> Vorschau bzw. Export.
|
||||
*
|
||||
* Reine Funktionen ohne Datenbank, Datei oder Protokollausgabe: hochgeladene
|
||||
* Zeilen (Namen, Personalnummern) verlassen den Arbeitsspeicher nie.
|
||||
*/
|
||||
|
||||
/** Grund, warum ein Export abgelehnt wurde. */
|
||||
export type KantineExportErrorCode = 'settingsMissing' | 'hasErrors' | 'qualityCheckFailed';
|
||||
|
||||
export class KantineExportError extends Error {
|
||||
constructor(
|
||||
readonly code: KantineExportErrorCode,
|
||||
message: string,
|
||||
readonly errors: ValidationError[] = [],
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'KantineExportError';
|
||||
}
|
||||
}
|
||||
|
||||
interface Analysis {
|
||||
rows: KantinenRawRow[];
|
||||
errors: ValidationError[];
|
||||
validation: ValidationResult;
|
||||
}
|
||||
|
||||
function analyze(buffer: Buffer): Analysis {
|
||||
const text = decodeCsvText(buffer);
|
||||
const parsed = parseKantinenCsv(text);
|
||||
const validation = validateKantinenData(parsed.rows);
|
||||
const errors = [...parsed.errors, ...validation.errors].sort((a, b) => a.row - b.row);
|
||||
|
||||
if (parsed.rows.length === 0 && parsed.errors.length === 0) {
|
||||
errors.push({
|
||||
row: 1,
|
||||
field: 'datei',
|
||||
code: 'noRows',
|
||||
message: 'Die Datei enthält keine Datenzeilen.',
|
||||
});
|
||||
}
|
||||
return { rows: parsed.rows, errors, validation };
|
||||
}
|
||||
|
||||
/** Summe der Betraege in Cent; nur Zeilen, deren Betrag das Format besteht. */
|
||||
function sumCents(rows: KantinenRawRow[]): number {
|
||||
let total = 0;
|
||||
for (const row of rows) {
|
||||
if (!isValidGermanNumber(row.betrag)) continue;
|
||||
total += Math.round(Number(row.betrag.trim().replace(',', '.')) * 100);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
export function processKantineCsv(
|
||||
buffer: Buffer,
|
||||
settings: KantineDatevSettings | null,
|
||||
): KantinePreview {
|
||||
const { rows, errors, validation } = analyze(buffer);
|
||||
|
||||
let blockedReason: KantinePreview['blockedReason'] = null;
|
||||
if (!settings) {
|
||||
blockedReason = 'settingsMissing';
|
||||
} else if (errors.length > 0) {
|
||||
blockedReason = 'errors';
|
||||
}
|
||||
|
||||
return {
|
||||
rowCount: rows.length,
|
||||
abrechnungsMonat: validation.abrechnungsMonat,
|
||||
totalCents: sumCents(rows),
|
||||
errors,
|
||||
warnings: validation.warnings,
|
||||
canExport: blockedReason === null,
|
||||
blockedReason,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildKantineExport(
|
||||
buffer: Buffer,
|
||||
settings: KantineDatevSettings | null,
|
||||
): FileResponse {
|
||||
if (!settings) {
|
||||
throw new KantineExportError(
|
||||
'settingsMissing',
|
||||
'Beraternummer, Mandantennummer und Lohnart sind noch nicht hinterlegt.',
|
||||
);
|
||||
}
|
||||
|
||||
const { rows, errors, validation } = analyze(buffer);
|
||||
if (errors.length > 0 || !validation.abrechnungsMonat) {
|
||||
throw new KantineExportError(
|
||||
'hasErrors',
|
||||
'Die Datei enthält Fehler und kann nicht exportiert werden.',
|
||||
errors,
|
||||
);
|
||||
}
|
||||
|
||||
const records = transformToDatevRecords(rows);
|
||||
const output = generateDatevOutput(records, validation.abrechnungsMonat, settings);
|
||||
|
||||
const check = qualityCheck(output);
|
||||
if (!check.passed) {
|
||||
throw new KantineExportError(
|
||||
'qualityCheckFailed',
|
||||
`Qualitätsprüfung fehlgeschlagen: ${check.errors.join('; ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
filename: buildKantineExportFilename(settings, validation.abrechnungsMonat),
|
||||
content: Buffer.from(output, 'utf8').toString('base64'),
|
||||
mimeType: 'text/plain',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ModuleRegistryService } from '../module-registry/module-registry.service';
|
||||
|
||||
/**
|
||||
* Traegt das Modul "Kantinenabrechnung" in die Modulverwaltung ein
|
||||
* (quick-261002-fm5). `isSystem: true` legt den Eintrag an, aktiviert ihn aber
|
||||
* NICHT je Mandant — der Administrator aktiviert ueber den Marktplatz und
|
||||
* erteilt die Freigabe.
|
||||
*/
|
||||
export async function seedKantineDatevModule(
|
||||
moduleRegistryService: ModuleRegistryService,
|
||||
): Promise<void> {
|
||||
await moduleRegistryService.seedModule({
|
||||
slug: 'kantine-datev',
|
||||
name: 'Kantinenabrechnung',
|
||||
version: '1.0.0',
|
||||
category: 'accounting',
|
||||
description: {
|
||||
de: 'Kantinen-CSV prüfen und als DATEV-Lohndatei (ASCII) für die Gehaltsabrechnung exportieren',
|
||||
en: 'Check canteen CSV files and export them as a DATEV payroll ASCII file',
|
||||
},
|
||||
isSystem: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((p: unknown) => p),
|
||||
}));
|
||||
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { KantineDatevService } from './kantine-datev.service';
|
||||
|
||||
const HEADER = 'PersNr;Name;Menge;EK;Netto;ZuAb;MwSt;Zuschuss;Betrag;Von;Bis';
|
||||
const GOOD = Buffer.from(
|
||||
[HEADER, '100;Max;1;1,00;1,00;0;0;0;7,94;01.03.2026;31.03.2026'].join('\r\n'),
|
||||
);
|
||||
|
||||
function setup(row: Record<string, string | null> | null = null) {
|
||||
const kantineDatevConfig = {
|
||||
findUnique: vi.fn(async () => row),
|
||||
upsert: vi.fn(async ({ create }: any) => create),
|
||||
};
|
||||
return { prisma: { kantineDatevConfig }, kantineDatevConfig };
|
||||
}
|
||||
|
||||
describe('KantineDatevService — Einstellungen', () => {
|
||||
it('liefert ohne Zeile leere Werte und configured=false', async () => {
|
||||
const { prisma } = setup(null);
|
||||
const res = await new KantineDatevService(prisma as any).getSettings('t1');
|
||||
expect(res).toEqual({ beraterNr: null, mandantNr: null, lohnart: null, configured: false });
|
||||
expect(forTenant).toHaveBeenCalledWith(prisma, 't1');
|
||||
});
|
||||
|
||||
it('configured=false, solange ein Feld fehlt', async () => {
|
||||
const { prisma } = setup({ beraterNr: '1', mandantNr: null, lohnart: '3' });
|
||||
expect((await new KantineDatevService(prisma as any).getSettings('t1')).configured).toBe(false);
|
||||
});
|
||||
|
||||
it('speichert per upsert auf tenantId (aus dem Argument)', async () => {
|
||||
const { prisma, kantineDatevConfig } = setup();
|
||||
const res = await new KantineDatevService(prisma as any).saveSettings('t1', {
|
||||
beraterNr: '1234567',
|
||||
mandantNr: '12345',
|
||||
lohnart: '1111',
|
||||
});
|
||||
expect(kantineDatevConfig.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { tenantId: 't1' } }),
|
||||
);
|
||||
expect(res.configured).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('KantineDatevService — Vorschau und Export', () => {
|
||||
it('Vorschau ohne Einstellungen sperrt mit settingsMissing', async () => {
|
||||
const { prisma } = setup(null);
|
||||
const p = await new KantineDatevService(prisma as any).preview('t1', GOOD);
|
||||
expect(p.blockedReason).toBe('settingsMissing');
|
||||
expect(p.rowCount).toBe(1);
|
||||
});
|
||||
|
||||
it('Export ohne Einstellungen -> 400 mit code settingsMissing', async () => {
|
||||
const { prisma } = setup(null);
|
||||
const err: any = await new KantineDatevService(prisma as any)
|
||||
.export('t1', GOOD)
|
||||
.catch((e) => e);
|
||||
expect(err).toBeInstanceOf(BadRequestException);
|
||||
expect(err.getResponse().code).toBe('settingsMissing');
|
||||
});
|
||||
|
||||
it('Export mit Fehlern -> 400 mit code hasErrors und Fehlerliste', async () => {
|
||||
const { prisma } = setup({ beraterNr: '1', mandantNr: '2', lohnart: '3' });
|
||||
const bad = Buffer.from(
|
||||
[HEADER, 'x;Max;1;1,00;1,00;0;0;0;7,94;01.03.2026;31.03.2026'].join('\n'),
|
||||
);
|
||||
const err: any = await new KantineDatevService(prisma as any).export('t1', bad).catch((e) => e);
|
||||
expect(err.getResponse().code).toBe('hasErrors');
|
||||
expect(err.getResponse().errors).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('Export mit Einstellungen liefert Datei', async () => {
|
||||
const { prisma } = setup({ beraterNr: '1234567', mandantNr: '12345', lohnart: '1111' });
|
||||
const res = await new KantineDatevService(prisma as any).export('t1', GOOD);
|
||||
expect(res.filename).toBe('LuG_1234567_12345_03_2026.sic');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { BadRequestException, Injectable, UnprocessableEntityException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import type { KantineDatevSettingsDto } from './dto/kantine-datev-settings.dto';
|
||||
import {
|
||||
buildKantineExport,
|
||||
KantineExportError,
|
||||
processKantineCsv,
|
||||
} from './kantine-datev.pipeline';
|
||||
import type { FileResponse, KantineDatevSettings, KantinePreview } from './kantine-datev.types';
|
||||
|
||||
export interface KantineSettingsResponse {
|
||||
beraterNr: string | null;
|
||||
mandantNr: string | null;
|
||||
lohnart: string | null;
|
||||
configured: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kantinenabrechnung (quick-261002-fm5). Die Einstellungen liegen je Mandant
|
||||
* in `KantineDatevConfig` (mandantengebunden); die hochgeladene CSV wird nur
|
||||
* im Arbeitsspeicher verarbeitet und weder gespeichert noch protokolliert.
|
||||
*/
|
||||
@Injectable()
|
||||
export class KantineDatevService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getSettings(tenantId: string): Promise<KantineSettingsResponse> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const row = await tenantPrisma.kantineDatevConfig.findUnique({ where: { tenantId } });
|
||||
const beraterNr = row?.beraterNr ?? null;
|
||||
const mandantNr = row?.mandantNr ?? null;
|
||||
const lohnart = row?.lohnart ?? null;
|
||||
return {
|
||||
beraterNr,
|
||||
mandantNr,
|
||||
lohnart,
|
||||
configured: Boolean(beraterNr && mandantNr && lohnart),
|
||||
};
|
||||
}
|
||||
|
||||
async saveSettings(
|
||||
tenantId: string,
|
||||
dto: KantineDatevSettingsDto,
|
||||
): Promise<KantineSettingsResponse> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const data = { beraterNr: dto.beraterNr, mandantNr: dto.mandantNr, lohnart: dto.lohnart };
|
||||
await tenantPrisma.kantineDatevConfig.upsert({
|
||||
where: { tenantId },
|
||||
create: { tenantId, ...data },
|
||||
update: data,
|
||||
});
|
||||
return { ...data, configured: true };
|
||||
}
|
||||
|
||||
private async loadConfigured(tenantId: string): Promise<KantineDatevSettings | null> {
|
||||
const s = await this.getSettings(tenantId);
|
||||
if (!s.configured || !s.beraterNr || !s.mandantNr || !s.lohnart) return null;
|
||||
return { beraterNr: s.beraterNr, mandantNr: s.mandantNr, lohnart: s.lohnart };
|
||||
}
|
||||
|
||||
async preview(tenantId: string, buffer: Buffer): Promise<KantinePreview> {
|
||||
return processKantineCsv(buffer, await this.loadConfigured(tenantId));
|
||||
}
|
||||
|
||||
async export(tenantId: string, buffer: Buffer): Promise<FileResponse> {
|
||||
const settings = await this.loadConfigured(tenantId);
|
||||
try {
|
||||
return buildKantineExport(buffer, settings);
|
||||
} catch (error) {
|
||||
if (error instanceof KantineExportError) {
|
||||
if (error.code === 'qualityCheckFailed') {
|
||||
throw new UnprocessableEntityException({ code: error.code, message: error.message });
|
||||
}
|
||||
throw new BadRequestException({
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
errors: error.errors,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildKantineExportFilename,
|
||||
generateDatevOutput,
|
||||
qualityCheck,
|
||||
transformBetrag,
|
||||
} from './kantine-datev.transformer';
|
||||
|
||||
const SETTINGS = { beraterNr: '1234567', mandantNr: '12345', lohnart: '1111' };
|
||||
|
||||
describe('transformBetrag', () => {
|
||||
it('macht den Betrag negativ mit Punkt und zwei Nachkommastellen', () => {
|
||||
expect(transformBetrag('7,94')).toBe('-7.94');
|
||||
expect(transformBetrag('51,5')).toBe('-51.50');
|
||||
expect(transformBetrag('0,00')).toBe('-0.00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateDatevOutput', () => {
|
||||
const out = generateDatevOutput(
|
||||
[
|
||||
{ personalNr: '100', betrag: '-7.94' },
|
||||
{ personalNr: '200', betrag: '-51.50' },
|
||||
],
|
||||
'03/2026',
|
||||
SETTINGS,
|
||||
);
|
||||
|
||||
it('schreibt den Kopf aus den Einstellungen mit 8 leeren Spalten', () => {
|
||||
const lines = out.split('\r\n');
|
||||
expect(lines[0]).toBe('1234567\t12345\t03/2026\t\t\t\t\t\t\t\t');
|
||||
});
|
||||
|
||||
it('schreibt Detailzeilen mit Lohnart und Betrag', () => {
|
||||
const lines = out.split('\r\n');
|
||||
expect(lines[1]).toBe('\t100\t\t1111\t-7.94\t\t\t\t\t\t');
|
||||
expect(lines[2]).toBe('\t200\t\t1111\t-51.50\t\t\t\t\t\t');
|
||||
});
|
||||
|
||||
it('hat in jeder Zeile 11 Spalten, CRLF und ein abschliessendes CRLF', () => {
|
||||
expect(out.endsWith('\r\n')).toBe(true);
|
||||
expect(out.replace(/\r\n/g, '')).not.toContain('\n');
|
||||
for (const line of out.split('\r\n').slice(0, -1)) {
|
||||
expect(line.split('\t')).toHaveLength(11);
|
||||
}
|
||||
});
|
||||
|
||||
it('besteht die Qualitaetspruefung', () => {
|
||||
expect(qualityCheck(out)).toEqual({ passed: true, errors: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('qualityCheck', () => {
|
||||
it('lehnt eine Datei nur mit LF ab', () => {
|
||||
const r = qualityCheck('a\tb\t\t\t\t\t\t\t\t\t\n');
|
||||
expect(r.passed).toBe(false);
|
||||
expect(r.errors).toContain('Datei endet nicht mit CRLF');
|
||||
expect(r.errors).toContain('Datei enthaelt einzelne LF-Zeilenenden (nur CRLF erlaubt)');
|
||||
});
|
||||
|
||||
it('lehnt eine Zeile mit 10 Spalten ab', () => {
|
||||
const r = qualityCheck('a\t\t\t\t\t\t\t\t\t\r\n');
|
||||
expect(r.passed).toBe(false);
|
||||
expect(r.errors[0]).toBe('Zeile 1: 10 Spalten gefunden, 11 erwartet');
|
||||
});
|
||||
|
||||
it('lehnt einen positiven Betrag ab', () => {
|
||||
const r = qualityCheck(`k\t\t\t\t\t\t\t\t\t\t\r\n\t1\t\t1111\t7.94\t\t\t\t\t\t\r\n`);
|
||||
expect(r.passed).toBe(false);
|
||||
expect(r.errors[0]).toContain('Betrag "7.94" ist nicht im Format -X.XX');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildKantineExportFilename', () => {
|
||||
it('folgt LuG_<Berater>_<Mandant>_<MM>_<YYYY>.sic', () => {
|
||||
expect(buildKantineExportFilename(SETTINGS, '03/2026')).toBe('LuG_1234567_12345_03_2026.sic');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { DatevRecord, KantineDatevSettings, KantinenRawRow } from './kantine-datev.types';
|
||||
|
||||
const SPALTEN_ANZAHL = 11;
|
||||
const TAB = '\t';
|
||||
const CRLF = '\r\n';
|
||||
|
||||
/**
|
||||
* Transformiert einen Betrag gemaess DATEV-Regel: Komma -> Punkt, immer
|
||||
* negativ, exakt 2 Nachkommastellen. "7,94" -> "-7.94", "51,5" -> "-51.50".
|
||||
*/
|
||||
export function transformBetrag(betrag: string): string {
|
||||
const cleaned = betrag.trim().replace(',', '.');
|
||||
const num = parseFloat(cleaned);
|
||||
const absValue = Math.abs(num);
|
||||
return `-${absValue.toFixed(2)}`;
|
||||
}
|
||||
|
||||
export function transformToDatevRecords(rows: KantinenRawRow[]): DatevRecord[] {
|
||||
return rows.map((row) => ({
|
||||
personalNr: row.personalNr.trim(),
|
||||
betrag: transformBetrag(row.betrag),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert die komplette DATEV-Lohn-ASCII-Datei.
|
||||
*
|
||||
* Kopf: Beraternr TAB Mandantennr TAB MM/YYYY + 8 leere Spalten
|
||||
* Detail: TAB PersonalNr TAB TAB Lohnart TAB -Betrag + 6 leere Spalten
|
||||
*
|
||||
* Exakt 11 Spalten je Zeile, CRLF-Zeilenenden, die Datei endet mit CRLF.
|
||||
* Berater-, Mandantennummer und Lohnart kommen aus den Einstellungen des
|
||||
* Mandanten, nicht aus festen Werten.
|
||||
*/
|
||||
export function generateDatevOutput(
|
||||
records: DatevRecord[],
|
||||
abrechnungsMonat: string,
|
||||
settings: KantineDatevSettings,
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(
|
||||
[settings.beraterNr, settings.mandantNr, abrechnungsMonat, '', '', '', '', '', '', '', ''].join(
|
||||
TAB,
|
||||
),
|
||||
);
|
||||
|
||||
for (const record of records) {
|
||||
lines.push(
|
||||
['', record.personalNr, '', settings.lohnart, record.betrag, '', '', '', '', '', ''].join(
|
||||
TAB,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join(CRLF) + CRLF;
|
||||
}
|
||||
|
||||
/**
|
||||
* Qualitaetspruefung der erzeugten Ausgabe: 11 Spalten je Zeile, nur CRLF,
|
||||
* Datei endet mit CRLF, alle Betraege negativ mit 2 Nachkommastellen.
|
||||
*/
|
||||
export function qualityCheck(output: string): { passed: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!output.endsWith(CRLF)) {
|
||||
errors.push('Datei endet nicht mit CRLF');
|
||||
}
|
||||
|
||||
const ohneCarriageReturn = output.replace(/\r\n/g, '');
|
||||
if (ohneCarriageReturn.includes('\n')) {
|
||||
errors.push('Datei enthaelt einzelne LF-Zeilenenden (nur CRLF erlaubt)');
|
||||
}
|
||||
|
||||
const zeilen = output.split(CRLF);
|
||||
const inhaltZeilen = zeilen.slice(0, -1);
|
||||
|
||||
if (inhaltZeilen.length === 0) {
|
||||
errors.push('Datei enthaelt keine Zeilen');
|
||||
return { passed: false, errors };
|
||||
}
|
||||
|
||||
for (let i = 0; i < inhaltZeilen.length; i++) {
|
||||
const spalten = inhaltZeilen[i].split(TAB);
|
||||
if (spalten.length !== SPALTEN_ANZAHL) {
|
||||
errors.push(`Zeile ${i + 1}: ${spalten.length} Spalten gefunden, ${SPALTEN_ANZAHL} erwartet`);
|
||||
}
|
||||
}
|
||||
|
||||
const betragRegex = /^-\d+\.\d{2}$/;
|
||||
for (let i = 1; i < inhaltZeilen.length; i++) {
|
||||
const spalten = inhaltZeilen[i].split(TAB);
|
||||
const betrag = spalten[4];
|
||||
if (betrag && !betragRegex.test(betrag)) {
|
||||
errors.push(
|
||||
`Zeile ${i + 1}: Betrag "${betrag}" ist nicht im Format -X.XX (negativ, 2 Nachkommastellen)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { passed: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
/** Dateiname des Downloads: LuG_<Beraternr>_<Mandantennr>_<MM>_<YYYY>.sic */
|
||||
export function buildKantineExportFilename(
|
||||
settings: KantineDatevSettings,
|
||||
abrechnungsMonat: string,
|
||||
): string {
|
||||
const [monat, jahr] = abrechnungsMonat.split('/');
|
||||
return `LuG_${settings.beraterNr}_${settings.mandantNr}_${monat}_${jahr}.sic`;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Typen der Kantinenabrechnung (quick-261002-fm5). Portiert aus der
|
||||
* Desktop-Vorlage; jeder Fehler und jede Warnung traegt zusaetzlich eine
|
||||
* stabile Kennung (`code`), damit die Oberflaeche den Text uebersetzen kann,
|
||||
* waehrend `message` den deutschen Originaltext behaelt.
|
||||
*/
|
||||
|
||||
/** Rohe CSV-Zeile nach dem Parsen. */
|
||||
export interface KantinenRawRow {
|
||||
personalNr: string;
|
||||
name: string;
|
||||
menge: string;
|
||||
ekPreis: string;
|
||||
netto: string;
|
||||
zuAbschlag: string;
|
||||
mwst: string;
|
||||
zuschuss: string;
|
||||
betrag: string;
|
||||
abrechnungVon: string;
|
||||
abrechnungBis: string;
|
||||
/** 1-basierte Zeilennummer in der Datei (fuer Fehlermeldungen). */
|
||||
line?: number;
|
||||
}
|
||||
|
||||
/** Validierter und transformierter Datensatz. */
|
||||
export interface DatevRecord {
|
||||
personalNr: string;
|
||||
/** z. B. "-51.50" (immer negativ, Punkt, 2 Nachkommastellen) */
|
||||
betrag: string;
|
||||
}
|
||||
|
||||
export type KantineErrorCode =
|
||||
| 'headerMissing'
|
||||
| 'headerColumns'
|
||||
| 'columnCount'
|
||||
| 'personalNrMissing'
|
||||
| 'personalNrNotNumeric'
|
||||
| 'betragMissing'
|
||||
| 'betragFormat'
|
||||
| 'vonMissing'
|
||||
| 'vonFormat'
|
||||
| 'bisMissing'
|
||||
| 'bisFormat'
|
||||
| 'multiMonthRange'
|
||||
| 'noRows';
|
||||
|
||||
export interface ValidationError {
|
||||
row: number;
|
||||
field: string;
|
||||
code: KantineErrorCode;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ValidationWarning {
|
||||
code: 'multipleMonths';
|
||||
message: string;
|
||||
params: { months: string[] };
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
isValid: boolean;
|
||||
errors: ValidationError[];
|
||||
warnings: ValidationWarning[];
|
||||
/** Format "MM/YYYY" */
|
||||
abrechnungsMonat: string | null;
|
||||
}
|
||||
|
||||
/** Nummern, die der Administrator je Mandant hinterlegt. */
|
||||
export interface KantineDatevSettings {
|
||||
beraterNr: string;
|
||||
mandantNr: string;
|
||||
lohnart: string;
|
||||
}
|
||||
|
||||
export type KantineBlockedReason = 'settingsMissing' | 'errors';
|
||||
|
||||
export interface KantinePreview {
|
||||
rowCount: number;
|
||||
abrechnungsMonat: string | null;
|
||||
/** Summe der gueltigen Betraege in Cent. */
|
||||
totalCents: number;
|
||||
errors: ValidationError[];
|
||||
warnings: ValidationWarning[];
|
||||
canExport: boolean;
|
||||
blockedReason: KantineBlockedReason | null;
|
||||
}
|
||||
|
||||
export interface FileResponse {
|
||||
filename: string;
|
||||
/** Base64 */
|
||||
content: string;
|
||||
mimeType: string;
|
||||
}
|
||||
@@ -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(
|
||||
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
|
||||
{ui}
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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(<KantineDatevPage />);
|
||||
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(<KantineDatevPage />);
|
||||
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(<KantineDatevPage />);
|
||||
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(<KantineDatevPage />);
|
||||
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(<KantineDatevPage />);
|
||||
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(<KantineDatevPage />);
|
||||
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(<KantineDatevPage />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 <ModuleAccessGate moduleSlug="kantine-datev">{children}</ModuleAccessGate>;
|
||||
}
|
||||
@@ -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<TabId>('billing');
|
||||
const [settings, setSettings] = useState<KantineSettings | null>(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 (
|
||||
<div className="mx-auto max-w-4xl space-y-6 p-3 sm:p-6">
|
||||
<PageHeader moduleSlug="kantine-datev" title={t('title')} description={t('description')} />
|
||||
<TabBar tabs={tabs} active={activeTab} onChange={setTab} />
|
||||
<div className="rounded-lg bg-card p-6 shadow-sm dark:border dark:border-border">
|
||||
{activeTab === 'billing' ? (
|
||||
<BillingTab
|
||||
settings={settings}
|
||||
settingsError={settingsError}
|
||||
isAdmin={isAdmin}
|
||||
onOpenSettings={() => setTab('settings')}
|
||||
/>
|
||||
) : (
|
||||
<SettingsTab settings={settings} onSaved={setSettings} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BillingTab({
|
||||
settings,
|
||||
settingsError,
|
||||
isAdmin,
|
||||
onOpenSettings,
|
||||
}: {
|
||||
settings: KantineSettings | null;
|
||||
settingsError: boolean;
|
||||
isAdmin: boolean;
|
||||
onOpenSettings: () => void;
|
||||
}) {
|
||||
const t = useTranslations('kantineDatev');
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [preview, setPreview] = useState<KantinePreview | null>(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 (
|
||||
<div className="space-y-5">
|
||||
{settingsError && <p className="text-sm text-destructive">{t('errors.request')}</p>}
|
||||
|
||||
{notConfigured && (
|
||||
<div className="space-y-2 rounded-md border border-status-warn/40 bg-status-warn/10 px-4 py-3 text-sm text-foreground">
|
||||
<p>{isAdmin ? t('notConfigured.admin') : t('notConfigured.user')}</p>
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSettings}
|
||||
className="font-medium underline underline-offset-2"
|
||||
>
|
||||
{t('notConfigured.adminAction')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FileDropArea
|
||||
accept=".csv,text/csv"
|
||||
file={file}
|
||||
onFile={handleFile}
|
||||
onClear={handleClear}
|
||||
placeholder={t('dropZone.placeholder')}
|
||||
hint={t('dropZone.formats')}
|
||||
clearLabel={t('dropZone.clear')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('noStorage')}</p>
|
||||
|
||||
{checking && <p className="text-sm text-muted-foreground">{t('checking')}</p>}
|
||||
|
||||
{preview && (
|
||||
<div className="space-y-5">
|
||||
<section aria-label={t('summary.title')}>
|
||||
<dl className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<SummaryItem label={t('summary.rows')} value={String(preview.rowCount)} />
|
||||
<SummaryItem
|
||||
label={t('summary.month')}
|
||||
value={preview.abrechnungsMonat ?? t('summary.noMonth')}
|
||||
/>
|
||||
<SummaryItem
|
||||
label={t('summary.total')}
|
||||
value={euro.format(preview.totalCents / 100)}
|
||||
/>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{preview.warnings.length > 0 && (
|
||||
<section className="space-y-1">
|
||||
<h2 className="text-sm font-semibold text-foreground">{t('warnings.title')}</h2>
|
||||
<ul className="space-y-1 text-sm text-status-warn-fg">
|
||||
{preview.warnings.map((w) => (
|
||||
<li key={`${w.code}-${w.message}`}>
|
||||
{w.code === 'multipleMonths' && w.params?.months
|
||||
? t('warnings.multipleMonths', { months: w.params.months.join(', ') })
|
||||
: w.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{preview.errors.length > 0 && (
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-sm font-semibold text-destructive">{t('errors.title')}</h2>
|
||||
<div className="overflow-x-auto rounded-md border border-border">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-muted/50 text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-medium">{t('errors.line')}</th>
|
||||
<th className="px-3 py-2 font-medium">{t('errors.field')}</th>
|
||||
<th className="px-3 py-2 font-medium">{t('errors.message')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.errors.map((e) => (
|
||||
<tr key={`${e.row}-${e.field}-${e.code}`} className="border-t border-border">
|
||||
<td className="px-3 py-2 tabular-nums">{e.row}</td>
|
||||
<td className="px-3 py-2">
|
||||
{t.has(`fields.${e.field}`) ? t(`fields.${e.field}`) : e.field}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{t.has(`errors.${e.code}`) ? t(`errors.${e.code}`) : e.message}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={BUTTON_CLASS}
|
||||
disabled={!canDownload}
|
||||
onClick={handleDownload}
|
||||
>
|
||||
{downloading ? t('download.busy') : t('download.button')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<p
|
||||
role={message.kind === 'error' ? 'alert' : 'status'}
|
||||
className={`text-sm ${message.kind === 'error' ? 'text-destructive' : 'text-status-ok-fg'}`}
|
||||
>
|
||||
{message.text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-md border border-border px-4 py-3">
|
||||
<dt className="text-xs text-muted-foreground">{label}</dt>
|
||||
<dd className="mt-0.5 text-lg font-semibold text-foreground">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<form onSubmit={handleSave} className="max-w-md space-y-4" noValidate>
|
||||
<p className="text-sm text-muted-foreground">{t('settings.intro')}</p>
|
||||
{fields.map((f) => {
|
||||
const showError = touched && !valid(f.value);
|
||||
return (
|
||||
<div key={f.id} className="space-y-1">
|
||||
<label htmlFor={`kantine-${f.id}`} className="text-sm font-medium text-foreground">
|
||||
{f.label}
|
||||
</label>
|
||||
<input
|
||||
id={`kantine-${f.id}`}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
maxLength={10}
|
||||
value={f.value}
|
||||
aria-invalid={showError}
|
||||
onChange={(e) => f.set(e.target.value.trim())}
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
{showError && <p className="text-xs text-destructive">{t('settings.digitsOnly')}</p>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button type="submit" className={BUTTON_CLASS} disabled={saving}>
|
||||
{saving ? t('settings.saving') : t('settings.save')}
|
||||
</button>
|
||||
{message && (
|
||||
<p
|
||||
role={message.kind === 'error' ? 'alert' : 'status'}
|
||||
className={`text-sm ${message.kind === 'error' ? 'text-destructive' : 'text-status-ok-fg'}`}
|
||||
>
|
||||
{message.text}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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<HTMLInputElement>(null);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
className="hidden"
|
||||
data-testid="file-drop-input"
|
||||
onChange={(e) => {
|
||||
const picked = e.target.files?.[0];
|
||||
if (picked) onFile(picked);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
const dropped = e.dataTransfer.files?.[0];
|
||||
if (dropped && !disabled) onFile(dropped);
|
||||
}}
|
||||
className={`block w-full cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${
|
||||
isDragOver ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'
|
||||
}`}
|
||||
>
|
||||
{file ? (
|
||||
<span className="text-sm text-foreground">
|
||||
{file.name}{' '}
|
||||
<span className="text-muted-foreground">({(file.size / 1024).toFixed(1)} KB)</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="block space-y-1">
|
||||
<span className="block text-sm text-muted-foreground">{placeholder}</span>
|
||||
{hint && <span className="block text-xs text-muted-foreground">{hint}</span>}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{file && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={clearLabel}
|
||||
onClick={onClear}
|
||||
className="absolute right-2 top-2 rounded border border-border bg-card px-2 py-0.5 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
interface TabBarProps<T extends string> {
|
||||
tabs: { id: T; label: string }[];
|
||||
active: T;
|
||||
onChange: (id: T) => void;
|
||||
}
|
||||
|
||||
/** Reiterleiste der Finanzbuchhaltungs-Module — Muster Zertifikat-Manager. */
|
||||
export function TabBar<T extends string>({ tabs, active, onChange }: TabBarProps<T>) {
|
||||
return (
|
||||
<nav className="flex gap-6 overflow-x-auto border-b border-border">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => onChange(tab.id)}
|
||||
aria-current={active === tab.id ? 'page' : undefined}
|
||||
className={`pb-2 text-sm font-medium transition-colors ${
|
||||
active === tab.id
|
||||
? 'border-b-2 border-primary-strong font-semibold text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -43,6 +43,13 @@ const GLYPHS: Record<ModuleIconId, ReactNode> = {
|
||||
<line x1="6" x2="6.01" y1="18" y2="18" />
|
||||
</>
|
||||
),
|
||||
utensils: (
|
||||
<>
|
||||
<path d="M3 2v7c0 1.1.9 2 2 2h4a2 2 0 0 0 2-2V2" />
|
||||
<path d="M7 2v20" />
|
||||
<path d="M21 15V2a5 5 0 0 0-5 5v6c0 1.1.9 2 2 2h3Zm0 0v7" />
|
||||
</>
|
||||
),
|
||||
tile: (
|
||||
<>
|
||||
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
|
||||
|
||||
@@ -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<AccountingRequestError> {
|
||||
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<T>(
|
||||
path: string,
|
||||
init: { method?: string; json?: unknown; form?: FormData } = {},
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {};
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<KantineSettings> {
|
||||
return accountingRequest(`${BASE}/settings`);
|
||||
}
|
||||
|
||||
export function saveKantineSettings(input: KantineSettingsInput): Promise<KantineSettings> {
|
||||
return accountingRequest(`${BASE}/settings`, { method: 'PUT', json: input });
|
||||
}
|
||||
|
||||
export function previewKantineCsv(file: File): Promise<KantinePreview> {
|
||||
return accountingRequest(`${BASE}/preview`, { method: 'POST', form: fileForm(file) });
|
||||
}
|
||||
|
||||
export function exportKantineCsv(file: File): Promise<FileResponse> {
|
||||
return accountingRequest(`${BASE}/export`, { method: 'POST', form: fileForm(file) });
|
||||
}
|
||||
@@ -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<string, ModuleIconId> = {
|
||||
'tender-radar': 'radar',
|
||||
@@ -15,6 +15,7 @@ const ICONS: Record<string, ModuleIconId> = {
|
||||
'cert-manager': 'certificate',
|
||||
domaincheck: 'globe',
|
||||
proxmox: 'server',
|
||||
'kantine-datev': 'utensils',
|
||||
};
|
||||
|
||||
/** Symbol eines Moduls; unbekannte Module bekommen das allgemeine Kachel-Symbol. */
|
||||
|
||||
@@ -59,6 +59,12 @@ export const MODULE_REGISTRY: Record<string, ModuleRegistryEntry> = {
|
||||
{ ssr: false },
|
||||
),
|
||||
},
|
||||
'kantine-datev': {
|
||||
component: dynamic(
|
||||
() => import('@/app/(portal)/modules/kantine-datev/page'),
|
||||
{ ssr: false },
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -29,6 +29,7 @@ const MODULE_TITLE_KEYS: Record<string, string> = {
|
||||
'cert-manager': 'certManager.title',
|
||||
'dkv-fleet': 'dkvFleet.pageTitle',
|
||||
'tender-radar': 'tenderRadar.page.title',
|
||||
'kantine-datev': 'kantineDatev.title',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -272,6 +272,7 @@ export const MODULE_CATEGORIES = [
|
||||
"fleet",
|
||||
"infrastructure",
|
||||
"procurement",
|
||||
"accounting",
|
||||
] as const;
|
||||
|
||||
export type ModuleCategory = (typeof MODULE_CATEGORIES)[number];
|
||||
|
||||
Reference in New Issue
Block a user