Compare commits
3 Commits
6a46bf4f4b
...
245f7a8268
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
245f7a8268 | ||
|
|
6582370c08 | ||
|
|
87f27682ce |
82
DXApp.TemplateKitProject/Migrations/20260527094043_AddResultFilePath.Designer.cs
generated
Normal file
82
DXApp.TemplateKitProject/Migrations/20260527094043_AddResultFilePath.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
85
DXApp.TemplateKitProject/Migrations/20260527133241_AddGuidelineId.Designer.cs
generated
Normal file
85
DXApp.TemplateKitProject/Migrations/20260527133241_AddGuidelineId.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)");
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>();
|
||||
|
||||
93
DXApp.TemplateKitProject/Services/PdfResultPackageService.cs
Normal file
93
DXApp.TemplateKitProject/Services/PdfResultPackageService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -11,5 +11,11 @@
|
||||
},
|
||||
"PdfExtraction": {
|
||||
"OutputDirectory": "C:\\PdfExtractions"
|
||||
},
|
||||
"PdfResultReports": {
|
||||
"InputDirectory": "C:\\PdfResultReports"
|
||||
},
|
||||
"PdfResults": {
|
||||
"OutputDirectory": "C:\\PdfResults"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user