diff --git a/brazilfiscalreport/dacte/dacte.py b/brazilfiscalreport/dacte/dacte.py index 9edd90ee..6ed31f44 100644 --- a/brazilfiscalreport/dacte/dacte.py +++ b/brazilfiscalreport/dacte/dacte.py @@ -5,6 +5,7 @@ import warnings import xml.etree.ElementTree as ET from io import BytesIO +from typing import Optional from xml.etree.ElementTree import Element from barcode.codex import Code128 @@ -43,6 +44,14 @@ def extract_text(node: Element, tag: str) -> str: return get_tag_text(node, URL, tag) +def to_float(value: Optional[str]) -> float: + """Converte valor numérico cru do XML, tolerando ausência/formato inválido.""" + try: + return float(value) + except (TypeError, ValueError): + return 0.0 + + class Dacte(xFPDF): def __init__(self, xml, config: DacteConfig = None): super().__init__(unit="mm", format="A4") @@ -1347,9 +1356,19 @@ def _draw_service_fee_value(self): self.cell(w=col_width / 2, h=4, text=titles[1], align="L") # Distribuir os componentes em 3 colunas com 3 linhas cada - col1 = self.comp_list[:3] # Primeiros 3 componentes - col2 = self.comp_list[3:6] # Próximos 3 componentes - col3 = self.comp_list[6:9] # Últimos 3 componentes + comp_list = self.comp_list + # O bloco comporta no máximo 9 linhas; do 10º componente em diante o valor + # era simplesmente descartado, sem aviso — e a soma dos componentes + # exibidos deixava de bater com o vTPrest impresso ao lado. Agrega o + # excedente numa linha "DEMAIS" para preservar a soma. O rótulo não é + # "OUTROS" porque xNome é texto livre e um componente real pode se + # chamar assim. + if len(comp_list) > 9: + demais = sum(to_float(valor) for _, valor in comp_list[8:]) + comp_list = comp_list[:8] + [("DEMAIS", f"{demais:.2f}")] + col1 = comp_list[:3] # Primeiros 3 componentes + col2 = comp_list[3:6] # Próximos 3 componentes + col3 = comp_list[6:9] # Últimos 3 componentes # Altura inicial para começo dos dados data_y = section_start_y + 6 diff --git a/tests/test_dacte_components.py b/tests/test_dacte_components.py new file mode 100644 index 00000000..a69c2199 --- /dev/null +++ b/tests/test_dacte_components.py @@ -0,0 +1,105 @@ +"""Componentes do valor da prestação além do 9º não podem sumir do DACTE. + +O quadro "COMPONENTES DO VALOR DA PRESTAÇÃO DO SERVIÇO" é um retângulo fixo de +18 mm que comporta 3 colunas × 3 linhas. O XSD do CT-e define `Comp` como +`maxOccurs="unbounded"`, então um CT-e válido pode declarar mais de 9 — e o +excedente era descartado sem aviso, deixando a soma dos componentes exibidos +diferente do vTPrest impresso na coluna ao lado. +""" + +import re +import zlib + +import pytest + +from brazilfiscalreport.dacte import Dacte +from brazilfiscalreport.dacte.dacte import to_float + + +def textos_posicionados(pdf_bytes): + """[(y, x, texto)] de cada trecho desenhado, lendo o content stream do PDF.""" + achados = [] + for bruto in re.findall(rb"stream\r?\n(.*?)endstream", pdf_bytes, re.S): + try: + fluxo = zlib.decompress(bruto.strip(b"\r\n")).decode("latin-1") + except zlib.error: + continue + for td_x, td_y, texto in re.findall( + r"([\d.-]+)\s+([\d.-]+)\s+Td\s*\((.*?)\)\s*Tj", fluxo + ): + achados.append((round(float(td_y), 2), round(float(td_x), 2), texto)) + return achados + + +@pytest.fixture +def xml_dacte(load_xml): + return load_xml("dacte/dacte_test_1.xml") + + +def xml_com_componentes(xml, quantidade): + """Substitui os componentes do XML por `quantidade` deles, de 10 em 10.""" + comps = "".join( + f"COMP {i}{i * 10}.00" + for i in range(1, quantidade + 1) + ) + return re.sub(r".*", comps, xml, count=1, flags=re.S) + + +def test_componentes_alem_do_nono_aparecem_agregados(xml_dacte): + """O bloco comporta 9 linhas; o excedente vira "DEMAIS" em vez de sumir.""" + xml = xml_com_componentes(xml_dacte, 12) + trechos = textos_posicionados(bytes(Dacte(xml=xml).output())) + texto = " ".join(t[2] for t in trechos) + + assert "DEMAIS" in texto, "o excedente de componentes sumiu do PDF" + + # Os 8 primeiros saem individualmente; do 9º ao 12º somam 90+100+110+120. + assert "COMP 8" in texto + assert "COMP 9" not in texto + assert "420.00" in texto, "a linha agregada não preserva a soma do excedente" + + +def test_nove_componentes_saem_sem_agregacao(xml_dacte): + """Exatamente 9 cabem no quadro: nada é agregado.""" + xml = xml_com_componentes(xml_dacte, 9) + texto = " ".join(t[2] for t in textos_posicionados(bytes(Dacte(xml=xml).output()))) + + assert "DEMAIS" not in texto + for i in range(1, 10): + assert f"COMP {i}" in texto + + +def test_soma_dos_componentes_exibidos_bate_com_o_total(xml_dacte): + """A soma do que está impresso tem de fechar, com ou sem agregação.""" + for quantidade in (6, 9, 12, 20): + xml = xml_com_componentes(xml_dacte, quantidade) + trechos = textos_posicionados(bytes(Dacte(xml=xml).output())) + rotulos = {t[2] for t in trechos} + + exibidos = [t for t in trechos if t[2].startswith("COMP ") or t[2] == "DEMAIS"] + assert len(exibidos) == min(quantidade, 9), ( + f"{quantidade} componentes deveriam render " + f"{min(quantidade, 9)} linhas, mas saíram {len(exibidos)}" + ) + + # As 3 colunas partilham a baseline, então cada linha do quadro é somada + # uma vez só — daí o set de baselines em vez de iterar sobre os nomes. + baselines = {y for y, _, _ in exibidos} + total_exibido = sum( + to_float(v) + for y, _, v in trechos + if y in baselines and re.fullmatch(r"\d+\.\d{2}", v) + ) + + esperado = sum(i * 10 for i in range(1, quantidade + 1)) + assert total_exibido == esperado, ( + f"com {quantidade} componentes o PDF soma {total_exibido}, " + f"mas o XML declara {esperado} (rótulos: {sorted(rotulos)[:5]})" + ) + + +def test_to_float_tolera_valor_ausente_ou_invalido(): + assert to_float("12.34") == 12.34 + assert to_float("") == 0.0 + assert to_float(None) == 0.0 + assert to_float("R$ 10") == 0.0