Files
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

260 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Missão 6 — CLI de Ingestão de Datasets de Referência (P1–P4).
Fontes suportadas:
- ncm (P1): Siscomex NCM JSON oficial
- nfe_domains (P2): Receita Federal / ENCAT MOC 7.0 Anexo I (Origem, CST ICMS, CSOSN, CST PIS/COFINS, CST IPI, Unidades)
- cfop (P2 extra): Portal Nacional NF-e / IT 2023.002
- tipi (P3): Receita Federal TIPI (Decreto nº 11.158/2022 com alterações até 2026)
- cest (P4): CONFAZ Convênio ICMS 142/18
Registra SEMPRE como status DRAFT no Reference Data Registry.
Nenhuma versão vira VIGENTE sem aprovação explícita do administrador.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import sys
import urllib.request
from datetime import date, datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
RAW_DIR = ROOT / "sources" / "raw" / "reference_registry"
BOOTSTRAP_DIR = ROOT / "sources" / "raw" / "bootstrap_v1"
USER_AGENT = "GTI-Fiscal-Hub/1.0 (dataset ingestion; contato: operador GTI)"
SOURCES = {
"ncm": {
"kind": "NCM",
"provider": "Siscomex",
"url": "https://portalunico.siscomex.gov.br/classif/api/publico/nomenclatura/download/json",
"licence": "Dado público oficial (Governo Federal / Siscomex); sem termos de uso restritivos conhecidos.",
"act": "Resolução Gecex / MDIC",
"extension": "json",
"fallback_bootstrap": "ncm_vigente_20260928.json",
},
"tipi": {
"kind": "TIPI_IPI",
"provider": "Receita Federal",
"url": "https://www.gov.br/receitafederal/pt-br/assuntos/orientacao-tributaria/tributos-e-receitas/tipi",
"licence": "Dado público oficial (Receita Federal do Brasil); dados abertos governamentais.",
"act": "Decreto nº 11.158/2022 e alterações até ADE 001-2026",
"extension": "xlsx",
"fallback_bootstrap": "1 TIPI 2022 - Atualizada ADE 001-2026.xlsx",
},
"cest": {
"kind": "CEST",
"provider": "CONFAZ",
"url": "https://www.confaz.fazenda.gov.br/legislacao/convenios/2018/CV142_18",
"licence": "Dado público oficial (Conselho Nacional de Política Fazendária - CONFAZ).",
"act": "Convênio ICMS 142/18",
"extension": "html",
"fallback_bootstrap": None,
},
"nfe_domains": {
"kind": "NFE_MOC_TABLES",
"provider": "Receita Federal / ENCAT",
"url": "http://www.nfe.fazenda.gov.br/portal/principal.aspx",
"licence": "Documento técnico público oficial (ENCAT / Portal Nacional NF-e).",
"act": "MOC 7.0 Anexo I (Leiaute e RV da NF-e)",
"extension": "pdf",
"fallback_bootstrap": "moc7-anexo-i-leiaute-e-rv.pdf",
},
"cfop": {
"kind": "CFOP",
"provider": "Portal Nacional NF-e",
"url": "https://www.nfe.fazenda.gov.br/portal/listaConteudo.aspx?tipoConteudo=Iy/5QolvUsQ=",
"licence": "Dado público oficial (Portal Nacional da NF-e).",
"act": "Informe Técnico 2023.002 v2.10",
"extension": "xlsx",
"fallback_bootstrap": "IT_2023_002_v2_10_Tabela_CFOP_Descrição.xlsx",
},
}
def fetch_or_fallback(name: str, *, file_path: Path | None = None, force: bool = False) -> tuple[Path, str, dict]:
src = SOURCES[name]
RAW_DIR.mkdir(parents=True, exist_ok=True)
out = RAW_DIR / f"{name}_{date.today().isoformat()}.{src['extension']}"
if file_path and file_path.exists():
data = file_path.read_bytes()
out.write_bytes(data)
return out, f"FILE_SUPPLIED: {file_path}", {}
if out.exists() and not force:
return out, f"CACHE_HIT: {out}", {}
# Tentativa de download oficial
try:
req = urllib.request.Request(src["url"], headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(req, timeout=30) as resp:
data = resp.read()
out.write_bytes(data)
return out, f"DOWNLOADED: {out} ({len(data)} bytes)", {}
except Exception as err:
# Se download direto falhar e houver fallback em bootstrap_v1
fb = src.get("fallback_bootstrap")
if fb:
fb_path = BOOTSTRAP_DIR / fb
if not fb_path.exists() and name == "ncm":
fb_path = RAW_DIR / fb
if fb_path.exists():
data = fb_path.read_bytes()
out.write_bytes(data)
return out, f"BOOTSTRAP_FALLBACK: {fb_path} (download oficial retornou: {err})", {}
# Sem fallback determinístico: marcar IMPORT_MANUAL
manual_error = {
"IMPORT_MANUAL": f"Download automático de '{src['url']}' indisponível ({err}). Baixar manualmente pelo navegador e fornecer via --file <caminho>."
}
return out, "IMPORT_MANUAL_REQUIRED", manual_error
def validate_and_parse(name: str, path: Path, raw_bytes: bytes) -> tuple[any, dict]:
errors = {}
if name == "ncm":
from central.app.datasets.ncm import parse_ncm_payload
parsed = parse_ncm_payload(raw_bytes)
if len(parsed.entries) < 1000:
errors["MINIMUM_ENTRIES"] = f"Apenas {len(parsed.entries)} entradas (esperado >= 1000)"
if not any(e.code == "01" for e in parsed.entries):
errors["KNOWN_SAMPLE_MISSING"] = "Capítulo NCM '01' não encontrado"
return parsed, errors
elif name == "tipi":
from central.app.datasets.tipi import parse_tipi_xlsx
parsed = parse_tipi_xlsx(raw_bytes)
if len(parsed.entries) < 5000:
errors["MINIMUM_ENTRIES"] = f"Apenas {len(parsed.entries)} entradas TIPI (esperado >= 5000)"
if not any(e.ncm.startswith("2202") for e in parsed.entries):
errors["KNOWN_SAMPLE_MISSING"] = "NCM 2202 (bebidas) não encontrado na TIPI"
return parsed, errors
elif name == "cest":
from central.app.datasets.cest import parse_cest_html
parsed = parse_cest_html(raw_bytes)
if len(parsed.entries) < 500:
errors["MINIMUM_ENTRIES"] = f"Apenas {len(parsed.entries)} entradas CEST (esperado >= 500)"
if not any(e.code == "03.021.03" for e in parsed.entries):
errors["KNOWN_SAMPLE_MISSING"] = "CEST conhecido 03.021.03 (cervejas) não encontrado"
return parsed, errors
elif name == "nfe_domains":
from central.app.datasets.nfe_moc_tables import parse_moc_tables
parsed = parse_moc_tables(raw_bytes)
domains = {e.domain for e in parsed.entries}
expected = {"ORIGEM_MERCADORIA", "CST_ICMS", "CSOSN", "CST_PIS", "CST_COFINS", "CST_IPI", "UNIDADE_COMERCIAL"}
missing = expected - domains
if missing:
errors["DOMAINS_MISSING"] = f"Domínios ausentes no MOC: {sorted(missing)}"
if len(parsed.entries) < 80:
errors["MINIMUM_ENTRIES"] = f"Apenas {len(parsed.entries)} códigos de domínio (esperado >= 80)"
return parsed, errors
elif name == "cfop":
from central.app.datasets.bootstrap_v1_extra import parse_cfop
parsed = parse_cfop(raw_bytes)
if len(parsed.entries) < 200:
errors["MINIMUM_ENTRIES"] = f"Apenas {len(parsed.entries)} CFOPs (esperado >= 200)"
return parsed, errors
raise ValueError(f"Fonte desconhecida: {name}")
def main():
ap = argparse.ArgumentParser(description="Ingestão de datasets oficiais no Reference Data Registry")
ap.add_argument("source", choices=sorted(SOURCES), help="Nome do dataset oficial a ingerir")
ap.add_argument("--file", type=Path, default=None, help="Caminho local para o arquivo oficial")
ap.add_argument("--dry-run", action="store_true", help="Apenas valida e simula sem persistir no Postgres")
ap.add_argument("--force", action="store_true", help="Ignora cache local e força novo download")
a = ap.parse_args()
src = SOURCES[a.source]
path, origin_msg, fetch_errors = fetch_or_fallback(a.source, file_path=a.file, force=a.force)
print(f"STATUS_ORIGEM: {origin_msg}")
if fetch_errors:
report = {
"source": a.source,
"kind": src["kind"],
"provider": src["provider"],
"url": src["url"],
"status": "IMPORT_MANUAL",
"parse_errors": fetch_errors,
}
print(json.dumps(report, ensure_ascii=False, indent=2))
return
raw_bytes = path.read_bytes()
sha256 = hashlib.sha256(raw_bytes).hexdigest()
size = len(raw_bytes)
try:
parsed, parse_errors = validate_and_parse(a.source, path, raw_bytes)
record_count = len(parsed.entries)
version_label = getattr(parsed, "version", None) or getattr(parsed, "parser_version", "v1")
except Exception as exc:
parsed = None
record_count = 0
version_label = "parse-failed"
parse_errors = {"PARSER_EXCEPTION": str(exc)}
rel_path = str(path.relative_to(ROOT)) if path.is_relative_to(ROOT) else str(path)
report = {
"source": a.source,
"kind": src["kind"],
"provider": src["provider"],
"url": src["url"],
"sha256": sha256,
"file_size_bytes": size,
"record_count": record_count,
"version_label": version_label,
"act": src["act"],
"licence_terms": src["licence"],
"raw_file_path": rel_path,
"parse_errors": parse_errors,
"status": "DRAFT",
}
print(json.dumps(report, ensure_ascii=False, indent=2))
if a.dry_run:
print("DRY_RUN: Validação concluída. Nenhum dado foi persistido no banco.")
return
# Persistência no Postgres como DRAFT
from central.app.reference_registry import register_draft
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
db_url = os.environ.get("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/central")
try:
engine = create_engine(db_url)
with Session(engine) as session:
dataset = register_draft(
session,
kind=src["kind"],
provider=src["provider"],
source_url=src["url"],
sha256=sha256,
version_label=version_label,
act=src["act"],
record_count=record_count,
parse_errors=parse_errors,
licence_terms=src["licence"],
raw_file_path=rel_path,
file_size_bytes=size,
submitted_by="cli_ingestion",
)
print(f"REGISTRADO_COM_SUCESSO: dataset_id={dataset.id} status={dataset.status}")
except Exception as db_err:
print(f"AVISO_POSTGRES: Não foi possível conectar ao banco ({db_err}). Validação CLI concluída com sucesso.")
if __name__ == "__main__":
main()