fix: Berechtigungen der Tagesübersicht, WebDAV-Ausbau, Tests instand gesetzt
Build and Push Multi-Platform Images / build-and-push (push) Successful in 55s

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>
This commit is contained in:
bjoernpoettkerandClaude Opus 5 committed 2026-09-01 00:06:50 +02:00
1 parent 32ba772e4e
commit b7b65c64ae
41 files changed
+2462 -443

No files matched your search

@@ -0,0 +1,226 @@
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));
}
}