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)
+
+ -
+ [{{ '✓' if r.checklist.official_source.passed else '✗' }}]
+ 1. Fonte Oficial: {{ r.checklist.official_source.provider }} · {{ r.checklist.official_source.url }} · {{ r.checklist.official_source.act }}
+
+ -
+ [{{ '✓' if r.checklist.integrity.passed else '✗' }}]
+ 2. Hash SHA-256 e Tamanho:
{{ r.checklist.integrity.sha256 }} {% if r.checklist.integrity.file_size_bytes %}({{ r.checklist.integrity.file_size_bytes }} bytes){% endif %}
+
+ -
+ [{{ '✓' if r.checklist.volume.passed else '✗' }}]
+ 3. Contagem de Linhas: {{ r.checklist.volume.record_count }} linhas registradas (esperado ≥ {{ r.checklist.volume.expected_minimum }})
+
+ -
+ [{{ '✓' if r.checklist.no_parse_errors else '✗' }}]
+ 4. Erros de Parse: {{ 'Nenhum erro de parse (deterministico)' if r.checklist.no_parse_errors else 'Existem erros de parse no processamento!' }}
+
+ -
+ [✓]
+ 5. Amostra de 5 Consultas com Resultado Conhecido:
+
+ {% for code, label in r.checklist.samples %}
+ {{ code }}: {{ label }}
+ {% endfor %}
+
+
+
+
+{% 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__":