"""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_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", "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 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", } 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, mode="DISABLED"): """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"]), context.get("cclasstrib_applicability")) return {"products": products, "apply": _rtc_apply_view(summary.get("rtc_auto_apply"), mode), "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, cclasstrib_applicability=None): """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: return None if isinstance(cclasstrib_applicability, dict) and cclasstrib_applicability.get("state") == "NOT_APPLICABLE": label = "cClassTrib: Aguardando confirmação do regime da empresa" if cclasstrib_applicability.get("basis") == "COMPANY_CRT_UNRESOLVED" else "cClassTrib: Não utilizado neste cliente" return {"applicable": False, "label": label} if 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)) 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 {"applicable": True, "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")} RTC_MODE_LABELS = {"DISABLED": "Desativada", "DRY_RUN": "Dry-run", "APPROVAL_REQUIRED": "Aprovação manual", "ENABLED": "Automática"} RTC_MODE_EXPLANATION = "Somente cClassTrib com regra fiscal determinística e catálogo validado pode ser preenchido automaticamente." def _rtc_apply_view(report, mode): """State of the (default DISABLED) centrally controlled fill. Never claims a write happened unless the Agent reported it.""" mode = mode if mode in RTC_MODE_LABELS else "DISABLED" 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 ""