From 95605c2ff53dc577ed539e5f502cc89e9a074b45 Mon Sep 17 00:00:00 2001 From: gti Date: Fri, 25 Sep 2026 16:04:29 -0300 Subject: [PATCH] feat: explain RTC blockers when no R3 candidates are eligible --- central/app/control_plane.py | 13 ++++ central/app/panel_presentation.py | 4 ++ central/app/web.py | 19 ++++-- central/static/control-panel.css | 6 ++ central/templates/rtc_control.html | 12 ++++ central/tests/test_control_panel_pages.py | 64 +++++++++++++++++++ central/tests/test_operational_view_models.py | 24 ++++++- 7 files changed, 137 insertions(+), 5 deletions(-) diff --git a/central/app/control_plane.py b/central/app/control_plane.py index 5a34696..124286f 100644 --- a/central/app/control_plane.py +++ b/central/app/control_plane.py @@ -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) diff --git a/central/app/panel_presentation.py b/central/app/panel_presentation.py index 2a1d6fd..e2b3ca3 100644 --- a/central/app/panel_presentation.py +++ b/central/app/panel_presentation.py @@ -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")} diff --git a/central/app/web.py b/central/app/web.py index 9495167..0ed6828 100644 --- a/central/app/web.py +++ b/central/app/web.py @@ -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() diff --git a/central/static/control-panel.css b/central/static/control-panel.css index 392352f..7bd8f96 100644 --- a/central/static/control-panel.css +++ b/central/static/control-panel.css @@ -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; } diff --git a/central/templates/rtc_control.html b/central/templates/rtc_control.html index 5c0e49d..531c91a 100644 --- a/central/templates/rtc_control.html +++ b/central/templates/rtc_control.html @@ -15,6 +15,18 @@
Catálogo incompatível{{ rtc.products.catalog_incompatible }}
{% endif %} +{% if rtc_empty_state.show %} +
+
+

Não há produtos elegíveis para preenchimento automático.

+

{{ 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.

+ {% if rtc_empty_state.simple_orientation_dominant %} +

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.

+ {% endif %} +
+ Ver produtos bloqueados ({{ rtc_empty_state.blocked_products }}) +
+{% endif %}

Modo de preenchimento

Decidido centralmente para esta instalação. Padrão: Desativada. Nenhuma configuração local no servidor do cliente.

diff --git a/central/tests/test_control_panel_pages.py b/central/tests/test_control_panel_pages.py index 7e067d8..f6dd76e 100644 --- a/central/tests/test_control_panel_pages.py +++ b/central/tests/test_control_panel_pages.py @@ -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", "APPLIED", "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&agent_id=' + str(agent_id) + '&field=RTC&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 diff --git a/central/tests/test_operational_view_models.py b/central/tests/test_operational_view_models.py index ee46489..791abab 100644 --- a/central/tests/test_operational_view_models.py +++ b/central/tests/test_operational_view_models.py @@ -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: