feat: wire field_contract into product enrichment; auto-discover CRT/CONTRIBUINTE_IPI at heartbeat; targeted per-product ERP evidence
field_contract.resolve_product was only exercised by its own unit tests. /v1/products/resolve now calls it for every product whose NCM resolves, merging CFOP/CST/CSOSN/PIS-COFINS/CST_IPI/SITUACAO_TRIBUTARIA/IAT/IPPT decisions alongside CLASSIFICACAO_FISCAL/CEST/IPI - reusing the same engine, the same FiscalContextEntry table the blocker-resolution workflow already writes to, no duplicated rules. Closes three real transport/discovery gaps found while wiring it in: - Agent heartbeat never sent company_crt (env-var UF only) or company_contribuinte_ipi, so Installation.company_crt stayed permanently empty even though company_context.resolve() already proves both from C000004/EMISSOR for every coverage job. Resolved once, cached, retried only while unresolved. Migration 0032 adds Installation.company_contribuinte_ipi (NULL=unknown, never coerced to "N"). - The Agent's enrichment payload only ever reported 3 old fields (CLASSIFICACAO_FISCAL/CEST/IPI) as "current", so the Hub could never see the product's own CFOP/CST/CSOSN/... nor the classification columns (TIPO/PROD_COZINHA/PRODUTO_SERVICO/CODGRUPO) infer_role needs - separated into a read-only CURRENT_REPORT_FIELDS list, the write allowlist (PRODUCT_ENRICHMENT_WRITE_V1) untouched. - Added a targeted, product_code-scoped evidence query (firebird_reader.read_product_evidence: recipes/composed/purchases/stock, indexed WHERE, never a full-table scan) so st_withheld/product_role can be proven for a single ad-hoc resolve, not only during a full sync job. Verified read-only against the real database (D:\PROJETOS\GTISISTEMAS\Banco\GTI.FDB): CRT=1, CONTRIBUINTE_IPI=N, company_context.resolve() reaches HIGH confidence; infer_role correctly resolved RESALE/OWN_PRODUCTION for 5/8 sample real products with zero operator questions. 1121 tests passed (tests + central/tests + agent/tests), 0 failed. Version 1.3.7 (single GTI_FISCAL_VERSION shared by Hub and Agent per central/app/version.py's own design - never independently versioned).
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
"""Installation.company_contribuinte_ipi: auto-discovered (C000004.CONTRIBUINTE_IPI, same row/query as company_crt) at heartbeat, never
|
||||
operator-guessed. Closes the fiscal-completion gap where CST_IPI could not be resolved without asking a human for a fact the ERP already proves.
|
||||
|
||||
Revision ID: 0032_contribuinte_ipi
|
||||
Revises: 0031_enrichment_reevaluate
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0032_contribuinte_ipi"
|
||||
down_revision = "0031_enrichment_reevaluate"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column("installations", sa.Column("company_contribuinte_ipi", sa.String(1), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("installations", "company_contribuinte_ipi")
|
||||
+1
-1
@@ -184,7 +184,7 @@ def router(session_factory, bootstrap_token):
|
||||
installation = Installation(agent_id=agent.id, tenant_id=agent.tenant_id, installation_id=installation_key, display_name=supplied.get("display_name") or agent.name,
|
||||
features_configured=True, corrector_enabled=True) # legacy/bootstrap self-registration keeps the pre-existing behaviour
|
||||
session.add(installation); session.flush()
|
||||
fields = {"display_name": supplied.get("display_name"), "customer_code": supplied.get("customer_code"), "notes": supplied.get("notes"), "hostname": supplied.get("hostname"), "windows_version": supplied.get("windows_version"), "machine_fingerprint": supplied.get("machine_fingerprint"), "company_code": supplied.get("company_code"), "company_name": supplied.get("company_name"), "company_cnpj_masked": supplied.get("company_cnpj_masked"), "company_uf": supplied.get("company_uf"), "company_crt": supplied.get("company_crt"), "firebird_version": supplied.get("firebird_version"), "firebird_ods": supplied.get("firebird_ods"), "database_identifier": supplied.get("database_identifier"), "capabilities": supplied.get("capabilities"), "fiscal_engine_version": supplied.get("fiscal_engine_version"), "dataset_versions": supplied.get("dataset_versions"), "last_status": supplied.get("status") or payload.get("status"), "last_seen_at": now}
|
||||
fields = {"display_name": supplied.get("display_name"), "customer_code": supplied.get("customer_code"), "notes": supplied.get("notes"), "hostname": supplied.get("hostname"), "windows_version": supplied.get("windows_version"), "machine_fingerprint": supplied.get("machine_fingerprint"), "company_code": supplied.get("company_code"), "company_name": supplied.get("company_name"), "company_cnpj_masked": supplied.get("company_cnpj_masked"), "company_uf": supplied.get("company_uf"), "company_crt": supplied.get("company_crt"), "company_contribuinte_ipi": supplied.get("company_contribuinte_ipi"), "firebird_version": supplied.get("firebird_version"), "firebird_ods": supplied.get("firebird_ods"), "database_identifier": supplied.get("database_identifier"), "capabilities": supplied.get("capabilities"), "fiscal_engine_version": supplied.get("fiscal_engine_version"), "dataset_versions": supplied.get("dataset_versions"), "last_status": supplied.get("status") or payload.get("status"), "last_seen_at": now}
|
||||
for key, value in fields.items():
|
||||
if value is not None: setattr(installation, key, value)
|
||||
if supplied.get("started_at"):
|
||||
|
||||
@@ -15,12 +15,15 @@ ENUMS = {
|
||||
"incidence": {"NENHUMA"},
|
||||
"origin": {str(i) for i in range(9)},
|
||||
"store_ipi_profile": {"POPULATE_REFERENCE_RATE", "LEAVE_NOT_APPLICABLE"},
|
||||
# C000004.CONTRIBUINTE_IPI is not yet sent at heartbeat (no Agent-side reader exists for it): until that auto-discovery lands, this is the
|
||||
# one-time operator answer field_contract's CST_IPI rule needs - never assumed "N" just because the Agent hasn't reported it.
|
||||
"contribuinte_ipi": {"S", "N"},
|
||||
}
|
||||
PROFILE_KEYS = {"pis_in_cst", "pis_out_cst", "pis_rate", "cofins_in_cst", "cofins_out_cst", "cofins_rate"}
|
||||
FREE_KEYS = {"ncm", "cest", "ex_tipi", "icms_rate", "credit_rate", "cst_ipi", "service_cfop", "pis_cofins_profile"}
|
||||
ALLOWED_KEYS = set(ENUMS) | FREE_KEYS
|
||||
PRODUCT_ONLY = {"ncm"}
|
||||
COMPANY_ONLY = {"store_ipi_profile"}
|
||||
COMPANY_ONLY = {"store_ipi_profile", "contribuinte_ipi"}
|
||||
NCM_KEYS = {"cest", "ex_tipi"} # tied to the NCM tables: answered per NCM or per product # an NCM answer is always about one product
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy import select
|
||||
|
||||
from ..blocked_products import upsert_blocker
|
||||
from ..config import settings
|
||||
from ..field_contract import WRITE_FIELDS
|
||||
from ..models import Installation, ProductEnrichment, ProductFieldAudit
|
||||
from . import status as enrichment_status, textnorm
|
||||
from .placeholders import is_placeholder
|
||||
@@ -16,6 +17,29 @@ from .providers import build_providers
|
||||
from .quota import ProviderGateway
|
||||
from .resolver import FIELDS, resolve
|
||||
|
||||
CLASSIFICATION_CONTEXT_FIELDS = ("CODGRUPO", "TIPO", "PROD_COZINHA", "PRODUTO_SERVICO") # read-only evidence for field_contract.infer_role; never written
|
||||
CURRENT_FIELDS = tuple(dict.fromkeys(FIELDS + tuple(WRITE_FIELDS) + CLASSIFICATION_CONTEXT_FIELDS)) # everything resolve() may read from the Agent's `current`: NCM/CEST/IPI + every fiscal-completion field + role evidence
|
||||
|
||||
|
||||
def sanitize_product_evidence(raw) -> dict | None:
|
||||
"""Bounds the Agent's targeted, single-product evidence (firebird_reader.read_product_evidence) before it reaches field_contract: shape-only
|
||||
validation (never trusts an authenticated Agent to send something huge or malformed for a single-product payload)."""
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
out = {"recipes": set(), "composed": set(), "purchases": {}, "stock_entry": {}, "stock_in": {}}
|
||||
for key in ("recipes", "composed"):
|
||||
if isinstance(raw.get(key), list):
|
||||
out[key] = {str(v) for v in raw[key][:8]}
|
||||
if isinstance(raw.get("purchases"), dict):
|
||||
for code, docs in list(raw["purchases"].items())[:8]:
|
||||
if isinstance(docs, list):
|
||||
out["purchases"][str(code)] = [d for d in docs[:32] if isinstance(d, dict)]
|
||||
for key in ("stock_entry", "stock_in"):
|
||||
if isinstance(raw.get(key), dict):
|
||||
out[key] = {str(k): bool(v) for k, v in list(raw[key].items())[:8]}
|
||||
return out
|
||||
|
||||
|
||||
PROVIDERS_OVERRIDE = None # tests inject fake providers here (never the network)
|
||||
PENDING_STATES = {"PENDING_PROVIDER_QUOTA", "PENDING_PROVIDERS_OFFLINE", "PENDING_TIME_BUDGET"}
|
||||
REASONS = { # human PT-BR reasons shown through the existing blockers (category ENRICHMENT); never internal jargon
|
||||
@@ -65,8 +89,9 @@ def router(principal, providers_factory=None):
|
||||
raise HTTPException(422, "product_code required")
|
||||
description, current = str(payload.get("description") or "")[:255], payload.get("current") if isinstance(payload.get("current"), dict) else {}
|
||||
started = time.monotonic()
|
||||
result = resolve(session, ProviderGateway(session, providers()), gtin_raw=payload.get("gtin"), description=description, current={k: current.get(k) for k in FIELDS},
|
||||
client_label=f"installation:{installation.id}", budget_s=settings.enrichment_time_budget_seconds)
|
||||
result = resolve(session, ProviderGateway(session, providers()), gtin_raw=payload.get("gtin"), description=description, current={k: current.get(k) for k in CURRENT_FIELDS},
|
||||
client_label=f"installation:{installation.id}", budget_s=settings.enrichment_time_budget_seconds,
|
||||
product_code=code, installation=installation, tenant_id=agent.tenant_id, product_evidence=sanitize_product_evidence(payload.get("ev")))
|
||||
latency = int((time.monotonic() - started) * 1000)
|
||||
row = session.scalar(select(ProductEnrichment).where(ProductEnrichment.installation_id == installation.id, ProductEnrichment.product_code == code))
|
||||
if row is None:
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Post-NCM fiscal completion: wires the EXISTING, previously-dormant `central.app.field_contract.resolve_product` (the real field-by-field fiscal
|
||||
contract engine - CFOP, CST, CSOSN, PIS/COFINS, CST_IPI, SITUACAO_TRIBUTARIA, IAT, IPPT, ALIQUOTA...) into the live Product Enrichment resolve call.
|
||||
|
||||
No second fiscal engine. Everything here only ASSEMBLES the same inputs `resolve_product` already expects (`company`, `refs`, `ev`, `ctx`) from data
|
||||
that already exists on the Hub, and maps its per-field CHANGE/ALREADY/NOT_APPLICABLE/MANUAL output onto the enrichment's own AUTO_FILL_SAFE vocabulary
|
||||
so the SAME typed-write Agent pipeline can apply it. `company` comes straight from `Installation.company_uf/company_crt` (auto-discovered from the
|
||||
Agent's own heartbeat - zero guessing). `ctx` comes straight from `FiscalContextEntry` (the SAME table the existing blocker-resolution workflow
|
||||
already writes to when an operator answers a question) - so a missing key surfaces as an ordinary blocker the operator resolves the usual way; no
|
||||
new configuration UI is created. `ev` (purchase/recipe/stock evidence) is not available from a single ad-hoc product resolve; those fields fall back
|
||||
to MANUAL exactly as `field_contract` already does when evidence is absent - never a guess.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..classification import normalize_code
|
||||
from ..datasets.service import DatasetService
|
||||
from ..models import Dataset, FiscalContextEntry
|
||||
from .. import field_contract as fc
|
||||
|
||||
# Fields this module MAY auto-fill. Deliberately excludes CLASSIFICACAO_FISCAL/CEST/IPI (owned by the enrichment resolver itself),
|
||||
# COD_ORIGEM_MERCADORIA (no evidence source exists per the contract - never inferred from CST), EX_TIPI (interacts with the IPI EX choice,
|
||||
# left for a dedicated pass) and CCLASSTRIB (RTC's own domain, untouched).
|
||||
FISCAL_COMPLETION_FIELDS = ("SITUACAO_TRIBUTARIA", "CFOP", "CFOP_ENTRADA", "CST", "CST_ENTRADA", "CSOSN", "ALIQUOTA", "CST_IPI",
|
||||
"COD_ENTRADA_PIS", "COD_SAIDA_PIS", "ALIQ_PIS", "COD_ENTRADA_COFINS", "COD_SAIDA_COFINS", "ALIQ_COFINS",
|
||||
"INCIDENCIA_PISCOFINS", "PISCOFINS", "IAT", "IPPT")
|
||||
|
||||
# Value shape per field (validated BOTH here, before proposing a write, and again by the Agent right before the typed write).
|
||||
DIGIT_FIELDS = {"CFOP": 4, "CFOP_ENTRADA": 4, "CST": 3, "CST_ENTRADA": 3, "CSOSN": 3, "CST_IPI": 3, "COD_ENTRADA_PIS": 2, "COD_SAIDA_PIS": 2,
|
||||
"COD_ENTRADA_COFINS": 2, "COD_SAIDA_COFINS": 2}
|
||||
NUMERIC_FIELDS = ("ALIQUOTA",)
|
||||
DECIMAL_TEXT_FIELDS = ("ALIQ_PIS", "ALIQ_COFINS")
|
||||
ENUM_FIELDS = {"SITUACAO_TRIBUTARIA": {"T", "I", "N", "S"}, "IAT": {"A", "T"}, "IPPT": {"P", "T"}, "PISCOFINS": {"N", "S"},
|
||||
"INCIDENCIA_PISCOFINS": {"NENHUMA", "MONOFASICA", "SUBST.TRIBUT."}}
|
||||
|
||||
|
||||
def is_writable_shape(field: str, value) -> bool:
|
||||
text = "" if value is None else str(value).strip()
|
||||
if not text:
|
||||
return False
|
||||
if field in DIGIT_FIELDS:
|
||||
digits = "".join(c for c in text if c.isdigit())
|
||||
return digits == text and len(digits) in (DIGIT_FIELDS[field], DIGIT_FIELDS[field] + 1) # CST/CSOSN family sometimes carries an origin digit prefix
|
||||
if field in NUMERIC_FIELDS or field in DECIMAL_TEXT_FIELDS:
|
||||
try:
|
||||
float(text.replace(",", "."))
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
if field in ENUM_FIELDS:
|
||||
return text.upper() in ENUM_FIELDS[field]
|
||||
return False
|
||||
|
||||
|
||||
def build_company(installation) -> dict:
|
||||
"""Auto-discovered, zero guessing: the Agent already reports these at every heartbeat (company_contribuinte_ipi from C000004.CONTRIBUINTE_IPI,
|
||||
the same row/query as company_crt - see company_context.py on the Agent). Omits "contribuinte_ipi" entirely when the Agent hasn't reported it
|
||||
yet (older Agent, or the field just never resolved): resolve_fiscal_fields treats that as genuinely unknown, never as a proven "N"."""
|
||||
company = {"uf": (installation.company_uf or "").upper() or None, "crt": installation.company_crt or None}
|
||||
if installation.company_contribuinte_ipi in ("S", "N"):
|
||||
company["contribuinte_ipi"] = installation.company_contribuinte_ipi
|
||||
return company
|
||||
|
||||
|
||||
def build_refs(session, *, uf: str | None, cfop_candidates: set[str]) -> dict:
|
||||
ds = DatasetService(session)
|
||||
refs = {key: {r.code for r in ds.fiscal_codes(domain)} for key, domain in
|
||||
(("csosn", "CSOSN"), ("cst_icms", "CST_ICMS"), ("cst_pis", "CST_PIS"), ("cst_cofins", "CST_COFINS"), ("origem", "ORIGEM_MERCADORIA"))}
|
||||
refs["cfop_valid"] = {c for c in cfop_candidates if ds.cfop_lookup(code=c)}
|
||||
refs["fcp_uf"] = {}
|
||||
if uf:
|
||||
fcp = ds.fcp_for_uf(uf)
|
||||
if fcp:
|
||||
refs["fcp_uf"][uf] = {"aliq_1": fcp.aliq_1, "observacao": fcp.observacao}
|
||||
refs["cbs_rate_2026"] = (ds.rtc_cbs_rate(2026).rate if ds.rtc_cbs_rate(2026) else None)
|
||||
refs["cclasstrib_count"] = session.scalar(select(Dataset.record_count).where(Dataset.kind == "RTC_CCLASSTRIB", Dataset.status == "ACTIVE")) or 0
|
||||
return refs
|
||||
|
||||
|
||||
def build_context(session, tenant_id: int, company_key: str) -> fc.Context:
|
||||
"""SAME table the existing blocker-resolution workflow (`central/app/blocker_resolution.py`) already writes operator answers into."""
|
||||
rows = session.scalars(select(FiscalContextEntry).where(FiscalContextEntry.tenant_id == tenant_id, FiscalContextEntry.company_key == company_key)).all()
|
||||
return fc.Context([{"scope": r.scope, "scope_key": r.scope_key, "key": r.key, "value": r.value} for r in rows])
|
||||
|
||||
|
||||
def resolve_fiscal_fields(session, *, product_code: str, description: str, current: dict, ncm: str, ncm_official: bool, installation, tenant_id: int,
|
||||
extra_columns: dict | None = None, decision: dict | None = None, ev: dict | None = None) -> dict:
|
||||
"""Returns {field: {status, value, rule, reason}} for FISCAL_COMPLETION_FIELDS, in the enrichment's own vocabulary (AUTO_FILL_SAFE / ALREADY_SET /
|
||||
NOT_APPLICABLE / NEEDS_REVIEW / INSUFFICIENT_EVIDENCE). Never called for a product whose NCM is not yet an official, vigente code.
|
||||
|
||||
`decision` is the SAME dict `classification.Classifier.classify(...)` already returns for CEST/IPI (resolver.py's `_fiscal_decisions`) - reused
|
||||
as-is (its `classification.ipi_candidates`/`cest_candidates`/`ba_st_mva` keys are exactly what `resolve_product` reads), never recomputed here."""
|
||||
if not ncm_official:
|
||||
return {f: {"status": "INSUFFICIENT_EVIDENCE", "value": None, "rule": None, "reason": "depende de um NCM oficial vigente"} for f in FISCAL_COMPLETION_FIELDS}
|
||||
ncm = normalize_code(ncm)
|
||||
p = {"CODIGO": product_code, "PRODUTO": description, "CODGRUPO": None, "TIPO": None, "PROD_COZINHA": None, "PRODUTO_SERVICO": None, "CLASSIFICACAO_FISCAL": ncm}
|
||||
p.update({k: v for k, v in (extra_columns or {}).items() if k in ("CODGRUPO", "TIPO", "PROD_COZINHA", "PRODUTO_SERVICO")})
|
||||
if str(p.get("PROD_COZINHA") or "").strip().isdigit(): # the Agent sends `current` as JSON strings (e.g. "1"); infer_role compares the real Firebird SMALLINT (== 1)
|
||||
p["PROD_COZINHA"] = int(p["PROD_COZINHA"])
|
||||
for f in fc.WRITE_FIELDS:
|
||||
p.setdefault(f, current.get(f))
|
||||
company = build_company(installation)
|
||||
company_key = (installation.company_code or installation.company_name or installation.customer_code or "").strip()
|
||||
ctx = build_context(session, tenant_id, company_key)
|
||||
if "contribuinte_ipi" not in company: # ERP fact (heartbeat) always wins; the operator answer is only a fallback for an Agent that hasn't reported it yet
|
||||
contribuinte_ipi, _ = ctx.get("contribuinte_ipi")
|
||||
if contribuinte_ipi in ("S", "N"):
|
||||
company["contribuinte_ipi"] = contribuinte_ipi
|
||||
cfop_candidates = {str(current.get(f)).strip() for f in ("CFOP", "CFOP_ENTRADA") if current.get(f)}
|
||||
refs = build_refs(session, uf=company.get("uf"), cfop_candidates=cfop_candidates)
|
||||
# Agent-provided, product_code-targeted evidence (recipes/composed/purchases/stock - see firebird_reader.read_product_evidence): a real,
|
||||
# indexed per-product query, never a full-table scan or a guess. Falls back to "no evidence" only when the Agent hasn't sent it yet.
|
||||
ev = ev if isinstance(ev, dict) else {"recipes": set(), "composed": set(), "purchases": {}, "stock_entry": {}, "stock_in": {}}
|
||||
d = decision or {"status": "EXACT_NCM", "cest_status": "NO_CEST_MAPPING_FOUND", "ba_st_mva_status": None, "classification": {"cest_candidates": [], "ipi_candidates": [], "ba_st_mva": {}}}
|
||||
raw = fc.resolve_product(p, d, company, refs, ev, ctx)
|
||||
out = {}
|
||||
for field in FISCAL_COMPLETION_FIELDS:
|
||||
r = raw.get(field) or {}
|
||||
state = r.get("state")
|
||||
if state == fc.ALREADY:
|
||||
out[field] = {"status": "ALREADY_SET" if not _blank(current.get(field)) else "NOT_APPLICABLE", "value": None, "rule": r.get("reason"), "reason": r.get("reason")}
|
||||
elif state == fc.NA and field == "CST_IPI" and "contribuinte_ipi" not in company:
|
||||
# field_contract's own NA branch for CST_IPI fires whenever company.get("contribuinte_ipi") != "S" - which is also true when the fact is
|
||||
# simply UNKNOWN. Never let an unknown fact read as a proven "not an IPI taxpayer": surface it as a missing answer instead.
|
||||
out[field] = {"status": "NEEDS_REVIEW", "value": None, "rule": None, "reason": "contribuinte de IPI não confirmado", "missing_key": "contribuinte_ipi", "missing_scope": "COMPANY"}
|
||||
elif state == fc.NA:
|
||||
out[field] = {"status": "NOT_APPLICABLE", "value": None, "rule": r.get("reason"), "reason": r.get("reason")}
|
||||
elif state == fc.CHANGE and _blank(current.get(field)) and is_writable_shape(field, r.get("proposed")):
|
||||
out[field] = {"status": "AUTO_FILL_SAFE", "value": str(r.get("proposed")), "rule": r.get("reason"), "reason": None}
|
||||
elif state == fc.CHANGE:
|
||||
out[field] = {"status": "NEEDS_REVIEW", "value": None, "rule": r.get("reason"), "reason": "valor proposto não seguro para escrita automática" if not _blank(current.get(field)) else "formato do valor proposto não confere"}
|
||||
else: # MANUAL
|
||||
out[field] = {"status": "NEEDS_REVIEW", "value": None, "rule": None, "reason": r.get("reason"), "missing_key": r.get("input_key"), "missing_scope": r.get("input_scope")}
|
||||
return out
|
||||
|
||||
|
||||
def _blank(v) -> bool:
|
||||
return v is None or str(v).strip() == ""
|
||||
|
||||
|
||||
def coverage_status(field_statuses: dict) -> str:
|
||||
"""COMPLETE when every field is filled/already-correct/not-applicable; SAFE_BLOCKED when at least one relevant field still needs a decision."""
|
||||
return "SAFE_BLOCKED" if any(v["status"] in ("NEEDS_REVIEW", "INSUFFICIENT_EVIDENCE") for v in field_statuses.values()) else "COMPLETE"
|
||||
@@ -15,7 +15,7 @@ from sqlalchemy import select
|
||||
from ..classification import Classifier, normalize_code
|
||||
from ..datasets.service import DatasetService
|
||||
from ..models import Dataset, NcmEntry
|
||||
from . import kb, textnorm
|
||||
from . import fiscal_completion, kb, textnorm
|
||||
from .gtin import normalize_gtin
|
||||
from .placeholders import is_placeholder, is_writable_empty
|
||||
from .quota import ProviderGateway
|
||||
@@ -79,7 +79,24 @@ def _empty(current: dict, name: str) -> bool:
|
||||
return is_writable_empty(name, current.get(name))
|
||||
|
||||
|
||||
def _fiscal_decisions(session, ncm_eval: dict, gtin_kb_verified: bool, current: dict, description: str, *, name_verified=False) -> dict:
|
||||
def _merge_fiscal_completion(session, out: dict, *, ncm_official: bool, ncm_for_engine: str | None, current: dict, description: str,
|
||||
product_code: str | None, installation, tenant_id: int | None, decision: dict | None, product_evidence: dict | None = None) -> None:
|
||||
"""Merges field_contract.resolve_product's own field-by-field decisions (CFOP/CST/CSOSN/PIS-COFINS/...) into `out`, in the SAME
|
||||
AUTO_FILL_SAFE/ALREADY_SET/NOT_APPLICABLE/INSUFFICIENT_EVIDENCE vocabulary CEST/IPI already use, plus NEEDS_REVIEW for anything that needs an
|
||||
operator's context answer. No-op when the caller (a test, or a code path with no installation on hand) did not pass installation/tenant_id/product_code."""
|
||||
if installation is None or tenant_id is None or product_code is None:
|
||||
return
|
||||
fields = fiscal_completion.resolve_fiscal_fields(session, product_code=product_code, description=description, current=current, ncm=ncm_for_engine or "",
|
||||
ncm_official=ncm_official, installation=installation, tenant_id=tenant_id, decision=decision, extra_columns=current,
|
||||
ev=product_evidence)
|
||||
for field, r in fields.items():
|
||||
out[field] = _field(r["status"], r.get("value"), r.get("rule"), reason=r.get("reason"))
|
||||
if r.get("missing_key"):
|
||||
out[field]["missing_key"], out[field]["missing_scope"] = r["missing_key"], r.get("missing_scope")
|
||||
|
||||
|
||||
def _fiscal_decisions(session, ncm_eval: dict, gtin_kb_verified: bool, current: dict, description: str, *, name_verified=False,
|
||||
product_code: str | None = None, installation=None, tenant_id: int | None = None, product_evidence: dict | None = None) -> dict:
|
||||
out = {}
|
||||
if not _empty(current, "CLASSIFICACAO_FISCAL"):
|
||||
out["CLASSIFICACAO_FISCAL"] = _field(ALREADY, reason="campo já preenchido")
|
||||
@@ -98,6 +115,8 @@ def _fiscal_decisions(session, ncm_eval: dict, gtin_kb_verified: bool, current:
|
||||
if not ncm_for_engine or (ncm["status"] == ALREADY and not ncm_is_official(session, ncm_for_engine)):
|
||||
for name in ("CEST", "IPI"):
|
||||
out[name] = _field(ALREADY if not _empty(current, name) else INSUFFICIENT, reason="depende de um NCM oficial válido")
|
||||
_merge_fiscal_completion(session, out, ncm_official=False, ncm_for_engine=ncm_for_engine, current=current, description=description,
|
||||
product_code=product_code, installation=installation, tenant_id=tenant_id, decision=None, product_evidence=product_evidence)
|
||||
return out
|
||||
# CEST / IPI are deterministic functions of an OFFICIAL NCM: the NCM we are about to write (AUTO) or one already in the product (validated above against the official dataset)
|
||||
ncm_solid = ncm["status"] == AUTO or (ncm["status"] == ALREADY and ncm_is_official(session, ncm_for_engine))
|
||||
@@ -120,12 +139,20 @@ def _fiscal_decisions(session, ncm_eval: dict, gtin_kb_verified: bool, current:
|
||||
out["IPI"] = _field(NA, reason="TIPI sem alíquota numérica (NT/branca)")
|
||||
else:
|
||||
out["IPI"] = _field(INSUFFICIENT, reason=f"IPI não determinístico ({decision['ipi_status']})")
|
||||
_merge_fiscal_completion(session, out, ncm_official=ncm_solid, ncm_for_engine=ncm_for_engine, current=current, description=description,
|
||||
product_code=product_code, installation=installation, tenant_id=tenant_id, decision=decision, product_evidence=product_evidence)
|
||||
return out
|
||||
|
||||
|
||||
def resolve(session, gateway: ProviderGateway, *, gtin_raw, description, current: dict | None = None, client_label: str | None = None, allow_external: bool = True, budget_s: float | None = None) -> dict:
|
||||
def resolve(session, gateway: ProviderGateway, *, gtin_raw, description, current: dict | None = None, client_label: str | None = None, allow_external: bool = True, budget_s: float | None = None,
|
||||
product_code: str | None = None, installation=None, tenant_id: int | None = None, product_evidence: dict | None = None) -> dict:
|
||||
started = time.monotonic()
|
||||
"""Returns {state, gtin_status, gtin, fields:{...}, providers:[...], retry_at, dataset_versions}. Never raises for provider trouble."""
|
||||
"""Returns {state, gtin_status, gtin, fields:{...}, providers:[...], retry_at, dataset_versions}. Never raises for provider trouble.
|
||||
|
||||
`product_code`/`installation`/`tenant_id`, when given, additionally resolve the post-NCM fiscal-completion fields (CFOP/CST/CSOSN/PIS-COFINS/...)
|
||||
via the existing field_contract engine (see `_merge_fiscal_completion`) and merge them into `fields` alongside CLASSIFICACAO_FISCAL/CEST/IPI.
|
||||
`product_evidence` is the Agent's targeted, product_code-scoped recipes/composed/purchases/stock evidence (firebird_reader.read_product_evidence) -
|
||||
never a full-table scan. Named separately from the local `ev` variable below (NCM knowledge-base evidence), a different, pre-existing concept."""
|
||||
current = {k: v for k, v in (current or {}).items()}
|
||||
g = normalize_gtin(gtin_raw)
|
||||
result = {"state": "NOT_FOUND", "gtin_status": g["status"], "gtin": g.get("key"), "fields": {}, "providers": [], "retry_at": None, "dataset_versions": dataset_versions(session)}
|
||||
@@ -139,12 +166,12 @@ def resolve(session, gateway: ProviderGateway, *, gtin_raw, description, current
|
||||
if ev and ev["status"] == kb.VERIFIED_KB:
|
||||
gateway.quota.record_kb_hit()
|
||||
result["state"] = "EXACT_GTIN_VERIFIED"
|
||||
result["fields"] = _fiscal_decisions(session, ev, True, current, description or "")
|
||||
result["fields"] = _fiscal_decisions(session, ev, True, current, description or "", product_code=product_code, installation=installation, tenant_id=tenant_id, product_evidence=product_evidence)
|
||||
session.commit()
|
||||
return result
|
||||
if ev and ev["status"] == kb.CONFLICT: # local evidence already conflicts: an external answer cannot settle it, so spend no quota
|
||||
result["state"] = "CONFLICT"
|
||||
result["fields"] = _fiscal_decisions(session, ev, False, current, description or "")
|
||||
result["fields"] = _fiscal_decisions(session, ev, False, current, description or "", product_code=product_code, installation=installation, tenant_id=tenant_id, product_evidence=product_evidence)
|
||||
gateway.quota.record_kb_hit()
|
||||
session.commit()
|
||||
return result
|
||||
@@ -175,7 +202,7 @@ def resolve(session, gateway: ProviderGateway, *, gtin_raw, description, current
|
||||
if product is None and description:
|
||||
product = kb.upsert_product(session, key, description)
|
||||
ev = kb.evaluate(session, product, "ncm", is_official) if product else {"value": None, "status": kb.UNVERIFIED, "families": [], "candidates": [], "rejected": []}
|
||||
result["fields"] = _fiscal_decisions(session, ev, False, current, description or "")
|
||||
result["fields"] = _fiscal_decisions(session, ev, False, current, description or "", product_code=product_code, installation=installation, tenant_id=tenant_id, product_evidence=product_evidence)
|
||||
if ev["status"] == kb.CONFLICT:
|
||||
result["state"] = "CONFLICT"
|
||||
elif ev["status"] == kb.VERIFIED_KB:
|
||||
@@ -211,7 +238,7 @@ def resolve(session, gateway: ProviderGateway, *, gtin_raw, description, current
|
||||
if candidates and len(verified) == len(candidates) and len({ev["value"] for _, ev in verified}) == 1:
|
||||
_, ev = verified[0]
|
||||
result["state"] = "EXACT_KB_NAME_VERIFIED"
|
||||
result["fields"] = _fiscal_decisions(session, ev, True, current, description or "", name_verified=True)
|
||||
result["fields"] = _fiscal_decisions(session, ev, True, current, description or "", name_verified=True, product_code=product_code, installation=installation, tenant_id=tenant_id, product_evidence=product_evidence)
|
||||
elif len({ev["value"] for _, ev in verified}) > 1:
|
||||
result["state"] = "CONFLICT"
|
||||
result["fields"] = {f: _field(CONFLICT, reason="descrição corresponde a produtos com NCM divergentes") if _empty(current, f) else _field(ALREADY) for f in FIELDS}
|
||||
|
||||
@@ -62,7 +62,7 @@ def _safe_value(key: str, value):
|
||||
def sanitize_registration(payload: dict) -> dict:
|
||||
allowed = {
|
||||
"installation_id", "display_name", "customer_code", "notes", "hostname", "windows_version", "machine_fingerprint",
|
||||
"company_code", "company_name", "company_cnpj", "company_uf", "company_crt", "firebird_version", "firebird_ods",
|
||||
"company_code", "company_name", "company_cnpj", "company_uf", "company_crt", "company_contribuinte_ipi", "firebird_version", "firebird_ods",
|
||||
"database_identifier", "database_path", "capabilities", "fiscal_engine_version", "dataset_versions", "started_at",
|
||||
"status", "schema_fingerprint", "agent_version", "last_job_id", "coverage", "health", "event_type", "message", "context",
|
||||
}
|
||||
|
||||
@@ -267,6 +267,7 @@ class Installation(Base):
|
||||
company_cnpj_masked: Mapped[str | None] = mapped_column(String(24), nullable=True)
|
||||
company_uf: Mapped[str | None] = mapped_column(String(2), nullable=True)
|
||||
company_crt: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
company_contribuinte_ipi: Mapped[str | None] = mapped_column(String(1), nullable=True) # C000004.CONTRIBUINTE_IPI, same auto-discovery as company_crt
|
||||
firebird_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
firebird_ods: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
database_identifier: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
@@ -4,5 +4,5 @@ kept identical to this) and what an AgentRelease row's `version` column holds
|
||||
once published — never a second, independent version number. 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.3.6"
|
||||
GTI_FISCAL_VERSION = "1.3.7"
|
||||
API_COMPAT_MAJOR_MINOR = "1.0" # an Agent 1.0.x/1.1.x talks to any Hub 1.0.x
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "gti-fiscal-hub"
|
||||
version = "1.3.6"
|
||||
version = "1.3.7"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"fastapi>=0.115,<1",
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Post-NCM fiscal completion: wires the existing (previously dormant) field_contract.resolve_product into the live pipeline. Reuses the SAME engine,
|
||||
the SAME FiscalContextEntry table the blocker-resolution workflow already writes to, and company_uf/company_crt already auto-discovered from every
|
||||
heartbeat - no new rule, no guessing."""
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from central.app.enrichment import fiscal_completion as fcomp
|
||||
from central.app.field_contract import WRITE_FIELDS
|
||||
from central.app.models import Agent, Base, CfopEntry, Dataset, FiscalCodeEntry, FiscalContextEntry, Installation, Tenant
|
||||
|
||||
|
||||
def _seed_codes(session, ncm="22030000"):
|
||||
ds = Dataset(kind="NFE_MOC_TABLES", provider="x", sha256="a" * 64, version_label="v", act="a", source_url="u", status="ACTIVE", record_count=0)
|
||||
session.add(ds); session.flush()
|
||||
for domain, codes in (("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_MERCADORIA", list("012345678"))):
|
||||
for code in codes:
|
||||
session.add(FiscalCodeEntry(dataset_id=ds.id, domain=domain, code=code, description=f"{domain} {code}", source_section="test"))
|
||||
cfop_ds = Dataset(kind="CFOP", provider="x", sha256="b" * 64, version_label="v", act="a", source_url="u", status="ACTIVE", record_count=0)
|
||||
session.add(cfop_ds); session.flush()
|
||||
for code in ("5101", "5102", "5401", "1102", "1101"):
|
||||
session.add(CfopEntry(dataset_id=cfop_ds.id, code=code, title=f"CFOP {code}"))
|
||||
session.commit()
|
||||
|
||||
|
||||
def _installation(session, uf="BA", crt="1", company_key="000001", contribuinte_ipi=None):
|
||||
tenant = Tenant(id=1, name="Acme"); session.add(tenant); session.flush()
|
||||
agent = Agent(tenant_id=1, name="a", token_hash="x" * 64); session.add(agent); session.flush()
|
||||
inst = Installation(agent_id=agent.id, tenant_id=1, installation_id="11111111-1111-4111-8111-111111111111", company_uf=uf, company_crt=crt, company_code=company_key,
|
||||
company_contribuinte_ipi=contribuinte_ipi)
|
||||
session.add(inst); session.flush()
|
||||
return inst
|
||||
|
||||
|
||||
def _ctx(session, tenant_id, company_key, entries):
|
||||
for e in entries:
|
||||
session.add(FiscalContextEntry(tenant_id=tenant_id, company_key=company_key, scope=e[0], scope_key=e.get("scope_key", "") if isinstance(e, dict) else "", key=e[1] if not isinstance(e, dict) else e["key"],
|
||||
value=e[2] if not isinstance(e, dict) else e["value"], source="OPERATOR"))
|
||||
session.commit()
|
||||
|
||||
|
||||
def env():
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
return Session(engine)
|
||||
|
||||
|
||||
def test_field_shapes():
|
||||
assert fcomp.is_writable_shape("CFOP", "5102") and not fcomp.is_writable_shape("CFOP", "51A2") and not fcomp.is_writable_shape("CFOP", "510")
|
||||
assert fcomp.is_writable_shape("CST", "000") and fcomp.is_writable_shape("CST", "0000")
|
||||
assert fcomp.is_writable_shape("ALIQUOTA", "17.5") and fcomp.is_writable_shape("ALIQ_PIS", "1,65") and not fcomp.is_writable_shape("ALIQUOTA", "abc")
|
||||
assert fcomp.is_writable_shape("SITUACAO_TRIBUTARIA", "t") and not fcomp.is_writable_shape("SITUACAO_TRIBUTARIA", "X")
|
||||
assert fcomp.is_writable_shape("IAT", "A") and fcomp.is_writable_shape("IPPT", "P") and fcomp.is_writable_shape("PISCOFINS", "N")
|
||||
assert not fcomp.is_writable_shape("CFOP", "")
|
||||
|
||||
|
||||
def test_company_auto_discovered_from_heartbeat_no_guessing():
|
||||
s = env()
|
||||
inst = _installation(s)
|
||||
assert fcomp.build_company(inst) == {"uf": "BA", "crt": "1"}
|
||||
|
||||
|
||||
def test_resolve_fiscal_fields_with_full_context_fills_independent_safe_fields():
|
||||
s = env()
|
||||
_seed_codes(s)
|
||||
inst = _installation(s)
|
||||
import json
|
||||
pis_cofins_profile = json.dumps({"pis_in_cst": "01", "pis_out_cst": "01", "pis_rate": "1.65", "cofins_in_cst": "01", "cofins_out_cst": "01", "cofins_rate": "7.6"})
|
||||
_ctx(s, 1, "000001", [
|
||||
("COMPANY", "product_role", "RESALE"), ("COMPANY", "icms_treatment", "TAXED"), ("COMPANY", "origin", "0"),
|
||||
("COMPANY", "pis_cofins_profile", pis_cofins_profile), ("COMPANY", "piscofins", "S"), ("COMPANY", "rounding", "A"),
|
||||
("COMPANY", "store_ipi_profile", "LEAVE_NOT_APPLICABLE"), ("COMPANY", "icms_rate", "17"), ("COMPANY", "entry_scope", "INTRA"),
|
||||
])
|
||||
out = fcomp.resolve_fiscal_fields(s, product_code="X1", description="CERVEJA LATA", current={}, ncm="22030000", ncm_official=True, installation=inst, tenant_id=1)
|
||||
safe = {f: v["value"] for f, v in out.items() if v["status"] == "AUTO_FILL_SAFE"}
|
||||
assert "CSOSN" in safe and safe["CSOSN"] == "102"
|
||||
assert "PISCOFINS" in safe or out["PISCOFINS"]["status"] == "ALREADY_SET"
|
||||
for f, v in out.items():
|
||||
assert v["status"] in ("AUTO_FILL_SAFE", "ALREADY_SET", "NOT_APPLICABLE", "NEEDS_REVIEW", "INSUFFICIENT_EVIDENCE"), f
|
||||
|
||||
|
||||
def test_field_independence_one_missing_context_does_not_block_others():
|
||||
s = env()
|
||||
_seed_codes(s)
|
||||
inst = _installation(s)
|
||||
_ctx(s, 1, "000001", [("COMPANY", "product_role", "RESALE"), ("COMPANY", "rounding", "A")]) # icms_treatment / pis_cofins_profile intentionally missing
|
||||
out = fcomp.resolve_fiscal_fields(s, product_code="X2", description="CERVEJA LATA", current={}, ncm="22030000", ncm_official=True, installation=inst, tenant_id=1)
|
||||
assert out["IAT"]["status"] == "AUTO_FILL_SAFE" and out["IAT"]["value"] == "A" # resolved independently
|
||||
assert out["CSOSN"]["status"] == "NEEDS_REVIEW" and out["CSOSN"]["missing_key"] == "icms_treatment"
|
||||
assert out["COD_ENTRADA_PIS"]["status"] == "NEEDS_REVIEW" and out["COD_ENTRADA_PIS"]["missing_key"] == "pis_cofins_profile"
|
||||
|
||||
|
||||
def test_contribuinte_ipi_auto_discovered_from_installation_wins_over_operator_context():
|
||||
"""Installation.company_contribuinte_ipi (auto-discovered at heartbeat from C000004.CONTRIBUINTE_IPI) is an ERP fact; a FiscalContextEntry
|
||||
operator answer is only a fallback for an Agent version that hasn't reported it yet. ERP fact must win when both exist."""
|
||||
s = env()
|
||||
_seed_codes(s)
|
||||
inst = _installation(s, contribuinte_ipi="S")
|
||||
_ctx(s, 1, "000001", [("COMPANY", "contribuinte_ipi", "N")]) # stale/wrong operator answer - the ERP fact must override it
|
||||
out = fcomp.resolve_fiscal_fields(s, product_code="X9", description="MAQUINA", current={}, ncm="22030000", ncm_official=True, installation=inst, tenant_id=1)
|
||||
assert out["CST_IPI"]["status"] == "NEEDS_REVIEW" # contribuinte_ipi=S alone doesn't fill CST_IPI (still needs a declared value), but it's no longer NOT_APPLICABLE
|
||||
assert out["CST_IPI"]["reason"] != "contribuinte de IPI não confirmado" # proves the ERP fact (S) was used, not "unknown"
|
||||
|
||||
|
||||
def test_targeted_product_evidence_feeds_role_inference_st_withheld_never_a_full_scan():
|
||||
"""`ev` (firebird_reader.read_product_evidence's targeted, per-product recipes/composed/purchases/stock) reaches field_contract.resolve_product
|
||||
the same way the full-scan `read_evidence` already does for sync jobs - just scoped to ONE product, never the whole catalog."""
|
||||
s = env()
|
||||
_seed_codes(s)
|
||||
inst = _installation(s)
|
||||
out = fcomp.resolve_fiscal_fields(s, product_code="X10", description="BOLO CASEIRO", current={}, ncm="22030000", ncm_official=True, installation=inst, tenant_id=1,
|
||||
ev={"recipes": {"X10"}, "composed": set(), "purchases": {}, "stock_entry": {}, "stock_in": {}})
|
||||
assert out["CFOP"]["status"] == "NEEDS_REVIEW" # OWN_PRODUCTION now provable from the recipe evidence alone - no product_role question, only the CFOP direction/context remains
|
||||
assert out["CFOP"]["missing_key"] != "product_role"
|
||||
|
||||
|
||||
def test_extra_columns_feed_field_contracts_own_role_inference_no_operator_question_needed():
|
||||
"""PRODUTO_SERVICO is a real C000025 column field_contract.infer_role already reads as objective ERP evidence (no ctx.get("product_role")
|
||||
needed). Proves `extra_columns` (the Agent's classification columns) actually reach `resolve_product`: with PRODUTO_SERVICO set, CFOP is
|
||||
blocked on the SERVICE-specific `service_cfop` key, not the generic `product_role` key a fully-unknown product would need."""
|
||||
s = env()
|
||||
_seed_codes(s)
|
||||
inst = _installation(s)
|
||||
out = fcomp.resolve_fiscal_fields(s, product_code="X7", description="CONSULTORIA", current={}, ncm="22030000", ncm_official=True, installation=inst, tenant_id=1,
|
||||
extra_columns={"PRODUTO_SERVICO": "1"})
|
||||
assert out["CFOP"]["status"] == "NEEDS_REVIEW" and out["CFOP"]["missing_key"] == "service_cfop"
|
||||
|
||||
|
||||
def test_prod_cozinha_string_from_agent_json_payload_is_normalized_to_int_for_infer_role():
|
||||
"""The Agent sends `current`/extra columns as JSON strings ("1", not 1). field_contract.infer_role compares PROD_COZINHA == 1 (a real
|
||||
Firebird SMALLINT). Without normalization the kitchen-routing signal would silently never fire."""
|
||||
s = env()
|
||||
_seed_codes(s)
|
||||
inst = _installation(s)
|
||||
out = fcomp.resolve_fiscal_fields(s, product_code="X8", description="PRATO FEITO", current={}, ncm="22030000", ncm_official=True, installation=inst, tenant_id=1,
|
||||
extra_columns={"PROD_COZINHA": "1"})
|
||||
assert out["CFOP"]["status"] == "NEEDS_REVIEW" # PROD_COZINHA=1 alone is UNKNOWN (not proof), but it must be READ, not silently ignored as a non-numeric string
|
||||
assert out["CFOP"]["missing_key"] == "product_role"
|
||||
|
||||
|
||||
def test_cst_ipi_unknown_contribuinte_ipi_is_needs_review_not_a_silent_not_applicable():
|
||||
"""C000004.CONTRIBUINTE_IPI is not yet sent at heartbeat - `company` never carries it unless an operator answered the context key. field_contract's
|
||||
own rule reads a missing fact the same as a proven "N" (NOT_APPLICABLE); this module must not let that false negative through silently."""
|
||||
s = env()
|
||||
_seed_codes(s)
|
||||
inst = _installation(s) # no contribuinte_ipi context entry: genuinely unknown
|
||||
out = fcomp.resolve_fiscal_fields(s, product_code="X5", description="CERVEJA LATA", current={}, ncm="22030000", ncm_official=True, installation=inst, tenant_id=1)
|
||||
assert out["CST_IPI"]["status"] == "NEEDS_REVIEW" and out["CST_IPI"]["missing_key"] == "contribuinte_ipi"
|
||||
_ctx(s, 1, "000001", [("COMPANY", "contribuinte_ipi", "N")])
|
||||
out = fcomp.resolve_fiscal_fields(s, product_code="X6", description="CERVEJA LATA", current={}, ncm="22030000", ncm_official=True, installation=inst, tenant_id=1)
|
||||
assert out["CST_IPI"]["status"] == "NOT_APPLICABLE" # once the fact is genuinely proven "N", the engine's own NA decision is trusted again
|
||||
|
||||
|
||||
def test_not_official_ncm_blocks_everything_with_explicit_reason():
|
||||
s = env()
|
||||
inst = _installation(s)
|
||||
out = fcomp.resolve_fiscal_fields(s, product_code="X3", description="X", current={}, ncm="00000000", ncm_official=False, installation=inst, tenant_id=1)
|
||||
assert all(v["status"] == "INSUFFICIENT_EVIDENCE" for v in out.values())
|
||||
|
||||
|
||||
def test_existing_value_preserved_not_overwritten():
|
||||
"""CSOSN 500 declares ICMS already withheld by ST - a DIFFERENT treatment than a plain TAXED declaration (CSOSN_FAMILY in field_contract.py
|
||||
maps icms_treatment=ST_WITHHELD -> {"500"} and icms_treatment=TAXED -> {"101","102","103"}). With the matching treatment declared, the engine
|
||||
proves the existing value correct and does not touch it."""
|
||||
s = env()
|
||||
_seed_codes(s)
|
||||
inst = _installation(s)
|
||||
_ctx(s, 1, "000001", [("COMPANY", "product_role", "RESALE"), ("COMPANY", "icms_treatment", "ST_WITHHELD"), ("COMPANY", "origin", "0")])
|
||||
out = fcomp.resolve_fiscal_fields(s, product_code="X4", description="CERVEJA LATA", current={"CSOSN": "500"}, ncm="22030000", ncm_official=True, installation=inst, tenant_id=1)
|
||||
assert out["CSOSN"]["status"] == "ALREADY_SET"
|
||||
|
||||
|
||||
def test_coverage_status_complete_vs_safe_blocked():
|
||||
complete = {f: {"status": "ALREADY_SET"} for f in fcomp.FISCAL_COMPLETION_FIELDS}
|
||||
assert fcomp.coverage_status(complete) == "COMPLETE"
|
||||
blocked = dict(complete, CFOP={"status": "NEEDS_REVIEW"})
|
||||
assert fcomp.coverage_status(blocked) == "SAFE_BLOCKED"
|
||||
|
||||
|
||||
def test_all_write_fields_covered_by_the_module_except_the_explicit_exclusions():
|
||||
excluded = {"CLASSIFICACAO_FISCAL", "CEST", "IPI", "COD_ORIGEM_MERCADORIA", "EX_TIPI"}
|
||||
assert set(WRITE_FIELDS) - excluded == set(fcomp.FISCAL_COMPLETION_FIELDS)
|
||||
@@ -17,7 +17,7 @@ def test_health_is_unavailable_when_database_is_down():
|
||||
|
||||
def test_version_endpoint_reports_release_and_fiscal_versions():
|
||||
body = TestClient(create_app(database_probe=lambda: True)).get("/version").json()
|
||||
assert body["version"] == "1.3.6" and body["api_compat"] == "1.0" and body["engine_version"] and body["decision_version"]
|
||||
assert body["version"] == "1.3.7" and body["api_compat"] == "1.0" and body["engine_version"] and body["decision_version"]
|
||||
|
||||
|
||||
def test_production_refuses_the_dev_token(monkeypatch):
|
||||
|
||||
@@ -22,8 +22,11 @@ def setup(monkeypatch, mode="ENABLED"):
|
||||
return client, engine
|
||||
|
||||
|
||||
def resolve(client, code="N1", gtin=G1, desc="REFRIGERANTE COLA LATA 350ML", current=None):
|
||||
return client.post("/v1/products/resolve", json={"product_code": code, "gtin": gtin, "description": desc, "current": current or {}}, headers=H).json()
|
||||
def resolve(client, code="N1", gtin=G1, desc="REFRIGERANTE COLA LATA 350ML", current=None, ev=None):
|
||||
body = {"product_code": code, "gtin": gtin, "description": desc, "current": current or {}}
|
||||
if ev is not None:
|
||||
body["ev"] = ev
|
||||
return client.post("/v1/products/resolve", json=body, headers=H).json()
|
||||
|
||||
|
||||
def test_disabled_installation_is_not_processed_and_never_calls_providers(monkeypatch):
|
||||
@@ -59,6 +62,43 @@ def test_applied_report_is_audited_and_human_override_is_final(monkeypatch):
|
||||
assert client.post("/v1/products/enrichment/applied", json={"product_code": "UNKNOWN", "fields": []}, headers=H).status_code == 404
|
||||
|
||||
|
||||
def test_resolve_wires_field_contract_fiscal_completion_fields_alongside_ncm(monkeypatch):
|
||||
"""field_contract.resolve_product was previously only exercised by its own unit tests. This proves the LIVE /v1/products/resolve endpoint
|
||||
now calls it too: once the NCM auto-fills, CFOP/CST/CSOSN/PIS-COFINS/... decisions ride along in the same response - no second engine, no
|
||||
separate round trip - using company_uf/company_crt already auto-discovered from the Agent's heartbeat (Installation.company_uf/company_crt)."""
|
||||
from central.app.enrichment import fiscal_completion as fcomp
|
||||
from central.app.models import FiscalContextEntry
|
||||
client, engine = setup(monkeypatch)
|
||||
with Session(engine) as s:
|
||||
inst = s.scalars(select(Installation)).one()
|
||||
inst.company_uf, inst.company_crt, inst.company_code = "BA", "1", "000001"
|
||||
s.commit()
|
||||
tenant_id = inst.tenant_id
|
||||
body = resolve(client)
|
||||
assert body["fields"]["CLASSIFICACAO_FISCAL"]["status"] == "AUTO_FILL_SAFE"
|
||||
for field in fcomp.FISCAL_COMPLETION_FIELDS: # every fiscal-completion field gets an explicit decision, never silently missing
|
||||
assert field in body["fields"], field
|
||||
assert body["fields"]["CSOSN"]["status"] == "NEEDS_REVIEW" and body["fields"]["CSOSN"]["missing_key"] == "icms_treatment" # 0 FiscalContextEntry rows: correctly blocked, not guessed
|
||||
with Session(engine) as s:
|
||||
s.add(FiscalContextEntry(tenant_id=tenant_id, company_key="000001", scope="COMPANY", scope_key="", key="icms_treatment", value="TAXED", source="OPERATOR"))
|
||||
s.commit()
|
||||
again = resolve(client, code="N9")
|
||||
assert again["fields"]["CSOSN"]["status"] == "AUTO_FILL_SAFE" and again["fields"]["CSOSN"]["value"] == "102" # same operator answer, field independently resolved once context exists
|
||||
|
||||
|
||||
def test_resolve_accepts_agents_targeted_product_evidence_end_to_end(monkeypatch):
|
||||
"""The Agent's targeted, product_code-scoped recipes/composed/purchases/stock evidence (firebird_reader.read_product_evidence) rides the
|
||||
resolve payload as `ev` and reaches field_contract.infer_role - proven end-to-end through the real HTTP route, not just the fiscal_completion
|
||||
unit tests. Recipe evidence alone proves OWN_PRODUCTION with zero operator questions."""
|
||||
client, engine = setup(monkeypatch)
|
||||
with Session(engine) as s:
|
||||
inst = s.scalars(select(Installation)).one()
|
||||
inst.company_uf, inst.company_crt, inst.company_code = "BA", "1", "000001"
|
||||
s.commit()
|
||||
body = resolve(client, code="N10", ev={"recipes": ["N10"], "composed": [], "purchases": {}, "stock_entry": {}, "stock_in": {}})
|
||||
assert body["fields"]["CFOP"]["status"] == "NEEDS_REVIEW" and body["fields"]["CFOP"].get("missing_key") != "product_role"
|
||||
|
||||
|
||||
def test_unknown_gtin_creates_a_human_blocker_in_portuguese(monkeypatch):
|
||||
client, engine = setup(monkeypatch)
|
||||
monkeypatch.setattr(enrich_api, "PROVIDERS_OVERRIDE", [Fake("cosmos", {}, True)])
|
||||
|
||||
@@ -53,6 +53,16 @@ def test_first_heartbeat_creates_exactly_one_initial_sync():
|
||||
assert len(initial) == 1 and initial[0].status == "PENDING" and initial[0].payload["reason"] == "INITIAL_SYNC"
|
||||
|
||||
|
||||
def test_heartbeat_metadata_stores_auto_discovered_company_crt_and_contribuinte_ipi():
|
||||
"""company_crt and company_contribuinte_ipi were never sent at heartbeat before (a real transport gap); once the Agent reports them
|
||||
(auto-discovered from C000004, never guessed), the Hub stores them on Installation - fiscal_completion.build_company reads them directly."""
|
||||
client, engine = _client()
|
||||
client.post("/v1/agents/heartbeat", json={"installation_id": INST, "status": "IDLE", "agent_version": "1.1.3", "company_uf": "BA", "company_crt": "1", "company_contribuinte_ipi": "S"}, headers=H)
|
||||
with Session(engine) as s:
|
||||
inst = s.scalar(select(Installation))
|
||||
assert inst.company_uf == "BA" and inst.company_crt == "1" and inst.company_contribuinte_ipi == "S"
|
||||
|
||||
|
||||
def test_initial_sync_completion_sets_coverage_and_invariant():
|
||||
client, engine = _client()
|
||||
hb(client); finish(client, engine)
|
||||
|
||||
Reference in New Issue
Block a user