Files
paperlessmanager/paperless-backend/src/erechnung/erechnung.service.ts
T
bjoernpoettkerandClaude Opus 5 b7b65c64ae
Build and Push Multi-Platform Images / build-and-push (push) Successful in 55s
fix: Berechtigungen der Tagesübersicht, WebDAV-Ausbau, Tests instand gesetzt
Die tägliche E-Mail-Zusammenfassung zeigte Nutzern Bereiche, für die sie
keine Berechtigung haben. Zwei unabhängige Ursachen:

- Das Backend prüfte weiterhin die Altgruppe PM_Belege. Die Umbenennung
  zu PM_Buchhaltung (ca1d371) war nur im Frontend angekommen, weshalb
  der Digest "Manuell bearbeiten" und "In Agrarmonitor" anbot, während
  die Oberfläche beide Bereiche sperrte.
- Der Cron-Versand wertet die Gruppen aus user_settings aus. Diese Spalte
  wurde nur beim Aufruf der Benutzereinstellungen gefüllt; entzogene
  Berechtigungen erreichten den Digest daher unter Umständen nie.

Behoben durch Angleichen des Gruppen-Mappings und den neuen
UserIdentitySyncService, der E-Mail, Benutzername und Gruppen bei jedem
authentifizierten Request aus dem Token spiegelt – ohne den Request zu
blockieren und ohne DB-Zugriff, solange sich das Token nicht ändert. Die
doppelte Identitätspflege im UserSettingsService entfällt.

WebDAV wird nicht eingesetzt und ist entfernt; Export-Ziele bieten nur
noch FTP. Damit verschwindet das ESM-Paket webdav, an dem zwei
Jest-Suites bereits beim Parsen scheiterten.

Veraltete Tests instand gesetzt: email.controller und settings.controller
mockten weniger Abhängigkeiten, als die Klassen inzwischen haben;
postprocessing.service.spec beschrieb noch das alte Regelmodell mit
Einzelfeldern statt FilterJson und ist gegen die heutige Filter-Engine
neu geschrieben (AND/OR, verschachtelte Gruppen, Fehlerprotokollierung).

Enthält außerdem den Arbeitsstand der E-Rechnungs-Mandantenzuordnung, da
sich beide Änderungen dieselben Dateien teilen (SettingsPage, package.json).

98 Tests in 12 Suites grün, Backend- und Frontend-Build sauber.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 00:06:50 +02:00

227 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Client>,
@InjectRepository(UserClient)
private readonly userClientRepo: Repository<UserClient>,
) {}
/** Wertet die E-Rechnung eines Paperless-Dokuments aus und schlägt Felder vor. */
async analysiereDokument(
documentId: number,
userId: string,
): Promise<ERechnungVorschlag> {
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<void> {
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<ERechnungDaten | null> {
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<ERechnungDaten | null> {
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<Set<number>> {
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));
}
}