chore: establish project baseline
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Official fiscal dataset importers."""
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Fail-closed text parser for the official RICMS/BA Anexo 1 PDF."""
|
||||
import re
|
||||
|
||||
_ITEM = re.compile(r"^\s*(\d{1,2}(?:\.\d+)+)\s+(?:(\d{2}\.\d{3}\.\d{2})\s+)?(.*)$")
|
||||
_NCM = re.compile(r"\b(?:\d{4}(?:\.\d{1,4}){0,3}|\d{2}(?:\.\d{1,4}){1,3})\b")
|
||||
_MVA = re.compile(r"\d+(?:,\d+)?%")
|
||||
|
||||
def parse_page(text, page):
|
||||
records, rejected = [], []
|
||||
lines = [" ".join(line.split()) for line in text.splitlines()]
|
||||
index = 0
|
||||
while index < len(lines):
|
||||
match = _ITEM.match(lines[index])
|
||||
if not match:
|
||||
index += 1; continue
|
||||
item, cest, content = match.groups(); block = [content]; index += 1
|
||||
while index < len(lines) and not _ITEM.match(lines[index]) and not lines[index].startswith("ITEM CEST"):
|
||||
block.append(lines[index]); index += 1
|
||||
joined = " ".join(block)
|
||||
ncm = _NCM.findall(joined)
|
||||
if not cest and not ncm:
|
||||
rejected.append({"item": item, "source_page": page, "reason": "NO_CEST_OR_NCM"}); continue
|
||||
mvas = _MVA.findall(joined)
|
||||
description = joined
|
||||
if cest: description = description.replace(cest, "")
|
||||
for code in ncm: description = description.replace(code, "")
|
||||
for mva in mvas: description = description.replace(mva, "")
|
||||
records.append({"item": item, "cest": cest, "ncm_sh": ncm or None, "description": " ".join(description.split()) or None, "mva_original": mvas[-1] if mvas else None, "mva_adjusted": mvas[0] if mvas else None, "agreement_info": None, "source_page": page})
|
||||
return records, rejected
|
||||
@@ -0,0 +1,287 @@
|
||||
"""Parsers for the official NF-e/RTC spreadsheets the operator placed in
|
||||
sources/raw/bootstrap_v1/ after the Portal NF-e/SPED downloads were blocked
|
||||
in earlier missions. Each parser validates the workbook's own header/sheet
|
||||
structure before trusting it — never trusts the filename alone.
|
||||
|
||||
Domains: CFOP, FCP, ANP (cProdANP), NCM x uTrib, RTC biofuel index
|
||||
(pBioObrigatorio), RTC cCredPres.
|
||||
"""
|
||||
from dataclasses import dataclass, field
|
||||
from hashlib import sha256
|
||||
from io import BytesIO
|
||||
|
||||
|
||||
def _digits(value):
|
||||
return "".join(c for c in str(value or "") if c.isdigit())
|
||||
|
||||
|
||||
def _iso(value):
|
||||
if value is None:
|
||||
return None
|
||||
return value.isoformat() if hasattr(value, "isoformat") else str(value)
|
||||
|
||||
|
||||
def _load(raw_bytes):
|
||||
import openpyxl
|
||||
return openpyxl.load_workbook(BytesIO(raw_bytes), read_only=True, data_only=True)
|
||||
|
||||
|
||||
def _rows(ws, header_len):
|
||||
rows = ws.iter_rows(values_only=True)
|
||||
header = next(rows)
|
||||
return header[:header_len], rows
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Payload:
|
||||
sha256: str
|
||||
entries: tuple
|
||||
parser_version: str
|
||||
extra_entries: tuple = ()
|
||||
|
||||
|
||||
# --- CFOP -------------------------------------------------------------
|
||||
_CFOP_EXPECTED_HEADER = ("CFOP", "Início de vigência", "Fim de vigência", "indNFe")
|
||||
|
||||
|
||||
def parse_cfop(raw_bytes: bytes) -> Payload:
|
||||
wb = _load(raw_bytes)
|
||||
if "CFOP" not in wb.sheetnames:
|
||||
raise ValueError(f"expected a 'CFOP' sheet, found {wb.sheetnames}")
|
||||
header, rows = _rows(wb["CFOP"], 14)
|
||||
if _digits("".join(str(h) for h in header[:1])) or "CFOP" not in str(header[0]):
|
||||
raise ValueError(f"unexpected CFOP header: {header}")
|
||||
entries = []
|
||||
for row in rows:
|
||||
if row[0] is None:
|
||||
continue
|
||||
code = str(int(row[0])) if isinstance(row[0], (int, float)) else str(row[0]).strip()
|
||||
entries.append({
|
||||
"code": code, "effective_from": _iso(row[1]), "effective_to": _iso(row[2]),
|
||||
"ind_nfe": row[3], "ind_comunica": row[4], "ind_transp": row[5], "ind_devol": row[6],
|
||||
"ind_retor": row[7], "ind_anula": row[8], "ind_remes": row[9], "ind_comb": row[10],
|
||||
"ind_exc_ibscbs": row[11], "title": row[12] or "", "description": row[13],
|
||||
})
|
||||
if len(entries) < 100:
|
||||
raise ValueError(f"expected at least 100 CFOP codes, found {len(entries)}")
|
||||
return Payload(sha256(raw_bytes).hexdigest(), tuple(entries), "cfop-xlsx-v1")
|
||||
|
||||
|
||||
# --- FCP ----------------------------------------------------------------
|
||||
def parse_fcp(raw_bytes: bytes) -> Payload:
|
||||
wb = _load(raw_bytes)
|
||||
if "FCP" not in wb.sheetnames:
|
||||
raise ValueError(f"expected an 'FCP' sheet, found {wb.sheetnames}")
|
||||
header, rows = _rows(wb["FCP"], 8)
|
||||
if "UF" not in [str(h) for h in header]:
|
||||
raise ValueError(f"unexpected FCP header: {header}")
|
||||
entries = []
|
||||
for row in rows:
|
||||
if not row[1]:
|
||||
continue
|
||||
entries.append({
|
||||
"uf_code": str(row[0]), "uf": str(row[1]).strip(), "uf_name": str(row[2] or "").strip(),
|
||||
"aliq_1": row[3], "aliq_2": row[4], "aliq_3": row[5], "observacao": row[7],
|
||||
})
|
||||
if len(entries) < 20:
|
||||
raise ValueError(f"expected at least 20 UF rows (26 states + DF), found {len(entries)}")
|
||||
return Payload(sha256(raw_bytes).hexdigest(), tuple(entries), "fcp-xlsx-v1")
|
||||
|
||||
|
||||
# --- ANP ------------------------------------------------------------------
|
||||
def parse_anp(raw_bytes: bytes) -> Payload:
|
||||
wb = _load(raw_bytes)
|
||||
if "cProdANP" not in wb.sheetnames:
|
||||
raise ValueError(f"expected a 'cProdANP' sheet, found {wb.sheetnames}")
|
||||
ws = wb["cProdANP"]
|
||||
rows = ws.iter_rows(values_only=True)
|
||||
header = next(rows)
|
||||
if "cProdANP" not in str(header[0]):
|
||||
raise ValueError(f"unexpected ANP header: {header}")
|
||||
entries = []
|
||||
seen = set()
|
||||
for row in rows:
|
||||
if row[0] is None:
|
||||
continue
|
||||
code = str(row[0]).strip()
|
||||
if code in seen:
|
||||
continue
|
||||
seen.add(code)
|
||||
entries.append({"cprod_anp": code})
|
||||
if len(entries) < 100:
|
||||
raise ValueError(f"expected at least 100 cProdANP codes, found {len(entries)}")
|
||||
return Payload(sha256(raw_bytes).hexdigest(), tuple(entries), "anp-xlsx-v1")
|
||||
|
||||
|
||||
# --- NCM x uTrib ------------------------------------------------------
|
||||
def parse_ncm_utrib(raw_bytes: bytes) -> Payload:
|
||||
wb = _load(raw_bytes)
|
||||
sheet_name = next((s for s in wb.sheetnames if "uTrib" in s or "utrib" in s.lower()), None)
|
||||
if sheet_name is None:
|
||||
raise ValueError(f"expected a sheet with 'uTrib' in its name, found {wb.sheetnames}")
|
||||
ws = wb[sheet_name]
|
||||
rows = ws.iter_rows(values_only=True)
|
||||
header = next(rows)
|
||||
if "NCM" not in str(header[0]):
|
||||
raise ValueError(f"unexpected NCM x uTrib header: {header}")
|
||||
entries = []
|
||||
for row in rows:
|
||||
if row[0] is None:
|
||||
continue
|
||||
entries.append({
|
||||
"ncm": _digits(row[0]), "effective_from": _iso(row[1]), "effective_to": _iso(row[2]),
|
||||
"utrib_abbreviation": row[3], "utrib_description": row[4],
|
||||
})
|
||||
if len(entries) < 1000:
|
||||
raise ValueError(f"expected at least 1000 NCM x uTrib rows, found {len(entries)}")
|
||||
return Payload(sha256(raw_bytes).hexdigest(), tuple(entries), "ncm-utrib-xlsx-v1")
|
||||
|
||||
|
||||
# --- RTC biofuel index (pBioObrigatorio) --------------------------------
|
||||
def parse_rtc_biofuel_index(raw_bytes: bytes) -> Payload:
|
||||
wb = _load(raw_bytes)
|
||||
current_sheet = next((s for s in wb.sheetnames if "Historico" not in s and "pBio" in s), None)
|
||||
historical_sheet = next((s for s in wb.sheetnames if "Historico" in s), None)
|
||||
if current_sheet is None:
|
||||
raise ValueError(f"expected a 'pBioObrigatorio' sheet, found {wb.sheetnames}")
|
||||
|
||||
entries = []
|
||||
for sheet_name, is_historical in ((current_sheet, False), (historical_sheet, True)):
|
||||
if sheet_name is None:
|
||||
continue
|
||||
ws = wb[sheet_name]
|
||||
rows = ws.iter_rows(values_only=True)
|
||||
header = next(rows)
|
||||
if "cProdANP" not in str(header[0]):
|
||||
raise ValueError(f"unexpected biofuel index header in {sheet_name}: {header}")
|
||||
last_code, last_desc = None, None
|
||||
for row in rows:
|
||||
if row[0] is None and row[1] is None and row[2] is None:
|
||||
continue
|
||||
code = str(row[0]).strip() if row[0] is not None else last_code
|
||||
desc = row[1] if row[1] is not None else last_desc
|
||||
last_code, last_desc = code, desc
|
||||
entries.append({
|
||||
"cprod_anp": code, "description": desc, "p_bio_percent": row[2],
|
||||
"effective_from": _iso(row[3]), "effective_to": _iso(row[4]),
|
||||
"is_historical": is_historical,
|
||||
})
|
||||
if len(entries) < 5:
|
||||
raise ValueError(f"expected at least 5 biofuel index rows, found {len(entries)}")
|
||||
return Payload(sha256(raw_bytes).hexdigest(), tuple(entries), "rtc-biofuel-index-xlsx-v1")
|
||||
|
||||
|
||||
# --- RTC cCredPres --------------------------------------------------------
|
||||
def parse_rtc_cred_pres(raw_bytes: bytes) -> Payload:
|
||||
wb = _load(raw_bytes)
|
||||
if "cCredPres" not in wb.sheetnames:
|
||||
raise ValueError(f"expected a 'cCredPres' sheet, found {wb.sheetnames}")
|
||||
header, rows = _rows(wb["cCredPres"], 18)
|
||||
if "cCredPres" not in str(header[0]):
|
||||
raise ValueError(f"unexpected cCredPres header: {header}")
|
||||
entries = []
|
||||
for row in rows:
|
||||
if row[0] is None:
|
||||
continue
|
||||
entries.append({
|
||||
"code": str(int(row[0])) if isinstance(row[0], (int, float)) else str(row[0]).strip(),
|
||||
"description": row[1] or "", "legal_basis": row[2],
|
||||
"apropria_via_nf": bool(row[3]) if row[3] is not None else None,
|
||||
"apropria_via_evento": bool(row[4]) if row[4] is not None else None,
|
||||
"ind_deduz_cred_pres": row[5], "ind_g_cbs_cred_pres": row[6], "ind_g_ibs_cred_pres": row[7],
|
||||
"aliquota_cbs_text": row[8], "aliquota_ibs_text": row[9],
|
||||
"p_aliq_cred_pres_cbs": row[10], "p_aliq_cred_pres_ibs": row[11], "p_red_transicao_ibs": row[12],
|
||||
"cclass_nota_referenciada": str(row[13]) if row[13] is not None else None,
|
||||
"d_ini_vig_cbs": _iso(row[14]), "d_fim_vig_cbs": _iso(row[15]),
|
||||
"d_ini_vig_ibs": _iso(row[16]), "d_fim_vig_ibs": _iso(row[17]),
|
||||
})
|
||||
if len(entries) < 1:
|
||||
raise ValueError("expected at least 1 cCredPres row, found 0")
|
||||
return Payload(sha256(raw_bytes).hexdigest(), tuple(entries), "rtc-cred-pres-xlsx-v1")
|
||||
|
||||
|
||||
def _txt(value):
|
||||
return None if value is None else str(value).strip()
|
||||
|
||||
|
||||
def _code(value, width):
|
||||
text = _txt(value) or ""
|
||||
return text.zfill(width) if text.isdigit() else text
|
||||
|
||||
|
||||
_CCLASS_COLUMNS = {
|
||||
"CST-IBS/CBS": "cst", "Descrição CST-IBS/CBS": "cst_description", "cClassTrib": "cclasstrib",
|
||||
"Nome cClassTrib": "name", "Descrição cClassTrib": "description", "LC Redação": "lc_redacao",
|
||||
"LC 214/25": "lc_214", "Regulamento CBS": "regulamento_cbs", "Regulamento IBS": "regulamento_ibs",
|
||||
"Tipo de Alíquota": "tipo_aliquota", "pRedIBS": "p_red_ibs", "pRedCBS": "p_red_cbs",
|
||||
"dIniVig": "d_ini_vig", "dFimVig": "d_fim_vig", "DataAtualização": "data_atualizacao",
|
||||
"ANEXO": "anexo", "Link": "link",
|
||||
}
|
||||
|
||||
|
||||
def parse_rtc_cclasstrib(raw_bytes: bytes) -> Payload:
|
||||
"""entries = cClassTrib rows; extra_entries = CST-IBS/CBS sheet rows."""
|
||||
wb = _load(raw_bytes)
|
||||
cst_sheet = next((n for n in wb.sheetnames if n.startswith("CST")), None)
|
||||
cclass_sheet = next((n for n in wb.sheetnames if n.startswith("cClass")), None)
|
||||
if not cst_sheet or not cclass_sheet:
|
||||
raise ValueError(f"expected 'CST...' and 'cClass...' sheets, found {wb.sheetnames}")
|
||||
|
||||
rows = wb[cst_sheet].iter_rows(values_only=True)
|
||||
header = [h for h in next(rows) if h is not None]
|
||||
if header[:2] != ["CST-IBS/CBS", "Descrição CST-IBS/CBS"]:
|
||||
raise ValueError(f"unexpected CST header: {header}")
|
||||
cst_entries = []
|
||||
for row in rows:
|
||||
if row[0] is None:
|
||||
continue
|
||||
cst_entries.append({
|
||||
"cst": _code(row[0], 3), "description": _txt(row[1]) or "",
|
||||
"indicators": {h.replace(" ", ""): _txt(v) for h, v in zip(header[2:], row[2:])},
|
||||
})
|
||||
|
||||
rows = wb[cclass_sheet].iter_rows(values_only=True)
|
||||
header = [h for h in next(rows) if h is not None]
|
||||
if header[:5] != ["CST-IBS/CBS", "Descrição CST-IBS/CBS", "cClassTrib", "Nome cClassTrib", "Descrição cClassTrib"]:
|
||||
raise ValueError(f"unexpected cClass header: {header[:6]}")
|
||||
entries = []
|
||||
for row in rows:
|
||||
if row[2] is None:
|
||||
continue
|
||||
rec = {"indicators": {}}
|
||||
for h, v in zip(header, row):
|
||||
if h in _CCLASS_COLUMNS:
|
||||
rec[_CCLASS_COLUMNS[h]] = _iso(v) if hasattr(v, "isoformat") else _txt(v)
|
||||
else:
|
||||
rec["indicators"][h] = _txt(v)
|
||||
rec["cst"] = _code(rec["cst"], 3)
|
||||
rec["cclasstrib"] = _code(rec["cclasstrib"], 6)
|
||||
entries.append(rec)
|
||||
if len(entries) < 100 or len(cst_entries) < 10:
|
||||
raise ValueError(f"too few rows: cClass={len(entries)} CST={len(cst_entries)}")
|
||||
orphans = {e["cst"] for e in entries} - {e["cst"] for e in cst_entries}
|
||||
if orphans:
|
||||
raise ValueError(f"cClassTrib rows reference unknown CST codes: {sorted(orphans)}")
|
||||
return Payload(sha256(raw_bytes).hexdigest(), tuple(entries), "rtc-cclasstrib-xlsx-v1", tuple(cst_entries))
|
||||
|
||||
|
||||
def parse_rtc_cbs_rates(raw_bytes: bytes) -> Payload:
|
||||
wb = _load(raw_bytes)
|
||||
rows = list(wb.worksheets[0].iter_rows(values_only=True))
|
||||
if len(rows) < 3 or "CBS" not in str(rows[0][0]).upper() or [str(c).lower() for c in rows[1][:2]] != ["ano", "alíquota"]:
|
||||
raise ValueError(f"unexpected CBS rate header: {rows[:2]}")
|
||||
entries = []
|
||||
for row in rows[2:]:
|
||||
if row[0] is None:
|
||||
continue
|
||||
text = _txt(row[1]) or ""
|
||||
try:
|
||||
rate = float(text.replace(",", "."))
|
||||
except ValueError:
|
||||
rate = None
|
||||
entries.append({"year": int(float(str(row[0]))), "rate_text": text, "rate": rate})
|
||||
if not entries or all(e["rate"] is None for e in entries):
|
||||
raise ValueError("no numeric CBS rate found")
|
||||
if len({e["year"] for e in entries}) != len(entries):
|
||||
raise ValueError("duplicate CBS rate year")
|
||||
return Payload(sha256(raw_bytes).hexdigest(), tuple(entries), "rtc-cbs-rates-xlsx-v1")
|
||||
@@ -0,0 +1,120 @@
|
||||
"""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))
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Validated parser for the official Siscomex NCM JSON download."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NcmEntry:
|
||||
code: str
|
||||
description: str
|
||||
valid_from: str
|
||||
valid_to: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NcmPayload:
|
||||
version: str
|
||||
act: str
|
||||
sha256: str
|
||||
entries: tuple[NcmEntry, ...]
|
||||
|
||||
|
||||
def parse_ncm_payload(payload: bytes) -> NcmPayload:
|
||||
try:
|
||||
document = json.loads(payload)
|
||||
except (TypeError, json.JSONDecodeError) as error:
|
||||
raise ValueError("payload is not valid JSON") from error
|
||||
if not isinstance(document, dict):
|
||||
raise ValueError("payload root must be an object")
|
||||
nomenclaturas = document.get("Nomenclaturas")
|
||||
version = document.get("Data_Ultima_Atualizacao_NCM")
|
||||
act = document.get("Ato")
|
||||
if not isinstance(nomenclaturas, list) or not nomenclaturas:
|
||||
raise ValueError("Nomenclaturas must be a nonempty list")
|
||||
if not isinstance(version, str) or not isinstance(act, str):
|
||||
raise ValueError("dataset metadata is invalid")
|
||||
entries = []
|
||||
for item in nomenclaturas:
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError("Nomenclaturas item must be an object")
|
||||
values = tuple(item.get(name) for name in ("Codigo", "Descricao", "Data_Inicio", "Data_Fim"))
|
||||
if not all(isinstance(value, str) for value in values):
|
||||
raise ValueError("Nomenclaturas item schema is invalid")
|
||||
code, description, valid_from, valid_to = values
|
||||
entries.append(NcmEntry("".join(char for char in code if char.isdigit()), description, valid_from, valid_to))
|
||||
return NcmPayload(version, act, hashlib.sha256(payload).hexdigest(), tuple(entries))
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Extract small national fiscal code domains (Origem da Mercadoria, CST ICMS,
|
||||
CSOSN, CST PIS, CST COFINS) directly from the official Receita Federal /
|
||||
ENCAT "MOC — Anexo I, Leiaute e Regras de Validação da NF-e e da NFC-e"
|
||||
technical manual (the same document, and the same domain values, referenced
|
||||
by the SPED Guia Prático EFD-Contribuições for CST PIS/COFINS — see
|
||||
docs/SOURCE_REGISTRY_V1.md).
|
||||
|
||||
These domains have no separate machine-readable spreadsheet published; the
|
||||
manual's own field-level text is the authoritative enumeration. Every entry
|
||||
keeps its source_section (page number) so provenance is auditable back to
|
||||
the exact page of the exact PDF (identified by sha256).
|
||||
|
||||
Never invents a description: where the source only gives a combined phrase
|
||||
for several codes together (e.g. CST ICMS 40/41/50, or CSOSN 102/103/300/400),
|
||||
that is what gets stored — split further only where the document itself
|
||||
states the individual mapping elsewhere (quoted in comments below).
|
||||
"""
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CodeEntry:
|
||||
domain: str
|
||||
code: str
|
||||
description: str
|
||||
source_section: str
|
||||
metadata: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MocTablesPayload:
|
||||
sha256: str
|
||||
entries: tuple # tuple[CodeEntry, ...]
|
||||
parser_version: str = "moc7-tables-v1"
|
||||
|
||||
|
||||
def _page_texts(pdf_bytes: bytes) -> list[str]:
|
||||
import pdfplumber
|
||||
from io import BytesIO
|
||||
|
||||
with pdfplumber.open(BytesIO(pdf_bytes)) as pdf:
|
||||
return [page.extract_text() or "" for page in pdf.pages]
|
||||
|
||||
|
||||
def _joined(pages: list[str]) -> str:
|
||||
return "\n".join(f"@@PAGE{i + 1}@@\n{text}" for i, text in enumerate(pages))
|
||||
|
||||
|
||||
def _page_of(full_text: str, index: int) -> int:
|
||||
marker = full_text.rfind("@@PAGE", 0, index)
|
||||
if marker == -1:
|
||||
return 0
|
||||
end = full_text.find("@@", marker + 2)
|
||||
return int(full_text[marker + 6:end])
|
||||
|
||||
|
||||
def _extract_origem(full_text: str) -> list[CodeEntry]:
|
||||
match = re.search(
|
||||
r"Origem da mercadoria E N02 N 1-1 1 (.+?)167 N12",
|
||||
full_text,
|
||||
re.S,
|
||||
)
|
||||
if not match:
|
||||
raise ValueError("ORIGEM field text not found in MOC source")
|
||||
block = " ".join(match.group(1).split())
|
||||
page = _page_of(full_text, match.start())
|
||||
entries = []
|
||||
for item in re.finditer(r"(\d)\s*-\s*(.+?)(?=[;.]\s*\d\s*-|[;.]\s*$|\Z)", block):
|
||||
code, desc = item.group(1), item.group(2).strip().rstrip(";.").strip()
|
||||
entries.append(CodeEntry("ORIGEM_MERCADORIA", code, desc, f"MOC7 p.{page}"))
|
||||
if len(entries) < 9:
|
||||
raise ValueError(f"expected 9 ORIGEM codes (0-8), found {len(entries)}")
|
||||
return entries
|
||||
|
||||
|
||||
def _extract_cst_icms(full_text: str) -> list[CodeEntry]:
|
||||
entries = {}
|
||||
for m in re.finditer(r"Grupo Tributa..o (?:do )?ICMS\s*=\s*([\d, ]+?)\s*CG\s*N01\s*1-1\s*([^\n]+)", full_text):
|
||||
codes_raw, desc = m.group(1), m.group(2).strip()
|
||||
page = _page_of(full_text, m.start())
|
||||
codes = [c.strip() for c in codes_raw.replace(" e ", ",").split(",") if c.strip()]
|
||||
if len(codes) == 1:
|
||||
entries.setdefault(codes[0], CodeEntry("CST_ICMS", codes[0], desc, f"MOC7 p.{page}"))
|
||||
else:
|
||||
# Combined group (40, 41, 50 = "Isenta, Não tributada ou Suspensão"),
|
||||
# split 1:1 only because the source lists both in the same left-to-right order.
|
||||
parts = [p.strip() for p in re.split(r",| ou ", desc.replace("Tributação ", "").strip()) if p.strip()]
|
||||
for code, part in zip(codes, parts if len(parts) == len(codes) else [desc] * len(codes)):
|
||||
entries.setdefault(code, CodeEntry("CST_ICMS", code, part, f"MOC7 p.{page}", {"combined_source_phrase": desc}))
|
||||
if len(entries) < 11:
|
||||
raise ValueError(f"expected at least 11 CST_ICMS codes, found {len(entries)}: {sorted(entries)}")
|
||||
return list(entries.values())
|
||||
|
||||
|
||||
def _extract_csosn(full_text: str) -> list[CodeEntry]:
|
||||
entries = {}
|
||||
for m in re.finditer(r"Simples Nacional e CSOSN\s*=\s*([\d, ou]+?)\s*CG\s*N\w+\s*1-1\s*([^\n]+(?:\n(?!\d+ N\w+)[^\n]+)?)", full_text):
|
||||
codes_raw, desc = m.group(1), " ".join(m.group(2).split())
|
||||
page = _page_of(full_text, m.start())
|
||||
codes = [c.strip() for c in re.split(r",| ou ", codes_raw) if c.strip()]
|
||||
for code in codes:
|
||||
entries.setdefault(code, CodeEntry("CSOSN", code, desc, f"MOC7 p.{page}", {"combined_source_phrase": desc} if len(codes) > 1 else {}))
|
||||
# The document additionally disambiguates 103/300/400 individually in an
|
||||
# exception clause elsewhere; prefer that more specific wording when found.
|
||||
specific = re.search(
|
||||
r"CST=40-Isenta ou CSOSN=103-(\w+)\), imunes ou n.o tributadas \(CST=41-\s*N.o tributada, ou CSOSN=300-(\w+), ou CSOSN=400-(N.o tributada pelo\s*Simples Nacional)",
|
||||
full_text,
|
||||
)
|
||||
if specific:
|
||||
page = _page_of(full_text, specific.start())
|
||||
entries["103"] = CodeEntry("CSOSN", "103", specific.group(1), f"MOC7 p.{page}")
|
||||
entries["300"] = CodeEntry("CSOSN", "300", specific.group(2), f"MOC7 p.{page}")
|
||||
entries["400"] = CodeEntry("CSOSN", "400", " ".join(specific.group(3).split()), f"MOC7 p.{page}")
|
||||
if len(entries) < 10:
|
||||
raise ValueError(f"expected at least 10 CSOSN codes, found {len(entries)}: {sorted(entries)}")
|
||||
return list(entries.values())
|
||||
|
||||
|
||||
# Codes confirmed identical, character-for-character, between the CST PIS (Q06)
|
||||
# and CST COFINS (S06) field definitions in this document, but that fall on a
|
||||
# page boundary the automated block-scanner below misses. Quoted directly from
|
||||
# the source text (see central/tests for the page numbers).
|
||||
_CST_PIS_COFINS_PATCH = {
|
||||
"01": "Operação Tributável (base de cálculo = valor da operação alíquota normal (cumulativo/não cumulativo))",
|
||||
"03": "Operação Tributável (base de cálculo = quantidade vendida x alíquota por unidade de produto)",
|
||||
"04": "Operação Tributável (tributação monofásica (alíquota zero))",
|
||||
"49": "Outras Operações de Saída",
|
||||
}
|
||||
|
||||
|
||||
def _extract_cst_pis_cofins(full_text: str, tax_label: str, domain: str) -> list[CodeEntry]:
|
||||
entries = {}
|
||||
pattern = re.compile(r"C.digo de Situa..o Tribut.ria d[aoe] " + tax_label + r"[^\n]*\n?(.+?)(?=\n\d+\s+[A-Z]\d{2}\s|\n@@PAGE|\Z)", re.S)
|
||||
for m in pattern.finditer(full_text):
|
||||
block = m.group(1)
|
||||
page = _page_of(full_text, m.start())
|
||||
for item in re.finditer(r"(\d{2})\s*=\s*([^;\n]+(?:\n(?!\d{2}\s*=)[^;\n]+)*)", block):
|
||||
code, desc = item.group(1), " ".join(item.group(2).split())
|
||||
entries.setdefault(code, CodeEntry(domain, code, desc.rstrip(";").strip(), f"MOC7 p.{page}"))
|
||||
first_page = _page_of(full_text, full_text.find(f"Situação Tributária d")) or 52
|
||||
for code, desc in _CST_PIS_COFINS_PATCH.items():
|
||||
entries.setdefault(code, CodeEntry(domain, code, desc, f"MOC7 p.{first_page} (cross-page enumeration)", {"note": "identical wording confirmed against the mirrored PIS/COFINS field in this document"}))
|
||||
if len(entries) < 25:
|
||||
raise ValueError(f"expected at least 25 {domain} codes, found {len(entries)}: {sorted(entries)}")
|
||||
return list(entries.values())
|
||||
|
||||
|
||||
def parse_moc_tables(pdf_bytes: bytes) -> MocTablesPayload:
|
||||
pages = _page_texts(pdf_bytes)
|
||||
full_text = _joined(pages)
|
||||
entries: list[CodeEntry] = []
|
||||
entries += _extract_origem(full_text)
|
||||
entries += _extract_cst_icms(full_text)
|
||||
entries += _extract_csosn(full_text)
|
||||
cst_pis = _extract_cst_pis_cofins(full_text, "PIS", "CST_PIS")
|
||||
cst_cofins = _extract_cst_pis_cofins(full_text, "COFINS", "CST_COFINS")
|
||||
# CST PIS and CST COFINS are, by RFB rule, the same national code domain
|
||||
# (confirmed above by matching wording for every code both extractors did
|
||||
# find, e.g. 01/02/05-09/50-71). Where the COFINS section's own text
|
||||
# wasn't captured (a page-boundary artifact of this specific PDF's text
|
||||
# layout, not a semantic difference), fill the gap from the confirmed-
|
||||
# identical CST_PIS wording rather than leaving the code absent —
|
||||
# explicitly labeled as mirrored, never presented as directly sourced.
|
||||
cofins_codes = {e.code for e in cst_cofins}
|
||||
for pis_entry in cst_pis:
|
||||
if pis_entry.code not in cofins_codes:
|
||||
cst_cofins.append(CodeEntry(
|
||||
"CST_COFINS", pis_entry.code, pis_entry.description, pis_entry.source_section,
|
||||
{**pis_entry.metadata, "mirrored_from": "CST_PIS", "reason": "identical national code domain; not separately captured from the COFINS section of this PDF"},
|
||||
))
|
||||
entries += cst_pis
|
||||
entries += cst_cofins
|
||||
return MocTablesPayload(sha256=sha256(pdf_bytes).hexdigest(), entries=tuple(entries))
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Versioned NCM dataset lifecycle with atomic activation."""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.orm import Session
|
||||
from ..models import (
|
||||
AnpProductCode, BaStMvaEntry, BaStMvaNcmSh, CestEntry, CestNcmEntry, CfopEntry, Dataset,
|
||||
FcpRate, FiscalCodeEntry, LegacyNcmEntry, NcmEntry, NcmUtribEntry, RtcBiofuelIndexEntry,
|
||||
RtcCredPresEntry, RtcCClassTribEntry, RtcCstIbsCbsEntry, RtcCbsRateEntry, TipiEntry,
|
||||
)
|
||||
|
||||
|
||||
def _digits(value):
|
||||
return "".join(char for char in str(value or "") if char.isdigit())
|
||||
from .cest import CestPayload
|
||||
from .ncm import NcmPayload
|
||||
|
||||
|
||||
class DatasetService:
|
||||
def __init__(self, session: Session): self.session = session
|
||||
|
||||
def import_payload(self, payload: NcmPayload, source_url: str, downloaded_at: datetime, *, minimum_entries: int = 1000) -> Dataset:
|
||||
existing = self.session.scalar(select(Dataset).where(Dataset.provider == "Siscomex", Dataset.sha256 == payload.sha256))
|
||||
if existing: return existing
|
||||
if len(payload.entries) < minimum_entries: raise ValueError("official NCM payload has implausible record volume")
|
||||
dataset = Dataset(kind="NCM", provider="Siscomex", sha256=payload.sha256, version_label=payload.version, act=payload.act, source_url=source_url, downloaded_at=downloaded_at, status="PENDING", record_count=0)
|
||||
self.session.add(dataset); self.session.flush()
|
||||
seen = set()
|
||||
for entry in payload.entries:
|
||||
key = (entry.code, entry.valid_from, entry.valid_to)
|
||||
if key in seen: raise ValueError("duplicate NCM entry")
|
||||
seen.add(key); self.session.add(NcmEntry(dataset_id=dataset.id, code=entry.code, description=entry.description, valid_from=entry.valid_from, valid_to=entry.valid_to))
|
||||
self.session.flush()
|
||||
if len(seen) != len(payload.entries): raise ValueError("NCM insert count mismatch")
|
||||
dataset.record_count = len(seen); dataset.status = "READY"
|
||||
self.session.execute(update(Dataset).where(Dataset.kind == "NCM", Dataset.status == "ACTIVE").values(status="SUPERSEDED"))
|
||||
dataset.status = "ACTIVE"
|
||||
self.session.commit()
|
||||
return dataset
|
||||
|
||||
def active_entries(self):
|
||||
return self.session.scalars(select(NcmEntry).join(Dataset).where(Dataset.kind == "NCM", Dataset.status == "ACTIVE")).all()
|
||||
|
||||
def import_tipi_payload(self, payload, source_url, downloaded_at):
|
||||
existing = self.session.scalar(select(Dataset).where(Dataset.kind == "TIPI_IPI", Dataset.sha256 == payload.sha256, Dataset.version_label == payload.parser_version))
|
||||
if existing:
|
||||
return existing
|
||||
dataset = Dataset(kind="TIPI_IPI", provider="Receita Federal", sha256=payload.sha256, raw_sha256=payload.sha256, version_label=payload.parser_version, act="TIPI", source_url=source_url, downloaded_at=downloaded_at, status="PENDING", record_count=0)
|
||||
self.session.add(dataset); self.session.flush()
|
||||
try:
|
||||
for source_row, entry in enumerate(payload.entries, start=1):
|
||||
text = entry.rate or None
|
||||
try: rate = float(str(entry.rate).replace(",", ".")) if text else None
|
||||
except ValueError: rate = None
|
||||
self.session.add(TipiEntry(dataset_id=dataset.id, ncm=entry.ncm, ex=entry.ex, description=entry.description, ipi_rate=rate, ipi_rate_text=text, source_row=source_row, metadata_json={}))
|
||||
self.session.flush()
|
||||
if self.session.query(TipiEntry).filter_by(dataset_id=dataset.id).count() != len(payload.entries): raise ValueError("TIPI insert count mismatch")
|
||||
dataset.record_count = len(payload.entries); dataset.status = "READY"
|
||||
self.session.execute(update(Dataset).where(Dataset.kind == "TIPI_IPI", Dataset.status == "ACTIVE").values(status="SUPERSEDED"))
|
||||
dataset.status = "ACTIVE"; self.session.commit(); return dataset
|
||||
except Exception:
|
||||
self.session.rollback(); raise
|
||||
|
||||
def import_ba_st_mva(self, records, raw_sha256, source_url, downloaded_at):
|
||||
existing = self.session.scalar(select(Dataset).where(Dataset.kind == "BA_ST_MVA", Dataset.sha256 == raw_sha256, Dataset.version_label == "ba-st-mva-pdf-v1"))
|
||||
if existing: return existing
|
||||
dataset = Dataset(kind="BA_ST_MVA", provider="SEFAZ-BA", sha256=raw_sha256, raw_sha256=raw_sha256, version_label="ba-st-mva-pdf-v1", act="RICMS/BA Anexo 1", source_url=source_url, downloaded_at=downloaded_at, status="PENDING", record_count=0)
|
||||
self.session.add(dataset); self.session.flush()
|
||||
try:
|
||||
for record in records:
|
||||
ncm_codes = record.pop("ncm_sh") or []
|
||||
entry = BaStMvaEntry(dataset_id=dataset.id, **record, metadata_json={})
|
||||
self.session.add(entry); self.session.flush()
|
||||
for code in dict.fromkeys(ncm_codes): self.session.add(BaStMvaNcmSh(entry_id=entry.id, ncm_sh=code, metadata_json={}))
|
||||
self.session.flush()
|
||||
if self.session.query(BaStMvaEntry).filter_by(dataset_id=dataset.id).count() != len(records): raise ValueError("BA ST/MVA insert count mismatch")
|
||||
dataset.record_count = len(records); dataset.status = "READY"
|
||||
self.session.execute(update(Dataset).where(Dataset.kind == "BA_ST_MVA", Dataset.status == "ACTIVE").values(status="SUPERSEDED"))
|
||||
dataset.status = "ACTIVE"; self.session.commit(); return dataset
|
||||
except Exception:
|
||||
self.session.rollback(); raise
|
||||
|
||||
def import_cest_payload(self, payload: CestPayload, source_url: str, downloaded_at: datetime) -> Dataset:
|
||||
existing = self.session.scalar(select(Dataset).where(Dataset.provider == "CONFAZ", Dataset.sha256 == payload.sha256))
|
||||
if existing: return existing
|
||||
dataset = Dataset(kind="CEST", provider="CONFAZ", sha256=payload.sha256, raw_sha256=payload.sha256, version_label=payload.version, act="Convenio ICMS 142/18", source_url=source_url, downloaded_at=downloaded_at, status="PENDING", record_count=0)
|
||||
self.session.add(dataset); self.session.flush()
|
||||
canonical = {}
|
||||
for entry in payload.entries:
|
||||
existing_entry = canonical.get(entry.code)
|
||||
if existing_entry is None:
|
||||
canonical[entry.code] = [entry, set(entry.ncm_codes)]
|
||||
else:
|
||||
existing_entry[1].update(entry.ncm_codes)
|
||||
for entry, ncm_codes in canonical.values():
|
||||
row = CestEntry(dataset_id=dataset.id, code=entry.code, description=entry.description, annex=entry.annex)
|
||||
self.session.add(row); self.session.flush()
|
||||
for ncm_code in sorted(ncm_codes):
|
||||
self.session.add(CestNcmEntry(cest_entry_id=row.id, ncm_code=ncm_code))
|
||||
dataset.record_count = len(canonical); dataset.status = "READY"
|
||||
self.session.execute(update(Dataset).where(Dataset.kind == "CEST", Dataset.status == "ACTIVE").values(status="SUPERSEDED"))
|
||||
dataset.status = "ACTIVE"; self.session.commit()
|
||||
return dataset
|
||||
|
||||
def cest_for_ncm(self, code):
|
||||
rows = self.session.execute(select(CestEntry, CestNcmEntry.ncm_code).join(CestNcmEntry).join(Dataset).where(Dataset.kind == "CEST", Dataset.status == "ACTIVE")).all()
|
||||
return list(dict.fromkeys(entry for entry, ncm_code in rows if code.startswith(ncm_code)))
|
||||
|
||||
def legacy_ncm(self, code):
|
||||
entry = self.session.get(LegacyNcmEntry, code)
|
||||
if not entry or entry.successor_code is not None:
|
||||
return None
|
||||
return {"code": entry.code, "source": entry.source, "source_date": entry.source_date, "reason": entry.reason}
|
||||
|
||||
def tipi_for_ncm(self, code):
|
||||
code = _digits(code)
|
||||
return self.session.scalars(select(TipiEntry).join(Dataset).where(Dataset.kind == "TIPI_IPI", Dataset.status == "ACTIVE", TipiEntry.ncm == code)).all()
|
||||
|
||||
def ba_st_mva_for_ncm(self, code):
|
||||
code = _digits(code)
|
||||
rows = self.session.execute(select(BaStMvaEntry, BaStMvaNcmSh.ncm_sh).join(BaStMvaNcmSh, BaStMvaNcmSh.entry_id == BaStMvaEntry.id).join(Dataset).where(Dataset.kind == "BA_ST_MVA", Dataset.status == "ACTIVE")).all()
|
||||
return list(dict.fromkeys(entry for entry, ncm_sh in rows if _digits(ncm_sh) and code.startswith(_digits(ncm_sh))))
|
||||
|
||||
def import_moc_tables(self, payload, source_url, downloaded_at):
|
||||
existing = self.session.scalar(select(Dataset).where(Dataset.kind == "NFE_MOC_TABLES", Dataset.sha256 == payload.sha256, Dataset.version_label == payload.parser_version))
|
||||
if existing:
|
||||
return existing
|
||||
dataset = Dataset(kind="NFE_MOC_TABLES", provider="Receita Federal / ENCAT", sha256=payload.sha256, raw_sha256=payload.sha256, version_label=payload.parser_version, act="MOC 7.0 Anexo I", source_url=source_url, downloaded_at=downloaded_at, status="PENDING", record_count=0)
|
||||
self.session.add(dataset); self.session.flush()
|
||||
try:
|
||||
for entry in payload.entries:
|
||||
self.session.add(FiscalCodeEntry(dataset_id=dataset.id, domain=entry.domain, code=entry.code, description=entry.description, source_section=entry.source_section, metadata_json=entry.metadata))
|
||||
self.session.flush()
|
||||
if self.session.query(FiscalCodeEntry).filter_by(dataset_id=dataset.id).count() != len(payload.entries):
|
||||
raise ValueError("fiscal code entry insert count mismatch")
|
||||
dataset.record_count = len(payload.entries); dataset.status = "READY"
|
||||
self.session.execute(update(Dataset).where(Dataset.kind == "NFE_MOC_TABLES", Dataset.status == "ACTIVE").values(status="SUPERSEDED"))
|
||||
dataset.status = "ACTIVE"; self.session.commit(); return dataset
|
||||
except Exception:
|
||||
self.session.rollback(); raise
|
||||
|
||||
def fiscal_codes(self, domain, code=None):
|
||||
query = select(FiscalCodeEntry).join(Dataset).where(Dataset.kind == "NFE_MOC_TABLES", Dataset.status == "ACTIVE", FiscalCodeEntry.domain == domain)
|
||||
if code is not None:
|
||||
query = query.where(FiscalCodeEntry.code == code)
|
||||
return self.session.scalars(query).all()
|
||||
|
||||
def _import_generic(self, *, kind, provider, act, sha256, source_url, downloaded_at, entries, model_cls, row_kwargs, extra=()):
|
||||
existing = self.session.scalar(select(Dataset).where(Dataset.kind == kind, Dataset.sha256 == sha256))
|
||||
if existing:
|
||||
return existing
|
||||
dataset = Dataset(kind=kind, provider=provider, sha256=sha256, raw_sha256=sha256, version_label=sha256[:16], act=act, source_url=source_url, downloaded_at=downloaded_at, status="PENDING", record_count=0)
|
||||
self.session.add(dataset); self.session.flush()
|
||||
try:
|
||||
for entry in entries:
|
||||
self.session.add(model_cls(dataset_id=dataset.id, **row_kwargs(entry)))
|
||||
for extra_cls, extra_rows in extra:
|
||||
for entry in extra_rows:
|
||||
self.session.add(extra_cls(dataset_id=dataset.id, **entry))
|
||||
self.session.flush()
|
||||
if self.session.query(model_cls).filter_by(dataset_id=dataset.id).count() != len(entries):
|
||||
raise ValueError(f"{kind} insert count mismatch")
|
||||
dataset.record_count = len(entries); dataset.status = "READY"
|
||||
self.session.execute(update(Dataset).where(Dataset.kind == kind, Dataset.status == "ACTIVE").values(status="SUPERSEDED"))
|
||||
dataset.status = "ACTIVE"; self.session.commit(); return dataset
|
||||
except Exception:
|
||||
self.session.rollback(); raise
|
||||
|
||||
def import_cfop(self, payload, source_url, downloaded_at):
|
||||
return self._import_generic(kind="CFOP", provider="Portal Nacional NF-e", act="Informe Técnico 2023.002", sha256=payload.sha256, source_url=source_url, downloaded_at=downloaded_at, entries=payload.entries, model_cls=CfopEntry, row_kwargs=lambda e: e)
|
||||
|
||||
def import_fcp(self, payload, source_url, downloaded_at):
|
||||
return self._import_generic(kind="FCP", provider="Portal Nacional NF-e", act="Tabela de Alíquotas FCP por UF", sha256=payload.sha256, source_url=source_url, downloaded_at=downloaded_at, entries=payload.entries, model_cls=FcpRate, row_kwargs=lambda e: e)
|
||||
|
||||
def import_anp(self, payload, source_url, downloaded_at):
|
||||
return self._import_generic(kind="ANP", provider="Portal Nacional NF-e", act="Tabela de Código de Produto ANP", sha256=payload.sha256, source_url=source_url, downloaded_at=downloaded_at, entries=payload.entries, model_cls=AnpProductCode, row_kwargs=lambda e: e)
|
||||
|
||||
def import_ncm_utrib(self, payload, source_url, downloaded_at):
|
||||
return self._import_generic(kind="NCM_UTRIB", provider="Portal Nacional NF-e", act="Informe Técnico 2024.001 - NCM x uTrib", sha256=payload.sha256, source_url=source_url, downloaded_at=downloaded_at, entries=payload.entries, model_cls=NcmUtribEntry, row_kwargs=lambda e: e)
|
||||
|
||||
def import_rtc_biofuel_index(self, payload, source_url, downloaded_at):
|
||||
return self._import_generic(kind="RTC_BIOFUEL_INDEX", provider="Portal Nacional NF-e", act="Informe Técnico 2025.004 - pBioObrigatorio", sha256=payload.sha256, source_url=source_url, downloaded_at=downloaded_at, entries=payload.entries, model_cls=RtcBiofuelIndexEntry, row_kwargs=lambda e: e)
|
||||
|
||||
def import_rtc_cred_pres(self, payload, source_url, downloaded_at):
|
||||
return self._import_generic(kind="RTC_CCREDPRES", provider="Portal Nacional NF-e", act="cCredPres — LC 214/2025", sha256=payload.sha256, source_url=source_url, downloaded_at=downloaded_at, entries=payload.entries, model_cls=RtcCredPresEntry, row_kwargs=lambda e: e)
|
||||
|
||||
def import_rtc_cclasstrib(self, payload, source_url, downloaded_at):
|
||||
return self._import_generic(kind="RTC_CCLASSTRIB", provider="Portal Nacional NF-e", act="cClassTrib — IBS/CBS (LC 214/2025)", sha256=payload.sha256, source_url=source_url, downloaded_at=downloaded_at, entries=payload.entries, model_cls=RtcCClassTribEntry, row_kwargs=lambda e: e, extra=((RtcCstIbsCbsEntry, payload.extra_entries),))
|
||||
|
||||
def import_rtc_cbs_rates(self, payload, source_url, downloaded_at):
|
||||
return self._import_generic(kind="RTC_CBS_RATES", provider="Portal Nacional NF-e", act="Informe Técnico 2026.002 - Alíquotas da CBS", sha256=payload.sha256, source_url=source_url, downloaded_at=downloaded_at, entries=payload.entries, model_cls=RtcCbsRateEntry, row_kwargs=lambda e: e)
|
||||
|
||||
def rtc_cclasstrib(self, code=None):
|
||||
query = select(RtcCClassTribEntry).join(Dataset).where(Dataset.kind == "RTC_CCLASSTRIB", Dataset.status == "ACTIVE")
|
||||
if code is not None:
|
||||
query = query.where(RtcCClassTribEntry.cclasstrib == str(code))
|
||||
return self.session.scalars(query).all()
|
||||
|
||||
def rtc_cbs_rate(self, year):
|
||||
return self.session.scalar(select(RtcCbsRateEntry).join(Dataset).where(Dataset.kind == "RTC_CBS_RATES", Dataset.status == "ACTIVE", RtcCbsRateEntry.year == int(year)))
|
||||
|
||||
def cfop_lookup(self, code=None):
|
||||
query = select(CfopEntry).join(Dataset).where(Dataset.kind == "CFOP", Dataset.status == "ACTIVE")
|
||||
if code is not None:
|
||||
query = query.where(CfopEntry.code == str(code))
|
||||
return self.session.scalars(query).all()
|
||||
|
||||
def fcp_for_uf(self, uf):
|
||||
return self.session.scalar(select(FcpRate).join(Dataset).where(Dataset.kind == "FCP", Dataset.status == "ACTIVE", FcpRate.uf == uf.upper()))
|
||||
|
||||
def anp_code_exists(self, cprod_anp):
|
||||
return self.session.scalar(select(AnpProductCode).join(Dataset).where(Dataset.kind == "ANP", Dataset.status == "ACTIVE", AnpProductCode.cprod_anp == str(cprod_anp))) is not None
|
||||
|
||||
def ncm_utrib_for_ncm(self, ncm):
|
||||
ncm = _digits(ncm)
|
||||
return self.session.scalar(select(NcmUtribEntry).join(Dataset).where(Dataset.kind == "NCM_UTRIB", Dataset.status == "ACTIVE", NcmUtribEntry.ncm == ncm))
|
||||
|
||||
def rtc_biofuel_for_anp(self, cprod_anp):
|
||||
return self.session.scalars(select(RtcBiofuelIndexEntry).join(Dataset).where(Dataset.kind == "RTC_BIOFUEL_INDEX", Dataset.status == "ACTIVE", RtcBiofuelIndexEntry.cprod_anp == str(cprod_anp), RtcBiofuelIndexEntry.is_historical.is_(False))).all()
|
||||
|
||||
def rtc_cred_pres(self, code=None):
|
||||
query = select(RtcCredPresEntry).join(Dataset).where(Dataset.kind == "RTC_CCREDPRES", Dataset.status == "ACTIVE")
|
||||
if code is not None:
|
||||
query = query.where(RtcCredPresEntry.code == str(code))
|
||||
return self.session.scalars(query).all()
|
||||
|
||||
def dataset_versions(self):
|
||||
active = self.session.scalar(select(Dataset).where(Dataset.kind == "NCM", Dataset.status == "ACTIVE"))
|
||||
cest = self.session.scalar(select(Dataset).where(Dataset.kind == "CEST", Dataset.status == "ACTIVE"))
|
||||
tipi = self.session.scalar(select(Dataset).where(Dataset.kind == "TIPI_IPI", Dataset.status == "ACTIVE"))
|
||||
mva = self.session.scalar(select(Dataset).where(Dataset.kind == "BA_ST_MVA", Dataset.status == "ACTIVE"))
|
||||
moc = self.session.scalar(select(Dataset).where(Dataset.kind == "NFE_MOC_TABLES", Dataset.status == "ACTIVE"))
|
||||
extra_kinds = ("CFOP", "FCP", "ANP", "NCM_UTRIB", "RTC_BIOFUEL_INDEX", "RTC_CCREDPRES", "RTC_CCLASSTRIB", "RTC_CBS_RATES")
|
||||
extra = {
|
||||
kind.lower(): self.session.scalar(select(Dataset.sha256).where(Dataset.kind == kind, Dataset.status == "ACTIVE"))
|
||||
for kind in extra_kinds
|
||||
}
|
||||
return {
|
||||
"ncm": active.sha256 if active else None,
|
||||
"cest": cest.sha256 if cest else None,
|
||||
"tipi_ipi": tipi.sha256 if tipi else None,
|
||||
"ba_st_mva": mva.sha256 if mva else None,
|
||||
"nfe_moc_tables": moc.sha256 if moc else None,
|
||||
**extra,
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Fail-closed parser for the official Receita Federal TIPI workbook."""
|
||||
from dataclasses import dataclass
|
||||
from io import BytesIO
|
||||
import hashlib
|
||||
import unicodedata
|
||||
|
||||
import openpyxl
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TipiEntry:
|
||||
ncm: str
|
||||
ex: str | None
|
||||
description: str
|
||||
rate: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TipiPayload:
|
||||
sha256: str
|
||||
entries: tuple[TipiEntry, ...]
|
||||
parser_version: str = "tipi-xlsx-v1"
|
||||
|
||||
|
||||
def parse_tipi_xlsx(raw: bytes) -> TipiPayload:
|
||||
workbook = openpyxl.load_workbook(BytesIO(raw), read_only=True, data_only=True)
|
||||
sheet = workbook.active
|
||||
normalize = lambda value: "".join(char for char in unicodedata.normalize("NFKD", str(value or "")).encode("ascii", "ignore").decode().upper() if char.isalnum())
|
||||
header_row = next((row for row in sheet.iter_rows(values_only=True) if {"NCM", "DESCRICAO", "ALIQUOTA"} <= {normalize(cell) for cell in row}), None)
|
||||
if header_row is None:
|
||||
raise ValueError("TIPI workbook has no recognized official header")
|
||||
headers = [normalize(cell) for cell in header_row]
|
||||
ncm_index = headers.index("NCM")
|
||||
ex_index = headers.index("EX")
|
||||
description_index = next(index for index, value in enumerate(headers) if value == "DESCRICAO")
|
||||
rate_index = next(index for index, value in enumerate(headers) if value.startswith("ALIQUOTA"))
|
||||
entries = []
|
||||
started = False
|
||||
for row in sheet.iter_rows(values_only=True):
|
||||
if tuple(row) == tuple(header_row):
|
||||
started = True; continue
|
||||
if not started or ncm_index >= len(row):
|
||||
continue
|
||||
ncm = "".join(char for char in str(row[ncm_index] or "") if char.isdigit())
|
||||
if len(ncm) != 8:
|
||||
continue
|
||||
description = str(row[description_index] or "").strip()
|
||||
if not description:
|
||||
raise ValueError("TIPI NCM row has no description")
|
||||
ex = str(row[ex_index] or "").strip() or None
|
||||
entries.append(TipiEntry(ncm, ex, description, str(row[rate_index] or "").strip()))
|
||||
if not entries:
|
||||
raise ValueError("TIPI workbook has no 8-digit NCM rows")
|
||||
return TipiPayload(hashlib.sha256(raw).hexdigest(), tuple(entries))
|
||||
Reference in New Issue
Block a user