feat(bloco-b): ingestao P2-P4 e checklist tecnico de aprovacao no reference registry

This commit is contained in:
gti
2026-09-28 16:20:54 -03:00
parent cd44ae637b
commit cf5bb64d9b
7 changed files with 513 additions and 86 deletions
+218 -58
View File
@@ -1,98 +1,258 @@
"""Missão 5 / Fase 1.3 — CLI ingestion for the Reference Data Registry.
"""Missão 6 — CLI de Ingestão de Datasets de Referência (P1–P4).
Read-only against Firebird (never touches it); writes only to the Hub's own Postgres, and only a
DRAFT row (never auto-activates - an ADMIN approves the diff separately, via the admin screen or
scripts/approve_reference_dataset.py). Rate-limit friendly: one HTTP GET per invocation, identifiable
User-Agent, respects Cache-Control (--force to bypass the local file cache).
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
python scripts/ingest_reference_dataset.py ncm --dry-run
python scripts/ingest_reference_dataset.py ncm
Currently supports: ncm (Siscomex, no login/captcha, JSON, official).
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
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",
"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 para consulta e reuso de classificação fiscal.",
"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(name: str, *, force: bool) -> Path:
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()}.json"
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:
print(f"CACHE_HIT: {out} (use --force para baixar de novo)")
return out
req = urllib.request.Request(src["url"], headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(req, timeout=120) as resp:
data = resp.read()
out.write_bytes(data)
print(f"DOWNLOADED: {out} ({len(data)} bytes)")
return out
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):
from central.app.datasets.ncm import parse_ncm_payload
if name != "ncm":
raise SystemExit(f"fonte '{name}' ainda não tem parser registrado")
payload = payload_bytes = path.read_bytes()
parsed = parse_ncm_payload(payload_bytes)
def validate_and_parse(name: str, path: Path, raw_bytes: bytes) -> tuple[any, dict]:
errors = {}
if len(parsed.entries) < 1000:
errors["MINIMUM_ENTRIES"] = f"apenas {len(parsed.entries)} entradas (esperado >= 1000)"
# amostra conhecida: capítulo "01" (Animais vivos) deve sempre existir na tabela vigente
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
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()
ap.add_argument("source", choices=sorted(SOURCES))
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--force", action="store_true")
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 = fetch(a.source, force=a.force)
sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
size = path.stat().st_size
parsed, errors = validate_and_parse(a.source, path)
report = {"source": a.source, "kind": src["kind"], "provider": src["provider"], "url": src["url"], "sha256": sha256,
"file_size_bytes": size, "record_count": len(parsed.entries), "version_label": parsed.version, "act": parsed.act,
"parse_errors": errors, "raw_file_path": str(path.relative_to(ROOT))}
print(json.dumps(report, ensure_ascii=False, indent=1))
if a.dry_run:
print("DRY_RUN: nada foi gravado no Postgres.")
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
if errors:
print("PARSE_ERRORS presentes: registrando como DRAFT com parse_errors preenchido (não pode ser aprovado até corrigir).")
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 central.app.main import create_app # noqa: F401 (ensures models are imported/registered)
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
import os
engine = create_engine(os.environ["DATABASE_URL"])
with Session(engine) as session:
dataset = register_draft(session, kind=src["kind"], provider=src["provider"], source_url=src["url"], sha256=sha256,
version_label=parsed.version, act=parsed.act, record_count=len(parsed.entries), parse_errors=errors,
licence_terms=src["licence"], raw_file_path=report["raw_file_path"], file_size_bytes=size, submitted_by="cli")
print(f"REGISTERED: dataset_id={dataset.id} status={dataset.status}")
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__":