Import ClientFlow production v4928.1.5.132.4
This commit is contained in:
@@ -6,6 +6,7 @@ health checks and unified opportunity timeline without changing business state.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -14,6 +15,12 @@ from sqlalchemy import text
|
||||
from app.config import settings
|
||||
from app.db import engine
|
||||
from app.operation_noise import is_low_value_no_opportunity_item, is_noise_operation_item
|
||||
from app.work_center_action_policy import (
|
||||
RECONSTRUCTED_SENSITIVE_ACTIONS,
|
||||
association_review_required,
|
||||
canonical_action_code,
|
||||
reconstructed_review_required,
|
||||
)
|
||||
|
||||
|
||||
def _int(value: Any) -> int:
|
||||
@@ -77,6 +84,197 @@ def build_chatwoot_conversation_url(conversation_id: Optional[str]) -> str:
|
||||
return f"{base_url}/app/accounts/{account_id}/conversations/{conversation_id}"
|
||||
|
||||
|
||||
|
||||
_IDENTITY_STOPWORDS = {
|
||||
"da", "de", "do", "dos", "das", "e", "lda", "ltda", "unipessoal",
|
||||
"sa", "s.a", "s.a.", "email", "mail", "geral", "info", "office",
|
||||
"frontoffice", "comercial", "vendas", "admin", "contacto", "contact",
|
||||
}
|
||||
|
||||
|
||||
def _norm_identity(value: Any) -> str:
|
||||
return " ".join(re.sub(r"[^0-9a-zA-ZÀ-ÿ]+", " ", str(value or "").casefold()).split())
|
||||
|
||||
|
||||
def _identity_tokens(value: Any) -> set[str]:
|
||||
return {
|
||||
token
|
||||
for token in _norm_identity(value).split()
|
||||
if len(token) >= 3 and token not in _IDENTITY_STOPWORDS
|
||||
}
|
||||
|
||||
|
||||
def _email_tokens(value: Any) -> set[str]:
|
||||
email = str(value or "").strip().casefold()
|
||||
local = email.split("@", 1)[0]
|
||||
return {
|
||||
token
|
||||
for token in re.split(r"[^0-9a-zA-ZÀ-ÿ]+", local)
|
||||
if len(token) >= 3 and token not in _IDENTITY_STOPWORDS
|
||||
}
|
||||
|
||||
|
||||
def _name_matches_email(name: Any, email: Any) -> bool:
|
||||
name_tokens = _identity_tokens(name)
|
||||
email_tokens = _email_tokens(email)
|
||||
if not name_tokens or not email_tokens:
|
||||
return False
|
||||
if name_tokens & email_tokens:
|
||||
return True
|
||||
return any(nt in et or et in nt for nt in name_tokens for et in email_tokens)
|
||||
|
||||
|
||||
def _looks_like_campaign_process(title: Any) -> bool:
|
||||
title_norm = _norm_identity(title)
|
||||
return "carregadores" in title_norm and "para" in title_norm
|
||||
|
||||
|
||||
def _process_customer_hint(title: Any) -> str:
|
||||
"""Extract the intended customer/company from common campaign/process titles.
|
||||
|
||||
This is only a UI safety hint. It must never create/link customers. It helps
|
||||
avoid displaying a contaminated Chatwoot contact name as the card owner.
|
||||
"""
|
||||
value = str(title or "").strip()
|
||||
if not value:
|
||||
return ""
|
||||
# Examples: "Re: Carregadores ... para a Nova Maquiambiente" and
|
||||
# "Processo reconstruído · F.S. Motors".
|
||||
if "·" in value:
|
||||
tail = value.rsplit("·", 1)[-1].strip()
|
||||
if tail and not tail.upper().startswith(("ORC.", "S0")):
|
||||
return tail
|
||||
match = re.search(r"\bpara\s+(?:a|o|as|os|à|ao)?\s*(.+)$", value, re.IGNORECASE)
|
||||
if match:
|
||||
candidate = re.sub(r"\s+", " ", match.group(1)).strip(" .:-–—")
|
||||
# Avoid returning a long quoted email/thread suffix.
|
||||
candidate = re.split(r"\s+(?:de:|from:|enviada:|sent:)", candidate, maxsplit=1, flags=re.IGNORECASE)[0].strip()
|
||||
if 2 <= len(candidate) <= 120:
|
||||
return candidate
|
||||
return ""
|
||||
|
||||
|
||||
def _sanitize_operation_identities(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Prevent polluted Chatwoot sender names from becoming card titles.
|
||||
|
||||
Chatwoot contact names can be reused or contaminated after campaign replies.
|
||||
The workbench must show the real process/customer context, not blindly trust
|
||||
``payload.sender.name``. This function is deliberately UI-only: it does not
|
||||
change tasks, contacts, customers or opportunities.
|
||||
"""
|
||||
name_to_emails: Dict[str, set[str]] = {}
|
||||
for item in items:
|
||||
name = str(item.get("sender_name") or item.get("contact_display_name") or "").strip()
|
||||
email = str(item.get("sender_email") or item.get("customer_email") or "").strip().casefold()
|
||||
key = _norm_identity(name)
|
||||
if key and email:
|
||||
name_to_emails.setdefault(key, set()).add(email)
|
||||
|
||||
repeated_names = {key for key, emails in name_to_emails.items() if len(emails) > 1}
|
||||
|
||||
for item in items:
|
||||
fiscal = str(item.get("fiscal_customer_name") or "").strip()
|
||||
if fiscal:
|
||||
item["contact_identity_status"] = "fiscal_customer"
|
||||
continue
|
||||
|
||||
sender_name = str(item.get("sender_name") or item.get("contact_display_name") or "").strip()
|
||||
sender_email = str(item.get("sender_email") or item.get("customer_email") or "").strip()
|
||||
opportunity_title = str(item.get("opportunity_title") or "").strip()
|
||||
hint = _process_customer_hint(opportunity_title)
|
||||
if hint:
|
||||
item["process_customer_hint"] = hint
|
||||
|
||||
key = _norm_identity(sender_name)
|
||||
reused = bool(key and key in repeated_names)
|
||||
email_match = _name_matches_email(sender_name, sender_email)
|
||||
campaign_context = _looks_like_campaign_process(opportunity_title)
|
||||
|
||||
unsafe = False
|
||||
reason = ""
|
||||
if sender_name and reused:
|
||||
unsafe = True
|
||||
reason = "sender_name_reused_across_emails"
|
||||
elif sender_name and campaign_context and hint and not email_match:
|
||||
unsafe = True
|
||||
reason = "sender_name_not_supported_by_email_or_process"
|
||||
|
||||
if unsafe:
|
||||
item["contact_identity_status"] = "unsafe"
|
||||
item["contact_identity_reason"] = reason
|
||||
# Force operation_card_title to prefer the process/company hint or
|
||||
# email instead of a possibly contaminated person name.
|
||||
item["contact_display_name"] = ""
|
||||
item["customer_name"] = hint or sender_email or "Contacto sem identificação"
|
||||
else:
|
||||
item["contact_identity_status"] = "trusted" if sender_name else "missing"
|
||||
if not sender_name and hint:
|
||||
item["customer_name"] = hint
|
||||
return items
|
||||
|
||||
|
||||
|
||||
def _is_reconstructed_mode(value: Any) -> bool:
|
||||
return str(value or "").strip().lower() in {
|
||||
"reconstructed_invoice_review",
|
||||
"historical_reconstructed",
|
||||
"legacy_review",
|
||||
}
|
||||
|
||||
|
||||
def _looks_like_association_review(item: Dict[str, Any]) -> bool:
|
||||
"""Return True only for an explicitly persisted association blocker.
|
||||
|
||||
v132 deliberately ignores opportunity titles such as "sem oportunidade".
|
||||
Those titles are historical provenance and must not invent a current blocker.
|
||||
"""
|
||||
return association_review_required(
|
||||
action_code=item.get("action_code"),
|
||||
linking_status=item.get("opportunity_linking_status"),
|
||||
)
|
||||
|
||||
|
||||
def _normalise_work_item_intent(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Expose one first safe operator action from persisted blockers/tasks.
|
||||
|
||||
No blocker is inferred from titles or free text. Association review and
|
||||
reconstructed review must be explicit in persisted state. The underlying
|
||||
task remains available through ``original_action_code`` for auditability.
|
||||
"""
|
||||
sensitive_codes = {canonical_action_code(code) for code in RECONSTRUCTED_SENSITIVE_ACTIONS}
|
||||
for item in items:
|
||||
original_code = canonical_action_code(item.get("action_code"))
|
||||
item["original_action_code"] = original_code
|
||||
|
||||
if _looks_like_association_review(item):
|
||||
item["action_code"] = "ASSOCIATE_OPPORTUNITY"
|
||||
item["queue"] = "rever"
|
||||
item["priority"] = "alta"
|
||||
item["opportunity_linking_status"] = item.get("opportunity_linking_status") or "review_required"
|
||||
source = str(item.get("source_system") or "").strip().lower()
|
||||
item["title"] = "Validar associação do documento" if source == "jasmin" else "Validar associação operacional"
|
||||
item["detail"] = (
|
||||
"Confirmar primeiro a que processo pertence a evidência. "
|
||||
"Ligar a uma oportunidade existente, criar uma nova ou ignorar; "
|
||||
"só depois executar ações comerciais, fiscais ou financeiras."
|
||||
)
|
||||
continue
|
||||
|
||||
metadata = item.get("opportunity_metadata") or {}
|
||||
if not isinstance(metadata, dict):
|
||||
metadata = {}
|
||||
if reconstructed_review_required(metadata) and original_code in sensitive_codes:
|
||||
item["action_code"] = "REVIEW_RECONSTRUCTED_PROCESS"
|
||||
item["queue"] = "rever"
|
||||
item["priority"] = "alta"
|
||||
item["title"] = "Validar processo reconstruído"
|
||||
item["detail"] = (
|
||||
"Confirmar cliente, documento principal, valor e evidência de pagamento "
|
||||
"antes de executar a ação financeira, fiscal ou logística sugerida."
|
||||
)
|
||||
item["reconstructed_review_required"] = True
|
||||
return items
|
||||
|
||||
def _attach_operation_urls(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
cleaned: List[Dict[str, Any]] = []
|
||||
for item in items:
|
||||
@@ -94,7 +292,7 @@ def _attach_operation_urls(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
else:
|
||||
item["chatwoot_url"] = ""
|
||||
cleaned.append(item)
|
||||
return cleaned
|
||||
return _sanitize_operation_identities(cleaned)
|
||||
|
||||
|
||||
def get_operations_summary(limit: int = 24) -> Dict[str, Any]:
|
||||
@@ -110,6 +308,7 @@ def get_operations_summary(limit: int = 24) -> Dict[str, Any]:
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM opportunities WHERE status = 'open')::int AS open_opportunities,
|
||||
(SELECT COUNT(*) FROM tasks WHERE status = 'pending')::int AS pending_tasks,
|
||||
(SELECT COUNT(*) FROM tasks WHERE status = 'pending' AND action_code LIKE 'FOLLOW_UP_%' AND due_at IS NOT NULL AND due_at > now())::int AS scheduled_followups,
|
||||
(SELECT COUNT(*) FROM integration_outbox WHERE status = 'pending')::int AS outbox_pending,
|
||||
(SELECT COUNT(*) FROM integration_outbox WHERE status = 'failed' AND COALESCE(last_error,'') NOT ILIKE '%limpo manualmente%' AND COALESCE(last_error,'') NOT ILIKE '%resolvido manualmente%')::int AS outbox_failed,
|
||||
(SELECT COUNT(*) FROM commercial_documents WHERE document_kind = 'quotation' AND status NOT IN ('failed','cancelled','converted'))::int AS open_quotations,
|
||||
@@ -125,7 +324,7 @@ def get_operations_summary(limit: int = 24) -> Dict[str, Any]:
|
||||
(SELECT COUNT(*) FROM tasks WHERE status = 'pending' AND (route = 'rever' OR action_code = 'REVIEW_MANUALLY'))::int AS review_tasks,
|
||||
(SELECT COUNT(*) FROM integration_outbox WHERE status IN ('failed','blocked') AND COALESCE(last_error,'') NOT ILIKE '%limpo manualmente%' AND COALESCE(last_error,'') NOT ILIKE '%resolvido manualmente%')::int AS blocked_outbox,
|
||||
(
|
||||
(SELECT COUNT(*) FROM tasks WHERE status = 'pending')
|
||||
(SELECT COUNT(*) FROM tasks WHERE status = 'pending' AND NOT (action_code LIKE 'FOLLOW_UP_%' AND due_at IS NOT NULL AND due_at > now()))
|
||||
+ (SELECT COUNT(*) FROM integration_outbox WHERE status IN ('failed','blocked') AND COALESCE(last_error,'') NOT ILIKE '%limpo manualmente%' AND COALESCE(last_error,'') NOT ILIKE '%resolvido manualmente%')
|
||||
+ (SELECT COUNT(*) FROM communications WHERE status = 'needs_review')
|
||||
)::int AS work_queue_total
|
||||
@@ -186,7 +385,7 @@ def get_operations_summary(limit: int = 24) -> Dict[str, Any]:
|
||||
|
||||
work_items = conn.execute(text("""
|
||||
SELECT * FROM (
|
||||
SELECT 'task' AS source, t.id::text AS id, t.created_at,
|
||||
SELECT 'task' AS source, t.id::text AS id, t.created_at, t.due_at,
|
||||
CASE
|
||||
WHEN COALESCE(t.metadata->>'opportunity_linking_status','') = 'ambiguous' THEN 'alta'
|
||||
ELSE COALESCE(t.priority, CASE WHEN t.due_at < now() THEN 'alta' ELSE 'normal' END)
|
||||
@@ -215,6 +414,8 @@ def get_operations_summary(limit: int = 24) -> Dict[str, Any]:
|
||||
t.opportunity_id::text,
|
||||
COALESCE(o.title, '') AS opportunity_title,
|
||||
COALESCE(NULLIF(re.payload->'sender'->>'name',''), NULLIF(re.payload->'sender'->>'email',''), NULLIF(t.contact_id,''), '') AS customer_name,
|
||||
NULLIF(re.payload->'sender'->>'name','') AS sender_name,
|
||||
NULLIF(re.payload->'sender'->>'email','') AS sender_email,
|
||||
COALESCE(cu_opp.name, cu_task.name, '') AS fiscal_customer_name,
|
||||
COALESCE(cu_opp.email, cu_task.email, '') AS fiscal_customer_email,
|
||||
COALESCE(cu_opp.tax_id, cu_task.tax_id, '') AS fiscal_customer_tax_id,
|
||||
@@ -222,9 +423,16 @@ def get_operations_summary(limit: int = 24) -> Dict[str, Any]:
|
||||
COALESCE(cu_opp.postal_zone, cu_task.postal_zone, '') AS fiscal_customer_postal_zone,
|
||||
COALESCE(cu_opp.city_name, cu_task.city_name, '') AS fiscal_customer_city_name,
|
||||
COALESCE(NULLIF(re.payload->'sender'->>'name',''), NULLIF(re.payload->'sender'->>'email',''), NULLIF(t.contact_id,''), '') AS contact_display_name,
|
||||
COALESCE(NULLIF(re.payload->'sender'->>'email',''), '') AS customer_email,
|
||||
COALESCE(t.metadata->>'no_opportunity_reason', '') AS no_opportunity_reason,
|
||||
'/tasks/' || t.id::text AS href,
|
||||
'Abrir' AS action_label
|
||||
'Abrir' AS action_label,
|
||||
COALESCE(t.metadata->>'opportunity_linking_status','') AS opportunity_linking_status,
|
||||
COALESCE(t.metadata, '{}'::jsonb) AS item_metadata,
|
||||
COALESCE(o.metadata, '{}'::jsonb) AS opportunity_metadata,
|
||||
COALESCE(o.stage, '') AS opportunity_stage,
|
||||
COALESCE(o.value_amount, 0) AS opportunity_value_amount,
|
||||
COALESCE(o.currency, 'EUR') AS opportunity_currency
|
||||
FROM tasks t
|
||||
LEFT JOIN opportunities o ON o.id = t.opportunity_id
|
||||
LEFT JOIN customers cu_opp ON cu_opp.id = o.local_customer_id
|
||||
@@ -232,10 +440,11 @@ def get_operations_summary(limit: int = 24) -> Dict[str, Any]:
|
||||
LEFT JOIN messages m ON m.id = t.message_id
|
||||
LEFT JOIN raw_events re ON re.id = t.raw_event_id
|
||||
WHERE t.status = 'pending'
|
||||
AND NOT (t.action_code LIKE 'FOLLOW_UP_%' AND t.due_at IS NOT NULL AND t.due_at > now())
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT 'outbox' AS source, io.id::text AS id, io.created_at,
|
||||
SELECT 'outbox' AS source, io.id::text AS id, io.created_at, NULL::timestamptz AS due_at,
|
||||
CASE WHEN io.status = 'failed' THEN 'alta' ELSE 'normal' END AS priority,
|
||||
'sistema' AS queue,
|
||||
upper(io.target_system || '_' || io.action_type) AS action_code,
|
||||
@@ -249,6 +458,8 @@ def get_operations_summary(limit: int = 24) -> Dict[str, Any]:
|
||||
NULLIF(io.payload->>'opportunity_id','') AS opportunity_id,
|
||||
COALESCE(o.title, '') AS opportunity_title,
|
||||
COALESCE(cu.name, '') AS customer_name,
|
||||
''::text AS sender_name,
|
||||
''::text AS sender_email,
|
||||
COALESCE(cu.name, '') AS fiscal_customer_name,
|
||||
COALESCE(cu.email, '') AS fiscal_customer_email,
|
||||
COALESCE(cu.tax_id, '') AS fiscal_customer_tax_id,
|
||||
@@ -256,9 +467,16 @@ def get_operations_summary(limit: int = 24) -> Dict[str, Any]:
|
||||
COALESCE(cu.postal_zone, '') AS fiscal_customer_postal_zone,
|
||||
COALESCE(cu.city_name, '') AS fiscal_customer_city_name,
|
||||
''::text AS contact_display_name,
|
||||
''::text AS customer_email,
|
||||
''::text AS no_opportunity_reason,
|
||||
'/outbox/' || io.id::text AS href,
|
||||
CASE WHEN io.status = 'failed' THEN 'Reprocessar' ELSE 'Abrir' END AS action_label
|
||||
CASE WHEN io.status = 'failed' THEN 'Reprocessar' ELSE 'Abrir' END AS action_label,
|
||||
''::text AS opportunity_linking_status,
|
||||
COALESCE(io.payload, '{}'::jsonb) AS item_metadata,
|
||||
COALESCE(o.metadata, '{}'::jsonb) AS opportunity_metadata,
|
||||
COALESCE(o.stage, '') AS opportunity_stage,
|
||||
COALESCE(o.value_amount, 0) AS opportunity_value_amount,
|
||||
COALESCE(o.currency, 'EUR') AS opportunity_currency
|
||||
FROM integration_outbox io
|
||||
LEFT JOIN opportunities o ON o.id::text = NULLIF(io.payload->>'opportunity_id','')
|
||||
LEFT JOIN customers cu ON cu.id = o.local_customer_id
|
||||
@@ -267,7 +485,7 @@ def get_operations_summary(limit: int = 24) -> Dict[str, Any]:
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT 'communication' AS source, c.id::text AS id, c.created_at,
|
||||
SELECT 'communication' AS source, c.id::text AS id, c.created_at, NULL::timestamptz AS due_at,
|
||||
CASE WHEN c.status = 'needs_review' THEN 'normal' ELSE 'normal' END AS priority,
|
||||
CASE
|
||||
WHEN c.classification IN ('comprovativo_pagamento','pedido_fatura','aceitacao_orcamento','dados_fiscais') THEN 'financeiro'
|
||||
@@ -287,6 +505,8 @@ def get_operations_summary(limit: int = 24) -> Dict[str, Any]:
|
||||
c.opportunity_id::text,
|
||||
COALESCE(o.title, '') AS opportunity_title,
|
||||
COALESCE(cu.name, c.sender_name, c.sender_email, '') AS customer_name,
|
||||
COALESCE(c.sender_name, '') AS sender_name,
|
||||
COALESCE(c.sender_email, '') AS sender_email,
|
||||
COALESCE(cu.name, '') AS fiscal_customer_name,
|
||||
COALESCE(cu.email, '') AS fiscal_customer_email,
|
||||
COALESCE(cu.tax_id, '') AS fiscal_customer_tax_id,
|
||||
@@ -294,9 +514,16 @@ def get_operations_summary(limit: int = 24) -> Dict[str, Any]:
|
||||
COALESCE(cu.postal_zone, '') AS fiscal_customer_postal_zone,
|
||||
COALESCE(cu.city_name, '') AS fiscal_customer_city_name,
|
||||
COALESCE(c.sender_name, c.sender_email, c.contact_id, '') AS contact_display_name,
|
||||
COALESCE(c.sender_email, '') AS customer_email,
|
||||
''::text AS no_opportunity_reason,
|
||||
'/communications/' || c.id::text AS href,
|
||||
CASE WHEN c.customer_id IS NULL THEN 'Associar cliente' ELSE 'Abrir' END AS action_label
|
||||
CASE WHEN c.customer_id IS NULL THEN 'Associar cliente' ELSE 'Abrir' END AS action_label,
|
||||
''::text AS opportunity_linking_status,
|
||||
COALESCE(c.metadata, '{}'::jsonb) AS item_metadata,
|
||||
COALESCE(o.metadata, '{}'::jsonb) AS opportunity_metadata,
|
||||
COALESCE(o.stage, '') AS opportunity_stage,
|
||||
COALESCE(o.value_amount, 0) AS opportunity_value_amount,
|
||||
COALESCE(o.currency, 'EUR') AS opportunity_currency
|
||||
FROM communications c
|
||||
LEFT JOIN customers cu ON cu.id = c.customer_id
|
||||
LEFT JOIN opportunities o ON o.id = c.opportunity_id
|
||||
@@ -308,7 +535,7 @@ def get_operations_summary(limit: int = 24) -> Dict[str, Any]:
|
||||
LIMIT :limit
|
||||
"""), {"limit": int(limit)}).mappings().all()
|
||||
|
||||
cleaned_work_items = _attach_operation_urls([dict(r) for r in work_items])
|
||||
cleaned_work_items = _attach_operation_urls(_normalise_work_item_intent([dict(r) for r in work_items]))
|
||||
cleaned_counts = {k: _int(v) for k, v in dict(counts).items()}
|
||||
# v4.9.0: the visible Operations total should match the queue the
|
||||
# operator can actually act on, not raw pending tasks that include mailbox
|
||||
@@ -375,6 +602,8 @@ def get_system_health_summary() -> Dict[str, Any]:
|
||||
(SELECT COUNT(*) FROM opportunities o JOIN customers c ON c.id = o.local_customer_id WHERE o.status = 'open' AND (COALESCE(c.tax_id,'') = '' OR COALESCE(c.email,'') = '' OR COALESCE(c.street_name,'') = '' OR COALESCE(c.postal_zone,'') = '' OR COALESCE(c.city_name,'') = ''))::int AS active_incomplete_fiscal_customers,
|
||||
(SELECT COUNT(*) FROM products WHERE active = TRUE AND COALESCE(jasmin_sales_item,'') = '')::int AS products_missing_external_code,
|
||||
(SELECT COUNT(*) FROM raw_events WHERE source_system = 'chatwoot')::int AS chatwoot_events_total,
|
||||
(SELECT COUNT(*) FROM raw_events WHERE source_system = 'chatwoot' AND event_type = 'message_created' AND processed = false AND ignored = false AND processing_error IS NULL AND COALESCE(payload #>> '{message_type}', '') = 'incoming')::int AS chatwoot_incoming_pending,
|
||||
(SELECT COUNT(*) FROM raw_events WHERE source_system = 'chatwoot' AND event_type = 'message_created' AND COALESCE(payload #>> '{message_type}', '') = 'incoming' AND created_at >= now() - interval '24 hours')::int AS chatwoot_incoming_24h,
|
||||
(SELECT EXTRACT(EPOCH FROM (now() - max(created_at)))::int FROM raw_events WHERE source_system = 'chatwoot')::int AS seconds_since_last_chatwoot_webhook,
|
||||
(SELECT COUNT(*) FROM tasks WHERE status = 'pending' AND route = 'vendas')::int AS tasks_pending_vendas,
|
||||
(SELECT COUNT(*) FROM tasks WHERE status = 'pending' AND route = 'financeiro')::int AS tasks_pending_financeiro,
|
||||
|
||||
Reference in New Issue
Block a user