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>
227 lines
7.1 KiB
TypeScript
227 lines
7.1 KiB
TypeScript
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));
|
||
}
|
||
}
|