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.
This commit is contained in:
Giuliano Paschoalino 2025-06-18 17:38:52 -03:00
parent 606b841435
commit e6b2180c94
20 changed files with 354 additions and 190 deletions

View File

@ -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<InvoiceIngestionService> _logger;
public InvoiceIngestionService(IMailListener mailListener, IAttachmentRepository attachmentRepository, IXmlParser xmlParser, IPdfParser pdfParser)
public InvoiceIngestionService(IMailListener mailListener, IAttachmentRepository attachmentRepository, IXmlParser xmlParser, IPdfParser pdfParser, ILogger<InvoiceIngestionService> 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<EnergyInvoice, Task>? 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<MatchingService> _logger;
public MatchingService(IAccessDbRepository accessDbRepository, ILogger<MatchingService> 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<BuyingRecord>();
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<BuyingRecord>();
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;
}
}

View File

@ -6,6 +6,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0-preview.4.25258.110" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-preview.4.25258.110" />
</ItemGroup>
</Project>

View File

@ -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<BuyingRecord> {
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<IAccessDbRepository>();
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);

View File

@ -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<ILogger<AttachmentRepository>>();
var repo = new AttachmentRepository("Host=localhost;Port=5432;Database=test;Username=test;Password=test", mockLogger.Object);
var invoice = new EnergyInvoice
{
MailId = "mailid",

View File

@ -11,6 +11,7 @@
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="3.2.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-preview.4.25258.110" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.4.2" />

View File

@ -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<IAttachmentRepository>();
var mockXmlParser = new Mock<IXmlParser>();
var mockPdfParser = new Mock<IPdfParser>();
var mockLogger = new Mock<ILogger<InvoiceIngestionService>>();
var testParsed = new ParsedInvoice { CnpjComp = "123", NumeroNF = "456" };
mockXmlParser.Setup(x => x.Parse(It.IsAny<Stream>())).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

View File

@ -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<ILogger<MailListener>>();
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<MailListener> logger) : base(config, logger) { }
public new void RaiseNewMailReceivedForTest(MailMessage mail) => base.RaiseNewMailReceivedForTest(mail);
}
}

View File

@ -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<ILogger<MatchingService>>();
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<IAccessDbRepository>();
var mockLogger = new Mock<ILogger<MatchingService>>();
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<BuyingRecord> {
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<IAccessDbRepository>();
var mockLogger = new Mock<ILogger<MatchingService>>();
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

View File

@ -8,6 +8,7 @@
<PackageReference Include="MailKit" Version="4.12.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0-preview.4.25258.110" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0-preview.4.25258.110" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.0-preview.4.25258.110" />
<PackageReference Include="Microsoft.Office.Interop.Outlook" Version="15.0.4797.1004" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Npgsql" Version="9.0.3" />

View File

@ -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<MailMessage> NewMailReceived = delegate { };
private readonly IConfiguration _config;
private readonly ILogger<MailListener> _logger;
private readonly List<string> _allowList;
private bool _listening;
public MailListener(IConfiguration config)
public MailListener(IConfiguration config, ILogger<MailListener> logger)
{
_config = config;
_logger = logger;
_allowList = _config.GetSection("Mail:SupplierAllowList").Get<List<string>>() ?? [];
}
@ -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");
}
});
}

View File

@ -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<AccessDbRepository>? logger = null) : IAccessDbRepository
{
private readonly string _connectionString = connectionString;
private readonly ILogger<AccessDbRepository>? _logger = logger;
private const string BuyingRecordColumns = @"
Dados_TE.Cod_TE,
@ -51,17 +53,27 @@ namespace ComplianceNFs.Infrastructure.Repositories
public IEnumerable<BuyingRecord> GetByCnpj(string CNPJ_comp)
{
var results = new List<BuyingRecord>();
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<BuyingRecord> GetByCnpjAndMonth(string CNPJ_comp, int refMonth)
{
var results = new List<BuyingRecord>();
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;
}

View File

@ -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<AttachmentRepository> _logger;
public AttachmentRepository(string connectionString, ILogger<AttachmentRepository> 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;
}
}
}
}

View File

@ -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;
/// </summary>
public partial class App : Application
{
public static ServiceProvider? ServiceProvider { get; private set; }
private ILogger<App>? _logger;
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
// Setup DI and logging
ServiceProvider = LoggingBootstrapper.CreateServiceProvider();
_logger = ServiceProvider.GetRequiredService<ILogger<App>>();
_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<ILogger<MainWindow>>();
var mainWindow = new MainWindow(mainWindowLogger);
mainWindow.Show();
}
}

View File

@ -2,6 +2,9 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0-preview.4.25258.110" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.0-preview.4.25258.110" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="9.0.6" />
</ItemGroup>
<ItemGroup>

View File

@ -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();
}
}

View File

@ -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
/// </summary>
public partial class MainWindow : Window
{
public MainWindow()
private readonly ILogger<MainWindow>? _logger;
public MainWindow(ILogger<MainWindow> logger)
{
InitializeComponent();
_logger = logger;
DataContext = new MonitorViewModel(new DummyStatusStream());
_logger?.LogInformation("MainWindow initialized");
}
}

View File

@ -10,6 +10,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0-preview.4.25258.110" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0-preview.4.25258.110" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.0-preview.4.25258.110" />
</ItemGroup>
<ItemGroup>

View File

@ -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<Microsoft.Extensions.Logging.ILogger<AttachmentRepository>>();
return new AttachmentRepository(pgConnectionString, logger);
});
services.AddSingleton<IMailListener, MailListener>();
services.AddSingleton<IXmlParser, XmlParser>();

View File

@ -21,32 +21,48 @@ public class Worker(ILogger<Worker> 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;
}
}
}

View File

@ -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.