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:
2026-10-02 11:32:55 +02:00
parent 0edd6e9b1a
commit 1f85277a3b
38 changed files with 2460 additions and 2 deletions
@@ -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;
}
}
}