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
25 changes: 22 additions & 3 deletions brazilfiscalreport/dacte/dacte.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
105 changes: 105 additions & 0 deletions tests/test_dacte_components.py
Original file line number Diff line number Diff line change
@@ -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><xNome>COMP {i}</xNome><vComp>{i * 10}.00</vComp></Comp>"
for i in range(1, quantidade + 1)
)
return re.sub(r"<Comp>.*</Comp>", 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
Loading