"""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 ." } 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()