diff --git a/src/ImportAssist.Cli/Program.cs b/src/ImportAssist.Cli/Program.cs index 4672e24..22a439d 100644 --- a/src/ImportAssist.Cli/Program.cs +++ b/src/ImportAssist.Cli/Program.cs @@ -1,5 +1,6 @@ using ImportAssist.Core; +// Einstiegspunkt für die lokale Prüfung: args[0] enthält den Pfad zur CSV-Datei. if (args.Length != 1) { Console.Error.WriteLine( @@ -25,11 +26,13 @@ try } catch (FormatException exception) { + // Ein fehlerhafter Dateiaufbau verhindert die Prüfung aller enthaltenen Rechnungen. Console.Error.WriteLine(exception.Message); Environment.ExitCode = 1; return; } +// Fachliche Fehler werden je Rechnung ausgegeben; sie ändern den Prozess-Exitcode nicht. foreach (var row in rows) { var errors = InvoiceValidator.Validate(row); diff --git a/src/ImportAssist.Core/ImportReport.cs b/src/ImportAssist.Core/ImportReport.cs index a57fed2..3bfb512 100644 --- a/src/ImportAssist.Core/ImportReport.cs +++ b/src/ImportAssist.Core/ImportReport.cs @@ -1,11 +1,29 @@ namespace ImportAssist.Core; +/// +/// Ordnet das fachliche Prüfergebnis einer Rechnung ihrer ursprünglichen CSV-Zeile zu. +/// +/// Zeilennummer in der CSV-Datei; die erste Datenzeile hat die Nummer 2. +/// Unverändert übernommene Rechnungsnummer, gegebenenfalls leer. +/// Gibt an, ob die fachliche Prüfung keine Fehler gefunden hat. +/// Fachliche Fehlermeldungen für diese Rechnung; bei gültigen Rechnungen leer. public sealed record InvoiceCheckResult( int LineNumber, string InvoiceId, bool IsValid, IReadOnlyList Errors); +/// +/// Fasst die Prüfung eines CSV-Blobs mit Ergebnissen je Rechnung oder einem Dateiformatfehler zusammen. +/// +/// Name des geprüften Blobs im Importcontainer. +/// Zeitpunkt der Berichtserstellung in UTC. +/// +/// Completed bei abgeschlossener Prüfung, auch mit ungültigen Rechnungen; +/// Rejected bei einem nicht verarbeitbaren CSV-Format. +/// +/// Ergebnisse je Rechnung; bei einem Dateiformatfehler leer. +/// Fehler des CSV-Dateiformats, getrennt von fachlichen Rechnungsfehlern. public sealed record ImportReport( string SourceBlob, DateTimeOffset CheckedAtUtc, @@ -13,7 +31,10 @@ public sealed record ImportReport( IReadOnlyList Rows, IReadOnlyList FileErrors) { + /// Gesamtzahl der im Bericht enthaltenen Rechnungsprüfungen. public int TotalCount => Rows.Count; + /// Anzahl der Rechnungen ohne fachliche Fehler. public int ValidCount => Rows.Count(row => row.IsValid); + /// Anzahl der Rechnungen mit fachlichen Fehlern; Dateiformatfehler zählen nicht mit. public int InvalidCount => TotalCount - ValidCount; } diff --git a/src/ImportAssist.Core/InvoiceCsvParser.cs b/src/ImportAssist.Core/InvoiceCsvParser.cs index cfecbbf..800fce8 100644 --- a/src/ImportAssist.Core/InvoiceCsvParser.cs +++ b/src/ImportAssist.Core/InvoiceCsvParser.cs @@ -1,11 +1,25 @@ namespace ImportAssist.Core; +/// +/// Liest Rechnungen aus dem vereinfachten, mit Semikolon getrennten Importformat. +/// public static class InvoiceCsvParser { + /// + /// Prüft den Aufbau der CSV-Datei und übernimmt die vier Spalten jeder Datenzeile als Text. + /// Die fachliche Prüfung der Rechnungen erfolgt separat durch . + /// + /// Vollständiger Dateiinhalt mit der Kopfzeile InvoiceId;CustomerId;Amount;Currency. + /// Rechnungszeilen in Dateireihenfolge; bei einer Datei nur mit Kopfzeile eine leere Liste. + /// + /// Die Kopfzeile fehlt oder weicht ab, oder eine Datenzeile enthält Anführungszeichen + /// beziehungsweise nicht genau vier Spalten. + /// public static IReadOnlyList Parse(string csv) { using var reader = new StringReader(csv); + // Ein optionales Unicode-BOM am Dateianfang gehört nicht zum Namen der ersten Spalte. var header = reader.ReadLine()?.TrimStart('\uFEFF'); if (header != "InvoiceId;CustomerId;Amount;Currency") @@ -21,6 +35,8 @@ public static class InvoiceCsvParser { lineNumber++; + // Das Importformat unterstützt keine in Anführungszeichen eingeschlossenen Felder. + // Auch Leerzeilen werden als Datenzeilen geprüft und nicht übersprungen. var columns = line.Split(';'); if (line.Contains('"') || columns.Length != 4) diff --git a/src/ImportAssist.Core/InvoiceRow.cs b/src/ImportAssist.Core/InvoiceRow.cs index 63914c5..0662b2b 100644 --- a/src/ImportAssist.Core/InvoiceRow.cs +++ b/src/ImportAssist.Core/InvoiceRow.cs @@ -1,5 +1,13 @@ namespace ImportAssist.Core; +/// +/// Enthält die ungeprüften Rechnungsfelder. Der Betrag bleibt zunächst Text, +/// damit auch ungültige Zahlen als fachlicher Prüfungsfehler gemeldet werden können. +/// +/// Rechnungsnummer; darf bei der fachlichen Prüfung nicht leer sein. +/// Kundennummer; darf bei der fachlichen Prüfung nicht leer sein. +/// Betrag als Text, für die Prüfung mit Dezimalpunkt und ohne Tausendertrennzeichen. +/// Übernommene Währungsangabe; wird derzeit nicht validiert. public sealed record InvoiceRow( string InvoiceId, string CustomerId, diff --git a/src/ImportAssist.Core/InvoiceValidator.cs b/src/ImportAssist.Core/InvoiceValidator.cs index c6bbfb7..ca62983 100644 --- a/src/ImportAssist.Core/InvoiceValidator.cs +++ b/src/ImportAssist.Core/InvoiceValidator.cs @@ -2,8 +2,17 @@ using System.Globalization; namespace ImportAssist.Core; +/// +/// Bündelt die fachlichen Rechnungsprüfungen für CLI, HTTP-Aufrufe und Blob-Importe. +/// public static class InvoiceValidator { + /// + /// Prüft Rechnungsnummer, Kundennummer und einen positiven, lesbaren Betrag. + /// Die Währung wird derzeit nicht geprüft. + /// + /// Zu prüfende Rechnung mit den ursprünglichen Feldwerten als Text. + /// Alle gefundenen Fehlermeldungen; eine leere Liste kennzeichnet eine gültige Rechnung. public static IReadOnlyList Validate(InvoiceRow row) { var errors = new List(); @@ -18,6 +27,8 @@ public static class InvoiceValidator errors.Add("Kundennummer fehlt."); } + // Das Zahlenformat ist unabhängig von der Serversprache: Dezimalpunkt und Vorzeichen + // sind erlaubt, Tausendertrennzeichen und umgebende Leerzeichen dagegen nicht. var allowedStyles = NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint; diff --git a/src/ImportAssist.Functions/InvoiceBlobTrigger.cs b/src/ImportAssist.Functions/InvoiceBlobTrigger.cs index 3ba61d0..05b89e7 100644 --- a/src/ImportAssist.Functions/InvoiceBlobTrigger.cs +++ b/src/ImportAssist.Functions/InvoiceBlobTrigger.cs @@ -5,6 +5,10 @@ using Microsoft.Extensions.Logging; namespace ImportAssist_Functions; +/// +/// Prüft CSV-Dateien aus dem Blobcontainer imports und erstellt JSON-Berichte im Container results. +/// +/// Vom Functions-Host bereitgestellter Logger für die Zusammenfassung der Prüfung. public sealed class InvoiceBlobTrigger( ILogger logger) { @@ -14,6 +18,12 @@ public sealed class InvoiceBlobTrigger( WriteIndented = true }; + /// + /// Liest und validiert einen importierten CSV-Blob und liefert den serialisierten Prüfbericht. + /// + /// Vom Blob-Trigger als Text geladener Dateiinhalt. + /// Über den Binding-Platzhalter name ermittelter Blobname im Importcontainer. + /// JSON-Bericht, den das Output-Binding unter results/{name}.json speichert. [Function("InvoiceBlobTrigger")] [BlobOutput( "results/{name}.json", @@ -37,6 +47,7 @@ public sealed class InvoiceBlobTrigger( var row = rows[index]; var errors = InvoiceValidator.Validate(row); + // Die Kopfzeile belegt Zeile 1; der nullbasierte Index beginnt deshalb bei CSV-Zeile 2. results.Add(new InvoiceCheckResult( LineNumber: index + 2, InvoiceId: row.InvoiceId, @@ -44,6 +55,7 @@ public sealed class InvoiceBlobTrigger( Errors: errors)); } + // Completed bezeichnet die abgeschlossene Prüfung, auch wenn einzelne Rechnungen ungültig sind. report = new ImportReport( SourceBlob: name, CheckedAtUtc: DateTimeOffset.UtcNow, @@ -53,6 +65,7 @@ public sealed class InvoiceBlobTrigger( } catch (FormatException exception) { + // Bei einem Formatfehler wird die gesamte Datei abgelehnt; es gibt keine Teilergebnisse. report = new ImportReport( SourceBlob: name, CheckedAtUtc: DateTimeOffset.UtcNow, @@ -71,6 +84,7 @@ public sealed class InvoiceBlobTrigger( report.ValidCount, report.InvalidCount); + // Das Output-Binding übernimmt das Speichern; dafür ist hier kein eigener Storage-Client nötig. return JsonSerializer.Serialize(report, JsonOptions); } } \ No newline at end of file diff --git a/src/ImportAssist.Functions/Program.cs b/src/ImportAssist.Functions/Program.cs index cd308f9..747dcda 100644 --- a/src/ImportAssist.Functions/Program.cs +++ b/src/ImportAssist.Functions/Program.cs @@ -6,10 +6,13 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using OpenTelemetry; +// Erstellt den Functions-Worker; args enthält die Startargumente des Prozesses. var builder = FunctionsApplication.CreateBuilder(args); +// Aktiviert die ASP.NET-Core-Anbindung für HTTP-Trigger mit HttpRequest und IActionResult. builder.ConfigureFunctionsWebApplication(); +// Telemetrie wird nur bei konfigurierter Application-Insights-Verbindung nach Azure exportiert. if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("APPLICATIONINSIGHTS_CONNECTION_STRING"))) { builder.Services.AddOpenTelemetry() diff --git a/src/ImportAssist.Functions/ValidateInvoice.cs b/src/ImportAssist.Functions/ValidateInvoice.cs index 5353d8e..7c2bf98 100644 --- a/src/ImportAssist.Functions/ValidateInvoice.cs +++ b/src/ImportAssist.Functions/ValidateInvoice.cs @@ -7,11 +7,23 @@ using Microsoft.Extensions.Logging; namespace ImportAssist_Functions; +/// +/// Stellt die fachliche Prüfung einer einzelnen Rechnung über einen HTTP-POST-Endpunkt bereit. +/// +/// Vom Functions-Host bereitgestellter Logger für das Ergebnis der Prüfung. public sealed class ValidateInvoice(ILogger logger) { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + /// + /// Liest eine Rechnung aus dem JSON-Request und prüft sie mit den gemeinsamen fachlichen Regeln. + /// + /// HTTP-Anfrage mit einem Rechnungsobjekt aus Textfeldern im Request-Body. + /// + /// HTTP 400 bei ungültigem JSON beziehungsweise einem nicht lesbaren oder fehlenden Rechnungsobjekt; + /// andernfalls HTTP 200 mit Rechnungsnummer, Gültigkeitskennzeichen und fachlichen Fehlermeldungen. + /// [Function("ValidateInvoice")] public async Task Run( [HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequest request) @@ -20,6 +32,7 @@ public sealed class ValidateInvoice(ILogger logger) try { + // Ein abgebrochener HTTP-Aufruf soll auch das Einlesen des Request-Bodys abbrechen. row = await JsonSerializer.DeserializeAsync( request.Body, JsonOptions, @@ -33,6 +46,7 @@ public sealed class ValidateInvoice(ILogger logger) }); } + // Der JSON-Wert null lässt sich deserialisieren, enthält aber keine prüfbare Rechnung. if (row is null) { return new BadRequestObjectResult(new @@ -47,6 +61,7 @@ public sealed class ValidateInvoice(ILogger logger) "Rechnungsprüfung abgeschlossen: {ErrorCount} Fehler.", errors.Count); + // Fachliche Fehler gehören zum regulären Prüfergebnis und werden deshalb mit HTTP 200 geliefert. return new OkObjectResult(new { invoiceId = row.InvoiceId, diff --git a/tools/ImportAssist.Upload/Program.cs b/tools/ImportAssist.Upload/Program.cs index 503b3c5..5ddded4 100644 --- a/tools/ImportAssist.Upload/Program.cs +++ b/tools/ImportAssist.Upload/Program.cs @@ -1,12 +1,16 @@ using Azure.Storage.Blobs; +// Lokales Hilfsprogramm für Azurite (UseDevelopmentStorage=true): +// lädt einen Import hoch; result liest dessen JSON-Prüfbericht. if (args.Length == 2 && args[0] == "result") { + // args[1] ist der beim Upload ausgegebene Blobname einschließlich der ursprünglichen Dateiendung. var resultBlob = new BlobClient( "UseDevelopmentStorage=true", "results", $"{args[1]}.json"); + // Die Function verarbeitet Uploads asynchron; der Bericht kann daher noch fehlen. if (!(await resultBlob.ExistsAsync()).Value) { Console.Error.WriteLine( @@ -31,6 +35,7 @@ var container = new BlobContainerClient( "UseDevelopmentStorage=true", "imports"); +// Beide Container werden auch bei einer frisch gestarteten lokalen Storage-Umgebung angelegt. await container.CreateIfNotExistsAsync(); var resultsContainer = new BlobContainerClient( @@ -39,6 +44,7 @@ var resultsContainer = new BlobContainerClient( await resultsContainer.CreateIfNotExistsAsync(); +// Die zufällige Kennung verhindert Namenskollisionen bei wiederholtem Upload derselben Datei. var blobName = $"{Guid.NewGuid():N}-{Path.GetFileName(args[0])}"; var blob = container.GetBlobClient(blobName);