diff --git a/DXApp.TemplateKitProject/Data/AppDbContext.cs b/DXApp.TemplateKitProject/Data/AppDbContext.cs index 9aab324..3cbf5ac 100644 --- a/DXApp.TemplateKitProject/Data/AppDbContext.cs +++ b/DXApp.TemplateKitProject/Data/AppDbContext.cs @@ -6,6 +6,7 @@ namespace DXApp.TemplateKitProject.Data; public class AppDbContext(DbContextOptions options) : DbContext(options) { public DbSet ZugferdInvoices { get; set; } + public DbSet InvoiceAttachments { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -27,6 +28,19 @@ public class AppDbContext(DbContextOptions options) : DbContext(op entity.Property(e => e.TaxAmount) .HasColumnType("decimal(18,2)"); + + // Relationship: One-to-Many mit InvoiceAttachments + entity.HasMany(e => e.Attachments) + .WithOne(a => a.ZugferdInvoice) + .HasForeignKey(a => a.ZugferdInvoiceId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + // Index für schnelleres Laden der Attachments einer Rechnung + entity.HasIndex(e => e.ZugferdInvoiceId) + .HasDatabaseName("IX_InvoiceAttachments_ZugferdInvoiceId"); }); } } \ No newline at end of file diff --git a/DXApp.TemplateKitProject/Migrations/20260602123528_AddInvoiceAttachments.Designer.cs b/DXApp.TemplateKitProject/Migrations/20260602123528_AddInvoiceAttachments.Designer.cs new file mode 100644 index 0000000..76e6cc0 --- /dev/null +++ b/DXApp.TemplateKitProject/Migrations/20260602123528_AddInvoiceAttachments.Designer.cs @@ -0,0 +1,141 @@ +// +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("20260602123528_AddInvoiceAttachments")] + partial class AddInvoiceAttachments + { + /// + 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.InvoiceAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExtractedAt") + .HasColumnType("datetime2"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("IsZugferdXml") + .HasColumnType("bit"); + + b.Property("OriginalFileName") + .HasColumnType("nvarchar(max)"); + + b.Property("SavedFilePath") + .HasColumnType("nvarchar(max)"); + + b.Property("ZugferdInvoiceId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ZugferdInvoiceId") + .HasDatabaseName("IX_InvoiceAttachments_ZugferdInvoiceId"); + + b.ToTable("InvoiceAttachments"); + }); + + 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("GuidelineId") + .HasColumnType("nvarchar(max)"); + + b.Property("Iban") + .HasColumnType("nvarchar(max)"); + + b.Property("ImportedAt") + .HasColumnType("datetime2"); + + b.Property("InvoiceDate") + .HasColumnType("datetime2"); + + b.Property("InvoiceNumber") + .HasColumnType("nvarchar(450)"); + + b.Property("RawXml") + .HasColumnType("nvarchar(max)"); + + b.Property("ResultFilePath") + .HasColumnType("nvarchar(max)"); + + b.Property("SellerName") + .HasColumnType("nvarchar(max)"); + + b.Property("SellerTaxId") + .HasColumnType("nvarchar(450)"); + + b.Property("SourceType") + .HasColumnType("nvarchar(max)"); + + b.Property("TaxAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("TotalAmount") + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("ImportedAt") + .HasDatabaseName("IX_ZugferdInvoices_ImportedAt"); + + b.HasIndex("InvoiceNumber", "SellerTaxId") + .HasDatabaseName("IX_ZugferdInvoices_InvoiceNumber_SellerTaxId"); + + b.ToTable("ZugferdInvoices"); + }); + + modelBuilder.Entity("DXApp.TemplateKitProject.Models.InvoiceAttachment", b => + { + b.HasOne("DXApp.TemplateKitProject.Models.ZugferdInvoice", "ZugferdInvoice") + .WithMany("Attachments") + .HasForeignKey("ZugferdInvoiceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ZugferdInvoice"); + }); + + modelBuilder.Entity("DXApp.TemplateKitProject.Models.ZugferdInvoice", b => + { + b.Navigation("Attachments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DXApp.TemplateKitProject/Migrations/20260602123528_AddInvoiceAttachments.cs b/DXApp.TemplateKitProject/Migrations/20260602123528_AddInvoiceAttachments.cs new file mode 100644 index 0000000..0613010 --- /dev/null +++ b/DXApp.TemplateKitProject/Migrations/20260602123528_AddInvoiceAttachments.cs @@ -0,0 +1,51 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DXApp.TemplateKitProject.Migrations +{ + /// + public partial class AddInvoiceAttachments : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "InvoiceAttachments", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + ZugferdInvoiceId = table.Column(type: "int", nullable: false), + OriginalFileName = table.Column(type: "nvarchar(max)", nullable: true), + SavedFilePath = table.Column(type: "nvarchar(max)", nullable: true), + FileSizeBytes = table.Column(type: "bigint", nullable: false), + IsZugferdXml = table.Column(type: "bit", nullable: false), + ExtractedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_InvoiceAttachments", x => x.Id); + table.ForeignKey( + name: "FK_InvoiceAttachments_ZugferdInvoices_ZugferdInvoiceId", + column: x => x.ZugferdInvoiceId, + principalTable: "ZugferdInvoices", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_InvoiceAttachments_ZugferdInvoiceId", + table: "InvoiceAttachments", + column: "ZugferdInvoiceId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "InvoiceAttachments"); + } + } +} diff --git a/DXApp.TemplateKitProject/Migrations/AppDbContextModelSnapshot.cs b/DXApp.TemplateKitProject/Migrations/AppDbContextModelSnapshot.cs index c8cfaee..68dbcb4 100644 --- a/DXApp.TemplateKitProject/Migrations/AppDbContextModelSnapshot.cs +++ b/DXApp.TemplateKitProject/Migrations/AppDbContextModelSnapshot.cs @@ -22,6 +22,40 @@ namespace DXApp.TemplateKitProject.Migrations SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + modelBuilder.Entity("DXApp.TemplateKitProject.Models.InvoiceAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExtractedAt") + .HasColumnType("datetime2"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("IsZugferdXml") + .HasColumnType("bit"); + + b.Property("OriginalFileName") + .HasColumnType("nvarchar(max)"); + + b.Property("SavedFilePath") + .HasColumnType("nvarchar(max)"); + + b.Property("ZugferdInvoiceId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ZugferdInvoiceId") + .HasDatabaseName("IX_InvoiceAttachments_ZugferdInvoiceId"); + + b.ToTable("InvoiceAttachments"); + }); + modelBuilder.Entity("DXApp.TemplateKitProject.Models.ZugferdInvoice", b => { b.Property("Id") @@ -82,6 +116,22 @@ namespace DXApp.TemplateKitProject.Migrations b.ToTable("ZugferdInvoices"); }); + + modelBuilder.Entity("DXApp.TemplateKitProject.Models.InvoiceAttachment", b => + { + b.HasOne("DXApp.TemplateKitProject.Models.ZugferdInvoice", "ZugferdInvoice") + .WithMany("Attachments") + .HasForeignKey("ZugferdInvoiceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ZugferdInvoice"); + }); + + modelBuilder.Entity("DXApp.TemplateKitProject.Models.ZugferdInvoice", b => + { + b.Navigation("Attachments"); + }); #pragma warning restore 612, 618 } } diff --git a/DXApp.TemplateKitProject/Models/InvoiceAttachment.cs b/DXApp.TemplateKitProject/Models/InvoiceAttachment.cs new file mode 100644 index 0000000..ba93cee --- /dev/null +++ b/DXApp.TemplateKitProject/Models/InvoiceAttachment.cs @@ -0,0 +1,18 @@ +namespace DXApp.TemplateKitProject.Models; + +/// +/// Extrahierte Anhnge einer ZUGFeRD-Rechnung (gespeichert in DB) +/// +public class InvoiceAttachment +{ + public int Id { get; set; } + public int ZugferdInvoiceId { get; set; } + public string OriginalFileName { get; set; } = string.Empty; + public string SavedFilePath { get; set; } = string.Empty; + public long FileSizeBytes { get; set; } + public bool IsZugferdXml { get; set; } + public DateTime ExtractedAt { get; set; } + + // Navigation Property + public ZugferdInvoice? ZugferdInvoice { get; set; } +} diff --git a/DXApp.TemplateKitProject/Models/ZugferdInvoice.cs b/DXApp.TemplateKitProject/Models/ZugferdInvoice.cs index 969fac2..72745f4 100644 --- a/DXApp.TemplateKitProject/Models/ZugferdInvoice.cs +++ b/DXApp.TemplateKitProject/Models/ZugferdInvoice.cs @@ -17,5 +17,8 @@ public string SourceType { get; set; } = string.Empty; // "Upload" oder "Email" public string ResultFilePath { get; set; } = string.Empty; // Pfad der Result-PDF public string GuidelineId { get; set; } = string.Empty; // ZUGFeRD Guideline-ID aus XMP + + // Navigation Property + public List Attachments { get; set; } = []; } } \ No newline at end of file diff --git a/DXApp.TemplateKitProject/Pages/Invoices/Details.cshtml b/DXApp.TemplateKitProject/Pages/Invoices/Details.cshtml index 066f16e..3195e07 100644 --- a/DXApp.TemplateKitProject/Pages/Invoices/Details.cshtml +++ b/DXApp.TemplateKitProject/Pages/Invoices/Details.cshtml @@ -2,6 +2,13 @@ @model DXApp.TemplateKitProject.Pages.Invoices.DetailsModel @{ ViewData["Title"] = "Rechnungsdetails"; + + string FormatFileSize(long bytes) + { + if (bytes < 1024) return $"{bytes} Bytes"; + if (bytes < 1024 * 1024) return $"{bytes / 1024.0:N2} KB"; + return $"{bytes / (1024.0 * 1024.0):N2} MB"; + } }

📄 Rechnungsdetails

@@ -11,7 +18,7 @@ { } @@ -50,6 +57,51 @@ else + @* Anhänge-Sektion *@ + @if (Model.Invoice.Attachments.Any()) + { +

📎 Anhänge (@Model.Invoice.Attachments.Count)

+
+ @foreach (var attachment in Model.Invoice.Attachments) + { + var icon = attachment.IsZugferdXml ? "📋" : "📄"; + var extension = System.IO.Path.GetExtension(attachment.OriginalFileName).ToLowerInvariant(); + icon = extension switch + { + ".xml" => "📋", + ".pdf" => "📄", + ".jpg" or ".jpeg" or ".png" or ".gif" => "🖼️", + ".txt" => "📝", + _ => "📎" + }; + + +
+ @icon + @attachment.OriginalFileName + @if (attachment.IsZugferdXml) + { + ZUGFeRD-XML + } +
+ + Größe: @FormatFileSize(attachment.FileSizeBytes) · + Extrahiert: @attachment.ExtractedAt.ToString("dd.MM.yyyy HH:mm") + +
+ Öffnen +
+ } +
+ } + else + { +

📎 Anhänge

+
Keine Anhänge extrahiert.
+ } + @(Html.DevExtreme().Popup() .ID("pdf-viewer-popup") .Title("PDF Viewer") diff --git a/DXApp.TemplateKitProject/Pages/Invoices/Details.cshtml.cs b/DXApp.TemplateKitProject/Pages/Invoices/Details.cshtml.cs index 8a79c8f..b169b98 100644 --- a/DXApp.TemplateKitProject/Pages/Invoices/Details.cshtml.cs +++ b/DXApp.TemplateKitProject/Pages/Invoices/Details.cshtml.cs @@ -12,7 +12,9 @@ public class DetailsModel(AppDbContext db) : PageModel public async Task OnGetAsync(int id) { - Invoice = await db.ZugferdInvoices.FirstOrDefaultAsync(i => i.Id == id); + Invoice = await db.ZugferdInvoices + .Include(i => i.Attachments) + .FirstOrDefaultAsync(i => i.Id == id); if (Invoice is null) return NotFound(); diff --git a/DXApp.TemplateKitProject/Pages/Invoices/Upload.cshtml.cs b/DXApp.TemplateKitProject/Pages/Invoices/Upload.cshtml.cs index 94d1407..db226ad 100644 --- a/DXApp.TemplateKitProject/Pages/Invoices/Upload.cshtml.cs +++ b/DXApp.TemplateKitProject/Pages/Invoices/Upload.cshtml.cs @@ -75,6 +75,29 @@ public class UploadModel( ImportedInvoice.ResultFilePath = ResultFilePath; await db.SaveChangesAsync(); } + + // 4. Attachments in DB speichern + if (Result.HasAttachments) + { + foreach (var attachment in Result.Attachments) + { + var invoiceAttachment = new InvoiceAttachment + { + ZugferdInvoiceId = ImportedInvoice.Id, + OriginalFileName = attachment.OriginalFileName, + SavedFilePath = attachment.SavedFilePath, + FileSizeBytes = attachment.FileSizeBytes, + IsZugferdXml = attachment.IsZugferdXml, + ExtractedAt = DateTime.UtcNow + }; + db.InvoiceAttachments.Add(invoiceAttachment); + } + await db.SaveChangesAsync(); + + logger.LogInformation( + "Rechnung '{InvoiceNumber}': {Count} Anhang/Anhänge in DB gespeichert.", + ImportedInvoice.InvoiceNumber, Result.Attachments.Count); + } } } } diff --git a/DXApp.TemplateKitProject/Pages/Invoices/ViewAttachment.cshtml b/DXApp.TemplateKitProject/Pages/Invoices/ViewAttachment.cshtml new file mode 100644 index 0000000..2a614a5 --- /dev/null +++ b/DXApp.TemplateKitProject/Pages/Invoices/ViewAttachment.cshtml @@ -0,0 +1,88 @@ +@page +@using DXApp.TemplateKitProject.Services +@model DXApp.TemplateKitProject.Pages.Invoices.ViewAttachmentModel +@{ + ViewData["Title"] = $"Attachment: {Model.FileName}"; +} + +

?? Attachment: @Model.FileName

+ + + +@switch (Model.ViewerType) +{ + case AttachmentViewerType.Pdf: +
+ PDF-Dateien knnen Sie ber die Result-PDF-Funktion anzeigen. +
+ break; + + case AttachmentViewerType.Xml: +
+
+ XML-Inhalt (ZUGFeRD/Factur-X) +
+
+ +
+
+
+ + + + + + + break; + + case AttachmentViewerType.Text: +
+
+ Text-Inhalt +
+
+
@Model.TextContent
+
+
+ break; + + case AttachmentViewerType.Image: +
+
+ @Model.FileName +
+
+ break; + + case AttachmentViewerType.Word: +
+ Word-Dokumente knnen nicht direkt angezeigt werden. +

Bitte laden Sie die Datei herunter oder implementieren Sie eine Konvertierung zu PDF.

+
+ break; + + default: +
+ Dieser Dateityp kann nicht angezeigt werden. +

Bitte laden Sie die Datei herunter.

+
+ break; +} diff --git a/DXApp.TemplateKitProject/Pages/Invoices/ViewAttachment.cshtml.cs b/DXApp.TemplateKitProject/Pages/Invoices/ViewAttachment.cshtml.cs new file mode 100644 index 0000000..1f2142f --- /dev/null +++ b/DXApp.TemplateKitProject/Pages/Invoices/ViewAttachment.cshtml.cs @@ -0,0 +1,42 @@ +using DXApp.TemplateKitProject.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using System.Text; + +namespace DXApp.TemplateKitProject.Pages.Invoices; + +public class ViewAttachmentModel(AttachmentViewerService viewerService) : PageModel +{ + public string FileName { get; private set; } = string.Empty; + public AttachmentViewerType ViewerType { get; private set; } + public string? TextContent { get; private set; } + + public IActionResult OnGet(string filePath) + { + if (string.IsNullOrEmpty(filePath) || !System.IO.File.Exists(filePath)) + return NotFound(); + + FileName = Path.GetFileName(filePath); + ViewerType = viewerService.DetermineViewerType(FileName); + + // Fr Text/XML: Inhalt laden + if (ViewerType == AttachmentViewerType.Xml || ViewerType == AttachmentViewerType.Text) + { + TextContent = System.IO.File.ReadAllText(filePath, Encoding.UTF8); + } + + return Page(); + } + + public IActionResult OnGetDownload(string filePath) + { + if (string.IsNullOrEmpty(filePath) || !System.IO.File.Exists(filePath)) + return NotFound(); + + var fileName = Path.GetFileName(filePath); + var mimeType = viewerService.GetMimeType(fileName); + var bytes = System.IO.File.ReadAllBytes(filePath); + + return File(bytes, mimeType, fileName); + } +} diff --git a/DXApp.TemplateKitProject/Program.cs b/DXApp.TemplateKitProject/Program.cs index 581ccd3..3e4a6af 100644 --- a/DXApp.TemplateKitProject/Program.cs +++ b/DXApp.TemplateKitProject/Program.cs @@ -18,6 +18,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); var app = builder.Build(); diff --git a/DXApp.TemplateKitProject/Services/AttachmentViewerService.cs b/DXApp.TemplateKitProject/Services/AttachmentViewerService.cs new file mode 100644 index 0000000..77a3069 --- /dev/null +++ b/DXApp.TemplateKitProject/Services/AttachmentViewerService.cs @@ -0,0 +1,49 @@ +using DXApp.TemplateKitProject.Models; + +namespace DXApp.TemplateKitProject.Services; + +public class AttachmentViewerService +{ + public AttachmentViewerType DetermineViewerType(string fileName) + { + var extension = Path.GetExtension(fileName).ToLowerInvariant(); + + return extension switch + { + ".pdf" => AttachmentViewerType.Pdf, + ".xml" => AttachmentViewerType.Xml, + ".txt" => AttachmentViewerType.Text, + ".jpg" or ".jpeg" or ".png" or ".gif" => AttachmentViewerType.Image, + ".docx" or ".doc" => AttachmentViewerType.Word, + _ => AttachmentViewerType.Download + }; + } + + public string GetMimeType(string fileName) + { + var extension = Path.GetExtension(fileName).ToLowerInvariant(); + + return extension switch + { + ".pdf" => "application/pdf", + ".xml" => "application/xml", + ".txt" => "text/plain", + ".jpg" or ".jpeg" => "image/jpeg", + ".png" => "image/png", + ".gif" => "image/gif", + ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".doc" => "application/msword", + _ => "application/octet-stream" + }; + } +} + +public enum AttachmentViewerType +{ + Pdf, // PDF.js Viewer + Xml, // Syntax-highlighted XML + Text, // Plain Text + Image, // Tag + Word, // Konvertierung zu PDF (optional) + Download // Nur Download-Button +}