feat: explain RTC blockers when no R3 candidates are eligible

This commit is contained in:
gti
2026-09-25 16:04:29 -03:00
parent d8f1bd3415
commit 95605c2ff5
7 changed files with 137 additions and 5 deletions
+13
View File
@@ -340,6 +340,19 @@ def blocker_filter_summary(session, *, tenant_id=None, agent_id=None, product_co
}
def rtc_blocker_summary(session, agent_id: int) -> dict:
"""Read-only, installation-scoped RTC blocker aggregates for the cClassTrib page."""
query = _blockers_query(session, agent_id=agent_id, field="RTC", status="OPEN", scope="all")
blockers = int(session.scalar(query.with_only_columns(func.count(BlockedProduct.id)).order_by(None)) or 0)
products = int(session.scalar(query.with_only_columns(func.count(func.distinct(BlockedProduct.product_code))).order_by(None)) or 0)
reason = func.coalesce(BlockedProduct.evidence_summary["reason"].as_string(), BlockedProduct.reason_code)
reasons = session.execute(
query.with_only_columns(reason, func.count(func.distinct(BlockedProduct.product_code)))
.group_by(reason).order_by(None)
).all()
return {"open_blockers": blockers, "products_affected": products, "reason_counts": {str(code or ""): int(count) for code, count in reasons}}
def _blocker_breakdown_query(query):
"""Group on the source column itself for PostgreSQL-safe SQL semantics."""
return query.with_only_columns(BlockedProduct.field, func.count(BlockedProduct.id)).group_by(BlockedProduct.field).order_by(None)
+4
View File
@@ -190,9 +190,13 @@ def _rtc_products_view(resolution, enabled):
ready = int(resolution.get("ready_to_apply", 0))
conflicts = int(resolution.get("conflicts", 0)) + int(resolution.get("official_mismatch", 0))
catalog = int(resolution.get("catalog_incompatible", 0))
reasons = resolution.get("by_reason") if isinstance(resolution.get("by_reason"), dict) else {}
simple_count = sum(int(count or 0) for reason, count in reasons.items() if str(reason).endswith("SIMPLES_ORIENTATION_PENDING_OFFICIAL_NT"))
other_reason_max = max((int(count or 0) for reason, count in reasons.items() if not str(reason).endswith("SIMPLES_ORIENTATION_PENDING_OFFICIAL_NT")), default=0)
blocked = total - valid - ready
pending = total - valid
return {"total": total, "valid": valid, "ready": ready, "blocked": blocked, "pending": pending, "conflicts": conflicts, "catalog_incompatible": catalog, "suggestion_only": int(resolution.get("suggestion_only", 0)),
"simple_orientation_count": simple_count, "simple_orientation_dominant": simple_count > 0 and simple_count > other_reason_max,
"headline": f"Reforma Tributária ativa — cClassTrib pendente em {pending} produto{'s' if pending != 1 else ''}" if pending else "Reforma Tributária ativa — todos os produtos com cClassTrib válido",
"catalog": (resolution.get("catalog_compatibility") or {}).get("compatibility")}
+15 -4
View File
@@ -16,7 +16,7 @@ from .blocker_resolution import BlockerResolutionUnavailable, _company_key, reso
from .context_keys import ALLOWED_KEYS, ENUMS
from .installation_identity import reference_agent_version
from .panel_presentation import RTC_MODE_EXPLANATION, RTC_MODE_LABELS, abort_reason_label, audit_outcome_label, candidate_status_label, catalog_state_label, job_kind_label, job_status_label, rtc_rule_label, rtc_status_view
from .control_plane import blocker_detail, blocker_filter_summary, dashboard_summary, installation_view, jobs_status_summary, list_blockers, list_installations, list_jobs, list_panel_events
from .control_plane import blocker_detail, blocker_filter_summary, dashboard_summary, installation_view, jobs_status_summary, list_blockers, list_installations, list_jobs, list_panel_events, rtc_blocker_summary
from .human_auth import create_session, current_session, require_csrf, verify_password
from .panel_presentation import format_ui_datetime
from .models import AdminUser, Agent, AgentEnrollment, AgentRelease, AgentSnapshot, BlockedProduct, FiscalContextEntry, Installation, Job, RtcCandidate, RtcWriteAudit, Tenant
@@ -346,15 +346,26 @@ def router(session_factory):
status = q.get("status") or "PENDING"
page = max(1, int(q.get("page", "1") or 1)) if str(q.get("page", "1")).isdigit() else 1
size = 50
base = select(RtcCandidate).where(RtcCandidate.installation_id == installation.id)
counts = dict(session.execute(select(RtcCandidate.status, func.count()).where(RtcCandidate.installation_id == installation.id).group_by(RtcCandidate.status)).all())
base = select(RtcCandidate).where(RtcCandidate.installation_id == installation.id, RtcCandidate.rule_id == "RTC-R3")
counts = dict(session.execute(select(RtcCandidate.status, func.count()).where(RtcCandidate.installation_id == installation.id, RtcCandidate.rule_id == "RTC-R3").group_by(RtcCandidate.status)).all())
shown = base.where(RtcCandidate.status == status) if status in ("PENDING", "APPROVED", "REJECTED", "APPLIED", "STALE") else base
total = int(session.scalar(select(func.count()).select_from(shown.subquery())) or 0)
rows = list(session.scalars(shown.order_by(RtcCandidate.product_code).offset((page - 1) * size).limit(size)))
audits = list(session.scalars(select(RtcWriteAudit).where(RtcWriteAudit.installation_id == installation.id, RtcWriteAudit.outcome.in_(("APPLIED", "ROLLED_BACK"))).order_by(RtcWriteAudit.id.desc()).limit(25)))
ops = _ops_view(session, installation, agent)
rtc_blockers = rtc_blocker_summary(session, agent_id)
rtc_products = ((ops.get("rtc") or {}).get("products") or {})
r3_candidates = int(rtc_products.get("ready", counts.get("PENDING", 0) + counts.get("APPROVED", 0)))
blocked_products = max(int(rtc_products.get("blocked", 0)), rtc_blockers["products_affected"])
reasons = rtc_blockers["reason_counts"]
simple_reason_count = sum(count for reason, count in reasons.items() if reason.endswith("SIMPLES_ORIENTATION_PENDING_OFFICIAL_NT"))
simple_reason_count = max(simple_reason_count, int(rtc_products.get("simple_orientation_count", 0)))
other_reason_max = max((count for reason, count in reasons.items() if not reason.endswith("SIMPLES_ORIENTATION_PENDING_OFFICIAL_NT")), default=0)
simple_orientation_dominant = bool(rtc_products.get("simple_orientation_dominant")) or simple_reason_count > other_reason_max
rtc_empty_state = {"show": r3_candidates == 0 and blocked_products > 0, "r3_candidates": r3_candidates,
"blocked_products": blocked_products, "simple_orientation_dominant": simple_orientation_dominant}
return render(request, "rtc_control.html", {"agent_id": agent_id, "installation": installation, "mode": installation.rtc_write_mode, "modes": RTC_MODE_LABELS, "explanation": RTC_MODE_EXPLANATION, "ops": ops,
"privileged": _is_privileged(auth_data[1]), "rows": rows, "counts": counts, "status": status, "page": page, "pages": max(1, -(-total // size)), "total": total, "audits": audits,
"privileged": _is_privileged(auth_data[1]), "rows": rows, "counts": counts, "status": status, "page": page, "pages": max(1, -(-total // size)), "total": total, "audits": audits, "rtc_empty_state": rtc_empty_state,
"L": {"status": candidate_status_label, "outcome": audit_outcome_label, "reason": abort_reason_label, "rule": rtc_rule_label, "catalog": catalog_state_label}})
finally: session.close()
+6
View File
@@ -60,6 +60,11 @@ small { display: block; font-size: 0.78rem; }
.list-row { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 12px 8px; margin: 0 -8px; border-bottom: 1px solid var(--cor-borda); border-radius: 8px; text-decoration: none; color: inherit; transition: background var(--transicao); }
.list-row:last-child { border-bottom: 0; }
.list-row:hover { background: var(--cor-superficie-alt); }
.rtc-empty-state { display: flex; align-items: center; justify-content: space-between; gap: 18px; border-left: 4px solid var(--cor-acento); background: var(--cor-superficie-alt); }
.rtc-empty-copy { min-width: 0; }
.rtc-empty-copy h2 { margin: 0 0 8px; }
.rtc-empty-copy p:last-child { margin-bottom: 0; }
.rtc-empty-state .botao { flex: 0 0 auto; margin: 0; }
.empty-state { color: var(--cor-texto-secundario); border: 1px dashed var(--cor-borda); border-radius: 8px; padding: 18px; margin: 0; }
/* ---------- badges: mesma forma do .badge do Gerenciador, cores semânticas dos tokens ---------- */
@@ -139,6 +144,7 @@ td a { text-decoration: none; }
}
@media (max-width: 768px) {
.cards, .cards.compact, .split { grid-template-columns: 1fr 1fr; }
.rtc-empty-state { align-items: stretch; flex-direction: column; }
.coverage { grid-template-columns: repeat(2, 1fr); }
.form-grid { grid-template-columns: 1fr; }
.form-grid .wide { grid-column: auto; }
+12
View File
@@ -15,6 +15,18 @@
<article class="metric"><span>Catálogo incompatível</span><strong>{{ rtc.products.catalog_incompatible }}</strong></article>
</section>
{% endif %}
{% if rtc_empty_state.show %}
<section class="panel rtc-empty-state" role="status" aria-live="polite">
<div class="rtc-empty-copy">
<h2>Não há produtos elegíveis para preenchimento automático.</h2>
<p>{{ rtc_empty_state.blocked_products }} produto{{ 's' if rtc_empty_state.blocked_products != 1 else '' }} {% if rtc_empty_state.blocked_products != 1 %}precisam de atenção fiscal, mas não podem ser preenchidos{% else %}precisa de atenção fiscal, mas não pode ser preenchido{% endif %} automaticamente pelas regras atuais.</p>
{% if rtc_empty_state.simple_orientation_dominant %}
<p>Esta empresa está no Simples Nacional. Os produtos estão bloqueados porque o sistema ainda não possui fundamento oficial suficiente para determinar automaticamente o cClassTrib. Alterar o modo de preenchimento não remove esse bloqueio.</p>
{% endif %}
</div>
<a class="botao" href="/blockers?scope=all&amp;agent_id={{ agent_id }}&amp;field=RTC&amp;status=OPEN">Ver produtos bloqueados ({{ rtc_empty_state.blocked_products }})</a>
</section>
{% endif %}
<section class="panel"><div class="panel-title"><div><h2>Modo de preenchimento</h2><p class="muted">Decidido centralmente para esta instalação. Padrão: Desativada. Nenhuma configuração local no servidor do cliente.</p></div></div>
<form method="post" action="/agents/{{ agent_id }}/rtc-mode"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+64
View File
@@ -705,3 +705,67 @@ def test_rtc_control_page_shows_human_labels_and_a_catalog_column_never_raw_enum
assert header in page
for raw in ("PENDING</td>", "APPLIED</td>", "ABORTED"):
assert raw not in page
def test_rtc_no_r3_candidates_shows_simples_explanation_and_filtered_blocker_link():
from central.app.models import BlockedProduct, RtcCandidate, RtcWriteAudit
client, agent_id, _ = _panel_client()
with client._session_factory() as session:
installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id))
installation.rtc_write_mode = "ENABLED"
session.add(RtcCandidate(tenant_id=installation.tenant_id, installation_id=installation.id, product_code="R4-SUGGESTION", description="Sugestão", ncm="10063021", proposed="000001", cst_ibscbs="000", rule_id="RTC-R4", basis="suggestion only", status="PENDING"))
session.add_all([
BlockedProduct(
tenant_id=installation.tenant_id,
installation_id=installation.id,
company="Acme",
product_code=f"RTC-{index:04d}",
description=f"Produto RTC {index}",
blocker_type="BLOCKED_FISCAL_DATA",
field="RTC",
reason_code="CCLASSTRIB_MISSING_UNRESOLVED",
evidence_summary={"reason": "RTC-R1:SIMPLES_ORIENTATION_PENDING_OFFICIAL_NT"},
input_fingerprint=f"rtc-input-{index}",
blocker_fingerprint=f"rtc-blocker-{index}",
first_seen_at=datetime.now(timezone.utc),
last_seen_at=datetime.now(timezone.utc),
)
for index in range(521)
])
session.add(Job(agent_id=agent_id, kind="REPROCESS_ALL", status="COMPLETED", payload={}, result_summary={
"fiscal_context": {
"rtc_enabled": True,
"rtc_supported": True,
"rtc_catalog_valid": True,
"crt": "1",
"legal_effective_context": {"cclasstrib_orientation": "PENDING_OFFICIAL_NT"},
"rtc_resolution": {
"total": 521, "valid_existing": 0, "ready_to_apply": 0, "suggestion_only": 0,
"ambiguous": 0, "insufficient": 521,
"by_reason": {"SIMPLES_ORIENTATION_PENDING_OFFICIAL_NT": 521},
},
},
"rtc_auto_apply": {"mode": "ENABLED", "ready_total": 0, "preview": [], "write_attempts": 0, "write_successes": 0},
}, created_at=datetime.now(timezone.utc), finished_at=datetime.now(timezone.utc)))
session.commit()
page = client.get(f"/agents/{agent_id}/rtc")
assert page.status_code == 200
assert "Não há produtos elegíveis para preenchimento automático." in page.text
assert "521 produtos precisam de atenção fiscal, mas não podem ser preenchidos automaticamente pelas regras atuais." in page.text
assert "Esta empresa está no Simples Nacional." in page.text
assert "SIMPLES_ORIENTATION_PENDING_OFFICIAL_NT" not in page.text
assert "R4-SUGGESTION" not in page.text
assert 'href="/blockers?scope=all&amp;agent_id=' + str(agent_id) + '&amp;field=RTC&amp;status=OPEN"' in page.text
assert "Nenhum candidato neste filtro." in page.text
blockers = client.get(f"/blockers?scope=all&agent_id={agent_id}&field=RTC&status=OPEN")
assert blockers.status_code == 200
assert "521 blockers" in blockers.text
with client._session_factory() as session:
assert session.scalar(select(Installation.rtc_write_mode).where(Installation.agent_id == agent_id)) == "ENABLED"
assert session.query(RtcCandidate).filter_by(installation_id=installation.id).count() == 1
assert session.query(RtcCandidate).filter_by(installation_id=installation.id, rule_id="RTC-R4").count() == 1
assert session.query(RtcWriteAudit).filter_by(installation_id=installation.id).count() == 0
+23 -1
View File
@@ -5,7 +5,7 @@ from sqlalchemy.dialects import postgresql
from central.app.audit import record_audit
from central.app.blocked_products import upsert_blocker
from central.app.control_plane import _blocker_breakdown_query, blocker_filter_summary, dashboard_summary, jobs_status_summary, list_blockers, list_installations, list_jobs
from central.app.control_plane import _blocker_breakdown_query, blocker_filter_summary, dashboard_summary, jobs_status_summary, list_blockers, list_installations, list_jobs, rtc_blocker_summary
from central.app.config import settings
from central.app.events import record_event
from central.app.models import Agent, AgentEnrollment, AgentRelease, AgentSnapshot, AuditEvent, BlockedProduct, Installation, Job, Tenant
@@ -238,6 +238,28 @@ def test_jobs_status_kpis_count_active_jobs_by_status_in_one_group():
assert jobs_status_summary(session, scope="all")["FAILED"] == 2
def test_rtc_blocker_summary_counts_products_and_simple_reason_server_side():
factory = setup_db()
with factory() as session:
_, agent, installation, _ = _installation(session, 1)
for index in range(521):
session.add(BlockedProduct(
tenant_id=installation.tenant_id, installation_id=installation.id, company="Client 1",
product_code=f"RTC-{index:04d}", description="RTC item", blocker_type="BLOCKED_FISCAL_DATA",
field="RTC", reason_code="CCLASSTRIB_MISSING_UNRESOLVED",
evidence_summary={"reason": "RTC-R1:SIMPLES_ORIENTATION_PENDING_OFFICIAL_NT"},
input_fingerprint=f"rtc-{index}", blocker_fingerprint=f"rtc-blocker-{index}",
first_seen_at=NOW, last_seen_at=NOW,
))
session.commit()
summary = rtc_blocker_summary(session, agent.id)
assert summary["open_blockers"] == 521
assert summary["products_affected"] == 521
assert summary["reason_counts"] == {"RTC-R1:SIMPLES_ORIENTATION_PENDING_OFFICIAL_NT": 521}
def test_attention_groups_rank_high_priority_before_limiting_recent_low_priority():
factory = setup_db()
with factory() as session: