From c45e837c2b450bef79407df55a0a80094c8fade6 Mon Sep 17 00:00:00 2001 From: OlgunR Date: Fri, 22 May 2026 14:01:07 +0200 Subject: [PATCH] Add ZUGFeRD parsing and invoice storage support Enhanced `ZugferdInvoice` model with default string values to prevent nulls. Updated `Upload.cshtml` to display parsed invoice data. Refactored `Upload.cshtml.cs` to handle ZUGFeRD XML parsing and database storage. Introduced `ImportedInvoice` property and buffered file processing with `MemoryStream`. Extended `ZugferdParserService` to support ZUGFeRD v1, v1.0 FeRD, and v2/Factur-X. Added version-specific parsing methods and namespaces. Improved date and decimal parsing for robustness. Added database migration (`20260522084606_InitialCreate`) to define `ZugferdInvoices` table. Updated migration snapshot to reflect schema changes. Fixed localization issue in `Upload.cshtml.cs` error message. --- .../20260522084606_InitialCreate.Designer.cs | 79 +++++++++++ .../20260522084606_InitialCreate.cs | 46 ++++++ .../Migrations/AppDbContextModelSnapshot.cs | 76 ++++++++++ .../Models/ZugferdInvoice.cs | 16 +-- .../Pages/Invoices/Upload.cshtml | 18 +++ .../Pages/Invoices/Upload.cshtml.cs | 22 ++- .../Services/ZugferdParserService.cs | 134 +++++++++++++++--- 7 files changed, 361 insertions(+), 30 deletions(-) create mode 100644 DXApp.TemplateKitProject/Migrations/20260522084606_InitialCreate.Designer.cs create mode 100644 DXApp.TemplateKitProject/Migrations/20260522084606_InitialCreate.cs create mode 100644 DXApp.TemplateKitProject/Migrations/AppDbContextModelSnapshot.cs diff --git a/DXApp.TemplateKitProject/Migrations/20260522084606_InitialCreate.Designer.cs b/DXApp.TemplateKitProject/Migrations/20260522084606_InitialCreate.Designer.cs new file mode 100644 index 0000000..6cd4e2c --- /dev/null +++ b/DXApp.TemplateKitProject/Migrations/20260522084606_InitialCreate.Designer.cs @@ -0,0 +1,79 @@ +ο»Ώ// +using System; +using DXApp.TemplateKitProject.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace DXApp.TemplateKitProject.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260522084606_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("DXApp.TemplateKitProject.Models.ZugferdInvoice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BuyerName") + .HasColumnType("nvarchar(max)"); + + b.Property("CurrencyCode") + .HasColumnType("nvarchar(max)"); + + b.Property("Iban") + .HasColumnType("nvarchar(max)"); + + b.Property("ImportedAt") + .HasColumnType("datetime2"); + + b.Property("InvoiceDate") + .HasColumnType("datetime2"); + + b.Property("InvoiceNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("RawXml") + .HasColumnType("nvarchar(max)"); + + b.Property("SellerName") + .HasColumnType("nvarchar(max)"); + + b.Property("SellerTaxId") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceType") + .HasColumnType("nvarchar(max)"); + + b.Property("TaxAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("TotalAmount") + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.ToTable("ZugferdInvoices"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DXApp.TemplateKitProject/Migrations/20260522084606_InitialCreate.cs b/DXApp.TemplateKitProject/Migrations/20260522084606_InitialCreate.cs new file mode 100644 index 0000000..93dca7a --- /dev/null +++ b/DXApp.TemplateKitProject/Migrations/20260522084606_InitialCreate.cs @@ -0,0 +1,46 @@ +ο»Ώusing System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DXApp.TemplateKitProject.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ZugferdInvoices", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + InvoiceNumber = table.Column(type: "nvarchar(max)", nullable: true), + InvoiceDate = table.Column(type: "datetime2", nullable: false), + SellerName = table.Column(type: "nvarchar(max)", nullable: true), + SellerTaxId = table.Column(type: "nvarchar(max)", nullable: true), + BuyerName = table.Column(type: "nvarchar(max)", nullable: true), + TotalAmount = table.Column(type: "decimal(18,2)", nullable: false), + TaxAmount = table.Column(type: "decimal(18,2)", nullable: false), + CurrencyCode = table.Column(type: "nvarchar(max)", nullable: true), + Iban = table.Column(type: "nvarchar(max)", nullable: true), + RawXml = table.Column(type: "nvarchar(max)", nullable: true), + ImportedAt = table.Column(type: "datetime2", nullable: false), + SourceType = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ZugferdInvoices", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ZugferdInvoices"); + } + } +} diff --git a/DXApp.TemplateKitProject/Migrations/AppDbContextModelSnapshot.cs b/DXApp.TemplateKitProject/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..ddaeced --- /dev/null +++ b/DXApp.TemplateKitProject/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,76 @@ +ο»Ώ// +using System; +using DXApp.TemplateKitProject.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace DXApp.TemplateKitProject.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("DXApp.TemplateKitProject.Models.ZugferdInvoice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BuyerName") + .HasColumnType("nvarchar(max)"); + + b.Property("CurrencyCode") + .HasColumnType("nvarchar(max)"); + + b.Property("Iban") + .HasColumnType("nvarchar(max)"); + + b.Property("ImportedAt") + .HasColumnType("datetime2"); + + b.Property("InvoiceDate") + .HasColumnType("datetime2"); + + b.Property("InvoiceNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("RawXml") + .HasColumnType("nvarchar(max)"); + + b.Property("SellerName") + .HasColumnType("nvarchar(max)"); + + b.Property("SellerTaxId") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceType") + .HasColumnType("nvarchar(max)"); + + b.Property("TaxAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("TotalAmount") + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.ToTable("ZugferdInvoices"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DXApp.TemplateKitProject/Models/ZugferdInvoice.cs b/DXApp.TemplateKitProject/Models/ZugferdInvoice.cs index fcb229b..3fa808f 100644 --- a/DXApp.TemplateKitProject/Models/ZugferdInvoice.cs +++ b/DXApp.TemplateKitProject/Models/ZugferdInvoice.cs @@ -3,17 +3,17 @@ public class ZugferdInvoice { public int Id { get; set; } - public string InvoiceNumber { get; set; } + public string InvoiceNumber { get; set; } = string.Empty; public DateTime InvoiceDate { get; set; } - public string SellerName { get; set; } - public string SellerTaxId { get; set; } - public string BuyerName { get; set; } + public string SellerName { get; set; } = string.Empty; + public string SellerTaxId { get; set; } = string.Empty; + public string BuyerName { get; set; } = string.Empty; public decimal TotalAmount { get; set; } public decimal TaxAmount { get; set; } - public string CurrencyCode { get; set; } - public string Iban { get; set; } - public string RawXml { get; set; } // Original-XML zur Sicherheit + public string CurrencyCode { get; set; } = string.Empty; + public string Iban { get; set; } = string.Empty; + public string RawXml { get; set; } = string.Empty; // Original-XML zur Sicherheit public DateTime ImportedAt { get; set; } - public string SourceType { get; set; } // "Upload" oder "Email" + public string SourceType { get; set; } = string.Empty; // "Upload" oder "Email" } } \ No newline at end of file diff --git a/DXApp.TemplateKitProject/Pages/Invoices/Upload.cshtml b/DXApp.TemplateKitProject/Pages/Invoices/Upload.cshtml index 53f439a..72c6e69 100644 --- a/DXApp.TemplateKitProject/Pages/Invoices/Upload.cshtml +++ b/DXApp.TemplateKitProject/Pages/Invoices/Upload.cshtml @@ -64,4 +64,22 @@ } + @if (Model.ImportedInvoice is not null) + { +
+

