Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/eContabil.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -208,10 +208,15 @@ static void RegistrarTrabalhosRecorrentes(IServiceProvider servicos)
{
var recorrentes = servicos.GetRequiredService<IRecurringJobManager>();

// 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<SincronizacaoGeralJob>(
"sincronizacao-geral",
job => job.ExecutarAsync(default),
"*/30 * * * *",
"*/5 * * * *",
new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });

recorrentes.AddOrUpdate<AlertaCertificadosVencendoJob>(
Expand Down
17 changes: 17 additions & 0 deletions src/eContabil.Application/Documentos/Consultas/FiltroDocumentos.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using eContabil.Domain.Documentos;
using eContabil.Shared;

namespace eContabil.Application.Documentos.Consultas;

Expand Down Expand Up @@ -31,6 +32,22 @@ public sealed record FiltroDocumentos(
string? Cursor = null,
int Tamanho = 50)
{
/// <summary>
/// Início do período, sempre em UTC.
/// </summary>
/// <remarks>
/// A query string pode trazer data sem fuso — <c>2026-07-29</c> —, e o binder devolve isso com
/// <c>Kind=Unspecified</c>, que o Npgsql recusa ao escrever em <c>timestamptz</c>. 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.
/// </remarks>
public DateTime? De { get; init; } = InstanteUtc.Normalizar(De);

/// <summary>Fim do período, sempre em UTC. Vale o mesmo que <see cref="De"/>.</summary>
public DateTime? Ate { get; init; } = InstanteUtc.Normalizar(Ate);

/// <summary>Diz se algum critério depende de dados que só existem no XML completo.</summary>
public bool ExigeDadosFiscais =>
!string.IsNullOrWhiteSpace(Cfop)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,11 @@ namespace eContabil.Application.Documentos.Consultas;

/// <summary>Captura consolidada por empresa num período.</summary>
public sealed record ObterRelatorioPorEmpresaQuery(DateTime? De, DateTime? Ate)
: IRequest<Result<IReadOnlyList<RelatorioPorEmpresaDto>>>;
: IRequest<Result<IReadOnlyList<RelatorioPorEmpresaDto>>>
{
/// <summary>Início do período, sempre em UTC. Vale o mesmo do filtro da grade.</summary>
public DateTime? De { get; init; } = InstanteUtc.Normalizar(De);

/// <summary>Fim do período, sempre em UTC.</summary>
public DateTime? Ate { get; init; } = InstanteUtc.Normalizar(Ate);
}
10 changes: 10 additions & 0 deletions src/eContabil.Domain/Empresas/IEmpresaRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,15 @@ public interface IEmpresaRepository : IRepository<Empresa>
Task<IReadOnlyList<Empresa>> ObterElegiveisParaSincronizarAsync(
DateTime agoraUtc, int limite, CancellationToken ct);

/// <summary>
/// Quando a próxima empresa vence, entre as que ainda não venceram.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
Task<DateTime?> ObterProximoVencimentoAsync(DateTime agoraUtc, CancellationToken ct);

Task<bool> CnpjJaCadastradoAsync(Cnpj cnpj, Guid? ignorarId, CancellationToken ct);
}
42 changes: 37 additions & 5 deletions src/eContabil.Infrastructure/Agendamento/SincronizacaoGeralJob.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ public async Task ExecutarAsync(CancellationToken ct)
}
}

RegistrarAtraso(elegiveis, agora);
await RegistrarAtrasoAsync(elegiveis, agora, ct);

CicloEnfileirado(log, elegiveis.Count);
}
Expand All @@ -69,11 +69,16 @@ public async Task ExecutarAsync(CancellationToken ct)
/// mede o tempo até uma nota nova aparecer: esse depende também de <c>IntervaloSemNovidade</c>, que
/// é com que frequência uma empresa sem novidade volta a ser consultada.
/// </remarks>
private void RegistrarAtraso(IReadOnlyList<Empresa> elegiveis, DateTime agora)
private async Task RegistrarAtrasoAsync(
IReadOnlyList<Empresa> 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);

Expand All @@ -83,11 +88,38 @@ private void RegistrarAtraso(IReadOnlyList<Empresa> elegiveis, DateTime agora)
}
}

/// <summary>
/// Mede o que o atraso máximo não alcança: quanto falta para a próxima empresa vencer.
/// </summary>
/// <remarks>
/// 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ó.
/// </remarks>
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) " +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -47,18 +51,53 @@ 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.");
}

/// <remarks>
/// 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.
/// </remarks>
private IEnumerable<Measurement<double>> LerEsperaAteAProxima()
{
var ticks = Interlocked.Read(ref _esperaAteOProximoEmTicks);

if (ticks >= 0)
{
yield return new Measurement<double>(TimeSpan.FromTicks(ticks).TotalSeconds);
}
}

/// <summary>Registra o que o ciclo encontrou.</summary>
/// <remarks>
/// <paramref name="atraso"/> é do documento mais atrasado da fila, não a média: média esconde
/// justamente a empresa que está furando o prazo.
/// </remarks>
public void RegistrarCiclo(TimeSpan atraso, int elegiveis, int enfileiradas)
/// <param name="atraso">Espera da empresa mais antiga da fila além do previsto.</param>
/// <param name="elegiveis">Empresas em condição de sincronizar no início do ciclo.</param>
/// <param name="enfileiradas">Empresas que o ciclo conseguiu enfileirar.</param>
/// <param name="esperaAteAProxima">
/// 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.
/// </param>
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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,21 @@ await Conjunto
.Take(limite)
.ToListAsync(ct);

/// <summary>
/// Menor <c>ProximaConsultaEm</c> ainda no futuro, entre as empresas que o ciclo consideraria.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public async Task<DateTime?> 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<bool> CnpjJaCadastradoAsync(Cnpj cnpj, Guid? ignorarId, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(cnpj);
Expand Down
27 changes: 27 additions & 0 deletions src/eContabil.Shared/InstanteUtc.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
namespace eContabil.Shared;

/// <summary>
/// Normaliza instantes vindos de fora para UTC.
/// </summary>
/// <remarks>
/// Toda coluna de data do banco é <c>timestamptz</c>, e o Npgsql recusa <see cref="DateTime"/> que não
/// seja <see cref="DateTimeKind.Utc"/> — inclusive o <see cref="DateTimeKind.Unspecified"/>, que é o que
/// o binder do ASP.NET produz quando a query string traz uma data sem fuso, como <c>2026-07-29</c>. 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.
/// </remarks>
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)
};
}
47 changes: 33 additions & 14 deletions src/econtabil-web/src/app/features/documentos/documentos.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 `<input type="date">` 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<KeysetPage<DocumentoLista>> {
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);
Expand All @@ -45,13 +70,7 @@ export class DocumentosService {
* muda a cada "carregar mais".
*/
resumir(filtro: FiltroDocumentos): Observable<ResumoDocumentos> {
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<ResumoDocumentos>(`${this.baseUrl}/documentos/resumo`, {
params: parametros,
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading