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 @@ -40,6 +40,17 @@ public async Task<Result<EmpresaDetalheDto>> Handle(
// consultar antes para saber se vale a pena chamar.
if (request.Ativa)
{
// O CNPJ é único entre as ativas, por índice parcial, justamente para permitir
// recadastrar uma empresa inativada. Se o recadastro aconteceu, reativar a antiga
// violaria o índice: melhor dizer o motivo do que devolver erro de banco.
var jaAtiva = await empresas.ObterPorCnpjAsync(empresa.Cnpj, cancellationToken);
if (jaAtiva is not null && jaAtiva.Id != empresa.Id)
{
return Result<EmpresaDetalheDto>.Falhou(Error.Conflito(
"empresa.cnpj_ja_ativo",
"Já existe uma empresa ativa com este CNPJ. Inative-a antes de reativar esta."));
}

empresa.Reativar();
}
else
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ namespace eContabil.Application.Empresas.Consultas;
/// Traz o estado da sincronização junto do cadastro porque é a pergunta que o contador faz ao abrir a
/// tela: "esta empresa está capturando?". <see cref="BloqueadaAte"/> preenchido significa rejeição por
/// consumo indevido — a tela mostra o horário de liberação em vez de um botão que falharia.
///
/// <see cref="Ativa"/> vem junto porque empresa inativa fica fora do ciclo de captura e precisa dizer
/// isso na grade. Sem o campo, a tela só sabia distinguir bloqueio e falta de certificado, e uma empresa
/// desligada aparecia como "Capturando" assim que o bloqueio dela expirava.
/// </remarks>
public sealed record EmpresaListaDto(
Guid Id,
Expand All @@ -18,4 +22,5 @@ public sealed record EmpresaListaDto(
DateTime? UltimaSincronizacaoEm,
DateTime ProximaConsultaEm,
DateTime? BloqueadaAte,
bool PossuiCertificadoAtivo);
bool PossuiCertificadoAtivo,
bool Ativa);
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ public async Task<Result<ResultadoManifestacao>> Handle(
// justamente o bloqueio que se está esperando expirar.
if (empresa.EstaBloqueada(agora))
{
return $"Empresa bloqueada pela SEFAZ até {empresa.BloqueadaAte:dd/MM/yyyy HH:mm} UTC.";
return $"Empresa bloqueada pela SEFAZ; nova tentativa em {TextoDeEspera.Descrever(empresa.BloqueadaAte!.Value - agora)}.";
}

if (certificado is null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ private async Task<Result<ResultadoSincronizacao>> PercorrerLotesAsync(
empresa.AgendarProximaConsulta(agora.Add(_opcoes.IntervaloSemNovidade));
return await FecharAsync(
empresa, DesfechoSincronizacao.EmDia, nsuInicial, contadores,
"Nenhum documento novo na SEFAZ.", modo, agora, ct);
DescreverColeta(contadores), modo, agora, ct);

case DesfechoConsulta.ConsumoIndevido:
// A rejeição traz o NSU corrente da SEFAZ: adotá-lo é o que tira do laço a empresa
Expand All @@ -150,9 +150,13 @@ private async Task<Result<ResultadoSincronizacao>> PercorrerLotesAsync(

ConsumoIndevido(log, empresa.Id, retorno.UltNsu);

// A espera vai como duração, não como horário: o texto é gravado no histórico e lido
// na tela, que exibe todo o resto no fuso do navegador. Um horário absoluto embutido
// na frase não teria como ser convertido e apareceria três horas à frente do relógio
// de quem lê.
return await FecharAsync(
empresa, DesfechoSincronizacao.Bloqueada, nsuInicial, contadores,
$"Rejeitada por consumo indevido; nova tentativa em {empresa.BloqueadaAte:dd/MM HH:mm} UTC.",
$"Rejeitada por consumo indevido; nova tentativa em {TextoDeEspera.Descrever(empresa.BloqueadaAte!.Value - agora)}.",
modo, agora, ct);

case DesfechoConsulta.NsuAcimaDoMaximo:
Expand Down Expand Up @@ -182,7 +186,8 @@ private async Task<Result<ResultadoSincronizacao>> PercorrerLotesAsync(

return await FecharAsync(
empresa, DesfechoSincronizacao.Parcial, nsuInicial, contadores,
"Teto de lotes desta execução atingido; a empresa foi reagendada.", modo, agora, ct);
$"{DescreverColeta(contadores)} Teto de lotes desta execução atingido; a empresa foi reagendada.",
modo, agora, ct);
}

/// <summary>
Expand Down Expand Up @@ -405,6 +410,48 @@ recebido.Tipo is TipoRetornoSefaz.ProcNFe or TipoRetornoSefaz.ResNFe
_ => SituacaoDocumento.Autorizada
};

/// <summary>
/// Descreve o que a execução recolheu, para o histórico exibido na tela.
/// </summary>
/// <remarks>
/// Um ciclo pode terminar com zero documentos novos e ainda assim ter arquivado dezenas de arquivos:
/// eventos de manifestação, cancelamento e carta de correção ocupam NSU, têm XML próprio e não viram
/// registro de documento. Anunciar "nenhum documento novo" nesse caso faz a tela parecer parada
/// enquanto o storage cresce, e foi o que levou a duvidar da captura quando ela estava funcionando.
/// </remarks>
private static string DescreverColeta(Contadores contadores)
{
List<string> partes = [];

if (contadores.Novos > 0)
{
partes.Add(Quantificar(contadores.Novos, "documento novo", "documentos novos"));
}

if (contadores.Promovidos > 0)
{
partes.Add(Quantificar(
contadores.Promovidos, "resumo promovido a XML completo", "resumos promovidos a XML completo"));
}

if (contadores.Eventos > 0)
{
partes.Add(Quantificar(contadores.Eventos, "evento arquivado", "eventos arquivados"));
}

if (contadores.Descartados > 0)
{
partes.Add(Quantificar(contadores.Descartados, "item descartado", "itens descartados"));
}

return partes.Count == 0
? "Nenhum documento novo na SEFAZ."
: $"{string.Join("; ", partes)}.";
}

private static string Quantificar(int quantidade, string singular, string plural) =>
quantidade == 1 ? $"1 {singular}" : $"{quantidade} {plural}";

private static string? Impedimento(Empresa empresa, CertificadoDigital? certificado, DateTime agora)
{
if (!empresa.EstaElegivelParaSincronizar(agora))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ public async Task<PagedResult<EmpresaListaDto>> ListarAsync(
var paginaAtual = Math.Max(pagina, 1);
var tamanhoPagina = Math.Clamp(tamanho, 1, 100);

var consulta = contexto.Empresas.AsNoTracking();
// A grade mostra também as inativas, sinalizadas como tal. O filtro global de `Empresa` as
// esconderia, e desligar a empresa a fazia sumir da tela — sem registro de exclusão, porque
// exclusão não é o que acontece: o cadastro continua lá, fora do ciclo de captura.
var consulta = contexto.Empresas.AsNoTracking().IgnoreQueryFilters();

if (!string.IsNullOrWhiteSpace(busca))
{
Expand Down Expand Up @@ -48,7 +51,12 @@ public async Task<PagedResult<EmpresaListaDto>> ListarAsync(
empresa.UltimaSincronizacaoEm,
empresa.ProximaConsultaEm,
empresa.BloqueadaAte,
contexto.CertificadosDigitais.Any(certificado => certificado.EmpresaId == empresa.Id)))
// A condição de certificado ativo vai explícita: `IgnoreQueryFilters` vale para a
// consulta inteira e desligaria também o filtro global de `CertificadoDigital`, fazendo
// um certificado revogado contar como válido.
contexto.CertificadosDigitais.Any(
certificado => certificado.EmpresaId == empresa.Id && certificado.Ativo),
empresa.Ativa))
.ToListAsync(ct);

return new PagedResult<EmpresaListaDto>(itens, total, paginaAtual, tamanhoPagina);
Expand Down
40 changes: 40 additions & 0 deletions src/eContabil.Shared/TextoDeEspera.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
namespace eContabil.Shared;

/// <summary>
/// Converte uma espera em texto legível, sem citar horário absoluto.
/// </summary>
/// <remarks>
/// As mensagens que descrevem um bloqueio são gravadas no histórico e exibidas na tela, que mostra todo
/// o resto no fuso do navegador. Um horário absoluto embutido na frase não teria como ser convertido e
/// apareceria adiantado em relação ao relógio de quem lê — o mesmo instante contado de dois jeitos, numa
/// tela só. Duração não tem fuso e resolve isso sem perder a informação.
/// </remarks>
public static class TextoDeEspera
{
/// <summary>
/// Descreve a espera em minutos ou em horas, arredondando para cima.
/// </summary>
/// <remarks>
/// Arredondar para cima evita anunciar uma liberação que ainda não vale: a espera de 59 minutos e
/// meio vira "1 hora", e quem tentar no minuto seguinte encontra a empresa liberada de fato.
/// </remarks>
public static string Descrever(TimeSpan espera)
{
var minutos = (int)Math.Ceiling(espera.TotalMinutes);

if (minutos < 60)
{
return Quantificar(Math.Max(minutos, 1), "minuto", "minutos");
}

var horas = minutos / 60;
var resto = minutos % 60;

return resto == 0
? Quantificar(horas, "hora", "horas")
: $"{Quantificar(horas, "hora", "horas")} e {Quantificar(resto, "minuto", "minutos")}";
}

private static string Quantificar(int quantidade, string singular, string plural) =>
quantidade == 1 ? $"1 {singular}" : $"{quantidade} {plural}";
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const EMPRESA = {
proximaConsultaEm: '2026-07-28T13:00:00Z',
bloqueadaAte: null,
possuiCertificadoAtivo: false,
ativa: true,
};

const CERTIFICADO_ACEITO = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -230,14 +230,20 @@ export class DashboardComponent implements OnInit, OnDestroy {
protected readonly execucoes = signal<readonly ExecucaoLista[]>([]);
protected readonly relatorio = signal<readonly RelatorioPorEmpresa[]>([]);

// Empresa inativa está fora do ciclo de captura, e por isso fica fora dos alertas: bloqueio e falta
// de certificado só importam em quem o sistema realmente consulta. Sem o filtro, desligar uma empresa
// deixava para trás um alerta que ninguém conseguia resolver.
protected readonly bloqueadas = computed(() =>
this.empresas().filter(
(empresa) => empresa.bloqueadaAte !== null && new Date(empresa.bloqueadaAte) > new Date(),
(empresa) =>
empresa.ativa &&
empresa.bloqueadaAte !== null &&
new Date(empresa.bloqueadaAte) > new Date(),
),
);

protected readonly semCertificado = computed(() =>
this.empresas().filter((empresa) => !empresa.possuiCertificadoAtivo),
this.empresas().filter((empresa) => empresa.ativa && !empresa.possuiCertificadoAtivo),
);

protected readonly documentosDeHoje = computed(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const EMPRESA: EmpresaLista = {
proximaConsultaEm: '2026-07-28T13:00:00Z',
bloqueadaAte: null,
possuiCertificadoAtivo: true,
ativa: true,
};

async function montar(edicao: EmpresaLista | null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ export interface EmpresaLista {
readonly proximaConsultaEm: string;
readonly bloqueadaAte: string | null;
readonly possuiCertificadoAtivo: boolean;
readonly ativa: boolean;
}

/** Empresa na tela de detalhe. */
export interface EmpresaDetalhe extends EmpresaLista {
readonly inscricaoEstadual: string | null;
readonly ambiente: AmbienteSefaz;
readonly ativa: boolean;
readonly maxNsu: number;
readonly criadoEm: string;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,112 @@
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { render, screen } from '@testing-library/angular';
import userEvent from '@testing-library/user-event';
import { of } from 'rxjs';
import { PagedResult } from '../../shared/api.model';
import { EmpresaLista } from './empresa.model';
import { EmpresasComponent } from './empresas.component';
import { EmpresasService } from './empresas.service';

const BASE: EmpresaLista = {
id: '019fa702-c756-76ae-81ec-b455ba519849',
razaoSocial: 'NEWNUTRITION BRASIL ALIMENTOS LTDA',
nomeFantasia: 'NewNutrition',
cnpj: '25146091000107',
uf: 'SP',
ultimoNsu: 452493,
ultimaSincronizacaoEm: '2026-07-29T13:12:33Z',
proximaConsultaEm: '2026-07-29T14:00:00Z',
bloqueadaAte: null,
possuiCertificadoAtivo: true,
ativa: true,
};

const alterarStatus = vi.fn();

async function montar(empresa: Partial<EmpresaLista> = {}) {
const linha: EmpresaLista = { ...BASE, ...empresa };

alterarStatus.mockReset();
alterarStatus.mockReturnValue(of(linha));

const pagina: PagedResult<EmpresaLista> = {
itens: [linha],
total: 1,
pagina: 1,
tamanho: 20,
temProximaPagina: false,
};

const renderizado = await render(EmpresasComponent, {
providers: [
provideHttpClient(),
provideHttpClientTesting(),
{
provide: EmpresasService,
useValue: { listar: () => of(pagina), alterarStatus },
},
],
});

// A carga da grade é assíncrona: sem esperar a linha aparecer, as expectativas correriam contra a
// tabela ainda vazia.
await screen.findByText(linha.razaoSocial);

return renderizado;
}

describe('EmpresasComponent', () => {
it('anuncia a pagina com um cabecalho acessivel', async () => {
await render(EmpresasComponent);
await montar();

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

it('marca a empresa inativa em vez de dizer que ela esta capturando', async () => {
// O backend tira a empresa desligada do ciclo. A tela dizia "Capturando" assim que o bloqueio
// dela expirava, e passava a discordar do que o sistema estava fazendo.
await montar({ ativa: false });

expect(screen.getByText('Inativa')).toBeInTheDocument();
expect(screen.queryByText('Capturando')).not.toBeInTheDocument();
});

it('mostra Inativa mesmo quando ainda ha bloqueio da SEFAZ', async () => {
// O bloqueio de uma empresa desligada é irrelevante: ela não seria consultada de qualquer forma.
await montar({ ativa: false, bloqueadaAte: '2099-01-01T00:00:00Z' });

expect(screen.getByText('Inativa')).toBeInTheDocument();
expect(screen.queryByText(/bloqueada até/i)).not.toBeInTheDocument();
});

it('mantem o bloqueio visivel na empresa ativa', async () => {
await montar({ ativa: true, bloqueadaAte: '2099-01-01T00:00:00Z' });

expect(screen.getByText(/bloqueada até/i)).toBeInTheDocument();
});

it('desabilita a sincronizacao da empresa inativa', async () => {
await montar({ ativa: false });

expect(screen.getByRole('button', { name: /sincronizar/i })).toBeDisabled();
});

it('oferece reativar no lugar de inativar e devolve a empresa ao ciclo', async () => {
// Sem esta ação, uma empresa desligada só voltava por chamada direta na API.
await montar({ ativa: false });

expect(screen.queryByRole('button', { name: /^inativar/i })).not.toBeInTheDocument();

await userEvent.click(screen.getByRole('button', { name: /^reativar/i }));

expect(alterarStatus).toHaveBeenCalledWith(BASE.id, true);
});

it('oferece inativar na empresa ativa', async () => {
await montar({ ativa: true });

expect(screen.getByRole('button', { name: /^inativar/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /^reativar/i })).not.toBeInTheDocument();
});
});
Loading
Loading