diff --git a/.env.example b/.env.example index d185673..0df7bc8 100644 --- a/.env.example +++ b/.env.example @@ -60,3 +60,11 @@ SEFAZ_AMBIENTE=Producao # Desligue (false) para subir a aplicacao sem worker: nenhum job roda e nada consulta a SEFAZ. AGENDAMENTO_ATIVO=true + +# Empresas enfileiradas por ciclo de trinta minutos. Empresa sem novidade volta em uma hora, entao +# metade da base fica elegivel a cada ciclo: 500 comporta mil empresas sem atraso. +AGENDAMENTO_EMPRESAS_POR_CICLO=500 + +# Trabalhos simultaneos. Cada worker fica bloqueado em rede durante a chamada a SEFAZ, nao em CPU, +# entao este numero pode passar do numero de nucleos. +AGENDAMENTO_WORKERS=8 diff --git a/RUNBOOK.md b/RUNBOOK.md index 7618d8e..483fe6e 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -148,6 +148,33 @@ AGENDAMENTO_ATIVO=false docker compose up -d --build api A API atende normalmente (grade, relatórios, download), só não roda job nenhum. Para religar, suba de novo sem a variável: `docker compose up -d api`. +### Capacidade do ciclo — quantas empresas o servidor comporta + +Dois números governam isso, e ambos são configuração: + +``` +AGENDAMENTO_EMPRESAS_POR_CICLO=500 # quantas entram na fila a cada trinta minutos +AGENDAMENTO_WORKERS=8 # quantas saem dela ao mesmo tempo +``` + +Empresa sem novidade é reagendada para daqui a uma hora, então a cada ciclo cerca de metade da base +fica elegível. O padrão de 500 atende **mil empresas** sem atraso. Acima disso o excedente escorrega +para o ciclo seguinte — nada se perde, porque a ordem é pela consulta mais antiga, mas o intervalo +efetivo cresce em silêncio. + +Sintoma de saturação: a coluna "Próxima consulta" da tela de empresas fica no passado para muitas +delas ao mesmo tempo. Confirme no banco: + +```sql +SELECT count(*) FROM empresas +WHERE ativa AND proxima_consulta_em <= now() AND (bloqueada_ate IS NULL OR bloqueada_ate <= now()); +``` + +Se o número passar de `AGENDAMENTO_EMPRESAS_POR_CICLO`, suba os dois valores juntos. Enfileirar mais do +que os workers drenam só transfere a espera de lugar. Cada worker fica bloqueado em rede durante a +chamada à SEFAZ — cerca de nove segundos —, não em processamento, então o número pode passar bem do +total de núcleos. + ### Falha ao arquivar no object storage O arquivamento repete sozinho até três vezes, com espera crescente e jitter, quando a falha é de rede ou diff --git a/docker-compose.yml b/docker-compose.yml index 1945a91..9e66006 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -110,6 +110,8 @@ services: # Desligado, a aplicação sobe sem worker: nenhum job roda e nada consulta a SEFAZ. É o que # permite subir uma versão nova do código sem gastar cota de consulta do CNPJ. Agendamento__ProcessarTrabalhos: ${AGENDAMENTO_ATIVO:-true} + Agendamento__EmpresasPorCiclo: ${AGENDAMENTO_EMPRESAS_POR_CICLO:-500} + Agendamento__Workers: ${AGENDAMENTO_WORKERS:-8} # Origens exatas separadas por ponto e vírgula. Vazio fecha a política — nunca curinga, porque a # API responde com credenciais. Cors__OrigensPermitidas: ${CORS_ORIGENS:-http://localhost:4200} diff --git a/src/eContabil.Api/Program.cs b/src/eContabil.Api/Program.cs index 34111bb..6b6ac38 100644 --- a/src/eContabil.Api/Program.cs +++ b/src/eContabil.Api/Program.cs @@ -55,7 +55,7 @@ // Nem toda instância processa fila: desligado, a aplicação segue enfileirando, mas o worker, o // dashboard e o registro recorrente ficam de fora — nenhum deles é acessível sem tocar o storage. var processaTrabalhos = builder.Configuration.GetValue("Agendamento:ProcessarTrabalhos", true); - builder.Services.AddAgendamento(conexaoPostgres, processaTrabalhos); + builder.Services.AddAgendamento(builder.Configuration, conexaoPostgres); // O Kestrel escreve o próprio cabeçalho Server ao montar a resposta, depois de qualquer middleware: // removê-lo no pipeline não surte efeito, e o nome do servidor só ajuda quem procura exploit conhecido. diff --git a/src/eContabil.Api/appsettings.json b/src/eContabil.Api/appsettings.json index 897cb67..0a3f940 100644 --- a/src/eContabil.Api/appsettings.json +++ b/src/eContabil.Api/appsettings.json @@ -83,7 +83,9 @@ "TetoDeLotes": 5 }, "Agendamento": { - "ProcessarTrabalhos": true + "ProcessarTrabalhos": true, + "EmpresasPorCiclo": 500, + "Workers": 8 }, "AllowedHosts": "*" } diff --git a/src/eContabil.Application/Sincronizacao/OpcoesAgendamento.cs b/src/eContabil.Application/Sincronizacao/OpcoesAgendamento.cs new file mode 100644 index 0000000..03463d6 --- /dev/null +++ b/src/eContabil.Application/Sincronizacao/OpcoesAgendamento.cs @@ -0,0 +1,50 @@ +using System.ComponentModel.DataAnnotations; + +namespace eContabil.Application.Sincronizacao; + +/// +/// Capacidade do agendador. +/// +/// +/// Estes dois números decidem quantas empresas o escritório comporta, e por isso não podem viver como +/// constante no código. Uma base que cresce de 200 para 2.000 clientes precisa deles ajustáveis sem +/// recompilar — e quem opera precisa poder subi-los durante um pico sem esperar um deploy. +/// +/// A relação entre eles importa. limita quantas entram na fila; os +/// limitam quantas saem dela dentro do ciclo. Enfileirar muito além do que os +/// workers drenam só transfere a espera de um lugar para o outro. +/// +public sealed class OpcoesAgendamento +{ + public const string Secao = "Agendamento"; + + /// Liga o processamento de trabalhos neste processo. + /// + /// Desligado, a API atende normalmente e não roda job nenhum — é como se sobe uma versão nova sem + /// que o worker retome os ciclos e gaste consulta à SEFAZ numa janela de manutenção. + /// + public bool ProcessarTrabalhos { get; set; } = true; + + /// + /// Empresas enfileiradas por ciclo de captura. + /// + /// + /// Empresa sem novidade é reagendada para daqui a uma hora, então a cada ciclo de trinta minutos + /// cerca de metade da base fica elegível. O padrão de 500 comporta mil empresas sem atraso; acima + /// disso o excedente escorrega para o ciclo seguinte, em silêncio e sem perda — a ordem é pela + /// consulta mais antiga, então ninguém fica para trás indefinidamente. + /// + [Range(1, 20_000)] + public int EmpresasPorCiclo { get; set; } = 500; + + /// + /// Trabalhos simultâneos por processo. + /// + /// + /// A biblioteca fiscal só expõe chamada síncrona, e cada consulta à SEFAZ leva cerca de nove + /// segundos. O worker fica bloqueado em entrada e saída, não em processamento — por isso este + /// número pode passar bem do número de núcleos sem saturar a máquina. + /// + [Range(1, 200)] + public int Workers { get; set; } = 8; +} diff --git a/src/eContabil.Application/Sincronizacao/SincronizarEmpresaCommandHandler.cs b/src/eContabil.Application/Sincronizacao/SincronizarEmpresaCommandHandler.cs index 00fd7ad..4cf2c90 100644 --- a/src/eContabil.Application/Sincronizacao/SincronizarEmpresaCommandHandler.cs +++ b/src/eContabil.Application/Sincronizacao/SincronizarEmpresaCommandHandler.cs @@ -199,6 +199,12 @@ private async Task ProcessarLoteAsync( // estouravam o índice único no commit, derrubando o lote inteiro e deixando os XMLs órfãos. var chavesDoLote = new HashSet(StringComparer.Ordinal); + // Os já gravados saem numa consulta só, antes do laço. Perguntar por um de cada vez custava + // cinquenta consultas por lote e mil numa execução que encadeia vinte, para responder algo que + // um único `IN` resolve. + var jaGravados = await documentos.ObterPorEmpresaEChavesAsync( + empresa.Id, ChavesValidasDe(retorno), ct); + foreach (var recebido in retorno.Documentos) { var chave = ChaveAcesso.Criar(recebido.ChaveAcesso); @@ -233,8 +239,7 @@ private async Task ProcessarLoteAsync( continue; } - var existente = await documentos.ObterPorEmpresaEChaveAsync(empresa.Id, chave.Valor, ct); - if (existente is not null) + if (jaGravados.TryGetValue(chave.Valor.Valor, out var existente)) { if (arquivamento.Valor is { } objeto && existente.OrigemConteudo is OrigemConteudo.Resumo) { @@ -276,6 +281,30 @@ private async Task ProcessarLoteAsync( return Result.Ok(); } + /// + /// As chaves aproveitáveis do lote, sem repetição. + /// + /// + /// Item com chave inválida fica de fora — ele seria descartado no laço de qualquer forma, e levá-lo + /// à consulta só alargaria o IN sem chance de casar com nada. + /// + private static List ChavesValidasDe(RetornoDistribuicaoDto retorno) + { + var chaves = new HashSet(); + + foreach (var recebido in retorno.Documentos) + { + var chave = ChaveAcesso.Criar(recebido.ChaveAcesso); + + if (chave.Sucesso) + { + chaves.Add(chave.Valor); + } + } + + return [.. chaves]; + } + /// /// Arquiva o XML quando houver. Devolve sucesso com valor nulo para resumo, que não tem arquivo. /// diff --git a/src/eContabil.Domain/Documentos/IDocumentoFiscalRepository.cs b/src/eContabil.Domain/Documentos/IDocumentoFiscalRepository.cs index 20e08a6..9d300ac 100644 --- a/src/eContabil.Domain/Documentos/IDocumentoFiscalRepository.cs +++ b/src/eContabil.Domain/Documentos/IDocumentoFiscalRepository.cs @@ -6,6 +6,17 @@ public interface IDocumentoFiscalRepository : IRepository { Task ObterPorEmpresaEChaveAsync(Guid empresaId, ChaveAcesso chave, CancellationToken ct); + /// + /// Os documentos da empresa entre as chaves informadas, indexados por chave. + /// + /// + /// Existe para o processamento de lote. A SEFAZ entrega até cinquenta documentos por chamada, e + /// perguntar por um de cada vez custava cinquenta consultas por lote — mil numa execução que + /// encadeia vinte. Uma consulta responde o mesmo. + /// + Task> ObterPorEmpresaEChavesAsync( + Guid empresaId, IReadOnlyCollection chaves, CancellationToken ct); + /// /// Indica se o XML daquela chave já foi arquivado por qualquer empresa. /// diff --git a/src/eContabil.Infrastructure/Agendamento/ManifestacaoGeralJob.cs b/src/eContabil.Infrastructure/Agendamento/ManifestacaoGeralJob.cs index 8a1e204..47aed8e 100644 --- a/src/eContabil.Infrastructure/Agendamento/ManifestacaoGeralJob.cs +++ b/src/eContabil.Infrastructure/Agendamento/ManifestacaoGeralJob.cs @@ -1,6 +1,8 @@ +using eContabil.Application.Sincronizacao; using eContabil.Domain.Documentos; using Hangfire; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace eContabil.Infrastructure.Agendamento; @@ -14,16 +16,17 @@ namespace eContabil.Infrastructure.Agendamento; /// public sealed partial class ManifestacaoGeralJob( IDocumentoFiscalRepository documentos, + IOptions opcoes, ILogger log) { - /// Teto de empresas por ciclo, para não inundar a fila de uma vez. - private const int LimitePorCiclo = 500; + private readonly OpcoesAgendamento _opcoes = + opcoes?.Value ?? throw new ArgumentNullException(nameof(opcoes)); [Queue(Filas.Padrao)] [DisableConcurrentExecution(timeoutInSeconds: 300)] public async Task ExecutarAsync(CancellationToken ct) { - var empresas = await documentos.ObterEmpresasComManifestacaoPendenteAsync(LimitePorCiclo, ct); + var empresas = await documentos.ObterEmpresasComManifestacaoPendenteAsync(_opcoes.EmpresasPorCiclo, ct); foreach (var empresaId in empresas) { diff --git a/src/eContabil.Infrastructure/Agendamento/SincronizacaoGeralJob.cs b/src/eContabil.Infrastructure/Agendamento/SincronizacaoGeralJob.cs index 21c0536..beffb93 100644 --- a/src/eContabil.Infrastructure/Agendamento/SincronizacaoGeralJob.cs +++ b/src/eContabil.Infrastructure/Agendamento/SincronizacaoGeralJob.cs @@ -1,7 +1,9 @@ +using eContabil.Application.Sincronizacao; using eContabil.Domain.Empresas; using eContabil.Shared; using Hangfire; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace eContabil.Infrastructure.Agendamento; @@ -16,10 +18,11 @@ namespace eContabil.Infrastructure.Agendamento; public sealed partial class SincronizacaoGeralJob( IEmpresaRepository empresas, TimeProvider relogio, + IOptions opcoes, ILogger log) { - /// Teto de empresas enfileiradas por ciclo, para não inundar a fila de uma vez. - private const int LimitePorCiclo = 500; + private readonly OpcoesAgendamento _opcoes = + opcoes?.Value ?? throw new ArgumentNullException(nameof(opcoes)); [Queue(Filas.Padrao)] [DisableConcurrentExecution(timeoutInSeconds: 300)] @@ -27,7 +30,7 @@ public async Task ExecutarAsync(CancellationToken ct) { var agora = relogio.AgoraUtc(); - var elegiveis = await empresas.ObterElegiveisParaSincronizarAsync(agora, LimitePorCiclo, ct); + var elegiveis = await empresas.ObterElegiveisParaSincronizarAsync(agora, _opcoes.EmpresasPorCiclo, ct); foreach (var empresa in elegiveis) { diff --git a/src/eContabil.Infrastructure/Certificados/CofreComCacheDeExecucao.cs b/src/eContabil.Infrastructure/Certificados/CofreComCacheDeExecucao.cs new file mode 100644 index 0000000..c51754c --- /dev/null +++ b/src/eContabil.Infrastructure/Certificados/CofreComCacheDeExecucao.cs @@ -0,0 +1,79 @@ +using System.Security.Cryptography; +using eContabil.Application.Certificados; +using eContabil.Shared; + +namespace eContabil.Infrastructure.Certificados; + +/// +/// Evita rebaixar e decifrar o mesmo certificado várias vezes dentro de uma execução. +/// +/// +/// Drenar uma empresa atrasada encadeia até vinte consultas à SEFAZ — duzentas na carga inicial — e cada +/// uma pedia o certificado ao cofre de novo. Era o mesmo arquivo baixado do object storage, decifrado +/// com AES-GCM e derivado com PBKDF2 duzentas vezes seguidas, para produzir exatamente o mesmo material. +/// +/// O escopo do cache é a execução, não o processo. Como o registro é Scoped e cada trabalho do +/// Hangfire abre o próprio escopo, a instância nasce e morre com a sincronização de uma empresa. Isso +/// preserva a regra que importa — chave privada não persiste entre execuções, não vai para disco e não +/// atravessa empresas — e elimina só a repetição dentro da janela em que o material já estaria na +/// memória de qualquer forma. +/// +/// O descarte zera os bytes decifrados. Não é garantia absoluta contra despejo de memória, mas encurta +/// a janela em que a chave fica legível no heap depois de deixar de ser necessária. +/// +public sealed class CofreComCacheDeExecucao(ICertificadoCofre cofre) : ICertificadoCofre, IDisposable +{ + private readonly Dictionary _carregados = []; + + public Task> GuardarAsync( + Guid certificadoId, + byte[] pfx, + string senha, + string cnpjEsperado, + DateTime agoraUtc, + CancellationToken ct) => + cofre.GuardarAsync(certificadoId, pfx, senha, cnpjEsperado, agoraUtc, ct); + + public async Task> CarregarAsync( + CertificadoLocalizacao localizacao, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(localizacao); + + if (_carregados.TryGetValue(localizacao.CertificadoId, out var jaCarregado)) + { + return Result.Ok(jaCarregado); + } + + var carregado = await cofre.CarregarAsync(localizacao, ct); + + if (carregado.Sucesso) + { + // A chave é o identificador do certificado, não o da empresa: a substituição de um + // certificado gera um identificador novo, e o material antigo nunca é servido no lugar dele. + _carregados[localizacao.CertificadoId] = carregado.Valor; + } + + return carregado; + } + + public Task RemoverAsync(string bucket, string objectName, CancellationToken ct) + { + // Remover invalida tudo: o arquivo deixou de existir, e servir o que estava em memória entregaria + // uma chave privada que o operador acabou de mandar apagar. + Descartar(); + + return cofre.RemoverAsync(bucket, objectName, ct); + } + + public void Dispose() => Descartar(); + + private void Descartar() + { + foreach (var certificado in _carregados.Values) + { + CryptographicOperations.ZeroMemory(certificado.Pfx); + } + + _carregados.Clear(); + } +} diff --git a/src/eContabil.Infrastructure/InfraestruturaServiceCollectionExtensions.cs b/src/eContabil.Infrastructure/InfraestruturaServiceCollectionExtensions.cs index 6929ebc..6d691a2 100644 --- a/src/eContabil.Infrastructure/InfraestruturaServiceCollectionExtensions.cs +++ b/src/eContabil.Infrastructure/InfraestruturaServiceCollectionExtensions.cs @@ -155,7 +155,14 @@ argumentos.Outcome.Exception is { } falha services.AddSingleton(); services.AddSingleton(); services.AddScoped(); - services.AddScoped(); + + // O cofre real fica atrás do cache de execução. Drenar uma empresa atrasada encadeia até + // duzentas consultas à SEFAZ, e cada uma pedia o mesmo certificado de volta ao object storage + // para decifrá-lo outra vez. O escopo do cache é o trabalho do Hangfire: a chave privada + // continua sem persistir entre execuções. + services.AddScoped(); + services.AddScoped(provedor => + new CofreComCacheDeExecucao(provedor.GetRequiredService())); return services; } @@ -239,24 +246,35 @@ public static IServiceCollection AddAutenticacao( /// enfileirando, mas não executa — é o que permite escalar API e processamento em separado. /// public static IServiceCollection AddAgendamento( - this IServiceCollection services, string conexaoPostgres, bool processarTrabalhos = true) + this IServiceCollection services, IConfiguration configuracao, string conexaoPostgres) { + ArgumentNullException.ThrowIfNull(configuracao); ArgumentException.ThrowIfNullOrWhiteSpace(conexaoPostgres); + services.AddOptions() + .Bind(configuracao.GetSection(OpcoesAgendamento.Secao)) + .ValidateDataAnnotations() + .ValidateOnStart(); + + // Lido aqui, e não por `IOptions`, porque a quantidade de workers é decidida na construção do + // servidor do Hangfire — antes de o provedor de serviços existir. + var opcoes = configuracao.GetSection(OpcoesAgendamento.Secao).Get() + ?? new OpcoesAgendamento(); + services.AddHangfire(configuracao => configuracao .SetDataCompatibilityLevel(CompatibilityLevel.Version_180) .UseSimpleAssemblyNameTypeSerializer() .UseRecommendedSerializerSettings() .UsePostgreSqlStorage(opcoes => opcoes.UseNpgsqlConnection(conexaoPostgres))); - if (processarTrabalhos) + if (opcoes.ProcessarTrabalhos) { - services.AddHangfireServer(opcoes => + services.AddHangfireServer(servidor => { // A ordem das filas é a prioridade de atendimento. A carga inicial vem por último: é // longa e não pode atrasar o ciclo recorrente das empresas já em dia. - opcoes.Queues = [Filas.Sincronizacao, Filas.Padrao, Filas.CargaInicial]; - opcoes.WorkerCount = 8; + servidor.Queues = [Filas.Sincronizacao, Filas.Padrao, Filas.CargaInicial]; + servidor.WorkerCount = opcoes.Workers; }); } diff --git a/src/eContabil.Infrastructure/Persistencia/Configuracoes/ItemDocumentoFiscalConfiguration.cs b/src/eContabil.Infrastructure/Persistencia/Configuracoes/ItemDocumentoFiscalConfiguration.cs index 19f5e54..4914e06 100644 --- a/src/eContabil.Infrastructure/Persistencia/Configuracoes/ItemDocumentoFiscalConfiguration.cs +++ b/src/eContabil.Infrastructure/Persistencia/Configuracoes/ItemDocumentoFiscalConfiguration.cs @@ -53,10 +53,18 @@ public void Configure(EntityTypeBuilder builder) .IsUnique() .HasDatabaseName("ix_itens_documento_numero"); - // Os filtros do contador são por CFOP e por NCM, e a consulta resultante pergunta quais notas - // contêm algum item com o código. Sem estes índices, cada filtro varreria a tabela inteira — - // que cresce por item, não por nota, e é a maior do banco. - builder.HasIndex(item => item.Cfop).HasDatabaseName("ix_itens_cfop"); - builder.HasIndex(item => item.Ncm).HasDatabaseName("ix_itens_ncm"); + // Os filtros do contador viram `EXISTS (SELECT 1 FROM itens WHERE documento_fiscal_id = ? AND + // cfop LIKE ?)`, e o índice precisa ter essa forma: primeiro o documento, depois o código. + // + // Indexar o código sozinho não atende essa pergunta. A consulta parte da grade — já estreitada + // por empresa e período — e sonda os itens de cada documento; um índice só por CFOP obrigaria o + // banco a percorrer todas as linhas com aquele código no acervo inteiro para depois cruzar com + // os documentos do recorte. Numa tabela que cresce por item, e não por nota, a diferença entre + // as duas formas é a diferença entre a consulta responder e a consulta expirar. + builder.HasIndex(item => new { item.DocumentoFiscalId, item.Cfop }) + .HasDatabaseName("ix_itens_documento_cfop"); + + builder.HasIndex(item => new { item.DocumentoFiscalId, item.Ncm }) + .HasDatabaseName("ix_itens_documento_ncm"); } } diff --git a/src/eContabil.Infrastructure/Persistencia/Migrations/20260729021833_ReindexarItensPorDocumentoECodigo.Designer.cs b/src/eContabil.Infrastructure/Persistencia/Migrations/20260729021833_ReindexarItensPorDocumentoECodigo.Designer.cs new file mode 100644 index 0000000..b5409e7 --- /dev/null +++ b/src/eContabil.Infrastructure/Persistencia/Migrations/20260729021833_ReindexarItensPorDocumentoECodigo.Designer.cs @@ -0,0 +1,824 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using eContabil.Infrastructure.Persistencia; + +#nullable disable + +namespace eContabil.Infrastructure.Persistencia.Migrations +{ + [DbContext(typeof(EContabilDbContext))] + [Migration("20260729021833_ReindexarItensPorDocumentoECodigo")] + partial class ReindexarItensPorDocumentoECodigo + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("eContabil.Domain.Certificados.CertificadoDigital", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Ativo") + .HasColumnType("boolean") + .HasColumnName("ativo"); + + b.Property("Bucket") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)") + .HasColumnName("bucket"); + + b.Property("CnpjTitular") + .IsRequired() + .HasMaxLength(14) + .HasColumnType("character varying(14)") + .HasColumnName("cnpj_titular"); + + b.Property("CriadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("criado_em"); + + b.Property("DataEmissao") + .HasColumnType("timestamp with time zone") + .HasColumnName("data_emissao"); + + b.Property("DataExpiracao") + .HasColumnType("timestamp with time zone") + .HasColumnName("data_expiracao"); + + b.Property("EmpresaId") + .HasColumnType("uuid") + .HasColumnName("empresa_id"); + + b.Property("HashArquivo") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("hash_arquivo"); + + b.Property("NomeArquivo") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("nome_arquivo"); + + b.Property("ObjectName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("object_name"); + + b.Property("SenhaCriptografada") + .IsRequired() + .HasColumnType("text") + .HasColumnName("senha_criptografada"); + + b.Property("Thumbprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("thumbprint"); + + b.Property("UfTitular") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("character varying(2)") + .HasColumnName("uf_titular"); + + b.Property("VersaoChave") + .HasColumnType("integer") + .HasColumnName("versao_chave"); + + b.HasKey("Id") + .HasName("pk_certificados_digitais"); + + b.HasIndex("DataExpiracao") + .HasDatabaseName("ix_certificados_expiracao") + .HasFilter("ativo"); + + b.HasIndex("EmpresaId") + .IsUnique() + .HasDatabaseName("ix_certificados_empresa_ativo") + .HasFilter("ativo"); + + b.ToTable("certificados_digitais", (string)null); + }); + + modelBuilder.Entity("eContabil.Domain.Documentos.DocumentoFiscal", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bucket") + .HasMaxLength(63) + .HasColumnType("character varying(63)") + .HasColumnName("bucket"); + + b.Property("Chave") + .IsRequired() + .HasMaxLength(44) + .HasColumnType("character varying(44)") + .HasColumnName("chave"); + + b.Property("CriadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("criado_em"); + + b.Property("DadosFiscaisExtraidos") + .HasColumnType("boolean") + .HasColumnName("dados_fiscais_extraidos"); + + b.Property("DataAutorizacao") + .HasColumnType("timestamp with time zone") + .HasColumnName("data_autorizacao"); + + b.Property("DataEmissao") + .HasColumnType("timestamp with time zone") + .HasColumnName("data_emissao"); + + b.Property("EmpresaId") + .HasColumnType("uuid") + .HasColumnName("empresa_id"); + + b.Property("HashXml") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("hash_xml"); + + b.Property("ManifestacaoStatus") + .HasColumnType("integer") + .HasColumnName("manifestacao_status"); + + b.Property("ManifestadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("manifestado_em"); + + b.Property("Modelo") + .HasColumnType("integer") + .HasColumnName("modelo"); + + b.Property("MotivoFalhaManifestacao") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("motivo_falha_manifestacao"); + + b.Property("NaturezaOperacao") + .HasMaxLength(60) + .HasColumnType("character varying(60)") + .HasColumnName("natureza_operacao"); + + b.Property("Nsu") + .HasColumnType("bigint") + .HasColumnName("nsu"); + + b.Property("Numero") + .HasColumnType("integer") + .HasColumnName("numero"); + + b.Property("ObjectName") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("object_name"); + + b.Property("OrigemConteudo") + .HasColumnType("integer") + .HasColumnName("origem_conteudo"); + + b.Property("Papel") + .HasColumnType("integer") + .HasColumnName("papel"); + + b.Property("PossuiXml") + .HasColumnType("boolean") + .HasColumnName("possui_xml"); + + b.Property("ProtocoloManifestacao") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("protocolo_manifestacao"); + + b.Property("Serie") + .HasColumnType("integer") + .HasColumnName("serie"); + + b.Property("Situacao") + .HasColumnType("integer") + .HasColumnName("situacao"); + + b.Property("TipoDocumento") + .HasColumnType("integer") + .HasColumnName("tipo_documento"); + + b.Property("ValorTotal") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasColumnName("valor_total"); + + b.HasKey("Id") + .HasName("pk_documentos_fiscais"); + + b.HasIndex("Chave") + .HasDatabaseName("ix_documentos_chave"); + + b.HasIndex("EmpresaId", "Chave") + .IsUnique() + .HasDatabaseName("ix_documentos_empresa_chave"); + + b.HasIndex("EmpresaId", "DataEmissao") + .IsDescending(false, true) + .HasDatabaseName("ix_documentos_empresa_emissao"); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("EmpresaId", "DataEmissao"), new[] { "Numero", "Serie", "ValorTotal", "Situacao", "PossuiXml" }); + + b.HasIndex("EmpresaId", "Nsu") + .HasDatabaseName("ix_documentos_empresa_nsu"); + + b.HasIndex(new[] { "EmpresaId", "DataEmissao" }, "ix_documentos_manifestacao_pendente") + .HasDatabaseName("ix_documentos_manifestacao_pendente") + .HasFilter("manifestacao_status = 1"); + + b.HasIndex(new[] { "Id" }, "ix_documentos_pendentes_de_extracao") + .HasDatabaseName("ix_documentos_pendentes_de_extracao") + .HasFilter("possui_xml AND NOT dados_fiscais_extraidos"); + + b.ToTable("documentos_fiscais", (string)null); + }); + + modelBuilder.Entity("eContabil.Domain.Documentos.ItemDocumentoFiscal", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AliquotaIcms") + .HasPrecision(7, 4) + .HasColumnType("numeric(7,4)") + .HasColumnName("aliquota_icms"); + + b.Property("BaseCalculoIcms") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("base_calculo_icms"); + + b.Property("Cest") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character varying(7)") + .HasColumnName("cest"); + + b.Property("Cfop") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("character varying(4)") + .HasColumnName("cfop"); + + b.Property("Codigo") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)") + .HasColumnName("codigo"); + + b.Property("CriadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("criado_em"); + + b.Property("CstIcms") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("character varying(4)") + .HasColumnName("cst_icms"); + + b.Property("Descricao") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("descricao"); + + b.Property("DocumentoFiscalId") + .HasColumnType("uuid") + .HasColumnName("documento_fiscal_id"); + + b.Property("Ncm") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("ncm"); + + b.Property("Numero") + .HasColumnType("integer") + .HasColumnName("numero"); + + b.Property("OrigemMercadoria") + .HasColumnType("integer") + .HasColumnName("origem_mercadoria"); + + b.Property("Quantidade") + .HasPrecision(15, 4) + .HasColumnType("numeric(15,4)") + .HasColumnName("quantidade"); + + b.Property("Unidade") + .IsRequired() + .HasMaxLength(6) + .HasColumnType("character varying(6)") + .HasColumnName("unidade"); + + b.Property("ValorCofins") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("valor_cofins"); + + b.Property("ValorIcms") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("valor_icms"); + + b.Property("ValorIcmsSt") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("valor_icms_st"); + + b.Property("ValorIpi") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("valor_ipi"); + + b.Property("ValorPis") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("valor_pis"); + + b.Property("ValorTotal") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("valor_total"); + + b.Property("ValorUnitario") + .HasPrecision(21, 10) + .HasColumnType("numeric(21,10)") + .HasColumnName("valor_unitario"); + + b.HasKey("Id") + .HasName("pk_itens_documento_fiscal"); + + b.HasIndex("DocumentoFiscalId", "Cfop") + .HasDatabaseName("ix_itens_documento_cfop"); + + b.HasIndex("DocumentoFiscalId", "Ncm") + .HasDatabaseName("ix_itens_documento_ncm"); + + b.HasIndex("DocumentoFiscalId", "Numero") + .IsUnique() + .HasDatabaseName("ix_itens_documento_numero"); + + b.ToTable("itens_documento_fiscal", (string)null); + }); + + modelBuilder.Entity("eContabil.Domain.Empresas.Empresa", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Ambiente") + .HasColumnType("integer") + .HasColumnName("ambiente"); + + b.Property("Ativa") + .HasColumnType("boolean") + .HasColumnName("ativa"); + + b.Property("BloqueadaAte") + .HasColumnType("timestamp with time zone") + .HasColumnName("bloqueada_ate"); + + b.Property("BloqueiosConsecutivos") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("bloqueios_consecutivos"); + + b.Property("Cnpj") + .IsRequired() + .HasMaxLength(14) + .HasColumnType("character varying(14)") + .HasColumnName("cnpj"); + + b.Property("CriadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("criado_em"); + + b.Property("InscricaoEstadual") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("inscricao_estadual"); + + b.Property("MaxNSU") + .HasColumnType("bigint") + .HasColumnName("max_nsu"); + + b.Property("NomeFantasia") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("nome_fantasia"); + + b.Property("ProximaConsultaEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("proxima_consulta_em"); + + b.Property("RazaoSocial") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("razao_social"); + + b.Property("Uf") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("character varying(2)") + .HasColumnName("uf"); + + b.Property("UltimaSincronizacaoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("ultima_sincronizacao_em"); + + b.Property("UltimoNSU") + .HasColumnType("bigint") + .HasColumnName("ultimo_nsu"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_empresas"); + + b.HasIndex("Cnpj") + .IsUnique() + .HasDatabaseName("ix_empresas_cnpj") + .HasFilter("ativa"); + + b.HasIndex("ProximaConsultaEm") + .HasDatabaseName("ix_empresas_proxima_consulta") + .HasFilter("ativa"); + + b.ToTable("empresas", (string)null); + }); + + modelBuilder.Entity("eContabil.Domain.Sincronizacoes.ExecucaoSincronizacao", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CriadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("criado_em"); + + b.Property("DocumentosNovos") + .HasColumnType("integer") + .HasColumnName("documentos_novos"); + + b.Property("DocumentosPromovidos") + .HasColumnType("integer") + .HasColumnName("documentos_promovidos"); + + b.Property("EmpresaId") + .HasColumnType("uuid") + .HasColumnName("empresa_id"); + + b.Property("FinalizadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("finalizado_em"); + + b.Property("IniciadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("iniciado_em"); + + b.Property("ItensDescartados") + .HasColumnType("integer") + .HasColumnName("itens_descartados"); + + b.Property("Mensagem") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("mensagem"); + + b.Property("Modo") + .HasColumnType("integer") + .HasColumnName("modo"); + + b.Property("NsuFinal") + .HasColumnType("bigint") + .HasColumnName("nsu_final"); + + b.Property("NsuInicial") + .HasColumnType("bigint") + .HasColumnName("nsu_inicial"); + + b.Property("Resultado") + .HasColumnType("integer") + .HasColumnName("resultado"); + + b.Property("XmlsArquivados") + .HasColumnType("integer") + .HasColumnName("xmls_arquivados"); + + b.HasKey("Id") + .HasName("pk_execucoes_sincronizacao"); + + b.HasIndex("FinalizadoEm") + .IsDescending() + .HasDatabaseName("ix_execucoes_fim"); + + b.HasIndex("EmpresaId", "FinalizadoEm") + .IsDescending(false, true) + .HasDatabaseName("ix_execucoes_empresa_fim"); + + b.ToTable("execucoes_sincronizacao", (string)null); + }); + + modelBuilder.Entity("eContabil.Domain.Usuarios.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CriadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("criado_em"); + + b.Property("CriadoPorIp") + .HasMaxLength(45) + .HasColumnType("character varying(45)") + .HasColumnName("criado_por_ip"); + + b.Property("ExpiraEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("expira_em"); + + b.Property("RevogadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("revogado_em"); + + b.Property("SubstituidoPorId") + .HasColumnType("uuid") + .HasColumnName("substituido_por_id"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash"); + + b.Property("UsuarioId") + .HasColumnType("uuid") + .HasColumnName("usuario_id"); + + b.HasKey("Id") + .HasName("pk_refresh_tokens"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ix_refresh_tokens_hash"); + + b.HasIndex("UsuarioId") + .HasDatabaseName("ix_refresh_tokens_usuario"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("eContabil.Domain.Usuarios.Usuario", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Ativo") + .HasColumnType("boolean") + .HasColumnName("ativo"); + + b.Property("CriadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("criado_em"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email"); + + b.Property("Nome") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("nome"); + + b.Property("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + b.Property("SenhaHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("senha_hash"); + + b.HasKey("Id") + .HasName("pk_usuarios"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_usuarios_email"); + + b.ToTable("usuarios", (string)null); + }); + + modelBuilder.Entity("eContabil.Domain.Documentos.DocumentoFiscal", b => + { + b.OwnsOne("eContabil.Domain.ValueObjects.ParticipanteFiscal", "Destinatario", b1 => + { + b1.Property("DocumentoFiscalId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Documento") + .IsRequired() + .HasMaxLength(14) + .HasColumnType("character varying(14)") + .HasColumnName("destinatario_documento"); + + b1.Property("Nome") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("destinatario_nome"); + + b1.HasKey("DocumentoFiscalId"); + + b1.ToTable("documentos_fiscais"); + + b1.WithOwner() + .HasForeignKey("DocumentoFiscalId") + .HasConstraintName("fk_documentos_fiscais_documentos_fiscais_id"); + }); + + b.OwnsOne("eContabil.Domain.ValueObjects.ParticipanteFiscal", "Emitente", b1 => + { + b1.Property("DocumentoFiscalId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Documento") + .IsRequired() + .HasMaxLength(14) + .HasColumnType("character varying(14)") + .HasColumnName("emitente_documento"); + + b1.Property("Nome") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("emitente_nome"); + + b1.HasKey("DocumentoFiscalId"); + + b1.HasIndex("Nome") + .HasDatabaseName("ix_documentos_emitente_nome"); + + NpgsqlIndexBuilderExtensions.HasMethod(b1.HasIndex("Nome"), "gin"); + NpgsqlIndexBuilderExtensions.HasOperators(b1.HasIndex("Nome"), new[] { "gin_trgm_ops" }); + + b1.ToTable("documentos_fiscais"); + + b1.WithOwner() + .HasForeignKey("DocumentoFiscalId") + .HasConstraintName("fk_documentos_fiscais_documentos_fiscais_id"); + }); + + b.OwnsOne("eContabil.Domain.ValueObjects.TotaisFiscais", "Totais", b1 => + { + b1.Property("DocumentoFiscalId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("BaseCalculoIcms") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("total_base_icms"); + + b1.Property("BaseCalculoIcmsSt") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("total_base_icms_st"); + + b1.Property("ValorCofins") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("total_cofins"); + + b1.Property("ValorDesconto") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("total_desconto"); + + b1.Property("ValorFrete") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("total_frete"); + + b1.Property("ValorIcms") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("total_icms"); + + b1.Property("ValorIcmsSt") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("total_icms_st"); + + b1.Property("ValorIpi") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("total_ipi"); + + b1.Property("ValorPis") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("total_pis"); + + b1.Property("ValorProdutos") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("total_produtos"); + + b1.HasKey("DocumentoFiscalId"); + + b1.ToTable("documentos_fiscais"); + + b1.WithOwner() + .HasForeignKey("DocumentoFiscalId") + .HasConstraintName("fk_documentos_fiscais_documentos_fiscais_id"); + }); + + b.Navigation("Destinatario") + .IsRequired(); + + b.Navigation("Emitente") + .IsRequired(); + + b.Navigation("Totais") + .IsRequired(); + }); + + modelBuilder.Entity("eContabil.Domain.Documentos.ItemDocumentoFiscal", b => + { + b.HasOne("eContabil.Domain.Documentos.DocumentoFiscal", null) + .WithMany("Itens") + .HasForeignKey("DocumentoFiscalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_itens_documento_fiscal_documentos_fiscais_documento_fiscal_"); + }); + + modelBuilder.Entity("eContabil.Domain.Usuarios.RefreshToken", b => + { + b.HasOne("eContabil.Domain.Usuarios.Usuario", null) + .WithMany("RefreshTokens") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_refresh_tokens_usuarios_usuario_id"); + }); + + modelBuilder.Entity("eContabil.Domain.Documentos.DocumentoFiscal", b => + { + b.Navigation("Itens"); + }); + + modelBuilder.Entity("eContabil.Domain.Usuarios.Usuario", b => + { + b.Navigation("RefreshTokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/eContabil.Infrastructure/Persistencia/Migrations/20260729021833_ReindexarItensPorDocumentoECodigo.cs b/src/eContabil.Infrastructure/Persistencia/Migrations/20260729021833_ReindexarItensPorDocumentoECodigo.cs new file mode 100644 index 0000000..2bcf25b --- /dev/null +++ b/src/eContabil.Infrastructure/Persistencia/Migrations/20260729021833_ReindexarItensPorDocumentoECodigo.cs @@ -0,0 +1,54 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace eContabil.Infrastructure.Persistencia.Migrations +{ + /// + public partial class ReindexarItensPorDocumentoECodigo : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "ix_itens_cfop", + table: "itens_documento_fiscal"); + + migrationBuilder.DropIndex( + name: "ix_itens_ncm", + table: "itens_documento_fiscal"); + + migrationBuilder.CreateIndex( + name: "ix_itens_documento_cfop", + table: "itens_documento_fiscal", + columns: new[] { "documento_fiscal_id", "cfop" }); + + migrationBuilder.CreateIndex( + name: "ix_itens_documento_ncm", + table: "itens_documento_fiscal", + columns: new[] { "documento_fiscal_id", "ncm" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "ix_itens_documento_cfop", + table: "itens_documento_fiscal"); + + migrationBuilder.DropIndex( + name: "ix_itens_documento_ncm", + table: "itens_documento_fiscal"); + + migrationBuilder.CreateIndex( + name: "ix_itens_cfop", + table: "itens_documento_fiscal", + column: "cfop"); + + migrationBuilder.CreateIndex( + name: "ix_itens_ncm", + table: "itens_documento_fiscal", + column: "ncm"); + } + } +} diff --git a/src/eContabil.Infrastructure/Persistencia/Migrations/EContabilDbContextModelSnapshot.cs b/src/eContabil.Infrastructure/Persistencia/Migrations/EContabilDbContextModelSnapshot.cs index 8dbf067..2ac2061 100644 --- a/src/eContabil.Infrastructure/Persistencia/Migrations/EContabilDbContextModelSnapshot.cs +++ b/src/eContabil.Infrastructure/Persistencia/Migrations/EContabilDbContextModelSnapshot.cs @@ -373,11 +373,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id") .HasName("pk_itens_documento_fiscal"); - b.HasIndex("Cfop") - .HasDatabaseName("ix_itens_cfop"); + b.HasIndex("DocumentoFiscalId", "Cfop") + .HasDatabaseName("ix_itens_documento_cfop"); - b.HasIndex("Ncm") - .HasDatabaseName("ix_itens_ncm"); + b.HasIndex("DocumentoFiscalId", "Ncm") + .HasDatabaseName("ix_itens_documento_ncm"); b.HasIndex("DocumentoFiscalId", "Numero") .IsUnique() diff --git a/src/eContabil.Infrastructure/Persistencia/Repositorios/DocumentoFiscalRepository.cs b/src/eContabil.Infrastructure/Persistencia/Repositorios/DocumentoFiscalRepository.cs index eb8cb6f..0b2c60e 100644 --- a/src/eContabil.Infrastructure/Persistencia/Repositorios/DocumentoFiscalRepository.cs +++ b/src/eContabil.Infrastructure/Persistencia/Repositorios/DocumentoFiscalRepository.cs @@ -22,6 +22,35 @@ public sealed class DocumentoFiscalRepository(EContabilDbContext contexto) .FirstOrDefaultAsync(documento => documento.EmpresaId == empresaId && documento.Chave == chave, ct); } + /// + /// + /// Rastreamento ligado e itens carregados: o que voltar daqui pode ser promovido de resumo para XML + /// completo na mesma unidade de trabalho, e a promoção substitui a lista de itens. + /// + /// A comparação é sobre o próprio objeto de valor, e não sobre o texto dentro dele. A chave é + /// mapeada por conversor, então Chave.Valor não é membro que o provedor saiba traduzir — ele + /// enxerga uma coluna só. Comparando o objeto, o conversor entra e a condição vira um IN. + /// + public async Task> ObterPorEmpresaEChavesAsync( + Guid empresaId, IReadOnlyCollection chaves, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(chaves); + + if (chaves.Count == 0) + { + return new Dictionary(StringComparer.Ordinal); + } + + var encontrados = await Conjunto + .AsTracking() + .Include(documento => documento.Itens) + .Where(documento => documento.EmpresaId == empresaId && chaves.Contains(documento.Chave)) + .ToListAsync(ct); + + return encontrados.ToDictionary( + documento => documento.Chave.Valor, StringComparer.Ordinal); + } + public async Task ExisteXmlArmazenadoAsync(ChaveAcesso chave, CancellationToken ct) { ArgumentNullException.ThrowIfNull(chave); diff --git a/tests/eContabil.Application.Tests/Sincronizacao/CenarioSincronizacao.cs b/tests/eContabil.Application.Tests/Sincronizacao/CenarioSincronizacao.cs index 4569eb8..1e6b5af 100644 --- a/tests/eContabil.Application.Tests/Sincronizacao/CenarioSincronizacao.cs +++ b/tests/eContabil.Application.Tests/Sincronizacao/CenarioSincronizacao.cs @@ -142,11 +142,20 @@ public CenarioSincronizacao ComStorageFora() return this; } + /// + /// O handler consulta os já gravados numa ida só, por chave, antes de percorrer o lote — e não um + /// por um dentro dele. O dublê acompanha essa forma. + /// public CenarioSincronizacao ComDocumentoJaGravado(DocumentoFiscal documento) { - Documentos.ObterPorEmpresaEChaveAsync( - Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(documento); + ArgumentNullException.ThrowIfNull(documento); + + Documentos.ObterPorEmpresaEChavesAsync( + Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(new Dictionary(StringComparer.Ordinal) + { + [documento.Chave.Valor] = documento + }); return this; } diff --git a/tests/eContabil.Application.Tests/Sincronizacao/OpcoesAgendamentoTests.cs b/tests/eContabil.Application.Tests/Sincronizacao/OpcoesAgendamentoTests.cs new file mode 100644 index 0000000..22522dd --- /dev/null +++ b/tests/eContabil.Application.Tests/Sincronizacao/OpcoesAgendamentoTests.cs @@ -0,0 +1,61 @@ +using System.ComponentModel.DataAnnotations; +using eContabil.Application.Sincronizacao; +using Xunit; + +namespace eContabil.Application.Tests.Sincronizacao; + +/// +/// Estes dois números decidem quantas empresas o escritório comporta. +/// +/// +/// Eram constantes no código, e mudá-los exigia um deploy. A validação existe para o erro aparecer no +/// boot: agendador com zero worker sobe calado e não processa nada — a fila enche, os documentos não +/// chegam, e nada no sistema diz por quê. +/// +public class OpcoesAgendamentoTests +{ + [Fact] + public void Padrao_ComportaMilEmpresasSemAtraso() + { + // Empresa sem novidade volta em uma hora, então metade da base fica elegível a cada ciclo de + // trinta minutos. Quinhentas por ciclo atendem mil empresas. + var opcoes = new OpcoesAgendamento(); + + Assert.Equal(500, opcoes.EmpresasPorCiclo); + Assert.Equal(8, opcoes.Workers); + Assert.True(opcoes.ProcessarTrabalhos); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void EmpresasPorCiclo_NaoPositivo_ERecusadoNoBoot(int valor) + { + var opcoes = new OpcoesAgendamento { EmpresasPorCiclo = valor }; + + Assert.False(Valido(opcoes)); + } + + [Theory] + [InlineData(0)] + [InlineData(201)] + public void Workers_ForaDaFaixa_ERecusadoNoBoot(int valor) + { + // Zero não processa nada; o teto existe porque cada worker é uma thread bloqueada em rede + // durante toda a chamada à SEFAZ, e passar de duzentos não compra vazão nenhuma. + var opcoes = new OpcoesAgendamento { Workers = valor }; + + Assert.False(Valido(opcoes)); + } + + [Fact] + public void Valores_DeUmEscritorioGrande_SaoAceitos() + { + var opcoes = new OpcoesAgendamento { EmpresasPorCiclo = 5_000, Workers = 32 }; + + Assert.True(Valido(opcoes)); + } + + private static bool Valido(OpcoesAgendamento opcoes) => + Validator.TryValidateObject(opcoes, new ValidationContext(opcoes), [], validateAllProperties: true); +} diff --git a/tests/eContabil.Infrastructure.Tests/Certificados/CofreComCacheDeExecucaoTests.cs b/tests/eContabil.Infrastructure.Tests/Certificados/CofreComCacheDeExecucaoTests.cs new file mode 100644 index 0000000..0b403e5 --- /dev/null +++ b/tests/eContabil.Infrastructure.Tests/Certificados/CofreComCacheDeExecucaoTests.cs @@ -0,0 +1,131 @@ +using System.Text; +using eContabil.Application.Certificados; +using eContabil.Infrastructure.Certificados; +using eContabil.Shared; +using NSubstitute; +using Xunit; + +namespace eContabil.Infrastructure.Tests.Certificados; + +/// +/// Drenar uma empresa atrasada encadeia até duzentas consultas à SEFAZ, e cada uma pedia o mesmo +/// certificado de volta ao object storage para decifrá-lo outra vez. +/// +/// +/// O cache tem escopo de execução. A regra que ele não pode afrouxar é a que importa: chave privada não +/// atravessa execuções nem empresas. +/// +public class CofreComCacheDeExecucaoTests +{ + private static readonly Guid Certificado = Guid.CreateVersion7(); + + private readonly ICertificadoCofre _interno = Substitute.For(); + + [Fact] + public async Task CarregarAsync_ChamadoVariasVezes_VaiAoCofreUmaVezSo() + { + ComCertificado(Certificado, "pfx-original"); + var cofre = new CofreComCacheDeExecucao(_interno); + + for (var lote = 0; lote < 20; lote++) + { + var carregado = await cofre.CarregarAsync( + Localizacao(Certificado), TestContext.Current.CancellationToken); + + Assert.True(carregado.Sucesso); + } + + await _interno.Received(1).CarregarAsync( + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task CarregarAsync_ComCertificadosDiferentes_NaoServeUmNoLugarDoOutro() + { + // Entregar a chave privada de um cliente numa consulta feita em nome de outro é o pior defeito + // possível neste sistema: o eContabil assinaria perante a SEFAZ em nome de quem não deveria. + var outro = Guid.CreateVersion7(); + + ComCertificado(Certificado, "pfx-da-primeira"); + ComCertificado(outro, "pfx-da-segunda"); + + var cofre = new CofreComCacheDeExecucao(_interno); + + var primeiro = await cofre.CarregarAsync( + Localizacao(Certificado), TestContext.Current.CancellationToken); + + var segundo = await cofre.CarregarAsync( + Localizacao(outro), TestContext.Current.CancellationToken); + + Assert.Equal("pfx-da-primeira", Encoding.UTF8.GetString(primeiro.Valor.Pfx)); + Assert.Equal("pfx-da-segunda", Encoding.UTF8.GetString(segundo.Valor.Pfx)); + } + + [Fact] + public async Task CarregarAsync_QuandoOCofreFalha_NaoGuardaOFracasso() + { + // Falha de rede no primeiro lote não pode condenar os dezenove seguintes. + _interno.CarregarAsync(Arg.Any(), Arg.Any()) + .Returns( + _ => Result.Falhou( + Error.Falha("storage.indisponivel", "MinIO fora.")), + _ => Result.Ok( + new CertificadoCarregadoDto(Encoding.UTF8.GetBytes("pfx"), "senha", "SP", "11222333000181"))); + + var cofre = new CofreComCacheDeExecucao(_interno); + + var primeira = await cofre.CarregarAsync( + Localizacao(Certificado), TestContext.Current.CancellationToken); + + var segunda = await cofre.CarregarAsync( + Localizacao(Certificado), TestContext.Current.CancellationToken); + + Assert.True(primeira.Falha); + Assert.True(segunda.Sucesso); + } + + [Fact] + public async Task RemoverAsync_InvalidaOQueEstavaEmMemoria() + { + // O operador acabou de mandar apagar a chave privada. Continuar servindo a cópia em memória + // entregaria exatamente o que ele pediu para destruir. + ComCertificado(Certificado, "pfx-original"); + var cofre = new CofreComCacheDeExecucao(_interno); + + await cofre.CarregarAsync(Localizacao(Certificado), TestContext.Current.CancellationToken); + await cofre.RemoverAsync("certificados", "arquivo.pfx.enc", TestContext.Current.CancellationToken); + await cofre.CarregarAsync(Localizacao(Certificado), TestContext.Current.CancellationToken); + + await _interno.Received(2).CarregarAsync( + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Dispose_ZeraOMaterialDecifrado() + { + // Não é garantia contra despejo de memória, mas encurta a janela em que a chave fica legível no + // heap depois de deixar de ser necessária. + ComCertificado(Certificado, "pfx-original"); + var cofre = new CofreComCacheDeExecucao(_interno); + + var carregado = await cofre.CarregarAsync( + Localizacao(Certificado), TestContext.Current.CancellationToken); + + var bytes = carregado.Valor.Pfx; + Assert.Contains(bytes, valor => valor != 0); + + cofre.Dispose(); + + Assert.All(bytes, valor => Assert.Equal(0, valor)); + } + + private void ComCertificado(Guid certificadoId, string conteudo) => + _interno.CarregarAsync( + Arg.Is(local => local != null && local.CertificadoId == certificadoId), + Arg.Any()) + .Returns(_ => Result.Ok(new CertificadoCarregadoDto( + Encoding.UTF8.GetBytes(conteudo), "senha", "SP", "11222333000181"))); + + private static CertificadoLocalizacao Localizacao(Guid certificadoId) => + new(certificadoId, "certificados", $"{certificadoId}.pfx.enc", "senha-cifrada", "SP", "11222333000181"); +} diff --git a/tests/eContabil.Infrastructure.Tests/Persistencia/DocumentoFiscalRepositoryTests.cs b/tests/eContabil.Infrastructure.Tests/Persistencia/DocumentoFiscalRepositoryTests.cs index 50128cd..c0a4872 100644 --- a/tests/eContabil.Infrastructure.Tests/Persistencia/DocumentoFiscalRepositoryTests.cs +++ b/tests/eContabil.Infrastructure.Tests/Persistencia/DocumentoFiscalRepositoryTests.cs @@ -12,6 +12,9 @@ public class DocumentoFiscalRepositoryTests(PostgresFixture postgres) { private const string ChaveValida = "35260711222333000181550010000123451123456787"; + /// Chave válida que nenhum teste grava — serve para provar a ausência. + private const string ChaveInexistente = "35260711222333000181550010000999991123456781"; + [Fact] public async Task Gravar_MesmaChaveParaAMesmaEmpresa_ERejeitadoPeloIndiceUnico() { @@ -195,6 +198,127 @@ public async Task ObterPendentesDeManifestacao_ComResumoSemXml_TrazODocumento() Assert.Single(pendentes); } + [Fact] + public async Task ObterPorEmpresaEChaves_TrazOsGravadosNumaConsultaSo() + { + // A SEFAZ entrega até cinquenta documentos por chamada. Perguntar por um de cada vez custava + // cinquenta consultas por lote e mil numa execução que encadeia vinte. + if (postgres.MotivoIndisponivel is { } motivo) + { + Assert.Skip(motivo); + return; + } + + await postgres.LimparAsync(); + var empresaId = await CriarEmpresaAsync(); + + await using (var escrita = postgres.CriarContexto()) + { + escrita.DocumentosFiscais.Add(Documento(empresaId, nsu: 1000)); + await escrita.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + await using var contexto = postgres.CriarContexto(); + var repositorio = new DocumentoFiscalRepository(contexto); + + var encontrados = await repositorio.ObterPorEmpresaEChavesAsync( + empresaId, + [Chave(ChaveValida), Chave(ChaveInexistente)], + TestContext.Current.CancellationToken); + + // A chave inexistente simplesmente não aparece — o chamador decide o que fazer com a ausência. + Assert.Single(encontrados); + Assert.True(encontrados.ContainsKey(ChaveValida)); + } + + [Fact] + public async Task ObterPorEmpresaEChaves_DevolveRastreadoParaPromocao() + { + // O que volta daqui pode ser promovido de resumo para XML completo na mesma unidade de trabalho. + if (postgres.MotivoIndisponivel is { } motivo) + { + Assert.Skip(motivo); + return; + } + + await postgres.LimparAsync(); + var empresaId = await CriarEmpresaAsync(); + + await using (var escrita = postgres.CriarContexto()) + { + escrita.DocumentosFiscais.Add(Documento(empresaId, nsu: 1000)); + await escrita.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + await using var contexto = postgres.CriarContexto(); + var repositorio = new DocumentoFiscalRepository(contexto); + + var encontrados = await repositorio.ObterPorEmpresaEChavesAsync( + empresaId, [Chave(ChaveValida)], TestContext.Current.CancellationToken); + + encontrados[ChaveValida].PromoverParaXmlCompleto("xmls", "2026/07/chave.xml", "hash"); + await contexto.SaveChangesAsync(TestContext.Current.CancellationToken); + + await using var conferencia = postgres.CriarContexto(); + var gravado = await conferencia.DocumentosFiscais.SingleAsync(TestContext.Current.CancellationToken); + + Assert.True(gravado.PossuiXml); + } + + [Fact] + public async Task ObterPorEmpresaEChaves_SemChaves_NaoVaiAoBanco() + { + // Lote inteiro descartado por chave inválida acontece. Um `IN` vazio seria consulta paga para + // devolver nada. + if (postgres.MotivoIndisponivel is { } motivo) + { + Assert.Skip(motivo); + return; + } + + await postgres.LimparAsync(); + + await using var contexto = postgres.CriarContexto(); + var repositorio = new DocumentoFiscalRepository(contexto); + + var encontrados = await repositorio.ObterPorEmpresaEChavesAsync( + Guid.CreateVersion7(), [], TestContext.Current.CancellationToken); + + Assert.Empty(encontrados); + } + + [Fact] + public async Task ObterPorEmpresaEChaves_NaoAtravessaEmpresas() + { + // A mesma NF-e chega para duas empresas do escritório sob NSUs diferentes. Devolver o registro + // da outra faria a sincronização promover o documento errado. + if (postgres.MotivoIndisponivel is { } motivo) + { + Assert.Skip(motivo); + return; + } + + await postgres.LimparAsync(); + var primeira = await CriarEmpresaAsync(); + var segunda = await CriarEmpresaAsync("11444777000161"); + + await using (var escrita = postgres.CriarContexto()) + { + escrita.DocumentosFiscais.Add(Documento(primeira, nsu: 1000)); + await escrita.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + await using var contexto = postgres.CriarContexto(); + var repositorio = new DocumentoFiscalRepository(contexto); + + var encontrados = await repositorio.ObterPorEmpresaEChavesAsync( + segunda, [Chave(ChaveValida)], TestContext.Current.CancellationToken); + + Assert.Empty(encontrados); + } + + private static ChaveAcesso Chave(string valor) => ChaveAcesso.Criar(valor).Valor; + private async Task CriarEmpresaAsync(string cnpj = "11222333000181") { await using var contexto = postgres.CriarContexto();