feat: re-check central RTC authorization right before every write (GET /v1/agents/me/authorizations), human labels on the cClassTrib control page, final corpus/scale evidence; 1.2.1
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -252,6 +252,16 @@ def router(session_factory, bootstrap_token):
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@routes.get("/agents/me/authorizations")
|
||||
def agent_authorizations(identity=Depends(principal)):
|
||||
"""Current central RTC write authorization of the authenticated Agent's own installation (read by the Agent right before it writes). Nothing else is exposed."""
|
||||
session, agent = identity
|
||||
try:
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent.id))
|
||||
return job_authorizations(session, installation) if installation is not None else {"rtc_write_mode": "DISABLED"}
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@routes.get("/agents/me")
|
||||
def agent_status(identity=Depends(principal)):
|
||||
session, agent = identity
|
||||
|
||||
@@ -121,7 +121,7 @@ def job_kind_label(value):
|
||||
|
||||
|
||||
_FISCAL_LABELS = {
|
||||
"CCLASSTRIB_MISSING": "cClassTrib ausente", "CCLASSTRIB_MISSING_UNRESOLVED": "cClassTrib não determinado", "CCLASSTRIB_AMBIGUOUS": "cClassTrib ambíguo", "CCLASSTRIB_CONFLICT": "cClassTrib conflitante",
|
||||
"CCLASSTRIB_MISSING": "cClassTrib ausente", "CCLASSTRIB_MISSING_UNRESOLVED": "cClassTrib não determinado", "CCLASSTRIB_AMBIGUOUS": "cClassTrib ambíguo", "CCLASSTRIB_CONFLICT": "cClassTrib existente diverge da classificação proposta",
|
||||
"CCLASSTRIB_OFFICIAL_MISMATCH": "cClassTrib diverge da tabela oficial", "RTC_LOCAL_CATALOG_INCOMPATIBLE": "Catálogo RTC incompatível", "RTC_RULE_EVIDENCE_MISSING": "Regra fiscal insuficiente", "CCLASSTRIB_INVALID_FORMAT": "cClassTrib com formato inválido", "CCLASSTRIB_NOT_IN_CATALOG": "cClassTrib fora do catálogo local",
|
||||
"RTC_CST_MISSING": "CST IBS/CBS ausente no catálogo", "RTC_CATALOG_INVALID": "Catálogo RTC local inválido",
|
||||
"PIS_CST_MISSING": "CST PIS ausente", "PIS_CST_INVALID": "CST PIS inválido", "PIS_RATE_MISSING": "Alíquota PIS ausente", "PIS_RATE_INVALID": "Alíquota PIS inválida", "PIS_CST_INCIDENCE_MISMATCH": "CST PIS incompatível com a incidência",
|
||||
@@ -137,7 +137,7 @@ def blocker_type_label(value):
|
||||
_REASON_LABELS = {
|
||||
"INVALID_NCM": "NCM inválido", "LEGACY_NCM": "NCM legado", "LEGACY_NCM_NO_AUTOMATIC_SUCCESSOR": "NCM legado sem sucessor automático",
|
||||
"LEGACY_NCM_REVIEW": "NCM legado em revisão", "LEGACY_NCM_REVIEW_REQUIRED": "NCM legado exige revisão", "LEGACY_OR_INVALID_NCM": "NCM legado ou inválido",
|
||||
"INSUFFICIENT_EVIDENCE": "Evidência insuficiente", "MISSING_EVIDENCE": "Evidência fiscal ausente", "BLOCKED_MISSING_EVIDENCE": "Evidência fiscal ausente",
|
||||
"INSUFFICIENT_EVIDENCE": "Evidência fiscal insuficiente", "MISSING_EVIDENCE": "Evidência fiscal ausente", "BLOCKED_MISSING_EVIDENCE": "Evidência fiscal ausente",
|
||||
"MISSING_CONTEXT": "Contexto fiscal ausente", "NO_EVIDENCE": "Sem evidência", "MISSING_PRODUCT_ORIGIN": "Origem do produto ausente",
|
||||
"MISSING_PROFILE": "Perfil fiscal ausente", "MISSING_ST_DETERMINATION": "Determinação de ST ausente", "NO_IPI_DECISION": "Sem decisão de IPI",
|
||||
"NO_NUMERIC_VALUE": "Sem valor numérico", "CHANGE_REQUIRED": "Alteração necessária", "SAME_VALUE": "Valor já correto", "AMBIGUOUS": "Ambíguo",
|
||||
@@ -207,3 +207,37 @@ def _rtc_apply_view(report, mode):
|
||||
report = report or {}
|
||||
return {"mode": mode, "state": RTC_MODE_LABELS[mode], "explanation": RTC_MODE_EXPLANATION, "applied_last_job": len(report.get("applied") or []), "aborted_last_job": len(report.get("aborted") or []),
|
||||
"ready_total": report.get("ready_total"), "capability": report.get("capability"), "authorized": mode in ("ENABLED", "APPROVAL_REQUIRED")}
|
||||
|
||||
|
||||
# ---- cClassTrib control page (Agent -> cClassTrib): candidate status, audit outcome/reason, rule and catalog wording ----
|
||||
_CANDIDATE_STATUS = {"PENDING": "Pendente", "APPROVED": "Aprovado", "REJECTED": "Rejeitado", "APPLIED": "Aplicado", "STALE": "Obsoleto"}
|
||||
_AUDIT_OUTCOME = {"APPLIED": "Aplicado", "ROLLED_BACK": "Revertido", "ABORTED": "Interrompido sem escrever", "FAILED": "Falha técnica", "ROLLBACK_REQUESTED": "Reversão solicitada"}
|
||||
_RULE_LABELS = {"RTC-R3": "Anexo com classe única (RTC-R3)", "RTC-R4": "Sugestão sem autorização de preenchimento (RTC-R4)", "RTC-A3": "Bloqueada por evidência (RTC-A3)", "RTC-G2": "Bloqueada por evidência (RTC-G2)"}
|
||||
_CATALOG_STATE = {"MATCH_SAFE": "Compatível com a tabela oficial", "MATCH_WITH_NON_MATERIAL_DIFFERENCE": "Diferença sem impacto (não autoriza escrita)", "MISMATCH": "Diverge da tabela oficial",
|
||||
"EXPIRED": "Classe oficial vencida", "MISSING_LOCAL": "Classe oficial ausente no catálogo local do SGV", "MISSING_OFFICIAL": "Classe local ausente na tabela oficial"}
|
||||
_ABORT_REASONS = {"VALUE_ALREADY_SET_NEVER_OVERWRITTEN": "cClassTrib existente diverge da classificação proposta", "NO_WRITE_REQUIRED": "Já preenchido com o valor proposto",
|
||||
"RTC_NO_LONGER_ENABLED": "Reforma tributária desligada no ERP", "RTC_CATALOG_INVALID": "Catálogo RTC local inválido", "LOCAL_CATALOG_CHANGED_SINCE_DECISION": "Catálogo local mudou desde a decisão",
|
||||
"COMPANY_CONTEXT_CHANGED": "Contexto da empresa mudou", "REGIME_NOT_NORMAL": "Regime sem regra segura de preenchimento", "PRODUCT_CHANGED_NCM": "NCM do produto mudou",
|
||||
"PRODUCT_CHANGED_SINCE_DECISION": "Produto mudou desde a decisão", "DECISION_CHANGED": "A classificação proposta mudou", "PRODUCT_NOT_FOUND": "Produto não encontrado",
|
||||
"PRODUCT_ROW_NOT_UNIQUE": "Código de produto duplicado", "UPDATE_ROWCOUNT_NOT_ONE": "Escrita recusada (contagem inesperada)", "ROLLBACK_CONFLICT_VALUE_CHANGED": "Valor alterado depois da escrita: reversão recusada",
|
||||
"ALREADY_EMPTY": "Já estava vazio", "ROLLBACK_REQUEST_INVALID": "Pedido de reversão inválido", "TECHNICAL_FAILURE": "Falha técnica", "AUTHORIZATION_CHANGED": "Autorização central mudou antes da escrita", "AUTHORIZATION_UNVERIFIABLE": "Autorização central não pôde ser confirmada"}
|
||||
|
||||
|
||||
def candidate_status_label(value):
|
||||
return _human_label(value, _CANDIDATE_STATUS)
|
||||
|
||||
|
||||
def audit_outcome_label(value):
|
||||
return _human_label(value, _AUDIT_OUTCOME)
|
||||
|
||||
|
||||
def rtc_rule_label(value):
|
||||
return _human_label(value, _RULE_LABELS)
|
||||
|
||||
|
||||
def catalog_state_label(value):
|
||||
return _human_label(value, _CATALOG_STATE)
|
||||
|
||||
|
||||
def abort_reason_label(value):
|
||||
return _human_label(value, _ABORT_REASONS) if value else ""
|
||||
|
||||
@@ -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.2.0"
|
||||
GTI_FISCAL_VERSION = "1.2.1"
|
||||
API_COMPAT_MAJOR_MINOR = "1.0" # an Agent 1.0.x/1.1.x talks to any Hub 1.0.x
|
||||
|
||||
+3
-2
@@ -15,7 +15,7 @@ from .config import settings
|
||||
from .blocker_resolution import BlockerResolutionUnavailable, _company_key, resolve_blocker_with_context
|
||||
from .context_keys import ALLOWED_KEYS, ENUMS
|
||||
from .installation_identity import reference_agent_version
|
||||
from .panel_presentation import RTC_MODE_EXPLANATION, RTC_MODE_LABELS, job_kind_label, job_status_label, rtc_status_view
|
||||
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 .human_auth import create_session, current_session, require_csrf, verify_password
|
||||
from .panel_presentation import format_ui_datetime
|
||||
@@ -344,7 +344,8 @@ def router(session_factory):
|
||||
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)
|
||||
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,
|
||||
"rows": rows, "counts": counts, "status": status, "page": page, "pages": max(1, -(-total // size)), "total": total, "audits": audits})
|
||||
"rows": rows, "counts": counts, "status": status, "page": page, "pages": max(1, -(-total // size)), "total": total, "audits": audits,
|
||||
"L": {"status": candidate_status_label, "outcome": audit_outcome_label, "reason": abort_reason_label, "rule": rtc_rule_label, "catalog": catalog_state_label}})
|
||||
finally: session.close()
|
||||
|
||||
@routes.post("/agents/{agent_id}/rtc-mode")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "gti-fiscal-hub"
|
||||
version = "1.2.0"
|
||||
version = "1.2.1"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"fastapi>=0.115,<1",
|
||||
|
||||
@@ -25,9 +25,9 @@
|
||||
<section class="panel"><div class="panel-title"><div><h2>Candidatos</h2><p class="muted">{{ total }} produto(s) neste filtro.</p></div></div>
|
||||
<nav class="tabs" aria-label="Filtro de candidatos">{% for st, label in [("PENDING","Pendentes"),("APPROVED","Aprovados"),("REJECTED","Rejeitados"),("APPLIED","Aplicados"),("STALE","Obsoletos")] %}<a class="{% if status==st %}active{% endif %}" href="?status={{ st }}">{{ label }} ({{ counts.get(st, 0) }})</a>{% endfor %}</nav>
|
||||
<form method="post" action="/agents/{{ agent_id }}/rtc-candidates"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<table><thead><tr><th></th><th>Produto</th><th>NCM</th><th>Atual</th><th>Proposto</th><th>CST IBS/CBS</th><th>Regra</th><th>Base</th><th>Status</th></tr></thead><tbody>
|
||||
{% for c in rows %}<tr><td><input type="checkbox" name="ids" value="{{ c.id }}" aria-label="Selecionar {{ c.product_code }}"></td><td>{{ c.product_code }} · {{ c.description or "" }}</td><td>{{ c.ncm }}</td><td>{{ c.current_value or "vazio" }}</td><td>{{ c.proposed }}</td><td>{{ c.cst_ibscbs }}</td><td>{{ c.rule_id }}</td><td>{{ c.basis }}</td><td>{{ c.status }}</td></tr>
|
||||
{% else %}<tr><td colspan="9" class="muted">Nenhum candidato neste filtro.</td></tr>{% endfor %}</tbody></table>
|
||||
<table><thead><tr><th></th><th>Produto</th><th>Descrição</th><th>NCM</th><th>cClassTrib atual</th><th>cClassTrib proposto</th><th>CST IBS/CBS</th><th>Regra</th><th>Fundamento</th><th>Catálogo</th><th>Situação</th></tr></thead><tbody>
|
||||
{% for c in rows %}<tr><td><input type="checkbox" name="ids" value="{{ c.id }}" aria-label="Selecionar {{ c.product_code }}"></td><td>{{ c.product_code }}</td><td>{{ c.description or "" }}</td><td>{{ c.ncm }}</td><td>{{ c.current_value or "vazio" }}</td><td>{{ c.proposed }}</td><td>{{ c.cst_ibscbs }}</td><td>{{ L.rule(c.rule_id) }}</td><td>{{ c.basis }}</td><td>{{ L.catalog("MATCH_SAFE") }}</td><td>{{ L.status(c.status) }}</td></tr>
|
||||
{% else %}<tr><td colspan="10" class="muted">Nenhum candidato neste filtro.</td></tr>{% endfor %}</tbody></table>
|
||||
<p>Página {{ page }} de {{ pages }}{% if page > 1 %} · <a href="?status={{ status }}&page={{ page - 1 }}">anterior</a>{% endif %}{% if page < pages %} · <a href="?status={{ status }}&page={{ page + 1 }}">próxima</a>{% endif %}</p>
|
||||
<p><button type="submit" name="action" value="approve" class="secondary">Aprovar selecionados</button> <button type="submit" name="action" value="reject" class="caution">Rejeitar selecionados</button>
|
||||
<button type="submit" name="action" value="approve" class="secondary" formaction="/agents/{{ agent_id }}/rtc-candidates?all=1">Aprovar todos os pendentes</button>
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
<section class="panel"><div class="panel-title"><div><h2>Auditoria e reversão</h2><p class="muted">Cada preenchimento pode ser revertido individualmente, desde que o valor no ERP ainda seja o gravado.</p></div></div>
|
||||
<table><thead><tr><th>#</th><th>Produto</th><th>Ação</th><th>Resultado</th><th>Antes → Depois</th><th>Regra</th><th>Origem</th><th>Reversão</th></tr></thead><tbody>
|
||||
{% for a in audits %}<tr><td>{{ a.id }}</td><td>{{ a.product_code }} · {{ a.description_snapshot or "" }}</td><td>{{ a.action or "APPLY" }}</td><td>{{ a.outcome }}{% if a.reason %} ({{ a.reason }}){% endif %}</td><td>{{ a.old_value or "vazio" }} → {{ a.new_value or "vazio" }}</td><td>{{ a.rule_id }}</td><td>{{ a.authorization_origin }}</td>
|
||||
{% for a in audits %}<tr><td>{{ a.id }}</td><td>{{ a.product_code }} · {{ a.description_snapshot or "" }}</td><td>{{ "Reversão" if a.action == "ROLLBACK" else "Preenchimento" }}</td><td>{{ L.outcome(a.outcome) }}{% if a.reason %} ({{ L.reason(a.reason) }}){% endif %}</td><td>{{ a.old_value or "vazio" }} → {{ a.new_value or "vazio" }}</td><td>{{ L.rule(a.rule_id) }}</td><td>{{ a.authorization_origin }}</td>
|
||||
<td>{% if a.action == "APPLY" and a.outcome == "APPLIED" and not a.rollback_state %}<form method="post" action="/agents/{{ agent_id }}/rtc-rollback" style="display:inline"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="audit_id" value="{{ a.id }}"><input type="text" name="confirm" placeholder="digite REVERTER" size="12" aria-label="Confirmar reversão"><button type="submit" class="caution">Reverter</button></form>{% else %}{{ a.rollback_state or "" }}{% endif %}</td></tr>
|
||||
{% else %}<tr><td colspan="8" class="muted">Nenhum preenchimento registrado.</td></tr>{% endfor %}</tbody></table></section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -689,3 +689,19 @@ def test_panel_shows_rtc_products_summary_and_the_pending_message_only_when_rtc_
|
||||
assert "Preenchimento automático" in on and "Desativada" in on and f"/agents/{agent_id}/rtc" in on
|
||||
for legacy in ("Escopo anterior", "Escopo ampliado"):
|
||||
assert legacy not in on
|
||||
|
||||
|
||||
def test_rtc_control_page_shows_human_labels_and_a_catalog_column_never_raw_enums():
|
||||
from central.app.panel_presentation import abort_reason_label, audit_outcome_label, candidate_status_label, catalog_state_label, blocker_reason_label, blocker_type_label, rtc_rule_label
|
||||
assert candidate_status_label("APPROVED") == "Aprovado" and audit_outcome_label("ROLLED_BACK") == "Revertido"
|
||||
assert rtc_rule_label("RTC-R4") == "Sugestão sem autorização de preenchimento (RTC-R4)"
|
||||
assert blocker_reason_label("INSUFFICIENT_EVIDENCE") == "Evidência fiscal insuficiente"
|
||||
assert blocker_type_label("CCLASSTRIB_CONFLICT") == "cClassTrib existente diverge da classificação proposta"
|
||||
assert catalog_state_label("MISSING_LOCAL") == "Classe oficial ausente no catálogo local do SGV"
|
||||
assert abort_reason_label("VALUE_ALREADY_SET_NEVER_OVERWRITTEN") == "cClassTrib existente diverge da classificação proposta"
|
||||
client, agent_id, _ = _panel_client()
|
||||
page = client.get(f"/agents/{agent_id}/rtc").text
|
||||
for header in ("cClassTrib atual", "cClassTrib proposto", "Fundamento", "Catálogo", "Situação", "Descrição"):
|
||||
assert header in page
|
||||
for raw in ("PENDING</td>", "APPLIED</td>", "ABORTED"):
|
||||
assert raw not in page
|
||||
|
||||
@@ -17,7 +17,7 @@ def test_health_is_unavailable_when_database_is_down():
|
||||
|
||||
def test_version_endpoint_reports_release_and_fiscal_versions():
|
||||
body = TestClient(create_app(database_probe=lambda: True)).get("/version").json()
|
||||
assert body["version"] == "1.2.0" and body["api_compat"] == "1.0" and body["engine_version"] and body["decision_version"]
|
||||
assert body["version"] == "1.2.1" and body["api_compat"] == "1.0" and body["engine_version"] and body["decision_version"]
|
||||
|
||||
|
||||
def test_production_refuses_the_dev_token(monkeypatch):
|
||||
|
||||
@@ -85,3 +85,15 @@ def test_audit_rows_never_carry_credentials_or_tokens():
|
||||
blob = repr([getattr(row, c.name) for c in RtcWriteAudit.__table__.columns])
|
||||
assert "password" not in blob.lower() and "'t'" not in blob and "Bearer" not in blob
|
||||
assert not any("pass" in c.name or "token" in c.name or "secret" in c.name for c in RtcWriteAudit.__table__.columns)
|
||||
|
||||
|
||||
def test_agent_can_reread_only_its_own_current_authorization_and_it_follows_the_operator():
|
||||
client, engine = _client()
|
||||
hb(client)
|
||||
assert client.get("/v1/agents/me/authorizations").status_code in (401, 403) # requires the Agent credential
|
||||
assert client.get("/v1/agents/me/authorizations", headers=H).json()["rtc_write_mode"] == "DISABLED"
|
||||
with Session(engine) as s:
|
||||
s.scalars(select(Installation)).one().rtc_write_mode = "ENABLED"
|
||||
s.commit()
|
||||
body = client.get("/v1/agents/me/authorizations", headers=H).json()
|
||||
assert body["rtc_write_mode"] == "ENABLED" and set(body) == {"rtc_write_mode", "rtc_approved_codes", "rtc_rollbacks"}
|
||||
|
||||
Reference in New Issue
Block a user