- resolver (rules RTC-R0..R4/A1..A3/G1..G2) over the official cClassTrib table 2026-06-22 and LC 214/2025 annexes; local vs official catalog comparison - ONE root-cause RTC blocker per product (missing/unresolved/ambiguous/conflict/official mismatch/catalog outdated/evidence missing) - auto-apply: C000025.CCLASSTRIB only, three switches (FIREBIRD_WRITES + RTC_AUTO_APPLY_ENABLED + Hub authorization), per-product re-read and re-decision, never overwrites, rowcount 1, audit table rtc_write_audits (migration 0027) - copy-only write E2E (280/280 applied, only CCLASSTRIB changed, 2nd run 0 writes, stale decision 0 writes); real databases untouched - docs: cclasstrib_resolution, rtc_auto_apply, unified_fiscal_completeness Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
210 lines
10 KiB
Python
210 lines
10 KiB
Python
"""Human-readable values used by the operational control panel."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
import os
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from .config import settings
|
|
|
|
|
|
def _as_utc(value: datetime | None) -> datetime | None:
|
|
if value is None:
|
|
return None
|
|
if value.tzinfo is None:
|
|
value = value.replace(tzinfo=timezone.utc)
|
|
return value.astimezone(timezone.utc)
|
|
|
|
|
|
def _ui_timezone() -> ZoneInfo:
|
|
return ZoneInfo(os.getenv("UI_TIMEZONE", settings.ui_timezone))
|
|
|
|
|
|
def format_ui_datetime(value: datetime | None) -> str:
|
|
"""Format a stored timestamp in the configured UI timezone."""
|
|
value = _as_utc(value)
|
|
if value is None:
|
|
return "-"
|
|
return value.astimezone(_ui_timezone()).strftime("%d/%m/%Y %H:%M")
|
|
|
|
|
|
def format_age(value: datetime | None, now: datetime | None = None) -> str:
|
|
value = _as_utc(value)
|
|
if value is None:
|
|
return "-"
|
|
now = _as_utc(now or datetime.now(timezone.utc))
|
|
seconds = int((now - value).total_seconds())
|
|
if seconds <= 0:
|
|
return "agora"
|
|
minutes = seconds // 60
|
|
if minutes < 1:
|
|
return "agora"
|
|
if minutes < 60:
|
|
return f"há {minutes} min"
|
|
return f"há {minutes // 60} h"
|
|
|
|
|
|
def format_duration(started_at: datetime | None, finished_at: datetime | None, now: datetime | None = None) -> str:
|
|
del now
|
|
started_at = _as_utc(started_at)
|
|
if started_at is None:
|
|
return "-"
|
|
if finished_at is None:
|
|
return "em andamento"
|
|
elapsed = max(0, int((_as_utc(finished_at) - started_at).total_seconds()))
|
|
hours, remainder = divmod(elapsed, 3600)
|
|
minutes, seconds = divmod(remainder, 60)
|
|
parts = []
|
|
if hours:
|
|
parts.append(f"{hours} h")
|
|
if minutes:
|
|
parts.append(f"{minutes} min")
|
|
if seconds or not parts:
|
|
parts.append(f"{seconds} s")
|
|
return " ".join(parts)
|
|
|
|
|
|
def _blocker_value(blocker, name: str) -> str:
|
|
if isinstance(blocker, dict):
|
|
return str(blocker.get(name) or "").upper()
|
|
return str(getattr(blocker, name, "") or "").upper()
|
|
|
|
|
|
def operational_priority(blocker) -> str:
|
|
blocker_type = _blocker_value(blocker, "blocker_type")
|
|
field = _blocker_value(blocker, "field")
|
|
reason = _blocker_value(blocker, "reason_code")
|
|
operational_text = f"{blocker_type} {reason}"
|
|
if blocker_type in {"TECHNICAL_FAILURE", "CONNECTION_ERROR", "AGENT_OFFLINE", "JOB_FAILED"} or any(token in operational_text for token in ("TECHNICAL", "CONNECTION", "TIMEOUT")):
|
|
return "CRITICAL"
|
|
if blocker_type in {"BLOCKED_MISSING_EVIDENCE", "MISSING_EVIDENCE"}:
|
|
return "HIGH"
|
|
if field in {"CFOP", "ST", "ICMS", "CSOSN", "NCM", "CEST", "PIS", "COFINS", "RTC"} or reason in {"INSUFFICIENT_EVIDENCE", "NO_EVIDENCE"}:
|
|
return "NORMAL"
|
|
return "LOW"
|
|
|
|
|
|
_HEALTH_LABELS = {
|
|
"ONLINE": "Online",
|
|
"DEGRADED": "Degradado",
|
|
"OFFLINE": "Offline",
|
|
"FAILED": "Falha",
|
|
"UNKNOWN": "Desconhecido",
|
|
}
|
|
|
|
|
|
def health_label(value) -> str:
|
|
if value is None or value == "":
|
|
return "-"
|
|
text = str(value)
|
|
return _HEALTH_LABELS.get(text.upper(), text)
|
|
|
|
|
|
def _human_label(value, labels):
|
|
if not value:
|
|
return "Não informado"
|
|
text = str(value).upper()
|
|
return labels.get(text, str(value).replace("_", " ").lower().capitalize())
|
|
|
|
|
|
def fiscal_status_label(value):
|
|
return _human_label(value, {"BLOCKED": "Bloqueado", "COMPLETE": "Completo", "INCOMPLETE": "Incompleto", "UNKNOWN": "Não informado"})
|
|
|
|
|
|
def job_status_label(value):
|
|
return _human_label(value, {"PENDING": "Pendente", "RUNNING": "Executando", "COMPLETED": "Concluído", "FAILED": "Falhou"})
|
|
|
|
|
|
def job_kind_label(value):
|
|
return _human_label(value, {"SYNC_INCREMENTAL": "Sincronização incremental", "INITIAL_SYNC": "Sincronização inicial", "REPROCESS_ALL": "Reprocessar tudo", "REPROCESS_PRODUCTS": "Reprocessar produtos"})
|
|
|
|
|
|
_FISCAL_LABELS = {
|
|
"CCLASSTRIB_MISSING": "cClassTrib ausente", "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",
|
|
"COFINS_CST_MISSING": "CST COFINS ausente", "COFINS_CST_INVALID": "CST COFINS inválido", "COFINS_RATE_MISSING": "Alíquota COFINS ausente", "COFINS_RATE_INVALID": "Alíquota COFINS inválida", "COFINS_CST_INCIDENCE_MISMATCH": "CST COFINS incompatível com a incidência",
|
|
"PIS_COFINS_FLAG_INCONSISTENT": "Flag PIS/COFINS inconsistente", "BLOCKED_FISCAL_DATA": "Dado fiscal do cadastro",
|
|
}
|
|
|
|
|
|
def blocker_type_label(value):
|
|
return _human_label(value, {"BLOCKED_MISSING_EVIDENCE": "Evidência fiscal ausente", "MISSING_EVIDENCE": "Evidência fiscal ausente", "TECHNICAL_FAILURE": "Falha técnica", "CONNECTION_ERROR": "Erro de conexão", "OTHER": "Outro", **_FISCAL_LABELS})
|
|
|
|
|
|
_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",
|
|
"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",
|
|
}
|
|
|
|
|
|
def blocker_reason_label(value):
|
|
return _human_label(value, {**_REASON_LABELS, **_FISCAL_LABELS})
|
|
|
|
|
|
def health_reason_label(value):
|
|
"""Operator wording for the technical health reason strings produced by compute_agent_health."""
|
|
text = str(value or "")
|
|
if text.startswith("last heartbeat exceeds"):
|
|
return "Sem heartbeat recente"
|
|
return {"no heartbeat received": "Nenhum heartbeat recebido", "persistent technical failure": "Falha técnica persistente",
|
|
"recent technical or job failure": "Falha técnica ou de job recente",
|
|
"recent heartbeat without critical technical failure": "Operando normalmente"}.get(text, text or "Não informado")
|
|
|
|
|
|
def priority_label(value):
|
|
return _human_label(value, {"CRITICAL": "Crítica", "HIGH": "Alta", "NORMAL": "Média", "MEDIUM": "Média", "LOW": "Baixa"})
|
|
|
|
|
|
def rtc_status_view(summary, authorized=False):
|
|
"""Discreet Reforma Tributaria status from the last completed job's `fiscal_context`, exactly as the Agent OBSERVED it in that installation's own database.
|
|
None when the Agent did not report it (older Agents)."""
|
|
context = (summary or {}).get("fiscal_context") if isinstance(summary, dict) else None
|
|
if not isinstance(context, dict) or "rtc_enabled" not in context:
|
|
return None
|
|
observed = None
|
|
if context.get("rtc_observed_at"):
|
|
try:
|
|
observed = format_ui_datetime(datetime.fromisoformat(str(context["rtc_observed_at"]).replace("Z", "+00:00")))
|
|
except ValueError:
|
|
observed = None
|
|
source = context.get("rtc_source") or "PARAMETROS.USAR_REFORMA_TRIBUTARIA"
|
|
products = _rtc_products_view(context.get("rtc_resolution"), bool(context["rtc_enabled"]))
|
|
return {"products": products, "apply": _rtc_apply_view(summary.get("rtc_auto_apply"), authorized), "enabled": bool(context["rtc_enabled"]), "label": "Ativa" if context["rtc_enabled"] else "Desativada", "supported": bool(context.get("rtc_supported")),
|
|
"catalog_valid": bool(context.get("rtc_catalog_valid")), "observed_at": observed, "conflict": bool(context.get("rtc_param_conflict")),
|
|
"title": f"Lido no banco do cliente ({source})" + (f" em {observed}" if observed else "") + (" - linhas de PARAMETROS divergentes: RTC tratada como desativada" if context.get("rtc_param_conflict") else "")}
|
|
|
|
|
|
def _rtc_products_view(resolution, enabled):
|
|
"""Compact 'Produtos RTC' summary. None when RTC is off (RTC never shows up as a problem) or the Agent did not report a resolution."""
|
|
if not enabled or not isinstance(resolution, dict) or not resolution.get("total"):
|
|
return None
|
|
total = int(resolution["total"])
|
|
valid = int(resolution.get("valid_existing", 0))
|
|
ready = int(resolution.get("ready_to_apply", 0))
|
|
blocked = total - valid - ready
|
|
pending = total - valid
|
|
return {"total": total, "valid": valid, "ready": ready, "blocked": blocked, "pending": pending,
|
|
"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")}
|
|
|
|
|
|
def _rtc_apply_view(report, authorized):
|
|
"""State of the (default OFF) automatic fill, for the operator. Never claims a write happened unless the Agent reported it."""
|
|
gate = (report or {}).get("gate") or {}
|
|
applied = len((report or {}).get("applied") or [])
|
|
missing = gate.get("missing") or []
|
|
labels = {"FIREBIRD_WRITES": "escrita no Firebird desligada no Agent", "RTC_AUTO_APPLY_ENABLED": "chave RTC_AUTO_APPLY_ENABLED desligada no Agent", "HUB_AUTHORIZATION": "sem autorização no Hub"}
|
|
if gate.get("allowed"):
|
|
state = "Ativo"
|
|
elif authorized:
|
|
state = "Autorizado no Hub, aguardando o Agent (" + "; ".join(labels.get(m, m) for m in missing if m != "HUB_AUTHORIZATION") + ")" if any(m != "HUB_AUTHORIZATION" for m in missing) else "Autorizado no Hub"
|
|
else:
|
|
state = "Desativado"
|
|
return {"authorized": bool(authorized), "state": state, "applied_last_job": applied, "aborted_last_job": len((report or {}).get("aborted") or []), "mode": (report or {}).get("mode")}
|