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