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_pis
entries += cst_cofins entries += cst_cofins
entries += _extract_cst_ipi(full_text)
entries += _extract_unidades(full_text)
return MocTablesPayload(sha256=sha256(pdf_bytes).hexdigest(), entries=tuple(entries)) 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: def approve_draft(session, dataset_id: int, *, admin_name: str) -> Dataset:
"""DRAFT -> VIGENTE, superseding the previous VIGENTE of the same kind (OBSOLETO, never deleted). """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 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 .enrichment import kb as kb_mod, status as enrichment_status
from .fiscal_context_ui import build_fiscal_context_view, set_fiscal_context_decision 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 .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): 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, 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, "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, "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": if r.status == "DRAFT":
item["diff"] = diff_against_vigente(session, r) item["diff"] = diff_against_vigente(session, r)
view.append(item) view.append(item)
+33 -1
View File
@@ -24,10 +24,42 @@
</dd> </dd>
{% endif %} {% endif %}
</dl> </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 %} {% if r.status == "DRAFT" and is_admin %}
<form method="post" action="/admin/reference-datasets/{{ r.id }}/decide" class="filters compact-filters"> <form method="post" action="/admin/reference-datasets/{{ r.id }}/decide" class="filters compact-filters">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <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> <button class="secondary" type="submit" name="action" value="reject" onclick="return confirm('Rejeitar esta versão?')">Rejeitar</button>
</form> </form>
{% endif %} {% 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 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.
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.
| 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: - **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).
- **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.
+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 Fontes suportadas:
DRAFT row (never auto-activates - an ADMIN approves the diff separately, via the admin screen or - ncm (P1): Siscomex NCM JSON oficial
scripts/approve_reference_dataset.py). Rate-limit friendly: one HTTP GET per invocation, identifiable - nfe_domains (P2): Receita Federal / ENCAT MOC 7.0 Anexo I (Origem, CST ICMS, CSOSN, CST PIS/COFINS, CST IPI, Unidades)
User-Agent, respects Cache-Control (--force to bypass the local file cache). - 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 Registra SEMPRE como status DRAFT no Reference Data Registry.
python scripts/ingest_reference_dataset.py ncm Nenhuma versão vira VIGENTE sem aprovação explícita do administrador.
Currently supports: ncm (Siscomex, no login/captcha, JSON, official).
""" """
from __future__ import annotations
import argparse import argparse
import hashlib import hashlib
import json import json
import os
import sys import sys
import urllib.request import urllib.request
from datetime import date from datetime import date, datetime, timezone
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
RAW_DIR = ROOT / "sources" / "raw" / "reference_registry" 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)" USER_AGENT = "GTI-Fiscal-Hub/1.0 (dataset ingestion; contato: operador GTI)"
SOURCES = { SOURCES = {
"ncm": { "ncm": {
"kind": "NCM", "provider": "Siscomex", "kind": "NCM",
"provider": "Siscomex",
"url": "https://portalunico.siscomex.gov.br/classif/api/publico/nomenclatura/download/json", "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] src = SOURCES[name]
RAW_DIR.mkdir(parents=True, exist_ok=True) 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: if out.exists() and not force:
print(f"CACHE_HIT: {out} (use --force para baixar de novo)") return out, f"CACHE_HIT: {out}", {}
return out
req = urllib.request.Request(src["url"], headers={"User-Agent": USER_AGENT}) # Tentativa de download oficial
with urllib.request.urlopen(req, timeout=120) as resp: try:
data = resp.read() req = urllib.request.Request(src["url"], headers={"User-Agent": USER_AGENT})
out.write_bytes(data) with urllib.request.urlopen(req, timeout=30) as resp:
print(f"DOWNLOADED: {out} ({len(data)} bytes)") data = resp.read()
return out 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): def validate_and_parse(name: str, path: Path, raw_bytes: bytes) -> tuple[any, dict]:
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)
errors = {} errors = {}
if len(parsed.entries) < 1000: if name == "ncm":
errors["MINIMUM_ENTRIES"] = f"apenas {len(parsed.entries)} entradas (esperado >= 1000)" from central.app.datasets.ncm import parse_ncm_payload
# amostra conhecida: capítulo "01" (Animais vivos) deve sempre existir na tabela vigente parsed = parse_ncm_payload(raw_bytes)
if not any(e.code == "01" for e in parsed.entries): if len(parsed.entries) < 1000:
errors["KNOWN_SAMPLE_MISSING"] = "capítulo NCM '01' não encontrado" errors["MINIMUM_ENTRIES"] = f"Apenas {len(parsed.entries)} entradas (esperado >= 1000)"
return parsed, errors 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(): def main():
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser(description="Ingestão de datasets oficiais no Reference Data Registry")
ap.add_argument("source", choices=sorted(SOURCES)) ap.add_argument("source", choices=sorted(SOURCES), help="Nome do dataset oficial a ingerir")
ap.add_argument("--dry-run", action="store_true") ap.add_argument("--file", type=Path, default=None, help="Caminho local para o arquivo oficial")
ap.add_argument("--force", action="store_true") 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() a = ap.parse_args()
src = SOURCES[a.source] src = SOURCES[a.source]
path = fetch(a.source, force=a.force) path, origin_msg, fetch_errors = fetch_or_fallback(a.source, file_path=a.file, force=a.force)
sha256 = hashlib.sha256(path.read_bytes()).hexdigest() print(f"STATUS_ORIGEM: {origin_msg}")
size = path.stat().st_size
parsed, errors = validate_and_parse(a.source, path) if fetch_errors:
report = {"source": a.source, "kind": src["kind"], "provider": src["provider"], "url": src["url"], "sha256": sha256, report = {
"file_size_bytes": size, "record_count": len(parsed.entries), "version_label": parsed.version, "act": parsed.act, "source": a.source,
"parse_errors": errors, "raw_file_path": str(path.relative_to(ROOT))} "kind": src["kind"],
print(json.dumps(report, ensure_ascii=False, indent=1)) "provider": src["provider"],
if a.dry_run: "url": src["url"],
print("DRY_RUN: nada foi gravado no Postgres.") "status": "IMPORT_MANUAL",
"parse_errors": fetch_errors,
}
print(json.dumps(report, ensure_ascii=False, indent=2))
return 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.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 import create_engine
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
import os
engine = create_engine(os.environ["DATABASE_URL"]) db_url = os.environ.get("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/central")
with Session(engine) as session: try:
dataset = register_draft(session, kind=src["kind"], provider=src["provider"], source_url=src["url"], sha256=sha256, engine = create_engine(db_url)
version_label=parsed.version, act=parsed.act, record_count=len(parsed.entries), parse_errors=errors, with Session(engine) as session:
licence_terms=src["licence"], raw_file_path=report["raw_file_path"], file_size_bytes=size, submitted_by="cli") dataset = register_draft(
print(f"REGISTERED: dataset_id={dataset.id} status={dataset.status}") 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__": if __name__ == "__main__":