import { Inject, Injectable, Logger, forwardRef } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { PaperlessService } from '../paperless/paperless.service'; import { Client } from '../database/entities/client.entity'; import { UserClient } from '../database/entities/user-client.entity'; import { extrahiereEingebetteteDateien } from './pdf-attachment.util'; import { ERechnungDaten, parseERechnung } from './erechnung-parser'; import { MandantMatcherService } from './mandant-matcher.service'; export interface ERechnungVorschlag { /** Ob das PDF überhaupt eine auswertbare E-Rechnung enthält. */ erechnung: boolean; mandantId: number | null; belegartId: number | null; /** Kurztext für das UI, wenn kein Vorschlag möglich war. */ hinweis: string | null; rechnung: { rechnungsnummer: string | null; rechnungsdatum: string | null; gesamtbetrag: number | null; waehrung: string | null; verkaeufer: string | null; typeCode: string | null; } | null; } /** Wie lange geparste Rechnungsdaten für das anschließende Speichern vorgehalten werden. */ const CACHE_TTL_MS = 30 * 60 * 1000; const CACHE_MAX = 200; @Injectable() export class ErechnungService { private readonly logger = new Logger(ErechnungService.name); /** * Zwischenspeicher zwischen Öffnen und Speichern eines Dokuments: das * Auto-Lernen beim PUT braucht dieselben Rechnungsdaten und soll dafür nicht * ein zweites Mal das PDF laden. */ private readonly cache = new Map< number, { daten: ERechnungDaten | null; ts: number } >(); constructor( @Inject(forwardRef(() => PaperlessService)) private readonly paperlessService: PaperlessService, private readonly matcher: MandantMatcherService, @InjectRepository(Client) private readonly clientRepo: Repository, @InjectRepository(UserClient) private readonly userClientRepo: Repository, ) {} /** Wertet die E-Rechnung eines Paperless-Dokuments aus und schlägt Felder vor. */ async analysiereDokument( documentId: number, userId: string, ): Promise { const daten = await this.ladeDaten(documentId); if (!daten) { return { erechnung: false, mandantId: null, belegartId: null, hinweis: null, rechnung: null, }; } const rechnung = { rechnungsnummer: daten.rechnungsnummer, rechnungsdatum: daten.rechnungsdatum, gesamtbetrag: daten.gesamtbetrag, waehrung: daten.waehrung, verkaeufer: daten.verkaeufer.name, typeCode: daten.typeCode, }; const belegartId = await this.matcher.belegart(daten.typeCode); const treffer = await this.matcher.match(daten); if (treffer.mehrdeutig) { return { erechnung: true, mandantId: null, belegartId, hinweis: 'Mehrere Mandanten passen zu dieser Rechnung – bitte manuell wählen.', rechnung, }; } if (treffer.clientId === null) { return { erechnung: true, mandantId: null, belegartId, hinweis: 'Kein Mandant zu dieser Rechnung gefunden.', rechnung, }; } // Vorschlag nie auf einen Mandanten setzen, den der Benutzer gar nicht // auswählen darf – das Select stünde sonst auf einer rohen ID. Der Name // wird bewusst nicht genannt. const erlaubt = await this.erlaubteClientIds(userId); if (!erlaubt.has(treffer.clientId)) { return { erechnung: true, mandantId: null, belegartId, hinweis: 'Zugehöriger Mandant ist dir nicht zugeordnet.', rechnung, }; } const client = await this.clientRepo.findOneBy({ Id: treffer.clientId }); return { erechnung: true, mandantId: client?.PaperlessUserId ?? null, belegartId, hinweis: client ? null : 'Kein Mandant zu dieser Rechnung gefunden.', rechnung, }; } /** * Lernt die Käufer-Kennungen der Rechnung auf den gespeicherten Mandanten. * Wird nur aufgerufen, wenn das Dokument tatsächlich eine E-Rechnung ist. */ async lerneAusDokument( documentId: number, paperlessUserId: number, ): Promise { try { const daten = await this.ladeDaten(documentId); if (!daten) return; const client = await this.clientRepo.findOneBy({ PaperlessUserId: paperlessUserId, }); if (!client) return; const anzahl = await this.matcher.lerne(daten, client.Id); if (anzahl > 0) { this.logger.log( `Dokument ${documentId}: ${anzahl} E-Rechnungs-Kennung(en) für Mandant "${client.Name}" gelernt.`, ); } } catch (error) { // Das Lernen darf das Speichern des Dokuments niemals scheitern lassen. this.logger.warn( `E-Rechnungs-Kennungen für Dokument ${documentId} konnten nicht gelernt werden: ${String(error)}`, ); } } /** Lädt und parst die Rechnungsdaten – mit kurzem Zwischenspeicher. */ private async ladeDaten(documentId: number): Promise { const zwischenspeicher = this.cache.get(documentId); if (zwischenspeicher && Date.now() - zwischenspeicher.ts < CACHE_TTL_MS) { return zwischenspeicher.daten; } let daten: ERechnungDaten | null = null; try { const pdf = await this.paperlessService.downloadDocument( documentId, 'original', ); daten = await this.leseAusPdf(pdf); } catch (error) { this.logger.warn( `E-Rechnung von Dokument ${documentId} konnte nicht gelesen werden: ${String(error)}`, ); } this.merke(documentId, daten); return daten; } /** Sucht im PDF das erste eingebettete XML, das sich als E-Rechnung lesen lässt. */ private async leseAusPdf(pdf: Buffer): Promise { const dateien = await extrahiereEingebetteteDateien(pdf); const kandidaten = dateien.filter( (d) => d.name.toLowerCase().endsWith('.xml') || d.inhalt .subarray(0, 512) .toString('latin1') .trimStart() .startsWith('<'), ); for (const datei of kandidaten) { const daten = parseERechnung(datei.inhalt); if (daten) return daten; } return null; } private merke(documentId: number, daten: ERechnungDaten | null): void { if (this.cache.size >= CACHE_MAX) { const aeltester = this.cache.keys().next(); if (!aeltester.done) this.cache.delete(aeltester.value); } this.cache.set(documentId, { daten, ts: Date.now() }); } /** * Mandanten, die der Benutzer auswählen darf – identisch zur Logik hinter * `GET /api/clients`, damit Vorschlag und Auswahlliste nie auseinanderlaufen. */ private async erlaubteClientIds(userId: string): Promise> { const zuordnungen = await this.userClientRepo.find({ where: { UserId: userId }, }); if (zuordnungen.length > 0) { return new Set(zuordnungen.map((z) => z.ClientId)); } const alle = await this.clientRepo.find(); return new Set(alle.map((c) => c.Id)); } }