From 87265abedbe39456aaee14e3c76cd6f80a694c0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Henrique=20Honjoya?= Date: Thu, 2 Jul 2026 21:28:17 -0300 Subject: [PATCH 1/2] feat: danfe tipo 2 --- DanfeSharp.Test/DanfeSharp.Test.csproj | 1 + .../DanfeSimplificadoTipo2Tests.cs | 421 ++++++++++++++++++ .../BlocoCabecalhoSimplificadoT2.cs | 60 +++ .../SimplificadoTipo2/BlocoConsultaChaveT2.cs | 34 ++ .../SimplificadoTipo2/BlocoConsumidorT2.cs | 58 +++ .../SimplificadoTipo2/BlocoIdentificacaoT2.cs | 42 ++ .../BlocoMensagemContingenciaT2.cs | 36 ++ .../SimplificadoTipo2/BlocoMensagensT2.cs | 70 +++ .../Blocos/SimplificadoTipo2/BlocoQrCodeT2.cs | 56 +++ .../BlocoTotaisSimplificadoT2.cs | 79 ++++ .../BlocoTributosIbsCbsT2.cs | 81 ++++ .../TabelaProdutosSimplificadoT2.cs | 63 +++ DanfeSharp/DanfeSimplificadoTipo2.cs | 221 +++++++++ DanfeSharp/Extentions.cs | 13 + DanfeSharp/Modelo/DanfeViewModel.cs | 57 +++ DanfeSharp/Modelo/TributosIbsCbsViewModel.cs | 41 ++ 16 files changed, 1333 insertions(+) create mode 100644 DanfeSharp.Test/DanfeSimplificadoTipo2Tests.cs create mode 100644 DanfeSharp/Blocos/SimplificadoTipo2/BlocoCabecalhoSimplificadoT2.cs create mode 100644 DanfeSharp/Blocos/SimplificadoTipo2/BlocoConsultaChaveT2.cs create mode 100644 DanfeSharp/Blocos/SimplificadoTipo2/BlocoConsumidorT2.cs create mode 100644 DanfeSharp/Blocos/SimplificadoTipo2/BlocoIdentificacaoT2.cs create mode 100644 DanfeSharp/Blocos/SimplificadoTipo2/BlocoMensagemContingenciaT2.cs create mode 100644 DanfeSharp/Blocos/SimplificadoTipo2/BlocoMensagensT2.cs create mode 100644 DanfeSharp/Blocos/SimplificadoTipo2/BlocoQrCodeT2.cs create mode 100644 DanfeSharp/Blocos/SimplificadoTipo2/BlocoTotaisSimplificadoT2.cs create mode 100644 DanfeSharp/Blocos/SimplificadoTipo2/BlocoTributosIbsCbsT2.cs create mode 100644 DanfeSharp/Blocos/SimplificadoTipo2/TabelaProdutosSimplificadoT2.cs create mode 100644 DanfeSharp/DanfeSimplificadoTipo2.cs create mode 100644 DanfeSharp/Modelo/TributosIbsCbsViewModel.cs diff --git a/DanfeSharp.Test/DanfeSharp.Test.csproj b/DanfeSharp.Test/DanfeSharp.Test.csproj index 353f536..d3fbd8d 100644 --- a/DanfeSharp.Test/DanfeSharp.Test.csproj +++ b/DanfeSharp.Test/DanfeSharp.Test.csproj @@ -63,6 +63,7 @@ + diff --git a/DanfeSharp.Test/DanfeSimplificadoTipo2Tests.cs b/DanfeSharp.Test/DanfeSimplificadoTipo2Tests.cs new file mode 100644 index 0000000..ccb1fb6 --- /dev/null +++ b/DanfeSharp.Test/DanfeSimplificadoTipo2Tests.cs @@ -0,0 +1,421 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Text.RegularExpressions; +using DanfeSharp.Esquemas.NFe; +using DanfeSharp.Modelo; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using org.pdfclown.tools; +using PdfFile = org.pdfclown.files.File; + +namespace DanfeSharp.Test +{ + /// + /// Testes do DANFE Simplificado Tipo 2 (NT 2026.003, tpImp=6) — 9 divisões + /// renderizadas pela classe DanfeSimplificadoTipo2. + /// See openspec/specs/danfe-simplificado-tipo-2/spec.md + /// + [TestClass] + public class DanfeSimplificadoTipo2Tests + { + public readonly string OutputDirectory = Path.Combine("Output", "SimplificadoTipo2"); + + public DanfeSimplificadoTipo2Tests() + { + if (!Directory.Exists(OutputDirectory)) + Directory.CreateDirectory(OutputDirectory); + } + + private static DanfeViewModel CriarViewModelTipo2() + { + var m = new DanfeViewModel + { + NfNumero = 123456, + NfSerie = 1, + ChaveAcesso = "35260607952851000109550010001234561000123459", + TipoAmbiente = 1, + TipoEmissao = FormaEmissao.Normal, + ProtocoloAutorizacao = "135260000012345 01/07/2026 10:30:00", + DataHoraEmissao = new DateTime(2026, 7, 1, 10, 15, 0), + EndConsulta = "SP".UrlNFeConsulta(1), + Emitente = new EmpresaViewModel + { + CnpjCpf = "07952851000109", + RazaoSocial = "Mercado Tupã Ltda", + EnderecoLogadrouro = "Avenida Brasil", + EnderecoNumero = "1000", + EnderecoBairro = "Centro", + EnderecoUf = "SP", + Municipio = "São Paulo" + }, + Destinatario = new EmpresaViewModel + { + CnpjCpf = "12345678909", + RazaoSocial = "João da Silva", + EnderecoLogadrouro = "Rua das Flores", + EnderecoNumero = "42", + EnderecoBairro = "Jardim", + EnderecoUf = "SP", + Municipio = "São Paulo" + }, + Produtos = new List + { + new ProdutoViewModel + { + Codigo = "001", + Descricao = "Arroz Tipo 1 5kg", + Quantidade = 2, + Unidade = "UN", + ValorUnitario = 25.50, + ValorTotal = 51.00 + } + }, + Pagamento = new List + { + new PagamentoViewModel + { + Troco = 9.00m, + DetalhePagamento = new List + { + new DetalheViewModel { FormaPagamento = FormaPagamento.fpDinheiro, Valor = 60.00m } + } + } + } + }; + + m.CalculoImposto.QuantidadeTotal = 2; + m.CalculoImposto.ValorTotalProdutos = 51.00; + m.CalculoImposto.ValorTotalNota = 51.00; + + return m; + } + + private string GerarPdfETexto(DanfeViewModel model, string outFileName) + { + var pdfPath = Path.Combine(OutputDirectory, outFileName); + using (var danfe = new DanfeSimplificadoTipo2(model)) + { + danfe.Gerar(); + danfe.Salvar(pdfPath); + } + return pdfPath; + } + + private static string ExtrairTextoPdf(string pdfPath) + { + var sb = new StringBuilder(); + using (var file = new PdfFile(pdfPath)) + { + var extractor = new TextExtractor(); + foreach (var page in file.Document.Pages) + { + var areaTextStrings = extractor.Extract(page); + foreach (var pair in areaTextStrings) + { + foreach (var textString in pair.Value) + { + sb.AppendLine(textString.Text); + } + } + } + } + return sb.ToString(); + } + + private static int ContarPaginas(string pdfPath) + { + using (var file = new PdfFile(pdfPath)) + { + return file.Document.Pages.Count; + } + } + + // === Divisão I — Cabeçalho === + + [TestMethod] + public void DivisaoI_CabecalhoContemTituloEmitenteECnpj() + { + var texto = ExtrairTextoPdf(GerarPdfETexto(CriarViewModelTipo2(), "DivisaoI_Cabecalho.pdf")); + + StringAssert.Contains(texto, "DANFE Simplificado Tipo 2"); + StringAssert.Contains(texto, "Mercado Tupã Ltda"); + StringAssert.Contains(texto, "07.952.851/0001-09"); + } + + // === Divisões II e III — Produtos e Totais === + + [TestMethod] + public void DivisaoII_ProdutoRenderizado() + { + var texto = ExtrairTextoPdf(GerarPdfETexto(CriarViewModelTipo2(), "DivisaoII_Produtos.pdf")); + + StringAssert.Contains(texto, "Arroz Tipo 1 5kg"); + StringAssert.Contains(texto, "QTD. TOTAL DE ITENS"); + } + + [TestMethod] + public void DivisaoIII_TrocoSemprePresente_MesmoSemValor() + { + var model = CriarViewModelTipo2(); + model.Pagamento[0].Troco = null; + + var texto = ExtrairTextoPdf(GerarPdfETexto(model, "DivisaoIII_TrocoZerado.pdf")); + + StringAssert.Contains(texto, "TROCO"); + StringAssert.Contains(texto, "VALOR PAGO"); + } + + [TestMethod] + public void DivisaoIII_FormaPagamentoEValores() + { + var texto = ExtrairTextoPdf(GerarPdfETexto(CriarViewModelTipo2(), "DivisaoIII_Pagamento.pdf")); + + StringAssert.Contains(texto, "Dinheiro"); + StringAssert.Contains(texto, "60,00"); + StringAssert.Contains(texto, "9,00"); + } + + // === Divisão III-A — IBS/CBS/IS === + + [TestMethod] + public void DivisaoIIIA_ComGrupoIbsCbs_RenderizaRotulosEValores() + { + var model = CriarViewModelTipo2(); + model.TributosIbsCbs = new TributosIbsCbsViewModel + { + BaseCalculo = 51.00m, + ValorIbs = 5.10m, + ValorIbsUF = 3.06m, + ValorIbsMun = 2.04m, + ValorCbs = 4.59m + }; + + var texto = ExtrairTextoPdf(GerarPdfETexto(model, "DivisaoIIIA_ComIbsCbs.pdf")); + + StringAssert.Contains(texto, "TRIBUTOS IBS/CBS"); + StringAssert.Contains(texto, "IBS"); + StringAssert.Contains(texto, "CBS"); + StringAssert.Contains(texto, "5,10"); + StringAssert.Contains(texto, "4,59"); + } + + [TestMethod] + public void DivisaoIIIA_SemGrupoIbsCbs_DivisaoOmitida() + { + var model = CriarViewModelTipo2(); + model.TributosIbsCbs = null; + + var texto = ExtrairTextoPdf(GerarPdfETexto(model, "DivisaoIIIA_SemIbsCbs.pdf")); + + Assert.IsFalse(texto.Contains("TRIBUTOS IBS/CBS"), + "Divisão III-A deve ser omitida quando o XML não tem o grupo IBSCBSTot (fidelidade ao XML)."); + } + + [TestMethod] + public void DivisaoIIIA_ImpostoSeletivo_QuandoPresente() + { + var model = CriarViewModelTipo2(); + model.TributosIbsCbs = new TributosIbsCbsViewModel { ValorIS = 1.23m }; + + var texto = ExtrairTextoPdf(GerarPdfETexto(model, "DivisaoIIIA_ComIS.pdf")); + + StringAssert.Contains(texto, "IMPOSTO SELETIVO (IS)"); + } + + // === Divisão IV — Consulta via chave === + + [TestMethod] + public void DivisaoIV_ChaveEmOnzeBlocosDeQuatroDigitos() + { + var model = CriarViewModelTipo2(); + var texto = ExtrairTextoPdf(GerarPdfETexto(model, "DivisaoIV_Chave.pdf")); + + StringAssert.Contains(texto, model.ChaveAcesso.SpaceOnAccessKey(), + "Chave deve aparecer formatada em 11 blocos de 4 dígitos separados por espaço."); + StringAssert.Contains(texto, "CHAVE DE ACESSO"); + StringAssert.Contains(texto, model.EndConsulta); + } + + // === Divisão V — QR Code === + + [TestMethod] + public void DivisaoV_ComQrCode_RenderizaSemExcecao() + { + var model = CriarViewModelTipo2(); + model.QrCode = "https://www.nfe.fazenda.gov.br/portal/consultaRecaptcha.aspx?p=35260607952851000109550010001234561000123459|3|1|1|ABCD"; + + var texto = ExtrairTextoPdf(GerarPdfETexto(model, "DivisaoV_ComQr.pdf")); + + StringAssert.Contains(texto, "CONSULTA VIA LEITOR DE QR CODE"); + } + + [TestMethod] + public void DivisaoV_SemQrCode_OmiteDivisaoSemExcecao() + { + var model = CriarViewModelTipo2(); + model.QrCode = null; + + var texto = ExtrairTextoPdf(GerarPdfETexto(model, "DivisaoV_SemQr.pdf")); + + Assert.IsFalse(texto.Contains("CONSULTA VIA LEITOR DE QR CODE"), + "Sem conteúdo de QR o bloco deve ser omitido, sem erro."); + } + + // === Divisão VI — Consumidor === + + [TestMethod] + public void DivisaoVI_ConsumidorComCpf() + { + var texto = ExtrairTextoPdf(GerarPdfETexto(CriarViewModelTipo2(), "DivisaoVI_ConsumidorCpf.pdf")); + + StringAssert.Contains(texto, "CONSUMIDOR CPF:"); + StringAssert.Contains(texto, "João da Silva"); + } + + [TestMethod] + public void DivisaoVI_ConsumidorComCnpj() + { + var model = CriarViewModelTipo2(); + model.Destinatario.CnpjCpf = "07952851000109"; + + var texto = ExtrairTextoPdf(GerarPdfETexto(model, "DivisaoVI_ConsumidorCnpj.pdf")); + + StringAssert.Contains(texto, "CONSUMIDOR CNPJ:"); + } + + [TestMethod] + public void DivisaoVI_ConsumidorNaoIdentificado() + { + var model = CriarViewModelTipo2(); + model.Destinatario = null; + + var texto = ExtrairTextoPdf(GerarPdfETexto(model, "DivisaoVI_NaoIdentificado.pdf")); + + StringAssert.Contains(texto, "CONSUMIDOR NÃO IDENTIFICADO"); + } + + // === Divisão VII — Identificação da NF-e === + + [TestMethod] + public void DivisaoVII_NumeroSerieProtocoloEVia() + { + var texto = ExtrairTextoPdf(GerarPdfETexto(CriarViewModelTipo2(), "DivisaoVII_Identificacao.pdf")); + + StringAssert.Contains(texto, "123456"); + StringAssert.Contains(texto, "Série: 1"); + StringAssert.Contains(texto, "PROTOCOLO DE AUTORIZAÇÃO"); + StringAssert.Contains(texto, "135260000012345"); + StringAssert.Contains(texto, "Via do Consumidor"); + } + + // === Divisão VIII — Homologação e Contingência === + + [TestMethod] + public void DivisaoVIII_Homologacao_ExibeMensagemSemValorFiscal() + { + var model = CriarViewModelTipo2(); + model.TipoAmbiente = 2; + + var texto = ExtrairTextoPdf(GerarPdfETexto(model, "DivisaoVIII_Homologacao.pdf")); + + StringAssert.Contains(texto, "EMITIDA EM AMBIENTE DE HOMOLOGAÇÃO - SEM VALOR FISCAL"); + } + + [TestMethod] + public void DivisaoVIII_ContingenciaPendente_MensagemEmDoisLocaisESegundaVia() + { + var model = CriarViewModelTipo2(); + model.TipoEmissao = FormaEmissao.ContingenciaFSDA; + model.ProtocoloAutorizacao = null; + + var path = GerarPdfETexto(model, "DivisaoVIII_Contingencia.pdf"); + var texto = ExtrairTextoPdf(path); + + Assert.AreEqual(2, ContarPaginas(path), "Contingência pendente deve gerar 2 vias (2 páginas)."); + + var ocorrencias = Regex.Matches(texto, "EMITIDA EM CONTINGÊNCIA").Count; + Assert.AreEqual(4, ocorrencias, "Mensagem deve aparecer em 2 locais por via × 2 vias = 4 ocorrências."); + + StringAssert.Contains(texto, "PENDENTE DE AUTORIZAÇÃO"); + StringAssert.Contains(texto, "Via do Consumidor"); + StringAssert.Contains(texto, "Via do Estabelecimento"); + } + + [TestMethod] + public void DivisaoVIII_EmissaoNormal_SemContingenciaEUmaViaSo() + { + var path = GerarPdfETexto(CriarViewModelTipo2(), "DivisaoVIII_Normal.pdf"); + var texto = ExtrairTextoPdf(path); + + Assert.AreEqual(1, ContarPaginas(path), "Emissão normal gera via única."); + Assert.IsFalse(texto.Contains("EMITIDA EM CONTINGÊNCIA")); + } + + [TestMethod] + public void DivisaoVIII_ContingenciaJaAutorizada_NaoExibeMensagemPendente() + { + // Autorizada após contingência: com protocolo presente não há pendência — + // mensagem e segunda via não se aplicam. + var model = CriarViewModelTipo2(); + model.TipoEmissao = FormaEmissao.ContingenciaFSDA; + + var path = GerarPdfETexto(model, "DivisaoVIII_ContingenciaAutorizada.pdf"); + var texto = ExtrairTextoPdf(path); + + Assert.AreEqual(1, ContarPaginas(path)); + Assert.IsFalse(texto.Contains("EMITIDA EM CONTINGÊNCIA")); + } + + // === Divisão IX — Mensagem do contribuinte === + + [TestMethod] + public void DivisaoIX_Lei12741EInformacoesComplementares() + { + var model = CriarViewModelTipo2(); + model.CalculoImposto.ValorAproximadoTributos = 9.18; + model.InformacoesComplementares = "Obrigado pela preferência!"; + model.InformacoesAdicionaisFisco = "Documento emitido conforme NT 2026.003."; + + var texto = ExtrairTextoPdf(GerarPdfETexto(model, "DivisaoIX_Mensagens.pdf")); + + StringAssert.Contains(texto, "LEI 12.741/2012"); + StringAssert.Contains(texto, "Obrigado pela preferência!"); + StringAssert.Contains(texto, "Documento emitido conforme NT 2026.003."); + } + + // === Smoke de altura dinâmica === + + [DataTestMethod] + [DataRow(1)] + [DataRow(30)] + [DataRow(200)] + public void Smoke_AlturaDinamica_GeraSemExcecao(int quantidadeProdutos) + { + var model = CriarViewModelTipo2(); + model.Produtos = new List(); + + for (int i = 1; i <= quantidadeProdutos; i++) + { + model.Produtos.Add(new ProdutoViewModel + { + Codigo = i.ToString(), + Descricao = $"Produto da linha {i}", + Quantidade = 1, + Unidade = "UN", + ValorUnitario = 1.00, + ValorTotal = 1.00 + }); + } + + var path = GerarPdfETexto(model, $"Smoke_{quantidadeProdutos}itens.pdf"); + + Assert.IsTrue(File.Exists(path)); + Assert.IsTrue(new FileInfo(path).Length > 0); + + var texto = ExtrairTextoPdf(path); + StringAssert.Contains(texto, $"Produto da linha {quantidadeProdutos}", + "Último produto deve estar dentro da página (altura estimada suficiente)."); + } + } +} diff --git a/DanfeSharp/Blocos/SimplificadoTipo2/BlocoCabecalhoSimplificadoT2.cs b/DanfeSharp/Blocos/SimplificadoTipo2/BlocoCabecalhoSimplificadoT2.cs new file mode 100644 index 0000000..d33602f --- /dev/null +++ b/DanfeSharp/Blocos/SimplificadoTipo2/BlocoCabecalhoSimplificadoT2.cs @@ -0,0 +1,60 @@ +using DanfeSharp.Modelo; +using org.pdfclown.documents.contents.composition; +using System.Drawing; + +namespace DanfeSharp.Blocos.SimplificadoTipo2 +{ + /// + /// Divisão I (NT 2026.003) — identificação do emitente + título destacado + /// "DANFE Simplificado Tipo 2". + /// + internal class BlocoCabecalhoSimplificadoT2 : ElementoBase + { + public BlocoCabecalhoSimplificadoT2(DanfeViewModel viewModel, Estilo estilo, PrimitiveComposer primitiveComposer) : base(estilo) + { + primitiveComposer.BeginLocalState(); + primitiveComposer.SetFont(estilo.FonteCampoTituloNegrito.FonteInterna, estilo.FonteCampoTituloNegrito.Tamanho); + + primitiveComposer.ShowText("DANFE Simplificado Tipo 2", new PointF(140, 12), XAlignmentEnum.Center, YAlignmentEnum.Middle, 0); + + float y = 22; + + var emitente = viewModel.Emitente; + var razaoSocial = emitente.RazaoSocial ?? string.Empty; + + primitiveComposer.SetFont(estilo.FonteCampoConteudoNegrito.FonteInterna, estilo.FonteCampoConteudoNegrito.Tamanho); + + if (razaoSocial.Length > 39) + { + primitiveComposer.ShowText(razaoSocial.Substring(0, 39), new PointF(140, y), XAlignmentEnum.Center, YAlignmentEnum.Top, 0); + y += 10; + primitiveComposer.ShowText(razaoSocial.Substring(39), new PointF(140, y), XAlignmentEnum.Center, YAlignmentEnum.Top, 0); + } + else + { + primitiveComposer.ShowText(razaoSocial, new PointF(140, y), XAlignmentEnum.Center, YAlignmentEnum.Top, 0); + } + + y += 10; + primitiveComposer.ShowText($"CNPJ: {Formatador.FormatarCnpj(emitente.CnpjCpf)}", new PointF(140, y), XAlignmentEnum.Center, YAlignmentEnum.Top, 0); + + primitiveComposer.SetFont(estilo.FonteCampoConteudo.FonteInterna, estilo.FonteCampoConteudo.Tamanho); + + if (!string.IsNullOrWhiteSpace(emitente.EnderecoLogadrouro) && + !string.IsNullOrWhiteSpace(emitente.Municipio)) + { + y += 10; + primitiveComposer.ShowText($"{emitente.EnderecoLogadrouro}, {emitente.EnderecoNumero}", new PointF(140, y), XAlignmentEnum.Center, YAlignmentEnum.Top, 0); + y += 10; + primitiveComposer.ShowText($"{emitente.EnderecoBairro} - {emitente.Municipio} - {emitente.EnderecoUf}", new PointF(140, y), XAlignmentEnum.Center, YAlignmentEnum.Top, 0); + } + + Y_NFC = y + 15; + + primitiveComposer.DrawLine(new PointF(15, Y_NFC), new PointF(265, Y_NFC)); + primitiveComposer.SetLineDash(new org.pdfclown.documents.contents.LineDash(new double[] { 3, 2 })); + primitiveComposer.Stroke(); + primitiveComposer.End(); + } + } +} diff --git a/DanfeSharp/Blocos/SimplificadoTipo2/BlocoConsultaChaveT2.cs b/DanfeSharp/Blocos/SimplificadoTipo2/BlocoConsultaChaveT2.cs new file mode 100644 index 0000000..aa3d42e --- /dev/null +++ b/DanfeSharp/Blocos/SimplificadoTipo2/BlocoConsultaChaveT2.cs @@ -0,0 +1,34 @@ +using DanfeSharp.Modelo; +using org.pdfclown.documents.contents.composition; +using System.Drawing; + +namespace DanfeSharp.Blocos.SimplificadoTipo2 +{ + /// + /// Divisão IV (NT 2026.003) — consulta pela chave de acesso: URL da SEFAZ e chave + /// de 44 dígitos em 11 blocos de 4 dígitos. + /// + internal class BlocoConsultaChaveT2 : ElementoBase + { + public BlocoConsultaChaveT2(DanfeViewModel viewModel, Estilo estilo, PrimitiveComposer primitiveComposer, float y) : base(estilo) + { + primitiveComposer.BeginLocalState(); + primitiveComposer.SetFont(estilo.FonteCampoConteudo.FonteInterna, estilo.FonteCampoConteudo.Tamanho); + primitiveComposer.ShowText("Consulta pela chave de acesso em", new PointF(140, y + 10), XAlignmentEnum.Center, YAlignmentEnum.Middle, 0); + primitiveComposer.ShowText(viewModel.EndConsulta, new PointF(140, y + 20), XAlignmentEnum.Center, YAlignmentEnum.Middle, 0); + primitiveComposer.ShowText("CHAVE DE ACESSO", new PointF(140, y + 30), XAlignmentEnum.Center, YAlignmentEnum.Middle, 0); + + // Chave em 11 blocos de 4 dígitos (54 caracteres com espaços) — fonte 7 + // para caber na largura útil do cupom. + primitiveComposer.SetFont(estilo.FonteCampoConteudo.FonteInterna, 7); + primitiveComposer.ShowText(viewModel.ChaveAcesso.SpaceOnAccessKey(), new PointF(140, y + 40), XAlignmentEnum.Center, YAlignmentEnum.Middle, 0); + + Y_NFC = y + 50; + + primitiveComposer.DrawLine(new PointF(15, Y_NFC), new PointF(265, Y_NFC)); + primitiveComposer.SetLineDash(new org.pdfclown.documents.contents.LineDash(new double[] { 3, 2 })); + primitiveComposer.Stroke(); + primitiveComposer.End(); + } + } +} diff --git a/DanfeSharp/Blocos/SimplificadoTipo2/BlocoConsumidorT2.cs b/DanfeSharp/Blocos/SimplificadoTipo2/BlocoConsumidorT2.cs new file mode 100644 index 0000000..2d1c680 --- /dev/null +++ b/DanfeSharp/Blocos/SimplificadoTipo2/BlocoConsumidorT2.cs @@ -0,0 +1,58 @@ +using DanfeSharp.Modelo; +using org.pdfclown.documents.contents.composition; +using System.Drawing; + +namespace DanfeSharp.Blocos.SimplificadoTipo2 +{ + /// + /// Divisão VI (NT 2026.003) — identificação do consumidor: "CONSUMIDOR CNPJ:" ou + /// "CONSUMIDOR CPF:" com nome e endereço quando presentes no XML. A obrigatoriedade + /// em operação não presencial é validada na emissão (domínio), não aqui. + /// + internal class BlocoConsumidorT2 : ElementoBase + { + public BlocoConsumidorT2(DanfeViewModel viewModel, Estilo estilo, PrimitiveComposer primitiveComposer, float y) : base(estilo) + { + primitiveComposer.BeginLocalState(); + primitiveComposer.SetFont(estilo.FonteCampoConteudo.FonteInterna, estilo.FonteCampoConteudo.Tamanho); + + var dest = viewModel.Destinatario; + + if (dest == null || string.IsNullOrWhiteSpace(dest.CnpjCpf)) + { + Y_NFC = y + 10; + primitiveComposer.ShowText("CONSUMIDOR NÃO IDENTIFICADO", new PointF(140, Y_NFC), XAlignmentEnum.Center, YAlignmentEnum.Middle, 0); + } + else + { + var rotuloDocumento = dest.CnpjCpf.Trim().Length == 14 ? "CNPJ" : "CPF"; + + Y_NFC = y + 10; + primitiveComposer.ShowText($"CONSUMIDOR {rotuloDocumento}: {Formatador.FormatarCpfCnpj(dest.CnpjCpf)}", new PointF(140, Y_NFC), XAlignmentEnum.Center, YAlignmentEnum.Middle, 0); + + if (!string.IsNullOrWhiteSpace(dest.RazaoSocial)) + { + Y_NFC += 10; + var nome = dest.RazaoSocial.Length > 60 ? dest.RazaoSocial.Substring(0, 60) : dest.RazaoSocial; + primitiveComposer.ShowText(nome, new PointF(140, Y_NFC), XAlignmentEnum.Center, YAlignmentEnum.Middle, 0); + } + + if (!string.IsNullOrWhiteSpace(dest.EnderecoLogadrouro) && + !string.IsNullOrWhiteSpace(dest.Municipio)) + { + Y_NFC += 10; + primitiveComposer.ShowText($"{dest.EnderecoLogadrouro}, {dest.EnderecoNumero} - {dest.EnderecoBairro}", new PointF(140, Y_NFC), XAlignmentEnum.Center, YAlignmentEnum.Middle, 0); + Y_NFC += 10; + primitiveComposer.ShowText($"{dest.Municipio} - {dest.EnderecoUf}", new PointF(140, Y_NFC), XAlignmentEnum.Center, YAlignmentEnum.Middle, 0); + } + } + + Y_NFC += 10; + + primitiveComposer.DrawLine(new PointF(15, Y_NFC), new PointF(265, Y_NFC)); + primitiveComposer.SetLineDash(new org.pdfclown.documents.contents.LineDash(new double[] { 3, 2 })); + primitiveComposer.Stroke(); + primitiveComposer.End(); + } + } +} diff --git a/DanfeSharp/Blocos/SimplificadoTipo2/BlocoIdentificacaoT2.cs b/DanfeSharp/Blocos/SimplificadoTipo2/BlocoIdentificacaoT2.cs new file mode 100644 index 0000000..5601396 --- /dev/null +++ b/DanfeSharp/Blocos/SimplificadoTipo2/BlocoIdentificacaoT2.cs @@ -0,0 +1,42 @@ +using DanfeSharp.Modelo; +using org.pdfclown.documents.contents.composition; +using System.Drawing; + +namespace DanfeSharp.Blocos.SimplificadoTipo2 +{ + /// + /// Divisão VII (NT 2026.003) — identificação da NF-e: número, série, data/hora de + /// emissão em horário local, rótulo da via e protocolo de autorização (quando + /// autorizada). + /// + internal class BlocoIdentificacaoT2 : ElementoBase + { + public BlocoIdentificacaoT2(DanfeViewModel viewModel, Estilo estilo, PrimitiveComposer primitiveComposer, float y, string rotuloVia) : base(estilo) + { + primitiveComposer.BeginLocalState(); + + primitiveComposer.SetFont(estilo.FonteCampoTituloNegrito.FonteInterna, estilo.FonteCampoTituloNegrito.Tamanho); + primitiveComposer.ShowText($"NF-e Nº: {viewModel.NfNumero} Série: {viewModel.NfSerie}", new PointF(140, y + 10), XAlignmentEnum.Center, YAlignmentEnum.Middle, 0); + + primitiveComposer.SetFont(estilo.FonteCampoConteudoNegrito.FonteInterna, estilo.FonteCampoConteudoNegrito.Tamanho); + primitiveComposer.ShowText($"{viewModel.DataHoraEmissao.FormatarDataHoraWithoutGMT()} - {rotuloVia}", new PointF(140, y + 20), XAlignmentEnum.Center, YAlignmentEnum.Middle, 0); + + Y_NFC = y + 20; + + if (!string.IsNullOrWhiteSpace(viewModel.ProtocoloAutorizacao)) + { + primitiveComposer.SetFont(estilo.FonteCampoConteudo.FonteInterna, estilo.FonteCampoConteudo.Tamanho); + primitiveComposer.ShowText("PROTOCOLO DE AUTORIZAÇÃO", new PointF(140, Y_NFC + 10), XAlignmentEnum.Center, YAlignmentEnum.Middle, 0); + primitiveComposer.ShowText(viewModel.ProtocoloAutorizacao, new PointF(140, Y_NFC + 20), XAlignmentEnum.Center, YAlignmentEnum.Middle, 0); + Y_NFC += 20; + } + + Y_NFC += 10; + + primitiveComposer.DrawLine(new PointF(15, Y_NFC), new PointF(265, Y_NFC)); + primitiveComposer.SetLineDash(new org.pdfclown.documents.contents.LineDash(new double[] { 3, 2 })); + primitiveComposer.Stroke(); + primitiveComposer.End(); + } + } +} diff --git a/DanfeSharp/Blocos/SimplificadoTipo2/BlocoMensagemContingenciaT2.cs b/DanfeSharp/Blocos/SimplificadoTipo2/BlocoMensagemContingenciaT2.cs new file mode 100644 index 0000000..640c368 --- /dev/null +++ b/DanfeSharp/Blocos/SimplificadoTipo2/BlocoMensagemContingenciaT2.cs @@ -0,0 +1,36 @@ +using DanfeSharp.Modelo; +using org.pdfclown.documents.contents.composition; +using System.Drawing; + +namespace DanfeSharp.Blocos.SimplificadoTipo2 +{ + /// + /// Divisão VIII (NT 2026.003) — mensagem de contingência "EMITIDA EM CONTINGÊNCIA / + /// PENDENTE DE AUTORIZAÇÃO", centralizada, caixa alta, 2 linhas. Instanciado em dois + /// locais: abaixo do cabeçalho e abaixo da identificação da NF-e. Não renderiza nada + /// quando a nota não está pendente de autorização. + /// + internal class BlocoMensagemContingenciaT2 : ElementoBase + { + public BlocoMensagemContingenciaT2(DanfeViewModel viewModel, Estilo estilo, PrimitiveComposer primitiveComposer, float y) : base(estilo) + { + if (!viewModel.PendenteAutorizacao) + { + Y_NFC = y; + return; + } + + primitiveComposer.BeginLocalState(); + primitiveComposer.SetFont(estilo.FonteCampoConteudoNegrito.FonteInterna, estilo.FonteCampoConteudoNegrito.Tamanho); + primitiveComposer.ShowText("EMITIDA EM CONTINGÊNCIA", new PointF(140, y + 10), XAlignmentEnum.Center, YAlignmentEnum.Middle, 0); + primitiveComposer.ShowText("PENDENTE DE AUTORIZAÇÃO", new PointF(140, y + 20), XAlignmentEnum.Center, YAlignmentEnum.Middle, 0); + + Y_NFC = y + 30; + + primitiveComposer.DrawLine(new PointF(15, Y_NFC), new PointF(265, Y_NFC)); + primitiveComposer.SetLineDash(new org.pdfclown.documents.contents.LineDash(new double[] { 3, 2 })); + primitiveComposer.Stroke(); + primitiveComposer.End(); + } + } +} diff --git a/DanfeSharp/Blocos/SimplificadoTipo2/BlocoMensagensT2.cs b/DanfeSharp/Blocos/SimplificadoTipo2/BlocoMensagensT2.cs new file mode 100644 index 0000000..ceeb171 --- /dev/null +++ b/DanfeSharp/Blocos/SimplificadoTipo2/BlocoMensagensT2.cs @@ -0,0 +1,70 @@ +using DanfeSharp.Modelo; +using org.pdfclown.documents.contents.composition; +using System; +using System.Drawing; + +namespace DanfeSharp.Blocos.SimplificadoTipo2 +{ + /// + /// Divisões VIII/IX (NT 2026.003) — mensagem fiscal (infAdFisco) e mensagem do + /// contribuinte (infCpl + tributos aproximados da Lei 12.741/2012). + /// + internal class BlocoMensagensT2 : ElementoBase + { + private const int MaxLength = 80; + + public BlocoMensagensT2(DanfeViewModel viewModel, Estilo estilo, PrimitiveComposer primitiveComposer, float y) : base(estilo) + { + Y_NFC = y; + + primitiveComposer.BeginLocalState(); + + if (!string.IsNullOrWhiteSpace(viewModel.InformacoesAdicionaisFisco)) + { + primitiveComposer.SetFont(estilo.FonteCampoConteudo.FonteInterna, 7); + MostrarTextoQuebrado(primitiveComposer, viewModel.InformacoesAdicionaisFisco); + } + + if (viewModel.CalculoImposto.ValorAproximadoTributos > 0) + { + primitiveComposer.SetFont(estilo.FonteCampoConteudoNegrito.FonteInterna, 7); + + Y_NFC += 10; + primitiveComposer.ShowText($"CONFORME LEI 12.741/2012 o valor aproximado dos tributos é {viewModel.CalculoImposto.ValorAproximadoTributos.Formatar()}", new PointF(140, Y_NFC), XAlignmentEnum.Center, YAlignmentEnum.Middle, 0); + + Y_NFC += 10; + primitiveComposer.ShowText($"O valor aproximado dos tributos Federais é {viewModel.CalculoImposto.ValorAproximadoTributosFederais.Formatar()}", new PointF(140, Y_NFC), XAlignmentEnum.Center, YAlignmentEnum.Middle, 0); + + Y_NFC += 10; + primitiveComposer.ShowText($"O valor aproximado dos tributos Estaduais é {viewModel.CalculoImposto.ValorAproximadoTributosEstaduais.Formatar()}", new PointF(140, Y_NFC), XAlignmentEnum.Center, YAlignmentEnum.Middle, 0); + } + + if (!string.IsNullOrWhiteSpace(viewModel.InformacoesComplementares)) + { + primitiveComposer.SetFont(estilo.FonteCampoConteudo.FonteInterna, 7); + MostrarTextoQuebrado(primitiveComposer, viewModel.InformacoesComplementares); + } + + primitiveComposer.End(); + } + + // Quebra o texto em linhas de até MaxLength caracteres (máx. 2, como no cupom + // NFC-e), centralizadas. + private void MostrarTextoQuebrado(PrimitiveComposer primitiveComposer, string texto) + { + Y_NFC += 10; + + if (texto.Length > MaxLength) + { + primitiveComposer.ShowText(texto.Substring(0, MaxLength), new PointF(140, Y_NFC), XAlignmentEnum.Center, YAlignmentEnum.Middle, 0); + + Y_NFC += 10; + primitiveComposer.ShowText(texto.Substring(MaxLength, Math.Min(texto.Length - MaxLength, MaxLength)), new PointF(140, Y_NFC), XAlignmentEnum.Center, YAlignmentEnum.Middle, 0); + } + else + { + primitiveComposer.ShowText(texto, new PointF(140, Y_NFC), XAlignmentEnum.Center, YAlignmentEnum.Middle, 0); + } + } + } +} diff --git a/DanfeSharp/Blocos/SimplificadoTipo2/BlocoQrCodeT2.cs b/DanfeSharp/Blocos/SimplificadoTipo2/BlocoQrCodeT2.cs new file mode 100644 index 0000000..cc26676 --- /dev/null +++ b/DanfeSharp/Blocos/SimplificadoTipo2/BlocoQrCodeT2.cs @@ -0,0 +1,56 @@ +using DanfeSharp.Modelo; +using org.pdfclown.documents; +using org.pdfclown.documents.contents.composition; +using System.Drawing; +using System.Drawing.Imaging; +using System.IO; + +namespace DanfeSharp.Blocos.SimplificadoTipo2 +{ + /// + /// Divisão V (NT 2026.003) — QR Code de consulta, mínimo 25×25 mm (≈ 70,9 pt) com + /// quiet zone de 3 mm. Renderizado apenas quando o consumer populou + /// ; a geração do conteúdo (QR v3 para mod. 55) + /// é responsabilidade de quem monta o ViewModel. + /// + internal class BlocoQrCodeT2 : ElementoBase + { + // 120 pt ≈ 42 mm, acima do mínimo de 25 mm da NT; quiet zone garantida pela + // centralização na largura de 280 pt. + private const float TamanhoQrCode = 120; + + public BlocoQrCodeT2(DanfeViewModel viewModel, Estilo estilo, PrimitiveComposer primitiveComposer, float y, Document context) : base(estilo) + { + if (string.IsNullOrWhiteSpace(viewModel.QrCode)) + { + Y_NFC = y; + return; + } + + primitiveComposer.BeginLocalState(); + primitiveComposer.SetFont(estilo.FonteCampoConteudo.FonteInterna, estilo.FonteCampoConteudo.Tamanho); + + Y_NFC = y + 10; + primitiveComposer.ShowText("CONSULTA VIA LEITOR DE QR CODE", new PointF(140, Y_NFC), XAlignmentEnum.Center, YAlignmentEnum.Top, 0); + Y_NFC += 10; + + using (var result = new MemoryStream()) + { + var bitmap = GerarQrCode.GerarQRCode(viewModel.QrCode); + bitmap.Save(result, ImageFormat.Jpeg); + result.Position = 0; + + var image = org.pdfclown.documents.contents.entities.Image.Get(result); + var imageXObject = image.ToXObject(context); + primitiveComposer.ShowXObject(imageXObject, new PointF(140, Y_NFC), new SizeF(TamanhoQrCode, TamanhoQrCode), XAlignmentEnum.Center, YAlignmentEnum.Top, 0); + } + + Y_NFC += TamanhoQrCode + 10; + + primitiveComposer.DrawLine(new PointF(15, Y_NFC), new PointF(265, Y_NFC)); + primitiveComposer.SetLineDash(new org.pdfclown.documents.contents.LineDash(new double[] { 3, 2 })); + primitiveComposer.Stroke(); + primitiveComposer.End(); + } + } +} diff --git a/DanfeSharp/Blocos/SimplificadoTipo2/BlocoTotaisSimplificadoT2.cs b/DanfeSharp/Blocos/SimplificadoTipo2/BlocoTotaisSimplificadoT2.cs new file mode 100644 index 0000000..cc0989b --- /dev/null +++ b/DanfeSharp/Blocos/SimplificadoTipo2/BlocoTotaisSimplificadoT2.cs @@ -0,0 +1,79 @@ +using DanfeSharp.Modelo; +using org.pdfclown.documents.contents.composition; +using System.Drawing; + +namespace DanfeSharp.Blocos.SimplificadoTipo2 +{ + /// + /// Divisão III (NT 2026.003) — totais da operação: quantidade de itens, valor dos + /// produtos, acréscimos/descontos, valor total, formas de pagamento, valor pago e + /// troco (a linha TROCO é sempre impressa, ainda que 0,00). + /// + internal class BlocoTotaisSimplificadoT2 : ElementoBase + { + public BlocoTotaisSimplificadoT2(DanfeViewModel viewModel, Estilo estilo, PrimitiveComposer primitiveComposer, float y) : base(estilo) + { + primitiveComposer.BeginLocalState(); + primitiveComposer.SetFont(estilo.FonteCampoConteudo.FonteInterna, estilo.FonteCampoConteudo.Tamanho); + + Y_NFC = y + 10; + primitiveComposer.ShowText("QTD. TOTAL DE ITENS", new PointF(25, Y_NFC), XAlignmentEnum.Left, YAlignmentEnum.Middle, 0); + primitiveComposer.ShowText(viewModel.CalculoImposto.QuantidadeTotal.ToString(), new PointF(250, Y_NFC), XAlignmentEnum.Right, YAlignmentEnum.Middle, 0); + + Y_NFC += 10; + primitiveComposer.ShowText("VALOR DOS PRODUTOS", new PointF(25, Y_NFC), XAlignmentEnum.Left, YAlignmentEnum.Middle, 0); + primitiveComposer.ShowText(viewModel.CalculoImposto.ValorTotalProdutos.Formatar(), new PointF(250, Y_NFC), XAlignmentEnum.Right, YAlignmentEnum.Middle, 0); + + var acrescimos = viewModel.CalculoImposto.ValorFrete + viewModel.CalculoImposto.ValorSeguro + viewModel.CalculoImposto.OutrasDespesas; + + if (acrescimos > 0) + { + Y_NFC += 10; + primitiveComposer.ShowText("ACRÉSCIMOS (+)", new PointF(25, Y_NFC), XAlignmentEnum.Left, YAlignmentEnum.Middle, 0); + primitiveComposer.ShowText(acrescimos.Formatar(), new PointF(250, Y_NFC), XAlignmentEnum.Right, YAlignmentEnum.Middle, 0); + } + + if (viewModel.CalculoImposto.Desconto > 0) + { + Y_NFC += 10; + primitiveComposer.ShowText("DESCONTO (-)", new PointF(25, Y_NFC), XAlignmentEnum.Left, YAlignmentEnum.Middle, 0); + primitiveComposer.ShowText(viewModel.CalculoImposto.Desconto.Formatar(), new PointF(250, Y_NFC), XAlignmentEnum.Right, YAlignmentEnum.Middle, 0); + } + + Y_NFC += 10; + primitiveComposer.SetFont(estilo.FonteCampoTituloNegrito.FonteInterna, estilo.FonteCampoTituloNegrito.Tamanho); + primitiveComposer.ShowText("VALOR TOTAL", new PointF(25, Y_NFC), XAlignmentEnum.Left, YAlignmentEnum.Middle, 0); + primitiveComposer.ShowText(viewModel.CalculoImposto.ValorTotalNota.Formatar(), new PointF(250, Y_NFC), XAlignmentEnum.Right, YAlignmentEnum.Middle, 0); + + Y_NFC += 10; + primitiveComposer.SetFont(estilo.FonteCampoConteudo.FonteInterna, estilo.FonteCampoConteudo.Tamanho); + primitiveComposer.ShowText("FORMAS DE PAGAMENTO", new PointF(25, Y_NFC), XAlignmentEnum.Left, YAlignmentEnum.Middle, 0); + + foreach (var pagamento in viewModel.Pagamento) + { + if (pagamento.DetalhePagamento == null) continue; + + foreach (var detalhe in pagamento.DetalhePagamento) + { + Y_NFC += 10; + primitiveComposer.ShowText(detalhe.FormaPagamento.FormaPagamentoToString(), new PointF(25, Y_NFC), XAlignmentEnum.Left, YAlignmentEnum.Middle, 0); + primitiveComposer.ShowText(detalhe.Valor.Formatar(), new PointF(250, Y_NFC), XAlignmentEnum.Right, YAlignmentEnum.Middle, 0); + } + } + + Y_NFC += 10; + primitiveComposer.ShowText("VALOR PAGO", new PointF(25, Y_NFC), XAlignmentEnum.Left, YAlignmentEnum.Middle, 0); + primitiveComposer.ShowText(viewModel.ValorPagoTotal.Formatar(), new PointF(250, Y_NFC), XAlignmentEnum.Right, YAlignmentEnum.Middle, 0); + + Y_NFC += 10; + primitiveComposer.ShowText("TROCO", new PointF(25, Y_NFC), XAlignmentEnum.Left, YAlignmentEnum.Middle, 0); + primitiveComposer.ShowText(viewModel.TrocoTotal.Formatar(), new PointF(250, Y_NFC), XAlignmentEnum.Right, YAlignmentEnum.Middle, 0); + + Y_NFC += 10; + primitiveComposer.DrawLine(new PointF(15, Y_NFC), new PointF(265, Y_NFC)); + primitiveComposer.SetLineDash(new org.pdfclown.documents.contents.LineDash(new double[] { 3, 2 })); + primitiveComposer.Stroke(); + primitiveComposer.End(); + } + } +} diff --git a/DanfeSharp/Blocos/SimplificadoTipo2/BlocoTributosIbsCbsT2.cs b/DanfeSharp/Blocos/SimplificadoTipo2/BlocoTributosIbsCbsT2.cs new file mode 100644 index 0000000..dd6449e --- /dev/null +++ b/DanfeSharp/Blocos/SimplificadoTipo2/BlocoTributosIbsCbsT2.cs @@ -0,0 +1,81 @@ +using DanfeSharp.Modelo; +using org.pdfclown.documents.contents.composition; +using System.Drawing; + +namespace DanfeSharp.Blocos.SimplificadoTipo2 +{ + /// + /// Divisão III-A (NT 2026.003) — discriminação dos tributos da Reforma Tributária + /// (IBS, CBS e IS quando houver). Omitido por inteiro quando o XML não tem o grupo + /// IBSCBSTot (fidelidade ao XML). + /// + internal class BlocoTributosIbsCbsT2 : ElementoBase + { + public BlocoTributosIbsCbsT2(DanfeViewModel viewModel, Estilo estilo, PrimitiveComposer primitiveComposer, float y) : base(estilo) + { + var tributos = viewModel.TributosIbsCbs; + + if (tributos == null) + { + Y_NFC = y; + return; + } + + primitiveComposer.BeginLocalState(); + + Y_NFC = y + 10; + primitiveComposer.SetFont(estilo.FonteCampoConteudoNegrito.FonteInterna, estilo.FonteCampoConteudoNegrito.Tamanho); + primitiveComposer.ShowText("TRIBUTOS IBS/CBS", new PointF(25, Y_NFC), XAlignmentEnum.Left, YAlignmentEnum.Middle, 0); + + primitiveComposer.SetFont(estilo.FonteCampoConteudo.FonteInterna, estilo.FonteCampoConteudo.Tamanho); + + if (tributos.BaseCalculo.HasValue) + { + Y_NFC += 10; + primitiveComposer.ShowText("BASE DE CÁLCULO IBS/CBS", new PointF(25, Y_NFC), XAlignmentEnum.Left, YAlignmentEnum.Middle, 0); + primitiveComposer.ShowText(tributos.BaseCalculo.Value.Formatar(), new PointF(250, Y_NFC), XAlignmentEnum.Right, YAlignmentEnum.Middle, 0); + } + + if (tributos.ValorIbs.HasValue) + { + Y_NFC += 10; + primitiveComposer.ShowText("IBS", new PointF(25, Y_NFC), XAlignmentEnum.Left, YAlignmentEnum.Middle, 0); + primitiveComposer.ShowText(tributos.ValorIbs.Value.Formatar(), new PointF(250, Y_NFC), XAlignmentEnum.Right, YAlignmentEnum.Middle, 0); + } + + if (tributos.ValorIbsUF.HasValue) + { + Y_NFC += 10; + primitiveComposer.ShowText(" IBS ESTADUAL (UF)", new PointF(25, Y_NFC), XAlignmentEnum.Left, YAlignmentEnum.Middle, 0); + primitiveComposer.ShowText(tributos.ValorIbsUF.Value.Formatar(), new PointF(250, Y_NFC), XAlignmentEnum.Right, YAlignmentEnum.Middle, 0); + } + + if (tributos.ValorIbsMun.HasValue) + { + Y_NFC += 10; + primitiveComposer.ShowText(" IBS MUNICIPAL", new PointF(25, Y_NFC), XAlignmentEnum.Left, YAlignmentEnum.Middle, 0); + primitiveComposer.ShowText(tributos.ValorIbsMun.Value.Formatar(), new PointF(250, Y_NFC), XAlignmentEnum.Right, YAlignmentEnum.Middle, 0); + } + + if (tributos.ValorCbs.HasValue) + { + Y_NFC += 10; + primitiveComposer.ShowText("CBS", new PointF(25, Y_NFC), XAlignmentEnum.Left, YAlignmentEnum.Middle, 0); + primitiveComposer.ShowText(tributos.ValorCbs.Value.Formatar(), new PointF(250, Y_NFC), XAlignmentEnum.Right, YAlignmentEnum.Middle, 0); + } + + if (tributos.ValorIS.HasValue) + { + Y_NFC += 10; + primitiveComposer.ShowText("IMPOSTO SELETIVO (IS)", new PointF(25, Y_NFC), XAlignmentEnum.Left, YAlignmentEnum.Middle, 0); + primitiveComposer.ShowText(tributos.ValorIS.Value.Formatar(), new PointF(250, Y_NFC), XAlignmentEnum.Right, YAlignmentEnum.Middle, 0); + } + + Y_NFC += 10; + primitiveComposer.DrawLine(new PointF(15, Y_NFC), new PointF(265, Y_NFC)); + primitiveComposer.SetLineDash(new org.pdfclown.documents.contents.LineDash(new double[] { 3, 2 })); + primitiveComposer.Stroke(); + primitiveComposer.End(); + } + } +} diff --git a/DanfeSharp/Blocos/SimplificadoTipo2/TabelaProdutosSimplificadoT2.cs b/DanfeSharp/Blocos/SimplificadoTipo2/TabelaProdutosSimplificadoT2.cs new file mode 100644 index 0000000..4b1373b --- /dev/null +++ b/DanfeSharp/Blocos/SimplificadoTipo2/TabelaProdutosSimplificadoT2.cs @@ -0,0 +1,63 @@ +using DanfeSharp.Modelo; +using org.pdfclown.documents.contents.composition; +using System.Drawing; + +namespace DanfeSharp.Blocos.SimplificadoTipo2 +{ + /// + /// Divisão II (NT 2026.003) — produtos/serviços: código, descrição, quantidade, + /// unidade, valor unitário e valor total por item. + /// + internal class TabelaProdutosSimplificadoT2 : ElementoBase + { + public TabelaProdutosSimplificadoT2(DanfeViewModel viewModel, Estilo estilo, PrimitiveComposer primitiveComposer, float y) : base(estilo) + { + primitiveComposer.BeginLocalState(); + + Y_NFC = y + 10; + + primitiveComposer.SetFont(estilo.FonteCampoConteudo.FonteInterna, estilo.FonteCampoConteudo.Tamanho); + primitiveComposer.ShowText("CÓDIGO", new PointF(65, Y_NFC), XAlignmentEnum.Right, YAlignmentEnum.Middle, 0); + primitiveComposer.ShowText("DESCRIÇÃO", new PointF(70, Y_NFC), XAlignmentEnum.Left, YAlignmentEnum.Middle, 0); + + Y_NFC += 10; + + primitiveComposer.ShowText("QTD.", new PointF(70, Y_NFC), XAlignmentEnum.Left, YAlignmentEnum.Middle, 0); + primitiveComposer.ShowText("UNID.", new PointF(100, Y_NFC), XAlignmentEnum.Left, YAlignmentEnum.Middle, 0); + primitiveComposer.ShowText("PREÇO", new PointF(200, Y_NFC), XAlignmentEnum.Right, YAlignmentEnum.Middle, 0); + primitiveComposer.ShowText("TOTAL", new PointF(250, Y_NFC), XAlignmentEnum.Right, YAlignmentEnum.Middle, 0); + + Y_NFC += 10; + + // Stroke imediato: texto emitido com path aberto é inválido pela spec do + // PDF e some na extração/renderização estrita. + primitiveComposer.DrawLine(new PointF(15, Y_NFC), new PointF(265, Y_NFC)); + primitiveComposer.SetLineDash(new org.pdfclown.documents.contents.LineDash(new double[] { 3, 2 })); + primitiveComposer.Stroke(); + + foreach (var produto in viewModel.Produtos) + { + Y_NFC += 10; + + primitiveComposer.ShowText(produto.Codigo.Length > 15 ? produto.Codigo.Substring(0, 10) : produto.Codigo, + new PointF(65, Y_NFC), XAlignmentEnum.Right, YAlignmentEnum.Middle, 0); + primitiveComposer.ShowText(produto.Descricao.Length > 40 ? produto.Descricao.Substring(0, 40) : produto.Descricao, + new PointF(70, Y_NFC), XAlignmentEnum.Left, YAlignmentEnum.Middle, 0); + + Y_NFC += 10; + + primitiveComposer.ShowText(produto.Quantidade.Formatar(), new PointF(90, Y_NFC), XAlignmentEnum.Right, YAlignmentEnum.Middle, 0); + primitiveComposer.ShowText(produto.Unidade, new PointF(100, Y_NFC), XAlignmentEnum.Left, YAlignmentEnum.Middle, 0); + primitiveComposer.ShowText(produto.ValorUnitario.Formatar(), new PointF(200, Y_NFC), XAlignmentEnum.Right, YAlignmentEnum.Middle, 0); + primitiveComposer.ShowText(produto.ValorTotal.Formatar(), new PointF(250, Y_NFC), XAlignmentEnum.Right, YAlignmentEnum.Middle, 0); + } + + Y_NFC += 10; + + primitiveComposer.DrawLine(new PointF(15, Y_NFC), new PointF(265, Y_NFC)); + primitiveComposer.SetLineDash(new org.pdfclown.documents.contents.LineDash(new double[] { 3, 2 })); + primitiveComposer.Stroke(); + primitiveComposer.End(); + } + } +} diff --git a/DanfeSharp/DanfeSimplificadoTipo2.cs b/DanfeSharp/DanfeSimplificadoTipo2.cs new file mode 100644 index 0000000..324ecaa --- /dev/null +++ b/DanfeSharp/DanfeSimplificadoTipo2.cs @@ -0,0 +1,221 @@ +using DanfeSharp.Blocos.NFC; +using DanfeSharp.Blocos.SimplificadoTipo2; +using DanfeSharp.Modelo; +using org.pdfclown.documents; +using org.pdfclown.documents.contents.composition; +using org.pdfclown.documents.contents.fonts; +using org.pdfclown.files; +using System; +using System.Drawing; + +namespace DanfeSharp +{ + /// + /// DANFE Simplificado Tipo 2 (NT 2026.003) — documento auxiliar em formato + /// cupom para NF-e modelo 55 emitida com tpImp=6, nas 9 divisões da NT. + /// Mesmo padrão do : página estreita (280 pt ≈ 98,8 mm, + /// acima do mínimo de 56 mm), altura dinâmica e blocos empilhados. Em contingência + /// pendente de autorização gera duas vias ("Via do Consumidor" e "Via do + /// Estabelecimento"), conforme a Divisão VIII. + /// + public class DanfeSimplificadoTipo2 : IDisposable + { + private const float Width = 280; + + private readonly StandardType1Font _FonteRegular; + private readonly StandardType1Font _FonteNegrito; + private readonly StandardType1Font _FonteItalico; + private readonly StandardType1Font.FamilyEnum _FonteFamilia; + private Boolean _FoiGerado; + private readonly string _creditos; + private readonly string _metadataCriador; + private readonly SizeF _size; + + public DanfeViewModel ViewModel { get; private set; } + public File File { get; private set; } + internal Document PdfDocument { get; private set; } + internal Estilo EstiloPadrao { get; private set; } + + public DanfeSimplificadoTipo2(DanfeViewModel viewModel, string creditos = null, string metadataCriador = null) + { + ViewModel = viewModel ?? throw new ArgumentNullException(nameof(viewModel)); + + // Divisão IV — sem URL informada pelo consumer, usa a consulta do portal + // nacional da NF-e, válida para qualquer UF. + if (string.IsNullOrWhiteSpace(viewModel.EndConsulta)) + { + viewModel.EndConsulta = (viewModel.Emitente?.EnderecoUf).UrlNFeConsulta(viewModel.TipoAmbiente); + } + + File = new File(); + PdfDocument = File.Document; + + _size = new SizeF(Width, EstimarAltura(viewModel)); + + _creditos = creditos ?? "Impresso com DanfeSharp"; + _metadataCriador = metadataCriador ?? String.Format("{0} {1} - {2}", "DanfeSharp", System.Reflection.Assembly.GetExecutingAssembly().GetName().Version, "https://github.com/SilverCard/DanfeSharp"); + + _FonteFamilia = StandardType1Font.FamilyEnum.Helvetica; + _FonteRegular = new StandardType1Font(PdfDocument, _FonteFamilia, false, false); + _FonteNegrito = new StandardType1Font(PdfDocument, _FonteFamilia, true, false); + _FonteItalico = new StandardType1Font(PdfDocument, _FonteFamilia, false, true); + + EstiloPadrao = new Estilo(_FonteRegular, _FonteNegrito, _FonteItalico, 9, 8); + + AdicionarMetadata(); + + _FoiGerado = false; + } + + // Estima a altura da página acompanhando os incrementos reais de cada bloco, + // com folga — o PDFClown 0.2.0-beta não permite redimensionar a página depois + // da composição. + private static float EstimarAltura(DanfeViewModel viewModel) + { + float altura = 100; // Divisão I — cabeçalho + folga + + if (viewModel.PendenteAutorizacao) + altura += 60; // Divisão VIII — contingência, 2 locais + + if (viewModel.TipoAmbiente == 2) + altura += 20; // Divisão VIII — homologação + + altura += 40 + viewModel.Produtos.Count * 20; // Divisão II — cabeçalho + 2 linhas/item + + altura += 90; // Divisão III — totais fixos + valor pago + troco + foreach (var pagamento in viewModel.Pagamento) + altura += (pagamento.DetalhePagamento?.Count ?? 0) * 10; + + if (viewModel.TributosIbsCbs != null) + altura += 90; // Divisão III-A + + altura += 60; // Divisão IV — consulta chave + + if (!string.IsNullOrWhiteSpace(viewModel.QrCode)) + altura += 160; // Divisão V — QR Code + + altura += 60; // Divisão VI — consumidor + + altura += 60; // Divisão VII — identificação + + if (!string.IsNullOrWhiteSpace(viewModel.InformacoesAdicionaisFisco)) + altura += 30; // Divisão VIII — infAdFisco + + if (viewModel.CalculoImposto.ValorAproximadoTributos > 0) + altura += 40; // Divisão IX — Lei 12.741 + + if (!string.IsNullOrWhiteSpace(viewModel.InformacoesComplementares)) + altura += 30; // Divisão IX — infCpl + + return altura + 30; // folga final + } + + private void AdicionarMetadata() + { + var info = PdfDocument.Information; + info[new org.pdfclown.objects.PdfName("ChaveAcesso")] = ViewModel.ChaveAcesso; + info[new org.pdfclown.objects.PdfName("TipoDocumento")] = "DANFE"; + info.CreationDate = DateTime.Now; + info.Title = "DANFE Simplificado Tipo 2 (Documento auxiliar da NFe)"; + info.Creator = _metadataCriador; + } + + public void Gerar() + { + if (_FoiGerado) throw new InvalidOperationException("O Danfe já foi gerado."); + + ComporVia("Via do Consumidor"); + + // Divisão VIII — em contingência pendente de autorização a NT exige a + // impressão de segunda via identificada, guardada até a autorização. + if (ViewModel.PendenteAutorizacao) + { + ComporVia("Via do Estabelecimento"); + } + + _FoiGerado = true; + } + + private void ComporVia(string rotuloVia) + { + var page = new Page(PdfDocument, _size); + PdfDocument.Pages.Add(page); + + var composer = new PrimitiveComposer(page); + + // Divisão I + var cabecalho = new BlocoCabecalhoSimplificadoT2(ViewModel, EstiloPadrao, composer); + + // Divisão VIII — contingência (1ª ocorrência, abaixo do cabeçalho) + var contingenciaTopo = new BlocoMensagemContingenciaT2(ViewModel, EstiloPadrao, composer, cabecalho.Y_NFC); + + // Divisão VIII — homologação (bloco NFC reusado: condiciona por TipoAmbiente == 2) + var informacaoFiscal = new BlocoInformacaoFiscal(ViewModel, EstiloPadrao, composer, contingenciaTopo.Y_NFC); + + // Divisão II + var tabelaProdutos = new TabelaProdutosSimplificadoT2(ViewModel, EstiloPadrao, composer, informacaoFiscal.Y_NFC); + + // Divisão III + var totais = new BlocoTotaisSimplificadoT2(ViewModel, EstiloPadrao, composer, tabelaProdutos.Y_NFC); + + // Divisão III-A + var tributosIbsCbs = new BlocoTributosIbsCbsT2(ViewModel, EstiloPadrao, composer, totais.Y_NFC); + + // Divisão IV + var consultaChave = new BlocoConsultaChaveT2(ViewModel, EstiloPadrao, composer, tributosIbsCbs.Y_NFC); + + // Divisão V + var qrCode = new BlocoQrCodeT2(ViewModel, EstiloPadrao, composer, consultaChave.Y_NFC, PdfDocument); + + // Divisão VI + var consumidor = new BlocoConsumidorT2(ViewModel, EstiloPadrao, composer, qrCode.Y_NFC); + + // Divisão VII + var identificacao = new BlocoIdentificacaoT2(ViewModel, EstiloPadrao, composer, consumidor.Y_NFC, rotuloVia); + + // Divisão VIII — contingência (2ª ocorrência, abaixo da identificação) + var contingenciaBase = new BlocoMensagemContingenciaT2(ViewModel, EstiloPadrao, composer, identificacao.Y_NFC); + + // Divisões VIII/IX — mensagens fiscal e do contribuinte + var mensagens = new BlocoMensagensT2(ViewModel, EstiloPadrao, composer, contingenciaBase.Y_NFC); + + composer.Flush(); + } + + public void Salvar(String path) + { + if (String.IsNullOrWhiteSpace(path)) throw new ArgumentException(nameof(path)); + + File.Save(path, SerializationModeEnum.Incremental); + } + + public void Salvar(System.IO.Stream stream) + { + if (stream == null) throw new ArgumentNullException(nameof(stream)); + + File.Save(new org.pdfclown.bytes.Stream(stream), SerializationModeEnum.Incremental); + } + + #region IDisposable Support + private bool disposedValue = false; + + protected virtual void Dispose(bool disposing) + { + if (!disposedValue) + { + if (disposing) + { + File.Dispose(); + } + + disposedValue = true; + } + } + + public void Dispose() + { + Dispose(true); + } + #endregion + } +} diff --git a/DanfeSharp/Extentions.cs b/DanfeSharp/Extentions.cs index 739c987..fd61242 100644 --- a/DanfeSharp/Extentions.cs +++ b/DanfeSharp/Extentions.cs @@ -206,6 +206,19 @@ public static string UrlNFCeProduction(this string state) return value; } + /// + /// URL de consulta pela chave de acesso da NF-e mod. 55 (DANFE Simplificado + /// Tipo 2, NT 2026.003 Divisão IV). O portal nacional atende a consulta de + /// qualquer UF; substituir por tabela por UF quando a SEFAZ publicar material + /// oficial com URLs próprias. + /// + public static string UrlNFeConsulta(this string state, int tipoAmbiente) + { + return tipoAmbiente == 2 + ? "www.homologacao.nfe.fazenda.gov.br/portal/consultaRecaptcha.aspx" + : "www.nfe.fazenda.gov.br/portal/consultaRecaptcha.aspx"; + } + public static string UrlNFCeTest(this string state) { var stateDictionary = new Dictionary diff --git a/DanfeSharp/Modelo/DanfeViewModel.cs b/DanfeSharp/Modelo/DanfeViewModel.cs index 41c7ac0..69692cd 100644 --- a/DanfeSharp/Modelo/DanfeViewModel.cs +++ b/DanfeSharp/Modelo/DanfeViewModel.cs @@ -313,6 +313,63 @@ public float Margem /// Justificativa da entrada em contingência - xJust /// public string ContingenciaJustificativa { get; set; } + + /// + /// Emitida em contingência e ainda sem protocolo de autorização — gatilho + /// das mensagens "EMITIDA EM CONTINGÊNCIA / Pendente de autorização" e da + /// segunda via no DANFE Simplificado Tipo 2 (NT 2026.003, Divisão VIII). + /// + public bool PendenteAutorizacao => TipoEmissao != FormaEmissao.Normal && String.IsNullOrWhiteSpace(ProtocoloAutorizacao); + #endregion + + #region DANFE Simplificado Tipo 2 (NT 2026.003) + + /// + /// Totais IBS/CBS/IS (grupo IBSCBSTot) — Divisão III-A do DANFE Simplificado + /// Tipo 2. Nulo quando o XML não tem o grupo (divisão omitida). + /// + public TributosIbsCbsViewModel TributosIbsCbs { get; set; } + + /// + /// Operação não presencial (indPres diferente de 1 e 5) — na NT 2026.003 a + /// identificação do consumidor (Divisão VI) é obrigatória nesses casos. + /// A validação acontece na emissão; aqui apenas informa o renderer. + /// + public bool OperacaoNaoPresencial { get; set; } + + /// + /// Soma dos valores pagos (vPag) de todas as formas de pagamento. + /// + public decimal ValorPagoTotal + { + get + { + decimal total = 0; + foreach (var pagamento in Pagamento) + { + if (pagamento.DetalhePagamento == null) continue; + foreach (var detalhe in pagamento.DetalhePagamento) + total += detalhe.Valor; + } + return total; + } + } + + /// + /// Soma do troco (vTroco) — a Divisão III imprime a linha TROCO sempre, + /// ainda que 0,00. + /// + public decimal TrocoTotal + { + get + { + decimal total = 0; + foreach (var pagamento in Pagamento) + total += pagamento.Troco ?? 0; + return total; + } + } + #endregion public DanfeViewModel() diff --git a/DanfeSharp/Modelo/TributosIbsCbsViewModel.cs b/DanfeSharp/Modelo/TributosIbsCbsViewModel.cs new file mode 100644 index 0000000..1c04dd0 --- /dev/null +++ b/DanfeSharp/Modelo/TributosIbsCbsViewModel.cs @@ -0,0 +1,41 @@ +namespace DanfeSharp.Modelo +{ + /// + /// Totais dos tributos da Reforma Tributária (grupo IBSCBSTot) para a + /// Divisão III-A do DANFE Simplificado Tipo 2 (NT 2026.003). + /// Instância nula no significa + /// que o XML não tem o grupo — a divisão é omitida (fidelidade ao XML). + /// + public class TributosIbsCbsViewModel + { + /// + /// Base de cálculo comum do IBS e da CBS (vBCIBSCBS) + /// + public decimal? BaseCalculo { get; set; } + + /// + /// Valor total do IBS (vIBS) + /// + public decimal? ValorIbs { get; set; } + + /// + /// Parcela estadual do IBS (vIBSUF) + /// + public decimal? ValorIbsUF { get; set; } + + /// + /// Parcela municipal do IBS (vIBSMun) + /// + public decimal? ValorIbsMun { get; set; } + + /// + /// Valor total da CBS (vCBS) + /// + public decimal? ValorCbs { get; set; } + + /// + /// Valor total do Imposto Seletivo (vIS), quando houver + /// + public decimal? ValorIS { get; set; } + } +} From 1d506d9c3a5501042bc8fdb8d5c50d90dd15b53a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Henrique=20Honjoya?= Date: Fri, 3 Jul 2026 10:36:11 -0300 Subject: [PATCH 2/2] docs: spec openspec do danfe simplificado tipo 2 (NT 2026.003) --- .../specs/danfe-simplificado-tipo-2/spec.md | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 openspec/specs/danfe-simplificado-tipo-2/spec.md diff --git a/openspec/specs/danfe-simplificado-tipo-2/spec.md b/openspec/specs/danfe-simplificado-tipo-2/spec.md new file mode 100644 index 0000000..d94fd61 --- /dev/null +++ b/openspec/specs/danfe-simplificado-tipo-2/spec.md @@ -0,0 +1,127 @@ +# danfe-simplificado-tipo-2 Specification + +## Purpose + +Define como o `nfe/DanfeSharp` renderiza o **DANFE Simplificado Tipo 2** — documento auxiliar em formato cupom para NF-e **modelo 55** emitida com `tpImp=6`, voltado ao varejo (operações que hoje seriam NFC-e). Renderização via classe dedicada `DanfeSimplificadoTipo2` (mesmo padrão de `DanfeNFC`: página estreita de altura dinâmica, blocos empilhados), consumindo o `DanfeViewModel` compartilhado. + +**Base normativa:** NT 2026.003 v1.00 (especificações técnicas do DANFE Simplificado Tipo 2), em atendimento à NT 2026.002 v1.00 e aos Ajustes SINIEF 32/2025 e 13/2026. Produção: 03/08/2026. A NT define **9 divisões** com conteúdo mínimo, papel de largura ≥ 56 mm, impressora comum e fidelidade ao XML (proibido imprimir dado que não conste no XML, exceto protocolo, cMsg/xMsg). + +## Requirements + +### Requirement: Divisão I — Cabeçalho + +O cupom SHALL iniciar com a identificação do emitente (razão social, CNPJ, endereço) e o título destacado **"DANFE Simplificado Tipo 2"**. Logotipo é opcional. + +#### Scenario: Cabeçalho com emitente e título + +- **GIVEN** um `DanfeViewModel` com emitente preenchido +- **WHEN** o PDF é gerado +- **THEN** o texto extraído contém a razão social, o CNPJ formatado e "DANFE Simplificado Tipo 2" + +### Requirement: Divisão II — Produtos e Divisão III — Totais + +A tabela de produtos SHALL exibir código, descrição, quantidade, unidade, valor unitário e valor total por item. Os totais SHALL exibir quantidade total de itens, valor dos produtos, desconto/acréscimos, valor total, as formas de pagamento com valores, a linha "VALOR PAGO" e a linha "TROCO" — o troco é **sempre impresso**, ainda que 0,00. + +#### Scenario: Troco sempre presente + +- **GIVEN** um `DanfeViewModel` sem `Pagamento[].Troco` informado +- **WHEN** o PDF é gerado +- **THEN** o texto extraído contém "TROCO" + +### Requirement: Divisão III-A — IBS/CBS/IS + +Quando o ViewModel tiver `TributosIbsCbs` preenchido, o cupom SHALL discriminar CBS, IBS (com parcelas UF/Município quando presentes) e IS (quando houver). Quando `TributosIbsCbs == null`, a divisão SHALL ser **omitida** (fidelidade ao XML). + +#### Scenario: Com grupo IBS/CBS + +- **GIVEN** `TributosIbsCbs` com valores de IBS e CBS +- **WHEN** o PDF é gerado +- **THEN** o texto contém os rótulos de IBS e CBS com os valores + +#### Scenario: Sem grupo IBS/CBS + +- **GIVEN** `TributosIbsCbs == null` +- **WHEN** o PDF é gerado +- **THEN** o texto NÃO contém rótulos de IBS/CBS + +### Requirement: Divisão IV — Consulta via chave de acesso + +O cupom SHALL exibir a URL de consulta da SEFAZ e a chave de acesso de 44 dígitos formatada em **11 blocos de 4 dígitos** separados por espaço. + +#### Scenario: Chave em 11 blocos + +- **GIVEN** uma chave de acesso de 44 dígitos +- **WHEN** o PDF é gerado +- **THEN** o texto contém a chave com espaços a cada 4 dígitos + +### Requirement: Divisão V — QR Code + +Quando `DanfeViewModel.QrCode` estiver preenchido, o cupom SHALL renderizar o QR Code com no mínimo 25×25 mm e quiet zone de 3 mm. Quando ausente, a divisão SHALL ser omitida sem erro (a geração do conteúdo do QR v3 para mod. 55 é responsabilidade do consumer). + +#### Scenario: QR presente + +- **GIVEN** `QrCode` preenchido +- **WHEN** o PDF é gerado +- **THEN** a imagem do QR é renderizada e o rótulo de consulta via QR Code aparece + +#### Scenario: QR ausente + +- **GIVEN** `QrCode` nulo ou vazio +- **WHEN** o PDF é gerado +- **THEN** a geração conclui sem exceção e sem o rótulo de QR Code + +### Requirement: Divisão VI — Consumidor + +Quando o destinatário estiver identificado, o cupom SHALL exibir "CONSUMIDOR" com CNPJ/CPF, nome e endereço (quando presentes no XML). Quando não identificado, SHALL exibir "CONSUMIDOR NÃO IDENTIFICADO". A obrigatoriedade de identificação em operação não presencial é validada na emissão (domínio), não no renderer. + +#### Scenario: Consumidor identificado + +- **GIVEN** destinatário com CPF e nome +- **WHEN** o PDF é gerado +- **THEN** o texto contém "CONSUMIDOR" e o CPF + +### Requirement: Divisão VII — Identificação da NF-e + +O cupom SHALL exibir número, série, data/hora de emissão em **horário local** (não UTC — conversão é responsabilidade do consumer ao popular `DataHoraEmissao`), o rótulo da via e o protocolo de autorização com data/hora. + +#### Scenario: Identificação completa + +- **GIVEN** NF-e autorizada com protocolo +- **WHEN** o PDF é gerado +- **THEN** o texto contém número, série, "PROTOCOLO DE AUTORIZAÇÃO" e o protocolo + +### Requirement: Divisão VIII — Mensagem fiscal, contingência e homologação + +Em ambiente de homologação (`TipoAmbiente == 2`), o cupom SHALL exibir "EMITIDA EM AMBIENTE DE HOMOLOGAÇÃO - SEM VALOR FISCAL". Em contingência pendente de autorização (`TipoEmissao != Normal` e sem protocolo), o cupom SHALL exibir "EMITIDA EM CONTINGÊNCIA" / "Pendente de autorização" (caixa alta, centralizado, 2 linhas) em **dois locais** — abaixo do cabeçalho e abaixo da identificação — e SHALL gerar **segunda via** com rótulo "Via do Estabelecimento". `infAdFisco` SHALL ser impresso quando presente. + +#### Scenario: Contingência pendente + +- **GIVEN** `TipoEmissao = ContingenciaFSDA` e `ProtocoloAutorizacao` vazio +- **WHEN** o PDF é gerado +- **THEN** "EMITIDA EM CONTINGÊNCIA" aparece 4 vezes no total (2 locais × 2 vias) e a segunda página contém "Via do Estabelecimento" + +#### Scenario: Emissão normal autorizada + +- **GIVEN** `TipoEmissao = Normal` com protocolo +- **WHEN** o PDF é gerado +- **THEN** o PDF tem 1 página e não contém "EMITIDA EM CONTINGÊNCIA" + +### Requirement: Divisão IX — Mensagem do contribuinte + +O cupom SHALL imprimir `infCpl` quando presente e a demonstração dos tributos aproximados (Lei 12.741/2012) quando `CalculoImposto.ValorAproximadoTributos > 0`. + +#### Scenario: Tributos aproximados + +- **GIVEN** `ValorAproximadoTributos > 0` +- **WHEN** o PDF é gerado +- **THEN** o texto contém a referência à Lei 12.741 + +### Requirement: Zero regression nos leiautes existentes + +A introdução do `DanfeSimplificadoTipo2` SHALL NOT alterar a renderização de `Danfe` (A4) nem de `DanfeNFC` (NFC-e). Os blocos novos vivem em `Blocos/SimplificadoTipo2/` e não modificam os blocos NFC existentes (exceção: reuso direto sem alteração de `BlocoInformacaoFiscal`). + +#### Scenario: Suíte existente permanece verde + +- **GIVEN** a suíte de testes existente do DanfeSharp +- **WHEN** os testes rodam após a change +- **THEN** todos os testes pré-existentes continuam passando