From cd44ae637b8a6f4d74e0b9795e88e3d1a28f2ed0 Mon Sep 17 00:00:00 2001 From: gti Date: Mon, 28 Sep 2026 16:12:21 -0300 Subject: [PATCH] feat(bloco-a): higiene alembic heads e inventario dos 9 datasets no registry --- central/app/datasets/inventory.py | 185 +++++++++++++++++++++++ central/app/reference_registry.py | 7 +- central/tests/test_mission_06_hygiene.py | 109 +++++++++++++ 3 files changed, 298 insertions(+), 3 deletions(-) create mode 100644 central/app/datasets/inventory.py create mode 100644 central/tests/test_mission_06_hygiene.py diff --git a/central/app/datasets/inventory.py b/central/app/datasets/inventory.py new file mode 100644 index 0000000..85ffe90 --- /dev/null +++ b/central/app/datasets/inventory.py @@ -0,0 +1,185 @@ +"""Missão 6 / Bloco A.2: Inventário dos 9 datasets existentes no Hub e migração para o Reference Data Registry. + +Datasets catalogados: +1. NCM (Siscomex) +2. TIPI_IPI (Receita Federal) +3. CEST (CONFAZ) +4. BA_ST_MVA (SEFAZ-BA) +5. NFE_MOC_TABLES (Receita Federal / ENCAT) +6. CFOP (Portal Nacional NF-e) +7. FCP (Portal Nacional NF-e) +8. ANP (Portal Nacional NF-e) +9. NCM_UTRIB (Portal Nacional NF-e) +""" +from __future__ import annotations +from dataclasses import dataclass +from typing import Optional +from sqlalchemy import select, update +from sqlalchemy.orm import Session +from ..models import Dataset + +@dataclass(frozen=True) +class DatasetInventoryItem: + kind: str + name: str + provider: str + source_url: str + version_label: str + expected_rows_approx: int + act: str + validity_info: str + coverage: str + licence_terms: str + raw_file_name: Optional[str] = None + + +DATASET_INVENTORY_9: tuple[DatasetInventoryItem, ...] = ( + DatasetInventoryItem( + kind="NCM", + name="Nomenclatura Comum do Mercosul (NCM)", + provider="Siscomex", + source_url="https://portalunico.siscomex.gov.br/classif/api/publico/nomenclatura/download/json", + version_label="2026-09-28", + expected_rows_approx=15156, + act="Resolução Gecex / MDIC", + validity_info="Vigente em 2026-09-28", + coverage="Códigos NCM de 8 dígitos, descrições oficiais, datas de início e fim de vigência nacional.", + licence_terms="Dado público oficial (Governo Federal / Siscomex); sem termos de uso restritivos conhecidos.", + raw_file_name="ncm_vigente_20260928.json", + ), + DatasetInventoryItem( + kind="TIPI_IPI", + name="Tabela de Incidência do IPI (TIPI)", + provider="Receita Federal", + source_url="https://www.gov.br/receitafederal/pt-br/assuntos/orientacao-tributaria/tributos-e-receitas/tipi", + version_label="tipi-xlsx-v1", + expected_rows_approx=11028, + act="Decreto nº 11.158/2022 e alterações até ADE 001-2026", + validity_info="Vigente desde 2022 com atualizações até 2026", + coverage="Alíquotas de IPI (percentuais ou NT) por NCM de 8 dígitos e código de exceção EX.", + licence_terms="Dado público oficial (Receita Federal do Brasil); dados abertos governamentais.", + raw_file_name="1 TIPI 2022 - Atualizada ADE 001-2026.xlsx", + ), + DatasetInventoryItem( + kind="CEST", + name="Código Especificador da Substituição Tributária (CEST)", + provider="CONFAZ", + source_url="https://www.confaz.fazenda.gov.br/legislacao/convenios/2018/CV142_18", + version_label="CV142_18", + expected_rows_approx=1096, + act="Convênio ICMS 142/18", + validity_info="Vigente com modificações sucessivas", + coverage="Relação de CEST (7 dígitos), segmentos (Anexos II a XXVI) e códigos NCM aplicáveis à ST.", + licence_terms="Dado público oficial (Conselho Nacional de Política Fazendária - CONFAZ).", + raw_file_name="CV142_18.html", + ), + DatasetInventoryItem( + kind="BA_ST_MVA", + name="Pauta Fiscal e Margens de Valor Agregado ST da Bahia", + provider="SEFAZ-BA", + source_url="http://www.sefaz.ba.gov.br/legislacao/ricms/anexo1.pdf", + version_label="ba-st-mva-pdf-v1", + expected_rows_approx=757, + act="RICMS/BA Anexo 1 (Decreto nº 13.780/2012)", + validity_info="Vigente em 2026 na Bahia", + coverage="Produtos sujeitos a ICMS-ST no Estado da Bahia, NCM/SH, CEST e percentuais de MVA original/ajustada.", + licence_terms="Legislação tributária pública estadual (SEFAZ-BA).", + raw_file_name="decreto_2012_13780_ricms_anexo_1_vigente_2026.pdf", + ), + DatasetInventoryItem( + kind="NFE_MOC_TABLES", + name="Tabelas de Domínios do MOC da NF-e / NFC-e", + provider="Receita Federal / ENCAT", + source_url="http://www.nfe.fazenda.gov.br/portal/principal.aspx", + version_label="moc7-tables-v1", + expected_rows_approx=104, + act="MOC 7.0 Anexo I (Visão Geral de Leiaute e Regras de Validação)", + validity_info="Vigente nacionalmente", + coverage="Domínios oficiais de ORIGEM_MERCADORIA (9), CST_ICMS (14), CSOSN (10), CST_PIS (36) e CST_COFINS (36).", + licence_terms="Documento técnico público oficial (ENCAT / Portal Nacional NF-e).", + raw_file_name="moc7-anexo-i-leiaute-e-rv.pdf", + ), + DatasetInventoryItem( + kind="CFOP", + name="Tabela de Códigos Fiscais de Operações e Prestações (CFOP)", + provider="Portal Nacional NF-e", + source_url="https://www.nfe.fazenda.gov.br/portal/listaConteudo.aspx?tipoConteudo=Iy/5QolvUsQ=", + version_label="cfop-xlsx-v1", + expected_rows_approx=593, + act="Informe Técnico 2023.002 v2.10", + validity_info="Vigente desde 2023", + coverage="CFOP de 4 dígitos, vigências, indicadores operacionais (devolução, anulação, remessa, combustível) e descrições.", + licence_terms="Dado público oficial (Portal Nacional da NF-e).", + raw_file_name="IT_2023_002_v2_10_Tabela_CFOP_Descrição.xlsx", + ), + DatasetInventoryItem( + kind="FCP", + name="Tabela de Alíquotas de FCP por UF", + provider="Portal Nacional NF-e", + source_url="https://www.nfe.fazenda.gov.br/portal/listaConteudo.aspx?tipoConteudo=Iy/5QolvUsQ=", + version_label="fcp-xlsx-v1", + expected_rows_approx=27, + act="Tabela de Alíquotas FCP por UF", + validity_info="Vigente (fevereiro/2025)", + coverage="Alíquotas adicionais do Fundo de Combate à Pobreza para os 26 estados e Distrito Federal.", + licence_terms="Dado público oficial (Portal Nacional da NF-e).", + raw_file_name="Tabela_Alíquotas_FCP_por_UF_2025-02_07.xlsx", + ), + DatasetInventoryItem( + kind="ANP", + name="Tabela de Códigos de Produto ANP", + provider="Portal Nacional NF-e", + source_url="https://www.nfe.fazenda.gov.br/portal/listaConteudo.aspx?tipoConteudo=Iy/5QolvUsQ=", + version_label="anp-xlsx-v1", + expected_rows_approx=184, + act="Tabela de Código de Produto ANP", + validity_info="Vigente (novembro/2024)", + coverage="Códigos cProdANP e descrições oficiais de combustíveis e derivados de petróleo regulados pela ANP.", + licence_terms="Dado público oficial (ANP / Portal Nacional da NF-e).", + raw_file_name="Tabela_Codigo_Produto_ANP-20241118.xlsx", + ), + DatasetInventoryItem( + kind="NCM_UTRIB", + name="Tabela de Correlação NCM x Unidade Tributável", + provider="Portal Nacional NF-e", + source_url="https://www.nfe.fazenda.gov.br/portal/listaConteudo.aspx?tipoConteudo=Iy/5QolvUsQ=", + version_label="ncm-utrib-xlsx-v1", + expected_rows_approx=10374, + act="Informe Técnico 2024.001 - NCM x uTrib", + validity_info="Vigente a partir de 01.10.2026 / 2024", + coverage="Mapeamento de cada NCM de 8 dígitos para a respectiva Unidade Tributável (uTrib) padrão nacional.", + licence_terms="Dado público oficial (Portal Nacional da NF-e).", + raw_file_name="IT2024.001v2.40 - Tabela NCM 2022 com Utrib_Comércio Exterior_vigência 01.10.26.xlsx", + ), +) + + +def sync_inventory_to_registry(session: Session) -> dict[str, int]: + """Sincroniza os datasets existentes com as metainformações do Reference Data Registry. + + Garante que os datasets já ativos no Hub (ACTIVE ou VIGENTE) possuam: + - licence_terms preenchidos + - act oficial correto + - raw_file_path preenchido quando disponível + - visibilidade no Registry sem criar segunda fonte ou duplicar linhas. + """ + inventory_map = {item.kind: item for item in DATASET_INVENTORY_9} + updated = 0 + active_datasets = session.scalars(select(Dataset).where(Dataset.status.in_(("ACTIVE", "VIGENTE")))).all() + for ds in active_datasets: + inv = inventory_map.get(ds.kind) + if inv: + changed = False + if not ds.licence_terms: + ds.licence_terms = inv.licence_terms + changed = True + if not ds.act: + ds.act = inv.act + changed = True + if inv.raw_file_name and not ds.raw_file_path: + ds.raw_file_path = f"sources/raw/bootstrap_v1/{inv.raw_file_name}" + changed = True + if changed: + updated += 1 + session.commit() + return {"active_found": len(active_datasets), "updated": updated} diff --git a/central/app/reference_registry.py b/central/app/reference_registry.py index 8ee2d75..f139e08 100644 --- a/central/app/reference_registry.py +++ b/central/app/reference_registry.py @@ -12,7 +12,8 @@ from sqlalchemy import select, update from .models import Dataset -STATUSES = ("DRAFT", "APPROVED", "VIGENTE", "OBSOLETO", "REJECTED") +STATUSES = ("DRAFT", "APPROVED", "VIGENTE", "ACTIVE", "OBSOLETO", "SUPERSEDED", "REJECTED") +VIGENTE_STATUSES = ("VIGENTE", "ACTIVE") class RegistryError(Exception): @@ -43,7 +44,7 @@ def diff_against_vigente(session, dataset: Dataset) -> dict: """A summary an ADMIN reviews before approving: what the current VIGENTE of the same kind looks like vs this DRAFT - never the row-by-row content (that lives in the kind-specific entry tables), just the registry-level facts that matter for an approval decision.""" - current = session.scalar(select(Dataset).where(Dataset.kind == dataset.kind, Dataset.status == "VIGENTE")) + current = session.scalar(select(Dataset).where(Dataset.kind == dataset.kind, Dataset.status.in_(VIGENTE_STATUSES)).order_by(Dataset.id.desc())) return { "kind": dataset.kind, "draft": {"id": dataset.id, "version_label": dataset.version_label, "sha256": dataset.sha256, "record_count": dataset.record_count, @@ -67,7 +68,7 @@ def approve_draft(session, dataset_id: int, *, admin_name: str) -> Dataset: raise RegistryError(f"only a DRAFT dataset can be approved (current status: {dataset.status})") if dataset.parse_errors: raise RegistryError("dataset has recorded parse errors - re-submit a clean version") - session.execute(update(Dataset).where(Dataset.kind == dataset.kind, Dataset.status == "VIGENTE").values(status="OBSOLETO")) + session.execute(update(Dataset).where(Dataset.kind == dataset.kind, Dataset.status.in_(VIGENTE_STATUSES)).values(status="OBSOLETO")) dataset.status = "VIGENTE" dataset.approved_by = admin_name dataset.approved_at = datetime.now(timezone.utc) diff --git a/central/tests/test_mission_06_hygiene.py b/central/tests/test_mission_06_hygiene.py new file mode 100644 index 0000000..81143bd --- /dev/null +++ b/central/tests/test_mission_06_hygiene.py @@ -0,0 +1,109 @@ +"""Missão 6 — Bloco A: Testes de Higiene (Migrations e Inventário dos 9 Datasets).""" +import subprocess +from pathlib import Path +from alembic.config import Config +from alembic.script import ScriptDirectory + +ROOT = Path(__file__).resolve().parents[2] + + +def test_alembic_master_head_is_single(): + """Garante que na branch master o alembic possui uma única head: 0034_reference_data_registry.""" + cfg = Config(str(ROOT / "central" / "alembic.ini")) + script = ScriptDirectory.from_config(cfg) + heads = script.get_heads() + assert heads == ["0034_reference_data_registry"], f"Esperado head única 0034, obtido: {heads}" + + +def test_feature_branch_migration_chains_to_0034(): + """Garante que na branch feature/cfop-ippt-write a migração 0035 encadeia exatamente em 0034.""" + # Obter conteúdo de 0035 na branch feature/cfop-ippt-write via git show + result = subprocess.run( + ["git", "show", "feature/cfop-ippt-write:central/alembic/versions/0035_cfop_ippt_write_mode.py"], + capture_output=True, + text=True, + cwd=str(ROOT), + ) + assert result.returncode == 0, f"Arquivo 0035_cfop_ippt_write_mode.py deve existir na branch feature/cfop-ippt-write: {result.stderr}" + content = result.stdout + assert 'revision = "0035_cfop_ippt_write_mode"' in content + assert 'down_revision = "0034_reference_data_registry"' in content + + # Verifica que 0033 não existe mais na branch + res_old = subprocess.run( + ["git", "show", "feature/cfop-ippt-write:central/alembic/versions/0033_cfop_ippt_write_mode.py"], + capture_output=True, + text=True, + cwd=str(ROOT), + ) + assert res_old.returncode != 0, "Migração 0033 não deve mais existir na branch feature" + + +def test_inventory_9_datasets_coverage(): + """Garante que os 9 datasets existentes no Hub estão integralmente catalogados.""" + from central.app.datasets.inventory import DATASET_INVENTORY_9 + kinds = [item.kind for item in DATASET_INVENTORY_9] + expected_kinds = ["NCM", "TIPI_IPI", "CEST", "BA_ST_MVA", "NFE_MOC_TABLES", "CFOP", "FCP", "ANP", "NCM_UTRIB"] + assert kinds == expected_kinds + for item in DATASET_INVENTORY_9: + assert item.name + assert item.provider + assert item.source_url.startswith("http") + assert item.version_label + assert item.expected_rows_approx > 0 + assert item.act + assert item.validity_info + assert item.coverage + assert item.licence_terms + + +def test_sync_inventory_to_registry_and_listing(): + """Garante que datasets ACTIVE são reconhecidos pelo Reference Data Registry.""" + from central.app.datasets.inventory import sync_inventory_to_registry + from central.app.reference_registry import list_registry, diff_against_vigente + from central.app.models import Dataset + from sqlalchemy import create_engine + from sqlalchemy.orm import Session + from central.app.models import Base + + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + + with Session(engine) as session: + # Cadastrar dataset com status antigo ACTIVE (sem licence_terms) + ds = Dataset( + kind="NCM", provider="Siscomex", sha256="abc123hash", + version_label="2026-09-28", act="", source_url="https://portalunico.siscomex.gov.br", + status="ACTIVE", record_count=15156, + ) + session.add(ds) + session.commit() + + # Antes do sync, campos novos eram vazios + assert ds.licence_terms is None + + # Executar sincronização + stats = sync_inventory_to_registry(session) + assert stats["active_found"] == 1 + assert stats["updated"] == 1 + assert ds.licence_terms is not None + assert ds.act == "Resolução Gecex / MDIC" + + # Listagem do registry deve listar datasets ACTIVE + listed = list_registry(session) + assert len(listed) == 1 + assert listed[0].kind == "NCM" + + # Diff contra DRAFT deve encontrar o ACTIVE como vigente + draft = Dataset( + kind="NCM", provider="Siscomex", sha256="diff123hash", + version_label="2026-10-01", act="Nova Resolução", source_url="url", + status="DRAFT", record_count=15200, + ) + session.add(draft) + session.commit() + diff = diff_against_vigente(session, draft) + assert diff["current_vigente"] is not None + assert diff["current_vigente"]["sha256"] == "abc123hash" + assert diff["record_count_delta"] == 15200 - 15156 +