diff --git a/README.md b/README.md index e1561d0..eacc7b1 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,8 @@ auditável de tudo que foi emitido contra ou pelos seus clientes. ✅ 7. API ✅ 14. Documentação ``` -**O backend está completo**: captura automática de NF-e na SEFAZ, manifestação de ciência, cofre de +**O backend está completo**: captura automática de NF-e na SEFAZ, extração dos dados fiscais do XML +(natureza da operação, totais de imposto e itens com CFOP, NCM e CST), manifestação de ciência, cofre de certificados cifrado, autenticação com perfis, agendamento e a borda endurecida (CORS, limite de requisições, cabeçalhos de segurança e exceção não tratada virando ProblemDetails). @@ -43,6 +44,10 @@ painel, empresas, certificados, documentos, detalhe do documento, perfil e login A captura já foi exercitada contra a SEFAZ de **produção**: NF-e reais entraram pela Distribuição DF-e, com XML completo arquivado no object storage e o ponteiro de NSU avançando entre as rodadas. +O XML é arquivado **descompactado**. A Distribuição DF-e entrega cada documento no elemento `docZip` — +o arquivo comprimido em gzip e codificado em base64 —, e a biblioteca fiscal decodifica o base64 mas +devolve os bytes ainda comprimidos. Arquivá-los como vieram produz `.xml` que nenhum leitor abre. + Uma ressalva que vale para qualquer instalação: o ponteiro de NSU pertence ao **CNPJ**, não à aplicação. Se outro sistema fiscal já consome a fila daquele CNPJ, a carga inicial não traz histórico — os NSUs anteriores já foram entregues a ele. Não é defeito, é como a SEFAZ define a fila. @@ -83,6 +88,9 @@ curl "http://localhost:8080/api/v1/documentos?empresaId=" curl "http://localhost:8080/api/v1/documentos//detalhe" curl -OJ "http://localhost:8080/api/v1/documentos//xml" +# filtrar por dados fiscais: CFOP e NCM aceitam prefixo (5 traz as saídas, 3004 traz a família) +curl "http://localhost:8080/api/v1/documentos?cfop=5102&ncm=3004&comSubstituicaoTributaria=true" + # totais do mesmo recorte da listagem (aceita os mesmos filtros) curl "http://localhost:8080/api/v1/documentos/resumo?empresaId=" diff --git a/RELEASE.md b/RELEASE.md index 495c0f2..909734e 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -28,6 +28,10 @@ - [ ] `/health/ready` com PostgreSQL, MinIO e SEFAZ - [ ] `/hangfire` recusando anônimo e aceitando `Admin` - [ ] Grade de documentos, detalhe e download de XML +- [ ] XML baixado abre num leitor de XML — sem renomear, sem descompactar à mão +- [ ] Detalhe mostra itens, CFOP, NCM, CST e os totais de imposto da nota +- [ ] Filtro por CFOP e NCM aceita prefixo, e a grade avisa quantos ficam de fora por só terem resumo +- [ ] `POST /jobs/reprocessar-acervo` zera a fila de extração do acervo existente - [ ] Totalizadores da grade e relatório por empresa batendo com a contagem no banco - [ ] Manifestação de ciência: documento sai de `Pendente` e o protocolo aparece no detalhe - [ ] Empresa bloqueada pela SEFAZ não recebe consulta nem evento até o prazo expirar diff --git a/RUNBOOK.md b/RUNBOOK.md index c5b1428..0620e39 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -148,6 +148,28 @@ AGENDAMENTO_ATIVO=false docker compose up -d --build api A API atende normalmente (grade, relatórios, download), só não roda job nenhum. Para religar, suba de novo sem a variável: `docker compose up -d api`. +### "A nota não tem itens, CFOP nem impostos na tela" + +O documento foi capturado antes de a extração existir: o XML está arquivado, mas nada foi lido dele. +A tela de detalhe diz isso em vez de mostrar zeros, que seriam lidos como fato. + +Dispare o reprocessamento — ele **não fala com a SEFAZ**, só lê o object storage, então pode rodar com o +CNPJ bloqueado por consumo indevido: + +```bash +curl -X POST "http://localhost:8080/api/v1/jobs/reprocessar-acervo?limite=200" \ + -H "Authorization: Bearer " +``` + +A resposta diz quantos foram visitados, extraídos e regravados. Enquanto `restamPendentes` for `true`, +repita — ou deixe o job do Hangfire encadear as rodadas sozinho. A varredura é idempotente: passar duas +vezes pelo mesmo documento substitui os itens, não os duplica. + +A mesma passada corrige o arquivo no bucket: versões anteriores gravavam os bytes do `docZip` como +vinham da SEFAZ, isto é, **XML comprimido em gzip com nome de `.xml`**. O download já descompacta na +leitura, então ele funciona antes mesmo do reprocessamento — o que a varredura resolve é o conteúdo +guardado, para quem inspeciona o bucket por fora da aplicação. + ### "O documento está lá mas não baixa o XML" Não é erro. A SEFAZ entrega **apenas o resumo** das notas em que a empresa é destinatária; o XML completo diff --git a/src/eContabil.Api/Controllers/DocumentosController.cs b/src/eContabil.Api/Controllers/DocumentosController.cs index ee49f24..a758e6d 100644 --- a/src/eContabil.Api/Controllers/DocumentosController.cs +++ b/src/eContabil.Api/Controllers/DocumentosController.cs @@ -32,12 +32,17 @@ public async Task>> Listar( [FromQuery] string? emitenteBusca, [FromQuery] decimal? valorMinimo, [FromQuery] decimal? valorMaximo, + [FromQuery] string? cfop, + [FromQuery] string? ncm, + [FromQuery] string? cstIcms, + [FromQuery] bool? comSubstituicaoTributaria, [FromQuery] string? cursor, [FromQuery] int tamanho = 50, CancellationToken ct = default) { var filtro = new FiltroDocumentos( - empresaId, de, ate, situacao, numero, emitenteBusca, valorMinimo, valorMaximo, cursor, tamanho); + empresaId, de, ate, situacao, numero, emitenteBusca, valorMinimo, valorMaximo, + cfop, ncm, cstIcms, comSubstituicaoTributaria, cursor, tamanho); return Responder(await mediator.Send(new ObterDocumentosQuery(filtro), ct)); } @@ -60,10 +65,15 @@ public async Task> Resumo( [FromQuery] string? emitenteBusca, [FromQuery] decimal? valorMinimo, [FromQuery] decimal? valorMaximo, + [FromQuery] string? cfop, + [FromQuery] string? ncm, + [FromQuery] string? cstIcms, + [FromQuery] bool? comSubstituicaoTributaria, CancellationToken ct = default) { var filtro = new FiltroDocumentos( - empresaId, de, ate, situacao, numero, emitenteBusca, valorMinimo, valorMaximo); + empresaId, de, ate, situacao, numero, emitenteBusca, valorMinimo, valorMaximo, + cfop, ncm, cstIcms, comSubstituicaoTributaria); return Responder(await mediator.Send(new ResumirDocumentosQuery(filtro), ct)); } diff --git a/src/eContabil.Api/Controllers/JobsController.cs b/src/eContabil.Api/Controllers/JobsController.cs index edf7006..c210baf 100644 --- a/src/eContabil.Api/Controllers/JobsController.cs +++ b/src/eContabil.Api/Controllers/JobsController.cs @@ -1,5 +1,6 @@ using eContabil.Api.Contratos.Jobs; using eContabil.Application.Autenticacao; +using eContabil.Application.Fiscal; using eContabil.Application.Manifestacao; using eContabil.Application.Sincronizacao; using eContabil.Application.Sincronizacao.Consultas; @@ -105,6 +106,23 @@ public async Task> Manifestar( Guid empresaId, CancellationToken ct) => Responder(await mediator.Send(new ManifestarCienciaCommand(empresaId), ct)); + /// + /// Relê os XMLs já arquivados para extrair deles os dados fiscais. + /// + /// + /// Não fala com a SEFAZ — lê apenas o object storage —, então pode ser disparado com o CNPJ + /// bloqueado por consumo indevido. É o que preenche itens, CFOP e impostos do acervo capturado antes + /// de a extração existir, e o que regrava em XML legível o que estiver arquivado compactado. + /// + /// Roda em rodadas: cada uma reenfileira a seguinte enquanto restar documento sem extrair. + /// + [HttpPost("reprocessar-acervo")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + public async Task> ReprocessarAcervo( + [FromQuery] int limite = 200, CancellationToken ct = default) => + Responder(await mediator.Send(new ReprocessarAcervoCommand(limite), ct)); + /// /// Histórico das últimas execuções de sincronização. /// diff --git a/src/eContabil.Application/AplicacaoServiceCollectionExtensions.cs b/src/eContabil.Application/AplicacaoServiceCollectionExtensions.cs index bd3481a..62e71ad 100644 --- a/src/eContabil.Application/AplicacaoServiceCollectionExtensions.cs +++ b/src/eContabil.Application/AplicacaoServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using eContabil.Application; using eContabil.Application.Comportamentos; +using eContabil.Application.Fiscal; using eContabil.Application.Manifestacao; using eContabil.Application.Sincronizacao; using eContabil.Shared; @@ -36,6 +37,9 @@ public static IServiceCollection AddAplicacao( // de um chamador vazaria para outro. services.AddScoped(); + // Sem estado próprio: a mesma instância serve a captura e ao reprocessamento do acervo. + services.AddSingleton(); + return services; } } diff --git a/src/eContabil.Application/Armazenamento/IXmlStorage.cs b/src/eContabil.Application/Armazenamento/IXmlStorage.cs index 649582f..63e1386 100644 --- a/src/eContabil.Application/Armazenamento/IXmlStorage.cs +++ b/src/eContabil.Application/Armazenamento/IXmlStorage.cs @@ -36,8 +36,27 @@ public interface IXmlStorage Task> SalvarEventoAsync( string chaveAcesso, long nsu, Stream xml, CancellationToken ct); - /// Abre o objeto para leitura em fluxo, sem carregá-lo inteiro em memória. + /// + /// Abre o objeto para leitura, já em XML legível. + /// + /// + /// O que estiver arquivado compactado é descompactado na saída: versões anteriores gravavam os bytes + /// do docZip como vinham da SEFAZ, e o acervo delas continua no bucket até o reprocessamento + /// passar. + /// Task> ObterAsync(string bucket, string objectName, CancellationToken ct); + /// + /// Substitui o conteúdo de um objeto que já existe, devolvendo o novo resumo criptográfico. + /// + /// + /// Contrapartida deliberada de , que nunca regrava. O reprocessamento + /// precisa exatamente do contrário: reescrever no lugar o arquivo que está em gzip. Separar as duas + /// operações impede que um retry de sincronização sobrescreva por engano um XML autorizado, que é + /// imutável. + /// + Task> SubstituirAsync( + string bucket, string objectName, Stream conteudo, CancellationToken ct); + Task ExisteAsync(string bucket, string objectName, CancellationToken ct); } diff --git a/src/eContabil.Application/Documentos/Consultas/DocumentoDetalheDto.cs b/src/eContabil.Application/Documentos/Consultas/DocumentoDetalheDto.cs index 2780d5f..415b151 100644 --- a/src/eContabil.Application/Documentos/Consultas/DocumentoDetalheDto.cs +++ b/src/eContabil.Application/Documentos/Consultas/DocumentoDetalheDto.cs @@ -6,8 +6,13 @@ namespace eContabil.Application.Documentos.Consultas; /// Documento na tela de detalhe. /// /// -/// Traz o que a grade omite: destinatário, autorização, NSU e o estado da manifestação. O NSU importa -/// no suporte — é por ele que se localiza a execução que trouxe o documento. +/// Traz o que a grade omite: destinatário, autorização, NSU, o estado da manifestação, os totais de +/// imposto e as linhas de produto. O NSU importa no suporte — é por ele que se localiza a execução que +/// trouxe o documento. +/// +/// Impostos e itens só existem onde o XML completo foi lido. Documento que ficou no resumo responde com +/// totais zerados e nenhum item, e é DadosFiscaisExtraidos que separa "sem imposto" de "ainda não +/// sabemos" — a tela precisa dizer qual dos dois é, ou o contador lê zero como fato. /// public sealed record DocumentoDetalheDto( Guid Id, @@ -35,4 +40,17 @@ public sealed record DocumentoDetalheDto( string? ProtocoloManifestacao, DateTime? ManifestadoEm, bool PossuiXml, - DateTime CapturadoEm); + DateTime CapturadoEm, + string? NaturezaOperacao, + bool DadosFiscaisExtraidos, + decimal TotalProdutos, + decimal TotalBaseIcms, + decimal TotalIcms, + decimal TotalBaseIcmsSt, + decimal TotalIcmsSt, + decimal TotalIpi, + decimal TotalPis, + decimal TotalCofins, + decimal TotalFrete, + decimal TotalDesconto, + IReadOnlyList Itens); diff --git a/src/eContabil.Application/Documentos/Consultas/FiltroDocumentos.cs b/src/eContabil.Application/Documentos/Consultas/FiltroDocumentos.cs index 43e5f52..af7f652 100644 --- a/src/eContabil.Application/Documentos/Consultas/FiltroDocumentos.cs +++ b/src/eContabil.Application/Documentos/Consultas/FiltroDocumentos.cs @@ -9,6 +9,11 @@ namespace eContabil.Application.Documentos.Consultas; /// Todos opcionais: a tela abre com o mês corrente e o contador vai estreitando. O /// é opaco — quem consome devolve o valor recebido sem interpretá-lo, o que /// permite mudar a estratégia de paginação sem quebrar cliente. +/// +/// , e são de item, não de nota: uma NF-e com +/// quarenta linhas pode ter cinco CFOPs. A pergunta que eles respondem é "quais notas **contêm** algum +/// item com este código". E como item só existe onde o XML completo foi lido, qualquer um deles exclui +/// em silêncio o documento que ficou no resumo — é por isso que a grade informa quantos ficaram de fora. /// public sealed record FiltroDocumentos( Guid? EmpresaId = null, @@ -19,5 +24,17 @@ public sealed record FiltroDocumentos( string? EmitenteBusca = null, decimal? ValorMinimo = null, decimal? ValorMaximo = null, + string? Cfop = null, + string? Ncm = null, + string? CstIcms = null, + bool? ComSubstituicaoTributaria = null, string? Cursor = null, - int Tamanho = 50); + int Tamanho = 50) +{ + /// Diz se algum critério depende de dados que só existem no XML completo. + public bool ExigeDadosFiscais => + !string.IsNullOrWhiteSpace(Cfop) + || !string.IsNullOrWhiteSpace(Ncm) + || !string.IsNullOrWhiteSpace(CstIcms) + || ComSubstituicaoTributaria is not null; +} diff --git a/src/eContabil.Application/Documentos/Consultas/ItemDocumentoDto.cs b/src/eContabil.Application/Documentos/Consultas/ItemDocumentoDto.cs new file mode 100644 index 0000000..f3dc4da --- /dev/null +++ b/src/eContabil.Application/Documentos/Consultas/ItemDocumentoDto.cs @@ -0,0 +1,30 @@ +namespace eContabil.Application.Documentos.Consultas; + +/// +/// Uma linha de produto na tela de detalhe. +/// +/// +/// É o que o contador confere antes de lançar: CFOP e CST decidem a escrituração, NCM decide a +/// classificação, e os valores de imposto por linha são o que ele confronta com a apuração. Sem isso na +/// tela, cada nota exigiria baixar o XML e lê-lo à mão. +/// +public sealed record ItemDocumentoDto( + int Numero, + string Codigo, + string Descricao, + string Cfop, + string Ncm, + string Cest, + string Unidade, + decimal Quantidade, + decimal ValorUnitario, + decimal ValorTotal, + int OrigemMercadoria, + string CstIcms, + decimal BaseCalculoIcms, + decimal AliquotaIcms, + decimal ValorIcms, + decimal ValorIcmsSt, + decimal ValorIpi, + decimal ValorPis, + decimal ValorCofins); diff --git a/src/eContabil.Application/Documentos/Consultas/ResumoDocumentosDto.cs b/src/eContabil.Application/Documentos/Consultas/ResumoDocumentosDto.cs index eb10c17..db65cce 100644 --- a/src/eContabil.Application/Documentos/Consultas/ResumoDocumentosDto.cs +++ b/src/eContabil.Application/Documentos/Consultas/ResumoDocumentosDto.cs @@ -6,6 +6,12 @@ namespace eContabil.Application.Documentos.Consultas; /// /// Vem de um endpoint próprio, e não junto da página: a listagem é por cursor justamente para não pagar /// um `COUNT` sobre a tabela inteira a cada página. O total é pedido uma vez, quando o filtro muda. +/// +/// ForaDoFiltroPorFaltarXml conta os documentos que atendem aos demais critérios mas não puderam +/// ser avaliados contra os filtros fiscais, por não terem XML completo lido; é zero quando nenhum filtro +/// de item está ativo. Existe para a grade poder dizer isso na tela: filtrar por CFOP esconde toda nota +/// que ficou no resumo, e sem o aviso o contador conclui que a nota sumiu do sistema — quando ela está +/// lá, só não tem item nenhum para comparar. /// public sealed record ResumoDocumentosDto( int Quantidade, @@ -13,4 +19,5 @@ public sealed record ResumoDocumentosDto( int ComXmlCompleto, int SomenteResumo, int Entradas, - int Saidas); + int Saidas, + int ForaDoFiltroPorFaltarXml); diff --git a/src/eContabil.Application/Fiscal/AplicadorDeDadosFiscais.cs b/src/eContabil.Application/Fiscal/AplicadorDeDadosFiscais.cs new file mode 100644 index 0000000..23f9102 --- /dev/null +++ b/src/eContabil.Application/Fiscal/AplicadorDeDadosFiscais.cs @@ -0,0 +1,54 @@ +using eContabil.Domain.Documentos; + +namespace eContabil.Application.Fiscal; + +/// +public sealed class AplicadorDeDadosFiscais(IExtratorDeDadosFiscais extrator) : IAplicadorDeDadosFiscais +{ + public int Aplicar(DocumentoFiscal documento, byte[] xml, DateTime agoraUtc) + { + ArgumentNullException.ThrowIfNull(documento); + + if (!documento.PossuiXml || xml is null || xml.Length == 0) + { + return 0; + } + + var dados = extrator.Extrair(xml); + + var itens = dados.Itens + .Select(item => ItemDocumentoFiscal.Criar( + documento.Id, + item.Numero, + item.Codigo, + item.Descricao, + item.Cfop, + item.Ncm, + item.Cest, + item.Unidade, + item.Quantidade, + item.ValorUnitario, + item.ValorTotal, + item.OrigemMercadoria, + item.CstIcms, + item.BaseCalculoIcms, + item.AliquotaIcms, + item.ValorIcms, + item.ValorIcmsSt, + item.ValorIpi, + item.ValorPis, + item.ValorCofins, + agoraUtc)) + + // Item que não passa na validação é descartado sozinho. Recusar a nota inteira por causa de + // uma linha faria o documento perder também os totais e a natureza da operação, que estão + // corretos — e ele sumiria da grade por um defeito de uma linha de produto. + .Where(resultado => resultado.Sucesso) + .Select(resultado => resultado.Valor) + .ToList(); + + var registro = documento.RegistrarDadosFiscais(dados.NaturezaOperacao, dados.Totais, itens); + + return registro.Sucesso ? itens.Count : 0; + } +} diff --git a/src/eContabil.Application/Fiscal/DadosFiscaisDto.cs b/src/eContabil.Application/Fiscal/DadosFiscaisDto.cs new file mode 100644 index 0000000..daa3d99 --- /dev/null +++ b/src/eContabil.Application/Fiscal/DadosFiscaisDto.cs @@ -0,0 +1,16 @@ +using eContabil.Domain.ValueObjects; + +namespace eContabil.Application.Fiscal; + +/// Tudo o que se extrai de um XML de NF-e além do que já vem no cabeçalho do lote. +public sealed record DadosFiscaisDto( + string NaturezaOperacao, + TotaisFiscais Totais, + IReadOnlyList Itens) +{ + /// + /// Instância nova a cada chamada, porque carrega os totais: dois documentos recebendo a mesma + /// totalização a compartilhariam, e o EF Core a trataria como uma entidade trocando de dono. + /// + public static DadosFiscaisDto Vazio => new(string.Empty, TotaisFiscais.Vazios, []); +} diff --git a/src/eContabil.Application/Fiscal/IAplicadorDeDadosFiscais.cs b/src/eContabil.Application/Fiscal/IAplicadorDeDadosFiscais.cs new file mode 100644 index 0000000..568e362 --- /dev/null +++ b/src/eContabil.Application/Fiscal/IAplicadorDeDadosFiscais.cs @@ -0,0 +1,17 @@ +using eContabil.Domain.Documentos; + +namespace eContabil.Application.Fiscal; + +/// Lê o XML e grava no documento a natureza da operação, os totais e os itens. +public interface IAplicadorDeDadosFiscais +{ + /// + /// Aplica ao documento o que o XML disser. Devolve quantos itens foram gravados. + /// + /// + /// É o único ponto onde XML vira dado fiscal no domínio. A captura e o reprocessamento do acervo + /// chegam por vias diferentes — uma com o lote da SEFAZ na mão, a outra lendo o object storage — e + /// precisam produzir exatamente o mesmo resultado para a mesma nota. + /// + int Aplicar(DocumentoFiscal documento, byte[] xml, DateTime agoraUtc); +} diff --git a/src/eContabil.Application/Fiscal/IExtratorDeDadosFiscais.cs b/src/eContabil.Application/Fiscal/IExtratorDeDadosFiscais.cs new file mode 100644 index 0000000..f57ae16 --- /dev/null +++ b/src/eContabil.Application/Fiscal/IExtratorDeDadosFiscais.cs @@ -0,0 +1,15 @@ +namespace eContabil.Application.Fiscal; + +/// Lê o XML de uma NF-e e devolve os dados fiscais que a grade e os relatórios consultam. +public interface IExtratorDeDadosFiscais +{ + /// + /// Extrai natureza da operação, totais de imposto e itens. + /// + /// + /// Nunca lança e nunca devolve nulo: XML ausente, truncado ou de um layout que não reconhecemos + /// resulta em . A captura de um lote de cinquenta documentos não + /// pode parar porque um deles veio malformado. + /// + DadosFiscaisDto Extrair(byte[] xml); +} diff --git a/src/eContabil.Application/Fiscal/ItemFiscalDto.cs b/src/eContabil.Application/Fiscal/ItemFiscalDto.cs new file mode 100644 index 0000000..e0afeac --- /dev/null +++ b/src/eContabil.Application/Fiscal/ItemFiscalDto.cs @@ -0,0 +1,23 @@ +namespace eContabil.Application.Fiscal; + +/// Uma linha de produto lida do XML, ainda sem vínculo com o documento gravado. +public sealed record ItemFiscalDto( + int Numero, + string Codigo, + string Descricao, + string Cfop, + string Ncm, + string Cest, + string Unidade, + decimal Quantidade, + decimal ValorUnitario, + decimal ValorTotal, + int OrigemMercadoria, + string CstIcms, + decimal BaseCalculoIcms, + decimal AliquotaIcms, + decimal ValorIcms, + decimal ValorIcmsSt, + decimal ValorIpi, + decimal ValorPis, + decimal ValorCofins); diff --git a/src/eContabil.Application/Fiscal/ReprocessarAcervoCommand.cs b/src/eContabil.Application/Fiscal/ReprocessarAcervoCommand.cs new file mode 100644 index 0000000..aaf9c26 --- /dev/null +++ b/src/eContabil.Application/Fiscal/ReprocessarAcervoCommand.cs @@ -0,0 +1,14 @@ +using eContabil.Shared; +using MediatR; + +namespace eContabil.Application.Fiscal; + +/// +/// Relê os XMLs já arquivados para extrair deles os dados fiscais. +/// +/// +/// Existe porque o acervo é anterior à extração: os documentos capturados antes dela têm arquivo no +/// storage e nenhum item no banco. Como o mesmo arquivo precisa ser lido de qualquer forma, a varredura +/// aproveita a passagem para corrigir também o que estiver gravado compactado. +/// +public sealed record ReprocessarAcervoCommand(int Limite = 500) : IRequest>; diff --git a/src/eContabil.Application/Fiscal/ReprocessarAcervoCommandHandler.cs b/src/eContabil.Application/Fiscal/ReprocessarAcervoCommandHandler.cs new file mode 100644 index 0000000..fa27342 --- /dev/null +++ b/src/eContabil.Application/Fiscal/ReprocessarAcervoCommandHandler.cs @@ -0,0 +1,157 @@ +using eContabil.Application.Armazenamento; +using eContabil.Domain; +using eContabil.Domain.Documentos; +using eContabil.Shared; +using MediatR; +using Microsoft.Extensions.Logging; + +namespace eContabil.Application.Fiscal; + +/// +/// Varre o acervo arquivado, extrai os dados fiscais e normaliza o que estiver compactado. +/// +/// +/// Uma única leitura por documento resolve as duas coisas, porque é o mesmo arquivo: o XML que precisa +/// ser lido para extrair CFOP e impostos é o mesmo que precisa sair do gzip para virar download que +/// abre. Fazer duas varreduras dobraria o tráfego contra o object storage sem ganho nenhum. +/// +/// Nada aqui é gravado em lotes parciais: ou a rodada inteira entra, ou nenhuma parte dela entra. Um +/// documento marcado como extraído sem os itens correspondentes sairia da fila para sempre — e não +/// existiria maneira de descobrir que ele ficou pela metade. +/// +public sealed partial class ReprocessarAcervoCommandHandler( + IDocumentoFiscalRepository documentos, + IXmlStorage storage, + IAplicadorDeDadosFiscais dadosFiscais, + IUnitOfWork unitOfWork, + TimeProvider relogio, + ILogger log) + : IRequestHandler> +{ + public async Task> Handle( + ReprocessarAcervoCommand request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var agora = relogio.AgoraUtc(); + var limite = Math.Clamp(request.Limite, 1, 2000); + + var pendentes = await documentos.ObterPendentesDeExtracaoAsync(limite, cancellationToken); + if (pendentes.Count == 0) + { + return Result.Ok( + new ResultadoReprocessamento(0, 0, 0, 0, RestamPendentes: false)); + } + + var extraidos = 0; + var recompactados = 0; + var semConteudo = 0; + + foreach (var documento in pendentes) + { + if (documento.Bucket is not { } bucket || documento.ObjectName is not { } objectName) + { + semConteudo++; + continue; + } + + var leitura = await storage.ObterAsync(bucket, objectName, cancellationToken); + if (leitura.Falha) + { + // Storage fora derruba a rodada inteira: insistir nos próximos só produziria uma + // sequência de falhas idênticas e marcaria como "sem conteúdo" documentos que estão lá. + if (leitura.Erro!.Codigo is not "storage.objeto_inexistente") + { + return Result.Falhou(leitura.Erro); + } + + ArquivoAusente(log, documento.Id, objectName); + semConteudo++; + continue; + } + + using var fluxo = leitura.Valor; + using var memoria = new MemoryStream(); + await fluxo.CopyToAsync(memoria, cancellationToken); + var xml = memoria.ToArray(); + + if (xml.Length == 0) + { + semConteudo++; + continue; + } + + // `ObterAsync` já devolve descompactado, então o download está correto de qualquer forma. + // Regravar é o que faz o próprio bucket parar de guardar gzip com nome de `.xml` — o que + // importa para quem inspeciona o storage por fora da aplicação. + if (await NormalizarArquivoAsync(documento, bucket, objectName, xml, cancellationToken)) + { + recompactados++; + } + + dadosFiscais.Aplicar(documento, xml, agora); + + if (documento.DadosFiscaisExtraidos) + { + documentos.Atualizar(documento); + extraidos++; + } + } + + await unitOfWork.SalvarAlteracoesAsync(cancellationToken); + + RodadaConcluida(log, pendentes.Count, extraidos, recompactados, semConteudo); + + return Result.Ok(new ResultadoReprocessamento( + pendentes.Count, extraidos, recompactados, semConteudo, + RestamPendentes: pendentes.Count == limite)); + } + + /// + /// Regrava o objeto em XML legível e devolve se o conteúdo arquivado de fato mudou. + /// + /// + /// O hash do conteúdo em claro contra o hash guardado no registro é o que diz se o bucket ainda tinha + /// o gzip: iguais, o arquivo já estava legível e a regravação foi inócua. Reescrever sempre, em vez + /// de conferir antes, custa uma chamada de rede por documento e evita depender do tamanho — que o + /// registro traz do momento do arquivamento e pode ter sido gravado por versões diferentes. + /// + private async Task NormalizarArquivoAsync( + DocumentoFiscal documento, string bucket, string objectName, byte[] xml, CancellationToken ct) + { + var regravado = await storage.SubstituirAsync( + bucket, objectName, new MemoryStream(xml, writable: false), ct); + + if (regravado.Falha) + { + // Regravar é conveniência: a leitura já descompacta, então o download continua correto mesmo + // que o arquivo siga em gzip no bucket. Não vale perder a extração por causa disso. + RegravacaoFalhou(log, documento.Id, regravado.Erro!.Mensagem); + + return false; + } + + var mudou = !string.Equals(documento.HashXml, regravado.Valor.HashSha256, StringComparison.Ordinal); + + documento.AtualizarHashDoXml(regravado.Valor.HashSha256); + + return mudou; + } + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Documento {DocumentoId} aponta para o objeto {ObjectName}, que não existe no storage")] + private static partial void ArquivoAusente(ILogger log, Guid documentoId, string objectName); + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Não foi possível regravar o XML do documento {DocumentoId}: {Motivo}")] + private static partial void RegravacaoFalhou(ILogger log, Guid documentoId, string motivo); + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Reprocessamento visitou {Visitados} documento(s): {Extraidos} extraído(s), " + + "{Recompactados} regravado(s), {SemConteudo} sem arquivo")] + private static partial void RodadaConcluida( + ILogger log, int visitados, int extraidos, int recompactados, int semConteudo); +} diff --git a/src/eContabil.Application/Fiscal/ResultadoReprocessamento.cs b/src/eContabil.Application/Fiscal/ResultadoReprocessamento.cs new file mode 100644 index 0000000..ae46061 --- /dev/null +++ b/src/eContabil.Application/Fiscal/ResultadoReprocessamento.cs @@ -0,0 +1,14 @@ +namespace eContabil.Application.Fiscal; + +/// O que uma rodada de reprocessamento fez. +/// Documentos lidos do storage nesta rodada. +/// Documentos cujos dados fiscais foram gravados. +/// Arquivos que estavam em gzip e foram regravados em XML legível. +/// Documentos cujo arquivo não foi encontrado ou veio vazio. +/// Se ficou fila para a próxima rodada. +public sealed record ResultadoReprocessamento( + int Visitados, + int Extraidos, + int Recompactados, + int SemConteudo, + bool RestamPendentes); diff --git a/src/eContabil.Application/Sincronizacao/SincronizarEmpresaCommandHandler.cs b/src/eContabil.Application/Sincronizacao/SincronizarEmpresaCommandHandler.cs index 0b341a7..00fd7ad 100644 --- a/src/eContabil.Application/Sincronizacao/SincronizarEmpresaCommandHandler.cs +++ b/src/eContabil.Application/Sincronizacao/SincronizarEmpresaCommandHandler.cs @@ -1,6 +1,7 @@ using System.Text; using eContabil.Application.Armazenamento; using eContabil.Application.Certificados; +using eContabil.Application.Fiscal; using eContabil.Application.Sefaz; using eContabil.Domain; using eContabil.Domain.Certificados; @@ -33,6 +34,7 @@ public sealed partial class SincronizarEmpresaCommandHandler( IDocumentoFiscalRepository documentos, IZeusFiscalService sefaz, IXmlStorage xmlStorage, + IAplicadorDeDadosFiscais dadosFiscais, IExecucaoSincronizacaoRepository execucoes, IUnitOfWork unitOfWork, TimeProvider relogio, @@ -237,6 +239,7 @@ private async Task ProcessarLoteAsync( if (arquivamento.Valor is { } objeto && existente.OrigemConteudo is OrigemConteudo.Resumo) { existente.PromoverParaXmlCompleto(objeto.Bucket, objeto.ObjectName, objeto.HashSha256); + dadosFiscais.Aplicar(existente, recebido.Xml, agora); documentos.Atualizar(existente); contadores.Promovidos++; } @@ -251,6 +254,7 @@ private async Task ProcessarLoteAsync( continue; } + dadosFiscais.Aplicar(documento, recebido.Xml, agora); novos.Add(documento); } diff --git a/src/eContabil.Domain/Documentos/DocumentoFiscal.cs b/src/eContabil.Domain/Documentos/DocumentoFiscal.cs index 4071b4d..4ce6c51 100644 --- a/src/eContabil.Domain/Documentos/DocumentoFiscal.cs +++ b/src/eContabil.Domain/Documentos/DocumentoFiscal.cs @@ -93,6 +93,33 @@ private DocumentoFiscal( public bool PossuiXml { get; private set; } + /// Natureza da operação declarada na nota (natOp), em texto livre do emissor. + public string? NaturezaOperacao { get; private set; } + + /// + /// Totais de impostos da nota. Zerados enquanto o XML completo não for lido. + /// + /// + /// Nunca nulo: o documento que só tem resumo responde zero em vez de obrigar cada consulta a + /// distinguir "sem imposto" de "ainda não sabemos". Quem precisa dessa diferença olha + /// . + /// + public TotaisFiscais Totais { get; private set; } = TotaisFiscais.Vazios; + + /// + /// Diz se o XML já foi lido e os dados fiscais extraídos dele. + /// + /// + /// Separado de de propósito: entre arquivar o XML e extrair seus dados existe + /// uma janela — o acervo capturado por versões anteriores tem arquivo mas não tem item nenhum. É esta + /// marca que o reprocessamento usa para saber o que ainda falta varrer. + /// + public bool DadosFiscaisExtraidos { get; private set; } + + private readonly List _itens = []; + + public IReadOnlyCollection Itens => _itens; + public static Result Criar( Guid empresaId, ChaveAcesso chave, @@ -225,4 +252,57 @@ public void MarcarManifestacaoFalhou(string motivo) } public void AtualizarSituacao(SituacaoDocumento situacao) => Situacao = situacao; + + /// + /// Atualiza o resumo criptográfico do arquivo, depois de o conteúdo arquivado ser reescrito. + /// + /// + /// O hash descreve o objeto que está no storage. Quando o reprocessamento regrava o arquivo — trocar + /// o gzip pelo XML em claro é o caso —, o hash antigo passa a apontar para um conteúdo que não existe + /// mais, e deixá-lo assim transformaria a única prova de integridade que temos numa mentira. + /// + public void AtualizarHashDoXml(string hashXml) + { + if (PossuiXml && !string.IsNullOrWhiteSpace(hashXml)) + { + HashXml = hashXml; + } + } + + /// + /// Grava os dados fiscais lidos do XML: natureza da operação, totais de imposto e itens. + /// + /// + /// Reentrante: o reprocessamento do acervo pode passar duas vezes pelo mesmo documento, e a segunda + /// substitui os itens em vez de duplicá-los. Substituir, e não ignorar, porque é assim que uma + /// correção no extrator chega ao que já foi gravado — se a segunda passada não valesse, o único jeito + /// de corrigir um campo mal lido seria apagar a linha do banco. + /// + public Result RegistrarDadosFiscais( + string? naturezaOperacao, TotaisFiscais totais, IEnumerable itens) + { + ArgumentNullException.ThrowIfNull(totais); + ArgumentNullException.ThrowIfNull(itens); + + if (!PossuiXml) + { + // Sem XML não há de onde extrair. Marcar assim mesmo faria o documento sair da fila de + // reprocessamento e nunca mais ser visitado quando o XML completo enfim chegasse. + return Result.Falhou(Error.Conflito( + "documento.sem_xml", "Não há XML completo de onde extrair os dados fiscais.")); + } + + NaturezaOperacao = string.IsNullOrWhiteSpace(naturezaOperacao) + ? null + : naturezaOperacao.Trim(); + + Totais = totais; + + _itens.Clear(); + _itens.AddRange(itens); + + DadosFiscaisExtraidos = true; + + return Result.Ok(); + } } diff --git a/src/eContabil.Domain/Documentos/IDocumentoFiscalRepository.cs b/src/eContabil.Domain/Documentos/IDocumentoFiscalRepository.cs index 00e8362..20e08a6 100644 --- a/src/eContabil.Domain/Documentos/IDocumentoFiscalRepository.cs +++ b/src/eContabil.Domain/Documentos/IDocumentoFiscalRepository.cs @@ -29,4 +29,14 @@ Task> ObterPendentesDeManifestacaoAsync( /// Empresas que têm documento aguardando ciência. Task> ObterEmpresasComManifestacaoPendenteAsync(int limite, CancellationToken ct); + + /// + /// Documentos com XML arquivado cujos dados fiscais ainda não foram lidos. + /// + /// + /// É a fila do reprocessamento do acervo. Fica vazia no regime normal, porque a captura extrai os + /// dados na mesma passada em que arquiva o XML; enche apenas quando o que já estava no storage + /// precisa ser varrido de novo — foi o caso do acervo capturado antes de a extração existir. + /// + Task> ObterPendentesDeExtracaoAsync(int limite, CancellationToken ct); } diff --git a/src/eContabil.Domain/Documentos/ItemDocumentoFiscal.cs b/src/eContabil.Domain/Documentos/ItemDocumentoFiscal.cs new file mode 100644 index 0000000..009f864 --- /dev/null +++ b/src/eContabil.Domain/Documentos/ItemDocumentoFiscal.cs @@ -0,0 +1,165 @@ +using eContabil.Shared; + +namespace eContabil.Domain.Documentos; + +/// +/// Uma linha de produto do documento fiscal. +/// +/// +/// CFOP, NCM e CST são de item, não de nota: uma NF-e com quarenta linhas pode carregar cinco CFOPs +/// diferentes. É por isso que o filtro do contador é "nota que **contenha** algum item com CFOP 5102", +/// e não "nota cujo CFOP é 5102" — a segunda pergunta não tem resposta. +/// +/// O item existe apenas onde há XML completo. O resumo que a SEFAZ entrega antes da manifestação traz +/// chave, emitente, valor e situação, e mais nada: nota que ficou só no resumo não tem item nenhum, e +/// qualquer filtro por item a deixa de fora. +/// +public sealed class ItemDocumentoFiscal : EntidadeBase +{ + /// Construtor do materializador do EF Core. + private ItemDocumentoFiscal() + { + } + + private ItemDocumentoFiscal(Guid documentoId, int numero, DateTime agoraUtc) : base(agoraUtc) + { + DocumentoFiscalId = documentoId; + Numero = numero; + } + + public Guid DocumentoFiscalId { get; private set; } + + /// Posição da linha na nota (nItem), começando em 1. + public int Numero { get; private set; } + + public string Codigo { get; private set; } = string.Empty; + + public string Descricao { get; private set; } = string.Empty; + + /// Código fiscal de operações e prestações — o que separa venda de devolução, dentro ou fora do estado. + public string Cfop { get; private set; } = string.Empty; + + /// Nomenclatura Comum do Mercosul: classificação do produto. + public string Ncm { get; private set; } = string.Empty; + + /// Código Especificador da Substituição Tributária; vazio quando não se aplica. + public string Cest { get; private set; } = string.Empty; + + public string Unidade { get; private set; } = string.Empty; + + public decimal Quantidade { get; private set; } + + public decimal ValorUnitario { get; private set; } + + public decimal ValorTotal { get; private set; } + + /// Origem da mercadoria (0 a 8): nacional, importada, com conteúdo de importação. + public int OrigemMercadoria { get; private set; } + + /// + /// CST do ICMS no regime normal, ou CSOSN no Simples Nacional. + /// + /// + /// Os dois ocupam o mesmo lugar na nota e nunca coexistem: a SEFAZ emite ICMS00 a ICMS90 + /// com CST, ou ICMSSN101 a ICMSSN900 com CSOSN. Separar em duas colunas obrigaria todo + /// filtro a conhecer o regime do emitente para saber onde procurar. + /// + public string CstIcms { get; private set; } = string.Empty; + + public decimal BaseCalculoIcms { get; private set; } + + public decimal AliquotaIcms { get; private set; } + + public decimal ValorIcms { get; private set; } + + public decimal ValorIcmsSt { get; private set; } + + public decimal ValorIpi { get; private set; } + + public decimal ValorPis { get; private set; } + + public decimal ValorCofins { get; private set; } + + public static Result Criar( + Guid documentoId, + int numero, + string? codigo, + string? descricao, + string? cfop, + string? ncm, + string? cest, + string? unidade, + decimal quantidade, + decimal valorUnitario, + decimal valorTotal, + int origemMercadoria, + string? cstIcms, + decimal baseCalculoIcms, + decimal aliquotaIcms, + decimal valorIcms, + decimal valorIcmsSt, + decimal valorIpi, + decimal valorPis, + decimal valorCofins, + DateTime agoraUtc) + { + var notificacoes = new List(); + + if (documentoId == Guid.Empty) + { + notificacoes.Add(new Notification(nameof(DocumentoFiscalId), "Documento é obrigatório.")); + } + + if (numero <= 0) + { + notificacoes.Add(new Notification(nameof(Numero), "Número do item deve ser positivo.")); + } + + if (notificacoes.Count > 0) + { + return Result.Invalido(notificacoes); + } + + // Sem validar formato de CFOP, NCM ou CST: o documento já foi autorizado pela SEFAZ, e recusar + // um item por divergência da nossa validação apagaria da grade uma nota que existe de verdade. + return Result.Ok(new ItemDocumentoFiscal(documentoId, numero, agoraUtc) + { + Codigo = Texto(codigo, 60), + Descricao = Texto(descricao, 120), + Cfop = SomenteDigitos(cfop, 4), + Ncm = SomenteDigitos(ncm, 8), + Cest = SomenteDigitos(cest, 7), + Unidade = Texto(unidade, 6), + Quantidade = quantidade, + ValorUnitario = valorUnitario, + ValorTotal = valorTotal, + OrigemMercadoria = origemMercadoria, + CstIcms = SomenteDigitos(cstIcms, 4), + BaseCalculoIcms = baseCalculoIcms, + AliquotaIcms = aliquotaIcms, + ValorIcms = valorIcms, + ValorIcmsSt = valorIcmsSt, + ValorIpi = valorIpi, + ValorPis = valorPis, + ValorCofins = valorCofins, + }); + } + + private static string Texto(string? valor, int limite) + { + var limpo = (valor ?? string.Empty).Trim(); + + return limpo.Length <= limite ? limpo : limpo[..limite]; + } + + /// + /// CFOP e NCM aparecem com ponto em nota de alguns emissores (5.102). Guardar só os dígitos + /// faz a busca do contador funcionar do jeito que ele digita, sem depender de como o emissor formatou. + /// + private static string SomenteDigitos(string? valor, int limite) + { + var digitos = new string((valor ?? string.Empty).Where(char.IsDigit).ToArray()); + + return digitos.Length <= limite ? digitos : digitos[..limite]; + } +} diff --git a/src/eContabil.Domain/ValueObjects/TotaisFiscais.cs b/src/eContabil.Domain/ValueObjects/TotaisFiscais.cs new file mode 100644 index 0000000..2de5e6e --- /dev/null +++ b/src/eContabil.Domain/ValueObjects/TotaisFiscais.cs @@ -0,0 +1,39 @@ +namespace eContabil.Domain.ValueObjects; + +/// +/// Totais de impostos e valores de um documento fiscal, como a SEFAZ os declara em ICMSTot. +/// +/// +/// São valores de cabeçalho: a nota inteira tem um total de ICMS, um de PIS, um de COFINS. É o que +/// permite filtrar por faixa sem descer aos itens, e o que o contador confere contra a apuração. +/// +/// Nenhuma validação de coerência entre os campos: o dado vem de uma nota já autorizada pela SEFAZ, e +/// recusar aqui faria o eContabil descartar um documento oficial por divergir da nossa aritmética. +/// Ausência de valor é sempre zero — a SEFAZ omite a tag quando o imposto não se aplica. +/// +public sealed record TotaisFiscais( + decimal ValorProdutos, + decimal BaseCalculoIcms, + decimal ValorIcms, + decimal BaseCalculoIcmsSt, + decimal ValorIcmsSt, + decimal ValorIpi, + decimal ValorPis, + decimal ValorCofins, + decimal ValorFrete, + decimal ValorDesconto) +{ + /// + /// Totais zerados, para o documento cujo XML ainda não foi lido. + /// + /// + /// Uma instância nova a cada chamada, e não uma compartilhada. Como tipo mapeado por composição, o + /// EF Core identifica cada totalização pelo documento que a possui: uma única instância servindo a + /// dois documentos aparece para ele como a mesma entidade mudando de dono, e a gravação do segundo + /// falha dizendo que a chave não pode ser alterada. + /// + public static TotaisFiscais Vazios => new(0m, 0m, 0m, 0m, 0m, 0m, 0m, 0m, 0m, 0m); + + /// Documento com substituição tributária, o recorte que o contador mais separa. + public bool TemSubstituicaoTributaria => ValorIcmsSt > 0m; +} diff --git a/src/eContabil.Infrastructure/Agendamento/ReprocessarAcervoJob.cs b/src/eContabil.Infrastructure/Agendamento/ReprocessarAcervoJob.cs new file mode 100644 index 0000000..14ffbf0 --- /dev/null +++ b/src/eContabil.Infrastructure/Agendamento/ReprocessarAcervoJob.cs @@ -0,0 +1,59 @@ +using eContabil.Application.Fiscal; +using Hangfire; +using MediatR; +using Microsoft.Extensions.Logging; + +namespace eContabil.Infrastructure.Agendamento; + +/// +/// Relê os XMLs arquivados para extrair deles os dados fiscais. +/// +/// +/// Não fala com a SEFAZ: lê apenas o object storage e o banco. É a diferença que permite rodá-lo com o +/// CNPJ bloqueado por consumo indevido, e é por isso que ele não passa perto das guardas da captura. +/// +/// Reenfileira a si mesmo enquanto restar fila, em vez de varrer tudo numa execução só. Um acervo de +/// cinco anos tem centenas de milhares de documentos, e segurar um worker por horas deixaria a captura +/// e a manifestação esperando atrás dele. +/// +public sealed partial class ReprocessarAcervoJob(ISender mediator, ILogger log) +{ + /// Documentos por rodada. + /// + /// Cada um custa uma leitura e uma escrita no object storage. Lotes maiores alongam a transação sem + /// acelerar nada, porque o gargalo é a rede contra o MinIO, não o banco. + /// + private const int PorRodada = 200; + + [Queue(Filas.Padrao)] + [DisableConcurrentExecution(timeoutInSeconds: 900)] + [AutomaticRetry(Attempts = 2, DelaysInSeconds = [120, 600])] + public async Task ExecutarAsync(CancellationToken ct) + { + var resultado = await mediator.Send(new ReprocessarAcervoCommand(PorRodada), ct); + + if (resultado.Falha) + { + // Lançar é proposital: é assim que o Hangfire registra a falha e agenda a nova tentativa. + throw new InvalidOperationException( + $"Reprocessamento do acervo falhou: {resultado.Erro!.Mensagem}"); + } + + var resumo = resultado.Valor; + + RodadaConcluida( + log, resumo.Visitados, resumo.Extraidos, resumo.Recompactados, resumo.SemConteudo); + + if (resumo.RestamPendentes) + { + BackgroundJob.Enqueue(job => job.ExecutarAsync(default)); + } + } + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Reprocessamento do acervo visitou {Visitados}: {Extraidos} com dados fiscais, " + + "{Recompactados} regravado(s) em XML legível, {SemConteudo} sem arquivo")] + private static partial void RodadaConcluida( + ILogger log, int visitados, int extraidos, int recompactados, int semConteudo); +} diff --git a/src/eContabil.Infrastructure/Compactacao/ConteudoGzip.cs b/src/eContabil.Infrastructure/Compactacao/ConteudoGzip.cs new file mode 100644 index 0000000..bb35d6f --- /dev/null +++ b/src/eContabil.Infrastructure/Compactacao/ConteudoGzip.cs @@ -0,0 +1,64 @@ +using System.IO.Compression; + +namespace eContabil.Infrastructure.Compactacao; + +/// +/// Normaliza conteúdo que pode chegar compactado em gzip. +/// +/// +/// A Distribuição DF-e entrega cada documento no elemento docZip: o XML comprimido em gzip e +/// codificado em base64. A biblioteca fiscal decodifica o base64 para montar seus objetos, mas devolve +/// os bytes ainda comprimidos — arquivar esses bytes direto produzia arquivos `.xml` que nenhum leitor +/// abre, porque são gzip com o nome errado. +/// +public static class ConteudoGzip +{ + // Assinatura do formato gzip (RFC 1952): 0x1f 0x8b, seguidos do método de compressão 0x08 (deflate). + private const byte Marca1 = 0x1F; + private const byte Marca2 = 0x8B; + private const byte MetodoDeflate = 0x08; + + /// Diz se o conteúdo começa com a assinatura de um fluxo gzip. + public static bool EstaCompactado(ReadOnlySpan conteudo) => + conteudo.Length >= 3 + && conteudo[0] == Marca1 + && conteudo[1] == Marca2 + && conteudo[2] == MetodoDeflate; + + /// + /// Devolve o conteúdo descompactado, ou o próprio conteúdo quando ele já está em claro. + /// + /// + /// Idempotente de propósito: o mesmo caminho serve para o que chega da SEFAZ agora e para o acervo + /// gravado por versões anteriores, sem que o chamador precise saber qual é qual. Conteúdo que se diz + /// gzip mas não descompacta volta como veio: perder o arquivo por causa de um cabeçalho coincidente + /// seria pior que gravar um byte a mais. + /// + public static byte[] Descompactar(byte[] conteudo) + { + ArgumentNullException.ThrowIfNull(conteudo); + + if (!EstaCompactado(conteudo)) + { + return conteudo; + } + + try + { + using var origem = new MemoryStream(conteudo); + using var gzip = new GZipStream(origem, CompressionMode.Decompress); + using var destino = new MemoryStream(); + + gzip.CopyTo(destino); + + // Fluxo truncado não lança: o `GZipStream` lê o cabeçalho, não encontra bloco nenhum e + // devolve zero bytes em silêncio. Aceitar isso apagaria o XML — o reprocessamento regravaria + // o objeto vazio e o documento perderia o conteúdo sem nenhum erro no caminho. + return destino.Length > 0 ? destino.ToArray() : conteudo; + } + catch (InvalidDataException) + { + return conteudo; + } + } +} diff --git a/src/eContabil.Infrastructure/Fiscal/ExtratorDeDadosFiscais.cs b/src/eContabil.Infrastructure/Fiscal/ExtratorDeDadosFiscais.cs new file mode 100644 index 0000000..3484156 --- /dev/null +++ b/src/eContabil.Infrastructure/Fiscal/ExtratorDeDadosFiscais.cs @@ -0,0 +1,141 @@ +using System.Globalization; +using System.Xml.Linq; +using eContabil.Application.Fiscal; +using eContabil.Domain.ValueObjects; +using eContabil.Infrastructure.Compactacao; + +namespace eContabil.Infrastructure.Fiscal; + +/// +/// Extrai os dados fiscais lendo o XML diretamente, sem passar pelo modelo de objetos da biblioteca. +/// +/// +/// A leitura é por de propósito. O mesmo extrator serve a dois caminhos que +/// chegam por vias diferentes — a captura, que recebe o lote da SEFAZ, e o reprocessamento, que lê o +/// arquivo do object storage. Tivessem eles extratores distintos, um corrigido e outro não, a nota +/// capturada hoje e a mesma nota reprocessada amanhã divergiriam no banco. +/// +/// Nada aqui valida o documento: ele já foi autorizado pela SEFAZ. Campo ausente vira vazio ou zero, e +/// o pior caso é uma nota que aparece sem CFOP — nunca uma nota que some da grade. +/// +public sealed class ExtratorDeDadosFiscais : IExtratorDeDadosFiscais +{ + private static readonly XNamespace _nfe = "http://www.portalfiscal.inf.br/nfe"; + + public DadosFiscaisDto Extrair(byte[] xml) + { + if (xml is null || xml.Length == 0) + { + return DadosFiscaisDto.Vazio; + } + + XDocument documento; + + try + { + using var fluxo = new MemoryStream(ConteudoGzip.Descompactar(xml)); + + documento = XDocument.Load(fluxo); + } + catch (Exception excecao) when (excecao is System.Xml.XmlException or InvalidDataException) + { + return DadosFiscaisDto.Vazio; + } + + // O arquivo pode ser um `nfeProc` (nota mais protocolo) ou uma `NFe` solta. `Descendants` acha + // o `infNFe` nos dois casos, sem o extrator precisar saber qual chegou. + var informacoes = documento.Descendants(_nfe + "infNFe").FirstOrDefault(); + if (informacoes is null) + { + return DadosFiscaisDto.Vazio; + } + + return new DadosFiscaisDto( + Texto(informacoes.Element(_nfe + "ide")?.Element(_nfe + "natOp")), + TotaisDe(informacoes.Element(_nfe + "total")?.Element(_nfe + "ICMSTot")), + [.. informacoes.Elements(_nfe + "det").Select(ItemDe).OfType()]); + } + + private static TotaisFiscais TotaisDe(XElement? total) + { + if (total is null) + { + return TotaisFiscais.Vazios; + } + + return new TotaisFiscais( + Decimal(total.Element(_nfe + "vProd")), + Decimal(total.Element(_nfe + "vBC")), + Decimal(total.Element(_nfe + "vICMS")), + Decimal(total.Element(_nfe + "vBCST")), + + // `vST` é o total do ICMS retido por substituição; o `vBCST` acima é a base dele. + Decimal(total.Element(_nfe + "vST")), + Decimal(total.Element(_nfe + "vIPI")), + Decimal(total.Element(_nfe + "vPIS")), + Decimal(total.Element(_nfe + "vCOFINS")), + Decimal(total.Element(_nfe + "vFrete")), + Decimal(total.Element(_nfe + "vDesc"))); + } + + private static ItemFiscalDto? ItemDe(XElement det) + { + var numero = Inteiro(det.Attribute("nItem")?.Value); + if (numero <= 0) + { + return null; + } + + var produto = det.Element(_nfe + "prod"); + var imposto = det.Element(_nfe + "imposto"); + var icms = GrupoDeIcms(imposto); + + return new ItemFiscalDto( + numero, + Texto(produto?.Element(_nfe + "cProd")), + Texto(produto?.Element(_nfe + "xProd")), + Texto(produto?.Element(_nfe + "CFOP")), + Texto(produto?.Element(_nfe + "NCM")), + Texto(produto?.Element(_nfe + "CEST")), + Texto(produto?.Element(_nfe + "uCom")), + Decimal(produto?.Element(_nfe + "qCom")), + Decimal(produto?.Element(_nfe + "vUnCom")), + Decimal(produto?.Element(_nfe + "vProd")), + Inteiro(Texto(icms?.Element(_nfe + "orig"))), + + // CST no regime normal, CSOSN no Simples Nacional: ocupam o mesmo lugar e nunca coexistem. + Texto(icms?.Element(_nfe + "CST") ?? icms?.Element(_nfe + "CSOSN")), + Decimal(icms?.Element(_nfe + "vBC")), + Decimal(icms?.Element(_nfe + "pICMS")), + Decimal(icms?.Element(_nfe + "vICMS")), + Decimal(icms?.Element(_nfe + "vICMSST")), + Decimal(imposto?.Element(_nfe + "IPI")?.Descendants(_nfe + "vIPI").FirstOrDefault()), + Decimal(imposto?.Element(_nfe + "PIS")?.Descendants(_nfe + "vPIS").FirstOrDefault()), + Decimal(imposto?.Element(_nfe + "COFINS")?.Descendants(_nfe + "vCOFINS").FirstOrDefault())); + } + + /// + /// O grupo do ICMS é polimórfico: dentro de ICMS vem um único filho que diz a tributação — + /// ICMS00, ICMS40, ICMSSN102 e mais uma dúzia de variantes. Cada uma expõe um + /// subconjunto diferente de campos. Pegar o primeiro filho e ler dele o que existir cobre todas sem + /// enumerar nenhuma, e uma variante nova do layout entra sozinha. + /// + private static XElement? GrupoDeIcms(XElement? imposto) => + imposto?.Element(_nfe + "ICMS")?.Elements().FirstOrDefault(); + + private static string Texto(XElement? elemento) => elemento?.Value.Trim() ?? string.Empty; + + /// + /// O layout da NF-e fixa ponto como separador decimal, independentemente da cultura de quem lê. + /// + private static decimal Decimal(XElement? elemento) => + decimal.TryParse( + elemento?.Value, NumberStyles.Number, CultureInfo.InvariantCulture, out var valor) + ? valor + : 0m; + + private static int Inteiro(string? valor) => + int.TryParse(valor, NumberStyles.Integer, CultureInfo.InvariantCulture, out var numero) + ? numero + : 0; +} diff --git a/src/eContabil.Infrastructure/InfraestruturaServiceCollectionExtensions.cs b/src/eContabil.Infrastructure/InfraestruturaServiceCollectionExtensions.cs index 46d26ed..0782b49 100644 --- a/src/eContabil.Infrastructure/InfraestruturaServiceCollectionExtensions.cs +++ b/src/eContabil.Infrastructure/InfraestruturaServiceCollectionExtensions.cs @@ -4,6 +4,7 @@ using eContabil.Application.Certificados; using eContabil.Application.Documentos.Consultas; using eContabil.Application.Empresas.Consultas; +using eContabil.Application.Fiscal; using eContabil.Application.Sefaz; using eContabil.Application.Sincronizacao; using eContabil.Application.Usuarios.Consultas; @@ -16,6 +17,7 @@ using eContabil.Infrastructure.Agendamento; using eContabil.Infrastructure.Certificados; using eContabil.Infrastructure.Diagnostico; +using eContabil.Infrastructure.Fiscal; using eContabil.Infrastructure.Persistencia; using eContabil.Infrastructure.Persistencia.Consultas; using eContabil.Infrastructure.Persistencia.Repositorios; @@ -112,6 +114,7 @@ public static IServiceCollection AddArmazenamentoESeguranca( services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddScoped(); services.AddScoped(); diff --git a/src/eContabil.Infrastructure/Persistencia/Configuracoes/DocumentoFiscalConfiguration.cs b/src/eContabil.Infrastructure/Persistencia/Configuracoes/DocumentoFiscalConfiguration.cs index 873605c..ed27fa3 100644 --- a/src/eContabil.Infrastructure/Persistencia/Configuracoes/DocumentoFiscalConfiguration.cs +++ b/src/eContabil.Infrastructure/Persistencia/Configuracoes/DocumentoFiscalConfiguration.cs @@ -79,6 +79,50 @@ public void Configure(EntityTypeBuilder builder) builder.Property(documento => documento.HashXml).HasMaxLength(64); builder.Property(documento => documento.PossuiXml).IsRequired(); + builder.Property(documento => documento.NaturezaOperacao).HasMaxLength(60); + builder.Property(documento => documento.DadosFiscaisExtraidos).IsRequired(); + + // Totais viram colunas da própria tabela, como emitente e destinatário: são um por nota, lidos + // junto dela, e uma tabela à parte custaria um join para trazer dez números. + builder.OwnsOne(documento => documento.Totais, totais => + { + // `numeric(15,2)` é o que o layout da NF-e comporta; `decimal` sem precisão viraria + // `numeric` sem limite no PostgreSQL, e o arredondamento deixaria de ser previsível. + totais.Property(valor => valor.ValorProdutos) + .HasColumnName("total_produtos").HasPrecision(15, 2).IsRequired(); + totais.Property(valor => valor.BaseCalculoIcms) + .HasColumnName("total_base_icms").HasPrecision(15, 2).IsRequired(); + totais.Property(valor => valor.ValorIcms) + .HasColumnName("total_icms").HasPrecision(15, 2).IsRequired(); + totais.Property(valor => valor.BaseCalculoIcmsSt) + .HasColumnName("total_base_icms_st").HasPrecision(15, 2).IsRequired(); + totais.Property(valor => valor.ValorIcmsSt) + .HasColumnName("total_icms_st").HasPrecision(15, 2).IsRequired(); + totais.Property(valor => valor.ValorIpi) + .HasColumnName("total_ipi").HasPrecision(15, 2).IsRequired(); + totais.Property(valor => valor.ValorPis) + .HasColumnName("total_pis").HasPrecision(15, 2).IsRequired(); + totais.Property(valor => valor.ValorCofins) + .HasColumnName("total_cofins").HasPrecision(15, 2).IsRequired(); + totais.Property(valor => valor.ValorFrete) + .HasColumnName("total_frete").HasPrecision(15, 2).IsRequired(); + totais.Property(valor => valor.ValorDesconto) + .HasColumnName("total_desconto").HasPrecision(15, 2).IsRequired(); + }); + + builder.Navigation(documento => documento.Itens) + .HasField("_itens") + .UsePropertyAccessMode(PropertyAccessMode.Field); + + // Fila do reprocessamento: documento com XML arquivado cujos dados fiscais ainda não foram + // lidos. O índice parcial some sozinho conforme a varredura avança — no regime normal ele fica + // vazio, porque a captura já extrai tudo. + builder.HasIndex( + documento => documento.Id, + "ix_documentos_pendentes_de_extracao") + .HasFilter("possui_xml AND NOT dados_fiscais_extraidos") + .HasDatabaseName("ix_documentos_pendentes_de_extracao"); + // A identidade é o par empresa + chave, não a chave sozinha: a mesma NF-e chega para duas // empresas do escritório sob NSUs diferentes. É também o que torna o reprocessamento de um lote // idempotente — a segunda gravação bate no índice e não duplica. diff --git a/src/eContabil.Infrastructure/Persistencia/Configuracoes/ItemDocumentoFiscalConfiguration.cs b/src/eContabil.Infrastructure/Persistencia/Configuracoes/ItemDocumentoFiscalConfiguration.cs new file mode 100644 index 0000000..19f5e54 --- /dev/null +++ b/src/eContabil.Infrastructure/Persistencia/Configuracoes/ItemDocumentoFiscalConfiguration.cs @@ -0,0 +1,62 @@ +using eContabil.Domain.Documentos; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace eContabil.Infrastructure.Persistencia.Configuracoes; + +public sealed class ItemDocumentoFiscalConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("itens_documento_fiscal"); + + builder.HasKey(item => item.Id); + builder.Property(item => item.Id).ValueGeneratedNever(); + + builder.Property(item => item.DocumentoFiscalId).IsRequired(); + builder.Property(item => item.Numero).IsRequired(); + + builder.Property(item => item.Codigo).HasMaxLength(60).IsRequired(); + builder.Property(item => item.Descricao).HasMaxLength(120).IsRequired(); + builder.Property(item => item.Cfop).HasMaxLength(4).IsRequired(); + builder.Property(item => item.Ncm).HasMaxLength(8).IsRequired(); + builder.Property(item => item.Cest).HasMaxLength(7).IsRequired(); + builder.Property(item => item.Unidade).HasMaxLength(6).IsRequired(); + builder.Property(item => item.CstIcms).HasMaxLength(4).IsRequired(); + + builder.Property(item => item.OrigemMercadoria).IsRequired(); + + // Quantidade tem quatro casas no layout da NF-e — granel e itens fracionados dependem delas. + builder.Property(item => item.Quantidade).HasPrecision(15, 4).IsRequired(); + builder.Property(item => item.ValorUnitario).HasPrecision(21, 10).IsRequired(); + + builder.Property(item => item.ValorTotal).HasPrecision(15, 2).IsRequired(); + builder.Property(item => item.BaseCalculoIcms).HasPrecision(15, 2).IsRequired(); + builder.Property(item => item.AliquotaIcms).HasPrecision(7, 4).IsRequired(); + builder.Property(item => item.ValorIcms).HasPrecision(15, 2).IsRequired(); + builder.Property(item => item.ValorIcmsSt).HasPrecision(15, 2).IsRequired(); + builder.Property(item => item.ValorIpi).HasPrecision(15, 2).IsRequired(); + builder.Property(item => item.ValorPis).HasPrecision(15, 2).IsRequired(); + builder.Property(item => item.ValorCofins).HasPrecision(15, 2).IsRequired(); + + // O item não existe sem a nota. `Cascade` é o que permite ao reprocessamento substituir a lista + // inteira numa segunda passada sem varrer filho por filho. + builder.HasOne() + .WithMany(documento => documento.Itens) + .HasForeignKey(item => item.DocumentoFiscalId) + .OnDelete(DeleteBehavior.Cascade); + + // A tela de detalhe pede "os itens desta nota, na ordem da nota" — é a leitura mais frequente. + builder.HasIndex(item => new { item.DocumentoFiscalId, item.Numero }) + .IsUnique() + .HasDatabaseName("ix_itens_documento_numero"); + + // Os filtros do contador são por CFOP e por NCM, e a consulta resultante pergunta quais notas + // contêm algum item com o código. Sem estes índices, cada filtro varreria a tabela inteira — + // que cresce por item, não por nota, e é a maior do banco. + builder.HasIndex(item => item.Cfop).HasDatabaseName("ix_itens_cfop"); + builder.HasIndex(item => item.Ncm).HasDatabaseName("ix_itens_ncm"); + } +} diff --git a/src/eContabil.Infrastructure/Persistencia/Consultas/DocumentoConsultas.cs b/src/eContabil.Infrastructure/Persistencia/Consultas/DocumentoConsultas.cs index 5011ad9..ca0b364 100644 --- a/src/eContabil.Infrastructure/Persistencia/Consultas/DocumentoConsultas.cs +++ b/src/eContabil.Infrastructure/Persistencia/Consultas/DocumentoConsultas.cs @@ -167,7 +167,45 @@ await contexto.DocumentosFiscais documento.ProtocoloManifestacao, documento.ManifestadoEm, documento.PossuiXml, - documento.CriadoEm)) + documento.CriadoEm, + documento.NaturezaOperacao, + documento.DadosFiscaisExtraidos, + documento.Totais.ValorProdutos, + documento.Totais.BaseCalculoIcms, + documento.Totais.ValorIcms, + documento.Totais.BaseCalculoIcmsSt, + documento.Totais.ValorIcmsSt, + documento.Totais.ValorIpi, + documento.Totais.ValorPis, + documento.Totais.ValorCofins, + documento.Totais.ValorFrete, + documento.Totais.ValorDesconto, + + // Os itens saem na mesma ida ao banco, projetados junto: são poucas dezenas por nota, e + // buscá-los numa segunda consulta só somaria latência à tela que já os exige. + documento.Itens + .OrderBy(item => item.Numero) + .Select(item => new ItemDocumentoDto( + item.Numero, + item.Codigo, + item.Descricao, + item.Cfop, + item.Ncm, + item.Cest, + item.Unidade, + item.Quantidade, + item.ValorUnitario, + item.ValorTotal, + item.OrigemMercadoria, + item.CstIcms, + item.BaseCalculoIcms, + item.AliquotaIcms, + item.ValorIcms, + item.ValorIcmsSt, + item.ValorIpi, + item.ValorPis, + item.ValorCofins)) + .ToList())) .FirstOrDefaultAsync(ct); /// @@ -189,11 +227,28 @@ public async Task ResumirAsync(FiltroDocumentos filtro, Can grupo.Count(documento => documento.PossuiXml), grupo.Count(documento => !documento.PossuiXml), grupo.Count(documento => documento.Papel == PapelEmpresa.Destinatario), - grupo.Count(documento => documento.Papel == PapelEmpresa.Emitente))) + grupo.Count(documento => documento.Papel == PapelEmpresa.Emitente), + ForaDoFiltroPorFaltarXml: 0)) .FirstOrDefaultAsync(ct); // Filtro sem resultado não tem grupo: o agrupamento some, e zero é a resposta correta. - return resumo ?? new ResumoDocumentosDto(0, 0m, 0, 0, 0, 0); + resumo ??= new ResumoDocumentosDto(0, 0m, 0, 0, 0, 0, 0); + + if (!filtro.ExigeDadosFiscais) + { + return resumo; + } + + // Segunda consulta só quando há filtro fiscal ativo: conta o que os demais critérios alcançam mas + // que nenhum filtro de item consegue avaliar, por não haver XML lido. Somar isso ao agrupamento + // acima seria mais barato, mas daria o número errado — lá a consulta já veio estreitada pelo + // próprio filtro de item, e o que se quer contar é justamente o que ele descartou. + var semDadosFiscais = await Filtrar( + contexto.DocumentosFiscais.AsNoTracking().IgnoreQueryFilters(), + filtro with { Cfop = null, Ncm = null, CstIcms = null, ComSubstituicaoTributaria = null }) + .CountAsync(documento => !documento.DadosFiscaisExtraidos, ct); + + return resumo with { ForaDoFiltroPorFaltarXml = semDadosFiscais }; } /// @@ -316,6 +371,59 @@ private static IQueryable Filtrar( consulta = consulta.Where(documento => documento.ValorTotal <= maximo); } + if (filtro.ComSubstituicaoTributaria is { } comSt) + { + // O total de ST fica no cabeçalho da nota: não precisa descer aos itens para responder. + consulta = comSt + ? consulta.Where(documento => documento.Totais.ValorIcmsSt > 0m) + : consulta.Where(documento => documento.Totais.ValorIcmsSt == 0m); + } + + return FiltrarPorItens(consulta, filtro); + } + + /// + /// Aplica os critérios que vivem nas linhas de produto. + /// + /// + /// Cada um vira um EXISTS próprio, e não um único EXISTS com as três condições juntas. + /// A diferença é de significado: filtrar CFOP 5102 e NCM 30049099 numa condição só pediria uma linha + /// que satisfaça as duas ao mesmo tempo, enquanto o contador está perguntando por notas que tenham + /// aquele CFOP **e** aquela mercadoria — não necessariamente na mesma linha. + /// + /// A comparação é por prefixo. É o que faz "5" trazer toda saída e "3004" toda a família do NCM, que + /// é como a classificação fiscal é organizada e como o contador de fato busca. + /// + private static IQueryable FiltrarPorItens( + IQueryable consulta, FiltroDocumentos filtro) + { + // `Like` com curinga só à direita vira busca por prefixo, que o índice B-tree atende. O valor já + // passou por `Digitos`, então não sobra `%` nem `_` para escapar. + if (Digitos(filtro.Cfop) is { Length: > 0 } cfop) + { + consulta = consulta.Where(documento => + documento.Itens.Any(item => EF.Functions.Like(item.Cfop, $"{cfop}%"))); + } + + if (Digitos(filtro.Ncm) is { Length: > 0 } ncm) + { + consulta = consulta.Where(documento => + documento.Itens.Any(item => EF.Functions.Like(item.Ncm, $"{ncm}%"))); + } + + if (Digitos(filtro.CstIcms) is { Length: > 0 } cst) + { + consulta = consulta.Where(documento => + documento.Itens.Any(item => item.CstIcms == cst)); + } + return consulta; } + + /// + /// O contador digita "5.102" ou "5102" conforme o emissor formatou na nota que ele tem à frente. Os + /// códigos são guardados só com dígitos, e a busca precisa fazer o mesmo com o que foi digitado. + /// + private static string Digitos(string? valor) => + new((valor ?? string.Empty).Where(char.IsDigit).ToArray()); } diff --git a/src/eContabil.Infrastructure/Persistencia/EContabilDbContext.cs b/src/eContabil.Infrastructure/Persistencia/EContabilDbContext.cs index 797368c..ee0a557 100644 --- a/src/eContabil.Infrastructure/Persistencia/EContabilDbContext.cs +++ b/src/eContabil.Infrastructure/Persistencia/EContabilDbContext.cs @@ -22,6 +22,8 @@ public sealed class EContabilDbContext(DbContextOptions opti public DbSet DocumentosFiscais => Set(); + public DbSet ItensDocumentoFiscal => Set(); + public DbSet Usuarios => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) diff --git a/src/eContabil.Infrastructure/Persistencia/Migrations/20260728234813_AdicionarItensETotaisFiscais.Designer.cs b/src/eContabil.Infrastructure/Persistencia/Migrations/20260728234813_AdicionarItensETotaisFiscais.Designer.cs new file mode 100644 index 0000000..994da79 --- /dev/null +++ b/src/eContabil.Infrastructure/Persistencia/Migrations/20260728234813_AdicionarItensETotaisFiscais.Designer.cs @@ -0,0 +1,824 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using eContabil.Infrastructure.Persistencia; + +#nullable disable + +namespace eContabil.Infrastructure.Persistencia.Migrations +{ + [DbContext(typeof(EContabilDbContext))] + [Migration("20260728234813_AdicionarItensETotaisFiscais")] + partial class AdicionarItensETotaisFiscais + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("eContabil.Domain.Certificados.CertificadoDigital", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Ativo") + .HasColumnType("boolean") + .HasColumnName("ativo"); + + b.Property("Bucket") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)") + .HasColumnName("bucket"); + + b.Property("CnpjTitular") + .IsRequired() + .HasMaxLength(14) + .HasColumnType("character varying(14)") + .HasColumnName("cnpj_titular"); + + b.Property("CriadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("criado_em"); + + b.Property("DataEmissao") + .HasColumnType("timestamp with time zone") + .HasColumnName("data_emissao"); + + b.Property("DataExpiracao") + .HasColumnType("timestamp with time zone") + .HasColumnName("data_expiracao"); + + b.Property("EmpresaId") + .HasColumnType("uuid") + .HasColumnName("empresa_id"); + + b.Property("HashArquivo") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("hash_arquivo"); + + b.Property("NomeArquivo") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("nome_arquivo"); + + b.Property("ObjectName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("object_name"); + + b.Property("SenhaCriptografada") + .IsRequired() + .HasColumnType("text") + .HasColumnName("senha_criptografada"); + + b.Property("Thumbprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("thumbprint"); + + b.Property("UfTitular") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("character varying(2)") + .HasColumnName("uf_titular"); + + b.Property("VersaoChave") + .HasColumnType("integer") + .HasColumnName("versao_chave"); + + b.HasKey("Id") + .HasName("pk_certificados_digitais"); + + b.HasIndex("DataExpiracao") + .HasDatabaseName("ix_certificados_expiracao") + .HasFilter("ativo"); + + b.HasIndex("EmpresaId") + .IsUnique() + .HasDatabaseName("ix_certificados_empresa_ativo") + .HasFilter("ativo"); + + b.ToTable("certificados_digitais", (string)null); + }); + + modelBuilder.Entity("eContabil.Domain.Documentos.DocumentoFiscal", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bucket") + .HasMaxLength(63) + .HasColumnType("character varying(63)") + .HasColumnName("bucket"); + + b.Property("Chave") + .IsRequired() + .HasMaxLength(44) + .HasColumnType("character varying(44)") + .HasColumnName("chave"); + + b.Property("CriadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("criado_em"); + + b.Property("DadosFiscaisExtraidos") + .HasColumnType("boolean") + .HasColumnName("dados_fiscais_extraidos"); + + b.Property("DataAutorizacao") + .HasColumnType("timestamp with time zone") + .HasColumnName("data_autorizacao"); + + b.Property("DataEmissao") + .HasColumnType("timestamp with time zone") + .HasColumnName("data_emissao"); + + b.Property("EmpresaId") + .HasColumnType("uuid") + .HasColumnName("empresa_id"); + + b.Property("HashXml") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("hash_xml"); + + b.Property("ManifestacaoStatus") + .HasColumnType("integer") + .HasColumnName("manifestacao_status"); + + b.Property("ManifestadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("manifestado_em"); + + b.Property("Modelo") + .HasColumnType("integer") + .HasColumnName("modelo"); + + b.Property("MotivoFalhaManifestacao") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("motivo_falha_manifestacao"); + + b.Property("NaturezaOperacao") + .HasMaxLength(60) + .HasColumnType("character varying(60)") + .HasColumnName("natureza_operacao"); + + b.Property("Nsu") + .HasColumnType("bigint") + .HasColumnName("nsu"); + + b.Property("Numero") + .HasColumnType("integer") + .HasColumnName("numero"); + + b.Property("ObjectName") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("object_name"); + + b.Property("OrigemConteudo") + .HasColumnType("integer") + .HasColumnName("origem_conteudo"); + + b.Property("Papel") + .HasColumnType("integer") + .HasColumnName("papel"); + + b.Property("PossuiXml") + .HasColumnType("boolean") + .HasColumnName("possui_xml"); + + b.Property("ProtocoloManifestacao") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("protocolo_manifestacao"); + + b.Property("Serie") + .HasColumnType("integer") + .HasColumnName("serie"); + + b.Property("Situacao") + .HasColumnType("integer") + .HasColumnName("situacao"); + + b.Property("TipoDocumento") + .HasColumnType("integer") + .HasColumnName("tipo_documento"); + + b.Property("ValorTotal") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasColumnName("valor_total"); + + b.HasKey("Id") + .HasName("pk_documentos_fiscais"); + + b.HasIndex("Chave") + .HasDatabaseName("ix_documentos_chave"); + + b.HasIndex("EmpresaId", "Chave") + .IsUnique() + .HasDatabaseName("ix_documentos_empresa_chave"); + + b.HasIndex("EmpresaId", "DataEmissao") + .IsDescending(false, true) + .HasDatabaseName("ix_documentos_empresa_emissao"); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("EmpresaId", "DataEmissao"), new[] { "Numero", "Serie", "ValorTotal", "Situacao", "PossuiXml" }); + + b.HasIndex("EmpresaId", "Nsu") + .HasDatabaseName("ix_documentos_empresa_nsu"); + + b.HasIndex(new[] { "EmpresaId", "DataEmissao" }, "ix_documentos_manifestacao_pendente") + .HasDatabaseName("ix_documentos_manifestacao_pendente") + .HasFilter("manifestacao_status = 1"); + + b.HasIndex(new[] { "Id" }, "ix_documentos_pendentes_de_extracao") + .HasDatabaseName("ix_documentos_pendentes_de_extracao") + .HasFilter("possui_xml AND NOT dados_fiscais_extraidos"); + + b.ToTable("documentos_fiscais", (string)null); + }); + + modelBuilder.Entity("eContabil.Domain.Documentos.ItemDocumentoFiscal", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AliquotaIcms") + .HasPrecision(7, 4) + .HasColumnType("numeric(7,4)") + .HasColumnName("aliquota_icms"); + + b.Property("BaseCalculoIcms") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("base_calculo_icms"); + + b.Property("Cest") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character varying(7)") + .HasColumnName("cest"); + + b.Property("Cfop") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("character varying(4)") + .HasColumnName("cfop"); + + b.Property("Codigo") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)") + .HasColumnName("codigo"); + + b.Property("CriadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("criado_em"); + + b.Property("CstIcms") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("character varying(4)") + .HasColumnName("cst_icms"); + + b.Property("Descricao") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("descricao"); + + b.Property("DocumentoFiscalId") + .HasColumnType("uuid") + .HasColumnName("documento_fiscal_id"); + + b.Property("Ncm") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("ncm"); + + b.Property("Numero") + .HasColumnType("integer") + .HasColumnName("numero"); + + b.Property("OrigemMercadoria") + .HasColumnType("integer") + .HasColumnName("origem_mercadoria"); + + b.Property("Quantidade") + .HasPrecision(15, 4) + .HasColumnType("numeric(15,4)") + .HasColumnName("quantidade"); + + b.Property("Unidade") + .IsRequired() + .HasMaxLength(6) + .HasColumnType("character varying(6)") + .HasColumnName("unidade"); + + b.Property("ValorCofins") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("valor_cofins"); + + b.Property("ValorIcms") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("valor_icms"); + + b.Property("ValorIcmsSt") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("valor_icms_st"); + + b.Property("ValorIpi") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("valor_ipi"); + + b.Property("ValorPis") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("valor_pis"); + + b.Property("ValorTotal") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("valor_total"); + + b.Property("ValorUnitario") + .HasPrecision(21, 10) + .HasColumnType("numeric(21,10)") + .HasColumnName("valor_unitario"); + + b.HasKey("Id") + .HasName("pk_itens_documento_fiscal"); + + b.HasIndex("Cfop") + .HasDatabaseName("ix_itens_cfop"); + + b.HasIndex("Ncm") + .HasDatabaseName("ix_itens_ncm"); + + b.HasIndex("DocumentoFiscalId", "Numero") + .IsUnique() + .HasDatabaseName("ix_itens_documento_numero"); + + b.ToTable("itens_documento_fiscal", (string)null); + }); + + modelBuilder.Entity("eContabil.Domain.Empresas.Empresa", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Ambiente") + .HasColumnType("integer") + .HasColumnName("ambiente"); + + b.Property("Ativa") + .HasColumnType("boolean") + .HasColumnName("ativa"); + + b.Property("BloqueadaAte") + .HasColumnType("timestamp with time zone") + .HasColumnName("bloqueada_ate"); + + b.Property("BloqueiosConsecutivos") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("bloqueios_consecutivos"); + + b.Property("Cnpj") + .IsRequired() + .HasMaxLength(14) + .HasColumnType("character varying(14)") + .HasColumnName("cnpj"); + + b.Property("CriadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("criado_em"); + + b.Property("InscricaoEstadual") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("inscricao_estadual"); + + b.Property("MaxNSU") + .HasColumnType("bigint") + .HasColumnName("max_nsu"); + + b.Property("NomeFantasia") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("nome_fantasia"); + + b.Property("ProximaConsultaEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("proxima_consulta_em"); + + b.Property("RazaoSocial") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("razao_social"); + + b.Property("Uf") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("character varying(2)") + .HasColumnName("uf"); + + b.Property("UltimaSincronizacaoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("ultima_sincronizacao_em"); + + b.Property("UltimoNSU") + .HasColumnType("bigint") + .HasColumnName("ultimo_nsu"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_empresas"); + + b.HasIndex("Cnpj") + .IsUnique() + .HasDatabaseName("ix_empresas_cnpj") + .HasFilter("ativa"); + + b.HasIndex("ProximaConsultaEm") + .HasDatabaseName("ix_empresas_proxima_consulta") + .HasFilter("ativa"); + + b.ToTable("empresas", (string)null); + }); + + modelBuilder.Entity("eContabil.Domain.Sincronizacoes.ExecucaoSincronizacao", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CriadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("criado_em"); + + b.Property("DocumentosNovos") + .HasColumnType("integer") + .HasColumnName("documentos_novos"); + + b.Property("DocumentosPromovidos") + .HasColumnType("integer") + .HasColumnName("documentos_promovidos"); + + b.Property("EmpresaId") + .HasColumnType("uuid") + .HasColumnName("empresa_id"); + + b.Property("FinalizadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("finalizado_em"); + + b.Property("IniciadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("iniciado_em"); + + b.Property("ItensDescartados") + .HasColumnType("integer") + .HasColumnName("itens_descartados"); + + b.Property("Mensagem") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("mensagem"); + + b.Property("Modo") + .HasColumnType("integer") + .HasColumnName("modo"); + + b.Property("NsuFinal") + .HasColumnType("bigint") + .HasColumnName("nsu_final"); + + b.Property("NsuInicial") + .HasColumnType("bigint") + .HasColumnName("nsu_inicial"); + + b.Property("Resultado") + .HasColumnType("integer") + .HasColumnName("resultado"); + + b.Property("XmlsArquivados") + .HasColumnType("integer") + .HasColumnName("xmls_arquivados"); + + b.HasKey("Id") + .HasName("pk_execucoes_sincronizacao"); + + b.HasIndex("FinalizadoEm") + .IsDescending() + .HasDatabaseName("ix_execucoes_fim"); + + b.HasIndex("EmpresaId", "FinalizadoEm") + .IsDescending(false, true) + .HasDatabaseName("ix_execucoes_empresa_fim"); + + b.ToTable("execucoes_sincronizacao", (string)null); + }); + + modelBuilder.Entity("eContabil.Domain.Usuarios.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CriadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("criado_em"); + + b.Property("CriadoPorIp") + .HasMaxLength(45) + .HasColumnType("character varying(45)") + .HasColumnName("criado_por_ip"); + + b.Property("ExpiraEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("expira_em"); + + b.Property("RevogadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("revogado_em"); + + b.Property("SubstituidoPorId") + .HasColumnType("uuid") + .HasColumnName("substituido_por_id"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash"); + + b.Property("UsuarioId") + .HasColumnType("uuid") + .HasColumnName("usuario_id"); + + b.HasKey("Id") + .HasName("pk_refresh_tokens"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ix_refresh_tokens_hash"); + + b.HasIndex("UsuarioId") + .HasDatabaseName("ix_refresh_tokens_usuario"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("eContabil.Domain.Usuarios.Usuario", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Ativo") + .HasColumnType("boolean") + .HasColumnName("ativo"); + + b.Property("CriadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("criado_em"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email"); + + b.Property("Nome") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("nome"); + + b.Property("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + b.Property("SenhaHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("senha_hash"); + + b.HasKey("Id") + .HasName("pk_usuarios"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_usuarios_email"); + + b.ToTable("usuarios", (string)null); + }); + + modelBuilder.Entity("eContabil.Domain.Documentos.DocumentoFiscal", b => + { + b.OwnsOne("eContabil.Domain.ValueObjects.ParticipanteFiscal", "Destinatario", b1 => + { + b1.Property("DocumentoFiscalId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Documento") + .IsRequired() + .HasMaxLength(14) + .HasColumnType("character varying(14)") + .HasColumnName("destinatario_documento"); + + b1.Property("Nome") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("destinatario_nome"); + + b1.HasKey("DocumentoFiscalId"); + + b1.ToTable("documentos_fiscais"); + + b1.WithOwner() + .HasForeignKey("DocumentoFiscalId") + .HasConstraintName("fk_documentos_fiscais_documentos_fiscais_id"); + }); + + b.OwnsOne("eContabil.Domain.ValueObjects.ParticipanteFiscal", "Emitente", b1 => + { + b1.Property("DocumentoFiscalId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Documento") + .IsRequired() + .HasMaxLength(14) + .HasColumnType("character varying(14)") + .HasColumnName("emitente_documento"); + + b1.Property("Nome") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("emitente_nome"); + + b1.HasKey("DocumentoFiscalId"); + + b1.HasIndex("Nome") + .HasDatabaseName("ix_documentos_emitente_nome"); + + NpgsqlIndexBuilderExtensions.HasMethod(b1.HasIndex("Nome"), "gin"); + NpgsqlIndexBuilderExtensions.HasOperators(b1.HasIndex("Nome"), new[] { "gin_trgm_ops" }); + + b1.ToTable("documentos_fiscais"); + + b1.WithOwner() + .HasForeignKey("DocumentoFiscalId") + .HasConstraintName("fk_documentos_fiscais_documentos_fiscais_id"); + }); + + b.OwnsOne("eContabil.Domain.ValueObjects.TotaisFiscais", "Totais", b1 => + { + b1.Property("DocumentoFiscalId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("BaseCalculoIcms") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("total_base_icms"); + + b1.Property("BaseCalculoIcmsSt") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("total_base_icms_st"); + + b1.Property("ValorCofins") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("total_cofins"); + + b1.Property("ValorDesconto") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("total_desconto"); + + b1.Property("ValorFrete") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("total_frete"); + + b1.Property("ValorIcms") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("total_icms"); + + b1.Property("ValorIcmsSt") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("total_icms_st"); + + b1.Property("ValorIpi") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("total_ipi"); + + b1.Property("ValorPis") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("total_pis"); + + b1.Property("ValorProdutos") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("total_produtos"); + + b1.HasKey("DocumentoFiscalId"); + + b1.ToTable("documentos_fiscais"); + + b1.WithOwner() + .HasForeignKey("DocumentoFiscalId") + .HasConstraintName("fk_documentos_fiscais_documentos_fiscais_id"); + }); + + b.Navigation("Destinatario") + .IsRequired(); + + b.Navigation("Emitente") + .IsRequired(); + + b.Navigation("Totais") + .IsRequired(); + }); + + modelBuilder.Entity("eContabil.Domain.Documentos.ItemDocumentoFiscal", b => + { + b.HasOne("eContabil.Domain.Documentos.DocumentoFiscal", null) + .WithMany("Itens") + .HasForeignKey("DocumentoFiscalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_itens_documento_fiscal_documentos_fiscais_documento_fiscal_"); + }); + + modelBuilder.Entity("eContabil.Domain.Usuarios.RefreshToken", b => + { + b.HasOne("eContabil.Domain.Usuarios.Usuario", null) + .WithMany("RefreshTokens") + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_refresh_tokens_usuarios_usuario_id"); + }); + + modelBuilder.Entity("eContabil.Domain.Documentos.DocumentoFiscal", b => + { + b.Navigation("Itens"); + }); + + modelBuilder.Entity("eContabil.Domain.Usuarios.Usuario", b => + { + b.Navigation("RefreshTokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/eContabil.Infrastructure/Persistencia/Migrations/20260728234813_AdicionarItensETotaisFiscais.cs b/src/eContabil.Infrastructure/Persistencia/Migrations/20260728234813_AdicionarItensETotaisFiscais.cs new file mode 100644 index 0000000..d2a5a72 --- /dev/null +++ b/src/eContabil.Infrastructure/Persistencia/Migrations/20260728234813_AdicionarItensETotaisFiscais.cs @@ -0,0 +1,238 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace eContabil.Infrastructure.Persistencia.Migrations +{ + /// + public partial class AdicionarItensETotaisFiscais : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "dados_fiscais_extraidos", + table: "documentos_fiscais", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "natureza_operacao", + table: "documentos_fiscais", + type: "character varying(60)", + maxLength: 60, + nullable: true); + + migrationBuilder.AddColumn( + name: "total_base_icms", + table: "documentos_fiscais", + type: "numeric(15,2)", + precision: 15, + scale: 2, + nullable: false, + defaultValue: 0m); + + migrationBuilder.AddColumn( + name: "total_base_icms_st", + table: "documentos_fiscais", + type: "numeric(15,2)", + precision: 15, + scale: 2, + nullable: false, + defaultValue: 0m); + + migrationBuilder.AddColumn( + name: "total_cofins", + table: "documentos_fiscais", + type: "numeric(15,2)", + precision: 15, + scale: 2, + nullable: false, + defaultValue: 0m); + + migrationBuilder.AddColumn( + name: "total_desconto", + table: "documentos_fiscais", + type: "numeric(15,2)", + precision: 15, + scale: 2, + nullable: false, + defaultValue: 0m); + + migrationBuilder.AddColumn( + name: "total_frete", + table: "documentos_fiscais", + type: "numeric(15,2)", + precision: 15, + scale: 2, + nullable: false, + defaultValue: 0m); + + migrationBuilder.AddColumn( + name: "total_icms", + table: "documentos_fiscais", + type: "numeric(15,2)", + precision: 15, + scale: 2, + nullable: false, + defaultValue: 0m); + + migrationBuilder.AddColumn( + name: "total_icms_st", + table: "documentos_fiscais", + type: "numeric(15,2)", + precision: 15, + scale: 2, + nullable: false, + defaultValue: 0m); + + migrationBuilder.AddColumn( + name: "total_ipi", + table: "documentos_fiscais", + type: "numeric(15,2)", + precision: 15, + scale: 2, + nullable: false, + defaultValue: 0m); + + migrationBuilder.AddColumn( + name: "total_pis", + table: "documentos_fiscais", + type: "numeric(15,2)", + precision: 15, + scale: 2, + nullable: false, + defaultValue: 0m); + + migrationBuilder.AddColumn( + name: "total_produtos", + table: "documentos_fiscais", + type: "numeric(15,2)", + precision: 15, + scale: 2, + nullable: false, + defaultValue: 0m); + + migrationBuilder.CreateTable( + name: "itens_documento_fiscal", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + documento_fiscal_id = table.Column(type: "uuid", nullable: false), + numero = table.Column(type: "integer", nullable: false), + codigo = table.Column(type: "character varying(60)", maxLength: 60, nullable: false), + descricao = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + cfop = table.Column(type: "character varying(4)", maxLength: 4, nullable: false), + ncm = table.Column(type: "character varying(8)", maxLength: 8, nullable: false), + cest = table.Column(type: "character varying(7)", maxLength: 7, nullable: false), + unidade = table.Column(type: "character varying(6)", maxLength: 6, nullable: false), + quantidade = table.Column(type: "numeric(15,4)", precision: 15, scale: 4, nullable: false), + valor_unitario = table.Column(type: "numeric(21,10)", precision: 21, scale: 10, nullable: false), + valor_total = table.Column(type: "numeric(15,2)", precision: 15, scale: 2, nullable: false), + origem_mercadoria = table.Column(type: "integer", nullable: false), + cst_icms = table.Column(type: "character varying(4)", maxLength: 4, nullable: false), + base_calculo_icms = table.Column(type: "numeric(15,2)", precision: 15, scale: 2, nullable: false), + aliquota_icms = table.Column(type: "numeric(7,4)", precision: 7, scale: 4, nullable: false), + valor_icms = table.Column(type: "numeric(15,2)", precision: 15, scale: 2, nullable: false), + valor_icms_st = table.Column(type: "numeric(15,2)", precision: 15, scale: 2, nullable: false), + valor_ipi = table.Column(type: "numeric(15,2)", precision: 15, scale: 2, nullable: false), + valor_pis = table.Column(type: "numeric(15,2)", precision: 15, scale: 2, nullable: false), + valor_cofins = table.Column(type: "numeric(15,2)", precision: 15, scale: 2, nullable: false), + criado_em = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_itens_documento_fiscal", x => x.id); + table.ForeignKey( + name: "fk_itens_documento_fiscal_documentos_fiscais_documento_fiscal_", + column: x => x.documento_fiscal_id, + principalTable: "documentos_fiscais", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_documentos_pendentes_de_extracao", + table: "documentos_fiscais", + column: "id", + filter: "possui_xml AND NOT dados_fiscais_extraidos"); + + migrationBuilder.CreateIndex( + name: "ix_itens_cfop", + table: "itens_documento_fiscal", + column: "cfop"); + + migrationBuilder.CreateIndex( + name: "ix_itens_documento_numero", + table: "itens_documento_fiscal", + columns: new[] { "documento_fiscal_id", "numero" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_itens_ncm", + table: "itens_documento_fiscal", + column: "ncm"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "itens_documento_fiscal"); + + migrationBuilder.DropIndex( + name: "ix_documentos_pendentes_de_extracao", + table: "documentos_fiscais"); + + migrationBuilder.DropColumn( + name: "dados_fiscais_extraidos", + table: "documentos_fiscais"); + + migrationBuilder.DropColumn( + name: "natureza_operacao", + table: "documentos_fiscais"); + + migrationBuilder.DropColumn( + name: "total_base_icms", + table: "documentos_fiscais"); + + migrationBuilder.DropColumn( + name: "total_base_icms_st", + table: "documentos_fiscais"); + + migrationBuilder.DropColumn( + name: "total_cofins", + table: "documentos_fiscais"); + + migrationBuilder.DropColumn( + name: "total_desconto", + table: "documentos_fiscais"); + + migrationBuilder.DropColumn( + name: "total_frete", + table: "documentos_fiscais"); + + migrationBuilder.DropColumn( + name: "total_icms", + table: "documentos_fiscais"); + + migrationBuilder.DropColumn( + name: "total_icms_st", + table: "documentos_fiscais"); + + migrationBuilder.DropColumn( + name: "total_ipi", + table: "documentos_fiscais"); + + migrationBuilder.DropColumn( + name: "total_pis", + table: "documentos_fiscais"); + + migrationBuilder.DropColumn( + name: "total_produtos", + table: "documentos_fiscais"); + } + } +} diff --git a/src/eContabil.Infrastructure/Persistencia/Migrations/EContabilDbContextModelSnapshot.cs b/src/eContabil.Infrastructure/Persistencia/Migrations/EContabilDbContextModelSnapshot.cs index 022624f..8dbf067 100644 --- a/src/eContabil.Infrastructure/Persistencia/Migrations/EContabilDbContextModelSnapshot.cs +++ b/src/eContabil.Infrastructure/Persistencia/Migrations/EContabilDbContextModelSnapshot.cs @@ -136,6 +136,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("timestamp with time zone") .HasColumnName("criado_em"); + b.Property("DadosFiscaisExtraidos") + .HasColumnType("boolean") + .HasColumnName("dados_fiscais_extraidos"); + b.Property("DataAutorizacao") .HasColumnType("timestamp with time zone") .HasColumnName("data_autorizacao"); @@ -170,6 +174,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("character varying(500)") .HasColumnName("motivo_falha_manifestacao"); + b.Property("NaturezaOperacao") + .HasMaxLength(60) + .HasColumnType("character varying(60)") + .HasColumnName("natureza_operacao"); + b.Property("Nsu") .HasColumnType("bigint") .HasColumnName("nsu"); @@ -240,9 +249,143 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasDatabaseName("ix_documentos_manifestacao_pendente") .HasFilter("manifestacao_status = 1"); + b.HasIndex(new[] { "Id" }, "ix_documentos_pendentes_de_extracao") + .HasDatabaseName("ix_documentos_pendentes_de_extracao") + .HasFilter("possui_xml AND NOT dados_fiscais_extraidos"); + b.ToTable("documentos_fiscais", (string)null); }); + modelBuilder.Entity("eContabil.Domain.Documentos.ItemDocumentoFiscal", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AliquotaIcms") + .HasPrecision(7, 4) + .HasColumnType("numeric(7,4)") + .HasColumnName("aliquota_icms"); + + b.Property("BaseCalculoIcms") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("base_calculo_icms"); + + b.Property("Cest") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character varying(7)") + .HasColumnName("cest"); + + b.Property("Cfop") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("character varying(4)") + .HasColumnName("cfop"); + + b.Property("Codigo") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)") + .HasColumnName("codigo"); + + b.Property("CriadoEm") + .HasColumnType("timestamp with time zone") + .HasColumnName("criado_em"); + + b.Property("CstIcms") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("character varying(4)") + .HasColumnName("cst_icms"); + + b.Property("Descricao") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("descricao"); + + b.Property("DocumentoFiscalId") + .HasColumnType("uuid") + .HasColumnName("documento_fiscal_id"); + + b.Property("Ncm") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("ncm"); + + b.Property("Numero") + .HasColumnType("integer") + .HasColumnName("numero"); + + b.Property("OrigemMercadoria") + .HasColumnType("integer") + .HasColumnName("origem_mercadoria"); + + b.Property("Quantidade") + .HasPrecision(15, 4) + .HasColumnType("numeric(15,4)") + .HasColumnName("quantidade"); + + b.Property("Unidade") + .IsRequired() + .HasMaxLength(6) + .HasColumnType("character varying(6)") + .HasColumnName("unidade"); + + b.Property("ValorCofins") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("valor_cofins"); + + b.Property("ValorIcms") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("valor_icms"); + + b.Property("ValorIcmsSt") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("valor_icms_st"); + + b.Property("ValorIpi") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("valor_ipi"); + + b.Property("ValorPis") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("valor_pis"); + + b.Property("ValorTotal") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("valor_total"); + + b.Property("ValorUnitario") + .HasPrecision(21, 10) + .HasColumnType("numeric(21,10)") + .HasColumnName("valor_unitario"); + + b.HasKey("Id") + .HasName("pk_itens_documento_fiscal"); + + b.HasIndex("Cfop") + .HasDatabaseName("ix_itens_cfop"); + + b.HasIndex("Ncm") + .HasDatabaseName("ix_itens_ncm"); + + b.HasIndex("DocumentoFiscalId", "Numero") + .IsUnique() + .HasDatabaseName("ix_itens_documento_numero"); + + b.ToTable("itens_documento_fiscal", (string)null); + }); + modelBuilder.Entity("eContabil.Domain.Empresas.Empresa", b => { b.Property("Id") @@ -568,11 +711,89 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasConstraintName("fk_documentos_fiscais_documentos_fiscais_id"); }); + b.OwnsOne("eContabil.Domain.ValueObjects.TotaisFiscais", "Totais", b1 => + { + b1.Property("DocumentoFiscalId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("BaseCalculoIcms") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("total_base_icms"); + + b1.Property("BaseCalculoIcmsSt") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("total_base_icms_st"); + + b1.Property("ValorCofins") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("total_cofins"); + + b1.Property("ValorDesconto") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("total_desconto"); + + b1.Property("ValorFrete") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("total_frete"); + + b1.Property("ValorIcms") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("total_icms"); + + b1.Property("ValorIcmsSt") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("total_icms_st"); + + b1.Property("ValorIpi") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("total_ipi"); + + b1.Property("ValorPis") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("total_pis"); + + b1.Property("ValorProdutos") + .HasPrecision(15, 2) + .HasColumnType("numeric(15,2)") + .HasColumnName("total_produtos"); + + b1.HasKey("DocumentoFiscalId"); + + b1.ToTable("documentos_fiscais"); + + b1.WithOwner() + .HasForeignKey("DocumentoFiscalId") + .HasConstraintName("fk_documentos_fiscais_documentos_fiscais_id"); + }); + b.Navigation("Destinatario") .IsRequired(); b.Navigation("Emitente") .IsRequired(); + + b.Navigation("Totais") + .IsRequired(); + }); + + modelBuilder.Entity("eContabil.Domain.Documentos.ItemDocumentoFiscal", b => + { + b.HasOne("eContabil.Domain.Documentos.DocumentoFiscal", null) + .WithMany("Itens") + .HasForeignKey("DocumentoFiscalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_itens_documento_fiscal_documentos_fiscais_documento_fiscal_"); }); modelBuilder.Entity("eContabil.Domain.Usuarios.RefreshToken", b => @@ -585,6 +806,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasConstraintName("fk_refresh_tokens_usuarios_usuario_id"); }); + modelBuilder.Entity("eContabil.Domain.Documentos.DocumentoFiscal", b => + { + b.Navigation("Itens"); + }); + modelBuilder.Entity("eContabil.Domain.Usuarios.Usuario", b => { b.Navigation("RefreshTokens"); diff --git a/src/eContabil.Infrastructure/Persistencia/Repositorios/DocumentoFiscalRepository.cs b/src/eContabil.Infrastructure/Persistencia/Repositorios/DocumentoFiscalRepository.cs index 7370aa8..eb8cb6f 100644 --- a/src/eContabil.Infrastructure/Persistencia/Repositorios/DocumentoFiscalRepository.cs +++ b/src/eContabil.Infrastructure/Persistencia/Repositorios/DocumentoFiscalRepository.cs @@ -61,4 +61,19 @@ await Conjunto .Distinct() .Take(limite) .ToListAsync(ct); + + /// + /// + /// Rastreamento ligado e itens carregados: a extração substitui a lista inteira, e sem os filhos na + /// memória o EF Core não teria como saber o que remover na segunda passada. + /// + public async Task> ObterPendentesDeExtracaoAsync( + int limite, CancellationToken ct) => + await Conjunto + .AsTracking() + .Include(documento => documento.Itens) + .Where(documento => documento.PossuiXml && !documento.DadosFiscaisExtraidos) + .OrderBy(documento => documento.CriadoEm) + .Take(limite) + .ToListAsync(ct); } diff --git a/src/eContabil.Infrastructure/Sefaz/TradutorDeRetornoSefaz.cs b/src/eContabil.Infrastructure/Sefaz/TradutorDeRetornoSefaz.cs index 60898d7..bb08cbe 100644 --- a/src/eContabil.Infrastructure/Sefaz/TradutorDeRetornoSefaz.cs +++ b/src/eContabil.Infrastructure/Sefaz/TradutorDeRetornoSefaz.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Text; using eContabil.Application.Sefaz; +using eContabil.Infrastructure.Compactacao; using NFe.Classes.Servicos.DistribuicaoDFe; using NFe.Classes.Servicos.DistribuicaoDFe.Schemas; @@ -65,7 +66,9 @@ public static RetornoDistribuicaoDto Traduzir(retDistDFeInt retorno) return null; } - var xml = item.XmlNfe ?? []; + // A biblioteca decodifica o base64 do `docZip` para montar seus objetos, mas devolve os bytes + // ainda comprimidos. Arquivar assim produzia `.xml` que nenhum leitor abre. + var xml = ConteudoGzip.Descompactar(item.XmlNfe ?? []); if (item.NfeProc is { } procNFe) { diff --git a/src/eContabil.Infrastructure/Storage/MinioXmlStorage.cs b/src/eContabil.Infrastructure/Storage/MinioXmlStorage.cs index 6a14c4f..675cc16 100644 --- a/src/eContabil.Infrastructure/Storage/MinioXmlStorage.cs +++ b/src/eContabil.Infrastructure/Storage/MinioXmlStorage.cs @@ -2,6 +2,7 @@ using System.Security.Cryptography; using eContabil.Application.Armazenamento; using eContabil.Domain.ValueObjects; +using eContabil.Infrastructure.Compactacao; using eContabil.Shared; using Microsoft.Extensions.Options; using Minio; @@ -86,6 +87,13 @@ await cliente.PutObjectAsync( } } + /// + /// + /// Descompacta o que estiver em gzip antes de devolver. Versões anteriores arquivavam os bytes do + /// docZip como vinham da SEFAZ, e o acervo gravado por elas continua no bucket até o + /// reprocessamento passar. A conferência custa três bytes e garante que ninguém receba um arquivo + /// que não abre só por ter baixado antes da varredura terminar. + /// public async Task> ObterAsync(string bucket, string objectName, CancellationToken ct) { try @@ -101,9 +109,9 @@ await cliente.GetObjectAsync( .WithCallbackStream((fluxo, cancelamento) => fluxo.CopyToAsync(destino, cancelamento)), ct); - destino.Position = 0; + var conteudo = ConteudoGzip.Descompactar(destino.ToArray()); - return Result.Ok(destino); + return Result.Ok(new MemoryStream(conteudo, writable: false)); } catch (ObjectNotFoundException) { @@ -117,6 +125,48 @@ await cliente.GetObjectAsync( } } + public async Task> SubstituirAsync( + string bucket, string objectName, Stream conteudo, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(conteudo); + + using var memoria = new MemoryStream(); + await conteudo.CopyToAsync(memoria, ct); + var bytes = memoria.ToArray(); + + if (bytes.Length == 0) + { + // Um objeto existente jamais é trocado por vazio: seria apagar o XML sem dizer que apagou. + return Result.Invalido("xml", "Conteúdo do XML está vazio."); + } + + try + { + using var origem = new MemoryStream(bytes); + + await cliente.PutObjectAsync( + new PutObjectArgs() + .WithBucket(bucket) + .WithObject(objectName) + .WithStreamData(origem) + .WithObjectSize(bytes.Length) + .WithContentType("application/xml"), + ct); + + return Result.Ok(new ObjetoArmazenadoDto( + bucket, + objectName, + Convert.ToHexStringLower(SHA256.HashData(bytes)), + bytes.Length, + JaExistia: true)); + } + catch (MinioException excecao) + { + return Result.Falhou(Error.Falha( + "storage.indisponivel", $"Não foi possível regravar o XML: {excecao.Message}")); + } + } + public async Task ExisteAsync(string bucket, string objectName, CancellationToken ct) { try diff --git a/src/econtabil-web/src/app/features/documentos/documento-detalhe.component.spec.ts b/src/econtabil-web/src/app/features/documentos/documento-detalhe.component.spec.ts index df45015..a112c6c 100644 --- a/src/econtabil-web/src/app/features/documentos/documento-detalhe.component.spec.ts +++ b/src/econtabil-web/src/app/features/documentos/documento-detalhe.component.spec.ts @@ -5,7 +5,7 @@ import { LOCALE_ID } from '@angular/core'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; -import { render, screen, waitFor } from '@testing-library/angular'; +import { render, screen, waitFor, within } from '@testing-library/angular'; import { ManifestacaoStatus, OrigemConteudo, @@ -44,6 +44,41 @@ const BASE: DocumentoDetalhe = { manifestadoEm: '2026-07-21T09:00:00Z', possuiXml: true, capturadoEm: '2026-07-21T08:00:00Z', + naturezaOperacao: 'VENDA DE MERCADORIA ADQUIRIDA', + dadosFiscaisExtraidos: true, + totalProdutos: 1000, + totalBaseIcms: 1000, + totalIcms: 180, + totalBaseIcmsSt: 0, + totalIcmsSt: 0, + totalIpi: 50, + totalPis: 16.5, + totalCofins: 76, + totalFrete: 0, + totalDesconto: 0, + itens: [ + { + numero: 1, + codigo: 'PROD-1', + descricao: 'PARACETAMOL 500MG', + cfop: '5102', + ncm: '30049099', + cest: '1300100', + unidade: 'CX', + quantidade: 10.5, + valorUnitario: 95.238095, + valorTotal: 1000, + origemMercadoria: 0, + cstIcms: '00', + baseCalculoIcms: 1000, + aliquotaIcms: 18, + valorIcms: 180, + valorIcmsSt: 0, + valorIpi: 50, + valorPis: 16.5, + valorCofins: 76, + }, + ], }; async function montar(documento: DocumentoDetalhe) { @@ -69,6 +104,13 @@ async function montar(documento: DocumentoDetalhe) { return resultado; } +/** O cartão de impostos, isolado da tabela de itens — as duas seções repetem os mesmos rótulos. */ +async function cartaoDeImpostos(): Promise { + const titulo = await screen.findByText('Impostos'); + + return titulo.closest('mat-card') as HTMLElement; +} + describe('DocumentoDetalheComponent', () => { it('identifica a empresa dona do documento', async () => { // O contador precisa saber de qual cliente é a nota antes de qualquer outra coisa. @@ -132,6 +174,55 @@ describe('DocumentoDetalheComponent', () => { expect(await screen.findByText(/596 — Evento apresentado após o prazo/)).toBeInTheDocument(); }); + it('lista os itens da nota com CFOP, NCM e CST', async () => { + // É o que o contador confere antes de lançar. Sem isso na tela, cada nota exigiria baixar o XML. + await montar(BASE); + + expect(await screen.findByText('PARACETAMOL 500MG')).toBeInTheDocument(); + expect(screen.getByText('5102')).toBeInTheDocument(); + expect(screen.getByText('30049099')).toBeInTheDocument(); + expect(screen.getByText('00')).toBeInTheDocument(); + }); + + it('mostra os totais de imposto declarados na nota', async () => { + await montar(BASE); + + // "ICMS" também é cabeçalho da tabela de itens: a busca precisa ser dentro do cartão de totais. + const totais = await cartaoDeImpostos(); + + expect(within(totais).getByText('ICMS')).toBeInTheDocument(); + expect(within(totais).getByText('Base do ICMS')).toBeInTheDocument(); + expect(within(totais).getByText('COFINS')).toBeInTheDocument(); + }); + + it('omite frete e ST quando a nota nao os declara', async () => { + // Células zeradas fixas competiriam por atenção com os impostos que de fato importam. + await montar(BASE); + + const totais = await cartaoDeImpostos(); + + expect(within(totais).queryByText('ICMS ST')).not.toBeInTheDocument(); + expect(within(totais).queryByText('Frete')).not.toBeInTheDocument(); + expect(within(totais).queryByText('Desconto')).not.toBeInTheDocument(); + }); + + it('mostra a natureza da operacao', async () => { + await montar(BASE); + + expect(await screen.findByText('VENDA DE MERCADORIA ADQUIRIDA')).toBeInTheDocument(); + }); + + it('explica quando o XML existe mas os dados fiscais ainda nao foram lidos', async () => { + // Zero na tela seria lido como fato. O acervo capturado antes da extração cai exatamente aqui. + await montar({ ...BASE, dadosFiscaisExtraidos: false, itens: [] }); + + await waitFor(() => + expect(screen.getByRole('status')).toHaveTextContent(/ainda não foram lidos/i), + ); + + expect(screen.queryByText('Itens')).not.toBeInTheDocument(); + }); + it('permite baixar quando o XML completo existe', async () => { await montar(BASE); diff --git a/src/econtabil-web/src/app/features/documentos/documento-detalhe.component.ts b/src/econtabil-web/src/app/features/documentos/documento-detalhe.component.ts index 1ee4f87..deb06c0 100644 --- a/src/econtabil-web/src/app/features/documentos/documento-detalhe.component.ts +++ b/src/econtabil-web/src/app/features/documentos/documento-detalhe.component.ts @@ -1,4 +1,4 @@ -import { CurrencyPipe } from '@angular/common'; +import { CurrencyPipe, DecimalPipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -42,6 +42,7 @@ const MANIFESTACAO: Readonly>> = { imports: [ RouterLink, CurrencyPipe, + DecimalPipe, ChaveAcessoPipe, CnpjPipe, DataHoraLocalPipe, @@ -79,6 +80,9 @@ const MANIFESTACAO: Readonly>> = {
Papel
{{ ehDestinatario() ? 'Destinatária (entrada)' : 'Emitente (saída)' }}
+ +
Natureza
+
{{ doc.naturezaOperacao || '—' }}
@@ -154,6 +158,104 @@ const MANIFESTACAO: Readonly>> = { + @if (doc.dadosFiscaisExtraidos) { + + + Impostos + Totais declarados na nota + + +
+ @for (imposto of totais(); track imposto.rotulo) { +
+ {{ imposto.rotulo }} + {{ imposto.valor | currency: 'BRL' }} +
+ } +
+
+
+ + + + Itens + + {{ doc.itens.length }} {{ doc.itens.length === 1 ? 'linha' : 'linhas' }} de produto + + + + @if (doc.itens.length === 0) { +

A nota não declara nenhuma linha de produto.

+ } @else { +
+ + + + + + + + + + + + + + + + + + + + @for (item of doc.itens; track item.numero) { + + + + + + + + + + + + + + + } + +
+ Itens da nota +
#DescriçãoNCMCFOPCSTUnQtdUnitárioTotalICMSIPIPIS/COFINS
{{ item.numero }} + {{ item.descricao || '—' }} + @if (item.codigo) { + {{ item.codigo }} + } + {{ item.ncm || '—' }}{{ item.cfop || '—' }}{{ item.cstIcms || '—' }}{{ item.unidade || '—' }}{{ item.quantidade | number: '1.0-4' }}{{ item.valorUnitario | number: '1.2-4' }}{{ item.valorTotal | currency: 'BRL' }} + {{ item.valorIcms | currency: 'BRL' }} + @if (item.aliquotaIcms > 0) { + {{ item.aliquotaIcms | number: '1.0-2' }}% + } + {{ item.valorIpi | currency: 'BRL' }} + {{ item.valorPis + item.valorCofins | currency: 'BRL' }} +
+
+ } +
+
+ } @else if (doc.possuiXml) { + + +

+ O XML desta nota está arquivado, mas os itens e impostos ainda não foram lidos dele. + Um administrador libera esses dados pelo reprocessamento do acervo. +

+
+
+ } + @if (doc.possuiXml) { @@ -254,6 +356,66 @@ const MANIFESTACAO: Readonly>> = { .aviso { color: #78350f; } + + .impostos { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); + gap: 0.75rem 1rem; + } + + .impostos .rotulo { + display: block; + font-size: 0.75rem; + color: rgb(0 0 0 / 60%); + } + + .impostos .numero { + font-variant-numeric: tabular-nums; + font-weight: 500; + } + + /* A tabela de itens tem doze colunas e não encolhe: quem rola é ela, nunca a página. */ + .rolagem { + overflow-x: auto; + } + + .grade { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; + } + + .grade th, + .grade td { + text-align: left; + padding: 0.4rem 0.5rem; + border-bottom: 1px solid rgb(0 0 0 / 12%); + white-space: nowrap; + } + + .grade td .secundario { + display: block; + font-size: 0.72rem; + color: rgb(0 0 0 / 55%); + } + + .numero-celula { + text-align: right; + font-variant-numeric: tabular-nums; + } + + .codigo { + font-family: monospace; + font-size: 0.78rem; + } + + .oculto { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + } `, }) export class DocumentoDetalheComponent implements OnInit { @@ -269,6 +431,39 @@ export class DocumentoDetalheComponent implements OnInit { () => this.documento()?.papel === PapelEmpresa.Destinatario, ); + /** + * Totais de imposto que a nota declara, na ordem em que o contador confere. + * + * Frete e desconto entram só quando existem: nota sem eles é a maioria, e duas células zeradas fixas + * competiriam por atenção com os impostos que de fato importam. + */ + protected readonly totais = computed(() => { + const doc = this.documento(); + + if (doc === null) { + return []; + } + + const linhas = [ + { rotulo: 'Produtos', valor: doc.totalProdutos }, + { rotulo: 'Base do ICMS', valor: doc.totalBaseIcms }, + { rotulo: 'ICMS', valor: doc.totalIcms }, + { rotulo: 'Base do ICMS ST', valor: doc.totalBaseIcmsSt }, + { rotulo: 'ICMS ST', valor: doc.totalIcmsSt }, + { rotulo: 'IPI', valor: doc.totalIpi }, + { rotulo: 'PIS', valor: doc.totalPis }, + { rotulo: 'COFINS', valor: doc.totalCofins }, + { rotulo: 'Frete', valor: doc.totalFrete }, + { rotulo: 'Desconto', valor: doc.totalDesconto }, + ]; + + return linhas.filter( + (linha) => + linha.valor !== 0 || + !['Base do ICMS ST', 'ICMS ST', 'Frete', 'Desconto'].includes(linha.rotulo), + ); + }); + /** * A manifestação só existe para quem recebe a nota, e "não se aplica" tem dois motivos bem * diferentes: ou a empresa emitiu a nota — ninguém manifesta a própria —, ou ela é a destinatária diff --git a/src/econtabil-web/src/app/features/documentos/documento.model.ts b/src/econtabil-web/src/app/features/documentos/documento.model.ts index 648082a..592def2 100644 --- a/src/econtabil-web/src/app/features/documentos/documento.model.ts +++ b/src/econtabil-web/src/app/features/documentos/documento.model.ts @@ -41,6 +41,13 @@ export interface FiltroDocumentos { readonly emitenteBusca: string | null; readonly valorMinimo: number | null; readonly valorMaximo: number | null; + + // Critérios de item: CFOP, NCM e CST vivem nas linhas de produto, não na nota. Excluem em silêncio + // todo documento que ficou no resumo, porque resumo não tem item nenhum — a grade avisa quantos. + readonly cfop: string | null; + readonly ncm: string | null; + readonly cstIcms: string | null; + readonly comSubstituicaoTributaria: boolean | null; } export const FILTRO_VAZIO: FiltroDocumentos = { @@ -52,6 +59,10 @@ export const FILTRO_VAZIO: FiltroDocumentos = { emitenteBusca: null, valorMinimo: null, valorMaximo: null, + cfop: null, + ncm: null, + cstIcms: null, + comSubstituicaoTributaria: null, }; /** Totais do recorte filtrado — vem de endpoint próprio, não da página. */ @@ -62,6 +73,33 @@ export interface ResumoDocumentos { readonly somenteResumo: number; readonly entradas: number; readonly saidas: number; + + // Documentos que atendem aos demais critérios mas não puderam ser avaliados contra os filtros + // fiscais, por não terem XML completo lido. + readonly foraDoFiltroPorFaltarXml: number; +} + +/** Uma linha de produto da nota. */ +export interface ItemDocumento { + readonly numero: number; + readonly codigo: string; + readonly descricao: string; + readonly cfop: string; + readonly ncm: string; + readonly cest: string; + readonly unidade: string; + readonly quantidade: number; + readonly valorUnitario: number; + readonly valorTotal: number; + readonly origemMercadoria: number; + readonly cstIcms: string; + readonly baseCalculoIcms: number; + readonly aliquotaIcms: number; + readonly valorIcms: number; + readonly valorIcmsSt: number; + readonly valorIpi: number; + readonly valorPis: number; + readonly valorCofins: number; } /** Documento na tela de detalhe. */ @@ -92,6 +130,22 @@ export interface DocumentoDetalhe { readonly manifestadoEm: string | null; readonly possuiXml: boolean; readonly capturadoEm: string; + + readonly naturezaOperacao: string | null; + + // Separa "sem imposto" de "ainda não lemos o XML": sem isso, zero na tela seria lido como fato. + readonly dadosFiscaisExtraidos: boolean; + readonly totalProdutos: number; + readonly totalBaseIcms: number; + readonly totalIcms: number; + readonly totalBaseIcmsSt: number; + readonly totalIcmsSt: number; + readonly totalIpi: number; + readonly totalPis: number; + readonly totalCofins: number; + readonly totalFrete: number; + readonly totalDesconto: number; + readonly itens: readonly ItemDocumento[]; } /** Uma linha do relatório de captura por empresa. */ diff --git a/src/econtabil-web/src/app/features/documentos/documentos.component.spec.ts b/src/econtabil-web/src/app/features/documentos/documentos.component.spec.ts index 7e9fb4d..99b0f91 100644 --- a/src/econtabil-web/src/app/features/documentos/documentos.component.spec.ts +++ b/src/econtabil-web/src/app/features/documentos/documentos.component.spec.ts @@ -1,10 +1,91 @@ -import { render, screen } from '@testing-library/angular'; +import { registerLocaleData } from '@angular/common'; +import { provideHttpClient } from '@angular/common/http'; +import localePt from '@angular/common/locales/pt'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { LOCALE_ID } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { patchState } from '@ngrx/signals'; +import { unprotected } from '@ngrx/signals/testing'; +import { render, screen, waitFor } from '@testing-library/angular'; +import { ResumoDocumentos } from './documento.model'; import { DocumentosComponent } from './documentos.component'; +import { DocumentosStore } from './documentos.store'; + +registerLocaleData(localePt); + +const RESUMO: ResumoDocumentos = { + quantidade: 12, + valorTotal: 34567.89, + comXmlCompleto: 12, + somenteResumo: 0, + entradas: 9, + saidas: 3, + foraDoFiltroPorFaltarXml: 0, +}; + +async function montar(resumo: ResumoDocumentos | null = null) { + const resultado = await render(DocumentosComponent, { + providers: [ + provideRouter([]), + provideHttpClient(), + provideHttpClientTesting(), + { provide: LOCALE_ID, useValue: 'pt-BR' }, + ], + }); + + if (resumo !== null) { + // O estado do store é protegido contra escrita de fora; `unprotected` monta o cenário sem depender + // da ordem em que as requisições da tela respondem. + patchState(unprotected(TestBed.inject(DocumentosStore)), { resumo }); + } + + await resultado.fixture.whenStable(); + + return resultado; +} describe('DocumentosComponent', () => { it('anuncia a pagina com um cabecalho acessivel', async () => { - await render(DocumentosComponent); + await montar(); expect(screen.getByRole('heading', { name: /documentos/i })).toBeInTheDocument(); }); + + it('oferece os filtros fiscais que o contador usa para separar operacoes', async () => { + await montar(); + + expect(screen.getByLabelText(/cfop/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/ncm/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/cst/i)).toBeInTheDocument(); + }); + + it('mostra os totais do recorte filtrado', async () => { + await montar(RESUMO); + + await waitFor(() => expect(screen.getByText(/34\.567,89/)).toBeInTheDocument()); + + expect(screen.getByText('12')).toBeInTheDocument(); + expect(screen.getByText('9')).toBeInTheDocument(); + }); + + it('avisa quantos documentos ficam de fora por nao terem XML lido', async () => { + // Filtro de item esconde toda nota que ficou no resumo, porque resumo não tem item nenhum. Sem o + // aviso, o contador conclui que a nota sumiu do sistema. + await montar({ ...RESUMO, foraDoFiltroPorFaltarXml: 8 }); + + await waitFor(() => + expect(screen.getByRole('status')).toHaveTextContent( + /8 documento\(s\) do período não entram neste filtro/i, + ), + ); + }); + + it('nao mostra o aviso quando nenhum documento ficou de fora', async () => { + await montar(RESUMO); + + await waitFor(() => expect(screen.getByText(/34.567,89/)).toBeInTheDocument()); + + expect(screen.queryByText(/não entram neste filtro/i)).not.toBeInTheDocument(); + }); }); diff --git a/src/econtabil-web/src/app/features/documentos/documentos.component.ts b/src/econtabil-web/src/app/features/documentos/documentos.component.ts index c5cd76d..aa32657 100644 --- a/src/econtabil-web/src/app/features/documentos/documentos.component.ts +++ b/src/econtabil-web/src/app/features/documentos/documentos.component.ts @@ -125,6 +125,32 @@ const MESES_DE_ALERTA = 12; Valor máximo + + + CFOP + + Aceita prefixo: 5 traz todas as saídas + + + + NCM + + Aceita prefixo: 3004 traz a família + + + + CST / CSOSN + + + + + Substituição tributária + + Tanto faz + Só com ICMS ST + Só sem ICMS ST + + @if (periodoLongo()) { @@ -171,6 +197,15 @@ const MESES_DE_ALERTA = 12; } + + @if (resumo.foraDoFiltroPorFaltarXml > 0) { + +

+ {{ resumo.foraDoFiltroPorFaltarXml }} documento(s) do período não entram neste filtro por + não terem XML completo lido — CFOP, NCM e CST só existem nas notas cujo XML foi capturado. +

+ } } @if (store.vazio() && !store.falhou()) { @@ -365,6 +400,10 @@ export class DocumentosComponent implements OnInit { emitenteBusca: [null as string | null], valorMinimo: [null as number | null], valorMaximo: [null as number | null], + cfop: [null as string | null], + ncm: [null as string | null], + cstIcms: [null as string | null], + comSubstituicaoTributaria: [null as boolean | null], }); async ngOnInit(): Promise { @@ -455,6 +494,10 @@ export class DocumentosComponent implements OnInit { emitenteBusca: valores.emitenteBusca || null, valorMinimo: valores.valorMinimo ?? null, valorMaximo: valores.valorMaximo ?? null, + cfop: valores.cfop || null, + ncm: valores.ncm || null, + cstIcms: valores.cstIcms || null, + comSubstituicaoTributaria: valores.comSubstituicaoTributaria ?? null, }; } @@ -472,6 +515,10 @@ export class DocumentosComponent implements OnInit { emitenteBusca: parametros.get('emitenteBusca'), valorMinimo: this.numeroDaUrl(parametros.get('valorMinimo')), valorMaximo: this.numeroDaUrl(parametros.get('valorMaximo')), + cfop: parametros.get('cfop'), + ncm: parametros.get('ncm'), + cstIcms: parametros.get('cstIcms'), + comSubstituicaoTributaria: this.booleanoDaUrl(parametros.get('comSubstituicaoTributaria')), }; } @@ -479,6 +526,11 @@ export class DocumentosComponent implements OnInit { return valor === null ? null : Number(valor); } + /** Ausente é "tanto faz", que não é o mesmo que "só sem ST". */ + private booleanoDaUrl(valor: string | null): boolean | null { + return valor === null ? null : valor === 'true'; + } + private paraQueryParams(filtro: FiltroDocumentos): Record { const parametros: Record = {}; diff --git a/tests/eContabil.Application.Tests/Fiscal/ReprocessarAcervoCommandHandlerTests.cs b/tests/eContabil.Application.Tests/Fiscal/ReprocessarAcervoCommandHandlerTests.cs new file mode 100644 index 0000000..2a730ca --- /dev/null +++ b/tests/eContabil.Application.Tests/Fiscal/ReprocessarAcervoCommandHandlerTests.cs @@ -0,0 +1,216 @@ +using System.Text; +using eContabil.Application.Armazenamento; +using eContabil.Application.Fiscal; +using eContabil.Domain; +using eContabil.Domain.Documentos; +using eContabil.Domain.ValueObjects; +using eContabil.Shared; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using Xunit; + +namespace eContabil.Application.Tests.Fiscal; + +/// +/// O reprocessamento existe porque o acervo é anterior à extração: arquivo no storage, nenhum item no +/// banco. Como o arquivo precisa ser lido de qualquer forma, a mesma passada corrige o que estiver +/// gravado compactado. +/// +public class ReprocessarAcervoCommandHandlerTests +{ + private static readonly DateTime AgoraUtc = new(2026, 7, 28, 12, 0, 0, DateTimeKind.Utc); + + private const string ChaveValida = "35260711222333000181550010000123451123456787"; + + private readonly IDocumentoFiscalRepository _documentos = Substitute.For(); + private readonly IXmlStorage _storage = Substitute.For(); + private readonly IAplicadorDeDadosFiscais _aplicador = Substitute.For(); + private readonly IUnitOfWork _unitOfWork = Substitute.For(); + + [Fact] + public async Task Handle_SemFila_NaoTocaNoStorage() + { + ComPendentes(); + + var resultado = await Executar(); + + Assert.True(resultado.Sucesso); + Assert.Equal(0, resultado.Valor.Visitados); + Assert.False(resultado.Valor.RestamPendentes); + + await _storage.DidNotReceiveWithAnyArgs() + .ObterAsync(default!, default!, TestContext.Current.CancellationToken); + } + + [Fact] + public async Task Handle_ComDocumentoArquivado_ExtraiERegravaOArquivo() + { + var documento = ComXml(); + ComPendentes(documento); + ComLeitura(""); + ComRegravacao("hash-em-claro"); + + _aplicador.Aplicar(documento, Arg.Any(), AgoraUtc) + .Returns(_ => + { + documento.RegistrarDadosFiscais("VENDA", TotaisFiscais.Vazios, []); + return 0; + }); + + var resultado = await Executar(); + + Assert.Equal(1, resultado.Valor.Visitados); + Assert.Equal(1, resultado.Valor.Extraidos); + Assert.Equal(1, resultado.Valor.Recompactados); + Assert.Equal("hash-em-claro", documento.HashXml); + + await _unitOfWork.Received(1).SalvarAlteracoesAsync(Arg.Any()); + } + + [Fact] + public async Task Handle_ComArquivoQueJaEstavaEmClaro_NaoContaComoRegravado() + { + // O hash bate com o que o registro já tinha: o conteúdo não mudou, e o relatório da rodada + // precisa dizer isso, ou o operador nunca saberá quando a varredura terminou de fato. + var documento = ComXml(); + ComPendentes(documento); + ComLeitura(""); + ComRegravacao("hash-original"); + + var resultado = await Executar(); + + Assert.Equal(0, resultado.Valor.Recompactados); + } + + [Fact] + public async Task Handle_ComArquivoAusenteNoStorage_PulaSemDerrubarARodada() + { + var ausente = ComXml(); + var bom = ComXml(); + + ComPendentes(ausente, bom); + ComRegravacao("hash-em-claro"); + + _storage.ObterAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns( + _ => Result.Falhou(Error.NaoEncontrado( + "storage.objeto_inexistente", "O arquivo não foi encontrado no armazenamento.")), + _ => Result.Ok(Fluxo(""))); + + var resultado = await Executar(); + + Assert.True(resultado.Sucesso); + Assert.Equal(1, resultado.Valor.SemConteudo); + Assert.Equal(2, resultado.Valor.Visitados); + } + + [Fact] + public async Task Handle_ComStorageIndisponivel_ParaARodadaEmVezDeMarcarTudoComoSemArquivo() + { + // Insistir nos seguintes só produziria uma sequência de falhas idênticas — e marcaria como "sem + // arquivo" documentos que estão lá, o que os tiraria da fila sem ninguém perceber. + ComPendentes(ComXml(), ComXml()); + + _storage.ObterAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Result.Falhou(Error.Falha("storage.indisponivel", "MinIO fora."))); + + var resultado = await Executar(); + + Assert.True(resultado.Falha); + Assert.Equal("storage.indisponivel", resultado.Erro!.Codigo); + + await _unitOfWork.DidNotReceiveWithAnyArgs() + .SalvarAlteracoesAsync(TestContext.Current.CancellationToken); + } + + [Fact] + public async Task Handle_ComRegravacaoRecusada_NaoImpedeAExtracao() + { + // Regravar é conveniência: a leitura já descompacta, então o download continua correto. Perder a + // extração por causa disso deixaria o documento sem itens e sem motivo aparente. + var documento = ComXml(); + ComPendentes(documento); + ComLeitura(""); + + _storage.SubstituirAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Result.Falhou( + Error.Falha("storage.indisponivel", "Não foi possível regravar o XML."))); + + _aplicador.Aplicar(documento, Arg.Any(), AgoraUtc) + .Returns(_ => + { + documento.RegistrarDadosFiscais("VENDA", TotaisFiscais.Vazios, []); + return 0; + }); + + var resultado = await Executar(); + + Assert.True(resultado.Sucesso); + Assert.Equal(1, resultado.Valor.Extraidos); + Assert.Equal(0, resultado.Valor.Recompactados); + } + + [Fact] + public async Task Handle_ComRodadaCheia_SinalizaQueRestamPendentes() + { + // O acervo de cinco anos não cabe numa execução. Sem o sinal, o job pararia na primeira rodada e + // o resto do acervo ficaria sem itens para sempre. + ComPendentes(ComXml(), ComXml()); + ComLeitura(""); + ComRegravacao("hash-em-claro"); + + var resultado = await Executar(limite: 2); + + Assert.True(resultado.Valor.RestamPendentes); + } + + private Task> Executar(int limite = 50) => + new ReprocessarAcervoCommandHandler( + _documentos, + _storage, + _aplicador, + _unitOfWork, + new FakeTimeProvider(new DateTimeOffset(AgoraUtc, TimeSpan.Zero)), + NullLogger.Instance) + .Handle(new ReprocessarAcervoCommand(limite), TestContext.Current.CancellationToken); + + private void ComPendentes(params DocumentoFiscal[] documentos) => + _documentos.ObterPendentesDeExtracaoAsync(Arg.Any(), Arg.Any()) + .Returns(documentos); + + private void ComLeitura(string xml) => + _storage.ObterAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(_ => Result.Ok(Fluxo(xml))); + + private void ComRegravacao(string hash) => + _storage.SubstituirAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(_ => Result.Ok( + new ObjetoArmazenadoDto("xmls", "2026/07/chave.xml", hash, 100, JaExistia: true))); + + private static MemoryStream Fluxo(string xml) => new(Encoding.UTF8.GetBytes(xml)); + + private static DocumentoFiscal ComXml() + { + var documento = DocumentoFiscal.Criar( + Guid.CreateVersion7(), + ChaveAcesso.Criar(ChaveValida).Valor, + nsu: 1000, + TipoDocumento.Nfe, + PapelEmpresa.Destinatario, + OrigemConteudo.Resumo, + ParticipanteFiscal.Criar("11222333000181", "FORNECEDOR LTDA").Valor, + ParticipanteFiscal.Criar("25146091000107", "NEWNUTRITION").Valor, + valorTotal: 1234.56m, + dataEmissao: AgoraUtc.AddDays(-1), + dataAutorizacao: AgoraUtc.AddDays(-1), + SituacaoDocumento.Autorizada, + AgoraUtc).Valor; + + documento.PromoverParaXmlCompleto("xmls", "2026/07/chave.xml", "hash-original"); + + return documento; + } +} diff --git a/tests/eContabil.Application.Tests/Sincronizacao/CenarioSincronizacao.cs b/tests/eContabil.Application.Tests/Sincronizacao/CenarioSincronizacao.cs index 6094aa4..4569eb8 100644 --- a/tests/eContabil.Application.Tests/Sincronizacao/CenarioSincronizacao.cs +++ b/tests/eContabil.Application.Tests/Sincronizacao/CenarioSincronizacao.cs @@ -1,4 +1,5 @@ using eContabil.Application.Armazenamento; +using eContabil.Application.Fiscal; using eContabil.Application.Sefaz; using eContabil.Application.Sincronizacao; using eContabil.Domain; @@ -152,8 +153,17 @@ public CenarioSincronizacao ComDocumentoJaGravado(DocumentoFiscal documento) public IExecucaoSincronizacaoRepository Execucoes { get; } = Substitute.For(); + /// + /// Dublê por padrão: a leitura do XML tem testes próprios. + /// + /// + /// O extrator de verdade vive na infraestrutura, e este projeto não a referencia — é o que impede um + /// teste de sincronização de virar, sem querer, um teste de parser de XML. + /// + public IAplicadorDeDadosFiscais DadosFiscais { get; } = Substitute.For(); + public SincronizarEmpresaCommandHandler Montar(int tetoDeLotes = 20) => - new(Empresas, Certificados, Documentos, Sefaz, Storage, Execucoes, UnitOfWork, + new(Empresas, Certificados, Documentos, Sefaz, Storage, DadosFiscais, Execucoes, UnitOfWork, new FakeTimeProvider(new DateTimeOffset(AgoraUtc, TimeSpan.Zero)), Options.Create(new OpcoesSincronizacao { TetoDeLotesIncremental = tetoDeLotes }), NullLogger.Instance); diff --git a/tests/eContabil.Domain.Tests/Documentos/DadosFiscaisDoDocumentoTests.cs b/tests/eContabil.Domain.Tests/Documentos/DadosFiscaisDoDocumentoTests.cs new file mode 100644 index 0000000..e84b092 --- /dev/null +++ b/tests/eContabil.Domain.Tests/Documentos/DadosFiscaisDoDocumentoTests.cs @@ -0,0 +1,129 @@ +using eContabil.Domain.Documentos; +using eContabil.Domain.Tests.Fakes; +using eContabil.Domain.ValueObjects; +using Xunit; + +namespace eContabil.Domain.Tests.Documentos; + +/// +/// Os dados fiscais chegam por duas vias — a captura e o reprocessamento do acervo — e as duas passam +/// pela mesma porta do agregado. +/// +public class DadosFiscaisDoDocumentoTests +{ + [Fact] + public void RegistrarDadosFiscais_ComXmlArquivado_GravaNaturezaTotaisEItens() + { + var documento = ComXml(); + + var resultado = documento.RegistrarDadosFiscais( + " VENDA DE MERCADORIA ", Totais(icms: 180m), [Item(documento.Id, numero: 1, cfop: "5102")]); + + Assert.True(resultado.Sucesso); + Assert.Equal("VENDA DE MERCADORIA", documento.NaturezaOperacao); + Assert.Equal(180m, documento.Totais.ValorIcms); + Assert.Single(documento.Itens); + Assert.True(documento.DadosFiscaisExtraidos); + } + + [Fact] + public void RegistrarDadosFiscais_SemXml_RecusaEMantemODocumentoNaFila() + { + // Marcar como extraído um documento que só tem resumo o tiraria da fila do reprocessamento para + // sempre — e ele nunca seria visitado quando o XML completo enfim chegasse. + var documento = DominioFake.CriarDocumento(Guid.CreateVersion7()); + + var resultado = documento.RegistrarDadosFiscais("VENDA", Totais(), []); + + Assert.True(resultado.Falha); + Assert.False(documento.DadosFiscaisExtraidos); + Assert.Empty(documento.Itens); + } + + [Fact] + public void RegistrarDadosFiscais_ChamadoDeNovo_SubstituiOsItensEmVezDeDuplicar() + { + // O reprocessamento pode passar duas vezes pelo mesmo documento. Acumular deixaria a nota com o + // dobro dos itens e o total conferido pelo contador não bateria mais com a nota impressa. + var documento = ComXml(); + documento.RegistrarDadosFiscais("VENDA", Totais(), [Item(documento.Id, 1, "5102")]); + + documento.RegistrarDadosFiscais( + "DEVOLUCAO", Totais(icms: 90m), [Item(documento.Id, 1, "1202"), Item(documento.Id, 2, "1202")]); + + Assert.Equal(2, documento.Itens.Count); + Assert.All(documento.Itens, item => Assert.Equal("1202", item.Cfop)); + Assert.Equal("DEVOLUCAO", documento.NaturezaOperacao); + Assert.Equal(90m, documento.Totais.ValorIcms); + } + + [Fact] + public void RegistrarDadosFiscais_ComNaturezaEmBranco_GravaNuloEmVezDeTextoVazio() + { + var documento = ComXml(); + + documento.RegistrarDadosFiscais(" ", Totais(), []); + + Assert.Null(documento.NaturezaOperacao); + } + + [Fact] + public void Totais_SemExtracao_NascemZeradosEmVezDeNulos() + { + // A grade soma esses campos. Nulo obrigaria cada consulta a tratar o caso, e um deslize viraria + // total errado em vez de erro visível. + var documento = DominioFake.CriarDocumento(Guid.CreateVersion7()); + + Assert.Equal(0m, documento.Totais.ValorIcms); + Assert.False(documento.Totais.TemSubstituicaoTributaria); + } + + [Fact] + public void AtualizarHashDoXml_SemXmlArquivado_NaoGravaNada() + { + var documento = DominioFake.CriarDocumento(Guid.CreateVersion7()); + + documento.AtualizarHashDoXml("hash-novo"); + + Assert.Null(documento.HashXml); + } + + [Fact] + public void AtualizarHashDoXml_ComXmlRegravado_PassaAApontarParaOConteudoAtual() + { + var documento = ComXml(); + + documento.AtualizarHashDoXml("hash-do-xml-em-claro"); + + Assert.Equal("hash-do-xml-em-claro", documento.HashXml); + } + + [Fact] + public void ItemDocumentoFiscal_ComCodigoFormatadoPeloEmissor_GuardaSomenteOsDigitos() + { + // Alguns emissores escrevem "5.102" no CFOP. Guardar a máscara faria a busca do contador falhar + // para umas notas e funcionar para outras, sem nenhum padrão visível. + var item = Item(Guid.CreateVersion7(), numero: 1, cfop: "5.102"); + + Assert.Equal("5102", item.Cfop); + } + + private static DocumentoFiscal ComXml() + { + var documento = DominioFake.CriarDocumento(Guid.CreateVersion7()); + documento.PromoverParaXmlCompleto("xmls", "2026/07/chave.xml", "hash-original"); + + return documento; + } + + private static TotaisFiscais Totais(decimal icms = 0m) => + new(1000m, 1000m, icms, 0m, 0m, 0m, 16.50m, 76m, 0m, 0m); + + private static ItemDocumentoFiscal Item(Guid documentoId, int numero, string cfop) => + ItemDocumentoFiscal.Criar( + documentoId, numero, "PROD-1", "PARACETAMOL", cfop, "30049099", "1300100", "CX", + quantidade: 10m, valorUnitario: 100m, valorTotal: 1000m, origemMercadoria: 0, + cstIcms: "00", baseCalculoIcms: 1000m, aliquotaIcms: 18m, valorIcms: 180m, + valorIcmsSt: 0m, valorIpi: 0m, valorPis: 16.50m, valorCofins: 76m, + DominioFake.AgoraUtc).Valor; +} diff --git a/tests/eContabil.Infrastructure.Tests/Compactacao/ConteudoGzipTests.cs b/tests/eContabil.Infrastructure.Tests/Compactacao/ConteudoGzipTests.cs new file mode 100644 index 0000000..0a4fd71 --- /dev/null +++ b/tests/eContabil.Infrastructure.Tests/Compactacao/ConteudoGzipTests.cs @@ -0,0 +1,80 @@ +using System.IO.Compression; +using System.Text; +using eContabil.Infrastructure.Compactacao; +using Xunit; + +namespace eContabil.Infrastructure.Tests.Compactacao; + +/// +/// A SEFAZ entrega os documentos comprimidos no docZip, e a biblioteca fiscal devolve esses bytes +/// como vieram. Arquivá-los direto produzia arquivos `.xml` que nenhum leitor abre. +/// +public class ConteudoGzipTests +{ + private const string Xml = """"""; + + [Fact] + public void Descompactar_ComConteudoEmGzip_DevolveOXmlOriginal() + { + var descompactado = ConteudoGzip.Descompactar(Compactar(Xml)); + + Assert.Equal(Xml, Encoding.UTF8.GetString(descompactado)); + } + + [Fact] + public void Descompactar_ComXmlEmClaro_DevolveOMesmoConteudo() + { + // Idempotência é o que permite chamar no caminho da captura e no do reprocessamento sem que + // nenhum dos dois precise saber em que estado o conteúdo chegou. + var original = Encoding.UTF8.GetBytes(Xml); + + Assert.Equal(original, ConteudoGzip.Descompactar(original)); + } + + [Fact] + public void Descompactar_ChamadoDuasVezes_NaoAlteraOResultado() + { + var uma = ConteudoGzip.Descompactar(Compactar(Xml)); + var duas = ConteudoGzip.Descompactar(uma); + + Assert.Equal(uma, duas); + } + + [Fact] + public void Descompactar_ComCabecalhoDeGzipMasCorpoQuebrado_DevolveOConteudoComoVeio() + { + // Perder o arquivo por causa de três bytes que coincidem com a assinatura seria pior que gravar + // um conteúdo que ninguém consegue ler: pelo menos ele continua lá para ser investigado. + byte[] enganoso = [0x1F, 0x8B, 0x08, 0x00, 0x99, 0x99, 0x99]; + + Assert.Equal(enganoso, ConteudoGzip.Descompactar(enganoso)); + } + + [Fact] + public void Descompactar_SemBytes_NaoLanca() + { + Assert.Empty(ConteudoGzip.Descompactar([])); + } + + [Theory] + [InlineData(new byte[] { 0x1F, 0x8B, 0x08 }, true)] + [InlineData(new byte[] { 0x1F, 0x8B, 0x00 }, false)] + [InlineData(new byte[] { 0x3C, 0x3F, 0x78 }, false)] + [InlineData(new byte[] { 0x1F, 0x8B }, false)] + public void EstaCompactado_ReconheceApenasAAssinaturaCompleta(byte[] conteudo, bool esperado) + { + Assert.Equal(esperado, ConteudoGzip.EstaCompactado(conteudo)); + } + + private static byte[] Compactar(string conteudo) + { + using var destino = new MemoryStream(); + + using (var gzip = new GZipStream(destino, CompressionMode.Compress, leaveOpen: true)) + { + gzip.Write(Encoding.UTF8.GetBytes(conteudo)); + } + + return destino.ToArray(); + } +} diff --git a/tests/eContabil.Infrastructure.Tests/Fiscal/ExtratorDeDadosFiscaisTests.cs b/tests/eContabil.Infrastructure.Tests/Fiscal/ExtratorDeDadosFiscaisTests.cs new file mode 100644 index 0000000..49e7731 --- /dev/null +++ b/tests/eContabil.Infrastructure.Tests/Fiscal/ExtratorDeDadosFiscaisTests.cs @@ -0,0 +1,160 @@ +using System.IO.Compression; +using System.Text; +using eContabil.Infrastructure.Fiscal; +using Xunit; + +namespace eContabil.Infrastructure.Tests.Fiscal; + +/// +/// O extrator é o que separa um XML de NF-e do que a grade e a tela de detalhe mostram. +/// +/// +/// Os XMLs aqui reproduzem o layout 4.00 real, inclusive o grupo polimórfico do ICMS: é justamente o +/// ponto onde um extrator ingênuo lê zero e o contador vê imposto sumido sem nenhum erro na tela. +/// +public class ExtratorDeDadosFiscaisTests +{ + private const string Ns = "http://www.portalfiscal.inf.br/nfe"; + + private readonly ExtratorDeDadosFiscais _extrator = new(); + + [Fact] + public void Extrair_ComNotaCompleta_TrazNaturezaTotaisEItens() + { + var dados = _extrator.Extrair(Bytes(NotaComDoisItens())); + + Assert.Equal("VENDA DE MERCADORIA ADQUIRIDA", dados.NaturezaOperacao); + Assert.Equal(9059.10m, dados.Totais.ValorProdutos); + Assert.Equal(149.48m, dados.Totais.ValorPis); + Assert.Equal(688.49m, dados.Totais.ValorCofins); + Assert.Equal(2, dados.Itens.Count); + } + + [Fact] + public void Extrair_ComIcmsTributado_LeCstEValoresDoGrupoCorreto() + { + // O grupo do ICMS é polimórfico: `ICMS00`, `ICMS40`, `ICMSSN102` e mais uma dúzia de variantes, + // cada uma com um subconjunto de campos. Procurar por um nome fixo devolveria zero em todas as + // outras. + var item = _extrator.Extrair(Bytes(NotaComDoisItens())).Itens[0]; + + Assert.Equal("5102", item.Cfop); + Assert.Equal("30049099", item.Ncm); + Assert.Equal("00", item.CstIcms); + Assert.Equal(1000.00m, item.BaseCalculoIcms); + Assert.Equal(18.00m, item.AliquotaIcms); + Assert.Equal(180.00m, item.ValorIcms); + Assert.Equal(0, item.OrigemMercadoria); + } + + [Fact] + public void Extrair_ComSimplesNacional_LeOCsosnNoLugarDoCst() + { + // CSOSN e CST ocupam o mesmo lugar e nunca coexistem. Ler só `CST` deixaria toda nota emitida + // por empresa do Simples sem classificação nenhuma. + var item = _extrator.Extrair(Bytes(NotaComDoisItens())).Itens[1]; + + Assert.Equal("102", item.CstIcms); + Assert.Equal(6, item.OrigemMercadoria); + } + + [Fact] + public void Extrair_ComXmlCompactado_DescompactaAntesDeLer() + { + // É como a SEFAZ entrega no `docZip`, e como o acervo anterior à correção está no storage. + var dados = _extrator.Extrair(Compactar(NotaComDoisItens())); + + Assert.Equal(2, dados.Itens.Count); + Assert.Equal(9059.10m, dados.Totais.ValorProdutos); + } + + [Theory] + [InlineData("")] + [InlineData("")] + [InlineData("erro do proxy")] + public void Extrair_ComConteudoInaproveitavel_DevolveVazioSemLancar(string conteudo) + { + // Um documento malformado no lote não pode derrubar a captura dos outros quarenta e nove. + var dados = _extrator.Extrair(Bytes(conteudo)); + + Assert.Empty(dados.Itens); + Assert.Equal(string.Empty, dados.NaturezaOperacao); + Assert.Equal(0m, dados.Totais.ValorProdutos); + } + + [Fact] + public void Extrair_SemBytes_DevolveVazio() + { + Assert.Empty(_extrator.Extrair([]).Itens); + } + + [Fact] + public void Extrair_ComValorDecimal_UsaPontoIndependenteDaCulturaDoServidor() + { + // O layout da NF-e fixa ponto como separador. Num servidor em pt-BR, um parse dependente de + // cultura leria 1234.56 como 123456 — e o valor do documento ficaria cem vezes maior. + var item = _extrator.Extrair(Bytes(NotaComDoisItens())).Itens[0]; + + Assert.Equal(1000.00m, item.ValorTotal); + Assert.Equal(10.5000m, item.Quantidade); + } + + private static byte[] Bytes(string conteudo) => Encoding.UTF8.GetBytes(conteudo); + + private static byte[] Compactar(string conteudo) + { + using var destino = new MemoryStream(); + + using (var gzip = new GZipStream(destino, CompressionMode.Compress, leaveOpen: true)) + { + gzip.Write(Bytes(conteudo)); + } + + return destino.ToArray(); + } + + private static string NotaComDoisItens() => + $""" + + + + VENDA DE MERCADORIA ADQUIRIDA55266464 + + + PROD-1PARACETAMOL 500MG + 3004909913001005102 + CX10.500095.23809500001000.00 + + + + 0003 + 1000.0018.00180.00 + + 50.00 + 16.50 + 76.00 + + + + + PROD-2DIPIRONA 1G + 300420995405 + UN200.000040.29550000008059.10 + + + 6102 + 07 + + + + + 1000.00180.000.000.00 + 9059.1025.0010.00 + 50.00149.48688.499074.10 + + + + + + """; +} diff --git a/tests/eContabil.Infrastructure.Tests/Persistencia/DocumentoConsultasTests.cs b/tests/eContabil.Infrastructure.Tests/Persistencia/DocumentoConsultasTests.cs index 1f01366..309624e 100644 --- a/tests/eContabil.Infrastructure.Tests/Persistencia/DocumentoConsultasTests.cs +++ b/tests/eContabil.Infrastructure.Tests/Persistencia/DocumentoConsultasTests.cs @@ -376,6 +376,222 @@ public async Task ObterDetalheAsync_ComIdentificadorDesconhecido_DevolveNulo() Assert.Null(detalhe); } + [Fact] + public async Task ListarAsync_ComFiltroDeCfop_TrazSomenteAsNotasQueContemAquelaLinha() + { + // CFOP é de item, não de nota: a pergunta é "quais notas contêm alguma linha com este código". + if (postgres.MotivoIndisponivel is { } motivo) + { + Assert.Skip(motivo); + return; + } + + var empresaId = await PrepararComItensAsync(); + await using var contexto = postgres.CriarContexto(); + var consultas = new DocumentoConsultas(contexto); + + var pagina = await consultas.ListarAsync( + new FiltroDocumentos(EmpresaId: empresaId, Cfop: "5102"), + TestContext.Current.CancellationToken); + + Assert.Single(pagina.Itens); + } + + [Fact] + public async Task ListarAsync_ComCfopParcial_BuscaPorPrefixo() + { + // "5" traz toda saída e "1" toda entrada. É como o contador de fato busca, e o índice atende. + if (postgres.MotivoIndisponivel is { } motivo) + { + Assert.Skip(motivo); + return; + } + + var empresaId = await PrepararComItensAsync(); + await using var contexto = postgres.CriarContexto(); + var consultas = new DocumentoConsultas(contexto); + + var saidas = await consultas.ListarAsync( + new FiltroDocumentos(EmpresaId: empresaId, Cfop: "5"), TestContext.Current.CancellationToken); + + Assert.Equal(2, saidas.Itens.Count); + } + + [Fact] + public async Task ListarAsync_ComCfopMascarado_IgnoraAPontuacaoDigitada() + { + // Alguns emissores escrevem "5.102" na nota, e é isso que o contador copia para o filtro. + if (postgres.MotivoIndisponivel is { } motivo) + { + Assert.Skip(motivo); + return; + } + + var empresaId = await PrepararComItensAsync(); + await using var contexto = postgres.CriarContexto(); + var consultas = new DocumentoConsultas(contexto); + + var pagina = await consultas.ListarAsync( + new FiltroDocumentos(EmpresaId: empresaId, Cfop: "5.102"), + TestContext.Current.CancellationToken); + + Assert.Single(pagina.Itens); + } + + [Fact] + public async Task ListarAsync_ComCfopENcm_ExigeOsDoisSemPrenderAMesmaLinha() + { + // Uma condição só pediria uma linha que satisfizesse ambos; a pergunta é por notas que tenham + // aquele CFOP **e** aquela mercadoria, não necessariamente no mesmo item. + if (postgres.MotivoIndisponivel is { } motivo) + { + Assert.Skip(motivo); + return; + } + + var empresaId = await PrepararComItensAsync(); + await using var contexto = postgres.CriarContexto(); + var consultas = new DocumentoConsultas(contexto); + + var pagina = await consultas.ListarAsync( + new FiltroDocumentos(EmpresaId: empresaId, Cfop: "5405", Ncm: "30049099"), + TestContext.Current.CancellationToken); + + Assert.Single(pagina.Itens); + } + + [Fact] + public async Task ResumirAsync_ComFiltroFiscal_ContaOsDocumentosQueFicaramDeForaPorNaoTerXml() + { + // Sem esse número na tela, o contador conclui que a nota sumiu do sistema — quando ela está lá, + // só não tem item nenhum para comparar. + if (postgres.MotivoIndisponivel is { } motivo) + { + Assert.Skip(motivo); + return; + } + + var empresaId = await PrepararComItensAsync(); + await using var contexto = postgres.CriarContexto(); + var consultas = new DocumentoConsultas(contexto); + + var resumo = await consultas.ResumirAsync( + new FiltroDocumentos(EmpresaId: empresaId, Cfop: "5102"), + TestContext.Current.CancellationToken); + + Assert.Equal(1, resumo.Quantidade); + Assert.Equal(1, resumo.ForaDoFiltroPorFaltarXml); + } + + [Fact] + public async Task ResumirAsync_SemFiltroFiscal_NaoPagaPelaSegundaConsulta() + { + if (postgres.MotivoIndisponivel is { } motivo) + { + Assert.Skip(motivo); + return; + } + + var empresaId = await PrepararComItensAsync(); + await using var contexto = postgres.CriarContexto(); + var consultas = new DocumentoConsultas(contexto); + + var resumo = await consultas.ResumirAsync( + new FiltroDocumentos(EmpresaId: empresaId), TestContext.Current.CancellationToken); + + Assert.Equal(3, resumo.Quantidade); + Assert.Equal(0, resumo.ForaDoFiltroPorFaltarXml); + } + + [Fact] + public async Task ObterDetalheAsync_TrazOsItensNaOrdemDaNota() + { + if (postgres.MotivoIndisponivel is { } motivo) + { + Assert.Skip(motivo); + return; + } + + var empresaId = await PrepararComItensAsync(); + await using var contexto = postgres.CriarContexto(); + var consultas = new DocumentoConsultas(contexto); + + var id = await contexto.DocumentosFiscais.AsNoTracking() + .Where(documento => documento.EmpresaId == empresaId && documento.Nsu == 2001) + .Select(documento => documento.Id) + .SingleAsync(TestContext.Current.CancellationToken); + + var detalhe = await consultas.ObterDetalheAsync(id, TestContext.Current.CancellationToken); + + Assert.NotNull(detalhe); + Assert.True(detalhe.DadosFiscaisExtraidos); + Assert.Equal("VENDA", detalhe.NaturezaOperacao); + Assert.Equal(180m, detalhe.TotalIcms); + Assert.Equal([1, 2], detalhe.Itens.Select(item => item.Numero)); + Assert.Equal("5405", detalhe.Itens[1].Cfop); + } + + /// + /// Três documentos: um só com CFOP 5102, um com 5405 e 5102, e um que ficou no resumo. + /// + /// + /// O terceiro é o que prova o ponto cego dos filtros de item — ele não tem linha nenhuma para + /// comparar, e qualquer critério fiscal o exclui em silêncio. + /// + private async Task PrepararComItensAsync() + { + await postgres.LimparAsync(); + + await using var contexto = postgres.CriarContexto(); + var empresa = EsquemaTests.EmpresaValida(); + contexto.Empresas.Add(empresa); + + var comUmItem = Documento(empresa.Id, indice: 0, nsu: 2000); + comUmItem.PromoverParaXmlCompleto("xmls", "2026/07/a.xml", "hash-a"); + comUmItem.RegistrarDadosFiscais("VENDA", Totais(), [Item(comUmItem.Id, 1, "5102", "30049099")]); + + var comDoisItens = Documento(empresa.Id, indice: 1, nsu: 2001); + comDoisItens.PromoverParaXmlCompleto("xmls", "2026/07/b.xml", "hash-b"); + comDoisItens.RegistrarDadosFiscais( + "VENDA", + Totais(), + [Item(comDoisItens.Id, 1, "6102", "22030000"), Item(comDoisItens.Id, 2, "5405", "30049099")]); + + // Sem XML: nenhum filtro de item o alcança, e é ele que o aviso da grade conta. + var somenteResumo = Documento(empresa.Id, indice: 2, nsu: 2002); + + contexto.DocumentosFiscais.AddRange(comUmItem, comDoisItens, somenteResumo); + await contexto.SaveChangesAsync(TestContext.Current.CancellationToken); + + return empresa.Id; + } + + private static TotaisFiscais Totais() => + new(1000m, 1000m, 180m, 0m, 0m, 0m, 16.50m, 76m, 0m, 0m); + + private static ItemDocumentoFiscal Item(Guid documentoId, int numero, string cfop, string ncm) => + ItemDocumentoFiscal.Criar( + documentoId, numero, "PROD", "PARACETAMOL", cfop, ncm, "", "CX", + quantidade: 10m, valorUnitario: 100m, valorTotal: 1000m, origemMercadoria: 0, + cstIcms: "00", baseCalculoIcms: 1000m, aliquotaIcms: 18m, valorIcms: 180m, + valorIcmsSt: 0m, valorIpi: 0m, valorPis: 16.50m, valorCofins: 76m, BaseUtc).Valor; + + private static DocumentoFiscal Documento(Guid empresaId, int indice, long nsu) => + DocumentoFiscal.Criar( + empresaId, + ChaveAcesso.Criar(ChaveDe(indice)).Valor, + nsu, + TipoDocumento.Nfe, + PapelEmpresa.Destinatario, + OrigemConteudo.Resumo, + ParticipanteFiscal.Criar("11222333000181", "FORNECEDOR LTDA").Valor, + ParticipanteFiscal.Criar("25146091000107", "NEWNUTRITION").Valor, + valorTotal: 100m * (indice + 1), + dataEmissao: BaseUtc.AddDays(indice), + dataAutorizacao: BaseUtc.AddDays(indice), + SituacaoDocumento.Autorizada, + BaseUtc).Valor; + private async Task PrepararAsync(int quantidade) { await postgres.LimparAsync(); diff --git a/tests/eContabil.Infrastructure.Tests/Storage/MinioXmlStorageTests.cs b/tests/eContabil.Infrastructure.Tests/Storage/MinioXmlStorageTests.cs index 1447f32..af08ac5 100644 --- a/tests/eContabil.Infrastructure.Tests/Storage/MinioXmlStorageTests.cs +++ b/tests/eContabil.Infrastructure.Tests/Storage/MinioXmlStorageTests.cs @@ -1,3 +1,4 @@ +using System.IO.Compression; using System.Text; using eContabil.Infrastructure.Storage; using Xunit; @@ -173,8 +174,97 @@ public async Task SalvarAsync_ComConteudoVazio_Falha() private const string ConteudoXml = """"""; + [Fact] + public async Task ObterAsync_ComObjetoArquivadoEmGzip_DevolveOXmlLegivel() + { + // O acervo capturado por versões anteriores guarda os bytes do `docZip` como vinham da SEFAZ: + // XML comprimido com nome de `.xml`. Sem descompactar na saída, o contador baixa um arquivo que + // nenhum leitor abre — e só descobre o motivo abrindo o binário. + if (minio.MotivoIndisponivel is { } motivo) + { + Assert.Skip(motivo); + return; + } + + var storage = new MinioXmlStorage(minio.Cliente, minio.Opcoes); + var chave = ChaveDe(200); + + var arquivado = await storage.SalvarAsync( + chave, Compactado(), TestContext.Current.CancellationToken); + + Assert.True(arquivado.Sucesso); + + var leitura = await storage.ObterAsync( + arquivado.Valor.Bucket, arquivado.Valor.ObjectName, TestContext.Current.CancellationToken); + + Assert.True(leitura.Sucesso); + + using var conteudo = new StreamReader(leitura.Valor, Encoding.UTF8); + + Assert.Equal(ConteudoXml, await conteudo.ReadToEndAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task SubstituirAsync_TrocaOConteudoEDevolveONovoHash() + { + // Contrapartida deliberada de `SalvarAsync`, que nunca regrava: o reprocessamento precisa + // reescrever no lugar o arquivo que está em gzip. + if (minio.MotivoIndisponivel is { } motivo) + { + Assert.Skip(motivo); + return; + } + + var storage = new MinioXmlStorage(minio.Cliente, minio.Opcoes); + var chave = ChaveDe(201); + + var original = await storage.SalvarAsync( + chave, Compactado(), TestContext.Current.CancellationToken); + + var regravado = await storage.SubstituirAsync( + original.Valor.Bucket, + original.Valor.ObjectName, + Xml(), + TestContext.Current.CancellationToken); + + Assert.True(regravado.Sucesso); + Assert.NotEqual(original.Valor.HashSha256, regravado.Valor.HashSha256); + } + + [Fact] + public async Task SubstituirAsync_ComConteudoVazio_RecusaEmVezDeApagarOArquivo() + { + if (minio.MotivoIndisponivel is { } motivo) + { + Assert.Skip(motivo); + return; + } + + var storage = new MinioXmlStorage(minio.Cliente, minio.Opcoes); + + var resultado = await storage.SubstituirAsync( + "xmls", "2026/07/qualquer.xml", new MemoryStream(), TestContext.Current.CancellationToken); + + Assert.True(resultado.Falha); + } + private static MemoryStream Xml() => new(Encoding.UTF8.GetBytes(ConteudoXml)); + /// O mesmo XML, comprimido como a SEFAZ o entrega no docZip. + private static MemoryStream Compactado() + { + var destino = new MemoryStream(); + + using (var gzip = new GZipStream(destino, CompressionMode.Compress, leaveOpen: true)) + { + gzip.Write(Encoding.UTF8.GetBytes(ConteudoXml)); + } + + destino.Position = 0; + + return destino; + } + /// /// Cada teste usa a sua própria chave. ///