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