diff --git a/src/eContabil.Application/Empresas/AlterarStatus/AlterarStatusEmpresaCommandHandler.cs b/src/eContabil.Application/Empresas/AlterarStatus/AlterarStatusEmpresaCommandHandler.cs index 14b2717..1441339 100644 --- a/src/eContabil.Application/Empresas/AlterarStatus/AlterarStatusEmpresaCommandHandler.cs +++ b/src/eContabil.Application/Empresas/AlterarStatus/AlterarStatusEmpresaCommandHandler.cs @@ -40,6 +40,17 @@ public async Task> 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.Falhou(Error.Conflito( + "empresa.cnpj_ja_ativo", + "Já existe uma empresa ativa com este CNPJ. Inative-a antes de reativar esta.")); + } + empresa.Reativar(); } else diff --git a/src/eContabil.Application/Empresas/Consultas/EmpresaListaDto.cs b/src/eContabil.Application/Empresas/Consultas/EmpresaListaDto.cs index b670713..0467a8a 100644 --- a/src/eContabil.Application/Empresas/Consultas/EmpresaListaDto.cs +++ b/src/eContabil.Application/Empresas/Consultas/EmpresaListaDto.cs @@ -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?". preenchido significa rejeição por /// consumo indevido — a tela mostra o horário de liberação em vez de um botão que falharia. +/// +/// 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. /// public sealed record EmpresaListaDto( Guid Id, @@ -18,4 +22,5 @@ public sealed record EmpresaListaDto( DateTime? UltimaSincronizacaoEm, DateTime ProximaConsultaEm, DateTime? BloqueadaAte, - bool PossuiCertificadoAtivo); + bool PossuiCertificadoAtivo, + bool Ativa); diff --git a/src/eContabil.Application/Manifestacao/ManifestarCienciaCommandHandler.cs b/src/eContabil.Application/Manifestacao/ManifestarCienciaCommandHandler.cs index 500cba5..b9895dc 100644 --- a/src/eContabil.Application/Manifestacao/ManifestarCienciaCommandHandler.cs +++ b/src/eContabil.Application/Manifestacao/ManifestarCienciaCommandHandler.cs @@ -75,7 +75,7 @@ public async Task> 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) diff --git a/src/eContabil.Application/Sincronizacao/SincronizarEmpresaCommandHandler.cs b/src/eContabil.Application/Sincronizacao/SincronizarEmpresaCommandHandler.cs index 4cf2c90..b913c3f 100644 --- a/src/eContabil.Application/Sincronizacao/SincronizarEmpresaCommandHandler.cs +++ b/src/eContabil.Application/Sincronizacao/SincronizarEmpresaCommandHandler.cs @@ -138,7 +138,7 @@ private async Task> 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 @@ -150,9 +150,13 @@ private async Task> 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: @@ -182,7 +186,8 @@ private async Task> 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); } /// @@ -405,6 +410,48 @@ recebido.Tipo is TipoRetornoSefaz.ProcNFe or TipoRetornoSefaz.ResNFe _ => SituacaoDocumento.Autorizada }; + /// + /// Descreve o que a execução recolheu, para o histórico exibido na tela. + /// + /// + /// 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. + /// + private static string DescreverColeta(Contadores contadores) + { + List 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)) diff --git a/src/eContabil.Infrastructure/Persistencia/Consultas/EmpresaConsultas.cs b/src/eContabil.Infrastructure/Persistencia/Consultas/EmpresaConsultas.cs index a1c8060..418e22b 100644 --- a/src/eContabil.Infrastructure/Persistencia/Consultas/EmpresaConsultas.cs +++ b/src/eContabil.Infrastructure/Persistencia/Consultas/EmpresaConsultas.cs @@ -16,7 +16,10 @@ public async Task> 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)) { @@ -48,7 +51,12 @@ public async Task> 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(itens, total, paginaAtual, tamanhoPagina); diff --git a/src/eContabil.Shared/TextoDeEspera.cs b/src/eContabil.Shared/TextoDeEspera.cs new file mode 100644 index 0000000..1959045 --- /dev/null +++ b/src/eContabil.Shared/TextoDeEspera.cs @@ -0,0 +1,40 @@ +namespace eContabil.Shared; + +/// +/// Converte uma espera em texto legível, sem citar horário absoluto. +/// +/// +/// 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. +/// +public static class TextoDeEspera +{ + /// + /// Descreve a espera em minutos ou em horas, arredondando para cima. + /// + /// + /// 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. + /// + 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}"; +} diff --git a/src/econtabil-web/src/app/features/certificados/certificados.component.spec.ts b/src/econtabil-web/src/app/features/certificados/certificados.component.spec.ts index fe1f152..e2ab9f1 100644 --- a/src/econtabil-web/src/app/features/certificados/certificados.component.spec.ts +++ b/src/econtabil-web/src/app/features/certificados/certificados.component.spec.ts @@ -20,6 +20,7 @@ const EMPRESA = { proximaConsultaEm: '2026-07-28T13:00:00Z', bloqueadaAte: null, possuiCertificadoAtivo: false, + ativa: true, }; const CERTIFICADO_ACEITO = { diff --git a/src/econtabil-web/src/app/features/dashboard/dashboard.component.ts b/src/econtabil-web/src/app/features/dashboard/dashboard.component.ts index 84ae42b..f4cd955 100644 --- a/src/econtabil-web/src/app/features/dashboard/dashboard.component.ts +++ b/src/econtabil-web/src/app/features/dashboard/dashboard.component.ts @@ -230,14 +230,20 @@ export class DashboardComponent implements OnInit, OnDestroy { protected readonly execucoes = signal([]); protected readonly relatorio = signal([]); + // 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(() => { diff --git a/src/econtabil-web/src/app/features/empresas/empresa-form.component.spec.ts b/src/econtabil-web/src/app/features/empresas/empresa-form.component.spec.ts index 68d0abe..c47809d 100644 --- a/src/econtabil-web/src/app/features/empresas/empresa-form.component.spec.ts +++ b/src/econtabil-web/src/app/features/empresas/empresa-form.component.spec.ts @@ -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) { diff --git a/src/econtabil-web/src/app/features/empresas/empresa.model.ts b/src/econtabil-web/src/app/features/empresas/empresa.model.ts index 6988d37..b2616d5 100644 --- a/src/econtabil-web/src/app/features/empresas/empresa.model.ts +++ b/src/econtabil-web/src/app/features/empresas/empresa.model.ts @@ -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; } diff --git a/src/econtabil-web/src/app/features/empresas/empresas.component.spec.ts b/src/econtabil-web/src/app/features/empresas/empresas.component.spec.ts index 1b7987f..7933ccd 100644 --- a/src/econtabil-web/src/app/features/empresas/empresas.component.spec.ts +++ b/src/econtabil-web/src/app/features/empresas/empresas.component.spec.ts @@ -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 = {}) { + const linha: EmpresaLista = { ...BASE, ...empresa }; + + alterarStatus.mockReset(); + alterarStatus.mockReturnValue(of(linha)); + + const pagina: PagedResult = { + 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(); + }); }); diff --git a/src/econtabil-web/src/app/features/empresas/empresas.component.ts b/src/econtabil-web/src/app/features/empresas/empresas.component.ts index f2878c7..80817b4 100644 --- a/src/econtabil-web/src/app/features/empresas/empresas.component.ts +++ b/src/econtabil-web/src/app/features/empresas/empresas.component.ts @@ -24,6 +24,9 @@ const DEBOUNCE_DE_BUSCA_MS = 300; * * A coluna de situação responde à pergunta que o contador faz ao abrir a tela: "esta empresa está * capturando?". Empresa bloqueada mostra o horário de liberação em vez de um botão que falharia. + * + * "Inativa" vem antes de qualquer outro estado: o backend tira a empresa desligada do ciclo sem olhar + * bloqueio nem certificado, e mostrar o motivo errado faria a tela discordar do que o sistema faz. */ @Component({ selector: 'ec-empresas', @@ -92,7 +95,9 @@ const DEBOUNCE_DE_BUSCA_MS = 300; {{ empresa.ultimoNsu }} {{ empresa.ultimaSincronizacaoEm | dataHoraLocal }} - @if (bloqueada(empresa)) { + @if (!empresa.ativa) { + Inativa + } @else if (bloqueada(empresa)) { Bloqueada até {{ empresa.bloqueadaAte | dataHoraLocal }} @@ -116,7 +121,9 @@ const DEBOUNCE_DE_BUSCA_MS = 300; - + @if (empresa.ativa) { + + } @else { + + } } @@ -189,6 +208,10 @@ const DEBOUNCE_DE_BUSCA_MS = 300; .situacao.ativa { color: #14532d; } + + .situacao.inativa { + color: var(--ec-texto-terciario); + } `, }) export class EmpresasComponent implements OnInit { @@ -211,6 +234,10 @@ export class EmpresasComponent implements OnInit { } protected tooltipDeSincronizacao(empresa: EmpresaLista): string { + if (!empresa.ativa) { + return 'Empresa inativa: reative antes de sincronizar.'; + } + if (this.bloqueada(empresa)) { return 'A SEFAZ bloqueou este CNPJ por consumo indevido; aguarde a liberação.'; } @@ -240,4 +267,14 @@ export class EmpresasComponent implements OnInit { } }); } + + /** + * Devolve a empresa ao ciclo de captura. + * + * Sem diálogo de confirmação: a ação não destrói nada e se desfaz no mesmo botão. O ponteiro de NSU + * não foi tocado durante a inatividade, então a captura retoma de onde parou. + */ + protected reativar(empresa: EmpresaLista): void { + void this.store.alterarStatus(empresa.id, true); + } } diff --git a/tests/eContabil.Application.Tests/Empresas/GestaoDeEmpresasTests.cs b/tests/eContabil.Application.Tests/Empresas/GestaoDeEmpresasTests.cs index 3f05f6b..5d3be58 100644 --- a/tests/eContabil.Application.Tests/Empresas/GestaoDeEmpresasTests.cs +++ b/tests/eContabil.Application.Tests/Empresas/GestaoDeEmpresasTests.cs @@ -103,6 +103,30 @@ public async Task Reativar_EmpresaInativa_VoltaAoCiclo() Assert.True(empresa.Ativa); } + [Fact] + public async Task Reativar_ComOMesmoCnpjJaAtivoEmOutroCadastro_RecusaComConflito() + { + // O CNPJ é único entre as ativas por índice parcial, o que permite recadastrar uma empresa + // inativada. Se o recadastro aconteceu, reativar a antiga violaria o índice — e o operador + // receberia erro de banco em vez do motivo. + var antiga = EmpresaComHistorico(); + antiga.Inativar(); + + var recadastrada = Empresa.Criar( + "EMPRESA RECADASTRADA LTDA", null, Cnpj.Criar(CnpjEmpresa).Valor, null, + Uf.Criar("SP").Valor, AmbienteSefaz.Producao, AgoraUtc).Valor; + + _empresas.ObterPorCnpjAsync(antiga.Cnpj, Arg.Any()).Returns(recadastrada); + + var resultado = await StatusHandler().Handle( + new AlterarStatusEmpresaCommand(antiga.Id, Ativa: true), TestContext.Current.CancellationToken); + + Assert.True(resultado.Falha); + Assert.Equal("empresa.cnpj_ja_ativo", resultado.Erro!.Codigo); + Assert.False(antiga.Ativa); + await _unitOfWork.DidNotReceive().SalvarAlteracoesAsync(Arg.Any()); + } + [Fact] public async Task AlterarStatus_ParaOEstadoQueJaVale_EhIdempotente() { diff --git a/tests/eContabil.Application.Tests/Sincronizacao/MensagemDaExecucaoTests.cs b/tests/eContabil.Application.Tests/Sincronizacao/MensagemDaExecucaoTests.cs new file mode 100644 index 0000000..03f8a1f --- /dev/null +++ b/tests/eContabil.Application.Tests/Sincronizacao/MensagemDaExecucaoTests.cs @@ -0,0 +1,124 @@ +using eContabil.Application.Sincronizacao; +using Xunit; + +namespace eContabil.Application.Tests.Sincronizacao; + +/// +/// O texto gravado no histórico é o que responde "a captura está funcionando?". +/// +/// +/// Um ciclo que recebe só eventos — manifestação, cancelamento, carta de correção de notas de terceiros +/// — termina com zero documentos novos e ainda assim grava dezenas de arquivos. A mensagem anunciava +/// "Nenhum documento novo na SEFAZ" e fazia a tela parecer parada enquanto o storage crescia. +/// +public class MensagemDaExecucaoTests +{ + [Fact] + public async Task Handle_QuandoOLoteTrazSoEventos_AMensagemOsCita() + { + var cenario = new CenarioSincronizacao() + .ComEmpresa(ultimoNsu: 500) + .ComCertificado() + .ComStorageFuncionando() + .ComRespostas( + Retorno.ComDocumentos(503, Retorno.Evento(501), Retorno.Evento(502), Retorno.Evento(503)), + Retorno.NadaNovo(503)); + + var resultado = await Executar(cenario); + + Assert.Equal(DesfechoSincronizacao.EmDia, resultado.Desfecho); + Assert.Equal("3 eventos arquivados.", resultado.Mensagem); + } + + [Fact] + public async Task Handle_ComUmEventoSo_UsaOSingular() + { + var cenario = new CenarioSincronizacao() + .ComEmpresa(ultimoNsu: 500) + .ComCertificado() + .ComStorageFuncionando() + .ComRespostas(Retorno.ComDocumentos(501, Retorno.Evento(501)), Retorno.NadaNovo(501)); + + var resultado = await Executar(cenario); + + Assert.Equal("1 evento arquivado.", resultado.Mensagem); + } + + [Fact] + public async Task Handle_QuandoNadaChega_MantemAMensagemDeEmDia() + { + var cenario = new CenarioSincronizacao() + .ComEmpresa(ultimoNsu: 500) + .ComCertificado() + .ComRespostas(Retorno.NadaNovo(500)); + + var resultado = await Executar(cenario); + + Assert.Equal("Nenhum documento novo na SEFAZ.", resultado.Mensagem); + } + + [Fact] + public async Task Handle_ComNotasEEventosNoMesmoCiclo_CitaOsDois() + { + var cenario = new CenarioSincronizacao() + .ComEmpresa(ultimoNsu: 500) + .ComCertificado() + .ComStorageFuncionando() + .ComRespostas( + Retorno.ComDocumentos(502, Retorno.NotaCompleta(501), Retorno.Evento(502)), + Retorno.NadaNovo(502)); + + var resultado = await Executar(cenario); + + Assert.Equal("1 documento novo; 1 evento arquivado.", resultado.Mensagem); + } + + [Fact] + public async Task Handle_QuandoOTetoDeLotesEAtingido_CitaOColhidoAntesDoReagendamento() + { + // Empresa atrasada volta para a fila com o teto batido. A mensagem precisa dizer o que já entrou, + // senão a execução parcial parece não ter feito nada. + var cenario = new CenarioSincronizacao() + .ComEmpresa(ultimoNsu: 500) + .ComCertificado() + .ComStorageFuncionando() + .ComRespostas( + Retorno.ComDocumentos(501, Retorno.Evento(501)), + Retorno.ComDocumentos(502, Retorno.Evento(502))); + + var resultado = await Executar(cenario, tetoDeLotes: 2); + + Assert.Equal(DesfechoSincronizacao.Parcial, resultado.Desfecho); + Assert.Equal( + "2 eventos arquivados. Teto de lotes desta execução atingido; a empresa foi reagendada.", + resultado.Mensagem); + } + + [Fact] + public async Task Handle_QuandoASefazRejeitaPorConsumoIndevido_NaoGravaHorarioAbsoluto() + { + // A frase é gravada no histórico e lida numa tela que converte tudo para o fuso do navegador. + // Um horário embutido no texto não teria como ser convertido. + var cenario = new CenarioSincronizacao() + .ComEmpresa(ultimoNsu: 500) + .ComCertificado() + .ComRespostas(Retorno.ConsumoIndevido(600)); + + var resultado = await Executar(cenario); + + Assert.Equal(DesfechoSincronizacao.Bloqueada, resultado.Desfecho); + Assert.Equal("Rejeitada por consumo indevido; nova tentativa em 1 hora.", resultado.Mensagem); + Assert.DoesNotContain("UTC", resultado.Mensagem, StringComparison.OrdinalIgnoreCase); + } + + private static async Task Executar( + CenarioSincronizacao cenario, int tetoDeLotes = 20) + { + var resultado = await cenario.Montar(tetoDeLotes).Handle( + new SincronizarEmpresaCommand(cenario.Empresa.Id), TestContext.Current.CancellationToken); + + Assert.True(resultado.Sucesso, resultado.Erro?.Mensagem); + + return resultado.Valor; + } +} diff --git a/tests/eContabil.Infrastructure.Tests/Persistencia/FiltrosGlobaisTests.cs b/tests/eContabil.Infrastructure.Tests/Persistencia/FiltrosGlobaisTests.cs index 33b0276..6537417 100644 --- a/tests/eContabil.Infrastructure.Tests/Persistencia/FiltrosGlobaisTests.cs +++ b/tests/eContabil.Infrastructure.Tests/Persistencia/FiltrosGlobaisTests.cs @@ -1,3 +1,4 @@ +using eContabil.Domain.Certificados; using eContabil.Domain.Empresas; using eContabil.Domain.Usuarios; using eContabil.Domain.ValueObjects; @@ -156,6 +157,86 @@ public async Task EmpresaInativada_NaoEntraNoCicloDeSincronizacao() Assert.Empty(elegiveis); } + [Fact] + public async Task EmpresaInativada_ApareceNaGradeDeEmpresas() + { + if (postgres.MotivoIndisponivel is { } motivo) + { + Assert.Skip(motivo); + return; + } + + await postgres.LimparAsync(); + await CriarEmpresaInativaAsync(); + + await using var contexto = postgres.CriarContexto(); + var consultas = new EmpresaConsultas(contexto); + + var pagina = await consultas.ListarAsync( + busca: null, pagina: 1, tamanho: 20, TestContext.Current.CancellationToken); + + // Inativar não é excluir: o cadastro continua, fora do ciclo de captura. Escondê-lo da grade + // fazia a empresa sumir da tela sem explicação e sem como reativá-la. + var empresa = Assert.Single(pagina.Itens); + Assert.False(empresa.Ativa); + Assert.Equal(1, pagina.Total); + } + + [Fact] + public async Task EmpresaInativada_NaoContaCertificadoInativoComoAtivo() + { + if (postgres.MotivoIndisponivel is { } motivo) + { + Assert.Skip(motivo); + return; + } + + await postgres.LimparAsync(); + var empresaId = await CriarEmpresaInativaAsync(comCertificadoInativo: true); + + await using var contexto = postgres.CriarContexto(); + var consultas = new EmpresaConsultas(contexto); + + var pagina = await consultas.ListarAsync( + busca: null, pagina: 1, tamanho: 20, TestContext.Current.CancellationToken); + + // O `IgnoreQueryFilters` que revela a empresa inativa vale para a consulta inteira e desligaria + // também o filtro de `CertificadoDigital`. Sem a condição explícita, um certificado revogado + // contaria como válido e a tela ofereceria sincronizar com ele. + var empresa = Assert.Single(pagina.Itens); + Assert.Equal(empresaId, empresa.Id); + Assert.False(empresa.PossuiCertificadoAtivo); + } + + private async Task CriarEmpresaInativaAsync(bool comCertificadoInativo = false) + { + await using var contexto = postgres.CriarContexto(); + + var empresa = Empresa.Criar( + "EMPRESA DESATIVADA LTDA", null, Cnpj.Criar("11222333000181").Valor, null, + Uf.Criar("SP").Valor, AmbienteSefaz.Producao, AgoraUtc).Valor; + + empresa.Inativar(); + contexto.Empresas.Add(empresa); + + if (comCertificadoInativo) + { + var certificado = CertificadoDigital.Criar( + Guid.CreateVersion7(), empresa.Id, "certificado.pfx", + "80317018376713DE75FB0603A964F8138A8C482B", Cnpj.Criar("11222333000181").Valor, + Uf.Criar("SP").Valor, AgoraUtc.AddMonths(-3), AgoraUtc.AddYears(1), "certificados", + $"{empresa.Id}/certificado.pfx.enc", "hash", "senha-cifrada", versaoChave: 1, + AgoraUtc).Valor; + + certificado.Inativar(); + contexto.CertificadosDigitais.Add(certificado); + } + + await contexto.SaveChangesAsync(TestContext.Current.CancellationToken); + + return empresa.Id; + } + private async Task CriarUsuarioInativoAsync() { await using var contexto = postgres.CriarContexto(); diff --git a/tests/eContabil.Shared.Tests/TextoDeEsperaTests.cs b/tests/eContabil.Shared.Tests/TextoDeEsperaTests.cs new file mode 100644 index 0000000..d6fb677 --- /dev/null +++ b/tests/eContabil.Shared.Tests/TextoDeEsperaTests.cs @@ -0,0 +1,49 @@ +using eContabil.Shared; +using Xunit; + +namespace eContabil.Shared.Tests; + +/// +/// A espera vira texto sem citar horário, porque a frase é lida num fuso que o servidor não conhece. +/// +public class TextoDeEsperaTests +{ + [Theory] + [InlineData(1, "1 minuto")] + [InlineData(30, "30 minutos")] + [InlineData(59, "59 minutos")] + [InlineData(60, "1 hora")] + [InlineData(120, "2 horas")] + [InlineData(90, "1 hora e 30 minutos")] + [InlineData(360, "6 horas")] + public void Descrever_ConverteAEsperaEmTexto(int minutos, string esperado) => + Assert.Equal(esperado, TextoDeEspera.Descrever(TimeSpan.FromMinutes(minutos))); + + [Fact] + public void Descrever_ArredondaParaCima() + { + // Anunciar "59 minutos" para uma espera de 59min30s convidaria a tentar cedo demais e a colher + // outro consumo indevido. + Assert.Equal("1 hora", TextoDeEspera.Descrever(TimeSpan.FromSeconds(3570))); + } + + [Fact] + public void Descrever_ComEsperaJaVencida_NaoDevolveZeroNemNegativo() + { + // O bloqueio pode expirar entre a leitura do relógio e a montagem da frase. + Assert.Equal("1 minuto", TextoDeEspera.Descrever(TimeSpan.Zero)); + Assert.Equal("1 minuto", TextoDeEspera.Descrever(TimeSpan.FromMinutes(-5))); + } + + [Fact] + public void Descrever_NuncaCitaHorarioAbsoluto() + { + // A regressão que este arquivo existe para impedir: a mensagem antes trazia "dd/MM HH:mm UTC", + // e a tela exibia o mesmo instante em dois fusos diferentes na mesma página. + var texto = TextoDeEspera.Descrever(TimeSpan.FromHours(4)); + + Assert.DoesNotContain("UTC", texto, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(":", texto, StringComparison.Ordinal); + Assert.DoesNotContain("/", texto, StringComparison.Ordinal); + } +}