Compare commits

..

3 Commits

Author SHA1 Message Date
OlgunR
245f7a8268 Add duplicate invoice detection and warning message
Added a warning message in `Upload.cshtml` to notify users when a duplicate invoice is detected. Introduced the `IsDuplicate` property in `UploadModel` to track duplicates and updated the `OnPostAsync` method to set this property based on the `ImportedAt` timestamp.

Enhanced the `ImportAsync` method in `ZugferdImportService` to include duplicate detection by checking the database for invoices with the same `InvoiceNumber` and `SellerTaxId`. If a duplicate is found, it logs a warning and returns the existing invoice.

Updated `ImportAsync` to accept an optional `guidelineId` parameter and added logging for duplicate detection and successful imports.
2026-05-28 08:35:01 +02:00
OlgunR
6582370c08 Add GuidelineId property to ZugferdInvoice model
Added a new `GuidelineId` property to the `ZugferdInvoice` model to store the ZUGFeRD Guideline ID extracted from XMP metadata. Updated the `AppDbContextModelSnapshot.cs` to reflect this change and created a migration (`20260527133241_AddGuidelineId`) to add the `GuidelineId` column to the `ZugferdInvoices` table.

Generated the corresponding designer file for the migration to define the updated database schema. Minor formatting changes were made to `ZugferdInvoice.cs` without functional impact.
2026-05-27 16:01:32 +02:00
OlgunR
87f27682ce Add support for generating result PDFs
Introduced the `ResultFilePath` property in the `ZugferdInvoice` model to store the path of generated result PDFs. Added a new service, `PdfResultPackageService`, to create result PDFs by converting the original PDF to PDF/A-3b format and attaching a report file. Updated `Upload.cshtml` and `Upload.cshtml.cs` to handle and display the `ResultFilePath`.

Created a migration to add the `ResultFilePath` column to the database. Updated `Program.cs` to register the new service and added configuration sections in `appsettings.json` for input and output directories. Enhanced error handling and logging for better traceability.
2026-05-27 13:43:14 +02:00
12 changed files with 396 additions and 6 deletions

View File

@@ -0,0 +1,82 @@
// <auto-generated />
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("20260527094043_AddResultFilePath")]
partial class AddResultFilePath
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("BuyerName")
.HasColumnType("nvarchar(max)");
b.Property<string>("CurrencyCode")
.HasColumnType("nvarchar(max)");
b.Property<string>("Iban")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("ImportedAt")
.HasColumnType("datetime2");
b.Property<DateTime>("InvoiceDate")
.HasColumnType("datetime2");
b.Property<string>("InvoiceNumber")
.HasColumnType("nvarchar(max)");
b.Property<string>("RawXml")
.HasColumnType("nvarchar(max)");
b.Property<string>("ResultFilePath")
.HasColumnType("nvarchar(max)");
b.Property<string>("SellerName")
.HasColumnType("nvarchar(max)");
b.Property<string>("SellerTaxId")
.HasColumnType("nvarchar(max)");
b.Property<string>("SourceType")
.HasColumnType("nvarchar(max)");
b.Property<decimal>("TaxAmount")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("TotalAmount")
.HasColumnType("decimal(18,2)");
b.HasKey("Id");
b.ToTable("ZugferdInvoices");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DXApp.TemplateKitProject.Migrations
{
/// <inheritdoc />
public partial class AddResultFilePath : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ResultFilePath",
table: "ZugferdInvoices",
type: "nvarchar(max)",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ResultFilePath",
table: "ZugferdInvoices");
}
}
}

View File

@@ -0,0 +1,85 @@
// <auto-generated />
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("20260527133241_AddGuidelineId")]
partial class AddGuidelineId
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("BuyerName")
.HasColumnType("nvarchar(max)");
b.Property<string>("CurrencyCode")
.HasColumnType("nvarchar(max)");
b.Property<string>("GuidelineId")
.HasColumnType("nvarchar(max)");
b.Property<string>("Iban")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("ImportedAt")
.HasColumnType("datetime2");
b.Property<DateTime>("InvoiceDate")
.HasColumnType("datetime2");
b.Property<string>("InvoiceNumber")
.HasColumnType("nvarchar(max)");
b.Property<string>("RawXml")
.HasColumnType("nvarchar(max)");
b.Property<string>("ResultFilePath")
.HasColumnType("nvarchar(max)");
b.Property<string>("SellerName")
.HasColumnType("nvarchar(max)");
b.Property<string>("SellerTaxId")
.HasColumnType("nvarchar(max)");
b.Property<string>("SourceType")
.HasColumnType("nvarchar(max)");
b.Property<decimal>("TaxAmount")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("TotalAmount")
.HasColumnType("decimal(18,2)");
b.HasKey("Id");
b.ToTable("ZugferdInvoices");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DXApp.TemplateKitProject.Migrations
{
/// <inheritdoc />
public partial class AddGuidelineId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "GuidelineId",
table: "ZugferdInvoices",
type: "nvarchar(max)",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "GuidelineId",
table: "ZugferdInvoices");
}
}
}

