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:
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "gti-fiscal-agent"
|
name = "gti-fiscal-agent"
|
||||||
version = "1.3.8"
|
version = "1.3.9"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = ["httpx>=0.27,<1"]
|
dependencies = ["httpx>=0.27,<1"]
|
||||||
|
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ class Config:
|
|||||||
self.poll_interval_seconds = float(env.get("POLL_INTERVAL_SECONDS", "5"))
|
self.poll_interval_seconds = float(env.get("POLL_INTERVAL_SECONDS", "5"))
|
||||||
# RTC auto-apply is a SEPARATE switch (default OFF): FIREBIRD_WRITES alone never lets the Agent fill cClassTrib.
|
# RTC auto-apply is a SEPARATE switch (default OFF): FIREBIRD_WRITES alone never lets the Agent fill cClassTrib.
|
||||||
self.rtc_auto_apply_max_per_job = int(env.get("RTC_AUTO_APPLY_MAX_PER_JOB", str(_rtc_apply.DEFAULT_MAX_PER_JOB)))
|
self.rtc_auto_apply_max_per_job = int(env.get("RTC_AUTO_APPLY_MAX_PER_JOB", str(_rtc_apply.DEFAULT_MAX_PER_JOB)))
|
||||||
self.agent_version = env.get("AGENT_VERSION", "1.3.8")
|
self.agent_version = env.get("AGENT_VERSION", "1.3.9")
|
||||||
self.schema_fingerprint = env.get("SCHEMA_FINGERPRINT", REFERENCE_SCHEMA_V1)
|
self.schema_fingerprint = env.get("SCHEMA_FINGERPRINT", REFERENCE_SCHEMA_V1)
|
||||||
self.state_db = env.get("AGENT_STATE_DB", str(AGENT_ROOT / "agent_state.sqlite"))
|
self.state_db = env.get("AGENT_STATE_DB", str(AGENT_ROOT / "agent_state.sqlite"))
|
||||||
# Pilot write authorization: both must be explicitly set by the installer for
|
# Pilot write authorization: both must be explicitly set by the installer for
|
||||||
@@ -356,7 +356,7 @@ def execute_job(job, config, executor, state, env):
|
|||||||
"fiscal_context": fiscal_context,
|
"fiscal_context": fiscal_context,
|
||||||
"rtc_auto_apply": rtc_apply,
|
"rtc_auto_apply": rtc_apply,
|
||||||
"fiscal_mode": getattr(config, "fiscal_mode", None),
|
"fiscal_mode": getattr(config, "fiscal_mode", None),
|
||||||
"release_version": env.get("AGENT_VERSION") or "1.3.8",
|
"release_version": env.get("AGENT_VERSION") or "1.3.9",
|
||||||
"products_total": len(products),
|
"products_total": len(products),
|
||||||
"products_changed": len(to_process),
|
"products_changed": len(to_process),
|
||||||
"products_processed": summary["processed"],
|
"products_processed": summary["processed"],
|
||||||
@@ -550,7 +550,7 @@ def run(config, env, *, max_iterations=None, stopper=None):
|
|||||||
def enforce_startup_preflight(env):
|
def enforce_startup_preflight(env):
|
||||||
"""Full read-only preflight at startup (Hub, auth, Firebird, capabilities, company context, guards). Fail closed."""
|
"""Full read-only preflight at startup (Hub, auth, Firebird, capabilities, company context, guards). Fail closed."""
|
||||||
from gti_fiscal_agent.preflight import run_preflight
|
from gti_fiscal_agent.preflight import run_preflight
|
||||||
report = run_preflight(env, agent_version=env.get("AGENT_VERSION") or "1.3.8")
|
report = run_preflight(env, agent_version=env.get("AGENT_VERSION") or "1.3.9")
|
||||||
logger.info("startup preflight: %s", json.dumps({k: report[k] for k in ("PREFLIGHT", "blockers", "fiscal_mode", "effective_firebird_writes")}, ensure_ascii=False))
|
logger.info("startup preflight: %s", json.dumps({k: report[k] for k in ("PREFLIGHT", "blockers", "fiscal_mode", "effective_firebird_writes")}, ensure_ascii=False))
|
||||||
print(json.dumps({"PREFLIGHT": report["PREFLIGHT"], "blockers": report["blockers"]}, ensure_ascii=False))
|
print(json.dumps({"PREFLIGHT": report["PREFLIGHT"], "blockers": report["blockers"]}, ensure_ascii=False))
|
||||||
if report["PREFLIGHT"] != "PASS":
|
if report["PREFLIGHT"] != "PASS":
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import os
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
AGENT_VERSION_DEFAULT = "1.3.8"
|
AGENT_VERSION_DEFAULT = "1.3.9"
|
||||||
REQUIRED_ENV = ("GTI_HUB_URL", "AGENT_TOKEN", "FIREBIRD_DATABASE_PATH", "FB_USER", "FB_PASSWORD")
|
REQUIRED_ENV = ("GTI_HUB_URL", "AGENT_TOKEN", "FIREBIRD_DATABASE_PATH", "FB_USER", "FB_PASSWORD")
|
||||||
REQUIRED_CAPABILITIES = ("supports_ncm", "supports_ipi", "supports_cest")
|
REQUIRED_CAPABILITIES = ("supports_ncm", "supports_ipi", "supports_cest")
|
||||||
FISCAL_MODES = ("DRY_RUN", "AUTO_APPLY")
|
FISCAL_MODES = ("DRY_RUN", "AUTO_APPLY")
|
||||||
|
|||||||
@@ -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
|
once published — never a second, independent version number. Fiscal semantics
|
||||||
are versioned separately (ENGINE_VERSION/DECISION_VERSION in classification.py,
|
are versioned separately (ENGINE_VERSION/DECISION_VERSION in classification.py,
|
||||||
DECISION_ENGINE_VERSION etc.) and are NOT changed by a release bump."""
|
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
|
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 .jobs import create_job
|
||||||
from .audit import record_audit
|
from .audit import record_audit
|
||||||
from .enrichment import kb as kb_mod, status as enrichment_status
|
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
|
from .enrichment.observability import coverage_metrics, kb_summary, official_sources, provider_sources
|
||||||
|
|
||||||
|
|
||||||
@@ -291,7 +292,8 @@ def router(session_factory):
|
|||||||
for event in events:
|
for event in events:
|
||||||
event["occurred_display"] = format_ui_datetime(datetime.fromisoformat(event["occurred_at"]) if event["occurred_at"] else None)
|
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")
|
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()
|
finally: session.close()
|
||||||
|
|
||||||
def _features_view(session, installation, enrich_code=None):
|
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)
|
return RedirectResponse(f"/agents/{agent_id}", status_code=303)
|
||||||
finally: session.close()
|
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")
|
@routes.post("/agents/{agent_id}/enrichment/reevaluate")
|
||||||
async def reevaluate_enrichment(request: Request, agent_id: int):
|
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."""
|
"""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."""
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "gti-fiscal-hub"
|
name = "gti-fiscal-hub"
|
||||||
version = "1.3.8"
|
version = "1.3.9"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi>=0.115,<1",
|
"fastapi>=0.115,<1",
|
||||||
|
|||||||
@@ -10,6 +10,19 @@
|
|||||||
{% if ops.enabled %}<form method="post" action="/agents/{{ detail.agent_id }}/pause" style="display:inline"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button type="submit" class="caution" title="Mantém o heartbeat, mas não executa jobs">Pausar processamento</button></form>{% else %}<form method="post" action="/agents/{{ detail.agent_id }}/resume" style="display:inline"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button type="submit" class="primary-action" title="Volta a executar jobs">Retomar processamento</button></form>{% endif %}
|
{% if ops.enabled %}<form method="post" action="/agents/{{ detail.agent_id }}/pause" style="display:inline"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button type="submit" class="caution" title="Mantém o heartbeat, mas não executa jobs">Pausar processamento</button></form>{% else %}<form method="post" action="/agents/{{ detail.agent_id }}/resume" style="display:inline"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button type="submit" class="primary-action" title="Volta a executar jobs">Retomar processamento</button></form>{% endif %}
|
||||||
</div></div><dl class="facts"><dt>Processamento</dt><dd><span class="badge {{ '' if ops.enabled else 'critical' }}">{{ "Ativo" if ops.enabled else "Pausado" }}</span> · sincronização automática a cada {{ ops.interval_minutes }} min</dd><dt>Última sincronização</dt><dd>{{ ops.last_sync or "ainda não sincronizado" }}{% if ops.last_changed is not none %} · {{ ops.last_changed }} produto(s) alterado(s){% endif %}</dd><dt>Sincronização agendada</dt><dd>{{ ops.next_sync or ("aguardando o primeiro sync" if ops.enabled else "pausado") }}</dd>{% if ops.rtc %}<dt>Reforma Tributária</dt><dd><span class="badge {{ "" if ops.rtc.enabled else "muted" }}" title="{{ ops.rtc.title }}">{{ ops.rtc.label }}</span></dd>{% endif %}{% if ops.rtc and ops.rtc.enabled %}{% if ops.rtc.products and not ops.rtc.products.applicable %}<dt>cClassTrib</dt><dd>{{ ops.rtc.products.label }}</dd>{% elif ops.rtc.products %}<dt>Produtos RTC</dt><dd><b>{{ ops.rtc.products.headline }}</b><br><small>Total {{ ops.rtc.products.total }} · com cClassTrib válido {{ ops.rtc.products.valid }} · prontos para preenchimento {{ ops.rtc.products.ready }} · ambíguos/bloqueados {{ ops.rtc.products.blocked }}</small></dd>{% endif %}
|
</div></div><dl class="facts"><dt>Processamento</dt><dd><span class="badge {{ '' if ops.enabled else 'critical' }}">{{ "Ativo" if ops.enabled else "Pausado" }}</span> · sincronização automática a cada {{ ops.interval_minutes }} min</dd><dt>Última sincronização</dt><dd>{{ ops.last_sync or "ainda não sincronizado" }}{% if ops.last_changed is not none %} · {{ ops.last_changed }} produto(s) alterado(s){% endif %}</dd><dt>Sincronização agendada</dt><dd>{{ ops.next_sync or ("aguardando o primeiro sync" if ops.enabled else "pausado") }}</dd>{% if ops.rtc %}<dt>Reforma Tributária</dt><dd><span class="badge {{ "" if ops.rtc.enabled else "muted" }}" title="{{ ops.rtc.title }}">{{ ops.rtc.label }}</span></dd>{% endif %}{% if ops.rtc and ops.rtc.enabled %}{% if ops.rtc.products and not ops.rtc.products.applicable %}<dt>cClassTrib</dt><dd>{{ ops.rtc.products.label }}</dd>{% elif ops.rtc.products %}<dt>Produtos RTC</dt><dd><b>{{ ops.rtc.products.headline }}</b><br><small>Total {{ ops.rtc.products.total }} · com cClassTrib válido {{ ops.rtc.products.valid }} · prontos para preenchimento {{ ops.rtc.products.ready }} · ambíguos/bloqueados {{ ops.rtc.products.blocked }}</small></dd>{% endif %}
|
||||||
<dt>Preenchimento automático (RTC, não é o Enriquecimento)</dt><dd>{{ ops.rtc.apply.state }}{% if ops.rtc.apply.applied_last_job %} · {{ ops.rtc.apply.applied_last_job }} preenchido(s) no último job{% endif %} · <a href="/agents/{{ detail.agent_id }}/rtc">Controle e candidatos cClassTrib</a></dd>{% endif %}<dt>Último job</dt><dd>{% if ops.last_job %}<a href="/jobs?scope=all&job_id={{ ops.last_job.id }}">{{ ops.last_job.kind_label }}</a> · {{ ops.last_job.status_label }}{% else %}nenhum{% endif %}</dd></dl></section>
|
<dt>Preenchimento automático (RTC, não é o Enriquecimento)</dt><dd>{{ ops.rtc.apply.state }}{% if ops.rtc.apply.applied_last_job %} · {{ ops.rtc.apply.applied_last_job }} preenchido(s) no último job{% endif %} · <a href="/agents/{{ detail.agent_id }}/rtc">Controle e candidatos cClassTrib</a></dd>{% endif %}<dt>Último job</dt><dd>{% if ops.last_job %}<a href="/jobs?scope=all&job_id={{ ops.last_job.id }}">{{ ops.last_job.kind_label }}</a> · {{ ops.last_job.status_label }}{% else %}nenhum{% endif %}</dd></dl></section>
|
||||||
|
{% if is_admin %}<section class="panel" id="contexto-fiscal"><div class="panel-title"><div><h2>Contexto Fiscal</h2><p class="muted">Somente as decisões que o motor fiscal (field_contract) realmente não consegue provar sozinho no ERP. Visível apenas para administradores.</p></div></div>
|
||||||
|
{% for d in fiscal_context %}<article class="panel" style="margin-top:12px"><div class="panel-title"><h3>{{ d.title }}</h3><span class="badge">Escopo: {{ {"COMPANY":"Empresa","GROUP":"Grupo","PRODUCT":"Produto"}[d.scope] }}</span></div>
|
||||||
|
<dl class="facts"><dt>Valor atual</dt><dd>{{ d.current_value or "Não confirmado" }}{% if d.last_changed %} <small class="muted">· alterado em {{ d.last_changed }} por {{ d.last_changed_by }}</small>{% endif %}</dd>
|
||||||
|
<dt>Sugestão</dt><dd>{{ d.suggestion or "Sem sugestão automática — evidência insuficiente" }}</dd>
|
||||||
|
<dt>Evidência</dt><dd>{{ d.evidence }} <small class="muted">(medido manualmente em {{ d.measured_at }} — não é uma consulta ao vivo)</small></dd>
|
||||||
|
<dt>Confiança</dt><dd>{{ d.confidence }}</dd>
|
||||||
|
<dt>Campos afetados</dt><dd>{{ d.affected_fields|join(", ") }}</dd></dl>
|
||||||
|
<form method="post" action="/agents/{{ detail.agent_id }}/fiscal-context" class="filters compact-filters"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="key" value="{{ d.key }}">
|
||||||
|
{% if d.allowed_values %}<label>Valor<select name="value">{% for v in d.allowed_values %}<option value="{{ v }}" {% if d.current_value==v %}selected{% endif %}>{{ v }}</option>{% endfor %}</select></label>{% else %}<label>Valor<input name="value" value="{{ d.current_value or '' }}" placeholder="ex.: {01,01,1.65,01,01,7.6} ou INTRA"></label>{% endif %}
|
||||||
|
<label>Motivo<input name="reason" placeholder="opcional" autocomplete="off"></label>
|
||||||
|
<button class="secondary" type="submit" name="fc_action" value="confirm">Confirmar</button>
|
||||||
|
<button class="secondary" type="submit" name="fc_action" value="clear" onclick="return confirm('Limpar esta decisão? Os produtos voltam a depender só do que o ERP prova.')">Limpar</button></form>
|
||||||
|
</article>{% endfor %}</section>{% endif %}
|
||||||
<section class="panel" id="funcionalidades"><div class="panel-title"><div><h2>Funcionalidades (Enriquecimento de produtos novos)</h2><p class="muted">Decididas no Hub para esta instalação — o Agent não tem chave local. O Enriquecimento de produtos novos NÃO é o preenchimento RTC, que tem controle próprio (<a href="/agents/{{ detail.agent_id }}/rtc">controle e candidatos</a>).</p></div></div>
|
<section class="panel" id="funcionalidades"><div class="panel-title"><div><h2>Funcionalidades (Enriquecimento de produtos novos)</h2><p class="muted">Decididas no Hub para esta instalação — o Agent não tem chave local. O Enriquecimento de produtos novos NÃO é o preenchimento RTC, que tem controle próprio (<a href="/agents/{{ detail.agent_id }}/rtc">controle e candidatos</a>).</p></div></div>
|
||||||
{% if features.waiting %}<p class="coverage-status error">Aguardando configuração de funcionalidades: esta instalação ainda não processa nada.</p>{% endif %}
|
{% if features.waiting %}<p class="coverage-status error">Aguardando configuração de funcionalidades: esta instalação ainda não processa nada.</p>{% endif %}
|
||||||
<dl class="facts"><dt>Corretor fiscal</dt><dd><span class="badge {{ '' if features.corrector_enabled else 'critical' }}">{{ "Ativo" if features.corrector_enabled else "Desativado" }}</span></dd><dt>Enriquecimento de produtos novos</dt><dd><span class="badge {{ '' if features.enrichment_mode != 'DISABLED' else 'critical' }}">{{ features.state_label }}</span></dd></dl>
|
<dl class="facts"><dt>Corretor fiscal</dt><dd><span class="badge {{ '' if features.corrector_enabled else 'critical' }}">{{ "Ativo" if features.corrector_enabled else "Desativado" }}</span></dd><dt>Enriquecimento de produtos novos</dt><dd><span class="badge {{ '' if features.enrichment_mode != 'DISABLED' else 'critical' }}">{{ features.state_label }}</span></dd></dl>
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Contexto Fiscal (Agent detail, ADMIN only): confirm/alter/clear one of the reduced set of real fiscal decisions
|
||||||
|
(icms_treatment/pis_cofins_profile/entry_scope). CSRF, RBAC and audit reuse the exact same mechanism already proven for
|
||||||
|
/agents/{id}/features; persistence reuses FiscalContextEntry, the same table blocker_resolution already writes to."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from central.app.models import AuditEvent, FiscalContextEntry, Installation, ProductEnrichment
|
||||||
|
from central.tests.test_control_panel_pages import _panel_client
|
||||||
|
from central.tests.test_rtc_security import _become, _post
|
||||||
|
|
||||||
|
|
||||||
|
def _company_key(client):
|
||||||
|
with client._session_factory() as s:
|
||||||
|
inst = s.scalars(select(Installation)).one()
|
||||||
|
return (inst.company_code or inst.company_name or "").strip(), inst.tenant_id, inst.id
|
||||||
|
|
||||||
|
|
||||||
|
def test_fiscal_context_tab_lists_only_the_three_reduced_decisions_with_evidence():
|
||||||
|
client, agent_id, _ = _panel_client()
|
||||||
|
_become(client, "ADMIN")
|
||||||
|
page = client.get(f"/agents/{agent_id}").text
|
||||||
|
assert "Contexto Fiscal" in page
|
||||||
|
for title in ("Tratamento padrão de ICMS", "Perfil de PIS/COFINS", "CFOP de entrada padrão"):
|
||||||
|
assert title in page
|
||||||
|
assert "487 de 521" in page # real evidence shown, not a raw internal key name
|
||||||
|
assert "<h3>Tratamento padrão de ICMS dos produtos comuns</h3>" in page # human title heads the card; internal key only appears as a hidden form field
|
||||||
|
|
||||||
|
|
||||||
|
def test_fiscal_context_hidden_from_non_admin():
|
||||||
|
client, agent_id, _ = _panel_client()
|
||||||
|
_become(client, "PADRAO")
|
||||||
|
page = client.get(f"/agents/{agent_id}").text
|
||||||
|
assert "Contexto Fiscal" not in page
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirming_a_decision_is_csrf_admin_and_audited_and_flags_reevaluation():
|
||||||
|
client, agent_id, _ = _panel_client()
|
||||||
|
company_key, tenant_id, installation_id = _company_key(client)
|
||||||
|
with client._session_factory() as s:
|
||||||
|
s.add(ProductEnrichment(tenant_id=tenant_id, installation_id=installation_id, product_code="P1", status="RESOLVED", ui_status="PRONTO_PARA_PREENCHER"))
|
||||||
|
s.commit()
|
||||||
|
_become(client, "PADRAO")
|
||||||
|
assert _post(client, f"/agents/{agent_id}/fiscal-context", key="icms_treatment", value="TAXED", fc_action="confirm").status_code == 403
|
||||||
|
_become(client, "ADMIN")
|
||||||
|
assert client.post(f"/agents/{agent_id}/fiscal-context", data={"csrf_token": "bad", "key": "icms_treatment", "value": "TAXED", "fc_action": "confirm"}, follow_redirects=False).status_code in (400, 403)
|
||||||
|
assert _post(client, f"/agents/{agent_id}/fiscal-context", key="icms_treatment", value="TAXED", fc_action="confirm").status_code == 303
|
||||||
|
with client._session_factory() as s:
|
||||||
|
entry = s.scalars(select(FiscalContextEntry).where(FiscalContextEntry.key == "icms_treatment")).one()
|
||||||
|
assert entry.scope == "COMPANY" and entry.value == "TAXED" and entry.source == "OPERATOR"
|
||||||
|
acts = [e.action for e in s.scalars(select(AuditEvent))]
|
||||||
|
assert "FISCAL_CONTEXT_DECISION" in acts
|
||||||
|
row = s.scalars(select(ProductEnrichment)).one()
|
||||||
|
assert row.reevaluate is True # existing products get a chance to pick up the new policy - never a silent no-op
|
||||||
|
|
||||||
|
|
||||||
|
def test_clearing_a_decision_deletes_the_entry_never_a_default_value():
|
||||||
|
client, agent_id, _ = _panel_client()
|
||||||
|
company_key, tenant_id, installation_id = _company_key(client)
|
||||||
|
_become(client, "ADMIN")
|
||||||
|
assert _post(client, f"/agents/{agent_id}/fiscal-context", key="icms_treatment", value="TAXED", fc_action="confirm").status_code == 303
|
||||||
|
assert _post(client, f"/agents/{agent_id}/fiscal-context", key="icms_treatment", fc_action="clear").status_code == 303
|
||||||
|
with client._session_factory() as s:
|
||||||
|
assert s.scalars(select(FiscalContextEntry).where(FiscalContextEntry.key == "icms_treatment")).first() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_value_is_rejected_never_silently_stored():
|
||||||
|
client, agent_id, _ = _panel_client()
|
||||||
|
_become(client, "ADMIN")
|
||||||
|
resp = client.post(f"/agents/{agent_id}/fiscal-context", data={"csrf_token": client.cookies.get("gti_csrf"), "key": "icms_treatment", "value": "NOT_A_REAL_TREATMENT", "fc_action": "confirm"}, follow_redirects=False)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
with client._session_factory() as s:
|
||||||
|
assert s.scalars(select(FiscalContextEntry)).first() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_key_is_refused():
|
||||||
|
client, agent_id, _ = _panel_client()
|
||||||
|
_become(client, "ADMIN")
|
||||||
|
resp = client.post(f"/agents/{agent_id}/fiscal-context", data={"csrf_token": client.cookies.get("gti_csrf"), "key": "not_a_real_key", "value": "x", "fc_action": "confirm"}, follow_redirects=False)
|
||||||
|
assert resp.status_code == 422
|
||||||
@@ -17,7 +17,7 @@ def test_health_is_unavailable_when_database_is_down():
|
|||||||
|
|
||||||
def test_version_endpoint_reports_release_and_fiscal_versions():
|
def test_version_endpoint_reports_release_and_fiscal_versions():
|
||||||
body = TestClient(create_app(database_probe=lambda: True)).get("/version").json()
|
body = TestClient(create_app(database_probe=lambda: True)).get("/version").json()
|
||||||
assert body["version"] == "1.3.8" and body["api_compat"] == "1.0" and body["engine_version"] and body["decision_version"]
|
assert body["version"] == "1.3.9" and body["api_compat"] == "1.0" and body["engine_version"] and body["decision_version"]
|
||||||
|
|
||||||
|
|
||||||
def test_production_refuses_the_dev_token(monkeypatch):
|
def test_production_refuses_the_dev_token(monkeypatch):
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"artifact": "RELEASE_MANIFEST",
|
"artifact": "RELEASE_MANIFEST",
|
||||||
"GTI_FISCAL_VERSION": "1.3.8",
|
"GTI_FISCAL_VERSION": "1.3.9",
|
||||||
"generated_at": "2026-09-28T00:30:00+00:00",
|
"generated_at": "2026-09-28T01:15:00+00:00",
|
||||||
"hub": {
|
"hub": {
|
||||||
"version": "1.3.8",
|
"version": "1.3.9",
|
||||||
"api_compat": "1.0",
|
"api_compat": "1.0",
|
||||||
"engine_version": "0.3.0",
|
"engine_version": "0.3.0",
|
||||||
"decision_version": "mission-04c-ipi-ex-split"
|
"decision_version": "mission-04c-ipi-ex-split"
|
||||||
},
|
},
|
||||||
"agent_version": "1.3.8",
|
"agent_version": "1.3.9",
|
||||||
"fiscal_engine": {
|
"fiscal_engine": {
|
||||||
"ENGINE_VERSION": "0.3.0",
|
"ENGINE_VERSION": "0.3.0",
|
||||||
"DECISION_VERSION": "mission-04c-ipi-ex-split",
|
"DECISION_VERSION": "mission-04c-ipi-ex-split",
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
"migrations": {
|
"migrations": {
|
||||||
"alembic_head_in_repo": "0032_contribuinte_ipi",
|
"alembic_head_in_repo": "0032_contribuinte_ipi",
|
||||||
"applied_revision": "0032_contribuinte_ipi",
|
"applied_revision": "0032_contribuinte_ipi",
|
||||||
"applied_revision_source": "confirmed live via docker exec alembic current on gti-fiscal-hub-fiscal-hub-1 during the 1.3.7 deploy; migration 0032 did not change again in 1.3.8",
|
"applied_revision_source": "confirmed live via docker exec alembic current on gti-fiscal-hub-fiscal-hub-1 during the 1.3.7 deploy; migration 0032 did not change again in 1.3.8/1.3.9",
|
||||||
"count": 32
|
"count": 32
|
||||||
},
|
},
|
||||||
"datasets_active": [
|
"datasets_active": [
|
||||||
@@ -89,5 +89,5 @@
|
|||||||
"record_count": 11103
|
"record_count": 11103
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"note": "1.3.8: fixes REPROCESS_PRODUCTS hardcoding company=None for _unified_fiscal/RTC (it isn't in SYNC_JOB_KINDS) - now resolves the real company context the same deterministic way every sync job already does, never guesses, falls back to None only when genuinely unresolved. No schema change. Agent code changed (run_agent_service.py): a new GTIFiscalAgent-Setup-1.3.8 installer is published this release."
|
"note": "1.3.9: adds the ADMIN-only Contexto Fiscal section to the Agent detail page (central/app/fiscal_context_ui.py) - confirm/alter/clear the 3 real fiscal decisions field_contract cannot derive alone (icms_treatment/pis_cofins_profile/entry_scope, reduced from an original 5-item list by auditing real production data: icms_rate/credit_rate and rounding were proven NOT_APPLICABLE/ALREADY_DERIVED for this CRT=1 company and dropped). Persists through the existing FiscalContextEntry table, CSRF+RBAC+audit reusing the same mechanism as /agents/{id}/features. No schema change. Hub only - Agent unchanged, no new installer published this release."
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
1.3.8
|
1.3.9
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ sys.path.insert(0, str(ROOT))
|
|||||||
def test_versions_are_coherent_across_hub_agent_and_packages():
|
def test_versions_are_coherent_across_hub_agent_and_packages():
|
||||||
from central.app.version import GTI_FISCAL_VERSION
|
from central.app.version import GTI_FISCAL_VERSION
|
||||||
v = (ROOT / "release" / "VERSION").read_text(encoding="utf-8").strip()
|
v = (ROOT / "release" / "VERSION").read_text(encoding="utf-8").strip()
|
||||||
assert v == GTI_FISCAL_VERSION == "1.3.8"
|
assert v == GTI_FISCAL_VERSION == "1.3.9"
|
||||||
pep = v.replace("-rc", "rc")
|
pep = v.replace("-rc", "rc")
|
||||||
for pp in ("central/pyproject.toml", "agent/pyproject.toml"):
|
for pp in ("central/pyproject.toml", "agent/pyproject.toml"):
|
||||||
assert f'version = "{pep}"' in (ROOT / pp).read_text(encoding="utf-8")
|
assert f'version = "{pep}"' in (ROOT / pp).read_text(encoding="utf-8")
|
||||||
assert '"AGENT_VERSION", "1.3.8"' in (ROOT / "agent" / "scripts" / "run_agent_service.py").read_text(encoding="utf-8")
|
assert '"AGENT_VERSION", "1.3.9"' in (ROOT / "agent" / "scripts" / "run_agent_service.py").read_text(encoding="utf-8")
|
||||||
manifest = ROOT / "release" / "RELEASE_MANIFEST.json"
|
manifest = ROOT / "release" / "RELEASE_MANIFEST.json"
|
||||||
if manifest.exists():
|
if manifest.exists():
|
||||||
m = json.loads(manifest.read_text(encoding="utf-8"))
|
m = json.loads(manifest.read_text(encoding="utf-8"))
|
||||||
|
|||||||
Reference in New Issue
Block a user