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:
2026-09-01 00:06:50 +02:00
co-authored by Claude Opus 5
parent 32ba772e4e
commit b7b65c64ae
41 changed files with 2462 additions and 443 deletions
@@ -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;
}