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:
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ö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ö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ö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 (ö) 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;
|
||||
}
|
||||
Reference in new issue
Block a user