feat: explain RTC blockers when no R3 candidates are eligible
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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&agent_id={{ agent_id }}&field=RTC&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 }}">
|
||||
|
||||
@@ -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&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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user