View File

@@ -36,6 +36,9 @@ namespace DXApp.TemplateKitProject.Migrations
b.Property<string>("CurrencyCode")
.HasColumnType("nvarchar(max)");
b.Property<string>("GuidelineId")
.HasColumnType("nvarchar(max)");
b.Property<string>("Iban")
.HasColumnType("nvarchar(max)");
@@ -51,6 +54,9 @@ namespace DXApp.TemplateKitProject.Migrations
b.Property<string>("RawXml")
.HasColumnType("nvarchar(max)");
b.Property<string>("ResultFilePath")
.HasColumnType("nvarchar(max)");
b.Property<string>("SellerName")
.HasColumnType("nvarchar(max)");

View File

@@ -15,5 +15,7 @@
public string RawXml { get; set; } = string.Empty; // Original-XML zur Sicherheit
public DateTime ImportedAt { get; set; }
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
}
}

View File

@@ -40,6 +40,13 @@
{
<strong>⚠ Kein ZUGFeRD-XML gefunden.</strong>
}
@if (!string.IsNullOrEmpty(Model.ResultFilePath))
{
<div class="alert alert-success mt-2">
📦 <strong>Result-PDF erstellt:</strong>
<small class="text-muted">@Model.ResultFilePath</small>
</div>
}
</div>
@* PDF/A-Konformitätsstufe anzeigen *@
@@ -97,5 +104,12 @@
<tr><th>Importiert am</th><td>@Model.ImportedInvoice.ImportedAt.ToString("dd.MM.yyyy HH:mm")</td></tr>
</table>
<div class="alert alert-success mt-2">✔ Rechnung wurde in der Datenbank gespeichert (ID: @Model.ImportedInvoice.Id)</div>
@if (Model.IsDuplicate)
{
<div class="alert alert-warning mt-2">
⚠️ <strong>Duplikat:</strong> Diese Rechnung wurde bereits importiert (ID: @Model.ImportedInvoice!.Id).
Es wurde kein neuer Eintrag angelegt.
</div>
}
}
}

View File

@@ -1,4 +1,5 @@
using DXApp.TemplateKitProject.Models;
using DXApp.TemplateKitProject.Data;
using DXApp.TemplateKitProject.Models;
using DXApp.TemplateKitProject.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
@@ -8,6 +9,8 @@ namespace DXApp.TemplateKitProject.Pages.Invoices;
public class UploadModel(
PdfAttachmentExtractorService extractor,
ZugferdImportService zugferdImportService,
PdfResultPackageService resultPackageService,
AppDbContext db,
ILogger<UploadModel> logger) : PageModel
{
[BindProperty]
@@ -17,6 +20,8 @@ public class UploadModel(
public bool ExtractionDone { get; private set; }
public string? ErrorMessage { get; private set; }
public ZugferdInvoice? ImportedInvoice { get; private set; }
public string? ResultFilePath { get; private set; }
public bool IsDuplicate { get; private set; }
public void OnGet()
{ }
@@ -42,8 +47,9 @@ public class UploadModel(
// Stream in MemoryStream puffern → kann zweimal gelesen werden
using var memStream = new MemoryStream();
await PdfFile.CopyToAsync(memStream);
var originalBytes = memStream.ToArray(); // ← neu: als byte[] merken
// 1. Anhänge extrahieren und auf Disk speichern
// 1. Anhänge extrahieren
memStream.Position = 0;
Result = extractor.ExtractAttachments(memStream, PdfFile.FileName);
@@ -51,7 +57,25 @@ public class UploadModel(
if (Result.HasZugferdXml)
{
memStream.Position = 0;
ImportedInvoice = await zugferdImportService.ImportAsync(memStream, "Upload");
ImportedInvoice = await zugferdImportService.ImportAsync(memStream, "Upload", Result.ZugferdGuidelineId);
// Duplikat erkennen: vorhandener Eintrag hat ImportedAt von früher
if (ImportedInvoice is not null && ImportedInvoice.ImportedAt < DateTime.UtcNow.AddSeconds(-5))
IsDuplicate = true;
// 3. Result-Package erstellen (nur wenn Import erfolgreich UND kein Duplikat)
if (ImportedInvoice is not null && !IsDuplicate)
{
ResultFilePath = await resultPackageService.CreateResultPackageAsync(
originalBytes, PdfFile.FileName, ImportedInvoice);
// ResultFilePath in DB aktualisieren
if (ResultFilePath is not null)
{
ImportedInvoice.ResultFilePath = ResultFilePath;
await db.SaveChangesAsync();
}
}
}
}
catch (Exception ex)

View File

@@ -14,6 +14,7 @@ builder.Services.AddDbContext<AppDbContext>(options =>
// Services
builder.Services.AddScoped<PdfAttachmentExtractorService>();
builder.Services.AddScoped<PdfResultPackageService>();
builder.Services.AddScoped<ZugferdExtractorService>();
builder.Services.AddScoped<ZugferdParserService>();
builder.Services.AddScoped<ZugferdImportService>();

View File

@@ -0,0 +1,93 @@
using DevExpress.Pdf;
using DXApp.TemplateKitProject.Models;
namespace DXApp.TemplateKitProject.Services;
public class PdfResultPackageService(
IConfiguration configuration,
ILogger<PdfResultPackageService> logger)
{
public async Task<string?> CreateResultPackageAsync(
byte[] originalPdfBytes,
string originalFileName,
ZugferdInvoice invoice)
{
// 1. Bericht-PDF suchen
var reportPath = FindReportFile(originalFileName);
if (reportPath is null)
{
logger.LogWarning(
"Kein Ergebnisbericht gefunden für '{FileName}'.", originalFileName);
return null;
}
logger.LogInformation(
"Ergebnisbericht gefunden: '{ReportPath}'.", reportPath);
// 2. Ausgabepfad bestimmen
var outputDir = configuration["PdfResults:OutputDirectory"]
?? Path.Combine(Path.GetTempPath(), "PdfResults");
Directory.CreateDirectory(outputDir);
var baseName = Path.GetFileNameWithoutExtension(originalFileName);
var outputPath = Path.Combine(outputDir, $"{baseName}_result.pdf");
// 3. Original auf PDF/A-3b hochstufen + Bericht anhängen
await Task.Run(() =>
{
// Original in MemoryStream laden
using var inputStream = new MemoryStream(originalPdfBytes);
using var outputStream = new MemoryStream();
// PDF/A-3b Konvertierung
var converter = new PdfDocumentConverter(inputStream);
converter.Convert(PdfCompatibility.PdfA3b);
// Konvertiertes PDF in MemoryStream speichern
using var convertedStream = new MemoryStream();
converter.SaveDocument(convertedStream);
convertedStream.Position = 0;
// Bericht als Anhang einbetten
using var processor = new PdfDocumentProcessor();
processor.LoadDocument(convertedStream);
processor.AttachFile(new PdfFileAttachment
{
FileName = Path.GetFileName(reportPath),
Description = "Ergebnisbericht",
MimeType = "application/pdf",
Relationship = PdfAssociatedFileRelationship.Supplement,
CreationDate = DateTime.Now,
Data = File.ReadAllBytes(reportPath)
});
// Speichern
processor.SaveDocument(outputPath);
});
logger.LogInformation(
"Result-PDF gespeichert: '{OutputPath}'.", outputPath);
return outputPath;
}
private string? FindReportFile(string originalFileName)
{
var inputDir = configuration["PdfResultReports:InputDirectory"]
?? Path.Combine(Path.GetTempPath(), "PdfResultReports");
if (!Directory.Exists(inputDir))
{
logger.LogWarning("Berichtsverzeichnis nicht gefunden: '{Dir}'.", inputDir);
return null;
}
// Konvention Option A: {originalname}_report.pdf
var baseName = Path.GetFileNameWithoutExtension(originalFileName);
var reportName = $"{baseName}_report.pdf";
var reportPath = Path.Combine(inputDir, reportName);
return File.Exists(reportPath) ? reportPath : null;
}
}

View File

@@ -1,5 +1,6 @@
using DXApp.TemplateKitProject.Data;
using DXApp.TemplateKitProject.Models;
using Microsoft.EntityFrameworkCore;
namespace DXApp.TemplateKitProject.Services;
@@ -9,7 +10,7 @@ public class ZugferdImportService(
AppDbContext db,
ILogger<ZugferdImportService> logger)
{
public async Task<ZugferdInvoice?> ImportAsync(Stream pdfStream, string sourceType)
public async Task<ZugferdInvoice?> ImportAsync(Stream pdfStream, string sourceType, string guidelineId = "")
{
var xml = extractor.ExtractXml(pdfStream);
@@ -20,11 +21,31 @@ public class ZugferdImportService(
}
var invoice = parser.Parse(xml);
// Duplikatprüfung
var duplicate = await db.ZugferdInvoices.FirstOrDefaultAsync(i =>
i.InvoiceNumber == invoice.InvoiceNumber &&
i.SellerTaxId == invoice.SellerTaxId);
if (duplicate is not null)
{
logger.LogWarning(
"Duplikat erkannt: Rechnung '{Number}' von '{Seller}' existiert bereits (ID: {Id}).",
invoice.InvoiceNumber, invoice.SellerName, duplicate.Id);
return duplicate;
}
invoice.SourceType = sourceType;
invoice.GuidelineId = guidelineId;
invoice.ImportedAt = DateTime.UtcNow;
db.ZugferdInvoices.Add(invoice);
await db.SaveChangesAsync();
logger.LogInformation(
"Rechnung '{Number}' von '{Seller}' importiert (ID: {Id}).",
invoice.InvoiceNumber, invoice.SellerName, invoice.Id);
return invoice;
}
}

View File

@@ -11,5 +11,11 @@
},
"PdfExtraction": {
"OutputDirectory": "C:\\PdfExtractions"
},
"PdfResultReports": {
"InputDirectory": "C:\\PdfResultReports"
},
"PdfResults": {
"OutputDirectory": "C:\\PdfResults"
}
}