Add 'fiscal/' from commit '102ba84500dbd9655a0471288c3e278652593ca9'

git-subtree-dir: fiscal
git-subtree-mainline: d95165fc8f
git-subtree-split: 102ba84500
This commit is contained in:
gti
2026-09-29 14:43:23 -03:00
868 changed files with 566394 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
from alembic import context
from sqlalchemy import engine_from_config, pool
from central.app.models import Base
from central.app.config import settings
config = context.config
target_metadata = Base.metadata
def run_migrations_offline():
context.configure(url=settings.database_url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"})
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
config.set_main_option("sqlalchemy.url", settings.database_url)
engine = engine_from_config(config.get_section(config.config_ini_section), prefix="sqlalchemy.", poolclass=pool.NullPool)
with engine.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
@@ -0,0 +1,99 @@
"""initial hub persistence
Revision ID: 0001
Frozen, explicit baseline. This revision used to run
``Base.metadata.create_all()``, which builds the schema from the *current*
models: on an empty database it created tables/columns that 0002..0018 then
tried to create again (first collision: ``datasets.raw_sha256`` in 0002), so
``alembic upgrade head`` could never run from zero.
This body reproduces exactly what 0001 created on the databases that went
through the real history: the seven base tables, *before* 0002 added
``datasets.raw_sha256`` and 0006 added the agent status/timestamp columns.
Constraint and index names match the ones those databases already have
(PostgreSQL defaults for PK/FK/UNIQUE, ``ix_<table>_<column>`` for indexes).
Databases already at 0001 or later never re-run this revision, so changing
its body only affects new installations. It must never import the ORM
models again: a migration describes one point in history, not the present.
"""
from alembic import op
import sqlalchemy as sa
revision = "0001"
down_revision = None
branch_labels = depends_on = None
def upgrade():
op.create_table(
"tenants",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(128), nullable=False, unique=True),
)
op.create_table(
"datasets",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("kind", sa.String(32), nullable=False),
sa.Column("provider", sa.String(128), nullable=False),
sa.Column("sha256", sa.String(64), nullable=False),
sa.Column("version_label", sa.String(256), nullable=False),
sa.Column("act", sa.Text(), nullable=False),
sa.Column("source_url", sa.Text(), nullable=False),
sa.Column("downloaded_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("status", sa.String(32), nullable=False),
sa.Column("record_count", sa.Integer(), nullable=False),
sa.UniqueConstraint("provider", "sha256", name="uq_dataset_provider_hash"),
)
op.create_index("ix_datasets_kind", "datasets", ["kind"])
op.create_index("ix_datasets_status", "datasets", ["status"])
op.create_table(
"ncm_entries",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("dataset_id", sa.Integer(), sa.ForeignKey("datasets.id"), nullable=False),
sa.Column("code", sa.String(16), nullable=False),
sa.Column("description", sa.Text(), nullable=False),
sa.Column("valid_from", sa.String(32), nullable=False),
sa.Column("valid_to", sa.String(32), nullable=False),
sa.UniqueConstraint("dataset_id", "code", "valid_from", "valid_to", name="uq_ncm_versioned_entry"),
)
op.create_index("ix_ncm_entries_dataset_id", "ncm_entries", ["dataset_id"])
op.create_index("ix_ncm_entries_code", "ncm_entries", ["code"])
op.create_table(
"agents",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id"), nullable=False),
sa.Column("name", sa.String(128), nullable=False),
sa.Column("token_hash", sa.String(64), nullable=False, unique=True),
)
op.create_index("ix_agents_tenant_id", "agents", ["tenant_id"])
op.create_table(
"classification_cache",
sa.Column("fingerprint", sa.String(64), primary_key=True),
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id"), nullable=False),
sa.Column("result", sa.JSON(), nullable=False),
)
op.create_index("ix_classification_cache_tenant_id", "classification_cache", ["tenant_id"])
op.create_table(
"classification_requests",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id"), nullable=False),
sa.Column("agent_id", sa.Integer(), sa.ForeignKey("agents.id"), nullable=False),
sa.Column("fingerprint", sa.String(64), nullable=False, unique=True),
sa.Column("payload", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_classification_requests_tenant_id", "classification_requests", ["tenant_id"])
op.create_index("ix_classification_requests_agent_id", "classification_requests", ["agent_id"])
op.create_table(
"classification_results",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("request_id", sa.Integer(), sa.ForeignKey("classification_requests.id"), nullable=False, unique=True),
sa.Column("result", sa.JSON(), nullable=False),
)
def downgrade():
for table in ("classification_results", "classification_requests", "classification_cache", "agents", "ncm_entries", "datasets", "tenants"):
op.drop_table(table)
@@ -0,0 +1,31 @@
"""add CEST links and official legacy NCM evidence
Revision ID: 0002
Revises: 0001
"""
from alembic import op
from sqlalchemy import Column, ForeignKey, Integer, String, Text, UniqueConstraint
revision = "0002"
down_revision = "0001"
branch_labels = depends_on = None
def upgrade():
op.add_column("datasets", Column("raw_sha256", String(64), nullable=True))
op.create_table("legacy_ncm_entries", Column("code", String(16), primary_key=True), Column("source", Text, nullable=False), Column("source_date", String(32), nullable=False), Column("reason", Text, nullable=False), Column("successor_code", String(16), nullable=True))
op.create_table("cest_entries", Column("id", Integer, primary_key=True), Column("dataset_id", Integer, ForeignKey("datasets.id"), nullable=False), Column("code", String(16), nullable=False), Column("description", Text, nullable=False), Column("annex", String(8), nullable=False), UniqueConstraint("dataset_id", "code", name="uq_cest_versioned_entry"))
op.create_table("cest_ncm_entries", Column("id", Integer, primary_key=True), Column("cest_entry_id", Integer, ForeignKey("cest_entries.id"), nullable=False), Column("ncm_code", String(16), nullable=False), UniqueConstraint("cest_entry_id", "ncm_code", name="uq_cest_ncm_link"))
op.create_index("ix_cest_entries_dataset_id", "cest_entries", ["dataset_id"])
op.create_index("ix_cest_entries_code", "cest_entries", ["code"])
op.create_index("ix_cest_ncm_entries_cest_entry_id", "cest_ncm_entries", ["cest_entry_id"])
op.create_index("ix_cest_ncm_entries_ncm_code", "cest_ncm_entries", ["ncm_code"])
def downgrade():
op.drop_table("cest_ncm_entries")
op.drop_table("cest_entries")
op.drop_table("legacy_ncm_entries")
op.drop_column("datasets", "raw_sha256")
@@ -0,0 +1,20 @@
"""add normalized TIPI entries
Revision ID: 0003
Revises: 0002
"""
from alembic import op
from sqlalchemy import Column, Float, ForeignKey, Integer, JSON, String, Text
revision = "0003"
down_revision = "0002"
branch_labels = depends_on = None
def upgrade():
op.create_table("tipi_entries", Column("id", Integer, primary_key=True), Column("dataset_id", Integer, ForeignKey("datasets.id"), nullable=False), Column("ncm", String(8), nullable=False), Column("ex", String(32)), Column("description", Text, nullable=False), Column("ipi_rate", Float, nullable=True), Column("ipi_rate_text", String(32)), Column("source_row", Integer, nullable=False), Column("metadata_json", JSON, nullable=False))
op.create_index("ix_tipi_entries_dataset_id", "tipi_entries", ["dataset_id"])
op.create_index("ix_tipi_entries_ncm", "tipi_entries", ["ncm"])
op.create_index("ix_tipi_entries_ncm_ex", "tipi_entries", ["ncm", "ex"])
def downgrade():
op.drop_table("tipi_entries")
@@ -0,0 +1,16 @@
"""add BA ST MVA entries
Revision ID: 0004
Revises: 0003
"""
from alembic import op
from sqlalchemy import Column, ForeignKey, Integer, JSON, String, Text
revision = "0004"
down_revision = "0003"
branch_labels = depends_on = None
def upgrade():
op.create_table("ba_st_mva_entries", Column("id", Integer, primary_key=True), Column("dataset_id", Integer, ForeignKey("datasets.id"), nullable=False), Column("item", String(32), nullable=False), Column("segment", Text), Column("cest", String(16)), Column("ncm_sh", String(32)), Column("description", Text), Column("mva_original", String(32)), Column("mva_adjusted", String(32)), Column("agreement_info", Text), Column("source_page", Integer, nullable=False), Column("metadata_json", JSON, nullable=False))
op.create_index("ix_ba_st_mva_entries_dataset_id", "ba_st_mva_entries", ["dataset_id"])
op.create_index("ix_ba_st_mva_entries_cest", "ba_st_mva_entries", ["cest"])
op.create_index("ix_ba_st_mva_entries_ncm_sh", "ba_st_mva_entries", ["ncm_sh"])
def downgrade(): op.drop_table("ba_st_mva_entries")
@@ -0,0 +1,15 @@
"""normalize BA ST NCM SH
Revision ID: 0005
Revises: 0004
"""
from alembic import op
from sqlalchemy import Column, ForeignKey, Integer, JSON, String, UniqueConstraint
revision="0005"; down_revision="0004"; branch_labels=depends_on=None
def upgrade():
op.drop_index("ix_ba_st_mva_entries_ncm_sh", table_name="ba_st_mva_entries")
op.drop_column("ba_st_mva_entries", "ncm_sh")
op.create_table("ba_st_mva_ncm_sh", Column("id", Integer, primary_key=True), Column("entry_id", Integer, ForeignKey("ba_st_mva_entries.id"), nullable=False), Column("ncm_sh", String(32), nullable=False), Column("metadata_json", JSON, nullable=False), UniqueConstraint("entry_id", "ncm_sh", name="uq_ba_st_mva_entry_ncm_sh"))
op.create_index("ix_ba_st_mva_ncm_sh_entry_id", "ba_st_mva_ncm_sh", ["entry_id"])
op.create_index("ix_ba_st_mva_ncm_sh_ncm_sh", "ba_st_mva_ncm_sh", ["ncm_sh"])
def downgrade(): pass
@@ -0,0 +1,43 @@
"""agent status fields and jobs table
Revision ID: 0006
Revises: 0005
"""
from alembic import op
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, JSON, String, Text
revision = "0006"; down_revision = "0005"; branch_labels = depends_on = None
def upgrade():
op.add_column("agents", Column("enabled", Boolean, server_default="true", nullable=False))
op.add_column("agents", Column("last_seen_at", DateTime(timezone=True), nullable=True))
op.add_column("agents", Column("last_status", String(32), nullable=True))
op.add_column("agents", Column("schema_fingerprint", String(64), nullable=True))
op.add_column("agents", Column("agent_version", String(32), nullable=True))
op.add_column("agents", Column("created_at", DateTime(timezone=True), server_default="now()", nullable=False))
op.add_column("agents", Column("updated_at", DateTime(timezone=True), server_default="now()", nullable=False))
op.create_table(
"jobs",
Column("id", Integer, primary_key=True),
Column("agent_id", Integer, ForeignKey("agents.id"), nullable=False),
Column("kind", String(32), nullable=False),
Column("status", String(16), nullable=False, server_default="PENDING"),
Column("payload", JSON, nullable=False),
Column("created_at", DateTime(timezone=True), server_default="now()", nullable=False),
Column("started_at", DateTime(timezone=True), nullable=True),
Column("finished_at", DateTime(timezone=True), nullable=True),
Column("attempts", Integer, nullable=False, server_default="0"),
Column("result_summary", JSON, nullable=True),
Column("error", Text, nullable=True),
)
op.create_index("ix_jobs_agent_id", "jobs", ["agent_id"])
op.create_index("ix_jobs_kind", "jobs", ["kind"])
op.create_index("ix_jobs_status", "jobs", ["status"])
def downgrade():
op.drop_table("jobs")
for column in ("enabled", "last_seen_at", "last_status", "schema_fingerprint", "agent_version", "created_at", "updated_at"):
op.drop_column("agents", column)
@@ -0,0 +1,30 @@
"""fiscal_code_entries table for ORIGEM/CST_ICMS/CSOSN/CST_PIS/CST_COFINS
Revision ID: 0007
Revises: 0006
"""
from alembic import op
from sqlalchemy import Column, ForeignKey, Integer, JSON, String, Text, UniqueConstraint
revision = "0007"; down_revision = "0006"; branch_labels = depends_on = None
def upgrade():
op.create_table(
"fiscal_code_entries",
Column("id", Integer, primary_key=True),
Column("dataset_id", Integer, ForeignKey("datasets.id"), nullable=False),
Column("domain", String(32), nullable=False),
Column("code", String(8), nullable=False),
Column("description", Text, nullable=False),
Column("source_section", String(96), nullable=False),
Column("metadata_json", JSON, nullable=False),
UniqueConstraint("dataset_id", "domain", "code", name="uq_fiscal_code_entry"),
)
op.create_index("ix_fiscal_code_entries_dataset_id", "fiscal_code_entries", ["dataset_id"])
op.create_index("ix_fiscal_code_entries_domain", "fiscal_code_entries", ["domain"])
op.create_index("ix_fiscal_code_entries_code", "fiscal_code_entries", ["code"])
def downgrade():
op.drop_table("fiscal_code_entries")
@@ -0,0 +1,124 @@
"""CFOP/FCP/ANP/NCM_UTRIB/RTC_BIOFUEL_INDEX/RTC_CCREDPRES tables
Revision ID: 0008
Revises: 0007
"""
from alembic import op
from sqlalchemy import Boolean, Column, Float, ForeignKey, Integer, String, Text, UniqueConstraint
revision = "0008"; down_revision = "0007"; branch_labels = depends_on = None
def upgrade():
op.create_table(
"cfop_entries",
Column("id", Integer, primary_key=True),
Column("dataset_id", Integer, ForeignKey("datasets.id"), nullable=False),
Column("code", String(8), nullable=False),
Column("title", Text, nullable=False),
Column("description", Text, nullable=True),
Column("effective_from", String(32), nullable=True),
Column("effective_to", String(32), nullable=True),
Column("ind_nfe", String(4), nullable=True),
Column("ind_comunica", String(4), nullable=True),
Column("ind_transp", String(4), nullable=True),
Column("ind_devol", String(4), nullable=True),
Column("ind_retor", String(4), nullable=True),
Column("ind_anula", String(4), nullable=True),
Column("ind_remes", String(4), nullable=True),
Column("ind_comb", String(4), nullable=True),
Column("ind_exc_ibscbs", String(4), nullable=True),
UniqueConstraint("dataset_id", "code", name="uq_cfop_entry"),
)
op.create_index("ix_cfop_entries_dataset_id", "cfop_entries", ["dataset_id"])
op.create_index("ix_cfop_entries_code", "cfop_entries", ["code"])
op.create_table(
"fcp_rates",
Column("id", Integer, primary_key=True),
Column("dataset_id", Integer, ForeignKey("datasets.id"), nullable=False),
Column("uf_code", String(4), nullable=False),
Column("uf", String(4), nullable=False),
Column("uf_name", String(64), nullable=False),
Column("aliq_1", String(32), nullable=True),
Column("aliq_2", String(32), nullable=True),
Column("aliq_3", String(32), nullable=True),
Column("observacao", Text, nullable=True),
UniqueConstraint("dataset_id", "uf", name="uq_fcp_rate_uf"),
)
op.create_index("ix_fcp_rates_dataset_id", "fcp_rates", ["dataset_id"])
op.create_index("ix_fcp_rates_uf", "fcp_rates", ["uf"])
op.create_table(
"anp_product_codes",
Column("id", Integer, primary_key=True),
Column("dataset_id", Integer, ForeignKey("datasets.id"), nullable=False),
Column("cprod_anp", String(16), nullable=False),
UniqueConstraint("dataset_id", "cprod_anp", name="uq_anp_product_code"),
)
op.create_index("ix_anp_product_codes_dataset_id", "anp_product_codes", ["dataset_id"])
op.create_index("ix_anp_product_codes_cprod_anp", "anp_product_codes", ["cprod_anp"])
op.create_table(
"ncm_utrib_entries",
Column("id", Integer, primary_key=True),
Column("dataset_id", Integer, ForeignKey("datasets.id"), nullable=False),
Column("ncm", String(16), nullable=False),
Column("effective_from", String(32), nullable=True),
Column("effective_to", String(32), nullable=True),
Column("utrib_abbreviation", String(16), nullable=True),
Column("utrib_description", Text, nullable=True),
UniqueConstraint("dataset_id", "ncm", name="uq_ncm_utrib_entry"),
)
op.create_index("ix_ncm_utrib_entries_dataset_id", "ncm_utrib_entries", ["dataset_id"])
op.create_index("ix_ncm_utrib_entries_ncm", "ncm_utrib_entries", ["ncm"])
op.create_table(
"rtc_biofuel_index_entries",
Column("id", Integer, primary_key=True),
Column("dataset_id", Integer, ForeignKey("datasets.id"), nullable=False),
Column("cprod_anp", String(16), nullable=True),
Column("description", Text, nullable=True),
Column("p_bio_percent", Float, nullable=True),
Column("effective_from", String(32), nullable=True),
Column("effective_to", String(32), nullable=True),
Column("is_historical", Boolean, nullable=False, server_default="false"),
)
op.create_index("ix_rtc_biofuel_index_entries_dataset_id", "rtc_biofuel_index_entries", ["dataset_id"])
op.create_index("ix_rtc_biofuel_index_entries_cprod_anp", "rtc_biofuel_index_entries", ["cprod_anp"])
op.create_table(
"rtc_cred_pres_entries",
Column("id", Integer, primary_key=True),
Column("dataset_id", Integer, ForeignKey("datasets.id"), nullable=False),
Column("code", String(8), nullable=False),
Column("description", Text, nullable=False),
Column("legal_basis", Text, nullable=True),
Column("apropria_via_nf", Boolean, nullable=True),
Column("apropria_via_evento", Boolean, nullable=True),
Column("ind_deduz_cred_pres", String(8), nullable=True),
Column("ind_g_cbs_cred_pres", String(8), nullable=True),
Column("ind_g_ibs_cred_pres", String(8), nullable=True),
Column("aliquota_cbs_text", Text, nullable=True),
Column("aliquota_ibs_text", Text, nullable=True),
Column("p_aliq_cred_pres_cbs", Text, nullable=True),
Column("p_aliq_cred_pres_ibs", Text, nullable=True),
Column("p_red_transicao_ibs", Text, nullable=True),
Column("cclass_nota_referenciada", String(16), nullable=True),
Column("d_ini_vig_cbs", String(32), nullable=True),
Column("d_fim_vig_cbs", String(32), nullable=True),
Column("d_ini_vig_ibs", String(32), nullable=True),
Column("d_fim_vig_ibs", String(32), nullable=True),
UniqueConstraint("dataset_id", "code", name="uq_rtc_cred_pres_code"),
)
op.create_index("ix_rtc_cred_pres_entries_dataset_id", "rtc_cred_pres_entries", ["dataset_id"])
op.create_index("ix_rtc_cred_pres_entries_code", "rtc_cred_pres_entries", ["code"])
def downgrade():
op.drop_table("rtc_cred_pres_entries")
op.drop_table("rtc_biofuel_index_entries")
op.drop_table("ncm_utrib_entries")
op.drop_table("anp_product_codes")
op.drop_table("fcp_rates")
op.drop_table("cfop_entries")
@@ -0,0 +1,20 @@
"""widen rtc_cred_pres_entries.cclass_nota_referenciada to Text
Some official rows carry free text ("cClass de alíquota zero") in this
column, not just the numeric code — VARCHAR(16) truncated them.
Revision ID: 0009
Revises: 0008
"""
from alembic import op
from sqlalchemy import Text
revision = "0009"; down_revision = "0008"; branch_labels = depends_on = None
def upgrade():
op.alter_column("rtc_cred_pres_entries", "cclass_nota_referenciada", type_=Text, existing_nullable=True)
def downgrade():
op.alter_column("rtc_cred_pres_entries", "cclass_nota_referenciada", type_=None, existing_nullable=True)
@@ -0,0 +1,48 @@
"""RTC cClassTrib, CST-IBS/CBS and CBS rate tables
Revision ID: 0010
Revises: 0009
"""
from alembic import op
from sqlalchemy import JSON, Column, Float, ForeignKey, Integer, String, Text, UniqueConstraint
revision = "0010"; down_revision = "0009"; branch_labels = depends_on = None
def _fk():
return Column("dataset_id", Integer, ForeignKey("datasets.id"), nullable=False, index=True)
def upgrade():
op.create_table(
"rtc_cst_ibscbs_entries",
Column("id", Integer, primary_key=True), _fk(),
Column("cst", String(8), nullable=False, index=True),
Column("description", Text, nullable=False),
Column("indicators", JSON, nullable=False),
UniqueConstraint("dataset_id", "cst", name="uq_rtc_cst_ibscbs"),
)
op.create_table(
"rtc_cclasstrib_entries",
Column("id", Integer, primary_key=True), _fk(),
Column("cst", String(8), nullable=False, index=True),
Column("cst_description", Text), Column("cclasstrib", String(8), nullable=False, index=True),
Column("name", Text), Column("description", Text), Column("lc_redacao", Text), Column("lc_214", Text),
Column("regulamento_cbs", Text), Column("regulamento_ibs", Text),
Column("tipo_aliquota", String(64)), Column("p_red_ibs", String(32)), Column("p_red_cbs", String(32)),
Column("d_ini_vig", String(32)), Column("d_fim_vig", String(32)), Column("data_atualizacao", String(32)),
Column("anexo", Text), Column("link", Text), Column("indicators", JSON, nullable=False),
UniqueConstraint("dataset_id", "cclasstrib", name="uq_rtc_cclasstrib"),
)
op.create_table(
"rtc_cbs_rate_entries",
Column("id", Integer, primary_key=True), _fk(),
Column("year", Integer, nullable=False, index=True),
Column("rate_text", Text, nullable=False), Column("rate", Float),
UniqueConstraint("dataset_id", "year", name="uq_rtc_cbs_rate_year"),
)
def downgrade():
for t in ("rtc_cbs_rate_entries", "rtc_cclasstrib_entries", "rtc_cst_ibscbs_entries"):
op.drop_table(t)
@@ -0,0 +1,31 @@
"""operator fiscal context entries (facts the ERP does not know: role, origin, ST retention, treatment, ...)
Revision ID: 0011
Revises: 0010
"""
from alembic import op
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
revision = "0011"; down_revision = "0010"; branch_labels = depends_on = None
def upgrade():
op.create_table(
"fiscal_context_entries",
Column("id", Integer, primary_key=True),
Column("tenant_id", Integer, ForeignKey("tenants.id"), nullable=False, index=True),
Column("company_key", String(64), nullable=False, index=True),
Column("scope", String(8), nullable=False),
Column("scope_key", String(64), nullable=False, server_default=""),
Column("key", String(48), nullable=False),
Column("value", Text, nullable=False),
Column("source", String(16), nullable=False, server_default="OPERATOR"),
Column("reason", Text, nullable=True),
Column("created_at", DateTime(timezone=True), server_default=func.now()),
Column("updated_at", DateTime(timezone=True), server_default=func.now()),
UniqueConstraint("tenant_id", "company_key", "scope", "scope_key", "key", name="uq_fiscal_context_entry"),
)
def downgrade():
op.drop_table("fiscal_context_entries")
@@ -0,0 +1,37 @@
"""Migration 0012: AI semantic layer V1 (KB, tenant context, work items, runs).
Revision ID: 0012
Revises: 0011
"""
from alembic import op
from sqlalchemy import Column, DateTime, Float, ForeignKey, Integer, JSON, String, Text, UniqueConstraint, func
revision = "0012"
down_revision = "0011"
branch_labels = depends_on = None
def upgrade():
op.create_table("kb_products", Column("id", Integer, primary_key=True), Column("fingerprint", String(64), nullable=False, unique=True), Column("gtin", String(16), unique=True), Column("identity_kind", String(16), nullable=False, server_default="FINGERPRINT"), Column("normalized_description", Text, nullable=False, server_default=""), Column("brand", Text), Column("unit", String(32)), Column("package_quantity", String(32)), Column("category", Text), Column("semantic_attributes", JSON, nullable=False), Column("created_at", DateTime(timezone=True), server_default=func.now()), Column("updated_at", DateTime(timezone=True), server_default=func.now()))
op.create_index("ix_kb_products_gtin", "kb_products", ["gtin"])
op.create_table("kb_knowledge", Column("id", Integer, primary_key=True), Column("kb_product_id", Integer, ForeignKey("kb_products.id"), nullable=False), Column("knowledge_key", String(48), nullable=False), Column("value", Text, nullable=False), Column("source", String(32), nullable=False), Column("confidence", Float, nullable=False, server_default="0"), Column("evidence", JSON, nullable=False), Column("model", String(64)), Column("prompt_version", String(32)), Column("dataset_versions", JSON, nullable=False), Column("created_at", DateTime(timezone=True), server_default=func.now()), Column("validated_at", DateTime(timezone=True)), UniqueConstraint("kb_product_id", "knowledge_key", "value", name="uq_kb_knowledge_value"))
op.create_index("ix_kb_knowledge_kb_product_id", "kb_knowledge", ["kb_product_id"])
op.create_index("ix_kb_knowledge_knowledge_key", "kb_knowledge", ["knowledge_key"])
op.create_table("kb_observations", Column("id", Integer, primary_key=True), Column("kb_product_id", Integer, ForeignKey("kb_products.id"), nullable=False), Column("tenant_id", Integer, ForeignKey("tenants.id"), nullable=False), Column("company_key", String(64), nullable=False), Column("erp_code", String(32), nullable=False), Column("database_label", String(64), nullable=False), Column("observed_fields", JSON, nullable=False), Column("source", String(32), nullable=False, server_default="ERP_OBSERVATION"), Column("observed_at", DateTime(timezone=True), server_default=func.now()))
op.create_index("ix_kb_observations_kb_product_id", "kb_observations", ["kb_product_id"])
op.create_table("tenant_product_context", Column("id", Integer, primary_key=True), Column("tenant_id", Integer, ForeignKey("tenants.id"), nullable=False), Column("company_key", String(64), nullable=False), Column("erp_code", String(32), nullable=False), Column("kb_product_id", Integer, ForeignKey("kb_products.id")), Column("product_role", String(16)), Column("st_withheld", String(4)), Column("operation_profile", JSON, nullable=False), Column("source", String(32), nullable=False, server_default="ERP_OBSERVATION"), Column("confidence", Float, nullable=False, server_default="0"), Column("created_at", DateTime(timezone=True), server_default=func.now()), Column("updated_at", DateTime(timezone=True), server_default=func.now()), UniqueConstraint("tenant_id", "company_key", "erp_code", name="uq_tenant_product_context"))
op.create_index("ix_tenant_product_context_tenant_id", "tenant_product_context", ["tenant_id"])
op.create_table("ai_work_items", Column("id", Integer, primary_key=True), Column("task_type", String(24), nullable=False), Column("dedup_key", String(160), nullable=False, unique=True), Column("question_refs", JSON, nullable=False), Column("payload", JSON, nullable=False), Column("status", String(24), nullable=False, server_default="PENDING"), Column("kb_product_id", Integer, ForeignKey("kb_products.id")), Column("cells_covered", Integer, nullable=False, server_default="0"), Column("created_at", DateTime(timezone=True), server_default=func.now()), Column("updated_at", DateTime(timezone=True), server_default=func.now()))
op.create_index("ix_ai_work_items_task_type", "ai_work_items", ["task_type"])
op.create_index("ix_ai_work_items_status", "ai_work_items", ["status"])
op.create_table("ai_runs", Column("id", Integer, primary_key=True), Column("provider", String(64), nullable=False), Column("model", String(64), nullable=False), Column("prompt_version", String(32), nullable=False), Column("work_item_ids", JSON, nullable=False), Column("input_tokens", Integer, nullable=False, server_default="0"), Column("output_tokens", Integer, nullable=False, server_default="0"), Column("cost_usd", Float, nullable=False, server_default="0"), Column("status", String(16), nullable=False, server_default="RUN"), Column("results", JSON, nullable=False), Column("budget_guard", JSON, nullable=False), Column("started_at", DateTime(timezone=True), server_default=func.now()), Column("finished_at", DateTime(timezone=True)))
def downgrade():
op.drop_table("ai_runs")
op.drop_table("ai_work_items")
op.drop_table("tenant_product_context")
op.drop_table("kb_observations")
op.drop_table("kb_knowledge")
op.drop_index("ix_kb_products_gtin", table_name="kb_products")
op.drop_table("kb_products")
@@ -0,0 +1,19 @@
"""Migration 0013: widen ai_work_items.status for V2 classification labels.
Revision ID: 0013
Revises: 0012
"""
from alembic import op
import sqlalchemy as sa
revision = "0013"
down_revision = "0012"
branch_labels = depends_on = None
def upgrade():
op.alter_column("ai_work_items", "status", existing_type=sa.String(24), type_=sa.String(32), nullable=False, existing_server_default="PENDING")
def downgrade():
op.alter_column("ai_work_items", "status", existing_type=sa.String(32), type_=sa.String(24), nullable=False, existing_server_default="PENDING")
@@ -0,0 +1,20 @@
"""Migration 0014: persistent LLM eval result cache.
Revision ID: 0014
Revises: 0013
"""
from alembic import op
from sqlalchemy import Column, DateTime, ForeignKey, Integer, JSON, String, UniqueConstraint, func
revision = "0014"
down_revision = "0013"
branch_labels = depends_on = None
def upgrade():
op.create_table("ai_eval_cache", Column("id", Integer, primary_key=True), Column("input_fingerprint", String(64), nullable=False), Column("candidate_set_hash", String(64), nullable=False), Column("prompt_version", String(32), nullable=False), Column("model", String(64), nullable=False), Column("dataset_version_ncm", String(64), nullable=False), Column("phase", String(16), nullable=False), Column("answer", JSON, nullable=False), Column("gold_ncm", String(8)), Column("created_at", DateTime(timezone=True), server_default=func.now()), UniqueConstraint("input_fingerprint", "candidate_set_hash", "prompt_version", "model", "dataset_version_ncm", name="uq_ai_eval_cache"))
op.create_index("ix_ai_eval_cache_input_fingerprint", "ai_eval_cache", ["input_fingerprint"])
def downgrade():
op.drop_table("ai_eval_cache")
@@ -0,0 +1,52 @@
"""Add central AI usage ledger and external GTIN cache."""
from alembic import op
import sqlalchemy as sa
revision = "0015_ai_budget_and_product_cache"
down_revision = "0014"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"ai_usage_ledger",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("provider", sa.String(length=64), nullable=False),
sa.Column("model", sa.String(length=64), nullable=False),
sa.Column("run_id", sa.String(length=128), nullable=False),
sa.Column("tenant", sa.String(length=128), nullable=False),
sa.Column("items", sa.Integer(), nullable=False, server_default="0"),
sa.Column("input_tokens", sa.Integer(), nullable=False, server_default="0"),
sa.Column("output_tokens", sa.Integer(), nullable=False, server_default="0"),
sa.Column("cost", sa.Float(), nullable=False, server_default="0"),
sa.Column("timestamp", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_ai_usage_ledger_run_id", "ai_usage_ledger", ["run_id"])
op.create_index("ix_ai_usage_ledger_tenant", "ai_usage_ledger", ["tenant"])
op.create_index("ix_ai_usage_ledger_timestamp", "ai_usage_ledger", ["timestamp"])
op.create_table(
"external_product_cache",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("gtin", sa.String(length=16), nullable=False),
sa.Column("provider", sa.String(length=64), nullable=False),
sa.Column("provider_record_version", sa.String(length=64), nullable=False),
sa.Column("retrieved_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("raw_hash", sa.String(length=64), nullable=False),
sa.Column("normalized_data", sa.JSON(), nullable=False),
sa.Column("cache_allowed", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("redistribution_allowed", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.UniqueConstraint("gtin", "provider", "provider_record_version", name="uq_external_product_cache_record"),
)
op.create_index("ix_external_product_cache_gtin", "external_product_cache", ["gtin"])
def downgrade():
op.drop_index("ix_external_product_cache_gtin", table_name="external_product_cache")
op.drop_table("external_product_cache")
op.drop_index("ix_ai_usage_ledger_timestamp", table_name="ai_usage_ledger")
op.drop_index("ix_ai_usage_ledger_tenant", table_name="ai_usage_ledger")
op.drop_index("ix_ai_usage_ledger_run_id", table_name="ai_usage_ledger")
op.drop_table("ai_usage_ledger")
@@ -0,0 +1,43 @@
"""Add deduplicated blocked product registry."""
from alembic import op
import sqlalchemy as sa
revision = "0016_blocked_product_registry"
down_revision = "0015_ai_budget_and_product_cache"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"blocked_products",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id"), nullable=False),
sa.Column("company", sa.String(length=128), nullable=False),
sa.Column("product_code", sa.String(length=64), nullable=False),
sa.Column("gtin", sa.String(length=16)),
sa.Column("description", sa.Text()),
sa.Column("blocker_type", sa.String(length=48), nullable=False),
sa.Column("field", sa.String(length=64), nullable=False),
sa.Column("reason_code", sa.String(length=96), nullable=False),
sa.Column("evidence_summary", sa.JSON(), nullable=False),
sa.Column("input_fingerprint", sa.String(length=64), nullable=False),
sa.Column("blocker_fingerprint", sa.String(length=64), nullable=False),
sa.Column("first_seen_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("last_seen_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("retry_count", sa.Integer(), server_default="0", nullable=False),
sa.Column("next_retry_at", sa.DateTime(timezone=True)),
sa.Column("resolved_at", sa.DateTime(timezone=True)),
sa.Column("resolution_source", sa.String(length=48)),
sa.UniqueConstraint("blocker_fingerprint", name="uq_blocked_product_fingerprint"),
)
for name, column in (("tenant", "tenant_id"), ("company", "company"), ("product", "product_code"), ("gtin", "gtin"), ("blocker_type", "blocker_type"), ("field", "field"), ("input", "input_fingerprint")):
op.create_index(f"ix_blocked_products_{name}", "blocked_products", [column])
def downgrade():
for name in ("input", "field", "blocker_type", "gtin", "product", "company", "tenant"):
op.drop_index(f"ix_blocked_products_{name}", table_name="blocked_products")
op.drop_table("blocked_products")
@@ -0,0 +1,101 @@
"""Add the operational control-plane records."""
from alembic import op
import sqlalchemy as sa
revision = "0017_control_plane_foundation"
down_revision = "0016_blocked_product_registry"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"installations",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("agent_id", sa.Integer(), sa.ForeignKey("agents.id"), nullable=False),
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id"), nullable=False),
sa.Column("installation_id", sa.String(36), nullable=False),
sa.Column("display_name", sa.String(160), nullable=False, server_default=""),
sa.Column("customer_code", sa.String(64)), sa.Column("notes", sa.Text()),
sa.Column("hostname", sa.String(255)), sa.Column("windows_version", sa.String(128)),
sa.Column("machine_fingerprint", sa.String(128)), sa.Column("company_code", sa.String(64)),
sa.Column("company_name", sa.String(160)), sa.Column("company_cnpj_masked", sa.String(24)),
sa.Column("company_uf", sa.String(2)), sa.Column("company_crt", sa.String(16)),
sa.Column("firebird_version", sa.String(64)), sa.Column("firebird_ods", sa.String(32)),
sa.Column("database_identifier", sa.String(255)), sa.Column("capabilities", sa.JSON(), nullable=False, server_default="{}"),
sa.Column("fiscal_engine_version", sa.String(64)), sa.Column("dataset_versions", sa.JSON(), nullable=False, server_default="{}"),
sa.Column("started_at", sa.DateTime(timezone=True)), sa.Column("last_seen_at", sa.DateTime(timezone=True)),
sa.Column("last_status", sa.String(32)), sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.UniqueConstraint("installation_id", name="uq_installation_identity"),
sa.UniqueConstraint("agent_id", name="uq_installation_agent"),
)
op.create_index("ix_installations_agent_id", "installations", ["agent_id"])
op.create_index("ix_installations_tenant_id", "installations", ["tenant_id"])
op.create_index("ix_installations_hostname", "installations", ["hostname"])
op.create_index("ix_installations_customer_code", "installations", ["customer_code"])
op.create_index("ix_installations_last_seen_at", "installations", ["last_seen_at"])
op.create_index("ix_installations_installation_id", "installations", ["installation_id"])
op.create_table(
"agent_snapshots",
sa.Column("id", sa.Integer(), primary_key=True), sa.Column("installation_id", sa.Integer(), sa.ForeignKey("installations.id"), nullable=False),
sa.Column("total_products", sa.Integer(), nullable=False, server_default="0"), sa.Column("fiscally_complete", sa.Integer(), nullable=False, server_default="0"),
sa.Column("safe_blocked", sa.Integer(), nullable=False, server_default="0"), sa.Column("failed_products", sa.Integer(), nullable=False, server_default="0"),
sa.Column("coverage_percent", sa.Float(), nullable=False, server_default="0"), sa.Column("blockers", sa.JSON(), nullable=False, server_default="{}"),
sa.Column("last_job_status", sa.String(16)), sa.Column("last_job_at", sa.DateTime(timezone=True)), sa.Column("last_successful_job_at", sa.DateTime(timezone=True)),
sa.Column("agent_health", sa.String(16), nullable=False, server_default="UNKNOWN"), sa.Column("health_reason", sa.Text()),
sa.Column("version_drift", sa.String(16), nullable=False, server_default="UNKNOWN"), sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.UniqueConstraint("installation_id", name="uq_agent_snapshot_installation"),
)
for name, column in (("installation", "installation_id"), ("health", "agent_health"), ("version_drift", "version_drift"), ("updated", "updated_at")):
op.create_index(f"ix_agent_snapshots_{name}", "agent_snapshots", [column])
op.create_table(
"agent_events",
sa.Column("id", sa.Integer(), primary_key=True), sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id"), nullable=False),
sa.Column("installation_id", sa.Integer(), sa.ForeignKey("installations.id"), nullable=False), sa.Column("agent_id", sa.Integer(), sa.ForeignKey("agents.id"), nullable=False),
sa.Column("severity", sa.String(16), nullable=False), sa.Column("event_type", sa.String(48), nullable=False), sa.Column("message", sa.Text(), nullable=False),
sa.Column("structured_context", sa.JSON(), nullable=False, server_default="{}"), sa.Column("job_id", sa.Integer(), sa.ForeignKey("jobs.id")),
sa.Column("product_code", sa.String(64)), sa.Column("blocker_id", sa.Integer(), sa.ForeignKey("blocked_products.id")), sa.Column("is_audit", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("occurred_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
for name, column in (("tenant", "tenant_id"), ("installation", "installation_id"), ("agent", "agent_id"), ("severity", "severity"), ("event_type", "event_type"), ("job", "job_id"), ("product", "product_code"), ("blocker", "blocker_id"), ("audit", "is_audit"), ("occurred", "occurred_at")):
op.create_index(f"ix_agent_events_{name}", "agent_events", [column])
op.create_table(
"admin_users",
sa.Column("id", sa.Integer(), primary_key=True), sa.Column("username", sa.String(128), nullable=False, unique=True),
sa.Column("password_hash", sa.String(255), nullable=False), sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), sa.Column("last_login_at", sa.DateTime(timezone=True)),
)
op.create_index("ix_admin_users_username", "admin_users", ["username"])
op.create_table(
"web_sessions",
sa.Column("id", sa.Integer(), primary_key=True), sa.Column("admin_user_id", sa.Integer(), sa.ForeignKey("admin_users.id"), nullable=False),
sa.Column("token_hash", sa.String(64), nullable=False, unique=True), sa.Column("csrf_hash", sa.String(64), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("last_seen_at", sa.DateTime(timezone=True), server_default=sa.func.now()), sa.Column("user_agent", sa.String(512)), sa.Column("remote_addr", sa.String(64)),
)
op.create_index("ix_web_sessions_admin_user_id", "web_sessions", ["admin_user_id"])
op.create_index("ix_web_sessions_token_hash", "web_sessions", ["token_hash"])
op.create_index("ix_web_sessions_expires_at", "web_sessions", ["expires_at"])
op.create_table(
"audit_events",
sa.Column("id", sa.Integer(), primary_key=True), sa.Column("admin_user_id", sa.Integer(), sa.ForeignKey("admin_users.id")),
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id")), sa.Column("action", sa.String(64), nullable=False),
sa.Column("target_type", sa.String(64), nullable=False), sa.Column("target_id", sa.String(128), nullable=False), sa.Column("context", sa.JSON(), nullable=False, server_default="{}"),
sa.Column("severity", sa.String(16), nullable=False, server_default="INFO"), sa.Column("occurred_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
for name, column in (("admin", "admin_user_id"), ("tenant", "tenant_id"), ("action", "action"), ("occurred", "occurred_at")):
op.create_index(f"ix_audit_events_{name}", "audit_events", [column])
def downgrade():
op.drop_table("audit_events")
op.drop_table("web_sessions")
op.drop_table("admin_users")
op.drop_table("agent_events")
op.drop_table("agent_snapshots")
op.drop_table("installations")
@@ -0,0 +1,20 @@
"""Associate new blocker rows with their authenticated installation."""
from alembic import op
import sqlalchemy as sa
revision = "0018_blocker_installation"
down_revision = "0017_control_plane_foundation"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("blocked_products", sa.Column("installation_id", sa.Integer(), sa.ForeignKey("installations.id"), nullable=True))
op.create_index("ix_blocked_products_installation_id", "blocked_products", ["installation_id"])
def downgrade():
op.drop_index("ix_blocked_products_installation_id", table_name="blocked_products")
op.drop_column("blocked_products", "installation_id")
@@ -0,0 +1,43 @@
"""Fix frozen timestamp defaults from 0006 and add model indexes never migrated.
0006 declared ``server_default="now()"`` as a plain string. SQLAlchemy renders
that as the literal ``'now()'``, which PostgreSQL casts to a timestamp once,
when the column is created — every later row got that same fixed instant
instead of the insert time (agents.created_at/updated_at, jobs.created_at).
The models rely on the server default, so Jobs and Agents registered after
0006 all carried the migration's timestamp. Existing rows are left as they
are: their real creation time is not recoverable.
The models also declare ``index=True`` on three columns that no migration
ever indexed.
Revision ID: 0019_fix_timestamp_defaults
Revises: 0018_blocker_installation
"""
from alembic import op
import sqlalchemy as sa
revision = "0019_fix_timestamp_defaults"
down_revision = "0018_blocker_installation"
branch_labels = None
depends_on = None
_TIMESTAMP_COLUMNS = (("agents", "created_at"), ("agents", "updated_at"), ("jobs", "created_at"))
_MISSING_INDEXES = (
("ix_kb_observations_tenant_id", "kb_observations", "tenant_id"),
("ix_kb_observations_company_key", "kb_observations", "company_key"),
("ix_tenant_product_context_company_key", "tenant_product_context", "company_key"),
)
def upgrade():
for table, column in _TIMESTAMP_COLUMNS:
op.alter_column(table, column, server_default=sa.func.now(), existing_type=sa.DateTime(timezone=True), existing_nullable=False)
for name, table, column in _MISSING_INDEXES:
op.create_index(name, table, [column])
def downgrade():
# The frozen-timestamp defaults are a bug and are deliberately not restored.
for name, table, _column in reversed(_MISSING_INDEXES):
op.drop_index(name, table_name=table)
@@ -0,0 +1,63 @@
"""Enforce NOT NULL on server-defaulted timestamps the models already require.
0011, 0012, 0014 and 0017 created these columns with ``server_default=now()``
but without ``nullable=False``, so the database accepted NULL while the models
declare them non-optional. No application path writes NULL to them (inserts
omit them and rely on the default; updates set ``now``), and before this
revision was written no NULL existed in production or in the development
database.
The upgrade refuses to run — changing nothing — if any NULL is found, instead
of guessing a value for it.
Revision ID: 0020_not_null_timestamps
Revises: 0019_fix_timestamp_defaults
"""
from alembic import op
import sqlalchemy as sa
revision = "0020_not_null_timestamps"
down_revision = "0019_fix_timestamp_defaults"
branch_labels = None
depends_on = None
COLUMNS = (
("admin_users", "created_at"),
("agent_events", "occurred_at"),
("agent_snapshots", "updated_at"),
("ai_eval_cache", "created_at"),
("ai_runs", "started_at"),
("ai_work_items", "created_at"),
("ai_work_items", "updated_at"),
("audit_events", "occurred_at"),
("fiscal_context_entries", "created_at"),
("fiscal_context_entries", "updated_at"),
("installations", "created_at"),
("installations", "updated_at"),
("kb_knowledge", "created_at"),
("kb_observations", "observed_at"),
("kb_products", "created_at"),
("kb_products", "updated_at"),
("tenant_product_context", "created_at"),
("tenant_product_context", "updated_at"),
("web_sessions", "created_at"),
("web_sessions", "last_seen_at"),
)
def upgrade():
bind = op.get_bind()
offenders = []
for table, column in COLUMNS:
nulls = bind.execute(sa.text(f'SELECT count(*) FROM "{table}" WHERE "{column}" IS NULL')).scalar()
if nulls:
offenders.append(f"{table}.{column}={nulls}")
if offenders:
raise RuntimeError("0020 aborted, NULL values present (fix them explicitly first): " + ", ".join(offenders))
for table, column in COLUMNS:
op.alter_column(table, column, nullable=False, existing_type=sa.DateTime(timezone=True), existing_server_default=sa.func.now())
def downgrade():
for table, column in reversed(COLUMNS):
op.alter_column(table, column, nullable=True, existing_type=sa.DateTime(timezone=True), existing_server_default=sa.func.now())
@@ -0,0 +1,59 @@
"""Federated identity for SSO from GTI Gerenciador.
admin_users stays the single human principal (web_sessions and audit_events
already point at it). Existing rows become auth_source='LOCAL' (the
break-glass admin keeps its password). Collaborators provisioned by SSO are
auth_source='GTI_GERENCIADOR': no password at all, identified by the panel's
immutable subject. The CHECK constraint makes a passwordless LOCAL account,
or a federated account with a password, impossible.
sso_used_assertions keeps every accepted assertion id (jti) so the same
assertion can never be used twice.
Revision ID: 0021_sso_federated_identity
Revises: 0020_not_null_timestamps
"""
from alembic import op
import sqlalchemy as sa
revision = "0021_sso_federated_identity"
down_revision = "0020_not_null_timestamps"
branch_labels = None
depends_on = None
AUTH_SOURCE_CHECK = (
"(auth_source = 'LOCAL' AND password_hash IS NOT NULL AND external_subject IS NULL)"
" OR (auth_source = 'GTI_GERENCIADOR' AND password_hash IS NULL AND external_subject IS NOT NULL)"
)
def upgrade():
op.add_column("admin_users", sa.Column("auth_source", sa.String(16), nullable=False, server_default="LOCAL"))
op.add_column("admin_users", sa.Column("external_subject", sa.String(128), nullable=True))
op.add_column("admin_users", sa.Column("display_name", sa.String(160), nullable=True))
op.add_column("admin_users", sa.Column("external_role", sa.String(32), nullable=True))
op.add_column("admin_users", sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False))
op.alter_column("admin_users", "password_hash", existing_type=sa.String(255), nullable=True)
op.create_unique_constraint("uq_admin_users_external_subject", "admin_users", ["external_subject"])
op.create_check_constraint("ck_admin_users_auth_source", "admin_users", AUTH_SOURCE_CHECK)
op.create_table(
"sso_used_assertions",
sa.Column("jti", sa.String(64), primary_key=True),
sa.Column("subject", sa.String(128), nullable=False),
sa.Column("used_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_sso_used_assertions_expires_at", "sso_used_assertions", ["expires_at"])
def downgrade():
bind = op.get_bind()
if bind.execute(sa.text("SELECT count(*) FROM admin_users WHERE auth_source <> 'LOCAL'")).scalar():
raise RuntimeError("0021 downgrade refused: federated users exist (their sessions/audit rows reference them)")
op.drop_index("ix_sso_used_assertions_expires_at", table_name="sso_used_assertions")
op.drop_table("sso_used_assertions")
op.drop_constraint("ck_admin_users_auth_source", "admin_users", type_="check")
op.drop_constraint("uq_admin_users_external_subject", "admin_users", type_="unique")
op.alter_column("admin_users", "password_hash", existing_type=sa.String(255), nullable=False)
for column in ("updated_at", "external_role", "display_name", "external_subject", "auth_source"):
op.drop_column("admin_users", column)
@@ -0,0 +1,88 @@
"""Agent distribution: releases + secure enrollment.
agent_releases catalogs published builds of the universal Windows Agent
package (the binary itself lives in local release storage, never in
Postgres). agent_enrollments tracks one pending-or-completed installation
per row, carrying two distinct hashed, single-use, short-TTL secrets: a
download token (the shareable package link) and an enrollment token
(embedded in the downloaded bundle, consumed once by POST /v1/agents/enroll).
Neither secret is ever stored in plaintext.
Revision ID: 0022_agent_releases_enrollment
Revises: 0021_sso_federated_identity
"""
from alembic import op
import sqlalchemy as sa
revision = "0022_agent_releases_enrollment"
down_revision = "0021_sso_federated_identity"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"agent_releases",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("version", sa.String(32), nullable=False),
sa.Column("platform", sa.String(16), nullable=False, server_default="windows"),
sa.Column("architecture", sa.String(16), nullable=False, server_default="x64"),
sa.Column("filename", sa.String(255), nullable=False),
sa.Column("size_bytes", sa.Integer(), nullable=False),
sa.Column("sha256", sa.String(64), nullable=False),
sa.Column("storage_path", sa.String(512), nullable=False),
sa.Column("status", sa.String(16), nullable=False, server_default="DRAFT"),
sa.Column("release_notes", sa.Text(), nullable=True),
sa.Column("created_by", sa.Integer(), sa.ForeignKey("admin_users.id"), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
sa.UniqueConstraint("version", "platform", "architecture", name="uq_agent_release_version"),
)
op.create_index("ix_agent_releases_version", "agent_releases", ["version"])
op.create_index("ix_agent_releases_sha256", "agent_releases", ["sha256"])
op.create_index("ix_agent_releases_status", "agent_releases", ["status"])
op.create_table(
"agent_enrollments",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("enrollment_uuid", sa.String(36), nullable=False),
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id"), nullable=False),
sa.Column("display_name", sa.String(160), nullable=False),
sa.Column("expected_hostname", sa.String(255), nullable=True),
sa.Column("release_id", sa.Integer(), sa.ForeignKey("agent_releases.id"), nullable=False),
sa.Column("status", sa.String(16), nullable=False, server_default="PENDING"),
sa.Column("created_by", sa.Integer(), sa.ForeignKey("admin_users.id"), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("download_token_hash", sa.String(64), nullable=True),
sa.Column("download_token_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("download_max_uses", sa.Integer(), nullable=False, server_default="1"),
sa.Column("download_uses", sa.Integer(), nullable=False, server_default="0"),
sa.Column("downloaded_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("enrollment_token_hash", sa.String(64), nullable=True),
sa.Column("enrollment_token_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("enrollment_used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("machine_fingerprint_seen", sa.String(128), nullable=True),
sa.Column("hostname_seen", sa.String(255), nullable=True),
sa.Column("hostname_mismatch", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("agent_id", sa.Integer(), sa.ForeignKey("agents.id"), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.UniqueConstraint("enrollment_uuid", name="uq_agent_enrollment_uuid"),
)
op.create_index("ix_agent_enrollments_uuid", "agent_enrollments", ["enrollment_uuid"])
op.create_index("ix_agent_enrollments_tenant", "agent_enrollments", ["tenant_id"])
op.create_index("ix_agent_enrollments_release", "agent_enrollments", ["release_id"])
op.create_index("ix_agent_enrollments_status", "agent_enrollments", ["status"])
op.create_index("ix_agent_enrollments_agent", "agent_enrollments", ["agent_id"])
def downgrade():
op.drop_index("ix_agent_enrollments_agent", table_name="agent_enrollments")
op.drop_index("ix_agent_enrollments_status", table_name="agent_enrollments")
op.drop_index("ix_agent_enrollments_release", table_name="agent_enrollments")
op.drop_index("ix_agent_enrollments_tenant", table_name="agent_enrollments")
op.drop_index("ix_agent_enrollments_uuid", table_name="agent_enrollments")
op.drop_table("agent_enrollments")
op.drop_index("ix_agent_releases_status", table_name="agent_releases")
op.drop_index("ix_agent_releases_sha256", table_name="agent_releases")
op.drop_index("ix_agent_releases_version", table_name="agent_releases")
op.drop_table("agent_releases")
@@ -0,0 +1,34 @@
"""Installation code (install_code) for the Windows installer EXE flow.
agent_enrollments gains the install-code columns used by the universal EXE
installer: the code is typed by the operator into Setup.exe and resolves the
installation on the Hub. Only the SHA-256 of the normalized code is ever
stored (install_code_hash); install_code_expires_at bounds its life and
install_code_created_at records when it was generated (rotation rewrites
these). install_code_attempts counts failed enrollment tries to support the
per-installation rate limit. No plaintext code column exists.
Revision ID: 0023_install_codes
Revises: 0022_agent_releases_enrollment
"""
from alembic import op
import sqlalchemy as sa
revision = "0023_install_codes"
down_revision = "0022_agent_releases_enrollment"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("agent_enrollments", sa.Column("install_code_hash", sa.String(64), nullable=True))
op.add_column("agent_enrollments", sa.Column("install_code_expires_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("agent_enrollments", sa.Column("install_code_attempts", sa.Integer(), nullable=False, server_default="0"))
op.add_column("agent_enrollments", sa.Column("install_code_created_at", sa.DateTime(timezone=True), nullable=True))
def downgrade():
op.drop_column("agent_enrollments", "install_code_created_at")
op.drop_column("agent_enrollments", "install_code_attempts")
op.drop_column("agent_enrollments", "install_code_expires_at")
op.drop_column("agent_enrollments", "install_code_hash")
@@ -0,0 +1,35 @@
"""Tenant mirror identity from GTI Gerenciador.
GTI Gerenciador (the panel) becomes the source of truth for clients.
tenants gains source ('LOCAL' | 'GTI_GERENCIADOR'), external_client_id
(Gerenciador's CLIENTE.ID), external_code, active and synced_at. Existing
rows (pre-dating this sync) become source='LOCAL' and are never offered for
a new installation, but keep every foreign key that already points at them.
Revision ID: 0024_tenant_gerenciador_mirror
Revises: 0023_install_codes
"""
from alembic import op
import sqlalchemy as sa
revision = "0024_tenant_gerenciador_mirror"
down_revision = "0023_install_codes"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("tenants", sa.Column("source", sa.String(16), nullable=False, server_default="LOCAL"))
op.add_column("tenants", sa.Column("external_client_id", sa.Integer(), nullable=True))
op.add_column("tenants", sa.Column("external_code", sa.String(16), nullable=True))
op.add_column("tenants", sa.Column("active", sa.Boolean(), nullable=False, server_default="true"))
op.add_column("tenants", sa.Column("synced_at", sa.DateTime(timezone=True), nullable=True))
op.create_index("ix_tenants_external_client_id", "tenants", ["external_client_id"])
op.create_unique_constraint("uq_tenant_external_identity", "tenants", ["source", "external_client_id"])
def downgrade():
op.drop_constraint("uq_tenant_external_identity", "tenants", type_="unique")
op.drop_index("ix_tenants_external_client_id", table_name="tenants")
for column in ("synced_at", "active", "external_code", "external_client_id", "source"):
op.drop_column("tenants", column)
@@ -0,0 +1,20 @@
"""Tenant.external_cnpj (digits) mirrored from GTI Gerenciador for the client selector search.
Revision ID: 0025_tenant_external_cnpj
Revises: 0024_tenant_gerenciador_mirror
"""
from alembic import op
import sqlalchemy as sa
revision = "0025_tenant_external_cnpj"
down_revision = "0024_tenant_gerenciador_mirror"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("tenants", sa.Column("external_cnpj", sa.String(20), nullable=True))
def downgrade():
op.drop_column("tenants", "external_cnpj")
@@ -0,0 +1,24 @@
"""Central processing control + sync bookkeeping per installation.
Revision ID: 0026_central_processing_control
Revises: 0025_tenant_external_cnpj
"""
from alembic import op
import sqlalchemy as sa
revision = "0026_central_processing_control"
down_revision = "0025_tenant_external_cnpj"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("installations", sa.Column("processing_enabled", sa.Boolean(), nullable=False, server_default="true"))
op.add_column("installations", sa.Column("sync_interval_seconds", sa.Integer(), nullable=False, server_default="1800"))
op.add_column("installations", sa.Column("last_sync_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("installations", sa.Column("last_sync_changed", sa.Integer(), nullable=True))
def downgrade():
for column in ("last_sync_changed", "last_sync_at", "sync_interval_seconds", "processing_enabled"):
op.drop_column("installations", column)
@@ -0,0 +1,42 @@
"""RTC auto-apply: per-installation authorization (default OFF) + immutable write audit.
Revision ID: 0027_rtc_auto_apply
Revises: 0026_central_processing_control
"""
from alembic import op
import sqlalchemy as sa
revision = "0027_rtc_auto_apply"
down_revision = "0026_central_processing_control"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("installations", sa.Column("rtc_auto_apply_authorized", sa.Boolean(), nullable=False, server_default="false"))
op.create_table(
"rtc_write_audits",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id"), nullable=False, index=True),
sa.Column("installation_id", sa.Integer(), sa.ForeignKey("installations.id"), nullable=False, index=True),
sa.Column("agent_id", sa.Integer(), sa.ForeignKey("agents.id"), nullable=False, index=True),
sa.Column("job_id", sa.Integer(), nullable=True, index=True),
sa.Column("client", sa.String(255), nullable=True),
sa.Column("product_code", sa.String(64), nullable=False, index=True),
sa.Column("field", sa.String(32), nullable=False),
sa.Column("outcome", sa.String(16), nullable=False, index=True),
sa.Column("reason", sa.String(96), nullable=True),
sa.Column("old_value", sa.String(64), nullable=True),
sa.Column("new_value", sa.String(64), nullable=True),
sa.Column("rule_id", sa.String(32), nullable=True),
sa.Column("evidence", sa.JSON(), nullable=False, server_default="{}"),
sa.Column("official_source_version", sa.JSON(), nullable=False, server_default="{}"),
sa.Column("local_catalog_hash", sa.String(64), nullable=True),
sa.Column("written_at", sa.String(40), nullable=True),
sa.Column("recorded_at", sa.DateTime(timezone=True), server_default=sa.func.now(), index=True),
)
def downgrade():
op.drop_table("rtc_write_audits")
op.drop_column("installations", "rtc_auto_apply_authorized")
@@ -0,0 +1,47 @@
"""RTC central write modes per installation, candidate preview/approval table, richer audit + rollback tracking.
Revision ID: 0028_rtc_write_modes
Revises: 0027_rtc_auto_apply
"""
from alembic import op
import sqlalchemy as sa
revision = "0028_rtc_write_modes"
down_revision = "0027_rtc_auto_apply"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("installations", sa.Column("rtc_write_mode", sa.String(24), nullable=False, server_default="DISABLED"))
op.execute("UPDATE installations SET rtc_write_mode = 'ENABLED' WHERE rtc_auto_apply_authorized") # keeps an existing explicit authorization
for name, kind in (("rule_version", sa.String(32)), ("company_crt", sa.String(8)), ("authorization_origin", sa.String(48)), ("description_snapshot", sa.String(255)),
("installation_uuid", sa.String(64)), ("rtc_status", sa.String(24)), ("action", sa.String(16)), ("rollback_state", sa.String(16)), ("rollback_of", sa.Integer()),
("local_catalog_state", sa.String(32))):
op.add_column("rtc_write_audits", sa.Column(name, kind, nullable=True))
op.create_index("ix_rtc_write_audits_rollback_of", "rtc_write_audits", ["rollback_of"])
op.create_table(
"rtc_candidates",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id"), nullable=False, index=True),
sa.Column("installation_id", sa.Integer(), sa.ForeignKey("installations.id"), nullable=False, index=True),
sa.Column("product_code", sa.String(64), nullable=False),
sa.Column("description", sa.String(255), nullable=True),
sa.Column("ncm", sa.String(16), nullable=True),
sa.Column("current_value", sa.String(16), nullable=True),
sa.Column("proposed", sa.String(16), nullable=False),
sa.Column("cst_ibscbs", sa.String(8), nullable=True),
sa.Column("rule_id", sa.String(32), nullable=False),
sa.Column("basis", sa.String(255), nullable=True),
sa.Column("status", sa.String(16), nullable=False, server_default="PENDING", index=True),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.UniqueConstraint("installation_id", "product_code", name="uq_rtc_candidate_product"),
)
def downgrade():
op.drop_table("rtc_candidates")
op.drop_index("ix_rtc_write_audits_rollback_of", table_name="rtc_write_audits")
for name in ("rule_version", "company_crt", "authorization_origin", "description_snapshot", "installation_uuid", "rtc_status", "action", "rollback_state", "rollback_of", "local_catalog_state"):
op.drop_column("rtc_write_audits", name)
op.drop_column("installations", "rtc_write_mode")
@@ -0,0 +1,140 @@
"""Per-installation functions (corrector / enrichment mode), KB field status, enrichment state + audit, provider state/usage/cache, official source refresh log.
Revision ID: 0029_product_enrichment
Revises: 0028_rtc_write_modes
"""
from alembic import op
import sqlalchemy as sa
revision = "0029_product_enrichment"
down_revision = "0028_rtc_write_modes"
branch_labels = None
depends_on = None
def _idx(table, *cols):
for col in cols:
op.create_index(f"ix_{table}_{col}", table, [col])
def upgrade():
# existing installations keep behaving exactly as before: corrector on, functions already configured
op.add_column("installations", sa.Column("corrector_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")))
op.add_column("installations", sa.Column("enrichment_mode", sa.String(16), nullable=False, server_default="DISABLED"))
op.add_column("installations", sa.Column("features_configured", sa.Boolean(), nullable=False, server_default=sa.text("true")))
op.add_column("agent_enrollments", sa.Column("features_selected", sa.Boolean(), nullable=False, server_default=sa.text("false")))
op.add_column("agent_enrollments", sa.Column("feature_corrector", sa.Boolean(), nullable=False, server_default=sa.text("false")))
op.add_column("agent_enrollments", sa.Column("feature_enrichment_mode", sa.String(16), nullable=False, server_default="DISABLED"))
op.add_column("kb_knowledge", sa.Column("status", sa.String(16), nullable=False, server_default="EVIDENCE_ONLY"))
op.create_index("ix_kb_knowledge_status", "kb_knowledge", ["status"])
op.create_table(
"product_enrichments",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id"), nullable=False),
sa.Column("installation_id", sa.Integer(), sa.ForeignKey("installations.id"), nullable=False),
sa.Column("product_code", sa.String(64), nullable=False),
sa.Column("gtin", sa.String(16), nullable=True),
sa.Column("description", sa.String(255), nullable=True),
sa.Column("status", sa.String(32), nullable=False, server_default="PENDING"),
sa.Column("decisions", sa.JSON(), nullable=False),
sa.Column("fields_applied", sa.JSON(), nullable=False),
sa.Column("human_overridden", sa.JSON(), nullable=False),
sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"),
sa.Column("next_attempt_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("latency_ms", sa.Integer(), nullable=True),
sa.Column("detected_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.UniqueConstraint("installation_id", "product_code", name="uq_product_enrichment"),
)
_idx("product_enrichments", "tenant_id", "installation_id", "gtin", "status", "next_attempt_at")
op.create_table(
"product_field_audits",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id"), nullable=False),
sa.Column("installation_id", sa.Integer(), sa.ForeignKey("installations.id"), nullable=False),
sa.Column("agent_id", sa.Integer(), sa.ForeignKey("agents.id"), nullable=False),
sa.Column("product_code", sa.String(64), nullable=False),
sa.Column("gtin", sa.String(16), nullable=True),
sa.Column("description_snapshot", sa.String(255), nullable=True),
sa.Column("field", sa.String(48), nullable=False),
sa.Column("old_value", sa.String(64), nullable=True),
sa.Column("new_value", sa.String(64), nullable=True),
sa.Column("rule", sa.String(64), nullable=True),
sa.Column("kb_status", sa.String(16), nullable=True),
sa.Column("evidence", sa.JSON(), nullable=False),
sa.Column("dataset_versions", sa.JSON(), nullable=False),
sa.Column("authorization_origin", sa.String(48), nullable=True),
sa.Column("job_id", sa.Integer(), nullable=True),
sa.Column("outcome", sa.String(24), nullable=False, server_default="APPLIED"),
sa.Column("reason", sa.String(96), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
_idx("product_field_audits", "tenant_id", "installation_id", "agent_id", "product_code")
op.create_table(
"provider_states",
sa.Column("provider", sa.String(32), primary_key=True),
sa.Column("configured", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("status", sa.String(16), nullable=False, server_default="NOT_CONFIGURED"),
sa.Column("quota_kind", sa.String(16), nullable=False, server_default="DOCUMENTED_RATE"),
sa.Column("daily_limit", sa.Integer(), nullable=True),
sa.Column("remaining", sa.Integer(), nullable=True),
sa.Column("reset_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("rate_per_minute", sa.Integer(), nullable=True),
sa.Column("circuit_open_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("consecutive_failures", sa.Integer(), nullable=False, server_default="0"),
sa.Column("last_request_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_success_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_error", sa.String(160), nullable=True),
sa.Column("last_error_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_latency_ms", sa.Integer(), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_table(
"provider_usage_daily",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("provider", sa.String(32), nullable=False),
sa.Column("day", sa.String(10), nullable=False),
*[sa.Column(name, sa.Integer(), nullable=False, server_default="0") for name in ("requests", "success", "not_found", "errors", "rate_limited", "timeouts", "cache_hits", "cache_misses")],
sa.UniqueConstraint("provider", "day", name="uq_provider_usage_day"),
)
_idx("provider_usage_daily", "provider", "day")
op.create_table(
"gtin_lookup_cache",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("gtin", sa.String(16), nullable=False),
sa.Column("provider", sa.String(32), nullable=False),
sa.Column("found", sa.Boolean(), nullable=False),
sa.Column("data", sa.JSON(), nullable=False),
sa.Column("fetched_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint("gtin", "provider", name="uq_gtin_lookup_cache"),
)
_idx("gtin_lookup_cache", "gtin", "expires_at")
op.create_table(
"source_refresh_runs",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("source", sa.String(32), nullable=False),
sa.Column("source_url", sa.Text(), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("status", sa.String(16), nullable=False, server_default="RUNNING"),
sa.Column("sha256", sa.String(64), nullable=True),
sa.Column("version_label", sa.String(64), nullable=True),
sa.Column("record_count", sa.Integer(), nullable=True),
sa.Column("error", sa.String(300), nullable=True),
)
_idx("source_refresh_runs", "source")
def downgrade():
for table in ("source_refresh_runs", "gtin_lookup_cache", "provider_usage_daily", "provider_states", "product_field_audits", "product_enrichments"):
op.drop_table(table)
op.drop_index("ix_kb_knowledge_status", table_name="kb_knowledge")
op.drop_column("kb_knowledge", "status")
for name in ("feature_enrichment_mode", "feature_corrector", "features_selected"):
op.drop_column("agent_enrollments", name)
for name in ("features_configured", "enrichment_mode", "corrector_enabled"):
op.drop_column("installations", name)
@@ -0,0 +1,48 @@
"""KB evidence model: wider per-field status (OBSERVED/CORROBORATED/EXTERNAL_CORROBORATED/...), promotion audit, provider counters for the panel.
Revision ID: 0030_kb_evidence_model
Revises: 0029_product_enrichment
"""
from alembic import op
import sqlalchemy as sa
revision = "0030_kb_evidence_model"
down_revision = "0029_product_enrichment"
branch_labels = None
depends_on = None
def upgrade():
op.alter_column("kb_knowledge", "status", existing_type=sa.String(16), type_=sa.String(24), existing_nullable=False, existing_server_default="EVIDENCE_ONLY")
op.execute("UPDATE kb_knowledge SET status='OBSERVED' WHERE status='EVIDENCE_ONLY'")
op.alter_column("kb_knowledge", "status", server_default="OBSERVED")
op.create_table(
"kb_promotion_audits",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("kb_product_id", sa.Integer(), sa.ForeignKey("kb_products.id"), nullable=False),
sa.Column("gtin", sa.String(16), nullable=True),
sa.Column("field", sa.String(24), nullable=False, server_default="ncm"),
sa.Column("value", sa.String(64), nullable=True),
sa.Column("old_status", sa.String(24), nullable=True),
sa.Column("new_status", sa.String(24), nullable=False),
sa.Column("families", sa.JSON(), nullable=False),
sa.Column("sources", sa.JSON(), nullable=False),
sa.Column("official_dataset", sa.JSON(), nullable=False),
sa.Column("rule_version", sa.String(32), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_kb_promotion_audits_kb_product_id", "kb_promotion_audits", ["kb_product_id"])
op.create_index("ix_kb_promotion_audits_gtin", "kb_promotion_audits", ["gtin"])
op.add_column("provider_usage_daily", sa.Column("found", sa.Integer(), nullable=False, server_default="0"))
op.add_column("provider_usage_daily", sa.Column("ncm_found", sa.Integer(), nullable=False, server_default="0"))
op.add_column("provider_usage_daily", sa.Column("kb_hits", sa.Integer(), nullable=False, server_default="0"))
def downgrade():
op.drop_column("provider_usage_daily", "kb_hits")
op.drop_column("provider_usage_daily", "ncm_found")
op.drop_column("provider_usage_daily", "found")
op.drop_table("kb_promotion_audits")
op.execute("UPDATE kb_knowledge SET status='EVIDENCE_ONLY' WHERE status IN ('OBSERVED','CORROBORATED','EXTERNAL_CORROBORATED')")
op.alter_column("kb_knowledge", "status", server_default="EVIDENCE_ONLY")
op.alter_column("kb_knowledge", "status", existing_type=sa.String(24), type_=sa.String(16), existing_nullable=False, existing_server_default="EVIDENCE_ONLY")
@@ -0,0 +1,28 @@
"""Enrichment re-evaluation + visible per-product status (no silent failures).
Revision ID: 0031_enrichment_reevaluate
Revises: 0030_kb_evidence_model
"""
from alembic import op
import sqlalchemy as sa
revision = "0031_enrichment_reevaluate"
down_revision = "0030_kb_evidence_model"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("product_enrichments", sa.Column("reevaluate", sa.Boolean(), nullable=False, server_default=sa.text("false")))
op.add_column("product_enrichments", sa.Column("last_attempt_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("product_enrichments", sa.Column("ui_status", sa.String(32), nullable=True))
op.add_column("product_enrichments", sa.Column("reason_code", sa.String(48), nullable=True))
op.create_index("ix_product_enrichments_reevaluate", "product_enrichments", ["reevaluate"])
op.create_index("ix_product_enrichments_ui_status", "product_enrichments", ["ui_status"])
def downgrade():
op.drop_index("ix_product_enrichments_ui_status", table_name="product_enrichments")
op.drop_index("ix_product_enrichments_reevaluate", table_name="product_enrichments")
for name in ("reason_code", "ui_status", "last_attempt_at", "reevaluate"):
op.drop_column("product_enrichments", name)
@@ -0,0 +1,21 @@
"""Installation.company_contribuinte_ipi: auto-discovered (C000004.CONTRIBUINTE_IPI, same row/query as company_crt) at heartbeat, never
operator-guessed. Closes the fiscal-completion gap where CST_IPI could not be resolved without asking a human for a fact the ERP already proves.
Revision ID: 0032_contribuinte_ipi
Revises: 0031_enrichment_reevaluate
"""
from alembic import op
import sqlalchemy as sa
revision = "0032_contribuinte_ipi"
down_revision = "0031_enrichment_reevaluate"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("installations", sa.Column("company_contribuinte_ipi", sa.String(1), nullable=True))
def downgrade():
op.drop_column("installations", "company_contribuinte_ipi")
@@ -0,0 +1,35 @@
"""Reference Data Registry (Missão 5, Fase 1): additive columns on datasets for licence terms,
vigência, file provenance, parse errors and admin approval tracking. Existing rows/kinds are
untouched - new nullable columns, no data migration, no behavior change for the 9 pre-existing
dataset kinds (their own service.py ingestion methods still auto-activate exactly as before).
Revision ID: 0034_reference_data_registry
Revises: 0032_contribuinte_ipi
Note: migration 0033_cfop_ippt_write_mode exists ONLY on feature/cfop-ippt-write, never merged to
master - this migration chains from 0032 (master's real head), not from 0033.
"""
from alembic import op
import sqlalchemy as sa
revision = "0034_reference_data_registry"
down_revision = "0032_contribuinte_ipi"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("datasets", sa.Column("licence_terms", sa.Text(), nullable=True))
op.add_column("datasets", sa.Column("valid_from", sa.String(32), nullable=True))
op.add_column("datasets", sa.Column("valid_to", sa.String(32), nullable=True))
op.add_column("datasets", sa.Column("file_size_bytes", sa.Integer(), nullable=True))
op.add_column("datasets", sa.Column("raw_file_path", sa.Text(), nullable=True))
op.add_column("datasets", sa.Column("parse_errors", sa.JSON(), nullable=False, server_default="{}"))
op.add_column("datasets", sa.Column("submitted_by", sa.String(128), nullable=True))
op.add_column("datasets", sa.Column("approved_by", sa.String(128), nullable=True))
op.add_column("datasets", sa.Column("approved_at", sa.DateTime(timezone=True), nullable=True))
def downgrade():
for name in ("approved_at", "approved_by", "submitted_by", "parse_errors", "raw_file_path", "file_size_bytes", "valid_to", "valid_from", "licence_terms"):
op.drop_column("datasets", name)
@@ -0,0 +1,39 @@
"""CFOP/IPPT write capability: per-installation write mode (DEFAULT OFF) + append-only audit table.
Revision ID: 0035_cfop_ippt_write_mode
Revises: 0034_reference_data_registry
"""
from alembic import op
import sqlalchemy as sa
revision = "0035_cfop_ippt_write_mode"
down_revision = "0034_reference_data_registry"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("installations", sa.Column("cfop_ippt_write_mode", sa.String(24), nullable=False, server_default="DISABLED"))
op.create_table(
"cfop_ippt_write_audits",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id"), nullable=False, index=True),
sa.Column("installation_id", sa.Integer(), sa.ForeignKey("installations.id"), nullable=False, index=True),
sa.Column("agent_id", sa.Integer(), sa.ForeignKey("agents.id"), nullable=False, index=True),
sa.Column("job_id", sa.Integer(), nullable=True, index=True),
sa.Column("product_code", sa.String(64), nullable=False, index=True),
sa.Column("field", sa.String(16), nullable=False),
sa.Column("outcome", sa.String(16), nullable=False, index=True),
sa.Column("reason", sa.String(96), nullable=True),
sa.Column("old_value", sa.String(16), nullable=True),
sa.Column("new_value", sa.String(16), nullable=True),
sa.Column("action", sa.String(16), nullable=True),
sa.Column("rollback_of", sa.Integer(), nullable=True, index=True),
sa.Column("written_at", sa.String(40), nullable=True),
sa.Column("recorded_at", sa.DateTime(timezone=True), server_default=sa.func.now(), index=True),
)
def downgrade():
op.drop_table("cfop_ippt_write_audits")
op.drop_column("installations", "cfop_ippt_write_mode")