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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user