πŸ“„ Geparste Rechnungsdaten

+ + + + + + + + + + + +
Rechnungsnummer@Model.ImportedInvoice.InvoiceNumber
Rechnungsdatum@Model.ImportedInvoice.InvoiceDate.ToString("dd.MM.yyyy")
VerkΓ€ufer@Model.ImportedInvoice.SellerName
USt-ID VerkΓ€ufer@Model.ImportedInvoice.SellerTaxId
KΓ€ufer@Model.ImportedInvoice.BuyerName
WΓ€hrung@Model.ImportedInvoice.CurrencyCode
Steuerbetrag@Model.ImportedInvoice.TaxAmount.ToString("N2")
Gesamtbetrag@Model.ImportedInvoice.TotalAmount.ToString("N2")
IBAN@Model.ImportedInvoice.Iban
Importiert am@Model.ImportedInvoice.ImportedAt.ToString("dd.MM.yyyy HH:mm")
+
βœ” Rechnung wurde in der Datenbank gespeichert (ID: @Model.ImportedInvoice.Id)
+ } } \ No newline at end of file diff --git a/DXApp.TemplateKitProject/Pages/Invoices/Upload.cshtml.cs b/DXApp.TemplateKitProject/Pages/Invoices/Upload.cshtml.cs index c70fe94..3e5cf8b 100644 --- a/DXApp.TemplateKitProject/Pages/Invoices/Upload.cshtml.cs +++ b/DXApp.TemplateKitProject/Pages/Invoices/Upload.cshtml.cs @@ -1,4 +1,4 @@ -using DXApp.TemplateKitProject.Models; +ο»Ώusing DXApp.TemplateKitProject.Models; using DXApp.TemplateKitProject.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; @@ -7,6 +7,7 @@ namespace DXApp.TemplateKitProject.Pages.Invoices; public class UploadModel( PdfAttachmentExtractorService extractor, + ZugferdImportService zugferdImportService, ILogger logger) : PageModel { [BindProperty] @@ -15,6 +16,7 @@ public class UploadModel( public PdfExtractionResult? Result { get; private set; } public bool ExtractionDone { get; private set; } public string? ErrorMessage { get; private set; } + public ZugferdInvoice? ImportedInvoice { get; private set; } public void OnGet() { } @@ -23,7 +25,7 @@ public class UploadModel( { if (PdfFile is null || PdfFile.Length == 0) { - ModelState.AddModelError(nameof(PdfFile), "Bitte eine PDF-Datei auswδhlen."); + ModelState.AddModelError(nameof(PdfFile), "Bitte eine PDF-Datei auswΓ€hlen."); return Page(); } @@ -37,8 +39,20 @@ public class UploadModel( try { - await using var stream = PdfFile.OpenReadStream(); - Result = extractor.ExtractAttachments(stream, PdfFile.FileName); + // Stream in MemoryStream puffern β†’ kann zweimal gelesen werden + using var memStream = new MemoryStream(); + await PdfFile.CopyToAsync(memStream); + + // 1. AnhΓ€nge extrahieren und auf Disk speichern + memStream.Position = 0; + Result = extractor.ExtractAttachments(memStream, PdfFile.FileName); + + // 2. Wenn ZUGFeRD-XML gefunden β†’ parsen und in DB speichern + if (Result.HasZugferdXml) + { + memStream.Position = 0; + ImportedInvoice = await zugferdImportService.ImportAsync(memStream, "Upload"); + } } catch (Exception ex) { diff --git a/DXApp.TemplateKitProject/Services/ZugferdParserService.cs b/DXApp.TemplateKitProject/Services/ZugferdParserService.cs index fd01b6c..41252b3 100644 --- a/DXApp.TemplateKitProject/Services/ZugferdParserService.cs +++ b/DXApp.TemplateKitProject/Services/ZugferdParserService.cs @@ -5,20 +5,77 @@ using System.Xml.Linq; public class ZugferdParserService { - // ZUGFeRD v2 / Factur-X Namespaces - private static readonly XNamespace Ram = "urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100"; + // ZUGFeRD v1 Namespace + private static readonly XNamespace RsmV1 = "urn:un:unece:uncefact:data:standard:CBFBUY:5"; - private static readonly XNamespace Rsm = "urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"; + // ZUGFeRD v2 / Factur-X Namespaces + private static readonly XNamespace RsmV2 = "urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"; + + private static readonly XNamespace Ram = "urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100"; private static readonly XNamespace Udt = "urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100"; + // ZUGFeRD v1.0 FeRD (CrossIndustryDocument) + private static readonly XNamespace RsmFerd1 = "urn:ferd:CrossIndustryDocument:invoice:1p0"; + + private static readonly XNamespace RamV12 = "urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:12"; + private static readonly XNamespace UdtV15 = "urn:un:unece:uncefact:data:standard:UnqualifiedDataType:15"; + + // NACHHER: public ZugferdInvoice Parse(string xml) { var doc = XDocument.Parse(xml); var root = doc.Root!; - var header = root.Element(Rsm + "ExchangedDocument"); - var trade = root.Element(Rsm + "SupplyChainTradeTransaction"); + if (root.Name.Namespace == RsmV1) return ParseV1(root, xml); + if (root.Name.Namespace == RsmFerd1) return ParseV1Ferd(root, xml); + return ParseV2(root, xml); + } + // ── ZUGFeRD v1 (CBFBUY:5) ──────────────────────────────────────────────── + private static ZugferdInvoice ParseV1(XElement root, string xml) + { + // In v1 haben Kind-Elemente KEINEN Namespace-PrΓ€fix + var ns = XNamespace.None; + var rsm = RsmV1; + + var header = root.Element(rsm + "HeaderExchangedDocument"); + var trade = root.Element(rsm + "SpecifiedSupplyChainTradeTransaction"); + var agreement = trade?.Element(ns + "ApplicableSupplyChainTradeAgreement"); + var settlement = trade?.Element(ns + "ApplicableSupplyChainTradeSettlement"); + + var seller = agreement?.Element(ns + "SellerTradeParty"); + var buyer = agreement?.Element(ns + "BuyerTradeParty"); + var summation = settlement?.Element(ns + "SpecifiedTradeSettlementMonetarySummation"); + var payment = settlement?.Element(ns + "SpecifiedTradeSettlementPaymentMeans"); + + // SellerTaxId: das SpecifiedTaxRegistration mit schemeID="VA" nehmen + var sellerTaxId = seller? + .Elements(ns + "SpecifiedTaxRegistration") + .FirstOrDefault(e => (string?)e.Element(ns + "ID")?.Attribute("schemeID") == "VA") + ?.Element(ns + "ID")?.Value ?? string.Empty; + + return new ZugferdInvoice + { + InvoiceNumber = header?.Element(ns + "ID")?.Value ?? string.Empty, + InvoiceDate = ParseDate(header?.Element(ns + "IssueDateTime")?.Value), + SellerName = seller?.Element(ns + "Name")?.Value ?? string.Empty, + SellerTaxId = sellerTaxId, + BuyerName = buyer?.Element(ns + "Name")?.Value ?? string.Empty, + CurrencyCode = settlement?.Element(ns + "InvoiceCurrencyCode")?.Value ?? "EUR", + TotalAmount = ParseDecimal(summation?.Element(ns + "GrandTotalAmount")?.Value), + TaxAmount = ParseDecimal(summation?.Element(ns + "TaxTotalAmount")?.Value), + Iban = payment?.Element(ns + "PayeePartyCreditorFinancialAccount") + ?.Element(ns + "IBANID")?.Value ?? string.Empty, + RawXml = xml, + ImportedAt = DateTime.UtcNow + }; + } + + // ── ZUGFeRD v2 / Factur-X ──────────────────────────────────────────────── + private static ZugferdInvoice ParseV2(XElement root, string xml) + { + var header = root.Element(RsmV2 + "ExchangedDocument"); + var trade = root.Element(RsmV2 + "SupplyChainTradeTransaction"); var agreement = trade?.Element(Ram + "ApplicableHeaderTradeAgreement"); var settlement = trade?.Element(Ram + "ApplicableHeaderTradeSettlement"); @@ -27,32 +84,73 @@ public class ZugferdParserService InvoiceNumber = header?.Element(Ram + "ID")?.Value ?? string.Empty, InvoiceDate = ParseDate(header?.Element(Ram + "IssueDateTime") ?.Element(Udt + "DateTimeString")?.Value), - SellerName = agreement?.Element(Ram + "SellerTradeParty") - ?.Element(Ram + "Name")?.Value ?? string.Empty, + ?.Element(Ram + "Name")?.Value ?? string.Empty, SellerTaxId = agreement?.Element(Ram + "SellerTradeParty") - ?.Element(Ram + "SpecifiedTaxRegistration") - ?.Element(Ram + "ID")?.Value ?? string.Empty, + ?.Element(Ram + "SpecifiedTaxRegistration") + ?.Element(Ram + "ID")?.Value ?? string.Empty, BuyerName = agreement?.Element(Ram + "BuyerTradeParty") - ?.Element(Ram + "Name")?.Value ?? string.Empty, - + ?.Element(Ram + "Name")?.Value ?? string.Empty, CurrencyCode = settlement?.Element(Ram + "InvoiceCurrencyCode")?.Value ?? "EUR", - TotalAmount = ParseDecimal(settlement?.Element(Ram + "SpecifiedTradeSettlementHeaderMonetarySummation") - ?.Element(Ram + "GrandTotalAmount")?.Value), - TaxAmount = ParseDecimal(settlement?.Element(Ram + "SpecifiedTradeSettlementHeaderMonetarySummation") - ?.Element(Ram + "TaxTotalAmount")?.Value), + TotalAmount = ParseDecimal(settlement + ?.Element(Ram + "SpecifiedTradeSettlementHeaderMonetarySummation") + ?.Element(Ram + "GrandTotalAmount")?.Value), + TaxAmount = ParseDecimal(settlement + ?.Element(Ram + "SpecifiedTradeSettlementHeaderMonetarySummation") + ?.Element(Ram + "TaxTotalAmount")?.Value), Iban = settlement?.Element(Ram + "SpecifiedTradeSettlementPaymentMeans") - ?.Element(Ram + "PayeePartyCreditorFinancialAccount") - ?.Element(Ram + "IBANID")?.Value ?? string.Empty, + ?.Element(Ram + "PayeePartyCreditorFinancialAccount") + ?.Element(Ram + "IBANID")?.Value ?? string.Empty, RawXml = xml, ImportedAt = DateTime.UtcNow }; } + // ── ZUGFeRD v1.0 FeRD (CrossIndustryDocument:invoice:1p0) ─────────────────── + private static ZugferdInvoice ParseV1Ferd(XElement root, string xml) + { + var rsm = RsmFerd1; + var ram = RamV12; + var udt = UdtV15; + + var header = root.Element(rsm + "HeaderExchangedDocument"); + var trade = root.Element(rsm + "SpecifiedSupplyChainTradeTransaction"); + var agreement = trade?.Element(ram + "ApplicableSupplyChainTradeAgreement"); + var settlement = trade?.Element(ram + "ApplicableSupplyChainTradeSettlement"); + + var seller = agreement?.Element(ram + "SellerTradeParty"); + var buyer = agreement?.Element(ram + "BuyerTradeParty"); + var summation = settlement?.Element(ram + "SpecifiedTradeSettlementMonetarySummation"); + var payment = settlement?.Element(ram + "SpecifiedTradeSettlementPaymentMeans"); + + var sellerTaxId = seller? + .Elements(ram + "SpecifiedTaxRegistration") + .FirstOrDefault(e => (string?)e.Element(ram + "ID")?.Attribute("schemeID") == "VA") + ?.Element(ram + "ID")?.Value ?? string.Empty; + + return new ZugferdInvoice + { + InvoiceNumber = header?.Element(ram + "ID")?.Value ?? string.Empty, + InvoiceDate = ParseDate(header?.Element(ram + "IssueDateTime") + ?.Element(udt + "DateTimeString")?.Value), + SellerName = seller?.Element(ram + "Name")?.Value ?? string.Empty, + SellerTaxId = sellerTaxId, + BuyerName = buyer?.Element(ram + "Name")?.Value ?? string.Empty, + CurrencyCode = settlement?.Element(ram + "InvoiceCurrencyCode")?.Value ?? "EUR", + TotalAmount = ParseDecimal(summation?.Element(ram + "GrandTotalAmount")?.Value), + TaxAmount = ParseDecimal(summation?.Element(ram + "TaxTotalAmount")?.Value), + Iban = payment?.Element(ram + "PayeePartyCreditorFinancialAccount") + ?.Element(ram + "IBANID")?.Value ?? string.Empty, + RawXml = xml, + ImportedAt = DateTime.UtcNow + }; + } + + // ── Hilfsmethoden ──────────────────────────────────────────────────────── private static DateTime ParseDate(string? value) { if (string.IsNullOrWhiteSpace(value)) return DateTime.MinValue; - return DateTime.TryParseExact(value, "yyyyMMdd", + return DateTime.TryParseExact(value.Trim(), "yyyyMMdd", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var dt) ? dt : DateTime.MinValue; }