"""View model for the operator workbench. This module is intentionally UI-focused: it does not change workflow rules, it only prepares the data returned by operations_service for full-page and HTMX partial rendering. """ from __future__ import annotations from datetime import datetime, timezone from typing import Any from app.operations_service import get_operations_summary from app.admin_ui.labels import primary_action_label, queue_label from app.admin_ui.guidance import item_requires_fiscal_customer, work_item_blockers # Legacy label kept for regression context: Ambíguas. OPERATION_FILTERS = [ ("all", "A fazer", "/operations?scope=all", "/operations/partials/work-items?scope=all"), ("bloqueadas", "Bloqueadas", "/operations?scope=bloqueadas", "/operations/partials/work-items?scope=bloqueadas"), ("ambiguas", "Associações por confirmar", "/operations?scope=ambiguas", "/operations/partials/work-items?scope=ambiguas"), ("atrasadas", "Atrasadas", "/operations?scope=atrasadas", "/operations/partials/work-items?scope=atrasadas"), ("vendas", "Vendas", "/operations?scope=vendas", "/operations/partials/work-items?scope=vendas"), ("financeiro", "Financeiro", "/operations?scope=financeiro", "/operations/partials/work-items?scope=financeiro"), ("logistica", "Logística", "/operations?scope=logistica", "/operations/partials/work-items?scope=logistica"), ("revisao", "Revisão", "/operations?scope=revisao", "/operations/partials/work-items?scope=revisao"), ("done", "Concluídas hoje", "/tasks?status=done", "/tasks/partials/list?status=done"), ] def _is_numeric_reference(value: Any) -> bool: value = str(value or "").strip() return bool(value) and value.isdigit() def is_classification_failed(item: dict[str, Any]) -> bool: detail = str(item.get("detail") or "").casefold() return "classificação da mensagem falhou" in detail or "classificacao da mensagem falhou" in detail def has_no_commercial_opportunity(item: dict[str, Any]) -> bool: reason = str(item.get("no_opportunity_reason") or "").strip() action_code = str(item.get("action_code") or "").upper() return bool(reason) or action_code in {"REVIEW_MANUALLY", "REMOVE_FROM_LIST", "IGNORE_SPAM", "NO_ACTION"} def _looks_like_generic_label(value: str) -> bool: value = " ".join(str(value or "").strip().casefold().split()) return value in {"", "geral", "cliente", "contacto", "sem nome"} # Legacy label intentionally no longer used as primary title: "Contacto Chatwoot #". # It is only suitable as technical metadata/fallback, not as operator identity. def operation_card_title(item: dict[str, Any]) -> str: if str(item.get("source") or "") == "outbox": return str(item.get("customer_name") or item.get("title") or "Integração") fiscal = str(item.get("fiscal_customer_name") or "").strip() contact = str(item.get("contact_display_name") or "").strip() customer = str(item.get("customer_name") or "").strip() process_hint = str(item.get("process_customer_hint") or "").strip() identity_status = str(item.get("contact_identity_status") or "").strip().lower() if fiscal and not _is_numeric_reference(fiscal) and not _looks_like_generic_label(fiscal): return fiscal if identity_status == "unsafe" and process_hint and not _is_numeric_reference(process_hint): return process_hint if contact and not _is_numeric_reference(contact) and not _looks_like_generic_label(contact): return contact if customer and not _is_numeric_reference(customer) and not _looks_like_generic_label(customer): return customer if process_hint and not _is_numeric_reference(process_hint): return process_hint return "Contacto sem identificação" def operation_card_subtitle(item: dict[str, Any]) -> str: if is_classification_failed(item): return "Classificação por rever" reason = str(item.get("no_opportunity_reason") or "").strip() if reason in {"system_or_bounce_message", "bounce_ignored"}: return "Mensagem automática / bounce" if reason: return "Sem oportunidade comercial" opportunity = str(item.get("opportunity_title") or "").strip() if opportunity: return opportunity if str(item.get("source") or "") == "outbox": return str(item.get("title") or "Integração") if str(item.get("action_code") or "").upper() in {"REMOVE_FROM_LIST", "IGNORE_SPAM", "NO_ACTION"}: return "Sem oportunidade comercial" conv = str(item.get("conversation_id") or "").strip() if operation_card_title(item) == "Contacto sem identificação" and conv: return f"Conversa Chatwoot #{conv}" return "Sem oportunidade associada" def operation_has_useful_details(item: dict[str, Any]) -> bool: if str(item.get("source") or "") == "outbox": return True if str(item.get("opportunity_id") or "").strip(): return True if str(item.get("fiscal_customer_name") or "").strip(): return True if item_requires_fiscal_customer(item): return True if work_item_blockers(item): return True return False def operation_card_detail(item: dict[str, Any]) -> str: if is_classification_failed(item): return "O sistema não conseguiu classificar com segurança. Abrir no Chatwoot e escolher a ação correta." reason = str(item.get("no_opportunity_reason") or "").strip() if reason in {"system_or_bounce_message", "bounce_ignored"}: return "Mensagem automática detetada. Não entra no fluxo operacional." if reason == "send_info_without_clear_commercial_intent": return "Responder sem criar oportunidade até existir intenção comercial clara." return str(item.get("detail") or item.get("request_text") or "").strip() def operation_status_chip(item: dict[str, Any]) -> str: if is_association_review(item): return "associação por confirmar" if str(item.get("action_code") or "").upper() == "REVIEW_RECONSTRUCTED_PROCESS": return "validação obrigatória" blockers = work_item_blockers(item) if blockers: return "bloqueada" if is_high_priority(item): return "prioridade alta" if has_no_commercial_opportunity(item): return "sem oportunidade comercial" return "normal" def is_association_review(item: dict[str, Any]) -> bool: linking = str(item.get("opportunity_linking_status") or "").lower() code = str(item.get("action_code") or "").upper() return linking in {"ambiguous", "review_required"} or code == "ASSOCIATE_OPPORTUNITY" def is_ambiguous(item: dict[str, Any]) -> bool: return is_association_review(item) def _parse_dt(value: Any) -> datetime | None: if not value: return None if isinstance(value, str): try: dt = datetime.fromisoformat(value.replace("Z", "+00:00")) except ValueError: return None else: dt = value if getattr(dt, "tzinfo", None) is None: dt = dt.replace(tzinfo=timezone.utc) return dt def is_scheduled_for_future(item: dict[str, Any]) -> bool: action_code = str(item.get("action_code") or "").upper() if not action_code.startswith("FOLLOW_UP_"): return False due_dt = _parse_dt(item.get("due_at")) return bool(due_dt and due_dt > datetime.now(timezone.utc)) def is_overdue(item: dict[str, Any]) -> bool: due_dt = _parse_dt(item.get("due_at")) if due_dt is not None: return due_dt < datetime.now(timezone.utc) created_dt = _parse_dt(item.get("created_at")) if not created_dt: return False queue = str(item.get("queue") or "").lower() hours = {"suporte": 2, "vendas": 4, "financeiro": 8, "operacoes": 24, "logistica": 24, "rever": 24}.get(queue, 24) return (datetime.now(timezone.utc) - created_dt).total_seconds() > hours * 3600 def operation_due_label(item: dict[str, Any], *, now: datetime | None = None) -> str: now = now or datetime.now(timezone.utc) due_dt = _parse_dt(item.get("due_at")) if due_dt is not None: seconds = (due_dt - now).total_seconds() days = int(abs(seconds) // 86400) hours = max(1, int(abs(seconds) // 3600)) if seconds < 0: if days >= 1: return f"Vencida há {days} dia(s)" return f"Vencida há {hours} h" if days >= 1: return f"Vence em {days} dia(s)" return f"Vence em {hours} h" created_dt = _parse_dt(item.get("created_at")) if created_dt is None: return "Sem prazo" age_seconds = max((now - created_dt).total_seconds(), 0) age_days = int(age_seconds // 86400) if age_days >= 1: return f"Criada há {age_days} dia(s)" return f"Criada há {max(1, int(age_seconds // 3600))} h" def operation_value(item: dict[str, Any]) -> float: for key in ("opportunity_value_amount", "amount", "total_amount"): try: value = float(item.get(key) or 0) except (TypeError, ValueError): continue if value > 0: return value return 0.0 def operation_queue_label(item: dict[str, Any]) -> str: return queue_label(item.get("queue")) def operation_priority_reason(item: dict[str, Any]) -> str: if is_association_review(item): return "Bloqueia a ligação segura do processo" if str(item.get("action_code") or "").upper() == "REVIEW_RECONSTRUCTED_PROCESS": return "Validação necessária antes de ação sensível" if is_overdue(item): return "Prazo ultrapassado" if str(item.get("source") or "").lower() == "outbox": return "Integração exige intervenção" if str(item.get("queue") or "").lower() == "suporte": return "Cliente aguarda resposta de suporte" value = operation_value(item) if value > 0 and str(item.get("queue") or "").lower() in {"financeiro", "vendas"}: return f"Impacto comercial/financeiro de {value:.2f} EUR" return "Prioridade definida pela fila operacional" def is_high_priority(item: dict[str, Any]) -> bool: status = str(item.get("status") or "").lower() source = str(item.get("source") or "").lower() queue = str(item.get("queue") or "").lower() action_code = str(item.get("action_code") or "").upper() priority = str(item.get("priority") or "").lower() if is_ambiguous(item): return True if source == "outbox" and status in {"failed", "blocked", "stale"}: return True if is_overdue(item): return True if is_classification_failed(item): return False if queue == "marketing" or action_code in {"REMOVE_FROM_LIST", "IGNORE_SPAM", "NO_ACTION", "REVIEW_MANUALLY"}: return False if action_code in {"SEND_PROFORMA", "SEND_INVOICE", "CONFIRM_PAYMENT", "PREPARE_ORDER", "VALIDATE_PHYSICAL_ORDER", "CREATE_SHIPMENT"}: return priority in {"alta", "high", "urgente", "critical"} or status in {"failed", "blocked"} return priority in {"alta", "high", "urgente"} or status in {"failed", "blocked"} def is_blocked(item: dict[str, Any]) -> bool: status = str(item.get("status") or "").lower() source = str(item.get("source") or "").lower() return status in {"failed", "blocked", "stale"} or source == "outbox" or bool(work_item_blockers(item)) def operation_item_matches_scope(item: dict[str, Any], scope: str) -> bool: queue = str(item.get("queue") or "").lower() action_code = str(item.get("action_code") or "").upper() scope = str(scope or "all").strip().lower() if scope in {"", "all", "todas"}: return True if scope == "bloqueadas": return is_blocked(item) if scope in {"ambiguas", "ambíguas"}: return is_ambiguous(item) if scope == "atrasadas": return is_overdue(item) if scope == "logistica": return queue in {"operacoes", "logistica", "logística"} if scope == "revisao": return queue in {"rever", "revisao", "revisão"} or action_code == "REVIEW_MANUALLY" return queue == scope def operation_primary_label(item: dict[str, Any]) -> str: if is_classification_failed(item): return "Rever mensagem" if str(item.get("source") or "") == "outbox": status = str(item.get("status") or "").lower() return "Reprocessar" if status == "failed" else "Ver bloqueio" if is_association_review(item): source = str(item.get("source_system") or "").strip().lower() if source == "jasmin": return "Validar associação do documento" if source == "odoo": return "Validar associação da venda" return "Validar associação" return primary_action_label(item.get("action_code") or item.get("action_label"), fallback="Abrir") def operation_origin_label(item: dict[str, Any]) -> str: source = str(item.get("source_system") or item.get("source") or "").strip() conv = str(item.get("conversation_id") or "").strip() if conv: return f"Chatwoot #{conv}" if source: return source return "ClientFlow" def build_operations_view_model(scope: str = "all", *, limit: int = 30) -> dict[str, Any]: data = get_operations_summary(limit=limit) counts = data.get("counts") or {} scope = str(scope or "all").strip().lower() work_items = list(data.get("work_items") or []) visible_items = [item for item in work_items if operation_item_matches_scope(item, scope)] high_items = [item for item in visible_items if is_high_priority(item)] normal_items = [item for item in visible_items if not is_high_priority(item)] review_total = int(counts.get("review_tasks", 0) or 0) + int(counts.get("communications_needs_review", 0) or 0) blocked_total = int(counts.get("blocked_outbox", 0) or 0) + sum(1 for item in work_items if is_blocked(item)) ambiguous_total = sum(1 for item in work_items if is_association_review(item)) overdue_total = int(counts.get("overdue_tasks", 0) or 0) + sum(1 for item in work_items if item.get("source") == "outbox" and str(item.get("status") or "").lower() in {"failed", "stale"}) return { "scope": scope, "counts": counts, "review_total": review_total, "blocked_total": blocked_total, "ambiguous_total": ambiguous_total, "overdue_total": overdue_total, "today_label": datetime.now(timezone.utc).strftime("%d/%m/%Y"), "filters": OPERATION_FILTERS, "visible_items": visible_items, "high_items": high_items, "normal_items": normal_items, }