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
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,37 @@

namespace eContabil.Application.Sincronizacao.Consultas;

/// <summary>Uma execução de sincronização, como aparece no histórico.</summary>
/// <summary>
/// Uma execução de sincronização, como aparece no histórico.
/// </summary>
/// <remarks>
/// A razão social vem junto porque o histórico é lido de cima para baixo, com várias empresas
/// intercaladas: duas linhas do mesmo minuto, uma concluída e outra bloqueada, não diziam a qual
/// cadastro cada uma pertencia.
/// </remarks>
public sealed record ExecucaoListaDto(
Guid Id,
Guid EmpresaId,
string RazaoSocial,
ModoSincronizacao Modo,
DateTime IniciadoEm,
DateTime FinalizadoEm,
double DuracaoSegundos,
long NsuInicial,
long NsuFinal,
int DocumentosNovos,
int XmlsArquivados,
int DocumentosPromovidos,
int ItensDescartados,
ResultadoExecucao Resultado,
string Mensagem);
string Mensagem)
{
/// <summary>
/// Duração da execução, derivada dos dois instantes.
/// </summary>
/// <remarks>
/// Calculada aqui, e não na projeção: a subtração de dois <c>timestamptz</c> devolve um intervalo
/// que o provedor não converte para segundos, e guardar o valor seria repetir no DTO um dado que os
/// dois campos já determinam.
/// </remarks>
public double DuracaoSegundos => (FinalizadoEm - IniciadoEm).TotalSeconds;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace eContabil.Application.Sincronizacao.Consultas;

/// <summary>
/// Leitura do histórico de execuções.
/// </summary>
/// <remarks>
/// Fica fora do repositório porque a tela precisa da razão social da empresa junto de cada linha, e
/// isso é uma junção — o repositório entrega entidades de sincronização, que não conhecem o cadastro.
/// </remarks>
public interface IExecucaoConsultas
{
/// <summary>Últimas execuções, da mais recente para a mais antiga.</summary>
Task<IReadOnlyList<ExecucaoListaDto>> ObterUltimasAsync(
Guid? empresaId, int quantidade, CancellationToken ct);
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
using eContabil.Domain.Sincronizacoes;
using eContabil.Shared;
using MediatR;

namespace eContabil.Application.Sincronizacao.Consultas;

public sealed class ObterExecucoesQueryHandler(IExecucaoSincronizacaoRepository execucoes)
public sealed class ObterExecucoesQueryHandler(IExecucaoConsultas execucoes)
: IRequestHandler<ObterExecucoesQuery, Result<IReadOnlyList<ExecucaoListaDto>>>
{
private const int QuantidadeMinima = 1;
Expand All @@ -19,24 +18,9 @@ public async Task<Result<IReadOnlyList<ExecucaoListaDto>>> Handle(
// string arraste milhares de linhas para montar uma tela que mostra as últimas dezenas.
var quantidade = Math.Clamp(request.Quantidade, QuantidadeMinima, QuantidadeMaxima);

var registros = await execucoes.ObterUltimasAsync(request.EmpresaId, quantidade, cancellationToken);
var registros = await execucoes.ObterUltimasAsync(
request.EmpresaId, quantidade, cancellationToken);

return Result<IReadOnlyList<ExecucaoListaDto>>.Ok([.. registros.Select(Mapear)]);
return Result<IReadOnlyList<ExecucaoListaDto>>.Ok(registros);
}

private static ExecucaoListaDto Mapear(ExecucaoSincronizacao execucao) => new(
execucao.Id,
execucao.EmpresaId,
execucao.Modo,
execucao.IniciadoEm,
execucao.FinalizadoEm,
execucao.Duracao.TotalSeconds,
execucao.NsuInicial,
execucao.NsuFinal,
execucao.DocumentosNovos,
execucao.XmlsArquivados,
execucao.DocumentosPromovidos,
execucao.ItensDescartados,
execucao.Resultado,
execucao.Mensagem);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,4 @@ namespace eContabil.Domain.Sincronizacoes;

public interface IExecucaoSincronizacaoRepository : IRepository<ExecucaoSincronizacao>
{
/// <summary>Últimas execuções, para o histórico do dashboard.</summary>
Task<IReadOnlyList<ExecucaoSincronizacao>> ObterUltimasAsync(
Guid? empresaId, int quantidade, CancellationToken ct);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using eContabil.Application.Fiscal;
using eContabil.Application.Sefaz;
using eContabil.Application.Sincronizacao;
using eContabil.Application.Sincronizacao.Consultas;
using eContabil.Application.Usuarios.Consultas;
using eContabil.Domain;
using eContabil.Domain.Certificados;
Expand Down Expand Up @@ -77,6 +78,7 @@ public static IServiceCollection AddInfraestrutura(this IServiceCollection servi
services.AddScoped<IEmpresaConsultas, EmpresaConsultas>();
services.AddScoped<IDocumentoConsultas, DocumentoConsultas>();
services.AddScoped<IUsuarioConsultas, UsuarioConsultas>();
services.AddScoped<IExecucaoConsultas, ExecucaoConsultas>();

// Relógio do sistema. Os testes substituem por FakeTimeProvider para exercitar bloqueio,
// expiração de certificado e janela de consulta sem depender do relógio da máquina.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using eContabil.Application.Sincronizacao.Consultas;
using Microsoft.EntityFrameworkCore;

namespace eContabil.Infrastructure.Persistencia.Consultas;

/// <summary>
/// Histórico de execuções projetado direto no banco, já com o nome da empresa.
/// </summary>
/// <remarks>
/// A junção usa <c>IgnoreQueryFilters</c> por causa do filtro global de <c>Empresa</c>: a execução de
/// uma empresa inativada continua no histórico — é justamente ela que explica por que a captura parou —
/// e sem isto a linha viria sem nome ou faria a consulta estourar.
/// </remarks>
public sealed class ExecucaoConsultas(EContabilDbContext contexto) : IExecucaoConsultas
{
public async Task<IReadOnlyList<ExecucaoListaDto>> ObterUltimasAsync(
Guid? empresaId, int quantidade, CancellationToken ct)
{
var consulta = contexto.ExecucoesSincronizacao.AsNoTracking().IgnoreQueryFilters();

if (empresaId is { } id)
{
consulta = consulta.Where(execucao => execucao.EmpresaId == id);
}

return await consulta
.OrderByDescending(execucao => execucao.FinalizadoEm)
.Take(quantidade)
.Select(execucao => new ExecucaoListaDto(
execucao.Id,
execucao.EmpresaId,
contexto.Empresas.Where(empresa => empresa.Id == execucao.EmpresaId)
.Select(empresa => empresa.RazaoSocial).First(),
execucao.Modo,
execucao.IniciadoEm,
execucao.FinalizadoEm,
execucao.NsuInicial,
execucao.NsuFinal,
execucao.DocumentosNovos,
execucao.XmlsArquivados,
execucao.DocumentosPromovidos,
execucao.ItensDescartados,
execucao.Resultado,
execucao.Mensagem))
.ToListAsync(ct);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using eContabil.Domain.Certificados;
using eContabil.Domain.Documentos;
using eContabil.Domain.Empresas;
using eContabil.Domain.Sincronizacoes;
using eContabil.Domain.Usuarios;
using Microsoft.EntityFrameworkCore;

Expand All @@ -26,6 +27,8 @@ public sealed class EContabilDbContext(DbContextOptions<EContabilDbContext> opti

public DbSet<Usuario> Usuarios => Set<Usuario>();

public DbSet<ExecucaoSincronizacao> ExecucoesSincronizacao => Set<ExecucaoSincronizacao>();

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
ArgumentNullException.ThrowIfNull(modelBuilder);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,8 @@
using eContabil.Domain.Sincronizacoes;
using Microsoft.EntityFrameworkCore;

namespace eContabil.Infrastructure.Persistencia.Repositorios;

public sealed class ExecucaoSincronizacaoRepository(EContabilDbContext contexto)
: RepositorioBase<ExecucaoSincronizacao>(contexto), IExecucaoSincronizacaoRepository
{
public async Task<IReadOnlyList<ExecucaoSincronizacao>> ObterUltimasAsync(
Guid? empresaId, int quantidade, CancellationToken ct)
{
var consulta = Conjunto.AsNoTracking();

if (empresaId is { } id)
{
consulta = consulta.Where(execucao => execucao.EmpresaId == id);
}

return await consulta
.OrderByDescending(execucao => execucao.FinalizadoEm)
.Take(Math.Clamp(quantidade, 1, 200))
.ToListAsync(ct);
}
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,79 @@
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { provideRouter } from '@angular/router';
import { render, screen } from '@testing-library/angular';
import { render, screen, within } from '@testing-library/angular';
import { of } from 'rxjs';
import { DocumentosService } from '../documentos/documentos.service';
import { EmpresasService } from '../empresas/empresas.service';
import { DashboardComponent } from './dashboard.component';
import { DashboardService } from './dashboard.service';
import { ExecucaoLista, ResultadoExecucao } from './dashboard.model';

const EXECUCAO: ExecucaoLista = {
id: '019faa02-c4ff-7832-9bee-f0eba912692a',
empresaId: '019fa9f6-b200-78f1-9d54-9c766fe493ca',
razaoSocial: 'RICARDO COELHO',
modo: 1,
iniciadoEm: '2026-07-29T15:30:09Z',
finalizadoEm: '2026-07-29T15:30:11Z',
duracaoSegundos: 1.6,
nsuInicial: 319438,
nsuFinal: 319478,
documentosNovos: 0,
xmlsArquivados: 40,
documentosPromovidos: 0,
itensDescartados: 0,
resultado: ResultadoExecucao.Sucesso,
mensagem: '40 eventos arquivados.',
};

async function montar(execucoes: readonly ExecucaoLista[]) {
return render(DashboardComponent, {
providers: [
provideRouter([]),
provideHttpClient(),
provideHttpClientTesting(),
{ provide: DashboardService, useValue: { execucoes: () => of(execucoes) } },
{
provide: EmpresasService,
useValue: {
listar: () =>
of({ itens: [], total: 0, pagina: 1, tamanho: 100, temProximaPagina: false }),
},
},
{ provide: DocumentosService, useValue: { relatorioPorEmpresa: () => of([]) } },
],
});
}

describe('DashboardComponent', () => {
it('anuncia a pagina com um cabecalho acessivel', async () => {
await render(DashboardComponent, {
providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting()],
});
await montar([]);

expect(screen.getByRole('heading', { name: /painel/i })).toBeInTheDocument();
});

it('identifica a empresa de cada execucao do historico', async () => {
// Duas empresas no mesmo minuto, uma concluída e outra bloqueada: sem a coluna não havia como
// saber a qual cadastro cada linha pertencia.
const bloqueada: ExecucaoLista = {
...EXECUCAO,
id: '019faa03-2d37-707d-82b0-9c8ea8c8367a',
razaoSocial: 'IMEVE INDUSTRIA DE MEDICAMENTOS VETERINARIOS S A',
resultado: ResultadoExecucao.Bloqueada,
mensagem: 'Rejeitada por consumo indevido; nova tentativa em 1 hora.',
};

await montar([EXECUCAO, bloqueada]);

expect(await screen.findByRole('columnheader', { name: /empresa/i })).toBeInTheDocument();

const linhaDoRicardo = screen.getByText('RICARDO COELHO').closest('tr');
expect(within(linhaDoRicardo!).getByText('40 eventos arquivados.')).toBeInTheDocument();

const linhaDaImeve = screen
.getByText('IMEVE INDUSTRIA DE MEDICAMENTOS VETERINARIOS S A')
.closest('tr');
expect(within(linhaDaImeve!).getByText(/consumo indevido/)).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ const ROTULOS_DE_RESULTADO: Readonly<Record<ResultadoExecucao, string>> = {
<thead>
<tr>
<th scope="col">Início</th>
<th scope="col">Empresa</th>
<th scope="col">Duração</th>
<th scope="col">Documentos</th>
<th scope="col">Resultado</th>
Expand All @@ -162,6 +163,7 @@ const ROTULOS_DE_RESULTADO: Readonly<Record<ResultadoExecucao, string>> = {
@for (execucao of execucoes(); track execucao.id) {
<tr>
<td>{{ execucao.iniciadoEm | dataHoraLocal }}</td>
<td>{{ execucao.razaoSocial }}</td>
<td class="numero-celula">{{ execucao.duracaoSegundos | number: '1.0-1' }} s</td>
<td class="numero-celula">{{ execucao.documentosNovos }}</td>
<td>{{ rotuloDoResultado(execucao) }}</td>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export enum ResultadoExecucao {
export interface ExecucaoLista {
readonly id: string;
readonly empresaId: string;
readonly razaoSocial: string;
readonly modo: number;
readonly iniciadoEm: string;
readonly finalizadoEm: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@ public class ObterExecucoesQueryHandlerTests
{
private static readonly DateTime AgoraUtc = new(2026, 7, 28, 12, 0, 0, DateTimeKind.Utc);

private readonly IExecucaoSincronizacaoRepository _execucoes =
Substitute.For<IExecucaoSincronizacaoRepository>();
private readonly IExecucaoConsultas _execucoes = Substitute.For<IExecucaoConsultas>();

[Theory]
[InlineData(0, 1)]
Expand All @@ -31,7 +30,7 @@ public async Task Handle_LimitaAQuantidadePedida(int pedida, int esperada)
}

[Fact]
public async Task Handle_ProjetaADuracaoEmSegundos()
public async Task Handle_DerivaADuracaoDosDoisInstantes()
{
var empresaId = Guid.CreateVersion7();

Expand All @@ -47,13 +46,30 @@ public async Task Handle_ProjetaADuracaoEmSegundos()
Assert.Equal(ResultadoExecucao.Sucesso, execucao.Resultado);
}

[Fact]
public async Task Handle_EntregaARazaoSocialDeCadaExecucao()
{
// O histórico intercala empresas: duas linhas do mesmo minuto, uma concluída e outra bloqueada,
// não diziam a qual cadastro cada uma pertencia.
var empresaId = Guid.CreateVersion7();

_execucoes.ObterUltimasAsync(null, Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns([ExecucaoDe(empresaId, TimeSpan.FromSeconds(2), "IMEVE INDUSTRIA LTDA")]);

var resultado = await Executar(new ObterExecucoesQuery(null, 10));

var execucao = Assert.Single(resultado.Valor);
Assert.Equal("IMEVE INDUSTRIA LTDA", execucao.RazaoSocial);
}

private Task<Result<IReadOnlyList<ExecucaoListaDto>>> Executar(ObterExecucoesQuery consulta) =>
new ObterExecucoesQueryHandler(_execucoes)
.Handle(consulta, TestContext.Current.CancellationToken);

private static ExecucaoSincronizacao ExecucaoDe(Guid empresaId, TimeSpan duracao) =>
ExecucaoSincronizacao.Registrar(
empresaId, ModoSincronizacao.Incremental, AgoraUtc, AgoraUtc.Add(duracao),
nsuInicial: 100, nsuFinal: 150, documentosNovos: 5, xmlsArquivados: 5,
documentosPromovidos: 1, itensDescartados: 0, ResultadoExecucao.Sucesso, mensagem: null);
private static ExecucaoListaDto ExecucaoDe(
Guid empresaId, TimeSpan duracao, string razaoSocial = "EMPRESA TESTE LTDA") => new(
Guid.CreateVersion7(), empresaId, razaoSocial, ModoSincronizacao.Incremental,
AgoraUtc, AgoraUtc.Add(duracao), NsuInicial: 100, NsuFinal: 150, DocumentosNovos: 5,
XmlsArquivados: 5, DocumentosPromovidos: 1, ItensDescartados: 0,
ResultadoExecucao.Sucesso, Mensagem: "Concluída.");
}
Loading
Loading