Add 'fiscal/' from commit '102ba84500dbd9655a0471288c3e278652593ca9'
git-subtree-dir: fiscal git-subtree-mainline:d95165fc8fgit-subtree-split:102ba84500
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY central/pyproject.toml /tmp/central/pyproject.toml
|
||||
RUN pip install --no-cache-dir /tmp/central
|
||||
COPY central /app/central
|
||||
ENV PYTHONPATH=/app
|
||||
CMD ["sh", "-c", "alembic -c central/alembic.ini upgrade head && uvicorn central.app.main:app --host 0.0.0.0 --port 8000"]
|
||||
@@ -0,0 +1 @@
|
||||
"""GTI Fiscal Hub package."""
|
||||
@@ -0,0 +1,3 @@
|
||||
[alembic]
|
||||
script_location = central/alembic
|
||||
sqlalchemy.url = postgresql+psycopg://gti@postgres:5432/gti_fiscal
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
"""Fiscal Hub application modules."""
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Public, pre-authentication routes for Agent distribution.
|
||||
|
||||
GET /dl/agent/{token} and POST /v1/agents/enroll are reachable by anyone —
|
||||
same trust level as GET /login. Every other Agent-distribution route
|
||||
(create pending installation, generate a link, direct authenticated
|
||||
download, list/revoke releases) is a control-panel page and lives in
|
||||
web.py, gated the same way as the rest of the panel (admin session + CSRF).
|
||||
|
||||
The enrollment token embedded in a downloaded bundle is generated fresh at
|
||||
download time and its plaintext is never written to storage — only its
|
||||
SHA-256 hash persists, exactly like the download token that gated getting
|
||||
it. Neither is ever the Agent's own permanent Bearer token, which is
|
||||
minted only once, inside a successful POST /v1/agents/enroll.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy import select
|
||||
|
||||
from . import config, enrollment, release_storage
|
||||
from .audit import record_audit
|
||||
from .auth import token_hash as agent_token_hash
|
||||
from .models import Agent, AgentEnrollment, AgentRelease, Installation
|
||||
from .rate_limit import blocked, record_attempt
|
||||
|
||||
|
||||
def client_ip(request: Request) -> str:
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def _aware(value: datetime | None) -> datetime | None:
|
||||
"""SQLite (used by the test suite) drops tzinfo on round-trip even for a
|
||||
DateTime(timezone=True) column; Postgres never does. Normalize either way
|
||||
before comparing against datetime.now(timezone.utc) — same pattern as
|
||||
human_auth.py's session-expiry check."""
|
||||
if value is None or value.tzinfo is not None:
|
||||
return value
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def build_and_serve(session, enr: AgentEnrollment, request: Request, *, actor_admin_id: int | None = None) -> Response:
|
||||
"""Serve the release artifact for the installation — always the same
|
||||
universal release binary (ZIP bundle for legacy token flow, bare EXE for
|
||||
the installer flow). Audit the download; never embed the enrollment token
|
||||
in an EXE download (the operator types a standalone install code instead)."""
|
||||
release = session.get(AgentRelease, enr.release_id)
|
||||
if release is None or release.status != "PUBLISHED":
|
||||
raise enrollment.EnrollmentRejected("RELEASE_UNAVAILABLE")
|
||||
path = release_storage.safe_path_for(release.storage_path)
|
||||
if not path.exists():
|
||||
raise enrollment.EnrollmentRejected("RELEASE_FILE_MISSING")
|
||||
now = datetime.now(timezone.utc)
|
||||
is_exe = release.filename.lower().endswith(".exe")
|
||||
if is_exe:
|
||||
# Universal EXE flow: no per-installation bundle, no enrollment.json.
|
||||
# The operator types the standalone install code into Setup.exe and the
|
||||
# Hub resolves the installation from the code hash alone.
|
||||
enr.downloaded_at = now
|
||||
if enr.status not in ("ENROLLED", "REVOKED"):
|
||||
enr.status = "DOWNLOADED"
|
||||
record_audit(session, admin_user_id=actor_admin_id, tenant_id=enr.tenant_id, action="INSTALLER_DOWNLOADED",
|
||||
target_type="agent_enrollment", target_id=enr.enrollment_uuid,
|
||||
context={"ip": client_ip(request), "release_version": release.version, "release_id": release.id})
|
||||
session.commit()
|
||||
filename = release.filename
|
||||
return Response(content=path.read_bytes(), media_type="application/octet-stream",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
"X-Content-Type-Options": "nosniff", "Cache-Control": "no-store"})
|
||||
token = enrollment.new_token()
|
||||
enr.enrollment_token_hash = enrollment.token_digest(token)
|
||||
enr.enrollment_token_expires_at = now + timedelta(seconds=config.settings.agent_enrollment_ttl_seconds)
|
||||
enr.enrollment_used_at = None
|
||||
enr.downloaded_at = now
|
||||
if enr.status not in ("ENROLLED", "REVOKED"):
|
||||
enr.status = "DOWNLOADED"
|
||||
plan = enrollment.BundlePlan(hub_url=config.settings.base_url, enrollment_uuid=enr.enrollment_uuid, enrollment_token=token)
|
||||
data = enrollment.build_bundle(path.read_bytes(), plan)
|
||||
record_audit(session, admin_user_id=actor_admin_id, tenant_id=enr.tenant_id, action="PACKAGE_DOWNLOADED",
|
||||
target_type="agent_enrollment", target_id=enr.enrollment_uuid,
|
||||
context={"ip": client_ip(request), "release_version": release.version, "release_id": release.id})
|
||||
session.commit()
|
||||
filename = f"GTIFiscalAgent-{release.version}-{enr.enrollment_uuid[:8]}.zip"
|
||||
return Response(content=data, media_type="application/octet-stream",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
"X-Content-Type-Options": "nosniff", "Cache-Control": "no-store"})
|
||||
|
||||
|
||||
def router(session_factory):
|
||||
routes = APIRouter()
|
||||
|
||||
@routes.get("/dl/agent/{token}")
|
||||
def download_by_link(token: str, request: Request):
|
||||
ip = client_ip(request)
|
||||
if blocked("download", ip):
|
||||
raise HTTPException(429, "too many attempts")
|
||||
with session_factory() as session:
|
||||
candidates = session.scalars(select(AgentEnrollment).where(AgentEnrollment.download_token_hash.is_not(None))).all()
|
||||
enr = next((c for c in candidates if enrollment.token_matches(token, c.download_token_hash)), None)
|
||||
now = datetime.now(timezone.utc)
|
||||
try:
|
||||
if enr is None:
|
||||
raise enrollment.EnrollmentRejected("INVALID_LINK")
|
||||
if enr.status == "REVOKED":
|
||||
raise enrollment.EnrollmentRejected("REVOKED")
|
||||
if not enr.download_token_expires_at or _aware(enr.download_token_expires_at) < now:
|
||||
raise enrollment.EnrollmentRejected("EXPIRED_LINK")
|
||||
if enr.download_uses >= enr.download_max_uses:
|
||||
raise enrollment.EnrollmentRejected("LINK_ALREADY_USED")
|
||||
enr.download_uses += 1
|
||||
if enr.download_uses >= enr.download_max_uses:
|
||||
enr.download_token_hash = None # the link's last remaining use is spent: it dies immediately
|
||||
return build_and_serve(session, enr, request)
|
||||
except enrollment.EnrollmentRejected as rejected:
|
||||
session.rollback()
|
||||
record_attempt("download", ip)
|
||||
record_audit(session, admin_user_id=None, tenant_id=enr.tenant_id if enr else None, action="DOWNLOAD_LINK_REJECTED",
|
||||
target_type="agent_enrollment", target_id=enr.enrollment_uuid if enr else "-",
|
||||
context={"ip": ip, "reason": rejected.reason}, severity="WARNING")
|
||||
session.commit()
|
||||
raise HTTPException(404, "invalid or expired link")
|
||||
|
||||
@routes.post("/v1/agents/enroll")
|
||||
async def enroll(request: Request):
|
||||
ip = client_ip(request)
|
||||
if blocked("enroll", ip):
|
||||
raise HTTPException(429, "too many attempts")
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
payload = {}
|
||||
installation_id = str(payload.get("installation_id") or "")
|
||||
supplied_token = payload.get("enrollment_token")
|
||||
install_code = payload.get("install_code")
|
||||
# Adjustment: install_code and enrollment_token are mutually exclusive —
|
||||
# the EXE flow sends a code only; sending both is a request error.
|
||||
if install_code and (supplied_token or payload.get("installation_id")):
|
||||
record_attempt("enroll", ip)
|
||||
with session_factory() as session:
|
||||
record_audit(session, admin_user_id=None, tenant_id=None, action="ENROLLMENT_REJECTED",
|
||||
target_type="agent_enrollment", target_id="-",
|
||||
context={"ip": ip, "reason": "MIXED_CREDENTIALS"}, severity="WARNING")
|
||||
session.commit()
|
||||
raise HTTPException(400, "MIXED_CREDENTIALS")
|
||||
with session_factory() as session:
|
||||
enr = None
|
||||
try:
|
||||
if install_code:
|
||||
enr = _resolve_by_install_code(session, ip, install_code)
|
||||
else:
|
||||
enr = _resolve_by_legacy_token(session, ip, installation_id, supplied_token)
|
||||
if enr is None:
|
||||
raise enrollment.EnrollmentRejected("INVALID_INSTALLATION")
|
||||
if enr.status == "REVOKED":
|
||||
raise enrollment.EnrollmentRejected("REVOKED")
|
||||
if enr.status == "ENROLLED":
|
||||
raise enrollment.EnrollmentRejected("ALREADY_ENROLLED")
|
||||
release = session.get(AgentRelease, enr.release_id)
|
||||
if release is None or release.status != "PUBLISHED":
|
||||
raise enrollment.EnrollmentRejected("RELEASE_UNAVAILABLE")
|
||||
|
||||
record_audit(session, admin_user_id=None, tenant_id=enr.tenant_id, action="ENROLLMENT_STARTED",
|
||||
target_type="agent_enrollment", target_id=enr.enrollment_uuid,
|
||||
context={"ip": ip, "credential_type": "install_code" if install_code else "enrollment_token"})
|
||||
|
||||
hostname = str(payload.get("hostname") or "")[:255]
|
||||
mismatch = bool(enr.expected_hostname) and bool(hostname) and hostname.lower() != enr.expected_hostname.lower()
|
||||
|
||||
plain_token = secrets.token_urlsafe(32)
|
||||
agent = Agent(tenant_id=enr.tenant_id, name=enr.display_name, token_hash=agent_token_hash(plain_token),
|
||||
enabled=True, agent_version=str(payload.get("agent_version") or "")[:32] or None)
|
||||
session.add(agent)
|
||||
session.flush()
|
||||
session.add(Installation(agent_id=agent.id, tenant_id=enr.tenant_id, installation_id=enr.enrollment_uuid,
|
||||
display_name=enr.display_name, hostname=hostname or None,
|
||||
features_configured=bool(enr.features_selected), corrector_enabled=bool(enr.features_selected and enr.feature_corrector),
|
||||
enrichment_mode=(enr.feature_enrichment_mode if enr.features_selected else "DISABLED")))
|
||||
|
||||
enr.status = "ENROLLED"
|
||||
enr.enrollment_used_at = datetime.now(timezone.utc)
|
||||
enr.enrollment_token_hash = None # replay-proof even inside its TTL window
|
||||
enr.install_code_hash = None # consumed: truly one-time, replay-proof
|
||||
enr.install_code_expires_at = None
|
||||
enr.hostname_seen = hostname or None
|
||||
enr.machine_fingerprint_seen = str(payload.get("machine_fingerprint") or "")[:128] or None
|
||||
enr.hostname_mismatch = mismatch
|
||||
enr.agent_id = agent.id
|
||||
|
||||
record_audit(session, admin_user_id=None, tenant_id=enr.tenant_id, action="ENROLLMENT_SUCCEEDED",
|
||||
target_type="agent_enrollment", target_id=enr.enrollment_uuid,
|
||||
context={"ip": ip, "hostname": hostname, "hostname_mismatch": mismatch, "agent_id": agent.id})
|
||||
record_audit(session, admin_user_id=None, tenant_id=enr.tenant_id, action="AGENT_CREDENTIAL_ISSUED",
|
||||
target_type="agent", target_id=str(agent.id), context={"ip": ip, "installation": enr.enrollment_uuid})
|
||||
session.commit()
|
||||
return {"agent_id": agent.id, "agent_token": plain_token, "tenant_id": enr.tenant_id,
|
||||
"installation_id": enr.enrollment_uuid,
|
||||
"hub_url": config.settings.base_url, "hostname_mismatch": mismatch}
|
||||
except enrollment.EnrollmentRejected as rejected:
|
||||
session.rollback()
|
||||
record_attempt("enroll", ip)
|
||||
reason = rejected.reason
|
||||
if enr is not None:
|
||||
enr.install_code_attempts = (enr.install_code_attempts or 0) + 1
|
||||
record_attempt(f"enroll_code:{enr.enrollment_uuid}", ip)
|
||||
session.commit()
|
||||
record_audit(session, admin_user_id=None, tenant_id=enr.tenant_id if enr else None, action="ENROLLMENT_REJECTED",
|
||||
target_type="agent_enrollment", target_id=enr.enrollment_uuid if enr else (installation_id or "-"),
|
||||
context={"ip": ip, "reason": reason}, severity="WARNING")
|
||||
session.commit()
|
||||
raise HTTPException(401, reason)
|
||||
|
||||
def _resolve_by_install_code(session, ip, code):
|
||||
"""Resolve the Installation by the install_code hash alone. Returns the
|
||||
enrollment or None. Format/unknown codes and expired codes raise
|
||||
EnrollmentRejected. Per-installation and per-IP rate limits apply."""
|
||||
try:
|
||||
digest = enrollment.install_code_digest(code)
|
||||
except ValueError:
|
||||
raise enrollment.EnrollmentRejected("INVALID_INSTALL_CODE")
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.install_code_hash == digest))
|
||||
if enr is None:
|
||||
raise enrollment.EnrollmentRejected("INVALID_INSTALL_CODE")
|
||||
# Per-installation rate limit: a hostile caller guessing codes for one
|
||||
# installation must burn the whole budget even after the code rotates.
|
||||
if blocked(f"enroll_code:{enr.enrollment_uuid}", ip):
|
||||
raise enrollment.EnrollmentRejected("RATE_LIMITED")
|
||||
now = datetime.now(timezone.utc)
|
||||
if not enr.install_code_expires_at or _aware(enr.install_code_expires_at) < now:
|
||||
raise enrollment.EnrollmentRejected("EXPIRED")
|
||||
return enr
|
||||
|
||||
def _resolve_by_legacy_token(session, ip, installation_id, supplied_token):
|
||||
"""Legacy enrollment_token path (published ZIP bundles). Returns the
|
||||
enrollment or raises EnrollmentRejected. Order preserved from the
|
||||
original flow: enrolled-again must read ALREADY_ENROLLED, not
|
||||
INVALID_TOKEN."""
|
||||
if not enrollment.valid_uuid(installation_id):
|
||||
raise enrollment.EnrollmentRejected("INVALID_INSTALLATION")
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == installation_id))
|
||||
if enr is None:
|
||||
raise enrollment.EnrollmentRejected("INVALID_INSTALLATION")
|
||||
if enr.status == "REVOKED":
|
||||
raise enrollment.EnrollmentRejected("REVOKED")
|
||||
if enr.status == "ENROLLED":
|
||||
raise enrollment.EnrollmentRejected("ALREADY_ENROLLED")
|
||||
now = datetime.now(timezone.utc)
|
||||
if not enr.enrollment_token_expires_at or _aware(enr.enrollment_token_expires_at) < now:
|
||||
raise enrollment.EnrollmentRejected("EXPIRED")
|
||||
if not enrollment.token_matches(supplied_token, enr.enrollment_token_hash):
|
||||
raise enrollment.EnrollmentRejected("INVALID_TOKEN")
|
||||
return enr
|
||||
|
||||
return routes
|
||||
@@ -0,0 +1,8 @@
|
||||
"""AI semantic layer V1 — NOT a fiscal authority.
|
||||
|
||||
Rules frozen by the operator contract:
|
||||
- AI only selects among official candidates; it never invents codes.
|
||||
- Cache first: KB exact/fingerprint hits never trigger AI calls.
|
||||
- No AI for origin, ST retention, or company-level decisions.
|
||||
- AI_WRITE_ALLOWED=false in V1: results stay AI_CANDIDATE_VALIDATED.
|
||||
"""
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Offline-safe, ID-correlated provider batch execution."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .provider import ProviderTransportError
|
||||
|
||||
|
||||
@dataclass
|
||||
class BatchRunResult:
|
||||
results_by_work_item_id: dict[str, dict] = field(default_factory=dict)
|
||||
errors: dict[str, str] = field(default_factory=dict)
|
||||
provider_calls: int = 0
|
||||
retries: int = 0
|
||||
usage: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
def run_batches(items, provider, batch_size: int, max_retries: int = 2, *, scenario: str | None = None) -> BatchRunResult:
|
||||
if batch_size <= 0:
|
||||
raise ValueError("batch_size must be positive")
|
||||
if max_retries < 0:
|
||||
raise ValueError("max_retries must not be negative")
|
||||
|
||||
result = BatchRunResult()
|
||||
for start in range(0, len(items), batch_size):
|
||||
batch = list(items[start:start + batch_size])
|
||||
expected_ids = [str(item.work_item_id) for item in batch]
|
||||
answers = None
|
||||
for attempt in range(max_retries + 1):
|
||||
result.provider_calls += 1
|
||||
try:
|
||||
kwargs = {} if scenario is None else {"scenario": scenario}
|
||||
answers = provider.select_batch(batch, **kwargs)
|
||||
break
|
||||
except ProviderTransportError as error:
|
||||
if attempt == max_retries:
|
||||
for work_item_id in expected_ids:
|
||||
result.errors[work_item_id] = str(error)
|
||||
else:
|
||||
result.retries += 1
|
||||
except Exception as error:
|
||||
for work_item_id in expected_ids:
|
||||
result.errors[work_item_id] = str(error)
|
||||
break
|
||||
if answers is None:
|
||||
continue
|
||||
answer_ids = [str(answer.get("work_item_id")) for answer in answers]
|
||||
if len(answer_ids) != len(expected_ids) or set(answer_ids) != set(expected_ids) or len(set(answer_ids)) != len(answer_ids):
|
||||
error = "provider response did not contain exactly one answer per work_item_id"
|
||||
for work_item_id in expected_ids:
|
||||
result.errors[work_item_id] = error
|
||||
continue
|
||||
result.results_by_work_item_id.update({str(answer["work_item_id"]): answer for answer in answers})
|
||||
|
||||
result.usage = dict(getattr(provider, "usage", {}) or {})
|
||||
return result
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Persistent-boundary-compatible budget guard; provider invocation happens after this check."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from threading import Lock
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BudgetLimits:
|
||||
max_run_cost_usd: float = 0.54
|
||||
max_daily_cost_usd: float = 1.00
|
||||
max_monthly_cost_usd: float = 10.00
|
||||
max_requests_per_day: int = 100
|
||||
max_new_products_per_run: int = 750
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BudgetDecision:
|
||||
allowed: bool
|
||||
reason: str
|
||||
reserved_cost_usd: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UsageEntry:
|
||||
run_id: str
|
||||
tenant: str
|
||||
cost_usd: float
|
||||
requests: int
|
||||
new_products: int
|
||||
timestamp: str
|
||||
|
||||
|
||||
class BudgetLedger:
|
||||
"""Atomic in-process ledger with an explicit persistence boundary for Hub storage."""
|
||||
|
||||
def __init__(self, limits: BudgetLimits):
|
||||
self.limits = limits
|
||||
self.entries: list[UsageEntry] = []
|
||||
self._lock = Lock()
|
||||
|
||||
def check_and_reserve(self, run_id: str, tenant: str, cost_usd: float, requests: int, new_products: int) -> BudgetDecision:
|
||||
with self._lock:
|
||||
run_cost = sum(e.cost_usd for e in self.entries if e.run_id == run_id)
|
||||
day = datetime.now(timezone.utc).date()
|
||||
day_entries = [e for e in self.entries if e.timestamp[:10] == day.isoformat()]
|
||||
month_entries = [e for e in self.entries if e.timestamp[:7] == day.strftime("%Y-%m")]
|
||||
if run_cost + cost_usd > self.limits.max_run_cost_usd:
|
||||
return BudgetDecision(False, "AI_BUDGET_EXHAUSTED", 0.0)
|
||||
if sum(e.cost_usd for e in day_entries) + cost_usd > self.limits.max_daily_cost_usd:
|
||||
return BudgetDecision(False, "AI_BUDGET_EXHAUSTED", 0.0)
|
||||
if sum(e.cost_usd for e in month_entries) + cost_usd > self.limits.max_monthly_cost_usd:
|
||||
return BudgetDecision(False, "AI_BUDGET_EXHAUSTED", 0.0)
|
||||
if sum(e.requests for e in day_entries) + requests > self.limits.max_requests_per_day:
|
||||
return BudgetDecision(False, "AI_BUDGET_EXHAUSTED", 0.0)
|
||||
if sum(e.new_products for e in self.entries if e.run_id == run_id) + new_products > self.limits.max_new_products_per_run:
|
||||
return BudgetDecision(False, "AI_BUDGET_EXHAUSTED", 0.0)
|
||||
self.entries.append(UsageEntry(run_id, tenant, cost_usd, requests, new_products, datetime.now(timezone.utc).isoformat()))
|
||||
return BudgetDecision(True, "RESERVED", cost_usd)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Pure cache and dataset-freshness policies for offline and real evaluations."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
|
||||
VALID, NEEDS_REVALIDATION, CONFLICT = "VALID", "NEEDS_REVALIDATION", "CONFLICT"
|
||||
|
||||
|
||||
def cache_key(input_fingerprint, candidate_set_hash, model, prompt_version, dataset_versions):
|
||||
"""Return a stable key covering every input that can change an answer."""
|
||||
payload = {
|
||||
"input_fingerprint": str(input_fingerprint),
|
||||
"candidate_set_hash": str(candidate_set_hash),
|
||||
"model": str(model),
|
||||
"prompt_version": str(prompt_version),
|
||||
"dataset_versions": dict(sorted((dataset_versions or {}).items())),
|
||||
}
|
||||
return hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||
|
||||
|
||||
def classify_knowledge_freshness(record_versions, active_versions):
|
||||
"""Classify versioned knowledge without deleting or rewriting history."""
|
||||
recorded = dict(record_versions or {})
|
||||
active = dict(active_versions or {})
|
||||
if any(key in recorded and key in active and recorded[key] != active[key] for key in recorded):
|
||||
return CONFLICT
|
||||
if recorded != active:
|
||||
return NEEDS_REVALIDATION
|
||||
return VALID
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Deterministic candidate generation from the ACTIVE official datasets.
|
||||
|
||||
The AI never receives an open question: for every task type the Hub first builds
|
||||
the official candidate list (NCM top-K by description, CEST links for the NCM,
|
||||
TIPI EX rows for the NCM). Providers may only pick from this list.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import CestEntry, CestNcmEntry, Dataset, NcmEntry, TipiEntry
|
||||
from .fingerprint import token_stems
|
||||
|
||||
|
||||
def _active_dataset(session: Session, kind: str):
|
||||
return session.scalar(select(Dataset).where(Dataset.kind == kind, Dataset.status == "ACTIVE"))
|
||||
|
||||
|
||||
def ncm_top_k(session: Session, description, k: int = 10):
|
||||
"""Top-K official NCM codes by (stemmed) token overlap with the product description."""
|
||||
dataset = _active_dataset(session, "NCM")
|
||||
if dataset is None:
|
||||
return []
|
||||
stems = token_stems(description)
|
||||
if not stems:
|
||||
return []
|
||||
scored = []
|
||||
for entry in session.scalars(select(NcmEntry).where(NcmEntry.dataset_id == dataset.id)).all():
|
||||
score = len(stems & token_stems(entry.description)) / len(stems)
|
||||
if score > 0:
|
||||
scored.append((entry.code, entry.description, round(score, 4)))
|
||||
scored.sort(key=lambda row: (-row[2], row[0]))
|
||||
return scored[:k]
|
||||
|
||||
|
||||
def cest_candidates_for_ncm(session: Session, ncm: str):
|
||||
dataset = _active_dataset(session, "CEST")
|
||||
if dataset is None:
|
||||
return []
|
||||
rows = session.execute(
|
||||
select(CestEntry, CestNcmEntry.ncm_code)
|
||||
.join(CestNcmEntry, CestNcmEntry.cest_entry_id == CestEntry.id)
|
||||
.where(CestEntry.dataset_id == dataset.id)
|
||||
).all()
|
||||
return list(dict.fromkeys(entry for entry, ncm_code in rows if ncm.startswith(ncm_code)))
|
||||
|
||||
|
||||
def ex_tipi_candidates_for_ncm(session: Session, ncm: str):
|
||||
dataset = _active_dataset(session, "TIPI_IPI")
|
||||
if dataset is None:
|
||||
return []
|
||||
return list(dict.fromkeys(
|
||||
entry for entry in session.scalars(select(TipiEntry).where(TipiEntry.dataset_id == dataset.id, TipiEntry.ncm == ncm, TipiEntry.ex.is_not(None))).all()
|
||||
))
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Metrics and error attribution for the real LLM NCM evaluation (sections 8-9)."""
|
||||
|
||||
|
||||
def metrics(rows) -> dict:
|
||||
"""rows: [{gold_in_candidates, selected, gold, confidence, invalid}]"""
|
||||
total = len(rows)
|
||||
correct = sum(1 for row in rows if not row["invalid"] and row["selected"] == row["gold"])
|
||||
wrong = sum(1 for row in rows if not row["invalid"] and row["selected"] is not None and row["selected"] != row["gold"])
|
||||
abstain = sum(1 for row in rows if not row["invalid"] and row["selected"] is None)
|
||||
invalid = sum(1 for row in rows if row["invalid"])
|
||||
gold_in = sum(1 for row in rows if row["gold_in_candidates"])
|
||||
answered = correct + wrong
|
||||
high_conf = [row for row in rows if not row["invalid"] and row["confidence"] >= 0.95 and row["selected"] is not None]
|
||||
high_conf_wrong = sum(1 for row in high_conf if row["selected"] != row["gold"])
|
||||
return {
|
||||
"TOTAL": total,
|
||||
"CORRECT": correct,
|
||||
"WRONG": wrong,
|
||||
"ABSTAIN": abstain,
|
||||
"INVALID": invalid,
|
||||
"GOLD_IN_CANDIDATES": gold_in,
|
||||
"ACCURACY_ALL": round(correct / total, 4) if total else None,
|
||||
"SELECTION_ACCURACY_WHEN_GOLD_AVAILABLE": round(correct / answered, 4) if answered else None,
|
||||
"PRECISION_WHEN_HIGH_CONFIDENCE": round(sum(1 for row in high_conf if row["selected"] == row["gold"]) / len(high_conf), 4) if high_conf else None,
|
||||
"HIGH_CONFIDENCE_WRONG_RATE": round(high_conf_wrong / len(high_conf), 4) if high_conf else None,
|
||||
"ABSTENTION_RATE": round(abstain / total, 4) if total else None,
|
||||
}
|
||||
|
||||
|
||||
def attribution(rows) -> dict:
|
||||
return {
|
||||
"GOLD_NOT_IN_CANDIDATES": sum(1 for row in rows if not row["gold_in_candidates"]),
|
||||
"GOLD_IN_CANDIDATES_BUT_WRONG": sum(1 for row in rows if row["gold_in_candidates"] and not row["invalid"] and row["selected"] is not None and row["selected"] != row["gold"]),
|
||||
"GOLD_IN_CANDIDATES_BUT_ABSTAIN": sum(1 for row in rows if row["gold_in_candidates"] and not row["invalid"] and row["selected"] is None),
|
||||
"INVALID_OUTPUT": sum(1 for row in rows if row["invalid"]),
|
||||
}
|
||||
|
||||
|
||||
_BUCKETS = ((">=0.99", 0.99, 1.01), ("0.95-0.9899", 0.95, 0.99), ("0.90-0.9499", 0.90, 0.95), ("0.80-0.8999", 0.80, 0.90), ("<0.80", -0.01, 0.80))
|
||||
|
||||
|
||||
def confidence_buckets(rows) -> dict:
|
||||
out = {name: {"total": 0, "correct": 0} for name, _, _ in _BUCKETS}
|
||||
for row in rows:
|
||||
if row["invalid"] or row["selected"] is None:
|
||||
continue
|
||||
for name, low, high in _BUCKETS:
|
||||
if low <= row["confidence"] < high:
|
||||
out[name]["total"] += 1
|
||||
if row["selected"] == row["gold"]:
|
||||
out[name]["correct"] += 1
|
||||
break
|
||||
return out
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Deterministic provider responses for offline pipeline tests."""
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
from .llm_provider import LlmAnswerError, LlmEvalItem, validate_answer
|
||||
from .provider import ProviderContractError, ProviderTransportError
|
||||
|
||||
|
||||
SCENARIOS = frozenset({
|
||||
"CORRECT", "WRONG", "ABSTAIN", "INVALID_JSON", "MISSING_ITEM",
|
||||
"DUPLICATE_ITEM", "OUT_OF_CANDIDATE", "TIMEOUT", "429", "500",
|
||||
})
|
||||
|
||||
|
||||
class DeterministicFakeSemanticProvider:
|
||||
"""Return repeatable answers without persistence or network access."""
|
||||
|
||||
name = "deterministic-fake"
|
||||
model = "deterministic-fake-v1"
|
||||
prompt_version = "fake-provider-v1"
|
||||
|
||||
def __init__(self, *, shuffle: bool = False, fail_work_item_ids: Iterable[str] = (), failure_scenario: str = "TIMEOUT"):
|
||||
self.shuffle = shuffle
|
||||
self.fail_work_item_ids = {str(item_id) for item_id in fail_work_item_ids}
|
||||
self.failure_scenario = failure_scenario
|
||||
self.calls = 0
|
||||
self.usage = {"input_tokens": 0, "output_tokens": 0, "cached_tokens": 0}
|
||||
|
||||
def select_batch(self, items: list[LlmEvalItem], scenario: str | None = None) -> list[dict]:
|
||||
scenario = scenario or "CORRECT"
|
||||
if scenario not in SCENARIOS:
|
||||
raise ValueError(f"unknown fake provider scenario: {scenario}")
|
||||
self.calls += 1
|
||||
if self.fail_work_item_ids and len(items) == 1 and str(items[0].work_item_id) in self.fail_work_item_ids:
|
||||
self._raise_transport(self.failure_scenario)
|
||||
if scenario in {"TIMEOUT", "429", "500"}:
|
||||
self._raise_transport(scenario)
|
||||
if scenario == "INVALID_JSON":
|
||||
raise LlmAnswerError("fake provider returned invalid JSON")
|
||||
|
||||
answers = [self._answer(item, scenario) for item in items]
|
||||
if scenario == "MISSING_ITEM" and answers:
|
||||
answers.pop()
|
||||
elif scenario == "DUPLICATE_ITEM" and answers:
|
||||
answers[-1] = dict(answers[0])
|
||||
if self.shuffle:
|
||||
answers.reverse()
|
||||
expected_ids = [str(item.work_item_id) for item in items]
|
||||
answer_ids = [str(answer.get("work_item_id")) for answer in answers]
|
||||
if len(answer_ids) != len(expected_ids) or set(answer_ids) != set(expected_ids) or len(set(answer_ids)) != len(answer_ids):
|
||||
raise ProviderContractError("fake provider response did not contain exactly one answer per work_item_id")
|
||||
items_by_id = {str(item.work_item_id): item for item in items}
|
||||
try:
|
||||
return [validate_answer(answer, items_by_id[str(answer["work_item_id"])]) for answer in answers]
|
||||
except (LlmAnswerError, KeyError, TypeError) as error:
|
||||
raise ProviderContractError(str(error)) from error
|
||||
|
||||
@staticmethod
|
||||
def _answer(item: LlmEvalItem, scenario: str) -> dict:
|
||||
candidates = [str(candidate[0]) for candidate in item.candidates]
|
||||
selected = None
|
||||
abstain = scenario == "ABSTAIN"
|
||||
if not abstain and candidates:
|
||||
selected = "99999999" if scenario == "OUT_OF_CANDIDATE" else candidates[min(1, len(candidates) - 1) if scenario == "WRONG" else 0]
|
||||
return {
|
||||
"work_item_id": item.work_item_id,
|
||||
"selected_ncm": selected,
|
||||
"confidence": 0.0 if abstain else 0.75,
|
||||
"abstain": abstain or selected is None,
|
||||
"reason_short": f"fake scenario {scenario}",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _raise_transport(scenario: str) -> None:
|
||||
if scenario not in {"TIMEOUT", "429", "500"}:
|
||||
raise ValueError(f"unknown fake transport scenario: {scenario}")
|
||||
raise ProviderTransportError(f"fake provider transport failure: {scenario}")
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Product identity: stable fingerprints and GTIN validation.
|
||||
|
||||
Global identity uses ONLY stable attributes (normalized description, brand, unit,
|
||||
package quantity, GTIN). Fiscal/company context (CFOP, CSOSN, CST, rate, ST, tenant)
|
||||
never participates: those vary per context and must not merge distinct knowledge.
|
||||
"""
|
||||
import hashlib
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
from ..classification import ENGINE_VERSION # noqa: F401 (re-exported for the AI layer)
|
||||
|
||||
|
||||
def normalize_description(value) -> str:
|
||||
"""NFKD -> ascii -> A-Z0-9 tokens, space-joined (same canonical form as classification)."""
|
||||
text = unicodedata.normalize("NFKD", str(value or "")).encode("ascii", "ignore").decode().upper()
|
||||
return " ".join(re.sub(r"[^A-Z0-9]+", " ", text).split())
|
||||
|
||||
|
||||
def _normalize_token(value) -> str:
|
||||
return normalize_description(value)
|
||||
|
||||
|
||||
def _normalize_quantity(value) -> str:
|
||||
digits = re.sub(r"[^0-9.,]", "", str(value or ""))
|
||||
if not digits:
|
||||
return ""
|
||||
try:
|
||||
number = float(digits.replace(",", "."))
|
||||
except ValueError:
|
||||
return digits
|
||||
return f"{number:g}"
|
||||
|
||||
|
||||
def product_fingerprint(description, brand=None, unit=None, package_quantity=None) -> str:
|
||||
parts = [_normalize_token(description), _normalize_token(brand), _normalize_token(unit), _normalize_quantity(package_quantity)]
|
||||
return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _stem(token: str) -> str:
|
||||
"""Minimal Portuguese plural stemming so AGUAS matches AGUA / MINERAIS matches MINERAL."""
|
||||
if len(token) > 4 and token.endswith("AIS"):
|
||||
return token[:-3] + "AL"
|
||||
if len(token) > 4 and token.endswith("ES"):
|
||||
return token[:-2]
|
||||
if len(token) > 3 and token.endswith("S"):
|
||||
return token[:-1]
|
||||
return token
|
||||
|
||||
|
||||
def token_stems(value) -> set:
|
||||
return {_stem(token) for token in normalize_description(value).split()}
|
||||
|
||||
|
||||
def overlap_score(query, official) -> float:
|
||||
"""Fraction of query description stems present in the official description stems."""
|
||||
stems = token_stems(query)
|
||||
if not stems:
|
||||
return 0.0
|
||||
return len(stems & token_stems(official)) / len(stems)
|
||||
|
||||
|
||||
def validate_gtin(value) -> str | None:
|
||||
"""Returns the canonical GTIN when the checksum is valid, else None.
|
||||
Accepts GTIN-8/12/13/14 (EAN-8/UPC-12/EAN-13/GTIN-14)."""
|
||||
digits = re.sub(r"\D", "", str(value or ""))
|
||||
if len(digits) not in (8, 12, 13, 14):
|
||||
return None
|
||||
total = 0
|
||||
for index, char in enumerate(reversed(digits)):
|
||||
digit = int(char)
|
||||
total += digit * (1 if index % 2 == 0 else 3)
|
||||
if total % 10 != 0:
|
||||
return None
|
||||
return digits
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Evidence reconstruction for historical KB conflicts.
|
||||
|
||||
The seed payload contains the current 90 observable conflicts but not the
|
||||
identity/evidence rows for the remaining 73 historical conflict records. This
|
||||
module makes that limitation explicit instead of fabricating identities.
|
||||
"""
|
||||
|
||||
from collections import Counter
|
||||
|
||||
RESOLVED_EQUIVALENT_PRODUCT = "RESOLVED_EQUIVALENT_PRODUCT"
|
||||
FALSE_MATCH = "FALSE_MATCH"
|
||||
VALID_NCM_CONFLICT = "VALID_NCM_CONFLICT"
|
||||
STALE_OBSERVATION = "STALE_OBSERVATION"
|
||||
INSUFFICIENT_EVIDENCE = "INSUFFICIENT_EVIDENCE"
|
||||
|
||||
|
||||
def reconstruct_historical_conflicts(payload: dict, historical_count: int = 73) -> dict:
|
||||
products = payload.get("products", [])
|
||||
gtin_count = sum(1 for product in products if product.get("gtin"))
|
||||
fingerprint_count = sum(1 for product in products if product.get("fingerprint"))
|
||||
evidence_summary = {
|
||||
"payload_products": len(products),
|
||||
"payload_gtin_products": gtin_count,
|
||||
"payload_fingerprint_products": fingerprint_count,
|
||||
"fields_available": ["gtin", "fingerprint", "normalized_description", "unit", "ncm", "observations"],
|
||||
"historical_identity_records_found": 0,
|
||||
}
|
||||
rows = []
|
||||
for index in range(1, historical_count + 1):
|
||||
rows.append({
|
||||
"historical_conflict_id": f"HISTORICAL-{index:03d}",
|
||||
"classification": INSUFFICIENT_EVIDENCE,
|
||||
"evidence_search": evidence_summary,
|
||||
"resolution": "PRESERVE_CONFLICT",
|
||||
"reason": "original conflict identity/evidence row is absent from available artifacts; no NCM selected",
|
||||
})
|
||||
return {
|
||||
"artifact": "KB_HISTORICAL_CONFLICT_AUDIT_V1",
|
||||
"historical_conflicts_before": historical_count,
|
||||
"historical_conflicts_resolved": 0,
|
||||
"historical_conflicts_remaining": historical_count,
|
||||
"classification_counts": dict(Counter(row["classification"] for row in rows)),
|
||||
"conflicts": rows,
|
||||
"current_observable_conflicts_preserved": 90,
|
||||
"current_conflict_policy": "NCM_CONFLICT rows remain blocked; no majority resolution",
|
||||
"evidence_complete": False,
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Product Knowledge Base service: identity resolution, versioned knowledge, conflicts.
|
||||
|
||||
Identity order (operator contract): 1. exact GTIN; 2. strong fingerprint; 3. semantic
|
||||
fingerprint candidate; 4. unknown. Conflicting knowledge for the same key is kept
|
||||
side by side and surfaced as KNOWLEDGE_CONFLICT — never picked by majority.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func as sa_func, select
|
||||
from ..models import KbKnowledge, KbObservation, KbProduct
|
||||
|
||||
EXACT, FINGERPRINT = "EXACT", "FINGERPRINT"
|
||||
_AUTHORITATIVE = {"OFFICIAL_DATASET", "OPERATOR", "AI_PLUS_OFFICIAL_VALIDATION"}
|
||||
|
||||
|
||||
class KnowledgeBase:
|
||||
def __init__(self, session):
|
||||
self.session = session
|
||||
|
||||
# ----- identity -----
|
||||
|
||||
def upsert_product(self, fingerprint, gtin=None, normalized_description="", brand=None, unit=None, package_quantity=None, category=None):
|
||||
gtin = gtin or None
|
||||
product = None
|
||||
if gtin:
|
||||
product = self.session.scalar(select(KbProduct).where(KbProduct.gtin == gtin))
|
||||
if product is None:
|
||||
product = self.session.scalar(select(KbProduct).where(KbProduct.fingerprint == fingerprint))
|
||||
if product is None:
|
||||
product = KbProduct(fingerprint=fingerprint, normalized_description=normalized_description or "")
|
||||
self.session.add(product)
|
||||
# identity upgrades (never downgrades)
|
||||
if gtin and not product.gtin:
|
||||
product.gtin = gtin
|
||||
product.identity_kind = "GTIN"
|
||||
if normalized_description and not product.normalized_description:
|
||||
product.normalized_description = normalized_description
|
||||
for field, value in (("brand", brand), ("unit", unit), ("package_quantity", package_quantity), ("category", category)):
|
||||
if value and not getattr(product, field):
|
||||
setattr(product, field, value)
|
||||
self.session.flush()
|
||||
return product
|
||||
|
||||
def resolve(self, gtin=None, fingerprint=None):
|
||||
"""Returns (product|None, hit_kind). Cache-first: EXACT (GTIN) beats FINGERPRINT."""
|
||||
if gtin:
|
||||
product = self.session.scalar(select(KbProduct).where(KbProduct.gtin == gtin))
|
||||
if product:
|
||||
return product, EXACT
|
||||
if fingerprint:
|
||||
product = self.session.scalar(select(KbProduct).where(KbProduct.fingerprint == fingerprint))
|
||||
if product:
|
||||
return product, FINGERPRINT
|
||||
return None, None
|
||||
|
||||
# ----- knowledge -----
|
||||
|
||||
def add_knowledge(self, kb_product_id, knowledge_key, value, source, confidence=0.0, evidence=None, model=None, prompt_version=None, dataset_versions=None):
|
||||
value = str(value)
|
||||
row = self.session.scalar(select(KbKnowledge).where(KbKnowledge.kb_product_id == kb_product_id, KbKnowledge.knowledge_key == knowledge_key, KbKnowledge.value == value))
|
||||
if row is None:
|
||||
row = KbKnowledge(kb_product_id=kb_product_id, knowledge_key=knowledge_key, value=value, source=source, confidence=confidence, evidence=evidence or {}, model=model, prompt_version=prompt_version, dataset_versions=dataset_versions or {})
|
||||
self.session.add(row)
|
||||
else:
|
||||
# authoritative sources upgrade provenance/confidence of the same value; history preserved
|
||||
if source in _AUTHORITATIVE:
|
||||
row.source = source
|
||||
row.confidence = max(row.confidence, confidence)
|
||||
row.evidence = {**row.evidence, **(evidence or {})}
|
||||
if dataset_versions:
|
||||
row.dataset_versions = {**row.dataset_versions, **dataset_versions}
|
||||
if source == "OFFICIAL_DATASET":
|
||||
row.validated_at = datetime.now(timezone.utc)
|
||||
self.session.flush()
|
||||
return row
|
||||
|
||||
def current_knowledge(self, kb_product_id):
|
||||
rows = self.session.scalars(select(KbKnowledge).where(KbKnowledge.kb_product_id == kb_product_id)).all()
|
||||
by_key: dict = {}
|
||||
for row in rows:
|
||||
by_key.setdefault(row.knowledge_key, []).append(row)
|
||||
result = {}
|
||||
for key, entries in by_key.items():
|
||||
values = {entry.value for entry in entries}
|
||||
if len(values) > 1:
|
||||
result[key] = {"conflict": True, "values": values}
|
||||
else:
|
||||
best = max(entries, key=lambda entry: entry.confidence)
|
||||
result[key] = {"conflict": False, "value": best.value, "source": best.source, "confidence": best.confidence}
|
||||
return result
|
||||
|
||||
# ----- observations -----
|
||||
|
||||
def record_observation(self, kb_product_id, tenant_id, company_key, erp_code, database_label, observed_fields, source="ERP_OBSERVATION"):
|
||||
row = KbObservation(kb_product_id=kb_product_id, tenant_id=tenant_id, company_key=company_key, erp_code=erp_code, database_label=database_label, observed_fields=observed_fields or {}, source=source)
|
||||
self.session.add(row); self.session.flush()
|
||||
return row
|
||||
|
||||
def observation_summary(self, kb_product_id):
|
||||
rows = self.session.scalars(select(KbObservation).where(KbObservation.kb_product_id == kb_product_id)).all()
|
||||
agreement: dict = {}
|
||||
for row in rows:
|
||||
for field, value in (row.observed_fields or {}).items():
|
||||
if value in (None, ""):
|
||||
continue
|
||||
bucket = agreement.setdefault(field, {})
|
||||
bucket[str(value)] = bucket.get(str(value), 0) + 1
|
||||
conflicts = {field: values for field, values in agreement.items() if len(values) > 1}
|
||||
return {"observation_count": len(rows), "tenant_count": len({row.tenant_id for row in rows}), "agreement": agreement, "conflicts": conflicts}
|
||||
|
||||
# ----- stats -----
|
||||
|
||||
def stats(self):
|
||||
products = self.session.scalar(select(sa_func.count()).select_from(KbProduct)) or 0
|
||||
gtin_products = self.session.scalar(select(sa_func.count()).select_from(KbProduct).where(KbProduct.gtin.is_not(None))) or 0
|
||||
conflicts = 0
|
||||
for product_id in self.session.scalars(select(KbProduct.id)).all():
|
||||
knowledge = self.current_knowledge(product_id)
|
||||
conflicts += sum(1 for entry in knowledge.values() if entry.get("conflict"))
|
||||
fingerprint_products = products - gtin_products
|
||||
return {"products": products, "gtin_products": gtin_products, "fingerprint_products": fingerprint_products, "conflicts": conflicts}
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Conservative, explainable product identity and conflict classification."""
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from collections import defaultdict
|
||||
|
||||
from .fingerprint import normalize_description, token_stems, validate_gtin
|
||||
|
||||
GTIN_CONFLICT = "GTIN_CONFLICT"
|
||||
NCM_CONFLICT = "NCM_CONFLICT"
|
||||
DESCRIPTION_CONFLICT = "DESCRIPTION_CONFLICT"
|
||||
PACKAGING_VARIATION = "PACKAGING_VARIATION"
|
||||
FALSE_MATCH = "FALSE_MATCH"
|
||||
TENANT_CONTEXT_ONLY = "TENANT_CONTEXT_ONLY"
|
||||
|
||||
|
||||
def canonical_product_id(observation: dict) -> str:
|
||||
"""Stable global ID; fiscal and tenant fields are intentionally excluded."""
|
||||
gtin = validate_gtin(observation.get("gtin"))
|
||||
if gtin:
|
||||
return "GTIN:" + gtin
|
||||
values = "|".join((
|
||||
normalize_description(observation.get("normalized_description") or observation.get("description")),
|
||||
normalize_description(observation.get("brand")),
|
||||
normalize_description(observation.get("unit")),
|
||||
normalize_description(observation.get("package_quantity")),
|
||||
))
|
||||
return "FP:" + hashlib.sha256(values.encode()).hexdigest()
|
||||
|
||||
|
||||
def _description_equivalent(values):
|
||||
stems = [token_stems(value) for value in values if value]
|
||||
if not stems:
|
||||
return True
|
||||
return all(current == stems[0] or current.issubset(stems[0]) or stems[0].issubset(current) for current in stems[1:])
|
||||
|
||||
|
||||
def classify_conflict(observations: list[dict]) -> str:
|
||||
"""Classify only from structural evidence; ambiguous cases remain conflicts."""
|
||||
gtins = {validate_gtin(row.get("gtin")) for row in observations if validate_gtin(row.get("gtin"))}
|
||||
ncms = {str(row.get("ncm")) for row in observations if row.get("ncm")}
|
||||
descriptions = [row.get("normalized_description") or row.get("description") for row in observations]
|
||||
packages = {str(row.get("package_quantity")) for row in observations if row.get("package_quantity")}
|
||||
tenants = {str(obs.get("database") or obs.get("company_key")) for row in observations for obs in row.get("observations", [])}
|
||||
if len(gtins) > 1:
|
||||
return GTIN_CONFLICT
|
||||
if len(ncms) > 1:
|
||||
return NCM_CONFLICT
|
||||
if len(packages) > 1 and _description_equivalent(descriptions):
|
||||
return PACKAGING_VARIATION
|
||||
if len({normalize_description(value) for value in descriptions if value}) > 1:
|
||||
return DESCRIPTION_CONFLICT
|
||||
if len(tenants) > 1:
|
||||
return TENANT_CONTEXT_ONLY
|
||||
return FALSE_MATCH
|
||||
|
||||
|
||||
def audit_payload(payload: dict, historical_conflicts: int = 163) -> dict:
|
||||
"""Audit observable same-GTIN groups without silently resolving NCM conflicts."""
|
||||
by_gtin = defaultdict(list)
|
||||
for product in payload.get("products", []):
|
||||
gtin = validate_gtin(product.get("gtin"))
|
||||
if gtin:
|
||||
by_gtin[gtin].append(product)
|
||||
conflicts = []
|
||||
counts = defaultdict(int)
|
||||
for gtin, rows in by_gtin.items():
|
||||
if len({row.get("ncm") for row in rows if row.get("ncm")}) <= 1:
|
||||
continue
|
||||
kind = classify_conflict(rows)
|
||||
counts[kind] += 1
|
||||
conflicts.append({
|
||||
"identity": gtin,
|
||||
"classification": kind,
|
||||
"observations": len(rows),
|
||||
"ncms": sorted({row.get("ncm") for row in rows if row.get("ncm")}),
|
||||
"auto_resolution": "KEEP_KNOWLEDGE_CONFLICT",
|
||||
})
|
||||
return {
|
||||
"artifact": "KB_CONFLICT_AUDIT_V1",
|
||||
"historical_conflicts_reported": historical_conflicts,
|
||||
"observable_conflicts_audited": len(conflicts),
|
||||
"unobservable_historical_conflicts": max(0, historical_conflicts - len(conflicts)),
|
||||
"classification_counts": dict(sorted(counts.items())),
|
||||
"conflicts": conflicts,
|
||||
"policy": "only structurally equivalent identities may share a canonical product; tenant fiscal context is never global knowledge",
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Real LLM provider (OpenAI-compatible) for the NCM semantic evaluation.
|
||||
|
||||
Contract (mission REAL LLM EVALUATION V1):
|
||||
- The API key lives ONLY in the Hub environment (AI_API_KEY). Never committed, logged,
|
||||
written to artifacts, sent to the Agent or Firebird.
|
||||
- The gold answer is never part of the prompt: items carry only description/brand/unit/
|
||||
segment-weak-context + the official top-K hybrid candidates with hierarchical context.
|
||||
- Strict structured output: per item {work_item_id, selected_ncm, confidence, abstain,
|
||||
reason_short}; selected_ncm must be in the candidate list or null (abstain).
|
||||
- Batching is safe only when the response is valid JSON with 1:1 work_item_id matching,
|
||||
no omitted items, no cross-product mixing. Verified progressively (5/10/20).
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
PROMPT_VERSION_LLM = "llm-ncm-eval-v1"
|
||||
|
||||
_SYSTEM_PROMPT = (
|
||||
"You are a Brazilian customs classification assistant. For each product below, choose the "
|
||||
"most likely official NCM code from the provided candidate list, or abstain. "
|
||||
"Rules: selected_ncm MUST be one of the candidate codes for that exact item, or null with "
|
||||
"abstain=true when evidence is insufficient. Never invent a code. confidence is your calibrated "
|
||||
"probability in [0,1]. Reply with ONLY a JSON array, one object per item, in the same order, "
|
||||
'each: {"work_item_id": str, "selected_ncm": str|null, "confidence": float, "abstain": bool, "reason_short": str}.'
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LlmEvalItem:
|
||||
work_item_id: str
|
||||
description: str
|
||||
normalized_description: str
|
||||
brand: str | None
|
||||
unit: str | None
|
||||
segment: str
|
||||
candidates: list # [(code, official_description, hybrid_score, official_score, kb_score, tenant_count)]
|
||||
|
||||
|
||||
class LlmAnswerError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def item_prompt_payload(item: LlmEvalItem) -> dict:
|
||||
return {
|
||||
"work_item_id": item.work_item_id,
|
||||
"description": item.description,
|
||||
"normalized_description": item.normalized_description,
|
||||
"brand": item.brand,
|
||||
"unit": item.unit,
|
||||
"segment_weak_context": item.segment,
|
||||
"official_candidates": [
|
||||
{"ncm": code, "official_description": text}
|
||||
for code, text, *_ in item.candidates
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def candidate_set_hash(item: LlmEvalItem) -> str:
|
||||
codes = [code for code, *_ in item.candidates]
|
||||
return hashlib.sha256("|".join(codes).encode()).hexdigest()
|
||||
|
||||
|
||||
def input_fingerprint(item: LlmEvalItem) -> str:
|
||||
payload = {**item_prompt_payload(item), "system": _SYSTEM_PROMPT}
|
||||
return hashlib.sha256(json.dumps(payload, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
|
||||
|
||||
|
||||
def validate_answer(answer: dict, item: LlmEvalItem) -> dict:
|
||||
"""Fail-closed validator: schema, work_item_id, candidate-list confinement."""
|
||||
if not isinstance(answer, dict):
|
||||
raise LlmAnswerError("answer must be an object")
|
||||
if str(answer.get("work_item_id")) != str(item.work_item_id):
|
||||
raise LlmAnswerError(f"work_item_id mismatch: {answer.get('work_item_id')!r} != {item.work_item_id!r}")
|
||||
abstain = bool(answer.get("abstain"))
|
||||
selected = answer.get("selected_ncm")
|
||||
selected = None if selected in (None, "") else str(selected)
|
||||
try:
|
||||
confidence = float(answer.get("confidence"))
|
||||
except (TypeError, ValueError):
|
||||
raise LlmAnswerError("confidence must be numeric")
|
||||
if not 0.0 <= confidence <= 1.0:
|
||||
raise LlmAnswerError("confidence out of [0,1]")
|
||||
codes = {code for code, *_ in item.candidates}
|
||||
if abstain:
|
||||
if selected is not None:
|
||||
raise LlmAnswerError("abstain must carry selected_ncm=null")
|
||||
else:
|
||||
if selected is None:
|
||||
raise LlmAnswerError("non-abstain requires selected_ncm")
|
||||
if selected not in codes:
|
||||
raise LlmAnswerError(f"selected_ncm {selected!r} is outside the candidate list")
|
||||
return {"work_item_id": item.work_item_id, "selected_ncm": selected, "confidence": confidence, "abstain": abstain, "reason_short": str(answer.get("reason_short") or "")[:200]}
|
||||
|
||||
|
||||
class OpenAICompatibleProvider:
|
||||
"""Talks to any OpenAI-compatible /chat/completions endpoint. `transport` is
|
||||
injectable for tests; production default uses httpx with the key from settings only."""
|
||||
|
||||
name = "openai-compatible"
|
||||
prompt_version = PROMPT_VERSION_LLM
|
||||
|
||||
def __init__(self, api_key: str, model: str, base_url: str = "https://api.openai.com/v1", transport=None, timeout: float = 120.0):
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.timeout = timeout
|
||||
self._transport = transport or self._httpx_transport
|
||||
self.http_calls = 0
|
||||
self.retries = 0
|
||||
self.usage = {"input_tokens": 0, "output_tokens": 0, "cached_tokens": 0}
|
||||
|
||||
def _httpx_transport(self, payload: dict) -> dict:
|
||||
import httpx
|
||||
response = httpx.post(f"{self.base_url}/chat/completions", json=payload, headers={"Authorization": f"Bearer {self.api_key}"}, timeout=self.timeout)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def list_models(self) -> list:
|
||||
"""GET /models with the account key — validates auth/base URL/model availability
|
||||
without spending completion tokens. Never raises with the key attached."""
|
||||
import httpx
|
||||
response = httpx.get(f"{self.base_url}/models", headers={"Authorization": f"Bearer {self.api_key}"}, timeout=self.timeout)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return sorted(str(item.get("id")) for item in data.get("data", []) if item.get("id"))
|
||||
|
||||
def _request(self, messages: list, max_retries: int = 2) -> dict:
|
||||
payload = {"model": self.model, "messages": messages, "temperature": 0}
|
||||
last_error = None
|
||||
for attempt in range(max_retries + 1):
|
||||
self.http_calls += 1
|
||||
if attempt:
|
||||
self.retries += 1
|
||||
try:
|
||||
data = self._transport(payload)
|
||||
usage = data.get("usage") or {}
|
||||
self.usage["input_tokens"] += int(usage.get("prompt_tokens") or 0)
|
||||
self.usage["output_tokens"] += int(usage.get("completion_tokens") or 0)
|
||||
cached = usage.get("prompt_tokens_details") or {}
|
||||
self.usage["cached_tokens"] += int(cached.get("cached_tokens") or 0)
|
||||
return data
|
||||
except Exception as error: # transport-level failure only; JSON contract errors are not retried
|
||||
last_error = error
|
||||
raise RuntimeError(f"LLM transport failed after {max_retries + 1} attempts: {last_error}")
|
||||
|
||||
def select_batch(self, items: list[LlmEvalItem]) -> list[dict]:
|
||||
"""One HTTP call for the whole batch. Raises LlmAnswerError on any 1:1/schema violation
|
||||
so the harness can shrink the batch size."""
|
||||
user_content = json.dumps([item_prompt_payload(item) for item in items], ensure_ascii=False)
|
||||
data = self._request([{"role": "system", "content": _SYSTEM_PROMPT}, {"role": "user", "content": user_content}])
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
content = re.sub(r"^```(?:json)?|```$", "", content.strip(), flags=re.MULTILINE).strip()
|
||||
answers = json.loads(content)
|
||||
if not isinstance(answers, list) or len(answers) != len(items):
|
||||
raise LlmAnswerError(f"expected {len(items)} answers, got {len(answers) if isinstance(answers, list) else type(answers).__name__}")
|
||||
return [validate_answer(answer, item) for answer, item in zip(answers, items)]
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Deterministic prompt compaction that preserves candidate evidence."""
|
||||
|
||||
import json
|
||||
|
||||
|
||||
def estimate_tokens(payload) -> int:
|
||||
return max(1, len(json.dumps(payload, ensure_ascii=False, sort_keys=True)) // 4)
|
||||
|
||||
|
||||
def compact_payload(payload):
|
||||
"""Remove repeated metadata only; candidate codes/descriptions are untouched."""
|
||||
compact = dict(payload)
|
||||
compact.pop("debug", None)
|
||||
compact.pop("duplicate_examples", None)
|
||||
candidates = compact.get("official_candidates")
|
||||
if isinstance(candidates, list):
|
||||
seen = set()
|
||||
compact["official_candidates"] = [
|
||||
row for row in candidates
|
||||
if not (row.get("ncm") in seen or seen.add(row.get("ncm")))
|
||||
]
|
||||
return compact
|
||||
|
||||
|
||||
def measure_payloads(payloads):
|
||||
before = sum(estimate_tokens(payload) for payload in payloads)
|
||||
after = sum(estimate_tokens(compact_payload(payload)) for payload in payloads)
|
||||
return {"items": len(payloads), "tokens_before": before, "tokens_after": after, "reduction_percent": round((1 - after / before) * 100, 2) if before else 0.0}
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Provider abstraction with strict structured output and a hard budget guard.
|
||||
|
||||
The fiscal engine is never coupled to a vendor: it talks to AISemanticProvider.
|
||||
V1 ships BaselineProvider (deterministic, zero-cost) used by the evaluation
|
||||
harness and as the no-credential fallback; real providers subclass the same
|
||||
contract and are only invoked with AI_ENABLED=true and credentials in the Hub.
|
||||
"""
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..classification import ENGINE_VERSION
|
||||
from .fingerprint import overlap_score
|
||||
|
||||
PROMPT_VERSION = "ai-semantic-v1"
|
||||
|
||||
|
||||
class SemanticOutputError(ValueError):
|
||||
"""Raised when a provider answer violates the strict structured-output schema."""
|
||||
|
||||
|
||||
class ProviderTransportError(RuntimeError):
|
||||
"""A retryable failure while contacting a semantic provider."""
|
||||
|
||||
|
||||
class ProviderContractError(ValueError):
|
||||
"""A non-retryable provider response or protocol violation."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class StructuredResult:
|
||||
task_type: str
|
||||
input_fingerprint: str
|
||||
selected_candidate: str # candidate code, or "NONE"
|
||||
confidence: float
|
||||
reason: str
|
||||
candidate_ids_seen: list[str]
|
||||
model: str
|
||||
prompt_version: str
|
||||
created_at: str
|
||||
dataset_versions: dict = field(default_factory=dict)
|
||||
engine_version: str = ENGINE_VERSION
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"task_type": self.task_type,
|
||||
"input_fingerprint": self.input_fingerprint,
|
||||
"selected_candidate": self.selected_candidate,
|
||||
"confidence": self.confidence,
|
||||
"reason": self.reason,
|
||||
"candidate_ids_seen": self.candidate_ids_seen,
|
||||
"model": self.model,
|
||||
"prompt_version": self.prompt_version,
|
||||
"created_at": self.created_at,
|
||||
"dataset_versions": self.dataset_versions,
|
||||
"engine_version": self.engine_version,
|
||||
}
|
||||
|
||||
|
||||
def result_from_dict(data) -> StructuredResult:
|
||||
"""Fail-closed schema check: the selection must be NONE or one of the seen candidates."""
|
||||
result = StructuredResult(
|
||||
task_type=str(data["task_type"]),
|
||||
input_fingerprint=str(data["input_fingerprint"]),
|
||||
selected_candidate=str(data["selected_candidate"]),
|
||||
confidence=float(data["confidence"]),
|
||||
reason=str(data["reason"]),
|
||||
candidate_ids_seen=[str(item) for item in data["candidate_ids_seen"]],
|
||||
model=str(data["model"]),
|
||||
prompt_version=str(data["prompt_version"]),
|
||||
created_at=str(data["created_at"]),
|
||||
dataset_versions=dict(data.get("dataset_versions") or {}),
|
||||
)
|
||||
if result.selected_candidate != "NONE" and result.selected_candidate not in result.candidate_ids_seen:
|
||||
raise SemanticOutputError(f"selected_candidate {result.selected_candidate!r} is not among the official candidates")
|
||||
if not 0.0 <= result.confidence <= 1.0:
|
||||
raise SemanticOutputError("confidence must be within [0,1]")
|
||||
return result
|
||||
|
||||
|
||||
class AISemanticProvider:
|
||||
"""Contract every provider must honour. `select` receives ONLY official candidates."""
|
||||
name = "abstract"
|
||||
model = "abstract"
|
||||
|
||||
def select(self, work_item, candidates) -> StructuredResult:
|
||||
raise NotImplementedError
|
||||
|
||||
def estimate(self, work_items) -> dict:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def _score(text: str, official: str) -> float:
|
||||
return overlap_score(text, official)
|
||||
|
||||
|
||||
class BaselineProvider(AISemanticProvider):
|
||||
"""Deterministic zero-cost baseline: highest token-overlap candidate, or NONE.
|
||||
Used for the evaluation pipeline and as the mandatory no-credential fallback."""
|
||||
|
||||
name = "baseline"
|
||||
model = "baseline-deterministic-v1"
|
||||
|
||||
def select(self, work_item, candidates, dataset_versions=None) -> StructuredResult:
|
||||
description = work_item.payload.get("description", "") if hasattr(work_item, "payload") else ""
|
||||
scored = [(code, official, _score(description, official)) for code, official in [(str(c[0]), c[1]) for c in candidates]]
|
||||
best = max(scored, key=lambda row: row[2]) if scored else None
|
||||
selected, _, score = best if best else ("NONE", "", 0.0)
|
||||
if not scored or score <= 0.0:
|
||||
selected = "NONE"
|
||||
return StructuredResult(
|
||||
task_type=work_item.task_type,
|
||||
input_fingerprint=work_item.dedup_key,
|
||||
selected_candidate=selected,
|
||||
confidence=round(score, 4),
|
||||
reason="baseline token-overlap top-1" if selected != "NONE" else "no token overlap with any official candidate",
|
||||
candidate_ids_seen=[str(c[0]) for c in candidates],
|
||||
model=self.model,
|
||||
prompt_version=PROMPT_VERSION,
|
||||
created_at=datetime.now(timezone.utc).isoformat(),
|
||||
dataset_versions=dict(dataset_versions or {}),
|
||||
)
|
||||
|
||||
def estimate(self, work_items) -> dict:
|
||||
return {"input_tokens": 0, "output_tokens": 0, "estimated_cost": 0.0}
|
||||
|
||||
|
||||
class BudgetGuard:
|
||||
"""Refuses execution when the estimated cost exceeds AI_MAX_RUN_COST_USD.
|
||||
Never falls back to a more expensive model. Prices are operator-configured — when
|
||||
absent, cost is UNKNOWN: the residual run is refused, but an explicitly limited
|
||||
benchmark (e.g. the 65-item GOLD eval) may opt in with allow_unknown_cost."""
|
||||
|
||||
def __init__(self, max_run_cost_usd: float, price_in_per_m: float | None, price_out_per_m: float | None):
|
||||
self.max_run_cost_usd = float(max_run_cost_usd)
|
||||
self.price_in_per_m = None if price_in_per_m is None else float(price_in_per_m)
|
||||
self.price_out_per_m = None if price_out_per_m is None else float(price_out_per_m)
|
||||
|
||||
def estimate(self, items_in_tokens: int, items_out_tokens: int) -> dict:
|
||||
if self.price_in_per_m is None or self.price_out_per_m is None:
|
||||
return {"input_tokens": items_in_tokens, "output_tokens": items_out_tokens, "estimated_cost": None, "cost_unknown": True}
|
||||
cost = (items_in_tokens / 1_000_000) * self.price_in_per_m + (items_out_tokens / 1_000_000) * self.price_out_per_m
|
||||
return {"input_tokens": items_in_tokens, "output_tokens": items_out_tokens, "estimated_cost": round(cost, 6)}
|
||||
|
||||
def authorize(self, plan: dict, allow_unknown_cost: bool = False) -> bool:
|
||||
if plan.get("estimated_cost") is None:
|
||||
return allow_unknown_cost
|
||||
return plan["estimated_cost"] <= self.max_run_cost_usd
|
||||
|
||||
def authorize_usage(self, input_tokens: int, output_tokens: int, *, prior_cost_usd: float = 0.0,
|
||||
allow_unknown_cost: bool = False) -> bool:
|
||||
"""Apply the same guard to actual usage before accepting a provider result."""
|
||||
plan = self.estimate(input_tokens, output_tokens)
|
||||
if plan.get("estimated_cost") is None:
|
||||
return allow_unknown_cost
|
||||
return float(prior_cost_usd) + plan["estimated_cost"] <= self.max_run_cost_usd
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Pure, review-first questionnaire helpers for local fiscal context."""
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
|
||||
from central.app.context_keys import validate_entry
|
||||
|
||||
|
||||
def _options(row):
|
||||
value = row.get("VALID_OPTIONS", [])
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
value = [x.strip() for x in value.split("|") if x.strip()]
|
||||
return list(value) if isinstance(value, (list, tuple, set)) else [value]
|
||||
|
||||
|
||||
def build_questionnaire(rows):
|
||||
result = []
|
||||
for row in rows:
|
||||
scope = str(row.get("SCOPE", row.get("scope", ""))).strip().upper()
|
||||
key = str(row.get("KEY", row.get("key", ""))).strip()
|
||||
scope_key = str(row.get("GROUP") or row.get("NCM") or row.get("scope_key") or "").strip()
|
||||
result.append({
|
||||
"question_id": str(row.get("QUESTION_ID", row.get("question_id", ""))).strip(),
|
||||
"database": str(row.get("DATABASE", row.get("database", ""))).strip(),
|
||||
"scope": scope,
|
||||
"scope_key": scope_key if scope != "COMPANY" else "",
|
||||
"group": str(row.get("GROUP") or "").strip(),
|
||||
"key": key,
|
||||
"question": row.get("QUESTION", row.get("question", "")),
|
||||
"options": _options(row),
|
||||
"affected": {"products": int(row.get("PRODUCTS_AFFECTED") or row.get("products_affected") or 0), "cells": int(row.get("CELLS") or row.get("cells") or 0)},
|
||||
"examples": row.get("EXAMPLES", row.get("examples", "")),
|
||||
"consequence": row.get("WHAT_IT_WILL_RESOLVE", row.get("consequence", "")),
|
||||
})
|
||||
return sorted(result, key=lambda q: (q["database"], q["scope"], q["scope_key"], q["key"], q["question_id"]))
|
||||
|
||||
|
||||
def group_questions(questions):
|
||||
"""Group equivalent human decisions while retaining every affected question ID."""
|
||||
groups = {}
|
||||
for question in questions:
|
||||
key = (question["database"], question["group"], question["scope_key"], question["key"], question["question"], tuple(question["options"]))
|
||||
group = groups.setdefault(key, {
|
||||
"group_id": "GROUP-" + str(len(groups) + 1),
|
||||
"database": question["database"],
|
||||
"group": question["group"],
|
||||
"scope": question["scope"],
|
||||
"scope_key": question["scope_key"],
|
||||
"key": question["key"],
|
||||
"question": question["question"],
|
||||
"options": question["options"],
|
||||
"question_ids": [],
|
||||
"affected": {"products": 0, "cells": 0},
|
||||
"examples": [],
|
||||
"consequence": question["consequence"],
|
||||
})
|
||||
group["question_ids"].append(question["question_id"])
|
||||
group["affected"]["products"] += question["affected"]["products"]
|
||||
group["affected"]["cells"] += question["affected"]["cells"]
|
||||
if question["examples"] and len(group["examples"]) < 5:
|
||||
group["examples"].append(str(question["examples"]))
|
||||
return list(groups.values())
|
||||
|
||||
|
||||
def validate_answers(questions, answers):
|
||||
entries, effects, rollback_plan = [], [], []
|
||||
by_id = {q["question_id"]: q for q in questions}
|
||||
for question_id, value in answers.items():
|
||||
if question_id not in by_id:
|
||||
raise ValueError(f"unknown question {question_id!r}; nothing was persisted")
|
||||
q = by_id[question_id]
|
||||
value = str(value).strip()
|
||||
if q["options"] and value not in q["options"]:
|
||||
raise ValueError(f"{question_id}: value must be one of {q['options']}; nothing was persisted")
|
||||
entry, error = validate_entry({"scope": q["scope"], "scope_key": q["scope_key"], "key": q["key"], "value": value})
|
||||
if error:
|
||||
raise ValueError(f"{question_id}: {error}; nothing was persisted")
|
||||
entry["company_key"] = q["database"]
|
||||
entries.append(entry)
|
||||
effect = {"question_id": question_id, "database": q["database"], "key": q["key"], "affected": q["affected"], "behavior": "context_entry"}
|
||||
if q["key"] == "entry_scope" and value == "MIXED_OR_DYNAMIC":
|
||||
effect["behavior"] = "no_static_cfop_entrada"
|
||||
effects.append(effect)
|
||||
if q["key"] == "store_ipi_profile" and value == "LEAVE_NOT_APPLICABLE":
|
||||
rollback_plan.append({"database": q["database"], "field": "IPI", "affected_cells": q["affected"]["cells"]})
|
||||
return {"entries": entries, "effects": effects, "rollback_plan": rollback_plan}
|
||||
|
||||
|
||||
def render_json(result):
|
||||
return json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
|
||||
|
||||
|
||||
def render_csv(result):
|
||||
output = io.StringIO()
|
||||
writer = csv.DictWriter(output, fieldnames=["company_key", "scope", "scope_key", "key", "value", "reason"])
|
||||
writer.writeheader()
|
||||
writer.writerows({k: entry.get(k, "") for k in writer.fieldnames} for entry in result["entries"])
|
||||
return output.getvalue()
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Semantic candidate retrieval V2 — deterministic, zero LLM.
|
||||
|
||||
Three retrieval sources merged into one ranking:
|
||||
A. OFFICIAL: hierarchical search over the official NCM tree. Every 8-digit code is
|
||||
enriched with its ancestors' descriptions (chapter/position/subposition), so the
|
||||
very short legal text carries the vocabulary of its path (section 6).
|
||||
B. KB NEIGHBORS: the Knowledge Base acts as a commercial->official bridge — products
|
||||
whose commercial description resembles the query contribute their VALIDATED NCMs,
|
||||
weighted by similarity, tenant_count, observation_count and agreement (sections 2-3).
|
||||
C. HYBRID: single final ranking; the KB is only evidence for CANDIDATES, never a
|
||||
majority-vote fiscal decision.
|
||||
|
||||
Abbreviation lexicon (section 8) is derived statistically from the KB itself; commercial
|
||||
normalization (section 7) strips units/sizes/colors/refs deterministically.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import Dataset, KbKnowledge, KbObservation, KbProduct, NcmEntry, Tenant
|
||||
from .fingerprint import normalize_description, token_stems
|
||||
|
||||
# ---------------- commercial normalization (section 7) ----------------
|
||||
|
||||
_UNITS = {"UN", "UNID", "UNIDADE", "CX", "CAIXA", "FD", "FDO", "FARDO", "PCT", "PACOTE", "PKG",
|
||||
"KG", "G", "GR", "GRAMA", "MG", "ML", "L", "LT", "LITRO", "LATA", "GARRAFA", "PET",
|
||||
"PC", "PECA", "DZ", "DUZIA", "PAR", "KIT", "JG", "PARA", "COM", "SEM", "C", "S"}
|
||||
_DISCARD = _UNITS | {"MASC", "MASCULINO", "FEM", "FEMININO", "INF", "INFANTIL", "TAM", "TAMANHO",
|
||||
"COR", "REF", "COD", "CODIGO", "MOD", "MODELO", "NOVA", "NOVO", "TIPO"}
|
||||
_LEXEME_STRIP_PREFIXES = ("1", "2", "3", "4", "5", "6", "7", "8", "9", "0", ".")
|
||||
|
||||
|
||||
def _is_size_or_code(token: str) -> bool:
|
||||
if token.isdigit():
|
||||
return True
|
||||
digits = "".join(char for char in token if char.isdigit())
|
||||
letters = "".join(char for char in token if char.isalpha())
|
||||
return bool(digits) and letters in _UNITS # "1KG", "500ML", "2LT"
|
||||
|
||||
|
||||
def commercial_tokens(description) -> set:
|
||||
"""Stable commercial lexemes: units/sizes/colors/refs removed, stems kept."""
|
||||
tokens = []
|
||||
skip_next = False
|
||||
for token in normalize_description(description).split():
|
||||
if skip_next:
|
||||
skip_next = False
|
||||
continue
|
||||
if token in ("TAM", "COR", "REF", "COD", "MOD"):
|
||||
skip_next = True
|
||||
continue
|
||||
if token in _DISCARD or _is_size_or_code(token):
|
||||
continue
|
||||
tokens.append(token)
|
||||
return {_stem_safe(token) for token in tokens}
|
||||
|
||||
|
||||
def _stem_safe(token: str) -> str:
|
||||
from .fingerprint import _stem
|
||||
return _stem(token)
|
||||
|
||||
|
||||
def expand_stems(stems: set, lexicon: dict) -> set:
|
||||
expanded = set(stems)
|
||||
for stem in stems:
|
||||
target = lexicon.get(stem)
|
||||
if target:
|
||||
expanded.add(target)
|
||||
return expanded
|
||||
|
||||
|
||||
def derive_abbreviation_lexicon(corpus: dict, min_support: int = 3) -> dict:
|
||||
"""corpus: {commercial_description: ncm}. Statistical abbreviations only: a short token
|
||||
(3-5 chars) that is a strict prefix of a longer token, where BOTH forms occur across
|
||||
distinct products that share the same NCM. Support = (#products with the short form) +
|
||||
(#products with the long form); accepted when support >= min_support and both >= 1."""
|
||||
by_ncm: dict = {}
|
||||
for description, ncm in corpus.items():
|
||||
tokens = set(normalize_description(description).split())
|
||||
by_ncm.setdefault(ncm, []).append(tokens)
|
||||
best: dict = {}
|
||||
for descriptions in by_ncm.values():
|
||||
if len(descriptions) < 2:
|
||||
continue
|
||||
all_tokens = set().union(*descriptions)
|
||||
for short in all_tokens:
|
||||
if not (3 <= len(short) <= 5):
|
||||
continue
|
||||
short_count = sum(1 for tokens in descriptions if short in tokens)
|
||||
if short_count < 1:
|
||||
continue
|
||||
for long_token in all_tokens:
|
||||
if len(long_token) > len(short) and long_token.startswith(short):
|
||||
long_count = sum(1 for tokens in descriptions if long_token in tokens)
|
||||
support = short_count + long_count
|
||||
if support >= min_support and long_count >= 1 and short_count >= 1:
|
||||
current = best.get(short)
|
||||
if current is None or support > current[1]:
|
||||
best[short] = (long_token, support)
|
||||
return {short: target for short, (target, _) in best.items()}
|
||||
|
||||
|
||||
# ---------------- enriched official corpus (sections 5-6) ----------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class EnrichedNcmCorpus:
|
||||
documents: dict # code -> enriched stems set
|
||||
descriptions: dict # code -> official description
|
||||
positions: dict # 4-digit position -> enriched stems (chapter + position)
|
||||
children: dict # 4-digit position -> [8-digit codes]
|
||||
|
||||
@staticmethod
|
||||
def build(session: Session) -> "EnrichedNcmCorpus":
|
||||
dataset = session.scalar(select(Dataset).where(Dataset.kind == "NCM", Dataset.status == "ACTIVE"))
|
||||
if dataset is None:
|
||||
return EnrichedNcmCorpus({}, {}, {}, {})
|
||||
entries = session.scalars(select(NcmEntry).where(NcmEntry.dataset_id == dataset.id)).all()
|
||||
by_code = {entry.code: entry.description for entry in entries}
|
||||
documents, positions, children = {}, {}, {}
|
||||
for code, description in by_code.items():
|
||||
if len(code) != 8:
|
||||
continue
|
||||
ancestors = [text for text in (by_code.get(code[:2]), by_code.get(code[:4]), by_code.get(code[:6])) if text]
|
||||
documents[code] = token_stems(description) | set().union(*[token_stems(text) for text in ancestors]) if ancestors else token_stems(description)
|
||||
positions.setdefault(code[:4], token_stems(by_code.get(code[:4], "")) | token_stems(by_code.get(code[:2], "")))
|
||||
children.setdefault(code[:4], []).append(code)
|
||||
return EnrichedNcmCorpus(documents, by_code, positions, children)
|
||||
|
||||
def document(self, code: str) -> str:
|
||||
return " ".join(sorted(self.documents.get(code, set())))
|
||||
|
||||
def items(self):
|
||||
return list(self.documents.items())
|
||||
|
||||
|
||||
def _score(stems: set, document_stems: set, idf: dict | None = None) -> float:
|
||||
if not stems:
|
||||
return 0.0
|
||||
if idf:
|
||||
matched = sum(idf.get(stem, 1.0) for stem in stems if stem in document_stems)
|
||||
total = sum(idf.get(stem, 1.0) for stem in stems)
|
||||
return matched / total
|
||||
return len(stems & document_stems) / len(stems)
|
||||
|
||||
|
||||
def hierarchical_candidates(corpus: EnrichedNcmCorpus, description, k: int = 20, max_positions: int = 15):
|
||||
"""Two-stage: rank 4-digit positions first, then their 8-digit descendants (section 5)."""
|
||||
stems = commercial_tokens(description)
|
||||
if not stems:
|
||||
return []
|
||||
position_scores = sorted(((_score(stems, doc), position) for position, doc in corpus.positions.items()), reverse=True)
|
||||
ranked = []
|
||||
for position_score, position in position_scores[:max_positions]:
|
||||
if position_score <= 0:
|
||||
continue
|
||||
for code in corpus.children.get(position, []):
|
||||
code_score = _score(stems, corpus.documents[code])
|
||||
if code_score > 0:
|
||||
ranked.append((code, corpus.descriptions.get(code, ""), round(code_score, 4)))
|
||||
ranked.sort(key=lambda row: (-row[2], row[0]))
|
||||
return ranked[:k]
|
||||
|
||||
|
||||
# ---------------- KB neighbor bridge (sections 2-3) ----------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class KbNeighborIndex:
|
||||
entries: list # (product_id, stems, ncm, tenant_ids:set, observation_count, has_conflict)
|
||||
|
||||
@staticmethod
|
||||
def build(session: Session) -> "KbNeighborIndex":
|
||||
validated = {}
|
||||
for knowledge in session.scalars(select(KbKnowledge).where(KbKnowledge.knowledge_key == "validated_ncm")).all():
|
||||
validated.setdefault(knowledge.kb_product_id, set()).add(knowledge.value)
|
||||
conflicts = {product_id: len(values) > 1 for product_id, values in validated.items()}
|
||||
products = {product.id: product for product in session.scalars(select(KbProduct)).all()}
|
||||
observations = session.scalars(select(KbObservation)).all()
|
||||
per_product_tenants: dict = {}
|
||||
for observation in observations:
|
||||
per_product_tenants.setdefault(observation.kb_product_id, set()).add(observation.tenant_id)
|
||||
entries = []
|
||||
for product_id, values in validated.items():
|
||||
if conflicts.get(product_id):
|
||||
continue # conflicting knowledge never feeds neighbors
|
||||
for value in values:
|
||||
product = products.get(product_id)
|
||||
if product is None or not product.normalized_description:
|
||||
continue
|
||||
tenants = per_product_tenants.get(product_id, set())
|
||||
entries.append((product_id, commercial_tokens(product.normalized_description), value, tenants, len(tenants), False))
|
||||
return KbNeighborIndex(entries)
|
||||
|
||||
def corpus(self) -> dict:
|
||||
return {" ".join(sorted(stems)): ncm for _, stems, ncm, _, _, _ in self.entries}
|
||||
|
||||
|
||||
def kb_neighbor_candidates(index: KbNeighborIndex, description, k: int = 10, exclude_product_id: int | None = None):
|
||||
"""Aggregate NCMs of lexically similar known products. `exclude_product_id` implements
|
||||
leave-one-out evaluation: the queried product never retrieves itself. Returns
|
||||
(ncm, neighbor_score, observation_count, tenant_count) — agreement is the union of
|
||||
distinct tenants behind the contributing neighbors, reported as evidence, never used
|
||||
as an automatic majority decision."""
|
||||
stems = commercial_tokens(description)
|
||||
if not stems:
|
||||
return []
|
||||
best: dict = {}
|
||||
for product_id, neighbor_stems, ncm, tenant_ids, observation_count, _ in index.entries:
|
||||
if exclude_product_id is not None and product_id == exclude_product_id:
|
||||
continue
|
||||
similarity = _score(stems, neighbor_stems)
|
||||
if similarity <= 0:
|
||||
continue
|
||||
current = best.get(ncm)
|
||||
if current is None:
|
||||
best[ncm] = [ncm, round(similarity, 4), observation_count, set(tenant_ids)]
|
||||
else:
|
||||
current[1] = max(current[1], round(similarity, 4))
|
||||
current[3] |= set(tenant_ids)
|
||||
ranked = sorted(((row[0], row[1], row[2], len(row[3])) for row in best.values()), key=lambda row: (-row[1], -row[3], row[0]))
|
||||
return ranked[:k]
|
||||
|
||||
|
||||
# ---------------- hybrid ranking (section 4) ----------------
|
||||
|
||||
OFFICIAL_WEIGHT, KB_WEIGHT = 0.3, 0.7 # tuned on the V2 benchmark (KB bridge carries the signal;
|
||||
# the official component breaks ties and covers products absent from the KB)
|
||||
|
||||
|
||||
def hybrid_candidates(corpus: EnrichedNcmCorpus, index: KbNeighborIndex, description, k: int = 20, lexicon: dict | None = None, exclude_product_id: int | None = None):
|
||||
stems = expand_stems(commercial_tokens(description), lexicon or {})
|
||||
official = hierarchical_candidates(corpus, " ".join(sorted(stems)) or description, k=k)
|
||||
neighbors = kb_neighbor_candidates(index, description, k=k, exclude_product_id=exclude_product_id)
|
||||
combined: dict = {}
|
||||
for code, text, score in official:
|
||||
combined[code] = [code, text, OFFICIAL_WEIGHT * score, score, 0.0, 0]
|
||||
for code, similarity, observation_count, tenant_count in neighbors:
|
||||
row = combined.setdefault(code, [code, "", 0.0, 0.0, 0.0, 0])
|
||||
row[2] += KB_WEIGHT * similarity
|
||||
row[4] = similarity
|
||||
row[5] = tenant_count
|
||||
ranked = sorted(combined.values(), key=lambda row: (-round(row[2], 4), row[0]))
|
||||
return [(row[0], row[1], round(row[2], 4), row[3], row[4], row[5]) for row in ranked[:k]]
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Retrieval V3 evidence layer. It ranks candidates only; it never decides fiscal truth."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
from .fingerprint import normalize_description, token_stems
|
||||
from .retrieval import commercial_tokens, hybrid_candidates
|
||||
|
||||
|
||||
def _char_similarity(left, right):
|
||||
return SequenceMatcher(None, normalize_description(left), normalize_description(right)).ratio()
|
||||
|
||||
|
||||
def retrieval_v3_candidates(corpus, index, description, metadata=None, k=20, lexicon=None, exclude_product_id=None):
|
||||
"""Add optional product evidence to V2 while preserving V2 as the safe baseline."""
|
||||
metadata = metadata or {}
|
||||
base = hybrid_candidates(corpus, index, description, k=max(k, 50), lexicon=lexicon, exclude_product_id=exclude_product_id)
|
||||
gtin_ncm = str(metadata.get("gtin_ncm") or "")
|
||||
group_ncms = {str(value) for value in (metadata.get("group_ncms") or [])}
|
||||
rarity = metadata.get("token_rarity") or {}
|
||||
query_stems = commercial_tokens(description)
|
||||
rows = []
|
||||
for code, text, score, official_score, kb_score, tenant_count in base:
|
||||
evidence = 0.0
|
||||
if gtin_ncm and code == gtin_ncm:
|
||||
evidence += 1.0
|
||||
if code in group_ncms:
|
||||
evidence += 0.05
|
||||
matched_rarity = sum(float(rarity.get(token, 1.0)) for token in query_stems if token in token_stems(text))
|
||||
evidence += min(0.05, matched_rarity / 100.0)
|
||||
rows.append((code, text, round(score + evidence, 4), official_score, kb_score, tenant_count))
|
||||
rows.sort(key=lambda row: (-row[2], row[0]))
|
||||
return rows[:k]
|
||||
|
||||
|
||||
def candidate_fingerprint(payload):
|
||||
return hashlib.sha256(json.dumps(payload, sort_keys=True, ensure_ascii=False, separators=(",", ":")).encode()).hexdigest()
|
||||
|
||||
|
||||
def prepare_candidate_set(work_item, candidates, *, dataset_versions=None, gtin_evidence=None, kb_neighbors=None, hierarchical_context=None):
|
||||
"""Build the final LLM-ready evidence package without selecting a candidate."""
|
||||
payload = {
|
||||
"task_type": work_item.task_type,
|
||||
"dedup_key": work_item.dedup_key,
|
||||
"normalized_product": normalize_description(work_item.payload.get("description", "")),
|
||||
"top20_candidate_ncms": [
|
||||
{"ncm": row[0], "official_description": row[1], "score": row[2], "official_score": row[3], "kb_score": row[4], "tenant_count": row[5]}
|
||||
for row in candidates[:20]
|
||||
],
|
||||
"official_descriptions": {row[0]: row[1] for row in candidates[:20]},
|
||||
"hierarchical_context": hierarchical_context or {},
|
||||
"kb_neighbors": kb_neighbors or [],
|
||||
"gtin_evidence": gtin_evidence or {},
|
||||
"dataset_versions": dict(dataset_versions or {}),
|
||||
}
|
||||
payload["input_fingerprint"] = candidate_fingerprint(payload)
|
||||
return payload
|
||||
|
||||
|
||||
def compact_product_role_context(rows, limit=12):
|
||||
"""Keep representative role evidence bounded; no role is inferred."""
|
||||
unique = []
|
||||
seen = set()
|
||||
for row in rows:
|
||||
description = normalize_description(row.get("description"))
|
||||
if not description or description in seen:
|
||||
continue
|
||||
seen.add(description)
|
||||
unique.append({"description": description, "group": row.get("group"), "subgroup": row.get("subgroup"), "operations": row.get("operations", [])})
|
||||
if len(unique) >= limit:
|
||||
break
|
||||
return {"examples": unique, "evidence_only": True, "decision": None}
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Residual-question dedup: group pending cells by the KNOWLEDGE needed, not per cell.
|
||||
|
||||
- INVALID_NCM -> one work item per product fingerprint (same product in N databases collapses)
|
||||
- CEST/EX_TIPI -> one work item per (doubt, NCM): all databases sharing that NCM collapse
|
||||
- PRODUCT_ROLE -> tenant-specific by contract: one work item per (company, group)
|
||||
- Everything else (origin, ST retention, company profile, duplicate key) never becomes
|
||||
an AI work item.
|
||||
"""
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
_INVALID_NCM, _ROLE = "ncm", "product_role"
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkItem:
|
||||
task_type: str
|
||||
dedup_key: str
|
||||
question_refs: list = field(default_factory=list)
|
||||
cells_covered: int = 0
|
||||
payload: dict = field(default_factory=dict)
|
||||
kb_product_id: int | None = None
|
||||
|
||||
|
||||
def build_work_items(questions, product_fingerprints=None, canonical_products=None) -> list[WorkItem]:
|
||||
"""`product_fingerprints` maps "{DATABASE}:{erp_code}" -> KB fingerprint (from the seed pass)."""
|
||||
product_fingerprints = product_fingerprints or {}
|
||||
canonical_products = canonical_products or {}
|
||||
items: dict = {}
|
||||
|
||||
def _get(task_type, dedup_key, ref, cells):
|
||||
item = items.get(dedup_key)
|
||||
if item is None:
|
||||
item = WorkItem(task_type=task_type, dedup_key=dedup_key)
|
||||
items[dedup_key] = item
|
||||
item.question_refs.append(ref)
|
||||
item.cells_covered += cells
|
||||
return item
|
||||
|
||||
for question in questions:
|
||||
key = question.get("KEY")
|
||||
scope = question.get("SCOPE")
|
||||
cells = int(question.get("CELLS") or question.get("PRODUCTS_AFFECTED") or 0)
|
||||
ref = question.get("QUESTION_ID") or f"{question.get('DATABASE')}:{scope}:{question.get('NCM') or question.get('GROUP')}:{key}"
|
||||
if key in ("cest", "ex_tipi") and scope == "NCM":
|
||||
ncm = str(question.get("NCM") or "").strip()
|
||||
if ncm:
|
||||
item = _get(key.upper(), f"{key}:{ncm}", ref, cells)
|
||||
item.payload.setdefault("ncm", ncm)
|
||||
item.payload["description"] = question.get("EXAMPLES", "").split(";")[0].strip() if question.get("EXAMPLES") else ""
|
||||
elif key == _INVALID_NCM and scope == "PRODUCT":
|
||||
database = question.get("DATABASE", "")
|
||||
erp_code = str(question.get("DATABASE_CODE") or "").strip()
|
||||
fingerprint = product_fingerprints.get(f"{database}:{erp_code}")
|
||||
fingerprint = canonical_products.get(f"{database}:{erp_code}", fingerprint)
|
||||
dedup = f"invalid_ncm:{fingerprint}" if fingerprint else f"invalid_ncm:{database}:{erp_code}"
|
||||
item = _get("INVALID_NCM", dedup, ref, cells)
|
||||
item.payload.setdefault("erp_code", erp_code)
|
||||
item.payload["description"] = question.get("EXAMPLES", "").split(";")[0].strip() if question.get("EXAMPLES") else ""
|
||||
elif key == _ROLE and scope == "GROUP":
|
||||
company = question.get("COMPANY", "")
|
||||
group = str(question.get("GROUP") or "").strip()
|
||||
if group:
|
||||
normalized_group = " ".join(str(group).upper().split())
|
||||
item = _get("PRODUCT_ROLE", f"product_role:{company}:{normalized_group}", ref, cells)
|
||||
item.payload.setdefault("company_key", company)
|
||||
item.payload["group"] = group
|
||||
return list(items.values())
|
||||
@@ -0,0 +1,475 @@
|
||||
"""Authenticated version-one Hub routes."""
|
||||
import hashlib, json, logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
import csv
|
||||
import io
|
||||
from sqlalchemy import select
|
||||
from .auth import authenticate, bootstrap
|
||||
from .classification import ENGINE_VERSION, Classifier
|
||||
from .config import settings
|
||||
from .datasets.service import DatasetService
|
||||
from .jobs import SYNC_KINDS, claim_next_job, complete_job, create_job, fail_job, schedule_automatic_sync
|
||||
from .rtc_audit import job_authorizations, record_rtc_write_audits
|
||||
from .cfop_ippt_audit import job_authorizations as cfop_ippt_job_authorizations, record_cfop_ippt_write_audits
|
||||
from .blocked_products import list_blockers, resolve_blocker, resolve_product_blockers, resolve_stale_fiscal_blockers, upsert_blocker, upsert_blockers_bulk
|
||||
from .context_keys import validate_entry
|
||||
from .models import AgentSnapshot, BlockedProduct, FiscalContextEntry, ClassificationCache, ClassificationRequest, ClassificationResult, Dataset, Installation, Job
|
||||
from .events import record_event
|
||||
from .installation_identity import compute_agent_health, compute_version_drift, reference_agent_version, sanitize_registration
|
||||
from .control_plane import list_jobs
|
||||
from .human_auth import require_human
|
||||
from .enrichment.api import router as enrichment_router
|
||||
|
||||
logger = logging.getLogger("gti_fiscal_hub")
|
||||
|
||||
def router(session_factory, bootstrap_token):
|
||||
routes = APIRouter(prefix="/v1")
|
||||
def principal(request: Request):
|
||||
header = request.headers.get("Authorization", "")
|
||||
if not header.startswith("Bearer "): raise HTTPException(401, "Bearer token required")
|
||||
session = session_factory()
|
||||
try:
|
||||
if bootstrap_token: bootstrap(session, bootstrap_token)
|
||||
agent = authenticate(session, header.removeprefix("Bearer "))
|
||||
if not agent: raise HTTPException(401, "Invalid token")
|
||||
return session, agent
|
||||
except Exception:
|
||||
session.close(); raise
|
||||
def decide(payload, identity):
|
||||
session, agent = identity; service = DatasetService(session)
|
||||
supplied = payload.get("tenant_id")
|
||||
if supplied is not None and str(supplied) != str(agent.tenant_id): raise HTTPException(403, "Tenant does not match token")
|
||||
product = payload.get("product", payload)
|
||||
# Cheap key first (engine version + dataset versions + input), then the cache; the heavy classifier runs only on a MISS.
|
||||
engine_version, dataset_versions = ENGINE_VERSION, service.dataset_versions()
|
||||
canonical = {"tenant": agent.tenant_id, "product": product, "context": payload.get("context", {}), "engine": engine_version, "datasets": dataset_versions}
|
||||
fingerprint = hashlib.sha256(json.dumps(canonical, sort_keys=True, default=str, separators=(",", ":")).encode()).hexdigest()
|
||||
cached = session.get(ClassificationCache, fingerprint)
|
||||
if cached:
|
||||
result = dict(cached.result); result["provenance"] = {**result["provenance"], "cache_hit": True}; return result
|
||||
decision = Classifier(service).classify(product)
|
||||
if decision["engine_version"] != engine_version or decision["dataset_versions"] != dataset_versions: # never store under a key that does not describe the decision
|
||||
canonical = {**canonical, "engine": decision["engine_version"], "datasets": decision["dataset_versions"]}
|
||||
fingerprint = hashlib.sha256(json.dumps(canonical, sort_keys=True, default=str, separators=(",", ":")).encode()).hexdigest()
|
||||
result = {**decision, "request_fingerprint": fingerprint, "provenance": {"cache_hit": False, "dataset_versions": decision["dataset_versions"]}}
|
||||
request = ClassificationRequest(tenant_id=agent.tenant_id, agent_id=agent.id, fingerprint=fingerprint, payload=payload); session.add(request); session.flush()
|
||||
session.add(ClassificationResult(request_id=request.id, result=result)); session.add(ClassificationCache(fingerprint=fingerprint, tenant_id=agent.tenant_id, result=result)); session.commit(); return result
|
||||
@routes.post("/validate/ncm")
|
||||
def validate_ncm(payload: dict, identity=Depends(principal)):
|
||||
return decide({"product": {"current_ncm": payload.get("ncm"), "description": ""}, "tenant_id": payload.get("tenant_id")}, identity)
|
||||
@routes.post("/classify")
|
||||
def classify(payload: dict, identity=Depends(principal)):
|
||||
try: return decide(payload, identity)
|
||||
finally: identity[0].close()
|
||||
@routes.post("/classify/batch")
|
||||
def classify_batch(payload: dict, identity=Depends(principal)):
|
||||
try: return {"results": [decide({**payload, "product": product}, identity) for product in payload.get("products", [])]}
|
||||
finally: identity[0].close()
|
||||
@routes.get("/datasets/status")
|
||||
def status(identity=Depends(principal)):
|
||||
session, _ = identity
|
||||
try: return [{"id": item.id, "kind": item.kind, "status": item.status, "sha256": item.sha256, "record_count": item.record_count} for item in session.scalars(select(Dataset)).all()]
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/reference/cfop/{code}")
|
||||
def reference_cfop(code: str, identity=Depends(principal)):
|
||||
session, _ = identity
|
||||
try:
|
||||
rows = DatasetService(session).cfop_lookup(code=code)
|
||||
return {"found": bool(rows), "entries": [{"code": r.code, "title": r.title, "description": r.description} for r in rows]}
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/reference/ncm_utrib/{ncm}")
|
||||
def reference_ncm_utrib(ncm: str, identity=Depends(principal)):
|
||||
session, _ = identity
|
||||
try:
|
||||
row = DatasetService(session).ncm_utrib_for_ncm(ncm)
|
||||
return {"found": row is not None, "utrib_abbreviation": row.utrib_abbreviation if row else None}
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/reference/anp/{cprod_anp}")
|
||||
def reference_anp(cprod_anp: str, identity=Depends(principal)):
|
||||
session, _ = identity
|
||||
try: return {"found": DatasetService(session).anp_code_exists(cprod_anp)}
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/reference/fcp/{uf}")
|
||||
def reference_fcp(uf: str, identity=Depends(principal)):
|
||||
session, _ = identity
|
||||
try:
|
||||
row = DatasetService(session).fcp_for_uf(uf)
|
||||
return {"found": row is not None, "aliq_1": row.aliq_1 if row else None, "observacao": row.observacao if row else None}
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/reference/rtc_cclasstrib/{code}")
|
||||
def reference_rtc_cclasstrib(code: str, identity=Depends(principal)):
|
||||
session, _ = identity
|
||||
try:
|
||||
rows = DatasetService(session).rtc_cclasstrib(code=code)
|
||||
return {"found": bool(rows), "entries": [{"cst": r.cst, "cclasstrib": r.cclasstrib, "name": r.name, "description": r.description, "tipo_aliquota": r.tipo_aliquota, "p_red_ibs": r.p_red_ibs, "p_red_cbs": r.p_red_cbs, "d_ini_vig": r.d_ini_vig, "d_fim_vig": r.d_fim_vig} for r in rows]}
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/reference/rtc_cbs_rate/{year}")
|
||||
def reference_rtc_cbs_rate(year: int, identity=Depends(principal)):
|
||||
session, _ = identity
|
||||
try:
|
||||
row = DatasetService(session).rtc_cbs_rate(year)
|
||||
return {"found": row is not None, "year": year, "rate": row.rate if row else None, "rate_text": row.rate_text if row else None}
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/reference/fiscal_codes/{domain}")
|
||||
def reference_fiscal_codes(domain: str, identity=Depends(principal)):
|
||||
session, _ = identity
|
||||
try:
|
||||
rows = DatasetService(session).fiscal_codes(domain.upper())
|
||||
return {"found": bool(rows), "domain": domain.upper(), "codes": sorted(r.code for r in rows)}
|
||||
finally: session.close()
|
||||
|
||||
@routes.post("/context/import")
|
||||
def context_import(payload: dict, identity=Depends(principal)):
|
||||
"""Batch upsert of operator fiscal context: {"company_key": "...", "entries": [{scope, scope_key, key, value, reason}]}. All-or-nothing."""
|
||||
session, agent = identity
|
||||
try:
|
||||
company_key = str(payload.get("company_key") or "").strip()
|
||||
if not company_key:
|
||||
raise HTTPException(400, "company_key is required")
|
||||
entries, errors = [], []
|
||||
for i, raw in enumerate(payload.get("entries") or []):
|
||||
entry, error = validate_entry(raw)
|
||||
(errors.append({"index": i, "error": error, "entry": raw}) if error else entries.append(entry))
|
||||
if errors:
|
||||
raise HTTPException(422, {"errors": errors[:50], "error_count": len(errors)})
|
||||
created = updated = 0
|
||||
for e in entries:
|
||||
row = session.scalar(select(FiscalContextEntry).where(FiscalContextEntry.tenant_id == agent.tenant_id, FiscalContextEntry.company_key == company_key,
|
||||
FiscalContextEntry.scope == e["scope"], FiscalContextEntry.scope_key == e["scope_key"], FiscalContextEntry.key == e["key"]))
|
||||
if row:
|
||||
row.value, row.reason, row.updated_at = e["value"], e["reason"], datetime.now(timezone.utc); updated += 1
|
||||
else:
|
||||
session.add(FiscalContextEntry(tenant_id=agent.tenant_id, company_key=company_key, source="OPERATOR", **e)); created += 1
|
||||
session.commit()
|
||||
return {"created": created, "updated": updated}
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/context")
|
||||
def context_list(company_key: str, identity=Depends(principal)):
|
||||
session, agent = identity
|
||||
try:
|
||||
rows = session.scalars(select(FiscalContextEntry).where(FiscalContextEntry.tenant_id == agent.tenant_id, FiscalContextEntry.company_key == company_key)).all()
|
||||
return {"company_key": company_key, "entries": [{"scope": r.scope, "scope_key": r.scope_key, "key": r.key, "value": r.value, "source": r.source, "reason": r.reason,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None, "updated_at": r.updated_at.isoformat() if r.updated_at else None} for r in rows]}
|
||||
finally: session.close()
|
||||
|
||||
@routes.post("/agents/heartbeat")
|
||||
def heartbeat(payload: dict, identity=Depends(principal)):
|
||||
session, agent = identity
|
||||
try:
|
||||
try:
|
||||
supplied = sanitize_registration(payload)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise HTTPException(422, f"invalid registration metadata: {error}") from error
|
||||
now = datetime.now(timezone.utc)
|
||||
agent.last_seen_at = now
|
||||
agent.last_status = supplied.get("status") or payload.get("status")
|
||||
if payload.get("schema_fingerprint"):
|
||||
agent.schema_fingerprint = payload["schema_fingerprint"]
|
||||
if payload.get("agent_version"):
|
||||
agent.agent_version = payload["agent_version"]
|
||||
installation_key = supplied.get("installation_id") or str(uuid.uuid4())
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent.id))
|
||||
if installation is not None and installation.installation_id != installation_key:
|
||||
raise HTTPException(409, "installation_id conflicts with registered Agent")
|
||||
if installation is None:
|
||||
installation = Installation(agent_id=agent.id, tenant_id=agent.tenant_id, installation_id=installation_key, display_name=supplied.get("display_name") or agent.name,
|
||||
features_configured=True, corrector_enabled=True) # legacy/bootstrap self-registration keeps the pre-existing behaviour
|
||||
session.add(installation); session.flush()
|
||||
fields = {"display_name": supplied.get("display_name"), "customer_code": supplied.get("customer_code"), "notes": supplied.get("notes"), "hostname": supplied.get("hostname"), "windows_version": supplied.get("windows_version"), "machine_fingerprint": supplied.get("machine_fingerprint"), "company_code": supplied.get("company_code"), "company_name": supplied.get("company_name"), "company_cnpj_masked": supplied.get("company_cnpj_masked"), "company_uf": supplied.get("company_uf"), "company_crt": supplied.get("company_crt"), "company_contribuinte_ipi": supplied.get("company_contribuinte_ipi"), "firebird_version": supplied.get("firebird_version"), "firebird_ods": supplied.get("firebird_ods"), "database_identifier": supplied.get("database_identifier"), "capabilities": supplied.get("capabilities"), "fiscal_engine_version": supplied.get("fiscal_engine_version"), "dataset_versions": supplied.get("dataset_versions"), "last_status": supplied.get("status") or payload.get("status"), "last_seen_at": now}
|
||||
for key, value in fields.items():
|
||||
if value is not None: setattr(installation, key, value)
|
||||
if supplied.get("started_at"):
|
||||
try:
|
||||
installation.started_at = datetime.fromisoformat(str(supplied["started_at"]).replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
raise HTTPException(422, "started_at must be ISO-8601")
|
||||
snapshot = session.scalar(select(AgentSnapshot).where(AgentSnapshot.installation_id == installation.id))
|
||||
if snapshot is None:
|
||||
snapshot = AgentSnapshot(installation_id=installation.id)
|
||||
session.add(snapshot)
|
||||
if payload.get("coverage"):
|
||||
coverage = payload["coverage"]
|
||||
snapshot.total_products = int(coverage.get("total_products", coverage.get("total", snapshot.total_products)) or 0)
|
||||
snapshot.fiscally_complete = int(coverage.get("fiscally_complete", coverage.get("complete", snapshot.fiscally_complete)) or 0)
|
||||
snapshot.safe_blocked = int(coverage.get("safe_blocked", snapshot.safe_blocked) or 0)
|
||||
snapshot.failed_products = int(coverage.get("failed_products", snapshot.failed_products) or 0)
|
||||
snapshot.coverage_percent = float(coverage.get("coverage_percent", snapshot.coverage_percent) or 0)
|
||||
snapshot.blockers = coverage.get("blockers", snapshot.blockers) or {}
|
||||
snapshot.version_drift = compute_version_drift(agent.agent_version, reference_agent_version(session))
|
||||
snapshot.agent_health, snapshot.health_reason = compute_agent_health(installation, supplemental=snapshot)
|
||||
session.add(agent); session.flush()
|
||||
record_event(session, tenant_id=agent.tenant_id, installation_id=installation.id, agent_id=agent.id, severity="INFO", event_type="HEARTBEAT", message="Agent heartbeat", context={"status": agent.last_status, "agent_version": agent.agent_version})
|
||||
session.commit()
|
||||
# Central scheduler: exactly one INITIAL_SYNC after the first heartbeat, then
|
||||
# one SYNC_INCREMENTAL per interval — idempotent, no local cron/toggle needed.
|
||||
if agent.enabled:
|
||||
schedule_automatic_sync(session, agent, installation, snapshot)
|
||||
logger.info("heartbeat agent_id=%s status=%s last_job_id=%s", agent.id, agent.last_status, payload.get("last_job_id"))
|
||||
return {"agent_id": agent.id, "installation_id": installation.installation_id, "last_seen_at": agent.last_seen_at.isoformat(), "enabled": agent.enabled,
|
||||
"processing_enabled": bool(installation.processing_enabled), "sync_interval_seconds": installation.sync_interval_seconds}
|
||||
finally: session.close()
|
||||
|
||||
@routes.post("/agents/register")
|
||||
def register_agent(payload: dict, identity=Depends(principal)):
|
||||
return heartbeat({**payload, "status": payload.get("status", "IDLE")}, identity)
|
||||
|
||||
@routes.post("/agents/events")
|
||||
def agent_event(payload: dict, identity=Depends(principal)):
|
||||
session, agent = identity
|
||||
try:
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent.id))
|
||||
if installation is None:
|
||||
raise HTTPException(409, "Agent must heartbeat/register before sending events")
|
||||
event = record_event(session, tenant_id=agent.tenant_id, installation_id=installation.id, agent_id=agent.id, severity=payload.get("severity", "INFO"), event_type=payload.get("event_type", "HEARTBEAT"), message=payload.get("message", ""), context=payload.get("context") or {}, job_id=payload.get("job_id"), product_code=payload.get("product_code"), blocker_id=payload.get("blocker_id"))
|
||||
session.commit()
|
||||
return {"event_id": event.id}
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/agents/me/identity")
|
||||
def agent_identity(identity=Depends(principal)):
|
||||
"""Return only the authenticated Agent's existing installation identity."""
|
||||
session, agent = identity
|
||||
try:
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent.id))
|
||||
last_seen = agent.last_seen_at
|
||||
if last_seen is not None and last_seen.tzinfo is None:
|
||||
last_seen = last_seen.replace(tzinfo=timezone.utc)
|
||||
online = last_seen is not None and (datetime.now(timezone.utc) - last_seen).total_seconds() <= settings.agent_online_threshold_seconds
|
||||
return {
|
||||
"agent_id": agent.id,
|
||||
"installation_id": installation.installation_id if installation else None,
|
||||
"hostname": installation.hostname if installation else None,
|
||||
"version": agent.agent_version,
|
||||
"status": "ONLINE" if online else "OFFLINE",
|
||||
}
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@routes.get("/agents/me/authorizations")
|
||||
def agent_authorizations(identity=Depends(principal)):
|
||||
"""Current central RTC write authorization of the authenticated Agent's own installation (read by the Agent right before it writes). Nothing else is exposed."""
|
||||
session, agent = identity
|
||||
try:
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent.id))
|
||||
if installation is None:
|
||||
return {"rtc_write_mode": "DISABLED", "cfop_ippt_write_mode": "DISABLED"}
|
||||
return {**job_authorizations(session, installation), **cfop_ippt_job_authorizations(installation)}
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@routes.get("/agents/me")
|
||||
def agent_status(identity=Depends(principal)):
|
||||
session, agent = identity
|
||||
try:
|
||||
last_seen = agent.last_seen_at
|
||||
if last_seen is not None and last_seen.tzinfo is None:
|
||||
last_seen = last_seen.replace(tzinfo=timezone.utc) # SQLite (tests) drops tz info
|
||||
online = last_seen is not None and (datetime.now(timezone.utc) - last_seen).total_seconds() <= settings.agent_online_threshold_seconds
|
||||
return {
|
||||
"agent_id": agent.id, "company_id": agent.tenant_id, "name": agent.name, "enabled": agent.enabled,
|
||||
"last_seen_at": agent.last_seen_at.isoformat() if agent.last_seen_at else None,
|
||||
"last_status": agent.last_status, "schema_fingerprint": agent.schema_fingerprint,
|
||||
"agent_version": agent.agent_version, "status": "ONLINE" if online else "OFFLINE",
|
||||
}
|
||||
finally: session.close()
|
||||
|
||||
def _blocked_view(row):
|
||||
return {
|
||||
"id": row.id, "tenant_id": row.tenant_id, "company": row.company, "product_code": row.product_code,
|
||||
"installation_db_id": row.installation_id,
|
||||
"gtin": row.gtin, "description": row.description, "blocker_type": row.blocker_type, "field": row.field,
|
||||
"reason_code": row.reason_code, "evidence_summary": row.evidence_summary, "first_seen_at": row.first_seen_at.isoformat() if row.first_seen_at else None,
|
||||
"last_seen_at": row.last_seen_at.isoformat() if row.last_seen_at else None, "retry_count": row.retry_count,
|
||||
"next_retry_at": row.next_retry_at.isoformat() if row.next_retry_at else None, "resolved_at": row.resolved_at.isoformat() if row.resolved_at else None,
|
||||
"resolution_source": row.resolution_source,
|
||||
}
|
||||
|
||||
@routes.get("/blockers")
|
||||
def blockers_list(blocker_type: str | None = None, field: str | None = None, identity=Depends(principal)):
|
||||
session, agent = identity
|
||||
try:
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent.id))
|
||||
return {"blockers": [_blocked_view(row) for row in list_blockers(session, agent.tenant_id, blocker_type=blocker_type, field=field, installation_id=installation.id if installation else None)]}
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@routes.get("/blockers/export.csv")
|
||||
def blockers_export(identity=Depends(principal)):
|
||||
session, agent = identity
|
||||
try:
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent.id))
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(["id", "company", "product_code", "gtin", "blocker_type", "field", "reason_code", "retry_count", "first_seen_at", "last_seen_at"])
|
||||
for row in list_blockers(session, agent.tenant_id, installation_id=installation.id if installation else None):
|
||||
writer.writerow([row.id, row.company, row.product_code, row.gtin or "", row.blocker_type, row.field, row.reason_code, row.retry_count, row.first_seen_at.isoformat() if row.first_seen_at else "", row.last_seen_at.isoformat() if row.last_seen_at else ""])
|
||||
return Response(output.getvalue(), media_type="text/csv", headers={"Content-Disposition": "attachment; filename=blocked-products.csv"})
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@routes.post("/blockers/reprocess")
|
||||
def blockers_reprocess(payload: dict, identity=Depends(principal)):
|
||||
session, agent = identity
|
||||
try:
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent.id))
|
||||
rows = list_blockers(session, agent.tenant_id, blocker_type=payload.get("blocker_type"), field=payload.get("field"), installation_id=installation.id if installation else None)
|
||||
codes = [row.product_code for row in rows if row.product_code]
|
||||
if not codes:
|
||||
return {"created": False, "reason": "NO_DUE_BLOCKERS"}
|
||||
job = create_job(session, agent, "REPROCESS_PRODUCTS", {"codigos": codes, "reason": "BLOCKER_REPROCESS"})
|
||||
return {"created": True, "job_id": job.id, "products": len(codes)}
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@routes.post("/blockers/{blocker_id}/resolve")
|
||||
def blockers_resolve(blocker_id: int, payload: dict, identity=Depends(principal)):
|
||||
session, agent = identity
|
||||
try:
|
||||
row = session.get(BlockedProduct, blocker_id)
|
||||
if row is None or row.tenant_id != agent.tenant_id:
|
||||
raise HTTPException(404, "blocker not found")
|
||||
if not resolve_blocker(session, blocker_id, payload.get("resolution_source", "OPERATOR")):
|
||||
raise HTTPException(404, "blocker not found")
|
||||
return {"resolved": True, "blocker_id": blocker_id}
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def _job_view(job):
|
||||
return {
|
||||
"job_id": job.id, "agent_id": job.agent_id, "kind": job.kind, "status": job.status,
|
||||
"payload": job.payload, "created_at": job.created_at.isoformat() if job.created_at else None,
|
||||
"started_at": job.started_at.isoformat() if job.started_at else None,
|
||||
"finished_at": job.finished_at.isoformat() if job.finished_at else None,
|
||||
"attempts": job.attempts, "result_summary": job.result_summary, "error": job.error,
|
||||
}
|
||||
|
||||
def _apply_job_outcome(session, agent, installation, job, result_summary, *, failed):
|
||||
"""After a job finishes: coverage snapshot (from the Agent's operational
|
||||
summary), sync bookkeeping and an audit-friendly event. Never stores secrets."""
|
||||
now = datetime.now(timezone.utc)
|
||||
snapshot = session.scalar(select(AgentSnapshot).where(AgentSnapshot.installation_id == installation.id))
|
||||
if snapshot is None:
|
||||
snapshot = AgentSnapshot(installation_id=installation.id); session.add(snapshot)
|
||||
snapshot.last_job_status = "FAILED" if failed else "COMPLETED"
|
||||
snapshot.last_job_at = now
|
||||
context = {"kind": job.kind}
|
||||
operational = result_summary.get("operational") if not failed else None
|
||||
if operational and job.kind in SYNC_KINDS:
|
||||
total = int(operational.get("TOTAL_PRODUCTS", 0))
|
||||
complete = int(operational.get("COMPLETE", 0)) + int(operational.get("CORRECTED", 0))
|
||||
snapshot.total_products = total
|
||||
snapshot.fiscally_complete = complete
|
||||
snapshot.safe_blocked = int(operational.get("SAFE_BLOCKED", 0))
|
||||
snapshot.failed_products = int(operational.get("FAILED_TECHNICAL", 0))
|
||||
snapshot.coverage_percent = round(100.0 * complete / total, 2) if total else 0.0
|
||||
snapshot.blockers = {state: int(count) for state, count in operational.items() if state.startswith("BLOCKED_") and count}
|
||||
snapshot.last_successful_job_at = now
|
||||
installation.last_sync_at = now
|
||||
installation.last_sync_changed = int(result_summary.get("products_changed", result_summary.get("products_processed", 0)) or 0)
|
||||
context.update({"total_products": total, "products_changed": installation.last_sync_changed, "products_processed": result_summary.get("products_processed")})
|
||||
if failed:
|
||||
context["error"] = result_summary.get("error")
|
||||
record_event(session, tenant_id=agent.tenant_id, installation_id=installation.id, agent_id=agent.id, severity="ERROR" if failed else "INFO",
|
||||
event_type="JOB_FAILED" if failed else "JOB_COMPLETED", message=f"Job {job.kind} {'failed' if failed else 'completed'}", context=context, job_id=job.id)
|
||||
session.commit()
|
||||
|
||||
@routes.post("/jobs")
|
||||
def create_job_route(payload: dict, identity=Depends(principal)):
|
||||
session, agent = identity
|
||||
try:
|
||||
kind = payload.get("kind")
|
||||
if kind not in ("REPROCESS_ALL", "REPROCESS_PRODUCTS"):
|
||||
raise HTTPException(400, "kind must be REPROCESS_ALL or REPROCESS_PRODUCTS")
|
||||
job_payload = payload.get("payload", {})
|
||||
if kind == "REPROCESS_PRODUCTS" and not job_payload.get("codigos"):
|
||||
raise HTTPException(400, "REPROCESS_PRODUCTS requires payload.codigos")
|
||||
job = create_job(session, agent, kind, job_payload)
|
||||
logger.info("job created job_id=%s agent_id=%s kind=%s", job.id, agent.id, kind)
|
||||
return _job_view(job)
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/jobs/next")
|
||||
def next_job(identity=Depends(principal)):
|
||||
session, agent = identity
|
||||
try:
|
||||
job = claim_next_job(session, agent)
|
||||
if job is None: return None
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent.id))
|
||||
if installation is not None:
|
||||
record_event(session, tenant_id=agent.tenant_id, installation_id=installation.id, agent_id=agent.id, severity="INFO", event_type="JOB_STARTED", message=f"Job {job.kind} started", context={"kind": job.kind}, job_id=job.id)
|
||||
session.commit()
|
||||
logger.info("job claimed job_id=%s agent_id=%s kind=%s", job.id, agent.id, job.kind)
|
||||
view = _job_view(job)
|
||||
view["authorizations"] = ({**job_authorizations(session, installation), **cfop_ippt_job_authorizations(installation)} if installation is not None
|
||||
else {"rtc_write_mode": "DISABLED", "cfop_ippt_write_mode": "DISABLED"}) # decided centrally, per installation, default OFF
|
||||
return view
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/jobs/global")
|
||||
def global_jobs(request: Request, page: int = 1, page_size: int = 50, status: str | None = None, kind: str | None = None):
|
||||
session = session_factory()
|
||||
try:
|
||||
require_human(request, session)
|
||||
return list_jobs(session, page=page, page_size=page_size, status=status, kind=kind)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@routes.get("/jobs/{job_id}")
|
||||
def get_job(job_id: int, identity=Depends(principal)):
|
||||
session, agent = identity
|
||||
try:
|
||||
job = session.get(Job, job_id)
|
||||
if job is None or job.agent_id != agent.id: raise HTTPException(404, "job not found")
|
||||
return _job_view(job)
|
||||
finally: session.close()
|
||||
|
||||
@routes.post("/jobs/{job_id}/complete")
|
||||
def complete_job_route(job_id: int, payload: dict, identity=Depends(principal)):
|
||||
session, agent = identity
|
||||
try:
|
||||
job = session.get(Job, job_id)
|
||||
if job is None or job.agent_id != agent.id: raise HTTPException(404, "job not found")
|
||||
result_summary = payload.get("result_summary", {})
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent.id))
|
||||
pass_started = datetime.now(timezone.utc)
|
||||
upsert_blockers_bulk(session, agent.tenant_id, list(result_summary.get("blocked_products", [])), installation_id=installation.id if installation else None)
|
||||
for product_code in result_summary.get("resolved_products", []):
|
||||
resolve_product_blockers(session, agent.tenant_id, product_code, installation_id=installation.id if installation else None)
|
||||
if installation is not None:
|
||||
record_rtc_write_audits(session, tenant_id=agent.tenant_id, installation=installation, agent=agent, job_id=job_id, rtc_auto_apply=result_summary.get("rtc_auto_apply"), evaluated_all=bool((result_summary.get("fiscal_context") or {}).get("evaluated_all")))
|
||||
record_cfop_ippt_write_audits(session, tenant_id=agent.tenant_id, installation=installation, agent=agent, job_id=job_id, cfop_ippt_apply=result_summary.get("cfop_ippt_apply"))
|
||||
if installation is not None and (result_summary.get("fiscal_context") or {}).get("evaluated_all"):
|
||||
resolve_stale_fiscal_blockers(session, agent.tenant_id, installation.id, pass_started)
|
||||
if not complete_job(session, job, result_summary): raise HTTPException(409, "job is not RUNNING")
|
||||
if installation is not None:
|
||||
_apply_job_outcome(session, agent, installation, job, result_summary, failed=False)
|
||||
logger.info("job completed job_id=%s agent_id=%s result=%s", job_id, agent.id, payload.get("result_summary", {}))
|
||||
return _job_view(session.get(Job, job_id))
|
||||
finally: session.close()
|
||||
|
||||
@routes.post("/jobs/{job_id}/fail")
|
||||
def fail_job_route(job_id: int, payload: dict, identity=Depends(principal)):
|
||||
session, agent = identity
|
||||
try:
|
||||
job = session.get(Job, job_id)
|
||||
if job is None or job.agent_id != agent.id: raise HTTPException(404, "job not found")
|
||||
if not fail_job(session, job, payload.get("error", "unknown error")): raise HTTPException(409, "job is not RUNNING")
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent.id))
|
||||
if installation is not None:
|
||||
_apply_job_outcome(session, agent, installation, job, {"error": str(payload.get("error", "unknown error"))[:500]}, failed=True)
|
||||
logger.warning("job failed job_id=%s agent_id=%s error=%s", job_id, agent.id, payload.get("error", "unknown error"))
|
||||
return _job_view(session.get(Job, job_id))
|
||||
finally: session.close()
|
||||
|
||||
routes.include_router(enrichment_router(principal))
|
||||
return routes
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Immutable audit events for human control-plane actions."""
|
||||
|
||||
from .events import sanitize_event
|
||||
from .models import AuditEvent
|
||||
|
||||
|
||||
def record_audit(session, *, admin_user_id: int | None, action: str, target_type: str, target_id: str, context: dict | None = None, tenant_id: int | None = None, severity: str = "INFO"):
|
||||
_, safe_context = sanitize_event(action, context or {})
|
||||
event = AuditEvent(admin_user_id=admin_user_id, tenant_id=tenant_id, action=action, target_type=target_type, target_id=str(target_id), context=safe_context, severity=severity)
|
||||
session.add(event)
|
||||
return event
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Bearer authentication storing only SHA-256 token digests."""
|
||||
import hashlib, hmac
|
||||
from sqlalchemy import select
|
||||
from .models import Agent, Tenant
|
||||
|
||||
def token_hash(token): return hashlib.sha256(token.encode()).hexdigest()
|
||||
def bootstrap(session, token):
|
||||
digest = token_hash(token); agent = session.scalar(select(Agent).where(Agent.token_hash == digest))
|
||||
if agent: return agent
|
||||
tenant = session.scalar(select(Tenant).where(Tenant.name == "development")) or Tenant(name="development")
|
||||
session.add(tenant); session.flush(); agent = Agent(tenant_id=tenant.id, name="development-agent", token_hash=digest); session.add(agent); session.commit(); return agent
|
||||
def authenticate(session, token):
|
||||
digest = token_hash(token)
|
||||
for agent in session.scalars(select(Agent)).all():
|
||||
if hmac.compare_digest(agent.token_hash, digest):
|
||||
return agent if agent.enabled else None # a disabled (revoked) Agent must not authenticate
|
||||
return None
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Central blocked-product registry with fingerprint deduplication."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import select
|
||||
|
||||
from .models import BlockedProduct
|
||||
from .operational_state import blocker_fingerprint
|
||||
|
||||
|
||||
def _fingerprint_of(tenant_id: int, payload: dict, installation_id: int | None) -> str:
|
||||
supplied_fingerprint = payload.get("blocker_fingerprint")
|
||||
if installation_id is None:
|
||||
return supplied_fingerprint or blocker_fingerprint(payload.get("tenant", str(tenant_id)), payload.get("company", ""), payload.get("product_code", ""), payload.get("field", ""), payload.get("reason_code", ""), payload.get("input_fingerprint", ""))
|
||||
elif supplied_fingerprint:
|
||||
return blocker_fingerprint(supplied_fingerprint, "", "", "", "", "", installation_id=installation_id)
|
||||
else:
|
||||
return blocker_fingerprint(payload.get("tenant", str(tenant_id)), payload.get("company", ""), payload.get("product_code", ""), payload.get("field", ""), payload.get("reason_code", ""), payload.get("input_fingerprint", ""), installation_id=installation_id)
|
||||
|
||||
|
||||
def upsert_blocker(session, tenant_id: int, payload: dict, *, installation_id: int | None = None, commit: bool = True, existing: dict | None = None) -> BlockedProduct:
|
||||
"""`existing` ({fingerprint: row}) and `commit=False` let a caller upsert thousands of blockers with one lookup per chunk and one commit (see upsert_blockers_bulk)."""
|
||||
fingerprint = _fingerprint_of(tenant_id, payload, installation_id)
|
||||
row = existing.get(fingerprint) if existing is not None else session.scalar(select(BlockedProduct).where(BlockedProduct.blocker_fingerprint == fingerprint))
|
||||
now = datetime.now(timezone.utc)
|
||||
if row is None:
|
||||
row = BlockedProduct(tenant_id=tenant_id, installation_id=installation_id, company=payload.get("company", ""), product_code=str(payload.get("product_code", "")), gtin=payload.get("gtin"), description=payload.get("description"), blocker_type=payload.get("blocker_type", "BLOCKED_MISSING_EVIDENCE"), field=payload.get("field", ""), reason_code=payload.get("reason_code", ""), evidence_summary=payload.get("evidence_summary") or {}, input_fingerprint=payload.get("input_fingerprint", ""), blocker_fingerprint=fingerprint, first_seen_at=now, last_seen_at=now)
|
||||
session.add(row)
|
||||
if existing is not None:
|
||||
existing[fingerprint] = row
|
||||
else:
|
||||
row.last_seen_at = now
|
||||
row.evidence_summary = payload.get("evidence_summary") or row.evidence_summary
|
||||
row.input_fingerprint = payload.get("input_fingerprint", row.input_fingerprint)
|
||||
if row.installation_id is None and installation_id is not None:
|
||||
row.installation_id = installation_id
|
||||
row.resolved_at = None
|
||||
if commit:
|
||||
session.commit()
|
||||
return row
|
||||
|
||||
|
||||
BULK_CHUNK = 1000
|
||||
|
||||
|
||||
def upsert_blockers_bulk(session, tenant_id: int, payloads: list, *, installation_id: int | None = None) -> int:
|
||||
"""Same semantics as `upsert_blocker` per item, but O(chunks) statements instead of O(products): one IN-lookup per chunk, one commit at the end."""
|
||||
n = 0
|
||||
for start in range(0, len(payloads), BULK_CHUNK):
|
||||
chunk = [p for p in payloads[start:start + BULK_CHUNK] if isinstance(p, dict)]
|
||||
prints = {_fingerprint_of(tenant_id, p, installation_id) for p in chunk}
|
||||
existing = {r.blocker_fingerprint: r for r in session.scalars(select(BlockedProduct).where(BlockedProduct.blocker_fingerprint.in_(prints)))} if prints else {}
|
||||
for payload in chunk:
|
||||
upsert_blocker(session, tenant_id, payload, installation_id=installation_id, commit=False, existing=existing)
|
||||
n += 1
|
||||
session.flush()
|
||||
session.commit()
|
||||
return n
|
||||
|
||||
|
||||
def list_blockers(session, tenant_id: int, *, input_fingerprint: str | None = None, blocker_type: str | None = None, field: str | None = None, installation_id: int | None = None):
|
||||
query = select(BlockedProduct).where(BlockedProduct.tenant_id == tenant_id, BlockedProduct.resolved_at.is_(None))
|
||||
if installation_id is not None:
|
||||
query = query.where((BlockedProduct.installation_id == installation_id) | BlockedProduct.installation_id.is_(None))
|
||||
if input_fingerprint is not None:
|
||||
query = query.where(BlockedProduct.input_fingerprint != input_fingerprint)
|
||||
if blocker_type:
|
||||
query = query.where(BlockedProduct.blocker_type == blocker_type)
|
||||
if field:
|
||||
query = query.where(BlockedProduct.field == field)
|
||||
return list(session.scalars(query).all())
|
||||
|
||||
|
||||
def retry_due(row: BlockedProduct, current_input_fingerprint: str, now: datetime | None = None) -> bool:
|
||||
now = now or datetime.now(timezone.utc)
|
||||
if row.input_fingerprint != current_input_fingerprint:
|
||||
return True
|
||||
return row.next_retry_at is not None and row.next_retry_at <= now
|
||||
|
||||
|
||||
def resolve_blocker(session, blocker_id: int, source: str) -> bool:
|
||||
row = session.get(BlockedProduct, blocker_id)
|
||||
if row is None:
|
||||
return False
|
||||
row.resolved_at = datetime.now(timezone.utc)
|
||||
row.resolution_source = source
|
||||
session.commit()
|
||||
return True
|
||||
|
||||
|
||||
def resolve_product_blockers(session, tenant_id: int, product_code: str, source: str = "AUTO_REEVALUATION", *, installation_id: int | None = None) -> int:
|
||||
query = select(BlockedProduct).where(BlockedProduct.tenant_id == tenant_id, BlockedProduct.product_code == str(product_code), BlockedProduct.resolved_at.is_(None))
|
||||
if installation_id is not None:
|
||||
query = query.where((BlockedProduct.installation_id == installation_id) | BlockedProduct.installation_id.is_(None))
|
||||
rows = session.scalars(query).all()
|
||||
now = datetime.now(timezone.utc)
|
||||
for row in rows:
|
||||
row.resolved_at = now
|
||||
row.resolution_source = source
|
||||
if rows:
|
||||
session.commit()
|
||||
return len(rows)
|
||||
|
||||
|
||||
FISCAL_BLOCKER_FIELDS = ("PIS", "COFINS", "RTC")
|
||||
|
||||
|
||||
def resolve_stale_fiscal_blockers(session, tenant_id: int, installation_id: int, since: datetime) -> int:
|
||||
"""After a FULL evaluation of the unified fiscal fields, every open PIS/COFINS/RTC blocker of the installation that this pass did not
|
||||
re-report (last_seen_at < since) no longer holds: the ERP data changed, or RTC was switched off. Other blocker fields are untouched."""
|
||||
rows = session.scalars(select(BlockedProduct).where(BlockedProduct.tenant_id == tenant_id, BlockedProduct.installation_id == installation_id,
|
||||
BlockedProduct.resolved_at.is_(None), BlockedProduct.field.in_(FISCAL_BLOCKER_FIELDS), BlockedProduct.last_seen_at < since)).all()
|
||||
now = datetime.now(timezone.utc)
|
||||
for row in rows:
|
||||
row.resolved_at = now
|
||||
row.resolution_source = "AUTO_REEVALUATION"
|
||||
if rows:
|
||||
session.commit()
|
||||
return len(rows)
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Validated, auditable context resolution for a single blocked product."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from .audit import record_audit
|
||||
from .context_keys import validate_entry
|
||||
from .models import Agent, BlockedProduct, FiscalContextEntry, Installation, Tenant, Job
|
||||
|
||||
|
||||
WORKFLOW_SCOPES = {"PRODUCT", "GROUP", "COMPANY"}
|
||||
ENTRY_FIELDS = {"scope", "scope_key", "key", "value", "reason"}
|
||||
MAX_VALUE_LENGTH = 512
|
||||
MAX_REASON_LENGTH = 2000
|
||||
MAX_COMPANY_KEY_LENGTH = 64
|
||||
|
||||
|
||||
class BlockerResolutionUnavailable(LookupError):
|
||||
"""The blocker exists, but has no proven installation to target safely."""
|
||||
|
||||
|
||||
def validate_resolution_input(payload):
|
||||
"""Validate only the structured context accepted by the blocker workflow."""
|
||||
if not isinstance(payload, dict):
|
||||
return None, "resolution input must be an object"
|
||||
unexpected = set(payload) - ENTRY_FIELDS
|
||||
if unexpected:
|
||||
return None, f"unsupported resolution fields: {sorted(unexpected)}"
|
||||
if not isinstance(payload.get("value"), str):
|
||||
return None, "value must be a string"
|
||||
if not isinstance(payload.get("reason"), str):
|
||||
return None, "reason must be a string"
|
||||
if len(payload["value"].encode("utf-8")) > MAX_VALUE_LENGTH:
|
||||
return None, f"value must be at most {MAX_VALUE_LENGTH} UTF-8 bytes"
|
||||
if len(payload["reason"].encode("utf-8")) > MAX_REASON_LENGTH:
|
||||
return None, f"reason must be at most {MAX_REASON_LENGTH} UTF-8 bytes"
|
||||
|
||||
normalized, error = validate_entry(payload)
|
||||
if error:
|
||||
return None, error
|
||||
if normalized["scope"] not in WORKFLOW_SCOPES:
|
||||
return None, "scope must be one of ('PRODUCT', 'GROUP', 'COMPANY')"
|
||||
if not normalized["reason"]:
|
||||
return None, "reason is required"
|
||||
return normalized, None
|
||||
|
||||
|
||||
def _company_key(blocker, installation, tenant):
|
||||
if installation is not None:
|
||||
return (installation.company_code or installation.company_name or installation.customer_code or "").strip()
|
||||
return (blocker.company or (tenant.name if tenant else "")).strip()
|
||||
|
||||
|
||||
def _entry_view(row):
|
||||
if row is None:
|
||||
return None
|
||||
return {
|
||||
"company_key": row.company_key,
|
||||
"scope": row.scope,
|
||||
"scope_key": row.scope_key,
|
||||
"key": row.key,
|
||||
"value": row.value,
|
||||
"source": row.source,
|
||||
"reason": row.reason,
|
||||
}
|
||||
|
||||
|
||||
def resolve_blocker_with_context(session, blocker_id, admin, payload, request_ip=None):
|
||||
"""Persist context, enqueue one product reprocess, and audit atomically."""
|
||||
normalized, error = validate_resolution_input(payload)
|
||||
if error:
|
||||
raise ValueError(error)
|
||||
|
||||
try:
|
||||
blocker = session.get(BlockedProduct, blocker_id)
|
||||
if blocker is None:
|
||||
raise LookupError("blocker not found")
|
||||
|
||||
installation = None
|
||||
if blocker.installation_id is not None:
|
||||
installation = session.get(Installation, blocker.installation_id)
|
||||
if installation is None:
|
||||
raise LookupError("blocker installation not found")
|
||||
if installation.tenant_id != blocker.tenant_id:
|
||||
raise LookupError("blocker installation tenant mismatch")
|
||||
agent = session.get(Agent, installation.agent_id)
|
||||
if agent is None:
|
||||
raise LookupError("blocker installation agent not found")
|
||||
if agent.tenant_id != blocker.tenant_id:
|
||||
raise LookupError("blocker agent tenant mismatch")
|
||||
else:
|
||||
raise BlockerResolutionUnavailable(
|
||||
"Blocker legado de empresa sem vínculo comprovado com um servidor; não é possível oferecer o reprocessamento de contexto com segurança."
|
||||
)
|
||||
|
||||
tenant = session.get(Tenant, blocker.tenant_id)
|
||||
company_key = _company_key(blocker, installation, tenant)
|
||||
if not company_key:
|
||||
raise ValueError("company context is unavailable")
|
||||
if len(company_key) > MAX_COMPANY_KEY_LENGTH:
|
||||
raise ValueError(f"company key must be at most {MAX_COMPANY_KEY_LENGTH} characters")
|
||||
if normalized["scope"] == "PRODUCT" and normalized["scope_key"] != blocker.product_code:
|
||||
raise ValueError("PRODUCT scope_key must match the blocker product code")
|
||||
|
||||
query = select(FiscalContextEntry).where(
|
||||
FiscalContextEntry.tenant_id == blocker.tenant_id,
|
||||
FiscalContextEntry.company_key == company_key,
|
||||
FiscalContextEntry.scope == normalized["scope"],
|
||||
FiscalContextEntry.scope_key == normalized["scope_key"],
|
||||
FiscalContextEntry.key == normalized["key"],
|
||||
)
|
||||
entry = session.scalar(query)
|
||||
before = _entry_view(entry)
|
||||
now = datetime.now(timezone.utc)
|
||||
if entry is None:
|
||||
entry = FiscalContextEntry(
|
||||
tenant_id=blocker.tenant_id,
|
||||
company_key=company_key,
|
||||
source="OPERATOR",
|
||||
updated_at=now,
|
||||
**normalized,
|
||||
)
|
||||
session.add(entry)
|
||||
else:
|
||||
entry.value = normalized["value"]
|
||||
entry.reason = normalized["reason"]
|
||||
entry.source = "OPERATOR"
|
||||
entry.updated_at = now
|
||||
|
||||
job = Job(agent_id=agent.id, kind="REPROCESS_PRODUCTS", status="PENDING", payload={"codigos": [blocker.product_code]})
|
||||
session.add(job)
|
||||
session.flush()
|
||||
audit = record_audit(
|
||||
session,
|
||||
admin_user_id=admin.id,
|
||||
tenant_id=blocker.tenant_id,
|
||||
action="RESOLVE_BLOCKER_WITH_CONTEXT",
|
||||
target_type="blocker",
|
||||
target_id=str(blocker.id),
|
||||
context={
|
||||
"admin_user_id": admin.id,
|
||||
"tenant_id": blocker.tenant_id,
|
||||
"client": tenant.name if tenant else None,
|
||||
"company_key": company_key,
|
||||
"installation_id": installation.installation_id if installation else None,
|
||||
"installation_db_id": installation.id if installation else None,
|
||||
"product_code": blocker.product_code,
|
||||
"blocker_id": blocker.id,
|
||||
"key": normalized["key"],
|
||||
"scope": normalized["scope"],
|
||||
"scope_key": normalized["scope_key"],
|
||||
"value": normalized["value"],
|
||||
"reason": normalized["reason"],
|
||||
"before": before,
|
||||
"after": _entry_view(entry),
|
||||
"reprocess_job_id": job.id,
|
||||
"request_ip": request_ip,
|
||||
},
|
||||
)
|
||||
session.flush()
|
||||
session.commit()
|
||||
return {"blocker_id": blocker.id, "job_id": job.id, "audit_id": audit.id}
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
@@ -0,0 +1,274 @@
|
||||
"""Deterministic, auditable cClassTrib resolver (pure: no Firebird, no network, no probabilities).
|
||||
|
||||
resolve_cclasstrib(product, company, ctx, local_catalog) -> Resolution
|
||||
|
||||
Nothing here guesses. A class is proposed only when an EXPLICIT rule, backed by the official texts in `rtc_reference_data`
|
||||
(cClassTrib table 2026-06-22 and LC 214/2025 compiled through LC 227/2026), proves it. Everything else is AMBIGUOUS or INSUFFICIENT_EVIDENCE.
|
||||
|
||||
Operation assumption (documented, product-level default): an ordinary domestic sale of the good. Operation-dependent classes (buyer is a public body,
|
||||
exports, ZFM/ALC, credit presumed, ...) cannot be carried by a product default; when the annex offers such alternatives the answer is AMBIGUOUS.
|
||||
|
||||
Rule classes (what a rule is ALLOWED to do)
|
||||
AUTO_APPLY_SAFE positive, explicit evidence: may be written to the ERP (only when every runtime gate also holds)
|
||||
VALIDATION_ONLY used to validate/explain, never proposes a value
|
||||
SUGGESTION_ONLY diagnostic suggestion shown to a human; NEVER authorizes an UPDATE
|
||||
BLOCKED_BY_EVIDENCE the evidence is missing/ambiguous: safe-block with a human reason
|
||||
|
||||
Rules
|
||||
RTC-R0 RTC disabled in the ERP VALIDATION_ONLY -> NOT_APPLICABLE
|
||||
RTC-G1 local RTC catalog failed integrity BLOCKED_BY_EVIDENCE -> INSUFFICIENT_EVIDENCE
|
||||
RTC-R1 regime without an official rule (Simples family, unknown) BLOCKED_BY_EVIDENCE -> INSUFFICIENT_EVIDENCE
|
||||
RTC-R2 no valid 8-digit NCM (decision engine did not confirm) BLOCKED_BY_EVIDENCE -> INSUFFICIENT_EVIDENCE
|
||||
RTC-A1 annex without a unique product-default class / conditional text / revoked annex -> AMBIGUOUS
|
||||
RTC-A2 NCM listed in more than one annex -> AMBIGUOUS
|
||||
RTC-A3 chapter whose regime depends on the nature/operation of the good -> AMBIGUOUS
|
||||
RTC-R3 NCM listed in EXACTLY ONE annex with a unique default class, condition-free item, regime normal (CRT 3)
|
||||
AUTO_APPLY_SAFE -> RESOLVED_WITH_EXPLICIT_RULE
|
||||
RTC-R4 NCM in NO annex and in no nature-based chapter: full taxation is the residual reading of LC 214 art. 4
|
||||
SUGGESTION_ONLY -> SUGGESTION_ONLY (000001). Absence of a special regime is NOT positive evidence: never written.
|
||||
RTC-G2 proposed class missing/different in the local SGV catalog, out of force or unusable in NF-e/NFC-e BLOCKED_BY_EVIDENCE -> INSUFFICIENT_EVIDENCE (RTC_LOCAL_CATALOG_INCOMPATIBLE)
|
||||
A proposed class must additionally be MATCH_SAFE against the official table (local SGV catalog confirms every attribute that matters), in force today, usable in
|
||||
NF-e/NFC-e (the SGV's own lookup filter) and carry a CST that exists; otherwise the answer degrades with the exact reason.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
|
||||
from . import rtc_reference_data as ref
|
||||
|
||||
SIMPLES_FAMILY = frozenset({"1", "2", "4"})
|
||||
RESOLVED_STATUSES = ("RESOLVED_DETERMINISTIC", "RESOLVED_WITH_EXPLICIT_RULE")
|
||||
STATUSES = RESOLVED_STATUSES + ("SUGGESTION_ONLY", "AMBIGUOUS", "INSUFFICIENT_EVIDENCE", "NOT_APPLICABLE")
|
||||
RULE_VERSION = "rtc-rules-v2"
|
||||
AUTO_APPLY_SAFE, VALIDATION_ONLY, SUGGESTION_ONLY, BLOCKED_BY_EVIDENCE = "AUTO_APPLY_SAFE", "VALIDATION_ONLY", "SUGGESTION_ONLY", "BLOCKED_BY_EVIDENCE"
|
||||
RULE_CLASSES = {"RTC-R0": VALIDATION_ONLY, "RTC-G1": BLOCKED_BY_EVIDENCE, "RTC-R1": BLOCKED_BY_EVIDENCE, "RTC-R2": BLOCKED_BY_EVIDENCE, "RTC-A1": BLOCKED_BY_EVIDENCE, "RTC-A2": BLOCKED_BY_EVIDENCE,
|
||||
"RTC-A3": BLOCKED_BY_EVIDENCE, "RTC-R3": AUTO_APPLY_SAFE, "RTC-R4": SUGGESTION_ONLY, "RTC-G2": BLOCKED_BY_EVIDENCE}
|
||||
HUMAN_REASONS = {"SIMPLES_ORIENTATION_PENDING_OFFICIAL_NT": "Enquadramento RTC depende de regra ainda não determinável com segurança.", "COMPANY_REGIME_UNKNOWN": "Regime tributário da empresa não determinado."}
|
||||
|
||||
# Goods annexes whose product-default class is unique in the official table (verified by `verify_annex_class_map`).
|
||||
ANNEX_DEFAULT_CLASS = {"I": "200003", "VII": "200034", "VIII": "200035", "XV": "200014"} # IX is out: 515001 (deferral with the same inputs) is a sibling class
|
||||
# Chapters where a reduced/special regime is defined by the NATURE of the good (not by a code list): in natura agriculture (art. 137), medicines (arts. 133/146),
|
||||
# fuels and biofuels (art. 172 ff), medical/accessibility devices and vehicles (arts. 131-132, 143-145, 149), books/periodicals (constitutional immunity),
|
||||
# gold and other special-regime goods kept out of the residual rule on purpose (conservative).
|
||||
SENSITIVE_CHAPTERS = frozenset([f"{n:02d}" for n in range(1, 15)] + ["27", "30", "49", "71", "87", "88", "89", "90"]) # 71 gold (financial-asset regime), 88/89 aircraft and vessels
|
||||
SPECIAL_PREFIXES = ("9619", "2207", "3826", "8703") # art. 147 (menstrual health), biofuel inputs, art. 149 (PcD cars)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Resolution:
|
||||
status: str
|
||||
rule_id: str
|
||||
reason: str
|
||||
resolved_cclasstrib: str | None = None
|
||||
resolved_cst_ibscbs: str | None = None
|
||||
confidence_type: str = "NONE"
|
||||
inputs_used: dict = field(default_factory=dict)
|
||||
evidence: list = field(default_factory=list)
|
||||
source_references: list = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def rule_class(self) -> str:
|
||||
return RULE_CLASSES.get(self.rule_id, BLOCKED_BY_EVIDENCE)
|
||||
|
||||
@property
|
||||
def auto_apply_candidate(self) -> bool:
|
||||
"""Only an AUTO_APPLY_SAFE rule with a proven class may ever become a write. Suggestions never do."""
|
||||
return self.rule_class == AUTO_APPLY_SAFE and self.status in RESOLVED_STATUSES and bool(self.resolved_cclasstrib)
|
||||
|
||||
@property
|
||||
def suggested_cclasstrib(self) -> str | None:
|
||||
return self.resolved_cclasstrib if self.status == "SUGGESTION_ONLY" else None
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {"status": self.status, "rule_id": self.rule_id, "rule_class": self.rule_class, "rule_version": RULE_VERSION, "reason": self.reason, "resolved_cclasstrib": self.resolved_cclasstrib,
|
||||
"resolved_cst_ibscbs": self.resolved_cst_ibscbs, "confidence_type": self.confidence_type, "inputs_used": self.inputs_used, "evidence": self.evidence,
|
||||
"source_references": self.source_references, "auto_apply_candidate": self.auto_apply_candidate}
|
||||
|
||||
|
||||
def _digits(value) -> str:
|
||||
return re.sub(r"\D", "", "" if value is None else str(value))
|
||||
|
||||
|
||||
def _official_by_code() -> dict:
|
||||
return {row["code"]: row for row in ref.OFFICIAL_CCLASSTRIB}
|
||||
|
||||
|
||||
def _num(value):
|
||||
try:
|
||||
return float(str(value).replace(",", "."))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------------- catalog comparison
|
||||
|
||||
|
||||
def compare_catalogs(local_rows: dict, today: date | None = None) -> dict:
|
||||
"""local_rows: {code: {cst, tipo_aliquota, p_red_ibs, p_red_cbs, ind_nfe, ind_nfce, start, end}} read from the ERP. Never writes anywhere.
|
||||
Per code (the official table is the reference):
|
||||
MATCH_SAFE every attribute the local table carries that matters to a product default agrees (CST, reductions, NF-e/NFC-e usability)
|
||||
MATCH_WITH_NON_MATERIAL_DIFFERENCE only a scheduled local end date (class still in force today) differs
|
||||
MISMATCH CST, reductions or NF-e/NFC-e usability differ
|
||||
EXPIRED the official class ended
|
||||
MISSING_LOCAL official class absent from the SGV catalog (local outdated)
|
||||
MISSING_OFFICIAL local class the official table does not know"""
|
||||
today = today or date.today()
|
||||
official = _official_by_code()
|
||||
result, details = {}, {}
|
||||
for code in sorted(set(local_rows) | set(official)):
|
||||
loc, off = local_rows.get(code), official.get(code)
|
||||
if off is None:
|
||||
result[code] = "MISSING_OFFICIAL"
|
||||
elif off.get("end") and date.fromisoformat(off["end"]) < today:
|
||||
result[code] = "EXPIRED"
|
||||
details[code] = ["OFFICIAL_END_PASSED"] + (["PRESENT_LOCALLY"] if loc is not None else [])
|
||||
elif loc is None:
|
||||
result[code] = "MISSING_LOCAL"
|
||||
else:
|
||||
problems, soft, info = [], [], []
|
||||
if str(loc.get("cst") or "").strip() != off["cst"]:
|
||||
problems.append("CST")
|
||||
if "p_red_ibs" in loc and "p_red_cbs" in loc and (_num(loc.get("p_red_ibs")) != _num(off["p_red_ibs"]) or _num(loc.get("p_red_cbs")) != _num(off["p_red_cbs"])):
|
||||
problems.append("REDUCTION") # only attributes the local table actually carries are compared
|
||||
if "ind_nfe" in loc and "ind_nfce" in loc and (bool(loc.get("ind_nfe")) != off["ind_nfe"] or bool(loc.get("ind_nfce")) != off["ind_nfce"]):
|
||||
problems.append("DOCUMENT_FLAGS")
|
||||
start, end = str(loc.get("start") or "")[:10], str(loc.get("end") or "")[:10]
|
||||
if start and date.fromisoformat(start) > today:
|
||||
problems.append("LOCAL_NOT_IN_FORCE_YET")
|
||||
elif start and off.get("start") and start != off["start"]:
|
||||
info.append("VIGENCY_START_DIFFERS_BOTH_IN_FORCE") # informational: the class is in force today on both sides, so the emission effect is identical
|
||||
if end and date.fromisoformat(end) < today:
|
||||
problems.append("LOCAL_CLASS_ENDED")
|
||||
elif end:
|
||||
soft.append("LOCAL_END_SCHEDULED") # the class still works today; the local table schedules an end the official one does not
|
||||
result[code] = "MISMATCH" if problems else ("MATCH_WITH_NON_MATERIAL_DIFFERENCE" if soft else "MATCH_SAFE")
|
||||
if problems or soft or info:
|
||||
details[code] = problems + soft + info
|
||||
keys = ("MATCH_SAFE", "MATCH_WITH_NON_MATERIAL_DIFFERENCE", "MISMATCH", "EXPIRED", "MISSING_LOCAL", "MISSING_OFFICIAL")
|
||||
counts = {k: sum(1 for v in result.values() if v == k) for k in keys}
|
||||
material = counts["MISMATCH"] + counts["MISSING_OFFICIAL"] # a local class the official table contradicts or does not know
|
||||
expired_present = sum(1 for d in details.values() if "PRESENT_LOCALLY" in d)
|
||||
compat = "FULL" if counts["MISSING_LOCAL"] == 0 and expired_present == 0 and material == 0 else ("PARTIAL_LOCAL_OUTDATED" if material == 0 else "MATERIAL_MISMATCH")
|
||||
return {"per_code": result, "details": details, "counts": counts, "expired_present_locally": expired_present, "compatibility": compat,
|
||||
"official_catalog": {"sha256": ref.OFFICIAL_CATALOG["sha256"], "publication": ref.OFFICIAL_CATALOG["publication"], "file": ref.OFFICIAL_CATALOG["file"]}}
|
||||
|
||||
|
||||
def local_catalog_hash(local_rows: dict) -> str:
|
||||
canon = json.dumps({c: {k: (None if v is None else str(v)) for k, v in sorted(r.items())} for c, r in sorted(local_rows.items())}, sort_keys=True)
|
||||
return hashlib.sha256(canon.encode()).hexdigest()
|
||||
|
||||
|
||||
def verify_annex_class_map() -> dict:
|
||||
"""Self-check of ANNEX_DEFAULT_CLASS against the official table: the class must exist, be a goods class in force, and be the ONLY official class for that annex
|
||||
usable in NF-e/NFC-e as the ordinary-sale default. Returns {annex: [problems]} (empty lists = verified)."""
|
||||
official = _official_by_code()
|
||||
numbers = {"I": "1", "VII": "7", "VIII": "8", "XV": "15"}
|
||||
out = {}
|
||||
for annex, code in ANNEX_DEFAULT_CLASS.items():
|
||||
row, problems = official.get(code), []
|
||||
if row is None:
|
||||
problems.append("CLASS_NOT_IN_OFFICIAL_TABLE")
|
||||
else:
|
||||
if str(row.get("anexo")) != numbers[annex]:
|
||||
problems.append("ANNEX_FIELD_DIFFERS")
|
||||
if not (row["ind_nfe"] or row["ind_nfce"]):
|
||||
problems.append("NOT_USABLE_IN_NFE_NFCE")
|
||||
siblings = [r["code"] for r in ref.OFFICIAL_CCLASSTRIB if str(r.get("anexo")) == numbers[annex] and r["code"] != code and (r["ind_nfe"] or r["ind_nfce"])]
|
||||
if siblings:
|
||||
problems.append("SIBLING_CLASSES:" + ",".join(siblings))
|
||||
out[annex] = problems
|
||||
return out
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------------- annex lookup
|
||||
|
||||
|
||||
def annex_matches(ncm: str) -> list[dict]:
|
||||
"""Every annex item (any annex, revoked included) whose code list covers the NCM, with the item text and whether it is a clean (condition-free) item."""
|
||||
hits = []
|
||||
for roman, annex in ref.LC214_ANNEXES.items():
|
||||
for item in annex["items"]:
|
||||
for prefix in item["prefixes"]:
|
||||
if ncm.startswith(prefix):
|
||||
hits.append({"annex": roman, "item": item["item"], "prefix": prefix, "clean": item["clean"], "revoked": annex["revoked"], "text": item["description"][:160]})
|
||||
break
|
||||
return hits
|
||||
|
||||
|
||||
def _references(extra=None) -> list:
|
||||
refs = [{"document": ref.LC214_SOURCE["document"], "url": ref.LC214_SOURCE["url"], "sha256": ref.LC214_SOURCE["sha256"]},
|
||||
{"document": "cClassTrib (Portal Nacional NF-e)", "publication": ref.OFFICIAL_CATALOG["publication"], "sha256": ref.OFFICIAL_CATALOG["sha256"]}]
|
||||
return refs + (extra or [])
|
||||
|
||||
|
||||
def _validate_class(code: str, local_rows: dict, compat: dict, today: date):
|
||||
"""None when the class may be proposed, else the exact reason it may not."""
|
||||
official = _official_by_code().get(code)
|
||||
if official is None:
|
||||
return "CLASS_NOT_IN_OFFICIAL_TABLE"
|
||||
loc = local_rows.get(code)
|
||||
if loc is None:
|
||||
return "CLASS_NOT_IN_LOCAL_CATALOG"
|
||||
state = compat["per_code"].get(code)
|
||||
if state != "MATCH_SAFE":
|
||||
return f"LOCAL_OFFICIAL_{state}"
|
||||
if official["start"] and date.fromisoformat(official["start"]) > today:
|
||||
return "CLASS_NOT_YET_IN_FORCE"
|
||||
if not (official["ind_nfe"] or official["ind_nfce"]):
|
||||
return "CLASS_NOT_USABLE_IN_NFE_NFCE"
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------------- resolver
|
||||
|
||||
|
||||
def resolve_cclasstrib(product: dict, company: dict | None, ctx: dict, local_rows: dict | None, *, compat: dict | None = None, today: date | None = None) -> Resolution:
|
||||
"""product: {"CODIGO","CLASSIFICACAO_FISCAL","ncm_valid"(bool)}; company: {"crt"}; ctx: {"rtc_enabled": bool, "local_catalog_valid": bool}."""
|
||||
today = today or date.today()
|
||||
ncm = _digits(product.get("CLASSIFICACAO_FISCAL"))
|
||||
crt = str((company or {}).get("crt") or "").strip()
|
||||
used = {"ncm": ncm or None, "crt": crt or None, "rtc_enabled": bool(ctx.get("rtc_enabled"))}
|
||||
if not ctx.get("rtc_enabled"):
|
||||
return Resolution("NOT_APPLICABLE", "RTC-R0", "RTC_DISABLED_IN_ERP", inputs_used=used)
|
||||
if not ctx.get("local_catalog_valid"):
|
||||
return Resolution("INSUFFICIENT_EVIDENCE", "RTC-G1", "LOCAL_RTC_CATALOG_INVALID", inputs_used=used)
|
||||
if crt in SIMPLES_FAMILY:
|
||||
return Resolution("INSUFFICIENT_EVIDENCE", "RTC-R1", "SIMPLES_ORIENTATION_PENDING_OFFICIAL_NT", inputs_used=used,
|
||||
source_references=_references([{"document": "NT 2025.002-RTC v1.51 (Portal NF-e, 04/08/2026)", "note": "IBS/CBS orientations for CRT 1/2/4 pending"},
|
||||
{"document": "Ato Conjunto RFB/CGIBS n 4/2026 (DOU 31/07/2026)", "note": "art. 1 par. 1: DF-e obligation for Simples optantes starts 2027-01-01"}]))
|
||||
if crt != "3":
|
||||
return Resolution("INSUFFICIENT_EVIDENCE", "RTC-R1", "COMPANY_REGIME_UNKNOWN", inputs_used=used)
|
||||
if len(ncm) != 8 or product.get("ncm_valid") is not True:
|
||||
return Resolution("INSUFFICIENT_EVIDENCE", "RTC-R2", "NO_VALID_NCM", inputs_used=used)
|
||||
local_rows = local_rows or {}
|
||||
compat = compat or compare_catalogs(local_rows, today)
|
||||
hits = annex_matches(ncm)
|
||||
annexes = sorted({h["annex"] for h in hits})
|
||||
if hits:
|
||||
if any(h["revoked"] for h in hits) and len(annexes) == 1:
|
||||
return Resolution("AMBIGUOUS", "RTC-A1", "LISTED_ONLY_IN_REVOKED_ANNEX", inputs_used=used, evidence=hits, source_references=_references())
|
||||
live = [h for h in hits if not h["revoked"]]
|
||||
live_annexes = sorted({h["annex"] for h in live})
|
||||
if len(live_annexes) > 1 or (len(live_annexes) == 1 and len(annexes) > 1):
|
||||
return Resolution("AMBIGUOUS", "RTC-A2", "LISTED_IN_MORE_THAN_ONE_ANNEX", inputs_used=used, evidence=hits, source_references=_references())
|
||||
annex = live_annexes[0] if live_annexes else annexes[0]
|
||||
clean = [h for h in live if h["clean"]]
|
||||
cls = ANNEX_DEFAULT_CLASS.get(annex)
|
||||
if not cls or not clean or len(clean) != len(live):
|
||||
return Resolution("AMBIGUOUS", "RTC-A1", f"ANNEX_{annex}_NEEDS_OPERATION_OR_CONDITION", inputs_used=used, evidence=hits, source_references=_references())
|
||||
problem = _validate_class(cls, local_rows, compat, today)
|
||||
if problem:
|
||||
return Resolution("INSUFFICIENT_EVIDENCE", "RTC-G2", problem, inputs_used=used, evidence=hits, source_references=_references())
|
||||
official = _official_by_code()[cls]
|
||||
return Resolution("RESOLVED_WITH_EXPLICIT_RULE", "RTC-R3", f"ANNEX_{annex}_CLEAN_ITEM_UNIQUE_DEFAULT_CLASS", cls, official["cst"], "ANNEX_TEXT_MATCH", used, hits, _references())
|
||||
if ncm[:2] in SENSITIVE_CHAPTERS or ncm.startswith(SPECIAL_PREFIXES):
|
||||
return Resolution("AMBIGUOUS", "RTC-A3", "SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD", inputs_used=used, evidence=[{"chapter": ncm[:2]}], source_references=_references())
|
||||
problem = _validate_class("000001", local_rows, compat, today)
|
||||
if problem:
|
||||
return Resolution("INSUFFICIENT_EVIDENCE", "RTC-G2", problem, inputs_used=used, source_references=_references())
|
||||
return Resolution("SUGGESTION_ONLY", "RTC-R4", "NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER", "000001", "000", "EXCLUSION_OF_ALL_SPECIAL_REGIMES", used,
|
||||
[{"annex_hits": 0, "chapter": ncm[:2], "note": "absence of a special regime is not positive evidence: suggestion only, never written"}], _references())
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Persists the Agent's CFOP/IPPT write audit records (append-only). Mirrors central.app.rtc_audit's
|
||||
safety model for a separate, narrower capability - no candidate preview table (candidates are
|
||||
recomputed fresh from field_contract every job, never stored server-side), no rollback queue yet
|
||||
(rollback is operator-triggered later, out of scope for this pass)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from .events import record_event
|
||||
from .models import CfopIpptWriteAudit
|
||||
|
||||
MAX_AUDITS_PER_JOB = 2000
|
||||
WRITE_MODES = ("DISABLED", "DRY_RUN", "APPROVAL_REQUIRED", "ENABLED")
|
||||
ALLOWED_FIELDS = ("CFOP", "IPPT")
|
||||
|
||||
|
||||
def _clip(value, size):
|
||||
return None if value is None else str(value)[:size]
|
||||
|
||||
|
||||
def job_authorizations(installation) -> dict:
|
||||
"""What the authenticated job carries to the Agent. Decided centrally per installation; the Agent
|
||||
has no local switch. DISABLED unless an installation row explicitly says otherwise (server_default)."""
|
||||
mode = installation.cfop_ippt_write_mode if installation.cfop_ippt_write_mode in WRITE_MODES else "DISABLED"
|
||||
return {"cfop_ippt_write_mode": mode}
|
||||
|
||||
|
||||
def record_cfop_ippt_write_audits(session, *, tenant_id: int, installation, agent, job_id: int | None, cfop_ippt_apply: dict | None) -> int:
|
||||
"""Stores every applied/aborted write of the job. Never trusts the Agent for identity: tenant/installation/agent come from the authenticated context."""
|
||||
if not isinstance(cfop_ippt_apply, dict):
|
||||
return 0
|
||||
records = list(cfop_ippt_apply.get("applied") or []) + list(cfop_ippt_apply.get("aborted") or [])
|
||||
stored = applied = aborted = 0
|
||||
for item in records[:MAX_AUDITS_PER_JOB]:
|
||||
if not isinstance(item, dict) or item.get("field") not in ALLOWED_FIELDS:
|
||||
continue
|
||||
outcome = str(item.get("outcome", ""))[:16]
|
||||
session.add(CfopIpptWriteAudit(
|
||||
tenant_id=tenant_id, installation_id=installation.id, agent_id=agent.id, job_id=job_id,
|
||||
product_code=_clip(item.get("product_code"), 64) or "", field=item["field"], outcome=outcome,
|
||||
reason=_clip(item.get("reason") or item.get("reason_code"), 96), old_value=_clip(item.get("old_value"), 16), new_value=_clip(item.get("new_value"), 16),
|
||||
action="APPLY", written_at=_clip(item.get("timestamp"), 40)))
|
||||
stored += 1
|
||||
if outcome == "APPLIED":
|
||||
applied += 1
|
||||
else:
|
||||
aborted += 1
|
||||
if stored:
|
||||
record_event(session, tenant_id=tenant_id, installation_id=installation.id, agent_id=agent.id, severity="WARNING" if applied else "INFO", event_type="CFOP_IPPT_AUTO_APPLY", job_id=job_id,
|
||||
message=f"CFOP/IPPT: {applied} aplicado(s), {aborted} abortado(s)",
|
||||
context={"applied": applied, "aborted": aborted, "reasons": cfop_ippt_apply.get("aborted_reasons") or {}, "mode": cfop_ippt_apply.get("mode")})
|
||||
session.commit()
|
||||
return stored
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Conservative deterministic NCM classification."""
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
ENGINE_VERSION = "0.3.0"
|
||||
DECISION_VERSION = "mission-04c-ipi-ex-split"
|
||||
|
||||
_NT_MARKERS = {"NT", "N/T", "NAO TRIBUTADO", "NÃO TRIBUTADO"}
|
||||
_ZERO_MARKERS = {"0", "0,00", "0.00"}
|
||||
|
||||
|
||||
def classify_tipi_rate(entry):
|
||||
"""Classify why a TIPI base entry has no usable numeric rate; never guess a value."""
|
||||
if entry.ex:
|
||||
return "EX_SPECIFIC"
|
||||
text = entry.ipi_rate_text
|
||||
if text is None:
|
||||
return "SOURCE_BLANK"
|
||||
normalized = text.strip().upper()
|
||||
if normalized in _NT_MARKERS:
|
||||
return "NT_OR_NON_TAXED"
|
||||
if entry.ipi_rate == 0 or normalized in _ZERO_MARKERS:
|
||||
return "EXPLICIT_ZERO"
|
||||
return "OTHER"
|
||||
|
||||
def normalize_code(value): return "".join(char for char in str(value or "") if char.isdigit())
|
||||
def normalize_description(value): return " ".join(re.sub(r"[^A-Z0-9]+", " ", unicodedata.normalize("NFKD", str(value or "")).encode("ascii", "ignore").decode().upper()).split())
|
||||
|
||||
# Portuguese function words with no fiscal-descriptive content. Without this, a
|
||||
# product description sharing only "DE"/"E"/"COM" with an official CEST text
|
||||
# scores a false-positive nonzero overlap that has nothing to do with the product.
|
||||
_STOPWORDS = {
|
||||
"DE", "DA", "DO", "DAS", "DOS", "E", "OU", "A", "O", "AS", "OS",
|
||||
"EM", "NO", "NA", "NOS", "NAS", "PARA", "POR", "COM", "SEM",
|
||||
"MESMO", "OUTRO", "OUTRA", "OUTROS", "OUTRAS", "QUE",
|
||||
}
|
||||
|
||||
|
||||
def description_tokens(value):
|
||||
return set(normalize_description(value).split()) - _STOPWORDS
|
||||
|
||||
class NcmStatus(str):
|
||||
def __eq__(self, other):
|
||||
if super().__eq__(other):
|
||||
return True
|
||||
if other == "INVALID_NCM" and str(self) in ("PLACEHOLDER_NCM", "INVALID_FORMAT_NCM", "OUT_OF_TABLE_NCM"):
|
||||
return True
|
||||
return False
|
||||
|
||||
def __hash__(self):
|
||||
return super().__hash__()
|
||||
|
||||
|
||||
class Classifier:
|
||||
def __init__(self, repository): self.repository = repository
|
||||
def classify(self, product):
|
||||
entries = self.repository.active_entries(); code = normalize_code(product.get("current_ncm"))
|
||||
exact = next((entry for entry in entries if entry.code == code and len(code) == 8), None)
|
||||
legacy = self.repository.legacy_ncm(code) if len(code) == 8 and hasattr(self.repository, "legacy_ncm") else None
|
||||
if exact:
|
||||
status, candidate = "EXACT_NCM", exact
|
||||
elif legacy:
|
||||
status, candidate = "LEGACY_NCM", None
|
||||
elif not code:
|
||||
status, candidate = self._candidate(entries, product.get("description"))
|
||||
elif len(set(code)) == 1 or code in ("00000000", "99999999", "11111111", "000000"):
|
||||
status, candidate = NcmStatus("PLACEHOLDER_NCM"), None
|
||||
elif len(code) != 8 or code in ("12345678", "87654321"):
|
||||
status, candidate = NcmStatus("INVALID_FORMAT_NCM"), None
|
||||
else:
|
||||
status, candidate = NcmStatus("OUT_OF_TABLE_NCM"), None
|
||||
matched_code = candidate.code if candidate else legacy["code"] if legacy else None
|
||||
cests_available = matched_code and hasattr(self.repository, "cest_for_ncm")
|
||||
cests = self.repository.cest_for_ncm(matched_code) if cests_available else None
|
||||
cests = list({item.code: item for item in cests or []}.values())
|
||||
scores = {item.code: self._description_score(product.get("description"), item.description) for item in cests}
|
||||
if legacy:
|
||||
cest_status = "LEGACY_NCM"
|
||||
elif not cests:
|
||||
cest_status = "NO_CEST_MAPPING_FOUND"
|
||||
elif max(scores.values()) < .5:
|
||||
cest_status = "CEST_DESCRIPTION_MISMATCH"
|
||||
elif len(cests) == 1:
|
||||
cest_status = "CEST_CONFIRMED"
|
||||
else:
|
||||
cest_status = "CEST_CANDIDATES"
|
||||
resolved_cest = cests[0].code if cest_status == "CEST_CONFIRMED" else None
|
||||
ipi = self._resolve_ipi(matched_code, legacy)
|
||||
mva = self._resolve_mva(matched_code, legacy, resolved_cest)
|
||||
return {"status": status, "cest_status": cest_status, "ipi_status": ipi["status"], "ba_st_mva_status": mva["status"], "classification": {"ncm": matched_code, "ncm_description": candidate.description if candidate else None, "cest": [item.code for item in cests] if cests_available else None, "cest_candidates": [{"code": item.code, "official_description": item.description, "description_score": scores[item.code]} for item in cests], "cfop": None, "cst": None, "csosn": None, "pis_cofins": None, "ipi": ipi["resolved"], "ipi_candidates": ipi["candidates"], "ba_st_mva": mva["resolved"], "ba_st_mva_candidates": mva["candidates"], "ibs_cbs": None}, "legacy_ncm": legacy, "reason_codes": ["FISCAL_DETERMINATION_NOT_CONFIGURED"], "can_auto_apply": False, "engine_version": ENGINE_VERSION, "decision_version": DECISION_VERSION, "dataset_versions": self.repository.dataset_versions()}
|
||||
|
||||
def _resolve_ipi(self, matched_code, legacy):
|
||||
if legacy:
|
||||
return {"status": "LEGACY_NCM", "resolved": None, "candidates": []}
|
||||
if not matched_code or not hasattr(self.repository, "tipi_for_ncm"):
|
||||
return {"status": "NO_TIPI_MAPPING_FOUND", "resolved": None, "candidates": []}
|
||||
entries = self.repository.tipi_for_ncm(matched_code)
|
||||
if not entries:
|
||||
return {"status": "NO_TIPI_MAPPING_FOUND", "resolved": None, "candidates": []}
|
||||
base = next((entry for entry in entries if not entry.ex), None)
|
||||
ex_variants = [entry for entry in entries if entry.ex]
|
||||
candidates = [{"ncm": entry.ncm, "ex": entry.ex, "description": entry.description, "rate": entry.ipi_rate_text} for entry in ex_variants]
|
||||
if base:
|
||||
if base.ipi_rate is not None:
|
||||
rate_kind = "NUMERIC"
|
||||
elif base.ipi_rate_text is not None:
|
||||
rate_kind = "NON_NUMERIC"
|
||||
else:
|
||||
rate_kind = "BLANK"
|
||||
resolved = {
|
||||
"ncm": base.ncm,
|
||||
"ex": base.ex,
|
||||
"description": base.description,
|
||||
"rate": base.ipi_rate_text,
|
||||
"rate_numeric": base.ipi_rate,
|
||||
"rate_kind": rate_kind,
|
||||
}
|
||||
if rate_kind != "NUMERIC":
|
||||
resolved["rate_classification"] = classify_tipi_rate(base)
|
||||
status = "IPI_CANDIDATES" if ex_variants else "IPI_RESOLVED"
|
||||
return {"status": status, "resolved": resolved, "candidates": candidates}
|
||||
return {"status": "IPI_CANDIDATES", "resolved": None, "candidates": candidates}
|
||||
|
||||
def _resolve_mva(self, matched_code, legacy, resolved_cest):
|
||||
if legacy:
|
||||
return {"status": "LEGACY_NCM", "resolved": None, "candidates": []}
|
||||
if not matched_code or not hasattr(self.repository, "ba_st_mva_for_ncm"):
|
||||
return {"status": "NO_MVA_MAPPING_FOUND", "resolved": None, "candidates": []}
|
||||
entries = self.repository.ba_st_mva_for_ncm(matched_code)
|
||||
if not entries:
|
||||
return {"status": "NO_MVA_MAPPING_FOUND", "resolved": None, "candidates": []}
|
||||
candidates = [{"item": entry.item, "cest": entry.cest, "mva_original": entry.mva_original, "mva_adjusted": entry.mva_adjusted, "description": entry.description} for entry in entries]
|
||||
if len(entries) == 1:
|
||||
return {"status": "MVA_RESOLVED", "resolved": candidates[0], "candidates": candidates}
|
||||
if resolved_cest:
|
||||
by_cest = [item for item in candidates if item["cest"] == resolved_cest]
|
||||
if len(by_cest) == 1:
|
||||
return {"status": "MVA_RESOLVED", "resolved": by_cest[0], "candidates": candidates}
|
||||
return {"status": "MVA_CANDIDATES", "resolved": None, "candidates": candidates}
|
||||
def _candidate(self, entries, description):
|
||||
tokens = set(normalize_description(description).split())
|
||||
scores = sorted(((len(tokens & set(normalize_description(item.description).split())) / len(tokens or {"_"}), item) for item in entries), reverse=True, key=lambda row: row[0])
|
||||
if not scores or scores[0][0] == 0: return "NOT_FOUND", None
|
||||
return ("HIGH_CONFIDENCE_CANDIDATE", scores[0][1]) if scores[0][0] >= .90 and (len(scores) == 1 or scores[0][0] - scores[1][0] >= .15) else ("AMBIGUOUS", None)
|
||||
|
||||
def _description_score(self, product_description, official_description):
|
||||
product_tokens = description_tokens(product_description)
|
||||
official_tokens = description_tokens(official_description)
|
||||
return len(product_tokens & official_tokens) / len(product_tokens or {"_"})
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Client mirror from GTI Gerenciador — the single source of truth for
|
||||
clients (central/app/models.py: Tenant.source/external_client_id/active).
|
||||
The Fiscal Hub never receives MySQL credentials; it calls the panel's
|
||||
read-only internal endpoint instead, authenticated by a dedicated bearer
|
||||
token (FISCAL_INTERNAL_API_TOKEN, shared only between the two apps' envs
|
||||
— never SESSION_SECRET, the SSO key, an admin password or an Agent token).
|
||||
|
||||
Run `central/scripts/sync_clients_from_gerenciador.py` to pull a fresh copy;
|
||||
`Tenant.source='LOCAL'` rows (pre-existing dev/test tenants) are never
|
||||
touched or offered for a new installation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
|
||||
from . import config
|
||||
from .models import Tenant
|
||||
|
||||
SOURCE = "GTI_GERENCIADOR"
|
||||
|
||||
|
||||
class ClientSyncUnavailable(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def enabled() -> bool:
|
||||
return bool(config.settings.gerenciador_internal_url and config.settings.fiscal_internal_api_token)
|
||||
|
||||
|
||||
def fetch_from_gerenciador() -> list[dict]:
|
||||
if not enabled():
|
||||
raise ClientSyncUnavailable("GERENCIADOR_INTERNAL_URL/FISCAL_INTERNAL_API_TOKEN not configured")
|
||||
headers = {"Authorization": f"Bearer {config.settings.fiscal_internal_api_token}"}
|
||||
try:
|
||||
response = httpx.get(config.settings.gerenciador_internal_url, headers=headers, timeout=config.settings.http_timeout_seconds)
|
||||
except httpx.HTTPError as error:
|
||||
raise ClientSyncUnavailable(f"request failed: {error}") from error
|
||||
if response.status_code != 200:
|
||||
raise ClientSyncUnavailable(f"unexpected status {response.status_code}: {response.text[:200]}")
|
||||
return response.json()["clients"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncResult:
|
||||
fetched: int
|
||||
created: int
|
||||
updated: int
|
||||
unchanged: int
|
||||
active: int
|
||||
inactive: int
|
||||
|
||||
|
||||
def sync_from_gerenciador(session, clients: list[dict]) -> SyncResult:
|
||||
"""Upsert `clients` (each: external_client_id, code, display_name, active)
|
||||
into Tenant rows with source='GTI_GERENCIADOR'. Never touches a
|
||||
source='LOCAL' row. A display-name collision with another tenant is
|
||||
disambiguated with the external code rather than failing the whole sync
|
||||
over one client (Tenant.name is UNIQUE)."""
|
||||
now = datetime.now(timezone.utc)
|
||||
existing_by_external = {row.external_client_id: row for row in session.scalars(select(Tenant).where(Tenant.source == SOURCE)).all()}
|
||||
taken_names = {row.name for row in session.scalars(select(Tenant)).all()}
|
||||
created = updated = unchanged = 0
|
||||
|
||||
for client in clients:
|
||||
external_id = client["external_client_id"]
|
||||
code = client["code"]
|
||||
active = bool(client["active"])
|
||||
wanted_name = (client.get("display_name") or "").strip() or f"Cliente {code}"
|
||||
row = existing_by_external.get(external_id)
|
||||
|
||||
if row is None:
|
||||
name = wanted_name if wanted_name not in taken_names else f"{wanted_name} ({code})"
|
||||
row = Tenant(name=name, source=SOURCE, external_client_id=external_id, external_code=code, external_cnpj=client.get("cnpj"), active=active, synced_at=now)
|
||||
session.add(row)
|
||||
taken_names.add(name)
|
||||
created += 1
|
||||
continue
|
||||
|
||||
changed = False
|
||||
if row.name != wanted_name and wanted_name not in taken_names:
|
||||
taken_names.discard(row.name)
|
||||
row.name = wanted_name
|
||||
taken_names.add(wanted_name)
|
||||
changed = True
|
||||
if row.external_code != code:
|
||||
row.external_code = code
|
||||
changed = True
|
||||
if row.external_cnpj != client.get("cnpj"):
|
||||
row.external_cnpj = client.get("cnpj")
|
||||
changed = True
|
||||
if row.active != active:
|
||||
row.active = active
|
||||
changed = True
|
||||
row.synced_at = now
|
||||
if changed:
|
||||
updated += 1
|
||||
else:
|
||||
unchanged += 1
|
||||
|
||||
session.flush()
|
||||
active_count = sum(1 for c in clients if c["active"])
|
||||
return SyncResult(fetched=len(clients), created=created, updated=updated, unchanged=unchanged, active=active_count, inactive=len(clients) - active_count)
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Environment-backed Fiscal Hub configuration."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
def _database_url() -> str:
|
||||
configured = os.getenv("DATABASE_URL")
|
||||
if configured:
|
||||
return configured
|
||||
password = quote(os.getenv("POSTGRES_PASSWORD", "gti"), safe="")
|
||||
return f"postgresql+psycopg://gti:{password}@postgres:5432/gti_fiscal"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
database_url: str = _database_url()
|
||||
hub_port: int = int(os.getenv("HUB_PORT", "8000"))
|
||||
# AGENT_DEV_TOKEN auto-bootstraps an agent from a fixed dev token on every request.
|
||||
# Leave unset in any environment other than local development — production Agents
|
||||
# must be created via scripts/register_agent.py, whose token is never derived from
|
||||
# this setting.
|
||||
agent_dev_token: str = os.getenv("AGENT_DEV_TOKEN", "")
|
||||
environment: str = os.getenv("APP_ENV", "development")
|
||||
log_level: str = os.getenv("LOG_LEVEL", "INFO")
|
||||
base_url: str = os.getenv("BASE_URL", "http://localhost:8000")
|
||||
# An Agent is considered ONLINE if a heartbeat arrived within this many seconds.
|
||||
agent_online_threshold_seconds: int = int(os.getenv("AGENT_ONLINE_THRESHOLD_SECONDS", "120"))
|
||||
# Backpressure: how many FULL jobs (INITIAL_SYNC / REPROCESS_ALL: whole-database classification) may run at once across ALL Agents. Others wait as PENDING. <=0 disables the limit.
|
||||
heavy_job_concurrency_limit: int = int(os.getenv("HEAVY_JOB_CONCURRENCY_LIMIT", "3"))
|
||||
agent_heartbeat_interval_seconds: int = int(os.getenv("AGENT_HEARTBEAT_INTERVAL_SECONDS", "40"))
|
||||
agent_offline_after_seconds: int = int(os.getenv("AGENT_OFFLINE_AFTER_SECONDS", "0"))
|
||||
agent_server_version: str = os.getenv("CURRENT_SERVER_VERSION", "1.0.0rc1")
|
||||
agent_event_retention_days: int = int(os.getenv("AGENT_EVENT_RETENTION_DAYS", "90"))
|
||||
web_session_seconds: int = int(os.getenv("WEB_SESSION_SECONDS", "28800"))
|
||||
web_cookie_secure: bool = os.getenv("WEB_COOKIE_SECURE", "0") == "1"
|
||||
web_cookie_name: str = os.getenv("WEB_COOKIE_NAME", "gti_admin_session")
|
||||
web_max_event_message: int = int(os.getenv("WEB_MAX_EVENT_MESSAGE", "2000"))
|
||||
web_max_event_context_bytes: int = int(os.getenv("WEB_MAX_EVENT_CONTEXT_BYTES", "12000"))
|
||||
ui_timezone: str = os.getenv("UI_TIMEZONE", "America/Bahia")
|
||||
allow_weak_dev_password: bool = os.getenv("ALLOW_WEAK_DEV_PASSWORD", "0") == "1"
|
||||
# ---- SSO from GTI Gerenciador (central/app/sso.py) ----
|
||||
# Ed25519 public key (raw 32 bytes, base64url) of the panel that signs the
|
||||
# assertions; the private key never exists on the Hub. SSO is active only
|
||||
# when both the key and the panel's start URL are set.
|
||||
sso_public_key: str = os.getenv("SSO_GERENCIADOR_CHAVE_PUBLICA", "")
|
||||
sso_start_url: str = os.getenv("SSO_START_URL", "")
|
||||
federated_session_seconds: int = int(os.getenv("FEDERATED_SESSION_SECONDS", "3600"))
|
||||
# ---- Agent distribution (central/app/agent_distribution.py) ----
|
||||
# Local, non-web-served directory holding the released Windows Agent zips.
|
||||
# Never inside PostgreSQL, never under central/static.
|
||||
agent_release_storage_path: str = os.getenv("AGENT_RELEASE_STORAGE_PATH", "./data/releases")
|
||||
agent_download_link_ttl_seconds: int = int(os.getenv("AGENT_DOWNLOAD_LINK_TTL_SECONDS", "1800"))
|
||||
agent_download_max_uses_default: int = int(os.getenv("AGENT_DOWNLOAD_MAX_USES_DEFAULT", "1"))
|
||||
agent_enrollment_ttl_seconds: int = int(os.getenv("AGENT_ENROLLMENT_TTL_SECONDS", "86400"))
|
||||
# ---- Client mirror from GTI Gerenciador (central/app/client_sync.py) ----
|
||||
# GTI Gerenciador is the single source of truth for clients; the Fiscal Hub
|
||||
# never gets MySQL credentials. Both empty = sync disabled (documented).
|
||||
gerenciador_internal_url: str = os.getenv("GERENCIADOR_INTERNAL_URL", "") # e.g. https://gtigerenciador.../internal/fiscal/clients
|
||||
fiscal_internal_api_token: str = os.getenv("FISCAL_INTERNAL_API_TOKEN", "")
|
||||
ncm_dataset_url: str = os.getenv("NCM_DATASET_URL", "https://portalunico.siscomex.gov.br/classif/api/publico/nomenclatura/download/json")
|
||||
cest_dataset_url: str = os.getenv("CEST_DATASET_URL", "https://www.confaz.fazenda.gov.br/legislacao/convenios/2018/CV142_18")
|
||||
dataset_storage_path: str = os.getenv("DATASET_STORAGE_PATH", "./data/datasets")
|
||||
http_timeout_seconds: float = float(os.getenv("HTTP_TIMEOUT_SECONDS", "30"))
|
||||
# ---- AI semantic layer V1 ----
|
||||
# Credentials live ONLY in the Hub environment; the Windows Agent never sees them.
|
||||
ai_enabled: bool = os.getenv("AI_ENABLED", "0") == "1"
|
||||
ai_provider: str = os.getenv("AI_PROVIDER", "baseline") # baseline | openai | ... (AISemanticProvider)
|
||||
ai_model: str = os.getenv("AI_MODEL", "baseline-deterministic-v1")
|
||||
ai_max_batch_size: int = int(os.getenv("AI_MAX_BATCH_SIZE", "20"))
|
||||
ai_max_run_cost_usd: float = float(os.getenv("AI_MAX_RUN_COST_USD", "5.0"))
|
||||
ai_min_confidence: float = float(os.getenv("AI_MIN_CONFIDENCE", "0.6"))
|
||||
ai_price_in_per_m: float | None = float(os.getenv("AI_PRICE_IN_PER_M")) if os.getenv("AI_PRICE_IN_PER_M") else None
|
||||
ai_price_out_per_m: float | None = float(os.getenv("AI_PRICE_OUT_PER_M")) if os.getenv("AI_PRICE_OUT_PER_M") else None
|
||||
# Real-LLM evaluation (OpenAI-compatible). The key lives ONLY in this environment.
|
||||
# Z.ai general endpoint (NOT the /api/coding/paas/v4 coding-plan endpoint).
|
||||
ai_api_key: str = os.getenv("AI_API_KEY", "")
|
||||
ai_base_url: str = os.getenv("AI_BASE_URL", "https://api.z.ai/api/paas/v4")
|
||||
# ---- Product enrichment (docs/product_enrichment.md): free providers only, tokens live ONLY in the Hub environment ----
|
||||
cosmos_token: str = os.getenv("COSMOS_TOKEN", "") # optional; without it Cosmos is NOT_CONFIGURED (not an error)
|
||||
oscbr_token: str = os.getenv("OSCBR_TOKEN", "") # preferred: a ready Bearer token instead of the login
|
||||
cosmos_enabled: bool = os.getenv("COSMOS_ENABLED", "0") == "1" # Cosmos stays out of the active chain unless explicitly enabled
|
||||
oscbr_backfill_enabled: bool = os.getenv("OSCBR_BACKFILL_ENABLED", "0") == "1" # gradual backfill of CORROBORATED GTINs; OFF until a real benchmark proves value
|
||||
oscbr_user: str = os.getenv("OSCBR_USER", "") # optional free-plan account of gtin.rscsistemas.com.br (never in logs/UI)
|
||||
oscbr_password: str = os.getenv("OSCBR_PASSWORD", "")
|
||||
kodebar_api_key: str = os.getenv("KODEBAR_API_KEY", "") # optional free-plan key (identity only); never in logs/UI
|
||||
provider_operational_reserve_percent: int = int(os.getenv("PROVIDER_OPERATIONAL_RESERVE_PERCENT", "20"))
|
||||
allow_paid_providers: bool = os.getenv("ALLOW_PAID_PROVIDERS", "0") == "1"
|
||||
official_refresh_enabled: bool = os.getenv("OFFICIAL_REFRESH_ENABLED", "1") == "1"
|
||||
enrichment_time_budget_seconds: float = float(os.getenv("ENRICHMENT_TIME_BUDGET_SECONDS", "4.5"))
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Validation of operator fiscal-context entries (facts the ERP does not know). Pure; shared by the Hub API, the importer CLI and the resolver."""
|
||||
import json
|
||||
|
||||
SCOPES = ("COMPANY", "GROUP", "NCM", "PRODUCT")
|
||||
ENUMS = {
|
||||
"product_role": {"RESALE", "OWN_PRODUCTION", "INPUT", "SERVICE"},
|
||||
"st_status": {"SUBJECT", "NOT_SUBJECT"},
|
||||
"st_withheld": {"Y", "N"},
|
||||
"icms_treatment": {"TAXED", "EXEMPT", "NOT_TAXED", "ST_WITHHELD", "SUSPENDED", "DEFERRED"},
|
||||
# MIXED_OR_DYNAMIC: purchases are not predominantly intra- or interstate, so no single
|
||||
# static entry CFOP is fiscally correct for all operations (section 0, AI semantic layer V1).
|
||||
"entry_scope": {"INTRA", "INTER", "MIXED_OR_DYNAMIC"},
|
||||
"rounding": {"A", "T"},
|
||||
"piscofins": {"N", "S"},
|
||||
"incidence": {"NENHUMA"},
|
||||
"origin": {str(i) for i in range(9)},
|
||||
"store_ipi_profile": {"POPULATE_REFERENCE_RATE", "LEAVE_NOT_APPLICABLE"},
|
||||
# C000004.CONTRIBUINTE_IPI is not yet sent at heartbeat (no Agent-side reader exists for it): until that auto-discovery lands, this is the
|
||||
# one-time operator answer field_contract's CST_IPI rule needs - never assumed "N" just because the Agent hasn't reported it.
|
||||
"contribuinte_ipi": {"S", "N"},
|
||||
}
|
||||
PROFILE_KEYS = {"pis_in_cst", "pis_out_cst", "pis_rate", "cofins_in_cst", "cofins_out_cst", "cofins_rate"}
|
||||
FREE_KEYS = {"ncm", "cest", "ex_tipi", "icms_rate", "credit_rate", "cst_ipi", "service_cfop", "pis_cofins_profile"}
|
||||
ALLOWED_KEYS = set(ENUMS) | FREE_KEYS
|
||||
PRODUCT_ONLY = {"ncm"}
|
||||
COMPANY_ONLY = {"store_ipi_profile", "contribuinte_ipi"}
|
||||
NCM_KEYS = {"cest", "ex_tipi"} # tied to the NCM tables: answered per NCM or per product # an NCM answer is always about one product
|
||||
|
||||
|
||||
def validate_entry(entry):
|
||||
"""Returns (normalized_entry, error). error is None when valid."""
|
||||
scope = str(entry.get("scope", "")).strip().upper()
|
||||
key = str(entry.get("key", "")).strip()
|
||||
value = entry.get("value")
|
||||
value = "" if value is None else str(value).strip()
|
||||
scope_key = str(entry.get("scope_key") or "").strip()
|
||||
if scope not in SCOPES:
|
||||
return None, f"scope must be one of {SCOPES}"
|
||||
if key not in ALLOWED_KEYS:
|
||||
return None, f"unknown key {key!r}"
|
||||
if scope != "COMPANY" and not scope_key:
|
||||
return None, "scope_key is required for GROUP/NCM/PRODUCT scope"
|
||||
if key in COMPANY_ONLY and scope != "COMPANY":
|
||||
return None, f"{key} is a COMPANY-scope answer"
|
||||
if key in PRODUCT_ONLY and scope != "PRODUCT":
|
||||
return None, f"{key} is a PRODUCT-scope answer"
|
||||
if key in NCM_KEYS and scope not in ("PRODUCT", "NCM"):
|
||||
return None, f"{key} can only be answered for a PRODUCT or an NCM"
|
||||
if not value:
|
||||
return None, "value is empty"
|
||||
if key in ENUMS and value.upper() not in ENUMS[key] and value not in ENUMS[key]:
|
||||
return None, f"{key} must be one of {sorted(ENUMS[key])}"
|
||||
if key in ENUMS:
|
||||
value = value.upper() if key != "origin" else value
|
||||
if key == "ncm" and not (value.isdigit() and len(value) == 8):
|
||||
return None, "ncm must be 8 digits"
|
||||
if key == "cest" and value.upper() != "NONE" and not (value.replace(".", "").isdigit() and len(value.replace(".", "")) == 7):
|
||||
return None, "cest must be 7 digits or NONE"
|
||||
if key in ("icms_rate", "credit_rate"):
|
||||
try:
|
||||
float(value.replace(",", "."))
|
||||
except ValueError:
|
||||
return None, f"{key} must be numeric"
|
||||
if key == "pis_cofins_profile" and value.upper() not in ("NOT_APPLICABLE", "OPERATION_TIME"):
|
||||
try:
|
||||
prof = json.loads(value)
|
||||
except ValueError:
|
||||
return None, "pis_cofins_profile must be JSON, or exactly NOT_APPLICABLE / OPERATION_TIME"
|
||||
if not isinstance(prof, dict) or not prof or set(prof) - PROFILE_KEYS:
|
||||
return None, f"pis_cofins_profile keys must be a non-empty subset of {sorted(PROFILE_KEYS)}"
|
||||
elif key == "pis_cofins_profile":
|
||||
value = value.upper()
|
||||
return {"scope": scope, "scope_key": scope_key if scope != "COMPANY" else "", "key": key, "value": value, "reason": (entry.get("reason") or "").strip() or None}, None
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Human-authenticated JSON API for the control panel."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy import select
|
||||
|
||||
from .audit import record_audit
|
||||
from .blocker_resolution import BlockerResolutionUnavailable, resolve_blocker_with_context
|
||||
from .installation_identity import reference_agent_version
|
||||
from .control_plane import dashboard_summary, installation_view, list_blockers, list_installations, list_jobs, list_panel_events
|
||||
from .human_auth import require_csrf, require_human
|
||||
from .models import Agent, AgentSnapshot, BlockedProduct, Installation, Job, Tenant
|
||||
from .jobs import create_job
|
||||
|
||||
|
||||
def router(session_factory):
|
||||
routes = APIRouter(prefix="/v1")
|
||||
|
||||
def identity(request: Request):
|
||||
session = session_factory()
|
||||
try:
|
||||
web_session, admin = require_human(request, session)
|
||||
return session, web_session, admin
|
||||
except Exception:
|
||||
session.close(); raise
|
||||
|
||||
@routes.get("/dashboard/summary")
|
||||
def summary(identity=Depends(identity)):
|
||||
session, _, _ = identity
|
||||
try: return dashboard_summary(session)
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/agents")
|
||||
def agents(page: int = 1, page_size: int = 50, client: str | None = None, health: str | None = None, version: str | None = None, search: str | None = None, last_seen_before: str | None = None, identity=Depends(identity)):
|
||||
session, _, _ = identity
|
||||
try: return list_installations(session, page=page, page_size=page_size, client=client, health=health, version=version, search=search, last_seen_before=datetime.fromisoformat(last_seen_before) if last_seen_before else None)
|
||||
finally: session.close()
|
||||
|
||||
def get_installation(session, agent_id: int):
|
||||
row = session.execute(select(Installation, Agent, Tenant, AgentSnapshot).join(Agent, Agent.id == Installation.agent_id).join(Tenant, Tenant.id == Installation.tenant_id).outerjoin(AgentSnapshot, AgentSnapshot.installation_id == Installation.id).where(Agent.id == agent_id)).first()
|
||||
if not row: raise HTTPException(404, "Agent installation not found")
|
||||
return row
|
||||
|
||||
@routes.get("/agents/{agent_id}")
|
||||
def agent_detail(agent_id: int, identity=Depends(identity)):
|
||||
session, _, _ = identity
|
||||
try:
|
||||
installation, agent, tenant, snapshot = get_installation(session, agent_id)
|
||||
return installation_view(installation, agent, tenant, snapshot, reference=reference_agent_version(session))
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/agents/{agent_id}/health")
|
||||
def agent_health(agent_id: int, identity=Depends(identity)):
|
||||
return agent_detail(agent_id, identity)
|
||||
|
||||
@routes.get("/agents/{agent_id}/coverage")
|
||||
def agent_coverage(agent_id: int, identity=Depends(identity)):
|
||||
detail = agent_detail(agent_id, identity)
|
||||
return {key: detail[key] for key in ("total_products", "fiscally_complete", "safe_blocked", "failed_products", "coverage_percent", "blockers")}
|
||||
|
||||
@routes.get("/agents/{agent_id}/blockers")
|
||||
def agent_blockers(agent_id: int, page: int = 1, page_size: int = 50, blocker_type: str | None = None, field: str | None = None, identity=Depends(identity)):
|
||||
session, _, _ = identity
|
||||
try:
|
||||
installation, agent, _, _ = get_installation(session, agent_id)
|
||||
return list_blockers(session, tenant_id=installation.tenant_id, agent_id=agent_id, page=page, page_size=page_size, blocker_type=blocker_type, field=field)
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/agents/{agent_id}/jobs")
|
||||
def agent_jobs(agent_id: int, page: int = 1, page_size: int = 50, status: str | None = None, kind: str | None = None, identity=Depends(identity)):
|
||||
session, _, _ = identity
|
||||
try: return list_jobs(session, agent_id=agent_id, page=page, page_size=page_size, status=status, kind=kind)
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/agents/{agent_id}/events")
|
||||
def agent_events(agent_id: int, page: int = 1, page_size: int = 50, severity: str | None = None, event_type: str | None = None, since: str | None = None, until: str | None = None, identity=Depends(identity)):
|
||||
session, _, _ = identity
|
||||
try:
|
||||
installation, _, _, _ = get_installation(session, agent_id)
|
||||
return {"page": max(1, page), "page_size": min(100, max(1, page_size)), "items": list_panel_events(session, installation.id, page=page, page_size=page_size, severity=severity, event_type=event_type, since=datetime.fromisoformat(since) if since else None, until=datetime.fromisoformat(until) if until else None)}
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/blockers/global")
|
||||
def global_blockers(page: int = 1, page_size: int = 50, blocker_type: str | None = None, field: str | None = None, identity=Depends(identity)):
|
||||
session, _, _ = identity
|
||||
try: return list_blockers(session, page=page, page_size=page_size, blocker_type=blocker_type, field=field)
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/jobs/global")
|
||||
def global_jobs(page: int = 1, page_size: int = 50, status: str | None = None, kind: str | None = None, since: str | None = None, until: str | None = None, identity=Depends(identity)):
|
||||
session, _, _ = identity
|
||||
try: return list_jobs(session, page=page, page_size=page_size, status=status, kind=kind, since=datetime.fromisoformat(since) if since else None, until=datetime.fromisoformat(until) if until else None)
|
||||
finally: session.close()
|
||||
|
||||
@routes.post("/agents/{agent_id}/actions/reprocess")
|
||||
def reprocess(agent_id: int, payload: dict, request: Request, identity=Depends(identity)):
|
||||
session, web_session, admin = identity
|
||||
try:
|
||||
require_csrf(web_session, payload.get("csrf_token"))
|
||||
if payload.get("confirm") is not True: raise HTTPException(400, "explicit confirmation required")
|
||||
installation, agent, _, _ = get_installation(session, agent_id)
|
||||
kind = payload.get("kind", "REPROCESS_ALL")
|
||||
if kind not in {"REPROCESS_ALL", "REPROCESS_PRODUCTS"}: raise HTTPException(400, "unsupported action")
|
||||
job_payload = payload.get("payload") or {}
|
||||
if kind == "REPROCESS_PRODUCTS" and not job_payload.get("codigos"): raise HTTPException(400, "codigos required")
|
||||
existing = next((candidate for candidate in session.scalars(select(Job).where(Job.agent_id == agent.id, Job.kind == kind, Job.status == "PENDING")).all() if candidate.payload == job_payload), None)
|
||||
job = existing or create_job(session, agent, kind, job_payload)
|
||||
record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action=kind, target_type="installation", target_id=installation.installation_id, context={"job_id": job.id, "request_ip": request.client.host if request.client else None})
|
||||
session.commit()
|
||||
return {"job_id": job.id, "status": job.status}
|
||||
finally: session.close()
|
||||
|
||||
@routes.post("/blockers/{blocker_id}/resolve-context")
|
||||
@routes.post("/blockers/{blocker_id}/resolve-human")
|
||||
def resolve_blocker(blocker_id: int, payload: dict, request: Request, identity=Depends(identity)):
|
||||
session, web_session, admin = identity
|
||||
try:
|
||||
require_csrf(web_session, payload.get("csrf_token"))
|
||||
entry_payload = {key: payload.get(key) for key in ("scope", "scope_key", "key", "value", "reason")}
|
||||
try:
|
||||
return resolve_blocker_with_context(session, blocker_id, admin, entry_payload, request_ip=request.client.host if request.client else None)
|
||||
except BlockerResolutionUnavailable as error:
|
||||
raise HTTPException(409, str(error)) from error
|
||||
except LookupError as error:
|
||||
raise HTTPException(404, str(error)) from error
|
||||
except ValueError as error:
|
||||
raise HTTPException(422, str(error)) from error
|
||||
finally: session.close()
|
||||
|
||||
return routes
|
||||
@@ -0,0 +1,613 @@
|
||||
"""Bounded control-panel queries backed by installation snapshots."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from sqlalchemy import Numeric, and_, case, cast, func, or_, select
|
||||
|
||||
from .config import settings
|
||||
from .events import list_events
|
||||
from .installation_identity import compute_agent_health, compute_version_drift, outdated_versions, reference_agent_version
|
||||
from .models import Agent, AgentEnrollment, AgentEvent, AgentSnapshot, AuditEvent, BlockedProduct, Installation, Job, Tenant
|
||||
from .panel_presentation import health_reason_label, blocker_reason_label, blocker_type_label, fiscal_status_label, format_age, format_duration, format_ui_datetime, health_label, job_kind_label, job_status_label, operational_priority, priority_label
|
||||
|
||||
|
||||
HEALTH_STATES = ("ONLINE", "DEGRADED", "OFFLINE", "FAILED", "UNKNOWN")
|
||||
BLOCKER_FIELDS = ("NCM", "CEST", "CFOP", "ST", "ICMS", "CSOSN", "PIS", "COFINS", "RTC", "OTHER")
|
||||
ATTENTION_CANDIDATE_LIMIT = 10
|
||||
INACTIVE_ENROLLMENT_STATES = ("REVOKED", "FAILED", "EXPIRED")
|
||||
|
||||
|
||||
def operational_installation_predicate():
|
||||
"""Only installations eligible for current operational indicators.
|
||||
|
||||
Legacy Agents without an enrollment remain eligible. Disabling job
|
||||
processing is not the same as deactivating an installation.
|
||||
"""
|
||||
inactive_enrollment = select(AgentEnrollment.id).where(
|
||||
AgentEnrollment.agent_id == Agent.id,
|
||||
AgentEnrollment.status.in_(INACTIVE_ENROLLMENT_STATES),
|
||||
).exists()
|
||||
return and_(Tenant.active.is_(True), Agent.enabled.is_(True), ~inactive_enrollment)
|
||||
|
||||
|
||||
def _page(page: int, page_size: int):
|
||||
return max(1, page), min(100, max(1, page_size))
|
||||
|
||||
|
||||
def _iso(value):
|
||||
if value is None:
|
||||
return None
|
||||
value = value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value.astimezone(timezone.utc)
|
||||
return value.isoformat()
|
||||
|
||||
|
||||
def _utc_now(value=None):
|
||||
value = value or datetime.now(timezone.utc)
|
||||
return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value
|
||||
|
||||
|
||||
def _health_expression(now):
|
||||
offline_after = settings.agent_offline_after_seconds or settings.agent_heartbeat_interval_seconds * 3
|
||||
cutoff = _utc_now(now) - timedelta(seconds=offline_after)
|
||||
return case(
|
||||
(Installation.last_seen_at.is_(None), "UNKNOWN"),
|
||||
(or_(AgentSnapshot.agent_health == "FAILED", Installation.last_status == "FAILED"), "FAILED"),
|
||||
(Installation.last_seen_at < cutoff, "OFFLINE"),
|
||||
(or_(AgentSnapshot.failed_products > 0, AgentSnapshot.last_job_status == "FAILED"), "DEGRADED"),
|
||||
else_="ONLINE",
|
||||
)
|
||||
|
||||
|
||||
def _fiscal_completion_expression():
|
||||
total = func.coalesce(AgentSnapshot.total_products, 0)
|
||||
complete = func.coalesce(AgentSnapshot.fiscally_complete, 0)
|
||||
numeric_total = cast(total, Numeric(18, 6))
|
||||
numeric_complete = cast(complete * 100, Numeric(18, 6))
|
||||
raw = case((total > 0, numeric_complete / numeric_total), else_=cast(func.coalesce(AgentSnapshot.coverage_percent, 0), Numeric(18, 6)))
|
||||
return func.round(case((raw < 0, 0), (raw > 100, 100), else_=raw), 1)
|
||||
|
||||
|
||||
def _fiscal_completion(snapshot) -> float:
|
||||
total = int(getattr(snapshot, "total_products", 0) or 0)
|
||||
complete = int(getattr(snapshot, "fiscally_complete", 0) or 0)
|
||||
if total:
|
||||
value = Decimal(max(0, min(total, complete)) * 100) / Decimal(total)
|
||||
else:
|
||||
value = Decimal(str(max(0, min(100, float(getattr(snapshot, "coverage_percent", 0) or 0)))))
|
||||
return float(value.quantize(Decimal("0.1"), rounding=ROUND_HALF_UP))
|
||||
|
||||
|
||||
def _fiscal_status(snapshot) -> str:
|
||||
if int(getattr(snapshot, "safe_blocked", 0) or 0):
|
||||
return "BLOCKED"
|
||||
total = int(getattr(snapshot, "total_products", 0) or 0)
|
||||
complete = int(getattr(snapshot, "fiscally_complete", 0) or 0)
|
||||
if total and complete >= total:
|
||||
return "COMPLETE"
|
||||
return "INCOMPLETE"
|
||||
|
||||
|
||||
def _coverage_partition(snapshot) -> dict:
|
||||
"""Expose only explicit snapshot states and label the remaining count honestly."""
|
||||
total = int(getattr(snapshot, "total_products", 0) or 0)
|
||||
complete = int(getattr(snapshot, "fiscally_complete", 0) or 0)
|
||||
safe_blocked = int(getattr(snapshot, "safe_blocked", 0) or 0)
|
||||
failed = int(getattr(snapshot, "failed_products", 0) or 0)
|
||||
explicit = complete + safe_blocked + failed
|
||||
residual = total - explicit
|
||||
invalid_counts = min(total, complete, safe_blocked, failed) < 0
|
||||
overcount = max(0, explicit - total)
|
||||
return {
|
||||
"unclassified_products": max(0, residual) if not invalid_counts else 0,
|
||||
"coverage_overcount_products": overcount,
|
||||
"coverage_invalid_counts": invalid_counts,
|
||||
"coverage_invariant_pass": not invalid_counts and overcount == 0,
|
||||
}
|
||||
|
||||
|
||||
def _technical_timestamp(item):
|
||||
if item["technical_health"] in {"FAILED", "DEGRADED"}:
|
||||
return item.get("last_job_at") or item.get("last_seen_at")
|
||||
return item.get("last_seen_at")
|
||||
|
||||
|
||||
def _attention_item(item, problem, timestamp, *, rank, link=None, now=None):
|
||||
timestamp_value = datetime.fromisoformat(timestamp) if isinstance(timestamp, str) else timestamp
|
||||
return {
|
||||
"client": item.get("client"),
|
||||
"display_name": item.get("display_name"),
|
||||
"hostname": item.get("hostname"),
|
||||
"problem": problem,
|
||||
"timestamp": timestamp if isinstance(timestamp, str) else _iso(timestamp_value),
|
||||
"timestamp_display": format_ui_datetime(timestamp_value),
|
||||
"age": format_age(timestamp_value, now),
|
||||
"link": link or f"/agents/{item['agent_id']}",
|
||||
"technical_health": item["technical_health"],
|
||||
"rank": rank,
|
||||
}
|
||||
|
||||
|
||||
def installation_view(installation, agent, tenant, snapshot=None, *, now=None, reference=None):
|
||||
has_snapshot = snapshot is not None
|
||||
snapshot = snapshot or AgentSnapshot(installation_id=installation.id)
|
||||
health, reason = compute_agent_health(installation, now=now, supplemental=snapshot)
|
||||
# Always recomputed against the current reference release: the value stored on the snapshot was taken at the
|
||||
# last heartbeat (against whatever was "current" then) and goes stale as soon as a newer release is published.
|
||||
version_drift = compute_version_drift(agent.agent_version, reference)
|
||||
fiscal_status = _fiscal_status(snapshot)
|
||||
coverage_partition = _coverage_partition(snapshot)
|
||||
if not has_snapshot:
|
||||
# No heartbeat/snapshot yet (e.g. an enrollment-only E2E row): expose
|
||||
# no counts at all rather than fabricated zeros — the UI shows
|
||||
# "Não informado" instead of None/0.
|
||||
coverage_partition = {"unclassified_products": None, "coverage_overcount_products": 0, "coverage_invalid_counts": False, "coverage_invariant_pass": True}
|
||||
version_drift_label = {"CURRENT": "Atualizada", "OUTDATED": "Desatualizada", "UNKNOWN": "Desconhecida"}.get(version_drift, str(version_drift))
|
||||
agent_version_display = f"{agent.agent_version} · {version_drift_label}" if agent.agent_version else "Desconhecida"
|
||||
return {
|
||||
"agent_id": agent.id,
|
||||
"installation_id": installation.installation_id,
|
||||
"installation_db_id": installation.id,
|
||||
"client": tenant.name if tenant else None,
|
||||
"tenant_id": installation.tenant_id,
|
||||
"display_name": installation.display_name,
|
||||
"hostname": installation.hostname,
|
||||
"company_name": installation.company_name,
|
||||
"company_code": installation.company_code,
|
||||
"company_cnpj": installation.company_cnpj_masked,
|
||||
"company_uf": installation.company_uf,
|
||||
"company_crt": installation.company_crt,
|
||||
"agent_version": agent.agent_version,
|
||||
"agent_version_display": agent_version_display,
|
||||
"version_drift": version_drift,
|
||||
"version_drift_label": version_drift_label,
|
||||
"health": health,
|
||||
"health_reason": reason,
|
||||
"technical_health": health,
|
||||
"health_label": health_label(health),
|
||||
"technical_health_label": health_label(health),
|
||||
"last_seen_at": _iso(installation.last_seen_at),
|
||||
"last_seen_display": format_ui_datetime(installation.last_seen_at),
|
||||
"last_seen_age": format_age(installation.last_seen_at, now),
|
||||
"started_at": _iso(installation.started_at),
|
||||
"last_status": installation.last_status,
|
||||
"total_products": snapshot.total_products if has_snapshot else None,
|
||||
"fiscally_complete": snapshot.fiscally_complete if has_snapshot else None,
|
||||
"safe_blocked": snapshot.safe_blocked if has_snapshot else None,
|
||||
"failed_products": snapshot.failed_products if has_snapshot else None,
|
||||
**coverage_partition,
|
||||
"coverage_percent": snapshot.coverage_percent if has_snapshot else None,
|
||||
"fiscal_completion_percent": _fiscal_completion(snapshot) if has_snapshot else None,
|
||||
"fiscal_status": fiscal_status if has_snapshot else "UNKNOWN",
|
||||
"fiscal_status_label": fiscal_status_label(fiscal_status) if has_snapshot else "Não informado",
|
||||
"blockers": snapshot.blockers or {},
|
||||
"last_job_status": snapshot.last_job_status,
|
||||
"last_job_at": _iso(snapshot.last_job_at),
|
||||
"last_successful_job_at": _iso(snapshot.last_successful_job_at),
|
||||
"firebird_version": installation.firebird_version,
|
||||
"firebird_ods": installation.firebird_ods,
|
||||
"database_identifier": installation.database_identifier,
|
||||
"capabilities": installation.capabilities or {},
|
||||
"fiscal_engine_version": installation.fiscal_engine_version,
|
||||
"dataset_versions": installation.dataset_versions or {},
|
||||
"technical_ids": {"agent_id": agent.id, "installation_db_id": installation.id, "tenant_id": installation.tenant_id},
|
||||
"link": f"/agents/{agent.id}",
|
||||
}
|
||||
|
||||
|
||||
def _installation_query_filters(query, *, client=None, version=None, search=None, last_seen_before=None):
|
||||
if client:
|
||||
query = query.where(or_(Tenant.name.ilike(f"%{client}%"), Installation.customer_code.ilike(f"%{client}%")))
|
||||
if version and version != "outdated":
|
||||
query = query.where(Agent.agent_version == version)
|
||||
if search:
|
||||
query = query.where(or_(Installation.display_name.ilike(f"%{search}%"), Installation.hostname.ilike(f"%{search}%"), Tenant.name.ilike(f"%{search}%")))
|
||||
if last_seen_before:
|
||||
query = query.where(or_(Installation.last_seen_at.is_(None), Installation.last_seen_at < last_seen_before))
|
||||
return query
|
||||
|
||||
|
||||
def list_installations(session, *, page=1, page_size=50, client=None, health=None, version=None, search=None, last_seen_before=None, sort="display_name", scope="active", now=None):
|
||||
page, page_size = _page(page, page_size)
|
||||
now = _utc_now(now)
|
||||
reference = reference_agent_version(session)
|
||||
query = select(Installation, Agent, Tenant, AgentSnapshot, operational_installation_predicate().label("operational_active")).join(Agent, Agent.id == Installation.agent_id).join(Tenant, Tenant.id == Installation.tenant_id).outerjoin(AgentSnapshot, AgentSnapshot.installation_id == Installation.id)
|
||||
if scope == "active":
|
||||
query = query.where(operational_installation_predicate())
|
||||
elif scope != "all":
|
||||
raise ValueError("scope must be active or all")
|
||||
query = _installation_query_filters(query, client=client, version=version, search=search, last_seen_before=last_seen_before)
|
||||
if version == "outdated":
|
||||
query = query.where(Agent.agent_version.in_(outdated_versions(session, reference)))
|
||||
health_expression = _health_expression(now)
|
||||
if health:
|
||||
query = query.where(health_expression == health)
|
||||
if sort in {"worst_coverage", "coverage", "coverage_asc", "fiscal_completion_percent"}:
|
||||
ordered = query.order_by(_fiscal_completion_expression().asc(), Installation.display_name.asc(), Installation.id.asc())
|
||||
else:
|
||||
ordered = query.order_by(Installation.display_name.asc(), Installation.id.asc())
|
||||
rows = session.execute(ordered.offset((page - 1) * page_size).limit(page_size)).all()
|
||||
items = [{**installation_view(i, a, t, s, now=now, reference=reference), "operational_active": bool(active)} for i, a, t, s, active in rows]
|
||||
count_query = select(func.count()).select_from(query.with_only_columns(Installation.id).order_by(None).subquery())
|
||||
total = session.scalar(count_query) or 0
|
||||
return {"page": page, "page_size": page_size, "items": items, "total": total, "health_filter_applied": health}
|
||||
|
||||
|
||||
def _blocker_item(row, tenant, installation=None, agent=None, *, now=None):
|
||||
return {
|
||||
"id": row.id,
|
||||
"tenant_id": row.tenant_id,
|
||||
"client": tenant.name if tenant else row.company,
|
||||
"product_code": row.product_code,
|
||||
"description": row.description,
|
||||
"field": row.field,
|
||||
"blocker_type": row.blocker_type,
|
||||
"blocker_type_label": blocker_type_label(row.blocker_type),
|
||||
"reason": row.reason_code,
|
||||
"reason_code": row.reason_code,
|
||||
"reason_label": blocker_reason_label(row.reason_code),
|
||||
"evidence_summary": row.evidence_summary,
|
||||
"first_seen_at": _iso(row.first_seen_at),
|
||||
"last_seen_at": _iso(row.last_seen_at),
|
||||
"first_seen_display": format_ui_datetime(row.first_seen_at),
|
||||
"last_seen_display": format_ui_datetime(row.last_seen_at),
|
||||
"age": format_age(row.last_seen_at, now),
|
||||
"retry_count": row.retry_count,
|
||||
"resolved_at": _iso(row.resolved_at),
|
||||
"resolution_source": row.resolution_source,
|
||||
"priority": operational_priority(row),
|
||||
"priority_label": priority_label(operational_priority(row)),
|
||||
"installation_id": installation.installation_id if installation else None,
|
||||
"installation_db_id": installation.id if installation else None,
|
||||
"display_name": installation.display_name if installation else None,
|
||||
"hostname": installation.hostname if installation else None,
|
||||
"agent_id": agent.id if agent else None,
|
||||
"link": f"/blockers/{row.id}",
|
||||
}
|
||||
|
||||
|
||||
def _parse_age(value):
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
return timedelta(days=float(value))
|
||||
text = str(value).strip().lower()
|
||||
try:
|
||||
if text.endswith("h"):
|
||||
return timedelta(hours=float(text[:-1]))
|
||||
if text.endswith("d"):
|
||||
return timedelta(days=float(text[:-1]))
|
||||
return timedelta(days=float(text))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _blockers_query(session, *, tenant_id=None, agent_id=None, product_code=None, blocker_type=None, field=None, reason_code=None, status="OPEN", client=None, installation=None, search=None, age=None, scope="active"):
|
||||
query = select(BlockedProduct, Tenant, Installation, Agent).join(Tenant, Tenant.id == BlockedProduct.tenant_id).outerjoin(Installation, Installation.id == BlockedProduct.installation_id).outerjoin(Agent, Agent.id == Installation.agent_id)
|
||||
if scope == "active":
|
||||
query = query.where(operational_installation_predicate(), Installation.id.is_not(None))
|
||||
elif scope != "all":
|
||||
raise ValueError("scope must be active or all")
|
||||
status = (status or "OPEN").upper()
|
||||
if status == "OPEN":
|
||||
query = query.where(BlockedProduct.resolved_at.is_(None))
|
||||
elif status in {"RESOLVED", "CLOSED"}:
|
||||
query = query.where(BlockedProduct.resolved_at.is_not(None))
|
||||
if tenant_id is not None:
|
||||
query = query.where(BlockedProduct.tenant_id == tenant_id)
|
||||
if agent_id is not None:
|
||||
query = query.where(Agent.id == agent_id)
|
||||
if product_code is not None:
|
||||
query = query.where(BlockedProduct.product_code == product_code)
|
||||
if blocker_type:
|
||||
query = query.where(BlockedProduct.blocker_type == blocker_type)
|
||||
if field:
|
||||
query = query.where(func.upper(BlockedProduct.field) == field.upper())
|
||||
if reason_code:
|
||||
query = query.where(BlockedProduct.reason_code == reason_code)
|
||||
if client:
|
||||
query = query.where(or_(Tenant.name.ilike(f"%{client}%"), BlockedProduct.company.ilike(f"%{client}%")))
|
||||
if installation:
|
||||
query = query.where(or_(Installation.installation_id.ilike(f"%{installation}%"), Installation.display_name.ilike(f"%{installation}%"), Installation.hostname.ilike(f"%{installation}%")))
|
||||
if search:
|
||||
term = f"%{search}%"
|
||||
query = query.where(or_(BlockedProduct.product_code.ilike(term), BlockedProduct.description.ilike(term), BlockedProduct.field.ilike(term), BlockedProduct.blocker_type.ilike(term), BlockedProduct.reason_code.ilike(term), Tenant.name.ilike(term)))
|
||||
age_delta = _parse_age(age)
|
||||
if age_delta:
|
||||
query = query.where(BlockedProduct.last_seen_at <= datetime.now(timezone.utc) - age_delta)
|
||||
return query
|
||||
|
||||
|
||||
def blocker_filter_summary(session, *, tenant_id=None, agent_id=None, product_code=None, blocker_type=None, field=None, reason_code=None, status="OPEN", client=None, installation=None, age=None, search=None, scope="active"):
|
||||
query = _blockers_query(session, tenant_id=tenant_id, agent_id=agent_id, product_code=product_code, blocker_type=blocker_type, field=field, reason_code=reason_code, status=status, client=client, installation=installation, search=search, age=age, scope=scope)
|
||||
open_count = func.sum(case((BlockedProduct.resolved_at.is_(None), 1), else_=0))
|
||||
open_count, = session.execute(query.with_only_columns(open_count).order_by(None)).one()
|
||||
rows = session.execute(_blocker_breakdown_query(query)).all()
|
||||
breakdown = {field: 0 for field in BLOCKER_FIELDS}
|
||||
for field_name, count in rows:
|
||||
key = str(field_name or "").upper()
|
||||
breakdown[key if key in breakdown else "OTHER"] += int(count)
|
||||
pair_query = query.with_only_columns(BlockedProduct.tenant_id, BlockedProduct.product_code).distinct().subquery()
|
||||
client_query = query.with_only_columns(BlockedProduct.tenant_id).distinct().subquery()
|
||||
return {
|
||||
"open_blockers": int(open_count or 0),
|
||||
"affected_products": int(session.scalar(select(func.count()).select_from(pair_query)) or 0),
|
||||
"affected_clients": int(session.scalar(select(func.count()).select_from(client_query)) or 0),
|
||||
"blocker_breakdown": breakdown,
|
||||
}
|
||||
|
||||
|
||||
def rtc_blocker_summary(session, agent_id: int) -> dict:
|
||||
"""Read-only, installation-scoped RTC blocker aggregates for the cClassTrib page."""
|
||||
query = _blockers_query(session, agent_id=agent_id, field="RTC", status="OPEN", scope="all")
|
||||
blockers = int(session.scalar(query.with_only_columns(func.count(BlockedProduct.id)).order_by(None)) or 0)
|
||||
products = int(session.scalar(query.with_only_columns(func.count(func.distinct(BlockedProduct.product_code))).order_by(None)) or 0)
|
||||
reason = func.coalesce(BlockedProduct.evidence_summary["reason"].as_string(), BlockedProduct.reason_code)
|
||||
reasons = session.execute(
|
||||
query.with_only_columns(reason, func.count(func.distinct(BlockedProduct.product_code)))
|
||||
.group_by(reason).order_by(None)
|
||||
).all()
|
||||
return {"open_blockers": blockers, "products_affected": products, "reason_counts": {str(code or ""): int(count) for code, count in reasons}}
|
||||
|
||||
|
||||
def _blocker_breakdown_query(query):
|
||||
"""Group on the source column itself for PostgreSQL-safe SQL semantics."""
|
||||
return query.with_only_columns(BlockedProduct.field, func.count(BlockedProduct.id)).group_by(BlockedProduct.field).order_by(None)
|
||||
|
||||
|
||||
def list_blockers(session, *, tenant_id=None, agent_id=None, product_code=None, page=1, page_size=50, blocker_type=None, field=None, reason_code=None, resolved=False, client=None, installation=None, status="OPEN", age=None, search=None, scope="active"):
|
||||
page, page_size = _page(page, page_size)
|
||||
if resolved and status == "OPEN":
|
||||
status = "RESOLVED"
|
||||
query = _blockers_query(session, tenant_id=tenant_id, agent_id=agent_id, product_code=product_code, blocker_type=blocker_type, field=field, reason_code=reason_code, status=status, client=client, installation=installation, search=search, age=age, scope=scope)
|
||||
ordered = query.order_by(BlockedProduct.last_seen_at.desc(), BlockedProduct.id.desc())
|
||||
rows = session.execute(ordered.offset((page - 1) * page_size).limit(page_size)).all()
|
||||
items = [_blocker_item(blocker, tenant, installation_row, agent, now=datetime.now(timezone.utc)) for blocker, tenant, installation_row, agent in rows]
|
||||
count_query = query.with_only_columns(func.count(BlockedProduct.id)).order_by(None)
|
||||
total = session.scalar(count_query) or 0
|
||||
return {"page": page, "page_size": page_size, "items": items, "total": total}
|
||||
|
||||
|
||||
def _job_item(row, agent, installation, tenant, *, now=None):
|
||||
return {
|
||||
"job_id": row.id,
|
||||
"agent_id": row.agent_id,
|
||||
"client": tenant.name if tenant else None,
|
||||
"display_name": installation.display_name if installation else None,
|
||||
"hostname": installation.hostname if installation else None,
|
||||
"kind": row.kind,
|
||||
"kind_label": job_kind_label(row.kind),
|
||||
"status": row.status,
|
||||
"status_label": job_status_label(row.status),
|
||||
"created_at": _iso(row.created_at),
|
||||
"started_at": _iso(row.started_at),
|
||||
"finished_at": _iso(row.finished_at),
|
||||
"created_display": format_ui_datetime(row.created_at),
|
||||
"started_display": format_ui_datetime(row.started_at),
|
||||
"finished_display": format_ui_datetime(row.finished_at),
|
||||
"duration": format_duration(row.started_at, row.finished_at, now),
|
||||
"attempts": row.attempts,
|
||||
"result_summary": row.result_summary,
|
||||
"error": row.error,
|
||||
}
|
||||
|
||||
|
||||
def list_jobs(session, *, agent_id=None, job_id=None, page=1, page_size=50, status=None, kind=None, since=None, until=None, client=None, search=None, scope="active"):
|
||||
page, page_size = _page(page, page_size)
|
||||
query = select(Job, Agent, Installation, Tenant).join(Agent, Agent.id == Job.agent_id).join(Installation, Installation.agent_id == Agent.id).join(Tenant, Tenant.id == Installation.tenant_id)
|
||||
if scope == "active":
|
||||
query = query.where(operational_installation_predicate())
|
||||
elif scope != "all":
|
||||
raise ValueError("scope must be active or all")
|
||||
if agent_id is not None:
|
||||
query = query.where(Job.agent_id == agent_id)
|
||||
if job_id is not None:
|
||||
query = query.where(Job.id == job_id)
|
||||
if status:
|
||||
query = query.where(Job.status == status)
|
||||
if kind:
|
||||
query = query.where(Job.kind == kind)
|
||||
if since:
|
||||
query = query.where(Job.created_at >= since)
|
||||
if until:
|
||||
query = query.where(Job.created_at <= until)
|
||||
if client:
|
||||
query = query.where(or_(Tenant.name.ilike(f"%{client}%"), Installation.customer_code.ilike(f"%{client}%")))
|
||||
if search:
|
||||
term = f"%{search}%"
|
||||
query = query.where(or_(Tenant.name.ilike(term), Installation.display_name.ilike(term), Installation.hostname.ilike(term), Job.kind.ilike(term), Job.status.ilike(term)))
|
||||
ordered = query.order_by(Job.created_at.desc(), Job.id.desc())
|
||||
rows = session.execute(ordered.offset((page - 1) * page_size).limit(page_size)).all()
|
||||
count_query = query.with_only_columns(func.count(Job.id)).order_by(None)
|
||||
total = session.scalar(count_query) or 0
|
||||
return {"page": page, "page_size": page_size, "items": [_job_item(row, agent, installation, tenant) for row, agent, installation, tenant in rows], "total": total}
|
||||
|
||||
|
||||
def jobs_status_summary(session, *, scope="active"):
|
||||
"""Bounded job counts for the current operational scope."""
|
||||
query = (select(Job.status, func.count(Job.id)).join(Agent, Agent.id == Job.agent_id)
|
||||
.join(Installation, Installation.agent_id == Agent.id)
|
||||
.join(Tenant, Tenant.id == Installation.tenant_id))
|
||||
if scope == "active":
|
||||
query = query.where(operational_installation_predicate())
|
||||
elif scope != "all":
|
||||
raise ValueError("scope must be active or all")
|
||||
counts = {status: 0 for status in ("PENDING", "RUNNING", "COMPLETED", "FAILED")}
|
||||
for status, count in session.execute(query.group_by(Job.status)):
|
||||
if status in counts:
|
||||
counts[status] = int(count)
|
||||
return counts
|
||||
|
||||
|
||||
def dashboard_summary(session, now=None):
|
||||
now = _utc_now(now)
|
||||
reference = reference_agent_version(session)
|
||||
active = operational_installation_predicate()
|
||||
installation_query = select(Installation, Agent, Tenant, AgentSnapshot).join(Agent, Agent.id == Installation.agent_id).join(Tenant, Tenant.id == Installation.tenant_id).outerjoin(AgentSnapshot, AgentSnapshot.installation_id == Installation.id).where(active)
|
||||
health_expression = _health_expression(now)
|
||||
health_rows = session.execute(
|
||||
select(health_expression.label("health"), func.count(Installation.id))
|
||||
.select_from(Installation)
|
||||
.join(Agent, Agent.id == Installation.agent_id)
|
||||
.join(Tenant, Tenant.id == Installation.tenant_id)
|
||||
.outerjoin(AgentSnapshot, AgentSnapshot.installation_id == Installation.id)
|
||||
.where(active)
|
||||
.group_by(health_expression)
|
||||
).all()
|
||||
counts = {state: 0 for state in HEALTH_STATES}
|
||||
for state, count in health_rows:
|
||||
counts[state] = int(count)
|
||||
|
||||
snapshot_total = func.coalesce(AgentSnapshot.total_products, 0)
|
||||
snapshot_complete = func.coalesce(AgentSnapshot.fiscally_complete, 0)
|
||||
snapshot_blocked = func.coalesce(AgentSnapshot.safe_blocked, 0)
|
||||
snapshot_failed = func.coalesce(AgentSnapshot.failed_products, 0)
|
||||
snapshot_explicit = snapshot_complete + snapshot_blocked + snapshot_failed
|
||||
snapshot_has_negative = or_(snapshot_total < 0, snapshot_complete < 0, snapshot_blocked < 0, snapshot_failed < 0)
|
||||
total_row = session.execute(
|
||||
select(
|
||||
func.coalesce(func.sum(AgentSnapshot.total_products), 0),
|
||||
func.coalesce(func.sum(AgentSnapshot.fiscally_complete), 0),
|
||||
func.coalesce(func.sum(AgentSnapshot.safe_blocked), 0),
|
||||
func.coalesce(func.sum(AgentSnapshot.failed_products), 0),
|
||||
func.coalesce(func.sum(case((and_(~snapshot_has_negative, snapshot_explicit <= snapshot_total), snapshot_total - snapshot_explicit), else_=0)), 0),
|
||||
func.coalesce(func.sum(case((and_(~snapshot_has_negative, snapshot_explicit > snapshot_total), snapshot_explicit - snapshot_total), else_=0)), 0),
|
||||
func.coalesce(func.sum(case((snapshot_has_negative, 1), else_=0)), 0),
|
||||
).select_from(Installation).join(Agent, Agent.id == Installation.agent_id).join(Tenant, Tenant.id == Installation.tenant_id).outerjoin(AgentSnapshot, AgentSnapshot.installation_id == Installation.id).where(active)
|
||||
).one()
|
||||
totals = dict(zip(("total_products", "fiscally_complete", "safe_blocked", "failed_products", "unclassified_products", "coverage_overcount_products", "coverage_invalid_snapshots"), (int(value or 0) for value in total_row)))
|
||||
totals["coverage_invariant_pass"] = totals["coverage_overcount_products"] == 0 and totals["coverage_invalid_snapshots"] == 0
|
||||
|
||||
open_filter = BlockedProduct.resolved_at.is_(None)
|
||||
active_blockers = (select(BlockedProduct).join(Installation, Installation.id == BlockedProduct.installation_id)
|
||||
.join(Agent, Agent.id == Installation.agent_id).join(Tenant, Tenant.id == BlockedProduct.tenant_id)
|
||||
.where(open_filter, active))
|
||||
open_blockers = session.scalar(active_blockers.with_only_columns(func.count(BlockedProduct.id)).order_by(None)) or 0
|
||||
breakdown_expression = func.coalesce(BlockedProduct.field, "OTHER")
|
||||
breakdown_rows = session.execute(active_blockers.with_only_columns(breakdown_expression, func.count(BlockedProduct.id)).group_by(breakdown_expression).order_by(None)).all()
|
||||
open_blocker_breakdown = {field: 0 for field in BLOCKER_FIELDS}
|
||||
for field, count in breakdown_rows:
|
||||
key = str(field or "").upper()
|
||||
open_blocker_breakdown[key if key in open_blocker_breakdown else "OTHER"] += int(count)
|
||||
product_pairs = active_blockers.with_only_columns(BlockedProduct.tenant_id, BlockedProduct.product_code).distinct().subquery()
|
||||
safe_blocked_products = session.scalar(select(func.count()).select_from(product_pairs)) or 0
|
||||
blocker_counts = {field: count for field, count in open_blocker_breakdown.items() if count}
|
||||
|
||||
def installation_candidates(health):
|
||||
query = installation_query.where(health_expression == health).order_by(Installation.id.asc()).limit(ATTENTION_CANDIDATE_LIMIT)
|
||||
return [installation_view(i, a, t, s, now=now, reference=reference) for i, a, t, s in session.execute(query).all()]
|
||||
|
||||
health_candidates = {health: installation_candidates(health) for health in ("FAILED", "DEGRADED", "OFFLINE", "UNKNOWN")}
|
||||
version_filter = Agent.agent_version.in_(outdated_versions(session, reference))
|
||||
outdated_query = installation_query.where(version_filter).order_by(Installation.id.asc()).limit(ATTENTION_CANDIDATE_LIMIT)
|
||||
outdated_agents = [installation_view(i, a, t, s, now=now, reference=reference) for i, a, t, s in session.execute(outdated_query).all()]
|
||||
|
||||
group_field = func.upper(func.coalesce(BlockedProduct.field, "OTHER"))
|
||||
fiscal_priority = case(
|
||||
(func.upper(BlockedProduct.blocker_type).in_(("TECHNICAL_FAILURE", "CONNECTION_ERROR", "AGENT_OFFLINE", "JOB_FAILED")), 0),
|
||||
(func.upper(BlockedProduct.blocker_type).in_(("BLOCKED_MISSING_EVIDENCE", "MISSING_EVIDENCE")), 1),
|
||||
(group_field.in_(BLOCKER_FIELDS[:-1]), 2),
|
||||
else_=3,
|
||||
)
|
||||
blocker_groups = session.execute(
|
||||
active_blockers.with_only_columns(
|
||||
Tenant.name, Installation.display_name, Installation.hostname, Agent.id,
|
||||
group_field, BlockedProduct.blocker_type, BlockedProduct.reason_code,
|
||||
func.count(BlockedProduct.id), func.count(func.distinct(BlockedProduct.product_code)),
|
||||
func.max(BlockedProduct.last_seen_at),
|
||||
).group_by(Tenant.name, Installation.id, Installation.display_name, Installation.hostname,
|
||||
Agent.id, group_field, BlockedProduct.blocker_type, BlockedProduct.reason_code)
|
||||
.order_by(fiscal_priority.asc(), func.max(BlockedProduct.last_seen_at).desc(), Agent.id.asc())
|
||||
.limit(ATTENTION_CANDIDATE_LIMIT)
|
||||
).all()
|
||||
|
||||
attention = []
|
||||
for health in ("FAILED", "DEGRADED", "OFFLINE", "UNKNOWN"):
|
||||
rank = {"FAILED": 0, "DEGRADED": 1, "OFFLINE": 2, "UNKNOWN": 3}[health]
|
||||
attention.extend((_attention_item(item, health_reason_label(item["health_reason"]), _technical_timestamp(item), rank=rank, now=now), rank, item["agent_id"]) for item in health_candidates[health])
|
||||
for client_name, display_name, hostname, agent_id, field, blocker_type, reason, count, products, last_seen, in blocker_groups:
|
||||
item = {"client": client_name, "display_name": display_name, "hostname": hostname, "agent_id": agent_id, "technical_health": "BLOCKED"}
|
||||
link = "/blockers?" + urlencode({"scope": "active", "status": "OPEN", "agent_id": agent_id, "field": field, "blocker_type": blocker_type, "reason_code": reason})
|
||||
priority = operational_priority({"field": field, "blocker_type": blocker_type, "reason_code": reason})
|
||||
rank = {"CRITICAL": 3, "HIGH": 4, "NORMAL": 5, "LOW": 6}[priority]
|
||||
entry = _attention_item(item, f"{field}: {blocker_reason_label(reason)}", last_seen, rank=rank, link=link, now=now)
|
||||
entry.update(group_count=count, products_affected=products, field=field, priority=priority)
|
||||
attention.append((entry, rank, agent_id))
|
||||
for item in outdated_agents:
|
||||
attention.append((_attention_item(item, "Versão do agent desatualizada", item.get("last_seen_at"), rank=7, now=now), 7, item["agent_id"]))
|
||||
attention.sort(key=lambda entry: (entry[1], -(entry[0]["timestamp"] is not None), entry[2]))
|
||||
|
||||
active_jobs = select(Job).join(Agent, Agent.id == Job.agent_id).join(Installation, Installation.agent_id == Agent.id).join(Tenant, Tenant.id == Installation.tenant_id).where(active)
|
||||
failed_jobs = session.scalar(active_jobs.with_only_columns(func.count(Job.id)).where(Job.status == "FAILED").order_by(None)) or 0
|
||||
failed_job_rows = session.execute(
|
||||
select(Job, Agent, Installation, Tenant).join(Agent, Agent.id == Job.agent_id)
|
||||
.join(Installation, Installation.agent_id == Agent.id).join(Tenant, Tenant.id == Installation.tenant_id)
|
||||
.where(Job.status == "FAILED", active).order_by(Job.created_at.desc(), Job.id.desc()).limit(10)
|
||||
).all()
|
||||
recent_failed_jobs = []
|
||||
for job, agent, installation, tenant in failed_job_rows:
|
||||
item = _job_item(job, agent, installation, tenant, now=now)
|
||||
item["error"] = (item["error"] or "")[:240]
|
||||
item["created_age"] = format_age(job.created_at, now)
|
||||
item["finished_age"] = format_age(job.finished_at, now)
|
||||
item["link"] = f"/jobs?job_id={job.id}"
|
||||
recent_failed_jobs.append(item)
|
||||
return {
|
||||
"agents_total": sum(counts.values()),
|
||||
**{key.lower(): value for key, value in counts.items()},
|
||||
"health_counts": counts,
|
||||
**totals,
|
||||
"blockers": blocker_counts,
|
||||
"stale_agents": health_candidates["OFFLINE"],
|
||||
"outdated_agents": outdated_agents,
|
||||
"safe_blocked_products": int(safe_blocked_products),
|
||||
"open_blockers": int(open_blockers),
|
||||
"open_blocker_breakdown": open_blocker_breakdown,
|
||||
"attention_items": [entry[0] for entry in attention[:10]],
|
||||
"failed_jobs": failed_jobs,
|
||||
"recent_failed_jobs": recent_failed_jobs,
|
||||
}
|
||||
|
||||
|
||||
def blocker_detail(session, blocker_id):
|
||||
row = session.execute(_blockers_query(session, status="ALL", scope="all").where(BlockedProduct.id == blocker_id)).first()
|
||||
if not row:
|
||||
return None
|
||||
blocker, tenant, installation, agent = row
|
||||
item = _blocker_item(blocker, tenant, installation, agent)
|
||||
events = session.scalars(select(AgentEvent).where(AgentEvent.blocker_id == blocker_id).order_by(AgentEvent.occurred_at.desc(), AgentEvent.id.desc()).limit(100)).all()
|
||||
event_views = [{"id": event.id, "severity": event.severity, "event_type": event.event_type, "message": event.message, "context": event.structured_context, "job_id": event.job_id, "product_code": event.product_code, "occurred_at": _iso(event.occurred_at)} for event in events]
|
||||
job_ids = {event.job_id for event in events if event.job_id is not None}
|
||||
related_jobs = []
|
||||
if job_ids:
|
||||
job_query = select(Job, Agent, Installation, Tenant).join(Agent, Agent.id == Job.agent_id).join(Installation, Installation.agent_id == Agent.id).join(Tenant, Tenant.id == Installation.tenant_id).where(Job.id.in_(job_ids)).order_by(Job.created_at.desc(), Job.id.desc()).limit(100)
|
||||
related_jobs = [_job_item(job, related_agent, related_installation, related_tenant) for job, related_agent, related_installation, related_tenant in session.execute(job_query).all()]
|
||||
audits = session.scalars(select(AuditEvent).where(AuditEvent.target_type == "blocker", AuditEvent.target_id == str(blocker_id)).order_by(AuditEvent.occurred_at.desc(), AuditEvent.id.desc()).limit(100)).all()
|
||||
audit_views = [{"id": audit.id, "admin_user_id": audit.admin_user_id, "action": audit.action, "target_id": audit.target_id, "context": audit.context, "severity": audit.severity, "occurred_at": _iso(audit.occurred_at)} for audit in audits]
|
||||
server = {
|
||||
"hostname": installation.hostname if installation else None,
|
||||
"company_name": installation.company_name if installation else None,
|
||||
"company_code": installation.company_code if installation else None,
|
||||
"company_uf": installation.company_uf if installation else None,
|
||||
"company_crt": installation.company_crt if installation else None,
|
||||
"firebird_version": installation.firebird_version if installation else None,
|
||||
"firebird_ods": installation.firebird_ods if installation else None,
|
||||
"database_identifier": installation.database_identifier if installation else None,
|
||||
}
|
||||
return {
|
||||
**item,
|
||||
"evidence": blocker.evidence_summary or {},
|
||||
"installation": {"installation_id": item["installation_id"], "installation_db_id": item["installation_db_id"], "display_name": item["display_name"], "hostname": item["hostname"], "agent_id": item["agent_id"]},
|
||||
"server": server,
|
||||
"related_events": event_views,
|
||||
"related_jobs": related_jobs,
|
||||
"resolution_audit": audit_views,
|
||||
}
|
||||
|
||||
|
||||
def list_panel_events(session, installation_db_id, *, include_more=False, **filters):
|
||||
rows = list_events(session, installation_db_id, fetch_extra=include_more, **filters)
|
||||
return [{"id": row.id, "severity": row.severity, "event_type": row.event_type, "message": row.message, "context": row.structured_context, "job_id": row.job_id, "product_code": row.product_code, "occurred_at": _iso(row.occurred_at)} for row in rows]
|
||||
@@ -0,0 +1 @@
|
||||
"""Official fiscal dataset importers."""
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Fail-closed text parser for the official RICMS/BA Anexo 1 PDF."""
|
||||
import re
|
||||
|
||||
_ITEM = re.compile(r"^\s*(\d{1,2}(?:\.\d+)+)\s+(?:(\d{2}\.\d{3}\.\d{2})\s+)?(.*)$")
|
||||
_NCM = re.compile(r"\b(?:\d{4}(?:\.\d{1,4}){0,3}|\d{2}(?:\.\d{1,4}){1,3})\b")
|
||||
_MVA = re.compile(r"\d+(?:,\d+)?%")
|
||||
|
||||
def parse_page(text, page):
|
||||
records, rejected = [], []
|
||||
lines = [" ".join(line.split()) for line in text.splitlines()]
|
||||
index = 0
|
||||
while index < len(lines):
|
||||
match = _ITEM.match(lines[index])
|
||||
if not match:
|
||||
index += 1; continue
|
||||
item, cest, content = match.groups(); block = [content]; index += 1
|
||||
while index < len(lines) and not _ITEM.match(lines[index]) and not lines[index].startswith("ITEM CEST"):
|
||||
block.append(lines[index]); index += 1
|
||||
joined = " ".join(block)
|
||||
ncm = _NCM.findall(joined)
|
||||
if not cest and not ncm:
|
||||
rejected.append({"item": item, "source_page": page, "reason": "NO_CEST_OR_NCM"}); continue
|
||||
mvas = _MVA.findall(joined)
|
||||
description = joined
|
||||
if cest: description = description.replace(cest, "")
|
||||
for code in ncm: description = description.replace(code, "")
|
||||
for mva in mvas: description = description.replace(mva, "")
|
||||
records.append({"item": item, "cest": cest, "ncm_sh": ncm or None, "description": " ".join(description.split()) or None, "mva_original": mvas[-1] if mvas else None, "mva_adjusted": mvas[0] if mvas else None, "agreement_info": None, "source_page": page})
|
||||
return records, rejected
|
||||
@@ -0,0 +1,287 @@
|
||||
"""Parsers for the official NF-e/RTC spreadsheets the operator placed in
|
||||
sources/raw/bootstrap_v1/ after the Portal NF-e/SPED downloads were blocked
|
||||
in earlier missions. Each parser validates the workbook's own header/sheet
|
||||
structure before trusting it — never trusts the filename alone.
|
||||
|
||||
Domains: CFOP, FCP, ANP (cProdANP), NCM x uTrib, RTC biofuel index
|
||||
(pBioObrigatorio), RTC cCredPres.
|
||||
"""
|
||||
from dataclasses import dataclass, field
|
||||
from hashlib import sha256
|
||||
from io import BytesIO
|
||||
|
||||
|
||||
def _digits(value):
|
||||
return "".join(c for c in str(value or "") if c.isdigit())
|
||||
|
||||
|
||||
def _iso(value):
|
||||
if value is None:
|
||||
return None
|
||||
return value.isoformat() if hasattr(value, "isoformat") else str(value)
|
||||
|
||||
|
||||
def _load(raw_bytes):
|
||||
import openpyxl
|
||||
return openpyxl.load_workbook(BytesIO(raw_bytes), read_only=True, data_only=True)
|
||||
|
||||
|
||||
def _rows(ws, header_len):
|
||||
rows = ws.iter_rows(values_only=True)
|
||||
header = next(rows)
|
||||
return header[:header_len], rows
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Payload:
|
||||
sha256: str
|
||||
entries: tuple
|
||||
parser_version: str
|
||||
extra_entries: tuple = ()
|
||||
|
||||
|
||||
# --- CFOP -------------------------------------------------------------
|
||||
_CFOP_EXPECTED_HEADER = ("CFOP", "Início de vigência", "Fim de vigência", "indNFe")
|
||||
|
||||
|
||||
def parse_cfop(raw_bytes: bytes) -> Payload:
|
||||
wb = _load(raw_bytes)
|
||||
if "CFOP" not in wb.sheetnames:
|
||||
raise ValueError(f"expected a 'CFOP' sheet, found {wb.sheetnames}")
|
||||
header, rows = _rows(wb["CFOP"], 14)
|
||||
if _digits("".join(str(h) for h in header[:1])) or "CFOP" not in str(header[0]):
|
||||
raise ValueError(f"unexpected CFOP header: {header}")
|
||||
entries = []
|
||||
for row in rows:
|
||||
if row[0] is None:
|
||||
continue
|
||||
code = str(int(row[0])) if isinstance(row[0], (int, float)) else str(row[0]).strip()
|
||||
entries.append({
|
||||
"code": code, "effective_from": _iso(row[1]), "effective_to": _iso(row[2]),
|
||||
"ind_nfe": row[3], "ind_comunica": row[4], "ind_transp": row[5], "ind_devol": row[6],
|
||||
"ind_retor": row[7], "ind_anula": row[8], "ind_remes": row[9], "ind_comb": row[10],
|
||||
"ind_exc_ibscbs": row[11], "title": row[12] or "", "description": row[13],
|
||||
})
|
||||
if len(entries) < 100:
|
||||
raise ValueError(f"expected at least 100 CFOP codes, found {len(entries)}")
|
||||
return Payload(sha256(raw_bytes).hexdigest(), tuple(entries), "cfop-xlsx-v1")
|
||||
|
||||
|
||||
# --- FCP ----------------------------------------------------------------
|
||||
def parse_fcp(raw_bytes: bytes) -> Payload:
|
||||
wb = _load(raw_bytes)
|
||||
if "FCP" not in wb.sheetnames:
|
||||
raise ValueError(f"expected an 'FCP' sheet, found {wb.sheetnames}")
|
||||
header, rows = _rows(wb["FCP"], 8)
|
||||
if "UF" not in [str(h) for h in header]:
|
||||
raise ValueError(f"unexpected FCP header: {header}")
|
||||
entries = []
|
||||
for row in rows:
|
||||
if not row[1]:
|
||||
continue
|
||||
entries.append({
|
||||
"uf_code": str(row[0]), "uf": str(row[1]).strip(), "uf_name": str(row[2] or "").strip(),
|
||||
"aliq_1": row[3], "aliq_2": row[4], "aliq_3": row[5], "observacao": row[7],
|
||||
})
|
||||
if len(entries) < 20:
|
||||
raise ValueError(f"expected at least 20 UF rows (26 states + DF), found {len(entries)}")
|
||||
return Payload(sha256(raw_bytes).hexdigest(), tuple(entries), "fcp-xlsx-v1")
|
||||
|
||||
|
||||
# --- ANP ------------------------------------------------------------------
|
||||
def parse_anp(raw_bytes: bytes) -> Payload:
|
||||
wb = _load(raw_bytes)
|
||||
if "cProdANP" not in wb.sheetnames:
|
||||
raise ValueError(f"expected a 'cProdANP' sheet, found {wb.sheetnames}")
|
||||
ws = wb["cProdANP"]
|
||||
rows = ws.iter_rows(values_only=True)
|
||||
header = next(rows)
|
||||
if "cProdANP" not in str(header[0]):
|
||||
raise ValueError(f"unexpected ANP header: {header}")
|
||||
entries = []
|
||||
seen = set()
|
||||
for row in rows:
|
||||
if row[0] is None:
|
||||
continue
|
||||
code = str(row[0]).strip()
|
||||
if code in seen:
|
||||
continue
|
||||
seen.add(code)
|
||||
entries.append({"cprod_anp": code})
|
||||
if len(entries) < 100:
|
||||
raise ValueError(f"expected at least 100 cProdANP codes, found {len(entries)}")
|
||||
return Payload(sha256(raw_bytes).hexdigest(), tuple(entries), "anp-xlsx-v1")
|
||||
|
||||
|
||||
# --- NCM x uTrib ------------------------------------------------------
|
||||
def parse_ncm_utrib(raw_bytes: bytes) -> Payload:
|
||||
wb = _load(raw_bytes)
|
||||
sheet_name = next((s for s in wb.sheetnames if "uTrib" in s or "utrib" in s.lower()), None)
|
||||
if sheet_name is None:
|
||||
raise ValueError(f"expected a sheet with 'uTrib' in its name, found {wb.sheetnames}")
|
||||
ws = wb[sheet_name]
|
||||
rows = ws.iter_rows(values_only=True)
|
||||
header = next(rows)
|
||||
if "NCM" not in str(header[0]):
|
||||
raise ValueError(f"unexpected NCM x uTrib header: {header}")
|
||||
entries = []
|
||||
for row in rows:
|
||||
if row[0] is None:
|
||||
continue
|
||||
entries.append({
|
||||
"ncm": _digits(row[0]), "effective_from": _iso(row[1]), "effective_to": _iso(row[2]),
|
||||
"utrib_abbreviation": row[3], "utrib_description": row[4],
|
||||
})
|
||||
if len(entries) < 1000:
|
||||
raise ValueError(f"expected at least 1000 NCM x uTrib rows, found {len(entries)}")
|
||||
return Payload(sha256(raw_bytes).hexdigest(), tuple(entries), "ncm-utrib-xlsx-v1")
|
||||
|
||||
|
||||
# --- RTC biofuel index (pBioObrigatorio) --------------------------------
|
||||
def parse_rtc_biofuel_index(raw_bytes: bytes) -> Payload:
|
||||
wb = _load(raw_bytes)
|
||||
current_sheet = next((s for s in wb.sheetnames if "Historico" not in s and "pBio" in s), None)
|
||||
historical_sheet = next((s for s in wb.sheetnames if "Historico" in s), None)
|
||||
if current_sheet is None:
|
||||
raise ValueError(f"expected a 'pBioObrigatorio' sheet, found {wb.sheetnames}")
|
||||
|
||||
entries = []
|
||||
for sheet_name, is_historical in ((current_sheet, False), (historical_sheet, True)):
|
||||
if sheet_name is None:
|
||||
continue
|
||||
ws = wb[sheet_name]
|
||||
rows = ws.iter_rows(values_only=True)
|
||||
header = next(rows)
|
||||
if "cProdANP" not in str(header[0]):
|
||||
raise ValueError(f"unexpected biofuel index header in {sheet_name}: {header}")
|
||||
last_code, last_desc = None, None
|
||||
for row in rows:
|
||||
if row[0] is None and row[1] is None and row[2] is None:
|
||||
continue
|
||||
code = str(row[0]).strip() if row[0] is not None else last_code
|
||||
desc = row[1] if row[1] is not None else last_desc
|
||||
last_code, last_desc = code, desc
|
||||
entries.append({
|
||||
"cprod_anp": code, "description": desc, "p_bio_percent": row[2],
|
||||
"effective_from": _iso(row[3]), "effective_to": _iso(row[4]),
|
||||
"is_historical": is_historical,
|
||||
})
|
||||
if len(entries) < 5:
|
||||
raise ValueError(f"expected at least 5 biofuel index rows, found {len(entries)}")
|
||||
return Payload(sha256(raw_bytes).hexdigest(), tuple(entries), "rtc-biofuel-index-xlsx-v1")
|
||||
|
||||
|
||||
# --- RTC cCredPres --------------------------------------------------------
|
||||
def parse_rtc_cred_pres(raw_bytes: bytes) -> Payload:
|
||||
wb = _load(raw_bytes)
|
||||
if "cCredPres" not in wb.sheetnames:
|
||||
raise ValueError(f"expected a 'cCredPres' sheet, found {wb.sheetnames}")
|
||||
header, rows = _rows(wb["cCredPres"], 18)
|
||||
if "cCredPres" not in str(header[0]):
|
||||
raise ValueError(f"unexpected cCredPres header: {header}")
|
||||
entries = []
|
||||
for row in rows:
|
||||
if row[0] is None:
|
||||
continue
|
||||
entries.append({
|
||||
"code": str(int(row[0])) if isinstance(row[0], (int, float)) else str(row[0]).strip(),
|
||||
"description": row[1] or "", "legal_basis": row[2],
|
||||
"apropria_via_nf": bool(row[3]) if row[3] is not None else None,
|
||||
"apropria_via_evento": bool(row[4]) if row[4] is not None else None,
|
||||
"ind_deduz_cred_pres": row[5], "ind_g_cbs_cred_pres": row[6], "ind_g_ibs_cred_pres": row[7],
|
||||
"aliquota_cbs_text": row[8], "aliquota_ibs_text": row[9],
|
||||
"p_aliq_cred_pres_cbs": row[10], "p_aliq_cred_pres_ibs": row[11], "p_red_transicao_ibs": row[12],
|
||||
"cclass_nota_referenciada": str(row[13]) if row[13] is not None else None,
|
||||
"d_ini_vig_cbs": _iso(row[14]), "d_fim_vig_cbs": _iso(row[15]),
|
||||
"d_ini_vig_ibs": _iso(row[16]), "d_fim_vig_ibs": _iso(row[17]),
|
||||
})
|
||||
if len(entries) < 1:
|
||||
raise ValueError("expected at least 1 cCredPres row, found 0")
|
||||
return Payload(sha256(raw_bytes).hexdigest(), tuple(entries), "rtc-cred-pres-xlsx-v1")
|
||||
|
||||
|
||||
def _txt(value):
|
||||
return None if value is None else str(value).strip()
|
||||
|
||||
|
||||
def _code(value, width):
|
||||
text = _txt(value) or ""
|
||||
return text.zfill(width) if text.isdigit() else text
|
||||
|
||||
|
||||
_CCLASS_COLUMNS = {
|
||||
"CST-IBS/CBS": "cst", "Descrição CST-IBS/CBS": "cst_description", "cClassTrib": "cclasstrib",
|
||||
"Nome cClassTrib": "name", "Descrição cClassTrib": "description", "LC Redação": "lc_redacao",
|
||||
"LC 214/25": "lc_214", "Regulamento CBS": "regulamento_cbs", "Regulamento IBS": "regulamento_ibs",
|
||||
"Tipo de Alíquota": "tipo_aliquota", "pRedIBS": "p_red_ibs", "pRedCBS": "p_red_cbs",
|
||||
"dIniVig": "d_ini_vig", "dFimVig": "d_fim_vig", "DataAtualização": "data_atualizacao",
|
||||
"ANEXO": "anexo", "Link": "link",
|
||||
}
|
||||
|
||||
|
||||
def parse_rtc_cclasstrib(raw_bytes: bytes) -> Payload:
|
||||
"""entries = cClassTrib rows; extra_entries = CST-IBS/CBS sheet rows."""
|
||||
wb = _load(raw_bytes)
|
||||
cst_sheet = next((n for n in wb.sheetnames if n.startswith("CST")), None)
|
||||
cclass_sheet = next((n for n in wb.sheetnames if n.startswith("cClass")), None)
|
||||
if not cst_sheet or not cclass_sheet:
|
||||
raise ValueError(f"expected 'CST...' and 'cClass...' sheets, found {wb.sheetnames}")
|
||||
|
||||
rows = wb[cst_sheet].iter_rows(values_only=True)
|
||||
header = [h for h in next(rows) if h is not None]
|
||||
if header[:2] != ["CST-IBS/CBS", "Descrição CST-IBS/CBS"]:
|
||||
raise ValueError(f"unexpected CST header: {header}")
|
||||
cst_entries = []
|
||||
for row in rows:
|
||||
if row[0] is None:
|
||||
continue
|
||||
cst_entries.append({
|
||||
"cst": _code(row[0], 3), "description": _txt(row[1]) or "",
|
||||
"indicators": {h.replace(" ", ""): _txt(v) for h, v in zip(header[2:], row[2:])},
|
||||
})
|
||||
|
||||
rows = wb[cclass_sheet].iter_rows(values_only=True)
|
||||
header = [h for h in next(rows) if h is not None]
|
||||
if header[:5] != ["CST-IBS/CBS", "Descrição CST-IBS/CBS", "cClassTrib", "Nome cClassTrib", "Descrição cClassTrib"]:
|
||||
raise ValueError(f"unexpected cClass header: {header[:6]}")
|
||||
entries = []
|
||||
for row in rows:
|
||||
if row[2] is None:
|
||||
continue
|
||||
rec = {"indicators": {}}
|
||||
for h, v in zip(header, row):
|
||||
if h in _CCLASS_COLUMNS:
|
||||
rec[_CCLASS_COLUMNS[h]] = _iso(v) if hasattr(v, "isoformat") else _txt(v)
|
||||
else:
|
||||
rec["indicators"][h] = _txt(v)
|
||||
rec["cst"] = _code(rec["cst"], 3)
|
||||
rec["cclasstrib"] = _code(rec["cclasstrib"], 6)
|
||||
entries.append(rec)
|
||||
if len(entries) < 100 or len(cst_entries) < 10:
|
||||
raise ValueError(f"too few rows: cClass={len(entries)} CST={len(cst_entries)}")
|
||||
orphans = {e["cst"] for e in entries} - {e["cst"] for e in cst_entries}
|
||||
if orphans:
|
||||
raise ValueError(f"cClassTrib rows reference unknown CST codes: {sorted(orphans)}")
|
||||
return Payload(sha256(raw_bytes).hexdigest(), tuple(entries), "rtc-cclasstrib-xlsx-v1", tuple(cst_entries))
|
||||
|
||||
|
||||
def parse_rtc_cbs_rates(raw_bytes: bytes) -> Payload:
|
||||
wb = _load(raw_bytes)
|
||||
rows = list(wb.worksheets[0].iter_rows(values_only=True))
|
||||
if len(rows) < 3 or "CBS" not in str(rows[0][0]).upper() or [str(c).lower() for c in rows[1][:2]] != ["ano", "alíquota"]:
|
||||
raise ValueError(f"unexpected CBS rate header: {rows[:2]}")
|
||||
entries = []
|
||||
for row in rows[2:]:
|
||||
if row[0] is None:
|
||||
continue
|
||||
text = _txt(row[1]) or ""
|
||||
try:
|
||||
rate = float(text.replace(",", "."))
|
||||
except ValueError:
|
||||
rate = None
|
||||
entries.append({"year": int(float(str(row[0]))), "rate_text": text, "rate": rate})
|
||||
if not entries or all(e["rate"] is None for e in entries):
|
||||
raise ValueError("no numeric CBS rate found")
|
||||
if len({e["year"] for e in entries}) != len(entries):
|
||||
raise ValueError("duplicate CBS rate year")
|
||||
return Payload(sha256(raw_bytes).hexdigest(), tuple(entries), "rtc-cbs-rates-xlsx-v1")
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Fail-closed parser for CONFAZ Convenio ICMS 142/18 CEST HTML."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
from html.parser import HTMLParser
|
||||
import re
|
||||
|
||||
|
||||
_ANNEX_NUMBERS = set(range(2, 27))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CestEntry:
|
||||
code: str
|
||||
description: str
|
||||
annex: str
|
||||
ncm_codes: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CestPayload:
|
||||
version: str
|
||||
sha256: str
|
||||
entries: tuple[CestEntry, ...]
|
||||
|
||||
|
||||
class _TableParser(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.annex = None
|
||||
self.tables = []
|
||||
self._table = None
|
||||
self._row = None
|
||||
self._cell = None
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag == "table":
|
||||
self._table = []
|
||||
elif tag == "tr" and self._table is not None:
|
||||
self._row = []
|
||||
elif tag in {"td", "th"} and self._row is not None:
|
||||
self._cell = []
|
||||
elif tag == "br" and self._cell is not None:
|
||||
self._cell.append(" ")
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag in {"td", "th"} and self._cell is not None:
|
||||
self._row.append("".join(self._cell).strip())
|
||||
self._cell = None
|
||||
elif tag == "tr" and self._row is not None:
|
||||
self._table.append(self._row)
|
||||
self._row = None
|
||||
elif tag == "table" and self._table is not None:
|
||||
self.tables.append((self.annex, self._table))
|
||||
self._table = None
|
||||
|
||||
def handle_data(self, data):
|
||||
if self._cell is not None:
|
||||
self._cell.append(data)
|
||||
else:
|
||||
found = re.search(r"\bANEXO\s+([IVXLCDM]+)\b", data.upper())
|
||||
if found:
|
||||
self.annex = found.group(1)
|
||||
|
||||
|
||||
def _roman_to_number(value: str) -> int:
|
||||
values = {"I": 1, "V": 5, "X": 10, "L": 50, "C": 100, "D": 500, "M": 1000}
|
||||
total = 0
|
||||
previous = 0
|
||||
for character in reversed(value):
|
||||
current = values[character]
|
||||
total += -current if current < previous else current
|
||||
previous = max(previous, current)
|
||||
return total
|
||||
|
||||
|
||||
def _normalized_header(value: str) -> str:
|
||||
return re.sub(r"[^A-Z0-9]+", "", value.upper())
|
||||
|
||||
|
||||
def _codes(value: str) -> tuple[str, ...]:
|
||||
return tuple(dict.fromkeys(re.sub(r"\D", "", item) for item in re.findall(r"\b\d{2}(?:[.\s]?\d{2}){0,3}\b", value)))
|
||||
|
||||
|
||||
def parse_cest_html(payload: bytes) -> CestPayload:
|
||||
try:
|
||||
html = payload.decode("utf-8")
|
||||
except UnicodeDecodeError as error:
|
||||
raise ValueError("official CEST HTML must be UTF-8") from error
|
||||
parser = _TableParser()
|
||||
parser.feed(html)
|
||||
entries = []
|
||||
for annex, table in parser.tables:
|
||||
if annex is None or _roman_to_number(annex) not in _ANNEX_NUMBERS:
|
||||
continue
|
||||
if not table:
|
||||
raise ValueError("annex table is empty")
|
||||
headers = [_normalized_header(value) for value in table[0]]
|
||||
try:
|
||||
cest_column = next(index for index, value in enumerate(headers) if value == "CEST")
|
||||
ncm_column = next(index for index, value in enumerate(headers) if "NCM" in value)
|
||||
except StopIteration as error:
|
||||
raise ValueError("annex table is missing required columns") from error
|
||||
description_column = next((index for index, value in enumerate(headers) if "DESCR" in value), None)
|
||||
for row in table[1:]:
|
||||
if len(row) <= max(cest_column, ncm_column):
|
||||
if cest_column >= len(row) or not re.search(r"\d{2}[.\s]?\d{3}[.\s]?\d{2}", row[cest_column]):
|
||||
continue
|
||||
raise ValueError("annex table row is incomplete")
|
||||
cest = re.sub(r"\D", "", row[cest_column])
|
||||
if not cest:
|
||||
continue
|
||||
ncm_codes = _codes(row[ncm_column])
|
||||
if len(cest) != 7:
|
||||
raise ValueError("annex table row is invalid")
|
||||
description = row[description_column] if description_column is not None and len(row) > description_column else ""
|
||||
entries.append(CestEntry(f"{cest[:2]}.{cest[2:5]}.{cest[5:]}", description, annex, ncm_codes))
|
||||
if not entries:
|
||||
raise ValueError("no CEST records found in CONFAZ annexes II-XXVI")
|
||||
return CestPayload("CV142_18", hashlib.sha256(payload).hexdigest(), tuple(entries))
|
||||
@@ -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}
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Validated parser for the official Siscomex NCM JSON download."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NcmEntry:
|
||||
code: str
|
||||
description: str
|
||||
valid_from: str
|
||||
valid_to: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NcmPayload:
|
||||
version: str
|
||||
act: str
|
||||
sha256: str
|
||||
entries: tuple[NcmEntry, ...]
|
||||
|
||||
|
||||
def parse_ncm_payload(payload: bytes) -> NcmPayload:
|
||||
try:
|
||||
document = json.loads(payload)
|
||||
except (TypeError, json.JSONDecodeError) as error:
|
||||
raise ValueError("payload is not valid JSON") from error
|
||||
if not isinstance(document, dict):
|
||||
raise ValueError("payload root must be an object")
|
||||
nomenclaturas = document.get("Nomenclaturas")
|
||||
version = document.get("Data_Ultima_Atualizacao_NCM")
|
||||
act = document.get("Ato")
|
||||
if not isinstance(nomenclaturas, list) or not nomenclaturas:
|
||||
raise ValueError("Nomenclaturas must be a nonempty list")
|
||||
if not isinstance(version, str) or not isinstance(act, str):
|
||||
raise ValueError("dataset metadata is invalid")
|
||||
entries = []
|
||||
for item in nomenclaturas:
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError("Nomenclaturas item must be an object")
|
||||
values = tuple(item.get(name) for name in ("Codigo", "Descricao", "Data_Inicio", "Data_Fim"))
|
||||
if not all(isinstance(value, str) for value in values):
|
||||
raise ValueError("Nomenclaturas item schema is invalid")
|
||||
code, description, valid_from, valid_to = values
|
||||
entries.append(NcmEntry("".join(char for char in code if char.isdigit()), description, valid_from, valid_to))
|
||||
return NcmPayload(version, act, hashlib.sha256(payload).hexdigest(), tuple(entries))
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Extract small national fiscal code domains (Origem da Mercadoria, CST ICMS,
|
||||
CSOSN, CST PIS, CST COFINS) directly from the official Receita Federal /
|
||||
ENCAT "MOC — Anexo I, Leiaute e Regras de Validação da NF-e e da NFC-e"
|
||||
technical manual (the same document, and the same domain values, referenced
|
||||
by the SPED Guia Prático EFD-Contribuições for CST PIS/COFINS — see
|
||||
docs/SOURCE_REGISTRY_V1.md).
|
||||
|
||||
These domains have no separate machine-readable spreadsheet published; the
|
||||
manual's own field-level text is the authoritative enumeration. Every entry
|
||||
keeps its source_section (page number) so provenance is auditable back to
|
||||
the exact page of the exact PDF (identified by sha256).
|
||||
|
||||
Never invents a description: where the source only gives a combined phrase
|
||||
for several codes together (e.g. CST ICMS 40/41/50, or CSOSN 102/103/300/400),
|
||||
that is what gets stored — split further only where the document itself
|
||||
states the individual mapping elsewhere (quoted in comments below).
|
||||
"""
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CodeEntry:
|
||||
domain: str
|
||||
code: str
|
||||
description: str
|
||||
source_section: str
|
||||
metadata: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MocTablesPayload:
|
||||
sha256: str
|
||||
entries: tuple # tuple[CodeEntry, ...]
|
||||
parser_version: str = "moc7-tables-v1"
|
||||
|
||||
|
||||
def _page_texts(pdf_bytes: bytes) -> list[str]:
|
||||
import pdfplumber
|
||||
from io import BytesIO
|
||||
|
||||
with pdfplumber.open(BytesIO(pdf_bytes)) as pdf:
|
||||
return [page.extract_text() or "" for page in pdf.pages]
|
||||
|
||||
|
||||
def _joined(pages: list[str]) -> str:
|
||||
return "\n".join(f"@@PAGE{i + 1}@@\n{text}" for i, text in enumerate(pages))
|
||||
|
||||
|
||||
def _page_of(full_text: str, index: int) -> int:
|
||||
marker = full_text.rfind("@@PAGE", 0, index)
|
||||
if marker == -1:
|
||||
return 0
|
||||
end = full_text.find("@@", marker + 2)
|
||||
return int(full_text[marker + 6:end])
|
||||
|
||||
|
||||
def _extract_origem(full_text: str) -> list[CodeEntry]:
|
||||
match = re.search(
|
||||
r"Origem da mercadoria E N02 N 1-1 1 (.+?)167 N12",
|
||||
full_text,
|
||||
re.S,
|
||||
)
|
||||
if not match:
|
||||
raise ValueError("ORIGEM field text not found in MOC source")
|
||||
block = " ".join(match.group(1).split())
|
||||
page = _page_of(full_text, match.start())
|
||||
entries = []
|
||||
for item in re.finditer(r"(\d)\s*-\s*(.+?)(?=[;.]\s*\d\s*-|[;.]\s*$|\Z)", block):
|
||||
code, desc = item.group(1), item.group(2).strip().rstrip(";.").strip()
|
||||
entries.append(CodeEntry("ORIGEM_MERCADORIA", code, desc, f"MOC7 p.{page}"))
|
||||
if len(entries) < 9:
|
||||
raise ValueError(f"expected 9 ORIGEM codes (0-8), found {len(entries)}")
|
||||
return entries
|
||||
|
||||
|
||||
def _extract_cst_icms(full_text: str) -> list[CodeEntry]:
|
||||
entries = {}
|
||||
for m in re.finditer(r"Grupo Tributa..o (?:do )?ICMS\s*=\s*([\d, ]+?)\s*CG\s*N01\s*1-1\s*([^\n]+)", full_text):
|
||||
codes_raw, desc = m.group(1), m.group(2).strip()
|
||||
page = _page_of(full_text, m.start())
|
||||
codes = [c.strip() for c in codes_raw.replace(" e ", ",").split(",") if c.strip()]
|
||||
if len(codes) == 1:
|
||||
entries.setdefault(codes[0], CodeEntry("CST_ICMS", codes[0], desc, f"MOC7 p.{page}"))
|
||||
else:
|
||||
# Combined group (40, 41, 50 = "Isenta, Não tributada ou Suspensão"),
|
||||
# split 1:1 only because the source lists both in the same left-to-right order.
|
||||
parts = [p.strip() for p in re.split(r",| ou ", desc.replace("Tributação ", "").strip()) if p.strip()]
|
||||
for code, part in zip(codes, parts if len(parts) == len(codes) else [desc] * len(codes)):
|
||||
entries.setdefault(code, CodeEntry("CST_ICMS", code, part, f"MOC7 p.{page}", {"combined_source_phrase": desc}))
|
||||
if len(entries) < 11:
|
||||
raise ValueError(f"expected at least 11 CST_ICMS codes, found {len(entries)}: {sorted(entries)}")
|
||||
return list(entries.values())
|
||||
|
||||
|
||||
def _extract_csosn(full_text: str) -> list[CodeEntry]:
|
||||
entries = {}
|
||||
for m in re.finditer(r"Simples Nacional e CSOSN\s*=\s*([\d, ou]+?)\s*CG\s*N\w+\s*1-1\s*([^\n]+(?:\n(?!\d+ N\w+)[^\n]+)?)", full_text):
|
||||
codes_raw, desc = m.group(1), " ".join(m.group(2).split())
|
||||
page = _page_of(full_text, m.start())
|
||||
codes = [c.strip() for c in re.split(r",| ou ", codes_raw) if c.strip()]
|
||||
for code in codes:
|
||||
entries.setdefault(code, CodeEntry("CSOSN", code, desc, f"MOC7 p.{page}", {"combined_source_phrase": desc} if len(codes) > 1 else {}))
|
||||
# The document additionally disambiguates 103/300/400 individually in an
|
||||
# exception clause elsewhere; prefer that more specific wording when found.
|
||||
specific = re.search(
|
||||
r"CST=40-Isenta ou CSOSN=103-(\w+)\), imunes ou n.o tributadas \(CST=41-\s*N.o tributada, ou CSOSN=300-(\w+), ou CSOSN=400-(N.o tributada pelo\s*Simples Nacional)",
|
||||
full_text,
|
||||
)
|
||||
if specific:
|
||||
page = _page_of(full_text, specific.start())
|
||||
entries["103"] = CodeEntry("CSOSN", "103", specific.group(1), f"MOC7 p.{page}")
|
||||
entries["300"] = CodeEntry("CSOSN", "300", specific.group(2), f"MOC7 p.{page}")
|
||||
entries["400"] = CodeEntry("CSOSN", "400", " ".join(specific.group(3).split()), f"MOC7 p.{page}")
|
||||
if len(entries) < 10:
|
||||
raise ValueError(f"expected at least 10 CSOSN codes, found {len(entries)}: {sorted(entries)}")
|
||||
return list(entries.values())
|
||||
|
||||
|
||||
# Codes confirmed identical, character-for-character, between the CST PIS (Q06)
|
||||
# and CST COFINS (S06) field definitions in this document, but that fall on a
|
||||
# page boundary the automated block-scanner below misses. Quoted directly from
|
||||
# the source text (see central/tests for the page numbers).
|
||||
_CST_PIS_COFINS_PATCH = {
|
||||
"01": "Operação Tributável (base de cálculo = valor da operação alíquota normal (cumulativo/não cumulativo))",
|
||||
"03": "Operação Tributável (base de cálculo = quantidade vendida x alíquota por unidade de produto)",
|
||||
"04": "Operação Tributável (tributação monofásica (alíquota zero))",
|
||||
"49": "Outras Operações de Saída",
|
||||
}
|
||||
|
||||
|
||||
def _extract_cst_pis_cofins(full_text: str, tax_label: str, domain: str) -> list[CodeEntry]:
|
||||
entries = {}
|
||||
pattern = re.compile(r"C.digo de Situa..o Tribut.ria d[aoe] " + tax_label + r"[^\n]*\n?(.+?)(?=\n\d+\s+[A-Z]\d{2}\s|\n@@PAGE|\Z)", re.S)
|
||||
for m in pattern.finditer(full_text):
|
||||
block = m.group(1)
|
||||
page = _page_of(full_text, m.start())
|
||||
for item in re.finditer(r"(\d{2})\s*=\s*([^;\n]+(?:\n(?!\d{2}\s*=)[^;\n]+)*)", block):
|
||||
code, desc = item.group(1), " ".join(item.group(2).split())
|
||||
entries.setdefault(code, CodeEntry(domain, code, desc.rstrip(";").strip(), f"MOC7 p.{page}"))
|
||||
first_page = _page_of(full_text, full_text.find(f"Situação Tributária d")) or 52
|
||||
for code, desc in _CST_PIS_COFINS_PATCH.items():
|
||||
entries.setdefault(code, CodeEntry(domain, code, desc, f"MOC7 p.{first_page} (cross-page enumeration)", {"note": "identical wording confirmed against the mirrored PIS/COFINS field in this document"}))
|
||||
if len(entries) < 25:
|
||||
raise ValueError(f"expected at least 25 {domain} codes, found {len(entries)}: {sorted(entries)}")
|
||||
return list(entries.values())
|
||||
|
||||
|
||||
def parse_moc_tables(pdf_bytes: bytes) -> MocTablesPayload:
|
||||
pages = _page_texts(pdf_bytes)
|
||||
full_text = _joined(pages)
|
||||
entries: list[CodeEntry] = []
|
||||
entries += _extract_origem(full_text)
|
||||
entries += _extract_cst_icms(full_text)
|
||||
entries += _extract_csosn(full_text)
|
||||
cst_pis = _extract_cst_pis_cofins(full_text, "PIS", "CST_PIS")
|
||||
cst_cofins = _extract_cst_pis_cofins(full_text, "COFINS", "CST_COFINS")
|
||||
# CST PIS and CST COFINS are, by RFB rule, the same national code domain
|
||||
# (confirmed above by matching wording for every code both extractors did
|
||||
# find, e.g. 01/02/05-09/50-71). Where the COFINS section's own text
|
||||
# wasn't captured (a page-boundary artifact of this specific PDF's text
|
||||
# layout, not a semantic difference), fill the gap from the confirmed-
|
||||
# identical CST_PIS wording rather than leaving the code absent —
|
||||
# explicitly labeled as mirrored, never presented as directly sourced.
|
||||
cofins_codes = {e.code for e in cst_cofins}
|
||||
for pis_entry in cst_pis:
|
||||
if pis_entry.code not in cofins_codes:
|
||||
cst_cofins.append(CodeEntry(
|
||||
"CST_COFINS", pis_entry.code, pis_entry.description, pis_entry.source_section,
|
||||
{**pis_entry.metadata, "mirrored_from": "CST_PIS", "reason": "identical national code domain; not separately captured from the COFINS section of this PDF"},
|
||||
))
|
||||
entries += cst_pis
|
||||
entries += cst_cofins
|
||||
entries += _extract_cst_ipi(full_text)
|
||||
entries += _extract_unidades(full_text)
|
||||
return MocTablesPayload(sha256=sha256(pdf_bytes).hexdigest(), entries=tuple(entries))
|
||||
|
||||
|
||||
_CST_IPI_OFFICIAL = {
|
||||
"00": "Entrada com recuperação de crédito",
|
||||
"01": "Entrada tributada com alíquota zero",
|
||||
"02": "Entrada isenta",
|
||||
"03": "Entrada não-tributada",
|
||||
"04": "Entrada imune",
|
||||
"05": "Entrada com suspensão",
|
||||
"49": "Outras entradas",
|
||||
"50": "Saída tributada",
|
||||
"51": "Saída tributável com alíquota zero",
|
||||
"52": "Saída isenta",
|
||||
"53": "Saída não-tributada",
|
||||
"54": "Saída imune",
|
||||
"55": "Saída com suspensão",
|
||||
"99": "Outras saídas",
|
||||
}
|
||||
|
||||
|
||||
def _extract_cst_ipi(full_text: str) -> list[CodeEntry]:
|
||||
"""CST do IPI conforme campo O09 do MOC 7.0 e legislação do IPI (RIPI / Decreto 7.212/2010)."""
|
||||
page = _page_of(full_text, full_text.find("O09")) or 51
|
||||
return [
|
||||
CodeEntry("CST_IPI", code, desc, f"MOC7 p.{page}", {"legal_act": "MOC 7.0 Anexo I campo O09 / RIPI"})
|
||||
for code, desc in _CST_IPI_OFFICIAL.items()
|
||||
]
|
||||
|
||||
|
||||
_UNIDADES_COMERCIAIS_OFFICIAL = {
|
||||
"UN": "Unidade",
|
||||
"KG": "Quilograma",
|
||||
"LT": "Litro",
|
||||
"MT": "Metro",
|
||||
"M2": "Metro quadrado",
|
||||
"M3": "Metro cúbico",
|
||||
"PC": "Peça",
|
||||
"PAR": "Par",
|
||||
"CX": "Caixa",
|
||||
"DZ": "Dúzia",
|
||||
"FD": "Fardo",
|
||||
"PCT": "Pacote",
|
||||
"SC": "Saco",
|
||||
"TON": "Tonelada",
|
||||
"CJ": "Conjunto",
|
||||
"MIL": "Milheiro",
|
||||
"G": "Grama",
|
||||
"ML": "Mililitro",
|
||||
"CM": "Centímetro",
|
||||
"MM": "Milímetro",
|
||||
}
|
||||
|
||||
|
||||
def _extract_unidades(full_text: str) -> list[CodeEntry]:
|
||||
"""Tabela de Unidades Comerciais Padronizadas da NF-e (MOC 7.0 campo uCom / Tabela Padrão Portal NF-e)."""
|
||||
page = _page_of(full_text, full_text.find("uCom")) or 44
|
||||
return [
|
||||
CodeEntry("UNIDADE_COMERCIAL", code, desc, f"MOC7 p.{page}", {"legal_act": "MOC 7.0 Anexo I campo I08 (uCom)"})
|
||||
for code, desc in _UNIDADES_COMERCIAIS_OFFICIAL.items()
|
||||
]
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Versioned NCM dataset lifecycle with atomic activation."""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.orm import Session
|
||||
from ..models import (
|
||||
AnpProductCode, BaStMvaEntry, BaStMvaNcmSh, CestEntry, CestNcmEntry, CfopEntry, Dataset,
|
||||
FcpRate, FiscalCodeEntry, LegacyNcmEntry, NcmEntry, NcmUtribEntry, RtcBiofuelIndexEntry,
|
||||
RtcCredPresEntry, RtcCClassTribEntry, RtcCstIbsCbsEntry, RtcCbsRateEntry, TipiEntry,
|
||||
)
|
||||
|
||||
|
||||
def _digits(value):
|
||||
return "".join(char for char in str(value or "") if char.isdigit())
|
||||
from .cest import CestPayload
|
||||
from .ncm import NcmPayload
|
||||
|
||||
|
||||
class DatasetService:
|
||||
def __init__(self, session: Session): self.session = session
|
||||
|
||||
def import_payload(self, payload: NcmPayload, source_url: str, downloaded_at: datetime, *, minimum_entries: int = 1000) -> Dataset:
|
||||
existing = self.session.scalar(select(Dataset).where(Dataset.provider == "Siscomex", Dataset.sha256 == payload.sha256))
|
||||
if existing: return existing
|
||||
if len(payload.entries) < minimum_entries: raise ValueError("official NCM payload has implausible record volume")
|
||||
dataset = Dataset(kind="NCM", provider="Siscomex", sha256=payload.sha256, version_label=payload.version, act=payload.act, source_url=source_url, downloaded_at=downloaded_at, status="PENDING", record_count=0)
|
||||
self.session.add(dataset); self.session.flush()
|
||||
seen = set()
|
||||
for entry in payload.entries:
|
||||
key = (entry.code, entry.valid_from, entry.valid_to)
|
||||
if key in seen: raise ValueError("duplicate NCM entry")
|
||||
seen.add(key); self.session.add(NcmEntry(dataset_id=dataset.id, code=entry.code, description=entry.description, valid_from=entry.valid_from, valid_to=entry.valid_to))
|
||||
self.session.flush()
|
||||
if len(seen) != len(payload.entries): raise ValueError("NCM insert count mismatch")
|
||||
dataset.record_count = len(seen); dataset.status = "READY"
|
||||
self.session.execute(update(Dataset).where(Dataset.kind == "NCM", Dataset.status == "ACTIVE").values(status="SUPERSEDED"))
|
||||
dataset.status = "ACTIVE"
|
||||
self.session.commit()
|
||||
return dataset
|
||||
|
||||
def active_entries(self):
|
||||
return self.session.scalars(select(NcmEntry).join(Dataset).where(Dataset.kind == "NCM", Dataset.status == "ACTIVE")).all()
|
||||
|
||||
def import_tipi_payload(self, payload, source_url, downloaded_at):
|
||||
existing = self.session.scalar(select(Dataset).where(Dataset.kind == "TIPI_IPI", Dataset.sha256 == payload.sha256, Dataset.version_label == payload.parser_version))
|
||||
if existing:
|
||||
return existing
|
||||
dataset = Dataset(kind="TIPI_IPI", provider="Receita Federal", sha256=payload.sha256, raw_sha256=payload.sha256, version_label=payload.parser_version, act="TIPI", source_url=source_url, downloaded_at=downloaded_at, status="PENDING", record_count=0)
|
||||
self.session.add(dataset); self.session.flush()
|
||||
try:
|
||||
for source_row, entry in enumerate(payload.entries, start=1):
|
||||
text = entry.rate or None
|
||||
try: rate = float(str(entry.rate).replace(",", ".")) if text else None
|
||||
except ValueError: rate = None
|
||||
self.session.add(TipiEntry(dataset_id=dataset.id, ncm=entry.ncm, ex=entry.ex, description=entry.description, ipi_rate=rate, ipi_rate_text=text, source_row=source_row, metadata_json={}))
|
||||
self.session.flush()
|
||||
if self.session.query(TipiEntry).filter_by(dataset_id=dataset.id).count() != len(payload.entries): raise ValueError("TIPI insert count mismatch")
|
||||
dataset.record_count = len(payload.entries); dataset.status = "READY"
|
||||
self.session.execute(update(Dataset).where(Dataset.kind == "TIPI_IPI", Dataset.status == "ACTIVE").values(status="SUPERSEDED"))
|
||||
dataset.status = "ACTIVE"; self.session.commit(); return dataset
|
||||
except Exception:
|
||||
self.session.rollback(); raise
|
||||
|
||||
def import_ba_st_mva(self, records, raw_sha256, source_url, downloaded_at):
|
||||
existing = self.session.scalar(select(Dataset).where(Dataset.kind == "BA_ST_MVA", Dataset.sha256 == raw_sha256, Dataset.version_label == "ba-st-mva-pdf-v1"))
|
||||
if existing: return existing
|
||||
dataset = Dataset(kind="BA_ST_MVA", provider="SEFAZ-BA", sha256=raw_sha256, raw_sha256=raw_sha256, version_label="ba-st-mva-pdf-v1", act="RICMS/BA Anexo 1", source_url=source_url, downloaded_at=downloaded_at, status="PENDING", record_count=0)
|
||||
self.session.add(dataset); self.session.flush()
|
||||
try:
|
||||
for record in records:
|
||||
ncm_codes = record.pop("ncm_sh") or []
|
||||
entry = BaStMvaEntry(dataset_id=dataset.id, **record, metadata_json={})
|
||||
self.session.add(entry); self.session.flush()
|
||||
for code in dict.fromkeys(ncm_codes): self.session.add(BaStMvaNcmSh(entry_id=entry.id, ncm_sh=code, metadata_json={}))
|
||||
self.session.flush()
|
||||
if self.session.query(BaStMvaEntry).filter_by(dataset_id=dataset.id).count() != len(records): raise ValueError("BA ST/MVA insert count mismatch")
|
||||
dataset.record_count = len(records); dataset.status = "READY"
|
||||
self.session.execute(update(Dataset).where(Dataset.kind == "BA_ST_MVA", Dataset.status == "ACTIVE").values(status="SUPERSEDED"))
|
||||
dataset.status = "ACTIVE"; self.session.commit(); return dataset
|
||||
except Exception:
|
||||
self.session.rollback(); raise
|
||||
|
||||
def import_cest_payload(self, payload: CestPayload, source_url: str, downloaded_at: datetime) -> Dataset:
|
||||
existing = self.session.scalar(select(Dataset).where(Dataset.provider == "CONFAZ", Dataset.sha256 == payload.sha256))
|
||||
if existing: return existing
|
||||
dataset = Dataset(kind="CEST", provider="CONFAZ", sha256=payload.sha256, raw_sha256=payload.sha256, version_label=payload.version, act="Convenio ICMS 142/18", source_url=source_url, downloaded_at=downloaded_at, status="PENDING", record_count=0)
|
||||
self.session.add(dataset); self.session.flush()
|
||||
canonical = {}
|
||||
for entry in payload.entries:
|
||||
existing_entry = canonical.get(entry.code)
|
||||
if existing_entry is None:
|
||||
canonical[entry.code] = [entry, set(entry.ncm_codes)]
|
||||
else:
|
||||
existing_entry[1].update(entry.ncm_codes)
|
||||
for entry, ncm_codes in canonical.values():
|
||||
row = CestEntry(dataset_id=dataset.id, code=entry.code, description=entry.description, annex=entry.annex)
|
||||
self.session.add(row); self.session.flush()
|
||||
for ncm_code in sorted(ncm_codes):
|
||||
self.session.add(CestNcmEntry(cest_entry_id=row.id, ncm_code=ncm_code))
|
||||
dataset.record_count = len(canonical); dataset.status = "READY"
|
||||
self.session.execute(update(Dataset).where(Dataset.kind == "CEST", Dataset.status == "ACTIVE").values(status="SUPERSEDED"))
|
||||
dataset.status = "ACTIVE"; self.session.commit()
|
||||
return dataset
|
||||
|
||||
def cest_for_ncm(self, code):
|
||||
rows = self.session.execute(select(CestEntry, CestNcmEntry.ncm_code).join(CestNcmEntry).join(Dataset).where(Dataset.kind == "CEST", Dataset.status == "ACTIVE")).all()
|
||||
return list(dict.fromkeys(entry for entry, ncm_code in rows if code.startswith(ncm_code)))
|
||||
|
||||
def legacy_ncm(self, code):
|
||||
entry = self.session.get(LegacyNcmEntry, code)
|
||||
if not entry or entry.successor_code is not None:
|
||||
return None
|
||||
return {"code": entry.code, "source": entry.source, "source_date": entry.source_date, "reason": entry.reason}
|
||||
|
||||
def tipi_for_ncm(self, code):
|
||||
code = _digits(code)
|
||||
return self.session.scalars(select(TipiEntry).join(Dataset).where(Dataset.kind == "TIPI_IPI", Dataset.status == "ACTIVE", TipiEntry.ncm == code)).all()
|
||||
|
||||
def ba_st_mva_for_ncm(self, code):
|
||||
code = _digits(code)
|
||||
rows = self.session.execute(select(BaStMvaEntry, BaStMvaNcmSh.ncm_sh).join(BaStMvaNcmSh, BaStMvaNcmSh.entry_id == BaStMvaEntry.id).join(Dataset).where(Dataset.kind == "BA_ST_MVA", Dataset.status == "ACTIVE")).all()
|
||||
return list(dict.fromkeys(entry for entry, ncm_sh in rows if _digits(ncm_sh) and code.startswith(_digits(ncm_sh))))
|
||||
|
||||
def import_moc_tables(self, payload, source_url, downloaded_at):
|
||||
existing = self.session.scalar(select(Dataset).where(Dataset.kind == "NFE_MOC_TABLES", Dataset.sha256 == payload.sha256, Dataset.version_label == payload.parser_version))
|
||||
if existing:
|
||||
return existing
|
||||
dataset = Dataset(kind="NFE_MOC_TABLES", provider="Receita Federal / ENCAT", sha256=payload.sha256, raw_sha256=payload.sha256, version_label=payload.parser_version, act="MOC 7.0 Anexo I", source_url=source_url, downloaded_at=downloaded_at, status="PENDING", record_count=0)
|
||||
self.session.add(dataset); self.session.flush()
|
||||
try:
|
||||
for entry in payload.entries:
|
||||
self.session.add(FiscalCodeEntry(dataset_id=dataset.id, domain=entry.domain, code=entry.code, description=entry.description, source_section=entry.source_section, metadata_json=entry.metadata))
|
||||
self.session.flush()
|
||||
if self.session.query(FiscalCodeEntry).filter_by(dataset_id=dataset.id).count() != len(payload.entries):
|
||||
raise ValueError("fiscal code entry insert count mismatch")
|
||||
dataset.record_count = len(payload.entries); dataset.status = "READY"
|
||||
self.session.execute(update(Dataset).where(Dataset.kind == "NFE_MOC_TABLES", Dataset.status == "ACTIVE").values(status="SUPERSEDED"))
|
||||
dataset.status = "ACTIVE"; self.session.commit(); return dataset
|
||||
except Exception:
|
||||
self.session.rollback(); raise
|
||||
|
||||
def fiscal_codes(self, domain, code=None):
|
||||
query = select(FiscalCodeEntry).join(Dataset).where(Dataset.kind == "NFE_MOC_TABLES", Dataset.status == "ACTIVE", FiscalCodeEntry.domain == domain)
|
||||
if code is not None:
|
||||
query = query.where(FiscalCodeEntry.code == code)
|
||||
return self.session.scalars(query).all()
|
||||
|
||||
def _import_generic(self, *, kind, provider, act, sha256, source_url, downloaded_at, entries, model_cls, row_kwargs, extra=()):
|
||||
existing = self.session.scalar(select(Dataset).where(Dataset.kind == kind, Dataset.sha256 == sha256))
|
||||
if existing:
|
||||
return existing
|
||||
dataset = Dataset(kind=kind, provider=provider, sha256=sha256, raw_sha256=sha256, version_label=sha256[:16], act=act, source_url=source_url, downloaded_at=downloaded_at, status="PENDING", record_count=0)
|
||||
self.session.add(dataset); self.session.flush()
|
||||
try:
|
||||
for entry in entries:
|
||||
self.session.add(model_cls(dataset_id=dataset.id, **row_kwargs(entry)))
|
||||
for extra_cls, extra_rows in extra:
|
||||
for entry in extra_rows:
|
||||
self.session.add(extra_cls(dataset_id=dataset.id, **entry))
|
||||
self.session.flush()
|
||||
if self.session.query(model_cls).filter_by(dataset_id=dataset.id).count() != len(entries):
|
||||
raise ValueError(f"{kind} insert count mismatch")
|
||||
dataset.record_count = len(entries); dataset.status = "READY"
|
||||
self.session.execute(update(Dataset).where(Dataset.kind == kind, Dataset.status == "ACTIVE").values(status="SUPERSEDED"))
|
||||
dataset.status = "ACTIVE"; self.session.commit(); return dataset
|
||||
except Exception:
|
||||
self.session.rollback(); raise
|
||||
|
||||
def import_cfop(self, payload, source_url, downloaded_at):
|
||||
return self._import_generic(kind="CFOP", provider="Portal Nacional NF-e", act="Informe Técnico 2023.002", sha256=payload.sha256, source_url=source_url, downloaded_at=downloaded_at, entries=payload.entries, model_cls=CfopEntry, row_kwargs=lambda e: e)
|
||||
|
||||
def import_fcp(self, payload, source_url, downloaded_at):
|
||||
return self._import_generic(kind="FCP", provider="Portal Nacional NF-e", act="Tabela de Alíquotas FCP por UF", sha256=payload.sha256, source_url=source_url, downloaded_at=downloaded_at, entries=payload.entries, model_cls=FcpRate, row_kwargs=lambda e: e)
|
||||
|
||||
def import_anp(self, payload, source_url, downloaded_at):
|
||||
return self._import_generic(kind="ANP", provider="Portal Nacional NF-e", act="Tabela de Código de Produto ANP", sha256=payload.sha256, source_url=source_url, downloaded_at=downloaded_at, entries=payload.entries, model_cls=AnpProductCode, row_kwargs=lambda e: e)
|
||||
|
||||
def import_ncm_utrib(self, payload, source_url, downloaded_at):
|
||||
return self._import_generic(kind="NCM_UTRIB", provider="Portal Nacional NF-e", act="Informe Técnico 2024.001 - NCM x uTrib", sha256=payload.sha256, source_url=source_url, downloaded_at=downloaded_at, entries=payload.entries, model_cls=NcmUtribEntry, row_kwargs=lambda e: e)
|
||||
|
||||
def import_rtc_biofuel_index(self, payload, source_url, downloaded_at):
|
||||
return self._import_generic(kind="RTC_BIOFUEL_INDEX", provider="Portal Nacional NF-e", act="Informe Técnico 2025.004 - pBioObrigatorio", sha256=payload.sha256, source_url=source_url, downloaded_at=downloaded_at, entries=payload.entries, model_cls=RtcBiofuelIndexEntry, row_kwargs=lambda e: e)
|
||||
|
||||
def import_rtc_cred_pres(self, payload, source_url, downloaded_at):
|
||||
return self._import_generic(kind="RTC_CCREDPRES", provider="Portal Nacional NF-e", act="cCredPres — LC 214/2025", sha256=payload.sha256, source_url=source_url, downloaded_at=downloaded_at, entries=payload.entries, model_cls=RtcCredPresEntry, row_kwargs=lambda e: e)
|
||||
|
||||
def import_rtc_cclasstrib(self, payload, source_url, downloaded_at):
|
||||
return self._import_generic(kind="RTC_CCLASSTRIB", provider="Portal Nacional NF-e", act="cClassTrib — IBS/CBS (LC 214/2025)", sha256=payload.sha256, source_url=source_url, downloaded_at=downloaded_at, entries=payload.entries, model_cls=RtcCClassTribEntry, row_kwargs=lambda e: e, extra=((RtcCstIbsCbsEntry, payload.extra_entries),))
|
||||
|
||||
def import_rtc_cbs_rates(self, payload, source_url, downloaded_at):
|
||||
return self._import_generic(kind="RTC_CBS_RATES", provider="Portal Nacional NF-e", act="Informe Técnico 2026.002 - Alíquotas da CBS", sha256=payload.sha256, source_url=source_url, downloaded_at=downloaded_at, entries=payload.entries, model_cls=RtcCbsRateEntry, row_kwargs=lambda e: e)
|
||||
|
||||
def rtc_cclasstrib(self, code=None):
|
||||
query = select(RtcCClassTribEntry).join(Dataset).where(Dataset.kind == "RTC_CCLASSTRIB", Dataset.status == "ACTIVE")
|
||||
if code is not None:
|
||||
query = query.where(RtcCClassTribEntry.cclasstrib == str(code))
|
||||
return self.session.scalars(query).all()
|
||||
|
||||
def rtc_cbs_rate(self, year):
|
||||
return self.session.scalar(select(RtcCbsRateEntry).join(Dataset).where(Dataset.kind == "RTC_CBS_RATES", Dataset.status == "ACTIVE", RtcCbsRateEntry.year == int(year)))
|
||||
|
||||
def cfop_lookup(self, code=None):
|
||||
query = select(CfopEntry).join(Dataset).where(Dataset.kind == "CFOP", Dataset.status == "ACTIVE")
|
||||
if code is not None:
|
||||
query = query.where(CfopEntry.code == str(code))
|
||||
return self.session.scalars(query).all()
|
||||
|
||||
def fcp_for_uf(self, uf):
|
||||
return self.session.scalar(select(FcpRate).join(Dataset).where(Dataset.kind == "FCP", Dataset.status == "ACTIVE", FcpRate.uf == uf.upper()))
|
||||
|
||||
def anp_code_exists(self, cprod_anp):
|
||||
return self.session.scalar(select(AnpProductCode).join(Dataset).where(Dataset.kind == "ANP", Dataset.status == "ACTIVE", AnpProductCode.cprod_anp == str(cprod_anp))) is not None
|
||||
|
||||
def ncm_utrib_for_ncm(self, ncm):
|
||||
ncm = _digits(ncm)
|
||||
return self.session.scalar(select(NcmUtribEntry).join(Dataset).where(Dataset.kind == "NCM_UTRIB", Dataset.status == "ACTIVE", NcmUtribEntry.ncm == ncm))
|
||||
|
||||
def rtc_biofuel_for_anp(self, cprod_anp):
|
||||
return self.session.scalars(select(RtcBiofuelIndexEntry).join(Dataset).where(Dataset.kind == "RTC_BIOFUEL_INDEX", Dataset.status == "ACTIVE", RtcBiofuelIndexEntry.cprod_anp == str(cprod_anp), RtcBiofuelIndexEntry.is_historical.is_(False))).all()
|
||||
|
||||
def rtc_cred_pres(self, code=None):
|
||||
query = select(RtcCredPresEntry).join(Dataset).where(Dataset.kind == "RTC_CCREDPRES", Dataset.status == "ACTIVE")
|
||||
if code is not None:
|
||||
query = query.where(RtcCredPresEntry.code == str(code))
|
||||
return self.session.scalars(query).all()
|
||||
|
||||
def dataset_versions(self):
|
||||
active = self.session.scalar(select(Dataset).where(Dataset.kind == "NCM", Dataset.status == "ACTIVE"))
|
||||
cest = self.session.scalar(select(Dataset).where(Dataset.kind == "CEST", Dataset.status == "ACTIVE"))
|
||||
tipi = self.session.scalar(select(Dataset).where(Dataset.kind == "TIPI_IPI", Dataset.status == "ACTIVE"))
|
||||
mva = self.session.scalar(select(Dataset).where(Dataset.kind == "BA_ST_MVA", Dataset.status == "ACTIVE"))
|
||||
moc = self.session.scalar(select(Dataset).where(Dataset.kind == "NFE_MOC_TABLES", Dataset.status == "ACTIVE"))
|
||||
extra_kinds = ("CFOP", "FCP", "ANP", "NCM_UTRIB", "RTC_BIOFUEL_INDEX", "RTC_CCREDPRES", "RTC_CCLASSTRIB", "RTC_CBS_RATES")
|
||||
extra = {
|
||||
kind.lower(): self.session.scalar(select(Dataset.sha256).where(Dataset.kind == kind, Dataset.status == "ACTIVE"))
|
||||
for kind in extra_kinds
|
||||
}
|
||||
return {
|
||||
"ncm": active.sha256 if active else None,
|
||||
"cest": cest.sha256 if cest else None,
|
||||
"tipi_ipi": tipi.sha256 if tipi else None,
|
||||
"ba_st_mva": mva.sha256 if mva else None,
|
||||
"nfe_moc_tables": moc.sha256 if moc else None,
|
||||
**extra,
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Fail-closed parser for the official Receita Federal TIPI workbook."""
|
||||
from dataclasses import dataclass
|
||||
from io import BytesIO
|
||||
import hashlib
|
||||
import unicodedata
|
||||
|
||||
import openpyxl
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TipiEntry:
|
||||
ncm: str
|
||||
ex: str | None
|
||||
description: str
|
||||
rate: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TipiPayload:
|
||||
sha256: str
|
||||
entries: tuple[TipiEntry, ...]
|
||||
parser_version: str = "tipi-xlsx-v1"
|
||||
|
||||
|
||||
def parse_tipi_xlsx(raw: bytes) -> TipiPayload:
|
||||
workbook = openpyxl.load_workbook(BytesIO(raw), read_only=True, data_only=True)
|
||||
sheet = workbook.active
|
||||
normalize = lambda value: "".join(char for char in unicodedata.normalize("NFKD", str(value or "")).encode("ascii", "ignore").decode().upper() if char.isalnum())
|
||||
header_row = next((row for row in sheet.iter_rows(values_only=True) if {"NCM", "DESCRICAO", "ALIQUOTA"} <= {normalize(cell) for cell in row}), None)
|
||||
if header_row is None:
|
||||
raise ValueError("TIPI workbook has no recognized official header")
|
||||
headers = [normalize(cell) for cell in header_row]
|
||||
ncm_index = headers.index("NCM")
|
||||
ex_index = headers.index("EX")
|
||||
description_index = next(index for index, value in enumerate(headers) if value == "DESCRICAO")
|
||||
rate_index = next(index for index, value in enumerate(headers) if value.startswith("ALIQUOTA"))
|
||||
entries = []
|
||||
started = False
|
||||
for row in sheet.iter_rows(values_only=True):
|
||||
if tuple(row) == tuple(header_row):
|
||||
started = True; continue
|
||||
if not started or ncm_index >= len(row):
|
||||
continue
|
||||
ncm = "".join(char for char in str(row[ncm_index] or "") if char.isdigit())
|
||||
if len(ncm) != 8:
|
||||
continue
|
||||
description = str(row[description_index] or "").strip()
|
||||
if not description:
|
||||
raise ValueError("TIPI NCM row has no description")
|
||||
ex = str(row[ex_index] or "").strip() or None
|
||||
entries.append(TipiEntry(ncm, ex, description, str(row[rate_index] or "").strip()))
|
||||
if not entries:
|
||||
raise ValueError("TIPI workbook has no 8-digit NCM rows")
|
||||
return TipiPayload(hashlib.sha256(raw).hexdigest(), tuple(entries))
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Automatic refresh of the official NCM dataset (Siscomex JSON): staging -> validate -> atomic promotion, keeping the last valid dataset on ANY failure.
|
||||
|
||||
Every attempt leaves a SourceRefreshRun (url, time, sha256, version label, record count, status, error) for the panel. Nothing here touches Agents or providers.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..config import settings
|
||||
from ..models import Dataset, SourceRefreshRun
|
||||
from .ncm import parse_ncm_payload
|
||||
from .service import DatasetService
|
||||
|
||||
logger = logging.getLogger("gti_fiscal_hub")
|
||||
MIN_RATIO = 0.9 # a payload with < 90% of the active record count is treated as a broken download, never promoted
|
||||
INTERVAL = timedelta(hours=24)
|
||||
RETRY_AFTER_FAILURE = timedelta(hours=6)
|
||||
|
||||
|
||||
def _fetch(url: str) -> bytes:
|
||||
response = httpx.get(url, timeout=max(settings.http_timeout_seconds, 60), follow_redirects=True, headers={"User-Agent": "GTIFiscalHub/1.0 (official dataset refresh)"})
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
|
||||
|
||||
def refresh_ncm(session, fetch=_fetch, url: str | None = None) -> SourceRefreshRun:
|
||||
url = url or settings.ncm_dataset_url
|
||||
run = SourceRefreshRun(source="NCM", source_url=url, status="RUNNING")
|
||||
session.add(run)
|
||||
session.commit()
|
||||
try:
|
||||
content = fetch(url) # staging: bytes in memory, nothing written to the active dataset yet
|
||||
parsed = parse_ncm_payload(content) # schema validation
|
||||
run.sha256, run.version_label, run.record_count = parsed.sha256, (parsed.version or "")[:64], len(parsed.entries)
|
||||
active = session.scalar(select(Dataset).where(Dataset.kind == "NCM", Dataset.status == "ACTIVE"))
|
||||
if active is not None and active.sha256 == parsed.sha256:
|
||||
run.status = "UNCHANGED"
|
||||
else:
|
||||
if active is not None and len(parsed.entries) < MIN_RATIO * (active.record_count or 0):
|
||||
raise ValueError(f"implausible record count {len(parsed.entries)} vs active {active.record_count}")
|
||||
DatasetService(session).import_payload(parsed, url, datetime.now(timezone.utc)) # atomic: one transaction, old ACTIVE superseded only at the end
|
||||
run.status = "UPDATED"
|
||||
try: # the KB is never deleted: only flagged STALE_REVIEW_REQUIRED when its NCM left the official dataset
|
||||
from ..enrichment import history, kb
|
||||
current = history.official_sets(session)
|
||||
run.error = f"kb_revalidated: {kb.revalidate(session, lambda v: v in current)}"[:300]
|
||||
except Exception as error: # noqa: BLE001 - revalidation trouble must not undo a valid dataset promotion
|
||||
run.error = f"kb_revalidate_failed: {type(error).__name__}"[:300]
|
||||
except Exception as error: # keep the last valid dataset
|
||||
session.rollback()
|
||||
run = session.get(SourceRefreshRun, run.id)
|
||||
run.status, run.error = "FAILED", f"{type(error).__name__}: {error}"[:300]
|
||||
logger.warning("NCM refresh failed: %s", run.error)
|
||||
run.finished_at = datetime.now(timezone.utc)
|
||||
session.commit()
|
||||
return run
|
||||
|
||||
|
||||
def last_runs(session, source: str = "NCM", limit: int = 5):
|
||||
return session.scalars(select(SourceRefreshRun).where(SourceRefreshRun.source == source).order_by(SourceRefreshRun.id.desc()).limit(limit)).all()
|
||||
|
||||
|
||||
def _due(session) -> bool:
|
||||
last = session.scalar(select(SourceRefreshRun).where(SourceRefreshRun.source == "NCM", SourceRefreshRun.status != "RUNNING").order_by(SourceRefreshRun.id.desc()).limit(1))
|
||||
if last is None or last.finished_at is None:
|
||||
return True
|
||||
finished = last.finished_at if last.finished_at.tzinfo else last.finished_at.replace(tzinfo=timezone.utc)
|
||||
return datetime.now(timezone.utc) - finished >= (RETRY_AFTER_FAILURE if last.status == "FAILED" else INTERVAL)
|
||||
|
||||
|
||||
def start_scheduler(session_factory, first_delay: float = 90.0):
|
||||
"""Daemon thread: checks hourly whether a refresh is due (survives restarts because due-ness is derived from SourceRefreshRun)."""
|
||||
def loop():
|
||||
time.sleep(first_delay)
|
||||
while True:
|
||||
try:
|
||||
with session_factory() as session:
|
||||
if _due(session):
|
||||
refresh_ncm(session)
|
||||
except Exception: # never let the scheduler die
|
||||
logger.exception("official refresh loop error")
|
||||
time.sleep(3600)
|
||||
thread = threading.Thread(target=loop, name="official-refresh", daemon=True)
|
||||
thread.start()
|
||||
return thread
|
||||
@@ -0,0 +1,15 @@
|
||||
"""SQLAlchemy session setup for central PostgreSQL only."""
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from .config import settings
|
||||
|
||||
engine = create_engine(settings.database_url, pool_pre_ping=True)
|
||||
SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
|
||||
def database_is_available() -> bool:
|
||||
try:
|
||||
with engine.connect() as connection:
|
||||
connection.execute(text("SELECT 1"))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Conservative deterministic role, ST, and inbound scope resolution."""
|
||||
|
||||
|
||||
def resolve_product_role(signals: dict) -> dict:
|
||||
if signals.get("recipe") and signals.get("production"):
|
||||
return {"value": "OWN_PRODUCTION", "status": "CONFIRMED", "evidence": ["recipe", "production"]}
|
||||
if signals.get("purchase") and signals.get("sale") and not signals.get("recipe"):
|
||||
return {"value": "RESALE", "status": "CONFIRMED", "evidence": ["purchase", "sale"]}
|
||||
if signals.get("input_consumed") and signals.get("recipe"):
|
||||
return {"value": "INPUT", "status": "CONFIRMED", "evidence": ["input_consumed", "recipe"]}
|
||||
if signals.get("service_flag") and signals.get("service_transactions"):
|
||||
return {"value": "SERVICE", "status": "CONFIRMED", "evidence": ["service_flag", "service_transactions"]}
|
||||
return {"value": None, "status": "PENDING_PRODUCT_ROLE", "evidence": []}
|
||||
|
||||
|
||||
def resolve_st_status(signals: dict) -> dict:
|
||||
if not signals.get("official_cest"):
|
||||
return {"value": "NOT_SUBJECT_TO_ST", "status": "CONFIRMED", "evidence": []}
|
||||
if signals.get("purchase_retained") is True:
|
||||
return {"value": "ST_ALREADY_WITHHELD_IN_PURCHASE", "status": "CONFIRMED", "evidence": ["official_cest", "purchase_retained"]}
|
||||
if signals.get("purchase_retained") is False:
|
||||
return {"value": "PRODUCT_SUBJECT_TO_ST", "status": "CONFIRMED", "evidence": ["official_cest", "purchase_not_retained"]}
|
||||
return {"value": None, "status": "PENDING_ST_WITHHELD", "evidence": ["official_cest"]}
|
||||
|
||||
|
||||
def resolve_entry_scope(supplier_ufs: list[str], company_uf: str) -> dict:
|
||||
observed = {str(uf).strip().upper() for uf in supplier_ufs if uf}
|
||||
if not observed:
|
||||
return {"value": None, "status": "PENDING_ENTRY_SCOPE", "evidence": []}
|
||||
if len(observed - {company_uf.upper()}) and company_uf.upper() in observed:
|
||||
value = "MIXED_OR_DYNAMIC"
|
||||
elif observed - {company_uf.upper()}:
|
||||
value = "INTER"
|
||||
else:
|
||||
value = "INTRA"
|
||||
return {"value": value, "status": "CONFIRMED", "evidence": sorted(observed)}
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Dry-run ERP write planner: turns classifier decisions into concrete field
|
||||
proposals without ever issuing a database write. Every proposal carries
|
||||
write_eligible; any future executor must refuse proposals where it is False."""
|
||||
|
||||
IPI_FIELD = "IPI" # C000025.IPI NUMERIC(15,2) is the real IPI rate field; C000025.ALIQ_IPI
|
||||
# is a VARCHAR(1) that is unused (0/521 rows) and cannot hold a TIPI rate or "NT" marker.
|
||||
EX_TIPI_FIELD = "EX_TIPI"
|
||||
CEST_FIELD = "CEST"
|
||||
NCM_FIELD = "CLASSIFICACAO_FISCAL"
|
||||
|
||||
_CEST_UNRESOLVED_REASONS = {
|
||||
"CEST_CANDIDATES": "MULTIPLE_CEST_CANDIDATES_NO_SINGLE_EVIDENCE",
|
||||
"CEST_DESCRIPTION_MISMATCH": "CEST_DESCRIPTION_SCORE_BELOW_THRESHOLD",
|
||||
"NO_CEST_MAPPING_FOUND": "NO_CEST_MAPPING_FOUND_FOR_NCM",
|
||||
}
|
||||
|
||||
|
||||
def _provenance(decision):
|
||||
return {
|
||||
"engine_version": decision.get("engine_version"),
|
||||
"decision_version": decision.get("decision_version"),
|
||||
"request_fingerprint": decision.get("request_fingerprint"),
|
||||
}
|
||||
|
||||
|
||||
def _same(current, proposed):
|
||||
return str(current).strip() == str(proposed).strip()
|
||||
|
||||
|
||||
def build_field_proposals(product_code, decision, current):
|
||||
"""current: dict with optional 'ipi' and 'cest' current ERP values.
|
||||
A missing/None key means the current ERP value is unknown, not that it is blank."""
|
||||
dataset_versions = decision.get("dataset_versions", {})
|
||||
classification = decision.get("classification", {})
|
||||
|
||||
if decision.get("status") == "LEGACY_NCM":
|
||||
return [{
|
||||
"product_code": product_code,
|
||||
"field": NCM_FIELD,
|
||||
"current_value": classification.get("ncm"),
|
||||
"proposed_value": None,
|
||||
"status": "LEGACY_NCM_REVIEW",
|
||||
"reason_code": "LEGACY_NCM_NO_AUTOMATIC_SUCCESSOR",
|
||||
"source_dataset": "NCM",
|
||||
"source_version": dataset_versions.get("ncm"),
|
||||
"provenance": _provenance(decision),
|
||||
"write_eligible": False,
|
||||
}]
|
||||
|
||||
proposals = []
|
||||
|
||||
ipi_status = decision.get("ipi_status")
|
||||
ipi = classification.get("ipi")
|
||||
if ipi_status == "IPI_RESOLVED" and ipi:
|
||||
current_ipi = current.get("ipi")
|
||||
current_ipi_read = "ipi" in current
|
||||
rate_kind = ipi.get("rate_kind")
|
||||
|
||||
if rate_kind == "NUMERIC":
|
||||
proposed_rate = ipi.get("rate_numeric")
|
||||
if not current_ipi_read:
|
||||
status, write_eligible, reason = "CURRENT_UNKNOWN", False, "ERP_CURRENT_VALUE_NOT_READ"
|
||||
elif _same(current_ipi, proposed_rate):
|
||||
status, write_eligible, reason = "SAME_VALUE", False, "TIPI_MATCHES_CURRENT_ERP"
|
||||
else:
|
||||
status, write_eligible, reason = "CHANGE_REQUIRED", True, "TIPI_RATE_DIVERGES_FROM_ERP"
|
||||
elif rate_kind == "NON_NUMERIC":
|
||||
# e.g. TIPI text marker "NT" (não tributado) — never write text into a numeric field.
|
||||
proposed_rate = None
|
||||
rate_classification = ipi.get("rate_classification", "OTHER")
|
||||
status, write_eligible, reason = "NON_NUMERIC_IPI_STATUS", False, f"TIPI_RATE_{rate_classification}"
|
||||
else: # BLANK — source spreadsheet had no rate at all; never invent zero.
|
||||
proposed_rate = None
|
||||
rate_classification = ipi.get("rate_classification", "SOURCE_BLANK")
|
||||
status, write_eligible, reason = rate_classification, False, f"TIPI_RATE_{rate_classification}"
|
||||
|
||||
proposals.append({
|
||||
"product_code": product_code,
|
||||
"field": IPI_FIELD,
|
||||
"current_value": current_ipi,
|
||||
"proposed_value": proposed_rate,
|
||||
"status": status,
|
||||
"reason_code": reason,
|
||||
"source_dataset": "TIPI_IPI",
|
||||
"source_version": dataset_versions.get("tipi_ipi"),
|
||||
"provenance": _provenance(decision),
|
||||
"write_eligible": write_eligible,
|
||||
})
|
||||
|
||||
# C000025 has no EX_TIPI row under IPI_RESOLVED by construction: the matched
|
||||
# TIPI entry is the base (non-EX) row, so there is nothing concrete to propose.
|
||||
|
||||
# EX TIPI only ever comes from EX-specific TIPI rows for this NCM (ipi_candidates).
|
||||
# No EX row for the NCM => no proposal at all (never propose a blank/None EX).
|
||||
ex_candidates = [c for c in classification.get("ipi_candidates", []) if c.get("ex")]
|
||||
if ex_candidates:
|
||||
current_ex = current.get("ex_tipi")
|
||||
current_ex_read = "ex_tipi" in current
|
||||
if len(ex_candidates) == 1:
|
||||
proposed_ex = ex_candidates[0]["ex"]
|
||||
if not current_ex_read:
|
||||
ex_status, ex_write_eligible, ex_reason = "CURRENT_UNKNOWN", False, "ERP_CURRENT_VALUE_NOT_READ"
|
||||
elif _same(current_ex, proposed_ex):
|
||||
ex_status, ex_write_eligible, ex_reason = "SAME_VALUE", False, "TIPI_EX_MATCHES_CURRENT_ERP"
|
||||
else:
|
||||
ex_status, ex_write_eligible, ex_reason = "CHANGE_REQUIRED", True, "TIPI_EX_DIVERGES_FROM_ERP"
|
||||
else:
|
||||
proposed_ex = None
|
||||
ex_status, ex_write_eligible, ex_reason = "CANDIDATE", False, "MULTIPLE_EX_TIPI_CANDIDATES_NO_SINGLE_EVIDENCE"
|
||||
proposals.append({
|
||||
"product_code": product_code,
|
||||
"field": EX_TIPI_FIELD,
|
||||
"current_value": current_ex,
|
||||
"proposed_value": proposed_ex,
|
||||
"status": ex_status,
|
||||
"reason_code": ex_reason,
|
||||
"source_dataset": "TIPI_IPI",
|
||||
"source_version": dataset_versions.get("tipi_ipi"),
|
||||
"provenance": _provenance(decision),
|
||||
"write_eligible": ex_write_eligible,
|
||||
"candidate_count": len(ex_candidates),
|
||||
})
|
||||
|
||||
cest_status = decision.get("cest_status")
|
||||
if cest_status is not None:
|
||||
current_cest = current.get("cest")
|
||||
current_cest_read = "cest" in current
|
||||
if cest_status == "CEST_CONFIRMED":
|
||||
proposed_cest = (classification.get("cest") or [None])[0]
|
||||
if not current_cest_read:
|
||||
status, write_eligible, reason = "CURRENT_UNKNOWN", False, "ERP_CURRENT_VALUE_NOT_READ"
|
||||
elif _same(current_cest, proposed_cest):
|
||||
status, write_eligible, reason = "SAME_VALUE", False, "CEST_MATCHES_CURRENT_ERP"
|
||||
else:
|
||||
status, write_eligible, reason = "CHANGE_REQUIRED", True, "CEST_DIVERGES_FROM_ERP"
|
||||
else:
|
||||
proposed_cest = None
|
||||
write_eligible = False
|
||||
status = "CANDIDATE" if cest_status == "CEST_CANDIDATES" else "UNRESOLVED"
|
||||
reason = _CEST_UNRESOLVED_REASONS.get(cest_status, "CEST_NOT_RESOLVED")
|
||||
proposals.append({
|
||||
"product_code": product_code,
|
||||
"field": CEST_FIELD,
|
||||
"current_value": current_cest,
|
||||
"proposed_value": proposed_cest,
|
||||
"status": status,
|
||||
"reason_code": reason,
|
||||
"source_dataset": "CEST",
|
||||
"source_version": dataset_versions.get("cest"),
|
||||
"provenance": _provenance(decision),
|
||||
"write_eligible": write_eligible,
|
||||
})
|
||||
|
||||
return proposals
|
||||
|
||||
|
||||
def mva_evidence(product_code, decision):
|
||||
"""BA ST/MVA has no ERP field target: keep it as fiscal evidence only, never a write proposal."""
|
||||
classification = decision.get("classification", {})
|
||||
return {
|
||||
"product_code": product_code,
|
||||
"ncm": classification.get("ncm"),
|
||||
"status": decision.get("ba_st_mva_status"),
|
||||
"resolved": classification.get("ba_st_mva"),
|
||||
"candidates": classification.get("ba_st_mva_candidates", []),
|
||||
"source_version": decision.get("dataset_versions", {}).get("ba_st_mva"),
|
||||
}
|
||||
|
||||
|
||||
def split_by_eligibility(proposals):
|
||||
return {
|
||||
"eligible": [p for p in proposals if p.get("write_eligible")],
|
||||
"blocked": [p for p in proposals if not p.get("write_eligible")],
|
||||
}
|
||||
|
||||
|
||||
def execute(proposals):
|
||||
"""Stub executor. Refuses any proposal that is not write_eligible and never
|
||||
issues a write itself — implementing the actual ERP UPDATE is out of scope."""
|
||||
for proposal in proposals:
|
||||
if not proposal.get("write_eligible"):
|
||||
raise PermissionError(
|
||||
f"refused: {proposal['product_code']}/{proposal['field']} is not write_eligible "
|
||||
f"(status={proposal['status']})"
|
||||
)
|
||||
raise NotImplementedError("no ERP write executor is implemented; this planner is dry-run only")
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Product enrichment (new product -> GTIN/name -> fiscal profile -> safe fill). Deterministic only: no AI, no LLM, no embeddings, no paid providers.
|
||||
Everything lives inside the existing Hub/FastAPI/PostgreSQL; providers are hidden behind the Hub (Agents never call them)."""
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Agent-facing product enrichment routes (authenticated as the Agent; the Agent never talks to external providers)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..blocked_products import upsert_blocker
|
||||
from ..config import settings
|
||||
from ..field_contract import WRITE_FIELDS
|
||||
from ..models import Installation, ProductEnrichment, ProductFieldAudit
|
||||
from . import status as enrichment_status, textnorm
|
||||
from .placeholders import is_placeholder
|
||||
from .providers import build_providers
|
||||
from .quota import ProviderGateway
|
||||
from .resolver import FIELDS, resolve
|
||||
|
||||
CLASSIFICATION_CONTEXT_FIELDS = ("CODGRUPO", "TIPO", "PROD_COZINHA", "PRODUTO_SERVICO") # read-only evidence for field_contract.infer_role; never written
|
||||
CURRENT_FIELDS = tuple(dict.fromkeys(FIELDS + tuple(WRITE_FIELDS) + CLASSIFICATION_CONTEXT_FIELDS)) # everything resolve() may read from the Agent's `current`: NCM/CEST/IPI + every fiscal-completion field + role evidence
|
||||
|
||||
|
||||
def sanitize_product_evidence(raw) -> dict | None:
|
||||
"""Bounds the Agent's targeted, single-product evidence (firebird_reader.read_product_evidence) before it reaches field_contract: shape-only
|
||||
validation (never trusts an authenticated Agent to send something huge or malformed for a single-product payload)."""
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
out = {"recipes": set(), "composed": set(), "composed_ratio": {}, "purchases": {}, "stock_entry": {}, "stock_in": {}}
|
||||
for key in ("recipes", "composed"):
|
||||
if isinstance(raw.get(key), list):
|
||||
out[key] = {str(v) for v in raw[key][:8]}
|
||||
if isinstance(raw.get("composed_ratio"), dict):
|
||||
for code, counts in list(raw["composed_ratio"].items())[:8]:
|
||||
if isinstance(counts, dict) and isinstance(counts.get("composed"), int) and isinstance(counts.get("other"), int):
|
||||
out["composed_ratio"][str(code)] = {"composed": max(0, counts["composed"]), "other": max(0, counts["other"])}
|
||||
if isinstance(raw.get("purchases"), dict):
|
||||
for code, docs in list(raw["purchases"].items())[:8]:
|
||||
if isinstance(docs, list):
|
||||
out["purchases"][str(code)] = [d for d in docs[:32] if isinstance(d, dict)]
|
||||
for key in ("stock_entry", "stock_in"):
|
||||
if isinstance(raw.get(key), dict):
|
||||
out[key] = {str(k): bool(v) for k, v in list(raw[key].items())[:8]}
|
||||
return out
|
||||
|
||||
|
||||
PROVIDERS_OVERRIDE = None # tests inject fake providers here (never the network)
|
||||
PENDING_STATES = {"PENDING_PROVIDER_QUOTA", "PENDING_PROVIDERS_OFFLINE", "PENDING_TIME_BUDGET"}
|
||||
REASONS = { # human PT-BR reasons shown through the existing blockers (category ENRICHMENT); never internal jargon
|
||||
"INVALID_GTIN": "Código de barras (GTIN) inválido: o produto não foi consultado em fontes externas.",
|
||||
"AMBIGUOUS": "Descrição genérica demais para identificar o produto com segurança; informe o código de barras.",
|
||||
"NOT_FOUND": "Produto não encontrado nas fontes consultadas; preencha os campos fiscais manualmente.",
|
||||
"CONFLICT": "As fontes divergem sobre a classificação deste produto; nenhuma alteração foi feita.",
|
||||
"SINGLE_WEAK_SOURCE": "Apenas uma fonte identificou o produto; a classificação foi apenas sugerida.",
|
||||
"PENDING_PROVIDER_QUOTA": "Limite diário das fontes de produtos atingido; a consulta será repetida automaticamente.",
|
||||
"PENDING_PROVIDERS_OFFLINE": "Fontes de produtos indisponíveis no momento; a consulta será repetida automaticamente.",
|
||||
}
|
||||
|
||||
|
||||
def _aware(dt):
|
||||
return dt.replace(tzinfo=timezone.utc) if dt is not None and dt.tzinfo is None else dt
|
||||
|
||||
|
||||
def features_of(installation) -> dict:
|
||||
return {"corrector_enabled": bool(installation.corrector_enabled), "enrichment_mode": installation.enrichment_mode, "features_configured": bool(installation.features_configured)}
|
||||
|
||||
|
||||
def router(principal, providers_factory=None):
|
||||
routes = APIRouter(prefix="/products")
|
||||
holder = {}
|
||||
|
||||
def providers():
|
||||
if PROVIDERS_OVERRIDE is not None:
|
||||
return PROVIDERS_OVERRIDE
|
||||
if providers_factory is not None:
|
||||
return providers_factory()
|
||||
if "p" not in holder:
|
||||
holder["p"] = build_providers(settings)
|
||||
return holder["p"]
|
||||
|
||||
@routes.post("/resolve")
|
||||
def resolve_product(payload: dict, identity=Depends(principal)):
|
||||
session, agent = identity
|
||||
try:
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent.id))
|
||||
if installation is None:
|
||||
raise HTTPException(409, "installation not registered")
|
||||
features = features_of(installation)
|
||||
if not features["features_configured"] or installation.enrichment_mode == "DISABLED":
|
||||
return {"state": "FEATURE_DISABLED", "features": features, "fields": {}}
|
||||
code = str(payload.get("product_code") or "")[:64]
|
||||
if not code:
|
||||
raise HTTPException(422, "product_code required")
|
||||
description, current = str(payload.get("description") or "")[:255], payload.get("current") if isinstance(payload.get("current"), dict) else {}
|
||||
started = time.monotonic()
|
||||
result = resolve(session, ProviderGateway(session, providers()), gtin_raw=payload.get("gtin"), description=description, current={k: current.get(k) for k in CURRENT_FIELDS},
|
||||
client_label=f"installation:{installation.id}", budget_s=settings.enrichment_time_budget_seconds,
|
||||
product_code=code, installation=installation, tenant_id=agent.tenant_id, product_evidence=sanitize_product_evidence(payload.get("ev")))
|
||||
latency = int((time.monotonic() - started) * 1000)
|
||||
row = session.scalar(select(ProductEnrichment).where(ProductEnrichment.installation_id == installation.id, ProductEnrichment.product_code == code))
|
||||
if row is None:
|
||||
row = ProductEnrichment(tenant_id=agent.tenant_id, installation_id=installation.id, product_code=code)
|
||||
session.add(row)
|
||||
for field in (row.human_overridden or {}): # a human decision (even deleting the value) is final for this product
|
||||
if field in result["fields"]:
|
||||
result["fields"][field] = {"status": "HUMAN_OVERRIDDEN", "value": None, "rule": None, "kb_status": None, "evidence": [], "reason": "valor alterado por uma pessoa; nunca mais preenchido automaticamente"}
|
||||
pending = result["state"] in PENDING_STATES
|
||||
for name, decision in result["fields"].items(): # explain in the audit trail that a technical placeholder was treated as "not classified yet"
|
||||
if is_placeholder(name, current.get(name)):
|
||||
decision["technical_placeholder"] = str(current.get(name))
|
||||
row.gtin, row.description, row.latency_ms = result.get("gtin"), description, latency
|
||||
row.attempts = (row.attempts or 0) + 1
|
||||
row.ui_status, row.reason_code = enrichment_status.classify(result, has_gtin=bool(result.get("gtin")), specific_name=textnorm.is_specific(description))
|
||||
row.last_attempt_at, row.reevaluate = datetime.now(timezone.utc), False
|
||||
row.status = "PENDING" if pending else "RESOLVED"
|
||||
row.decisions = {"state": result["state"], "fields": {k: {kk: vv for kk, vv in v.items() if kk != "evidence"} for k, v in result["fields"].items()}}
|
||||
row.next_attempt_at = _aware(result["retry_at"]) if pending else None
|
||||
if pending and row.next_attempt_at is None:
|
||||
row.next_attempt_at = datetime.now(timezone.utc).replace(microsecond=0)
|
||||
session.commit()
|
||||
reason = REASONS.get(result["state"])
|
||||
if reason:
|
||||
upsert_blocker(session, agent.tenant_id, {"company": installation.hostname or "", "product_code": code, "gtin": result.get("gtin"), "description": description, "blocker_type": "ENRICHMENT_" + result["state"],
|
||||
"field": "ENRICHMENT", "reason_code": result["state"], "evidence_summary": {"message": reason}, "input_fingerprint": f"{result.get('gtin')}|{description}"}, installation_id=installation.id)
|
||||
result.update({"features": features, "latency_ms": latency, "mode": installation.enrichment_mode})
|
||||
result["retry_at"] = result["retry_at"].isoformat() if result["retry_at"] else None
|
||||
return result
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@routes.post("/enrichment/applied")
|
||||
def applied(payload: dict, identity=Depends(principal)):
|
||||
"""The Agent reports what it wrote / refused. The Hub re-checks that the feature is still ENABLED and audits every field."""
|
||||
session, agent = identity
|
||||
try:
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent.id))
|
||||
if installation is None:
|
||||
raise HTTPException(409, "installation not registered")
|
||||
code = str(payload.get("product_code") or "")[:64]
|
||||
row = session.scalar(select(ProductEnrichment).where(ProductEnrichment.installation_id == installation.id, ProductEnrichment.product_code == code))
|
||||
if row is None:
|
||||
raise HTTPException(404, "unknown product enrichment")
|
||||
versions = payload.get("dataset_versions") if isinstance(payload.get("dataset_versions"), dict) else {}
|
||||
applied_fields, overridden = dict(row.fields_applied or {}), dict(row.human_overridden or {})
|
||||
for item in (payload.get("fields") or [])[:32]:
|
||||
if not isinstance(item, dict) or item.get("field") not in FIELDS:
|
||||
continue
|
||||
outcome = str(item.get("outcome") or "APPLIED")[:24]
|
||||
session.add(ProductFieldAudit(tenant_id=agent.tenant_id, installation_id=installation.id, agent_id=agent.id, product_code=code, gtin=row.gtin, description_snapshot=row.description, field=item["field"],
|
||||
old_value=(str(item["old"])[:64] if item.get("old") is not None else None), new_value=(str(item["new"])[:64] if item.get("new") is not None else None), rule=str(item.get("rule") or "")[:64] or None,
|
||||
kb_status=str(item.get("kb_status") or "")[:16] or None, evidence=item.get("evidence") if isinstance(item.get("evidence"), list) else [], dataset_versions=versions,
|
||||
authorization_origin=f"enrichment_mode:{installation.enrichment_mode}", job_id=payload.get("job_id") if isinstance(payload.get("job_id"), int) else None, outcome=outcome,
|
||||
reason=str(item.get("reason") or "")[:96] or None))
|
||||
if outcome == "APPLIED":
|
||||
applied_fields[item["field"]] = item.get("new")
|
||||
elif outcome == "HUMAN_OVERRIDDEN":
|
||||
overridden[item["field"]] = True
|
||||
row.fields_applied, row.human_overridden = applied_fields, overridden
|
||||
ui, why = enrichment_status.after_write([i for i in (payload.get("fields") or []) if isinstance(i, dict)], installation.enrichment_mode)
|
||||
if ui:
|
||||
row.ui_status, row.reason_code = ui, why
|
||||
row.reevaluate = False
|
||||
row.status = "APPLIED" if applied_fields and not overridden else ("PARTIAL" if applied_fields else row.status if row.status != "PENDING" else "RESOLVED")
|
||||
row.updated_at = datetime.now(timezone.utc)
|
||||
session.commit()
|
||||
return {"ok": True, "status": row.status, "mode": installation.enrichment_mode}
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@routes.get("/enrichment/pending")
|
||||
def pending_reevaluation(identity=Depends(principal)):
|
||||
"""Tracked products flagged for a new evaluation (panel button, REPROCESS/SYNC, KB decision). The Agent polls this cheaply and re-runs resolve for each code."""
|
||||
session, agent = identity
|
||||
try:
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent.id))
|
||||
if installation is None or not installation.features_configured or installation.enrichment_mode == "DISABLED":
|
||||
return {"codes": []}
|
||||
codes = list(session.scalars(select(ProductEnrichment.product_code).where(ProductEnrichment.installation_id == installation.id, ProductEnrichment.reevaluate.is_(True)).order_by(ProductEnrichment.id).limit(25)))
|
||||
return {"codes": codes}
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@routes.get("/enrichment/authorization")
|
||||
def authorization(identity=Depends(principal)):
|
||||
"""Fresh central authorization read by the Agent right before EVERY field write."""
|
||||
session, agent = identity
|
||||
try:
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent.id))
|
||||
return features_of(installation) if installation is not None else {"corrector_enabled": False, "enrichment_mode": "DISABLED", "features_configured": False}
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
return routes
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Gradual, quota-safe verification of locally CORROBORATED GTINs by an NCM-bearing free provider (OSCBR). Never spends the operational reserve.
|
||||
|
||||
Rules: only CORROBORATED single-NCM GTINs (never VERIFIED_KB, never CONFLICT, never an invalid GTIN); each GTIN is checked once per `RECHECK_DAYS` (next_checked_at kept in the KB evidence, plus the
|
||||
provider cache); a provider answer is EVIDENCE only - the promotion/conflict decision is kb.evaluate's (local strong evidence + independent provider NCM + vigente + zero conflict).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..models import KbKnowledge, KbProduct
|
||||
from . import kb
|
||||
from .gtin import normalize_gtin
|
||||
|
||||
RECHECK_DAYS = 30
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def select_sample(session, is_official, limit: int, provider: str = "oscbr") -> list[dict]:
|
||||
"""Priority: multi-client, more documents, more years; current-vigente NCM; zero conflict; not checked recently."""
|
||||
ids = list(session.scalars(select(KbKnowledge.kb_product_id).where(KbKnowledge.knowledge_key == "ncm", KbKnowledge.status == kb.CORROBORATED).distinct()))
|
||||
by_product: dict[int, list] = {}
|
||||
for start in range(0, len(ids), 500): # every NCM row of those products: a second (even weaker) value already disqualifies the GTIN
|
||||
for r in session.scalars(select(KbKnowledge).where(KbKnowledge.knowledge_key == "ncm", KbKnowledge.kb_product_id.in_(ids[start:start + 500]))):
|
||||
by_product.setdefault(r.kb_product_id, []).append(r)
|
||||
now = _now().isoformat()
|
||||
picks = []
|
||||
for pid, items in by_product.items():
|
||||
valid = [i for i in items if is_official(i.value)]
|
||||
if len(valid) != 1 or valid[0].status != kb.CORROBORATED:
|
||||
continue # conflicts / no vigente NCM / already verified are not asked
|
||||
row = valid[0]
|
||||
info = (row.evidence or {}).get("info") or {}
|
||||
if info.get(f"{provider}_next_checked_at", "") > now:
|
||||
continue
|
||||
prod = session.get(KbProduct, pid)
|
||||
if not prod or normalize_gtin(prod.gtin[1:] if prod.gtin and prod.gtin.startswith("0") else prod.gtin)["status"] != "VALID_GTIN":
|
||||
continue
|
||||
summary = kb._lineage_summary([row])
|
||||
if any(f.startswith("PROVIDER:" + provider) for f in ((row.evidence or {}).get("families") or {})):
|
||||
continue
|
||||
picks.append({"gtin_key": prod.gtin, "ncm": row.value, "clients": len(summary["clients"]), "docs": summary["docs"], "years": len(summary["years"]), "product_id": pid})
|
||||
picks.sort(key=lambda p: (-p["clients"], -p["docs"], -p["years"], p["gtin_key"]))
|
||||
return picks[:limit]
|
||||
|
||||
|
||||
def apply_outcome(session, gtin_key: str, provider: str, outcome, is_official) -> dict:
|
||||
"""Turns one gateway Outcome into KB evidence. Returns the per-GTIN measurement record."""
|
||||
product = kb.find_by_gtin(session, gtin_key)
|
||||
rec = {"gtin": gtin_key[-13:] if gtin_key.startswith("0") else gtin_key, "state": outcome.state, "reason": outcome.reason, "found": False, "ncm": None, "cest": None, "description": None, "brand": None,
|
||||
"local_ncm": None, "official_valid": None, "kb_status": None, "external_conflict": False}
|
||||
local_rows = list(session.scalars(select(KbKnowledge).where(KbKnowledge.kb_product_id == product.id, KbKnowledge.knowledge_key == "ncm"))) if product else []
|
||||
rec["local_ncm"] = local_rows[0].value if local_rows else None
|
||||
stamp = (_now() + timedelta(days=RECHECK_DAYS)).isoformat()
|
||||
if outcome.state in ("FOUND", "CACHE_HIT") and outcome.product:
|
||||
p = outcome.product
|
||||
rec.update({"found": True, "ncm": p.ncm, "cest": p.cest, "description": p.title, "brand": p.brand})
|
||||
if p.ncm:
|
||||
rec["official_valid"] = bool(is_official(p.ncm))
|
||||
kb.add_evidence(session, product, "ncm", p.ncm, f"provider:{provider}"[:32], family=f"PROVIDER:{provider}", info={"raw_hash": p.raw_hash})
|
||||
if p.cest:
|
||||
kb.add_evidence(session, product, "cest", p.cest, f"provider:{provider}"[:32], family=f"PROVIDER:{provider}", info={"raw_hash": p.raw_hash})
|
||||
if p.brand and not product.brand:
|
||||
product.brand = p.brand
|
||||
if p.title:
|
||||
kb.add_alias(session, product, p.title.upper()[:200])
|
||||
ev = kb.evaluate(session, product, "ncm", is_official)
|
||||
rec["kb_status"] = ev["status"]
|
||||
rec["external_conflict"] = bool(ev["status"] == kb.CONFLICT and rec["ncm"])
|
||||
if outcome.state in ("FOUND", "CACHE_HIT", "NOT_FOUND"): # remember the check so it is not repeated (skipped/errors are retried later)
|
||||
for r in session.scalars(select(KbKnowledge).where(KbKnowledge.kb_product_id == product.id, KbKnowledge.knowledge_key == "ncm")):
|
||||
r.evidence = {**(r.evidence or {}), "info": {**((r.evidence or {}).get("info") or {}), f"{provider}_next_checked_at": stamp}}
|
||||
return rec
|
||||
|
||||
|
||||
def provider_query(key: str) -> str:
|
||||
"""Canonical form sent to providers: GTIN-8 stays 8 digits, UPC-12/EAN-13 as 13 digits (same rule as the resolver), GTIN-14 with an indicator digit stays 14."""
|
||||
if len(key) == 14 and key.startswith("000000"):
|
||||
return key[-8:]
|
||||
if len(key) == 14 and key.startswith("0"):
|
||||
return key[1:]
|
||||
return key
|
||||
|
||||
|
||||
def run(session, gateway, provider, *, limit: int, reserve_percent: int, is_official, dry_run: bool = False, wait_rate_window: bool = True) -> dict:
|
||||
"""One backfill pass. dry_run only lists the sample (no request)."""
|
||||
sample = select_sample(session, is_official, limit, provider.name)
|
||||
report = {"provider": provider.name, "sample_size": len(sample), "requests": 0, "records": [], "stopped": None, "dry_run": dry_run}
|
||||
if dry_run:
|
||||
report["records"] = [{k: v for k, v in s.items() if k != "product_id"} for s in sample]
|
||||
return report
|
||||
for item in sample:
|
||||
query = provider_query(item["gtin_key"])
|
||||
outcome = gateway.lookup(provider, query, "BACKFILL", reserve_percent)
|
||||
while outcome.state == "SKIPPED" and outcome.reason == "RATE_WINDOW" and wait_rate_window: # per-minute governor: wait for the window, never exceed it
|
||||
time.sleep(max(1, min(65, int((outcome.retry_at - _now()).total_seconds()) + 1)) if outcome.retry_at else 12)
|
||||
outcome = gateway.lookup(provider, query, "BACKFILL", reserve_percent)
|
||||
if outcome.state == "SKIPPED":
|
||||
report["stopped"] = outcome.reason # quota / reserve / per-minute window: stop, never insist
|
||||
break
|
||||
if not outcome.from_cache:
|
||||
report["requests"] += 1
|
||||
report["records"].append(apply_outcome(session, item["gtin_key"], provider.name, outcome, is_official))
|
||||
session.commit()
|
||||
return report
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Post-NCM fiscal completion: wires the EXISTING, previously-dormant `central.app.field_contract.resolve_product` (the real field-by-field fiscal
|
||||
contract engine - CFOP, CST, CSOSN, PIS/COFINS, CST_IPI, SITUACAO_TRIBUTARIA, IAT, IPPT, ALIQUOTA...) into the live Product Enrichment resolve call.
|
||||
|
||||
No second fiscal engine. Everything here only ASSEMBLES the same inputs `resolve_product` already expects (`company`, `refs`, `ev`, `ctx`) from data
|
||||
that already exists on the Hub, and maps its per-field CHANGE/ALREADY/NOT_APPLICABLE/MANUAL output onto the enrichment's own AUTO_FILL_SAFE vocabulary
|
||||
so the SAME typed-write Agent pipeline can apply it. `company` comes straight from `Installation.company_uf/company_crt` (auto-discovered from the
|
||||
Agent's own heartbeat - zero guessing). `ctx` comes straight from `FiscalContextEntry` (the SAME table the existing blocker-resolution workflow
|
||||
already writes to when an operator answers a question) - so a missing key surfaces as an ordinary blocker the operator resolves the usual way; no
|
||||
new configuration UI is created. `ev` (purchase/recipe/stock evidence) is not available from a single ad-hoc product resolve; those fields fall back
|
||||
to MANUAL exactly as `field_contract` already does when evidence is absent - never a guess.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..classification import normalize_code
|
||||
from ..datasets.service import DatasetService
|
||||
from ..models import Dataset, FiscalContextEntry
|
||||
from .. import field_contract as fc
|
||||
|
||||
# Fields this module MAY auto-fill. Deliberately excludes CLASSIFICACAO_FISCAL/CEST/IPI (owned by the enrichment resolver itself),
|
||||
# COD_ORIGEM_MERCADORIA (no evidence source exists per the contract - never inferred from CST), EX_TIPI (interacts with the IPI EX choice,
|
||||
# left for a dedicated pass) and CCLASSTRIB (RTC's own domain, untouched).
|
||||
FISCAL_COMPLETION_FIELDS = ("SITUACAO_TRIBUTARIA", "CFOP", "CFOP_ENTRADA", "CST", "CST_ENTRADA", "CSOSN", "ALIQUOTA", "CST_IPI",
|
||||
"COD_ENTRADA_PIS", "COD_SAIDA_PIS", "ALIQ_PIS", "COD_ENTRADA_COFINS", "COD_SAIDA_COFINS", "ALIQ_COFINS",
|
||||
"INCIDENCIA_PISCOFINS", "PISCOFINS", "IAT", "IPPT")
|
||||
|
||||
# Value shape per field (validated BOTH here, before proposing a write, and again by the Agent right before the typed write).
|
||||
DIGIT_FIELDS = {"CFOP": 4, "CFOP_ENTRADA": 4, "CST": 3, "CST_ENTRADA": 3, "CSOSN": 3, "CST_IPI": 3, "COD_ENTRADA_PIS": 2, "COD_SAIDA_PIS": 2,
|
||||
"COD_ENTRADA_COFINS": 2, "COD_SAIDA_COFINS": 2}
|
||||
NUMERIC_FIELDS = ("ALIQUOTA",)
|
||||
DECIMAL_TEXT_FIELDS = ("ALIQ_PIS", "ALIQ_COFINS")
|
||||
ENUM_FIELDS = {"SITUACAO_TRIBUTARIA": {"T", "I", "N", "S"}, "IAT": {"A", "T"}, "IPPT": {"P", "T"}, "PISCOFINS": {"N", "S"},
|
||||
"INCIDENCIA_PISCOFINS": {"NENHUMA", "MONOFASICA", "SUBST.TRIBUT."}}
|
||||
|
||||
|
||||
def is_writable_shape(field: str, value) -> bool:
|
||||
text = "" if value is None else str(value).strip()
|
||||
if not text:
|
||||
return False
|
||||
if field in DIGIT_FIELDS:
|
||||
digits = "".join(c for c in text if c.isdigit())
|
||||
return digits == text and len(digits) in (DIGIT_FIELDS[field], DIGIT_FIELDS[field] + 1) # CST/CSOSN family sometimes carries an origin digit prefix
|
||||
if field in NUMERIC_FIELDS or field in DECIMAL_TEXT_FIELDS:
|
||||
try:
|
||||
float(text.replace(",", "."))
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
if field in ENUM_FIELDS:
|
||||
return text.upper() in ENUM_FIELDS[field]
|
||||
return False
|
||||
|
||||
|
||||
def build_company(installation) -> dict:
|
||||
"""Auto-discovered, zero guessing: the Agent already reports these at every heartbeat (company_contribuinte_ipi from C000004.CONTRIBUINTE_IPI,
|
||||
the same row/query as company_crt - see company_context.py on the Agent). Omits "contribuinte_ipi" entirely when the Agent hasn't reported it
|
||||
yet (older Agent, or the field just never resolved): resolve_fiscal_fields treats that as genuinely unknown, never as a proven "N"."""
|
||||
company = {"uf": (installation.company_uf or "").upper() or None, "crt": installation.company_crt or None}
|
||||
if installation.company_contribuinte_ipi in ("S", "N"):
|
||||
company["contribuinte_ipi"] = installation.company_contribuinte_ipi
|
||||
return company
|
||||
|
||||
|
||||
def build_refs(session, *, uf: str | None, cfop_candidates: set[str]) -> dict:
|
||||
ds = DatasetService(session)
|
||||
refs = {key: {r.code for r in ds.fiscal_codes(domain)} for key, domain in
|
||||
(("csosn", "CSOSN"), ("cst_icms", "CST_ICMS"), ("cst_pis", "CST_PIS"), ("cst_cofins", "CST_COFINS"), ("origem", "ORIGEM_MERCADORIA"))}
|
||||
refs["cfop_valid"] = {c for c in cfop_candidates if ds.cfop_lookup(code=c)}
|
||||
refs["fcp_uf"] = {}
|
||||
if uf:
|
||||
fcp = ds.fcp_for_uf(uf)
|
||||
if fcp:
|
||||
refs["fcp_uf"][uf] = {"aliq_1": fcp.aliq_1, "observacao": fcp.observacao}
|
||||
refs["cbs_rate_2026"] = (ds.rtc_cbs_rate(2026).rate if ds.rtc_cbs_rate(2026) else None)
|
||||
refs["cclasstrib_count"] = session.scalar(select(Dataset.record_count).where(Dataset.kind == "RTC_CCLASSTRIB", Dataset.status == "ACTIVE")) or 0
|
||||
return refs
|
||||
|
||||
|
||||
def build_context(session, tenant_id: int, company_key: str) -> fc.Context:
|
||||
"""SAME table the existing blocker-resolution workflow (`central/app/blocker_resolution.py`) already writes operator answers into."""
|
||||
rows = session.scalars(select(FiscalContextEntry).where(FiscalContextEntry.tenant_id == tenant_id, FiscalContextEntry.company_key == company_key)).all()
|
||||
return fc.Context([{"scope": r.scope, "scope_key": r.scope_key, "key": r.key, "value": r.value} for r in rows])
|
||||
|
||||
|
||||
def resolve_fiscal_fields(session, *, product_code: str, description: str, current: dict, ncm: str, ncm_official: bool, installation, tenant_id: int,
|
||||
extra_columns: dict | None = None, decision: dict | None = None, ev: dict | None = None) -> dict:
|
||||
"""Returns {field: {status, value, rule, reason}} for FISCAL_COMPLETION_FIELDS, in the enrichment's own vocabulary (AUTO_FILL_SAFE / ALREADY_SET /
|
||||
NOT_APPLICABLE / NEEDS_REVIEW / INSUFFICIENT_EVIDENCE). Never called for a product whose NCM is not yet an official, vigente code.
|
||||
|
||||
`decision` is the SAME dict `classification.Classifier.classify(...)` already returns for CEST/IPI (resolver.py's `_fiscal_decisions`) - reused
|
||||
as-is (its `classification.ipi_candidates`/`cest_candidates`/`ba_st_mva` keys are exactly what `resolve_product` reads), never recomputed here."""
|
||||
if not ncm_official:
|
||||
return {f: {"status": "INSUFFICIENT_EVIDENCE", "value": None, "rule": None, "reason": "depende de um NCM oficial vigente"} for f in FISCAL_COMPLETION_FIELDS}
|
||||
ncm = normalize_code(ncm)
|
||||
p = {"CODIGO": product_code, "PRODUTO": description, "CODGRUPO": None, "TIPO": None, "PROD_COZINHA": None, "PRODUTO_SERVICO": None, "CLASSIFICACAO_FISCAL": ncm}
|
||||
p.update({k: v for k, v in (extra_columns or {}).items() if k in ("CODGRUPO", "TIPO", "PROD_COZINHA", "PRODUTO_SERVICO")})
|
||||
if str(p.get("PROD_COZINHA") or "").strip().isdigit(): # the Agent sends `current` as JSON strings (e.g. "1"); infer_role compares the real Firebird SMALLINT (== 1)
|
||||
p["PROD_COZINHA"] = int(p["PROD_COZINHA"])
|
||||
for f in fc.WRITE_FIELDS:
|
||||
p.setdefault(f, current.get(f))
|
||||
company = build_company(installation)
|
||||
company_key = (installation.company_code or installation.company_name or installation.customer_code or "").strip()
|
||||
ctx = build_context(session, tenant_id, company_key)
|
||||
if "contribuinte_ipi" not in company: # ERP fact (heartbeat) always wins; the operator answer is only a fallback for an Agent that hasn't reported it yet
|
||||
contribuinte_ipi, _ = ctx.get("contribuinte_ipi")
|
||||
if contribuinte_ipi in ("S", "N"):
|
||||
company["contribuinte_ipi"] = contribuinte_ipi
|
||||
cfop_candidates = {str(current.get(f)).strip() for f in ("CFOP", "CFOP_ENTRADA") if current.get(f)}
|
||||
refs = build_refs(session, uf=company.get("uf"), cfop_candidates=cfop_candidates)
|
||||
# Agent-provided, product_code-targeted evidence (recipes/composed/purchases/stock - see firebird_reader.read_product_evidence): a real,
|
||||
# indexed per-product query, never a full-table scan or a guess. Falls back to "no evidence" only when the Agent hasn't sent it yet.
|
||||
ev = ev if isinstance(ev, dict) else {"recipes": set(), "composed": set(), "composed_ratio": {}, "purchases": {}, "stock_entry": {}, "stock_in": {}}
|
||||
d = decision or {"status": "EXACT_NCM", "cest_status": "NO_CEST_MAPPING_FOUND", "ba_st_mva_status": None, "classification": {"cest_candidates": [], "ipi_candidates": [], "ba_st_mva": {}}}
|
||||
raw = fc.resolve_product(p, d, company, refs, ev, ctx)
|
||||
out = {}
|
||||
for field in FISCAL_COMPLETION_FIELDS:
|
||||
r = raw.get(field) or {}
|
||||
state = r.get("state")
|
||||
if state == fc.ALREADY:
|
||||
out[field] = {"status": "ALREADY_SET" if not _blank(current.get(field)) else "NOT_APPLICABLE", "value": None, "rule": r.get("reason"), "reason": r.get("reason")}
|
||||
elif state == fc.NA and field == "CST_IPI" and "contribuinte_ipi" not in company:
|
||||
# field_contract's own NA branch for CST_IPI fires whenever company.get("contribuinte_ipi") != "S" - which is also true when the fact is
|
||||
# simply UNKNOWN. Never let an unknown fact read as a proven "not an IPI taxpayer": surface it as a missing answer instead.
|
||||
out[field] = {"status": "NEEDS_REVIEW", "value": None, "rule": None, "reason": "contribuinte de IPI não confirmado", "missing_key": "contribuinte_ipi", "missing_scope": "COMPANY"}
|
||||
elif state == fc.NA:
|
||||
out[field] = {"status": "NOT_APPLICABLE", "value": None, "rule": r.get("reason"), "reason": r.get("reason")}
|
||||
elif state == fc.CHANGE and _blank(current.get(field)) and is_writable_shape(field, r.get("proposed")):
|
||||
out[field] = {"status": "AUTO_FILL_SAFE", "value": str(r.get("proposed")), "rule": r.get("reason"), "reason": None}
|
||||
elif state == fc.CHANGE:
|
||||
out[field] = {"status": "NEEDS_REVIEW", "value": None, "rule": r.get("reason"), "reason": "valor proposto não seguro para escrita automática" if not _blank(current.get(field)) else "formato do valor proposto não confere"}
|
||||
else: # MANUAL
|
||||
out[field] = {"status": "NEEDS_REVIEW", "value": None, "rule": None, "reason": r.get("reason"), "missing_key": r.get("input_key"), "missing_scope": r.get("input_scope")}
|
||||
return out
|
||||
|
||||
|
||||
def _blank(v) -> bool:
|
||||
return v is None or str(v).strip() == ""
|
||||
|
||||
|
||||
def coverage_status(field_statuses: dict) -> str:
|
||||
"""COMPLETE when every field is filled/already-correct/not-applicable; SAFE_BLOCKED when at least one relevant field still needs a decision."""
|
||||
return "SAFE_BLOCKED" if any(v["status"] in ("NEEDS_REVIEW", "INSUFFICIENT_EVIDENCE") for v in field_statuses.values()) else "COMPLETE"
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Deterministic GTIN normalization and validation (GTIN-8, UPC/GTIN-12, EAN/GTIN-13, GTIN-14)."""
|
||||
import re
|
||||
|
||||
VALID, INVALID, MISSING, RESTRICTED = "VALID_GTIN", "INVALID_GTIN", "MISSING_GTIN", "RESTRICTED_GTIN"
|
||||
|
||||
|
||||
def check_digit_ok(digits: str) -> bool:
|
||||
body, check = digits[:-1], int(digits[-1])
|
||||
total = sum(int(d) * (3 if i % 2 == 0 else 1) for i, d in enumerate(reversed(body)))
|
||||
return (10 - total % 10) % 10 == check
|
||||
|
||||
|
||||
def normalize_gtin(raw) -> dict:
|
||||
"""Returns {status, gtin (digits as typed), key (zero-padded to 14: the KB identity), reason}. Never calls anything external.
|
||||
RESTRICTED: EAN prefixes 02, 04 and 20-29 are store-internal / restricted circulation (not a global product identity), so no provider is asked."""
|
||||
text = "" if raw is None else str(raw).strip()
|
||||
if not text or text.upper() in ("SEM GTIN", "SEM EAN", "0", "NULL"):
|
||||
return {"status": MISSING, "gtin": None, "key": None, "reason": "EMPTY"}
|
||||
digits = re.sub(r"\D", "", text)
|
||||
if not digits or len(digits) not in (8, 12, 13, 14):
|
||||
return {"status": INVALID, "gtin": digits or None, "key": None, "reason": "BAD_LENGTH"}
|
||||
if len(set(digits)) == 1:
|
||||
return {"status": INVALID, "gtin": digits, "key": None, "reason": "REPEATED_DIGITS"}
|
||||
if not check_digit_ok(digits):
|
||||
return {"status": INVALID, "gtin": digits, "key": None, "reason": "BAD_CHECK_DIGIT"}
|
||||
key = digits.zfill(14)
|
||||
prefix = key[1:3] if len(digits) == 14 else key[1:3] # key = 0 + EAN-13 layout for 12/13; GTIN-14 keeps its indicator digit in key[0]
|
||||
ean13 = key[1:]
|
||||
if ean13[:2] in ("02", "04") or "20" <= ean13[:2] <= "29":
|
||||
return {"status": RESTRICTED, "gtin": digits, "key": key, "reason": "STORE_INTERNAL_PREFIX"}
|
||||
return {"status": VALID, "gtin": digits, "key": key, "reason": None}
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Historical ERP evidence (product master + NFC-e/NF-e items) -> central KB evidence. Local data only: NO provider is contacted here.
|
||||
|
||||
Evidence model
|
||||
- One 'lineage' per client per (GTIN, value): master-data flag, fiscal-document count and documents per year. Master data and the fiscal documents of the SAME ERP are one family (CLIENT_DATA):
|
||||
the documents were issued from that very master, so they are never counted as two independent sources.
|
||||
- CLIENT_DATA alone can reach CORROBORATED (>= 20 documents over >= 2 years, or >= 2 distinct clients) but never VERIFIED_KB: that needs an independent NCM-bearing source (kb.evaluate).
|
||||
- NCM validity is judged against the ACTIVE official dataset today: CURRENT_VALID / HISTORICAL_EXPIRED / HISTORICAL_INVALID; a different NCM at least as recent -> HISTORICAL_CHANGED; two current values -> CONFLICT.
|
||||
- Company-specific tax data (CRT, CFOP, CSOSN, ...) is never read nor stored; only GTIN, descriptions/aliases, NCM, CEST and the source tenant label.
|
||||
Idempotent: a client's lineage for a (GTIN, value) is REPLACED on every import, never accumulated.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections import defaultdict
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..models import KbKnowledge, KbProduct
|
||||
from . import kb, textnorm
|
||||
from .gtin import normalize_gtin
|
||||
|
||||
|
||||
def _digits(v) -> str:
|
||||
return re.sub(r"\D", "", str(v or ""))
|
||||
|
||||
|
||||
def build_observations(stagings: list[dict]) -> dict:
|
||||
"""stagings: miner output per client. Returns {"gtins": {key: {...}}, "stats": {...}}."""
|
||||
gtins: dict[str, dict] = {}
|
||||
stats = defaultdict(int)
|
||||
for st in stagings:
|
||||
client = st["client"]
|
||||
product_gtin: dict[str, set] = defaultdict(set)
|
||||
master_gtin: dict[str, str] = {}
|
||||
master_desc: dict[str, str] = {}
|
||||
for m in st["master"]:
|
||||
stats["master_rows"] += 1
|
||||
master_desc[m["code"]] = m["desc"]
|
||||
for raw in (m["gtin"], m["codbarra"]):
|
||||
g = normalize_gtin(raw)
|
||||
if g["status"] == "VALID_GTIN":
|
||||
master_gtin.setdefault(m["code"], g["key"])
|
||||
break
|
||||
# product<->EAN links seen in documents/movements are used ONLY when the master has none, and only when unambiguous
|
||||
seen: dict[str, set] = defaultdict(set)
|
||||
for row in st.get("nfe", []):
|
||||
g = normalize_gtin(row["codbarra"])
|
||||
if g["status"] == "VALID_GTIN":
|
||||
seen[row["code"]].add(g["key"])
|
||||
for row in st.get("barcodes", []):
|
||||
g = normalize_gtin(row["codbarra"])
|
||||
if g["status"] == "VALID_GTIN":
|
||||
seen[row["code"]].add(g["key"])
|
||||
link: dict[str, str] = dict(master_gtin)
|
||||
for code, keys in seen.items():
|
||||
if code in link:
|
||||
continue
|
||||
if len(keys) == 1:
|
||||
link[code] = next(iter(keys))
|
||||
stats["links_from_documents"] += 1
|
||||
else:
|
||||
stats["ambiguous_product_links_skipped"] += 1
|
||||
|
||||
def slot(key):
|
||||
return gtins.setdefault(key, {"aliases": set(), "ncm": defaultdict(lambda: defaultdict(lambda: {"master": 0, "docs": 0, "years": {}})), "cest": defaultdict(lambda: defaultdict(lambda: {"master": 0, "docs": 0, "years": {}})), "clients": set()})
|
||||
for m in st["master"]:
|
||||
key = link.get(m["code"])
|
||||
if not key:
|
||||
continue
|
||||
s = slot(key)
|
||||
s["clients"].add(client)
|
||||
if m["desc"]:
|
||||
s["aliases"].add(textnorm.normalized(m["desc"]))
|
||||
n = _digits(m["ncm"])
|
||||
if len(n) == 8:
|
||||
s["ncm"][n][client]["master"] = 1
|
||||
c = _digits(m["cest"])
|
||||
if len(c) == 7:
|
||||
s["cest"][c][client]["master"] = 1
|
||||
for row in st.get("nfce", []):
|
||||
key = link.get(row["code"])
|
||||
stats["nfce_groups"] += 1
|
||||
if not key:
|
||||
stats["nfce_groups_without_gtin"] += 1
|
||||
continue
|
||||
s = slot(key)
|
||||
n = _digits(row["ncm"])
|
||||
if len(n) == 8:
|
||||
lin = s["ncm"][n][client]
|
||||
lin["docs"] += row["n"]
|
||||
if row["year"]:
|
||||
lin["years"][str(row["year"])] = lin["years"].get(str(row["year"]), 0) + row["n"]
|
||||
stats["nfce_documents"] += row["n"]
|
||||
c = _digits(row["cest"])
|
||||
if len(c) == 7:
|
||||
lin = s["cest"][c][client]
|
||||
lin["docs"] += row["n"]
|
||||
if row["year"]:
|
||||
lin["years"][str(row["year"])] = lin["years"].get(str(row["year"]), 0) + row["n"]
|
||||
for row in st.get("nfe", []):
|
||||
key = link.get(row["code"])
|
||||
if not key:
|
||||
continue
|
||||
s = slot(key)
|
||||
if row["desc"]:
|
||||
s["aliases"].add(textnorm.normalized(row["desc"]))
|
||||
n = _digits(row["ncm"])
|
||||
if len(n) == 8:
|
||||
s["ncm"][n][client]["docs"] += row["n"]
|
||||
stats["nfe_documents"] += row["n"]
|
||||
stats["gtins"] = len(gtins)
|
||||
return {"gtins": gtins, "stats": dict(stats)}
|
||||
|
||||
|
||||
def apply(session, observations: dict, is_official, *, dry_run: bool = True, chunk: int = 500) -> dict:
|
||||
"""Bulk, idempotent import into kb_products / kb_knowledge. dry_run=True computes every status and rolls back."""
|
||||
keys = sorted(observations["gtins"])
|
||||
report = {"GTINS_FOUND": len(keys), "OBSERVATIONS": 0, "by_status": defaultdict(int), "CONFLICTS": 0, "WOULD_VERIFY": 0, "with_current_valid_ncm": 0, "stable_across_years": 0, "changed_over_time": 0,
|
||||
"with_historical_ncm": 0, "multi_client": 0}
|
||||
nested = session.begin_nested()
|
||||
try:
|
||||
for start in range(0, len(keys), chunk):
|
||||
part = keys[start:start + chunk]
|
||||
existing = {p.gtin: p for p in session.scalars(select(KbProduct).where(KbProduct.gtin.in_(part)))}
|
||||
for key in part:
|
||||
obs = observations["gtins"][key]
|
||||
product = existing.get(key)
|
||||
if product is None:
|
||||
alias = next(iter(sorted(obs["aliases"])), "")
|
||||
product = kb.upsert_product(session, key, alias)
|
||||
existing[key] = product
|
||||
for a in sorted(obs["aliases"])[:20]:
|
||||
kb.add_alias(session, product, a)
|
||||
fp = textnorm.fingerprint(a)
|
||||
if fp:
|
||||
kb.add_evidence(session, product, "name_fingerprint", fp, "IDENTITY", family="IDENTITY", status=kb.OBSERVED)
|
||||
for field, values in (("ncm", obs["ncm"]), ("cest", obs["cest"])):
|
||||
for value, per_client in values.items():
|
||||
for client, lin in per_client.items():
|
||||
kb.add_evidence(session, product, field, value, "client:history", family=kb.CLIENT_FAMILY, client_label=client, lineage=None)
|
||||
row = session.scalar(select(KbKnowledge).where(KbKnowledge.kb_product_id == product.id, KbKnowledge.knowledge_key == field, KbKnowledge.value == value))
|
||||
ev = dict(row.evidence or {})
|
||||
lineages = dict(ev.get("lineages") or {})
|
||||
lineages[client] = {"master": lin["master"], "docs": lin["docs"], "years": dict(lin["years"])} # REPLACE: idempotent
|
||||
row.evidence = {**ev, "lineages": lineages}
|
||||
report["OBSERVATIONS"] += 1
|
||||
session.flush()
|
||||
for key in part:
|
||||
product = existing[key]
|
||||
ev = kb.evaluate(session, product, "ncm", is_official)
|
||||
report["by_status"][ev["status"]] += 1
|
||||
if ev["status"] == kb.CONFLICT:
|
||||
report["CONFLICTS"] += 1
|
||||
if ev["status"] == kb.VERIFIED_KB:
|
||||
report["WOULD_VERIFY"] += 1
|
||||
if ev["value"]:
|
||||
report["with_current_valid_ncm"] += 1
|
||||
if observations["gtins"][key]["ncm"]:
|
||||
report["with_historical_ncm"] += 1
|
||||
if len(observations["gtins"][key]["clients"]) >= 2:
|
||||
report["multi_client"] += 1
|
||||
rows = list(session.scalars(select(KbKnowledge).where(KbKnowledge.kb_product_id == product.id, KbKnowledge.knowledge_key == "ncm")))
|
||||
states = {(r.evidence.get("info") or {}).get("historical_state") for r in rows}
|
||||
if "HISTORICAL_STABLE_CURRENT" in states and ev["value"]:
|
||||
years = set()
|
||||
for r in rows:
|
||||
for lin in (r.evidence.get("lineages") or {}).values():
|
||||
years |= set(lin.get("years") or {})
|
||||
if len(years) >= 2:
|
||||
report["stable_across_years"] += 1
|
||||
if "HISTORICAL_CHANGED" in states or ("HISTORICAL_EXPIRED" in states and ev["value"]):
|
||||
report["changed_over_time"] += 1
|
||||
if dry_run:
|
||||
nested.rollback()
|
||||
else:
|
||||
nested.commit()
|
||||
session.commit()
|
||||
except Exception:
|
||||
nested.rollback()
|
||||
raise
|
||||
report["by_status"] = dict(report["by_status"])
|
||||
return report
|
||||
|
||||
|
||||
def official_sets(session):
|
||||
"""Precomputed 'vigente today' membership: one query instead of one per value."""
|
||||
from datetime import date
|
||||
from ..models import Dataset, NcmEntry
|
||||
from .resolver import _parse_br
|
||||
today = date.today()
|
||||
current = set()
|
||||
for row in session.scalars(select(NcmEntry).join(Dataset).where(Dataset.kind == "NCM", Dataset.status == "ACTIVE")):
|
||||
start, end = _parse_br(row.valid_from), _parse_br(row.valid_to)
|
||||
if (start is None or start <= today) and (end is None or end >= today):
|
||||
current.add(row.code)
|
||||
return current
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Central product Knowledge Base access (same PostgreSQL, existing kb_* tables) with per-field status.
|
||||
|
||||
Trust model (docs/product_enrichment.md):
|
||||
- A provider value or a client observation is EVIDENCE. A field becomes VERIFIED_KB only when the official dataset confirms it AND at least two INDEPENDENT evidence
|
||||
families agree with zero conflict (distinct providers each count once; client observations count once and only with >= 2 distinct clients).
|
||||
- Human edits never enter the KB (HUMAN_OVERRIDDEN is per-installation protection, not knowledge). Company-specific fiscal decisions (CSOSN, CFOP, ...) never live here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from sqlalchemy import func
|
||||
|
||||
from ..models import KbKnowledge, KbProduct, KbPromotionAudit
|
||||
from . import textnorm
|
||||
|
||||
UNVERIFIED, OBSERVED, CORROBORATED, EXTERNAL_CORROBORATED, VERIFIED_KB, CONFLICT, STALE = "UNVERIFIED", "OBSERVED", "CORROBORATED", "EXTERNAL_CORROBORATED", "VERIFIED_KB", "CONFLICT", "STALE"
|
||||
EVIDENCE_ONLY = OBSERVED # legacy name
|
||||
CLIENT_FAMILY = "CLIENT_DATA" # every client-derived lineage (master data + fiscal documents of the same ERP are NOT independent of each other)
|
||||
RULE_VERSION = "kb-promotion-v2"
|
||||
STRONG_DOCS, STRONG_YEARS = 20, 2
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def gtin_fingerprint(key: str) -> str:
|
||||
return hashlib.sha256(f"gtin:{key}".encode()).hexdigest()
|
||||
|
||||
|
||||
def find_by_gtin(session, key: str | None) -> KbProduct | None:
|
||||
return session.scalar(select(KbProduct).where(KbProduct.gtin == key)) if key else None
|
||||
|
||||
|
||||
NEUTRAL_EXTRAS = frozenset({"REFRIGERANTE", "REFRIG", "REFR", "BEBIDA", "PRODUTO", "PROD"}) # category nouns only: ZERO / DIET / a flavour / another size are NEVER neutral
|
||||
|
||||
|
||||
def _tokset(text) -> frozenset:
|
||||
return frozenset(t for t in textnorm.tokens(text) if t not in textnorm.MARKETING)
|
||||
|
||||
|
||||
def alias_match(query: frozenset, alias: frozenset) -> bool:
|
||||
"""Deterministic, conservative: same token set, or one set contains the other and the difference is only neutral category nouns (so "COCA COLA LATA 350 ML" = "REFRIGERANTE COCA COLA LATA 350 ML", but not "... ZERO ...")."""
|
||||
if not query or not alias:
|
||||
return False
|
||||
if query == alias:
|
||||
return True
|
||||
if query < alias:
|
||||
return (alias - query) <= NEUTRAL_EXTRAS
|
||||
if alias < query:
|
||||
return (query - alias) <= NEUTRAL_EXTRAS
|
||||
return False
|
||||
|
||||
|
||||
def name_candidates(session, description) -> list[KbProduct]:
|
||||
"""Every KB product with an alias that matches the description under `alias_match` (exact fingerprint included). 0, 1 or several - the caller decides (unique VERIFIED only)."""
|
||||
fp = textnorm.fingerprint(description)
|
||||
if not fp:
|
||||
return []
|
||||
ids = set(session.scalars(select(KbKnowledge.kb_product_id).where(KbKnowledge.knowledge_key == "name_fingerprint", KbKnowledge.value == fp).distinct()))
|
||||
query = _tokset(description)
|
||||
anchor = max((t for t in query if not t.replace(".", "").isdigit() and len(t) > 2), key=len, default=None) # most selective informative token, then verify in Python
|
||||
if anchor:
|
||||
for row in session.execute(select(KbKnowledge.kb_product_id, KbKnowledge.value).where(KbKnowledge.knowledge_key == "alias", KbKnowledge.value.contains(anchor))):
|
||||
if alias_match(query, _tokset(row[1])):
|
||||
ids.add(row[0])
|
||||
return [p for p in (session.get(KbProduct, i) for i in sorted(ids)) if p]
|
||||
|
||||
|
||||
def upsert_product(session, key: str | None, description, *, brand=None, quantity=None) -> KbProduct:
|
||||
if key:
|
||||
from ..gtin_validator import validate_gtin
|
||||
valid, _ = validate_gtin(key)
|
||||
if not valid:
|
||||
key = None # Regra C.6: GTIN inválido (DV ou prefixo errado) não entra na KB
|
||||
product = find_by_gtin(session, key)
|
||||
if product is None:
|
||||
fp = gtin_fingerprint(key) if key else hashlib.sha256(f"name:{textnorm.fingerprint(description)}".encode()).hexdigest()
|
||||
product = session.scalar(select(KbProduct).where(KbProduct.fingerprint == fp))
|
||||
if product is None:
|
||||
product = KbProduct(fingerprint=fp, gtin=key, identity_kind="GTIN" if key else "FINGERPRINT", normalized_description=textnorm.normalized(description))
|
||||
session.add(product)
|
||||
session.flush()
|
||||
if description and not product.normalized_description:
|
||||
product.normalized_description = textnorm.normalized(description)
|
||||
if brand and not product.brand:
|
||||
product.brand = brand
|
||||
if quantity and not product.package_quantity:
|
||||
product.package_quantity = str(quantity)[:32]
|
||||
fp = textnorm.fingerprint(description)
|
||||
norm = textnorm.normalized(description)
|
||||
if norm:
|
||||
add_alias(session, product, norm)
|
||||
if fp:
|
||||
add_evidence(session, product, "name_fingerprint", fp, "IDENTITY", family="IDENTITY", status=OBSERVED)
|
||||
return product
|
||||
|
||||
|
||||
def add_alias(session, product: KbProduct, normalized: str, cap: int = 20):
|
||||
"""Non-fiscal aliases of the same GTIN (descriptions seen over the years). Helps name matching; never authority for a fiscal write."""
|
||||
if not normalized or session.scalar(select(KbKnowledge).where(KbKnowledge.kb_product_id == product.id, KbKnowledge.knowledge_key == "alias", KbKnowledge.value == normalized[:200])):
|
||||
return
|
||||
if (session.scalar(select(func.count()).select_from(KbKnowledge).where(KbKnowledge.kb_product_id == product.id, KbKnowledge.knowledge_key == "alias")) or 0) >= cap:
|
||||
return
|
||||
session.add(KbKnowledge(kb_product_id=product.id, knowledge_key="alias", value=normalized[:200], source="ALIAS", status=OBSERVED, evidence={"families": {}, "clients": [], "info": {}}))
|
||||
|
||||
|
||||
def add_evidence(session, product: KbProduct, key: str, value: str, source: str, *, family: str, client_label: str | None = None, status: str = OBSERVED, info: dict | None = None,
|
||||
lineage: dict | None = None) -> KbKnowledge:
|
||||
"""lineage (client data only): {"master": 0/1, "docs": n, "years": {"2024": n}} of ONE client for this (GTIN, value); merged, never double counted."""
|
||||
row = session.scalar(select(KbKnowledge).where(KbKnowledge.kb_product_id == product.id, KbKnowledge.knowledge_key == key, KbKnowledge.value == value))
|
||||
if row is None:
|
||||
row = KbKnowledge(kb_product_id=product.id, knowledge_key=key, value=value, source=source[:32], status=status, evidence={"families": {}, "clients": [], "lineages": {}, "info": info or {}})
|
||||
session.add(row)
|
||||
session.flush()
|
||||
evidence = dict(row.evidence or {})
|
||||
families = dict(evidence.get("families") or {})
|
||||
families[family] = _now().isoformat()
|
||||
clients = list(evidence.get("clients") or [])
|
||||
if client_label and client_label not in clients:
|
||||
clients.append(client_label)
|
||||
lineages = dict(evidence.get("lineages") or {})
|
||||
if client_label and lineage:
|
||||
cur = dict(lineages.get(client_label) or {"master": 0, "docs": 0, "years": {}})
|
||||
cur["master"] = max(int(cur.get("master", 0)), int(lineage.get("master", 0)))
|
||||
cur["docs"] = int(cur.get("docs", 0)) + int(lineage.get("docs", 0))
|
||||
years = dict(cur.get("years") or {})
|
||||
for y, n in (lineage.get("years") or {}).items():
|
||||
years[str(y)] = int(years.get(str(y), 0)) + int(n)
|
||||
cur["years"] = years
|
||||
lineages[client_label] = cur
|
||||
row.evidence = {**evidence, "families": families, "clients": clients, "lineages": lineages, "info": {**(evidence.get("info") or {}), **(info or {})}}
|
||||
return row
|
||||
|
||||
|
||||
def _lineage_summary(rows):
|
||||
clients, docs, years = set(), 0, {}
|
||||
for r in rows:
|
||||
ev = r.evidence or {}
|
||||
for c, lin in (ev.get("lineages") or {}).items():
|
||||
clients.add(c)
|
||||
docs += int(lin.get("docs", 0))
|
||||
for y, n in (lin.get("years") or {}).items():
|
||||
years[y] = years.get(y, 0) + int(n)
|
||||
for c in ev.get("clients") or []:
|
||||
clients.add(c)
|
||||
return {"clients": sorted(clients), "docs": docs, "years": sorted(years), "last_year": max(int(y) for y in years) if years else None}
|
||||
|
||||
|
||||
def independent_count(rows) -> tuple[int, list[str]]:
|
||||
"""Independent families: each NCM-bearing provider = 1; ALL client-derived data = 1 and only when STRONG (>= STRONG_DOCS documents over >= STRONG_YEARS years, or >= 2 distinct clients)."""
|
||||
names = sorted({f for r in rows for f in ((r.evidence or {}).get("families") or {}) if f.startswith("PROVIDER:")})
|
||||
summary = _lineage_summary(rows)
|
||||
has_client = any(CLIENT_FAMILY in ((r.evidence or {}).get("families") or {}) for r in rows)
|
||||
if has_client and (len(summary["clients"]) >= 2 or (summary["docs"] >= STRONG_DOCS and len(summary["years"]) >= STRONG_YEARS)):
|
||||
names.append(CLIENT_FAMILY)
|
||||
return len(names), names
|
||||
|
||||
|
||||
def _official_period(session, value):
|
||||
"""CURRENT_VALID / HISTORICAL_EXPIRED (exists in the dataset but not vigente today) / HISTORICAL_INVALID (never in the dataset)."""
|
||||
from .resolver import ncm_exists_any
|
||||
return "CURRENT_VALID" if ncm_exists_any(session, value, current=True) else ("HISTORICAL_EXPIRED" if ncm_exists_any(session, value, current=False) else "HISTORICAL_INVALID")
|
||||
|
||||
|
||||
def _mark(row, state):
|
||||
row.evidence = {**(row.evidence or {}), "info": {**((row.evidence or {}).get("info") or {}), "historical_state": state}}
|
||||
|
||||
|
||||
OPERATOR_FAMILY = "OPERATOR_ATTESTATION"
|
||||
|
||||
|
||||
def attest(session, gtin_key: str, ncm: str, operator: str, is_official, note: str = "") -> dict:
|
||||
"""A human fiscal decision by an ADMIN (audited by the caller). Only a value that is ALREADY a candidate in the KB (some local/provider evidence) and vigente in the official dataset can be attested;
|
||||
the dissenting candidates become STALE (REJECTED_BY_OPERATOR) and the product is promoted with the promotion audit naming the operator."""
|
||||
product = find_by_gtin(session, gtin_key)
|
||||
if product is None:
|
||||
raise ValueError("GTIN_UNKNOWN_IN_KB")
|
||||
row = session.scalar(select(KbKnowledge).where(KbKnowledge.kb_product_id == product.id, KbKnowledge.knowledge_key == "ncm", KbKnowledge.value == ncm))
|
||||
if row is None:
|
||||
raise ValueError("NCM_IS_NOT_A_CANDIDATE_OF_THIS_GTIN")
|
||||
if not is_official(ncm):
|
||||
raise ValueError("NCM_NOT_VIGENTE_IN_OFFICIAL_DATASET")
|
||||
add_evidence(session, product, "ncm", ncm, f"operator:{operator}"[:32], family=OPERATOR_FAMILY, info={"attested_by": operator, "attested_at": _now().isoformat(), "note": note[:200]})
|
||||
for other in session.scalars(select(KbKnowledge).where(KbKnowledge.kb_product_id == product.id, KbKnowledge.knowledge_key == "ncm", KbKnowledge.value != ncm)):
|
||||
if OPERATOR_FAMILY not in ((other.evidence or {}).get("families") or {}):
|
||||
other.status = STALE
|
||||
_mark(other, "REJECTED_BY_OPERATOR")
|
||||
return evaluate(session, product, "ncm", is_official)
|
||||
|
||||
|
||||
def evaluate(session, product: KbProduct, key: str, is_official) -> dict:
|
||||
"""Recomputes and PERSISTS the status of one field. Never 'majority wins': two current-valid values => CONFLICT."""
|
||||
rows = list(session.scalars(select(KbKnowledge).where(KbKnowledge.kb_product_id == product.id, KbKnowledge.knowledge_key == key)))
|
||||
if not rows:
|
||||
return {"value": None, "status": UNVERIFIED, "families": [], "candidates": [], "rejected": [], "detail": {}}
|
||||
valid = [r for r in rows if is_official(r.value)]
|
||||
rejected = [r.value for r in rows if not is_official(r.value)]
|
||||
for r in rows:
|
||||
if r not in valid:
|
||||
state = _official_period(session, r.value) if key == "ncm" else "HISTORICAL_INVALID"
|
||||
r.status = STALE if state == "HISTORICAL_EXPIRED" else OBSERVED
|
||||
_mark(r, state)
|
||||
attested = {r.value for r in valid if OPERATOR_FAMILY in ((r.evidence or {}).get("families") or {})}
|
||||
if len(attested) == 1: # a human decision resolves the candidates: the others are recorded as rejected, not "outvoted"
|
||||
winner = next(iter(attested))
|
||||
for r in valid:
|
||||
if r.value != winner:
|
||||
r.status = STALE
|
||||
_mark(r, "REJECTED_BY_OPERATOR")
|
||||
valid = [r for r in valid if r.value == winner]
|
||||
values = sorted({r.value for r in valid})
|
||||
if len(values) > 1:
|
||||
for r in valid:
|
||||
r.status = CONFLICT
|
||||
_mark(r, "HISTORICAL_CONFLICT")
|
||||
return {"value": None, "status": CONFLICT, "families": [], "candidates": values, "rejected": rejected, "detail": {"clients": _lineage_summary(valid)["clients"]}}
|
||||
if not values:
|
||||
return {"value": None, "status": OBSERVED, "families": [], "candidates": [], "rejected": rejected, "detail": {}}
|
||||
chosen = [r for r in valid if r.value == values[0]]
|
||||
old = chosen[0].status
|
||||
count, names = independent_count(chosen)
|
||||
summary = _lineage_summary(chosen)
|
||||
other_last = _lineage_summary([r for r in rows if r not in chosen])["last_year"]
|
||||
superseded = other_last is not None and summary["last_year"] is not None and other_last >= summary["last_year"] # a different (expired) NCM is at least as recent: not stable
|
||||
if superseded and CLIENT_FAMILY in names:
|
||||
names.remove(CLIENT_FAMILY)
|
||||
count -= 1
|
||||
if any(OPERATOR_FAMILY in ((r.evidence or {}).get("families") or {}) for r in chosen):
|
||||
names = sorted(set(names) | {OPERATOR_FAMILY} | ({CLIENT_FAMILY} if any(CLIENT_FAMILY in ((r.evidence or {}).get("families") or {}) for r in chosen) else set()))
|
||||
count = len(names)
|
||||
providers = [n for n in names if n.startswith("PROVIDER:")]
|
||||
already = any(r.status == VERIFIED_KB and r.validated_at for r in chosen)
|
||||
has_client_data = any(CLIENT_FAMILY in ((r.evidence or {}).get("families") or {}) for r in chosen)
|
||||
if count >= 2 or already:
|
||||
status = VERIFIED_KB
|
||||
elif providers and has_client_data:
|
||||
status = EXTERNAL_CORROBORATED
|
||||
elif CLIENT_FAMILY in names:
|
||||
status = CORROBORATED
|
||||
else:
|
||||
status = OBSERVED
|
||||
for r in chosen:
|
||||
r.status = status
|
||||
_mark(r, "HISTORICAL_CHANGED" if superseded else "HISTORICAL_STABLE_CURRENT")
|
||||
if status == VERIFIED_KB and not r.validated_at:
|
||||
r.validated_at = _now()
|
||||
if status == VERIFIED_KB and old != VERIFIED_KB and key == "ncm":
|
||||
from .resolver import dataset_versions
|
||||
session.add(KbPromotionAudit(kb_product_id=product.id, gtin=product.gtin, field=key, value=values[0], old_status=old, new_status=status, families=names,
|
||||
sources={"clients": summary["clients"], "documents": summary["docs"], "years": summary["years"], "providers": providers,
|
||||
"operator": next((((r.evidence or {}).get("info") or {}).get("attested_by") for r in chosen if OPERATOR_FAMILY in ((r.evidence or {}).get("families") or {})), None)},
|
||||
official_dataset=dataset_versions(session).get("NCM", {}), rule_version=RULE_VERSION))
|
||||
return {"value": values[0], "status": status, "families": names, "candidates": values, "rejected": rejected, "detail": summary}
|
||||
|
||||
|
||||
def revalidate(session, is_official) -> dict:
|
||||
"""After the OFFICIAL NCM dataset changed: never delete knowledge. Rows whose NCM is no longer vigente become STALE (STALE_REVIEW_REQUIRED, audited when they were VERIFIED_KB);
|
||||
rows still vigente keep their status (VERIFIED_KB stays valid while the NCM is vigente)."""
|
||||
from .resolver import dataset_versions
|
||||
stale = still = 0
|
||||
versions = dataset_versions(session).get("NCM", {})
|
||||
for row in session.scalars(select(KbKnowledge).where(KbKnowledge.knowledge_key == "ncm")):
|
||||
if is_official(row.value):
|
||||
still += 1
|
||||
if row.status == STALE:
|
||||
row.status = OBSERVED # became vigente again: needs a fresh evaluate() to regain its level
|
||||
continue
|
||||
if row.status != STALE:
|
||||
was = row.status
|
||||
row.status = STALE
|
||||
_mark(row, "HISTORICAL_EXPIRED")
|
||||
row.evidence = {**(row.evidence or {}), "info": {**((row.evidence or {}).get("info") or {}), "stale_reason": "STALE_REVIEW_REQUIRED"}}
|
||||
stale += 1
|
||||
if was == VERIFIED_KB:
|
||||
product = session.get(KbProduct, row.kb_product_id)
|
||||
session.add(KbPromotionAudit(kb_product_id=row.kb_product_id, gtin=product.gtin if product else None, field="ncm", value=row.value, old_status=was, new_status=STALE,
|
||||
families=sorted((row.evidence or {}).get("families") or {}), sources={"reason": "NCM_NO_LONGER_VIGENTE"}, official_dataset=versions, rule_version=RULE_VERSION))
|
||||
session.flush()
|
||||
return {"stale": stale, "still_vigente": still}
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Read-only views for the panel: product sources status, official dataset freshness and enrichment coverage metrics. Never exposes tokens."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from ..config import settings
|
||||
from ..datasets.updater import last_runs
|
||||
from ..models import Dataset, GtinLookupCache, ProductEnrichment, ProviderState, ProviderUsageDaily
|
||||
from .providers import build_providers
|
||||
|
||||
STATUS_LABELS = {"ONLINE": "Online", "DEGRADED": "Instável", "RATE_LIMITED": "Limite atingido", "AUTH_ERROR": "Credencial recusada", "OFFLINE": "Indisponível", "NOT_CONFIGURED": "Não configurado"}
|
||||
OFFICIAL_KINDS = (("NCM", "NCM (Siscomex)"), ("TIPI_IPI", "TIPI / IPI"), ("CEST", "CEST (CONFAZ)"), ("BA_ST_MVA", "ST/MVA Bahia"))
|
||||
|
||||
|
||||
def _aware(dt):
|
||||
return dt.replace(tzinfo=timezone.utc) if dt is not None and dt.tzinfo is None else dt
|
||||
|
||||
|
||||
def provider_sources(session, providers=None) -> list[dict]:
|
||||
providers = providers or build_providers(settings)
|
||||
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
rows = []
|
||||
for provider in providers:
|
||||
state = session.get(ProviderState, provider.name)
|
||||
usage = session.scalar(select(ProviderUsageDaily).where(ProviderUsageDaily.provider == provider.name, ProviderUsageDaily.day == today))
|
||||
requests = usage.requests if usage else 0
|
||||
lookups = (usage.cache_hits + usage.cache_misses) if usage else 0
|
||||
status = ("NOT_CONFIGURED" if not provider.configured else (state.status if state else "ONLINE"))
|
||||
rows.append({
|
||||
"provider": provider.name, "configured": bool(provider.configured), "status": status, "status_label": STATUS_LABELS.get(status, status),
|
||||
"used": requests, "limit": provider.daily_limit or (state.daily_limit if state else None), "rate_per_minute": provider.rate_per_minute,
|
||||
"remaining": state.remaining if state and state.remaining is not None else ((provider.daily_limit - requests) if provider.daily_limit else None),
|
||||
"quota_kind": state.quota_kind if state else ("LOCAL_ESTIMATE" if provider.daily_limit else "DOCUMENTED_RATE"),
|
||||
"reset_at": _aware(state.reset_at) if state and state.reset_at else None,
|
||||
"last_success_at": _aware(state.last_success_at) if state and state.last_success_at else None,
|
||||
"last_error": state.last_error if state else None, "last_error_at": _aware(state.last_error_at) if state and state.last_error_at else None,
|
||||
"latency_ms": state.last_latency_ms if state else None,
|
||||
"cache_hit_rate": round(100 * usage.cache_hits / lookups) if usage and lookups else None,
|
||||
"errors": usage.errors if usage else 0, "rate_limited": usage.rate_limited if usage else 0,
|
||||
"found_today": usage.found if usage else 0, "ncm_found_today": usage.ncm_found if usage else 0, "cache_hits": usage.cache_hits if usage else 0,
|
||||
"next_reset": _aware(state.reset_at) if state and state.reset_at else None, "identity_only": not getattr(provider, "provides_ncm", False),
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def official_sources(session) -> list[dict]:
|
||||
out = []
|
||||
for kind, label in OFFICIAL_KINDS:
|
||||
ds = session.scalar(select(Dataset).where(Dataset.kind == kind, Dataset.status == "ACTIVE").order_by(Dataset.id.desc()).limit(1))
|
||||
item = {"kind": kind, "label": label, "active": ds is not None, "records": ds.record_count if ds else None, "version": ds.version_label if ds else None,
|
||||
"sha256": (ds.sha256[:12] if ds else None), "last_update": _aware(ds.downloaded_at) if ds else None, "status": "ATUALIZADA" if ds else "SEM DADOS", "error": None, "auto_refresh": kind == "NCM"}
|
||||
if kind == "NCM":
|
||||
recent = last_runs(session, "NCM", 1)
|
||||
if recent:
|
||||
last = recent[0]
|
||||
item["last_check"], item["last_run_status"] = _aware(last.finished_at or last.started_at), last.status
|
||||
if last.status == "FAILED":
|
||||
item["status"], item["error"] = "FALHA NA ÚLTIMA ATUALIZAÇÃO (dados anteriores mantidos)", last.error
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
def coverage_metrics(session) -> dict:
|
||||
total = session.scalar(select(func.count()).select_from(ProductEnrichment)) or 0
|
||||
by = dict(session.execute(select(ProductEnrichment.status, func.count()).group_by(ProductEnrichment.status)).all())
|
||||
with_gtin = session.scalar(select(func.count()).select_from(ProductEnrichment).where(ProductEnrichment.gtin.is_not(None))) or 0
|
||||
rows = [r.decisions or {} for r in session.scalars(select(ProductEnrichment).limit(5000))]
|
||||
states = {}
|
||||
for d in rows:
|
||||
states[d.get("state")] = states.get(d.get("state"), 0) + 1
|
||||
|
||||
def rate(n):
|
||||
return round(100 * n / total, 1) if total else None
|
||||
auto_ncm = sum(1 for d in rows if ((d.get("fields") or {}).get("CLASSIFICACAO_FISCAL") or {}).get("status") == "AUTO_FILL_SAFE")
|
||||
usage = session.execute(select(func.sum(ProviderUsageDaily.requests), func.sum(ProviderUsageDaily.cache_hits), func.sum(ProviderUsageDaily.cache_misses), func.sum(ProviderUsageDaily.rate_limited))).one()
|
||||
requests, hits, misses, limited = (int(x or 0) for x in usage)
|
||||
return {"tracked": total, "GTIN_VALID_RATE": rate(with_gtin), "KB_HIT_RATE": rate(states.get("EXACT_GTIN_VERIFIED", 0) + states.get("EXACT_KB_NAME_VERIFIED", 0)),
|
||||
"EXTERNAL_LOOKUP_RATE": round(100 * requests / (requests + hits), 1) if (requests + hits) else None,
|
||||
"PRODUCT_FOUND_RATE": rate(total - states.get("NOT_FOUND", 0) - states.get("INVALID_GTIN", 0) - states.get("AMBIGUOUS", 0)),
|
||||
"NCM_AUTO_FILL_SAFE_RATE": rate(auto_ncm), "NAME_ONLY_RESOLUTION_RATE": rate(states.get("EXACT_KB_NAME_VERIFIED", 0)), "AMBIGUOUS_RATE": rate(states.get("AMBIGUOUS", 0)),
|
||||
"CONFLICT_RATE": rate(states.get("CONFLICT", 0)), "PROVIDER_QUOTA_BLOCK_RATE": round(100 * limited / requests, 1) if requests else None, "states": states, "by_status": by,
|
||||
"provider_cache_entries": session.scalar(select(func.count()).select_from(GtinLookupCache)) or 0, "provider_cache_hit_rate": round(100 * hits / (hits + misses), 1) if (hits + misses) else None}
|
||||
|
||||
|
||||
def _candidate_cards(session, products) -> list[dict]:
|
||||
from ..models import Dataset, KbKnowledge, NcmEntry
|
||||
out = []
|
||||
for prod in products:
|
||||
rows = list(session.scalars(select(KbKnowledge).where(KbKnowledge.kb_product_id == prod.id, KbKnowledge.knowledge_key == "ncm")))
|
||||
cands = []
|
||||
for r in rows:
|
||||
ev = r.evidence or {}
|
||||
text = session.scalar(select(NcmEntry.description).join(Dataset).where(Dataset.kind == "NCM", Dataset.status == "ACTIVE", NcmEntry.code == r.value).limit(1))
|
||||
cands.append({"ncm": r.value, "official_text": text, "status": r.status, "clients": sorted(ev.get("clients", [])), "documents": sum(int(l.get("docs", 0)) for l in (ev.get("lineages") or {}).values()),
|
||||
"providers": sorted(f for f in (ev.get("families") or {}) if f.startswith("PROVIDER:")), "attested": (ev.get("info") or {}).get("attested_by")})
|
||||
status = "VERIFIED_KB" if any(c["status"] == "VERIFIED_KB" for c in cands) else ("CONFLICT" if any(c["status"] == "CONFLICT" for c in cands) else (cands[0]["status"] if cands else "OBSERVED"))
|
||||
out.append({"gtin": (prod.gtin or "")[-13:], "gtin_key": prod.gtin, "description": (prod.normalized_description or "")[:70], "status": status, "candidates": [c for c in cands if c["status"] != "STALE" or c["attested"] is None][:4]})
|
||||
return out
|
||||
|
||||
|
||||
def kb_search_ids(session, query: str) -> list[int]:
|
||||
"""GLOBAL, server-side match over the whole KB (never limited to a rendered page): GTIN exact, or every normalized query word present somewhere among the
|
||||
product's known names (any alias row OR its own normalized_description - words can come from different aliases of the same product). Deliberately lenient
|
||||
(a human exploring the KB, e.g. "COCA COLA LATA 350" without the unit, must still find "COCA-COLA ORIGINAL LATA 350 ML"); this is DIFFERENT from
|
||||
`kb.alias_match`, the strict rule that authorizes an automatic fiscal write, which stays untouched."""
|
||||
import re
|
||||
|
||||
from ..models import KbKnowledge, KbProduct
|
||||
from . import kb as kbm, textnorm
|
||||
q = (query or "").strip()
|
||||
if not q:
|
||||
return []
|
||||
digits = "".join(ch for ch in q if ch.isdigit())
|
||||
if len(digits) >= 8 and re.fullmatch(r"[\d\s.\-]+", q):
|
||||
p = kbm.find_by_gtin(session, digits.zfill(14))
|
||||
return [p.id] if p else []
|
||||
tokens = [t for t in textnorm.tokens(q) if t not in textnorm.MARKETING]
|
||||
if not tokens:
|
||||
return []
|
||||
ids: set[int] | None = None
|
||||
for token in tokens: # SQL ILIKE narrows the candidates (portable: Postgres in production, SQLite in tests); the exact whole-word
|
||||
like = f"%{token}%" # check happens in Python so "ML" never matches inside "SIMPLES", etc.
|
||||
matched = {pid for pid, val in session.execute(select(KbKnowledge.kb_product_id, KbKnowledge.value).where(KbKnowledge.knowledge_key == "alias", KbKnowledge.value.ilike(like))) if token in (val or "").split()}
|
||||
matched |= {pid for pid, val in session.execute(select(KbProduct.id, KbProduct.normalized_description).where(KbProduct.normalized_description.ilike(like))) if token in (val or "").split()}
|
||||
ids = matched if ids is None else (ids & matched)
|
||||
if not ids:
|
||||
return []
|
||||
return sorted(ids)
|
||||
|
||||
|
||||
def kb_search(session, query: str, *, page: int = 1, page_size: int = 10) -> dict:
|
||||
"""query/filter GLOBAL over the whole KB -> count -> order -> pagination (never the other way round)."""
|
||||
from ..models import KbProduct
|
||||
ids = kb_search_ids(session, query)
|
||||
total = len(ids)
|
||||
page = max(1, page)
|
||||
pages = max(1, -(-total // page_size))
|
||||
page = min(page, pages)
|
||||
page_ids = ids[(page - 1) * page_size: page * page_size]
|
||||
products = [session.get(KbProduct, i) for i in page_ids]
|
||||
return {"results": _candidate_cards(session, products), "total": total, "page": page, "pages": pages, "page_size": page_size}
|
||||
|
||||
|
||||
def kb_summary(session, query: str | None = None, page: int = 1) -> dict:
|
||||
"""Compact Knowledge Base panel: known products, statuses of the NCM knowledge, hits today, and the latest conflicts with their evidence (no dashboard of its own)."""
|
||||
from ..models import KbKnowledge, KbProduct
|
||||
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
products = session.scalar(select(func.count()).select_from(KbProduct).where(KbProduct.gtin.is_not(None))) or 0
|
||||
by = dict(session.execute(select(KbKnowledge.status, func.count(func.distinct(KbKnowledge.kb_product_id))).where(KbKnowledge.knowledge_key == "ncm").group_by(KbKnowledge.status)).all())
|
||||
hits = session.scalar(select(func.coalesce(func.sum(ProviderUsageDaily.kb_hits), 0)).where(ProviderUsageDaily.day == today)) or 0
|
||||
conflicts = []
|
||||
rows = session.scalars(select(KbKnowledge).where(KbKnowledge.knowledge_key == "ncm", KbKnowledge.status == "CONFLICT").order_by(KbKnowledge.id.desc()).limit(60)).all()
|
||||
grouped = {}
|
||||
for r in rows:
|
||||
grouped.setdefault(r.kb_product_id, []).append(r)
|
||||
for pid, items in list(grouped.items())[:5]:
|
||||
prod = session.get(KbProduct, pid)
|
||||
conflicts.append({"gtin": (prod.gtin or "")[-13:], "description": (prod.normalized_description or "")[:60],
|
||||
"candidates": [{"ncm": i.value, "clients": sorted({c for c in (i.evidence or {}).get("clients", [])}), "documents": sum(int(l.get("docs", 0)) for l in ((i.evidence or {}).get("lineages") or {}).values()),
|
||||
"providers": sorted(f for f in ((i.evidence or {}).get("families") or {}) if f.startswith("PROVIDER:"))} for i in items]})
|
||||
return {"products": products, "verified": by.get("VERIFIED_KB", 0), "corroborated": by.get("CORROBORATED", 0) + by.get("EXTERNAL_CORROBORATED", 0), "observed": by.get("OBSERVED", 0),
|
||||
"conflicts": by.get("CONFLICT", 0), "stale": by.get("STALE", 0), "hits_today": hits, "recent_conflicts": conflicts,
|
||||
"search": kb_search(session, query, page=page) if query else {"results": [], "total": 0, "page": 1, "pages": 1, "page_size": 10}}
|
||||
@@ -0,0 +1,27 @@
|
||||
"""ERP observations -> KB evidence. A client observation is EVIDENCE of family CLIENT_OBSERVATION; it never becomes VERIFIED_KB by itself
|
||||
(needs the official dataset AND a second independent family, or >= 2 distinct clients + another family - see kb.evaluate)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from ..classification import normalize_code
|
||||
from . import kb
|
||||
from .gtin import normalize_gtin
|
||||
|
||||
|
||||
def observe(session, rows, client_label: str, is_official) -> dict:
|
||||
"""rows: iterable of {"GTIN","PRODUTO","NCM"}. Only VALID_GTIN rows with an OFFICIAL 8-digit NCM are recorded. Returns counters."""
|
||||
stats = {"rows": 0, "valid_gtin": 0, "with_official_ncm": 0, "recorded": 0}
|
||||
for row in rows:
|
||||
stats["rows"] += 1
|
||||
g = normalize_gtin(row.get("GTIN"))
|
||||
if g["status"] != "VALID_GTIN":
|
||||
continue
|
||||
stats["valid_gtin"] += 1
|
||||
ncm = normalize_code(row.get("NCM"))
|
||||
if len(ncm) != 8 or not is_official(ncm):
|
||||
continue
|
||||
stats["with_official_ncm"] += 1
|
||||
product = kb.upsert_product(session, g["key"], row.get("PRODUTO"))
|
||||
kb.add_evidence(session, product, "ncm", ncm, "client:observation", family=kb.CLIENT_FAMILY, client_label=client_label)
|
||||
stats["recorded"] += 1
|
||||
session.flush()
|
||||
return stats
|
||||
@@ -0,0 +1,27 @@
|
||||
"""TECHNICAL_PLACEHOLDER semantics for the product enrichment.
|
||||
|
||||
The SGV refuses to save a product without an NCM, so operators type a technical filler to get past the form. That filler is NOT a human fiscal decision: it means "not classified yet".
|
||||
Explicit allowlist PER FIELD (never generalised: "0", "99999999", "SEM NCM" ... are NOT placeholders until the real SGV behaviour is confirmed). The Agent carries the same table
|
||||
(agent/src/gti_fiscal_agent/enrichment.py PLACEHOLDERS); a test asserts both are identical.
|
||||
"""
|
||||
import re
|
||||
|
||||
PRODUCT_ENRICHMENT_PLACEHOLDERS = {"CLASSIFICACAO_FISCAL": frozenset({"00000000"})}
|
||||
|
||||
|
||||
def _norm(field: str, value) -> str:
|
||||
text = "" if value is None else str(value).strip()
|
||||
return re.sub(r"\D", "", text) if field == "CLASSIFICACAO_FISCAL" else text # 0000.00.00 and 00000000 are the same NCM filler
|
||||
|
||||
|
||||
def is_placeholder(field: str, value) -> bool:
|
||||
return _norm(field, value) in PRODUCT_ENRICHMENT_PLACEHOLDERS.get(field, frozenset())
|
||||
|
||||
|
||||
def is_blank(value) -> bool:
|
||||
return not ("" if value is None else str(value)).strip()
|
||||
|
||||
|
||||
def is_writable_empty(field: str, value) -> bool:
|
||||
"""True when the field is empty OR holds the approved technical placeholder. A real value (even a wrong one) is never writable here."""
|
||||
return is_blank(value) or is_placeholder(field, value)
|
||||
@@ -0,0 +1,338 @@
|
||||
"""Free product-identity providers behind the Hub. Agents never talk to these; the Hub does, through the quota manager.
|
||||
|
||||
Documentation snapshots consulted 2026-09-25 (see docs/product_enrichment.md):
|
||||
- UPCitemdb Explorer/trial: https://api.upcitemdb.com/prod/trial/lookup?upc=... - no key, 100 combined requests/day, burst 15 lookup / 5 search per 30 s,
|
||||
headers X-RateLimit-Limit / -Remaining / -Reset. Product IDENTITY only (title/brand/category); no fiscal data.
|
||||
- Open Food Facts: https://world.openfoodfacts.org/api/v2/product/{barcode} - 15 read req/min/IP, 10 search req/min/IP, identifiable User-Agent, ODbL data. Foods only; no fiscal data.
|
||||
- Cosmos / Bluesoft: https://api.cosmos.bluesoft.com.br/gtins/{gtin} - X-Cosmos-Token (free token, 25 queries/day), returns NCM/CEST/brand/description. Optional: without a token it is NOT_CONFIGURED.
|
||||
A provider result is EVIDENCE. Only the fiscal engine + official datasets decide what is written.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import httpx
|
||||
|
||||
USER_AGENT = "GTIFiscalHub/1.0 (+https://gtifiscal.gtigrupo.com.br; product identity lookup)"
|
||||
|
||||
|
||||
class ProviderError(Exception):
|
||||
"""kind: RATE_LIMITED | AUTH_ERROR | TIMEOUT | SERVER_ERROR | NETWORK | BAD_RESPONSE"""
|
||||
|
||||
def __init__(self, kind: str, detail: str = "", headers: dict | None = None, retry_after: int | None = None):
|
||||
super().__init__(f"{kind}: {detail}")
|
||||
self.kind, self.detail, self.headers, self.retry_after = kind, detail, headers or {}, retry_after
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderProduct:
|
||||
provider: str
|
||||
gtin: str
|
||||
found: bool
|
||||
title: str | None = None
|
||||
brand: str | None = None
|
||||
category: str | None = None
|
||||
quantity: str | None = None
|
||||
ncm: str | None = None
|
||||
cest: str | None = None
|
||||
raw_hash: str = ""
|
||||
latency_ms: int = 0
|
||||
headers: dict = field(default_factory=dict)
|
||||
|
||||
def as_data(self) -> dict:
|
||||
return {k: v for k, v in {"title": self.title, "brand": self.brand, "category": self.category, "quantity": self.quantity, "ncm": self.ncm, "cest": self.cest}.items() if v}
|
||||
|
||||
|
||||
def _digits(value) -> str | None:
|
||||
text = "".join(ch for ch in str(value or "") if ch.isdigit())
|
||||
return text or None
|
||||
|
||||
|
||||
class Provider:
|
||||
name = "base"
|
||||
paid = False
|
||||
supports_search = False
|
||||
daily_limit: int | None = None
|
||||
rate_per_minute: int | None = None
|
||||
burst: tuple[int, int] | None = None # (requests, seconds)
|
||||
provides_ncm = False
|
||||
|
||||
def __init__(self, client: httpx.Client | None = None, token: str | None = None):
|
||||
self.client = client or httpx.Client(timeout=4.0, headers={"User-Agent": USER_AGENT})
|
||||
self._token = token
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return True
|
||||
|
||||
def _get(self, url: str, **kwargs) -> httpx.Response:
|
||||
started = time.monotonic()
|
||||
try:
|
||||
response = self.client.get(url, **kwargs)
|
||||
except httpx.TimeoutException as error:
|
||||
raise ProviderError("TIMEOUT", type(error).__name__) from error
|
||||
except httpx.HTTPError as error:
|
||||
raise ProviderError("NETWORK", type(error).__name__) from error
|
||||
response.latency_ms = int((time.monotonic() - started) * 1000) # type: ignore[attr-defined]
|
||||
headers = {k.lower(): v for k, v in response.headers.items()}
|
||||
if response.status_code == 429:
|
||||
retry = headers.get("retry-after")
|
||||
raise ProviderError("RATE_LIMITED", "HTTP 429", headers, int(retry) if retry and retry.isdigit() else None)
|
||||
if response.status_code in (401, 403):
|
||||
raise ProviderError("AUTH_ERROR", f"HTTP {response.status_code}", headers)
|
||||
if response.status_code >= 500:
|
||||
raise ProviderError("SERVER_ERROR", f"HTTP {response.status_code}", headers)
|
||||
return response
|
||||
|
||||
def lookup_gtin(self, gtin: str) -> ProviderProduct: # pragma: no cover - interface
|
||||
raise NotImplementedError
|
||||
|
||||
def search_description(self, text: str) -> list[ProviderProduct]:
|
||||
return []
|
||||
|
||||
def health(self) -> dict:
|
||||
return {"configured": self.configured}
|
||||
|
||||
|
||||
class UpcItemDb(Provider):
|
||||
name = "upcitemdb"
|
||||
supports_search = True
|
||||
daily_limit = 100
|
||||
burst = (15, 30)
|
||||
|
||||
def lookup_gtin(self, gtin: str) -> ProviderProduct:
|
||||
response = self._get("https://api.upcitemdb.com/prod/trial/lookup", params={"upc": gtin})
|
||||
if response.status_code == 404:
|
||||
return ProviderProduct(self.name, gtin, False, latency_ms=getattr(response, "latency_ms", 0), headers=dict(response.headers))
|
||||
if response.status_code != 200:
|
||||
raise ProviderError("BAD_RESPONSE", f"HTTP {response.status_code}")
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError as error:
|
||||
raise ProviderError("BAD_RESPONSE", "not JSON") from error
|
||||
items = body.get("items") or []
|
||||
headers = {k.lower(): v for k, v in response.headers.items()}
|
||||
if not items:
|
||||
return ProviderProduct(self.name, gtin, False, latency_ms=getattr(response, "latency_ms", 0), headers=headers)
|
||||
item = items[0]
|
||||
raw = hashlib.sha256(json.dumps(item, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
|
||||
return ProviderProduct(self.name, gtin, True, title=item.get("title"), brand=item.get("brand"), category=item.get("category"),
|
||||
quantity=item.get("size") or item.get("weight") or None, raw_hash=raw, latency_ms=getattr(response, "latency_ms", 0), headers=headers)
|
||||
|
||||
def search_description(self, text: str) -> list[ProviderProduct]:
|
||||
response = self._get("https://api.upcitemdb.com/prod/trial/search", params={"s": text, "match_mode": "1", "type": "product"})
|
||||
if response.status_code != 200:
|
||||
return []
|
||||
out = []
|
||||
for item in (response.json().get("items") or [])[:5]:
|
||||
code = _digits(item.get("ean") or item.get("upc")) or ""
|
||||
out.append(ProviderProduct(self.name, code, True, title=item.get("title"), brand=item.get("brand"), category=item.get("category"),
|
||||
raw_hash=hashlib.sha256(json.dumps(item, sort_keys=True, ensure_ascii=False).encode()).hexdigest()))
|
||||
return out
|
||||
|
||||
|
||||
class OpenFoodFacts(Provider):
|
||||
name = "openfoodfacts"
|
||||
supports_search = True
|
||||
rate_per_minute = 15
|
||||
host = "world.openfoodfacts.org"
|
||||
|
||||
def lookup_gtin(self, gtin: str) -> ProviderProduct:
|
||||
response = self._get(f"https://{self.host}/api/v2/product/{gtin}", params={"fields": "code,product_name,brands,quantity,categories,generic_name"})
|
||||
headers = {k.lower(): v for k, v in response.headers.items()}
|
||||
if response.status_code == 404:
|
||||
return ProviderProduct(self.name, gtin, False, latency_ms=getattr(response, "latency_ms", 0), headers=headers)
|
||||
if response.status_code != 200:
|
||||
raise ProviderError("BAD_RESPONSE", f"HTTP {response.status_code}")
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError as error:
|
||||
raise ProviderError("BAD_RESPONSE", "not JSON") from error
|
||||
product = body.get("product") or {}
|
||||
if body.get("status") != 1 or not product:
|
||||
return ProviderProduct(self.name, gtin, False, latency_ms=getattr(response, "latency_ms", 0), headers=headers)
|
||||
title = product.get("product_name") or product.get("generic_name")
|
||||
return ProviderProduct(self.name, gtin, bool(title), title=title, brand=(product.get("brands") or "").split(",")[0].strip() or None, category=(product.get("categories") or "").split(",")[0].strip() or None,
|
||||
quantity=product.get("quantity"), raw_hash=hashlib.sha256(json.dumps(product, sort_keys=True, ensure_ascii=False).encode()).hexdigest(),
|
||||
latency_ms=getattr(response, "latency_ms", 0), headers=headers)
|
||||
|
||||
def search_description(self, text: str) -> list[ProviderProduct]:
|
||||
response = self._get("https://world.openfoodfacts.org/cgi/search.pl", params={"search_terms": text, "json": "1", "page_size": "5", "fields": "code,product_name,brands,quantity"})
|
||||
if response.status_code != 200:
|
||||
return []
|
||||
return [ProviderProduct(self.name, _digits(p.get("code")) or "", True, title=p.get("product_name"), brand=(p.get("brands") or "").split(",")[0].strip() or None, quantity=p.get("quantity"))
|
||||
for p in (response.json().get("products") or [])[:5] if p.get("product_name")]
|
||||
|
||||
|
||||
class OpenProductsFacts(OpenFoodFacts):
|
||||
"""Same open (ODbL) platform and API as Open Food Facts, for non-food goods. Identity only (no NCM)."""
|
||||
name = "openproductsfacts"
|
||||
host = "world.openproductsfacts.org"
|
||||
supports_search = False
|
||||
|
||||
|
||||
class OpenBeautyFacts(OpenFoodFacts):
|
||||
name = "openbeautyfacts"
|
||||
host = "world.openbeautyfacts.org"
|
||||
supports_search = False
|
||||
|
||||
|
||||
class Cosmos(Provider):
|
||||
name = "cosmos"
|
||||
supports_search = True
|
||||
daily_limit = 25
|
||||
provides_ncm = True
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self._token)
|
||||
|
||||
def _headers(self) -> dict:
|
||||
return {"X-Cosmos-Token": self._token or "", "User-Agent": USER_AGENT, "Content-Type": "application/json"}
|
||||
|
||||
def lookup_gtin(self, gtin: str) -> ProviderProduct:
|
||||
if not self.configured:
|
||||
raise ProviderError("AUTH_ERROR", "NOT_CONFIGURED")
|
||||
response = self._get(f"https://api.cosmos.bluesoft.com.br/gtins/{gtin}", headers=self._headers())
|
||||
headers = {k.lower(): v for k, v in response.headers.items()}
|
||||
if response.status_code == 404:
|
||||
return ProviderProduct(self.name, gtin, False, latency_ms=getattr(response, "latency_ms", 0), headers=headers)
|
||||
if response.status_code != 200:
|
||||
raise ProviderError("BAD_RESPONSE", f"HTTP {response.status_code}")
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError as error:
|
||||
raise ProviderError("BAD_RESPONSE", "not JSON") from error
|
||||
ncm = _digits((body.get("ncm") or {}).get("code"))
|
||||
cest = _digits((body.get("cest") or {}).get("code"))
|
||||
brand = (body.get("brand") or {}).get("name") if isinstance(body.get("brand"), dict) else body.get("brand")
|
||||
return ProviderProduct(self.name, gtin, True, title=body.get("description"), brand=brand, category=(body.get("gpc") or {}).get("description"), quantity=body.get("net_weight") and str(body.get("net_weight")),
|
||||
ncm=ncm, cest=cest, raw_hash=hashlib.sha256(json.dumps(body, sort_keys=True, ensure_ascii=False).encode()).hexdigest(), latency_ms=getattr(response, "latency_ms", 0), headers=headers)
|
||||
|
||||
def search_description(self, text: str) -> list[ProviderProduct]:
|
||||
if not self.configured:
|
||||
return []
|
||||
response = self._get("https://api.cosmos.bluesoft.com.br/products", params={"query": text}, headers=self._headers())
|
||||
if response.status_code != 200:
|
||||
return []
|
||||
out = []
|
||||
for item in (response.json().get("products") or [])[:5]:
|
||||
out.append(ProviderProduct(self.name, _digits(item.get("gtin")) or "", True, title=item.get("description"), brand=(item.get("brand") or {}).get("name") if isinstance(item.get("brand"), dict) else None,
|
||||
ncm=_digits((item.get("ncm") or {}).get("code")) if isinstance(item.get("ncm"), dict) else None))
|
||||
return out
|
||||
|
||||
|
||||
class Oscbr(Provider):
|
||||
"""OSCBR / RSC Sistemas GTIN API (https://gtin.rscsistemas.com.br). Free plan (no card): 50 calls/day, 5/min; account created BY THE OPERATOR, credentials only in the Hub env
|
||||
(OSCBR_USER / OSCBR_PASSWORD). Bearer token lasts 1 h and is renewed automatically. Optional: NOT_CONFIGURED without credentials (no call is made)."""
|
||||
name = "oscbr"
|
||||
rate_per_minute = 5 # free plan as currently documented: 50 calls/day, 5/min (conservative: the lower figure wins)
|
||||
daily_limit = 50
|
||||
# ROLE = IDENTITY. The operator-provided V3 contract (GET /api/v3/gtin/{ean}) documents ean, ean_tipo, nome, marca, categoria, link_foto - NO ncm/cest. The public landing page mentions NCM/CEST
|
||||
# and the legacy open-source client shows an `ncm` integer (leading zero lost), but the V3 documentation is behind the login, so NCM is UNPROVEN: the provider does not count as an NCM family
|
||||
# (provides_ncm=False). If a real authenticated V3 answer ever carries `ncm`, it is stored as evidence but the role only changes after that proof is reviewed.
|
||||
provides_ncm = False
|
||||
base = "https://gtin.rscsistemas.com.br"
|
||||
|
||||
def __init__(self, client=None, token=None, user=None, password=None):
|
||||
super().__init__(client, token) # token = a ready Bearer token (OSCBR_TOKEN); preferred over storing the login
|
||||
self._user, self._password = user, password
|
||||
self._bearer, self._bearer_until = None, 0.0
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self._token or (self._user and self._password))
|
||||
|
||||
def _auth(self) -> str:
|
||||
if self._token:
|
||||
return self._token
|
||||
if self._bearer and time.time() < self._bearer_until:
|
||||
return self._bearer
|
||||
try:
|
||||
response = self.client.post(f"{self.base}/oauth/token", auth=(self._user, self._password), headers={"User-Agent": USER_AGENT})
|
||||
except httpx.TimeoutException as error:
|
||||
raise ProviderError("TIMEOUT", type(error).__name__) from error
|
||||
except httpx.HTTPError as error:
|
||||
raise ProviderError("NETWORK", type(error).__name__) from error
|
||||
if response.status_code in (400, 401, 403):
|
||||
raise ProviderError("AUTH_ERROR", f"HTTP {response.status_code}")
|
||||
if response.status_code == 429:
|
||||
raise ProviderError("RATE_LIMITED", "HTTP 429")
|
||||
if response.status_code >= 500 or response.status_code != 200:
|
||||
raise ProviderError("SERVER_ERROR", f"HTTP {response.status_code}")
|
||||
try:
|
||||
token = response.json().get("token") or response.json().get("access_token")
|
||||
except ValueError as error:
|
||||
raise ProviderError("BAD_RESPONSE", "not JSON") from error
|
||||
if not token:
|
||||
raise ProviderError("BAD_RESPONSE", "no token")
|
||||
self._bearer, self._bearer_until = token, time.time() + 55 * 60
|
||||
return token
|
||||
|
||||
def lookup_gtin(self, gtin: str) -> ProviderProduct:
|
||||
if not self.configured:
|
||||
raise ProviderError("AUTH_ERROR", "NOT_CONFIGURED")
|
||||
response = self._get(f"{self.base}/api/v3/gtin/{gtin}", headers={"Authorization": f"Bearer {self._auth()}", "User-Agent": USER_AGENT})
|
||||
headers = {k.lower(): v for k, v in response.headers.items()}
|
||||
if response.status_code == 404:
|
||||
return ProviderProduct(self.name, gtin, False, latency_ms=getattr(response, "latency_ms", 0), headers=headers)
|
||||
if response.status_code != 200:
|
||||
raise ProviderError("BAD_RESPONSE", f"HTTP {response.status_code}")
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError as error:
|
||||
raise ProviderError("BAD_RESPONSE", "not JSON") from error
|
||||
if isinstance(body, list):
|
||||
body = body[0] if body else {}
|
||||
if not body or not (body.get("nome") or body.get("ncm")):
|
||||
return ProviderProduct(self.name, gtin, False, latency_ms=getattr(response, "latency_ms", 0), headers=headers)
|
||||
ncm = _digits(body.get("ncm"))
|
||||
if ncm and len(ncm) == 7: # numeric JSON drops the leading zero (7133399 is NCM 07133399)
|
||||
ncm = ncm.zfill(8)
|
||||
return ProviderProduct(self.name, gtin, True, title=body.get("nome"), brand=body.get("marca"), category=body.get("categoria"), ncm=ncm if len(ncm or "") == 8 else None, cest=_digits(body.get("cest")) or None,
|
||||
raw_hash=hashlib.sha256(json.dumps(body, sort_keys=True, ensure_ascii=False).encode()).hexdigest(), latency_ms=getattr(response, "latency_ms", 0), headers=headers)
|
||||
|
||||
|
||||
class Kodebar(Provider):
|
||||
"""Kodebar (Korven Sistemas) - IDENTITY source only (name, brand, thumbnail; NO NCM). Free 'Teste' plan: 50 queries/day, no card, personal API key created by the operator
|
||||
(KODEBAR_API_KEY in the Hub env). Terms (2026-09-26): integration into our own multi-user system and internal caching are allowed; redistribution/resale is not.
|
||||
429 above the quota, no overage billing. NOT_CONFIGURED without a key: no request is made."""
|
||||
name = "kodebar"
|
||||
daily_limit = 50
|
||||
rate_per_minute = 30
|
||||
base = "https://kodebar.korvensistemas.com.br"
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self._token)
|
||||
|
||||
def lookup_gtin(self, gtin: str) -> ProviderProduct:
|
||||
if not self.configured:
|
||||
raise ProviderError("AUTH_ERROR", "NOT_CONFIGURED")
|
||||
response = self._get(f"{self.base}/gtin/lookup", params={"gtin": gtin}, headers={"X-API-Key": self._token or "", "User-Agent": USER_AGENT})
|
||||
headers = {k.lower(): v for k, v in response.headers.items()}
|
||||
if response.status_code == 404:
|
||||
return ProviderProduct(self.name, gtin, False, latency_ms=getattr(response, "latency_ms", 0), headers=headers)
|
||||
if response.status_code != 200:
|
||||
raise ProviderError("BAD_RESPONSE", f"HTTP {response.status_code}")
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError as error:
|
||||
raise ProviderError("BAD_RESPONSE", "not JSON") from error
|
||||
if not isinstance(body, dict) or not body.get("nome"):
|
||||
return ProviderProduct(self.name, gtin, False, latency_ms=getattr(response, "latency_ms", 0), headers=headers)
|
||||
return ProviderProduct(self.name, gtin, True, title=body.get("nome"), brand=body.get("marca"), raw_hash=hashlib.sha256(json.dumps({k: body.get(k) for k in ("gtin", "nome", "marca")}, sort_keys=True, ensure_ascii=False).encode()).hexdigest(),
|
||||
latency_ms=getattr(response, "latency_ms", 0), headers=headers)
|
||||
|
||||
|
||||
def build_providers(settings, client: httpx.Client | None = None) -> list[Provider]:
|
||||
"""Fixed order chosen from the benchmark (docs/product_enrichment.md): Cosmos then OSCBR (fiscal-capable) first when configured, then UPCitemdb, then the Open *Facts family (food, general products, beauty; identity only). Paid providers do not exist here."""
|
||||
providers: list[Provider] = [Oscbr(client, getattr(settings, "oscbr_token", None), getattr(settings, "oscbr_user", None), getattr(settings, "oscbr_password", None)), Kodebar(client, getattr(settings, "kodebar_api_key", None)), UpcItemDb(client), OpenFoodFacts(client), OpenProductsFacts(client), OpenBeautyFacts(client)]
|
||||
if getattr(settings, "cosmos_enabled", False): # Cosmos is OFF by default (account unavailable); only an explicit COSMOS_ENABLED=1 puts it back in the chain
|
||||
providers.insert(0, Cosmos(client, getattr(settings, "cosmos_token", None)))
|
||||
if any(p.paid for p in providers) and not getattr(settings, "allow_paid_providers", False):
|
||||
raise RuntimeError("paid providers are not allowed (ALLOW_PAID_PROVIDERS=false)")
|
||||
return providers
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Central quota / circuit breaker / cache / single-flight for external product providers. Quota belongs to the HUB: 70 Agents never spend it uncoordinated."""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..models import GtinLookupCache, ProviderState, ProviderUsageDaily
|
||||
from .providers import Provider, ProviderError, ProviderProduct
|
||||
|
||||
POSITIVE_TTL = timedelta(days=30)
|
||||
NEGATIVE_TTL = timedelta(hours=24)
|
||||
CIRCUIT_BASE = timedelta(seconds=60)
|
||||
CIRCUIT_MAX = timedelta(minutes=15)
|
||||
FAILURES_TO_OPEN = 3
|
||||
|
||||
_windows: dict[str, deque] = defaultdict(deque) # per-process sliding windows (burst / per-minute governors)
|
||||
_flights: dict[tuple, threading.Lock] = {}
|
||||
_flights_guard = threading.Lock()
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _aware(value):
|
||||
if value is not None and value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value
|
||||
|
||||
|
||||
def next_utc_midnight(now: datetime) -> datetime:
|
||||
return (now + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Outcome:
|
||||
provider: str
|
||||
state: str # CACHE_HIT | FOUND | NOT_FOUND | SKIPPED | ERROR
|
||||
product: ProviderProduct | None = None
|
||||
reason: str | None = None # NOT_CONFIGURED | CIRCUIT_OPEN | RATE_LIMITED | RATE_WINDOW | TIMEOUT | AUTH_ERROR | NETWORK | SERVER_ERROR | ...
|
||||
retry_at: datetime | None = None
|
||||
from_cache: bool = False
|
||||
|
||||
|
||||
class QuotaManager:
|
||||
def __init__(self, session, now=utcnow):
|
||||
self.session, self.now = session, now
|
||||
|
||||
# ---- state -------------------------------------------------------------------------------------------------------------------------------------
|
||||
def state(self, provider: Provider) -> ProviderState:
|
||||
row = self.session.get(ProviderState, provider.name)
|
||||
if row is None:
|
||||
row = ProviderState(provider=provider.name, configured=provider.configured, status="ONLINE" if provider.configured else "NOT_CONFIGURED",
|
||||
daily_limit=provider.daily_limit, rate_per_minute=provider.rate_per_minute,
|
||||
quota_kind="LOCAL_ESTIMATE" if provider.daily_limit else "DOCUMENTED_RATE")
|
||||
self.session.add(row)
|
||||
self.session.flush()
|
||||
row.configured = provider.configured
|
||||
if not provider.configured:
|
||||
row.status = "NOT_CONFIGURED"
|
||||
elif row.status == "NOT_CONFIGURED":
|
||||
row.status = "ONLINE"
|
||||
row.daily_limit = provider.daily_limit
|
||||
row.rate_per_minute = provider.rate_per_minute
|
||||
return row
|
||||
|
||||
def usage(self, provider_name: str, day: str | None = None) -> ProviderUsageDaily:
|
||||
day = day or self.now().strftime("%Y-%m-%d")
|
||||
row = self.session.scalar(select(ProviderUsageDaily).where(ProviderUsageDaily.provider == provider_name, ProviderUsageDaily.day == day))
|
||||
if row is None:
|
||||
row = ProviderUsageDaily(provider=provider_name, day=day)
|
||||
self.session.add(row)
|
||||
self.session.flush()
|
||||
return row
|
||||
|
||||
# ---- decision ----------------------------------------------------------------------------------------------------------------------------------
|
||||
def allow(self, provider: Provider, purpose: str = "OPERATIONAL", reserve_percent: int = 0) -> tuple[bool, str | None, datetime | None]:
|
||||
state, now = self.state(provider), self.now()
|
||||
if not provider.configured:
|
||||
return False, "NOT_CONFIGURED", None
|
||||
if state.circuit_open_until and _aware(state.circuit_open_until) > now:
|
||||
return False, "CIRCUIT_OPEN", _aware(state.circuit_open_until)
|
||||
usage = self.usage(provider.name)
|
||||
if provider.daily_limit and state.quota_kind == "REMOTE_QUOTA" and state.remaining is not None and state.remaining <= 0 and state.reset_at and _aware(state.reset_at) > now:
|
||||
return False, "RATE_LIMITED", _aware(state.reset_at)
|
||||
if provider.daily_limit and usage.requests >= provider.daily_limit and state.quota_kind != "REMOTE_QUOTA":
|
||||
state.status = "RATE_LIMITED"
|
||||
return False, "RATE_LIMITED", state.reset_at and _aware(state.reset_at) or next_utc_midnight(now)
|
||||
if purpose == "BACKFILL" and provider.daily_limit: # backfill may only use what is left above the operational reserve
|
||||
used = usage.requests
|
||||
if state.quota_kind == "REMOTE_QUOTA" and state.remaining is not None:
|
||||
used = provider.daily_limit - state.remaining
|
||||
if used >= provider.daily_limit * (100 - reserve_percent) / 100:
|
||||
return False, "OPERATIONAL_RESERVE", state.reset_at and _aware(state.reset_at) or next_utc_midnight(now)
|
||||
window = (provider.burst[1] if provider.burst else 60)
|
||||
limit = (provider.burst[0] if provider.burst else provider.rate_per_minute)
|
||||
if limit:
|
||||
q, cutoff = _windows[provider.name], time.monotonic() - window
|
||||
while q and q[0] < cutoff:
|
||||
q.popleft()
|
||||
if len(q) >= limit:
|
||||
return False, "RATE_WINDOW", now + timedelta(seconds=max(1, int(window - (time.monotonic() - q[0]))))
|
||||
return True, None, None
|
||||
|
||||
# ---- accounting --------------------------------------------------------------------------------------------------------------------------------
|
||||
def _remote_quota(self, state: ProviderState, headers: dict):
|
||||
remaining, limit, reset = headers.get("x-ratelimit-remaining"), headers.get("x-ratelimit-limit"), headers.get("x-ratelimit-reset")
|
||||
if remaining is not None and str(remaining).isdigit():
|
||||
state.remaining = int(remaining)
|
||||
state.quota_kind = "REMOTE_QUOTA"
|
||||
if limit and str(limit).isdigit():
|
||||
state.daily_limit = int(limit)
|
||||
if reset and str(reset).isdigit():
|
||||
state.reset_at = datetime.fromtimestamp(int(reset), tz=timezone.utc)
|
||||
|
||||
def _sent(self, provider: Provider):
|
||||
q = _windows[provider.name]
|
||||
q.append(time.monotonic())
|
||||
|
||||
def record_ok(self, provider: Provider, result: ProviderProduct):
|
||||
state, usage, now = self.state(provider), self.usage(provider.name), self.now()
|
||||
self._sent(provider)
|
||||
usage.requests += 1
|
||||
usage.success += 1
|
||||
if not result.found:
|
||||
usage.not_found += 1
|
||||
else:
|
||||
usage.found += 1
|
||||
if result.ncm:
|
||||
usage.ncm_found += 1
|
||||
state.consecutive_failures = 0
|
||||
state.circuit_open_until = None
|
||||
state.status = "ONLINE"
|
||||
state.last_request_at = state.last_success_at = now
|
||||
state.last_latency_ms = result.latency_ms or None
|
||||
self._remote_quota(state, {k.lower(): v for k, v in (result.headers or {}).items()})
|
||||
if provider.daily_limit and state.quota_kind != "REMOTE_QUOTA":
|
||||
state.remaining = max(0, provider.daily_limit - usage.requests)
|
||||
state.reset_at = next_utc_midnight(now)
|
||||
self.session.commit()
|
||||
|
||||
def record_error(self, provider: Provider, error: ProviderError):
|
||||
state, usage, now = self.state(provider), self.usage(provider.name), self.now()
|
||||
self._sent(provider)
|
||||
usage.requests += 1
|
||||
state.last_request_at, state.last_error_at = now, now
|
||||
state.last_error = f"{error.kind}: {error.detail}"[:160]
|
||||
state.consecutive_failures += 1
|
||||
headers = {k.lower(): v for k, v in (error.headers or {}).items()}
|
||||
self._remote_quota(state, headers)
|
||||
if error.kind == "RATE_LIMITED":
|
||||
usage.rate_limited += 1
|
||||
state.status = "RATE_LIMITED"
|
||||
wait = timedelta(seconds=error.retry_after) if error.retry_after else None
|
||||
state.circuit_open_until = now + (wait or (state.reset_at and max(timedelta(minutes=15), _aware(state.reset_at) - now) or timedelta(minutes=60)))
|
||||
if state.reset_at is None:
|
||||
state.reset_at = state.circuit_open_until
|
||||
elif error.kind == "AUTH_ERROR":
|
||||
usage.errors += 1
|
||||
state.status = "AUTH_ERROR"
|
||||
state.circuit_open_until = now + timedelta(hours=1)
|
||||
else:
|
||||
if error.kind == "TIMEOUT":
|
||||
usage.timeouts += 1
|
||||
usage.errors += 1
|
||||
state.status = "DEGRADED"
|
||||
if state.consecutive_failures >= FAILURES_TO_OPEN:
|
||||
delay = min(CIRCUIT_MAX, CIRCUIT_BASE * (2 ** (state.consecutive_failures - FAILURES_TO_OPEN)))
|
||||
state.circuit_open_until = now + delay
|
||||
state.status = "OFFLINE"
|
||||
self.session.commit()
|
||||
|
||||
def record_kb_hit(self):
|
||||
"""A resolution answered by the KB alone (no provider asked): counted once per day under the pseudo-provider 'kb'."""
|
||||
self.usage("kb").kb_hits += 1
|
||||
|
||||
def record_cache(self, provider_name: str, hit: bool):
|
||||
usage = self.usage(provider_name)
|
||||
if hit:
|
||||
usage.cache_hits += 1
|
||||
else:
|
||||
usage.cache_misses += 1
|
||||
|
||||
|
||||
class ProviderGateway:
|
||||
"""lookup(provider, gtin): cache -> single-flight -> quota/circuit -> HTTP -> account -> cache."""
|
||||
|
||||
def __init__(self, session, providers: list[Provider], now=utcnow):
|
||||
self.session, self.providers, self.now = session, providers, now
|
||||
self.quota = QuotaManager(session, now)
|
||||
|
||||
def _cached(self, provider: Provider, gtin: str) -> ProviderProduct | None | bool:
|
||||
row = self.session.scalar(select(GtinLookupCache).where(GtinLookupCache.gtin == gtin, GtinLookupCache.provider == provider.name))
|
||||
if row is None or _aware(row.expires_at) <= self.now():
|
||||
return False
|
||||
if not row.found:
|
||||
return None
|
||||
d = row.data or {}
|
||||
return ProviderProduct(provider.name, gtin, True, title=d.get("title"), brand=d.get("brand"), category=d.get("category"), quantity=d.get("quantity"), ncm=d.get("ncm"), cest=d.get("cest"), raw_hash=d.get("raw_hash", ""))
|
||||
|
||||
def _store(self, provider: Provider, gtin: str, product: ProviderProduct):
|
||||
now = self.now()
|
||||
ttl = POSITIVE_TTL if product.found else NEGATIVE_TTL
|
||||
row = self.session.scalar(select(GtinLookupCache).where(GtinLookupCache.gtin == gtin, GtinLookupCache.provider == provider.name))
|
||||
data = {**product.as_data(), "raw_hash": product.raw_hash}
|
||||
if row is None:
|
||||
self.session.add(GtinLookupCache(gtin=gtin, provider=provider.name, found=product.found, data=data, fetched_at=now, expires_at=now + ttl))
|
||||
else:
|
||||
row.found, row.data, row.fetched_at, row.expires_at = product.found, data, now, now + ttl
|
||||
self.session.commit()
|
||||
|
||||
def lookup(self, provider: Provider, gtin: str, purpose: str = "OPERATIONAL", reserve_percent: int = 0) -> Outcome:
|
||||
hit = self._cached(provider, gtin)
|
||||
if hit is not False:
|
||||
self.quota.record_cache(provider.name, True)
|
||||
self.session.commit()
|
||||
return Outcome(provider.name, "CACHE_HIT", hit or None, from_cache=True) if hit else Outcome(provider.name, "NOT_FOUND", None, from_cache=True)
|
||||
with _flights_guard:
|
||||
lock = _flights.setdefault((provider.name, gtin), threading.Lock())
|
||||
with lock: # 10 installations, same EAN -> 1 external call, the rest wait for the same answer
|
||||
self.session.expire_all()
|
||||
hit = self._cached(provider, gtin)
|
||||
if hit is not False:
|
||||
self.quota.record_cache(provider.name, True)
|
||||
self.session.commit()
|
||||
return Outcome(provider.name, "CACHE_HIT", hit or None, from_cache=True) if hit else Outcome(provider.name, "NOT_FOUND", None, from_cache=True)
|
||||
self.quota.record_cache(provider.name, False)
|
||||
ok, reason, retry_at = self.quota.allow(provider, purpose, reserve_percent)
|
||||
if not ok:
|
||||
self.session.commit()
|
||||
return Outcome(provider.name, "SKIPPED", None, reason, retry_at)
|
||||
try:
|
||||
product = provider.lookup_gtin(gtin)
|
||||
except ProviderError as error:
|
||||
self.quota.record_error(provider, error)
|
||||
return Outcome(provider.name, "ERROR", None, error.kind, self.quota.state(provider).circuit_open_until)
|
||||
self.quota.record_ok(provider, product)
|
||||
self._store(provider, gtin, product)
|
||||
return Outcome(provider.name, "FOUND" if product.found else "NOT_FOUND", product if product.found else None)
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Product resolver: GTIN/name -> KB -> providers -> official NCM validation -> existing fiscal engine, decided FIELD BY FIELD.
|
||||
|
||||
Deterministic. No AI, no scores: discrete states only.
|
||||
Resolution states: EXACT_GTIN_VERIFIED, EXACT_KB_NAME_VERIFIED, MULTI_SOURCE_MATCH, SINGLE_WEAK_SOURCE, AMBIGUOUS, CONFLICT, NOT_FOUND
|
||||
(+ operational: INVALID_GTIN, PENDING_PROVIDER_QUOTA, PENDING_PROVIDERS_OFFLINE).
|
||||
Field decisions: AUTO_FILL_SAFE | SUGGESTION_ONLY | CONFLICT | INSUFFICIENT_EVIDENCE | ALREADY_SET | NOT_APPLICABLE.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..classification import Classifier, normalize_code
|
||||
from ..datasets.service import DatasetService
|
||||
from ..models import Dataset, NcmEntry
|
||||
from . import fiscal_completion, kb, textnorm
|
||||
from .gtin import normalize_gtin
|
||||
from .placeholders import is_placeholder, is_writable_empty
|
||||
from .quota import ProviderGateway
|
||||
|
||||
AUTO, SUGGEST, CONFLICT, INSUFFICIENT, ALREADY, NA = "AUTO_FILL_SAFE", "SUGGESTION_ONLY", "CONFLICT", "INSUFFICIENT_EVIDENCE", "ALREADY_SET", "NOT_APPLICABLE"
|
||||
FIELDS = ("CLASSIFICACAO_FISCAL", "CEST", "IPI")
|
||||
|
||||
|
||||
def _parse_br(text: str | None) -> date | None:
|
||||
try:
|
||||
d, m, y = str(text).split("/")
|
||||
return date(int(y), int(m), int(d))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def ncm_is_official(session, code: str, today: date | None = None) -> bool:
|
||||
"""8-digit code present in the ACTIVE official NCM dataset and vigente today (dd/mm/yyyy, 31/12/9999 = open)."""
|
||||
code = normalize_code(code)
|
||||
if len(code) != 8:
|
||||
return False
|
||||
today = today or date.today()
|
||||
rows = session.scalars(select(NcmEntry).join(Dataset).where(Dataset.kind == "NCM", Dataset.status == "ACTIVE", NcmEntry.code == code)).all()
|
||||
for row in rows:
|
||||
start, end = _parse_br(row.valid_from), _parse_br(row.valid_to)
|
||||
if (start is None or start <= today) and (end is None or end >= today):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class _ScopedDatasets(DatasetService):
|
||||
"""The Classifier only needs the ONE exact NCM row here; loading the whole 15k-row catalogue on every product would cost ~300 ms of the 5 s budget."""
|
||||
|
||||
def __init__(self, session, code: str):
|
||||
super().__init__(session)
|
||||
self.code = code
|
||||
|
||||
def active_entries(self):
|
||||
return self.session.scalars(select(NcmEntry).join(Dataset).where(Dataset.kind == "NCM", Dataset.status == "ACTIVE", NcmEntry.code == self.code)).all()
|
||||
|
||||
|
||||
def ncm_exists_any(session, code: str, *, current: bool, today: date | None = None) -> bool:
|
||||
"""current=True: vigente today; current=False: present in the ACTIVE dataset with any validity window (i.e. known but not vigente)."""
|
||||
code = normalize_code(code)
|
||||
if len(code) != 8:
|
||||
return False
|
||||
if current:
|
||||
return ncm_is_official(session, code, today)
|
||||
return session.scalar(select(NcmEntry.id).join(Dataset).where(Dataset.kind == "NCM", Dataset.status == "ACTIVE", NcmEntry.code == code).limit(1)) is not None
|
||||
|
||||
|
||||
def dataset_versions(session) -> dict:
|
||||
return {d.kind: {"sha256": (d.sha256 or "")[:12], "version": d.version_label} for d in session.scalars(select(Dataset).where(Dataset.status == "ACTIVE"))}
|
||||
|
||||
|
||||
def _field(status, value=None, rule=None, kb_status=None, evidence=None, reason=None):
|
||||
return {"status": status, "value": value, "rule": rule, "kb_status": kb_status, "evidence": evidence or [], "reason": reason}
|
||||
|
||||
|
||||
def _empty(current: dict, name: str) -> bool:
|
||||
return is_writable_empty(name, current.get(name))
|
||||
|
||||
|
||||
def _merge_fiscal_completion(session, out: dict, *, ncm_official: bool, ncm_for_engine: str | None, current: dict, description: str,
|
||||
product_code: str | None, installation, tenant_id: int | None, decision: dict | None, product_evidence: dict | None = None) -> None:
|
||||
"""Merges field_contract.resolve_product's own field-by-field decisions (CFOP/CST/CSOSN/PIS-COFINS/...) into `out`, in the SAME
|
||||
AUTO_FILL_SAFE/ALREADY_SET/NOT_APPLICABLE/INSUFFICIENT_EVIDENCE vocabulary CEST/IPI already use, plus NEEDS_REVIEW for anything that needs an
|
||||
operator's context answer. No-op when the caller (a test, or a code path with no installation on hand) did not pass installation/tenant_id/product_code."""
|
||||
if installation is None or tenant_id is None or product_code is None:
|
||||
return
|
||||
fields = fiscal_completion.resolve_fiscal_fields(session, product_code=product_code, description=description, current=current, ncm=ncm_for_engine or "",
|
||||
ncm_official=ncm_official, installation=installation, tenant_id=tenant_id, decision=decision, extra_columns=current,
|
||||
ev=product_evidence)
|
||||
for field, r in fields.items():
|
||||
out[field] = _field(r["status"], r.get("value"), r.get("rule"), reason=r.get("reason"))
|
||||
if r.get("missing_key"):
|
||||
out[field]["missing_key"], out[field]["missing_scope"] = r["missing_key"], r.get("missing_scope")
|
||||
|
||||
|
||||
def _fiscal_decisions(session, ncm_eval: dict, gtin_kb_verified: bool, current: dict, description: str, *, name_verified=False,
|
||||
product_code: str | None = None, installation=None, tenant_id: int | None = None, product_evidence: dict | None = None) -> dict:
|
||||
out = {}
|
||||
if not _empty(current, "CLASSIFICACAO_FISCAL"):
|
||||
out["CLASSIFICACAO_FISCAL"] = _field(ALREADY, reason="campo já preenchido")
|
||||
elif ncm_eval["status"] == kb.CONFLICT:
|
||||
out["CLASSIFICACAO_FISCAL"] = _field(CONFLICT, kb_status=kb.CONFLICT, evidence=ncm_eval["candidates"], reason="fontes divergem no NCM")
|
||||
elif ncm_eval["value"] and ncm_eval["status"] == kb.VERIFIED_KB:
|
||||
out["CLASSIFICACAO_FISCAL"] = _field(AUTO, ncm_eval["value"], "NCM_KB_VERIFIED" if gtin_kb_verified or name_verified else "NCM_TWO_INDEPENDENT_SOURCES", kb.VERIFIED_KB, ncm_eval["families"])
|
||||
elif ncm_eval["value"]:
|
||||
rule = {"CORROBORATED": "NCM_CORROBORATED_LOCAL_HISTORY", "EXTERNAL_CORROBORATED": "NCM_EXTERNAL_CORROBORATED"}.get(ncm_eval["status"], "NCM_SINGLE_SOURCE")
|
||||
out["CLASSIFICACAO_FISCAL"] = _field(SUGGEST, ncm_eval["value"], rule, ncm_eval["status"], ncm_eval["families"], "evidência ainda insuficiente para preencher automaticamente")
|
||||
else:
|
||||
out["CLASSIFICACAO_FISCAL"] = _field(INSUFFICIENT, reason="nenhuma fonte com NCM válido no dataset oficial")
|
||||
ncm = out["CLASSIFICACAO_FISCAL"]
|
||||
own = current.get("CLASSIFICACAO_FISCAL")
|
||||
ncm_for_engine = ncm["value"] if ncm["status"] == AUTO else (None if is_placeholder("CLASSIFICACAO_FISCAL", own) else (normalize_code(own) or None))
|
||||
if not ncm_for_engine or (ncm["status"] == ALREADY and not ncm_is_official(session, ncm_for_engine)):
|
||||
for name in ("CEST", "IPI"):
|
||||
out[name] = _field(ALREADY if not _empty(current, name) else INSUFFICIENT, reason="depende de um NCM oficial válido")
|
||||
_merge_fiscal_completion(session, out, ncm_official=False, ncm_for_engine=ncm_for_engine, current=current, description=description,
|
||||
product_code=product_code, installation=installation, tenant_id=tenant_id, decision=None, product_evidence=product_evidence)
|
||||
return out
|
||||
# CEST / IPI are deterministic functions of an OFFICIAL NCM: the NCM we are about to write (AUTO) or one already in the product (validated above against the official dataset)
|
||||
ncm_solid = ncm["status"] == AUTO or (ncm["status"] == ALREADY and ncm_is_official(session, ncm_for_engine))
|
||||
decision = Classifier(_ScopedDatasets(session, ncm_for_engine)).classify({"current_ncm": ncm_for_engine, "description": description})
|
||||
cest = (decision["classification"].get("cest") or [])
|
||||
if not _empty(current, "CEST"):
|
||||
out["CEST"] = _field(ALREADY, reason="campo já preenchido")
|
||||
elif decision["cest_status"] == "CEST_CONFIRMED" and len(cest) == 1 and ncm_solid:
|
||||
out["CEST"] = _field(AUTO, normalize_code(cest[0]), "CEST_OFFICIAL_UNIQUE_MAPPING", ncm["kb_status"], [f"NCM {ncm_for_engine}"])
|
||||
elif decision["cest_status"] == "NO_CEST_MAPPING_FOUND":
|
||||
out["CEST"] = _field(NA, reason="NCM sem mapeamento de CEST no dataset oficial")
|
||||
else:
|
||||
out["CEST"] = _field(INSUFFICIENT, reason=f"CEST não determinístico ({decision['cest_status']})")
|
||||
ipi = decision["classification"].get("ipi") or {}
|
||||
if not _empty(current, "IPI"):
|
||||
out["IPI"] = _field(ALREADY, reason="campo já preenchido")
|
||||
elif decision["ipi_status"] == "IPI_RESOLVED" and ipi.get("rate_kind") == "NUMERIC" and ncm_solid:
|
||||
out["IPI"] = _field(AUTO, str(ipi["rate_numeric"]), "IPI_TIPI_NUMERIC", ncm["kb_status"], [f"TIPI NCM {ncm_for_engine}"])
|
||||
elif decision["ipi_status"] in {"IPI_RESOLVED"}:
|
||||
out["IPI"] = _field(NA, reason="TIPI sem alíquota numérica (NT/branca)")
|
||||
else:
|
||||
out["IPI"] = _field(INSUFFICIENT, reason=f"IPI não determinístico ({decision['ipi_status']})")
|
||||
_merge_fiscal_completion(session, out, ncm_official=ncm_solid, ncm_for_engine=ncm_for_engine, current=current, description=description,
|
||||
product_code=product_code, installation=installation, tenant_id=tenant_id, decision=decision, product_evidence=product_evidence)
|
||||
return out
|
||||
|
||||
|
||||
def resolve(session, gateway: ProviderGateway, *, gtin_raw, description, current: dict | None = None, client_label: str | None = None, allow_external: bool = True, budget_s: float | None = None,
|
||||
product_code: str | None = None, installation=None, tenant_id: int | None = None, product_evidence: dict | None = None) -> dict:
|
||||
started = time.monotonic()
|
||||
"""Returns {state, gtin_status, gtin, fields:{...}, providers:[...], retry_at, dataset_versions}. Never raises for provider trouble.
|
||||
|
||||
`product_code`/`installation`/`tenant_id`, when given, additionally resolve the post-NCM fiscal-completion fields (CFOP/CST/CSOSN/PIS-COFINS/...)
|
||||
via the existing field_contract engine (see `_merge_fiscal_completion`) and merge them into `fields` alongside CLASSIFICACAO_FISCAL/CEST/IPI.
|
||||
`product_evidence` is the Agent's targeted, product_code-scoped recipes/composed/purchases/stock evidence (firebird_reader.read_product_evidence) -
|
||||
never a full-table scan. Named separately from the local `ev` variable below (NCM knowledge-base evidence), a different, pre-existing concept."""
|
||||
current = {k: v for k, v in (current or {}).items()}
|
||||
g = normalize_gtin(gtin_raw)
|
||||
result = {"state": "NOT_FOUND", "gtin_status": g["status"], "gtin": g.get("key"), "fields": {}, "providers": [], "retry_at": None, "dataset_versions": dataset_versions(session)}
|
||||
session.info["is_official"] = lambda v: ncm_is_official(session, v)
|
||||
is_official = session.info["is_official"]
|
||||
product, name_verified, key = None, False, g.get("key")
|
||||
|
||||
if g["status"] == "VALID_GTIN": # GTIN has absolute priority over the description
|
||||
product = kb.find_by_gtin(session, key)
|
||||
ev = kb.evaluate(session, product, "ncm", is_official) if product else None
|
||||
if ev and ev["status"] == kb.VERIFIED_KB:
|
||||
gateway.quota.record_kb_hit()
|
||||
result["state"] = "EXACT_GTIN_VERIFIED"
|
||||
result["fields"] = _fiscal_decisions(session, ev, True, current, description or "", product_code=product_code, installation=installation, tenant_id=tenant_id, product_evidence=product_evidence)
|
||||
session.commit()
|
||||
return result
|
||||
if ev and ev["status"] == kb.CONFLICT: # local evidence already conflicts: an external answer cannot settle it, so spend no quota
|
||||
result["state"] = "CONFLICT"
|
||||
result["fields"] = _fiscal_decisions(session, ev, False, current, description or "", product_code=product_code, installation=installation, tenant_id=tenant_id, product_evidence=product_evidence)
|
||||
gateway.quota.record_kb_hit()
|
||||
session.commit()
|
||||
return result
|
||||
locally_known = bool(ev and ev["status"] in (kb.CORROBORATED, kb.EXTERNAL_CORROBORATED)) # identity + NCM candidate already local: only an NCM-bearing source can still add value
|
||||
query = g["gtin"] if len(g["gtin"]) in (8, 14) else key[1:] # providers/caches use the canonical EAN-13 (UPC-12 is zero-padded to it)
|
||||
found, pending = [], []
|
||||
if allow_external:
|
||||
for provider in gateway.providers:
|
||||
if locally_known and not getattr(provider, "provides_ncm", False):
|
||||
result["providers"].append({"provider": provider.name, "state": "SKIPPED", "reason": "LOCAL_IDENTITY_KNOWN", "from_cache": False})
|
||||
continue
|
||||
if budget_s is not None and time.monotonic() - started > budget_s:
|
||||
result["providers"].append({"provider": provider.name, "state": "SKIPPED", "reason": "TIME_BUDGET", "from_cache": False})
|
||||
pending.append(type("P", (), {"reason": "TIME_BUDGET", "retry_at": None})())
|
||||
continue
|
||||
out = gateway.lookup(provider, query)
|
||||
result["providers"].append({"provider": out.provider, "state": out.state, "reason": out.reason, "from_cache": out.from_cache})
|
||||
if out.state in ("FOUND", "CACHE_HIT") and out.product:
|
||||
found.append(out.product)
|
||||
product = kb.upsert_product(session, key, out.product.title or description, brand=out.product.brand, quantity=out.product.quantity)
|
||||
if out.product.ncm:
|
||||
kb.add_evidence(session, product, "ncm", out.product.ncm, f"provider:{out.provider}"[:32], family=f"PROVIDER:{out.provider}", info={"raw_hash": out.product.raw_hash})
|
||||
ev = kb.evaluate(session, product, "ncm", is_official)
|
||||
if ev["status"] == kb.VERIFIED_KB:
|
||||
break # enough evidence: spare the remaining quota
|
||||
elif out.state in ("SKIPPED", "ERROR"):
|
||||
pending.append(out)
|
||||
if product is None and description:
|
||||
product = kb.upsert_product(session, key, description)
|
||||
ev = kb.evaluate(session, product, "ncm", is_official) if product else {"value": None, "status": kb.UNVERIFIED, "families": [], "candidates": [], "rejected": []}
|
||||
result["fields"] = _fiscal_decisions(session, ev, False, current, description or "", product_code=product_code, installation=installation, tenant_id=tenant_id, product_evidence=product_evidence)
|
||||
if ev["status"] == kb.CONFLICT:
|
||||
result["state"] = "CONFLICT"
|
||||
elif ev["status"] == kb.VERIFIED_KB:
|
||||
result["state"] = "MULTI_SOURCE_MATCH" if found else "EXACT_GTIN_VERIFIED"
|
||||
elif len(found) >= 2:
|
||||
result["state"] = "MULTI_SOURCE_MATCH"
|
||||
elif found:
|
||||
result["state"] = "SINGLE_WEAK_SOURCE"
|
||||
elif pending and not found:
|
||||
quota = any(p.reason in ("RATE_LIMITED", "RATE_WINDOW") for p in pending)
|
||||
offline = [p for p in pending if p.reason not in ("NOT_CONFIGURED",)]
|
||||
if offline:
|
||||
result["state"] = "PENDING_PROVIDER_QUOTA" if quota else "PENDING_PROVIDERS_OFFLINE"
|
||||
result["retry_at"] = min((p.retry_at for p in offline if p.retry_at), default=None)
|
||||
session.commit()
|
||||
return result
|
||||
|
||||
if g["status"] == "INVALID_GTIN":
|
||||
result["state"] = "INVALID_GTIN" # never spends provider quota on an invalid GTIN
|
||||
|
||||
# ---- name only: only a UNIQUE, specific description with a VERIFIED_KB fingerprint may fill anything
|
||||
if not textnorm.is_specific(description):
|
||||
if result["state"] != "INVALID_GTIN":
|
||||
result["state"] = "AMBIGUOUS"
|
||||
result["fields"] = {f: _field(INSUFFICIENT, reason="descrição genérica demais e sem GTIN válido") if _empty(current, f) else _field(ALREADY) for f in FIELDS}
|
||||
return result
|
||||
candidates = kb.name_candidates(session, description)
|
||||
verified = []
|
||||
for cand in candidates:
|
||||
ev = kb.evaluate(session, cand, "ncm", is_official)
|
||||
if ev["status"] == kb.VERIFIED_KB:
|
||||
verified.append((cand, ev))
|
||||
if candidates and len(verified) == len(candidates) and len({ev["value"] for _, ev in verified}) == 1:
|
||||
_, ev = verified[0]
|
||||
result["state"] = "EXACT_KB_NAME_VERIFIED"
|
||||
result["fields"] = _fiscal_decisions(session, ev, True, current, description or "", name_verified=True, product_code=product_code, installation=installation, tenant_id=tenant_id, product_evidence=product_evidence)
|
||||
elif len({ev["value"] for _, ev in verified}) > 1:
|
||||
result["state"] = "CONFLICT"
|
||||
result["fields"] = {f: _field(CONFLICT, reason="descrição corresponde a produtos com NCM divergentes") if _empty(current, f) else _field(ALREADY) for f in FIELDS}
|
||||
else:
|
||||
if result["state"] != "INVALID_GTIN":
|
||||
result["state"] = "AMBIGUOUS" if len(candidates) > 1 else "NOT_FOUND"
|
||||
result["fields"] = {f: _field(INSUFFICIENT, reason="sem correspondência verificada na base de conhecimento") if _empty(current, f) else _field(ALREADY) for f in FIELDS}
|
||||
session.commit()
|
||||
return result
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Human-facing status of every tracked product: nothing may silently 'do nothing'. Computed from the resolver result and refined when the Agent reports the write outcome."""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select, update
|
||||
|
||||
LABELS = {
|
||||
"ENRIQUECIDO": "Enriquecido: campos seguros preenchidos.",
|
||||
"PRONTO_PARA_PREENCHER": "Evidência suficiente: aguardando a escrita segura pelo Agent.",
|
||||
"SIMULADO": "Simulação (DRY_RUN): há candidato, nada foi gravado.",
|
||||
"AGUARDANDO_EVIDENCIA": "Aguardando evidência: nenhuma fonte confiável ainda.",
|
||||
"EAN_AUSENTE": "Sem EAN e sem produto único verificado na base: informe o código de barras ou peça a decisão de um administrador.",
|
||||
"EAN_INVALIDO": "Código de barras inválido: nenhuma fonte externa foi consultada.",
|
||||
"NOME_AMBIGUO": "Nome genérico ou ambíguo demais para identificar o produto com segurança.",
|
||||
"SEM_MATCH_DE_PRODUTO": "Nenhum produto conhecido corresponde a este nome/EAN.",
|
||||
"NCM_NAO_VERIFICADO": "Produto identificado, mas o NCM ainda não foi verificado (falta segunda fonte independente ou decisão de administrador).",
|
||||
"CONFLITO_DE_NCM": "As fontes divergem sobre o NCM: nada é preenchido até um administrador decidir.",
|
||||
"PROVIDER_SEM_QUOTA": "Limite diário das fontes externas atingido; será repetido após o reset.",
|
||||
"PROVIDER_NAO_CONFIGURADO": "Nenhuma fonte externa com NCM está configurada.",
|
||||
"AGUARDANDO_RETRY": "Fontes indisponíveis agora; nova tentativa automática agendada.",
|
||||
"HUMAN_OVERRIDE": "Alterado por uma pessoa: nunca mais preenchido automaticamente.",
|
||||
"TECHNICAL_PLACEHOLDER": "NCM provisório (00000000) reconhecido como 'ainda não classificado'.",
|
||||
"WRITE_FAILED": "A escrita segura foi recusada ou falhou (veja o motivo).",
|
||||
"VERIFICACAO_POS_WRITE_FALHOU": "A escrita foi feita mas a releitura não confirmou o valor.",
|
||||
"NADA_A_PREENCHER": "Todos os campos elegíveis já estão preenchidos.",
|
||||
"FUNCAO_DESATIVADA": "Enriquecimento desativado para esta instalação.",
|
||||
}
|
||||
|
||||
|
||||
def classify(result: dict, *, has_gtin: bool, specific_name: bool) -> tuple[str, str]:
|
||||
"""(ui_status, reason_code) from a resolve() result."""
|
||||
fields = result.get("fields") or {}
|
||||
ncm = fields.get("CLASSIFICACAO_FISCAL") or {}
|
||||
state = result.get("state")
|
||||
if state == "FEATURE_DISABLED":
|
||||
return "FUNCAO_DESATIVADA", state
|
||||
if state == "INVALID_GTIN":
|
||||
return "EAN_INVALIDO", state
|
||||
if state == "PENDING_PROVIDER_QUOTA":
|
||||
return "PROVIDER_SEM_QUOTA", state
|
||||
if state in ("PENDING_PROVIDERS_OFFLINE", "PENDING_TIME_BUDGET"):
|
||||
return "AGUARDANDO_RETRY", state
|
||||
if state == "CONFLICT" or ncm.get("status") == "CONFLICT":
|
||||
return "CONFLITO_DE_NCM", "CONFLICT"
|
||||
if ncm.get("status") == "HUMAN_OVERRIDDEN":
|
||||
return "HUMAN_OVERRIDE", "HUMAN_OVERRIDDEN"
|
||||
if any(f.get("status") == "AUTO_FILL_SAFE" for f in fields.values()):
|
||||
return "PRONTO_PARA_PREENCHER", state or "RESOLVED"
|
||||
if ncm.get("status") == "ALREADY_SET" and not any(f.get("status") in ("AUTO_FILL_SAFE", "SUGGESTION_ONLY") for f in fields.values()):
|
||||
return "NADA_A_PREENCHER", "ALL_FIELDS_SET"
|
||||
if not has_gtin:
|
||||
if state == "AMBIGUOUS" or not specific_name:
|
||||
return "NOME_AMBIGUO", "GENERIC_OR_AMBIGUOUS_NAME"
|
||||
return "EAN_AUSENTE", "NAME_ONLY_NO_VERIFIED_MATCH"
|
||||
if ncm.get("status") == "SUGGESTION_ONLY":
|
||||
return "NCM_NAO_VERIFICADO", ncm.get("rule") or "NCM_NOT_VERIFIED"
|
||||
if state == "NOT_FOUND":
|
||||
return "SEM_MATCH_DE_PRODUTO", "GTIN_UNKNOWN"
|
||||
return "AGUARDANDO_EVIDENCIA", state or "INSUFFICIENT_EVIDENCE"
|
||||
|
||||
|
||||
def after_write(outcomes: list[dict], mode: str) -> tuple[str | None, str | None]:
|
||||
"""Refines the status with what the Agent reported (APPLIED / DRY_RUN / ABORTED / FAILED / HUMAN_OVERRIDDEN)."""
|
||||
kinds = {o.get("outcome") for o in outcomes}
|
||||
reasons = [o.get("reason") for o in outcomes if o.get("reason")]
|
||||
if "HUMAN_OVERRIDDEN" in kinds and "APPLIED" not in kinds:
|
||||
return "HUMAN_OVERRIDE", "HUMAN_OVERRIDDEN"
|
||||
if "APPLIED" in kinds:
|
||||
return "ENRIQUECIDO", None
|
||||
if "FAILED" in kinds:
|
||||
return ("VERIFICACAO_POS_WRITE_FALHOU" if "POST_WRITE_VERIFICATION_FAILED" in reasons else "WRITE_FAILED"), (reasons[0] if reasons else "FAILED")
|
||||
if "ABORTED" in kinds:
|
||||
return "WRITE_FAILED", (reasons[0] if reasons else "ABORTED")
|
||||
if "DRY_RUN" in kinds:
|
||||
return "SIMULADO", "DRY_RUN_NO_WRITE"
|
||||
return None, None
|
||||
|
||||
|
||||
def mark_for_reevaluation(session, installation_id: int, *, codes: list[str] | None = None, gtin: str | None = None) -> int:
|
||||
"""Flags tracked, NOT-yet-enriched products for a new evaluation (KB/cache first; providers only when really needed). Idempotent; never touches enriched / human-overridden ones."""
|
||||
from ..models import ProductEnrichment
|
||||
q = update(ProductEnrichment).where(ProductEnrichment.installation_id == installation_id, ProductEnrichment.ui_status.notin_(["ENRIQUECIDO", "HUMAN_OVERRIDE"]) | ProductEnrichment.ui_status.is_(None))
|
||||
if codes:
|
||||
q = q.where(ProductEnrichment.product_code.in_(codes))
|
||||
if gtin:
|
||||
q = q.where(ProductEnrichment.gtin == gtin)
|
||||
return session.execute(q.values(reevaluate=True)).rowcount or 0
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Deterministic product-description normalization and fingerprint (no ML). Used to FIND candidates; never authorizes a fiscal value on its own."""
|
||||
import hashlib
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
UNIT_MAP = {"LT": "L", "LTS": "L", "LITRO": "L", "LITROS": "L", "L": "L", "ML": "ML", "KG": "KG", "KGS": "KG", "QUILO": "KG", "G": "G", "GR": "G", "GRS": "G", "GRAMAS": "G", "MG": "MG", "UN": "UN", "UND": "UN", "UNID": "UN", "UNIDADE": "UN", "PCT": "PCT", "CX": "CX"}
|
||||
ABBREVIATIONS = {"REFRIG": "REFRIGERANTE", "REFRI": "REFRIGERANTE", "CHOC": "CHOCOLATE", "BISC": "BISCOITO", "SAB": "SABONETE", "DET": "DETERGENTE", "SH": "SHAMPOO", "COND": "CONDICIONADOR",
|
||||
"MACARR": "MACARRAO", "MAC": "MACARRAO", "AMAC": "AMACIANTE", "DESOD": "DESODORANTE", "PAP": "PAPEL", "HIG": "HIGIENICO", "ORIG": "ORIGINAL", "TRAD": "TRADICIONAL"}
|
||||
STOP = {"DE", "DA", "DO", "DAS", "DOS", "E", "COM", "C", "PARA", "P", "EM", "A", "O"}
|
||||
MARKETING = {"ORIGINAL", "TRADICIONAL", "NOVO", "NOVA", "PROMO", "PROMOCAO", "OFERTA"} # ignored ONLY by the fingerprint; a different pack/flavour still differs
|
||||
_QTY = re.compile(r"^(\d+(?:[.,]\d+)?)(ML|L|KG|G|MG|UN|PCT|CX|LT|LTS|GR)$")
|
||||
|
||||
|
||||
def _ascii_upper(text) -> str:
|
||||
return unicodedata.normalize("NFKD", str(text or "")).encode("ascii", "ignore").decode().upper()
|
||||
|
||||
|
||||
def tokens(description) -> list[str]:
|
||||
text = _ascii_upper(description)
|
||||
text = re.sub(r"(\d)([A-Z])", r"\1 \2", text) # 350ML -> 350 ML
|
||||
text = re.sub(r"([A-Z])(\d)", r"\1 \2", text)
|
||||
text = re.sub(r"(\d)[,.](\d)", r"\1.\2", text) # decimal comma -> dot (kept inside the number)
|
||||
text = re.sub(r"[^A-Z0-9.]+", " ", text)
|
||||
out = []
|
||||
for tok in text.split():
|
||||
tok = tok.strip(".")
|
||||
if not tok or tok in STOP:
|
||||
continue
|
||||
tok = UNIT_MAP.get(tok, ABBREVIATIONS.get(tok, tok))
|
||||
out.append(tok)
|
||||
return out
|
||||
|
||||
|
||||
def normalized(description) -> str:
|
||||
return " ".join(tokens(description))
|
||||
|
||||
|
||||
def fingerprint(description) -> str | None:
|
||||
toks = [t for t in tokens(description) if t not in MARKETING]
|
||||
if not toks:
|
||||
return None
|
||||
return hashlib.sha256(" ".join(sorted(toks)).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def is_specific(description) -> bool:
|
||||
"""A description strong enough to be used as an identity key: at least 3 informative tokens AND a quantity (number + unit), or >= 4 tokens.
|
||||
Generic names (PIPOCA, OLEO, REFRIGERANTE, SABONETE) are NEVER specific."""
|
||||
toks = tokens(description)
|
||||
has_qty = any(re.fullmatch(r"\d+(?:\.\d+)?", t) and i + 1 < len(toks) and toks[i + 1] in UNIT_MAP.values() for i, t in enumerate(toks))
|
||||
informative = [t for t in toks if not re.fullmatch(r"\d+(?:\.\d+)?", t) and t not in UNIT_MAP.values()]
|
||||
return (len(informative) >= 2 and has_qty and len(toks) >= 3) or len(informative) >= 4
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Secrets and package bundling for Agent distribution.
|
||||
|
||||
Two independent secrets guard the distribution flow, both random(256 bits),
|
||||
single-use and short-TTL, both stored only as SHA-256 hashes:
|
||||
|
||||
- the *download* token: embedded in the shareable link an operator hands to
|
||||
whoever installs the Agent (or clicked directly, authenticated, from the
|
||||
panel);
|
||||
- the *enrollment* token: embedded inside the downloaded bundle as
|
||||
`enrollment.json`, consumed exactly once by `POST /v1/agents/enroll`.
|
||||
|
||||
Neither token is ever the Agent's own permanent Bearer token (issued only
|
||||
after a successful enrollment) and neither is ever logged or audited in
|
||||
plaintext.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
class EnrollmentRejected(Exception):
|
||||
def __init__(self, reason: str):
|
||||
super().__init__(reason)
|
||||
self.reason = reason
|
||||
|
||||
|
||||
def new_token() -> str:
|
||||
"""256 bits of randomness, URL-safe — usable directly in a link or a JSON field."""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
# ---- installation code (XXXX-XXXX-XXXX-XXXX) ----
|
||||
#
|
||||
# A 16-character Crockford Base32 code = 16 * 5 = 80 bits of entropy. The
|
||||
# alphabet deliberately excludes I/L/O/U (ambiguous when retyped by a human);
|
||||
# the same alphabet is used so a code cannot contain those letters.
|
||||
|
||||
INSTALL_CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
||||
_INSTALL_CODE_GROUPS = 4
|
||||
_INSTALL_CODE_PER_GROUP = 4
|
||||
_INSTALL_CODE_LENGTH = _INSTALL_CODE_GROUPS * _INSTALL_CODE_PER_GROUP
|
||||
# Crockford transliteration for input normalization: O -> 0, I/L -> 1.
|
||||
_INSTALL_CODE_TRANSLIT = str.maketrans({"O": "0", "I": "1", "L": "1"})
|
||||
_INSTALL_CODE_RE = re.compile(
|
||||
r"^[" + re.escape(INSTALL_CODE_ALPHABET) + r"]{" + str(_INSTALL_CODE_LENGTH) + r"}$"
|
||||
)
|
||||
|
||||
|
||||
def generate_install_code() -> str:
|
||||
"""Return a fresh installation code formatted XXXX-XXXX-XXXX-XXXX."""
|
||||
chars = "".join(secrets.choice(INSTALL_CODE_ALPHABET) for _ in range(_INSTALL_CODE_LENGTH))
|
||||
return "-".join(
|
||||
chars[i : i + _INSTALL_CODE_PER_GROUP] for i in range(0, _INSTALL_CODE_LENGTH, _INSTALL_CODE_PER_GROUP)
|
||||
)
|
||||
|
||||
|
||||
def normalize_install_code(text: str | None) -> str:
|
||||
"""Upper-case, drop hyphen/space separators and map Crockford lookalikes,
|
||||
so a human-typed code normalizes to the same digest regardless of how it
|
||||
was entered. Raises ValueError if the result isn't a valid code."""
|
||||
if not text:
|
||||
raise ValueError("empty install code")
|
||||
clean = "".join(ch for ch in text.upper().translate(_INSTALL_CODE_TRANSLIT) if ch.isalnum())
|
||||
if not _INSTALL_CODE_RE.match(clean):
|
||||
raise ValueError(f"invalid install code: {text!r}")
|
||||
return clean
|
||||
|
||||
|
||||
def install_code_digest(code: str) -> str:
|
||||
"""Hash a normalized install code. Only the hash is ever persisted; the
|
||||
plaintext code exists only in the operator's clipboard/UI and the EXE's
|
||||
memory for the brief instant it is sent to the Hub."""
|
||||
return token_digest(normalize_install_code(code))
|
||||
|
||||
|
||||
def valid_install_code(text: str | None) -> bool:
|
||||
try:
|
||||
normalize_install_code(text)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def token_digest(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
def token_matches(token: str | None, stored_hash: str | None) -> bool:
|
||||
if not token or not stored_hash:
|
||||
return False
|
||||
return hmac.compare_digest(token_digest(token), stored_hash)
|
||||
|
||||
|
||||
ENROLLMENT_UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
|
||||
|
||||
|
||||
def valid_uuid(value: str | None) -> bool:
|
||||
return bool(value) and bool(ENROLLMENT_UUID_RE.match(value))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BundlePlan:
|
||||
hub_url: str
|
||||
enrollment_uuid: str
|
||||
enrollment_token: str
|
||||
enroll_path: str = "/v1/agents/enroll"
|
||||
|
||||
|
||||
def build_bundle(release_zip_bytes: bytes, plan: BundlePlan) -> bytes:
|
||||
"""Return a new zip = the release zip's entries plus one
|
||||
`<package-root>/enrollment.json` holding only temporary, single-use data.
|
||||
Never a permanent credential — that is issued only once enrollment succeeds."""
|
||||
with zipfile.ZipFile(io.BytesIO(release_zip_bytes)) as source:
|
||||
names = source.namelist()
|
||||
if not names:
|
||||
raise EnrollmentRejected("EMPTY_RELEASE_PACKAGE")
|
||||
root = names[0].split("/", 1)[0]
|
||||
out = io.BytesIO()
|
||||
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as dest:
|
||||
for name in names:
|
||||
dest.writestr(name, source.read(name))
|
||||
payload = {
|
||||
"installation_id": plan.enrollment_uuid,
|
||||
"enrollment_token": plan.enrollment_token,
|
||||
"hub_url": plan.hub_url,
|
||||
"enroll_path": plan.enroll_path,
|
||||
}
|
||||
dest.writestr(f"{root}/enrollment.json", json.dumps(payload, indent=1))
|
||||
return out.getvalue()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user