"""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"} 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"}) _RTC_LABELS = { "CCLASSTRIB_MISSING": "cClassTrib ausente", "CCLASSTRIB_INVALID": "cClassTrib inválido", "CCLASSTRIB_NOT_IN_CATALOG": "cClassTrib fora do catálogo local", "RTC_CST_INVALID": "CST IBS/CBS inválido", "PIS_CST_MISSING": "CST PIS ausente", "PIS_CST_INVALID": "CST PIS inválido", "PIS_RATE_INVALID": "Alíquota PIS inválida", "COFINS_CST_MISSING": "CST COFINS ausente", "COFINS_CST_INVALID": "CST COFINS inválido", "COFINS_RATE_INVALID": "Alíquota COFINS inválida", "PIS_COFINS_FLAG_INCONSISTENT": "Flag PIS/COFINS inconsistente", } 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", **_RTC_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) 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 expanded_scope_view(summary): """Panel-ready view of the Agent's `expanded_scope` job summary (legacy and expanded coverage side by side). None when absent/unavailable.""" if not isinstance(summary, dict) or "expanded_scope" not in summary: return None legacy, expanded, total = summary.get("legacy_scope") or {}, summary["expanded_scope"] or {}, summary.get("total_products") or 0 percent = lambda part: round(100 * part / total, 1) if total else None return {"total": total, "legacy": legacy, "expanded": expanded, "legacy_percent": percent(legacy.get("complete", 0)), "expanded_percent": percent(expanded.get("complete", 0)), "blockers": [{"type": key, "label": blocker_type_label(key), "count": count} for key, count in (summary.get("blockers_by_type") or {}).items()], "capabilities": summary.get("capabilities") or {}, "local_catalog_only": bool(summary.get("local_catalog_only"))}