diff --git a/src/eContabil.Api/Program.cs b/src/eContabil.Api/Program.cs index 6b6ac38..e22d53f 100644 --- a/src/eContabil.Api/Program.cs +++ b/src/eContabil.Api/Program.cs @@ -208,10 +208,15 @@ static void RegistrarTrabalhosRecorrentes(IServiceProvider servicos) { var recorrentes = servicos.GetRequiredService(); + // De cinco em cinco minutos, e não de trinta em trinta. O intervalo entre consultas à SEFAZ não + // muda — quem o define é `IntervaloSemNovidade`, e a elegibilidade continua sendo a mesma cláusula. + // O que muda é a granularidade da grade: com trinta minutos, a empresa que vencia poucos segundos + // depois do tique perdia a janela inteira e só voltava no ciclo seguinte, esticando o intervalo real + // de uma hora para uma hora e meia. O custo por tique é uma consulta em índice parcial. recorrentes.AddOrUpdate( "sincronizacao-geral", job => job.ExecutarAsync(default), - "*/30 * * * *", + "*/5 * * * *", new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); recorrentes.AddOrUpdate( diff --git a/src/eContabil.Application/Documentos/Consultas/FiltroDocumentos.cs b/src/eContabil.Application/Documentos/Consultas/FiltroDocumentos.cs index af7f652..5ecaa1e 100644 --- a/src/eContabil.Application/Documentos/Consultas/FiltroDocumentos.cs +++ b/src/eContabil.Application/Documentos/Consultas/FiltroDocumentos.cs @@ -1,4 +1,5 @@ using eContabil.Domain.Documentos; +using eContabil.Shared; namespace eContabil.Application.Documentos.Consultas; @@ -31,6 +32,22 @@ public sealed record FiltroDocumentos( string? Cursor = null, int Tamanho = 50) { + /// + /// Início do período, sempre em UTC. + /// + /// + /// A query string pode trazer data sem fuso — 2026-07-29 —, e o binder devolve isso com + /// Kind=Unspecified, que o Npgsql recusa ao escrever em timestamptz. Normalizar aqui + /// resolve para qualquer chamador, em vez de repetir a conversão em cada endpoint. + /// + /// Instante, não data: quem escolhe o dia é o navegador, que é o único que conhece o fuso de quem + /// está olhando a tela, e manda os extremos já resolvidos. + /// + public DateTime? De { get; init; } = InstanteUtc.Normalizar(De); + + /// Fim do período, sempre em UTC. Vale o mesmo que . + public DateTime? Ate { get; init; } = InstanteUtc.Normalizar(Ate); + /// Diz se algum critério depende de dados que só existem no XML completo. public bool ExigeDadosFiscais => !string.IsNullOrWhiteSpace(Cfop) diff --git a/src/eContabil.Application/Documentos/Consultas/ObterRelatorioPorEmpresaQuery.cs b/src/eContabil.Application/Documentos/Consultas/ObterRelatorioPorEmpresaQuery.cs index 51dd07d..3d94570 100644 --- a/src/eContabil.Application/Documentos/Consultas/ObterRelatorioPorEmpresaQuery.cs +++ b/src/eContabil.Application/Documentos/Consultas/ObterRelatorioPorEmpresaQuery.cs @@ -5,4 +5,11 @@ namespace eContabil.Application.Documentos.Consultas; /// Captura consolidada por empresa num período. public sealed record ObterRelatorioPorEmpresaQuery(DateTime? De, DateTime? Ate) - : IRequest>>; + : IRequest>> +{ + /// Início do período, sempre em UTC. Vale o mesmo do filtro da grade. + public DateTime? De { get; init; } = InstanteUtc.Normalizar(De); + + /// Fim do período, sempre em UTC. + public DateTime? Ate { get; init; } = InstanteUtc.Normalizar(Ate); +} diff --git a/src/eContabil.Domain/Empresas/IEmpresaRepository.cs b/src/eContabil.Domain/Empresas/IEmpresaRepository.cs index 4f05af3..33489f3 100644 --- a/src/eContabil.Domain/Empresas/IEmpresaRepository.cs +++ b/src/eContabil.Domain/Empresas/IEmpresaRepository.cs @@ -13,5 +13,15 @@ public interface IEmpresaRepository : IRepository Task> ObterElegiveisParaSincronizarAsync( DateTime agoraUtc, int limite, CancellationToken ct); + /// + /// Quando a próxima empresa vence, entre as que ainda não venceram. + /// + /// + /// Serve só para o diagnóstico do ciclo vazio: quando nada é elegível, é o que distingue "não há + /// trabalho" de "há trabalho a poucos segundos daqui". Devolve nulo quando nenhuma empresa está + /// programada. + /// + Task ObterProximoVencimentoAsync(DateTime agoraUtc, CancellationToken ct); + Task CnpjJaCadastradoAsync(Cnpj cnpj, Guid? ignorarId, CancellationToken ct); } diff --git a/src/eContabil.Infrastructure/Agendamento/SincronizacaoGeralJob.cs b/src/eContabil.Infrastructure/Agendamento/SincronizacaoGeralJob.cs index dd1b7a4..ff1cc6b 100644 --- a/src/eContabil.Infrastructure/Agendamento/SincronizacaoGeralJob.cs +++ b/src/eContabil.Infrastructure/Agendamento/SincronizacaoGeralJob.cs @@ -53,7 +53,7 @@ public async Task ExecutarAsync(CancellationToken ct) } } - RegistrarAtraso(elegiveis, agora); + await RegistrarAtrasoAsync(elegiveis, agora, ct); CicloEnfileirado(log, elegiveis.Count); } @@ -69,11 +69,16 @@ public async Task ExecutarAsync(CancellationToken ct) /// mede o tempo até uma nota nova aparecer: esse depende também de IntervaloSemNovidade, que /// é com que frequência uma empresa sem novidade volta a ser consultada. /// - private void RegistrarAtraso(IReadOnlyList elegiveis, DateTime agora) + private async Task RegistrarAtrasoAsync( + IReadOnlyList elegiveis, DateTime agora, CancellationToken ct) { - var atraso = elegiveis.Count > 0 - ? agora - elegiveis[0].ProximaConsultaEm - : TimeSpan.Zero; + if (elegiveis.Count == 0) + { + await RegistrarCicloVazioAsync(agora, ct); + return; + } + + var atraso = agora - elegiveis[0].ProximaConsultaEm; metricas.RegistrarCiclo(atraso, elegiveis.Count, elegiveis.Count); @@ -83,11 +88,38 @@ private void RegistrarAtraso(IReadOnlyList elegiveis, DateTime agora) } } + /// + /// Mede o que o atraso máximo não alcança: quanto falta para a próxima empresa vencer. + /// + /// + /// O atraso só enxerga quem já venceu, então um ciclo vazio sempre media zero — inclusive quando + /// havia empresa vencendo segundos depois do tique, que era o caso enquanto a grade era de trinta + /// minutos e o intervalo real virava uma hora e meia. + /// + /// A consulta extra acontece só aqui. No ciclo que enfileirou alguma coisa a resposta não + /// acrescenta nada, e o caminho normal continua com uma consulta só. + /// + private async Task RegistrarCicloVazioAsync(DateTime agora, CancellationToken ct) + { + var proximo = await empresas.ObterProximoVencimentoAsync(agora, ct); + var espera = proximo is { } vencimento ? vencimento - agora : (TimeSpan?)null; + + metricas.RegistrarCiclo(TimeSpan.Zero, elegiveis: 0, enfileiradas: 0, espera); + + CicloVazio(log, espera?.TotalSeconds ?? -1); + } + [LoggerMessage( Level = LogLevel.Information, Message = "Ciclo de sincronização enfileirou {Quantidade} empresa(s) elegível(is)")] private static partial void CicloEnfileirado(ILogger log, int quantidade); + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Ciclo de sincronização não encontrou empresa elegível; a próxima vence em " + + "{EsperaEmSegundos:0.0} segundo(s) (negativo: nenhuma programada)")] + private static partial void CicloVazio(ILogger log, double esperaEmSegundos); + [LoggerMessage( Level = LogLevel.Warning, Message = "Captura atrasada: a empresa mais antiga da fila esperou {AtrasoEmMinutos:0.0} minuto(s) " + diff --git a/src/eContabil.Infrastructure/Observabilidade/MetricasDeCaptura.cs b/src/eContabil.Infrastructure/Observabilidade/MetricasDeCaptura.cs index 73d2720..c59fd77 100644 --- a/src/eContabil.Infrastructure/Observabilidade/MetricasDeCaptura.cs +++ b/src/eContabil.Infrastructure/Observabilidade/MetricasDeCaptura.cs @@ -28,6 +28,10 @@ public sealed class MetricasDeCaptura : IDisposable private long _elegiveis; private long _enfileiradas; + // Negativo significa "nada programado", que é diferente de zero — zero seria uma empresa vencendo + // exatamente agora. + private long _esperaAteOProximoEmTicks = -1; + public MetricasDeCaptura() { _medidor = new Meter(Nome); @@ -47,6 +51,29 @@ public MetricasDeCaptura() "econtabil.captura.empresas_enfileiradas", () => Interlocked.Read(ref _enfileiradas), description: "Empresas que o ciclo conseguiu enfileirar."); + + // Lido junto de `empresas_elegiveis`: zero elegíveis com espera de poucos segundos é o ciclo + // perdendo a janela por pouco, que o atraso máximo não enxerga — ele só mede quem já venceu. + _medidor.CreateObservableGauge( + "econtabil.captura.espera_ate_a_proxima", + LerEsperaAteAProxima, + unit: "s", + description: "Quanto falta para a próxima empresa vencer, quando nenhuma está elegível."); + } + + /// + /// Devolve sequência vazia quando não há nada programado. É como a API de métricas expressa "sem + /// medição neste instante" — o instrumento não aceita tipo anulável, e reportar zero diria que uma + /// empresa está vencendo agora. + /// + private IEnumerable> LerEsperaAteAProxima() + { + var ticks = Interlocked.Read(ref _esperaAteOProximoEmTicks); + + if (ticks >= 0) + { + yield return new Measurement(TimeSpan.FromTicks(ticks).TotalSeconds); + } } /// Registra o que o ciclo encontrou. @@ -54,11 +81,23 @@ public MetricasDeCaptura() /// é do documento mais atrasado da fila, não a média: média esconde /// justamente a empresa que está furando o prazo. /// - public void RegistrarCiclo(TimeSpan atraso, int elegiveis, int enfileiradas) + /// Espera da empresa mais antiga da fila além do previsto. + /// Empresas em condição de sincronizar no início do ciclo. + /// Empresas que o ciclo conseguiu enfileirar. + /// + /// Quanto falta para a próxima empresa vencer, ou nulo quando não há nada programado. Só é apurado + /// no ciclo vazio, que é o único em que a informação acrescenta algo. + /// + public void RegistrarCiclo( + TimeSpan atraso, int elegiveis, int enfileiradas, TimeSpan? esperaAteAProxima = null) { Interlocked.Exchange(ref _atrasoEmTicks, Math.Max(atraso.Ticks, 0)); Interlocked.Exchange(ref _elegiveis, elegiveis); Interlocked.Exchange(ref _enfileiradas, enfileiradas); + + Interlocked.Exchange( + ref _esperaAteOProximoEmTicks, + esperaAteAProxima is { } espera ? Math.Max(espera.Ticks, 0) : -1); } public void Dispose() => _medidor.Dispose(); diff --git a/src/eContabil.Infrastructure/Persistencia/Repositorios/EmpresaRepository.cs b/src/eContabil.Infrastructure/Persistencia/Repositorios/EmpresaRepository.cs index 5226d5b..2311a40 100644 --- a/src/eContabil.Infrastructure/Persistencia/Repositorios/EmpresaRepository.cs +++ b/src/eContabil.Infrastructure/Persistencia/Repositorios/EmpresaRepository.cs @@ -46,6 +46,21 @@ await Conjunto .Take(limite) .ToListAsync(ct); + /// + /// Menor ProximaConsultaEm ainda no futuro, entre as empresas que o ciclo consideraria. + /// + /// + /// Um agregado no banco, sem materializar entidade: o ciclo só quer saber o instante. As mesmas + /// cláusulas da elegibilidade, invertida apenas a do vencimento — o bloqueio continua excluindo, + /// porque empresa bloqueada não é trabalho iminente, é espera. + /// + public async Task ObterProximoVencimentoAsync(DateTime agoraUtc, CancellationToken ct) => + await Conjunto + .Where(empresa => empresa.Ativa) + .Where(empresa => empresa.ProximaConsultaEm > agoraUtc) + .Where(empresa => empresa.BloqueadaAte == null || empresa.BloqueadaAte <= agoraUtc) + .MinAsync(empresa => (DateTime?)empresa.ProximaConsultaEm, ct); + public async Task CnpjJaCadastradoAsync(Cnpj cnpj, Guid? ignorarId, CancellationToken ct) { ArgumentNullException.ThrowIfNull(cnpj); diff --git a/src/eContabil.Shared/InstanteUtc.cs b/src/eContabil.Shared/InstanteUtc.cs new file mode 100644 index 0000000..351b7dd --- /dev/null +++ b/src/eContabil.Shared/InstanteUtc.cs @@ -0,0 +1,27 @@ +namespace eContabil.Shared; + +/// +/// Normaliza instantes vindos de fora para UTC. +/// +/// +/// Toda coluna de data do banco é timestamptz, e o Npgsql recusa que não +/// seja — inclusive o , que é o que +/// o binder do ASP.NET produz quando a query string traz uma data sem fuso, como 2026-07-29. Sem +/// esta normalização a consulta morre com "Cannot write DateTime with Kind=Unspecified" no meio da +/// execução, e não na validação. +/// +/// Data sem fuso é lida como UTC: é a única suposição que o servidor pode fazer sozinho. Quem conhece o +/// fuso do usuário é o navegador, e é de lá que precisam vir os instantes exatos. +/// +public static class InstanteUtc +{ + public static DateTime? Normalizar(DateTime? instante) => + instante is { } valor ? Normalizar(valor) : null; + + public static DateTime Normalizar(DateTime instante) => instante.Kind switch + { + DateTimeKind.Utc => instante, + DateTimeKind.Local => instante.ToUniversalTime(), + _ => DateTime.SpecifyKind(instante, DateTimeKind.Utc) + }; +} diff --git a/src/econtabil-web/src/app/features/documentos/documentos.service.ts b/src/econtabil-web/src/app/features/documentos/documentos.service.ts index ca7dbdf..cdf74bb 100644 --- a/src/econtabil-web/src/app/features/documentos/documentos.service.ts +++ b/src/econtabil-web/src/app/features/documentos/documentos.service.ts @@ -2,6 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; import { Observable } from 'rxjs'; import { API_BASE_URL } from '../../core/http/api.config'; +import { fimDoDia, inicioDoDia } from './periodo-do-filtro'; import { KeysetPage } from '../../shared/api.model'; import { DocumentoDetalhe, @@ -16,18 +17,42 @@ export class DocumentosService { private readonly http = inject(HttpClient); private readonly baseUrl = inject(API_BASE_URL); + /** + * Monta a query string do filtro, resolvendo o período no fuso do navegador. + * + * `de` e `ate` chegam como data pura, vinda do `` e mantida assim no filtro e na + * URL. Aqui viram os instantes que delimitam o dia escolhido, que é o que a API compara com uma + * coluna `timestamptz`. + */ + private parametrosDoFiltro(filtro: FiltroDocumentos): HttpParams { + let parametros = new HttpParams(); + + for (const [chave, valor] of Object.entries(filtro)) { + if (valor === null || valor === '') { + continue; + } + + const instante = + chave === 'de' + ? inicioDoDia(String(valor)) + : chave === 'ate' + ? fimDoDia(String(valor)) + : String(valor); + + if (instante !== null) { + parametros = parametros.set(chave, instante); + } + } + + return parametros; + } + listar( filtro: FiltroDocumentos, cursor: string | null, tamanho = 50, ): Observable> { - let parametros = new HttpParams().set('tamanho', tamanho); - - for (const [chave, valor] of Object.entries(filtro)) { - if (valor !== null && valor !== '') { - parametros = parametros.set(chave, String(valor)); - } - } + let parametros = this.parametrosDoFiltro(filtro).set('tamanho', tamanho); if (cursor !== null) { parametros = parametros.set('cursor', cursor); @@ -45,13 +70,7 @@ export class DocumentosService { * muda a cada "carregar mais". */ resumir(filtro: FiltroDocumentos): Observable { - let parametros = new HttpParams(); - - for (const [chave, valor] of Object.entries(filtro)) { - if (valor !== null && valor !== '') { - parametros = parametros.set(chave, String(valor)); - } - } + const parametros = this.parametrosDoFiltro(filtro); return this.http.get(`${this.baseUrl}/documentos/resumo`, { params: parametros, diff --git a/src/econtabil-web/src/app/features/documentos/periodo-do-filtro.spec.ts b/src/econtabil-web/src/app/features/documentos/periodo-do-filtro.spec.ts new file mode 100644 index 0000000..a558114 --- /dev/null +++ b/src/econtabil-web/src/app/features/documentos/periodo-do-filtro.spec.ts @@ -0,0 +1,41 @@ +import { fimDoDia, inicioDoDia } from './periodo-do-filtro'; + +describe('periodoDoFiltro', () => { + it('resolve o inicio do dia no fuso do navegador', () => { + const instante = inicioDoDia('2026-07-29'); + + // Comparado contra a data local equivalente em vez de um literal em UTC: o resultado depende do fuso + // da máquina, e fixar "03:00Z" só passaria em UTC-3. + expect(instante).toBe(new Date(2026, 6, 29, 0, 0, 0, 0).toISOString()); + }); + + it('resolve o fim do dia no ultimo milissegundo', () => { + // O filtro compara com `<=`. Usar a meia-noite seguinte arrastaria o primeiro instante do dia + // seguinte para dentro do recorte. + const instante = fimDoDia('2026-07-29'); + + expect(instante).toBe(new Date(2026, 6, 29, 23, 59, 59, 999).toISOString()); + }); + + it('cobre o dia inteiro entre os dois extremos', () => { + const de = new Date(inicioDoDia('2026-07-29')!); + const ate = new Date(fimDoDia('2026-07-29')!); + + expect(ate.getTime() - de.getTime()).toBe(86_399_999); + }); + + it('mantem o dia escolhido no calendario de quem escolheu', () => { + // `new Date('2026-07-29')` é meia-noite UTC pela especificação, e no Brasil isso cai às 21h do dia + // 28. A implementação decompõe a data em partes justamente para não escorregar um dia inteiro. + expect(new Date(inicioDoDia('2026-07-29')!).getDate()).toBe(29); + expect(new Date(fimDoDia('2026-07-29')!).getDate()).toBe(29); + }); + + it('devolve nulo para valor que nao e data', () => { + // O campo pode chegar vazio ou pela metade enquanto o usuário digita, e mandar isso para a API + // produzia a data sem fuso que derrubava a consulta. + expect(inicioDoDia('')).toBeNull(); + expect(inicioDoDia('2026-07')).toBeNull(); + expect(fimDoDia('29/07/2026')).toBeNull(); + }); +}); diff --git a/src/econtabil-web/src/app/features/documentos/periodo-do-filtro.ts b/src/econtabil-web/src/app/features/documentos/periodo-do-filtro.ts new file mode 100644 index 0000000..b45b9f0 --- /dev/null +++ b/src/econtabil-web/src/app/features/documentos/periodo-do-filtro.ts @@ -0,0 +1,55 @@ +/** + * Converte a data escolhida na tela no instante correspondente, no fuso de quem está olhando. + * + * O campo é um ``: devolve `2026-07-29`, sem hora e sem fuso. Mandar isso direto + * para a API produzia `DateTime` com `Kind=Unspecified`, que o Npgsql recusa ao comparar com uma coluna + * `timestamptz` — a consulta morria no meio da execução. + * + * O navegador é o único lugar que sabe em que fuso o usuário está, então é aqui que o dia vira + * intervalo. Quem escolhe "29/07" em São Paulo quer o que foi emitido entre 29/07 00:00 e 29/07 23:59 + * no relógio dele, não em UTC — a diferença são três horas de notas em cada ponta. + * + * O filtro e a URL continuam guardando a data pura: é o que o campo exibe de volta e o que faz o + * endereço permanecer legível e compartilhável. + */ +export function inicioDoDia(data: string): string | null { + const partes = decompor(data); + + if (partes === null) { + return null; + } + + const [ano, mes, dia] = partes; + + return new Date(ano, mes, dia, 0, 0, 0, 0).toISOString(); +} + +export function fimDoDia(data: string): string | null { + const partes = decompor(data); + + if (partes === null) { + return null; + } + + const [ano, mes, dia] = partes; + + // 23:59:59.999 e não a meia-noite seguinte: o filtro compara com `<=`, e o dia escolhido precisa + // entrar inteiro sem arrastar o primeiro milissegundo do dia seguinte. + return new Date(ano, mes, dia, 23, 59, 59, 999).toISOString(); +} + +/** + * Quebra `YYYY-MM-DD` em ano, mês e dia. + * + * Necessário porque `new Date('2026-07-29')` é interpretado como meia-noite **UTC** pela especificação, + * e não no fuso local — usá-lo deslocaria o dia inteiro. + */ +function decompor(data: string): [number, number, number] | null { + const partes = /^(\d{4})-(\d{2})-(\d{2})$/.exec(data); + + if (partes === null) { + return null; + } + + return [Number(partes[1]), Number(partes[2]) - 1, Number(partes[3])]; +} diff --git a/tests/eContabil.Application.Tests/Documentos/PeriodoDoFiltroTests.cs b/tests/eContabil.Application.Tests/Documentos/PeriodoDoFiltroTests.cs new file mode 100644 index 0000000..da49b9f --- /dev/null +++ b/tests/eContabil.Application.Tests/Documentos/PeriodoDoFiltroTests.cs @@ -0,0 +1,76 @@ +using eContabil.Application.Documentos.Consultas; +using Xunit; + +namespace eContabil.Application.Tests.Documentos; + +/// +/// O filtro normaliza o período para UTC antes de chegar ao banco. +/// +/// +/// A garantia mora no próprio contrato, e não no endpoint: qualquer chamador que monte um +/// — controller, teste, job futuro — recebe o instante já em UTC. Antes, +/// uma data sem fuso vinda da query string derrubava a consulta com "Cannot write DateTime with +/// Kind=Unspecified to PostgreSQL type 'timestamp with time zone'". +/// +public class PeriodoDoFiltroTests +{ + [Fact] + public void Filtro_ComDatasSemFuso_NormalizaParaUtc() + { + var filtro = new FiltroDocumentos( + De: new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Unspecified), + Ate: new DateTime(2026, 7, 29, 23, 59, 59, DateTimeKind.Unspecified)); + + Assert.Equal(DateTimeKind.Utc, filtro.De!.Value.Kind); + Assert.Equal(DateTimeKind.Utc, filtro.Ate!.Value.Kind); + } + + [Fact] + public void Filtro_ComDatasSemFuso_PreservaOsExtremosDoPeriodo() + { + // Normalizar não pode mexer no relógio de quem já mandou o instante certo: o fim do dia + // continua sendo o fim do dia. + var filtro = new FiltroDocumentos( + De: new DateTime(2026, 7, 1, 3, 0, 0, DateTimeKind.Unspecified), + Ate: new DateTime(2026, 7, 30, 2, 59, 59, DateTimeKind.Unspecified)); + + Assert.Equal(new DateTime(2026, 7, 1, 3, 0, 0, DateTimeKind.Utc), filtro.De); + Assert.Equal(new DateTime(2026, 7, 30, 2, 59, 59, DateTimeKind.Utc), filtro.Ate); + } + + [Fact] + public void Filtro_SemPeriodo_ContinuaSemPeriodo() + { + var filtro = new FiltroDocumentos(); + + Assert.Null(filtro.De); + Assert.Null(filtro.Ate); + } + + [Fact] + public void Filtro_AoAjustarOTamanho_MantemOPeriodoNormalizado() + { + // O handler reduz o tamanho com uma expressão `with`, que não reexecuta os inicializadores. + // O período precisa sobreviver à cópia. + var original = new FiltroDocumentos( + De: new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Unspecified), + Tamanho: 5_000); + + var ajustado = original with { Tamanho = 200 }; + + Assert.Equal(DateTimeKind.Utc, ajustado.De!.Value.Kind); + Assert.Equal(original.De, ajustado.De); + } + + [Fact] + public void RelatorioPorEmpresa_ComDatasSemFuso_NormalizaParaUtc() + { + // O relatório do painel tem o mesmo par de parâmetros e o mesmo risco. + var consulta = new ObterRelatorioPorEmpresaQuery( + new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Unspecified), + new DateTime(2026, 7, 29, 23, 59, 59, DateTimeKind.Unspecified)); + + Assert.Equal(DateTimeKind.Utc, consulta.De!.Value.Kind); + Assert.Equal(DateTimeKind.Utc, consulta.Ate!.Value.Kind); + } +} diff --git a/tests/eContabil.Infrastructure.Tests/Agendamento/SincronizacaoGeralJobTests.cs b/tests/eContabil.Infrastructure.Tests/Agendamento/SincronizacaoGeralJobTests.cs index a9becda..22489bd 100644 --- a/tests/eContabil.Infrastructure.Tests/Agendamento/SincronizacaoGeralJobTests.cs +++ b/tests/eContabil.Infrastructure.Tests/Agendamento/SincronizacaoGeralJobTests.cs @@ -57,6 +57,55 @@ public async Task ExecutarAsync_SemEmpresaElegivel_PublicaAtrasoZero() Assert.Equal(0, coletor.Ler("econtabil.captura.atraso_maximo", metricas)); } + [Fact] + public async Task ExecutarAsync_SemEmpresaElegivel_PublicaQuantoFaltaParaAProxima() + { + // O atraso máximo só enxerga quem já venceu, então o ciclo vazio media sempre zero — inclusive + // quando havia empresa vencendo segundos depois do tique, que era o que esticava o intervalo real + // de uma hora para uma hora e meia enquanto a grade era de trinta minutos. + ComElegiveis(); + ComProximoVencimento(AgoraUtc.AddSeconds(7)); + + using var metricas = new MetricasDeCaptura(); + using var coletor = new ColetorDeMetricas(); + + await Montar(metricas).ExecutarAsync(TestContext.Current.CancellationToken); + + Assert.Equal(7, coletor.Ler("econtabil.captura.espera_ate_a_proxima", metricas)); + Assert.Equal(0, coletor.Ler("econtabil.captura.empresas_elegiveis", metricas)); + } + + [Fact] + public async Task ExecutarAsync_SemEmpresaProgramada_NaoPublicaEsperaNenhuma() + { + // Ausência de medição, e não zero: zero diria que uma empresa está vencendo agora, o que levaria + // a investigar um ciclo perdido que não existe. + ComElegiveis(); + ComProximoVencimento(null); + + using var metricas = new MetricasDeCaptura(); + using var coletor = new ColetorDeMetricas(); + + await Montar(metricas).ExecutarAsync(TestContext.Current.CancellationToken); + + Assert.Equal(double.NaN, coletor.Ler("econtabil.captura.espera_ate_a_proxima", metricas)); + } + + [Fact] + public async Task ExecutarAsync_ComEmpresaElegivel_NaoConsultaOProximoVencimento() + { + // A consulta extra existe só para explicar o ciclo vazio. No ciclo que enfileirou alguma coisa a + // resposta não acrescenta nada, e o caminho normal precisa continuar com uma consulta só. + ComElegiveis(AtrasadaEm(TimeSpan.FromMinutes(5))); + + using var metricas = new MetricasDeCaptura(); + + await Montar(metricas).ExecutarAsync(TestContext.Current.CancellationToken); + + await _empresas.DidNotReceive().ObterProximoVencimentoAsync( + Arg.Any(), Arg.Any()); + } + [Fact] public async Task ExecutarAsync_ComAtrasoAcimaDoAceitavel_Avisa() { @@ -73,8 +122,9 @@ public async Task ExecutarAsync_ComAtrasoAcimaDoAceitavel_Avisa() [Fact] public async Task ExecutarAsync_ComAtrasoDeUmCicloInteiro_NaoAvisa() { - // Empresa que atingiu o teto de lotes volta para a fila na hora e espera até trinta minutos pelo - // ciclo seguinte. É operação normal — avisar aqui treinaria o operador a ignorar o alerta. + // Trinta minutos de espera continuam sendo operação normal: empresa reagendada disputa worker com + // as demais, e a fila pode andar mais devagar que a grade do ciclo. Avisar aqui treinaria o + // operador a ignorar o alerta. ComElegiveis(AtrasadaEm(TimeSpan.FromMinutes(30))); var log = LogQueRegistra(); @@ -90,6 +140,10 @@ private void ComElegiveis(params Empresa[] elegiveis) => Arg.Any(), Arg.Any(), Arg.Any()) .Returns(elegiveis); + private void ComProximoVencimento(DateTime? vencimento) => + _empresas.ObterProximoVencimentoAsync(Arg.Any(), Arg.Any()) + .Returns(vencimento); + private SincronizacaoGeralJob Montar( MetricasDeCaptura metricas, ILogger? log = null) => new(_empresas, diff --git a/tests/eContabil.Infrastructure.Tests/Persistencia/FiltrosGlobaisTests.cs b/tests/eContabil.Infrastructure.Tests/Persistencia/FiltrosGlobaisTests.cs index 6537417..fd0765b 100644 --- a/tests/eContabil.Infrastructure.Tests/Persistencia/FiltrosGlobaisTests.cs +++ b/tests/eContabil.Infrastructure.Tests/Persistencia/FiltrosGlobaisTests.cs @@ -208,6 +208,79 @@ public async Task EmpresaInativada_NaoContaCertificadoInativoComoAtivo() Assert.False(empresa.PossuiCertificadoAtivo); } + [Fact] + public async Task ObterProximoVencimentoAsync_IgnoraEmpresaInativaEBloqueada() + { + if (postgres.MotivoIndisponivel is { } motivo) + { + Assert.Skip(motivo); + return; + } + + await postgres.LimparAsync(); + + await using (var escrita = postgres.CriarContexto()) + { + var inativa = Nova("DESLIGADA LTDA", "11222333000181"); + inativa.Inativar(); + inativa.AgendarProximaConsulta(AgoraUtc.AddMinutes(1)); + + var bloqueada = Nova("BLOQUEADA LTDA", "11444777000161"); + bloqueada.Bloquear(AgoraUtc, TimeSpan.FromHours(1), TimeSpan.FromHours(6)); + bloqueada.AgendarProximaConsulta(AgoraUtc.AddMinutes(2)); + + var normal = Nova("NORMAL LTDA", "25146091000107"); + normal.AgendarProximaConsulta(AgoraUtc.AddMinutes(7)); + + escrita.Empresas.AddRange(inativa, bloqueada, normal); + await escrita.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + await using var contexto = postgres.CriarContexto(); + var repositorio = new EmpresaRepository(contexto); + + var proximo = await repositorio.ObterProximoVencimentoAsync( + AgoraUtc, TestContext.Current.CancellationToken); + + // A empresa desligada e a bloqueada vencem antes, e nenhuma das duas é trabalho iminente: uma + // está fora do ciclo, a outra está cumprindo espera. Contá-las faria o diagnóstico de ciclo vazio + // apontar para um trabalho que não vai acontecer. + Assert.Equal(AgoraUtc.AddMinutes(7), proximo); + } + + [Fact] + public async Task ObterProximoVencimentoAsync_SemEmpresaProgramada_DevolveNulo() + { + if (postgres.MotivoIndisponivel is { } motivo) + { + Assert.Skip(motivo); + return; + } + + await postgres.LimparAsync(); + + await using (var escrita = postgres.CriarContexto()) + { + // Já vencida: é candidata do ciclo, não do "próximo vencimento". + var vencida = Nova("JA VENCIDA LTDA", "11222333000181"); + vencida.AgendarProximaConsulta(AgoraUtc.AddMinutes(-5)); + + escrita.Empresas.Add(vencida); + await escrita.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + await using var contexto = postgres.CriarContexto(); + var repositorio = new EmpresaRepository(contexto); + + Assert.Null(await repositorio.ObterProximoVencimentoAsync( + AgoraUtc, TestContext.Current.CancellationToken)); + } + + private static Empresa Nova(string razaoSocial, string cnpj) => + Empresa.Criar( + razaoSocial, null, Cnpj.Criar(cnpj).Valor, null, + Uf.Criar("SP").Valor, AmbienteSefaz.Producao, AgoraUtc).Valor; + private async Task CriarEmpresaInativaAsync(bool comCertificadoInativo = false) { await using var contexto = postgres.CriarContexto(); diff --git a/tests/eContabil.Shared.Tests/InstanteUtcTests.cs b/tests/eContabil.Shared.Tests/InstanteUtcTests.cs new file mode 100644 index 0000000..ae108ae --- /dev/null +++ b/tests/eContabil.Shared.Tests/InstanteUtcTests.cs @@ -0,0 +1,67 @@ +using eContabil.Shared; +using Xunit; + +namespace eContabil.Shared.Tests; + +/// +/// Toda data que entra pela borda precisa chegar ao Npgsql em UTC. +/// +/// +/// O provedor recusa ao escrever em timestamptz, e é +/// exatamente isso que o binder produz para uma query string com de=2026-07-29. A falha aparecia +/// no meio da execução da consulta, não na validação. +/// +public class InstanteUtcTests +{ + [Fact] + public void Normalizar_ComKindUnspecified_AssumeUtc() + { + // O caso que quebrava o filtro de documentos. + var semFuso = new DateTime(2026, 7, 29, 0, 0, 0, DateTimeKind.Unspecified); + + var normalizado = InstanteUtc.Normalizar(semFuso); + + Assert.Equal(DateTimeKind.Utc, normalizado.Kind); + Assert.Equal(new DateTime(2026, 7, 29, 0, 0, 0, DateTimeKind.Utc), normalizado); + } + + [Fact] + public void Normalizar_ComKindUtc_NaoAlteraOInstante() + { + var utc = new DateTime(2026, 7, 29, 3, 0, 0, DateTimeKind.Utc); + + Assert.Equal(utc, InstanteUtc.Normalizar(utc)); + Assert.Equal(DateTimeKind.Utc, InstanteUtc.Normalizar(utc).Kind); + } + + [Fact] + public void Normalizar_ComKindLocal_ConverteOInstante() + { + // Converte de verdade, em vez de só trocar o rótulo: o horário precisa mudar junto do fuso. + var local = new DateTime(2026, 7, 29, 12, 0, 0, DateTimeKind.Local); + + var normalizado = InstanteUtc.Normalizar(local); + + Assert.Equal(DateTimeKind.Utc, normalizado.Kind); + Assert.Equal(local.ToUniversalTime(), normalizado); + } + + [Fact] + public void Normalizar_ComNulo_ContinuaNulo() + { + // Período aberto é caso legítimo: filtrar só pela ponta inicial, ou por nenhuma. + Assert.Null(InstanteUtc.Normalizar((DateTime?)null)); + } + + [Fact] + public void Normalizar_ComValorOpcional_PreservaOInstante() + { + DateTime? semFuso = new DateTime(2026, 7, 29, 23, 59, 59, DateTimeKind.Unspecified); + + var normalizado = InstanteUtc.Normalizar(semFuso); + + Assert.NotNull(normalizado); + Assert.Equal(DateTimeKind.Utc, normalizado.Value.Kind); + Assert.Equal(23, normalizado.Value.Hour); + } +}