feat(bloco-b): ingestao P2-P4 e checklist tecnico de aprovacao no reference registry
This commit is contained in:
@@ -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()
|
||||
]
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 ≥ {{ 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 %}
|
||||
|
||||
@@ -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
|
||||
@@ -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).
|
||||
|
||||
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user