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,148 @@
import { parseERechnung } from './erechnung-parser';
const CII = `<?xml version="1.0" encoding="UTF-8"?>
<rsm:CrossIndustryInvoice
xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100"
xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100">
<rsm:ExchangedDocument>
<ram:ID>RE-2024-0815</ram:ID>
<ram:TypeCode>380</ram:TypeCode>
<ram:IssueDateTime>
<udt:DateTimeString format="102">20240315</udt:DateTimeString>
</ram:IssueDateTime>
</rsm:ExchangedDocument>
<rsm:SupplyChainTradeTransaction>
<ram:ApplicableHeaderTradeAgreement>
<ram:BuyerReference>04011000-12345-06</ram:BuyerReference>
<ram:SellerTradeParty>
<ram:Name>Saatgut Nord GmbH</ram:Name>
<ram:SpecifiedTaxRegistration>
<ram:ID schemeID="VA">DE111111111</ram:ID>
</ram:SpecifiedTaxRegistration>
</ram:SellerTradeParty>
<ram:BuyerTradeParty>
<ram:ID>K-4711</ram:ID>
<ram:Name>Hof P&#246;ttker GbR</ram:Name>
<ram:SpecifiedTaxRegistration>
<ram:ID schemeID="FC">123/456/789</ram:ID>
</ram:SpecifiedTaxRegistration>
<ram:SpecifiedTaxRegistration>
<ram:ID schemeID="VA">DE987654321</ram:ID>
</ram:SpecifiedTaxRegistration>
<ram:URIUniversalCommunication>
<ram:URIID schemeID="EM">buchhaltung@hof-poettker.de</ram:URIID>
</ram:URIUniversalCommunication>
</ram:BuyerTradeParty>
</ram:ApplicableHeaderTradeAgreement>
<ram:ApplicableHeaderTradeSettlement>
<ram:InvoiceCurrencyCode>EUR</ram:InvoiceCurrencyCode>
<ram:SpecifiedTradeSettlementHeaderMonetarySummation>
<ram:GrandTotalAmount>1190.00</ram:GrandTotalAmount>
</ram:SpecifiedTradeSettlementHeaderMonetarySummation>
</ram:ApplicableHeaderTradeSettlement>
</rsm:SupplyChainTradeTransaction>
</rsm:CrossIndustryInvoice>`;
const UBL = `<?xml version="1.0" encoding="UTF-8"?>
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">
<cbc:ID>2024-4711</cbc:ID>
<cbc:IssueDate>2024-03-15</cbc:IssueDate>
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
<cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
<cbc:BuyerReference>04011000-12345-06</cbc:BuyerReference>
<cac:AccountingSupplierParty>
<cac:Party>
<cac:PartyLegalEntity><cbc:RegistrationName>Saatgut Nord GmbH</cbc:RegistrationName></cac:PartyLegalEntity>
</cac:Party>
</cac:AccountingSupplierParty>
<cac:AccountingCustomerParty>
<cac:Party>
<cbc:EndpointID schemeID="EM">buchhaltung@hof-poettker.de</cbc:EndpointID>
<cac:PartyIdentification><cbc:ID>K-4711</cbc:ID></cac:PartyIdentification>
<cac:PartyName><cbc:Name>Hof P&#246;ttker</cbc:Name></cac:PartyName>
<cac:PartyTaxScheme>
<cbc:CompanyID>DE987654321</cbc:CompanyID>
<cac:TaxScheme><cbc:ID>VAT</cbc:ID></cac:TaxScheme>
</cac:PartyTaxScheme>
<cac:PartyLegalEntity><cbc:RegistrationName>Hof P&#246;ttker GbR</cbc:RegistrationName></cac:PartyLegalEntity>
</cac:Party>
</cac:AccountingCustomerParty>
<cac:LegalMonetaryTotal>
<cbc:TaxInclusiveAmount currencyID="EUR">1190.00</cbc:TaxInclusiveAmount>
<cbc:PayableAmount currencyID="EUR">1190.00</cbc:PayableAmount>
</cac:LegalMonetaryTotal>
</Invoice>`;
const UBL_GUTSCHRIFT = `<?xml version="1.0" encoding="UTF-8"?>
<CreditNote xmlns="urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2"
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">
<cbc:ID>GS-1</cbc:ID>
<cbc:CreditNoteTypeCode>381</cbc:CreditNoteTypeCode>
</CreditNote>`;
describe('parseERechnung', () => {
it('liest eine CII-Rechnung (ZUGFeRD/Factur-X) vollständig aus', () => {
const daten = parseERechnung(CII);
expect(daten).not.toBeNull();
expect(daten!.syntax).toBe('CII');
expect(daten!.rechnungsnummer).toBe('RE-2024-0815');
expect(daten!.rechnungsdatum).toBe('2024-03-15');
expect(daten!.typeCode).toBe('380');
expect(daten!.waehrung).toBe('EUR');
expect(daten!.gesamtbetrag).toBe(1190);
expect(daten!.leitwegId).toBe('04011000-12345-06');
expect(daten!.verkaeufer.name).toBe('Saatgut Nord GmbH');
expect(daten!.kaeufer.name).toBe('Hof Pöttker GbR');
expect(daten!.kaeufer.kennung).toBe('K-4711');
expect(daten!.kaeufer.email).toBe('buchhaltung@hof-poettker.de');
});
it('nimmt bei mehreren Steuerregistrierungen die mit schemeID "VA"', () => {
// Die Steuernummer (FC) steht im Beispiel vor der USt-IdNr. (VA).
expect(parseERechnung(CII)!.kaeufer.ustId).toBe('DE987654321');
});
it('liest eine UBL-Rechnung (XRechnung) aus', () => {
const daten = parseERechnung(UBL);
expect(daten).not.toBeNull();
expect(daten!.syntax).toBe('UBL');
expect(daten!.rechnungsnummer).toBe('2024-4711');
expect(daten!.rechnungsdatum).toBe('2024-03-15');
expect(daten!.typeCode).toBe('380');
expect(daten!.gesamtbetrag).toBe(1190);
expect(daten!.kaeufer.ustId).toBe('DE987654321');
expect(daten!.kaeufer.kennung).toBe('K-4711');
// BT-44 ist der eingetragene Name, nicht der Handelsname aus PartyName.
expect(daten!.kaeufer.name).toBe('Hof Pöttker GbR');
});
it('liest den Typ-Code einer UBL-Gutschrift', () => {
expect(parseERechnung(UBL_GUTSCHRIFT)!.typeCode).toBe('381');
});
it('verarbeitet CII und UBL zur selben Käufer-Identität', () => {
const cii = parseERechnung(CII)!;
const ubl = parseERechnung(UBL)!;
expect(cii.kaeufer.ustId).toBe(ubl.kaeufer.ustId);
expect(cii.kaeufer.kennung).toBe(ubl.kaeufer.kennung);
});
it('gibt null zurück bei fremdem XML oder kaputtem Inhalt', () => {
expect(
parseERechnung('<Lieferschein><ID>1</ID></Lieferschein>'),
).toBeNull();
expect(parseERechnung('kein xml')).toBeNull();
expect(parseERechnung(Buffer.from([0x00, 0x01, 0x02]))).toBeNull();
});
it('akzeptiert das XML auch als Buffer', () => {
expect(parseERechnung(Buffer.from(CII, 'utf8'))!.rechnungsnummer).toBe(
'RE-2024-0815',
);
});
});
@@ -0,0 +1,244 @@
import { XMLParser } from 'fast-xml-parser';
export type ERechnungSyntax = 'CII' | 'UBL';
export interface ERechnungPartei {
name: string | null;
/** USt-IdNr. (schemeID "VA" bzw. TaxScheme "VAT") */
ustId: string | null;
/** Kennung der Partei (BT-46 beim Käufer, BT-29 beim Verkäufer) */
kennung: string | null;
/** Elektronische Adresse (BT-49 bzw. BT-34) */
email: string | null;
}
export interface ERechnungDaten {
syntax: ERechnungSyntax;
/** Rechnungsnummer (BT-1) */
rechnungsnummer: string | null;
/** Rechnungsdatum (BT-2) als ISO-Datum */
rechnungsdatum: string | null;
/** Rechnungstyp (BT-3), z. B. "380" = Rechnung, "381" = Gutschrift */
typeCode: string | null;
/** Währung (BT-5) */
waehrung: string | null;
/** Bruttogesamtbetrag (BT-112) */
gesamtbetrag: number | null;
/** Käuferreferenz (BT-10) – im deutschen B2G die Leitweg-ID */
leitwegId: string | null;
kaeufer: ERechnungPartei;
verkaeufer: ERechnungPartei;
}
// removeNSPrefix schneidet die frei wählbaren Namespace-Präfixe (ram:, rsm:,
// cac:, cbc:, …) ab – ohne das wären die Pfade je nach Erzeuger andere.
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
removeNSPrefix: true,
parseTagValue: false,
parseAttributeValue: false,
trimValues: true,
// Ohne htmlEntities bleiben numerische Referenzen (&#246;) als Text stehen –
// ein so verstümmelter Käufername würde den Namensvergleich zerstören.
htmlEntities: true,
});
/**
* Parst ein E-Rechnungs-XML in der Syntax CII (ZUGFeRD/Factur-X/XRechnung-CII)
* oder UBL (XRechnung-UBL). Gibt `null` zurück, wenn das XML keine der beiden
* Wurzeln trägt oder gar nicht lesbar ist.
*/
export function parseERechnung(xml: Buffer | string): ERechnungDaten | null {
let wurzel: unknown;
try {
wurzel = parser.parse(Buffer.isBuffer(xml) ? xml.toString('utf8') : xml);
} catch {
return null;
}
const cii = kind(wurzel, 'CrossIndustryInvoice');
if (cii) return parseCii(cii);
const ubl = kind(wurzel, 'Invoice');
if (ubl) return parseUbl(ubl, 'Invoice');
const gutschrift = kind(wurzel, 'CreditNote');
if (gutschrift) return parseUbl(gutschrift, 'CreditNote');
return null;
}
// === CII (ZUGFeRD / Factur-X / XRechnung-CII) ===
function parseCii(inv: unknown): ERechnungDaten {
const kopf = kind(inv, 'ExchangedDocument');
const transaktion = kind(inv, 'SupplyChainTradeTransaction');
const vereinbarung = kind(transaktion, 'ApplicableHeaderTradeAgreement');
const abrechnung = kind(transaktion, 'ApplicableHeaderTradeSettlement');
const summen = kind(
abrechnung,
'SpecifiedTradeSettlementHeaderMonetarySummation',
);
const datum = kind(kopf, 'IssueDateTime');
return {
syntax: 'CII',
rechnungsnummer: text(kind(kopf, 'ID')),
rechnungsdatum: normalisiereDatum(
text(kind(datum, 'DateTimeString')) ?? text(datum),
),
typeCode: text(kind(kopf, 'TypeCode')),
waehrung: text(kind(abrechnung, 'InvoiceCurrencyCode')),
gesamtbetrag: zuBetrag(
text(kind(summen, 'GrandTotalAmount')) ??
text(kind(summen, 'DuePayableAmount')),
),
leitwegId: text(kind(vereinbarung, 'BuyerReference')),
kaeufer: parseCiiPartei(kind(vereinbarung, 'BuyerTradeParty')),
verkaeufer: parseCiiPartei(kind(vereinbarung, 'SellerTradeParty')),
};
}
function parseCiiPartei(partei: unknown): ERechnungPartei {
if (!partei) return leerePartei();
const ustId = liste(kind(partei, 'SpecifiedTaxRegistration'))
.map((r) => kind(r, 'ID'))
.find((id) => attribut(id, 'schemeID') === 'VA');
const kontaktMail = liste(kind(partei, 'DefinedTradeContact'))
.map((k) => text(kind(kind(k, 'EmailURIUniversalCommunication'), 'URIID')))
.find((m): m is string => !!m);
return {
name: text(kind(partei, 'Name')),
ustId: text(ustId),
kennung: text(kind(partei, 'ID')) ?? text(kind(partei, 'GlobalID')),
email:
text(kind(kind(partei, 'URIUniversalCommunication'), 'URIID')) ??
kontaktMail ??
null,
};
}
// === UBL (XRechnung-UBL) ===
function parseUbl(
inv: unknown,
wurzelName: 'Invoice' | 'CreditNote',
): ERechnungDaten {
const summen = kind(inv, 'LegalMonetaryTotal');
return {
syntax: 'UBL',
rechnungsnummer: text(kind(inv, 'ID')),
rechnungsdatum: normalisiereDatum(text(kind(inv, 'IssueDate'))),
typeCode: text(
kind(
inv,
wurzelName === 'CreditNote' ? 'CreditNoteTypeCode' : 'InvoiceTypeCode',
),
),
waehrung: text(kind(inv, 'DocumentCurrencyCode')),
gesamtbetrag: zuBetrag(
text(kind(summen, 'TaxInclusiveAmount')) ??
text(kind(summen, 'PayableAmount')),
),
leitwegId: text(kind(inv, 'BuyerReference')),
kaeufer: parseUblPartei(
kind(kind(inv, 'AccountingCustomerParty'), 'Party'),
),
verkaeufer: parseUblPartei(
kind(kind(inv, 'AccountingSupplierParty'), 'Party'),
),
};
}
function parseUblPartei(partei: unknown): ERechnungPartei {
if (!partei) return leerePartei();
const ustId = liste(kind(partei, 'PartyTaxScheme'))
.filter(
(s) =>
(text(kind(kind(s, 'TaxScheme'), 'ID')) ?? '').toUpperCase() === 'VAT',
)
.map((s) => kind(s, 'CompanyID'))[0];
const rechtseinheit = kind(partei, 'PartyLegalEntity');
return {
// BT-44/BT-27 ist der eingetragene Name, BT-45/BT-28 nur der Handelsname.
name:
text(kind(rechtseinheit, 'RegistrationName')) ??
text(kind(kind(partei, 'PartyName'), 'Name')),
ustId: text(ustId),
kennung:
text(kind(kind(partei, 'PartyIdentification'), 'ID')) ??
text(kind(rechtseinheit, 'CompanyID')),
email:
text(kind(partei, 'EndpointID')) ??
text(kind(kind(partei, 'Contact'), 'ElectronicMail')),
};
}
// === Hilfsfunktionen ===
function leerePartei(): ERechnungPartei {
return { name: null, ustId: null, kennung: null, email: null };
}
/**
* Steigt einen Schritt in den Parse-Baum ab. Wiederholte Elemente liefert der
* Parser als Array – hier zählt dann das erste Vorkommen.
*/
function kind(knoten: unknown, name: string): unknown {
const erstes: unknown = Array.isArray(knoten)
? (knoten as unknown[])[0]
: knoten;
if (!erstes || typeof erstes !== 'object') return undefined;
return (erstes as Record<string, unknown>)[name];
}
/** Liest den Textinhalt eines Knotens – auch wenn er Attribute trägt. */
function text(knoten: unknown): string | null {
if (knoten === null || knoten === undefined) return null;
if (typeof knoten === 'string') return knoten.trim() || null;
if (typeof knoten === 'number' || typeof knoten === 'boolean') {
return String(knoten);
}
if (Array.isArray(knoten)) return text((knoten as unknown[])[0]);
if (typeof knoten === 'object') {
return text((knoten as Record<string, unknown>)['#text']);
}
return null;
}
function attribut(knoten: unknown, name: string): string | null {
const wert = kind(knoten, `@_${name}`);
return typeof wert === 'string' ? wert : null;
}
function liste(knoten: unknown): unknown[] {
if (knoten === null || knoten === undefined) return [];
return Array.isArray(knoten) ? (knoten as unknown[]) : [knoten];
}
function zuBetrag(wert: string | null): number | null {
if (!wert) return null;
const zahl = Number(wert);
return Number.isFinite(zahl) ? zahl : null;
}
/**
* CII liefert das Datum als "20240315" (format="102"), UBL bereits als
* ISO-Datum. Alles andere wird unverändert durchgereicht.
*/
function normalisiereDatum(wert: string | null): string | null {
if (!wert) return null;
if (/^\d{8}$/.test(wert)) {
return `${wert.slice(0, 4)}-${wert.slice(4, 6)}-${wert.slice(6, 8)}`;
}
if (/^\d{4}-\d{2}-\d{2}/.test(wert)) return wert.slice(0, 10);
return wert;
}
@@ -0,0 +1,24 @@
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Client } from '../database/entities/client.entity';
import { ClientIdentifier } from '../database/entities/client-identifier.entity';
import { DocumentType } from '../database/entities/document-type.entity';
import { UserClient } from '../database/entities/user-client.entity';
import { PaperlessModule } from '../paperless/paperless.module';
import { ErechnungService } from './erechnung.service';
import { MandantMatcherService } from './mandant-matcher.service';
@Module({
imports: [
TypeOrmModule.forFeature([
Client,
ClientIdentifier,
DocumentType,
UserClient,
]),
forwardRef(() => PaperlessModule),
],
providers: [ErechnungService, MandantMatcherService],
exports: [ErechnungService, MandantMatcherService],
})
export class ErechnungModule {}
@@ -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));
}
}
@@ -0,0 +1,208 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { Client } from '../database/entities/client.entity';
import {
ClientIdentifier,
ClientIdentifierTyp,
} from '../database/entities/client-identifier.entity';
import { DocumentType } from '../database/entities/document-type.entity';
import type { ERechnungDaten } from './erechnung-parser';
export interface KennungsKandidat {
Typ: ClientIdentifierTyp;
Wert: string;
}
export interface MandantTreffer {
clientId: number | null;
/** Woher der Treffer stammt – `null`, wenn keiner gefunden wurde. */
quelle: 'kennung' | 'name' | null;
/** Mehrere Mandanten passten; es wird bewusst keiner vorgeschlagen. */
mehrdeutig: boolean;
}
/** Maximale Länge einer Kennung – entspricht ClientIdentifier.Wert. */
const MAX_KENNUNG_LAENGE = 100;
/**
* Grobstruktur einer Leitweg-ID: Grobadressierung, optionale Feinadressierung,
* zweistellige Prüfziffer. BT-10 ist ein Freitextfeld – im B2B steht dort oft
* eine Bestellnummer. Nur strukturell passende Werte werden gelernt, sonst
* würde jede Rechnung eine wertlose Kennung anlegen.
*/
const LEITWEG_ID_MUSTER = /^\d{2,12}(-[0-9A-Za-z]{1,30})?-\d{2}$/;
/** Rechtsformen und Zusätze, die für den Namensvergleich irrelevant sind. */
const RECHTSFORMEN =
/\b(gmbh\s*&\s*co\.?\s*kg|gmbh|mbh|kgaa|kg|ohg|gbr|ug\s*\(haftungsbeschraenkt\)|ug|ag|se|partg\s*mbb|partg|mbb|e\.?\s*k\.?|e\.?\s*v\.?|e\.?\s*g\.?|&\s*co\.?)\b/g;
/**
* Normalisiert einen Firmennamen für den exakten Vergleich: Kleinschreibung,
* deutsche Umlaut-Transliteration, Rechtsformen und Sonderzeichen entfernt.
* Bewusst kein Ähnlichkeitsmaß – ein Fuzzy-Treffer würde bei ähnlich benannten
* Mandanten genau die gefährlichen Verwechslungen erzeugen.
*/
export function normalisiereFirmenname(name: string | null): string {
if (!name) return '';
return name
.toLowerCase()
.replace(/ä/g, 'ae')
.replace(/ö/g, 'oe')
.replace(/ü/g, 'ue')
.replace(/ß/g, 'ss')
.replace(RECHTSFORMEN, ' ')
.replace(/[^a-z0-9]/g, '');
}
/** Vereinheitlicht eine Kennung: Großschreibung, ohne Leer- und Trennzeichen. */
export function normalisiereKennung(wert: string | null): string {
if (!wert) return '';
return wert.toUpperCase().replace(/[^A-Z0-9]/g, '');
}
/**
* Sammelt die Käufer-Kennungen einer E-Rechnung, gegen die gematcht wird.
* `nurLernbare` filtert zusätzlich auf das, was dauerhaft gespeichert werden
* darf – eine BT-10-Bestellnummer gehört nicht in die Zuordnungstabelle.
*/
export function sammleKennungen(
daten: ERechnungDaten,
nurLernbare = false,
): KennungsKandidat[] {
const kandidaten: KennungsKandidat[] = [];
const hinzu = (Typ: ClientIdentifierTyp, roh: string | null) => {
const Wert = normalisiereKennung(roh);
if (!Wert || Wert.length > MAX_KENNUNG_LAENGE) return;
if (kandidaten.some((k) => k.Typ === Typ && k.Wert === Wert)) return;
kandidaten.push({ Typ, Wert });
};
hinzu('ustid', daten.kaeufer.ustId);
hinzu('kaeuferkennung', daten.kaeufer.kennung);
const leitweg = daten.leitwegId?.trim() ?? '';
if (leitweg && (!nurLernbare || LEITWEG_ID_MUSTER.test(leitweg))) {
hinzu('leitwegid', leitweg);
}
return kandidaten;
}
/**
* Bestimmt den Mandanten zu einer E-Rechnung: zuerst über die gepflegten
* Kennungen, danach über einen exakten Namensvergleich. Passen mehrere
* Mandanten, gibt es keinen Vorschlag.
*/
export function findeMandant(
daten: ERechnungDaten,
clients: Client[],
identifiers: ClientIdentifier[],
): MandantTreffer {
const kandidaten = sammleKennungen(daten);
const perKennung = new Set(
identifiers
.filter((i) =>
kandidaten.some((k) => k.Typ === i.Typ && k.Wert === i.Wert),
)
.map((i) => i.ClientId),
);
if (perKennung.size === 1) {
return {
clientId: [...perKennung][0],
quelle: 'kennung',
mehrdeutig: false,
};
}
if (perKennung.size > 1) {
return { clientId: null, quelle: null, mehrdeutig: true };
}
const gesuchterName = normalisiereFirmenname(daten.kaeufer.name);
if (!gesuchterName) {
return { clientId: null, quelle: null, mehrdeutig: false };
}
const perName = clients.filter(
(c) => normalisiereFirmenname(c.Name) === gesuchterName,
);
if (perName.length === 1) {
return { clientId: perName[0].Id, quelle: 'name', mehrdeutig: false };
}
return { clientId: null, quelle: null, mehrdeutig: perName.length > 1 };
}
/**
* Bildet den Rechnungstyp (BT-3) auf eine Belegart ab. Passt kein oder mehr als
* ein Eintrag, wird nichts vorgeschlagen – geraten wird nicht.
*/
export function findeBelegart(
typeCode: string | null,
docTypes: DocumentType[],
): number | null {
if (!typeCode) return null;
const gesucht = typeCode.trim();
const treffer = docTypes.filter((dt) =>
(dt.ERechnungTypeCodes ?? '')
.split(',')
.map((c) => c.trim())
.filter(Boolean)
.includes(gesucht),
);
return treffer.length === 1 ? treffer[0].DocumentTypeId : null;
}
@Injectable()
export class MandantMatcherService {
constructor(
@InjectRepository(Client)
private readonly clientRepo: Repository<Client>,
@InjectRepository(ClientIdentifier)
private readonly identifierRepo: Repository<ClientIdentifier>,
@InjectRepository(DocumentType)
private readonly docTypeRepo: Repository<DocumentType>,
) {}
async match(daten: ERechnungDaten): Promise<MandantTreffer> {
const kandidaten = sammleKennungen(daten);
const identifiers = kandidaten.length
? await this.identifierRepo.find({
where: { Wert: In(kandidaten.map((k) => k.Wert)) },
})
: [];
const clients = await this.clientRepo.find();
return findeMandant(daten, clients, identifiers);
}
async belegart(typeCode: string | null): Promise<number | null> {
if (!typeCode) return null;
return findeBelegart(typeCode, await this.docTypeRepo.find());
}
/**
* Schreibt die starken Käufer-Kennungen der Rechnung auf den Mandanten fort,
* den der Benutzer tatsächlich gespeichert hat. Bereits vergebene Kennungen
* bleiben unangetastet – eine bestehende Zuordnung wird nie still umgebogen.
*/
async lerne(daten: ERechnungDaten, clientId: number): Promise<number> {
const kandidaten = sammleKennungen(daten, true);
if (!kandidaten.length) return 0;
const vorhanden = await this.identifierRepo.find({
where: { Wert: In(kandidaten.map((k) => k.Wert)) },
});
const neu = kandidaten
.filter(
(k) => !vorhanden.some((v) => v.Typ === k.Typ && v.Wert === k.Wert),
)
.map((k) => this.identifierRepo.create({ ...k, ClientId: clientId }));
if (!neu.length) return 0;
await this.identifierRepo.save(neu);
return neu.length;
}
}
@@ -0,0 +1,262 @@
import {
findeBelegart,
findeMandant,
normalisiereFirmenname,
normalisiereKennung,
sammleKennungen,
} from './mandant-matcher.service';
import type { ERechnungDaten } from './erechnung-parser';
import { Client } from '../database/entities/client.entity';
import { ClientIdentifier } from '../database/entities/client-identifier.entity';
import { DocumentType } from '../database/entities/document-type.entity';
function daten(overrides: Partial<ERechnungDaten> = {}): ERechnungDaten {
return {
syntax: 'CII',
rechnungsnummer: 'RE-1',
rechnungsdatum: '2024-03-15',
typeCode: '380',
waehrung: 'EUR',
gesamtbetrag: 100,
leitwegId: null,
kaeufer: { name: null, ustId: null, kennung: null, email: null },
verkaeufer: { name: null, ustId: null, kennung: null, email: null },
...overrides,
};
}
function client(Id: number, Name: string): Client {
return { Id, Name, PaperlessUserId: Id + 100, AgrarmonitorBetriebId: null };
}
function kennung(
Id: number,
ClientId: number,
Typ: ClientIdentifier['Typ'],
Wert: string,
): ClientIdentifier {
return { Id, ClientId, Typ, Wert };
}
describe('normalisiereFirmenname', () => {
it('macht Schreibweise, Umlaute und Rechtsform irrelevant', () => {
expect(normalisiereFirmenname('Hof Pöttker GbR')).toBe(
normalisiereFirmenname('hof poettker'),
);
expect(normalisiereFirmenname('Müller & Söhne GmbH & Co. KG')).toBe(
normalisiereFirmenname('Mueller & Soehne'),
);
expect(normalisiereFirmenname('Meier-Schulze e.K.')).toBe(
normalisiereFirmenname('Meier Schulze'),
);
expect(normalisiereFirmenname('Weiß AG')).toBe('weiss');
});
it('trennt weiterhin unterschiedliche Namen', () => {
expect(normalisiereFirmenname('Hof Pöttker')).not.toBe(
normalisiereFirmenname('Hof Pöttker-Holtrup'),
);
});
it('lässt Namensbestandteile stehen, die nur wie eine Rechtsform aussehen', () => {
// "E. Krause" darf nicht als "e.K." verschluckt werden.
expect(normalisiereFirmenname('E. Krause')).toBe('ekrause');
});
it('liefert leeren String für fehlende Namen', () => {
expect(normalisiereFirmenname(null)).toBe('');
expect(normalisiereFirmenname(' ')).toBe('');
});
});
describe('normalisiereKennung', () => {
it('vereinheitlicht Schreibweise und Trennzeichen', () => {
expect(normalisiereKennung('de 987 654 321')).toBe('DE987654321');
expect(normalisiereKennung('04011000-12345-06')).toBe('040110001234506');
expect(normalisiereKennung(null)).toBe('');
});
});
describe('sammleKennungen', () => {
it('sammelt USt-IdNr., Käuferkennung und Leitweg-ID', () => {
const k = sammleKennungen(
daten({
leitwegId: '04011000-12345-06',
kaeufer: {
name: null,
ustId: 'DE987654321',
kennung: 'K-4711',
email: null,
},
}),
);
expect(k).toEqual([
{ Typ: 'ustid', Wert: 'DE987654321' },
{ Typ: 'kaeuferkennung', Wert: 'K4711' },
{ Typ: 'leitwegid', Wert: '040110001234506' },
]);
});
it('lernt aus BT-10 nur strukturell gültige Leitweg-IDs', () => {
const freitext = daten({ leitwegId: 'Bestellung 4711' });
// Zum Matchen wird der Wert benutzt …
expect(sammleKennungen(freitext)).toEqual([
{ Typ: 'leitwegid', Wert: 'BESTELLUNG4711' },
]);
// … gelernt wird er nicht, sonst entstünde je Rechnung eine Karteileiche.
expect(sammleKennungen(freitext, true)).toEqual([]);
const leitweg = daten({ leitwegId: '04011000-12345-06' });
expect(sammleKennungen(leitweg, true)).toEqual([
{ Typ: 'leitwegid', Wert: '040110001234506' },
]);
});
it('ignoriert leere Kennungen', () => {
expect(sammleKennungen(daten())).toEqual([]);
});
});
describe('findeMandant', () => {
const clients = [client(1, 'Hof Pöttker GbR'), client(2, 'Hof Holtrup')];
it('trifft über eine gepflegte Kennung', () => {
const treffer = findeMandant(
daten({
kaeufer: {
name: 'Ganz anderer Name',
ustId: 'DE987654321',
kennung: null,
email: null,
},
}),
clients,
[kennung(1, 2, 'ustid', 'DE987654321')],
);
expect(treffer).toEqual({
clientId: 2,
quelle: 'kennung',
mehrdeutig: false,
});
});
it('fällt auf den normalisierten Namensvergleich zurück', () => {
const treffer = findeMandant(
daten({
kaeufer: {
name: 'hof poettker',
ustId: null,
kennung: null,
email: null,
},
}),
clients,
[],
);
expect(treffer).toEqual({ clientId: 1, quelle: 'name', mehrdeutig: false });
});
it('schlägt nichts vor, wenn mehrere Kennungen auf verschiedene Mandanten zeigen', () => {
const treffer = findeMandant(
daten({
leitwegId: '04011000-12345-06',
kaeufer: {
name: 'Hof Pöttker GbR',
ustId: 'DE987654321',
kennung: null,
email: null,
},
}),
clients,
[
kennung(1, 1, 'ustid', 'DE987654321'),
kennung(2, 2, 'leitwegid', '040110001234506'),
],
);
expect(treffer).toEqual({ clientId: null, quelle: null, mehrdeutig: true });
});
it('schlägt nichts vor, wenn mehrere Mandanten denselben Namen tragen', () => {
const treffer = findeMandant(
daten({
kaeufer: { name: 'Hof Test', ustId: null, kennung: null, email: null },
}),
[client(1, 'Hof Test'), client(2, 'Hof Test GmbH')],
[],
);
expect(treffer).toEqual({ clientId: null, quelle: null, mehrdeutig: true });
});
it('schlägt nichts vor, wenn nichts passt', () => {
const treffer = findeMandant(
daten({
kaeufer: { name: 'Unbekannt', ustId: null, kennung: null, email: null },
}),
clients,
[],
);
expect(treffer).toEqual({
clientId: null,
quelle: null,
mehrdeutig: false,
});
});
it('ignoriert Kennungen, die zwar existieren, aber zu einer anderen Art gehören', () => {
const treffer = findeMandant(
daten({
kaeufer: {
name: null,
ustId: 'DE987654321',
kennung: null,
email: null,
},
}),
clients,
[kennung(1, 2, 'kaeuferkennung', 'DE987654321')],
);
expect(treffer.clientId).toBeNull();
});
});
describe('findeBelegart', () => {
const docType = (
DocumentTypeId: number,
ERechnungTypeCodes: string | null,
): DocumentType => ({
Id: DocumentTypeId,
DocumentTypeId,
TitelTemplate: '',
TagNotReady: null,
TagReady: null,
FreigabeErforderlich: null,
ERechnungTypeCodes,
});
const typen = [docType(3, '380, 384'), docType(7, '381'), docType(9, null)];
it('bildet den Typ-Code auf die konfigurierte Belegart ab', () => {
expect(findeBelegart('380', typen)).toBe(3);
expect(findeBelegart('384', typen)).toBe(3);
expect(findeBelegart('381', typen)).toBe(7);
});
it('rät nicht bei unbekanntem oder fehlendem Code', () => {
expect(findeBelegart('875', typen)).toBeNull();
expect(findeBelegart(null, typen)).toBeNull();
});
it('schlägt nichts vor, wenn ein Code doppelt konfiguriert ist', () => {
expect(
findeBelegart('380', [docType(3, '380'), docType(4, '380')]),
).toBeNull();
});
});
@@ -0,0 +1,51 @@
import { PDFDocument } from 'pdf-lib';
import { extrahiereEingebetteteDateien } from './pdf-attachment.util';
const XML = '<?xml version="1.0"?><rsm:CrossIndustryInvoice/>';
async function pdfMitAnhang(
...anhaenge: { name: string; inhalt: string }[]
): Promise<Buffer> {
const doc = await PDFDocument.create();
doc.addPage();
for (const a of anhaenge) {
await doc.attach(Buffer.from(a.inhalt, 'utf8'), a.name, {
mimeType: 'text/xml',
});
}
return Buffer.from(await doc.save());
}
describe('extrahiereEingebetteteDateien', () => {
it('liest einen eingebetteten ZUGFeRD-Anhang aus', async () => {
const pdf = await pdfMitAnhang({ name: 'factur-x.xml', inhalt: XML });
const dateien = await extrahiereEingebetteteDateien(pdf);
expect(dateien).toHaveLength(1);
expect(dateien[0].name).toBe('factur-x.xml');
expect(dateien[0].inhalt.toString('utf8')).toBe(XML);
});
it('liefert jeden Anhang nur einmal, obwohl er in /Names und /AF steht', async () => {
const pdf = await pdfMitAnhang(
{ name: 'xrechnung.xml', inhalt: XML },
{ name: 'anhang.txt', inhalt: 'Lieferschein' },
);
const dateien = await extrahiereEingebetteteDateien(pdf);
expect(dateien.map((d) => d.name).sort()).toEqual([
'anhang.txt',
'xrechnung.xml',
]);
});
it('liefert eine leere Liste für ein PDF ohne Anhänge', async () => {
const doc = await PDFDocument.create();
doc.addPage();
const pdf = Buffer.from(await doc.save());
expect(await extrahiereEingebetteteDateien(pdf)).toEqual([]);
});
});
@@ -0,0 +1,126 @@
import {
PDFArray,
PDFDict,
PDFDocument,
PDFHexString,
PDFName,
PDFRawStream,
PDFStream,
PDFString,
decodePDFRawStream,
} from 'pdf-lib';
export interface EingebetteteDatei {
name: string;
inhalt: Buffer;
}
/** Schutz gegen zyklische oder absurd tiefe Namensbäume. */
const MAX_TIEFE = 32;
/**
* Liest die im PDF eingebetteten Dateien aus – also die Anhänge, über die
* ZUGFeRD/Factur-X und XRechnung ihr XML transportieren (PDF/A-3).
*
* pdf-lib bietet dafür keine öffentliche API (nur `attach()` zum Schreiben),
* deshalb werden die beiden Stellen, an denen Erzeuger Anhänge ablegen, direkt
* aus dem Katalog gelesen: der Namensbaum /Root/Names/EmbeddedFiles und die
* Liste zugeordneter Dateien /Root/AF.
*/
export async function extrahiereEingebetteteDateien(
pdf: Buffer,
): Promise<EingebetteteDatei[]> {
const doc = await PDFDocument.load(pdf, {
ignoreEncryption: true,
throwOnInvalidObject: false,
updateMetadata: false,
});
const filespecs: PDFDict[] = [];
const katalog = doc.catalog;
const names = katalog.lookupMaybe(PDFName.of('Names'), PDFDict);
const embeddedFiles = names?.lookupMaybe(
PDFName.of('EmbeddedFiles'),
PDFDict,
);
if (embeddedFiles) sammleAusNamensbaum(embeddedFiles, filespecs, 0);
const af = katalog.lookupMaybe(PDFName.of('AF'), PDFArray);
if (af) {
for (let i = 0; i < af.size(); i++) {
const spec = af.lookupMaybe(i, PDFDict);
if (spec) filespecs.push(spec);
}
}
const dateien: EingebetteteDatei[] = [];
const gesehen = new Set<string>();
for (const spec of filespecs) {
const name =
leseText(spec, 'UF') ?? leseText(spec, 'F') ?? leseText(spec, 'DOS');
const inhalt = leseInhalt(spec);
if (!inhalt) continue;
// Derselbe Anhang steht bei PDF/A-3 regelmäßig in /Names *und* /AF.
const schluessel = `${name ?? ''}:${inhalt.length}`;
if (gesehen.has(schluessel)) continue;
gesehen.add(schluessel);
dateien.push({ name: name ?? 'unbenannt', inhalt });
}
return dateien;
}
function sammleAusNamensbaum(
knoten: PDFDict,
ziel: PDFDict[],
tiefe: number,
): void {
if (tiefe > MAX_TIEFE) return;
// Blattknoten: /Names ist ein flaches [Name, Filespec, Name, Filespec, …].
const eintraege = knoten.lookupMaybe(PDFName.of('Names'), PDFArray);
if (eintraege) {
for (let i = 1; i < eintraege.size(); i += 2) {
const spec = eintraege.lookupMaybe(i, PDFDict);
if (spec) ziel.push(spec);
}
}
const kids = knoten.lookupMaybe(PDFName.of('Kids'), PDFArray);
if (kids) {
for (let i = 0; i < kids.size(); i++) {
const kind = kids.lookupMaybe(i, PDFDict);
if (kind) sammleAusNamensbaum(kind, ziel, tiefe + 1);
}
}
}
function leseText(dict: PDFDict, schluessel: string): string | null {
const wert = dict.lookupMaybe(
PDFName.of(schluessel),
PDFString,
PDFHexString,
);
return wert ? wert.decodeText() : null;
}
/** Holt den dekomprimierten Inhalt aus /EF (bevorzugt /UF, sonst /F). */
function leseInhalt(spec: PDFDict): Buffer | null {
const ef = spec.lookupMaybe(PDFName.of('EF'), PDFDict);
if (!ef) return null;
for (const schluessel of ['UF', 'F', 'DOS', 'Mac', 'Unix']) {
const stream = ef.lookupMaybe(PDFName.of(schluessel), PDFStream);
if (!(stream instanceof PDFRawStream)) continue;
try {
return Buffer.from(decodePDFRawStream(stream).decode());
} catch {
// Beschädigter oder unbekannt kodierter Stream – nächsten Schlüssel probieren.
}
}
return null;
}