Files
GTI_GERENCIADOR_FISCAL/fiscal/central/app/datasets/cest.py
T
gti d21a9d1140 Add 'fiscal/' from commit '102ba84500dbd9655a0471288c3e278652593ca9'
git-subtree-dir: fiscal
git-subtree-mainline: d95165fc8f
git-subtree-split: 102ba84500
2026-09-29 14:43:23 -03:00

121 lines
4.2 KiB
Python

"""Fail-closed parser for CONFAZ Convenio ICMS 142/18 CEST HTML."""
from dataclasses import dataclass
import hashlib
from html.parser import HTMLParser
import re
_ANNEX_NUMBERS = set(range(2, 27))
@dataclass(frozen=True)
class CestEntry:
code: str
description: str
annex: str
ncm_codes: tuple[str, ...]
@dataclass(frozen=True)
class CestPayload:
version: str
sha256: str
entries: tuple[CestEntry, ...]
class _TableParser(HTMLParser):
def __init__(self):
super().__init__(convert_charrefs=True)
self.annex = None
self.tables = []
self._table = None
self._row = None
self._cell = None
def handle_starttag(self, tag, attrs):
if tag == "table":
self._table = []
elif tag == "tr" and self._table is not None:
self._row = []
elif tag in {"td", "th"} and self._row is not None:
self._cell = []
elif tag == "br" and self._cell is not None:
self._cell.append(" ")
def handle_endtag(self, tag):
if tag in {"td", "th"} and self._cell is not None:
self._row.append("".join(self._cell).strip())
self._cell = None
elif tag == "tr" and self._row is not None:
self._table.append(self._row)
self._row = None
elif tag == "table" and self._table is not None:
self.tables.append((self.annex, self._table))
self._table = None
def handle_data(self, data):
if self._cell is not None:
self._cell.append(data)
else:
found = re.search(r"\bANEXO\s+([IVXLCDM]+)\b", data.upper())
if found:
self.annex = found.group(1)
def _roman_to_number(value: str) -> int:
values = {"I": 1, "V": 5, "X": 10, "L": 50, "C": 100, "D": 500, "M": 1000}
total = 0
previous = 0
for character in reversed(value):
current = values[character]
total += -current if current < previous else current
previous = max(previous, current)
return total
def _normalized_header(value: str) -> str:
return re.sub(r"[^A-Z0-9]+", "", value.upper())
def _codes(value: str) -> tuple[str, ...]:
return tuple(dict.fromkeys(re.sub(r"\D", "", item) for item in re.findall(r"\b\d{2}(?:[.\s]?\d{2}){0,3}\b", value)))
def parse_cest_html(payload: bytes) -> CestPayload:
try:
html = payload.decode("utf-8")
except UnicodeDecodeError as error:
raise ValueError("official CEST HTML must be UTF-8") from error
parser = _TableParser()
parser.feed(html)
entries = []
for annex, table in parser.tables:
if annex is None or _roman_to_number(annex) not in _ANNEX_NUMBERS:
continue
if not table:
raise ValueError("annex table is empty")
headers = [_normalized_header(value) for value in table[0]]
try:
cest_column = next(index for index, value in enumerate(headers) if value == "CEST")
ncm_column = next(index for index, value in enumerate(headers) if "NCM" in value)
except StopIteration as error:
raise ValueError("annex table is missing required columns") from error
description_column = next((index for index, value in enumerate(headers) if "DESCR" in value), None)
for row in table[1:]:
if len(row) <= max(cest_column, ncm_column):
if cest_column >= len(row) or not re.search(r"\d{2}[.\s]?\d{3}[.\s]?\d{2}", row[cest_column]):
continue
raise ValueError("annex table row is incomplete")
cest = re.sub(r"\D", "", row[cest_column])
if not cest:
continue
ncm_codes = _codes(row[ncm_column])
if len(cest) != 7:
raise ValueError("annex table row is invalid")
description = row[description_column] if description_column is not None and len(row) > description_column else ""
entries.append(CestEntry(f"{cest[:2]}.{cest[2:5]}.{cest[5:]}", description, annex, ncm_codes))
if not entries:
raise ValueError("no CEST records found in CONFAZ annexes II-XXVI")
return CestPayload("CV142_18", hashlib.sha256(payload).hexdigest(), tuple(entries))