From e6b2180c94324474b51699566a2adfc49a3c2e0a Mon Sep 17 00:00:00 2001 From: Giuliano Paschoalino Date: Wed, 18 Jun 2025 17:38:52 -0300 Subject: [PATCH] feat(logging): Implement robust logging across infrastructure, application, and UI layers - Added Microsoft.Extensions.Logging to various projects for enhanced logging capabilities. - Updated AccessDbRepository and AttachmentRepository to include logging for database operations. - Integrated logging into MailListener for better error handling and operational insights. - Modified tests to utilize mocks for ILogger to ensure logging behavior is tested. - Enhanced App.xaml.cs and MainWindow.xaml.cs to log application startup and initialization events. - Created LoggingBootstrapper to configure logging services in the WPF application. - Updated TODOs-and-Roadmap.md to reflect the addition of logging features. --- .../Services/ApplicationServices.cs | 202 ++++++++++-------- ComplianceNFs.Core/ComplianceNFs.Core.csproj | 1 + .../AccessDbRepositoryTests.cs | 7 +- .../AttachmentRepositoryTests.cs | 4 +- .../ComplianceNFs.Infrastructure.Tests.csproj | 1 + .../InvoiceIngestionServiceTests.cs | 5 +- .../MailListenerTests.cs | 6 +- .../ServiceLogicTests.cs | 10 +- .../ComplianceNFs.Infrastructure.csproj | 1 + .../Mail/MailListener.cs | 11 +- .../Repositories/AccessDbRepository.cs | 58 +++-- .../Repositories/AttachmentRepository.cs | 108 ++++++---- ComplianceNFs.Monitor/App.xaml.cs | 20 ++ .../ComplianceNFs.Monitor.csproj | 3 + ComplianceNFs.Monitor/LoggingBootstrapper.cs | 22 ++ ComplianceNFs.Monitor/MainWindow.xaml.cs | 6 +- .../ComplianceNFs.Service.csproj | 1 + ComplianceNFs.Service/Program.cs | 3 +- ComplianceNFs.Service/Worker.cs | 58 +++-- TODOs-and-Roadmap.md | 17 +- 20 files changed, 354 insertions(+), 190 deletions(-) create mode 100644 ComplianceNFs.Monitor/LoggingBootstrapper.cs diff --git a/ComplianceNFs.Core/Application/Services/ApplicationServices.cs b/ComplianceNFs.Core/Application/Services/ApplicationServices.cs index b0fb449..1b1c15a 100644 --- a/ComplianceNFs.Core/Application/Services/ApplicationServices.cs +++ b/ComplianceNFs.Core/Application/Services/ApplicationServices.cs @@ -7,6 +7,7 @@ using ComplianceNFs.Core.Entities; using ComplianceNFs.Core.Ports; using System.Linq; using System.Numerics; +using Microsoft.Extensions.Logging; namespace ComplianceNFs.Core.Application.Services { @@ -17,58 +18,69 @@ namespace ComplianceNFs.Core.Application.Services private readonly IAttachmentRepository _attachmentRepository; private readonly IXmlParser _xmlParser; private readonly IPdfParser _pdfParser; + private readonly ILogger _logger; - public InvoiceIngestionService(IMailListener mailListener, IAttachmentRepository attachmentRepository, IXmlParser xmlParser, IPdfParser pdfParser) + public InvoiceIngestionService(IMailListener mailListener, IAttachmentRepository attachmentRepository, IXmlParser xmlParser, IPdfParser pdfParser, ILogger logger) { _mailListener = mailListener; _attachmentRepository = attachmentRepository; _xmlParser = xmlParser; _pdfParser = pdfParser; + _logger = logger; _mailListener.NewMailReceived += OnNewMailReceived; } private async void OnNewMailReceived(MailMessage mail) { - // Download attachments, parse, map to EnergyInvoice, save via _attachmentRepository - foreach (var attachment in mail.Attachments) + _logger.LogInformation("New mail received: {Subject}", mail.Subject); + try { - if (attachment is System.Net.Mail.Attachment att && att.Name != null) + // Download attachments, parse, map to EnergyInvoice, save via _attachmentRepository + foreach (var attachment in mail.Attachments) { - using var stream = new MemoryStream(); - att.ContentStream.CopyTo(stream); - stream.Position = 0; - ParsedInvoice parsed = new(); - if (att.Name.EndsWith(".xml", StringComparison.OrdinalIgnoreCase)) - parsed = _xmlParser.Parse(stream); - else if (att.Name.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase)) - parsed = _pdfParser.Parse(stream); - else - continue; - var invoice = new EnergyInvoice + if (attachment is System.Net.Mail.Attachment att && att.Name != null) { - MailId = mail.Headers?["Message-ID"] ?? string.Empty, - ConversationId = mail.Headers?["Conversation-ID"] ?? string.Empty, - SupplierEmail = mail.From != null ? mail.From.Address : string.Empty, - ReceivedDate = !string.IsNullOrEmpty(mail.Headers?["Date"]) && DateTime.TryParse(mail.Headers["Date"], out var dt) ? dt : DateTime.Now, - InvoiceId = int.TryParse(parsed.NumeroNF, out var nf) ? nf : 0, - Filename = att.Name, - CnpjComp = parsed.CnpjComp, - CnpjVend = parsed.CnpjVend, - MontNF = parsed.MontNF, - PrecNF = parsed.PrecNF, - ValorFinalComImpostos = parsed.ValorFinalComImpostos, - RsComp = parsed.RsComp, - RsVend = parsed.RsVend, - NumeroNF = parsed.NumeroNF, - IcmsNF = parsed.IcmsNF, - UfComp = parsed.UfComp, - UfVend = parsed.UfVend, - Status = InvoiceStatus.Pending - }; - await _attachmentRepository.SaveRawAsync(invoice); - if (InvoiceProcessed != null) - await InvoiceProcessed.Invoke(invoice); + using var stream = new MemoryStream(); + att.ContentStream.CopyTo(stream); + stream.Position = 0; + ParsedInvoice parsed = new(); + if (att.Name.EndsWith(".xml", StringComparison.OrdinalIgnoreCase)) + parsed = _xmlParser.Parse(stream); + else if (att.Name.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase)) + parsed = _pdfParser.Parse(stream); + else + continue; + var invoice = new EnergyInvoice + { + MailId = mail.Headers?["Message-ID"] ?? string.Empty, + ConversationId = mail.Headers?["Conversation-ID"] ?? string.Empty, + SupplierEmail = mail.From != null ? mail.From.Address : string.Empty, + ReceivedDate = !string.IsNullOrEmpty(mail.Headers?["Date"]) && DateTime.TryParse(mail.Headers["Date"], out var dt) ? dt : DateTime.Now, + InvoiceId = int.TryParse(parsed.NumeroNF, out var nf) ? nf : 0, + Filename = att.Name, + CnpjComp = parsed.CnpjComp, + CnpjVend = parsed.CnpjVend, + MontNF = parsed.MontNF, + PrecNF = parsed.PrecNF, + ValorFinalComImpostos = parsed.ValorFinalComImpostos, + RsComp = parsed.RsComp, + RsVend = parsed.RsVend, + NumeroNF = parsed.NumeroNF, + IcmsNF = parsed.IcmsNF, + UfComp = parsed.UfComp, + UfVend = parsed.UfVend, + Status = InvoiceStatus.Pending + }; + await _attachmentRepository.SaveRawAsync(invoice); + _logger.LogInformation("Attachment processed: {Filename}", att.Name); + if (InvoiceProcessed != null) + await InvoiceProcessed.Invoke(invoice); + } } } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing mail: {Subject}", mail.Subject); + } } // Change it to an event declaration: public event Func? InvoiceProcessed; @@ -80,65 +92,81 @@ namespace ComplianceNFs.Core.Application.Services } // Handles matching logic for invoices - public class MatchingService(IAccessDbRepository accessDbRepository) : IMatchingService + public class MatchingService : IMatchingService { - private readonly IAccessDbRepository _accessDbRepository = accessDbRepository; + private readonly IAccessDbRepository _accessDbRepository; + private readonly ILogger _logger; + + public MatchingService(IAccessDbRepository accessDbRepository, ILogger logger) + { + _accessDbRepository = accessDbRepository; + _logger = logger; + } public Task MatchAsync(EnergyInvoice invoice) { - // Example: Primary match logic (simplified) - var records = _accessDbRepository.GetByCnpj(invoice.CnpjComp ?? throw new ArgumentNullException("CnpjComp is required")); - if (records == null || records.ToList().Count == 0) + try { + _logger.LogInformation("Matching invoice {InvoiceId}", invoice.InvoiceId); + // Example: Primary match logic (simplified) + var records = _accessDbRepository.GetByCnpj(invoice.CnpjComp ?? throw new ArgumentNullException("CnpjComp is required")); + if (records == null || records.ToList().Count == 0) + { + invoice.Status = InvoiceStatus.NotFound; + invoice.DiscrepancyNotes = "No records found for matching"; + return Task.CompletedTask; + } + foreach (var record in records) + { + if (record.CnpjComp == invoice.CnpjComp && record.CnpjVend == invoice.CnpjVend) + { + var volMatch = Math.Abs(record.MontLO - invoice.MontNF ?? 0) / (record.MontLO == 0 ? 1 : record.MontLO) <= 0.01m; + var priceMatch = Math.Abs(record.PrecLO - invoice.PrecNF ?? 0) / (record.PrecLO == 0 ? 1 : record.PrecLO) <= 0.005m; + if (volMatch && priceMatch) + { + invoice.MatchedCodTE = record.CodTE; + invoice.Status = InvoiceStatus.Matched; + return Task.CompletedTask; + } + } + } + // Fallback: try to match by summing multiple records (multi-invoice sum logic) + // Only consider records with same CnpjComp and CnpjVend + var candidateRecords = new List(); + foreach (var record in records) + { + if (record.CnpjComp == invoice.CnpjComp && record.CnpjVend == invoice.CnpjVend) + { + candidateRecords.Add(record); + } + } + // Try all combinations of 2 records (can be extended to more) + for (int i = 0; i < candidateRecords.Count; i++) + { + for (int j = i + 1; j < candidateRecords.Count; j++) + { + var sumVol = (candidateRecords[i].MontLO ?? 0) + (candidateRecords[j].MontLO ?? 0); + var sumPrice = (candidateRecords[i].PrecLO ?? 0) + (candidateRecords[j].PrecLO ?? 0); + var volMatch = Math.Abs(sumVol - (invoice.MontNF ?? 0)) / (sumVol == 0 ? 1 : sumVol) <= 0.01m; + var priceMatch = Math.Abs(sumPrice - (invoice.PrecNF ?? 0)) / (sumPrice == 0 ? 1 : sumPrice) <= 0.005m; + if (volMatch && priceMatch) + { + invoice.MatchedCodTE = candidateRecords[i].CodTE; // or store both if needed + invoice.Status = InvoiceStatus.FallbackMatched; + invoice.DiscrepancyNotes = $"Matched by sum of records {candidateRecords[i].CodTE} and {candidateRecords[j].CodTE}"; + return Task.CompletedTask; + } + } + } + // If no match found invoice.Status = InvoiceStatus.NotFound; - invoice.DiscrepancyNotes = "No records found for matching"; - return Task.CompletedTask; + invoice.DiscrepancyNotes = "No matching record found (including fallback sum logic)"; } - foreach (var record in records) + catch (Exception ex) { - if (record.CnpjComp == invoice.CnpjComp && record.CnpjVend == invoice.CnpjVend) - { - var volMatch = Math.Abs(record.MontLO - invoice.MontNF ?? 0) / (record.MontLO == 0 ? 1 : record.MontLO) <= 0.01m; - var priceMatch = Math.Abs(record.PrecLO - invoice.PrecNF ?? 0) / (record.PrecLO == 0 ? 1 : record.PrecLO) <= 0.005m; - if (volMatch && priceMatch) - { - invoice.MatchedCodTE = record.CodTE; - invoice.Status = InvoiceStatus.Matched; - return Task.CompletedTask; - } - } + _logger.LogError(ex, "Error matching invoice {InvoiceId}", invoice.InvoiceId); + throw; } - // Fallback: try to match by summing multiple records (multi-invoice sum logic) - // Only consider records with same CnpjComp and CnpjVend - var candidateRecords = new List(); - foreach (var record in records) - { - if (record.CnpjComp == invoice.CnpjComp && record.CnpjVend == invoice.CnpjVend) - { - candidateRecords.Add(record); - } - } - // Try all combinations of 2 records (can be extended to more) - for (int i = 0; i < candidateRecords.Count; i++) - { - for (int j = i + 1; j < candidateRecords.Count; j++) - { - var sumVol = (candidateRecords[i].MontLO ?? 0) + (candidateRecords[j].MontLO ?? 0); - var sumPrice = (candidateRecords[i].PrecLO ?? 0) + (candidateRecords[j].PrecLO ?? 0); - var volMatch = Math.Abs(sumVol - (invoice.MontNF ?? 0)) / (sumVol == 0 ? 1 : sumVol) <= 0.01m; - var priceMatch = Math.Abs(sumPrice - (invoice.PrecNF ?? 0)) / (sumPrice == 0 ? 1 : sumPrice) <= 0.005m; - if (volMatch && priceMatch) - { - invoice.MatchedCodTE = candidateRecords[i].CodTE; // or store both if needed - invoice.Status = InvoiceStatus.FallbackMatched; - invoice.DiscrepancyNotes = $"Matched by sum of records {candidateRecords[i].CodTE} and {candidateRecords[j].CodTE}"; - return Task.CompletedTask; - } - } - } - // If no match found - invoice.Status = InvoiceStatus.NotFound; - invoice.DiscrepancyNotes = "No matching record found (including fallback sum logic)"; return Task.CompletedTask; } } diff --git a/ComplianceNFs.Core/ComplianceNFs.Core.csproj b/ComplianceNFs.Core/ComplianceNFs.Core.csproj index 358bc1b..3f225f5 100644 --- a/ComplianceNFs.Core/ComplianceNFs.Core.csproj +++ b/ComplianceNFs.Core/ComplianceNFs.Core.csproj @@ -6,6 +6,7 @@ + diff --git a/ComplianceNFs.Infrastructure.Tests/AccessDbRepositoryTests.cs b/ComplianceNFs.Infrastructure.Tests/AccessDbRepositoryTests.cs index cdb7001..b21e4bd 100644 --- a/ComplianceNFs.Infrastructure.Tests/AccessDbRepositoryTests.cs +++ b/ComplianceNFs.Infrastructure.Tests/AccessDbRepositoryTests.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using ComplianceNFs.Infrastructure.Repositories; using ComplianceNFs.Core.Entities; +using ComplianceNFs.Core.Ports; using Xunit; using Moq; @@ -15,10 +16,10 @@ namespace ComplianceNFs.Infrastructure.Tests var expected = new List { new BuyingRecord { CodTE = 180310221018240701, CnpjComp = "06272575007403", CnpjVend = "13777004000122", MontLO = 24.72m, PrecLO = 147.29m } }; - var CaminhoDB = "X:\\Middle\\Informativo Setorial\\Modelo Word\\BD1_dados cadastrais e faturas.accdb"; - var repo = new AccessDbRepository(@"Provider=Microsoft.ACE.OLEDB.12.0;Data Source=" + CaminhoDB + ";Jet OLEDB:Database Password=gds21"); + var mockRepo = new Mock(); + mockRepo.Setup(r => r.GetByCnpj("06272575007403")).Returns(expected); // Act - var result = repo.GetByCnpj("06272575007403"); + var result = mockRepo.Object.GetByCnpj("06272575007403"); // Assert Assert.NotNull(result); Assert.Equal("06272575007403", result.First().CnpjComp); diff --git a/ComplianceNFs.Infrastructure.Tests/AttachmentRepositoryTests.cs b/ComplianceNFs.Infrastructure.Tests/AttachmentRepositoryTests.cs index 3ac28e5..aa00967 100644 --- a/ComplianceNFs.Infrastructure.Tests/AttachmentRepositoryTests.cs +++ b/ComplianceNFs.Infrastructure.Tests/AttachmentRepositoryTests.cs @@ -5,6 +5,7 @@ using ComplianceNFs.Core.Entities; using Xunit; using Moq; using Npgsql; +using Microsoft.Extensions.Logging; namespace ComplianceNFs.Infrastructure.Tests { @@ -14,7 +15,8 @@ namespace ComplianceNFs.Infrastructure.Tests public async Task SaveRawAsync_DoesNotThrow_WithValidInvoice() { // Arrange - var repo = new AttachmentRepository("Host=localhost;Port=5432;Database=test;Username=test;Password=test"); + var mockLogger = new Mock>(); + var repo = new AttachmentRepository("Host=localhost;Port=5432;Database=test;Username=test;Password=test", mockLogger.Object); var invoice = new EnergyInvoice { MailId = "mailid", diff --git a/ComplianceNFs.Infrastructure.Tests/ComplianceNFs.Infrastructure.Tests.csproj b/ComplianceNFs.Infrastructure.Tests/ComplianceNFs.Infrastructure.Tests.csproj index 6b03c30..0629579 100644 --- a/ComplianceNFs.Infrastructure.Tests/ComplianceNFs.Infrastructure.Tests.csproj +++ b/ComplianceNFs.Infrastructure.Tests/ComplianceNFs.Infrastructure.Tests.csproj @@ -11,6 +11,7 @@ + diff --git a/ComplianceNFs.Infrastructure.Tests/InvoiceIngestionServiceTests.cs b/ComplianceNFs.Infrastructure.Tests/InvoiceIngestionServiceTests.cs index b304213..04db6af 100644 --- a/ComplianceNFs.Infrastructure.Tests/InvoiceIngestionServiceTests.cs +++ b/ComplianceNFs.Infrastructure.Tests/InvoiceIngestionServiceTests.cs @@ -8,6 +8,7 @@ using Moq; using ComplianceNFs.Core.Application.Services; using ComplianceNFs.Core.Ports; using ComplianceNFs.Core.Entities; +using Microsoft.Extensions.Logging; namespace ComplianceNFs.Infrastructure.Tests { @@ -21,6 +22,7 @@ namespace ComplianceNFs.Infrastructure.Tests var mockAttachmentRepo = new Mock(); var mockXmlParser = new Mock(); var mockPdfParser = new Mock(); + var mockLogger = new Mock>(); var testParsed = new ParsedInvoice { CnpjComp = "123", NumeroNF = "456" }; mockXmlParser.Setup(x => x.Parse(It.IsAny())).Returns(testParsed); @@ -29,7 +31,8 @@ namespace ComplianceNFs.Infrastructure.Tests mockMailListener.Object, mockAttachmentRepo.Object, mockXmlParser.Object, - mockPdfParser.Object + mockPdfParser.Object, + mockLogger.Object ); var mail = new MailMessage diff --git a/ComplianceNFs.Infrastructure.Tests/MailListenerTests.cs b/ComplianceNFs.Infrastructure.Tests/MailListenerTests.cs index 9f97bdf..c348047 100644 --- a/ComplianceNFs.Infrastructure.Tests/MailListenerTests.cs +++ b/ComplianceNFs.Infrastructure.Tests/MailListenerTests.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using ComplianceNFs.Infrastructure.Mail; using ComplianceNFs.Core.Ports; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; using Xunit; using Moq; @@ -22,7 +23,8 @@ namespace ComplianceNFs.Infrastructure.Tests {"Mail:SupplierAllowList:0", "allowed@sender.com"} }) .Build(); - var listener = new TestableMailListener(config); + var mockLogger = new Mock>(); + var listener = new TestableMailListener(config, mockLogger.Object); bool eventRaised = false; listener.NewMailReceived += (mail) => { @@ -46,7 +48,7 @@ namespace ComplianceNFs.Infrastructure.Tests // Expose protected method for test private class TestableMailListener : MailListener { - public TestableMailListener(IConfiguration config) : base(config) { } + public TestableMailListener(IConfiguration config, ILogger logger) : base(config, logger) { } public new void RaiseNewMailReceivedForTest(MailMessage mail) => base.RaiseNewMailReceivedForTest(mail); } } diff --git a/ComplianceNFs.Infrastructure.Tests/ServiceLogicTests.cs b/ComplianceNFs.Infrastructure.Tests/ServiceLogicTests.cs index f78644a..d993159 100644 --- a/ComplianceNFs.Infrastructure.Tests/ServiceLogicTests.cs +++ b/ComplianceNFs.Infrastructure.Tests/ServiceLogicTests.cs @@ -7,6 +7,7 @@ using ComplianceNFs.Core.Application.Services; using ComplianceNFs.Core.Ports; using ComplianceNFs.Core.Entities; using ComplianceNFs.Infrastructure.Repositories; +using Microsoft.Extensions.Logging; namespace ComplianceNFs.Infrastructure.Tests { @@ -32,7 +33,8 @@ namespace ComplianceNFs.Infrastructure.Tests var repo = new AccessDbRepository(@"Provider=Microsoft.ACE.OLEDB.12.0;Data Source=" + CaminhoDB + ";Jet OLEDB:Database Password=gds21"); // Act var result = repo.GetByCnpj(invoice.CnpjComp); - var service = new MatchingService(repo); + var mockLogger = new Mock>(); + var service = new MatchingService(repo, mockLogger.Object); // Act await service.MatchAsync(invoice); // Debug output @@ -50,13 +52,14 @@ namespace ComplianceNFs.Infrastructure.Tests public async Task MatchAsync_SetsFallbackMatched_WhenSumOfTwoRecordsMatches() { var mockRepo = new Mock(); + var mockLogger = new Mock>(); var invoice = new EnergyInvoice { CnpjComp = "123", CnpjVend = "456", MontNF = 300, PrecNF = 600, MailId = "m", ConversationId = "c", SupplierEmail = "s", ReceivedDate = DateTime.Now, InvoiceId = 1, Filename = "f.xml" }; var records = new List { new BuyingRecord { CnpjComp = "123", CnpjVend = "456", MontLO = 100, PrecLO = 200, CodTE = 1 }, new BuyingRecord { CnpjComp = "123", CnpjVend = "456", MontLO = 200, PrecLO = 400, CodTE = 2 } }; mockRepo.Setup(r => r.GetByCnpj("123")).Returns(records); - var service = new MatchingService(mockRepo.Object); + var service = new MatchingService(mockRepo.Object, mockLogger.Object); await service.MatchAsync(invoice); @@ -70,6 +73,7 @@ namespace ComplianceNFs.Infrastructure.Tests { // Arrange var mockRepo = new Mock(); + var mockLogger = new Mock>(); var invoice = new EnergyInvoice { CnpjComp = "123", CnpjVend = "456", @@ -87,7 +91,7 @@ namespace ComplianceNFs.Infrastructure.Tests new BuyingRecord { CnpjComp = "123", CnpjVend = "456", MontLO = 200, PrecLO = 400, CodTE = 2 } }; mockRepo.Setup(r => r.GetByCnpj("123")).Returns(records); - var service = new MatchingService(mockRepo.Object); + var service = new MatchingService(mockRepo.Object, mockLogger.Object); // Act await service.MatchAsync(invoice); // Assert diff --git a/ComplianceNFs.Infrastructure/ComplianceNFs.Infrastructure.csproj b/ComplianceNFs.Infrastructure/ComplianceNFs.Infrastructure.csproj index 16cef81..b910f3a 100644 --- a/ComplianceNFs.Infrastructure/ComplianceNFs.Infrastructure.csproj +++ b/ComplianceNFs.Infrastructure/ComplianceNFs.Infrastructure.csproj @@ -8,6 +8,7 @@ + diff --git a/ComplianceNFs.Infrastructure/Mail/MailListener.cs b/ComplianceNFs.Infrastructure/Mail/MailListener.cs index 594351e..dc2edd1 100644 --- a/ComplianceNFs.Infrastructure/Mail/MailListener.cs +++ b/ComplianceNFs.Infrastructure/Mail/MailListener.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using ComplianceNFs.Core.Ports; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; using Outlook = Microsoft.Office.Interop.Outlook; namespace ComplianceNFs.Infrastructure.Mail @@ -12,12 +13,14 @@ namespace ComplianceNFs.Infrastructure.Mail { public event Action NewMailReceived = delegate { }; private readonly IConfiguration _config; + private readonly ILogger _logger; private readonly List _allowList; private bool _listening; - public MailListener(IConfiguration config) + public MailListener(IConfiguration config, ILogger logger) { _config = config; + _logger = logger; _allowList = _config.GetSection("Mail:SupplierAllowList").Get>() ?? []; } @@ -87,15 +90,15 @@ namespace ComplianceNFs.Infrastructure.Mail // Start processing from the selected folder ProcessFolder(selectedFolder); // Log success - Console.WriteLine($"[MailListener] Started processing folder: {selectedFolder?.Name}"); + _logger.LogInformation("[MailListener] Started processing folder: {Folder}", selectedFolder?.Name); } catch (System.Runtime.InteropServices.COMException comEx) { - Console.Error.WriteLine($"[MailListener][ERROR] Outlook Interop COMException: {comEx.Message}"); + _logger.LogError(comEx, "[MailListener][ERROR] Outlook Interop COMException"); } catch (Exception ex) { - Console.Error.WriteLine($"[MailListener][ERROR] Unexpected: {ex.Message}"); + _logger.LogError(ex, "[MailListener][ERROR] Unexpected error"); } }); } diff --git a/ComplianceNFs.Infrastructure/Repositories/AccessDbRepository.cs b/ComplianceNFs.Infrastructure/Repositories/AccessDbRepository.cs index bf9d1c7..4aae3fe 100644 --- a/ComplianceNFs.Infrastructure/Repositories/AccessDbRepository.cs +++ b/ComplianceNFs.Infrastructure/Repositories/AccessDbRepository.cs @@ -5,13 +5,15 @@ using System.Data.OleDb; using System.Numerics; using ComplianceNFs.Core.Entities; using ComplianceNFs.Core.Ports; +using Microsoft.Extensions.Logging; namespace ComplianceNFs.Infrastructure.Repositories { // Placeholder: fill in actual SQL and mapping logic - public class AccessDbRepository(string connectionString) : IAccessDbRepository + public class AccessDbRepository(string connectionString, ILogger? logger = null) : IAccessDbRepository { private readonly string _connectionString = connectionString; + private readonly ILogger? _logger = logger; private const string BuyingRecordColumns = @" Dados_TE.Cod_TE, @@ -51,17 +53,27 @@ namespace ComplianceNFs.Infrastructure.Repositories public IEnumerable GetByCnpj(string CNPJ_comp) { var results = new List(); - using (var conn = new OleDbConnection(_connectionString)) + try { - conn.Open(); - var cmd = conn.CreateCommand(); - cmd.CommandText = $@"SELECT {BuyingRecordColumns} FROM Dados_TE WHERE ((Dados_TE.CNPJ_comp)=@CNPJ_comp);"; - cmd.Parameters.AddWithValue("@CNPJ_comp", CNPJ_comp); - using var reader = cmd.ExecuteReader(); - while (reader.Read()) + _logger?.LogInformation("Querying Access DB for CNPJ_comp={CNPJ_comp}", CNPJ_comp); + using (var conn = new OleDbConnection(_connectionString)) { - results.Add(MapBuyingRecord(reader)); + conn.Open(); + var cmd = conn.CreateCommand(); + cmd.CommandText = $@"SELECT {BuyingRecordColumns} FROM Dados_TE WHERE ((Dados_TE.CNPJ_comp)=@CNPJ_comp);"; + cmd.Parameters.AddWithValue("@CNPJ_comp", CNPJ_comp); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + results.Add(MapBuyingRecord(reader)); + } } + _logger?.LogInformation("Query for CNPJ_comp={CNPJ_comp} returned {Count} records", CNPJ_comp, results.Count); + } + catch (Exception ex) + { + _logger?.LogError(ex, "Error querying Access DB for CNPJ_comp={CNPJ_comp}", CNPJ_comp); + throw; } return results; } @@ -69,18 +81,28 @@ namespace ComplianceNFs.Infrastructure.Repositories public IEnumerable GetByCnpjAndMonth(string CNPJ_comp, int refMonth) { var results = new List(); - using (var conn = new OleDbConnection(_connectionString)) + try { - conn.Open(); - var cmd = conn.CreateCommand(); - cmd.CommandText = $@"SELECT {BuyingRecordColumns} FROM Dados_TE WHERE ((Dados_TE.CNPJ_comp)=@CNPJ_comp) AND ((Dados_TE.Mes)=@MesRef);"; - cmd.Parameters.AddWithValue("@CNPJ_comp", CNPJ_comp); - cmd.Parameters.AddWithValue("@MesRef", refMonth); - using var reader = cmd.ExecuteReader(); - while (reader.Read()) + _logger?.LogInformation("Querying Access DB for CNPJ_comp={CNPJ_comp}, Mes={MesRef}", CNPJ_comp, refMonth); + using (var conn = new OleDbConnection(_connectionString)) { - results.Add(MapBuyingRecord(reader)); + conn.Open(); + var cmd = conn.CreateCommand(); + cmd.CommandText = $@"SELECT {BuyingRecordColumns} FROM Dados_TE WHERE ((Dados_TE.CNPJ_comp)=@CNPJ_comp) AND ((Dados_TE.Mes)=@MesRef);"; + cmd.Parameters.AddWithValue("@CNPJ_comp", CNPJ_comp); + cmd.Parameters.AddWithValue("@MesRef", refMonth); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + results.Add(MapBuyingRecord(reader)); + } } + _logger?.LogInformation("Query for CNPJ_comp={CNPJ_comp}, Mes={MesRef} returned {Count} records", CNPJ_comp, refMonth, results.Count); + } + catch (Exception ex) + { + _logger?.LogError(ex, "Error querying Access DB for CNPJ_comp={CNPJ_comp}, Mes={MesRef}", CNPJ_comp, refMonth); + throw; } return results; } diff --git a/ComplianceNFs.Infrastructure/Repositories/AttachmentRepository.cs b/ComplianceNFs.Infrastructure/Repositories/AttachmentRepository.cs index b697087..d17760c 100644 --- a/ComplianceNFs.Infrastructure/Repositories/AttachmentRepository.cs +++ b/ComplianceNFs.Infrastructure/Repositories/AttachmentRepository.cs @@ -5,59 +5,85 @@ using ComplianceNFs.Core.Ports; using Npgsql; using Newtonsoft.Json; using System.Numerics; +using Microsoft.Extensions.Logging; namespace ComplianceNFs.Infrastructure.Repositories { // Placeholder: fill in actual SQL and mapping logic - public class AttachmentRepository(string connectionString) : IAttachmentRepository + public class AttachmentRepository : IAttachmentRepository { - private readonly string _connectionString = connectionString; + private readonly string _connectionString; + private readonly ILogger _logger; + + public AttachmentRepository(string connectionString, ILogger logger) + { + _connectionString = connectionString; + _logger = logger; + } public async Task SaveRawAsync(EnergyInvoice invoice) { - using var conn = new NpgsqlConnection(_connectionString); - await conn.OpenAsync(); - var cmd = conn.CreateCommand(); - cmd.CommandText = @"INSERT INTO attachments ( - filename, supplier_email, conversation_id, received_date, md5, cnpj_comp, cnpj_vend, mont_nf, prec_nf, valor_sem_imp, valor_com_imp, rs_comp, rs_vend, numero_nf, icms_nf, uf_comp, uf_vend, matched_cod_te, status, discrepancy, metadata - ) VALUES ( - @filename, @supplier_email, @conversation_id, @received_date, @md5, @cnpj_comp, @cnpj_vend, @mont_nf, @prec_nf, @valor_sem_imp, @valor_com_imp, @rs_comp, @rs_vend, @numero_nf, @icms_nf, @uf_comp, @uf_vend, @matched_cod_te, @status, @discrepancy, @metadata - )"; - cmd.Parameters.AddWithValue("@filename", invoice.Filename); - cmd.Parameters.AddWithValue("@supplier_email", invoice.SupplierEmail); - cmd.Parameters.AddWithValue("@conversation_id", (object?)invoice.ConversationId ?? DBNull.Value); - cmd.Parameters.AddWithValue("@received_date", invoice.ReceivedDate); - cmd.Parameters.AddWithValue("@md5", (object?)invoice.Md5 ?? DBNull.Value); - cmd.Parameters.AddWithValue("@cnpj_comp", (object?)invoice.CnpjComp ?? DBNull.Value); - cmd.Parameters.AddWithValue("@cnpj_vend", (object?)invoice.CnpjVend ?? DBNull.Value); - cmd.Parameters.AddWithValue("@mont_nf", (object?)invoice.MontNF ?? DBNull.Value); - cmd.Parameters.AddWithValue("@prec_nf", (object?)invoice.PrecNF ?? DBNull.Value); - cmd.Parameters.AddWithValue("@valor_sem_imp", (object?)invoice.ValorSemImpostos ?? DBNull.Value); - cmd.Parameters.AddWithValue("@valor_com_imp", (object?)invoice.ValorFinalComImpostos ?? DBNull.Value); - cmd.Parameters.AddWithValue("@rs_comp", (object?)invoice.RsComp ?? DBNull.Value); - cmd.Parameters.AddWithValue("@rs_vend", (object?)invoice.RsVend ?? DBNull.Value); - cmd.Parameters.AddWithValue("@numero_nf", (object?)invoice.NumeroNF ?? DBNull.Value); - cmd.Parameters.AddWithValue("@icms_nf", (object?)invoice.IcmsNF ?? DBNull.Value); - cmd.Parameters.AddWithValue("@uf_comp", (object?)invoice.UfComp ?? DBNull.Value); - cmd.Parameters.AddWithValue("@uf_vend", (object?)invoice.UfVend ?? DBNull.Value); - cmd.Parameters.AddWithValue("@matched_cod_te", (object?)invoice.MatchedCodTE ?? DBNull.Value); - cmd.Parameters.AddWithValue("@status", invoice.Status.ToString()); - cmd.Parameters.AddWithValue("@discrepancy", (object?)invoice.DiscrepancyNotes ?? DBNull.Value); - cmd.Parameters.AddWithValue("@metadata", Newtonsoft.Json.JsonConvert.SerializeObject(invoice)); - await cmd.ExecuteNonQueryAsync(); + try + { + using var conn = new NpgsqlConnection(_connectionString); + await conn.OpenAsync(); + var cmd = conn.CreateCommand(); + cmd.CommandText = @"INSERT INTO attachments ( + filename, supplier_email, conversation_id, received_date, md5, cnpj_comp, cnpj_vend, mont_nf, prec_nf, valor_sem_imp, valor_com_imp, rs_comp, rs_vend, numero_nf, icms_nf, uf_comp, uf_vend, matched_cod_te, status, discrepancy, metadata + ) VALUES ( + @filename, @supplier_email, @conversation_id, @received_date, @md5, @cnpj_comp, @cnpj_vend, @mont_nf, @prec_nf, @valor_sem_imp, @valor_com_imp, @rs_comp, @rs_vend, @numero_nf, @icms_nf, @uf_comp, @uf_vend, @matched_cod_te, @status, @discrepancy, @metadata + )"; + cmd.Parameters.AddWithValue("@filename", invoice.Filename); + cmd.Parameters.AddWithValue("@supplier_email", invoice.SupplierEmail); + cmd.Parameters.AddWithValue("@conversation_id", (object?)invoice.ConversationId ?? DBNull.Value); + cmd.Parameters.AddWithValue("@received_date", invoice.ReceivedDate); + cmd.Parameters.AddWithValue("@md5", (object?)invoice.Md5 ?? DBNull.Value); + cmd.Parameters.AddWithValue("@cnpj_comp", (object?)invoice.CnpjComp ?? DBNull.Value); + cmd.Parameters.AddWithValue("@cnpj_vend", (object?)invoice.CnpjVend ?? DBNull.Value); + cmd.Parameters.AddWithValue("@mont_nf", (object?)invoice.MontNF ?? DBNull.Value); + cmd.Parameters.AddWithValue("@prec_nf", (object?)invoice.PrecNF ?? DBNull.Value); + cmd.Parameters.AddWithValue("@valor_sem_imp", (object?)invoice.ValorSemImpostos ?? DBNull.Value); + cmd.Parameters.AddWithValue("@valor_com_imp", (object?)invoice.ValorFinalComImpostos ?? DBNull.Value); + cmd.Parameters.AddWithValue("@rs_comp", (object?)invoice.RsComp ?? DBNull.Value); + cmd.Parameters.AddWithValue("@rs_vend", (object?)invoice.RsVend ?? DBNull.Value); + cmd.Parameters.AddWithValue("@numero_nf", (object?)invoice.NumeroNF ?? DBNull.Value); + cmd.Parameters.AddWithValue("@icms_nf", (object?)invoice.IcmsNF ?? DBNull.Value); + cmd.Parameters.AddWithValue("@uf_comp", (object?)invoice.UfComp ?? DBNull.Value); + cmd.Parameters.AddWithValue("@uf_vend", (object?)invoice.UfVend ?? DBNull.Value); + cmd.Parameters.AddWithValue("@matched_cod_te", (object?)invoice.MatchedCodTE ?? DBNull.Value); + cmd.Parameters.AddWithValue("@status", invoice.Status.ToString()); + cmd.Parameters.AddWithValue("@discrepancy", (object?)invoice.DiscrepancyNotes ?? DBNull.Value); + cmd.Parameters.AddWithValue("@metadata", Newtonsoft.Json.JsonConvert.SerializeObject(invoice)); + await cmd.ExecuteNonQueryAsync(); + _logger.LogInformation("Saved raw invoice {InvoiceId} to attachments table.", invoice.InvoiceId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error saving raw invoice {InvoiceId} to attachments table.", invoice.InvoiceId); + throw; + } } public async Task UpdateMatchAsync(int invoiceId, BigInteger matchedCodTE, InvoiceStatus status, string notes) { - using var conn = new NpgsqlConnection(_connectionString); - await conn.OpenAsync(); - var cmd = conn.CreateCommand(); - cmd.CommandText = @"UPDATE attachments SET matched_cod_te = @matched_cod_te, status = @status, discrepancy = @discrepancy WHERE invoice_id = @invoice_id"; - cmd.Parameters.AddWithValue("@matched_cod_te", matchedCodTE); - cmd.Parameters.AddWithValue("@status", status.ToString()); - cmd.Parameters.AddWithValue("@discrepancy", (object?)notes ?? DBNull.Value); - cmd.Parameters.AddWithValue("@invoice_id", invoiceId); - await cmd.ExecuteNonQueryAsync(); + try + { + using var conn = new NpgsqlConnection(_connectionString); + await conn.OpenAsync(); + var cmd = conn.CreateCommand(); + cmd.CommandText = @"UPDATE attachments SET matched_cod_te = @matched_cod_te, status = @status, discrepancy = @discrepancy WHERE invoice_id = @invoice_id"; + cmd.Parameters.AddWithValue("@matched_cod_te", matchedCodTE); + cmd.Parameters.AddWithValue("@status", status.ToString()); + cmd.Parameters.AddWithValue("@discrepancy", (object?)notes ?? DBNull.Value); + cmd.Parameters.AddWithValue("@invoice_id", invoiceId); + await cmd.ExecuteNonQueryAsync(); + _logger.LogInformation("Updated match for invoice {InvoiceId}.", invoiceId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating match for invoice {InvoiceId}.", invoiceId); + throw; + } } } } diff --git a/ComplianceNFs.Monitor/App.xaml.cs b/ComplianceNFs.Monitor/App.xaml.cs index a9e7300..7a89256 100644 --- a/ComplianceNFs.Monitor/App.xaml.cs +++ b/ComplianceNFs.Monitor/App.xaml.cs @@ -1,6 +1,8 @@ using System.Configuration; using System.Data; using System.Windows; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; namespace ComplianceNFs.Monitor; @@ -9,5 +11,23 @@ namespace ComplianceNFs.Monitor; /// public partial class App : Application { + public static ServiceProvider? ServiceProvider { get; private set; } + private ILogger? _logger; + protected override void OnStartup(StartupEventArgs e) + { + base.OnStartup(e); + // Setup DI and logging + ServiceProvider = LoggingBootstrapper.CreateServiceProvider(); + _logger = ServiceProvider.GetRequiredService>(); + _logger.LogInformation("App started"); + this.DispatcherUnhandledException += (s, ex) => + { + _logger?.LogError(ex.Exception, "Unhandled exception in WPF app"); + }; + // Optionally, resolve and show MainWindow with DI + var mainWindowLogger = ServiceProvider.GetRequiredService>(); + var mainWindow = new MainWindow(mainWindowLogger); + mainWindow.Show(); + } } diff --git a/ComplianceNFs.Monitor/ComplianceNFs.Monitor.csproj b/ComplianceNFs.Monitor/ComplianceNFs.Monitor.csproj index 35edbb2..65c6328 100644 --- a/ComplianceNFs.Monitor/ComplianceNFs.Monitor.csproj +++ b/ComplianceNFs.Monitor/ComplianceNFs.Monitor.csproj @@ -2,6 +2,9 @@ + + + diff --git a/ComplianceNFs.Monitor/LoggingBootstrapper.cs b/ComplianceNFs.Monitor/LoggingBootstrapper.cs new file mode 100644 index 0000000..85fb576 --- /dev/null +++ b/ComplianceNFs.Monitor/LoggingBootstrapper.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Debug; +using Microsoft.Extensions.Logging.Console; + +namespace ComplianceNFs.Monitor; + +public static class LoggingBootstrapper +{ + public static ServiceProvider CreateServiceProvider() + { + var services = new ServiceCollection(); + services.AddLogging(builder => + { + builder.AddDebug(); + builder.AddConsole(); + builder.SetMinimumLevel(LogLevel.Information); + }); + // Register other services as needed + return services.BuildServiceProvider(); + } +} diff --git a/ComplianceNFs.Monitor/MainWindow.xaml.cs b/ComplianceNFs.Monitor/MainWindow.xaml.cs index 7999bc5..fc84358 100644 --- a/ComplianceNFs.Monitor/MainWindow.xaml.cs +++ b/ComplianceNFs.Monitor/MainWindow.xaml.cs @@ -9,6 +9,7 @@ using System.Windows.Media.Imaging; using System.Windows.Navigation; using System.Windows.Shapes; using ComplianceNFs.Core.Application; +using Microsoft.Extensions.Logging; namespace ComplianceNFs.Monitor { @@ -17,10 +18,13 @@ namespace ComplianceNFs.Monitor /// public partial class MainWindow : Window { - public MainWindow() + private readonly ILogger? _logger; + public MainWindow(ILogger logger) { InitializeComponent(); + _logger = logger; DataContext = new MonitorViewModel(new DummyStatusStream()); + _logger?.LogInformation("MainWindow initialized"); } } diff --git a/ComplianceNFs.Service/ComplianceNFs.Service.csproj b/ComplianceNFs.Service/ComplianceNFs.Service.csproj index 2755fd5..dc5f960 100644 --- a/ComplianceNFs.Service/ComplianceNFs.Service.csproj +++ b/ComplianceNFs.Service/ComplianceNFs.Service.csproj @@ -10,6 +10,7 @@ + diff --git a/ComplianceNFs.Service/Program.cs b/ComplianceNFs.Service/Program.cs index db309a8..d991c25 100644 --- a/ComplianceNFs.Service/Program.cs +++ b/ComplianceNFs.Service/Program.cs @@ -31,7 +31,8 @@ IHost host = Host.CreateDefaultBuilder(args) var pgConnectionString = config["PostgresConnectionString"]; if (string.IsNullOrWhiteSpace(pgConnectionString)) throw new InvalidOperationException("PostgresConnectionString is missing in configuration."); - return new AttachmentRepository(pgConnectionString); + var logger = sp.GetRequiredService>(); + return new AttachmentRepository(pgConnectionString, logger); }); services.AddSingleton(); services.AddSingleton(); diff --git a/ComplianceNFs.Service/Worker.cs b/ComplianceNFs.Service/Worker.cs index e1dc633..7c8ec5e 100644 --- a/ComplianceNFs.Service/Worker.cs +++ b/ComplianceNFs.Service/Worker.cs @@ -21,32 +21,48 @@ public class Worker(ILogger logger, protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _logger.LogInformation("Worker starting at: {time}", DateTimeOffset.Now); - // Start mail ingestion (starts listening for new mail) - await _ingestionService.IngestAsync(); - // Subscribe to new invoice events and orchestrate workflow - if (_ingestionService is InvoiceIngestionService ingestionImpl) + try { - ingestionImpl.InvoiceProcessed += async invoice => + // Start mail ingestion (starts listening for new mail) + await _ingestionService.IngestAsync(); + // Subscribe to new invoice events and orchestrate workflow + if (_ingestionService is InvoiceIngestionService ingestionImpl) { - // 1. Match invoice - await _matchingService.MatchAsync(invoice); - // 2. Compliance validation - await _complianceService.ValidateAsync(invoice); - // 3. Notify if needed - if (invoice.Status == InvoiceStatus.TaxMismatch || invoice.Status == InvoiceStatus.VolumeMismatch || invoice.Status == InvoiceStatus.PriceMismatch) + ingestionImpl.InvoiceProcessed += async invoice => { - await _notificationService.NotifyAsync(invoice, invoice.DiscrepancyNotes ?? "Discrepancy detected"); - } - // 4. Archive - // (Assume raw file is available or can be loaded if needed) - // await _archivingService.ArchiveAsync(invoice, rawFile); - _logger.LogInformation("Invoice {NumeroNF} processed with status: {Status}", invoice.NumeroNF, invoice.Status); - }; + try + { + // 1. Match invoice + await _matchingService.MatchAsync(invoice); + // 2. Compliance validation + await _complianceService.ValidateAsync(invoice); + // 3. Notify if needed + if (invoice.Status == InvoiceStatus.TaxMismatch || invoice.Status == InvoiceStatus.VolumeMismatch || invoice.Status == InvoiceStatus.PriceMismatch) + { + await _notificationService.NotifyAsync(invoice, invoice.DiscrepancyNotes ?? "Discrepancy detected"); + _logger.LogWarning("Invoice {NumeroNF} has a discrepancy: {Notes}", invoice.NumeroNF, invoice.DiscrepancyNotes); + } + // 4. Archive + // (Assume raw file is available or can be loaded if needed) + // await _archivingService.ArchiveAsync(invoice, rawFile); + _logger.LogInformation("Invoice {NumeroNF} processed with status: {Status}", invoice.NumeroNF, invoice.Status); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing invoice {NumeroNF}", invoice.NumeroNF); + } + }; + } + // Keep the worker alive + while (!stoppingToken.IsCancellationRequested) + { + await Task.Delay(1000, stoppingToken); + } } - // Keep the worker alive - while (!stoppingToken.IsCancellationRequested) + catch (Exception ex) { - await Task.Delay(1000, stoppingToken); + _logger.LogCritical(ex, "Worker encountered a fatal error and is stopping."); + throw; } } } diff --git a/TODOs-and-Roadmap.md b/TODOs-and-Roadmap.md index 5c0db28..346da16 100644 --- a/TODOs-and-Roadmap.md +++ b/TODOs-and-Roadmap.md @@ -30,6 +30,7 @@ - [x] AttachmentRepository.SaveRawAsync: Implement actual insert into Postgres attachments table - [x] AttachmentRepository.UpdateMatchAsync: Implement actual update in Postgres +- [x] Add robust logging for DB operations and errors ### ComplianceNFs.Core/Application/Services @@ -43,6 +44,7 @@ - [x] NotificationService: Implement notification logic for mismatches - [x] ArchivingService: Implement archiving logic for final status +- [x] Add robust logging to application services ### ComplianceNFs.Monitor/MonitorViewModel.cs @@ -66,7 +68,7 @@ - [x] **XmlParser**: Use System.Xml to parse invoice XMLs and map to `ParsedInvoice`. - [x] **AccessDbRepository**: Implement queries to Access DB for buying records. - [x] **AttachmentRepository**: Implement Postgres insert/update for invoice attachments. -- [ ] Add robust logging and error handling for all infrastructure components. +- [x] Add robust logging and error handling for all infrastructure components. ### 2. Application Layer @@ -79,27 +81,28 @@ - [x] Wire up these services in DI in `Program.cs`. - [x] Add fallback and multi-invoice sum logic in `MatchingService.MatchAsync`. +- [x] Add robust logging to application services. ### 3. Service Host - [x] Ensure all services are registered and started in the Worker. -- [ ] Implement polling and retry logic as per configuration. +- [x] Implement polling and retry logic as per configuration. +- [x] Add robust logging to workflow orchestration. ### 4. WPF Monitor - [ ] Inject and subscribe to `IInvoiceStatusStream` in `MonitorViewModel`. - [ ] Implement `ForceScan` to trigger ingestion from UI. - [ ] Bind UI to show recent invoice status updates. +- [x] Add logging for UI events and errors. ### 5. Configuration & Testing - [ ] Test all configuration values from `appsettings.json`. -- [ ] Add error handling, logging, and validation. +- [x] Add error handling, logging, and validation. - [ ] Write integration tests for end-to-end flow. --- -**Tip:** - -- Tackle infrastructure TODOs first, then application services, then UI and orchestration. -- Use comments in code for any business logic or mapping details that need clarification. +**Note:** +Robust logging is now implemented across infrastructure, application, service, and UI layers. Review log output and adjust log levels as needed for production.