feat: Contexto Fiscal admin UI - reduces fiscal policy list to 3 real decisions
Adds an ADMIN-only "Contexto Fiscal" section to the Agent detail page: confirm/alter/clear the fiscal decisions
field_contract.resolve_product genuinely cannot derive from the ERP alone (icms_treatment, pis_cofins_profile,
entry_scope). Persists through the existing FiscalContextEntry table (the same one blocker_resolution already
writes operator answers into) - no new persistence model, no opaque JSON box. CSRF + RBAC + audit reuse the exact
mechanism already proven for /agents/{id}/features. Confirming or clearing flags every already-enriched product on
the installation for re-evaluation.
The list was reduced from 5 candidates to 3 by auditing real production data (Agent 2, 2026-09-27/28):
icms_rate/credit_rate and rounding were dropped - both proven NOT_APPLICABLE / ALREADY_DERIVED for this CRT=1
company from real field_contract dry-runs, not assumption. Real evidence surfaced in the UI is a dated, one-time
manual read-only measurement (disclosed in the card copy itself as such) - the Hub has no direct Firebird access,
so this is not a live query; a follow-up should pipe it through the heartbeat the same way CRT/CONTRIBUINTE_IPI
were wired.
Also found, during the same audit, that this client's NFC-e emission ignores the product's cadastral PIS/COFINS
CST/rate fields entirely (verified on the 3 real products that DO have them populated - the emitted documents never
carry the cadastral value) - documented as a strong OPEN recommendation for a future field_contract change, not
applied here, since changing canonical fiscal semantics needs accountant sign-off, not just my own inference.
1129 tests passed (tests + central/tests + agent/tests), 0 failed. Version 1.3.9 (single GTI_FISCAL_VERSION). Hub
only - Agent unchanged, no new installer this release.
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
"""Contexto Fiscal: the ADMIN-only, human-language view of the handful of fiscal decisions field_contract.resolve_product genuinely cannot
|
||||
derive from the ERP alone (see central/app/enrichment/fiscal_completion.py). Reuses FiscalContextEntry (the SAME table blocker_resolution
|
||||
already writes operator answers into) - no new persistence model, no opaque JSON box in the UI.
|
||||
|
||||
Each decision here was reduced from a larger candidate list by auditing REAL production data (Agent 2, 2026-09-27/28): icms_rate/credit_rate
|
||||
and rounding were REMOVED (proven NOT_APPLICABLE / ALREADY_DERIVED for this CRT=1 company - see git history), leaving only the three that
|
||||
real evidence could not resolve unattended. Evidence text below is a dated, one-time manual read-only measurement (see the audit trail in
|
||||
git commit messages), not a live query - the Hub has no direct Firebird access; only the Agent does, and this evidence has not yet been
|
||||
piped through the heartbeat. This is disclosed in the UI itself so the admin never mistakes it for a live number."""
|
||||
from __future__ import annotations
|
||||
|
||||
DECISIONS = [
|
||||
{
|
||||
"key": "icms_treatment", "scope": "COMPANY", "title": "Tratamento padrão de ICMS dos produtos comuns",
|
||||
"evidence": "CSOSN padrão do ERP (C000004.ICMS_CSOSN): 102. 487 de 521 produtos cadastrados já usam 102. As exceções existentes "
|
||||
"(500 = ICMS-ST retido, em 5 produtos; 101 = com direito a crédito, em 4 produtos; 103, em 1 produto) são preservadas "
|
||||
"e nunca sobrescritas - a confirmação abaixo só afeta os produtos com CSOSN em branco (28 hoje).",
|
||||
"suggestion": "TAXED", "allowed_values": ["TAXED", "EXEMPT", "NOT_TAXED", "ST_WITHHELD", "SUSPENDED", "DEFERRED"],
|
||||
"affected_fields": ["CST", "CSOSN"], "confidence": "Média (real, medida em produção; não é garantia de que todo produto novo seja 102)",
|
||||
"measured_at": "2026-09-27",
|
||||
},
|
||||
{
|
||||
"key": "pis_cofins_profile", "scope": "COMPANY", "title": "Perfil de PIS/COFINS (código e alíquota de entrada/saída)",
|
||||
"evidence": "34.946 itens de NFC-e emitidos; 3.336 (9,5%) carregam um CST de PIS/COFINS na nota, sempre o código 01 - mas os "
|
||||
"mesmos produtos têm os campos de cadastro (código de saída, alíquota) sempre em branco no SGV, incluindo os poucos "
|
||||
"produtos que TÊM esses campos preenchidos manualmente (a nota emitida ignora o valor cadastrado). Isso indica que a "
|
||||
"emissão não lê esse cadastro para este cliente - mas não é conclusivo o bastante para marcar os 6 campos como "
|
||||
"não aplicável sem confirmação humana.", "suggestion": None,
|
||||
"affected_fields": ["COD_ENTRADA_PIS", "COD_SAIDA_PIS", "ALIQ_PIS", "COD_ENTRADA_COFINS", "COD_SAIDA_COFINS", "ALIQ_COFINS"],
|
||||
"confidence": "Baixa para um perfil único da empresa; considerar tratar como não aplicável ao cadastro (pendente de confirmação contábil)",
|
||||
"measured_at": "2026-09-27",
|
||||
},
|
||||
{
|
||||
"key": "entry_scope", "scope": "COMPANY", "title": "CFOP de entrada padrão (compras dentro/fora do estado)",
|
||||
"evidence": "Apenas 12 documentos de compra reais no ERP para 526 produtos; 11 sem CFOP preenchido, 1 com CFOP intra-estadual. "
|
||||
"Evidência real insuficiente para qualquer sugestão - o CFOP de entrada normalmente é definido pela operação/documento "
|
||||
"de compra, não pelo cadastro do produto.", "suggestion": None,
|
||||
"affected_fields": ["CFOP_ENTRADA"], "confidence": "Muito baixa - recomendação: tratar como definido no momento da operação, não como cadastro obrigatório",
|
||||
"measured_at": "2026-09-27",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
VALID_KEYS = {d["key"] for d in DECISIONS}
|
||||
|
||||
|
||||
def set_fiscal_context_decision(session, *, tenant_id: int, company_key: str, key: str, value: str | None, reason: str, admin, installation_id: int):
|
||||
"""Confirm/alter/clear one Contexto Fiscal decision. value=None clears it (the product/group falls back to whatever the engine can
|
||||
prove on its own - never a silent default). Reuses the SAME table and validator the blocker-resolution workflow already writes
|
||||
through; flags every already-enriched product on this installation for re-evaluation so the new (or cleared) policy takes effect."""
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import select
|
||||
from . import context_keys
|
||||
from .audit import record_audit
|
||||
from .enrichment.status import mark_for_reevaluation
|
||||
from .models import FiscalContextEntry
|
||||
|
||||
if key not in VALID_KEYS:
|
||||
raise ValueError(f"unknown Contexto Fiscal key {key!r}")
|
||||
entry = session.scalar(select(FiscalContextEntry).where(FiscalContextEntry.tenant_id == tenant_id, FiscalContextEntry.company_key == company_key,
|
||||
FiscalContextEntry.scope == "COMPANY", FiscalContextEntry.scope_key == "", FiscalContextEntry.key == key))
|
||||
before = entry.value if entry else None
|
||||
if value is None:
|
||||
if entry is not None:
|
||||
session.delete(entry)
|
||||
else:
|
||||
normalized, error = context_keys.validate_entry({"scope": "COMPANY", "key": key, "value": value, "reason": reason})
|
||||
if error:
|
||||
raise ValueError(error)
|
||||
if entry is None:
|
||||
entry = FiscalContextEntry(tenant_id=tenant_id, company_key=company_key, source="OPERATOR", updated_at=datetime.now(timezone.utc), **normalized)
|
||||
session.add(entry)
|
||||
else:
|
||||
entry.value, entry.reason, entry.source, entry.updated_at = normalized["value"], normalized["reason"], "OPERATOR", datetime.now(timezone.utc)
|
||||
flagged = mark_for_reevaluation(session, installation_id)
|
||||
record_audit(session, admin_user_id=admin.id, tenant_id=tenant_id, action="FISCAL_CONTEXT_DECISION", target_type="installation", target_id=company_key,
|
||||
context={"key": key, "from": before, "to": value, "reason": reason, "operator": admin.display_name or admin.username, "role": admin.external_role or admin.auth_source, "products_flagged": flagged},
|
||||
severity="WARNING")
|
||||
|
||||
|
||||
def build_fiscal_context_view(session, tenant_id: int, company_key: str) -> list[dict]:
|
||||
from sqlalchemy import select
|
||||
from .models import FiscalContextEntry
|
||||
current = {(r.scope, r.scope_key, r.key): r for r in session.scalars(
|
||||
select(FiscalContextEntry).where(FiscalContextEntry.tenant_id == tenant_id, FiscalContextEntry.company_key == company_key))}
|
||||
out = []
|
||||
for d in DECISIONS:
|
||||
row = current.get((d["scope"], "", d["key"]))
|
||||
out.append({**d, "current_value": row.value if row else None, "current_reason": row.reason if row else None,
|
||||
"last_changed": row.updated_at.isoformat() if row and getattr(row, "updated_at", None) else None,
|
||||
"last_changed_by": row.source if row else None})
|
||||
return out
|
||||
@@ -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.8"
|
||||
GTI_FISCAL_VERSION = "1.3.9"
|
||||
API_COMPAT_MAJOR_MINOR = "1.0" # an Agent 1.0.x/1.1.x talks to any Hub 1.0.x
|
||||
|
||||
+29
-1
@@ -23,6 +23,7 @@ from .models import AdminUser, Agent, AgentEnrollment, AgentRelease, AgentSnapsh
|
||||
from .jobs import create_job
|
||||
from .audit import record_audit
|
||||
from .enrichment import kb as kb_mod, status as enrichment_status
|
||||
from .fiscal_context_ui import build_fiscal_context_view, set_fiscal_context_decision
|
||||
from .enrichment.observability import coverage_metrics, kb_summary, official_sources, provider_sources
|
||||
|
||||
|
||||
@@ -291,7 +292,8 @@ def router(session_factory):
|
||||
for event in events:
|
||||
event["occurred_display"] = format_ui_datetime(datetime.fromisoformat(event["occurred_at"]) if event["occurred_at"] else None)
|
||||
agent_blockers = list_blockers(session, tenant_id=installation.tenant_id, agent_id=agent_id, page=1, page_size=25, scope="all")
|
||||
return render(request, "agent_detail.html", {"detail": detail, "tab": tab, "blockers": agent_blockers, "blocker_breakdown": blocker_filter_summary(session, tenant_id=installation.tenant_id, agent_id=agent_id, status="OPEN", scope="all")["blocker_breakdown"], "jobs": list_jobs(session, agent_id=agent_id, page=1, page_size=25, scope="all"), "events": events, "event_page": max(1, event_page), "has_more": has_more, "severity": severity or "", "event_type": event_type or "", "job_id": job_id or "", "product_code": product_code or "", "since": since or "", "until": until or "", "event_query_base": query_base(tab=tab, severity=severity, event_type=event_type, job_id=job_id, product_code=product_code, since=since, until=until), "auth": auth_data, "csrf_token": request.cookies.get("gti_csrf", ""), "ops": _ops_view(session, installation, agent), "features": _features_view(session, installation, enrich_code), "enrich_code": enrich_code or "", "is_admin": _is_privileged(auth_data[1])})
|
||||
fiscal_context = build_fiscal_context_view(session, installation.tenant_id, _company_key(None, installation, None))
|
||||
return render(request, "agent_detail.html", {"detail": detail, "tab": tab, "blockers": agent_blockers, "blocker_breakdown": blocker_filter_summary(session, tenant_id=installation.tenant_id, agent_id=agent_id, status="OPEN", scope="all")["blocker_breakdown"], "jobs": list_jobs(session, agent_id=agent_id, page=1, page_size=25, scope="all"), "events": events, "event_page": max(1, event_page), "has_more": has_more, "severity": severity or "", "event_type": event_type or "", "job_id": job_id or "", "product_code": product_code or "", "since": since or "", "until": until or "", "event_query_base": query_base(tab=tab, severity=severity, event_type=event_type, job_id=job_id, product_code=product_code, since=since, until=until), "auth": auth_data, "csrf_token": request.cookies.get("gti_csrf", ""), "ops": _ops_view(session, installation, agent), "features": _features_view(session, installation, enrich_code), "enrich_code": enrich_code or "", "is_admin": _is_privileged(auth_data[1]), "fiscal_context": fiscal_context})
|
||||
finally: session.close()
|
||||
|
||||
def _features_view(session, installation, enrich_code=None):
|
||||
@@ -344,6 +346,32 @@ def router(session_factory):
|
||||
return RedirectResponse(f"/agents/{agent_id}", status_code=303)
|
||||
finally: session.close()
|
||||
|
||||
@routes.post("/agents/{agent_id}/fiscal-context")
|
||||
async def set_fiscal_context(request: Request, agent_id: int):
|
||||
"""Contexto Fiscal (ADMIN only, CSRF, audited): confirm/alter/clear one of the handful of real fiscal decisions field_contract
|
||||
cannot derive from the ERP alone. Writes through FiscalContextEntry - the same table blocker_resolution already writes to."""
|
||||
session, auth_data, redirect = page_auth(request)
|
||||
if redirect: return redirect
|
||||
try:
|
||||
values = await form_values(request); web_session, admin = auth_data
|
||||
require_csrf(web_session, values.get("csrf_token"))
|
||||
agent, installation = _rtc_target(session, agent_id)
|
||||
if not agent or not installation: return HTMLResponse("Agent not found", status_code=404)
|
||||
if not _is_privileged(admin):
|
||||
return _deny(session, admin, installation.tenant_id, "fiscal_context:set", installation.installation_id)
|
||||
key = (values.get("key") or "").strip()
|
||||
action = (values.get("fc_action") or "confirm").strip()
|
||||
value = None if action == "clear" else (values.get("value") or "").strip() or None
|
||||
reason = (values.get("reason") or "").strip() or f"{action} via painel"
|
||||
company_key = _company_key(None, installation, None)
|
||||
try:
|
||||
set_fiscal_context_decision(session, tenant_id=installation.tenant_id, company_key=company_key, key=key, value=value, reason=reason, admin=admin, installation_id=installation.id)
|
||||
except ValueError as error:
|
||||
return HTMLResponse(str(error), status_code=422)
|
||||
session.commit()
|
||||
return RedirectResponse(f"/agents/{agent_id}#contexto-fiscal", status_code=303)
|
||||
finally: session.close()
|
||||
|
||||
@routes.post("/agents/{agent_id}/enrichment/reevaluate")
|
||||
async def reevaluate_enrichment(request: Request, agent_id: int):
|
||||
"""Explicit, idempotent 'evaluate again' for one product (code=...) or every tracked product that is not enriched yet. KB/cache first; providers only when really needed."""
|
||||
|
||||
Reference in New Issue
Block a user