fix: Berechtigungen der Tagesübersicht, WebDAV-Ausbau, Tests instand gesetzt
Build and Push Multi-Platform Images / build-and-push (push) Successful in 55s
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:
@@ -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 (ö) 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;
|
||||
}
|
||||
Reference in New Issue
Block a user