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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -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.
Expand Down Expand Up @@ -83,6 +88,9 @@ curl "http://localhost:8080/api/v1/documentos?empresaId=<id>"
curl "http://localhost:8080/api/v1/documentos/<documentoId>/detalhe"
curl -OJ "http://localhost:8080/api/v1/documentos/<documentoId>/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=<id>"

Expand Down
4 changes: 4 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>"
```

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
Expand Down
14 changes: 12 additions & 2 deletions src/eContabil.Api/Controllers/DocumentosController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,17 @@ public async Task<ActionResult<KeysetPage<DocumentoListaDto>>> 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));
}
Expand All @@ -60,10 +65,15 @@ public async Task<ActionResult<ResumoDocumentosDto>> 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));
}
Expand Down
18 changes: 18 additions & 0 deletions src/eContabil.Api/Controllers/JobsController.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -105,6 +106,23 @@ public async Task<ActionResult<ResultadoManifestacao>> Manifestar(
Guid empresaId, CancellationToken ct) =>
Responder(await mediator.Send(new ManifestarCienciaCommand(empresaId), ct));

/// <summary>
/// Relê os XMLs já arquivados para extrair deles os dados fiscais.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[HttpPost("reprocessar-acervo")]
[ProducesResponseType<ResultadoReprocessamento>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status503ServiceUnavailable)]
public async Task<ActionResult<ResultadoReprocessamento>> ReprocessarAcervo(
[FromQuery] int limite = 200, CancellationToken ct = default) =>
Responder(await mediator.Send(new ReprocessarAcervoCommand(limite), ct));

/// <summary>
/// Histórico das últimas execuções de sincronização.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -36,6 +37,9 @@ public static IServiceCollection AddAplicacao(
// de um chamador vazaria para outro.
services.AddScoped<NotificationContext>();

// Sem estado próprio: a mesma instância serve a captura e ao reprocessamento do acervo.
services.AddSingleton<IAplicadorDeDadosFiscais, AplicadorDeDadosFiscais>();

return services;
}
}
21 changes: 20 additions & 1 deletion src/eContabil.Application/Armazenamento/IXmlStorage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,27 @@ public interface IXmlStorage
Task<Result<ObjetoArmazenadoDto>> SalvarEventoAsync(
string chaveAcesso, long nsu, Stream xml, CancellationToken ct);

/// <summary>Abre o objeto para leitura em fluxo, sem carregá-lo inteiro em memória.</summary>
/// <summary>
/// Abre o objeto para leitura, já em XML legível.
/// </summary>
/// <remarks>
/// O que estiver arquivado compactado é descompactado na saída: versões anteriores gravavam os bytes
/// do <c>docZip</c> como vinham da SEFAZ, e o acervo delas continua no bucket até o reprocessamento
/// passar.
/// </remarks>
Task<Result<Stream>> ObterAsync(string bucket, string objectName, CancellationToken ct);

/// <summary>
/// Substitui o conteúdo de um objeto que já existe, devolvendo o novo resumo criptográfico.
/// </summary>
/// <remarks>
/// Contrapartida deliberada de <see cref="SalvarAsync"/>, 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.
/// </remarks>
Task<Result<ObjetoArmazenadoDto>> SubstituirAsync(
string bucket, string objectName, Stream conteudo, CancellationToken ct);

Task<bool> ExisteAsync(string bucket, string objectName, CancellationToken ct);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,13 @@ namespace eContabil.Application.Documentos.Consultas;
/// Documento na tela de detalhe.
/// </summary>
/// <remarks>
/// 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 é <c>DadosFiscaisExtraidos</c> que separa "sem imposto" de "ainda não
/// sabemos" — a tela precisa dizer qual dos dois é, ou o contador lê zero como fato.
/// </remarks>
public sealed record DocumentoDetalheDto(
Guid Id,
Expand Down Expand Up @@ -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<ItemDocumentoDto> Itens);
Original file line number Diff line number Diff line change
Expand Up @@ -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
/// <see cref="Cursor"/> é opaco — quem consome devolve o valor recebido sem interpretá-lo, o que
/// permite mudar a estratégia de paginação sem quebrar cliente.
///
/// <see cref="Cfop"/>, <see cref="Ncm"/> e <see cref="CstIcms"/> 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.
/// </remarks>
public sealed record FiltroDocumentos(
Guid? EmpresaId = null,
Expand All @@ -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)
{
/// <summary>Diz se algum critério depende de dados que só existem no XML completo.</summary>
public bool ExigeDadosFiscais =>
!string.IsNullOrWhiteSpace(Cfop)
|| !string.IsNullOrWhiteSpace(Ncm)
|| !string.IsNullOrWhiteSpace(CstIcms)
|| ComSubstituicaoTributaria is not null;
}
30 changes: 30 additions & 0 deletions src/eContabil.Application/Documentos/Consultas/ItemDocumentoDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
namespace eContabil.Application.Documentos.Consultas;

/// <summary>
/// Uma linha de produto na tela de detalhe.
/// </summary>
/// <remarks>
/// É 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.
/// </remarks>
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);
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,18 @@ namespace eContabil.Application.Documentos.Consultas;
/// <remarks>
/// 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.
///
/// <c>ForaDoFiltroPorFaltarXml</c> 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.
/// </remarks>
public sealed record ResumoDocumentosDto(
int Quantidade,
decimal ValorTotal,
int ComXmlCompleto,
int SomenteResumo,
int Entradas,
int Saidas);
int Saidas,
int ForaDoFiltroPorFaltarXml);
54 changes: 54 additions & 0 deletions src/eContabil.Application/Fiscal/AplicadorDeDadosFiscais.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using eContabil.Domain.Documentos;

namespace eContabil.Application.Fiscal;

/// <inheritdoc />
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;
}
}
16 changes: 16 additions & 0 deletions src/eContabil.Application/Fiscal/DadosFiscaisDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using eContabil.Domain.ValueObjects;

namespace eContabil.Application.Fiscal;

/// <summary>Tudo o que se extrai de um XML de NF-e além do que já vem no cabeçalho do lote.</summary>
public sealed record DadosFiscaisDto(
string NaturezaOperacao,
TotaisFiscais Totais,
IReadOnlyList<ItemFiscalDto> Itens)
{
/// <remarks>
/// 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.
/// </remarks>
public static DadosFiscaisDto Vazio => new(string.Empty, TotaisFiscais.Vazios, []);
}
17 changes: 17 additions & 0 deletions src/eContabil.Application/Fiscal/IAplicadorDeDadosFiscais.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using eContabil.Domain.Documentos;

namespace eContabil.Application.Fiscal;

/// <summary>Lê o XML e grava no documento a natureza da operação, os totais e os itens.</summary>
public interface IAplicadorDeDadosFiscais
{
/// <summary>
/// Aplica ao documento o que o XML disser. Devolve quantos itens foram gravados.
/// </summary>
/// <remarks>
/// É 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.
/// </remarks>
int Aplicar(DocumentoFiscal documento, byte[] xml, DateTime agoraUtc);
}
Loading
Loading