Files
paperlessmanager/paperless-backend/src/erechnung/erechnung-parser.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

245 lines
7.6 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 { 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;
}