chore: establish project baseline
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,14 @@
|
||||
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_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()
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,11 @@
|
||||
"""initial hub persistence
|
||||
|
||||
Revision ID: 0001
|
||||
"""
|
||||
from alembic import op
|
||||
from central.app.models import Base
|
||||
revision = "0001"
|
||||
down_revision = None
|
||||
branch_labels = depends_on = None
|
||||
def upgrade(): Base.metadata.create_all(op.get_bind())
|
||||
def downgrade(): Base.metadata.drop_all(op.get_bind())
|
||||
@@ -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 @@
|
||||
"""Fiscal Hub application modules."""
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Authenticated version-one Hub routes."""
|
||||
import hashlib, json, logging
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
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 claim_next_job, complete_job, create_job, fail_job
|
||||
from .context_keys import validate_entry
|
||||
from .models import FiscalContextEntry, ClassificationCache, ClassificationRequest, ClassificationResult, Dataset, Job
|
||||
|
||||
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:
|
||||
agent.last_seen_at = datetime.now(timezone.utc)
|
||||
agent.last_status = 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"]
|
||||
session.add(agent); session.commit()
|
||||
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, "last_seen_at": agent.last_seen_at.isoformat(), "enabled": agent.enabled}
|
||||
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 _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,
|
||||
}
|
||||
|
||||
@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
|
||||
logger.info("job claimed job_id=%s agent_id=%s kind=%s", job.id, agent.id, job.kind)
|
||||
return _job_view(job)
|
||||
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")
|
||||
if not complete_job(session, job, payload.get("result_summary", {})): raise HTTPException(409, "job is not RUNNING")
|
||||
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")
|
||||
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()
|
||||
|
||||
return routes
|
||||
@@ -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,131 @@
|
||||
"""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 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
|
||||
else:
|
||||
status, candidate = ("INVALID_NCM", None) if code else self._candidate(entries, product.get("description"))
|
||||
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,36 @@
|
||||
"""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"))
|
||||
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"))
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,66 @@
|
||||
"""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"},
|
||||
"entry_scope": {"INTRA", "INTER"},
|
||||
"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"},
|
||||
}
|
||||
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"}
|
||||
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":
|
||||
try:
|
||||
prof = json.loads(value)
|
||||
except ValueError:
|
||||
return None, "pis_cofins_profile must be JSON"
|
||||
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)}"
|
||||
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 @@
|
||||
"""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,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,176 @@
|
||||
"""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
|
||||
return MocTablesPayload(sha256=sha256(pdf_bytes).hexdigest(), entries=tuple(entries))
|
||||
@@ -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,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,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,532 @@
|
||||
"""Fiscal Field Contract V1: one contract + one resolver for EVERY persistent fiscal field of the SGV product screen (C000025).
|
||||
|
||||
Pure functions. Every (product, field) ends in exactly one of:
|
||||
ALREADY_CORRECT | CHANGE_REQUIRED | NOT_APPLICABLE | MANUAL_INPUT_REQUIRED(<input_key>) (CORRECTED is set by the writer after a verified write)
|
||||
Nothing is invented: a value is proposed only when an official dataset, the company context, ERP evidence or an OPERATOR context entry proves it.
|
||||
A valid ERP declaration that no proven evidence contradicts is ALREADY_CORRECT (reason DECLARED_VALID_NOT_CONTRADICTED); the report keeps
|
||||
those apart from rule-proven results.
|
||||
|
||||
Operator context (Hub `fiscal_context_entries`) is hierarchical: product > product group (CODGRUPO) > company.
|
||||
"""
|
||||
from .fiscal_decision import _ex_described # noqa: F401 (description evidence helper shared with the decision engine)
|
||||
|
||||
CONTRACT_VERSION = "field-contract-v1"
|
||||
ALREADY, CHANGE, CORRECTED, NA, MANUAL = "ALREADY_CORRECT", "CHANGE_REQUIRED", "CORRECTED", "NOT_APPLICABLE", "MANUAL_INPUT_REQUIRED"
|
||||
|
||||
WRITE_FIELDS = ["CLASSIFICACAO_FISCAL", "IPI", "EX_TIPI", "CEST", "SITUACAO_TRIBUTARIA", "CFOP", "CFOP_ENTRADA", "CST", "CST_ENTRADA", "CSOSN", "ALIQUOTA", "COD_ORIGEM_MERCADORIA",
|
||||
"CST_IPI", "COD_ENTRADA_PIS", "COD_SAIDA_PIS", "ALIQ_PIS", "COD_ENTRADA_COFINS", "COD_SAIDA_COFINS", "ALIQ_COFINS", "INCIDENCIA_PISCOFINS", "PISCOFINS", "IAT", "IPPT"]
|
||||
|
||||
# ---- contract metadata (docs/FISCAL_FIELD_CONTRACT_V1.json is generated from this) ------------------------------------------------------------------
|
||||
def _c(ui, sem, dtype, source, inputs, rule, valid, na, manual, validation, prov, write=True):
|
||||
return {"UI_LABEL": ui, "SEMANTICS": sem, "DATA_TYPE": dtype, "SOURCE_OF_TRUTH": source, "REQUIRED_INPUTS": inputs, "RESOLUTION_RULE": rule, "VALID_VALUES": valid,
|
||||
"NOT_APPLICABLE_RULE": na, "MANUAL_INPUT_RULE": manual, "WRITE_ALLOWED": write, "VALIDATION_RULE": validation, "PROVENANCE": prov}
|
||||
|
||||
|
||||
CONTRACT = {
|
||||
"CLASSIFICACAO_FISCAL": _c("NCM", "NCM canônico (8 dígitos)", "VARCHAR(20)", "dataset NCM (Siscomex) ACTIVE + legacy NCM table", ["current NCM"],
|
||||
"VALID -> ALREADY_CORRECT. LEGACY/INVALID/MISSING never auto-replaced from the description; an OPERATOR context `ncm` that the Hub validates as EXACT_NCM becomes CHANGE_REQUIRED and every dependent field is reprocessed with it",
|
||||
"8-digit code present in the ACTIVE NCM dataset", "never (every product has an NCM)", "MANUAL_INPUT_REQUIRED(ncm) for LEGACY/INVALID/MISSING or an invalid manual value", "Hub classify status == EXACT_NCM", "NCM dataset sha256 + engine version"),
|
||||
"IPI": _c("Alíquota IPI %", "alíquota numérica de IPI", "DOUBLE PRECISION", "dataset TIPI_IPI ACTIVE", ["valid NCM", "TIPI row (and EX choice when several rows)"],
|
||||
"TIPI numeric rate -> ALREADY_CORRECT if equal else CHANGE_REQUIRED. NT/non-numeric and blank rates never write (NOT_APPLICABLE, never zero)", "float >= 0", "TIPI rate is NT/non-numeric or blank", "MANUAL_INPUT_REQUIRED(ex_tipi) when several TIPI rows exist for the NCM", "numeric, from TIPI row", "TIPI dataset sha256"),
|
||||
"EX_TIPI": _c("EX TIPI", "código EX da TIPI", "VARCHAR(3)", "dataset TIPI_IPI + product description evidence", ["valid NCM", "TIPI EX rows"],
|
||||
"EX applies only when the product description carries the official EX description; otherwise the base rate applies (no EX)", "EX code of an existing TIPI EX row", "NCM has no EX row, or the description evidences none",
|
||||
"MANUAL_INPUT_REQUIRED(ex_tipi) when the ERP holds an EX that no TIPI row supports", "value in TIPI EX rows for the NCM", "TIPI dataset sha256"),
|
||||
"CEST": _c("CEST", "CEST (7 dígitos sem pontuação)", "VARCHAR(7)", "dataset CEST (CONFAZ 142/18) ACTIVE + BA_ST_MVA", ["valid NCM", "description"],
|
||||
"single defensible official CEST -> ALREADY/CHANGE; ERP CEST that is one of the official codes of the NCM -> ALREADY_CORRECT; several official candidates with a blank CEST -> MANUAL; NCM absent from the CEST list -> NOT_APPLICABLE",
|
||||
"official CEST code of the NCM", "NCM not in the CEST list, or the description matches no official CEST item", "MANUAL_INPUT_REQUIRED(cest) when several official candidates exist, or the ERP CEST is not official for the NCM", "digits equal to an official CEST of the NCM", "CEST dataset sha256"),
|
||||
"SITUACAO_TRIBUTARIA": _c("Seletor ao lado do CEST", "T=Tributado, I=Isento, N=Não tributado, S=Substituição Tributária", "VARCHAR(2)", "ST engine (BA Anexo 1 + CEST corroboration) + purchase evidence / operator st_withheld", ["ST status", "ST withheld"],
|
||||
"ST proven and withheld -> S. Otherwise a valid declaration stays", "T | I | N | S", "never", "MANUAL_INPUT_REQUIRED(st_withheld) when ST is proven but retention unknown; (icms_treatment) when the field is blank", "value in T/I/N/S", "BA_ST_MVA + CEST dataset sha256"),
|
||||
"CFOP": _c("CFOP (Nota Fiscal de Saída)", "CFOP padrão de saída (intra-UF)", "VARCHAR(4)", "dataset CFOP oficial + product role + ST", ["product_role", "ST status", "ST withheld"],
|
||||
"RESALE: non-ST 5102, ST withheld 5405; OWN_PRODUCTION: 5101 / 5401; a declared official 5xxx that is not contradicted by proven role/ST stays", "official CFOP starting with 5", "INPUT (not a sale item)", "MANUAL_INPUT_REQUIRED(product_role | st_status | st_withheld | service_cfop)", "code in CFOP dataset", "CFOP dataset sha256"),
|
||||
"CFOP_ENTRADA": _c("CFOP (Nota Fiscal de Entrada)", "CFOP padrão de entrada", "CHAR(4)", "dataset CFOP + product role + ST + company entry scope", ["product_role", "ST withheld", "entry_scope (company)"],
|
||||
"RESALE 1102/2102, ST withheld 1403/2403, OWN_PRODUCTION/INPUT 1101/2101 (ST 1401/2401); declared official 1xxx/2xxx that is not contradicted stays", "official CFOP starting with 1 or 2", "SERVICE", "MANUAL_INPUT_REQUIRED(product_role | entry_scope | st_withheld)", "code in CFOP dataset", "CFOP dataset sha256"),
|
||||
"CST": _c("CST de ICMS (Nota Fiscal de Saída)", "CST ICMS = origem(1)+CST(2), regime normal", "VARCHAR(10)", "MOC CST_ICMS + ORIGEM tables + icms_treatment", ["CRT 2/3", "icms_treatment", "origin"],
|
||||
"CRT 1 uses CSOSN. CRT 2/3: treatment TAXED 00 / EXEMPT 40 / NOT_TAXED 41 / ST_WITHHELD 60; a valid declaration that the treatment does not contradict stays", "3 digits: origin 0-8 + CST_ICMS code", "CRT = 1", "MANUAL_INPUT_REQUIRED(icms_treatment | origin) when blank and not derivable", "origin in ORIGEM table, last two digits in CST_ICMS", "MOC tables sha256"),
|
||||
"CST_ENTRADA": _c("CST de ICMS (Nota Fiscal de Entrada)", "CST ICMS de entrada, regime normal", "CHAR(4)", "MOC tables + icms_treatment", ["CRT 2/3", "icms_treatment", "origin"],
|
||||
"same derivation as CST for CRT 2/3", "3 digits origin+CST", "CRT = 1", "MANUAL_INPUT_REQUIRED(icms_treatment | origin)", "as CST", "MOC tables sha256"),
|
||||
"CSOSN": _c("CSOSN Classificação", "CSOSN de saída (Simples Nacional)", "VARCHAR(60)", "MOC CSOSN table + icms_treatment (operator or proven ST)", ["CRT 1", "icms_treatment"],
|
||||
"treatment TAXED -> 101/102/103 family (default 102), EXEMPT/NOT_TAXED -> 300/400 (default 400), ST_WITHHELD -> 500; a valid declaration outside a proven contradiction stays", "CSOSN code in MOC table", "CRT != 1", "MANUAL_INPUT_REQUIRED(icms_treatment) when blank/invalid", "code in CSOSN table", "MOC tables sha256"),
|
||||
"ALIQUOTA": _c("Aliq. ICMS", "alíquota ICMS % de destaque", "NUMERIC(15,2)", "regime + CSOSN/CST family + operator icms_rate", ["CSOSN or CST family", "icms_rate (company/group) for taxed regime-normal items"],
|
||||
"no ICMS highlighted (CSOSN 102/103/300/400/500/202/203/900 or CST 40/41/60/50/51) -> NOT_APPLICABLE; taxed items keep a declared value or take the operator icms_rate", "numeric >= 0", "no ICMS is highlighted for the item", "MANUAL_INPUT_REQUIRED(icms_rate | credit_rate)", "numeric", "operator context / company regime"),
|
||||
"COD_ORIGEM_MERCADORIA": _c("Código da origem da Mercadoria (0/1/2)", "origem da mercadoria (0 Nacional, 1 Estrangeira-importação direta, 2 Estrangeira-mercado interno)", "INTEGER", "operator origin only (no ERP field carries origin evidence)", ["origin"],
|
||||
"declared 0/1/2 stays; blank needs operator/evidence: the CST first digit is padding in the SGV (audit V2) and is NOT origin evidence; never defaulted to 0", "0 | 1 | 2", "never", "MANUAL_INPUT_REQUIRED(origin)", "value in {0,1,2}", "SGV DFM: hidden edit CodOrigemMercadoria + RgOrigemMercadoria.OnClick"),
|
||||
"CST_IPI": _c("CST IPI", "CST do IPI", "CHAR(3)", "company C000004.CONTRIBUINTE_IPI", ["company IPI taxpayer flag"],
|
||||
"company not an IPI taxpayer (CONTRIBUINTE_IPI != S) -> NOT_APPLICABLE; taxpayer -> declared value stays", "IPI CST code", "company is not an IPI taxpayer", "MANUAL_INPUT_REQUIRED(cst_ipi) for IPI taxpayers with a blank value", "non-blank code", "C000004.CONTRIBUINTE_IPI"),
|
||||
"COD_ENTRADA_PIS": _c("Pis Entrada", "CST PIS de entrada", "VARCHAR(3)", "MOC CST_PIS + operator pis_cofins_profile", ["pis_cofins_profile"], "profile in_cst (validated against CST_PIS) -> ALREADY/CHANGE; else a valid declared code stays", "CST PIS code", "never", "MANUAL_INPUT_REQUIRED(pis_cofins_profile)", "code in CST_PIS table", "MOC tables sha256"),
|
||||
"COD_SAIDA_PIS": _c("Pis Saída", "CST PIS de saída", "VARCHAR(3)", "MOC CST_PIS + operator pis_cofins_profile", ["pis_cofins_profile"], "profile out_cst -> ALREADY/CHANGE; else a valid declared code stays", "CST PIS code", "never", "MANUAL_INPUT_REQUIRED(pis_cofins_profile)", "code in CST_PIS table", "MOC tables sha256"),
|
||||
"ALIQ_PIS": _c("Alíquota (Pis)", "alíquota PIS %", "VARCHAR(8)", "operator pis_cofins_profile", ["pis_cofins_profile"], "profile pis_rate -> ALREADY/CHANGE; else a declared value stays (never assumed 0)", "decimal text", "never", "MANUAL_INPUT_REQUIRED(pis_cofins_profile)", "numeric text", "operator context"),
|
||||
"COD_ENTRADA_COFINS": _c("Cofins Entrada", "CST COFINS de entrada", "VARCHAR(3)", "MOC CST_COFINS + operator pis_cofins_profile", ["pis_cofins_profile"], "profile in_cst -> ALREADY/CHANGE; else a valid declared code stays", "CST COFINS code", "never", "MANUAL_INPUT_REQUIRED(pis_cofins_profile)", "code in CST_COFINS table", "MOC tables sha256"),
|
||||
"COD_SAIDA_COFINS": _c("Cofins Saída", "CST COFINS de saída", "VARCHAR(3)", "MOC CST_COFINS + operator pis_cofins_profile", ["pis_cofins_profile"], "profile out_cst -> ALREADY/CHANGE; else a valid declared code stays", "CST COFINS code", "never", "MANUAL_INPUT_REQUIRED(pis_cofins_profile)", "code in CST_COFINS table", "MOC tables sha256"),
|
||||
"ALIQ_COFINS": _c("Alíquota (Cofins)", "alíquota COFINS %", "VARCHAR(8)", "operator pis_cofins_profile", ["pis_cofins_profile"], "profile cofins_rate -> ALREADY/CHANGE; else a declared value stays (never assumed 0)", "decimal text", "never", "MANUAL_INPUT_REQUIRED(pis_cofins_profile)", "numeric text", "operator context"),
|
||||
"INCIDENCIA_PISCOFINS": _c("Incidência", "tipo de incidência PIS/COFINS", "VARCHAR(30)", "ERP declaration (NENHUMA / MONOFÁSICA / SUBST.TRIBUTÁRIA) + operator incidence", ["incidence (group) when blank"], "valid declaration stays; operator `incidence` may set it", "NENHUMA | MONOFÁSICA | SUBST.TRIBUTÁRIA", "never", "MANUAL_INPUT_REQUIRED(incidence)", "value in domain", "ERP domain observed in 4 databases"),
|
||||
"PISCOFINS": _c("PIS/COFINS (combo)", "flag PIS/COFINS N/S", "VARCHAR(1)", "ERP declaration + operator piscofins", ["piscofins when blank"], "valid N/S stays; operator `piscofins` may set it", "N | S", "never", "MANUAL_INPUT_REQUIRED(piscofins)", "value in N/S", "ERP domain observed in 4 databases"),
|
||||
"IAT": _c("IAT", "indicador de arredondamento (A) ou truncamento (T)", "VARCHAR(1)", "company rounding policy (operator) or ERP declaration", ["rounding (company) when blank"], "valid A/T stays; blank -> operator `rounding`", "A | T", "never", "MANUAL_INPUT_REQUIRED(rounding)", "value in A/T", "PAF-ECF/Convênio registro de produto layout (IAT)"),
|
||||
"IPPT": _c("IPPT", "indicador de produção própria (P) ou de terceiros (T)", "VARCHAR(1)", "product_role (own production evidence / purchase evidence / operator)", ["product_role"],
|
||||
"OWN_PRODUCTION -> P, RESALE -> T; INPUT/SERVICE not applicable", "P | T", "INPUT or SERVICE", "MANUAL_INPUT_REQUIRED(product_role)", "value in P/T", "PAF-ECF/Convênio registro de produto layout (IPPT)"),
|
||||
}
|
||||
for _f, _v in CONTRACT.items():
|
||||
_v["ERP_FIELD"] = _f
|
||||
|
||||
RTC_CAPABILITY = {"RTC_CCLASSTRIB": "ERP_SCHEMA_NOT_READY", "RTC_CCREDPRES": "ERP_SCHEMA_NOT_READY", "RTC_CBS_RATES": "ERP_SCHEMA_NOT_READY"}
|
||||
|
||||
ORIGIN_UI = {"0", "1", "2"}
|
||||
CSOSN_FAMILY = {"TAXED": ("102", {"101", "102", "103"}), "EXEMPT": ("400", {"300", "400"}), "NOT_TAXED": ("400", {"300", "400"}), "ST_WITHHELD": ("500", {"500"}), "ST_CHARGED": ("202", {"201", "202", "203"})}
|
||||
CST_TREATMENT = {"TAXED": "00", "EXEMPT": "40", "NOT_TAXED": "41", "ST_WITHHELD": "60", "SUSPENDED": "50", "DEFERRED": "51"}
|
||||
NO_ICMS_CSOSN = {"102", "103", "300", "400", "500", "202", "203", "900"}
|
||||
NO_ICMS_CST = {"40", "41", "60", "50", "51"}
|
||||
RESALE_CFOP = {"5101", "5102", "5401", "5405"}
|
||||
ENTRY_CFOP = {"1101", "1102", "1401", "1403", "2101", "2102", "2401", "2403"}
|
||||
|
||||
|
||||
class Context:
|
||||
"""Operator context lookup: product > group > company."""
|
||||
|
||||
def __init__(self, entries=()):
|
||||
self._by = {}
|
||||
for e in entries:
|
||||
self._by[(e["scope"].upper(), str(e.get("scope_key") or ""), e["key"])] = e["value"]
|
||||
|
||||
def get(self, key, code=None, group=None, ncm=None):
|
||||
for scope, sk in (("PRODUCT", code), ("GROUP", group), ("NCM", ncm), ("COMPANY", "")):
|
||||
if scope != "COMPANY" and not sk:
|
||||
continue
|
||||
v = self._by.get((scope, str(sk or ""), key))
|
||||
if v is not None and str(v).strip() != "":
|
||||
return str(v).strip(), scope
|
||||
return None, None
|
||||
|
||||
|
||||
def _s(v):
|
||||
return None if v is None else str(v).strip()
|
||||
|
||||
|
||||
def _blank(v):
|
||||
return v is None or str(v).strip() == ""
|
||||
|
||||
|
||||
def _digits(v):
|
||||
return "".join(c for c in str(v or "") if c.isdigit())
|
||||
|
||||
|
||||
def _r(field, state, current, proposed=None, reason="", key=None, scope=None, **extra):
|
||||
return {"field": field, "state": state, "current": current, "proposed": proposed, "reason": reason, "input_key": key, "input_scope": scope,
|
||||
"write_eligible": state == CHANGE and CONTRACT[field]["WRITE_ALLOWED"], **extra}
|
||||
|
||||
|
||||
def infer_role(p, ev, ctx, code, group, ncm=None):
|
||||
v, scope = ctx.get("product_role", code, group, ncm)
|
||||
if v in ("RESALE", "OWN_PRODUCTION", "INPUT", "SERVICE"):
|
||||
return v, [f"OPERATOR({scope})"]
|
||||
if _s(p.get("PRODUTO_SERVICO")):
|
||||
return "SERVICE", ["C000025.PRODUTO_SERVICO"]
|
||||
if (_s(p.get("TIPO")) or "").upper() == "MATERIA-PRIMA":
|
||||
return "INPUT", ["C000025.TIPO=MATERIA-PRIMA"]
|
||||
own = [n for n, on in (("PROD_COZINHA=1", p.get("PROD_COZINHA") == 1), ("RECEITA_INGREDIENTES", code in ev.get("recipes", ())), ("C000032.TIPO=COMPOSICAO", code in ev.get("composed", ()))) if on]
|
||||
docs = bool(ev.get("purchases", {}).get(code)) or ev.get("stock_entry", {}).get(code, False)
|
||||
stock = ev.get("stock_in", {}).get(code, False)
|
||||
if own and docs:
|
||||
return "UNKNOWN", own + ["ALSO_PURCHASE_DOCUMENTS_CONFLICT"]
|
||||
strong_own = [n for n in own if n != "PROD_COZINHA=1"]
|
||||
if strong_own:
|
||||
return "OWN_PRODUCTION", own
|
||||
if own: # audit V2: the kitchen-routing flag alone is not proof of own production
|
||||
return "UNKNOWN", own + ["PROD_COZINHA_ALONE_NOT_PRODUCTION_PROOF"]
|
||||
if (_s(p.get("TIPO")) or "").upper() == "VENDA" and (docs or stock):
|
||||
return "RESALE", ["C000025.TIPO=VENDA", "PURCHASE_DOCUMENT" if docs else "STOCK_ENTRIES(C000100.ENTRADAS>0)"]
|
||||
return "UNKNOWN", ["NO_OBJECTIVE_ROLE_EVIDENCE"]
|
||||
|
||||
|
||||
def _isnum(v):
|
||||
try:
|
||||
float(v)
|
||||
return True
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
_SPED_CACHE = {}
|
||||
|
||||
|
||||
def sped_pis_cofins_match(ncm):
|
||||
"""Active SPED EFD-Contribuicoes table 4.3.10/12/13/14/15 entry (explicit NCM prefix) covering the NCM -> list of (table, saida_cst, code, start). Empty when none."""
|
||||
if "map" not in _SPED_CACHE:
|
||||
import json as _j
|
||||
from pathlib import Path as _P
|
||||
m = {}
|
||||
f = _P(__file__).resolve().parents[2] / "docs" / "sources" / "SPED_PISCOFINS_TABLES_V3.json"
|
||||
if f.exists():
|
||||
for tab, t in _j.loads(f.read_text(encoding="utf-8"))["tables"].items():
|
||||
for e in t.get("entries", []):
|
||||
if e["end"] is None:
|
||||
for pref in e["ncm_prefixes"]:
|
||||
m.setdefault(pref, set()).add((tab, t["saida_cst"][-1], e["code"], e["start"]))
|
||||
_SPED_CACHE["map"] = m
|
||||
hits = set()
|
||||
for n in (4, 6, 8):
|
||||
hits |= _SPED_CACHE["map"].get(ncm[:n], set()) if len(ncm) >= n else set()
|
||||
return sorted(hits)
|
||||
|
||||
|
||||
def resolve_product(p, d, company, refs, ev, ctx, manual_ncm=None):
|
||||
"""p: product row (all contract columns + CODIGO, PRODUTO, CODGRUPO, TIPO, PROD_COZINHA, PRODUTO_SERVICO). d: Hub decision for the EFFECTIVE NCM
|
||||
(the manual NCM when one was supplied and validated). manual_ncm: (value, is_valid) or None. Returns {field: result}."""
|
||||
from .fiscal_decision import decide_product
|
||||
from .icms_context import st_status, withheld_status
|
||||
code, group = _s(p["CODIGO"]), _s(p.get("CODGRUPO"))
|
||||
NCM = _digits(p.get("CLASSIFICACAO_FISCAL"))
|
||||
crt, uf = company.get("crt"), company.get("uf")
|
||||
out = {}
|
||||
cur = {f: _s(p.get(f)) for f in WRITE_FIELDS}
|
||||
|
||||
# ---------------- NCM ----------------
|
||||
ncm_ok = d.get("status") == "EXACT_NCM"
|
||||
if manual_ncm is not None:
|
||||
val, valid = manual_ncm
|
||||
if not valid:
|
||||
out["CLASSIFICACAO_FISCAL"] = _r("CLASSIFICACAO_FISCAL", MANUAL, cur["CLASSIFICACAO_FISCAL"], reason="MANUAL_NCM_NOT_VALID_IN_NCM_DATASET", key="ncm", scope="PRODUCT")
|
||||
elif _digits(val) == _digits(cur["CLASSIFICACAO_FISCAL"]) and d.get("status") == "EXACT_NCM":
|
||||
out["CLASSIFICACAO_FISCAL"] = _r("CLASSIFICACAO_FISCAL", ALREADY, cur["CLASSIFICACAO_FISCAL"], reason="OPERATOR_NCM_EQUALS_CURRENT")
|
||||
else:
|
||||
out["CLASSIFICACAO_FISCAL"] = _r("CLASSIFICACAO_FISCAL", CHANGE, cur["CLASSIFICACAO_FISCAL"], _digits(val), reason="OPERATOR_NCM_VALIDATED_BY_NCM_DATASET")
|
||||
elif ncm_ok:
|
||||
out["CLASSIFICACAO_FISCAL"] = _r("CLASSIFICACAO_FISCAL", ALREADY, cur["CLASSIFICACAO_FISCAL"], reason="NCM_VALID_IN_DATASET")
|
||||
else:
|
||||
why = "NCM_MISSING" if _blank(cur["CLASSIFICACAO_FISCAL"]) else ("NCM_LEGACY_NO_UNAMBIGUOUS_SUCCESSOR" if d.get("status") == "LEGACY_NCM" else "NCM_INVALID")
|
||||
out["CLASSIFICACAO_FISCAL"] = _r("CLASSIFICACAO_FISCAL", MANUAL, cur["CLASSIFICACAO_FISCAL"], reason=why, key="ncm", scope="PRODUCT")
|
||||
|
||||
def blocked(field):
|
||||
return _r(field, MANUAL, cur[field], reason="BLOCKED_BY_NCM", key="ncm", scope="PRODUCT")
|
||||
|
||||
eff_ncm = out["CLASSIFICACAO_FISCAL"]["proposed"] or cur["CLASSIFICACAO_FISCAL"]
|
||||
prod = {**p, "CLASSIFICACAO_FISCAL": eff_ncm}
|
||||
|
||||
# ---------------- context facts ----------------
|
||||
role, role_ev = infer_role(p, ev, ctx, code, group, NCM)
|
||||
st_ctx, st_scope = ctx.get("st_status", code, group)
|
||||
ba = (d.get("classification") or {}).get("ba_st_mva") or {}
|
||||
if st_ctx in ("SUBJECT", "NOT_SUBJECT"):
|
||||
st = st_ctx
|
||||
elif ncm_ok and uf == "BA":
|
||||
s, _ = st_status(d.get("status"), d.get("ba_st_mva_status"), d.get("cest_status"), cur["CEST"], ba.get("cest"), None)
|
||||
st = {"ST_CONFIRMED": "SUBJECT", "ST_NOT_APPLICABLE": "NOT_SUBJECT"}.get(s, "UNKNOWN")
|
||||
else:
|
||||
st = "UNKNOWN"
|
||||
wh_ctx, _ = ctx.get("st_withheld", code, group)
|
||||
wh_doc, _ = withheld_status(ev.get("purchases", {}).get(code, []))
|
||||
withheld = wh_ctx if wh_ctx in ("Y", "N") else ("Y" if wh_doc == "ST_ALREADY_WITHHELD_CONFIRMED" else None) # purchase items flagged N are weak evidence: kept as unknown
|
||||
treat, _ = ctx.get("icms_treatment", code, group)
|
||||
if not treat and st == "SUBJECT" and withheld == "Y":
|
||||
treat = "ST_WITHHELD"
|
||||
origin_ctx, _ = ctx.get("origin", code, group)
|
||||
|
||||
# ---------------- decision-engine backed fields ----------------
|
||||
if ncm_ok:
|
||||
rec = decide_product({**prod, "CODIGO": code, "IPI": p.get("IPI"), "EX_TIPI": p.get("EX_TIPI"), "CEST": p.get("CEST")}, d, {"uf": uf or "", "crt": crt or ""}, refs)
|
||||
ipi_field = "IPI"
|
||||
if not ncm_ok:
|
||||
out["IPI"], out["EX_TIPI"], out["CEST"] = blocked("IPI"), blocked("EX_TIPI"), blocked("CEST")
|
||||
else:
|
||||
# IPI
|
||||
ex_ctx, _ = ctx.get("ex_tipi", code, group, NCM)
|
||||
cands = (d.get("classification") or {}).get("ipi_candidates") or []
|
||||
chosen = None
|
||||
if ex_ctx:
|
||||
chosen = next((c for c in cands if (c.get("ex") is None if ex_ctx.upper() == "NONE" else str(c.get("ex")) == ex_ctx)), None)
|
||||
if chosen and chosen.get("rate") not in (None, ""):
|
||||
try:
|
||||
rate = float(chosen["rate"])
|
||||
except ValueError:
|
||||
rate = None
|
||||
if rate is not None:
|
||||
same = cur["IPI"] is not None and abs(float(cur["IPI"]) - rate) < 1e-9
|
||||
out["IPI"] = _r("IPI", ALREADY if same else CHANGE, cur["IPI"], None if same else rate, "OPERATOR_EX_SELECTED_TIPI_ROW")
|
||||
if "IPI" not in out:
|
||||
r = rec["IPI"]
|
||||
if r["status"] == "RULE_RESOLVED":
|
||||
out["IPI"] = _r("IPI", ALREADY if r["reason_code"].endswith("SAME_VALUE") else CHANGE, cur["IPI"], None if r["reason_code"].endswith("SAME_VALUE") else r["proposed_value"], "TIPI_NUMERIC_RATE")
|
||||
elif r["status"] == "RULE_CANDIDATE":
|
||||
out["IPI"] = _r("IPI", MANUAL, cur["IPI"], reason="SEVERAL_TIPI_ROWS_FOR_NCM", key="ex_tipi", scope="PRODUCT",
|
||||
choices=[{"ex": c.get("ex"), "rate": c.get("rate"), "description": c.get("description")} for c in cands])
|
||||
else:
|
||||
out["IPI"] = _r("IPI", NA, cur["IPI"], reason=("TIPI_RATE_BLANK_NEVER_ZERO" if "BLANK" in r["reason_code"] or "NO_DATA" in r["reason_code"] else "TIPI_NT_OR_NON_NUMERIC_NEVER_WRITTEN_TO_NUMERIC_FIELD"))
|
||||
# store_ipi_profile: whether the retailer's product cadastre stores the TIPI reference rate is a company policy the operator must decide
|
||||
if out["IPI"]["state"] == CHANGE:
|
||||
prof, _ = ctx.get("store_ipi_profile", code, group)
|
||||
if prof == "LEAVE_NOT_APPLICABLE":
|
||||
out["IPI"] = _r("IPI", NA, cur["IPI"], reason="STORE_IPI_PROFILE_LEAVE_NOT_APPLICABLE")
|
||||
elif prof != "POPULATE_REFERENCE_RATE":
|
||||
out["IPI"] = _r("IPI", MANUAL, cur["IPI"], reason="STORE_IPI_PROFILE_NOT_DECIDED", key="store_ipi_profile", scope="COMPANY")
|
||||
# EX_TIPI
|
||||
ex_rows = [c for c in cands if c.get("ex")]
|
||||
if ex_ctx:
|
||||
if ex_ctx.upper() == "NONE":
|
||||
out["EX_TIPI"] = _r("EX_TIPI", NA, cur["EX_TIPI"], reason="OPERATOR_SAYS_NO_EX")
|
||||
elif any(str(c["ex"]) == ex_ctx for c in ex_rows):
|
||||
out["EX_TIPI"] = _r("EX_TIPI", ALREADY if cur["EX_TIPI"] == ex_ctx else CHANGE, cur["EX_TIPI"], None if cur["EX_TIPI"] == ex_ctx else ex_ctx, "OPERATOR_EX_VALID_IN_TIPI")
|
||||
else:
|
||||
out["EX_TIPI"] = _r("EX_TIPI", MANUAL, cur["EX_TIPI"], reason="OPERATOR_EX_NOT_IN_TIPI_ROWS", key="ex_tipi", scope="PRODUCT")
|
||||
else:
|
||||
r = rec["EX_TIPI"]
|
||||
if r["status"] == "RULE_RESOLVED":
|
||||
out["EX_TIPI"] = _r("EX_TIPI", ALREADY if r["reason_code"].endswith("SAME_VALUE") else CHANGE, cur["EX_TIPI"], None if r["reason_code"].endswith("SAME_VALUE") else r["proposed_value"], "TIPI_EX_DESCRIPTION_EVIDENCE")
|
||||
elif cur["EX_TIPI"] and not any(str(c["ex"]) == cur["EX_TIPI"] for c in ex_rows):
|
||||
out["EX_TIPI"] = _r("EX_TIPI", MANUAL, cur["EX_TIPI"], reason="ERP_EX_NOT_SUPPORTED_BY_TIPI", key="ex_tipi", scope="PRODUCT")
|
||||
elif cur["EX_TIPI"]:
|
||||
out["EX_TIPI"] = _r("EX_TIPI", ALREADY, cur["EX_TIPI"], reason="DECLARED_VALID_NOT_CONTRADICTED")
|
||||
else:
|
||||
out["EX_TIPI"] = _r("EX_TIPI", NA, cur["EX_TIPI"], reason="NO_EX_ROW_FOR_NCM" if not ex_rows else "DESCRIPTION_EVIDENCES_NO_EX_BASE_RATE_APPLIES")
|
||||
# CEST
|
||||
cest_ctx, _ = ctx.get("cest", code, group, NCM)
|
||||
cls = d.get("classification") or {}
|
||||
official = {_digits(c["code"]): c for c in (cls.get("cest_candidates") or [])}
|
||||
cs = d.get("cest_status")
|
||||
cur_cest = _digits(cur["CEST"])
|
||||
if cest_ctx:
|
||||
if cest_ctx.upper() == "NONE":
|
||||
out["CEST"] = _r("CEST", NA, cur["CEST"], reason="OPERATOR_SAYS_NO_CEST")
|
||||
elif _digits(cest_ctx) in official:
|
||||
out["CEST"] = _r("CEST", ALREADY if cur_cest == _digits(cest_ctx) else CHANGE, cur["CEST"], None if cur_cest == _digits(cest_ctx) else _digits(cest_ctx), "OPERATOR_CEST_OFFICIAL_FOR_NCM")
|
||||
else:
|
||||
out["CEST"] = _r("CEST", MANUAL, cur["CEST"], reason="OPERATOR_CEST_NOT_OFFICIAL_FOR_NCM", key="cest", scope="PRODUCT")
|
||||
elif cs == "CEST_CONFIRMED":
|
||||
proposed = _digits((cls.get("cest") or [None])[0])
|
||||
out["CEST"] = _r("CEST", ALREADY if cur_cest == proposed else CHANGE, cur["CEST"], None if cur_cest == proposed else proposed, "SINGLE_DEFENSIBLE_OFFICIAL_CEST")
|
||||
elif cur_cest and cur_cest in official:
|
||||
out["CEST"] = _r("CEST", ALREADY, cur["CEST"], reason="ERP_CEST_IS_OFFICIAL_FOR_NCM")
|
||||
elif cs == "NO_CEST_MAPPING_FOUND":
|
||||
out["CEST"] = _r("CEST", NA, cur["CEST"], reason="NCM_NOT_IN_CEST_LIST") if not cur_cest else _r("CEST", MANUAL, cur["CEST"], reason="ERP_CEST_BUT_NCM_NOT_IN_CEST_LIST", key="cest", scope="PRODUCT")
|
||||
elif cs == "CEST_CANDIDATES" and not cur_cest:
|
||||
out["CEST"] = _r("CEST", MANUAL, cur["CEST"], reason="SEVERAL_OFFICIAL_CEST_FOR_NCM", key="cest", scope="PRODUCT",
|
||||
choices=[{"code": v["code"], "description": v["official_description"], "score": v.get("description_score")} for v in official.values()])
|
||||
elif cs == "CEST_DESCRIPTION_MISMATCH" and not cur_cest:
|
||||
out["CEST"] = _r("CEST", NA, cur["CEST"], reason="DESCRIPTION_MATCHES_NO_OFFICIAL_CEST_ITEM")
|
||||
elif cur_cest:
|
||||
out["CEST"] = _r("CEST", MANUAL, cur["CEST"], reason="ERP_CEST_NOT_OFFICIAL_FOR_NCM", key="cest", scope="PRODUCT")
|
||||
else:
|
||||
out["CEST"] = _r("CEST", NA, cur["CEST"], reason="NO_CEST_APPLICABLE")
|
||||
|
||||
# ---------------- SITUACAO_TRIBUTARIA ----------------
|
||||
sit = cur["SITUACAO_TRIBUTARIA"]
|
||||
valid_sit = sit in ("T", "I", "N", "S")
|
||||
if not ncm_ok:
|
||||
out["SITUACAO_TRIBUTARIA"] = blocked("SITUACAO_TRIBUTARIA")
|
||||
elif st == "SUBJECT" and withheld == "Y":
|
||||
out["SITUACAO_TRIBUTARIA"] = _r("SITUACAO_TRIBUTARIA", ALREADY if sit == "S" else CHANGE, sit, None if sit == "S" else "S", "ST_PROVEN_AND_WITHHELD")
|
||||
elif st == "SUBJECT" and withheld is None:
|
||||
out["SITUACAO_TRIBUTARIA"] = _r("SITUACAO_TRIBUTARIA", ALREADY, sit, reason="DECLARED_S_CONSISTENT_WITH_PROVEN_ST") if sit == "S" else _r("SITUACAO_TRIBUTARIA", MANUAL, sit, reason="ST_PROVEN_RETENTION_UNKNOWN", key="st_withheld", scope="GROUP")
|
||||
elif valid_sit and not (st == "NOT_SUBJECT" and sit == "S"):
|
||||
out["SITUACAO_TRIBUTARIA"] = _r("SITUACAO_TRIBUTARIA", ALREADY, sit, reason="DECLARED_VALID_NOT_CONTRADICTED")
|
||||
elif valid_sit:
|
||||
out["SITUACAO_TRIBUTARIA"] = _r("SITUACAO_TRIBUTARIA", MANUAL, sit, reason="DECLARED_S_BUT_NOT_IN_ANY_ST_LIST", key="icms_treatment", scope="GROUP")
|
||||
else:
|
||||
out["SITUACAO_TRIBUTARIA"] = _r("SITUACAO_TRIBUTARIA", MANUAL, sit, reason="BLANK_OR_INVALID", key="icms_treatment", scope="GROUP")
|
||||
|
||||
# ---------------- CFOP saída ----------------
|
||||
cfop, cfop_valid = cur["CFOP"], refs["cfop_valid"]
|
||||
if role == "INPUT":
|
||||
out["CFOP"] = _r("CFOP", NA, cfop, reason="INPUT_IS_NOT_A_SALE_ITEM")
|
||||
elif role == "SERVICE":
|
||||
out["CFOP"] = _r("CFOP", MANUAL, cfop, reason="SERVICE_CFOP_NEEDS_OPERATOR", key="service_cfop", scope="GROUP")
|
||||
elif not ncm_ok:
|
||||
out["CFOP"] = blocked("CFOP")
|
||||
else:
|
||||
expected = None
|
||||
if role in ("RESALE", "OWN_PRODUCTION"):
|
||||
base = "5102" if role == "RESALE" else "5101"
|
||||
if st == "NOT_SUBJECT":
|
||||
expected = base
|
||||
elif st == "SUBJECT" and withheld == "Y":
|
||||
expected = "5405" if role == "RESALE" else "5401"
|
||||
valid_cur = cfop in cfop_valid and cfop.startswith("5")
|
||||
erp_st_declared = cur["CSOSN"] in ("500", "201", "202", "203") or (cur["CST"] or "")[-2:] in ("60", "10", "70", "30")
|
||||
if valid_cur and expected and expected in ("5102", "5101") and cfop in ("5405", "5401") and erp_st_declared:
|
||||
out["CFOP"] = _r("CFOP", MANUAL, cfop, reason="OFFICIAL_ST_TABLE_CONFLICTS_WITH_ERP_ST_DECLARATION", key="st_status", scope="GROUP")
|
||||
elif valid_cur and expected and cfop != expected and cfop in RESALE_CFOP:
|
||||
out["CFOP"] = _r("CFOP", CHANGE, cfop, expected, "PROVEN_ROLE_AND_ST_CONTRADICT_DECLARED_CFOP")
|
||||
elif valid_cur and expected == cfop:
|
||||
out["CFOP"] = _r("CFOP", ALREADY, cfop, reason="RULE_PROVEN_EQUALS_DECLARED")
|
||||
elif valid_cur:
|
||||
out["CFOP"] = _r("CFOP", ALREADY, cfop, reason="DECLARED_VALID_NOT_CONTRADICTED")
|
||||
elif expected:
|
||||
out["CFOP"] = _r("CFOP", CHANGE, cfop, expected, "DERIVED_FROM_ROLE_AND_ST" + ("_REPLACING_INVALID" if cfop else "_FILLING_BLANK"))
|
||||
else:
|
||||
key = "product_role" if role == "UNKNOWN" else ("st_status" if st == "UNKNOWN" else "st_withheld")
|
||||
out["CFOP"] = _r("CFOP", MANUAL, cfop, reason="CFOP_NEEDS_" + key.upper(), key=key, scope="GROUP")
|
||||
|
||||
# ---------------- CFOP entrada ----------------
|
||||
cfe = cur["CFOP_ENTRADA"]
|
||||
scope_ctx, _ = ctx.get("entry_scope", code, group)
|
||||
if role == "SERVICE":
|
||||
out["CFOP_ENTRADA"] = _r("CFOP_ENTRADA", NA, cfe, reason="SERVICE_HAS_NO_GOODS_ENTRY")
|
||||
elif not ncm_ok:
|
||||
out["CFOP_ENTRADA"] = blocked("CFOP_ENTRADA")
|
||||
else:
|
||||
exp_e = None
|
||||
if scope_ctx in ("INTRA", "INTER") and role in ("RESALE", "OWN_PRODUCTION", "INPUT") and st != "UNKNOWN" and not (st == "SUBJECT" and withheld is None):
|
||||
first = "1" if scope_ctx == "INTRA" else "2"
|
||||
stw = st == "SUBJECT" and withheld == "Y"
|
||||
exp_e = first + ("403" if stw else "102") if role == "RESALE" else first + ("401" if stw else "101")
|
||||
valid_e = cfe in cfop_valid and cfe[:1] in ("1", "2")
|
||||
if valid_e and exp_e and cfe != exp_e and cfe in ENTRY_CFOP:
|
||||
out["CFOP_ENTRADA"] = _r("CFOP_ENTRADA", CHANGE, cfe, exp_e, "PROVEN_ROLE_ST_AND_SCOPE_CONTRADICT_DECLARED_CFOP_ENTRADA")
|
||||
elif valid_e:
|
||||
out["CFOP_ENTRADA"] = _r("CFOP_ENTRADA", ALREADY, cfe, reason="DECLARED_VALID_NOT_CONTRADICTED")
|
||||
elif exp_e:
|
||||
out["CFOP_ENTRADA"] = _r("CFOP_ENTRADA", CHANGE, cfe, exp_e, "DERIVED_FROM_ROLE_ST_AND_ENTRY_SCOPE")
|
||||
else:
|
||||
key = "product_role" if role == "UNKNOWN" else ("entry_scope" if scope_ctx not in ("INTRA", "INTER") else ("st_status" if st == "UNKNOWN" else "st_withheld"))
|
||||
out["CFOP_ENTRADA"] = _r("CFOP_ENTRADA", MANUAL, cfe, reason="CFOP_ENTRADA_NEEDS_" + key.upper(), key=key, scope="COMPANY" if key == "entry_scope" else "GROUP")
|
||||
|
||||
# ---------------- origin (needed by CST) ----------------
|
||||
cod_o = cur["COD_ORIGEM_MERCADORIA"]
|
||||
cst_digit = (cur["CST"] or "")[:1] if cur["CST"] and len(cur["CST"]) == 3 and (cur["CST"][:1] in "012345678") else None
|
||||
if origin_ctx in ORIGIN_UI:
|
||||
out["COD_ORIGEM_MERCADORIA"] = _r("COD_ORIGEM_MERCADORIA", ALREADY if cod_o == origin_ctx else CHANGE, cod_o, None if cod_o == origin_ctx else int(origin_ctx), "OPERATOR_ORIGIN")
|
||||
elif origin_ctx:
|
||||
out["COD_ORIGEM_MERCADORIA"] = _r("COD_ORIGEM_MERCADORIA", MANUAL, cod_o, reason="OPERATOR_ORIGIN_NOT_REPRESENTABLE_ON_SGV_SCREEN_0_1_2", key="origin", scope="GROUP")
|
||||
elif cod_o in ORIGIN_UI:
|
||||
out["COD_ORIGEM_MERCADORIA"] = _r("COD_ORIGEM_MERCADORIA", ALREADY, cod_o, reason="DECLARED_VALID_NOT_CONTRADICTED")
|
||||
else:
|
||||
# V2 audit: the CST first digit is padding in the SGV (declared-origin 1/2 products also carry CST digit 0), so it is NOT evidence of origin
|
||||
out["COD_ORIGEM_MERCADORIA"] = _r("COD_ORIGEM_MERCADORIA", MANUAL, cod_o, reason="ORIGIN_NO_EVIDENCE", key="origin", scope="GROUP")
|
||||
origin_digit = out["COD_ORIGEM_MERCADORIA"]["proposed"] if out["COD_ORIGEM_MERCADORIA"]["proposed"] is not None else (cod_o if cod_o in ORIGIN_UI else None)
|
||||
origin_digit = None if origin_digit is None else str(origin_digit)
|
||||
|
||||
# ---------------- CST / CST_ENTRADA (regime normal) ----------------
|
||||
for field in ("CST", "CST_ENTRADA"):
|
||||
v = cur[field]
|
||||
if crt == "1":
|
||||
out[field] = _r(field, NA, v, reason="CRT1_SIMPLES_EMISSION_USES_CSOSN")
|
||||
continue
|
||||
if crt not in ("2", "3"):
|
||||
out[field] = _r(field, MANUAL, v, reason="CRT_UNKNOWN", key="crt", scope="COMPANY")
|
||||
continue
|
||||
valid_c = bool(v) and len(v) == 3 and v[0] in "012345678" and v[1:] in refs["cst_icms"]
|
||||
exp = (origin_digit + CST_TREATMENT[treat]) if (treat in CST_TREATMENT and origin_digit) else None
|
||||
if valid_c and exp and v != exp and v[1:] != exp[1:]:
|
||||
out[field] = _r(field, CHANGE, v, exp, "TREATMENT_CONTRADICTS_DECLARED_CST")
|
||||
elif valid_c:
|
||||
out[field] = _r(field, ALREADY, v, reason="DECLARED_VALID_NOT_CONTRADICTED")
|
||||
elif exp:
|
||||
out[field] = _r(field, CHANGE, v, exp, "DERIVED_FROM_TREATMENT_AND_ORIGIN")
|
||||
else:
|
||||
out[field] = _r(field, MANUAL, v, reason="CST_NEEDS_ICMS_TREATMENT" if origin_digit else "CST_NEEDS_ORIGIN", key="icms_treatment" if origin_digit else "origin", scope="GROUP")
|
||||
|
||||
# ---------------- CSOSN ----------------
|
||||
v = cur["CSOSN"]
|
||||
if crt != "1":
|
||||
out["CSOSN"] = _r("CSOSN", NA, v, reason="CSOSN_IS_THE_SIMPLES_NACIONAL_CODE_CRT_IS_" + str(crt))
|
||||
elif not ncm_ok:
|
||||
out["CSOSN"] = blocked("CSOSN")
|
||||
else:
|
||||
fam = CSOSN_FAMILY.get(treat)
|
||||
if v in refs["csosn"] and (not fam or v in fam[1]):
|
||||
out["CSOSN"] = _r("CSOSN", ALREADY, v, reason="RULE_PROVEN_EQUALS_DECLARED" if fam else "DECLARED_VALID_NOT_CONTRADICTED")
|
||||
elif v in refs["csosn"] and fam:
|
||||
out["CSOSN"] = _r("CSOSN", CHANGE, v, fam[0], "TREATMENT_CONTRADICTS_DECLARED_CSOSN")
|
||||
elif fam:
|
||||
out["CSOSN"] = _r("CSOSN", CHANGE, v, fam[0], "DERIVED_FROM_TREATMENT" + ("_REPLACING_INVALID" if v else "_FILLING_BLANK"))
|
||||
else:
|
||||
out["CSOSN"] = _r("CSOSN", MANUAL, v, reason="CSOSN_NEEDS_ICMS_TREATMENT", key="icms_treatment", scope="GROUP")
|
||||
|
||||
# ---------------- ALIQUOTA ----------------
|
||||
v = cur["ALIQUOTA"]
|
||||
csosn_now = out["CSOSN"]["proposed"] or cur["CSOSN"]
|
||||
cst_now = out["CST"]["proposed"] or cur["CST"]
|
||||
rate_ctx, _ = ctx.get("icms_rate", code, group)
|
||||
if crt == "1" and (csosn_now in NO_ICMS_CSOSN):
|
||||
out["ALIQUOTA"] = _r("ALIQUOTA", NA, v, reason="SIMPLES_CSOSN_HIGHLIGHTS_NO_ICMS_RATE")
|
||||
elif crt in ("2", "3") and cst_now and cst_now[1:] in NO_ICMS_CST:
|
||||
out["ALIQUOTA"] = _r("ALIQUOTA", NA, v, reason="CST_HIGHLIGHTS_NO_ICMS_RATE")
|
||||
elif rate_ctx:
|
||||
try:
|
||||
r = float(rate_ctx.replace(",", "."))
|
||||
same = v is not None and abs(float(v) - r) < 1e-9
|
||||
out["ALIQUOTA"] = _r("ALIQUOTA", ALREADY if same else CHANGE, v, None if same else r, "OPERATOR_ICMS_RATE")
|
||||
except ValueError:
|
||||
out["ALIQUOTA"] = _r("ALIQUOTA", MANUAL, v, reason="OPERATOR_ICMS_RATE_NOT_NUMERIC", key="icms_rate", scope="COMPANY")
|
||||
elif v not in (None, ""):
|
||||
out["ALIQUOTA"] = _r("ALIQUOTA", ALREADY, v, reason="DECLARED_VALID_NOT_CONTRADICTED")
|
||||
else:
|
||||
out["ALIQUOTA"] = _r("ALIQUOTA", MANUAL, v, reason="TAXED_ITEM_WITHOUT_ICMS_RATE", key="credit_rate" if crt == "1" else "icms_rate", scope="COMPANY")
|
||||
|
||||
# ---------------- CST IPI ----------------
|
||||
v = cur["CST_IPI"]
|
||||
if company.get("contribuinte_ipi") != "S":
|
||||
out["CST_IPI"] = _r("CST_IPI", NA, v, reason="COMPANY_IS_NOT_AN_IPI_TAXPAYER_C000004.CONTRIBUINTE_IPI")
|
||||
elif v:
|
||||
out["CST_IPI"] = _r("CST_IPI", ALREADY, v, reason="DECLARED_VALID_NOT_CONTRADICTED")
|
||||
else:
|
||||
out["CST_IPI"] = _r("CST_IPI", MANUAL, v, reason="IPI_TAXPAYER_WITHOUT_CST_IPI", key="cst_ipi", scope="GROUP")
|
||||
|
||||
# ---------------- PIS / COFINS ----------------
|
||||
prof_raw, prof_scope = ctx.get("pis_cofins_profile", code, group)
|
||||
import json as _json
|
||||
try:
|
||||
prof = _json.loads(prof_raw) if prof_raw else {}
|
||||
except ValueError:
|
||||
prof = {}
|
||||
for field, pkey, dom in (("COD_ENTRADA_PIS", "pis_in_cst", "cst_pis"), ("COD_SAIDA_PIS", "pis_out_cst", "cst_pis"), ("ALIQ_PIS", "pis_rate", None),
|
||||
("COD_ENTRADA_COFINS", "cofins_in_cst", "cst_cofins"), ("COD_SAIDA_COFINS", "cofins_out_cst", "cst_cofins"), ("ALIQ_COFINS", "cofins_rate", None)):
|
||||
v = cur[field]
|
||||
want = _s(prof.get(pkey)) if prof else None
|
||||
if want is not None and dom and want not in refs[dom]:
|
||||
out[field] = _r(field, MANUAL, v, reason="PROFILE_VALUE_NOT_IN_OFFICIAL_CST_TABLE", key="pis_cofins_profile", scope="COMPANY")
|
||||
elif want is not None:
|
||||
out[field] = _r(field, ALREADY if v == want else CHANGE, v, None if v == want else want, "OPERATOR_PIS_COFINS_PROFILE")
|
||||
elif v and (dom is None or v in refs[dom]):
|
||||
out[field] = _r(field, ALREADY, v, reason="DECLARED_VALID_NOT_CONTRADICTED")
|
||||
elif v:
|
||||
out[field] = _r(field, MANUAL, v, reason="DECLARED_CST_NOT_IN_OFFICIAL_TABLE", key="pis_cofins_profile", scope="COMPANY")
|
||||
elif crt == "1" and company.get("pis_cofins_unused_by_emission"):
|
||||
hits = sped_pis_cofins_match(NCM) if ncm_ok else []
|
||||
csts = {h[1] for h in hits}
|
||||
if field in ("COD_SAIDA_PIS", "COD_SAIDA_COFINS") and len(csts) == 1 and role == "RESALE":
|
||||
want = next(iter(csts))
|
||||
out[field] = _r(field, CHANGE, v, want, "SPED_TABLE_%s_ENTRY_%s_NCM_%s" % (hits[0][0], hits[0][2], NCM))
|
||||
elif field in ("COD_SAIDA_PIS", "COD_SAIDA_COFINS") and len(csts) > 1:
|
||||
out[field] = _r(field, MANUAL, v, reason="SPED_TABLES_CONFLICT_FOR_NCM", key="pis_cofins_profile", scope="PRODUCT")
|
||||
elif field in ("ALIQ_PIS", "ALIQ_COFINS") and len(csts) == 1 and role == "RESALE":
|
||||
out[field] = _r(field, NA, v, reason="RATE_NOT_INFORMED_FOR_CST_" + next(iter(csts)))
|
||||
else:
|
||||
out[field] = _r(field, NA, v, reason="SGV_EMISSION_IGNORES_PIS_COFINS_PRODUCT_FIELDS_SIMPLES_NACIONAL")
|
||||
else:
|
||||
out[field] = _r(field, MANUAL, v, reason="NO_OFFICIAL_RULE_SOURCE_IN_CORE_DATASETS", key="pis_cofins_profile", scope="COMPANY")
|
||||
|
||||
# ---------------- incidence / PISCOFINS / IAT / IPPT ----------------
|
||||
v = cur["INCIDENCIA_PISCOFINS"]
|
||||
inc_ctx, _ = ctx.get("incidence", code, group)
|
||||
if inc_ctx == "NENHUMA":
|
||||
out["INCIDENCIA_PISCOFINS"] = _r("INCIDENCIA_PISCOFINS", ALREADY if v == "NENHUMA" else CHANGE, v, None if v == "NENHUMA" else "NENHUMA", "OPERATOR_INCIDENCE")
|
||||
elif v:
|
||||
out["INCIDENCIA_PISCOFINS"] = _r("INCIDENCIA_PISCOFINS", ALREADY, v, reason="DECLARED_VALID_NOT_CONTRADICTED")
|
||||
else:
|
||||
out["INCIDENCIA_PISCOFINS"] = _r("INCIDENCIA_PISCOFINS", MANUAL, v, reason="BLANK_INCIDENCE", key="incidence", scope="GROUP")
|
||||
v = cur["PISCOFINS"]
|
||||
pc_ctx, _ = ctx.get("piscofins", code, group)
|
||||
if pc_ctx in ("N", "S"):
|
||||
out["PISCOFINS"] = _r("PISCOFINS", ALREADY if v == pc_ctx else CHANGE, v, None if v == pc_ctx else pc_ctx, "OPERATOR_PISCOFINS")
|
||||
elif v in ("N", "S"):
|
||||
out["PISCOFINS"] = _r("PISCOFINS", ALREADY, v, reason="DECLARED_VALID_NOT_CONTRADICTED")
|
||||
else:
|
||||
out["PISCOFINS"] = _r("PISCOFINS", MANUAL, v, reason="BLANK_OR_INVALID", key="piscofins", scope="GROUP")
|
||||
v = cur["IAT"]
|
||||
rd_ctx, _ = ctx.get("rounding", code, group)
|
||||
if rd_ctx in ("A", "T"):
|
||||
out["IAT"] = _r("IAT", ALREADY if v == rd_ctx else CHANGE, v, None if v == rd_ctx else rd_ctx, "OPERATOR_ROUNDING_POLICY")
|
||||
elif v in ("A", "T"):
|
||||
out["IAT"] = _r("IAT", ALREADY, v, reason="DECLARED_VALID_NOT_CONTRADICTED")
|
||||
else:
|
||||
out["IAT"] = _r("IAT", MANUAL, v, reason="BLANK_OR_INVALID", key="rounding", scope="COMPANY")
|
||||
v = cur["IPPT"]
|
||||
if role in ("INPUT", "SERVICE"):
|
||||
out["IPPT"] = _r("IPPT", NA, v, reason="INPUT_OR_SERVICE_HAS_NO_PRODUCTION_INDICATOR")
|
||||
elif role == "UNKNOWN":
|
||||
out["IPPT"] = _r("IPPT", ALREADY, v, reason="DECLARED_VALID_NOT_CONTRADICTED") if v in ("P", "T") else _r("IPPT", MANUAL, v, reason="IPPT_NEEDS_PRODUCT_ROLE", key="product_role", scope="GROUP")
|
||||
else:
|
||||
exp = "P" if role == "OWN_PRODUCTION" else "T"
|
||||
out["IPPT"] = _r("IPPT", ALREADY if v == exp else CHANGE, v, None if v == exp else exp, "DERIVED_FROM_PRODUCT_ROLE:" + ",".join(role_ev))
|
||||
for r in out.values():
|
||||
r.setdefault("role", role)
|
||||
out["_facts"] = {"role": role, "role_evidence": role_ev, "st": st, "st_withheld": withheld, "icms_treatment": treat, "origin_digit": origin_digit}
|
||||
return out
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Fiscal Decision Engine V1: independent per-field, per-product decisions.
|
||||
|
||||
Pure functions (no I/O). Every domain gets its own decision; a dataset being
|
||||
available is never treated as a rule being resolved. Nothing here writes to
|
||||
any database — it only marks `write_eligible`, and only for fields whose ERP
|
||||
mapping is confirmed AND whose rule is deterministic AND that need no
|
||||
operation context.
|
||||
|
||||
Status vocabulary (per domain):
|
||||
RULE_RESOLVED, RULE_CANDIDATE, RULE_UNRESOLVED, OPERATION_CONTEXT_REQUIRED
|
||||
plus flags data_available / match_found. `action_class` is the Phase-14 bucket:
|
||||
AUTO_APPLY_SAFE, REVIEW_REQUIRED, OPERATION_CONTEXT_REQUIRED, NO_RULE, NO_DATA
|
||||
(+ ALREADY_CORRECT when the ERP value already equals the deterministic one).
|
||||
"""
|
||||
from .dry_run_planner import build_field_proposals
|
||||
|
||||
DECISION_ENGINE_VERSION = "fiscal-decision-v1"
|
||||
|
||||
NATURE = {
|
||||
"NCM": "PRODUCT_INTRINSIC", "IPI": "PRODUCT_INTRINSIC", "EX_TIPI": "PRODUCT_INTRINSIC",
|
||||
"CEST": "PRODUCT_INTRINSIC", "MVA": "PRODUCT_INTRINSIC",
|
||||
"ORIGEM": "PRODUCT_INTRINSIC",
|
||||
"CFOP": "OPERATION_DEPENDENT", "CSOSN": "OPERATION_DEPENDENT", "CST_ICMS": "OPERATION_DEPENDENT",
|
||||
"PIS_CST": "OPERATION_DEPENDENT", "COFINS_CST": "OPERATION_DEPENDENT",
|
||||
"PIS_COFINS_RATE": "COMPANY_CONTEXT_DEPENDENT", "FCP": "OPERATION_DEPENDENT",
|
||||
"RTC_CCLASSTRIB": "OPERATION_DEPENDENT", "RTC_CBS_RATE": "OPERATION_DEPENDENT",
|
||||
"RTC_CCREDPRES": "OPERATION_DEPENDENT",
|
||||
}
|
||||
|
||||
# ERP_FIELD, SEMANTICS, DB_TYPE, CONFIDENCE, EVIDENCE — audited against the live
|
||||
# C000025 schema (RDB$ metadata: no field comments exist) and value distributions
|
||||
# over all 521 rows (scripts/audit_erp_fields.py). No supplier DDL/doc file was
|
||||
# found in the repo, so semantic confirmation rests on value-domain evidence.
|
||||
ERP_FIELD_MAP = [
|
||||
{"ERP_FIELD": "CLASSIFICACAO_FISCAL", "SEMANTICS": "NCM canônico (8 dígitos)", "DB_TYPE": "VARCHAR(20)", "CONFIDENCE": "HIGH", "EVIDENCE": "54 distinct 8-digit values, 495 valid in NCM dataset; prior-mission confirmed (NCM column is 100% NULL)", "MAPPING": "CONFIRMED"},
|
||||
{"ERP_FIELD": "IPI", "SEMANTICS": "alíquota numérica de IPI (%)", "DB_TYPE": "DOUBLE PRECISION", "CONFIDENCE": "HIGH", "EVIDENCE": "numeric type; 105 rows = 19.5 written earlier from TIPI and verified; prior-mission confirmed", "MAPPING": "CONFIRMED"},
|
||||
{"ERP_FIELD": "EX_TIPI", "SEMANTICS": "código EX da TIPI", "DB_TYPE": "VARCHAR(3)", "CONFIDENCE": "HIGH", "EVIDENCE": "prior-mission confirmed; 100% NULL now", "MAPPING": "CONFIRMED"},
|
||||
{"ERP_FIELD": "CEST", "SEMANTICS": "CEST do produto (7 dígitos sem pontuação)", "DB_TYPE": "VARCHAR(7)", "CONFIDENCE": "HIGH", "EVIDENCE": "values like 0601100/1708400 = CEST 06.011.00/17.084.00 without dots; prior-mission confirmed", "MAPPING": "CONFIRMED"},
|
||||
{"ERP_FIELD": "CFOP", "SEMANTICS": "CFOP padrão de SAÍDA do produto (default, não a operação real)", "DB_TYPE": "VARCHAR(4)", "CONFIDENCE": "HIGH", "EVIDENCE": "values 5102 (493), 5405 (3), 5656 (2): all valid 5xxx output CFOPs in CFOP dataset; 5405=ST, 5656=fuel", "MAPPING": "CONFIRMED"},
|
||||
{"ERP_FIELD": "CFOP_ENTRADA", "SEMANTICS": "CFOP padrão de entrada", "DB_TYPE": "SMALLINT", "CONFIDENCE": "LOW", "EVIDENCE": "100% NULL in all 521 rows; name only", "MAPPING": "MAPPING_UNCONFIRMED"},
|
||||
{"ERP_FIELD": "CST", "SEMANTICS": "CST ICMS de saída em 3 dígitos = ORIGEM(1)+CST(2)", "DB_TYPE": "VARCHAR(10)", "CONFIDENCE": "MEDIUM", "EVIDENCE": "values 040/000/060/020/051: last 2 digits all in CST_ICMS dataset, first digit in ORIGEM dataset (0)", "MAPPING": "CONFIRMED"},
|
||||
{"ERP_FIELD": "CST_ENTRADA", "SEMANTICS": "CST ICMS de entrada", "DB_TYPE": "SMALLINT", "CONFIDENCE": "LOW", "EVIDENCE": "100% NULL; name only", "MAPPING": "MAPPING_UNCONFIRMED"},
|
||||
{"ERP_FIELD": "ALIQUOTA", "SEMANTICS": "alíquota de ICMS % (saída)", "DB_TYPE": "NUMERIC(8,2)", "CONFIDENCE": "MEDIUM", "EVIDENCE": "values 20.5 (492), 17 (24), 0 (4), 21.5 (1): plausible BA ICMS rates; no doc confirms", "MAPPING": "CONFIRMED"},
|
||||
{"ERP_FIELD": "CSOSN", "SEMANTICS": "CSOSN de saída (Simples Nacional)", "DB_TYPE": "VARCHAR(60)", "CONFIDENCE": "HIGH", "EVIDENCE": "values 102/500/101/103 all in CSOSN dataset; company CRT=1", "MAPPING": "CONFIRMED"},
|
||||
{"ERP_FIELD": "COD_ENTRADA_PIS", "SEMANTICS": "CST PIS de entrada", "DB_TYPE": "VARCHAR(3)", "CONFIDENCE": "MEDIUM", "EVIDENCE": "2 non-null rows ('2','73'); mixed formatting ('2' vs 2-digit codes) — too sparse", "MAPPING": "MAPPING_UNCONFIRMED"},
|
||||
{"ERP_FIELD": "COD_SAIDA_PIS", "SEMANTICS": "CST PIS de saída", "DB_TYPE": "VARCHAR(3)", "CONFIDENCE": "MEDIUM", "EVIDENCE": "3 non-null rows ('01','04','2'); inconsistent formatting", "MAPPING": "MAPPING_UNCONFIRMED"},
|
||||
{"ERP_FIELD": "ALIQ_PIS", "SEMANTICS": "alíquota PIS (texto, vírgula decimal)", "DB_TYPE": "VARCHAR(8)", "CONFIDENCE": "LOW", "EVIDENCE": "3 non-null rows ('0,65','2', blank); text type", "MAPPING": "MAPPING_UNCONFIRMED"},
|
||||
{"ERP_FIELD": "COD_ENTRADA_COFINS", "SEMANTICS": "CST COFINS de entrada", "DB_TYPE": "VARCHAR(3)", "CONFIDENCE": "MEDIUM", "EVIDENCE": "2 non-null rows; too sparse", "MAPPING": "MAPPING_UNCONFIRMED"},
|
||||
{"ERP_FIELD": "COD_SAIDA_COFINS", "SEMANTICS": "CST COFINS de saída", "DB_TYPE": "VARCHAR(3)", "CONFIDENCE": "MEDIUM", "EVIDENCE": "3 non-null rows; inconsistent formatting", "MAPPING": "MAPPING_UNCONFIRMED"},
|
||||
{"ERP_FIELD": "ALIQ_COFINS", "SEMANTICS": "alíquota COFINS (texto)", "DB_TYPE": "VARCHAR(8)", "CONFIDENCE": "LOW", "EVIDENCE": "3 non-null rows ('2','3', blank)", "MAPPING": "MAPPING_UNCONFIRMED"},
|
||||
{"ERP_FIELD": "CST_IPI", "SEMANTICS": "CST do IPI", "DB_TYPE": "SMALLINT", "CONFIDENCE": "LOW", "EVIDENCE": "1 non-null row ('040'); IPI CST is 2 digits — value contradicts", "MAPPING": "MAPPING_UNCONFIRMED"},
|
||||
{"ERP_FIELD": "ORIGEM", "SEMANTICS": "origem da mercadoria (0-8)", "DB_TYPE": "VARCHAR(1)", "CONFIDENCE": "LOW", "EVIDENCE": "100% NULL; origin digit is instead embedded in CST (first digit)", "MAPPING": "MAPPING_UNCONFIRMED"},
|
||||
{"ERP_FIELD": "IAT", "SEMANTICS": "indicador arredondamento/truncamento (A/T)", "DB_TYPE": "VARCHAR(1)", "CONFIDENCE": "MEDIUM", "EVIDENCE": "constant 'A' in 521 rows; not a fiscal-decision field", "MAPPING": "CONFIRMED"},
|
||||
{"ERP_FIELD": "INCIDENCIA_PISCOFINS", "SEMANTICS": "tipo de incidência PIS/COFINS", "DB_TYPE": "VARCHAR(30)", "CONFIDENCE": "HIGH", "EVIDENCE": "values NENHUMA (519), MONOFÁSICA (1), SUBST.TRIBUTÁRIA (1)", "MAPPING": "CONFIRMED"},
|
||||
{"ERP_FIELD": "IPPT", "SEMANTICS": "indicador produção própria/terceiros (P/T)", "DB_TYPE": "VARCHAR(1)", "CONFIDENCE": "MEDIUM", "EVIDENCE": "constant 'T' in 521 rows; not a fiscal-decision field", "MAPPING": "CONFIRMED"},
|
||||
]
|
||||
|
||||
_ACTION = {
|
||||
"RULE_RESOLVED": "AUTO_APPLY_SAFE", # only if write_eligible; refined below
|
||||
"RULE_CANDIDATE": "REVIEW_REQUIRED",
|
||||
"OPERATION_CONTEXT_REQUIRED": "OPERATION_CONTEXT_REQUIRED",
|
||||
}
|
||||
|
||||
|
||||
def _rec(domain, current, proposed, status, reason, confidence, provenance, write_eligible=False,
|
||||
data_available=True, match_found=False, action=None, **extra):
|
||||
if action is None:
|
||||
if status == "RULE_RESOLVED":
|
||||
action = "AUTO_APPLY_SAFE" if write_eligible else "ALREADY_CORRECT" if reason.endswith("SAME_VALUE") else "NO_RULE"
|
||||
elif status == "RULE_UNRESOLVED":
|
||||
action = "NO_DATA" if not data_available else "NO_RULE"
|
||||
else:
|
||||
action = _ACTION[status]
|
||||
return {
|
||||
"domain": domain, "nature": NATURE[domain], "current_value": current, "proposed_value": proposed,
|
||||
"status": status, "reason_code": reason, "confidence": confidence, "provenance": provenance,
|
||||
"write_eligible": write_eligible, "action_class": action,
|
||||
"data_available": data_available, "match_found": match_found, **extra,
|
||||
}
|
||||
|
||||
|
||||
def _norm_words(text):
|
||||
import unicodedata
|
||||
text = unicodedata.normalize("NFKD", str(text or "")).encode("ascii", "ignore").decode().lower()
|
||||
return {w for w in "".join(c if c.isalnum() else " " for c in text).split() if len(w) >= 4}
|
||||
|
||||
|
||||
def _ex_described(product_description, ex_description):
|
||||
"""An EX code applies to a product only if the product's own description
|
||||
carries the EX's official description terms. A lone EX row for the NCM is
|
||||
NOT evidence that this product is that EX (e.g. EX 'Chope' vs any beer)."""
|
||||
ex_words = _norm_words(ex_description)
|
||||
return bool(ex_words) and ex_words <= _norm_words(product_description)
|
||||
|
||||
|
||||
def _blank(value):
|
||||
return value is None or str(value).strip() == ""
|
||||
|
||||
|
||||
def decide_product(product, decision, company, refs):
|
||||
"""company: {"uf": "BA", "crt": "1"}; refs: reference-data snapshot dict:
|
||||
cfop_valid(set of codes), cst_icms/csosn/cst_pis/cst_cofins/origem (sets),
|
||||
fcp_uf(dict uf->{'aliq_1':...}), cbs_rate_2026 (float|None), cclasstrib_count (int)."""
|
||||
versions = decision.get("dataset_versions", {})
|
||||
prov = lambda kind: {"dataset": kind, "version": versions.get(kind.lower()), "engine": DECISION_ENGINE_VERSION} # noqa: E731
|
||||
out = {}
|
||||
|
||||
# ---- NCM (never written) -------------------------------------------------
|
||||
raw_ncm = product.get("CLASSIFICACAO_FISCAL")
|
||||
status = decision.get("status")
|
||||
if _blank(raw_ncm):
|
||||
out["NCM"] = _rec("NCM", raw_ncm, None, "RULE_UNRESOLVED", "NCM_MISSING", "NONE", prov("NCM"), data_available=False)
|
||||
elif status == "EXACT_NCM":
|
||||
out["NCM"] = _rec("NCM", raw_ncm, raw_ncm, "RULE_RESOLVED", "NCM_VALID_SAME_VALUE", "HIGH", prov("NCM"), match_found=True)
|
||||
elif status == "LEGACY_NCM":
|
||||
out["NCM"] = _rec("NCM", raw_ncm, None, "RULE_CANDIDATE", "LEGACY_NCM_REVIEW_REQUIRED", "LOW", prov("NCM"), match_found=True, action="REVIEW_REQUIRED")
|
||||
else:
|
||||
out["NCM"] = _rec("NCM", raw_ncm, None, "RULE_CANDIDATE", "NCM_INVALID_REVIEW_REQUIRED", "LOW", prov("NCM"), action="REVIEW_REQUIRED")
|
||||
|
||||
legacy = status == "LEGACY_NCM"
|
||||
proposals = {p["field"]: p for p in build_field_proposals(
|
||||
product["CODIGO"], decision,
|
||||
{"ipi": product.get("IPI"), "cest": product.get("CEST"), "ex_tipi": product.get("EX_TIPI")})}
|
||||
|
||||
# ---- IPI -------------------------------------------------------------------
|
||||
p = proposals.get("IPI")
|
||||
ipi_status = decision.get("ipi_status")
|
||||
if p and p["status"] in ("SAME_VALUE", "CHANGE_REQUIRED"):
|
||||
same = p["status"] == "SAME_VALUE"
|
||||
out["IPI"] = _rec("IPI", p["current_value"], p["proposed_value"], "RULE_RESOLVED",
|
||||
"TIPI_NUMERIC_SAME_VALUE" if same else "TIPI_NUMERIC_DIVERGES_FROM_ERP", "HIGH", prov("TIPI_IPI"),
|
||||
write_eligible=p["write_eligible"], match_found=True)
|
||||
elif p:
|
||||
blank = p["status"] == "SOURCE_BLANK" or p["reason_code"].endswith("SOURCE_BLANK")
|
||||
out["IPI"] = _rec("IPI", p["current_value"], None, "RULE_UNRESOLVED", p["reason_code"], "NONE", prov("TIPI_IPI"),
|
||||
data_available=not blank, match_found=True, action="NO_DATA" if blank else "NO_RULE")
|
||||
elif ipi_status == "IPI_CANDIDATES":
|
||||
out["IPI"] = _rec("IPI", product.get("IPI"), None, "RULE_CANDIDATE", "MULTIPLE_TIPI_CANDIDATES", "LOW", prov("TIPI_IPI"), match_found=True)
|
||||
else:
|
||||
out["IPI"] = _rec("IPI", product.get("IPI"), None, "RULE_UNRESOLVED", "LEGACY_NCM" if legacy else "NO_TIPI_MAPPING_FOUND", "NONE", prov("TIPI_IPI"))
|
||||
|
||||
# ---- EX_TIPI ---------------------------------------------------------------
|
||||
p = proposals.get("EX_TIPI")
|
||||
if p:
|
||||
ex_desc = next((c.get("description") for c in (decision.get("classification") or {}).get("ipi_candidates", []) if c.get("ex")), None)
|
||||
if p["status"] in ("SAME_VALUE", "CHANGE_REQUIRED") and not _ex_described(product.get("PRODUTO"), ex_desc):
|
||||
out["EX_TIPI"] = _rec("EX_TIPI", p["current_value"], None, "RULE_CANDIDATE", "SINGLE_EX_FOR_NCM_BUT_PRODUCT_DESCRIPTION_DOES_NOT_EVIDENCE_IT", "LOW", prov("TIPI_IPI"), match_found=True)
|
||||
elif p["status"] in ("SAME_VALUE", "CHANGE_REQUIRED"):
|
||||
out["EX_TIPI"] = _rec("EX_TIPI", p["current_value"], p["proposed_value"], "RULE_RESOLVED", "EX_" + p["status"], "HIGH", prov("TIPI_IPI"), write_eligible=p["write_eligible"], match_found=True)
|
||||
else:
|
||||
out["EX_TIPI"] = _rec("EX_TIPI", p["current_value"], None, "RULE_CANDIDATE", p["reason_code"], "LOW", prov("TIPI_IPI"), match_found=True)
|
||||
else:
|
||||
out["EX_TIPI"] = _rec("EX_TIPI", product.get("EX_TIPI"), None, "RULE_UNRESOLVED", "NO_EX_TIPI_FOR_NCM", "NONE", prov("TIPI_IPI"), data_available=not legacy)
|
||||
|
||||
# ---- CEST ------------------------------------------------------------------
|
||||
p = proposals.get("CEST")
|
||||
if p and p["status"] in ("SAME_VALUE", "CHANGE_REQUIRED"):
|
||||
out["CEST"] = _rec("CEST", p["current_value"], p["proposed_value"], "RULE_RESOLVED", "CEST_" + p["status"], "HIGH", prov("CEST"), write_eligible=p["write_eligible"], match_found=True)
|
||||
elif p and p["status"] == "CANDIDATE":
|
||||
n = len((decision.get("classification") or {}).get("cest_candidates") or [])
|
||||
out["CEST"] = _rec("CEST", p["current_value"], None, "RULE_CANDIDATE", p["reason_code"], "LOW", prov("CEST"), match_found=True, candidate_count=n)
|
||||
else:
|
||||
out["CEST"] = _rec("CEST", product.get("CEST"), None, "RULE_UNRESOLVED", p["reason_code"] if p else ("LEGACY_NCM" if legacy else "NO_CEST_DECISION"), "NONE", prov("CEST"), data_available=False if not p else True)
|
||||
|
||||
# ---- BA ST / MVA (no ERP destination field: never eligible) ---------------
|
||||
mva_status = decision.get("ba_st_mva_status")
|
||||
if mva_status == "MVA_RESOLVED":
|
||||
entry = (decision.get("classification") or {}).get("ba_st_mva") or {}
|
||||
out["MVA"] = _rec("MVA", None, entry.get("mva_original") or entry.get("mva_adjusted"), "RULE_RESOLVED", "MVA_RESOLVED_NO_ERP_TARGET_FIELD", "HIGH", prov("BA_ST_MVA"), match_found=True, action="NO_RULE", mva_status="MVA_RESOLVED")
|
||||
elif mva_status == "MVA_CANDIDATES":
|
||||
out["MVA"] = _rec("MVA", None, None, "RULE_CANDIDATE", "MULTIPLE_MVA_ITEMS_NEED_CEST_CONFIRMATION", "LOW", prov("BA_ST_MVA"), match_found=True, mva_status="MVA_CANDIDATE")
|
||||
elif mva_status == "NO_MVA_MAPPING_FOUND":
|
||||
out["MVA"] = _rec("MVA", None, None, "RULE_UNRESOLVED", "MVA_NOT_APPLICABLE_NO_BA_ANNEX1_ENTRY_FOR_NCM", "MEDIUM", prov("BA_ST_MVA"), action="NO_RULE", mva_status="MVA_NOT_APPLICABLE")
|
||||
else:
|
||||
out["MVA"] = _rec("MVA", None, None, "RULE_UNRESOLVED", "LEGACY_OR_INVALID_NCM", "NONE", prov("BA_ST_MVA"), data_available=False, mva_status="MVA_UNRESOLVED")
|
||||
mva_subject = mva_status in ("MVA_RESOLVED", "MVA_CANDIDATES")
|
||||
|
||||
# ---- CFOP (operation dependent) --------------------------------------------
|
||||
cfop = product.get("CFOP")
|
||||
cfop_valid = not _blank(cfop) and str(cfop).strip() in refs["cfop_valid"]
|
||||
out["CFOP"] = _rec("CFOP", cfop, None, "OPERATION_CONTEXT_REQUIRED", "CFOP_DEPENDS_ON_OPERATION_DIRECTION_UF_ST_PURPOSE", "HIGH", prov("CFOP"),
|
||||
data_available=True, match_found=cfop_valid, domain_valid=cfop_valid, current_is_blank=_blank(cfop))
|
||||
|
||||
# ---- CSOSN / CST ICMS / ORIGEM ---------------------------------------------
|
||||
csosn = product.get("CSOSN")
|
||||
csosn_valid = not _blank(csosn) and str(csosn).strip() in refs["csosn"]
|
||||
flags = {}
|
||||
if mva_subject and csosn_valid and str(csosn).strip() == "102":
|
||||
flags["consistency_flag"] = "CSOSN_102_BUT_NCM_IN_BA_ST_ANNEX1"
|
||||
out["CSOSN"] = _rec("CSOSN", csosn, None, "OPERATION_CONTEXT_REQUIRED", "CSOSN_DEPENDS_ON_SALE_SCENARIO_ST_RETENTION_CONSUMER", "HIGH", prov("NFE_MOC_TABLES"),
|
||||
match_found=csosn_valid, domain_valid=csosn_valid, **flags)
|
||||
|
||||
cst = product.get("CST")
|
||||
cst_s = "" if _blank(cst) else str(cst).strip()
|
||||
cst_valid = len(cst_s) == 3 and cst_s[1:] in refs["cst_icms"] and cst_s[0] in refs["origem"]
|
||||
out["CST_ICMS"] = _rec("CST_ICMS", cst, None, "OPERATION_CONTEXT_REQUIRED", "CRT1_USES_CSOSN_CST_DEPENDS_ON_OPERATION", "MEDIUM", prov("NFE_MOC_TABLES"),
|
||||
match_found=cst_valid, domain_valid=cst_valid)
|
||||
|
||||
origin_candidate = cst_s[0] if cst_valid else None
|
||||
out["ORIGEM"] = _rec("ORIGEM", product.get("ORIGEM"), origin_candidate, "RULE_CANDIDATE" if origin_candidate else "RULE_UNRESOLVED",
|
||||
"ORIGIN_ONLY_FROM_ERP_CST_FIRST_DIGIT_NO_OBJECTIVE_PRODUCT_ORIGIN" if origin_candidate else "NO_ORIGIN_INFORMATION",
|
||||
"LOW", prov("NFE_MOC_TABLES"), data_available=bool(origin_candidate),
|
||||
action="REVIEW_REQUIRED" if origin_candidate else "NO_DATA", mapping="MAPPING_UNCONFIRMED")
|
||||
|
||||
# ---- PIS / COFINS ----------------------------------------------------------
|
||||
for domain, field, dom in (("PIS_CST", "COD_SAIDA_PIS", "cst_pis"), ("COFINS_CST", "COD_SAIDA_COFINS", "cst_cofins")):
|
||||
cur = product.get(field)
|
||||
valid = not _blank(cur) and str(cur).strip() in refs[dom]
|
||||
out[domain] = _rec(domain, cur, None, "OPERATION_CONTEXT_REQUIRED", "CST_PIS_COFINS_DEPENDS_ON_OPERATION_AND_INCIDENCE_NO_DETERMINISTIC_RULE_IN_CORE_DATASETS", "MEDIUM", prov("NFE_MOC_TABLES"),
|
||||
match_found=valid, domain_valid=valid, mapping="MAPPING_UNCONFIRMED")
|
||||
out["PIS_COFINS_RATE"] = _rec("PIS_COFINS_RATE", product.get("ALIQ_PIS"), None, "RULE_UNRESOLVED", "NO_RATE_SOURCE_4_3_10_17_DEFERRED_NEVER_ASSUME_ZERO", "NONE", prov("NFE_MOC_TABLES"), data_available=False, action="NO_RULE")
|
||||
|
||||
# ---- FCP -------------------------------------------------------------------
|
||||
fcp = refs["fcp_uf"].get(company["uf"])
|
||||
out["FCP"] = _rec("FCP", None, None, "OPERATION_CONTEXT_REQUIRED", "FCP_APPLICABILITY_DEPENDS_ON_DESTINATION_UF_OPERATION_AND_ITEM_LIST", "HIGH", prov("FCP"),
|
||||
data_available=fcp is not None, match_found=False, fcp_rate_available=fcp is not None, company_uf=company["uf"])
|
||||
|
||||
# ---- RTC -------------------------------------------------------------------
|
||||
out["RTC_CCLASSTRIB"] = _rec("RTC_CCLASSTRIB", None, None, "OPERATION_CONTEXT_REQUIRED", "NO_NCM_TO_CCLASSTRIB_MAP_IN_ACTIVE_DATASETS_AND_DEPENDS_ON_OPERATION", "NONE", prov("RTC_CCLASSTRIB"),
|
||||
data_available=refs["cclasstrib_count"] > 0, candidate_count=0)
|
||||
out["RTC_CBS_RATE"] = _rec("RTC_CBS_RATE", None, None, "OPERATION_CONTEXT_REQUIRED", "CBS_2026_RATE_APPLIES_ONLY_AFTER_CCLASSTRIB_RESOLVED_NOT_UNIVERSAL", "NONE", prov("RTC_CBS_RATES"),
|
||||
data_available=refs["cbs_rate_2026"] is not None, reference_rate_2026=refs["cbs_rate_2026"])
|
||||
out["RTC_CCREDPRES"] = _rec("RTC_CCREDPRES", None, None, "RULE_UNRESOLVED", "OFFICIAL_CONDITION_NOT_SATISFIED_OR_NOT_KNOWABLE_FROM_PRODUCT", "NONE", prov("RTC_CCREDPRES"), action="NO_RULE")
|
||||
return out
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Conservative fiscal determination with no inferred tax rules."""
|
||||
|
||||
|
||||
class FiscalDetermination:
|
||||
def determine(self, context):
|
||||
candidates = context.get("cest_candidates", [])
|
||||
cest = candidates[0] if len(candidates) == 1 else None
|
||||
reason_codes = ["OFFICIAL_RULE_NOT_IMPLEMENTED", "MISSING_ST_DETERMINATION", "MISSING_PRODUCT_ORIGIN"]
|
||||
if len(candidates) > 1:
|
||||
reason_codes.append("CEST_AMBIGUOUS")
|
||||
return {"cest": cest, "csosn": None, "cfop": None, "confidence": 0.0, "reason_codes": reason_codes, "can_auto_apply": False}
|
||||
@@ -0,0 +1,107 @@
|
||||
"""ICMS Context Engine V1: ST applicability, ST withholding evidence, product role, CSOSN decision V2.
|
||||
|
||||
Pure functions, no I/O, no writes (every output has write_eligible=False). History is only used to
|
||||
validate/flag; it is never an input to a decision.
|
||||
"""
|
||||
ICMS_CONTEXT_ENGINE_VERSION = "icms-context-v1"
|
||||
|
||||
|
||||
def _digits(v):
|
||||
return "".join(c for c in str(v or "") if c.isdigit())
|
||||
|
||||
|
||||
def st_status(ncm_status, mva_status, cest_status, erp_cest, ba_item_cest, ba_ncm_sh_len):
|
||||
"""ST_CONFIRMED needs a unique BA Anexo 1 item whose CEST the ERP product itself carries
|
||||
(or a confirmed CEST). ST_NOT_APPLICABLE needs BOTH official lists (BA Anexo 1 and CEST) silent
|
||||
for a valid NCM. A CEST *candidate* never confirms ST."""
|
||||
if ncm_status != "EXACT_NCM":
|
||||
return "ST_UNKNOWN", "NCM_NOT_VALID"
|
||||
if mva_status == "MVA_RESOLVED":
|
||||
if cest_status == "CEST_CONFIRMED" or (_digits(erp_cest) and _digits(erp_cest) == _digits(ba_item_cest)):
|
||||
return "ST_CONFIRMED", "UNIQUE_BA_ANNEX1_ITEM_CORROBORATED_BY_CEST"
|
||||
return "ST_CANDIDATE", "UNIQUE_BA_ANNEX1_ITEM_WITHOUT_CEST_CORROBORATION"
|
||||
if mva_status == "MVA_CANDIDATES":
|
||||
return "ST_CANDIDATE", "MULTIPLE_BA_ANNEX1_ITEMS"
|
||||
if mva_status == "NO_MVA_MAPPING_FOUND":
|
||||
if cest_status == "NO_CEST_MAPPING_FOUND":
|
||||
return "ST_NOT_APPLICABLE", "NCM_ABSENT_FROM_BA_ANNEX1_AND_CEST_LIST"
|
||||
return "ST_UNKNOWN", "CEST_LISTED_BUT_BA_ANNEX1_ABSENT"
|
||||
return "ST_UNKNOWN", "NO_ST_EVIDENCE"
|
||||
|
||||
|
||||
def withheld_status(purchase_rows):
|
||||
"""purchase_rows: entrada item rows for the product with keys icms_retido (S/N), valor_retido, sub_valor.
|
||||
CONFIRMED only with positive purchase-side evidence; explicit 'N'/0 is NOT_CONFIRMED; no rows UNKNOWN."""
|
||||
if not purchase_rows:
|
||||
return "ST_WITHHOLDING_UNKNOWN", "NO_PURCHASE_ITEM_FOR_PRODUCT"
|
||||
for r in purchase_rows:
|
||||
if str(r.get("icms_retido") or "").strip().upper() == "S" or float(r.get("valor_retido") or 0) > 0 or float(r.get("sub_valor") or 0) > 0:
|
||||
return "ST_ALREADY_WITHHELD_CONFIRMED", "PURCHASE_ITEM_SHOWS_WITHHELD_ICMS_ST"
|
||||
return "ST_ALREADY_WITHHELD_NOT_CONFIRMED", "PURCHASE_ITEMS_FLAG_ICMS_RETIDO_N_AND_ZERO_ST_VALUES"
|
||||
|
||||
|
||||
def product_role(erp_tipo, prod_cozinha, has_recipe, sold_as_composition, service_flag, purchase_evidence):
|
||||
ev = []
|
||||
if service_flag:
|
||||
return "SERVICE", ["PRODUTO_SERVICO"]
|
||||
if str(erp_tipo or "").strip().upper() == "MATERIA-PRIMA":
|
||||
return "INPUT", ["C000025.TIPO=MATERIA-PRIMA"]
|
||||
own = []
|
||||
if prod_cozinha: own.append("PROD_COZINHA=1")
|
||||
if has_recipe: own.append("RECEITA_INGREDIENTES")
|
||||
if sold_as_composition: own.append("C000032.TIPO=COMPOSICAO")
|
||||
if own and purchase_evidence:
|
||||
return "UNKNOWN", own + ["ALSO_HAS_PURCHASE_EVIDENCE_CONFLICT"]
|
||||
if own:
|
||||
return "OWN_PRODUCTION", own
|
||||
if str(erp_tipo or "").strip().upper() == "VENDA" and purchase_evidence:
|
||||
return "RESALE", ["C000025.TIPO=VENDA", "PURCHASE_DOCUMENT_EVIDENCE"]
|
||||
return "UNKNOWN", ev or ["NO_OBJECTIVE_ROLE_EVIDENCE"]
|
||||
|
||||
|
||||
def csosn_v2(current, company_default, ncm_status, st, withheld, role, csosn_domain):
|
||||
"""States: CSOSN_RESOLVED / CANDIDATE / CONTEXT_REQUIRED / CONFLICT / UNRESOLVED.
|
||||
RESOLVED only where the fiscal condition is explicitly proven (ST confirmed + withholding confirmed + role
|
||||
known -> 500). Everything else stays conservative; never assumes 102."""
|
||||
cur = None if current is None or str(current).strip() == "" else str(current).strip()
|
||||
out = {"field": "CSOSN", "current_value": cur, "proposed_csosn": None, "write_eligible": False, "engine": ICMS_CONTEXT_ENGINE_VERSION}
|
||||
def res(state, reason, conf, action, proposed=None, **kw):
|
||||
return {**out, "status": state, "reason_code": reason, "confidence": conf, "action_class": action, "proposed_csosn": proposed, **kw}
|
||||
if ncm_status != "EXACT_NCM":
|
||||
return res("CSOSN_UNRESOLVED", "NCM_NOT_VALID", "NONE", "NO_RULE")
|
||||
if role in ("INPUT", "SERVICE"):
|
||||
return res("CSOSN_CONTEXT_REQUIRED", f"ROLE_{role}_NOT_A_MERCHANDISE_SALE_DEFAULT", "NONE", "CONTEXT_REQUIRED")
|
||||
if st == "ST_CONFIRMED":
|
||||
if withheld == "ST_ALREADY_WITHHELD_CONFIRMED" and role in ("RESALE",):
|
||||
state = "CSOSN_RESOLVED"
|
||||
return res(state, "ST_CONFIRMED_AND_WITHHOLDING_CONFIRMED_RESALE", "HIGH", "ALREADY_CORRECT" if cur == "500" else "AUTO_APPLY_SAFE", "500")
|
||||
if withheld == "ST_ALREADY_WITHHELD_NOT_CONFIRMED" and cur == "500":
|
||||
return res("CSOSN_CONFLICT", "CSOSN_500_BUT_PURCHASE_ITEMS_SHOW_NO_ST_WITHHELD", "MEDIUM", "REVIEW_REQUIRED")
|
||||
if withheld == "ST_ALREADY_WITHHELD_CONFIRMED" and cur != "500":
|
||||
return res("CSOSN_CONFLICT", "ST_WITHHELD_CONFIRMED_BUT_CSOSN_NOT_500", "MEDIUM", "REVIEW_REQUIRED", "500")
|
||||
return res("CSOSN_CONTEXT_REQUIRED", "ST_CONFIRMED_WITHHOLDING_OR_ROLE_UNPROVEN", "NONE", "CONTEXT_REQUIRED",
|
||||
consistency_flag=None if cur == "500" else "ST_CONFIRMED_BUT_CSOSN_NOT_500")
|
||||
if st in ("ST_CANDIDATE", "ST_UNKNOWN"):
|
||||
return res("CSOSN_CONTEXT_REQUIRED", f"{st}_APPLICABILITY_UNPROVEN", "NONE", "CONTEXT_REQUIRED")
|
||||
# ST_NOT_APPLICABLE: taxed vs exempt/not-taxed treatment (102 vs 400/300) has no source
|
||||
if role in ("RESALE", "OWN_PRODUCTION") and cur == company_default and cur in csosn_domain:
|
||||
return res("CSOSN_CANDIDATE", "NON_ST_MATCHES_COMPANY_DEFAULT_ICMS_TREATMENT_UNPROVEN", "MEDIUM", "REVIEW_REQUIRED", cur)
|
||||
if role == "UNKNOWN":
|
||||
return res("CSOSN_CONTEXT_REQUIRED", "PRODUCT_ROLE_UNKNOWN", "NONE", "CONTEXT_REQUIRED")
|
||||
return res("CSOSN_CANDIDATE" if cur else "CSOSN_CONTEXT_REQUIRED", "DIFFERS_FROM_OR_LACKS_COMPANY_DEFAULT_TREATMENT_UNPROVEN", "LOW", "REVIEW_REQUIRED" if cur else "CONTEXT_REQUIRED")
|
||||
|
||||
|
||||
def st_conflict_class(cest_status, mva_status, ncm_sh_len, erp_cest, ba_item_cest, erp_cfop, history_emitted_current, non_st_but_erp_5405):
|
||||
"""Primary label for an ST-related CFOP/CSOSN conflict, plus all evidence flags."""
|
||||
flags = []
|
||||
if non_st_but_erp_5405 or (_digits(erp_cest) and _digits(ba_item_cest) and _digits(erp_cest) != _digits(ba_item_cest)):
|
||||
flags.append("BA_ST_RULE_CONFLICT")
|
||||
if history_emitted_current:
|
||||
flags.append("ERP_HISTORY_CONFLICT")
|
||||
if cest_status == "CEST_CANDIDATES":
|
||||
flags.append("MULTIPLE_CEST")
|
||||
if cest_status == "CEST_DESCRIPTION_MISMATCH":
|
||||
flags.append("DESCRIPTION_MISMATCH")
|
||||
if ncm_sh_len is not None and ncm_sh_len <= 4:
|
||||
flags.append("NCM_TOO_BROAD")
|
||||
return (flags[0] if flags else "NO_EVIDENCE"), flags or ["NO_EVIDENCE"]
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Minimal job queue for Agent orchestration: create, atomically claim, complete, fail."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from .models import Job
|
||||
|
||||
_VALID_KINDS = {"REPROCESS_ALL", "REPROCESS_PRODUCTS"}
|
||||
_MAX_ERROR_LENGTH = 2000
|
||||
|
||||
|
||||
def create_job(session, agent, kind, payload=None):
|
||||
if kind not in _VALID_KINDS:
|
||||
raise ValueError(f"unsupported job kind: {kind}")
|
||||
job = Job(agent_id=agent.id, kind=kind, status="PENDING", payload=payload or {})
|
||||
session.add(job)
|
||||
session.commit()
|
||||
return job
|
||||
|
||||
|
||||
def claim_next_job(session, agent):
|
||||
"""Atomically claim the oldest PENDING job for this agent only.
|
||||
A single UPDATE...WHERE status='PENDING' guards against two concurrent
|
||||
pollers (this agent or any other) claiming the same job twice."""
|
||||
candidate = session.scalar(
|
||||
select(Job.id)
|
||||
.where(Job.agent_id == agent.id, Job.status == "PENDING")
|
||||
.order_by(Job.created_at.asc())
|
||||
.limit(1)
|
||||
)
|
||||
if candidate is None:
|
||||
return None
|
||||
now = datetime.now(timezone.utc)
|
||||
result = session.execute(
|
||||
update(Job)
|
||||
.where(Job.id == candidate, Job.status == "PENDING")
|
||||
.values(status="RUNNING", started_at=now, attempts=Job.attempts + 1)
|
||||
)
|
||||
session.commit()
|
||||
if result.rowcount != 1:
|
||||
return None # another poller won the race; caller can poll again
|
||||
return session.get(Job, candidate)
|
||||
|
||||
|
||||
def complete_job(session, job, result_summary):
|
||||
result = session.execute(
|
||||
update(Job)
|
||||
.where(Job.id == job.id, Job.status == "RUNNING")
|
||||
.values(status="COMPLETED", finished_at=datetime.now(timezone.utc), result_summary=result_summary)
|
||||
)
|
||||
session.commit()
|
||||
return result.rowcount == 1
|
||||
|
||||
|
||||
def fail_job(session, job, error):
|
||||
sanitized = str(error)[:_MAX_ERROR_LENGTH]
|
||||
result = session.execute(
|
||||
update(Job)
|
||||
.where(Job.id == job.id, Job.status == "RUNNING")
|
||||
.values(status="FAILED", finished_at=datetime.now(timezone.utc), error=sanitized)
|
||||
)
|
||||
session.commit()
|
||||
return result.rowcount == 1
|
||||
@@ -0,0 +1,39 @@
|
||||
"""FastAPI application factory."""
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from .config import settings
|
||||
from .db import SessionLocal, database_is_available
|
||||
from .api import router
|
||||
from .version import GTI_FISCAL_VERSION
|
||||
|
||||
logging.basicConfig(level=getattr(logging, settings.log_level.upper(), logging.INFO))
|
||||
logger = logging.getLogger("gti_fiscal_hub")
|
||||
|
||||
|
||||
def create_app(database_probe: Callable[[], bool] = database_is_available, session_factory=SessionLocal, bootstrap_token: str | None = None) -> FastAPI:
|
||||
app = FastAPI(title="GTI Fiscal Hub", version=GTI_FISCAL_VERSION)
|
||||
if settings.environment == "production" and settings.agent_dev_token:
|
||||
raise RuntimeError("AGENT_DEV_TOKEN must not be set when APP_ENV=production (register real Agents with central/scripts/register_agent.py)")
|
||||
logger.info("starting GTI Fiscal Hub (environment=%s)", settings.environment)
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict[str, str]:
|
||||
if not database_probe():
|
||||
logger.warning("health check failed: postgres unavailable")
|
||||
raise HTTPException(status_code=503, detail="postgres unavailable")
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.get("/version")
|
||||
def version() -> dict[str, str]:
|
||||
from .classification import DECISION_VERSION, ENGINE_VERSION
|
||||
from .version import API_COMPAT_MAJOR_MINOR, GTI_FISCAL_VERSION
|
||||
return {"version": GTI_FISCAL_VERSION, "api_compat": API_COMPAT_MAJOR_MINOR, "engine_version": ENGINE_VERSION, "decision_version": DECISION_VERSION}
|
||||
|
||||
app.include_router(router(session_factory, settings.agent_dev_token if bootstrap_token is None else bootstrap_token))
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1,341 @@
|
||||
"""PostgreSQL records owned by the Fiscal Hub."""
|
||||
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, JSON, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class Dataset(Base):
|
||||
__tablename__ = "datasets"
|
||||
__table_args__ = (UniqueConstraint("provider", "sha256", name="uq_dataset_provider_hash"),)
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
kind: Mapped[str] = mapped_column(String(32), index=True)
|
||||
provider: Mapped[str] = mapped_column(String(128))
|
||||
sha256: Mapped[str] = mapped_column(String(64))
|
||||
raw_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
version_label: Mapped[str] = mapped_column(String(256))
|
||||
act: Mapped[str] = mapped_column(Text)
|
||||
source_url: Mapped[str] = mapped_column(Text)
|
||||
downloaded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
status: Mapped[str] = mapped_column(String(32), index=True)
|
||||
record_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
|
||||
class NcmEntry(Base):
|
||||
__tablename__ = "ncm_entries"
|
||||
__table_args__ = (UniqueConstraint("dataset_id", "code", "valid_from", "valid_to", name="uq_ncm_versioned_entry"),)
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
dataset_id: Mapped[int] = mapped_column(ForeignKey("datasets.id"), index=True)
|
||||
code: Mapped[str] = mapped_column(String(16), index=True)
|
||||
description: Mapped[str] = mapped_column(Text)
|
||||
valid_from: Mapped[str] = mapped_column(String(32))
|
||||
valid_to: Mapped[str] = mapped_column(String(32))
|
||||
|
||||
|
||||
class LegacyNcmEntry(Base):
|
||||
__tablename__ = "legacy_ncm_entries"
|
||||
code: Mapped[str] = mapped_column(String(16), primary_key=True)
|
||||
source: Mapped[str] = mapped_column(Text)
|
||||
source_date: Mapped[str] = mapped_column(String(32))
|
||||
reason: Mapped[str] = mapped_column(Text)
|
||||
successor_code: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
|
||||
|
||||
class CestEntry(Base):
|
||||
__tablename__ = "cest_entries"
|
||||
__table_args__ = (UniqueConstraint("dataset_id", "code", name="uq_cest_versioned_entry"),)
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
dataset_id: Mapped[int] = mapped_column(ForeignKey("datasets.id"), index=True)
|
||||
code: Mapped[str] = mapped_column(String(16), index=True)
|
||||
description: Mapped[str] = mapped_column(Text)
|
||||
annex: Mapped[str] = mapped_column(String(8))
|
||||
|
||||
|
||||
class CestNcmEntry(Base):
|
||||
__tablename__ = "cest_ncm_entries"
|
||||
__table_args__ = (UniqueConstraint("cest_entry_id", "ncm_code", name="uq_cest_ncm_link"),)
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
cest_entry_id: Mapped[int] = mapped_column(ForeignKey("cest_entries.id"), index=True)
|
||||
ncm_code: Mapped[str] = mapped_column(String(16), index=True)
|
||||
|
||||
|
||||
class TipiEntry(Base):
|
||||
__tablename__ = "tipi_entries"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
dataset_id: Mapped[int] = mapped_column(ForeignKey("datasets.id"), index=True)
|
||||
ncm: Mapped[str] = mapped_column(String(8), index=True)
|
||||
ex: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
description: Mapped[str] = mapped_column(Text)
|
||||
ipi_rate: Mapped[float | None] = mapped_column(nullable=True)
|
||||
ipi_rate_text: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
source_row: Mapped[int] = mapped_column(Integer)
|
||||
metadata_json: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
|
||||
|
||||
class BaStMvaEntry(Base):
|
||||
__tablename__ = "ba_st_mva_entries"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
dataset_id: Mapped[int] = mapped_column(ForeignKey("datasets.id"), index=True)
|
||||
item: Mapped[str] = mapped_column(String(32))
|
||||
segment: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
cest: Mapped[str | None] = mapped_column(String(16), index=True, nullable=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
mva_original: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
mva_adjusted: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
agreement_info: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
source_page: Mapped[int] = mapped_column(Integer)
|
||||
metadata_json: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
|
||||
class BaStMvaNcmSh(Base):
|
||||
__tablename__ = "ba_st_mva_ncm_sh"
|
||||
__table_args__ = (UniqueConstraint("entry_id", "ncm_sh", name="uq_ba_st_mva_entry_ncm_sh"),)
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
entry_id: Mapped[int] = mapped_column(ForeignKey("ba_st_mva_entries.id"), index=True)
|
||||
ncm_sh: Mapped[str] = mapped_column(String(32), index=True)
|
||||
metadata_json: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
|
||||
|
||||
class FiscalCodeEntry(Base):
|
||||
"""Small national fiscal code domains sourced from the official NF-e MOC
|
||||
manual: ORIGEM_MERCADORIA, CST_ICMS, CSOSN, CST_PIS, CST_COFINS."""
|
||||
__tablename__ = "fiscal_code_entries"
|
||||
__table_args__ = (UniqueConstraint("dataset_id", "domain", "code", name="uq_fiscal_code_entry"),)
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
dataset_id: Mapped[int] = mapped_column(ForeignKey("datasets.id"), index=True)
|
||||
domain: Mapped[str] = mapped_column(String(32), index=True)
|
||||
code: Mapped[str] = mapped_column(String(8), index=True)
|
||||
description: Mapped[str] = mapped_column(Text)
|
||||
source_section: Mapped[str] = mapped_column(String(96))
|
||||
metadata_json: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
|
||||
|
||||
class CfopEntry(Base):
|
||||
"""CFOP (Código Fiscal de Operações e Prestações) — Portal Nacional NF-e."""
|
||||
__tablename__ = "cfop_entries"
|
||||
__table_args__ = (UniqueConstraint("dataset_id", "code", name="uq_cfop_entry"),)
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
dataset_id: Mapped[int] = mapped_column(ForeignKey("datasets.id"), index=True)
|
||||
code: Mapped[str] = mapped_column(String(8), index=True)
|
||||
title: Mapped[str] = mapped_column(Text)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
effective_from: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
effective_to: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
ind_nfe: Mapped[str | None] = mapped_column(String(4), nullable=True)
|
||||
ind_comunica: Mapped[str | None] = mapped_column(String(4), nullable=True)
|
||||
ind_transp: Mapped[str | None] = mapped_column(String(4), nullable=True)
|
||||
ind_devol: Mapped[str | None] = mapped_column(String(4), nullable=True)
|
||||
ind_retor: Mapped[str | None] = mapped_column(String(4), nullable=True)
|
||||
ind_anula: Mapped[str | None] = mapped_column(String(4), nullable=True)
|
||||
ind_remes: Mapped[str | None] = mapped_column(String(4), nullable=True)
|
||||
ind_comb: Mapped[str | None] = mapped_column(String(4), nullable=True)
|
||||
ind_exc_ibscbs: Mapped[str | None] = mapped_column(String(4), nullable=True)
|
||||
|
||||
|
||||
class FcpRate(Base):
|
||||
"""FCP (Fundo de Combate à Pobreza) alíquotas por UF — Portal Nacional NF-e."""
|
||||
__tablename__ = "fcp_rates"
|
||||
__table_args__ = (UniqueConstraint("dataset_id", "uf", name="uq_fcp_rate_uf"),)
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
dataset_id: Mapped[int] = mapped_column(ForeignKey("datasets.id"), index=True)
|
||||
uf_code: Mapped[str] = mapped_column(String(4))
|
||||
uf: Mapped[str] = mapped_column(String(4), index=True)
|
||||
uf_name: Mapped[str] = mapped_column(String(64))
|
||||
aliq_1: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
aliq_2: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
aliq_3: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
observacao: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
|
||||
class AnpProductCode(Base):
|
||||
"""cProdANP — official code list used by NF-e, published on the Portal Nacional NF-e."""
|
||||
__tablename__ = "anp_product_codes"
|
||||
__table_args__ = (UniqueConstraint("dataset_id", "cprod_anp", name="uq_anp_product_code"),)
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
dataset_id: Mapped[int] = mapped_column(ForeignKey("datasets.id"), index=True)
|
||||
cprod_anp: Mapped[str] = mapped_column(String(16), index=True)
|
||||
|
||||
|
||||
class NcmUtribEntry(Base):
|
||||
"""NCM x uTrib (unidade tributável de comércio exterior) — Portal Nacional NF-e."""
|
||||
__tablename__ = "ncm_utrib_entries"
|
||||
__table_args__ = (UniqueConstraint("dataset_id", "ncm", name="uq_ncm_utrib_entry"),)
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
dataset_id: Mapped[int] = mapped_column(ForeignKey("datasets.id"), index=True)
|
||||
ncm: Mapped[str] = mapped_column(String(16), index=True)
|
||||
effective_from: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
effective_to: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
utrib_abbreviation: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
utrib_description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
|
||||
class RtcBiofuelIndexEntry(Base):
|
||||
"""RTC pBioObrigatorio (índice de biocombustível) by cProdANP — Portal Nacional NF-e."""
|
||||
__tablename__ = "rtc_biofuel_index_entries"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
dataset_id: Mapped[int] = mapped_column(ForeignKey("datasets.id"), index=True)
|
||||
cprod_anp: Mapped[str | None] = mapped_column(String(16), index=True, nullable=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
p_bio_percent: Mapped[float | None] = mapped_column(nullable=True)
|
||||
effective_from: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
effective_to: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
is_historical: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
|
||||
class RtcCredPresEntry(Base):
|
||||
"""RTC cCredPres (crédito presumido IBS/CBS), LC 214/2025 — Portal Nacional NF-e.
|
||||
Keeps every official field distinctly so future automatic determination
|
||||
doesn't require re-parsing the source."""
|
||||
__tablename__ = "rtc_cred_pres_entries"
|
||||
__table_args__ = (UniqueConstraint("dataset_id", "code", name="uq_rtc_cred_pres_code"),)
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
dataset_id: Mapped[int] = mapped_column(ForeignKey("datasets.id"), index=True)
|
||||
code: Mapped[str] = mapped_column(String(8), index=True)
|
||||
description: Mapped[str] = mapped_column(Text)
|
||||
legal_basis: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
apropria_via_nf: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
apropria_via_evento: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
ind_deduz_cred_pres: Mapped[str | None] = mapped_column(String(8), nullable=True)
|
||||
ind_g_cbs_cred_pres: Mapped[str | None] = mapped_column(String(8), nullable=True)
|
||||
ind_g_ibs_cred_pres: Mapped[str | None] = mapped_column(String(8), nullable=True)
|
||||
aliquota_cbs_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
aliquota_ibs_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
p_aliq_cred_pres_cbs: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
p_aliq_cred_pres_ibs: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
p_red_transicao_ibs: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# Free text in some rows (e.g. "cClass de alíquota zero"), not always the numeric code.
|
||||
cclass_nota_referenciada: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
d_ini_vig_cbs: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
d_fim_vig_cbs: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
d_ini_vig_ibs: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
d_fim_vig_ibs: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
|
||||
|
||||
class Tenant(Base):
|
||||
__tablename__ = "tenants"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(128), unique=True)
|
||||
|
||||
|
||||
class Agent(Base):
|
||||
__tablename__ = "agents"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"), index=True) # company_id
|
||||
name: Mapped[str] = mapped_column(String(128))
|
||||
token_hash: Mapped[str] = mapped_column(String(64), unique=True)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
|
||||
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_status: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
schema_fingerprint: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
agent_version: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class Job(Base):
|
||||
__tablename__ = "jobs"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
agent_id: Mapped[int] = mapped_column(ForeignKey("agents.id"), index=True)
|
||||
kind: Mapped[str] = mapped_column(String(32), index=True) # REPROCESS_ALL | REPROCESS_PRODUCTS
|
||||
status: Mapped[str] = mapped_column(String(16), index=True, default="PENDING", server_default="PENDING")
|
||||
payload: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
attempts: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
result_summary: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
|
||||
class ClassificationRequest(Base):
|
||||
__tablename__ = "classification_requests"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"), index=True)
|
||||
agent_id: Mapped[int] = mapped_column(ForeignKey("agents.id"), index=True)
|
||||
fingerprint: Mapped[str] = mapped_column(String(64), unique=True)
|
||||
payload: Mapped[dict] = mapped_column(JSON)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class ClassificationResult(Base):
|
||||
__tablename__ = "classification_results"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
request_id: Mapped[int] = mapped_column(ForeignKey("classification_requests.id"), unique=True)
|
||||
result: Mapped[dict] = mapped_column(JSON)
|
||||
|
||||
|
||||
class ClassificationCache(Base):
|
||||
__tablename__ = "classification_cache"
|
||||
fingerprint: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"), index=True)
|
||||
result: Mapped[dict] = mapped_column(JSON)
|
||||
|
||||
|
||||
class RtcCstIbsCbsEntry(Base):
|
||||
"""RTC CST-IBS/CBS (sheet 'CST ...' of the official cClassTrib workbook)."""
|
||||
__tablename__ = "rtc_cst_ibscbs_entries"
|
||||
__table_args__ = (UniqueConstraint("dataset_id", "cst", name="uq_rtc_cst_ibscbs"),)
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
dataset_id: Mapped[int] = mapped_column(ForeignKey("datasets.id"), index=True)
|
||||
cst: Mapped[str] = mapped_column(String(8), index=True)
|
||||
description: Mapped[str] = mapped_column(Text)
|
||||
indicators: Mapped[dict] = mapped_column(JSON)
|
||||
|
||||
|
||||
class RtcCClassTribEntry(Base):
|
||||
"""RTC cClassTrib (sheet 'cClass ...'). Every official column is kept:
|
||||
key fields typed, all ind_* flags in `indicators` keyed by official header."""
|
||||
__tablename__ = "rtc_cclasstrib_entries"
|
||||
__table_args__ = (UniqueConstraint("dataset_id", "cclasstrib", name="uq_rtc_cclasstrib"),)
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
dataset_id: Mapped[int] = mapped_column(ForeignKey("datasets.id"), index=True)
|
||||
cst: Mapped[str] = mapped_column(String(8), index=True)
|
||||
cst_description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
cclasstrib: Mapped[str] = mapped_column(String(8), index=True)
|
||||
name: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
lc_redacao: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
lc_214: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
regulamento_cbs: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
regulamento_ibs: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
tipo_aliquota: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
p_red_ibs: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
p_red_cbs: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
d_ini_vig: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
d_fim_vig: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
data_atualizacao: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
anexo: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
link: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
indicators: Mapped[dict] = mapped_column(JSON)
|
||||
|
||||
|
||||
class RtcCbsRateEntry(Base):
|
||||
"""RTC CBS rate per year (IT 2026.002). rate is NULL when the official
|
||||
text has no number yet (e.g. 'aguarda legislação')."""
|
||||
__tablename__ = "rtc_cbs_rate_entries"
|
||||
__table_args__ = (UniqueConstraint("dataset_id", "year", name="uq_rtc_cbs_rate_year"),)
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
dataset_id: Mapped[int] = mapped_column(ForeignKey("datasets.id"), index=True)
|
||||
year: Mapped[int] = mapped_column(Integer, index=True)
|
||||
rate_text: Mapped[str] = mapped_column(Text)
|
||||
rate: Mapped[float | None] = mapped_column(nullable=True)
|
||||
|
||||
|
||||
class FiscalContextEntry(Base):
|
||||
"""Operator-supplied fiscal fact (product role, origin, ST retention, treatment, ...). Hierarchy: PRODUCT > GROUP > COMPANY."""
|
||||
__tablename__ = "fiscal_context_entries"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "company_key", "scope", "scope_key", "key", name="uq_fiscal_context_entry"),)
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"), index=True)
|
||||
company_key: Mapped[str] = mapped_column(String(64), index=True)
|
||||
scope: Mapped[str] = mapped_column(String(8))
|
||||
scope_key: Mapped[str] = mapped_column(String(64), default="", server_default="")
|
||||
key: Mapped[str] = mapped_column(String(48))
|
||||
value: Mapped[str] = mapped_column(Text)
|
||||
source: Mapped[str] = mapped_column(String(16), default="OPERATOR", server_default="OPERATOR")
|
||||
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Product Fiscal Defaults Engine V1 — C000025.CFOP / CSOSN / CST default decisions.
|
||||
|
||||
Pure functions. Scope: intra-UF (BA) NFC-e-style sale of merchandise, company CRT=1 (Simples).
|
||||
Historical documents are NEVER an input here; they are used by the runner only to validate.
|
||||
|
||||
CFOP rule chain (all must hold, else CANDIDATE/CONTEXT/UNRESOLVED):
|
||||
ERP CFOP mapping HIGH (product default, saída) + NCM EXACT + current CFOP in official table + first digit 5
|
||||
(saída, intra-UF) + ERP declares resale family (5102/5405: 'adquirida ou recebida de terceiros')
|
||||
+ ST applicability known from BA Anexo 1 (MVA_RESOLVED -> subject, NO_MVA_MAPPING_FOUND -> not subject)
|
||||
=> derived CFOP = 5405 if subject else 5102. Equal to current => RESOLVED (already correct).
|
||||
Different => CANDIDATE only: one of the inputs is wrong and the engine cannot tell which
|
||||
(ST retention by the supplier is not available), so it is never write-eligible.
|
||||
The chain never yields a write: filling a blank CFOP would need the resale-vs-production declaration that
|
||||
only the current CFOP carries, so blank => OPERATION_CONTEXT_REQUIRED.
|
||||
|
||||
CSOSN: no dataset states whether a product is taxed (102), exempt/not taxed (400/300), ST-charged (500)...
|
||||
=> never RESOLVED. Company default C000004.ICMS_CSOSN is only a CANDIDATE anchor for non-ST products.
|
||||
"""
|
||||
PRODUCT_DEFAULTS_ENGINE_VERSION = "product-defaults-v1"
|
||||
RESALE_CFOPS = {"5102", "5405"}
|
||||
|
||||
|
||||
def _blank(v):
|
||||
return v is None or str(v).strip() == ""
|
||||
|
||||
|
||||
def _out(field, current, proposed, state, reason, action, **extra):
|
||||
return {"field": field, "current_value": current, "proposed_value": proposed, "status": state, "reason_code": reason,
|
||||
"action_class": action, "write_eligible": False, "engine": PRODUCT_DEFAULTS_ENGINE_VERSION, **extra}
|
||||
|
||||
|
||||
def st_applicability(ncm_status, mva_status):
|
||||
if ncm_status != "EXACT_NCM":
|
||||
return "UNKNOWN_NCM_NOT_VALID"
|
||||
if mva_status == "MVA_RESOLVED":
|
||||
return "SUBJECT"
|
||||
if mva_status == "NO_MVA_MAPPING_FOUND":
|
||||
return "NOT_SUBJECT"
|
||||
return "UNKNOWN_UNCONFIRMED"
|
||||
|
||||
|
||||
def cfop_default(product, ncm_status, mva_status, cfop_valid):
|
||||
cur = None if _blank(product.get("CFOP")) else str(product["CFOP"]).strip()
|
||||
st = st_applicability(ncm_status, mva_status)
|
||||
base = {"st_applicability": st}
|
||||
if st == "UNKNOWN_NCM_NOT_VALID":
|
||||
return _out("CFOP", cur, None, "CFOP_UNRESOLVED", "NCM_NOT_VALID_CANNOT_ASSESS_ST", "NO_RULE", **base)
|
||||
if cur is None:
|
||||
return _out("CFOP", cur, None, "CFOP_OPERATION_CONTEXT_REQUIRED", "NO_DECLARED_RESALE_OR_PRODUCTION_FAMILY", "OPERATION_CONTEXT_REQUIRED", **base)
|
||||
if cur not in cfop_valid:
|
||||
return _out("CFOP", cur, None, "CFOP_UNRESOLVED", "CURRENT_CFOP_NOT_IN_OFFICIAL_TABLE", "REVIEW_REQUIRED", **base)
|
||||
if cur[0] != "5":
|
||||
return _out("CFOP", cur, None, "CFOP_OPERATION_CONTEXT_REQUIRED", "DIRECTION_OR_UF_SCOPE_NOT_INTRA_UF_SAIDA", "OPERATION_CONTEXT_REQUIRED", **base)
|
||||
if cur not in RESALE_CFOPS:
|
||||
return _out("CFOP", cur, None, "CFOP_OPERATION_CONTEXT_REQUIRED", "NO_RULE_FOR_DECLARED_CFOP_FAMILY", "OPERATION_CONTEXT_REQUIRED", **base)
|
||||
if st == "UNKNOWN_UNCONFIRMED":
|
||||
return _out("CFOP", cur, None, "CFOP_OPERATION_CONTEXT_REQUIRED", "ST_APPLICABILITY_UNCONFIRMED_NEEDS_CEST_CONFIRMATION", "OPERATION_CONTEXT_REQUIRED", **base)
|
||||
derived = "5405" if st == "SUBJECT" else "5102"
|
||||
if derived == cur:
|
||||
return _out("CFOP", cur, derived, "CFOP_DEFAULT_RESOLVED", "CONSISTENT_WITH_ST_STATUS_SAME_VALUE", "ALREADY_CORRECT", **base)
|
||||
return _out("CFOP", cur, derived, "CFOP_DEFAULT_CANDIDATE", "ST_STATUS_CONFLICTS_WITH_ERP_CFOP_RETENTION_CONTEXT_MISSING", "REVIEW_REQUIRED", **base)
|
||||
|
||||
|
||||
def csosn_default(product, ncm_status, mva_status, csosn_valid, company_default):
|
||||
cur = None if _blank(product.get("CSOSN")) else str(product["CSOSN"]).strip()
|
||||
st = st_applicability(ncm_status, mva_status)
|
||||
base = {"st_applicability": st, "company_default": company_default}
|
||||
if st == "UNKNOWN_NCM_NOT_VALID":
|
||||
return _out("CSOSN", cur, None, "CSOSN_UNRESOLVED", "NCM_NOT_VALID_CANNOT_ASSESS_ST", "NO_RULE", **base)
|
||||
if st == "UNKNOWN_UNCONFIRMED":
|
||||
return _out("CSOSN", cur, None, "CSOSN_OPERATION_CONTEXT_REQUIRED", "ST_APPLICABILITY_UNCONFIRMED", "OPERATION_CONTEXT_REQUIRED", **base)
|
||||
if st == "SUBJECT":
|
||||
flag = "ST_SUBJECT_BUT_CSOSN_NOT_500" if cur != "500" else "ST_CSOSN_500_RETENTION_UNVERIFIED"
|
||||
return _out("CSOSN", cur, None, "CSOSN_OPERATION_CONTEXT_REQUIRED", "ST_RETENTION_BY_SUPPLIER_UNKNOWN", "OPERATION_CONTEXT_REQUIRED", consistency_flag=flag, **base)
|
||||
# NOT_SUBJECT: 102 / 400 / 300 / 103 depend on the product's ICMS treatment, for which no source exists
|
||||
if cur is not None and cur == company_default and cur in csosn_valid:
|
||||
return _out("CSOSN", cur, cur, "CSOSN_DEFAULT_CANDIDATE", "MATCHES_COMPANY_DEFAULT_PRODUCT_ICMS_TREATMENT_UNPROVEN", "REVIEW_REQUIRED", **base)
|
||||
if cur is None:
|
||||
return _out("CSOSN", cur, company_default, "CSOSN_DEFAULT_CANDIDATE", "BLANK_COMPANY_DEFAULT_ONLY_ANCHOR_TREATMENT_UNPROVEN", "REVIEW_REQUIRED", **base)
|
||||
return _out("CSOSN", cur, None, "CSOSN_DEFAULT_CANDIDATE", "DIFFERS_FROM_COMPANY_DEFAULT_NO_TREATMENT_SOURCE", "REVIEW_REQUIRED", **base)
|
||||
|
||||
|
||||
def cst_status(product, cst_icms_codes, origem_codes):
|
||||
cur = None if _blank(product.get("CST")) else str(product["CST"]).strip()
|
||||
valid = cur is not None and len(cur) == 3 and cur[1:] in cst_icms_codes and cur[0] in origem_codes
|
||||
return {"field": "CST", "current_value": cur, "status": "CST_REFERENCE_ONLY" if valid else "CST_INVALID_REVIEW",
|
||||
"reason_code": "CRT1_EMISSION_USES_CSOSN_CST_IS_ORIGIN_PLUS_CST" if valid else "NOT_ORIGIN_DIGIT_PLUS_VALID_CST",
|
||||
"origin_digit": cur[0] if valid else None, "write_eligible": False, "engine": PRODUCT_DEFAULTS_ENGINE_VERSION}
|
||||
|
||||
|
||||
def validate_against_history(proposed, emitted_values):
|
||||
"""History validates only; returns MATCH / MISMATCH / UNVALIDATED."""
|
||||
if not emitted_values or proposed is None:
|
||||
return "UNVALIDATED"
|
||||
return "MATCH" if set(emitted_values) == {proposed} else "MISMATCH"
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Single source of truth for the release version (RC1). Fiscal semantics are versioned separately
|
||||
(ENGINE_VERSION/DECISION_VERSION in classification.py, DECISION_ENGINE_VERSION etc.) and are NOT changed by a release bump."""
|
||||
GTI_FISCAL_VERSION = "1.0.0-rc1"
|
||||
API_COMPAT_MAJOR_MINOR = "1.0" # an Agent 1.0.x talks to any Hub 1.0.x
|
||||
@@ -0,0 +1,15 @@
|
||||
[project]
|
||||
name = "gti-fiscal-hub"
|
||||
version = "1.0.0rc1"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"fastapi>=0.115,<1",
|
||||
"uvicorn[standard]>=0.30,<1",
|
||||
"sqlalchemy>=2.0,<3",
|
||||
"psycopg[binary]>=3.2,<4",
|
||||
"alembic>=1.13,<2",
|
||||
"httpx>=0.27,<1",
|
||||
"openpyxl>=3.1,<4",
|
||||
"pypdf>=4.0,<6",
|
||||
"pdfplumber>=0.11,<1",
|
||||
]
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Promote RICMS-BA Anexo 1 (BA_ST_MVA) from the local official PDF snapshot.
|
||||
|
||||
No network fetch: RICMS-BA Anexo 1 has no stable public direct-download URL
|
||||
(the SEFAZ-BA legislation portal serves it via a session-gated viewer), so
|
||||
the authoritative copy is kept in sources/raw/bootstrap_v1/. This script
|
||||
re-validates it against the currently ACTIVE Postgres dataset and only
|
||||
promotes a new version if the parsed content actually differs, never
|
||||
overwriting a valid ACTIVE version with a partial/regressed parse.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from pypdf import PdfReader
|
||||
|
||||
from central.app.datasets.ba_st_mva import parse_page
|
||||
from central.app.datasets.service import DatasetService
|
||||
from central.app.db import SessionLocal
|
||||
from central.app.models import Dataset
|
||||
|
||||
SOURCE = Path("sources/raw/bootstrap_v1/decreto_2012_13780_ricms_anexo_1_vigente_2026.pdf")
|
||||
SOURCE_URL = f"file://{SOURCE.name}"
|
||||
|
||||
|
||||
def main():
|
||||
raw_bytes = SOURCE.read_bytes()
|
||||
raw_sha256 = hashlib.sha256(raw_bytes).hexdigest()
|
||||
|
||||
records, rejected = [], []
|
||||
for page, value in enumerate(PdfReader(SOURCE).pages, start=1):
|
||||
found, failed = parse_page(value.extract_text() or "", page)
|
||||
records.extend(found)
|
||||
rejected.extend(failed)
|
||||
|
||||
if not records:
|
||||
print(json.dumps({"status": "FAILED", "reason": "parser produced zero records; keeping existing ACTIVE dataset"}))
|
||||
return
|
||||
|
||||
with SessionLocal() as session:
|
||||
current_active = session.scalar(
|
||||
__import__("sqlalchemy").select(Dataset).where(Dataset.kind == "BA_ST_MVA", Dataset.status == "ACTIVE")
|
||||
)
|
||||
if current_active is not None and current_active.raw_sha256 == raw_sha256:
|
||||
print(json.dumps({
|
||||
"status": "UNCHANGED", "dataset_id": current_active.id, "raw_sha256": raw_sha256,
|
||||
"record_count": current_active.record_count,
|
||||
}))
|
||||
return
|
||||
if current_active is not None and len(records) < current_active.record_count * 0.9:
|
||||
# Smoke gate: refuse to promote a parse that lost >10% of records versus
|
||||
# the currently ACTIVE version — never silently replace a good version
|
||||
# with a broken one.
|
||||
print(json.dumps({
|
||||
"status": "FAILED",
|
||||
"reason": f"new parse yielded {len(records)} records, active has {current_active.record_count} (>10% loss); keeping ACTIVE",
|
||||
}))
|
||||
return
|
||||
|
||||
dataset = DatasetService(session).import_ba_st_mva(records, raw_sha256, SOURCE_URL, datetime.now(timezone.utc))
|
||||
|
||||
print(json.dumps({
|
||||
"status": dataset.status, "dataset_id": dataset.id, "raw_sha256": raw_sha256,
|
||||
"record_count": dataset.record_count, "rejected": len(rejected),
|
||||
"previously_active_count": current_active.record_count if current_active else None,
|
||||
}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Promote CFOP/FCP/ANP/NCM_UTRIB/RTC_BIOFUEL_INDEX/RTC_CCREDPRES from the
|
||||
official spreadsheets the operator placed in sources/raw/bootstrap_v1/ after
|
||||
the Portal NF-e/SPED downloads were session-blocked. No network fetch.
|
||||
"""
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from central.app.datasets import bootstrap_v1_extra as parsers
|
||||
from central.app.datasets.service import DatasetService
|
||||
from central.app.db import SessionLocal
|
||||
|
||||
ROOT = Path("sources/raw/bootstrap_v1")
|
||||
|
||||
JOBS = [
|
||||
("CFOP", ROOT / "IT_2023_002_v2_10_Tabela_CFOP_Descrição.xlsx", parsers.parse_cfop, "import_cfop"),
|
||||
("FCP", ROOT / "Tabela_Alíquotas_FCP_por_UF_2025-02_07.xlsx", parsers.parse_fcp, "import_fcp"),
|
||||
("ANP", ROOT / "Tabela_Codigo_Produto_ANP-20241118.xlsx", parsers.parse_anp, "import_anp"),
|
||||
("NCM_UTRIB", ROOT / "IT2024.001v2.40 - Tabela NCM 2022 com Utrib_Comércio Exterior_vigência 01.10.26.xlsx", parsers.parse_ncm_utrib, "import_ncm_utrib"),
|
||||
("RTC_BIOFUEL_INDEX", ROOT / "IT 2025.004 v.1.20 - Tabela de Índice.xlsx", parsers.parse_rtc_biofuel_index, "import_rtc_biofuel_index"),
|
||||
("RTC_CCREDPRES", ROOT / "cCredPres_2026-06-22.xlsx", parsers.parse_rtc_cred_pres, "import_rtc_cred_pres"),
|
||||
("RTC_CCLASSTRIB", ROOT / "cClassTrib 2026-06-22.xlsx", parsers.parse_rtc_cclasstrib, "import_rtc_cclasstrib"),
|
||||
("RTC_CBS_RATES", ROOT / "IT 2026.002 v.1.00 - Aliquotas da CBS.xlsx", parsers.parse_rtc_cbs_rates, "import_rtc_cbs_rates"),
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
results = {}
|
||||
with SessionLocal() as session:
|
||||
service = DatasetService(session)
|
||||
for domain, path, parse_fn, method_name in JOBS:
|
||||
try:
|
||||
if not path.exists():
|
||||
results[domain] = {"status": "MISSING_BOOTSTRAP_FILE", "path": str(path)}
|
||||
continue
|
||||
payload = parse_fn(path.read_bytes())
|
||||
dataset = getattr(service, method_name)(payload, f"file://{path.name}", datetime.now(timezone.utc))
|
||||
results[domain] = {"status": dataset.status, "dataset_id": dataset.id, "sha256": dataset.sha256, "record_count": dataset.record_count}
|
||||
except Exception as error: # noqa: BLE001 — isolate one domain's failure from the rest
|
||||
results[domain] = {"status": "FAILED", "reason": f"{type(error).__name__}: {error}"}
|
||||
|
||||
print(json.dumps(results, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Download and promote the official CONFAZ CV142_18 HTML CEST source."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from central.app.config import settings
|
||||
from central.app.datasets.cest import parse_cest_html
|
||||
from central.app.datasets.service import DatasetService
|
||||
from central.app.db import SessionLocal
|
||||
|
||||
|
||||
def main():
|
||||
response = httpx.get(settings.cest_dataset_url, timeout=settings.http_timeout_seconds, follow_redirects=True)
|
||||
response.raise_for_status()
|
||||
parsed = parse_cest_html(response.content)
|
||||
path = Path(settings.dataset_storage_path)
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
(path / f"{parsed.sha256}.html").write_bytes(response.content)
|
||||
with SessionLocal() as session:
|
||||
dataset = DatasetService(session).import_cest_payload(parsed, settings.cest_dataset_url, datetime.now(timezone.utc))
|
||||
print({"id": dataset.id, "status": dataset.status, "sha256": dataset.sha256, "record_count": dataset.record_count, "version": parsed.version})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Seed reviewed official legacy NCM evidence; it never selects a successor."""
|
||||
|
||||
from central.app.db import SessionLocal
|
||||
from central.app.models import LegacyNcmEntry
|
||||
|
||||
|
||||
LEGACY_NCM = {
|
||||
"code": "02071400",
|
||||
"source": "Siscomex NCM official nomenclature download",
|
||||
"source_date": "2026-09-17",
|
||||
"reason": "NCM not present in current official nomenclature; no official successor.",
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
with SessionLocal() as session:
|
||||
entry = session.get(LegacyNcmEntry, LEGACY_NCM["code"])
|
||||
if entry is None:
|
||||
session.add(LegacyNcmEntry(**LEGACY_NCM, successor_code=None))
|
||||
session.commit()
|
||||
print({"code": LEGACY_NCM["code"], "status": "LEGACY_NCM", "successor_code": None})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Promote ORIGEM_MERCADORIA/CST_ICMS/CSOSN/CST_PIS/CST_COFINS from the local
|
||||
official MOC (Anexo I NF-e/NFC-e) PDF. No network fetch — this manual has no
|
||||
stable public direct-download URL either (session-gated portal), so the
|
||||
authoritative copy lives in sources/raw/bootstrap_v1/.
|
||||
"""
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from central.app.datasets.nfe_moc_tables import parse_moc_tables
|
||||
from central.app.datasets.service import DatasetService
|
||||
from central.app.db import SessionLocal
|
||||
|
||||
SOURCE = Path("sources/raw/bootstrap_v1/moc7-anexo-i-leiaute-e-rv.pdf")
|
||||
SOURCE_URL = f"file://{SOURCE.name}"
|
||||
|
||||
|
||||
def main():
|
||||
raw_bytes = SOURCE.read_bytes()
|
||||
payload = parse_moc_tables(raw_bytes)
|
||||
|
||||
with SessionLocal() as session:
|
||||
dataset = DatasetService(session).import_moc_tables(payload, SOURCE_URL, datetime.now(timezone.utc))
|
||||
|
||||
from collections import Counter
|
||||
domains = Counter(e.domain for e in payload.entries)
|
||||
print(json.dumps({
|
||||
"status": dataset.status, "dataset_id": dataset.id, "sha256": dataset.sha256,
|
||||
"record_count": dataset.record_count, "domains": dict(domains),
|
||||
}, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Download, persist and promote the official NCM dataset."""
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
import httpx
|
||||
from central.app.config import settings
|
||||
from central.app.datasets.ncm import parse_ncm_payload
|
||||
from central.app.datasets.service import DatasetService
|
||||
from central.app.db import SessionLocal
|
||||
|
||||
def main():
|
||||
response = httpx.get(settings.ncm_dataset_url, timeout=settings.http_timeout_seconds, follow_redirects=True)
|
||||
response.raise_for_status()
|
||||
parsed = parse_ncm_payload(response.content)
|
||||
path = Path(settings.dataset_storage_path); path.mkdir(parents=True, exist_ok=True)
|
||||
(path / f"{parsed.sha256}.json").write_bytes(response.content)
|
||||
with SessionLocal() as session:
|
||||
dataset = DatasetService(session).import_payload(parsed, settings.ncm_dataset_url, datetime.now(timezone.utc))
|
||||
print({"id": dataset.id, "status": dataset.status, "sha256": dataset.sha256, "record_count": dataset.record_count})
|
||||
if __name__ == "__main__": main()
|
||||
@@ -0,0 +1,12 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from pypdf import PdfReader
|
||||
from central.app.datasets.ba_st_mva import parse_page
|
||||
|
||||
source = Path("sources/raw/bootstrap_v1/decreto_2012_13780_ricms_anexo_1_vigente_2026.pdf")
|
||||
records, rejected = [], []
|
||||
for page, value in enumerate(PdfReader(source).pages, start=1):
|
||||
found, failed = parse_page(value.extract_text() or "", page)
|
||||
records.extend(found); rejected.extend(failed)
|
||||
Path("docs/ba_st_mva_inspection.json").write_text(json.dumps({"records": records, "rejected": rejected}, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(json.dumps({"pages": page, "records": len(records), "rejected": len(rejected), "cest": sum(bool(x["cest"]) for x in records), "ncm": sum(bool(x["ncm_sh"]) for x in records), "mva": sum(bool(x["mva_original"] or x["mva_adjusted"]) for x in records)}))
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Administrative CLI: register a new Agent directly against Postgres.
|
||||
|
||||
No new HTTP endpoint is needed for this — it's a one-time, operator-run
|
||||
action per installation, run from wherever DATABASE_URL is reachable (the
|
||||
Hub host, or a machine with network access to Postgres). Only the token's
|
||||
SHA-256 hash is ever stored; the plaintext token is printed once and must
|
||||
be copied into that installation's local Agent config immediately.
|
||||
|
||||
Usage:
|
||||
python central/scripts/register_agent.py --company "Restaurante X" --name "loja-01"
|
||||
"""
|
||||
import argparse
|
||||
import secrets
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "central"))
|
||||
|
||||
from sqlalchemy.orm import Session # noqa: E402
|
||||
|
||||
from app.auth import token_hash # noqa: E402
|
||||
from app.db import engine # noqa: E402
|
||||
from app.models import Agent, Tenant # noqa: E402
|
||||
|
||||
|
||||
def register_agent(session, company_name, agent_name, token=None):
|
||||
token = token or secrets.token_urlsafe(32)
|
||||
tenant = session.query(Tenant).filter_by(name=company_name).one_or_none()
|
||||
if tenant is None:
|
||||
tenant = Tenant(name=company_name)
|
||||
session.add(tenant)
|
||||
session.flush()
|
||||
agent = Agent(tenant_id=tenant.id, name=agent_name, token_hash=token_hash(token), enabled=True)
|
||||
session.add(agent)
|
||||
session.commit()
|
||||
# Read everything the caller needs now, while the session is still open —
|
||||
# session.commit() expires ORM attributes by default, and callers may use
|
||||
# these values after the session (and its `with` block) has closed.
|
||||
return {"id": agent.id, "tenant_id": tenant.id, "enabled": agent.enabled}, token
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--company", required=True, help="Company/tenant name (created if it doesn't exist)")
|
||||
parser.add_argument("--name", required=True, help="Human-readable name for this Agent installation, e.g. 'loja-01'")
|
||||
args = parser.parse_args()
|
||||
|
||||
with Session(engine) as session:
|
||||
info, token = register_agent(session, args.company, args.name)
|
||||
|
||||
print("Agent registered. Copy these into that installation's local .env NOW — the token is shown only once:")
|
||||
print(f" AGENT_ID={info['id']}")
|
||||
print(f" AGENT_TOKEN={token}")
|
||||
print(f" COMPANY_ID={info['tenant_id']}")
|
||||
print(" FIREBIRD_WRITES=0")
|
||||
print(f"enabled={info['enabled']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,9 @@
|
||||
def test_parser_extracts_item_cest_and_ncm_from_official_table_text():
|
||||
from central.app.datasets.ba_st_mva import parse_page
|
||||
|
||||
records, rejected = parse_page("ITEM CEST NCM/SH DESCRIÇÃO\n3.3 03.003.00 2201.1 Água mineral 114%", 1)
|
||||
|
||||
assert records[0]["item"] == "3.3"
|
||||
assert records[0]["cest"] == "03.003.00"
|
||||
assert records[0]["ncm_sh"] == ["2201.1"]
|
||||
assert not rejected
|
||||
@@ -0,0 +1,113 @@
|
||||
from datetime import datetime, timezone
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from central.app.datasets import bootstrap_v1_extra as parsers
|
||||
from central.app.datasets.service import DatasetService
|
||||
from central.app.models import Base
|
||||
|
||||
ROOT = Path(__file__).parents[2] / "sources" / "raw" / "bootstrap_v1"
|
||||
FILES = {
|
||||
"cfop": ROOT / "IT_2023_002_v2_10_Tabela_CFOP_Descrição.xlsx",
|
||||
"fcp": ROOT / "Tabela_Alíquotas_FCP_por_UF_2025-02_07.xlsx",
|
||||
"anp": ROOT / "Tabela_Codigo_Produto_ANP-20241118.xlsx",
|
||||
"ncm_utrib": ROOT / "IT2024.001v2.40 - Tabela NCM 2022 com Utrib_Comércio Exterior_vigência 01.10.26.xlsx",
|
||||
"biofuel": ROOT / "IT 2025.004 v.1.20 - Tabela de Índice.xlsx",
|
||||
"credpres": ROOT / "cCredPres_2026-06-22.xlsx",
|
||||
}
|
||||
pytestmark = pytest.mark.skipif(not all(p.exists() for p in FILES.values()), reason="bootstrap_v1 extra source files not present locally")
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def _payload(name):
|
||||
parse_fn = {"cfop": parsers.parse_cfop, "fcp": parsers.parse_fcp, "anp": parsers.parse_anp,
|
||||
"ncm_utrib": parsers.parse_ncm_utrib, "biofuel": parsers.parse_rtc_biofuel_index,
|
||||
"credpres": parsers.parse_rtc_cred_pres}[name]
|
||||
return parse_fn(FILES[name].read_bytes())
|
||||
|
||||
|
||||
def _engine():
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
return engine
|
||||
|
||||
|
||||
def test_cfop_parses_full_table_with_official_fields():
|
||||
payload = _payload("cfop")
|
||||
assert len(payload.entries) >= 500
|
||||
sample = payload.entries[0]
|
||||
assert set(sample) >= {"code", "title", "description", "effective_from", "ind_nfe", "ind_comb"}
|
||||
|
||||
|
||||
def test_fcp_covers_all_states_and_df():
|
||||
payload = _payload("fcp")
|
||||
ufs = {e["uf"] for e in payload.entries}
|
||||
assert len(ufs) == 27 # 26 states + DF
|
||||
assert "BA" in ufs
|
||||
|
||||
|
||||
def test_anp_has_no_invented_descriptions():
|
||||
payload = _payload("anp")
|
||||
assert len(payload.entries) >= 900
|
||||
assert all(set(e) == {"cprod_anp"} for e in payload.entries) # source truly has no description column
|
||||
|
||||
|
||||
def test_ncm_utrib_covers_thousands_of_ncms():
|
||||
payload = _payload("ncm_utrib")
|
||||
assert len(payload.entries) >= 10000
|
||||
assert all(len(e["ncm"]) == 8 for e in payload.entries[:50])
|
||||
|
||||
|
||||
def test_biofuel_index_separates_current_from_historical():
|
||||
payload = _payload("biofuel")
|
||||
current = [e for e in payload.entries if not e["is_historical"]]
|
||||
historical = [e for e in payload.entries if e["is_historical"]]
|
||||
assert current and historical
|
||||
assert all(e["p_bio_percent"] is not None for e in current)
|
||||
|
||||
|
||||
def test_credpres_preserves_rich_official_fields_not_just_code_description():
|
||||
payload = _payload("credpres")
|
||||
sample = payload.entries[0]
|
||||
assert sample["legal_basis"]
|
||||
assert "d_ini_vig_cbs" in sample and "aliquota_cbs_text" in sample
|
||||
|
||||
|
||||
@pytest.mark.parametrize("domain,method,lookup", [
|
||||
("cfop", "import_cfop", lambda s: s.cfop_lookup(code="1101")),
|
||||
("fcp", "import_fcp", lambda s: [s.fcp_for_uf("BA")]),
|
||||
("anp", "import_anp", lambda s: [s.anp_code_exists("110203073")]),
|
||||
("ncm_utrib", "import_ncm_utrib", lambda s: [s.ncm_utrib_for_ncm("01012100")]),
|
||||
("biofuel", "import_rtc_biofuel_index", lambda s: s.rtc_biofuel_for_anp("320102001")),
|
||||
("credpres", "import_rtc_cred_pres", lambda s: s.rtc_cred_pres(code="1")),
|
||||
])
|
||||
def test_import_idempotent_and_lookup_smoke(domain, method, lookup):
|
||||
engine = _engine()
|
||||
with Session(engine) as session:
|
||||
service = DatasetService(session)
|
||||
payload = _payload(domain)
|
||||
first = getattr(service, method)(payload, f"file://{domain}", datetime.now(timezone.utc))
|
||||
second = getattr(service, method)(payload, f"file://{domain}", datetime.now(timezone.utc))
|
||||
assert first.id == second.id
|
||||
assert second.status == "ACTIVE"
|
||||
assert second.record_count == len(payload.entries)
|
||||
|
||||
result = lookup(service)
|
||||
assert result
|
||||
assert all(item is not None and item is not False for item in result) or result == [True]
|
||||
|
||||
|
||||
def test_dataset_versions_includes_all_new_kinds():
|
||||
engine = _engine()
|
||||
with Session(engine) as session:
|
||||
service = DatasetService(session)
|
||||
versions = service.dataset_versions()
|
||||
for key in ("cfop", "fcp", "anp", "ncm_utrib", "rtc_biofuel_index", "rtc_ccredpres"):
|
||||
assert key in versions
|
||||
assert versions[key] is None
|
||||
service.import_cfop(_payload("cfop"), "file://cfop", datetime.now(timezone.utc))
|
||||
assert service.dataset_versions()["cfop"] is not None
|
||||
@@ -0,0 +1,38 @@
|
||||
from central.app.classification import Classifier
|
||||
|
||||
|
||||
class _Repository:
|
||||
def __init__(self, cest_entries):
|
||||
self._cest_entries = cest_entries
|
||||
|
||||
def active_entries(self):
|
||||
return [type("Entry", (), {"code": "19022000", "description": "Massas"})()]
|
||||
|
||||
def cest_for_ncm(self, code):
|
||||
return self._cest_entries if code == "19022000" else []
|
||||
|
||||
def legacy_ncm(self, code):
|
||||
return None
|
||||
|
||||
def dataset_versions(self):
|
||||
return {"ncm": "n", "cest": "c"}
|
||||
|
||||
|
||||
def test_stopwords_do_not_inflate_description_score():
|
||||
# "RISOLE DE CAMARAO" vs an official text that only shares the stopword "DE"
|
||||
# ("... preparadas de outro modo") must score 0, not a false-positive overlap.
|
||||
cest = type("Cest", (), {
|
||||
"code": "17.048.02",
|
||||
"description": "Massas alimenticias recheadas (mesmo cozidas ou preparadas de outro modo)",
|
||||
})()
|
||||
result = Classifier(_Repository([cest])).classify({"current_ncm": "19022000", "description": "RISOLE DE CAMARAO"})
|
||||
candidate = result["classification"]["cest_candidates"][0]
|
||||
assert candidate["description_score"] == 0.0
|
||||
assert result["cest_status"] == "CEST_DESCRIPTION_MISMATCH"
|
||||
|
||||
|
||||
def test_genuine_token_overlap_still_scores_above_zero():
|
||||
cest = type("Cest", (), {"code": "17.024.01", "description": "Queijo mucarela"})()
|
||||
result = Classifier(_Repository([cest])).classify({"current_ncm": "19022000", "description": "BOLINHO DE QUEIJO"})
|
||||
candidate = result["classification"]["cest_candidates"][0]
|
||||
assert candidate["description_score"] > 0
|
||||
@@ -0,0 +1,62 @@
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from central.app import api as api_module
|
||||
from central.app.main import create_app
|
||||
from central.app.models import Base
|
||||
|
||||
HEADERS = {"Authorization": "Bearer test-token"}
|
||||
PAYLOAD = {"current_ncm": "22030000", "description": "CERVEJA LATA"}
|
||||
|
||||
|
||||
def make_client(monkeypatch, calls, engine_version="0.3.0", datasets=None):
|
||||
versions = datasets if datasets is not None else {"ncm": "aaa"}
|
||||
|
||||
class FakeClassifier:
|
||||
def __init__(self, repository): pass
|
||||
|
||||
def classify(self, product):
|
||||
calls.append(product)
|
||||
return {"status": "EXACT_NCM", "engine_version": api_module.ENGINE_VERSION, "decision_version": "x", "dataset_versions": dict(versions), "classification": {"ncm": product["current_ncm"]}}
|
||||
|
||||
monkeypatch.setattr(api_module, "Classifier", FakeClassifier)
|
||||
monkeypatch.setattr(api_module, "ENGINE_VERSION", engine_version)
|
||||
monkeypatch.setattr(api_module.DatasetService, "dataset_versions", lambda self: dict(versions))
|
||||
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
return TestClient(create_app(session_factory=lambda: Session(engine), bootstrap_token="test-token")), versions
|
||||
|
||||
|
||||
def test_cache_hit_does_not_run_the_classifier(monkeypatch):
|
||||
calls = []
|
||||
client, _ = make_client(monkeypatch, calls)
|
||||
first = client.post("/v1/classify", json=PAYLOAD, headers=HEADERS).json()
|
||||
assert first["provenance"]["cache_hit"] is False and len(calls) == 1
|
||||
for _ in range(3):
|
||||
again = client.post("/v1/classify", json=PAYLOAD, headers=HEADERS).json()
|
||||
assert again["provenance"]["cache_hit"] is True
|
||||
assert {k: v for k, v in again.items() if k != "provenance"} == {k: v for k, v in first.items() if k != "provenance"} # same fiscal result
|
||||
assert len(calls) == 1 # CLASSIFIER_EXECUTIONS_ON_HIT = 0
|
||||
|
||||
|
||||
def test_batch_hits_skip_the_classifier_and_new_inputs_miss(monkeypatch):
|
||||
calls = []
|
||||
client, _ = make_client(monkeypatch, calls)
|
||||
products = [PAYLOAD, {"current_ncm": "19022000", "description": "MASSA"}]
|
||||
client.post("/v1/classify/batch", json={"products": products}, headers=HEADERS)
|
||||
assert len(calls) == 2
|
||||
res = client.post("/v1/classify/batch", json={"products": products + [{"current_ncm": "20099000", "description": "SUCO"}]}, headers=HEADERS).json()["results"]
|
||||
assert [r["provenance"]["cache_hit"] for r in res] == [True, True, False] and len(calls) == 3
|
||||
|
||||
|
||||
def test_engine_or_dataset_version_change_invalidates_naturally(monkeypatch):
|
||||
calls = []
|
||||
client, versions = make_client(monkeypatch, calls)
|
||||
client.post("/v1/classify", json=PAYLOAD, headers=HEADERS)
|
||||
versions["ncm"] = "bbb" # a new ACTIVE dataset version changes dataset_versions()
|
||||
assert client.post("/v1/classify", json=PAYLOAD, headers=HEADERS).json()["provenance"]["cache_hit"] is False
|
||||
monkeypatch.setattr(api_module, "ENGINE_VERSION", "0.4.0")
|
||||
assert client.post("/v1/classify", json=PAYLOAD, headers=HEADERS).json()["provenance"]["cache_hit"] is False
|
||||
assert len(calls) == 3
|
||||
@@ -0,0 +1,31 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from central.app.classification import classify_tipi_rate
|
||||
|
||||
|
||||
def _entry(ex=None, ipi_rate=None, ipi_rate_text=None):
|
||||
return SimpleNamespace(ex=ex, ipi_rate=ipi_rate, ipi_rate_text=ipi_rate_text)
|
||||
|
||||
|
||||
def test_ex_specific():
|
||||
assert classify_tipi_rate(_entry(ex="01")) == "EX_SPECIFIC"
|
||||
|
||||
|
||||
def test_source_blank():
|
||||
assert classify_tipi_rate(_entry(ipi_rate_text=None)) == "SOURCE_BLANK"
|
||||
|
||||
|
||||
def test_nt_or_non_taxed():
|
||||
assert classify_tipi_rate(_entry(ipi_rate_text="NT")) == "NT_OR_NON_TAXED"
|
||||
|
||||
|
||||
def test_explicit_zero_text():
|
||||
assert classify_tipi_rate(_entry(ipi_rate_text="0,00")) == "EXPLICIT_ZERO"
|
||||
|
||||
|
||||
def test_explicit_zero_numeric():
|
||||
assert classify_tipi_rate(_entry(ipi_rate=0, ipi_rate_text="0")) == "EXPLICIT_ZERO"
|
||||
|
||||
|
||||
def test_other_unrecognized_marker():
|
||||
assert classify_tipi_rate(_entry(ipi_rate_text="ISENTO")) == "OTHER"
|
||||
@@ -0,0 +1,11 @@
|
||||
import importlib
|
||||
|
||||
|
||||
def test_database_url_encodes_password_from_component_environment(monkeypatch):
|
||||
monkeypatch.delenv("DATABASE_URL", raising=False)
|
||||
monkeypatch.setenv("POSTGRES_PASSWORD", "a@b/c")
|
||||
import central.app.config as config
|
||||
|
||||
module = importlib.reload(config)
|
||||
|
||||
assert module.settings.database_url == "postgresql+psycopg://gti:a%40b%2Fc@postgres:5432/gti_fiscal"
|
||||
@@ -0,0 +1,180 @@
|
||||
import pytest
|
||||
|
||||
from central.app.dry_run_planner import (
|
||||
build_field_proposals,
|
||||
execute,
|
||||
mva_evidence,
|
||||
split_by_eligibility,
|
||||
)
|
||||
|
||||
|
||||
def _decision(**overrides):
|
||||
base = {
|
||||
"status": "EXACT_NCM",
|
||||
"cest_status": "NO_CEST_MAPPING_FOUND",
|
||||
"ipi_status": "NO_TIPI_MAPPING_FOUND",
|
||||
"ba_st_mva_status": "NO_MVA_MAPPING_FOUND",
|
||||
"classification": {
|
||||
"ncm": "07069000",
|
||||
"cest": [],
|
||||
"ipi": None,
|
||||
"ba_st_mva": None,
|
||||
"ba_st_mva_candidates": [],
|
||||
},
|
||||
"dataset_versions": {"ncm": "n1", "cest": "c1", "tipi_ipi": "t1", "ba_st_mva": "m1"},
|
||||
"engine_version": "0.1.0",
|
||||
"decision_version": "mvp",
|
||||
"request_fingerprint": "fp",
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def test_ipi_resolved_numeric_current_unknown():
|
||||
decision = _decision(ipi_status="IPI_RESOLVED", classification={**_decision()["classification"], "ipi": {"rate": "3.25", "rate_numeric": 3.25, "rate_kind": "NUMERIC", "ex": None}})
|
||||
proposals = build_field_proposals("000026", decision, {})
|
||||
ipi = next(p for p in proposals if p["field"] == "IPI")
|
||||
assert ipi["status"] == "CURRENT_UNKNOWN"
|
||||
assert ipi["write_eligible"] is False
|
||||
assert ipi["proposed_value"] == 3.25
|
||||
|
||||
|
||||
def test_ipi_resolved_numeric_same_value():
|
||||
decision = _decision(ipi_status="IPI_RESOLVED", classification={**_decision()["classification"], "ipi": {"rate": "3.25", "rate_numeric": 3.25, "rate_kind": "NUMERIC", "ex": None}})
|
||||
proposals = build_field_proposals("000026", decision, {"ipi": 3.25, "ex_tipi": None})
|
||||
ipi = next(p for p in proposals if p["field"] == "IPI")
|
||||
assert ipi["status"] == "SAME_VALUE"
|
||||
assert ipi["write_eligible"] is False
|
||||
|
||||
|
||||
def test_ipi_resolved_numeric_change_required():
|
||||
decision = _decision(ipi_status="IPI_RESOLVED", classification={**_decision()["classification"], "ipi": {"rate": "3.25", "rate_numeric": 3.25, "rate_kind": "NUMERIC", "ex": None}})
|
||||
proposals = build_field_proposals("000026", decision, {"ipi": None, "ex_tipi": None})
|
||||
ipi = next(p for p in proposals if p["field"] == "IPI")
|
||||
assert ipi["status"] == "CHANGE_REQUIRED"
|
||||
assert ipi["write_eligible"] is True
|
||||
assert ipi["proposed_value"] == 3.25
|
||||
|
||||
|
||||
def test_ipi_non_numeric_marker_never_proposes_a_write():
|
||||
decision = _decision(ipi_status="IPI_RESOLVED", classification={**_decision()["classification"], "ipi": {"rate": "NT", "rate_numeric": None, "rate_kind": "NON_NUMERIC", "rate_classification": "NT_OR_NON_TAXED", "ex": None}})
|
||||
proposals = build_field_proposals("000004", decision, {"ipi": None, "ex_tipi": None})
|
||||
ipi = next(p for p in proposals if p["field"] == "IPI")
|
||||
assert ipi["status"] == "NON_NUMERIC_IPI_STATUS"
|
||||
assert ipi["proposed_value"] is None
|
||||
assert ipi["write_eligible"] is False
|
||||
assert ipi["reason_code"] == "TIPI_RATE_NT_OR_NON_TAXED"
|
||||
|
||||
|
||||
def test_ipi_resolved_without_concrete_rate_still_produces_a_row():
|
||||
decision = _decision(ipi_status="IPI_RESOLVED", classification={**_decision()["classification"], "ipi": {"rate": None, "rate_numeric": None, "rate_kind": "BLANK", "rate_classification": "SOURCE_BLANK", "ex": None}})
|
||||
proposals = build_field_proposals("000018", decision, {"ipi": 5, "ex_tipi": None})
|
||||
ipi = next(p for p in proposals if p["field"] == "IPI")
|
||||
assert ipi["status"] == "SOURCE_BLANK"
|
||||
assert ipi["proposed_value"] is None
|
||||
assert ipi["write_eligible"] is False
|
||||
assert ipi["reason_code"] == "TIPI_RATE_SOURCE_BLANK"
|
||||
|
||||
|
||||
def test_no_ex_tipi_row_when_no_ex_candidates():
|
||||
decision = _decision(ipi_status="IPI_RESOLVED", classification={**_decision()["classification"], "ipi": {"rate": "3.25", "rate_numeric": 3.25, "rate_kind": "NUMERIC", "ex": None}, "ipi_candidates": []})
|
||||
proposals = build_field_proposals("000026", decision, {"ipi": None, "ex_tipi": None})
|
||||
assert all(p["field"] != "EX_TIPI" for p in proposals)
|
||||
|
||||
|
||||
def test_ex_tipi_concrete_single_candidate_change_required():
|
||||
decision = _decision(
|
||||
ipi_status="IPI_CANDIDATES",
|
||||
classification={**_decision()["classification"], "ipi": None, "ipi_candidates": [{"ncm": "07069000", "ex": "01", "description": "specific", "rate": "5"}]},
|
||||
)
|
||||
proposals = build_field_proposals("000004", decision, {"ex_tipi": None})
|
||||
ex = next(p for p in proposals if p["field"] == "EX_TIPI")
|
||||
assert ex["status"] == "CHANGE_REQUIRED"
|
||||
assert ex["proposed_value"] == "01"
|
||||
assert ex["write_eligible"] is True
|
||||
|
||||
|
||||
def test_ex_tipi_multiple_candidates_is_candidate_not_eligible():
|
||||
decision = _decision(
|
||||
ipi_status="IPI_CANDIDATES",
|
||||
classification={**_decision()["classification"], "ipi": None, "ipi_candidates": [
|
||||
{"ncm": "07069000", "ex": "01", "description": "a", "rate": "5"},
|
||||
{"ncm": "07069000", "ex": "02", "description": "b", "rate": "7"},
|
||||
]},
|
||||
)
|
||||
proposals = build_field_proposals("000004", decision, {"ex_tipi": None})
|
||||
ex = next(p for p in proposals if p["field"] == "EX_TIPI")
|
||||
assert ex["status"] == "CANDIDATE"
|
||||
assert ex["proposed_value"] is None
|
||||
assert ex["write_eligible"] is False
|
||||
|
||||
|
||||
def test_cest_confirmed_change_required():
|
||||
decision = _decision(cest_status="CEST_CONFIRMED", classification={**_decision()["classification"], "cest": ["17.087.02"]})
|
||||
proposals = build_field_proposals("000018", decision, {"cest": ""})
|
||||
cest = next(p for p in proposals if p["field"] == "CEST")
|
||||
assert cest["status"] == "CHANGE_REQUIRED"
|
||||
assert cest["write_eligible"] is True
|
||||
assert cest["proposed_value"] == "17.087.02"
|
||||
|
||||
|
||||
def test_cest_candidates_not_eligible():
|
||||
decision = _decision(cest_status="CEST_CANDIDATES")
|
||||
proposals = build_field_proposals("000024", decision, {"cest": ""})
|
||||
cest = next(p for p in proposals if p["field"] == "CEST")
|
||||
assert cest["status"] == "CANDIDATE"
|
||||
assert cest["proposed_value"] is None
|
||||
assert cest["write_eligible"] is False
|
||||
|
||||
|
||||
def test_cest_description_mismatch_not_eligible():
|
||||
decision = _decision(cest_status="CEST_DESCRIPTION_MISMATCH")
|
||||
proposals = build_field_proposals("000018", decision, {"cest": ""})
|
||||
cest = next(p for p in proposals if p["field"] == "CEST")
|
||||
assert cest["status"] == "UNRESOLVED"
|
||||
assert cest["reason_code"] == "CEST_DESCRIPTION_SCORE_BELOW_THRESHOLD"
|
||||
assert cest["write_eligible"] is False
|
||||
|
||||
|
||||
def test_legacy_ncm_review_no_invented_successor():
|
||||
decision = _decision(status="LEGACY_NCM", classification={**_decision()["classification"], "ncm": "02071400"})
|
||||
proposals = build_field_proposals("000016", decision, {"ipi": "3.25", "cest": ""})
|
||||
assert len(proposals) == 1
|
||||
row = proposals[0]
|
||||
assert row["field"] == "CLASSIFICACAO_FISCAL"
|
||||
assert row["status"] == "LEGACY_NCM_REVIEW"
|
||||
assert row["proposed_value"] is None
|
||||
assert row["write_eligible"] is False
|
||||
|
||||
|
||||
def test_mva_never_produces_a_write_proposal():
|
||||
decision = _decision(ba_st_mva_status="MVA_CANDIDATES", classification={**_decision()["classification"], "ba_st_mva_candidates": [{"item": "1"}]})
|
||||
proposals = build_field_proposals("000024", decision, {"cest": ""})
|
||||
assert all(p["field"] != "BA_ST_MVA" for p in proposals)
|
||||
evidence = mva_evidence("000024", decision)
|
||||
assert evidence["status"] == "MVA_CANDIDATES"
|
||||
assert evidence["candidates"]
|
||||
|
||||
|
||||
def test_split_by_eligibility():
|
||||
decision = _decision(ipi_status="IPI_RESOLVED", classification={**_decision()["classification"], "ipi": {"rate": "3.25", "rate_numeric": 3.25, "rate_kind": "NUMERIC", "ex": None}})
|
||||
proposals = build_field_proposals("000026", decision, {})
|
||||
split = split_by_eligibility(proposals)
|
||||
assert split["blocked"] == proposals
|
||||
assert split["eligible"] == []
|
||||
|
||||
|
||||
def test_execute_refuses_non_eligible_proposal():
|
||||
decision = _decision(ipi_status="IPI_RESOLVED", classification={**_decision()["classification"], "ipi": {"rate": "3.25", "rate_numeric": 3.25, "rate_kind": "NUMERIC", "ex": None}})
|
||||
proposals = build_field_proposals("000026", decision, {})
|
||||
with pytest.raises(PermissionError):
|
||||
execute(proposals)
|
||||
|
||||
|
||||
def test_execute_never_writes_even_when_eligible():
|
||||
decision = _decision(ipi_status="IPI_RESOLVED", classification={**_decision()["classification"], "ipi": {"rate": "3.25", "rate_numeric": 3.25, "rate_kind": "NUMERIC", "ex": None}})
|
||||
proposals = build_field_proposals("000026", decision, {"ipi": "0"})
|
||||
eligible_only = split_by_eligibility(proposals)["eligible"]
|
||||
assert eligible_only
|
||||
with pytest.raises(NotImplementedError):
|
||||
execute(eligible_only)
|
||||
@@ -0,0 +1,270 @@
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from central.app.field_contract import (ALREADY, CHANGE, CONTRACT, MANUAL, NA, WRITE_FIELDS, Context, infer_role, resolve_product)
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
REFS = {"cfop_valid": {"5101", "5102", "5401", "5405", "1102", "1101", "1403", "2102"}, "csosn": {"101", "102", "103", "300", "400", "500"}, "cst_icms": {"00", "20", "40", "41", "60"},
|
||||
"cst_pis": {"01", "49", "98"}, "cst_cofins": {"01", "49", "98"}, "origem": set("012345678"), "fcp_uf": {}, "cbs_rate_2026": None, "cclasstrib_count": 0}
|
||||
CO1 = {"uf": "BA", "crt": "1", "contribuinte_ipi": "N", "company_code": "000001"}
|
||||
CO3 = {"uf": "BA", "crt": "3", "contribuinte_ipi": "S", "company_code": "000001"}
|
||||
|
||||
|
||||
def product(**kw):
|
||||
p = {f: None for f in WRITE_FIELDS}
|
||||
p.update({"CODIGO": "000001", "PRODUTO": "CERVEJA LATA", "CODGRUPO": "000019", "TIPO": "VENDA", "PROD_COZINHA": None, "PRODUTO_SERVICO": None, "CLASSIFICACAO_FISCAL": "22030000",
|
||||
"CFOP": "5102", "CSOSN": "102", "CST": "000", "SITUACAO_TRIBUTARIA": "T", "IAT": "A", "IPPT": "T", "INCIDENCIA_PISCOFINS": "NENHUMA", "PISCOFINS": "N"})
|
||||
p.update(kw)
|
||||
return p
|
||||
|
||||
|
||||
def decision(**kw):
|
||||
d = {"status": "EXACT_NCM", "cest_status": "NO_CEST_MAPPING_FOUND", "ipi_status": "IPI_RESOLVED", "ba_st_mva_status": "NO_MVA_MAPPING_FOUND", "dataset_versions": {},
|
||||
"classification": {"ncm": "22030000", "ipi": {"rate_kind": "NUMERIC", "rate_numeric": 3.9, "rate": "3.9"}, "ipi_candidates": [], "cest": None, "cest_candidates": [], "ba_st_mva": None, "ba_st_mva_candidates": []}}
|
||||
cls = kw.pop("classification", {})
|
||||
d.update(kw)
|
||||
d["classification"] = {**d["classification"], **cls}
|
||||
return d
|
||||
|
||||
|
||||
EV = {"recipes": set(), "composed": set(), "purchases": {}, "stock_entry": {}, "stock_in": {"000001": True}}
|
||||
|
||||
|
||||
IPI_POP = {"scope": "COMPANY", "scope_key": "", "key": "store_ipi_profile", "value": "POPULATE_REFERENCE_RATE"}
|
||||
|
||||
|
||||
def run(p=None, d=None, co=CO1, ctx=None, ev=EV, manual=None, ipi_profile=IPI_POP):
|
||||
return resolve_product(p or product(), d or decision(), co, REFS, ev, Context((ctx or []) + ([ipi_profile] if ipi_profile else [])), manual_ncm=manual)
|
||||
|
||||
|
||||
def st(res, f):
|
||||
return res[f]["state"], res[f]["proposed"]
|
||||
|
||||
|
||||
def test_every_write_field_has_a_complete_contract():
|
||||
keys = {"ERP_FIELD", "UI_LABEL", "SEMANTICS", "DATA_TYPE", "SOURCE_OF_TRUTH", "REQUIRED_INPUTS", "RESOLUTION_RULE", "VALID_VALUES", "NOT_APPLICABLE_RULE", "MANUAL_INPUT_RULE", "WRITE_ALLOWED", "VALIDATION_RULE", "PROVENANCE"}
|
||||
assert set(CONTRACT) == set(WRITE_FIELDS) and len(WRITE_FIELDS) == 23
|
||||
for f, c in CONTRACT.items():
|
||||
assert keys <= set(c) and all(c[k] not in (None, "", []) for k in keys - {"WRITE_ALLOWED"}), f
|
||||
|
||||
|
||||
def test_only_five_final_states_and_manual_always_names_its_input():
|
||||
res = run()
|
||||
for f in WRITE_FIELDS:
|
||||
assert res[f]["state"] in (ALREADY, CHANGE, NA, MANUAL)
|
||||
if res[f]["state"] == MANUAL:
|
||||
assert res[f]["input_key"]
|
||||
assert res[f]["write_eligible"] == (res[f]["state"] == CHANGE)
|
||||
|
||||
|
||||
def test_ncm_states_and_operator_ncm_reprocessing():
|
||||
bad = run(p=product(CLASSIFICACAO_FISCAL="12345678"), d=decision(status="INVALID_NCM"))
|
||||
assert st(bad, "CLASSIFICACAO_FISCAL")[0] == MANUAL and bad["CLASSIFICACAO_FISCAL"]["input_key"] == "ncm"
|
||||
for f in ("IPI", "EX_TIPI", "CEST", "CFOP", "CSOSN"):
|
||||
assert bad[f]["state"] == MANUAL and bad[f]["reason"] == "BLOCKED_BY_NCM"
|
||||
assert st(run(manual=("22030000", False), p=product(CLASSIFICACAO_FISCAL="12345678"), d=decision(status="INVALID_NCM")), "CLASSIFICACAO_FISCAL")[0] == MANUAL
|
||||
fixed = run(manual=("22030000", True), p=product(CLASSIFICACAO_FISCAL="12345678"), d=decision())
|
||||
assert st(fixed, "CLASSIFICACAO_FISCAL") == (CHANGE, "22030000") and fixed["IPI"]["state"] == CHANGE and fixed["IPI"]["proposed"] == 3.9 # dependents reprocessed with the manual NCM
|
||||
|
||||
|
||||
def test_ipi_rules_never_write_nt_or_blank_and_ask_when_ambiguous():
|
||||
assert st(run(), "IPI") == (CHANGE, 3.9)
|
||||
assert st(run(p=product(IPI=3.9)), "IPI")[0] == ALREADY
|
||||
nt = run(d=decision(classification={"ipi": {"rate_kind": "NON_NUMERIC", "rate_classification": "NT"}}))
|
||||
assert nt["IPI"]["state"] == NA and not nt["IPI"]["write_eligible"]
|
||||
blank = run(d=decision(classification={"ipi": {"rate_kind": "BLANK"}}))
|
||||
assert blank["IPI"]["state"] == NA and "NEVER_ZERO" in blank["IPI"]["reason"]
|
||||
amb = decision(ipi_status="IPI_CANDIDATES", classification={"ipi": None, "ipi_candidates": [{"ex": None, "rate": "5", "description": "base"}, {"ex": "1", "rate": "0", "description": "Chope"}]})
|
||||
r = run(d=amb)
|
||||
assert r["IPI"]["state"] == MANUAL and r["IPI"]["input_key"] == "ex_tipi" and len(r["IPI"]["choices"]) == 2
|
||||
solved = run(d=amb, ctx=[{"scope": "PRODUCT", "scope_key": "000001", "key": "ex_tipi", "value": "NONE"}])
|
||||
assert st(solved, "IPI") == (CHANGE, 5.0) and solved["EX_TIPI"]["state"] == NA
|
||||
|
||||
|
||||
def test_cest_contract():
|
||||
off = [{"code": "03.021.03", "official_description": "Cerveja em lata", "description_score": 1.0}, {"code": "03.021.05", "official_description": "PET", "description_score": 0.5}]
|
||||
conf = run(d=decision(cest_status="CEST_CONFIRMED", classification={"cest": ["03.021.03"], "cest_candidates": off}))
|
||||
assert st(conf, "CEST") == (CHANGE, "0302103")
|
||||
assert run(p=product(CEST="0302103"), d=decision(cest_status="CEST_CANDIDATES", classification={"cest_candidates": off}))["CEST"]["state"] == ALREADY
|
||||
many = run(d=decision(cest_status="CEST_CANDIDATES", classification={"cest_candidates": off}))
|
||||
assert many["CEST"]["state"] == MANUAL and many["CEST"]["input_key"] == "cest" and len(many["CEST"]["choices"]) == 2
|
||||
assert run(d=decision(cest_status="CEST_DESCRIPTION_MISMATCH", classification={"cest_candidates": off}))["CEST"]["state"] == NA
|
||||
assert run()["CEST"]["state"] == NA
|
||||
assert run(p=product(CEST="9999999"))["CEST"]["state"] == MANUAL
|
||||
picked = run(d=decision(cest_status="CEST_CANDIDATES", classification={"cest_candidates": off}), ctx=[{"scope": "NCM", "scope_key": "22030000", "key": "cest", "value": "03.021.05"}])
|
||||
assert st(picked, "CEST") == (CHANGE, "0302105")
|
||||
|
||||
|
||||
def test_cfop_role_and_st_rules():
|
||||
ok = run() # RESALE (stock entries), not subject to ST, declared 5102 == derived
|
||||
assert ok["CFOP"]["state"] == ALREADY and ok["_facts"]["role"] == "RESALE"
|
||||
assert st(run(p=product(CFOP="5405")), "CFOP") == (CHANGE, "5102") # 5405 but proven NOT_SUBJECT
|
||||
assert st(run(p=product(CFOP=None)), "CFOP") == (CHANGE, "5102") # blank filled
|
||||
unk = run(d=decision(ba_st_mva_status="MVA_CANDIDATES"), p=product(CFOP=None)) # ST unknown, blank -> asks
|
||||
assert unk["CFOP"]["state"] == MANUAL and unk["CFOP"]["input_key"] == "st_status"
|
||||
assert run(d=decision(ba_st_mva_status="MVA_CANDIDATES"))["CFOP"]["state"] == ALREADY # declared valid, not contradicted
|
||||
role_unknown = run(ev={**EV, "stock_in": {}}, p=product(CFOP=None))
|
||||
assert role_unknown["CFOP"]["state"] == MANUAL and role_unknown["CFOP"]["input_key"] == "product_role"
|
||||
own = run(ev={**EV, "recipes": {"000001"}}, p=product(CFOP="5102"))
|
||||
assert st(own, "CFOP") == (CHANGE, "5101")
|
||||
assert run(p=product(TIPO="MATERIA-PRIMA"))["CFOP"]["state"] == NA
|
||||
|
||||
|
||||
def test_cfop_entrada_needs_company_entry_scope_then_derives():
|
||||
r = run(p=product(CFOP_ENTRADA=None))
|
||||
assert r["CFOP_ENTRADA"]["state"] == MANUAL and r["CFOP_ENTRADA"]["input_key"] == "entry_scope"
|
||||
r = run(p=product(CFOP_ENTRADA=None), ctx=[{"scope": "COMPANY", "scope_key": "", "key": "entry_scope", "value": "INTRA"}])
|
||||
assert st(r, "CFOP_ENTRADA") == (CHANGE, "1102")
|
||||
assert run(p=product(CFOP_ENTRADA="1102"))["CFOP_ENTRADA"]["state"] == ALREADY
|
||||
|
||||
|
||||
def test_icms_code_fields_follow_the_regime():
|
||||
r1 = run()
|
||||
assert r1["CST"]["state"] == NA and r1["CST_ENTRADA"]["state"] == NA and r1["CSOSN"]["state"] == ALREADY
|
||||
assert run(p=product(CSOSN=None))["CSOSN"]["state"] == MANUAL
|
||||
assert st(run(p=product(CSOSN=None), ctx=[{"scope": "GROUP", "scope_key": "000019", "key": "icms_treatment", "value": "EXEMPT"}]), "CSOSN") == (CHANGE, "400")
|
||||
assert st(run(p=product(CSOSN="102"), ctx=[{"scope": "GROUP", "scope_key": "000019", "key": "icms_treatment", "value": "ST_WITHHELD"}]), "CSOSN") == (CHANGE, "500")
|
||||
r3 = run(co=CO3, p=product(CSOSN=None))
|
||||
assert r3["CSOSN"]["state"] == NA and r3["CST"]["state"] == ALREADY and r3["ALIQUOTA"]["state"] in (ALREADY, MANUAL)
|
||||
blank = run(co=CO3, p=product(CST=None, COD_ORIGEM_MERCADORIA="0"))
|
||||
assert blank["CST"]["state"] == MANUAL and blank["CST"]["input_key"] == "icms_treatment"
|
||||
filled = run(co=CO3, p=product(CST=None, COD_ORIGEM_MERCADORIA="0"), ctx=[{"scope": "COMPANY", "scope_key": "", "key": "icms_treatment", "value": "TAXED"}])
|
||||
assert st(filled, "CST") == (CHANGE, "000")
|
||||
|
||||
|
||||
def test_aliquota_not_copied_blindly():
|
||||
assert run()["ALIQUOTA"]["state"] == NA # Simples CSOSN 102 highlights no ICMS
|
||||
assert run(p=product(CSOSN="101", ALIQUOTA=None))["ALIQUOTA"]["input_key"] == "credit_rate"
|
||||
assert st(run(co=CO3, p=product(ALIQUOTA="17"), ctx=[{"scope": "COMPANY", "scope_key": "", "key": "icms_rate", "value": "20,5"}]), "ALIQUOTA") == (CHANGE, 20.5)
|
||||
|
||||
|
||||
def test_origin_binding_rules():
|
||||
r0 = run(p=product(COD_ORIGEM_MERCADORIA=None, CST="040"))["COD_ORIGEM_MERCADORIA"] # audit V2: the CST first digit is padding, never origin evidence
|
||||
assert r0["state"] == MANUAL and r0["reason"] == "ORIGIN_NO_EVIDENCE" and r0["proposed"] is None
|
||||
assert run(p=product(COD_ORIGEM_MERCADORIA="0", CST="040"))["COD_ORIGEM_MERCADORIA"]["state"] == ALREADY
|
||||
assert run(p=product(COD_ORIGEM_MERCADORIA="2", CST="040"))["COD_ORIGEM_MERCADORIA"]["state"] == ALREADY # declared origin stays; CST digit is padding
|
||||
assert run(p=product(COD_ORIGEM_MERCADORIA=None, CST="540"))["COD_ORIGEM_MERCADORIA"]["state"] == MANUAL # 5 is not on the SGV screen
|
||||
assert st(run(p=product(COD_ORIGEM_MERCADORIA=None, CST="540"), ctx=[{"scope": "GROUP", "scope_key": "000019", "key": "origin", "value": "1"}]), "COD_ORIGEM_MERCADORIA") == (CHANGE, 1)
|
||||
|
||||
|
||||
def test_cst_ipi_pis_cofins_incidence_iat_ippt():
|
||||
assert run()["CST_IPI"]["state"] == NA
|
||||
assert run(co=CO3)["CST_IPI"]["state"] == MANUAL
|
||||
r = run()
|
||||
assert r["COD_SAIDA_PIS"]["state"] == MANUAL and r["COD_SAIDA_PIS"]["input_key"] == "pis_cofins_profile" and r["ALIQ_COFINS"]["state"] == MANUAL
|
||||
prof = '{"pis_out_cst": "49", "pis_in_cst": "98", "pis_rate": "0", "cofins_out_cst": "49", "cofins_in_cst": "98", "cofins_rate": "0"}'
|
||||
r = run(ctx=[{"scope": "COMPANY", "scope_key": "", "key": "pis_cofins_profile", "value": prof}])
|
||||
assert st(r, "COD_SAIDA_PIS") == (CHANGE, "49") and st(r, "COD_ENTRADA_COFINS") == (CHANGE, "98") and st(r, "ALIQ_PIS") == (CHANGE, "0")
|
||||
bad = run(ctx=[{"scope": "COMPANY", "scope_key": "", "key": "pis_cofins_profile", "value": '{"pis_out_cst": "77"}'}])
|
||||
assert bad["COD_SAIDA_PIS"]["state"] == MANUAL # not in the official CST table
|
||||
assert run(p=product(IAT=None))["IAT"]["input_key"] == "rounding"
|
||||
assert run()["INCIDENCIA_PISCOFINS"]["state"] == ALREADY
|
||||
assert st(run(ev={**EV, "recipes": {"000001"}}), "IPPT") == (CHANGE, "P") and run()["IPPT"]["state"] == ALREADY
|
||||
assert run(p=product(TIPO="MATERIA-PRIMA"))["IPPT"]["state"] == NA
|
||||
|
||||
|
||||
def test_context_hierarchy_product_beats_group_beats_company():
|
||||
ctx = Context([{"scope": "COMPANY", "scope_key": "", "key": "product_role", "value": "RESALE"}, {"scope": "GROUP", "scope_key": "G1", "key": "product_role", "value": "OWN_PRODUCTION"},
|
||||
{"scope": "PRODUCT", "scope_key": "P1", "key": "product_role", "value": "INPUT"}])
|
||||
assert ctx.get("product_role", "P1", "G1") == ("INPUT", "PRODUCT")
|
||||
assert ctx.get("product_role", "P2", "G1") == ("OWN_PRODUCTION", "GROUP")
|
||||
assert ctx.get("product_role", "P2", "G9") == ("RESALE", "COMPANY")
|
||||
assert Context([]).get("product_role", "P2", "G9") == (None, None)
|
||||
|
||||
|
||||
def test_role_inference_is_evidence_based_and_conflicts_stay_unknown():
|
||||
assert infer_role(product(TIPO="MATERIA-PRIMA"), EV, Context([]), "000001", "1")[0] == "INPUT"
|
||||
assert infer_role(product(), {**EV, "stock_in": {}}, Context([]), "000001", "1")[0] == "UNKNOWN"
|
||||
assert infer_role(product(PROD_COZINHA=1), {**EV, "purchases": {"000001": [{}]}}, Context([]), "000001", "1")[0] == "UNKNOWN"
|
||||
assert infer_role(product(PROD_COZINHA=1), EV, Context([]), "000001", "1")[0] == "UNKNOWN" # kitchen flag alone is not proof
|
||||
assert infer_role(product(PROD_COZINHA=1), {**EV, "recipes": {"000001"}}, Context([]), "000001", "1")[0] == "OWN_PRODUCTION"
|
||||
|
||||
|
||||
# ---------------- executor guards ----------------
|
||||
def load_executor():
|
||||
spec = importlib.util.spec_from_file_location("fwe_local", ROOT / "scripts" / "firebird_write_executor.py")
|
||||
m = importlib.util.module_from_spec(spec); sys.modules[spec.name] = m; spec.loader.exec_module(m)
|
||||
return m
|
||||
|
||||
|
||||
class FakeCursor:
|
||||
def __init__(self, db):
|
||||
self.db, self.rowcount, self._row = db, 0, None
|
||||
|
||||
def execute(self, sql, params=()):
|
||||
s = " ".join(sql.split())
|
||||
if "RDB$FIELD_TYPE" in s:
|
||||
self._row = (37, 20)
|
||||
elif s.startswith("SELECT") and "FROM C000025" in s:
|
||||
fields = [x.strip() for x in s.split("SELECT")[1].split("FROM")[0].split(",")]
|
||||
row = self.db["rows"].get(params[0])
|
||||
self._row = tuple(row[f] for f in fields) if row else None
|
||||
elif s.startswith("UPDATE"):
|
||||
row = self.db["rows"].get(params[-1])
|
||||
self.rowcount = 0 if row is None else self.db["update_rowcount"]
|
||||
if row is not None and self.rowcount == 1:
|
||||
for f, v in zip([x.split("=")[0].strip() for x in s.split("SET")[1].split("WHERE")[0].split(",")], params[:-1]):
|
||||
self.db["pending"][f] = v
|
||||
|
||||
def fetchone(self):
|
||||
return self._row
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class FakeConn:
|
||||
def __init__(self, rows, update_rowcount=1):
|
||||
self.db = {"rows": rows, "update_rowcount": update_rowcount, "pending": {}}
|
||||
self.rolled_back = self.committed = False
|
||||
|
||||
def cursor(self):
|
||||
return FakeCursor(self.db)
|
||||
|
||||
def commit(self):
|
||||
for row in self.db["rows"].values():
|
||||
row.update(self.db["pending"])
|
||||
self.committed = True
|
||||
|
||||
def rollback(self):
|
||||
self.db["pending"].clear(); self.rolled_back = True
|
||||
|
||||
|
||||
ALLOWED = r"D:\PROJETOS\GTISISTEMAS\Projeto\Banco\CONSTRUCAO.FDB"
|
||||
|
||||
|
||||
def test_executor_allowlist_is_exact_and_field_set_is_the_contract():
|
||||
ex = load_executor()
|
||||
assert ex._LOCAL_COMPLETION_FIELDS == set(WRITE_FIELDS)
|
||||
allow = ex.load_local_allowlist()
|
||||
assert len(allow) == 5
|
||||
for path in (ALLOWED, r"D:\PROJETOS\GTISISTEMAS\Projeto\TESTESOFICIAIS.FDB"):
|
||||
ex.assert_local_completion_target(path, allow)
|
||||
for bad in (r"D:\PROJETOS\GTISISTEMAS\Banco\GTI.FDB", r"D:\PROJETOS\GTISISTEMAS\Projeto\Banco\OUTRA.FDB", r"D:\PROJETOS\GTISISTEMAS\Projeto\Banco\CONSTRUCAO.FDB.bak", r"D:\PROJETOS\GTISISTEMAS\Projeto\Banco"):
|
||||
with pytest.raises(ex.WriteRefused):
|
||||
ex.assert_local_completion_target(bad, allow)
|
||||
|
||||
|
||||
def test_guarded_product_update_success_drift_rowcount_and_refusals():
|
||||
ex = load_executor()
|
||||
allow = ex.load_local_allowlist()
|
||||
conn = FakeConn({"000001": {"IPI": None, "CEST": "1234567"}})
|
||||
rec = ex.execute_guarded_product_update(conn, database_path=ALLOWED, product_code="000001", changes={"IPI": (None, 3.9), "CEST": ("1234567", "7654321")}, now="t", allowlist=allow)
|
||||
assert rec["rowcount"] == 1 and rec["fields"]["IPI"]["after"] == 3.9 and conn.committed
|
||||
with pytest.raises(ex.WriteRefused, match="drift"):
|
||||
ex.execute_guarded_product_update(FakeConn({"000001": {"IPI": 1.0}}), database_path=ALLOWED, product_code="000001", changes={"IPI": (None, 3.9)}, now="t", allowlist=allow)
|
||||
c2 = FakeConn({"000001": {"IPI": None}}, update_rowcount=2)
|
||||
with pytest.raises(ex.WriteRefused, match="rowcount"):
|
||||
ex.execute_guarded_product_update(c2, database_path=ALLOWED, product_code="000001", changes={"IPI": (None, 3.9)}, now="t", allowlist=allow)
|
||||
assert c2.rolled_back
|
||||
with pytest.raises(ex.WriteRefused, match="allowlist"):
|
||||
ex.execute_guarded_product_update(conn, database_path=r"D:\PROJETOS\GTISISTEMAS\Banco\GTI.FDB", product_code="000001", changes={"IPI": (None, 1)}, now="t", allowlist=allow)
|
||||
with pytest.raises(ex.WriteRefused, match="contract write target"):
|
||||
ex.execute_guarded_product_update(conn, database_path=ALLOWED, product_code="000001", changes={"CODIGO": ("000001", "9")}, now="t", allowlist=allow)
|
||||
with pytest.raises(ex.WriteRefused, match="null"):
|
||||
ex.execute_guarded_product_update(conn, database_path=ALLOWED, product_code="000001", changes={"IPI": (None, None)}, now="t", allowlist=allow)
|
||||
with pytest.raises(ex.WriteRefused, match="only C000025"):
|
||||
ex.execute_guarded_product_update(conn, database_path=ALLOWED, product_code="000001", changes={"IPI": (None, 1)}, now="t", allowlist=allow, table="NFCE_ITENS")
|
||||
@@ -0,0 +1,45 @@
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from central.app.context_keys import validate_entry
|
||||
from central.app.main import create_app
|
||||
from central.app.models import Base
|
||||
|
||||
H = {"Authorization": "Bearer test-token"}
|
||||
|
||||
|
||||
def client():
|
||||
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
return TestClient(create_app(session_factory=lambda: Session(engine), bootstrap_token="test-token"))
|
||||
|
||||
|
||||
def test_validation_rules():
|
||||
ok, err = validate_entry({"scope": "group", "scope_key": "000019", "key": "product_role", "value": "resale"})
|
||||
assert err is None and ok["value"] == "RESALE" and ok["scope"] == "GROUP"
|
||||
for bad in ({"scope": "GROUP", "key": "product_role", "value": "RESALE"}, # group without key
|
||||
{"scope": "COMPANY", "key": "product_role", "value": "BOSS"}, # not in enum
|
||||
{"scope": "GROUP", "scope_key": "1", "key": "ncm", "value": "22030000"}, # ncm is product scope
|
||||
{"scope": "PRODUCT", "scope_key": "1", "key": "ncm", "value": "2203"}, # not 8 digits
|
||||
{"scope": "COMPANY", "key": "pis_cofins_profile", "value": "{\"x\": 1}"}, # unknown profile key
|
||||
{"scope": "COMPANY", "key": "icms_rate", "value": "abc"}, {"scope": "COMPANY", "key": "nope", "value": "1"}):
|
||||
assert validate_entry(bad)[1]
|
||||
assert validate_entry({"scope": "COMPANY", "key": "pis_cofins_profile", "value": "{\"pis_out_cst\": \"49\"}"})[1] is None
|
||||
|
||||
|
||||
def test_import_is_all_or_nothing_upserting_and_listed():
|
||||
c = client()
|
||||
body = {"company_key": "abc123", "entries": [{"scope": "GROUP", "scope_key": "000019", "key": "product_role", "value": "RESALE", "reason": "revenda"},
|
||||
{"scope": "COMPANY", "key": "entry_scope", "value": "INTRA"}]}
|
||||
assert c.post("/v1/context/import", json=body, headers=H).json() == {"created": 2, "updated": 0}
|
||||
body["entries"][0]["value"] = "OWN_PRODUCTION"
|
||||
assert c.post("/v1/context/import", json=body, headers=H).json() == {"created": 0, "updated": 2}
|
||||
listed = c.get("/v1/context", params={"company_key": "abc123"}, headers=H).json()["entries"]
|
||||
assert {(e["scope"], e["key"], e["value"]) for e in listed} == {("GROUP", "product_role", "OWN_PRODUCTION"), ("COMPANY", "entry_scope", "INTRA")}
|
||||
bad = {"company_key": "abc123", "entries": [{"scope": "COMPANY", "key": "rounding", "value": "A"}, {"scope": "COMPANY", "key": "rounding", "value": "Z"}]}
|
||||
r = c.post("/v1/context/import", json=bad, headers=H)
|
||||
assert r.status_code == 422
|
||||
assert len(c.get("/v1/context", params={"company_key": "abc123"}, headers=H).json()["entries"]) == 2 # nothing partially stored
|
||||
assert c.get("/v1/context", params={"company_key": "other"}, headers=H).json()["entries"] == []
|
||||
@@ -0,0 +1,71 @@
|
||||
from central.app.fiscal_decision import ERP_FIELD_MAP, decide_product
|
||||
|
||||
REFS = {"cfop_valid": {"5102"}, "csosn": {"102", "500"}, "cst_icms": {"00", "40", "60"}, "cst_pis": {"01"}, "cst_cofins": {"01"},
|
||||
"origem": set("012345678"), "fcp_uf": {"BA": {"aliq_1": "Fixo:2.00"}}, "cbs_rate_2026": 0.009, "cclasstrib_count": 164}
|
||||
COMPANY = {"uf": "BA", "crt": "1"}
|
||||
|
||||
|
||||
def product(**kw):
|
||||
base = {"CODIGO": "1", "PRODUTO": "X", "CLASSIFICACAO_FISCAL": "22030000", "CFOP": "5102", "CST": "040", "CSOSN": "102",
|
||||
"CEST": None, "IPI": None, "EX_TIPI": None, "ORIGEM": None, "COD_SAIDA_PIS": None, "COD_SAIDA_COFINS": None, "ALIQ_PIS": None}
|
||||
base.update(kw)
|
||||
return base
|
||||
|
||||
|
||||
def decision(**kw):
|
||||
base = {"status": "EXACT_NCM", "ipi_status": "IPI_RESOLVED", "cest_status": "CEST_CANDIDATES", "ba_st_mva_status": "MVA_CANDIDATES",
|
||||
"dataset_versions": {}, "classification": {"ncm": "22030000", "ipi": {"rate_kind": "NUMERIC", "rate_numeric": 3.9}, "cest_candidates": [1, 2]}}
|
||||
base.update(kw)
|
||||
return base
|
||||
|
||||
|
||||
def test_numeric_ipi_change_is_the_only_auto_apply():
|
||||
out = decide_product(product(IPI=5102.0), decision(), COMPANY, REFS)
|
||||
assert out["IPI"]["action_class"] == "AUTO_APPLY_SAFE" and out["IPI"]["write_eligible"] and out["IPI"]["proposed_value"] == 3.9
|
||||
assert [d for d, r in out.items() if r["write_eligible"]] == ["IPI"]
|
||||
|
||||
|
||||
def test_same_ipi_is_already_correct_not_write():
|
||||
out = decide_product(product(IPI=3.9), decision(), COMPANY, REFS)
|
||||
assert out["IPI"]["action_class"] == "ALREADY_CORRECT" and not out["IPI"]["write_eligible"]
|
||||
|
||||
|
||||
def test_non_numeric_and_blank_ipi_never_write():
|
||||
d = decision(classification={"ipi": {"rate_kind": "NON_NUMERIC", "rate_classification": "NT"}})
|
||||
assert not decide_product(product(), d, COMPANY, REFS)["IPI"]["write_eligible"]
|
||||
d = decision(classification={"ipi": {"rate_kind": "BLANK"}})
|
||||
r = decide_product(product(), d, COMPANY, REFS)["IPI"]
|
||||
assert not r["write_eligible"] and r["status"] == "RULE_UNRESOLVED"
|
||||
|
||||
|
||||
def test_operation_dependent_fields_never_resolved_or_written():
|
||||
out = decide_product(product(), decision(), COMPANY, REFS)
|
||||
for dom in ("CFOP", "CSOSN", "CST_ICMS", "PIS_CST", "COFINS_CST", "FCP", "RTC_CCLASSTRIB", "RTC_CBS_RATE"):
|
||||
assert out[dom]["status"] == "OPERATION_CONTEXT_REQUIRED" and not out[dom]["write_eligible"]
|
||||
assert out["CFOP"]["domain_valid"] is True and out["FCP"]["fcp_rate_available"] is True
|
||||
assert out["RTC_CBS_RATE"]["proposed_value"] is None and out["RTC_CBS_RATE"]["reference_rate_2026"] == 0.009
|
||||
|
||||
|
||||
def test_ncm_legacy_is_review_and_never_write():
|
||||
out = decide_product(product(), decision(status="LEGACY_NCM"), COMPANY, REFS)
|
||||
assert out["NCM"]["action_class"] == "REVIEW_REQUIRED" and not any(r["write_eligible"] for r in out.values())
|
||||
|
||||
|
||||
def test_origin_only_candidate_and_unconfirmed_mapping():
|
||||
out = decide_product(product(), decision(), COMPANY, REFS)
|
||||
assert out["ORIGEM"]["status"] == "RULE_CANDIDATE" and not out["ORIGEM"]["write_eligible"]
|
||||
assert {f["ERP_FIELD"] for f in ERP_FIELD_MAP if f["MAPPING"] == "MAPPING_UNCONFIRMED"} >= {"ORIGEM", "CFOP_ENTRADA", "CST_ENTRADA"}
|
||||
|
||||
|
||||
def test_mva_never_written_even_when_resolved():
|
||||
d = decision(ba_st_mva_status="MVA_RESOLVED", classification={"ipi": {"rate_kind": "NUMERIC", "rate_numeric": 1}, "ba_st_mva": {"mva_original": "140%"}})
|
||||
r = decide_product(product(IPI=1), d, COMPANY, REFS)["MVA"]
|
||||
assert r["status"] == "RULE_RESOLVED" and not r["write_eligible"]
|
||||
|
||||
|
||||
def test_single_ex_without_description_evidence_is_candidate_not_write():
|
||||
cls = {"ipi": {"rate_kind": "NUMERIC", "rate_numeric": 3.9}, "ipi_candidates": [{"ex": "1", "description": "Chope"}]}
|
||||
out = decide_product(product(PRODUTO="CERVEJA LATA", IPI=3.9), decision(classification=cls), COMPANY, REFS)
|
||||
assert out["EX_TIPI"]["status"] == "RULE_CANDIDATE" and not out["EX_TIPI"]["write_eligible"]
|
||||
out = decide_product(product(PRODUTO="CHOPE ESCURO", IPI=3.9), decision(classification=cls), COMPANY, REFS)
|
||||
assert out["EX_TIPI"]["status"] == "RULE_RESOLVED" and out["EX_TIPI"]["write_eligible"]
|
||||
@@ -0,0 +1,9 @@
|
||||
from central.app.fiscal_determination import FiscalDetermination
|
||||
|
||||
|
||||
def test_returns_unresolved_csosn_and_cfop_without_official_rule():
|
||||
result = FiscalDetermination().determine({"ncm": "08043000", "cest_candidates": []})
|
||||
|
||||
assert result["csosn"] is None
|
||||
assert result["cfop"] is None
|
||||
assert "OFFICIAL_RULE_NOT_IMPLEMENTED" in result["reason_codes"]
|
||||
@@ -0,0 +1,29 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from central.app.main import create_app
|
||||
|
||||
|
||||
def test_health_reports_database_dependency():
|
||||
client = TestClient(create_app(database_probe=lambda: True))
|
||||
|
||||
assert client.get("/health").json() == {"status": "ok"}
|
||||
|
||||
|
||||
def test_health_is_unavailable_when_database_is_down():
|
||||
client = TestClient(create_app(database_probe=lambda: False))
|
||||
|
||||
assert client.get("/health").status_code == 503
|
||||
|
||||
|
||||
def test_version_endpoint_reports_release_and_fiscal_versions():
|
||||
body = TestClient(create_app(database_probe=lambda: True)).get("/version").json()
|
||||
assert body["version"] == "1.0.0-rc1" and body["api_compat"] == "1.0" and body["engine_version"] and body["decision_version"]
|
||||
|
||||
|
||||
def test_production_refuses_the_dev_token(monkeypatch):
|
||||
import pytest
|
||||
from central.app import main as main_module
|
||||
monkeypatch.setattr(main_module.settings.__class__, "environment", "production", raising=False) if False else None
|
||||
monkeypatch.setattr(main_module, "settings", type("S", (), {"environment": "production", "agent_dev_token": "dev", "log_level": "INFO"})())
|
||||
with pytest.raises(RuntimeError):
|
||||
create_app(database_probe=lambda: True)
|
||||
@@ -0,0 +1,58 @@
|
||||
from central.app.icms_context import csosn_v2, product_role, st_conflict_class, st_status, withheld_status
|
||||
|
||||
DOM = {"102", "500", "101", "400"}
|
||||
|
||||
|
||||
def test_cest_candidate_never_confirms_st():
|
||||
assert st_status("EXACT_NCM", "MVA_CANDIDATES", "CEST_CANDIDATES", "", None, 4)[0] == "ST_CANDIDATE"
|
||||
assert st_status("EXACT_NCM", "MVA_RESOLVED", "CEST_CANDIDATES", "", "03.021.03", 4)[0] == "ST_CANDIDATE"
|
||||
|
||||
|
||||
def test_st_confirmed_needs_erp_cest_corroboration():
|
||||
assert st_status("EXACT_NCM", "MVA_RESOLVED", "CEST_CANDIDATES", "0302103", "03.021.03", 4)[0] == "ST_CONFIRMED"
|
||||
assert st_status("EXACT_NCM", "MVA_RESOLVED", "CEST_CONFIRMED", "", "03.021.03", 4)[0] == "ST_CONFIRMED"
|
||||
|
||||
|
||||
def test_st_not_applicable_needs_both_lists_silent_and_valid_ncm():
|
||||
assert st_status("EXACT_NCM", "NO_MVA_MAPPING_FOUND", "NO_CEST_MAPPING_FOUND", None, None, None)[0] == "ST_NOT_APPLICABLE"
|
||||
assert st_status("EXACT_NCM", "NO_MVA_MAPPING_FOUND", "CEST_CANDIDATES", None, None, None)[0] == "ST_UNKNOWN"
|
||||
assert st_status("LEGACY_NCM", "NO_MVA_MAPPING_FOUND", "NO_CEST_MAPPING_FOUND", None, None, None)[0] == "ST_UNKNOWN"
|
||||
|
||||
|
||||
def test_withholding_needs_positive_purchase_evidence():
|
||||
assert withheld_status([])[0] == "ST_WITHHOLDING_UNKNOWN"
|
||||
assert withheld_status([{"icms_retido": "N", "valor_retido": 0, "sub_valor": 0}])[0] == "ST_ALREADY_WITHHELD_NOT_CONFIRMED"
|
||||
assert withheld_status([{"icms_retido": "S", "valor_retido": 0, "sub_valor": 0}])[0] == "ST_ALREADY_WITHHELD_CONFIRMED"
|
||||
assert withheld_status([{"icms_retido": "N", "valor_retido": 5, "sub_valor": 0}])[0] == "ST_ALREADY_WITHHELD_CONFIRMED"
|
||||
|
||||
|
||||
def test_role_uses_objective_evidence_only():
|
||||
assert product_role("MATERIA-PRIMA", False, False, False, False, False)[0] == "INPUT"
|
||||
assert product_role("VENDA", True, False, False, False, False)[0] == "OWN_PRODUCTION"
|
||||
assert product_role("VENDA", False, False, False, False, True)[0] == "RESALE"
|
||||
assert product_role("VENDA", False, False, False, False, False)[0] == "UNKNOWN"
|
||||
assert product_role("VENDA", True, False, False, False, True)[0] == "UNKNOWN"
|
||||
|
||||
|
||||
def test_csosn_never_assumes_102_and_never_writes():
|
||||
for st in ("ST_CANDIDATE", "ST_UNKNOWN"):
|
||||
r = csosn_v2("102", "102", "EXACT_NCM", st, "ST_WITHHOLDING_UNKNOWN", "RESALE", DOM)
|
||||
assert r["status"] == "CSOSN_CONTEXT_REQUIRED" and r["proposed_csosn"] is None
|
||||
r = csosn_v2("102", "102", "EXACT_NCM", "ST_NOT_APPLICABLE", "ST_WITHHOLDING_UNKNOWN", "RESALE", DOM)
|
||||
assert r["status"] == "CSOSN_CANDIDATE" and not r["write_eligible"]
|
||||
assert csosn_v2("102", "102", "EXACT_NCM", "ST_NOT_APPLICABLE", "ST_WITHHOLDING_UNKNOWN", "UNKNOWN", DOM)["status"] == "CSOSN_CONTEXT_REQUIRED"
|
||||
assert csosn_v2("102", "102", "LEGACY_NCM", "ST_NOT_APPLICABLE", "ST_WITHHOLDING_UNKNOWN", "RESALE", DOM)["status"] == "CSOSN_UNRESOLVED"
|
||||
|
||||
|
||||
def test_csosn_resolved_only_with_st_withholding_and_role_proven():
|
||||
r = csosn_v2("102", "102", "EXACT_NCM", "ST_CONFIRMED", "ST_ALREADY_WITHHELD_CONFIRMED", "RESALE", DOM)
|
||||
assert r["status"] == "CSOSN_RESOLVED" and r["proposed_csosn"] == "500" and r["write_eligible"] is False
|
||||
assert csosn_v2("102", "102", "EXACT_NCM", "ST_CONFIRMED", "ST_WITHHOLDING_UNKNOWN", "RESALE", DOM)["status"] == "CSOSN_CONTEXT_REQUIRED"
|
||||
assert csosn_v2("500", "102", "EXACT_NCM", "ST_CONFIRMED", "ST_ALREADY_WITHHELD_NOT_CONFIRMED", "RESALE", DOM)["status"] == "CSOSN_CONFLICT"
|
||||
|
||||
|
||||
def test_conflict_classes():
|
||||
assert st_conflict_class("CEST_CANDIDATES", "MVA_RESOLVED", 4, "", "03.021.03", "5102", True, False)[0] == "ERP_HISTORY_CONFLICT"
|
||||
assert st_conflict_class(None, "NO_MVA_MAPPING_FOUND", None, "", None, "5405", False, True)[0] == "BA_ST_RULE_CONFLICT"
|
||||
assert st_conflict_class("CEST_DESCRIPTION_MISMATCH", "MVA_RESOLVED", 6, "", None, "5102", False, False)[0] == "DESCRIPTION_MISMATCH"
|
||||
assert st_conflict_class(None, "MVA_RESOLVED", 6, "", None, "5102", False, False)[0] == "NO_EVIDENCE"
|
||||
@@ -0,0 +1,132 @@
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from central.app.jobs import claim_next_job, complete_job, create_job, fail_job
|
||||
from central.app.main import create_app
|
||||
from central.app.models import Agent, Base, Job, Tenant
|
||||
|
||||
|
||||
def _make_agent(session):
|
||||
tenant = Tenant(name="development")
|
||||
session.add(tenant); session.flush()
|
||||
agent = Agent(tenant_id=tenant.id, name="agent-a", token_hash="hash-a")
|
||||
other = Agent(tenant_id=tenant.id, name="agent-b", token_hash="hash-b")
|
||||
session.add_all([agent, other]); session.commit()
|
||||
return agent, other
|
||||
|
||||
|
||||
def test_claim_is_scoped_to_owning_agent_and_marks_running():
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
agent, other = _make_agent(session)
|
||||
job = create_job(session, agent, "REPROCESS_ALL")
|
||||
assert claim_next_job(session, other) is None # other agent never sees it
|
||||
claimed = claim_next_job(session, agent)
|
||||
assert claimed.id == job.id
|
||||
assert claimed.status == "RUNNING"
|
||||
assert claimed.attempts == 1
|
||||
|
||||
|
||||
def test_claim_is_not_reentrant_once_running():
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
agent, _ = _make_agent(session)
|
||||
create_job(session, agent, "REPROCESS_ALL")
|
||||
first = claim_next_job(session, agent)
|
||||
second = claim_next_job(session, agent) # repeated polling must not duplicate consumption
|
||||
assert first is not None
|
||||
assert second is None
|
||||
|
||||
|
||||
def test_completed_job_can_never_run_again():
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
agent, _ = _make_agent(session)
|
||||
create_job(session, agent, "REPROCESS_ALL")
|
||||
job = claim_next_job(session, agent)
|
||||
assert complete_job(session, job, {"writes": 0}) is True
|
||||
assert session.get(Job, job.id).status == "COMPLETED"
|
||||
assert claim_next_job(session, agent) is None
|
||||
assert complete_job(session, job, {"writes": 1}) is False # already terminal
|
||||
|
||||
|
||||
def test_failed_job_is_terminal_with_sanitized_error():
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
agent, _ = _make_agent(session)
|
||||
create_job(session, agent, "REPROCESS_ALL")
|
||||
job = claim_next_job(session, agent)
|
||||
assert fail_job(session, job, "boom " * 1000) is True
|
||||
stored = session.get(Job, job.id)
|
||||
assert stored.status == "FAILED"
|
||||
assert len(stored.error) <= 2000
|
||||
|
||||
|
||||
def _client():
|
||||
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
app = create_app(session_factory=lambda: Session(engine), bootstrap_token="test-token")
|
||||
return TestClient(app), engine
|
||||
|
||||
|
||||
def test_job_api_end_to_end_flow():
|
||||
client, engine = _client()
|
||||
headers = {"Authorization": "Bearer test-token"}
|
||||
|
||||
heartbeat = client.post("/v1/agents/heartbeat", json={"status": "IDLE", "schema_fingerprint": "abc", "agent_version": "0.1.0"}, headers=headers)
|
||||
assert heartbeat.status_code == 200
|
||||
assert heartbeat.json()["enabled"] is True
|
||||
|
||||
created = client.post("/v1/jobs", json={"kind": "REPROCESS_PRODUCTS", "payload": {"codigos": ["000026"]}}, headers=headers)
|
||||
assert created.status_code == 200
|
||||
job_id = created.json()["job_id"]
|
||||
assert created.json()["status"] == "PENDING"
|
||||
|
||||
claimed = client.get("/v1/jobs/next", headers=headers)
|
||||
assert claimed.json()["job_id"] == job_id
|
||||
assert claimed.json()["status"] == "RUNNING"
|
||||
|
||||
empty = client.get("/v1/jobs/next", headers=headers)
|
||||
assert empty.json() is None # no more pending work; not the same job again
|
||||
|
||||
completed = client.post(f"/v1/jobs/{job_id}/complete", json={"result_summary": {"writes_succeeded": 1}}, headers=headers)
|
||||
assert completed.json()["status"] == "COMPLETED"
|
||||
assert completed.json()["result_summary"]["writes_succeeded"] == 1
|
||||
|
||||
fetched = client.get(f"/v1/jobs/{job_id}", headers=headers)
|
||||
assert fetched.json()["status"] == "COMPLETED"
|
||||
|
||||
|
||||
def test_agent_status_derives_online_offline():
|
||||
client, engine = _client()
|
||||
headers = {"Authorization": "Bearer test-token"}
|
||||
|
||||
offline = client.get("/v1/agents/me", headers=headers)
|
||||
assert offline.json()["status"] == "OFFLINE" # never heartbeated yet
|
||||
|
||||
client.post("/v1/agents/heartbeat", json={"status": "IDLE"}, headers=headers)
|
||||
online = client.get("/v1/agents/me", headers=headers)
|
||||
body = online.json()
|
||||
assert body["status"] == "ONLINE"
|
||||
assert body["enabled"] is True
|
||||
assert "company_id" in body
|
||||
|
||||
|
||||
def test_job_creation_rejects_unknown_kind():
|
||||
client, _ = _client()
|
||||
headers = {"Authorization": "Bearer test-token"}
|
||||
response = client.post("/v1/jobs", json={"kind": "DELETE_EVERYTHING"}, headers=headers)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_reprocess_products_requires_codigos():
|
||||
client, _ = _client()
|
||||
headers = {"Authorization": "Bearer test-token"}
|
||||
response = client.post("/v1/jobs", json={"kind": "REPROCESS_PRODUCTS", "payload": {}}, headers=headers)
|
||||
assert response.status_code == 400
|
||||
@@ -0,0 +1,38 @@
|
||||
from central.app.field_contract import ALREADY, CHANGE, MANUAL, NA, sped_pis_cofins_match
|
||||
from central.tests.test_field_contract import CO1, CO3, EV, decision, product, run, st
|
||||
|
||||
UNUSED = {**CO1, "pis_cofins_unused_by_emission": True}
|
||||
|
||||
|
||||
def test_sped_table_lookup_is_prefix_based_and_active_only():
|
||||
hits = sped_pis_cofins_match("10063021")
|
||||
assert hits and {h[1] for h in hits} == {"06"} and hits[0][0] == "4.3.13"
|
||||
assert sped_pis_cofins_match("99999999") == []
|
||||
|
||||
|
||||
def test_simples_pis_cofins_written_only_from_official_table_and_never_assumed_zero():
|
||||
r = run(p=product(CLASSIFICACAO_FISCAL="10063021"), co=UNUSED)
|
||||
assert st(r, "COD_SAIDA_PIS") == (CHANGE, "06") and st(r, "COD_SAIDA_COFINS") == (CHANGE, "06")
|
||||
assert r["ALIQ_PIS"]["state"] == NA and r["COD_ENTRADA_PIS"]["state"] == NA
|
||||
plain = run(co=UNUSED) # NCM without any SPED special regime: SGV does not consume the field, nothing is invented
|
||||
assert all(plain[f]["state"] == NA and plain[f]["proposed"] is None for f in ("COD_SAIDA_PIS", "ALIQ_PIS", "COD_ENTRADA_COFINS"))
|
||||
assert run(co=CO1)["COD_SAIDA_PIS"]["state"] == MANUAL # without emission evidence the company question remains
|
||||
assert run(co={**CO3, "pis_cofins_unused_by_emission": True})["COD_SAIDA_PIS"]["state"] == MANUAL # evidence only relaxes CRT 1
|
||||
|
||||
|
||||
def test_cfop_never_contradicts_the_erp_st_declaration():
|
||||
r = run(p=product(CFOP="5405", CSOSN="500", CST="060", SITUACAO_TRIBUTARIA="T"))
|
||||
assert r["CFOP"]["state"] != CHANGE or r["CFOP"]["proposed"] != "5102"
|
||||
|
||||
|
||||
def test_kitchen_flag_alone_and_cst_digit_never_prove_role_or_origin():
|
||||
r = run(p=product(PROD_COZINHA=1, COD_ORIGEM_MERCADORIA=None, CST="040"))
|
||||
assert r["COD_ORIGEM_MERCADORIA"]["state"] == MANUAL and r["COD_ORIGEM_MERCADORIA"]["reason"] == "ORIGIN_NO_EVIDENCE"
|
||||
assert r["IPPT"]["proposed"] != "P" or r["IPPT"]["state"] != CHANGE
|
||||
|
||||
|
||||
def test_store_ipi_profile_is_a_company_decision_never_defaulted():
|
||||
undecided = run(ipi_profile=None)
|
||||
assert undecided["IPI"]["state"] == MANUAL and undecided["IPI"]["input_key"] == "store_ipi_profile"
|
||||
assert run(ipi_profile={"scope": "COMPANY", "scope_key": "", "key": "store_ipi_profile", "value": "LEAVE_NOT_APPLICABLE"})["IPI"]["state"] == NA
|
||||
assert run()["IPI"]["state"] == CHANGE # POPULATE_REFERENCE_RATE keeps the TIPI rule
|
||||
@@ -0,0 +1,66 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from central.app.datasets.ncm import parse_ncm_payload
|
||||
|
||||
|
||||
PAYLOAD = b'{"Data_Ultima_Atualizacao_NCM":"2026-09-17","Ato":"Ato","Nomenclaturas":[{"Codigo":"08043000","Descricao":"Abacaxis frescos","Data_Inicio":"2022-01-01","Data_Fim":"9999-12-31"}]}'
|
||||
|
||||
|
||||
def test_import_promotes_and_is_idempotent():
|
||||
from central.app.datasets.service import DatasetService
|
||||
from central.app.models import Base, Dataset, NcmEntry
|
||||
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
service = DatasetService(session)
|
||||
first = service.import_payload(parse_ncm_payload(PAYLOAD), "https://source", datetime.now(timezone.utc), minimum_entries=1)
|
||||
second = service.import_payload(parse_ncm_payload(PAYLOAD), "https://source", datetime.now(timezone.utc), minimum_entries=1)
|
||||
assert first.id == second.id
|
||||
assert session.get(Dataset, first.id).status == "ACTIVE"
|
||||
assert session.query(NcmEntry).filter_by(dataset_id=first.id).count() == 1
|
||||
|
||||
|
||||
def test_classifier_validates_ncm_and_never_proposes_fiscal_fields():
|
||||
from central.app.classification import Classifier
|
||||
|
||||
class Repository:
|
||||
def active_entries(self):
|
||||
return [type("Entry", (), {"code": "08043000", "description": "Abacaxis frescos"})()]
|
||||
|
||||
def dataset_versions(self):
|
||||
return {"ncm": "dataset-1"}
|
||||
|
||||
result = Classifier(Repository()).classify({"description": "abacaxi", "current_ncm": "0804.30.00"})
|
||||
assert result["status"] == "EXACT_NCM"
|
||||
assert result["classification"]["ncm"] == "08043000"
|
||||
assert result["classification"]["cest"] is None
|
||||
assert result["can_auto_apply"] is False
|
||||
|
||||
|
||||
def test_api_auth_and_cache():
|
||||
from central.app.main import create_app
|
||||
from central.app.models import Base, Dataset, NcmEntry
|
||||
|
||||
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
dataset = Dataset(kind="NCM", provider="Siscomex", sha256="a" * 64, version_label="v1", act="act", source_url="url", status="ACTIVE", record_count=1)
|
||||
session.add(dataset)
|
||||
session.flush()
|
||||
session.add(NcmEntry(dataset_id=dataset.id, code="08043000", description="Abacaxis frescos", valid_from="2022", valid_to="9999"))
|
||||
session.commit()
|
||||
app = create_app(session_factory=lambda: Session(engine), bootstrap_token="test-token")
|
||||
client = TestClient(app)
|
||||
payload = {"product": {"product_id": "1", "description": "abacaxi", "current_ncm": "08043000"}}
|
||||
assert client.post("/v1/classify", json=payload).status_code == 401
|
||||
headers = {"Authorization": "Bearer test-token"}
|
||||
first = client.post("/v1/classify", json=payload, headers=headers).json()
|
||||
second = client.post("/v1/classify", json=payload, headers=headers).json()
|
||||
assert first["request_fingerprint"] == second["request_fingerprint"]
|
||||
assert second["provenance"]["cache_hit"] is True
|
||||
@@ -0,0 +1,172 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
CEST_HTML = b"""
|
||||
<html><body>
|
||||
<table><tr><td>Ignored</td></tr></table>
|
||||
<h2>ANEXO II</h2>
|
||||
<table>
|
||||
<tr><th>ITEM</th><th>CEST</th><th>NCM/SH</th><th>DESCRICAO</th></tr>
|
||||
<tr><td>1.0</td><td>01.001.00</td><td>0804.30.00</td><td>Abacaxis</td></tr>
|
||||
<tr><td>2.0</td><td>01.002.00</td><td>0804.30.00<br>0207.14.00</td><td>Frutas e aves</td></tr>
|
||||
</table>
|
||||
<h2>ANEXO XXVII</h2>
|
||||
<table><tr><th>CEST</th><th>NCM</th></tr><tr><td>99.999.99</td><td>9999.99.99</td></tr></table>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
|
||||
def test_cest_parser_accepts_only_covered_annex_tables_and_hashes_raw_html():
|
||||
from central.app.datasets.cest import parse_cest_html
|
||||
|
||||
parsed = parse_cest_html(CEST_HTML)
|
||||
|
||||
assert parsed.version == "CV142_18"
|
||||
assert parsed.sha256
|
||||
assert parsed.entries[0].annex == "II"
|
||||
assert parsed.entries[1].ncm_codes == ("08043000", "02071400")
|
||||
assert len(parsed.entries) == 2
|
||||
|
||||
|
||||
def test_cest_parser_fails_closed_when_an_annex_table_has_no_required_columns():
|
||||
from central.app.datasets.cest import parse_cest_html
|
||||
|
||||
with pytest.raises(ValueError, match="required columns"):
|
||||
parse_cest_html(b"<h2>ANEXO II</h2><table><tr><th>CEST</th></tr><tr><td>01.001.00</td></tr></table>")
|
||||
|
||||
|
||||
def test_cest_parser_accepts_official_four_digit_ncm_headings():
|
||||
from central.app.datasets.cest import parse_cest_html
|
||||
|
||||
parsed = parse_cest_html(b"<h2>ANEXO III</h2><table><tr><th>CEST</th><th>NCM/SH</th></tr><tr><td>02.001.00</td><td>2205</td></tr></table>")
|
||||
|
||||
assert parsed.entries[0].ncm_codes == ("2205",)
|
||||
|
||||
|
||||
def test_cest_parser_ignores_official_editorial_rows_without_cest_data():
|
||||
from central.app.datasets.cest import parse_cest_html
|
||||
|
||||
parsed = parse_cest_html(b"<h2>ANEXO II</h2><table><tr><th>CEST</th><th>NCM/SH</th></tr><tr><td>Nova redacao dada ao item.</td></tr><tr><td>01.001.00</td><td>3815.12.10</td></tr></table>")
|
||||
|
||||
assert [entry.code for entry in parsed.entries] == ["01.001.00"]
|
||||
|
||||
|
||||
def test_cest_parser_preserves_chapter_ncm_prefixes_and_empty_official_ncm_cells():
|
||||
from central.app.datasets.cest import parse_cest_html
|
||||
|
||||
parsed = parse_cest_html(b"<h2>ANEXO XXVI</h2><table><tr><th>CEST</th><th>NCM/SH</th></tr><tr><td>28.055.00</td><td>Capitulo 33</td></tr><tr><td>28.056.00</td><td></td></tr></table>")
|
||||
|
||||
assert parsed.entries[0].ncm_codes == ("33",)
|
||||
assert parsed.entries[1].ncm_codes == ()
|
||||
|
||||
|
||||
def test_classifier_distinguishes_exact_legacy_and_invalid_ncm_and_uses_cest_matches():
|
||||
from central.app.classification import Classifier
|
||||
|
||||
class Repository:
|
||||
def active_entries(self):
|
||||
return [type("Entry", (), {"code": "08043000", "description": "Abacaxis"})()]
|
||||
|
||||
def cest_for_ncm(self, code):
|
||||
return [type("Cest", (), {"code": "01.001.00", "description": "Abacaxis"})()] if code == "08043000" else []
|
||||
|
||||
def legacy_ncm(self, code):
|
||||
return {"code": code, "source": "Siscomex", "source_date": "2026-09-17", "reason": "NCM not present in current official nomenclature; no official successor."} if code == "02071400" else None
|
||||
|
||||
def dataset_versions(self):
|
||||
return {"ncm": "ncm-version", "cest": "cest-version"}
|
||||
|
||||
classifier = Classifier(Repository())
|
||||
|
||||
exact = classifier.classify({"current_ncm": "0804.30.00", "description": "abacaxi"})
|
||||
legacy = classifier.classify({"current_ncm": "0207.14.00"})
|
||||
invalid = classifier.classify({"current_ncm": "0000.00.00"})
|
||||
|
||||
assert exact["status"] == "EXACT_NCM"
|
||||
assert exact["classification"]["cest"] == ["01.001.00"]
|
||||
assert legacy["status"] == "LEGACY_NCM"
|
||||
assert legacy["classification"]["ncm"] == "02071400"
|
||||
assert legacy["legacy_ncm"]["reason"].endswith("no official successor.")
|
||||
assert invalid["status"] == "INVALID_NCM"
|
||||
|
||||
|
||||
def test_classifier_reports_confirmed_cest_only_for_one_distinct_official_candidate():
|
||||
from central.app.classification import Classifier
|
||||
|
||||
class Repository:
|
||||
def active_entries(self):
|
||||
return [type("Entry", (), {"code": "08043000", "description": "Abacaxi"})()]
|
||||
def cest_for_ncm(self, code):
|
||||
return [type("Cest", (), {"code": "01.001.00", "description": "Abacaxi"})()]
|
||||
def legacy_ncm(self, code): return None
|
||||
def dataset_versions(self): return {"ncm": "n", "cest": "c"}
|
||||
|
||||
result = Classifier(Repository()).classify({"current_ncm": "08043000", "description": "Abacaxi"})
|
||||
assert result["cest_status"] == "CEST_CONFIRMED"
|
||||
|
||||
|
||||
def test_classifier_reports_candidates_for_multiple_distinct_cest_codes():
|
||||
from central.app.classification import Classifier
|
||||
|
||||
class Repository:
|
||||
def active_entries(self):
|
||||
return [type("Entry", (), {"code": "08043000", "description": "Abacaxi"})()]
|
||||
def cest_for_ncm(self, code):
|
||||
return [type("Cest", (), {"code": "01.001.00", "description": "Abacaxi"})(), type("Cest", (), {"code": "01.002.00", "description": "Frutas"})()]
|
||||
def legacy_ncm(self, code): return None
|
||||
def dataset_versions(self): return {"ncm": "n", "cest": "c"}
|
||||
|
||||
result = Classifier(Repository()).classify({"current_ncm": "08043000", "description": "Abacaxi"})
|
||||
assert result["cest_status"] == "CEST_CANDIDATES"
|
||||
assert result["classification"]["cest"] == ["01.001.00", "01.002.00"]
|
||||
|
||||
|
||||
def test_cest_import_creates_many_to_many_ncm_links_and_stores_html_metadata():
|
||||
from central.app.datasets.cest import parse_cest_html
|
||||
from central.app.datasets.service import DatasetService
|
||||
from central.app.models import Base, CestEntry, CestNcmEntry, Dataset
|
||||
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
dataset = DatasetService(session).import_cest_payload(
|
||||
parse_cest_html(CEST_HTML), "https://www.confaz.fazenda.gov.br/legislacao/convenios/2018/CV142_18", datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
assert dataset.kind == "CEST"
|
||||
assert dataset.raw_sha256 == dataset.sha256
|
||||
assert session.query(CestEntry).filter_by(dataset_id=dataset.id).count() == 2
|
||||
assert session.query(CestNcmEntry).count() == 3
|
||||
|
||||
|
||||
def test_cest_lookup_matches_official_ncm_prefix_links():
|
||||
from central.app.datasets.cest import parse_cest_html
|
||||
from central.app.datasets.service import DatasetService
|
||||
from central.app.models import Base
|
||||
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
service = DatasetService(session)
|
||||
service.import_cest_payload(parse_cest_html(b"<h2>ANEXO III</h2><table><tr><th>CEST</th><th>NCM</th></tr><tr><td>02.001.00</td><td>2205</td></tr></table>"), "https://source", datetime.now(timezone.utc))
|
||||
|
||||
assert [entry.code for entry in service.cest_for_ncm("22051000")] == ["02.001.00"]
|
||||
|
||||
|
||||
def test_cest_import_unions_repeated_official_cest_codes_into_one_link_set():
|
||||
from central.app.datasets.cest import parse_cest_html
|
||||
from central.app.datasets.service import DatasetService
|
||||
from central.app.models import Base, CestEntry, CestNcmEntry
|
||||
|
||||
payload = parse_cest_html(b"<h2>ANEXO II</h2><table><tr><th>CEST</th><th>NCM</th></tr><tr><td>01.042.00</td><td>8421.32.00</td></tr><tr><td>01.042.00</td><td>8421.39.20</td></tr></table>")
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
dataset = DatasetService(session).import_cest_payload(payload, "https://source", datetime.now(timezone.utc))
|
||||
|
||||
assert session.query(CestEntry).filter_by(dataset_id=dataset.id).count() == 1
|
||||
assert {link.ncm_code for link in session.query(CestNcmEntry).all()} == {"84213200", "84213920"}
|
||||
@@ -0,0 +1,17 @@
|
||||
import pytest
|
||||
|
||||
from central.app.datasets.ncm import parse_ncm_payload
|
||||
|
||||
|
||||
def test_parser_accepts_verified_siscomex_payload_shape():
|
||||
payload = b'{"Data_Ultima_Atualizacao_NCM":"Vigente em 17/09/2026","Ato":"Resolucao Gecex 926/2026","Nomenclaturas":[{"Codigo":"08043000","Descricao":"Abacaxis.","Data_Inicio":"01/04/2022","Data_Fim":"31/12/9999"}]}'
|
||||
|
||||
parsed = parse_ncm_payload(payload)
|
||||
|
||||
assert parsed.version == "Vigente em 17/09/2026"
|
||||
assert parsed.entries[0].code == "08043000"
|
||||
|
||||
|
||||
def test_parser_rejects_unknown_payload_shape():
|
||||
with pytest.raises(ValueError, match="Nomenclaturas"):
|
||||
parse_ncm_payload(b'{"items": []}')
|
||||
@@ -0,0 +1,93 @@
|
||||
from datetime import datetime, timezone
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from central.app.datasets.nfe_moc_tables import parse_moc_tables
|
||||
from central.app.datasets.service import DatasetService
|
||||
from central.app.models import Base
|
||||
|
||||
SOURCE = Path(__file__).parents[2] / "sources" / "raw" / "bootstrap_v1" / "moc7-anexo-i-leiaute-e-rv.pdf"
|
||||
pytestmark = pytest.mark.skipif(not SOURCE.exists(), reason="official MOC PDF not present locally")
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _payload():
|
||||
# Parsing this 153-page PDF is expensive (~40s); every test in this module
|
||||
# shares one parse instead of re-running it per test.
|
||||
return parse_moc_tables(SOURCE.read_bytes())
|
||||
|
||||
|
||||
def test_parses_all_five_domains_with_expected_minimum_coverage():
|
||||
payload = _payload()
|
||||
from collections import Counter
|
||||
counts = Counter(e.domain for e in payload.entries)
|
||||
assert counts["ORIGEM_MERCADORIA"] == 9
|
||||
assert counts["CST_ICMS"] >= 11
|
||||
assert counts["CSOSN"] >= 10
|
||||
assert counts["CST_PIS"] >= 29
|
||||
assert counts["CST_COFINS"] >= 29
|
||||
|
||||
|
||||
def test_every_entry_has_provenance_and_no_invented_text():
|
||||
payload = _payload()
|
||||
for entry in payload.entries:
|
||||
assert entry.source_section.startswith("MOC7 p.")
|
||||
assert entry.description.strip()
|
||||
assert entry.code.strip()
|
||||
|
||||
|
||||
def test_origem_codes_are_0_through_8():
|
||||
payload = _payload()
|
||||
codes = sorted(e.code for e in payload.entries if e.domain == "ORIGEM_MERCADORIA")
|
||||
assert codes == [str(n) for n in range(9)]
|
||||
|
||||
|
||||
def test_cst_icms_includes_split_group_40_41_50():
|
||||
payload = _payload()
|
||||
by_code = {e.code: e for e in payload.entries if e.domain == "CST_ICMS"}
|
||||
assert "isenta" in by_code["40"].description.lower()
|
||||
assert "tributada" in by_code["41"].description.lower()
|
||||
assert "suspens" in by_code["50"].description.lower()
|
||||
|
||||
|
||||
def test_reimport_is_idempotent():
|
||||
payload = _payload()
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
service = DatasetService(session)
|
||||
first = service.import_moc_tables(payload, "file://moc7.pdf", datetime.now(timezone.utc))
|
||||
second = service.import_moc_tables(payload, "file://moc7.pdf", datetime.now(timezone.utc))
|
||||
assert first.id == second.id
|
||||
assert second.status == "ACTIVE"
|
||||
|
||||
|
||||
def test_lookup_smoke_for_each_domain():
|
||||
payload = _payload()
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
service = DatasetService(session)
|
||||
service.import_moc_tables(payload, "file://moc7.pdf", datetime.now(timezone.utc))
|
||||
|
||||
assert len(service.fiscal_codes("ORIGEM_MERCADORIA")) == 9
|
||||
assert service.fiscal_codes("ORIGEM_MERCADORIA", code="0")[0].code == "0"
|
||||
assert service.fiscal_codes("CST_ICMS", code="00")
|
||||
assert service.fiscal_codes("CSOSN", code="101")
|
||||
assert service.fiscal_codes("CST_PIS", code="01")
|
||||
assert service.fiscal_codes("CST_COFINS", code="01")
|
||||
assert service.fiscal_codes("CST_ICMS", code="99") == []
|
||||
|
||||
|
||||
def test_dataset_versions_includes_nfe_moc_tables_and_changes_hash_on_reimport():
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
service = DatasetService(session)
|
||||
assert service.dataset_versions()["nfe_moc_tables"] is None
|
||||
service.import_moc_tables(_payload(), "file://moc7.pdf", datetime.now(timezone.utc))
|
||||
assert service.dataset_versions()["nfe_moc_tables"] is not None
|
||||
@@ -0,0 +1,73 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from central.app.product_defaults import cfop_default, cst_status, csosn_default, validate_against_history
|
||||
|
||||
CFOPS = {"5102", "5405", "5656", "1102"}
|
||||
CSOSNS = {"101", "102", "103", "500", "400"}
|
||||
|
||||
|
||||
def P(**kw):
|
||||
return {"CFOP": "5102", "CSOSN": "102", "CST": "040", **kw}
|
||||
|
||||
|
||||
def test_cfop_consistent_non_st_is_resolved_but_never_a_write():
|
||||
r = cfop_default(P(), "EXACT_NCM", "NO_MVA_MAPPING_FOUND", CFOPS)
|
||||
assert r["status"] == "CFOP_DEFAULT_RESOLVED" and r["action_class"] == "ALREADY_CORRECT" and not r["write_eligible"]
|
||||
|
||||
|
||||
def test_cfop_st_conflict_is_candidate_review_not_write():
|
||||
r = cfop_default(P(), "EXACT_NCM", "MVA_RESOLVED", CFOPS)
|
||||
assert r["status"] == "CFOP_DEFAULT_CANDIDATE" and r["proposed_value"] == "5405" and r["action_class"] == "REVIEW_REQUIRED" and not r["write_eligible"]
|
||||
|
||||
|
||||
def test_cfop_insufficient_context_never_resolved():
|
||||
assert cfop_default(P(), "EXACT_NCM", "MVA_CANDIDATES", CFOPS)["status"] == "CFOP_OPERATION_CONTEXT_REQUIRED"
|
||||
assert cfop_default(P(CFOP=None), "EXACT_NCM", "NO_MVA_MAPPING_FOUND", CFOPS)["status"] == "CFOP_OPERATION_CONTEXT_REQUIRED"
|
||||
assert cfop_default(P(CFOP="6102"), "EXACT_NCM", "NO_MVA_MAPPING_FOUND", CFOPS | {"6102"})["status"] == "CFOP_OPERATION_CONTEXT_REQUIRED"
|
||||
assert cfop_default(P(), "LEGACY_NCM", "NO_MVA_MAPPING_FOUND", CFOPS)["status"] == "CFOP_UNRESOLVED"
|
||||
assert cfop_default(P(CFOP="9999"), "EXACT_NCM", "NO_MVA_MAPPING_FOUND", CFOPS)["status"] == "CFOP_UNRESOLVED"
|
||||
|
||||
|
||||
def test_csosn_is_never_resolved_and_never_universal_102():
|
||||
for mva in ("MVA_RESOLVED", "MVA_CANDIDATES", "NO_MVA_MAPPING_FOUND"):
|
||||
r = csosn_default(P(), "EXACT_NCM", mva, CSOSNS, "102")
|
||||
assert r["status"] != "CSOSN_DEFAULT_RESOLVED" and not r["write_eligible"]
|
||||
r = csosn_default(P(CSOSN="101"), "EXACT_NCM", "NO_MVA_MAPPING_FOUND", CSOSNS, "102")
|
||||
assert r["proposed_value"] is None
|
||||
|
||||
|
||||
def test_csosn_st_subject_flags_conflict_and_requires_retention_context():
|
||||
r = csosn_default(P(), "EXACT_NCM", "MVA_RESOLVED", CSOSNS, "102")
|
||||
assert r["status"] == "CSOSN_OPERATION_CONTEXT_REQUIRED" and r["consistency_flag"] == "ST_SUBJECT_BUT_CSOSN_NOT_500"
|
||||
|
||||
|
||||
def test_history_validates_but_does_not_create_rules():
|
||||
assert validate_against_history("5102", ["5102", "5102"]) == "MATCH"
|
||||
assert validate_against_history("5102", ["5102", "5405"]) == "MISMATCH"
|
||||
assert validate_against_history("5102", []) == "UNVALIDATED"
|
||||
assert validate_against_history(None, ["5102"]) == "UNVALIDATED" # history alone never proposes a value
|
||||
|
||||
|
||||
def test_cst_is_reference_only_under_crt1():
|
||||
r = cst_status(P(), {"40", "00"}, set("012345678"))
|
||||
assert r["status"] == "CST_REFERENCE_ONLY" and r["origin_digit"] == "0" and not r["write_eligible"]
|
||||
assert cst_status(P(CST="99"), {"40"}, set("0"))["status"] == "CST_INVALID_REVIEW"
|
||||
|
||||
|
||||
def test_engine_is_deterministic_idempotent():
|
||||
a = cfop_default(P(), "EXACT_NCM", "MVA_RESOLVED", CFOPS)
|
||||
assert a == cfop_default(P(), "EXACT_NCM", "MVA_RESOLVED", CFOPS)
|
||||
|
||||
|
||||
REC = Path(__file__).resolve().parents[2] / "docs" / "NCM_HISTORICAL_RECONCILIATION_V1.json"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not REC.exists(), reason="reconciliation artifact not generated")
|
||||
def test_reconciliation_accounts_for_every_mismatch_and_finds_no_technical_bug():
|
||||
d = json.loads(REC.read_text(encoding="utf-8"))
|
||||
assert sum(d["classification"].values()) == d["mismatches_total"]
|
||||
assert d["classification"].get("NORMALIZATION_BUG", 0) == 0
|
||||
assert d["trusted_corpus"]["TRUSTED_HISTORICAL_ITEMS"] > 0
|
||||
@@ -0,0 +1,53 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from central.app.datasets.service import DatasetService
|
||||
from central.app.main import create_app
|
||||
from central.app.models import AnpProductCode, Base, CfopEntry, Dataset, FcpRate, NcmUtribEntry
|
||||
|
||||
|
||||
def _client_with_reference_data():
|
||||
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
dataset = Dataset(kind="CFOP", provider="p", sha256="a" * 64, version_label="v", act="a", source_url="u", status="ACTIVE", record_count=1)
|
||||
session.add(dataset); session.flush()
|
||||
session.add(CfopEntry(dataset_id=dataset.id, code="1101", title="Compra", description="desc"))
|
||||
|
||||
d2 = Dataset(kind="NCM_UTRIB", provider="p", sha256="b" * 64, version_label="v", act="a", source_url="u", status="ACTIVE", record_count=1)
|
||||
session.add(d2); session.flush()
|
||||
session.add(NcmUtribEntry(dataset_id=d2.id, ncm="01012100", utrib_abbreviation="UN", utrib_description="Unidade"))
|
||||
|
||||
d3 = Dataset(kind="ANP", provider="p", sha256="c" * 64, version_label="v", act="a", source_url="u", status="ACTIVE", record_count=1)
|
||||
session.add(d3); session.flush()
|
||||
session.add(AnpProductCode(dataset_id=d3.id, cprod_anp="110203073"))
|
||||
|
||||
d4 = Dataset(kind="FCP", provider="p", sha256="d" * 64, version_label="v", act="a", source_url="u", status="ACTIVE", record_count=1)
|
||||
session.add(d4); session.flush()
|
||||
session.add(FcpRate(dataset_id=d4.id, uf_code="29", uf="BA", uf_name="BAHIA", aliq_1="Fixo:2.00", observacao="obs"))
|
||||
session.commit()
|
||||
app = create_app(session_factory=lambda: Session(engine), bootstrap_token="test-token")
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_reference_endpoints_found_and_not_found():
|
||||
client = _client_with_reference_data()
|
||||
headers = {"Authorization": "Bearer test-token"}
|
||||
|
||||
assert client.get("/v1/reference/cfop/1101", headers=headers).json()["found"] is True
|
||||
assert client.get("/v1/reference/cfop/9999", headers=headers).json()["found"] is False
|
||||
|
||||
assert client.get("/v1/reference/ncm_utrib/01012100", headers=headers).json()["found"] is True
|
||||
assert client.get("/v1/reference/ncm_utrib/99999999", headers=headers).json()["found"] is False
|
||||
|
||||
assert client.get("/v1/reference/anp/110203073", headers=headers).json()["found"] is True
|
||||
assert client.get("/v1/reference/anp/000000000", headers=headers).json()["found"] is False
|
||||
|
||||
fcp = client.get("/v1/reference/fcp/BA", headers=headers).json()
|
||||
assert fcp["found"] is True
|
||||
assert fcp["aliq_1"] == "Fixo:2.00"
|
||||
assert client.get("/v1/reference/fcp/ZZ", headers=headers).json()["found"] is False
|
||||
@@ -0,0 +1,77 @@
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from central.app.auth import authenticate, token_hash
|
||||
from central.app.models import Base
|
||||
|
||||
SCRIPT = Path(__file__).parents[1] / "scripts" / "register_agent.py"
|
||||
|
||||
|
||||
def load_module():
|
||||
spec = importlib.util.spec_from_file_location("register_agent", SCRIPT)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
register_agent_module = load_module()
|
||||
|
||||
|
||||
def test_register_agent_creates_tenant_and_hashed_token_only():
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
info, token = register_agent_module.register_agent(session, "Restaurante X", "loja-01")
|
||||
found = authenticate(session, token)
|
||||
assert found is not None
|
||||
assert found.id == info["id"]
|
||||
assert found.token_hash == token_hash(token)
|
||||
assert found.token_hash != token # only the hash is ever persisted
|
||||
|
||||
# Mirrors the real CLI's main(): reading the returned info AFTER the
|
||||
# session (and its `with` block) has closed. This is exactly the scenario
|
||||
# that broke when register_agent() returned live ORM objects instead of
|
||||
# plain values (DetachedInstanceError) — must keep working unattached.
|
||||
assert info["enabled"] is True
|
||||
assert isinstance(info["tenant_id"], int)
|
||||
|
||||
|
||||
def test_register_agent_reuses_existing_tenant():
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
info1, _ = register_agent_module.register_agent(session, "Restaurante X", "loja-01")
|
||||
info2, _ = register_agent_module.register_agent(session, "Restaurante X", "loja-02")
|
||||
|
||||
assert info1["tenant_id"] == info2["tenant_id"]
|
||||
assert info1["id"] != info2["id"]
|
||||
|
||||
|
||||
def test_register_agent_generates_unique_tokens():
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
_, token1 = register_agent_module.register_agent(session, "A", "a1")
|
||||
_, token2 = register_agent_module.register_agent(session, "A", "a2")
|
||||
assert token1 != token2
|
||||
|
||||
|
||||
def test_disabled_agent_cannot_authenticate():
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
from central.app.auth import authenticate, token_hash
|
||||
from central.app.models import Agent, Base, Tenant
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as s:
|
||||
tenant = Tenant(name="t"); s.add(tenant); s.flush()
|
||||
agent = Agent(tenant_id=tenant.id, name="a", token_hash=token_hash("secret-token")); s.add(agent); s.commit()
|
||||
assert authenticate(s, "secret-token").id == agent.id
|
||||
agent.enabled = False; s.commit()
|
||||
assert authenticate(s, "secret-token") is None
|
||||
assert authenticate(s, "wrong") is None
|
||||
@@ -0,0 +1,62 @@
|
||||
from datetime import datetime, timezone
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from central.app.datasets import bootstrap_v1_extra as parsers
|
||||
from central.app.datasets.service import DatasetService
|
||||
from central.app.models import Base, RtcCstIbsCbsEntry
|
||||
|
||||
ROOT = Path(__file__).parents[2] / "sources" / "raw" / "bootstrap_v1"
|
||||
CCLASS = ROOT / "cClassTrib 2026-06-22.xlsx"
|
||||
CBS = ROOT / "IT 2026.002 v.1.00 - Aliquotas da CBS.xlsx"
|
||||
pytestmark = pytest.mark.skipif(not (CCLASS.exists() and CBS.exists()), reason="RTC bootstrap files not present locally")
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def _cclass():
|
||||
return parsers.parse_rtc_cclasstrib(CCLASS.read_bytes())
|
||||
|
||||
|
||||
def _session():
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
return Session(engine)
|
||||
|
||||
|
||||
def test_cclasstrib_keys_unique_and_rich_fields_kept():
|
||||
p = _cclass()
|
||||
codes = [e["cclasstrib"] for e in p.entries]
|
||||
assert len(codes) == len(set(codes)) >= 100
|
||||
assert all(len(c) == 6 for c in codes)
|
||||
assert p.entries[0]["indicators"] and p.entries[0]["d_ini_vig"]
|
||||
assert {e["cst"] for e in p.entries} <= {e["cst"] for e in p.extra_entries}
|
||||
|
||||
|
||||
def test_cbs_rates_2026_numeric_and_2027_pending():
|
||||
p = parsers.parse_rtc_cbs_rates(CBS.read_bytes())
|
||||
by_year = {e["year"]: e for e in p.entries}
|
||||
assert by_year[2026]["rate"] == pytest.approx(0.009)
|
||||
assert by_year[2027]["rate"] is None and by_year[2027]["rate_text"]
|
||||
|
||||
|
||||
def test_import_idempotent_lookup_and_versions():
|
||||
with _session() as s:
|
||||
svc = DatasetService(s)
|
||||
assert svc.dataset_versions()["rtc_cclasstrib"] is None
|
||||
now = datetime.now(timezone.utc)
|
||||
a = svc.import_rtc_cclasstrib(_cclass(), "file://x", now)
|
||||
b = svc.import_rtc_cclasstrib(_cclass(), "file://x", now)
|
||||
assert a.id == b.id and b.status == "ACTIVE" and b.record_count == len(_cclass().entries)
|
||||
assert s.query(RtcCstIbsCbsEntry).count() == len(_cclass().extra_entries)
|
||||
assert svc.rtc_cclasstrib(code="000001")
|
||||
|
||||
cbs_payload = parsers.parse_rtc_cbs_rates(CBS.read_bytes())
|
||||
c = svc.import_rtc_cbs_rates(cbs_payload, "file://y", now)
|
||||
assert svc.import_rtc_cbs_rates(cbs_payload, "file://y", now).id == c.id
|
||||
assert svc.rtc_cbs_rate(2026).rate == pytest.approx(0.009)
|
||||
v = svc.dataset_versions()
|
||||
assert v["rtc_cclasstrib"] and v["rtc_cbs_rates"]
|
||||
@@ -0,0 +1,29 @@
|
||||
from io import BytesIO
|
||||
|
||||
import openpyxl
|
||||
|
||||
|
||||
def test_tipi_parser_requires_official_columns_and_reads_ncm_rows():
|
||||
from central.app.datasets.tipi import parse_tipi_xlsx
|
||||
|
||||
workbook = openpyxl.Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.append(["NCM", "EX", "DESCRIÇÃO", "ALÍQUOTA (%)"])
|
||||
sheet.append(["0101.21.00", None, "Reprodutores", "0"])
|
||||
raw = BytesIO(); workbook.save(raw)
|
||||
|
||||
payload = parse_tipi_xlsx(raw.getvalue())
|
||||
|
||||
assert payload.entries[0].ncm == "01012100"
|
||||
assert payload.entries[0].rate == "0"
|
||||
|
||||
|
||||
def test_tipi_parser_preserves_ex_in_the_ncm_key():
|
||||
from central.app.datasets.tipi import parse_tipi_xlsx
|
||||
workbook = openpyxl.Workbook(); sheet = workbook.active
|
||||
sheet.append(["NCM", "EX", "DESCRIÇÃO", "ALÍQUOTA (%)"])
|
||||
sheet.append(["0101.21.00", "01", "Com EX", "NT"])
|
||||
raw = BytesIO(); workbook.save(raw)
|
||||
entry = parse_tipi_xlsx(raw.getvalue()).entries[0]
|
||||
assert entry.ex == "01"
|
||||
assert entry.rate == "NT"
|
||||
Reference in New Issue
Block a user