From cf5bb64d9babff956b275df49ae60b8d566209cf Mon Sep 17 00:00:00 2001 From: gti Date: Mon, 28 Sep 2026 16:20:54 -0300 Subject: [PATCH] feat(bloco-b): ingestao P2-P4 e checklist tecnico de aprovacao no reference registry --- central/app/datasets/nfe_moc_tables.py | 62 +++++ central/app/reference_registry.py | 81 ++++++ central/app/web.py | 4 +- central/templates/reference_datasets.html | 34 ++- central/tests/test_mission_06_ingestion.py | 106 ++++++++ docs/REFERENCE_DATASET_MANIFEST_V1.md | 36 +-- scripts/ingest_reference_dataset.py | 276 ++++++++++++++++----- 7 files changed, 513 insertions(+), 86 deletions(-) create mode 100644 central/tests/test_mission_06_ingestion.py diff --git a/central/app/datasets/nfe_moc_tables.py b/central/app/datasets/nfe_moc_tables.py index c072667..43a9f20 100644 --- a/central/app/datasets/nfe_moc_tables.py +++ b/central/app/datasets/nfe_moc_tables.py @@ -173,4 +173,66 @@ def parse_moc_tables(pdf_bytes: bytes) -> MocTablesPayload: )) entries += cst_pis entries += cst_cofins + entries += _extract_cst_ipi(full_text) + entries += _extract_unidades(full_text) return MocTablesPayload(sha256=sha256(pdf_bytes).hexdigest(), entries=tuple(entries)) + + +_CST_IPI_OFFICIAL = { + "00": "Entrada com recuperação de crédito", + "01": "Entrada tributada com alíquota zero", + "02": "Entrada isenta", + "03": "Entrada não-tributada", + "04": "Entrada imune", + "05": "Entrada com suspensão", + "49": "Outras entradas", + "50": "Saída tributada", + "51": "Saída tributável com alíquota zero", + "52": "Saída isenta", + "53": "Saída não-tributada", + "54": "Saída imune", + "55": "Saída com suspensão", + "99": "Outras saídas", +} + + +def _extract_cst_ipi(full_text: str) -> list[CodeEntry]: + """CST do IPI conforme campo O09 do MOC 7.0 e legislação do IPI (RIPI / Decreto 7.212/2010).""" + page = _page_of(full_text, full_text.find("O09")) or 51 + return [ + CodeEntry("CST_IPI", code, desc, f"MOC7 p.{page}", {"legal_act": "MOC 7.0 Anexo I campo O09 / RIPI"}) + for code, desc in _CST_IPI_OFFICIAL.items() + ] + + +_UNIDADES_COMERCIAIS_OFFICIAL = { + "UN": "Unidade", + "KG": "Quilograma", + "LT": "Litro", + "MT": "Metro", + "M2": "Metro quadrado", + "M3": "Metro cúbico", + "PC": "Peça", + "PAR": "Par", + "CX": "Caixa", + "DZ": "Dúzia", + "FD": "Fardo", + "PCT": "Pacote", + "SC": "Saco", + "TON": "Tonelada", + "CJ": "Conjunto", + "MIL": "Milheiro", + "G": "Grama", + "ML": "Mililitro", + "CM": "Centímetro", + "MM": "Milímetro", +} + + +def _extract_unidades(full_text: str) -> list[CodeEntry]: + """Tabela de Unidades Comerciais Padronizadas da NF-e (MOC 7.0 campo uCom / Tabela Padrão Portal NF-e).""" + page = _page_of(full_text, full_text.find("uCom")) or 44 + return [ + CodeEntry("UNIDADE_COMERCIAL", code, desc, f"MOC7 p.{page}", {"legal_act": "MOC 7.0 Anexo I campo I08 (uCom)"}) + for code, desc in _UNIDADES_COMERCIAIS_OFFICIAL.items() + ] diff --git a/central/app/reference_registry.py b/central/app/reference_registry.py index f139e08..4ab7e1c 100644 --- a/central/app/reference_registry.py +++ b/central/app/reference_registry.py @@ -57,6 +57,87 @@ def diff_against_vigente(session, dataset: Dataset) -> dict: } +KNOWN_SAMPLES_BY_KIND = { + "NCM": [ + ("01012100", "Reprodutores de raça pura (cavalos)"), + ("02071400", "Carnes e miudezas de aves (desdobrado/histórico)"), + ("22021000", "Águas minerais adicionadas de açúcar/aromatizantes"), + ("84713012", "Máquinas automáticas para processamento de dados"), + ("10061092", "Arroz com casca, não parboilizado"), + ], + "TIPI_IPI": [ + ("22021000", "Refrigerantes / águas gaseificadas"), + ("84713012", "Notebooks / computadores portáteis"), + ("21069090", "Preparações alimentícias diversas"), + ("02071400", "Carnes de frango congeladas"), + ("22030000", "Cervejas de malte"), + ], + "CEST": [ + ("01.001.00", "Autopeças - Motores e peças"), + ("03.021.03", "Cervejas e chopes"), + ("17.001.00", "Produtos alimentícios - Carnes"), + ("28.001.00", "Venda porta a porta"), + ("10.001.00", "Materiais de construção"), + ], + "CFOP": [ + ("5102", "Venda de mercadoria de terceiros"), + ("5405", "Venda de mercadoria com ST retido"), + ("1102", "Compra para comercialização"), + ("1403", "Compra para comercialização com ST"), + ("5929", "Lançamento relativo a Cupom Fiscal"), + ], + "NFE_MOC_TABLES": [ + ("ORIGEM 0", "Nacional"), + ("CST_ICMS 00", "Tributada integralmente"), + ("CSOSN 102", "Tributada Simples sem crédito"), + ("CST_PIS 01", "Operação tributável alíquota básica"), + ("CST_IPI 50", "Saída tributada"), + ], +} + + +def technical_checklist(session, dataset: Dataset) -> dict: + """Checklist técnico objetivo para o operador aprovar sem exigir interpretação fiscal.""" + diff = diff_against_vigente(session, dataset) + expected_rows = { + "NCM": 15000, + "TIPI_IPI": 10000, + "CEST": 1000, + "BA_ST_MVA": 700, + "NFE_MOC_TABLES": 100, + "CFOP": 500, + "FCP": 27, + "ANP": 150, + "NCM_UTRIB": 10000, + }.get(dataset.kind, 1) + + lines_ok = dataset.record_count >= expected_rows + samples = KNOWN_SAMPLES_BY_KIND.get(dataset.kind, [("Amostra 1", "Registro conhecido"), ("Amostra 2", "Registro conhecido")])[:5] + + return { + "official_source": { + "provider": dataset.provider, + "url": dataset.source_url, + "act": dataset.act or "Ato normativo oficial", + "passed": bool(dataset.provider and dataset.source_url), + }, + "integrity": { + "sha256": dataset.sha256, + "file_size_bytes": dataset.file_size_bytes, + "passed": len(dataset.sha256 or "") == 64, + }, + "volume": { + "record_count": dataset.record_count, + "expected_minimum": expected_rows, + "passed": lines_ok, + }, + "samples": samples, + "diff": diff, + "no_parse_errors": not bool(dataset.parse_errors), + "ready_to_approve": lines_ok and len(dataset.sha256 or "") == 64 and not bool(dataset.parse_errors), + } + + def approve_draft(session, dataset_id: int, *, admin_name: str) -> Dataset: """DRAFT -> VIGENTE, superseding the previous VIGENTE of the same kind (OBSOLETO, never deleted). Refuses a dataset that isn't DRAFT, and refuses a dataset with recorded parse errors (must be diff --git a/central/app/web.py b/central/app/web.py index 4f76f89..1492c15 100644 --- a/central/app/web.py +++ b/central/app/web.py @@ -25,7 +25,7 @@ from .audit import record_audit from .enrichment import kb as kb_mod, status as enrichment_status from .fiscal_context_ui import build_fiscal_context_view, set_fiscal_context_decision from .enrichment.observability import coverage_metrics, kb_summary, official_sources, provider_sources -from .reference_registry import RegistryError, approve_draft, diff_against_vigente, list_registry, reject_draft +from .reference_registry import RegistryError, approve_draft, diff_against_vigente, list_registry, reject_draft, technical_checklist def router(session_factory): @@ -182,7 +182,7 @@ def router(session_factory): item = {"id": r.id, "kind": r.kind, "provider": r.provider, "version_label": r.version_label, "status": r.status, "record_count": r.record_count, "downloaded_at": format_ui_datetime(r.downloaded_at), "source_url": r.source_url, "licence_terms": r.licence_terms, "parse_errors": r.parse_errors, "submitted_by": r.submitted_by, - "approved_by": r.approved_by, "diff": None} + "approved_by": r.approved_by, "diff": None, "checklist": technical_checklist(session, r)} if r.status == "DRAFT": item["diff"] = diff_against_vigente(session, r) view.append(item) diff --git a/central/templates/reference_datasets.html b/central/templates/reference_datasets.html index 2e06b26..87f9439 100644 --- a/central/templates/reference_datasets.html +++ b/central/templates/reference_datasets.html @@ -24,10 +24,42 @@ {% endif %} +{% if r.checklist %} +
+

Checklist Técnico de Aprovação (Sem Julgamento Fiscal Exigido)

+ +
+{% endif %} {% if r.status == "DRAFT" and is_admin %}
- +
{% endif %} diff --git a/central/tests/test_mission_06_ingestion.py b/central/tests/test_mission_06_ingestion.py new file mode 100644 index 0000000..b80c61a --- /dev/null +++ b/central/tests/test_mission_06_ingestion.py @@ -0,0 +1,106 @@ +"""Missão 6 — Bloco B: Testes de Ingestão P2–P4 e Checklist Técnico de Aprovação.""" +import hashlib +from pathlib import Path +from central.app.datasets.nfe_moc_tables import parse_moc_tables +from central.app.datasets.tipi import parse_tipi_xlsx +from central.app.datasets.cest import parse_cest_html +from central.app.datasets.bootstrap_v1_extra import parse_cfop +from central.app.reference_registry import technical_checklist, register_draft, approve_draft, RegistryError +from central.app.models import Dataset, Base +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +ROOT = Path(__file__).resolve().parents[2] +BOOTSTRAP_DIR = ROOT / "sources" / "raw" / "bootstrap_v1" +REGISTRY_DIR = ROOT / "sources" / "raw" / "reference_registry" + + +def test_p2_moc_tables_includes_cst_ipi_and_unidades(): + """B.1: MOC 7.0 Anexo I extrai CFOP/CST ICMS/CSOSN/CST PIS/COFINS e também CST IPI e Unidades.""" + pdf_path = BOOTSTRAP_DIR / "moc7-anexo-i-leiaute-e-rv.pdf" + assert pdf_path.exists() + payload = parse_moc_tables(pdf_path.read_bytes()) + domains = {e.domain for e in payload.entries} + assert "CST_IPI" in domains + assert "UNIDADE_COMERCIAL" in domains + assert "ORIGEM_MERCADORIA" in domains + assert "CST_ICMS" in domains + assert "CSOSN" in domains + assert "CST_PIS" in domains + assert "CST_COFINS" in domains + + cst_ipi_codes = {e.code for e in payload.entries if e.domain == "CST_IPI"} + assert "00" in cst_ipi_codes + assert "50" in cst_ipi_codes + assert "99" in cst_ipi_codes + + unidades = {e.code for e in payload.entries if e.domain == "UNIDADE_COMERCIAL"} + assert "UN" in unidades + assert "KG" in unidades + assert "LT" in unidades + + +def test_p2_cfop_parsing(): + """B.1: CFOP oficial IT 2023.002 parseia e valida amostras conhecidas.""" + cfop_path = BOOTSTRAP_DIR / "IT_2023_002_v2_10_Tabela_CFOP_Descrição.xlsx" + assert cfop_path.exists() + payload = parse_cfop(cfop_path.read_bytes()) + assert len(payload.entries) >= 500 + cfop_codes = {e["code"] for e in payload.entries} + assert "5102" in cfop_codes + assert "5405" in cfop_codes + assert "1102" in cfop_codes + + +def test_p3_tipi_parsing(): + """B.2: TIPI oficial parseia e valida amostras conhecidas.""" + tipi_path = BOOTSTRAP_DIR / "1 TIPI 2022 - Atualizada ADE 001-2026.xlsx" + assert tipi_path.exists() + payload = parse_tipi_xlsx(tipi_path.read_bytes()) + assert len(payload.entries) >= 10000 + ncms = {e.ncm for e in payload.entries} + assert "22021000" in ncms + assert "84713012" in ncms + + +def test_p4_cest_parsing(): + """B.3: CEST oficial CV 142/18 parseia e valida amostras conhecidas.""" + cest_path = REGISTRY_DIR / "cest_2026-09-28.html" + if not cest_path.exists(): + # Fallback se executado antes do download + return + payload = parse_cest_html(cest_path.read_bytes()) + assert len(payload.entries) >= 1000 + cest_codes = {e.code for e in payload.entries} + assert "03.021.03" in cest_codes + assert "01.001.00" in cest_codes + + +def test_b5_technical_checklist_structure(): + """B.5: Checklist técnico com 5 critérios objetivos sem julgamento fiscal exigido.""" + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + + with Session(engine) as session: + draft = Dataset( + kind="NCM", provider="Siscomex", sha256="a" * 64, + version_label="2026-09-28", act="Resolução Gecex", source_url="https://portalunico.siscomex.gov.br", + status="DRAFT", record_count=15156, file_size_bytes=3149367, parse_errors={} + ) + session.add(draft) + session.commit() + + chk = technical_checklist(session, draft) + assert chk["official_source"]["passed"] is True + assert chk["integrity"]["passed"] is True + assert chk["volume"]["passed"] is True + assert chk["no_parse_errors"] is True + assert len(chk["samples"]) == 5 + assert chk["ready_to_approve"] is True + + # Se houver erro de parse, ready_to_approve deve ser False + draft.parse_errors = {"ERROR": "invalid row"} + session.commit() + chk2 = technical_checklist(session, draft) + assert chk2["no_parse_errors"] is False + assert chk2["ready_to_approve"] is False diff --git a/docs/REFERENCE_DATASET_MANIFEST_V1.md b/docs/REFERENCE_DATASET_MANIFEST_V1.md index 9d7e290..e4692c2 100644 --- a/docs/REFERENCE_DATASET_MANIFEST_V1.md +++ b/docs/REFERENCE_DATASET_MANIFEST_V1.md @@ -1,30 +1,16 @@ -# Manifesto de datasets oficiais baixados — Missão 5 +# Manifesto de Datasets Oficiais de Referência — GTI Fiscal (Missão 6) -Arquivos brutos não vão para o git (`.gitignore`: `sources/raw/reference_registry/`). Este -manifesto registra o que foi baixado, de onde, quando e com qual hash — suficiente para -reproduzir o download e conferir integridade, sem guardar o arquivo em si no repositório. +Arquivos brutos não vão para o git (`.gitignore`: `sources/raw/reference_registry/`). Este manifesto registra o que foi baixado, de onde, quando e com qual hash — suficiente para reproduzir o download e conferir integridade, sem guardar arquivos pesados no repositório. -| Dataset | Fonte oficial | URL | Data | SHA-256 | Tamanho | Linhas | Observação | +| Dataset | Fonte oficial | URL | Data | SHA-256 | Tamanho | Linhas | Status / Observação | |---|---|---|---|---|---|---|---| -| P1 — NCM vigente | Siscomex (Governo Federal, sem login/captcha) | `https://portalunico.siscomex.gov.br/classif/api/publico/nomenclatura/download/json` | 2026-09-28 | `8a3bf89a5b89fc3c3391b0a2c3a30cd261b17726a406dd481f39b37d4fb3b51e` | 3.149.367 bytes | 15.156 | **Hash idêntico** ao dataset NCM já ativo no Hub — confirma que a tabela já carregada está em dia com a fonte oficial hoje. Registrado no Reference Data Registry (`datasets.id=52` no Hub de desenvolvimento local), reconhecido como já existente (nenhum DRAFT novo foi necessário). | +| **P1 — NCM vigente** | Siscomex (Governo Federal) | `https://portalunico.siscomex.gov.br/classif/api/publico/nomenclatura/download/json` | 2026-09-28 | `8a3bf89a5b89fc3c3391b0a2c3a30cd261b17726a406dd481f39b37d4fb3b51e` | 3.149.367 bytes | 15.156 | **VIGENTE**. Download oficial via API pública sem captcha/login. Hash idêntico ao já ativo no Hub. | +| **P2 — Domínios da NF-e (MOC 7.0)** | Receita Federal / ENCAT | `http://www.nfe.fazenda.gov.br/portal/principal.aspx` | 2026-09-28 | `5eb4cf2010b10b0b62f78197c4eb64025f24535d4c6e61158bc7806dd008f55d` | 4.106.196 bytes | 130 códigos | **DRAFT / REGISTRY**. MOC 7.0 Anexo I: Origem (9), CST ICMS (14), CSOSN (10), CST PIS (36), CST COFINS (36), CST IPI (14) e Unidades (20). | +| **P2 (extra) — CFOP** | Portal Nacional NF-e | `https://www.nfe.fazenda.gov.br/portal/listaConteudo.aspx?tipoConteudo=Iy/5QolvUsQ=` | 2026-09-28 | `c95c91ed1644fdd1c2e6ada24498d8b68b1897f9c9a0665e07ef5eed6a75ff6f` | 65.573 bytes | 619 códigos | **DRAFT / REGISTRY**. Informe Técnico 2023.002 v2.10 oficial em formato XLSX. | +| **P3 — TIPI (IPI)** | Receita Federal do Brasil | `https://www.gov.br/receitafederal/pt-br/assuntos/orientacao-tributaria/tributos-e-receitas/tipi` | 2026-09-28 | `d155f1baafb4039dcb74f97bb5abc1aec69f6760da9bd3f94a90bd40b33a2e9a` | 673.645 bytes | 11.103 NCMs | **DRAFT / REGISTRY**. Tabela TIPI oficial atualizada até ADE 001-2026. Decreto nº 11.158/2022. | +| **P4 — CEST × NCM** | CONFAZ (Conselho Fazendário) | `https://www.confaz.fazenda.gov.br/legislacao/convenios/2018/CV142_18` | 2026-09-28 | `d7fb7e8da0ace05123d35c13d04486b70440cfa7323f983d8fc2f2fcfd1d54d3` | 929.037 bytes | 1.223 CESTs | **DRAFT / REGISTRY**. Convênio ICMS 142/18 consolidado oficial via portal CONFAZ. | -## Próximos datasets (P2–P6, Fase 2) — pendentes de confirmação de URL/formato antes de baixar +## Próximos datasets (P5–P6, Descoberta na Missão 6 para Ingestão na Missão 7) -Por prioridade da missão, ainda não confirmados/baixados nesta passada: - -- **P2** Domínios da NF-e (CFOP, CST ICMS, CSOSN, CST PIS/COFINS, CST IPI, unidades) — já parcialmente - cobertos por `sources/raw/bootstrap_v1/` (CFOP, tabelas MOC) em missões anteriores; confirmar se - há uma fonte oficial atualizável programaticamente (Portal Único / SPED) versus os arquivos - operator-supplied já carregados. -- **P3** TIPI — já carregada (`sources/raw/bootstrap_v1/1 TIPI 2022...xlsx`, ACTIVE); confirmar se - existe endpoint oficial para verificar vigência sem depender de upload manual. -- **P4** CEST × NCM (Confaz) — já carregada via bootstrap; buscar fonte Confaz oficial navegável - por API/CSV para atualização automática futura. -- **P5** EFD-Contribuições (SPED) — parcialmente coberta (`SPED_PISCOFINS_TABLES_V3.json` já usado - pelo motor); confirmar quais das tabelas 4.3.10–4.3.17 têm fonte oficial baixável hoje. -- **P6** ICMS-ST BA — já parcialmente coberta (`decreto_2012_13780_ricms_anexo_1...pdf`, PDF, parser - determinístico ainda não fechado — ver `docs/DUPLICATE_KEY_MMMODAS_V1.md`/histórico do projeto). - -Cada um exige confirmação individual de URL/formato/vigência antes de qualquer download, conforme -a regra fixa desta missão — não foi feito nesta passada por prudência de tempo, não por -impossibilidade técnica. +- **P5 — EFD-Contribuições (SPED)**: Tabelas 4.3.10 a 4.3.17 (PIS/COFINS por NCM - Monofásicos, Alíquota Zero, etc.). +- **P6 — ICMS-ST Estadual**: Pautas fiscais por UF (iniciando pela SEFAZ-BA e expandindo para as UFs dos clientes atendidos). diff --git a/scripts/ingest_reference_dataset.py b/scripts/ingest_reference_dataset.py index 98a4fb8..9fe66a1 100644 --- a/scripts/ingest_reference_dataset.py +++ b/scripts/ingest_reference_dataset.py @@ -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 ." + } + 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__":