Release v4928.1.4.2 stable
This commit is contained in:
235
app/admin_ui/view_models/operations.py
Normal file
235
app/admin_ui/view_models/operations.py
Normal file
@@ -0,0 +1,235 @@
|
||||
"""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
|
||||
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 item.get("customer_name") or "").strip()
|
||||
if fiscal and not _is_numeric_reference(fiscal) and not _looks_like_generic_label(fiscal):
|
||||
return fiscal
|
||||
if contact and not _is_numeric_reference(contact) and not _looks_like_generic_label(contact):
|
||||
return contact
|
||||
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:
|
||||
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_ambiguous(item: dict[str, Any]) -> bool:
|
||||
return str(item.get("opportunity_linking_status") or "").lower() == "ambiguous"
|
||||
|
||||
|
||||
def is_overdue(item: dict[str, Any]) -> bool:
|
||||
created = item.get("created_at")
|
||||
if not created:
|
||||
return False
|
||||
if isinstance(created, str):
|
||||
try:
|
||||
created_dt = datetime.fromisoformat(created.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return False
|
||||
else:
|
||||
created_dt = created
|
||||
if created_dt.tzinfo is None:
|
||||
created_dt = created_dt.replace(tzinfo=timezone.utc)
|
||||
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 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", "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"
|
||||
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_ambiguous(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,
|
||||
}
|
||||
Reference in New Issue
Block a user