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
+62
View File
@@ -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()
]
+81
View File
@@ -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
+2 -2
View File
@@ -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)
+33 -1
View File
@@ -24,10 +24,42 @@
</dd>
{% endif %}
</dl>
{% if r.checklist %}
<div class="technical-checklist" style="background:#f8f9fa; border:1px solid #e2e8f0; border-radius:6px; padding:12px; margin:12px 0;">
<h4 style="margin-top:0; margin-bottom:8px; font-size:14px; text-transform:uppercase; color:#475569;">Checklist Técnico de Aprovação (Sem Julgamento Fiscal Exigido)</h4>
<ul style="list-style:none; padding-left:0; margin-bottom:10px; font-size:13px; line-height:1.6;">
<li>
<span style="font-weight:bold; color:{{ 'green' if r.checklist.official_source.passed else 'red' }}">[{{ '✓' if r.checklist.official_source.passed else '✗' }}]</span>
<strong>1. Fonte Oficial:</strong> {{ r.checklist.official_source.provider }} · <a href="{{ r.checklist.official_source.url }}" target="_blank" rel="noopener">{{ r.checklist.official_source.url }}</a> · <em>{{ r.checklist.official_source.act }}</em>
</li>
<li>
<span style="font-weight:bold; color:{{ 'green' if r.checklist.integrity.passed else 'red' }}">[{{ '✓' if r.checklist.integrity.passed else '✗' }}]</span>
<strong>2. Hash SHA-256 e Tamanho:</strong> <code style="font-size:11px;">{{ r.checklist.integrity.sha256 }}</code> {% if r.checklist.integrity.file_size_bytes %}({{ r.checklist.integrity.file_size_bytes }} bytes){% endif %}
</li>
<li>
<span style="font-weight:bold; color:{{ 'green' if r.checklist.volume.passed else 'red' }}">[{{ '✓' if r.checklist.volume.passed else '✗' }}]</span>
<strong>3. Contagem de Linhas:</strong> {{ r.checklist.volume.record_count }} linhas registradas (esperado &ge; {{ r.checklist.volume.expected_minimum }})
</li>
<li>
<span style="font-weight:bold; color:{{ 'green' if r.checklist.no_parse_errors else 'red' }}">[{{ '✓' if r.checklist.no_parse_errors else '✗' }}]</span>
<strong>4. Erros de Parse:</strong> {{ 'Nenhum erro de parse (deterministico)' if r.checklist.no_parse_errors else 'Existem erros de parse no processamento!' }}
</li>
<li>
<span style="font-weight:bold; color:green">[✓]</span>
<strong>5. Amostra de 5 Consultas com Resultado Conhecido:</strong>
<ol style="margin-top:4px; margin-bottom:4px; padding-left:20px;">
{% for code, label in r.checklist.samples %}
<li><code>{{ code }}</code>: {{ label }}</li>
{% endfor %}
</ol>
</li>
</ul>
</div>
{% endif %}
{% if r.status == "DRAFT" and is_admin %}
<form method="post" action="/admin/reference-datasets/{{ r.id }}/decide" class="filters compact-filters">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="secondary" type="submit" name="action" value="approve" {% if r.parse_errors %}disabled title="corrija os erros de parse antes de aprovar"{% endif %}>Aprovar (vira vigente)</button>
<button class="secondary" type="submit" name="action" value="approve" {% if not r.checklist.ready_to_approve %}disabled title="Checklist técnico não satisfeito ou erros presentes"{% endif %}>Aprovar (vira vigente)</button>
<button class="secondary" type="submit" name="action" value="reject" onclick="return confirm('Rejeitar esta versão?')">Rejeitar</button>
</form>
{% endif %}
+106
View File
@@ -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
+11 -25
View File
@@ -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).
+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__":