feat: Initialize ComplianceNFs project structure with core, infrastructure, service, and monitor components
- Created ComplianceNFs.Core project with domain entities and ports - Implemented BuyingRecord, EnergyInvoice, ParsedInvoice entities - Defined domain interfaces for mail listening, XML and PDF parsing, and repository access - Established ComplianceNFs.Infrastructure project with file archiving, mail listening, and data access implementations - Developed PDF and XML parsers for invoice data extraction - Set up AccessDbRepository and AttachmentRepository for data retrieval and storage - Created ComplianceNFs.Service project for background processing and service orchestration - Implemented Worker service for periodic tasks - Established ComplianceNFs.Monitor project with WPF UI for monitoring invoice statuses - Added ViewModel for UI data binding and command handling - Configured project files for .NET 9.0 and necessary package references - Created initial appsettings.json for configuration management - Added TODOs and roadmap for future development
This commit is contained in:
commit
690ab131aa
45
.gitignore
vendored
Normal file
45
.gitignore
vendored
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
# .NET build artifacts
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
*.user
|
||||||
|
*.suo
|
||||||
|
*.userosscache
|
||||||
|
*.sln.docstates
|
||||||
|
|
||||||
|
# VS Code
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# Test results
|
||||||
|
TestResults/
|
||||||
|
|
||||||
|
# Build logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
Thumbs.db
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Rider/JetBrains
|
||||||
|
.idea/
|
||||||
|
*.sln.iml
|
||||||
|
|
||||||
|
# Local settings
|
||||||
|
appsettings.Development.json
|
||||||
|
|
||||||
|
# Backup/history
|
||||||
|
.history/
|
||||||
|
|
||||||
|
# Global.json lock
|
||||||
|
project.lock.json
|
||||||
|
|
||||||
|
# Others
|
||||||
|
*.ncrunch*
|
||||||
|
*.localhistory
|
||||||
|
*.vs/
|
||||||
|
|
||||||
|
# Ignore all Debug/Release folders
|
||||||
|
**/bin/
|
||||||
|
**/obj/
|
||||||
|
**/Debug/
|
||||||
|
**/Release/
|
||||||
|
.fake
|
||||||
44
ComplianceNFs.Core/Application/ApplicationInterfaces.cs
Normal file
44
ComplianceNFs.Core/Application/ApplicationInterfaces.cs
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using ComplianceNFs.Core.Entities;
|
||||||
|
|
||||||
|
namespace ComplianceNFs.Core.Application
|
||||||
|
{
|
||||||
|
// Handles ingestion of invoices from mail attachments
|
||||||
|
public interface IInvoiceIngestionService
|
||||||
|
{
|
||||||
|
Task IngestAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handles matching logic for invoices
|
||||||
|
public interface IMatchingService
|
||||||
|
{
|
||||||
|
Task MatchAsync(EnergyInvoice invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handles compliance validation
|
||||||
|
public interface IComplianceService
|
||||||
|
{
|
||||||
|
Task ValidateAsync(EnergyInvoice invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handles notifications for mismatches
|
||||||
|
public interface INotificationService
|
||||||
|
{
|
||||||
|
Task NotifyAsync(EnergyInvoice invoice, string message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handles archiving of files
|
||||||
|
public interface IArchivingService
|
||||||
|
{
|
||||||
|
Task ArchiveAsync(EnergyInvoice invoice, byte[] rawFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For streaming invoice status updates (for Monitor)
|
||||||
|
public interface IInvoiceStatusStream
|
||||||
|
{
|
||||||
|
event Action<EnergyInvoice> StatusUpdated;
|
||||||
|
IEnumerable<EnergyInvoice> GetRecent(int count = 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
151
ComplianceNFs.Core/Application/Services/ApplicationServices.cs
Normal file
151
ComplianceNFs.Core/Application/Services/ApplicationServices.cs
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Net.Mail;
|
||||||
|
using System.IO;
|
||||||
|
using ComplianceNFs.Core.Entities;
|
||||||
|
using ComplianceNFs.Core.Ports;
|
||||||
|
|
||||||
|
namespace ComplianceNFs.Core.Application.Services
|
||||||
|
{
|
||||||
|
// Handles ingestion of invoices from mail attachments
|
||||||
|
public class InvoiceIngestionService : IInvoiceIngestionService
|
||||||
|
{
|
||||||
|
private readonly IMailListener _mailListener;
|
||||||
|
private readonly IAttachmentRepository _attachmentRepository;
|
||||||
|
private readonly IXmlParser _xmlParser;
|
||||||
|
private readonly IPdfParser _pdfParser;
|
||||||
|
public InvoiceIngestionService(IMailListener mailListener, IAttachmentRepository attachmentRepository, IXmlParser xmlParser, IPdfParser pdfParser)
|
||||||
|
{
|
||||||
|
_mailListener = mailListener;
|
||||||
|
_attachmentRepository = attachmentRepository;
|
||||||
|
_xmlParser = xmlParser;
|
||||||
|
_pdfParser = pdfParser;
|
||||||
|
_mailListener.NewMailReceived += OnNewMailReceived;
|
||||||
|
}
|
||||||
|
private async void OnNewMailReceived(MailMessage mail)
|
||||||
|
{
|
||||||
|
// Download attachments, parse, map to EnergyInvoice, save via _attachmentRepository
|
||||||
|
foreach (var attachment in mail.Attachments)
|
||||||
|
{
|
||||||
|
if (attachment is System.Net.Mail.Attachment att && att.Name != null)
|
||||||
|
{
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
att.ContentStream.CopyTo(stream);
|
||||||
|
stream.Position = 0;
|
||||||
|
ParsedInvoice parsed = new ParsedInvoice();
|
||||||
|
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
|
||||||
|
{
|
||||||
|
Filename = att.Name,
|
||||||
|
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,
|
||||||
|
CnpjComp = parsed.CnpjComp,
|
||||||
|
CnpjVend = parsed.CnpjVend,
|
||||||
|
MontNF = parsed.MontNF,
|
||||||
|
PrecNF = parsed.PrecNF,
|
||||||
|
ValorSemImpostos = parsed.ValorSemImpostos,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public Task IngestAsync()
|
||||||
|
{
|
||||||
|
_mailListener.StartListening();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handles matching logic for invoices
|
||||||
|
public class MatchingService : IMatchingService
|
||||||
|
{
|
||||||
|
private readonly IAccessDbRepository _accessDbRepository;
|
||||||
|
public MatchingService(IAccessDbRepository accessDbRepository)
|
||||||
|
{
|
||||||
|
_accessDbRepository = accessDbRepository;
|
||||||
|
}
|
||||||
|
public Task MatchAsync(EnergyInvoice invoice)
|
||||||
|
{
|
||||||
|
// Example: Primary match logic (simplified)
|
||||||
|
var records = _accessDbRepository.GetByUnidade(invoice.CnpjComp);
|
||||||
|
foreach (var record in records)
|
||||||
|
{
|
||||||
|
if (record.CnpjComp == invoice.CnpjComp && record.CnpjVend == invoice.CnpjVend)
|
||||||
|
{
|
||||||
|
var volMatch = Math.Abs(record.MontLO - invoice.MontNF) / record.MontLO <= 0.01m;
|
||||||
|
var priceMatch = Math.Abs(record.PrecLO - invoice.PrecNF) / record.PrecLO <= 0.005m;
|
||||||
|
if (volMatch && priceMatch)
|
||||||
|
{
|
||||||
|
invoice.MatchedCodTE = record.CodTE;
|
||||||
|
invoice.Status = InvoiceStatus.Matched;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// TODO: Add fallback and multi-invoice sum logic
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handles compliance validation
|
||||||
|
public class ComplianceService : IComplianceService
|
||||||
|
{
|
||||||
|
public Task ValidateAsync(EnergyInvoice invoice)
|
||||||
|
{
|
||||||
|
// Example: Tax compliance check
|
||||||
|
if (invoice.Status == InvoiceStatus.Matched || invoice.Status == InvoiceStatus.FallbackMatched)
|
||||||
|
{
|
||||||
|
var impliedTax = invoice.ValorFinalComImpostos / (invoice.ValorSemImpostos == 0 ? 1 : invoice.ValorSemImpostos) - 1;
|
||||||
|
if (Math.Abs(impliedTax - invoice.IcmsNF) > 0.01m)
|
||||||
|
{
|
||||||
|
invoice.Status = InvoiceStatus.TaxMismatch;
|
||||||
|
invoice.DiscrepancyNotes = $"Tax mismatch: implied={impliedTax:P2}, expected={invoice.IcmsNF:P2}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
invoice.Status = InvoiceStatus.Validated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handles notifications for mismatches
|
||||||
|
public class NotificationService : INotificationService
|
||||||
|
{
|
||||||
|
public Task NotifyAsync(EnergyInvoice invoice, string message)
|
||||||
|
{
|
||||||
|
// Example: Send notification (placeholder)
|
||||||
|
// In production, use SMTP or other email service
|
||||||
|
Console.WriteLine($"Notify {invoice.SupplierEmail}: {message}");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handles archiving of files
|
||||||
|
public class ArchivingService : IArchivingService
|
||||||
|
{
|
||||||
|
private readonly IFileArchiver _fileArchiver;
|
||||||
|
public ArchivingService(IFileArchiver fileArchiver)
|
||||||
|
{
|
||||||
|
_fileArchiver = fileArchiver;
|
||||||
|
}
|
||||||
|
public Task ArchiveAsync(EnergyInvoice invoice, byte[] rawFile)
|
||||||
|
{
|
||||||
|
return _fileArchiver.ArchiveAsync(invoice, rawFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
ComplianceNFs.Core/ComplianceNFs.Core.csproj
Normal file
12
ComplianceNFs.Core/ComplianceNFs.Core.csproj
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0-preview.4.25258.110" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
1
ComplianceNFs.Core/Entities/.keep
Normal file
1
ComplianceNFs.Core/Entities/.keep
Normal file
@ -0,0 +1 @@
|
|||||||
|
// Contains domain entities for ComplianceNFs
|
||||||
17
ComplianceNFs.Core/Entities/BuyingRecord.cs
Normal file
17
ComplianceNFs.Core/Entities/BuyingRecord.cs
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace ComplianceNFs.Core.Entities
|
||||||
|
{
|
||||||
|
public class BuyingRecord
|
||||||
|
{
|
||||||
|
public int CodTE { get; set; }
|
||||||
|
public string CodSmartUnidade { get; set; }
|
||||||
|
public int Mes { get; set; } // month as integer
|
||||||
|
public int Ano { get; set; } // year as integer
|
||||||
|
public string CnpjComp { get; set; }
|
||||||
|
public string CnpjVend { get; set; }
|
||||||
|
public decimal MontLO { get; set; } // expected volume
|
||||||
|
public decimal PrecLO { get; set; } // expected unit price
|
||||||
|
// … other client fields omitted
|
||||||
|
}
|
||||||
|
}
|
||||||
42
ComplianceNFs.Core/Entities/EnergyInvoice.cs
Normal file
42
ComplianceNFs.Core/Entities/EnergyInvoice.cs
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace ComplianceNFs.Core.Entities
|
||||||
|
{
|
||||||
|
public class EnergyInvoice
|
||||||
|
{
|
||||||
|
public int InvoiceId { get; set; } // PK
|
||||||
|
public string Filename { get; set; }
|
||||||
|
public string SupplierEmail { get; set; }
|
||||||
|
public string ConversationId { get; set; }
|
||||||
|
public DateTime ReceivedDate { get; set; }
|
||||||
|
public string Md5 { get; set; }
|
||||||
|
public string CnpjComp { get; set; }
|
||||||
|
public string CnpjVend { get; set; }
|
||||||
|
public decimal MontNF { get; set; }
|
||||||
|
public decimal PrecNF { get; set; }
|
||||||
|
public decimal ValorSemImpostos { get; set; }
|
||||||
|
public decimal ValorFinalComImpostos { get; set; }
|
||||||
|
public string RsComp { get; set; }
|
||||||
|
public string RsVend { get; set; }
|
||||||
|
public string NumeroNF { get; set; }
|
||||||
|
public decimal IcmsNF { get; set; }
|
||||||
|
public string UfComp { get; set; }
|
||||||
|
public string UfVend { get; set; }
|
||||||
|
public int? MatchedCodTE { get; set; } // FK to BuyingRecord
|
||||||
|
public InvoiceStatus Status { get; set; }
|
||||||
|
public string DiscrepancyNotes { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum InvoiceStatus
|
||||||
|
{
|
||||||
|
Pending,
|
||||||
|
Matched,
|
||||||
|
FallbackMatched,
|
||||||
|
VolumeMismatch,
|
||||||
|
PriceMismatch,
|
||||||
|
TaxMismatch,
|
||||||
|
NotFound,
|
||||||
|
Error,
|
||||||
|
Validated
|
||||||
|
}
|
||||||
|
}
|
||||||
18
ComplianceNFs.Core/Entities/ParsedInvoice.cs
Normal file
18
ComplianceNFs.Core/Entities/ParsedInvoice.cs
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
namespace ComplianceNFs.Core.Entities
|
||||||
|
{
|
||||||
|
public class ParsedInvoice
|
||||||
|
{
|
||||||
|
public string CnpjComp { get; set; }
|
||||||
|
public string CnpjVend { get; set; }
|
||||||
|
public decimal MontNF { get; set; }
|
||||||
|
public decimal PrecNF { get; set; }
|
||||||
|
public decimal ValorSemImpostos { get; set; }
|
||||||
|
public decimal ValorFinalComImpostos { get; set; }
|
||||||
|
public string RsComp { get; set; }
|
||||||
|
public string RsVend { get; set; }
|
||||||
|
public string NumeroNF { get; set; }
|
||||||
|
public decimal IcmsNF { get; set; }
|
||||||
|
public string UfComp { get; set; }
|
||||||
|
public string UfVend { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
1
ComplianceNFs.Core/Ports/.keep
Normal file
1
ComplianceNFs.Core/Ports/.keep
Normal file
@ -0,0 +1 @@
|
|||||||
|
// Contains domain interfaces (ports) for ComplianceNFs
|
||||||
41
ComplianceNFs.Core/Ports/DomainInterfaces.cs
Normal file
41
ComplianceNFs.Core/Ports/DomainInterfaces.cs
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Net.Mail;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace ComplianceNFs.Core.Ports
|
||||||
|
{
|
||||||
|
public interface IMailListener
|
||||||
|
{
|
||||||
|
void StartListening();
|
||||||
|
event Action<MailMessage> NewMailReceived;
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IXmlParser
|
||||||
|
{
|
||||||
|
Entities.ParsedInvoice Parse(Stream xmlStream);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IPdfParser
|
||||||
|
{
|
||||||
|
Entities.ParsedInvoice Parse(Stream pdfStream);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IAccessDbRepository
|
||||||
|
{
|
||||||
|
IEnumerable<Entities.BuyingRecord> GetByUnidade(string codSmartUnidade);
|
||||||
|
IEnumerable<Entities.BuyingRecord> GetByUnidadeAndMonth(string codSmartUnidade, int month, int year);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IAttachmentRepository
|
||||||
|
{
|
||||||
|
Task SaveRawAsync(Entities.EnergyInvoice invoice);
|
||||||
|
Task UpdateMatchAsync(int invoiceId, int matchedCodTE, Entities.InvoiceStatus status, string notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IFileArchiver
|
||||||
|
{
|
||||||
|
Task ArchiveAsync(Entities.EnergyInvoice invoice, byte[] rawFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="3.2.0" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.4.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\ComplianceNFs.Infrastructure\ComplianceNFs.Infrastructure.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
76
ComplianceNFs.Infrastructure.Tests/UnitTest1.cs
Normal file
76
ComplianceNFs.Infrastructure.Tests/UnitTest1.cs
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using ComplianceNFs.Core.Entities;
|
||||||
|
using ComplianceNFs.Infrastructure.Archiving;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ComplianceNFs.Infrastructure.Tests
|
||||||
|
{
|
||||||
|
public class FileArchiverTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _testBasePath;
|
||||||
|
public FileArchiverTests()
|
||||||
|
{
|
||||||
|
_testBasePath = Path.Combine(Path.GetTempPath(), "ComplianceNFsTestArchive");
|
||||||
|
if (Directory.Exists(_testBasePath))
|
||||||
|
Directory.Delete(_testBasePath, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ArchiveAsync_CreatesFolderAndWritesFile()
|
||||||
|
{
|
||||||
|
var archiver = new FileArchiver(_testBasePath);
|
||||||
|
var invoice = new EnergyInvoice
|
||||||
|
{
|
||||||
|
Filename = "testfile.txt",
|
||||||
|
Status = InvoiceStatus.Validated
|
||||||
|
};
|
||||||
|
var data = new byte[] { 1, 2, 3, 4 };
|
||||||
|
|
||||||
|
await archiver.ArchiveAsync(invoice, data);
|
||||||
|
|
||||||
|
var expectedFolder = Path.Combine(_testBasePath, "Validated");
|
||||||
|
var expectedFile = Path.Combine(expectedFolder, "testfile.txt");
|
||||||
|
Assert.True(Directory.Exists(expectedFolder));
|
||||||
|
Assert.True(File.Exists(expectedFile));
|
||||||
|
var fileData = await File.ReadAllBytesAsync(expectedFile);
|
||||||
|
Assert.Equal(data, fileData);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ArchiveAsync_OverwritesExistingFile()
|
||||||
|
{
|
||||||
|
var archiver = new FileArchiver(_testBasePath);
|
||||||
|
var invoice = new EnergyInvoice
|
||||||
|
{
|
||||||
|
Filename = "testfile.txt",
|
||||||
|
Status = InvoiceStatus.Validated
|
||||||
|
};
|
||||||
|
var data1 = new byte[] { 1, 2, 3 };
|
||||||
|
var data2 = new byte[] { 9, 8, 7 };
|
||||||
|
|
||||||
|
await archiver.ArchiveAsync(invoice, data1);
|
||||||
|
await archiver.ArchiveAsync(invoice, data2);
|
||||||
|
|
||||||
|
var expectedFile = Path.Combine(_testBasePath, "Validated", "testfile.txt");
|
||||||
|
var fileData = await File.ReadAllBytesAsync(expectedFile);
|
||||||
|
Assert.Equal(data2, fileData);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_testBasePath))
|
||||||
|
Directory.Delete(_testBasePath, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UnitTest1
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Test1()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
ComplianceNFs.Infrastructure/Archiving/FileArchiver.cs
Normal file
33
ComplianceNFs.Infrastructure/Archiving/FileArchiver.cs
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using ComplianceNFs.Core.Entities;
|
||||||
|
using ComplianceNFs.Core.Ports;
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace ComplianceNFs.Infrastructure.Archiving
|
||||||
|
{
|
||||||
|
// Moves files to archive folders by status
|
||||||
|
public class FileArchiver : IFileArchiver
|
||||||
|
{
|
||||||
|
private readonly string _basePath;
|
||||||
|
public FileArchiver(string basePath)
|
||||||
|
{
|
||||||
|
_basePath = basePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ArchiveAsync(EnergyInvoice invoice, byte[] rawFile)
|
||||||
|
{
|
||||||
|
// Create subfolder for invoice.Status
|
||||||
|
var statusFolder = Path.Combine(_basePath, invoice.Status.ToString());
|
||||||
|
if (!Directory.Exists(statusFolder))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(statusFolder);
|
||||||
|
}
|
||||||
|
// Build file path
|
||||||
|
var filePath = Path.Combine(statusFolder, invoice.Filename);
|
||||||
|
// Write file (overwrite if exists)
|
||||||
|
await File.WriteAllBytesAsync(filePath, rawFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\ComplianceNFs.Core\ComplianceNFs.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<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="Newtonsoft.Json" Version="13.0.3" />
|
||||||
|
<PackageReference Include="Npgsql" Version="9.0.3" />
|
||||||
|
<PackageReference Include="System.Data.OleDb" Version="10.0.0-preview.4.25258.110" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
57
ComplianceNFs.Infrastructure/Mail/MailListener.cs
Normal file
57
ComplianceNFs.Infrastructure/Mail/MailListener.cs
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
using System;
|
||||||
|
using System.Net.Mail;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using ComplianceNFs.Core.Ports;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using MailKit.Net.Imap;
|
||||||
|
using MailKit.Security;
|
||||||
|
using MimeKit;
|
||||||
|
|
||||||
|
namespace ComplianceNFs.Infrastructure.Mail
|
||||||
|
{
|
||||||
|
public class MailListener : IMailListener
|
||||||
|
{
|
||||||
|
public event Action<MailMessage> NewMailReceived;
|
||||||
|
private readonly IConfiguration _config;
|
||||||
|
private ImapClient _client;
|
||||||
|
private readonly List<string> _allowList;
|
||||||
|
private bool _listening;
|
||||||
|
|
||||||
|
public MailListener(IConfiguration config)
|
||||||
|
{
|
||||||
|
_config = config;
|
||||||
|
_allowList = _config.GetSection("Mail:SupplierAllowList").Get<List<string>>() ?? new List<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void StartListening()
|
||||||
|
{
|
||||||
|
if (_listening) return;
|
||||||
|
_listening = true;
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
_client = new ImapClient();
|
||||||
|
await _client.ConnectAsync(_config["Mail:Host"], int.Parse(_config["Mail:Port"] ?? "0"), SecureSocketOptions.SslOnConnect);
|
||||||
|
await _client.AuthenticateAsync(_config["Mail:User"], _config["Mail:Password"]);
|
||||||
|
await _client.Inbox.OpenAsync(MailKit.FolderAccess.ReadOnly);
|
||||||
|
foreach (var uid in await _client.Inbox.SearchAsync(MailKit.Search.SearchQuery.NotSeen))
|
||||||
|
{
|
||||||
|
var message = await _client.Inbox.GetMessageAsync(uid);
|
||||||
|
var senderAddress = message.From.Mailboxes.FirstOrDefault()?.Address;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(senderAddress) && _allowList.Contains(senderAddress))
|
||||||
|
{
|
||||||
|
var mailMsg = new MailMessage
|
||||||
|
{
|
||||||
|
From = new MailAddress(senderAddress), // Fix for CS1922 and CS8670
|
||||||
|
Subject = message.Subject,
|
||||||
|
Body = message.TextBody
|
||||||
|
};
|
||||||
|
NewMailReceived?.Invoke(mailMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// For demo: not a real polling loop. Add timer/polling for production.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
31
ComplianceNFs.Infrastructure/Parsers/PdfParser.cs
Normal file
31
ComplianceNFs.Infrastructure/Parsers/PdfParser.cs
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using ComplianceNFs.Core.Entities;
|
||||||
|
using ComplianceNFs.Core.Ports;
|
||||||
|
|
||||||
|
namespace ComplianceNFs.Infrastructure.Parsers
|
||||||
|
{
|
||||||
|
public class PdfParser : IPdfParser
|
||||||
|
{
|
||||||
|
public ParsedInvoice Parse(Stream pdfStream)
|
||||||
|
{
|
||||||
|
// Minimal demo: just read bytes as text (replace with real PDF parsing in production)
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
pdfStream.CopyTo(ms);
|
||||||
|
var text = System.Text.Encoding.UTF8.GetString(ms.ToArray());
|
||||||
|
// Example: extract CNPJ and values using regex (replace with real patterns)
|
||||||
|
var cnpjComp = Regex.Match(text, @"CNPJComp: (\d{14})").Groups[1].Value;
|
||||||
|
var cnpjVend = Regex.Match(text, @"CNPJVend: (\d{14})").Groups[1].Value;
|
||||||
|
var montNF = decimal.TryParse(Regex.Match(text, @"MontNF: ([\d,.]+)").Groups[1].Value, out var m) ? m : 0;
|
||||||
|
var precNF = decimal.TryParse(Regex.Match(text, @"PrecNF: ([\d,.]+)").Groups[1].Value, out var p) ? p : 0;
|
||||||
|
return new ParsedInvoice
|
||||||
|
{
|
||||||
|
CnpjComp = cnpjComp,
|
||||||
|
CnpjVend = cnpjVend,
|
||||||
|
MontNF = montNF,
|
||||||
|
PrecNF = precNF
|
||||||
|
// ...fill other fields as needed
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
ComplianceNFs.Infrastructure/Parsers/XmlParser.cs
Normal file
33
ComplianceNFs.Infrastructure/Parsers/XmlParser.cs
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
using System.IO;
|
||||||
|
using ComplianceNFs.Core.Entities;
|
||||||
|
using ComplianceNFs.Core.Ports;
|
||||||
|
|
||||||
|
namespace ComplianceNFs.Infrastructure.Parsers
|
||||||
|
{
|
||||||
|
// Placeholder: fill in actual XML parsing logic
|
||||||
|
public class XmlParser : IXmlParser
|
||||||
|
{
|
||||||
|
public ParsedInvoice Parse(Stream xmlStream)
|
||||||
|
{
|
||||||
|
// Use System.Xml to parse known elements
|
||||||
|
var doc = new System.Xml.XmlDocument();
|
||||||
|
doc.Load(xmlStream);
|
||||||
|
var invoice = new ParsedInvoice
|
||||||
|
{
|
||||||
|
CnpjComp = doc.SelectSingleNode("//CNPJComp")?.InnerText,
|
||||||
|
CnpjVend = doc.SelectSingleNode("//CNPJVend")?.InnerText,
|
||||||
|
MontNF = decimal.TryParse(doc.SelectSingleNode("//MontNF")?.InnerText, out var mont) ? mont : 0,
|
||||||
|
PrecNF = decimal.TryParse(doc.SelectSingleNode("//PrecNF")?.InnerText, out var prec) ? prec : 0,
|
||||||
|
ValorSemImpostos = decimal.TryParse(doc.SelectSingleNode("//ValorSemImpostos")?.InnerText, out var vsi) ? vsi : 0,
|
||||||
|
ValorFinalComImpostos = decimal.TryParse(doc.SelectSingleNode("//ValorFinalComImpostos")?.InnerText, out var vfi) ? vfi : 0,
|
||||||
|
RsComp = doc.SelectSingleNode("//RsComp")?.InnerText,
|
||||||
|
RsVend = doc.SelectSingleNode("//RsVend")?.InnerText,
|
||||||
|
NumeroNF = doc.SelectSingleNode("//NumeroNF")?.InnerText,
|
||||||
|
IcmsNF = decimal.TryParse(doc.SelectSingleNode("//IcmsNF")?.InnerText, out var icms) ? icms : 0,
|
||||||
|
UfComp = doc.SelectSingleNode("//UfComp")?.InnerText,
|
||||||
|
UfVend = doc.SelectSingleNode("//UfVend")?.InnerText
|
||||||
|
};
|
||||||
|
return invoice;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Data;
|
||||||
|
using System.Data.OleDb;
|
||||||
|
using ComplianceNFs.Core.Entities;
|
||||||
|
using ComplianceNFs.Core.Ports;
|
||||||
|
|
||||||
|
namespace ComplianceNFs.Infrastructure.Repositories
|
||||||
|
{
|
||||||
|
// Placeholder: fill in actual SQL and mapping logic
|
||||||
|
public class AccessDbRepository : IAccessDbRepository
|
||||||
|
{
|
||||||
|
private readonly string _connectionString;
|
||||||
|
public AccessDbRepository(string connectionString)
|
||||||
|
{
|
||||||
|
_connectionString = connectionString;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<BuyingRecord> GetByUnidade(string codSmartUnidade)
|
||||||
|
{
|
||||||
|
var results = new List<BuyingRecord>();
|
||||||
|
using (var conn = new OleDbConnection(_connectionString))
|
||||||
|
{
|
||||||
|
conn.Open();
|
||||||
|
var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT Cod_TE, Cod_Smart_unidade, Mes, Ano, CNPJ_comp, CNPJ_vend, Mont_LO, Prec_LO FROM Dados_TE WHERE Cod_Smart_unidade = ?";
|
||||||
|
cmd.Parameters.AddWithValue("@CodSmartUnidade", codSmartUnidade);
|
||||||
|
using (var reader = cmd.ExecuteReader())
|
||||||
|
{
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
results.Add(new BuyingRecord
|
||||||
|
{
|
||||||
|
CodTE = reader.GetInt32(0),
|
||||||
|
CodSmartUnidade = reader.GetString(1),
|
||||||
|
Mes = reader.GetInt32(2),
|
||||||
|
Ano = reader.GetInt32(3),
|
||||||
|
CnpjComp = reader.GetString(4),
|
||||||
|
CnpjVend = reader.GetString(5),
|
||||||
|
MontLO = reader.GetDecimal(6),
|
||||||
|
PrecLO = reader.GetDecimal(7)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<BuyingRecord> GetByUnidadeAndMonth(string codSmartUnidade, int month, int year)
|
||||||
|
{
|
||||||
|
var results = new List<BuyingRecord>();
|
||||||
|
using (var conn = new OleDbConnection(_connectionString))
|
||||||
|
{
|
||||||
|
conn.Open();
|
||||||
|
var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT Cod_TE, Cod_Smart_unidade, Mes, Ano, CNPJ_comp, CNPJ_vend, Mont_LO, Prec_LO FROM Dados_TE WHERE Cod_Smart_unidade = ? AND Mes = ? AND Ano = ?";
|
||||||
|
cmd.Parameters.AddWithValue("@CodSmartUnidade", codSmartUnidade);
|
||||||
|
cmd.Parameters.AddWithValue("@Mes", month);
|
||||||
|
cmd.Parameters.AddWithValue("@Ano", year);
|
||||||
|
using (var reader = cmd.ExecuteReader())
|
||||||
|
{
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
results.Add(new BuyingRecord
|
||||||
|
{
|
||||||
|
CodTE = reader.GetInt32(0),
|
||||||
|
CodSmartUnidade = reader.GetString(1),
|
||||||
|
Mes = reader.GetInt32(2),
|
||||||
|
Ano = reader.GetInt32(3),
|
||||||
|
CnpjComp = reader.GetString(4),
|
||||||
|
CnpjVend = reader.GetString(5),
|
||||||
|
MontLO = reader.GetDecimal(6),
|
||||||
|
PrecLO = reader.GetDecimal(7)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using ComplianceNFs.Core.Entities;
|
||||||
|
using ComplianceNFs.Core.Ports;
|
||||||
|
using Npgsql;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace ComplianceNFs.Infrastructure.Repositories
|
||||||
|
{
|
||||||
|
// Placeholder: fill in actual SQL and mapping logic
|
||||||
|
public class AttachmentRepository : IAttachmentRepository
|
||||||
|
{
|
||||||
|
private readonly string _connectionString;
|
||||||
|
public AttachmentRepository(string connectionString)
|
||||||
|
{
|
||||||
|
_connectionString = connectionString;
|
||||||
|
}
|
||||||
|
|
||||||
|
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", invoice.Md5);
|
||||||
|
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", invoice.MontNF);
|
||||||
|
cmd.Parameters.AddWithValue("@prec_nf", invoice.PrecNF);
|
||||||
|
cmd.Parameters.AddWithValue("@valor_sem_imp", invoice.ValorSemImpostos);
|
||||||
|
cmd.Parameters.AddWithValue("@valor_com_imp", invoice.ValorFinalComImpostos);
|
||||||
|
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", invoice.IcmsNF);
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateMatchAsync(int invoiceId, int 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
ComplianceNFs.Monitor/App.xaml
Normal file
9
ComplianceNFs.Monitor/App.xaml
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<Application x:Class="ComplianceNFs.Monitor.App"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:local="clr-namespace:ComplianceNFs.Monitor"
|
||||||
|
StartupUri="MainWindow.xaml">
|
||||||
|
<Application.Resources>
|
||||||
|
|
||||||
|
</Application.Resources>
|
||||||
|
</Application>
|
||||||
13
ComplianceNFs.Monitor/App.xaml.cs
Normal file
13
ComplianceNFs.Monitor/App.xaml.cs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
using System.Configuration;
|
||||||
|
using System.Data;
|
||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace ComplianceNFs.Monitor;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Interaction logic for App.xaml
|
||||||
|
/// </summary>
|
||||||
|
public partial class App : Application
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
10
ComplianceNFs.Monitor/AssemblyInfo.cs
Normal file
10
ComplianceNFs.Monitor/AssemblyInfo.cs
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
[assembly:ThemeInfo(
|
||||||
|
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
|
||||||
|
//(used if a resource is not found in the page,
|
||||||
|
// or application resource dictionaries)
|
||||||
|
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
|
||||||
|
//(used if a resource is not found in the page,
|
||||||
|
// app, or any theme specific resource dictionaries)
|
||||||
|
)]
|
||||||
19
ComplianceNFs.Monitor/ComplianceNFs.Monitor.csproj
Normal file
19
ComplianceNFs.Monitor/ComplianceNFs.Monitor.csproj
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.WindowsDesktop">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0-preview.4.25258.110" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\ComplianceNFs.Core\ComplianceNFs.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net9.0-windows</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<UseWPF>true</UseWPF>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
25
ComplianceNFs.Monitor/MainWindow.xaml
Normal file
25
ComplianceNFs.Monitor/MainWindow.xaml
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<Window x:Class="ComplianceNFs.Monitor.MainWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:local="clr-namespace:ComplianceNFs.Monitor"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
Title="ComplianceNFs Monitor" Height="450" Width="800">
|
||||||
|
<Grid Margin="10">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<ListView x:Name="LogList" Grid.Row="0" ItemsSource="{Binding RecentInvoices}">
|
||||||
|
<ListView.View>
|
||||||
|
<GridView>
|
||||||
|
<GridViewColumn Header="Timestamp" DisplayMemberBinding="{Binding ReceivedDate}" Width="150"/>
|
||||||
|
<GridViewColumn Header="Filename" DisplayMemberBinding="{Binding Filename}" Width="250"/>
|
||||||
|
<GridViewColumn Header="Status" DisplayMemberBinding="{Binding Status}" Width="120"/>
|
||||||
|
</GridView>
|
||||||
|
</ListView.View>
|
||||||
|
</ListView>
|
||||||
|
<Button Grid.Row="1" Content="Force Scan" Command="{Binding ForceScanCommand}" Height="32" Width="120" HorizontalAlignment="Right" Margin="0,10,0,0"/>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
24
ComplianceNFs.Monitor/MainWindow.xaml.cs
Normal file
24
ComplianceNFs.Monitor/MainWindow.xaml.cs
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Data;
|
||||||
|
using System.Windows.Documents;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
using System.Windows.Navigation;
|
||||||
|
using System.Windows.Shapes;
|
||||||
|
|
||||||
|
namespace ComplianceNFs.Monitor;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Interaction logic for MainWindow.xaml
|
||||||
|
/// </summary>
|
||||||
|
public partial class MainWindow : Window
|
||||||
|
{
|
||||||
|
public MainWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
DataContext = new MonitorViewModel();
|
||||||
|
}
|
||||||
|
}
|
||||||
47
ComplianceNFs.Monitor/MonitorViewModel.cs
Normal file
47
ComplianceNFs.Monitor/MonitorViewModel.cs
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using ComplianceNFs.Core.Entities;
|
||||||
|
|
||||||
|
namespace ComplianceNFs.Monitor
|
||||||
|
{
|
||||||
|
public class MonitorViewModel : INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
public ObservableCollection<EnergyInvoice> RecentInvoices { get; } = new();
|
||||||
|
public ICommand ForceScanCommand { get; }
|
||||||
|
|
||||||
|
public event PropertyChangedEventHandler PropertyChanged;
|
||||||
|
|
||||||
|
public MonitorViewModel()
|
||||||
|
{
|
||||||
|
// TODO: Inject IInvoiceStatusStream and subscribe to updates
|
||||||
|
ForceScanCommand = new RelayCommand(_ => ForceScan());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ForceScan()
|
||||||
|
{
|
||||||
|
// TODO: Call service to force ingestion cycle
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void OnPropertyChanged([CallerMemberName] string name = null)
|
||||||
|
{
|
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RelayCommand : ICommand
|
||||||
|
{
|
||||||
|
private readonly Action<object> _execute;
|
||||||
|
private readonly Func<object, bool> _canExecute;
|
||||||
|
public RelayCommand(Action<object> execute, Func<object, bool> canExecute = null)
|
||||||
|
{
|
||||||
|
_execute = execute;
|
||||||
|
_canExecute = canExecute;
|
||||||
|
}
|
||||||
|
public bool CanExecute(object parameter) => _canExecute == null || _canExecute(parameter);
|
||||||
|
public void Execute(object parameter) => _execute(parameter);
|
||||||
|
public event EventHandler CanExecuteChanged { add { } remove { } }
|
||||||
|
}
|
||||||
|
}
|
||||||
19
ComplianceNFs.Service/ComplianceNFs.Service.csproj
Normal file
19
ComplianceNFs.Service/ComplianceNFs.Service.csproj
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<UserSecretsId>dotnet-ComplianceNFs.Service-386f44bf-03c8-490c-96b9-3852d8c3d01a</UserSecretsId>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<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" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\ComplianceNFs.Core\ComplianceNFs.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\ComplianceNFs.Infrastructure\ComplianceNFs.Infrastructure.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
36
ComplianceNFs.Service/Program.cs
Normal file
36
ComplianceNFs.Service/Program.cs
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
using ComplianceNFs.Service;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using ComplianceNFs.Core.Ports;
|
||||||
|
using ComplianceNFs.Infrastructure.Repositories;
|
||||||
|
using ComplianceNFs.Infrastructure.Mail;
|
||||||
|
using ComplianceNFs.Infrastructure.Parsers;
|
||||||
|
using ComplianceNFs.Infrastructure.Archiving;
|
||||||
|
|
||||||
|
IHost host = Host.CreateDefaultBuilder(args)
|
||||||
|
.ConfigureAppConfiguration((hostingContext, config) =>
|
||||||
|
{
|
||||||
|
config.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
|
||||||
|
})
|
||||||
|
.ConfigureServices((context, services) =>
|
||||||
|
{
|
||||||
|
var config = context.Configuration;
|
||||||
|
// Register infrastructure
|
||||||
|
services.AddSingleton<IAccessDbRepository>(sp => new AccessDbRepository(config["AccessConnectionString"]));
|
||||||
|
services.AddSingleton<IAttachmentRepository>(sp => new AttachmentRepository(config["PostgresConnectionString"]));
|
||||||
|
services.AddSingleton<IMailListener, MailListener>();
|
||||||
|
services.AddSingleton<IXmlParser, XmlParser>();
|
||||||
|
services.AddSingleton<IPdfParser, PdfParser>();
|
||||||
|
services.AddSingleton<IFileArchiver>(sp => new FileArchiver(config["ArchiveBasePath"]));
|
||||||
|
// Register application services
|
||||||
|
services.AddSingleton<ComplianceNFs.Core.Application.Services.InvoiceIngestionService>();
|
||||||
|
services.AddSingleton<ComplianceNFs.Core.Application.Services.MatchingService>();
|
||||||
|
services.AddSingleton<ComplianceNFs.Core.Application.Services.ComplianceService>();
|
||||||
|
services.AddSingleton<ComplianceNFs.Core.Application.Services.NotificationService>();
|
||||||
|
services.AddSingleton<ComplianceNFs.Core.Application.Services.ArchivingService>();
|
||||||
|
services.AddHostedService<Worker>();
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
host.Run();
|
||||||
11
ComplianceNFs.Service/Properties/launchSettings.json
Normal file
11
ComplianceNFs.Service/Properties/launchSettings.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"profiles": {
|
||||||
|
"ComplianceNFs.Service": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"environmentVariables": {
|
||||||
|
"DOTNET_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
ComplianceNFs.Service/Worker.cs
Normal file
20
ComplianceNFs.Service/Worker.cs
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
namespace ComplianceNFs.Service;
|
||||||
|
|
||||||
|
public class Worker : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly ILogger<Worker> _logger;
|
||||||
|
|
||||||
|
public Worker(ILogger<Worker> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
|
||||||
|
await Task.Delay(1000, stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
ComplianceNFs.Service/appsettings.json
Normal file
24
ComplianceNFs.Service/appsettings.json
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"AccessConnectionString": "Provider=Microsoft.ACE.OLEDB.12.0;Data Source=X:\\Back\\Controle NFs\\Dados.accdb;",
|
||||||
|
"PostgresConnectionString": "Host=…;Port=5432;Database=…;Username=…;Password=…;",
|
||||||
|
"Mail": {
|
||||||
|
"Host": "outlook.office365.com",
|
||||||
|
"Port": 993,
|
||||||
|
"User": "service@yourcompany.com",
|
||||||
|
"Password": "…",
|
||||||
|
"SupplierAllowList": [ "faturamento@…", "nfe@…" ]
|
||||||
|
},
|
||||||
|
"PollingIntervalMinutes": 2,
|
||||||
|
"Tolerances": {
|
||||||
|
"VolumePercent": 1.0,
|
||||||
|
"PricePercent": 0.5,
|
||||||
|
"TaxPercent": 1.0
|
||||||
|
},
|
||||||
|
"ArchiveBasePath": "X:\\Back\\Controle NFs\\Fs\\",
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.Hosting.Lifetime": "Information"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
40
ComplianceNFs.sln
Normal file
40
ComplianceNFs.sln
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.0.31903.59
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ComplianceNFs.Core", "ComplianceNFs.Core\ComplianceNFs.Core.csproj", "{43625B9E-D806-490C-A65C-DEC88263B563}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ComplianceNFs.Infrastructure", "ComplianceNFs.Infrastructure\ComplianceNFs.Infrastructure.csproj", "{86962CF6-BC05-4167-972B-1F24AC9348D4}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ComplianceNFs.Monitor", "ComplianceNFs.Monitor\ComplianceNFs.Monitor.csproj", "{03F7AF44-2394-410A-98ED-4973B7F66FF4}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ComplianceNFs.Service", "ComplianceNFs.Service\ComplianceNFs.Service.csproj", "{72BFBD25-0814-420D-8524-21448479A771}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{43625B9E-D806-490C-A65C-DEC88263B563}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{43625B9E-D806-490C-A65C-DEC88263B563}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{43625B9E-D806-490C-A65C-DEC88263B563}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{43625B9E-D806-490C-A65C-DEC88263B563}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{86962CF6-BC05-4167-972B-1F24AC9348D4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{86962CF6-BC05-4167-972B-1F24AC9348D4}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{86962CF6-BC05-4167-972B-1F24AC9348D4}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{86962CF6-BC05-4167-972B-1F24AC9348D4}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{03F7AF44-2394-410A-98ED-4973B7F66FF4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{03F7AF44-2394-410A-98ED-4973B7F66FF4}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{03F7AF44-2394-410A-98ED-4973B7F66FF4}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{03F7AF44-2394-410A-98ED-4973B7F66FF4}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{72BFBD25-0814-420D-8524-21448479A771}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{72BFBD25-0814-420D-8524-21448479A771}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{72BFBD25-0814-420D-8524-21448479A771}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{72BFBD25-0814-420D-8524-21448479A771}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
101
TODOs-and-Roadmap.md
Normal file
101
TODOs-and-Roadmap.md
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
# ComplianceNFs TODOs and Roadmap
|
||||||
|
|
||||||
|
## TODOs in Codebase
|
||||||
|
|
||||||
|
### ComplianceNFs.Infrastructure/Archiving/FileArchiver.cs
|
||||||
|
|
||||||
|
- [x] FileArchiver.ArchiveAsync: Create subfolder for invoice.Status, move file
|
||||||
|
|
||||||
|
### ComplianceNFs.Infrastructure/Mail/MailListener.cs
|
||||||
|
|
||||||
|
- [x] MailListener.StartListening: Connect to mailbox, filter by allowlist, raise NewMailReceived
|
||||||
|
|
||||||
|
### ComplianceNFs.Infrastructure/Parsers/PdfParser.cs
|
||||||
|
|
||||||
|
- [x] PdfParser.Parse: Use iTextSharp or PdfSharp to extract text/numbers via regex
|
||||||
|
|
||||||
|
### ComplianceNFs.Infrastructure/Parsers/XmlParser.cs
|
||||||
|
|
||||||
|
- [x] XmlParser.Parse: Use System.Xml to parse known elements
|
||||||
|
|
||||||
|
### ComplianceNFs.Infrastructure/Repositories/AccessDbRepository.cs
|
||||||
|
|
||||||
|
- [x] AccessDbRepository.GetByUnidade: Implement actual query to Access DB
|
||||||
|
|
||||||
|
- [x] AccessDbRepository.GetByUnidadeAndMonth: Implement actual query to Access DB
|
||||||
|
|
||||||
|
### ComplianceNFs.Infrastructure/Repositories/AttachmentRepository.cs
|
||||||
|
|
||||||
|
- [x] AttachmentRepository.SaveRawAsync: Implement actual insert into Postgres attachments table
|
||||||
|
|
||||||
|
- [x] AttachmentRepository.UpdateMatchAsync: Implement actual update in Postgres
|
||||||
|
|
||||||
|
### ComplianceNFs.Core/Application/Services
|
||||||
|
|
||||||
|
- [x] InvoiceIngestionService: Implement ingestion logic and subscribe to NewMailReceived
|
||||||
|
|
||||||
|
- [x] MatchingService: Implement invoice matching logic
|
||||||
|
|
||||||
|
- [x] ComplianceService: Implement compliance validation logic
|
||||||
|
|
||||||
|
- [x] NotificationService: Implement notification logic for mismatches
|
||||||
|
|
||||||
|
- [x] ArchivingService: Implement archiving logic for final status
|
||||||
|
|
||||||
|
### ComplianceNFs.Monitor/MonitorViewModel.cs
|
||||||
|
|
||||||
|
- [ ] MonitorViewModel: Inject IInvoiceStatusStream and subscribe to updates
|
||||||
|
|
||||||
|
- [ ] MonitorViewModel.ForceScan: Call service to force ingestion cycle
|
||||||
|
|
||||||
|
### ComplianceNFs.Service/Program.cs
|
||||||
|
|
||||||
|
- [ ] Register application services (InvoiceIngestionService, MatchingService, etc.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roadmap to Finish the App
|
||||||
|
|
||||||
|
### 1. Infrastructure Layer
|
||||||
|
|
||||||
|
- [x] **FileArchiver**: Implement logic to create subfolders by `InvoiceStatus` and move files accordingly.
|
||||||
|
- [x] **MailListener**: Use MailKit to connect to IMAP/Exchange, filter by allowlist, and raise `NewMailReceived` event.
|
||||||
|
- [x] **PdfParser**: Integrate iTextSharp or PdfSharp, extract invoice data using regex.
|
||||||
|
- [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.
|
||||||
|
|
||||||
|
### 2. Application Layer
|
||||||
|
|
||||||
|
- [x] Implement and register:
|
||||||
|
- InvoiceIngestionService
|
||||||
|
- MatchingService
|
||||||
|
- ComplianceService
|
||||||
|
- NotificationService
|
||||||
|
- ArchivingService
|
||||||
|
|
||||||
|
- [ ] Wire up these services in DI in `Program.cs`.
|
||||||
|
|
||||||
|
### 3. Service Host
|
||||||
|
|
||||||
|
- [ ] Ensure all services are registered and started in the Worker.
|
||||||
|
- [ ] Implement polling and retry logic as per configuration.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### 5. Configuration & Testing
|
||||||
|
|
||||||
|
- [ ] Test all configuration values from `appsettings.json`.
|
||||||
|
- [ ] 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.
|
||||||
5
global.json
Normal file
5
global.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"sdk": {
|
||||||
|
"version": "9.0.101"
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user