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).
144 lines
11 KiB
Python
144 lines
11 KiB
Python
"""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"
|