1481 lines
71 KiB
Python
1481 lines
71 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
Proxy/caché entre WACRM (Supabase) y Odoo.
|
|
Expone endpoints REST para el frontend SKEEN.
|
|
"""
|
|
|
|
from odoo import http, fields
|
|
from odoo.http import request
|
|
from .frontend import json_response, _parse_json_body
|
|
from .auth import require_role, require_perm
|
|
import os
|
|
import hmac
|
|
import logging
|
|
import requests
|
|
import re
|
|
import time
|
|
import uuid
|
|
from datetime import datetime, timedelta
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
# ============================================================
|
|
# Sincronización incremental
|
|
#
|
|
# El caché local se llena desde Supabase (WACRM). Recorrer las 9.750 filas
|
|
# de `contacts` y todos los mensajes en cada ciclo costaba ~75 s, así que la
|
|
# pantalla nunca alcanzaba a sincronizar. Ahora:
|
|
# - los contactos se traen SOLO si están referenciados por una conversación
|
|
# o un deal, o si cambiaron desde la última marca;
|
|
# - conversaciones/mensajes/deals se traen por fecha de actualización;
|
|
# - las escrituras van en lote (una búsqueda + un create múltiple);
|
|
# - la poda de lo que ya no existe en el origen SOLO corre en un ciclo
|
|
# completo (cada FULL_SYNC_INTERVAL segundos), nunca en un parcial.
|
|
# Las marcas viven en ir.config_parameter y solo avanzan, nunca retroceden.
|
|
# ============================================================
|
|
PARAM_CONV_MARK = 'skeen.wacrm.last_sync' # marca de conversations
|
|
PARAM_MSG_MARK = 'skeen.wacrm.last_msg_sync' # marca de messages
|
|
PARAM_CONTACT_MARK = 'skeen.wacrm.last_contact_sync'
|
|
PARAM_DEAL_MARK = 'skeen.wacrm.last_deal_sync'
|
|
PARAM_LAST_SYNC_AT = 'skeen.wacrm.last_sync_at' # hora local del último ciclo ok
|
|
PARAM_LAST_FULL_AT = 'skeen.wacrm.last_full_sync_at'
|
|
PARAM_NOTIFY_KEY = 'skeen.wacrm.notify_key' # llave del aviso puntual
|
|
FULL_SYNC_INTERVAL = 300 # segundos entre ciclos completos (poda)
|
|
INCREMENTAL_CONTACT_PAGE = 1000 # tope de contactos cambiados por ciclo
|
|
SUPABASE_PAGE_SIZE = 1000
|
|
EPOCH_SUPABASE = '1970-01-01T00:00:00.000000+00:00'
|
|
|
|
|
|
def _get_supabase_config():
|
|
"""Lee configuración de Supabase desde variables de entorno o ir.config_parameter."""
|
|
get_param = request.env['ir.config_parameter'].sudo().get_param
|
|
url = os.environ.get('SUPABASE_URL') or get_param('skeen.supabase.url')
|
|
key = os.environ.get('SUPABASE_SERVICE_ROLE_KEY') or get_param('skeen.supabase.service_role_key')
|
|
if not url or not key:
|
|
raise Exception('Falta configuración SUPABASE_URL o SUPABASE_SERVICE_ROLE_KEY')
|
|
return url.rstrip('/'), key
|
|
|
|
|
|
def _supabase_request(method, table, params=None, json=None, headers_extra=None):
|
|
url, key = _get_supabase_config()
|
|
headers = {
|
|
'apikey': key,
|
|
'Authorization': f'Bearer {key}',
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json',
|
|
}
|
|
if headers_extra:
|
|
headers.update(headers_extra)
|
|
response = requests.request(method, f"{url}/rest/v1/{table}", headers=headers, params=params or {}, json=json, timeout=30)
|
|
response.raise_for_status()
|
|
return response.json() if response.text else {}
|
|
|
|
|
|
def _get_wacrm_config():
|
|
"""Lee configuración de WACRM API desde variables de entorno o ir.config_parameter."""
|
|
get_param = request.env['ir.config_parameter'].sudo().get_param
|
|
url = os.environ.get('WACRM_API_URL') or get_param('skeen.wacrm.api_url') or 'http://localhost:3000'
|
|
key = os.environ.get('WACRM_API_KEY') or get_param('skeen.wacrm.api_key')
|
|
if not url or not key:
|
|
raise Exception('Falta configuración WACRM_API_URL o WACRM_API_KEY')
|
|
return url.rstrip('/'), key
|
|
|
|
|
|
def _wacrm_request(method, path, json=None, params=None):
|
|
url, key = _get_wacrm_config()
|
|
headers = {
|
|
'Authorization': f'Bearer {key}',
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json',
|
|
}
|
|
response = requests.request(method, f"{url}/api/v1{path}", headers=headers, json=json, params=params or {}, timeout=30)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
|
|
|
|
def _get_hermes_bridge_config():
|
|
"""Lee configuración del bridge de Hermes."""
|
|
get_param = request.env['ir.config_parameter'].sudo().get_param
|
|
url = os.environ.get('HERMES_BRIDGE_URL') or get_param('skeen.hermes.bridge_url') or 'http://localhost:8090'
|
|
return url.rstrip('/')
|
|
|
|
|
|
def _hermes_bridge_request(path, json=None):
|
|
url = _get_hermes_bridge_config()
|
|
headers = {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json',
|
|
}
|
|
response = requests.request('POST', f"{url}{path}", headers=headers, json=json or {}, timeout=30)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
|
|
|
|
def _supabase_server_time():
|
|
"""Hora del propio Supabase (cabecera Date), en UTC naive.
|
|
|
|
Se usa para arrancar las marcas con el reloj del origen y no con el de
|
|
Odoo, que puede estar desfasado.
|
|
"""
|
|
url, key = _get_supabase_config()
|
|
try:
|
|
from email.utils import parsedate_to_datetime
|
|
from datetime import timezone
|
|
response = requests.get(
|
|
f'{url}/rest/v1/',
|
|
headers={'apikey': key, 'Authorization': f'Bearer {key}'},
|
|
timeout=10)
|
|
return parsedate_to_datetime(response.headers['Date']).astimezone(timezone.utc).replace(tzinfo=None)
|
|
except Exception as e:
|
|
_logger.warning('No se pudo leer la hora de Supabase: %s', e)
|
|
return None
|
|
|
|
|
|
def _supabase_request_paginated(table, params=None, page_size=1000):
|
|
"""Itera sobre todas las páginas de resultados de PostgREST.
|
|
|
|
PostgREST limita `limit` al máximo configurado (por defecto 1000).
|
|
Usamos offset para traer todos los registros en lotes.
|
|
"""
|
|
all_data = []
|
|
offset = 0
|
|
params = dict(params or {})
|
|
params['limit'] = page_size
|
|
params['order'] = params.get('order', 'id.asc')
|
|
while True:
|
|
params['offset'] = offset
|
|
page = _supabase_request('GET', table, params)
|
|
if not page:
|
|
break
|
|
all_data.extend(page)
|
|
if len(page) < page_size:
|
|
break
|
|
offset += page_size
|
|
return all_data
|
|
|
|
|
|
def _parse_datetime(value):
|
|
if not value:
|
|
return None
|
|
try:
|
|
from datetime import timezone
|
|
dt = datetime.fromisoformat(value.replace('Z', '+00:00'))
|
|
if dt.tzinfo:
|
|
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
|
|
return dt
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _to_supabase_dt(dt):
|
|
"""Datetime naive UTC → texto ISO 8601 que PostgREST acepta en filtros."""
|
|
return dt.strftime('%Y-%m-%dT%H:%M:%S.%f') + '+00:00'
|
|
|
|
|
|
def _max_dt(rows, field):
|
|
"""Máximo (naive UTC) del campo en las filas, o None si no hay ninguno."""
|
|
mayor = None
|
|
for row in rows:
|
|
dt = _parse_datetime(row.get(field))
|
|
if dt and (mayor is None or dt > mayor):
|
|
mayor = dt
|
|
return mayor
|
|
|
|
|
|
def _chunks(items, size):
|
|
for i in range(0, len(items), size):
|
|
yield items[i:i + size]
|
|
|
|
|
|
def _changed_vals(record, vals):
|
|
"""Subconjunto de `vals` que difiere de lo que ya tiene el registro."""
|
|
cambios = {}
|
|
for field, valor in vals.items():
|
|
actual = record[field]
|
|
if hasattr(actual, 'ids'):
|
|
actual = actual.id or False
|
|
valor = valor or False
|
|
# Un campo vacío en Odoo se lee como False; el origen manda None. Se
|
|
# igualan para no reescribir la fila en cada ciclo.
|
|
if valor is None:
|
|
valor = False
|
|
if actual is None:
|
|
actual = False
|
|
if isinstance(actual, datetime) and isinstance(valor, datetime):
|
|
if abs((actual - valor).total_seconds()) < 0.001:
|
|
continue
|
|
elif actual == valor:
|
|
continue
|
|
cambios[field] = valor
|
|
return cambios
|
|
|
|
|
|
def _bulk_upsert(model, rows, key='external_id'):
|
|
"""Crea/actualiza en lote. Devuelve {clave: registro}.
|
|
|
|
Una sola búsqueda por lote, un `create` múltiple y escrituras agrupadas
|
|
por valores idénticos: nada de una consulta por fila.
|
|
"""
|
|
if not rows:
|
|
return {}
|
|
by_key = {rec[key]: rec for rec in model.search([(key, 'in', [r[key] for r in rows])])}
|
|
nuevos = [r for r in rows if r[key] not in by_key]
|
|
nuevos_ids = set()
|
|
if nuevos:
|
|
for rec in model.create(nuevos):
|
|
by_key[rec[key]] = rec
|
|
nuevos_ids.add(rec.id)
|
|
grupos = {}
|
|
for row in rows:
|
|
rec = by_key.get(row[key])
|
|
if rec is None or rec.id in nuevos_ids:
|
|
continue
|
|
cambios = _changed_vals(rec, {k: v for k, v in row.items() if k != key})
|
|
if not cambios:
|
|
continue
|
|
firma = tuple(sorted((k, str(v)) for k, v in cambios.items()))
|
|
previos = grupos.get(firma)
|
|
grupos[firma] = (cambios, (previos[1] | rec) if previos else rec)
|
|
for cambios, recs in grupos.values():
|
|
recs.write(cambios)
|
|
return by_key
|
|
|
|
|
|
def _contacto_a_vals(item):
|
|
return {
|
|
'external_id': item['id'],
|
|
'account_id': item.get('account_id'),
|
|
'phone': item.get('phone'),
|
|
'name': item.get('name') or item.get('phone'),
|
|
'email': item.get('email'),
|
|
'company': item.get('company'),
|
|
'avatar_url': item.get('avatar_url'),
|
|
'created_at': _parse_datetime(item.get('created_at')),
|
|
'updated_at': _parse_datetime(item.get('updated_at')),
|
|
}
|
|
|
|
|
|
def _conversacion_a_vals(item, contacts_map):
|
|
vals = {
|
|
'external_id': item['id'],
|
|
'status': item.get('status') or 'open',
|
|
'assigned_agent_id': item.get('assigned_agent_id') or None,
|
|
'assigned_agent': str(item.get('assigned_agent_id')) if item.get('assigned_agent_id') else None,
|
|
'last_message_text': item.get('last_message_text'),
|
|
'last_message_at': _parse_datetime(item.get('last_message_at')),
|
|
'unread_count': item.get('unread_count', 0),
|
|
'created_at': _parse_datetime(item.get('created_at')),
|
|
'updated_at': _parse_datetime(item.get('updated_at')),
|
|
}
|
|
contacto = contacts_map.get(item.get('contact_id'))
|
|
if contacto:
|
|
vals['contact_id'] = contacto.id
|
|
elif not item.get('contact_id'):
|
|
vals['contact_id'] = False
|
|
# Si el contacto no vino en el lote no se toca el vínculo ya guardado.
|
|
return vals
|
|
|
|
|
|
def _traer_contactos(env, contact_ids):
|
|
"""Upserta los contactos indicados trayéndolos del origen. Devuelve el mapa."""
|
|
if not contact_ids:
|
|
return {}
|
|
items = []
|
|
for trozo in _chunks(sorted(contact_ids), 150):
|
|
items.extend(_supabase_request('GET', 'contacts', {
|
|
'select': '*', 'id': 'in.(%s)' % ','.join(trozo)}))
|
|
return _bulk_upsert(env['skeen.wacrm.contact'].sudo(),
|
|
[_contacto_a_vals(i) for i in items])
|
|
|
|
|
|
def _traer_conversaciones(env, conv_ids, contacts_map):
|
|
"""Trae del origen las conversaciones indicadas (y sus contactos)."""
|
|
if not conv_ids:
|
|
return {}
|
|
items = []
|
|
for trozo in _chunks(sorted(conv_ids), 100):
|
|
items.extend(_supabase_request('GET', 'conversations', {
|
|
'select': '*', 'id': 'in.(%s)' % ','.join(trozo)}))
|
|
faltan = {i['contact_id'] for i in items if i.get('contact_id')} - set(contacts_map)
|
|
contacts_map.update(_traer_contactos(env, faltan))
|
|
return _bulk_upsert(env['skeen.wacrm.conversation'].sudo(),
|
|
[_conversacion_a_vals(i, contacts_map) for i in items])
|
|
|
|
|
|
class SkeenWacrmProxyController(http.Controller):
|
|
"""Endpoints proxy WACRM para el frontend."""
|
|
|
|
# ============================================================
|
|
# Sincronización
|
|
# ============================================================
|
|
@http.route('/skeen/frontend/v1/wacrm/sync', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
|
def sync_wacrm(self, **kw):
|
|
"""Sincroniza el caché local con Supabase (WACRM).
|
|
|
|
Por defecto corre en modo incremental: solo trae lo que cambió desde
|
|
la última marca. Cada FULL_SYNC_INTERVAL segundos —o con `full=1`, o
|
|
la primera vez— corre un ciclo completo, que además poda del caché lo
|
|
que ya no existe en el origen.
|
|
|
|
Un solo sync a la vez: dos transacciones escribiendo las mismas filas
|
|
se abortan mutuamente ("could not serialize access"), así que el
|
|
segundo responde 'busy' y ante un conflicto real se reintenta.
|
|
"""
|
|
# El lock de transacción se libera solo al terminar la petición.
|
|
try:
|
|
request.env.cr.execute(
|
|
"SELECT pg_try_advisory_xact_lock(hashtext('skeen_wacrm_sync'))")
|
|
libre = request.env.cr.fetchone()[0]
|
|
except Exception:
|
|
libre = True
|
|
ICP = request.env['ir.config_parameter'].sudo()
|
|
if not libre:
|
|
return json_response({
|
|
'status': 'busy',
|
|
'message': 'Ya hay una sincronización en curso; se actualizará en unos segundos',
|
|
'last_sync_at': ICP.get_param(PARAM_LAST_SYNC_AT),
|
|
})
|
|
pedir_full = str(kw.get('full', '')).lower() in ('1', 'true', 'yes', 'si')
|
|
if not pedir_full:
|
|
try:
|
|
pedir_full = bool((_parse_json_body() or {}).get('full'))
|
|
except Exception:
|
|
pedir_full = False
|
|
ultimo = None
|
|
for intento in range(3):
|
|
try:
|
|
result = self._do_sync(full=pedir_full)
|
|
return json_response({'status': 'success', 'result': result})
|
|
except Exception as e:
|
|
ultimo = e
|
|
if 'could not serialize' not in str(e).lower():
|
|
break
|
|
request.env.cr.rollback()
|
|
_logger.warning('Sync WACRM en conflicto (intento %s/3), reintentando', intento + 1)
|
|
time.sleep(0.8 * (intento + 1))
|
|
_logger.exception('Error sincronizando WACRM')
|
|
return json_response({'status': 'error', 'message': str(ultimo)}, 500)
|
|
|
|
def _do_sync(self, full=False):
|
|
inicio = time.monotonic()
|
|
ICP = request.env['ir.config_parameter'].sudo()
|
|
Contact = request.env['skeen.wacrm.contact'].sudo()
|
|
Conv = request.env['skeen.wacrm.conversation'].sudo()
|
|
Msg = request.env['skeen.wacrm.message'].sudo()
|
|
Deal = request.env['skeen.wacrm.deal'].sudo()
|
|
|
|
marca_conv = ICP.get_param(PARAM_CONV_MARK)
|
|
marca_msg = ICP.get_param(PARAM_MSG_MARK)
|
|
marca_contacto = ICP.get_param(PARAM_CONTACT_MARK)
|
|
marca_deal = ICP.get_param(PARAM_DEAL_MARK)
|
|
ultimo_full = _parse_datetime(ICP.get_param(PARAM_LAST_FULL_AT))
|
|
|
|
# Sin marcas previas (primer arranque) o con el ciclo completo vencido,
|
|
# se hace una pasada completa: es la única que poda lo que ya no existe.
|
|
if not marca_conv or not marca_msg:
|
|
full = True
|
|
elif not ultimo_full or (fields.Datetime.now() - ultimo_full).total_seconds() >= FULL_SYNC_INTERVAL:
|
|
full = True
|
|
modo = 'full' if full else 'incremental'
|
|
account_id = None
|
|
|
|
# 1. Conversaciones (completas la primera vez y en cada ciclo completo;
|
|
# después solo las que cambiaron desde la marca).
|
|
params_conv = {'select': '*', 'order': 'id.asc' if full else 'updated_at.asc,id.asc'}
|
|
if not full:
|
|
params_conv['updated_at'] = 'gte.%s' % marca_conv
|
|
conversations_data = _supabase_request_paginated('conversations', params_conv)
|
|
|
|
# 2. Deals (igual criterio)
|
|
params_deal = {'select': '*', 'order': 'id.asc' if full else 'updated_at.asc,id.asc'}
|
|
if not full:
|
|
params_deal['updated_at'] = 'gte.%s' % (marca_deal or EPOCH_SUPABASE)
|
|
deals_data = _supabase_request_paginated('deals', params_deal)
|
|
|
|
# 3. Contactos: solo los que hacen falta. Los referenciados por lo que
|
|
# se acaba de traer, más los que cambiaron desde la última marca.
|
|
# La marca arranca en la hora DEL ORIGEN en el primer ciclo: el
|
|
# histórico de pacientes importados no se recorre, solo se siguen
|
|
# los cambios posteriores (acotado a una página por ciclo, para que
|
|
# un reimport masivo no arrastre las 9.750 filas de golpe).
|
|
if not marca_contacto:
|
|
servidor = _supabase_server_time()
|
|
if servidor:
|
|
marca_contacto = _to_supabase_dt(servidor)
|
|
ICP.set_param(PARAM_CONTACT_MARK, marca_contacto)
|
|
referenciados = [i['contact_id'] for i in conversations_data + deals_data if i.get('contact_id')]
|
|
referenciados = list(dict.fromkeys(referenciados))
|
|
contacts_data = []
|
|
for trozo in _chunks(referenciados, 150):
|
|
contacts_data.extend(_supabase_request('GET', 'contacts', {
|
|
'select': '*', 'id': 'in.(%s)' % ','.join(trozo)}))
|
|
contactos_barridos = []
|
|
if not full and marca_contacto:
|
|
contactos_barridos = _supabase_request('GET', 'contacts', {
|
|
'select': '*', 'updated_at': 'gte.%s' % marca_contacto,
|
|
'order': 'updated_at.asc,id.asc', 'limit': INCREMENTAL_CONTACT_PAGE})
|
|
contacts_data.extend(contactos_barridos)
|
|
contacts_data = list({i['id']: i for i in contacts_data}.values())
|
|
for item in contacts_data:
|
|
if not account_id and item.get('account_id'):
|
|
account_id = item['account_id']
|
|
if not account_id:
|
|
fila = _supabase_request('GET', 'contacts', {'select': 'account_id', 'limit': 1})
|
|
account_id = fila[0].get('account_id') if fila else None
|
|
contacts_map = _bulk_upsert(Contact, [_contacto_a_vals(i) for i in contacts_data])
|
|
|
|
# 4. Conversaciones: el vínculo con el contacto se resuelve con el mapa
|
|
# anterior. Si un contacto no vino en el lote, no se pisa el vínculo.
|
|
conversations_map = _bulk_upsert(
|
|
Conv, [_conversacion_a_vals(i, contacts_map) for i in conversations_data])
|
|
|
|
# 5. Mensajes: completos la primera vez y en cada ciclo completo; luego
|
|
# solo los nuevos, en orden ascendente (con descendente + offset los
|
|
# mensajes que van entrando desplazan las páginas y se duplican).
|
|
params_msg = {'select': '*', 'order': 'created_at.asc,id.asc'}
|
|
if not full:
|
|
params_msg['created_at'] = 'gte.%s' % marca_msg
|
|
messages_data = _supabase_request_paginated('messages', params_msg, page_size=SUPABASE_PAGE_SIZE)
|
|
|
|
# Una conversación que no vino en el lote puede tener mensajes nuevos
|
|
# (lo normal en incremental): se resuelve contra el caché y, si tampoco
|
|
# está, se trae del origen junto con su contacto.
|
|
pendientes = {m['conversation_id'] for m in messages_data if m.get('conversation_id')} - set(conversations_map)
|
|
if pendientes:
|
|
locales = Conv.search([('external_id', 'in', list(pendientes))])
|
|
for conv in locales:
|
|
conversations_map[conv.external_id] = conv
|
|
pendientes -= set(locales.mapped('external_id'))
|
|
if pendientes:
|
|
conversations_map.update(_traer_conversaciones(request.env, pendientes, contacts_map))
|
|
|
|
msg_vals = []
|
|
for item in messages_data:
|
|
conversation = conversations_map.get(item.get('conversation_id'))
|
|
if not conversation:
|
|
continue
|
|
msg_vals.append({
|
|
'external_id': item['id'],
|
|
'conversation_id': conversation.id,
|
|
'sender_type': {'contact': 'customer', 'user': 'agent'}.get(
|
|
item.get('sender_type', 'customer'), item.get('sender_type', 'customer')),
|
|
'content_type': item.get('content_type') or 'text',
|
|
'content_text': item.get('content_text') or item.get('content'),
|
|
'media_url': item.get('media_url'),
|
|
'template_name': item.get('template_name'),
|
|
'status': item.get('status', 'sent'),
|
|
'created_at': _parse_datetime(item.get('created_at')),
|
|
})
|
|
_bulk_upsert(Msg, msg_vals)
|
|
|
|
# 6. Pipelines & Stages (catálogo diminuto: siempre completo)
|
|
pipelines_data = _supabase_request_paginated('pipelines', {'select': '*', 'order': 'id.asc'})
|
|
pipelines_map = _bulk_upsert(request.env['skeen.wacrm.pipeline'].sudo(), [{
|
|
'external_id': item['id'],
|
|
'name': item.get('name'),
|
|
'created_at': _parse_datetime(item.get('created_at')),
|
|
} for item in pipelines_data])
|
|
|
|
stages_data = _supabase_request_paginated('pipeline_stages', {'select': '*', 'order': 'id.asc'})
|
|
stages_map = _bulk_upsert(request.env['skeen.wacrm.stage'].sudo(), [{
|
|
'external_id': item['id'],
|
|
'pipeline_id': pipelines_map[item['pipeline_id']].id if pipelines_map.get(item.get('pipeline_id')) else False,
|
|
'name': item.get('name'),
|
|
'position': item.get('position', 0),
|
|
'color': item.get('color') or '#3b82f6',
|
|
} for item in stages_data])
|
|
|
|
# 7. Deals — mapa profiles.id → user_id (Supabase guarda assigned_to como
|
|
# profiles.id; localmente usamos user_id, como assigned_agent_id).
|
|
profiles_usermap = {}
|
|
try:
|
|
for p in _supabase_request('GET', 'profiles', params={'select': 'id,user_id'}):
|
|
profiles_usermap[p['id']] = p['user_id']
|
|
except Exception:
|
|
pass
|
|
_bulk_upsert(Deal, [{
|
|
'external_id': item['id'],
|
|
'title': item.get('title') or 'Sin título',
|
|
'contact_id': contacts_map[item['contact_id']].id if contacts_map.get(item.get('contact_id')) else False,
|
|
'conversation_id': item.get('conversation_id'),
|
|
'pipeline_id': pipelines_map[item['pipeline_id']].id if pipelines_map.get(item.get('pipeline_id')) else False,
|
|
'stage_id': stages_map[item['stage_id']].id if stages_map.get(item.get('stage_id')) else False,
|
|
'value': float(item.get('value', 0) or 0),
|
|
'currency': item.get('currency', 'USD'),
|
|
'status': item.get('status', 'open'),
|
|
'expected_close_date': fields.Date.to_date(item.get('expected_close_date')),
|
|
'notes': item.get('notes'),
|
|
'assigned_to': profiles_usermap.get(item.get('assigned_to')) if item.get('assigned_to') else None,
|
|
'created_at': _parse_datetime(item.get('created_at')),
|
|
'updated_at': _parse_datetime(item.get('updated_at')),
|
|
} for item in deals_data])
|
|
|
|
# 8. Auto-crear deals: contactos con conversación sin deal abierto.
|
|
# Se crean EN SUPABASE (la app WACRM nativa los ve) y se espejan local.
|
|
# Idempotente: un deal por contacto. En incremental `deals_data` es
|
|
# parcial, así que la lista de contactos con deal se pide al origen.
|
|
deals_created = 0
|
|
try:
|
|
if conversations_map and pipelines_map and account_id:
|
|
pipeline = sorted(pipelines_map.values(), key=lambda p: p.name or '')[0]
|
|
stage = request.env['skeen.wacrm.stage'].sudo().search(
|
|
[('pipeline_id', '=', pipeline.id)], order='position asc', limit=1)
|
|
# user_id requerido por Supabase: el primer profile (owner de la cuenta)
|
|
owner = _supabase_request('GET', 'profiles', params={
|
|
'select': 'user_id', 'order': 'created_at.asc', 'limit': 1})
|
|
owner_id = owner[0]['user_id'] if owner else None
|
|
if stage and owner_id:
|
|
# Un deal por contacto: no crear si ya tiene cualquiera (open o ganado)
|
|
contacts_with_deals = {str(d.get('contact_id')) for d in deals_data if d.get('contact_id')}
|
|
if not full:
|
|
con_conv = [c.external_id for c in conversations_map.values() if c.contact_id]
|
|
for trozo in _chunks(con_conv, 100):
|
|
remotos = _supabase_request('GET', 'deals', {
|
|
'select': 'contact_id',
|
|
'contact_id': 'in.(%s)' % ','.join(trozo)})
|
|
contacts_with_deals |= {str(d['contact_id']) for d in remotos if d.get('contact_id')}
|
|
for conv in conversations_map.values():
|
|
contact = conv.contact_id
|
|
if not contact:
|
|
continue
|
|
if contact.external_id in contacts_with_deals:
|
|
continue
|
|
if Deal.search([('contact_id', '=', contact.id)], limit=1):
|
|
continue
|
|
new_id = str(uuid.uuid4())
|
|
try:
|
|
_supabase_request('POST', 'deals', json={
|
|
'id': new_id,
|
|
'user_id': owner_id,
|
|
'account_id': account_id,
|
|
'pipeline_id': pipeline.external_id,
|
|
'stage_id': stage.external_id,
|
|
'contact_id': contact.external_id,
|
|
'conversation_id': conv.external_id,
|
|
'title': contact.name or contact.phone or 'Lead WhatsApp',
|
|
'value': 0,
|
|
'currency': 'MXN',
|
|
'status': 'open',
|
|
}, headers_extra={'Prefer': 'return=minimal'})
|
|
Deal.create({
|
|
'external_id': new_id,
|
|
'title': contact.name or contact.phone or 'Lead WhatsApp',
|
|
'contact_id': contact.id,
|
|
'conversation_id': conv.external_id,
|
|
'pipeline_id': pipeline.id,
|
|
'stage_id': stage.id,
|
|
'value': 0,
|
|
'currency': 'MXN',
|
|
'status': 'open',
|
|
})
|
|
contacts_with_deals.add(contact.external_id)
|
|
deals_created += 1
|
|
except Exception as e:
|
|
_logger.warning('No se pudo auto-crear deal para contacto %s: %s', contact.external_id, e)
|
|
except Exception as e:
|
|
_logger.warning('Auto-deals omitidos: %s', e)
|
|
|
|
# 9. Poda: SOLO en el ciclo completo. Comparar contra un lote parcial
|
|
# (incremental o una página suelta) borraría filas válidas; por eso
|
|
# el borrado queda reservado a la pasada que trajo TODO el origen.
|
|
# Los mensajes de una conversación borrada caen por cascade.
|
|
borrados = {'conversations': 0, 'messages': 0}
|
|
if full:
|
|
remotos_conv = set(conversations_map)
|
|
sobran_conv = Conv.search([]).filtered(lambda c: c.external_id not in remotos_conv)
|
|
if sobran_conv:
|
|
borrados['conversations'] = len(sobran_conv)
|
|
sobran_conv.unlink()
|
|
remotos_msg = {m['id'] for m in messages_data}
|
|
sobran_msg = Msg.search([]).filtered(lambda m: m.external_id not in remotos_msg)
|
|
if sobran_msg:
|
|
borrados['messages'] = len(sobran_msg)
|
|
sobran_msg.unlink()
|
|
|
|
# 10. Marcas: avanzan al máximo visto en cada tabla, nunca retroceden.
|
|
ahora = fields.Datetime.now()
|
|
ICP.set_param(PARAM_LAST_SYNC_AT, _to_supabase_dt(ahora))
|
|
if full:
|
|
ICP.set_param(PARAM_LAST_FULL_AT, _to_supabase_dt(ahora))
|
|
|
|
def _avanzar(param, nuevo):
|
|
if not nuevo:
|
|
return
|
|
previo = _parse_datetime(ICP.get_param(param))
|
|
if previo is None or nuevo > previo:
|
|
ICP.set_param(param, _to_supabase_dt(nuevo))
|
|
|
|
_avanzar(PARAM_CONV_MARK, _max_dt(conversations_data, 'updated_at'))
|
|
_avanzar(PARAM_MSG_MARK, _max_dt(messages_data, 'created_at'))
|
|
# Solo la página del barrido mueve la marca de contactos: los
|
|
# referenciados se traen siempre aparte, y usarlos aquí saltaría
|
|
# contactos intermedios del barrido.
|
|
_avanzar(PARAM_CONTACT_MARK, _max_dt(contactos_barridos, 'updated_at'))
|
|
_avanzar(PARAM_DEAL_MARK, _max_dt(deals_data, 'updated_at'))
|
|
|
|
return {
|
|
'mode': modo,
|
|
'contacts': len(contacts_data),
|
|
'conversations': len(conversations_data),
|
|
'messages': len(messages_data),
|
|
'pipelines': len(pipelines_data),
|
|
'stages': len(stages_data),
|
|
'deals': len(deals_data),
|
|
'deals_created': deals_created,
|
|
'deleted': borrados,
|
|
'account_id': account_id,
|
|
'duration_ms': int((time.monotonic() - inicio) * 1000),
|
|
'last_sync_at': ICP.get_param(PARAM_LAST_SYNC_AT),
|
|
}
|
|
|
|
def _sync_conversacion(self, external_id):
|
|
"""Alta puntual de UNA conversación, su contacto y sus mensajes."""
|
|
Conv = request.env['skeen.wacrm.conversation'].sudo()
|
|
convs = _supabase_request('GET', 'conversations', {
|
|
'select': '*', 'id': 'eq.%s' % external_id})
|
|
if convs:
|
|
contacts_map = _traer_contactos(
|
|
request.env, {c['contact_id'] for c in convs if c.get('contact_id')})
|
|
_bulk_upsert(Conv, [_conversacion_a_vals(c, contacts_map) for c in convs])
|
|
conv = Conv.search([('external_id', '=', external_id)], limit=1)
|
|
if not conv:
|
|
return {'conversation': 0, 'messages': 0}
|
|
msgs = _supabase_request_paginated('messages', {
|
|
'select': '*', 'conversation_id': 'eq.%s' % external_id,
|
|
'order': 'created_at.asc,id.asc'})
|
|
_bulk_upsert(request.env['skeen.wacrm.message'].sudo(), [{
|
|
'external_id': m['id'],
|
|
'conversation_id': conv.id,
|
|
'sender_type': {'contact': 'customer', 'user': 'agent'}.get(
|
|
m.get('sender_type', 'customer'), m.get('sender_type', 'customer')),
|
|
'content_type': m.get('content_type') or 'text',
|
|
'content_text': m.get('content_text') or m.get('content'),
|
|
'media_url': m.get('media_url'),
|
|
'template_name': m.get('template_name'),
|
|
'status': m.get('status', 'sent'),
|
|
'created_at': _parse_datetime(m.get('created_at')),
|
|
} for m in msgs])
|
|
return {'conversation': len(convs), 'messages': len(msgs)}
|
|
|
|
@http.route('/skeen/frontend/v1/wacrm/notify', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
|
def wacrm_notify(self, **kw):
|
|
"""Aviso puntual del puente de Hermes: "llegó un mensaje a esta conversación".
|
|
|
|
Da de alta solo esa conversación, su contacto y sus mensajes, para que
|
|
la bandeja se actualice en segundos sin esperar el ciclo. Es aditivo y
|
|
a prueba de fallos: si el aviso falla o se pierde, la sincronización
|
|
periódica cubre el mensaje igual. Se protege con llave compartida.
|
|
"""
|
|
if request.httprequest.method == 'OPTIONS':
|
|
return json_response({}, 200)
|
|
try:
|
|
esperada = os.environ.get('SKEEN_WACRM_NOTIFY_KEY') or \
|
|
request.env['ir.config_parameter'].sudo().get_param(PARAM_NOTIFY_KEY)
|
|
if not esperada:
|
|
return json_response({'status': 'error', 'message': 'Aviso puntual deshabilitado'}, 503)
|
|
if not hmac.compare_digest(request.httprequest.headers.get('X-Skeen-Key') or '', esperada):
|
|
return json_response({'status': 'error', 'message': 'Llave inválida'}, 401)
|
|
external_id = (_parse_json_body().get('conversation_id') or '').strip()
|
|
if not external_id:
|
|
return json_response({'status': 'error', 'message': 'Falta conversation_id'}, 400)
|
|
try:
|
|
request.env.cr.execute(
|
|
"SELECT pg_try_advisory_xact_lock(hashtext('skeen_wacrm_sync'))")
|
|
libre = request.env.cr.fetchone()[0]
|
|
except Exception:
|
|
libre = True
|
|
if not libre:
|
|
return json_response({'status': 'busy'})
|
|
return json_response({'status': 'success', 'result': self._sync_conversacion(external_id)})
|
|
except Exception as e:
|
|
_logger.exception('Error en el aviso puntual de WACRM')
|
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
|
|
|
# ============================================================
|
|
# Mensajes
|
|
# ============================================================
|
|
@http.route('/skeen/frontend/v1/wacrm/messages', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
|
@require_role('lectura')
|
|
@require_perm('whatsapp.responder')
|
|
def send_message(self, **kw):
|
|
try:
|
|
data = _parse_json_body()
|
|
conversation_id = data.get('conversation_id')
|
|
to = data.get('to')
|
|
text = data.get('text')
|
|
assigned_agent_id = data.get('assigned_agent_id')
|
|
|
|
if not text:
|
|
return json_response({'status': 'error', 'message': 'Falta texto del mensaje'}, 400)
|
|
if not conversation_id and not to:
|
|
return json_response({'status': 'error', 'message': 'Falta conversation_id o destinatario'}, 400)
|
|
|
|
# Resolver teléfono de destino
|
|
phone = to
|
|
conv_external_id = conversation_id
|
|
if conversation_id and not phone:
|
|
conv = request.env['skeen.wacrm.conversation'].sudo().search([('external_id', '=', conversation_id)], limit=1)
|
|
if conv and conv.contact_id:
|
|
phone = conv.contact_id.phone
|
|
elif conv:
|
|
phone = conv.contact_phone
|
|
|
|
# Normalizar a E.164
|
|
if phone and not phone.startswith('+'):
|
|
digits = re.sub(r'\D', '', phone)
|
|
phone = '+' + digits.lstrip('0') if digits else ''
|
|
|
|
if not phone:
|
|
return json_response({'status': 'error', 'message': 'No se pudo resolver el teléfono destino'}, 400)
|
|
|
|
# Enviar mensaje vía bridge de Hermes (tiene la API key real de WACRM)
|
|
result = _hermes_bridge_request('/send-message', {
|
|
'conversation_id': conv_external_id,
|
|
'text': text,
|
|
'assigned_agent_id': assigned_agent_id,
|
|
})
|
|
|
|
return json_response({'status': 'success', 'message': 'Mensaje enviado', 'result': result})
|
|
except Exception as e:
|
|
_logger.exception('Error enviando mensaje WACRM')
|
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
|
|
|
@http.route('/skeen/frontend/v1/wacrm/messages', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
|
def list_messages(self, **kw):
|
|
try:
|
|
domain = []
|
|
if kw.get('conversation_id'):
|
|
conv = request.env['skeen.wacrm.conversation'].sudo().search([('external_id', '=', kw.get('conversation_id'))], limit=1)
|
|
if conv:
|
|
domain.append(('conversation_id', '=', conv.id))
|
|
if kw.get('contact_id'):
|
|
contact = request.env['skeen.wacrm.contact'].sudo().search([('external_id', '=', kw.get('contact_id'))], limit=1)
|
|
if contact:
|
|
domain.append(('contact_id', '=', contact.id))
|
|
if kw.get('search'):
|
|
search = kw.get('search')
|
|
domain += ['|', ('content_text', 'ilike', search), ('contact_name', 'ilike', search)]
|
|
|
|
messages = request.env['skeen.wacrm.message'].sudo().search(domain, order='created_at desc', limit=200)
|
|
return json_response({
|
|
'status': 'success',
|
|
'messages': [self._message_to_dict(m) for m in messages],
|
|
})
|
|
except Exception as e:
|
|
_logger.exception('Error listando mensajes WACRM')
|
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
|
|
|
def _supabase_message_to_dict(self, m):
|
|
"""Mensaje de Supabase en el formato que consume el frontend."""
|
|
return {
|
|
'id': m.get('id'),
|
|
'external_id': m.get('id'),
|
|
'conversation_id': m.get('conversation_id'),
|
|
'sender_type': {'contact': 'customer', 'user': 'agent'}.get(m.get('sender_type', 'customer'), m.get('sender_type', 'customer')),
|
|
'content_type': m.get('content_type', 'text'),
|
|
'content_text': m.get('content_text') or m.get('content') or '',
|
|
'media_url': m.get('media_url') or '',
|
|
'template_name': m.get('template_name') or '',
|
|
'status': m.get('status', 'sent'),
|
|
'created_at': (m.get('created_at') or '')[:19].replace('T', ' '),
|
|
}
|
|
|
|
@http.route('/skeen/frontend/v1/wacrm/conversations/<string:external_id>/messages', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
|
def list_conversation_messages_origin(self, external_id, **kw):
|
|
"""Historial COMPLETO de una conversación, leído directo del origen
|
|
(Supabase del WACRM), paginado. Devuelve los mensajes en orden
|
|
ascendente dentro de la página; `has_more` indica si hay página previa
|
|
(mensajes más antiguos)."""
|
|
try:
|
|
page = max(int(kw.get('page', 1) or 1), 1)
|
|
page_size = min(max(int(kw.get('page_size', 50) or 50), 1), 200)
|
|
data = _supabase_request('GET', 'messages', params={
|
|
'select': '*',
|
|
'conversation_id': f'eq.{external_id}',
|
|
'order': 'created_at.desc',
|
|
'limit': page_size,
|
|
'offset': (page - 1) * page_size,
|
|
})
|
|
msgs = [self._supabase_message_to_dict(m) for m in data]
|
|
msgs.reverse() # ascendente dentro de la página
|
|
return json_response({
|
|
'status': 'success',
|
|
'messages': msgs,
|
|
'page': page,
|
|
'page_size': page_size,
|
|
'has_more': len(data) == page_size,
|
|
})
|
|
except Exception as e:
|
|
_logger.exception('Error listando mensajes del origen WACRM')
|
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
|
|
|
@http.route('/skeen/frontend/v1/wacrm/conversations', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
|
def list_conversations(self, **kw):
|
|
try:
|
|
domain = []
|
|
if kw.get('search'):
|
|
search = kw.get('search')
|
|
domain += ['|', ('contact_name', 'ilike', search), ('contact_phone', 'ilike', search)]
|
|
conversations = request.env['skeen.wacrm.conversation'].sudo().search(domain, order='last_message_at desc', limit=200)
|
|
return json_response({
|
|
'status': 'success',
|
|
'conversations': [self._conversation_to_dict(c) for c in conversations],
|
|
'last_sync_at': request.env['ir.config_parameter'].sudo().get_param(PARAM_LAST_SYNC_AT),
|
|
})
|
|
except Exception as e:
|
|
_logger.exception('Error listando conversaciones WACRM')
|
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
|
|
|
@http.route('/skeen/frontend/v1/wacrm/members', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
|
def list_members(self, **kw):
|
|
try:
|
|
rows = _supabase_request(
|
|
'GET',
|
|
'profiles',
|
|
params={'select': 'user_id,full_name,email,avatar_url,account_role,created_at', 'order': 'created_at.asc'}
|
|
)
|
|
members = [{
|
|
'id': r.get('user_id'),
|
|
'profile_id': r.get('id'),
|
|
'name': r.get('full_name') or r.get('email') or r.get('user_id'),
|
|
'email': r.get('email'),
|
|
'avatar_url': r.get('avatar_url'),
|
|
'role': r.get('account_role') or 'user',
|
|
} for r in rows]
|
|
return json_response({'status': 'success', 'members': members})
|
|
except Exception as e:
|
|
_logger.exception('Error listando miembros WACRM')
|
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
|
|
|
@http.route('/skeen/frontend/v1/wacrm/conversations/<string:external_id>/assign', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
|
@require_role('lectura')
|
|
@require_perm('whatsapp.asignar')
|
|
def assign_conversation(self, external_id, **kw):
|
|
try:
|
|
data = _parse_json_body()
|
|
assigned_agent_id = data.get('assigned_agent_id')
|
|
|
|
# Actualizar directamente en Supabase
|
|
update = {'assigned_agent_id': assigned_agent_id, 'updated_at': datetime.utcnow().isoformat() + 'Z'}
|
|
_supabase_request(
|
|
'PATCH',
|
|
'conversations',
|
|
params={'id': f'eq.{external_id}'},
|
|
json=update,
|
|
headers_extra={'Prefer': 'return=minimal'}
|
|
)
|
|
|
|
# Actualizar cache local en Odoo
|
|
conv = request.env['skeen.wacrm.conversation'].sudo().search([('external_id', '=', external_id)], limit=1)
|
|
if conv:
|
|
conv.write({
|
|
'assigned_agent_id': assigned_agent_id or None,
|
|
'assigned_agent': str(assigned_agent_id) if assigned_agent_id else None,
|
|
})
|
|
|
|
return json_response({'status': 'success', 'assigned_agent_id': assigned_agent_id})
|
|
except Exception as e:
|
|
_logger.exception('Error asignando conversación WACRM')
|
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
|
|
|
@http.route('/skeen/frontend/v1/wacrm/conversations/<string:external_id>/assigned', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
|
def is_conversation_assigned(self, external_id, **kw):
|
|
try:
|
|
conv = request.env['skeen.wacrm.conversation'].sudo().search([('external_id', '=', external_id)], limit=1)
|
|
if not conv:
|
|
return json_response({'status': 'success', 'assigned': False})
|
|
return json_response({
|
|
'status': 'success',
|
|
'assigned': bool(conv.assigned_agent_id),
|
|
'assigned_agent_id': conv.assigned_agent_id or None,
|
|
'assigned_agent_name': conv.assigned_agent or None,
|
|
})
|
|
except Exception as e:
|
|
_logger.exception('Error consultando asignación WACRM')
|
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
|
|
|
# ============================================================
|
|
# Leads / Deals
|
|
# ============================================================
|
|
@http.route('/skeen/frontend/v1/wacrm/pipelines', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
|
def list_pipelines(self, **kw):
|
|
try:
|
|
pipelines = request.env['skeen.wacrm.pipeline'].sudo().search([], order='name asc')
|
|
return json_response({
|
|
'status': 'success',
|
|
'pipelines': [{
|
|
'id': p.id,
|
|
'external_id': p.external_id,
|
|
'name': p.name,
|
|
'stages': [{
|
|
'id': s.id,
|
|
'external_id': s.external_id,
|
|
'name': s.name,
|
|
'position': s.position,
|
|
'color': s.color or '#3b82f6',
|
|
} for s in request.env['skeen.wacrm.stage'].sudo().search([('pipeline_id', '=', p.id)], order='position asc')]
|
|
} for p in pipelines],
|
|
})
|
|
except Exception as e:
|
|
_logger.exception('Error listando pipelines WACRM')
|
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
|
|
|
@http.route('/skeen/frontend/v1/wacrm/leads', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
|
def list_leads(self, **kw):
|
|
try:
|
|
domain = []
|
|
if kw.get('status'):
|
|
domain.append(('status', '=', kw.get('status')))
|
|
if kw.get('pipeline_id'):
|
|
pipe = request.env['skeen.wacrm.pipeline'].sudo().search([('external_id', '=', kw.get('pipeline_id'))], limit=1)
|
|
if pipe:
|
|
domain.append(('pipeline_id', '=', pipe.id))
|
|
if kw.get('search'):
|
|
search = kw.get('search')
|
|
domain += ['|', ('title', 'ilike', search), ('contact_name', 'ilike', search)]
|
|
|
|
deals = request.env['skeen.wacrm.deal'].sudo().search(domain, order='created_at desc', limit=200)
|
|
return json_response({
|
|
'status': 'success',
|
|
'leads': [self._deal_to_dict(d) for d in deals],
|
|
})
|
|
except Exception as e:
|
|
_logger.exception('Error listando leads WACRM')
|
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
|
|
|
@http.route('/skeen/frontend/v1/wacrm/leads/<int:lead_id>/status', type='http', auth='none', methods=['PUT'], csrf=False)
|
|
@require_role('lectura')
|
|
@require_perm('whatsapp.asignar')
|
|
def update_lead_status(self, lead_id, **kw):
|
|
try:
|
|
deal = request.env['skeen.wacrm.deal'].sudo().browse(lead_id)
|
|
if not deal.exists():
|
|
return json_response({'status': 'error', 'message': 'Lead no encontrado'}, 404)
|
|
data = _parse_json_body()
|
|
update_remote = {}
|
|
if data.get('status') in ('open', 'won', 'lost'):
|
|
deal.write({'status': data.get('status')})
|
|
update_remote['status'] = data.get('status')
|
|
if data.get('stage_id'):
|
|
stage = request.env['skeen.wacrm.stage'].sudo().search([('external_id', '=', data.get('stage_id'))], limit=1)
|
|
if stage:
|
|
deal.write({'stage_id': stage.id})
|
|
update_remote['stage_id'] = stage.external_id
|
|
if 'assigned_to' in data:
|
|
# La UI manda user_id; el FK remoto deals.assigned_to apunta a profiles.id
|
|
assigned = data.get('assigned_to') or None
|
|
deal.write({'assigned_to': assigned})
|
|
if assigned:
|
|
prof = _supabase_request('GET', 'profiles', params={
|
|
'select': 'id', 'user_id': f'eq.{assigned}', 'limit': 1})
|
|
update_remote['assigned_to'] = prof[0]['id'] if prof else None
|
|
else:
|
|
update_remote['assigned_to'] = None
|
|
if update_remote:
|
|
# Reflejar en Supabase para que el próximo sync no lo revierta
|
|
update_remote['updated_at'] = datetime.utcnow().isoformat() + 'Z'
|
|
_supabase_request(
|
|
'PATCH', 'deals',
|
|
params={'id': f'eq.{deal.external_id}'},
|
|
json=update_remote,
|
|
headers_extra={'Prefer': 'return=minimal'})
|
|
return json_response({'status': 'success', 'lead': self._deal_to_dict(deal)})
|
|
except Exception as e:
|
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
|
|
|
@http.route('/skeen/frontend/v1/wacrm/leads/<int:lead_id>/convert', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
|
@require_role('lectura')
|
|
@require_perm('whatsapp.asignar')
|
|
def convert_lead(self, lead_id, **kw):
|
|
"""Convierte el lead en paciente SKEEN (crea o liga por teléfono) y lo marca ganado"""
|
|
try:
|
|
deal = request.env['skeen.wacrm.deal'].sudo().browse(lead_id)
|
|
if not deal.exists():
|
|
return json_response({'status': 'error', 'message': 'Lead no encontrado'}, 404)
|
|
# Idempotente: ya convertido
|
|
if deal.partner_id:
|
|
return json_response({
|
|
'status': 'success',
|
|
'lead': self._deal_to_dict(deal),
|
|
'patient_id': deal.partner_id.id,
|
|
'created': False,
|
|
})
|
|
contact = deal.contact_id
|
|
if not contact:
|
|
return json_response({'status': 'error', 'message': 'El lead no tiene contacto'}, 400)
|
|
# Normalizar teléfono (52 + 10 dígitos, como el padrón de pacientes)
|
|
digits = re.sub(r'\D', '', contact.phone or '')
|
|
if len(digits) == 10:
|
|
phone = '52' + digits
|
|
else:
|
|
phone = digits
|
|
if not phone:
|
|
return json_response({'status': 'error', 'message': 'El contacto no tiene teléfono'}, 400)
|
|
Partner = request.env['res.partner'].sudo()
|
|
partner = Partner.search([('is_patient', '=', True), '|', ('phone', '=', phone), ('mobile', '=', phone)], limit=1)
|
|
created = False
|
|
if not partner:
|
|
partner = Partner.create({
|
|
'name': contact.name or phone,
|
|
'phone': phone,
|
|
'is_patient': True,
|
|
'source': 'whatsapp',
|
|
})
|
|
created = True
|
|
deal.write({'partner_id': partner.id, 'status': 'won'})
|
|
_supabase_request(
|
|
'PATCH', 'deals',
|
|
params={'id': f'eq.{deal.external_id}'},
|
|
json={'status': 'won', 'updated_at': datetime.utcnow().isoformat() + 'Z'},
|
|
headers_extra={'Prefer': 'return=minimal'})
|
|
return json_response({
|
|
'status': 'success',
|
|
'lead': self._deal_to_dict(deal),
|
|
'patient_id': partner.id,
|
|
'created': created,
|
|
})
|
|
except Exception as e:
|
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
|
|
|
def _message_to_dict(self, m):
|
|
return {
|
|
'id': m.id,
|
|
'external_id': m.external_id,
|
|
'conversation_id': m.conversation_id.external_id if m.conversation_id else None,
|
|
'contact_name': m.contact_id.name if m.contact_id else '',
|
|
'contact_phone': m.contact_id.phone if m.contact_id else '',
|
|
'sender_type': m.sender_type,
|
|
'content_type': m.content_type,
|
|
'content_text': m.content_text or '',
|
|
'media_url': m.media_url or '',
|
|
'template_name': m.template_name or '',
|
|
'status': m.status,
|
|
'created_at': m.created_at.strftime('%Y-%m-%d %H:%M:%S') if m.created_at else None,
|
|
}
|
|
|
|
def _conversation_to_dict(self, c):
|
|
return {
|
|
'id': c.id,
|
|
'external_id': c.external_id,
|
|
'contact_id': c.contact_id.external_id if c.contact_id else None,
|
|
'contact_name': c.contact_name,
|
|
'contact_phone': c.contact_phone,
|
|
'status': c.status,
|
|
'assigned_agent': c.assigned_agent or '',
|
|
'assigned_agent_id': c.assigned_agent_id or '',
|
|
'last_message_text': c.last_message_text or '',
|
|
'last_message_at': c.last_message_at.strftime('%Y-%m-%d %H:%M:%S') if c.last_message_at else None,
|
|
'unread_count': c.unread_count,
|
|
'created_at': c.created_at.strftime('%Y-%m-%d %H:%M:%S') if c.created_at else None,
|
|
'tags': self._tags_de(c.external_id),
|
|
}
|
|
|
|
# ============================================================
|
|
# Notas internas y etiquetas (viven en Odoo, sobreviven al sync)
|
|
# ============================================================
|
|
def _tags_de(self, external_id):
|
|
rows = request.env['skeen.wacrm.conversation.tag'].sudo().search([
|
|
('conversation_external_id', '=', external_id)])
|
|
return [{'id': r.tag_id.id, 'name': r.tag_id.name, 'color': r.tag_id.color}
|
|
for r in rows if r.tag_id.active]
|
|
|
|
def _note_to_dict(self, n):
|
|
return {
|
|
'id': n.id,
|
|
'body': n.body,
|
|
'author': n.author_name or n.author_login or '',
|
|
'date': n.create_date.strftime('%Y-%m-%d %H:%M') if n.create_date else None,
|
|
'resolved': n.resolved,
|
|
'resolved_at': n.resolved_at.strftime('%Y-%m-%d %H:%M') if n.resolved_at else None,
|
|
'resolved_by': n.resolved_by or '',
|
|
}
|
|
|
|
def _tag_to_dict(self, t):
|
|
return {'id': t.id, 'name': t.name, 'color': t.color, 'sequence': t.sequence,
|
|
'active': t.active}
|
|
|
|
# ---- Notas internas ----
|
|
@http.route('/skeen/frontend/v1/wacrm/conversations/<string:external_id>/notes', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
|
@require_role('lectura')
|
|
def list_notes(self, external_id, **kw):
|
|
"""Notas internas de la conversación. Nunca se envían al paciente."""
|
|
try:
|
|
notes = request.env['skeen.wacrm.note'].sudo().search([
|
|
('conversation_external_id', '=', external_id)])
|
|
return json_response({
|
|
'status': 'success',
|
|
'notes': [self._note_to_dict(n) for n in notes],
|
|
'pendientes': notes.filtered(lambda n: not n.resolved).__len__(),
|
|
})
|
|
except Exception as e:
|
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
|
|
|
@http.route('/skeen/frontend/v1/wacrm/conversations/<string:external_id>/notes', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
|
@require_role('recepcion')
|
|
@require_perm('whatsapp.responder')
|
|
def create_note(self, external_id, **kw):
|
|
try:
|
|
data = _parse_json_body()
|
|
body = (data.get('body') or '').strip()
|
|
if not body:
|
|
return json_response({'status': 'error', 'message': 'La nota no puede estar vacía'}, 400)
|
|
me = getattr(request, 'frontend_user', None)
|
|
conv = request.env['skeen.wacrm.conversation'].sudo().search(
|
|
[('external_id', '=', external_id)], limit=1)
|
|
note = request.env['skeen.wacrm.note'].sudo().create({
|
|
'conversation_external_id': external_id,
|
|
'contact_name': conv.contact_name if conv else '',
|
|
'body': body,
|
|
'author_name': getattr(me, 'name', '') or '',
|
|
'author_login': getattr(me, 'login', '') or '',
|
|
})
|
|
return json_response({'status': 'success', 'note': self._note_to_dict(note)}, 201)
|
|
except Exception as e:
|
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
|
|
|
@http.route('/skeen/frontend/v1/wacrm/notes/<int:note_id>', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
|
@require_role('recepcion')
|
|
@require_perm('whatsapp.responder')
|
|
def update_note(self, note_id, **kw):
|
|
"""Marca/desmarca una nota como atendida, o edita su texto."""
|
|
try:
|
|
note = request.env['skeen.wacrm.note'].sudo().browse(note_id)
|
|
if not note.exists():
|
|
return json_response({'status': 'error', 'message': 'Nota no encontrada'}, 404)
|
|
data = _parse_json_body()
|
|
vals = {}
|
|
if 'body' in data:
|
|
body = (data.get('body') or '').strip()
|
|
if not body:
|
|
return json_response({'status': 'error', 'message': 'La nota no puede estar vacía'}, 400)
|
|
vals['body'] = body
|
|
if 'resolved' in data:
|
|
me = getattr(request, 'frontend_user', None)
|
|
resolved = bool(data.get('resolved'))
|
|
vals['resolved'] = resolved
|
|
vals['resolved_at'] = fields.Datetime.now() if resolved else False
|
|
vals['resolved_by'] = (getattr(me, 'name', '') or getattr(me, 'login', '')) if resolved else ''
|
|
if vals:
|
|
note.write(vals)
|
|
return json_response({'status': 'success', 'note': self._note_to_dict(note)})
|
|
except Exception as e:
|
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
|
|
|
@http.route('/skeen/frontend/v1/wacrm/notes/<int:note_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
|
|
@require_role('recepcion')
|
|
@require_perm('whatsapp.responder')
|
|
def delete_note(self, note_id, **kw):
|
|
try:
|
|
note = request.env['skeen.wacrm.note'].sudo().browse(note_id)
|
|
if not note.exists():
|
|
return json_response({'status': 'error', 'message': 'Nota no encontrada'}, 404)
|
|
note.unlink()
|
|
return json_response({'status': 'success', 'message': 'Nota eliminada'})
|
|
except Exception as e:
|
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
|
|
|
# ---- Etiquetas ----
|
|
@http.route('/skeen/frontend/v1/wacrm/tags', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
|
@require_role('lectura')
|
|
def list_tags(self, **kw):
|
|
try:
|
|
tags = request.env['skeen.wacrm.tag'].sudo().search([('active', '=', True)])
|
|
return json_response({'status': 'success', 'tags': [self._tag_to_dict(t) for t in tags]})
|
|
except Exception as e:
|
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
|
|
|
@http.route('/skeen/frontend/v1/wacrm/tags', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
|
@require_role('recepcion')
|
|
@require_perm('whatsapp.responder')
|
|
def create_tag(self, **kw):
|
|
try:
|
|
data = _parse_json_body()
|
|
name = (data.get('name') or '').strip()
|
|
if not name:
|
|
return json_response({'status': 'error', 'message': 'El nombre es obligatorio'}, 400)
|
|
# active_test=False: una etiqueta desactivada debe poder reactivarse
|
|
Tag = request.env['skeen.wacrm.tag'].sudo().with_context(active_test=False)
|
|
existente = Tag.search([('name', '=ilike', name)], limit=1)
|
|
if existente and existente.active:
|
|
return json_response({'status': 'error', 'message': 'Ya existe una etiqueta con ese nombre'}, 409)
|
|
if existente:
|
|
# Estaba desactivada: se reactiva en vez de fallar por el nombre
|
|
existente.write({
|
|
'active': True,
|
|
'color': data.get('color') or existente.color or '#605ca8',
|
|
'sequence': int(data.get('sequence') or existente.sequence or 10),
|
|
})
|
|
return json_response({'status': 'success', 'tag': self._tag_to_dict(existente)})
|
|
tag = Tag.create({
|
|
'name': name,
|
|
'color': data.get('color') or '#605ca8',
|
|
'sequence': int(data.get('sequence') or 10),
|
|
})
|
|
return json_response({'status': 'success', 'tag': self._tag_to_dict(tag)}, 201)
|
|
except Exception as e:
|
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
|
|
|
@http.route('/skeen/frontend/v1/wacrm/tags/<int:tag_id>', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
|
@require_role('recepcion')
|
|
@require_perm('whatsapp.responder')
|
|
def update_tag(self, tag_id, **kw):
|
|
try:
|
|
tag = request.env['skeen.wacrm.tag'].sudo().browse(tag_id)
|
|
if not tag.exists():
|
|
return json_response({'status': 'error', 'message': 'Etiqueta no encontrada'}, 404)
|
|
data = _parse_json_body()
|
|
vals = {}
|
|
if 'name' in data:
|
|
name = (data.get('name') or '').strip()
|
|
if not name:
|
|
return json_response({'status': 'error', 'message': 'El nombre es obligatorio'}, 400)
|
|
dup = request.env['skeen.wacrm.tag'].sudo().with_context(active_test=False).search(
|
|
[('name', '=ilike', name), ('id', '!=', tag_id), ('active', '=', True)], limit=1)
|
|
if dup:
|
|
return json_response({'status': 'error', 'message': 'Ya existe una etiqueta con ese nombre'}, 409)
|
|
vals['name'] = name
|
|
if 'color' in data:
|
|
vals['color'] = data.get('color') or '#605ca8'
|
|
if 'sequence' in data:
|
|
vals['sequence'] = int(data.get('sequence') or 10)
|
|
if 'active' in data:
|
|
vals['active'] = bool(data.get('active'))
|
|
if vals:
|
|
tag.write(vals)
|
|
return json_response({'status': 'success', 'tag': self._tag_to_dict(tag)})
|
|
except Exception as e:
|
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
|
|
|
@http.route('/skeen/frontend/v1/wacrm/tags/<int:tag_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
|
|
@require_role('recepcion')
|
|
@require_perm('whatsapp.responder')
|
|
def delete_tag(self, tag_id, **kw):
|
|
"""Desactiva la etiqueta (conserva el histórico de asignaciones)."""
|
|
try:
|
|
tag = request.env['skeen.wacrm.tag'].sudo().browse(tag_id)
|
|
if not tag.exists():
|
|
return json_response({'status': 'error', 'message': 'Etiqueta no encontrada'}, 404)
|
|
tag.write({'active': False})
|
|
return json_response({'status': 'success', 'message': 'Etiqueta desactivada'})
|
|
except Exception as e:
|
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
|
|
|
@http.route('/skeen/frontend/v1/wacrm/conversations/<string:external_id>/tags', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
|
@require_role('recepcion')
|
|
def assign_tag(self, external_id, **kw):
|
|
"""Asigna o quita una etiqueta de la conversación.
|
|
|
|
Body: {"tag_id": 3, "action": "add" | "remove"} (action por defecto add).
|
|
"""
|
|
try:
|
|
data = _parse_json_body()
|
|
tag_id = int(data.get('tag_id') or 0)
|
|
tag = request.env['skeen.wacrm.tag'].sudo().browse(tag_id)
|
|
if not tag.exists():
|
|
return json_response({'status': 'error', 'message': 'Etiqueta no encontrada'}, 404)
|
|
Rel = request.env['skeen.wacrm.conversation.tag'].sudo()
|
|
action = (data.get('action') or 'add').lower()
|
|
if action == 'remove':
|
|
Rel.search([('conversation_external_id', '=', external_id),
|
|
('tag_id', '=', tag_id)]).unlink()
|
|
else:
|
|
if not Rel.search([('conversation_external_id', '=', external_id),
|
|
('tag_id', '=', tag_id)], limit=1):
|
|
me = getattr(request, 'frontend_user', None)
|
|
Rel.create({
|
|
'conversation_external_id': external_id,
|
|
'tag_id': tag_id,
|
|
'assigned_by': getattr(me, 'login', '') or '',
|
|
})
|
|
return json_response({'status': 'success', 'tags': self._tags_de(external_id)})
|
|
except Exception as e:
|
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
|
|
|
# ============================================================
|
|
# Métricas para gerencia
|
|
# ============================================================
|
|
@http.route('/skeen/frontend/v1/wacrm/metrics', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
|
@require_role('lectura')
|
|
def wacrm_metrics(self, **kw):
|
|
"""Tablero de atención de WhatsApp para gerencia.
|
|
|
|
Criterios (documentados también en la respuesta):
|
|
- "primera respuesta": minutos entre el PRIMER mensaje entrante del
|
|
cliente y el PRIMER mensaje saliente posterior en la misma
|
|
conversación. En WACRM el bot se registra como sender_type='agent',
|
|
así que cuenta como respuesta del equipo.
|
|
- "sin responder": conversaciones cuyo ÚLTIMO mensaje es del cliente y
|
|
lleva más de N horas (parámetro `sin_respuesta_horas`, 4 por defecto).
|
|
- "leads ganados/perdidos del mes": deals con status won/lost y
|
|
updated_at dentro del mes en curso.
|
|
"""
|
|
try:
|
|
# Sólo admin/gerencia: el rango de roles no distingue gerencia de
|
|
# medico (ambos 50), así que se comprueba el rol explícitamente.
|
|
if request.frontend_user.role not in ('admin', 'gerencia'):
|
|
return json_response({'status': 'error', 'message': 'Sólo gerencia puede ver las métricas'}, 403)
|
|
from statistics import median
|
|
hoy = datetime.utcnow()
|
|
horas = float(kw.get('sin_respuesta_horas') or 4)
|
|
|
|
convs = _supabase_request_paginated('conversations', {'select': '*'})
|
|
msgs = _supabase_request_paginated(
|
|
'messages', {'select': 'conversation_id,sender_type,created_at', 'order': 'created_at.asc'})
|
|
|
|
def en_rango(dt, ini, fin=None):
|
|
return dt is not None and dt >= ini and (fin is None or dt <= fin)
|
|
|
|
ini_hoy = hoy.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
ini_semana = ini_hoy - timedelta(days=ini_hoy.weekday())
|
|
ini_mes = ini_hoy.replace(day=1)
|
|
|
|
creadas_hoy = creadas_semana = creadas_mes = 0
|
|
for c in convs:
|
|
dt = _parse_datetime(c.get('created_at'))
|
|
if not dt:
|
|
continue
|
|
if dt >= ini_hoy:
|
|
creadas_hoy += 1
|
|
if dt >= ini_semana:
|
|
creadas_semana += 1
|
|
if dt >= ini_mes:
|
|
creadas_mes += 1
|
|
|
|
entrantes_hoy = salientes_hoy = 0
|
|
entrantes_semana = salientes_semana = 0
|
|
entrantes_mes = salientes_mes = 0
|
|
for m in msgs:
|
|
dt = _parse_datetime(m.get('created_at'))
|
|
if not dt:
|
|
continue
|
|
es_in = m.get('sender_type') == 'customer'
|
|
if dt >= ini_hoy:
|
|
entrantes_hoy += 1 if es_in else 0
|
|
salientes_hoy += 0 if es_in else 1
|
|
if dt >= ini_semana:
|
|
entrantes_semana += 1 if es_in else 0
|
|
salientes_semana += 0 if es_in else 1
|
|
if dt >= ini_mes:
|
|
entrantes_mes += 1 if es_in else 0
|
|
salientes_mes += 0 if es_in else 1
|
|
|
|
# Primera respuesta por conversación
|
|
por_conv = {}
|
|
for m in msgs:
|
|
dt = _parse_datetime(m.get('created_at'))
|
|
if not dt:
|
|
continue
|
|
por_conv.setdefault(m.get('conversation_id'), []).append((dt, m.get('sender_type')))
|
|
|
|
tiempos = []
|
|
for cid, lista in por_conv.items():
|
|
lista.sort(key=lambda x: x[0])
|
|
primera_in = next((dt for dt, st in lista if st == 'customer'), None)
|
|
if not primera_in:
|
|
continue
|
|
respuesta = next((dt for dt, st in lista if st != 'customer' and dt >= primera_in), None)
|
|
if respuesta:
|
|
tiempos.append((respuesta - primera_in).total_seconds() / 60.0)
|
|
|
|
sin_responder = 0
|
|
detalle_sin_responder = []
|
|
for c in convs:
|
|
lista = por_conv.get(c.get('id'))
|
|
if not lista:
|
|
continue
|
|
ultimo_dt, ultimo_st = max(lista, key=lambda x: x[0])
|
|
if ultimo_st != 'customer':
|
|
continue
|
|
horas_esperando = (hoy - ultimo_dt).total_seconds() / 3600.0
|
|
if horas_esperando >= horas:
|
|
sin_responder += 1
|
|
detalle_sin_responder.append({
|
|
'conversation_id': c.get('id'),
|
|
'horas': round(horas_esperando, 1),
|
|
'ultimo_mensaje': (c.get('last_message_text') or '')[:120],
|
|
})
|
|
detalle_sin_responder.sort(key=lambda d: -d['horas'])
|
|
|
|
# Leads del mes
|
|
deals = _supabase_request_paginated('deals', {'select': 'id,status,created_at,updated_at,title,value'})
|
|
ganados = perdidos = abiertos = 0
|
|
valor_ganado = 0.0
|
|
for d in deals:
|
|
dt = _parse_datetime(d.get('updated_at')) or _parse_datetime(d.get('created_at'))
|
|
if not dt or dt < ini_mes:
|
|
continue
|
|
st = d.get('status')
|
|
if st == 'won':
|
|
ganados += 1
|
|
valor_ganado += float(d.get('value') or 0)
|
|
elif st == 'lost':
|
|
perdidos += 1
|
|
elif st == 'open':
|
|
abiertos += 1
|
|
|
|
return json_response({
|
|
'status': 'success',
|
|
'generado': hoy.strftime('%Y-%m-%d %H:%M'),
|
|
'periodo': {'desde': ini_mes.strftime('%Y-%m-%d'), 'hasta': hoy.strftime('%Y-%m-%d')},
|
|
'sin_respuesta_horas': horas,
|
|
'conversaciones': {
|
|
'hoy': creadas_hoy,
|
|
'semana': creadas_semana,
|
|
'mes': creadas_mes,
|
|
'total': len(convs),
|
|
},
|
|
'mensajes': {
|
|
'hoy': {'entrantes': entrantes_hoy, 'salientes': salientes_hoy},
|
|
'semana': {'entrantes': entrantes_semana, 'salientes': salientes_semana},
|
|
'mes': {'entrantes': entrantes_mes, 'salientes': salientes_mes},
|
|
},
|
|
'primera_respuesta': {
|
|
'muestras': len(tiempos),
|
|
'promedio_min': round(sum(tiempos) / len(tiempos), 1) if tiempos else None,
|
|
'mediana_min': round(median(tiempos), 1) if tiempos else None,
|
|
},
|
|
'sin_responder': {
|
|
'total': sin_responder,
|
|
'horas_minimas': horas,
|
|
'detalle': detalle_sin_responder[:20],
|
|
},
|
|
'leads': {
|
|
'ganados': ganados,
|
|
'perdidos': perdidos,
|
|
'abiertos': abiertos,
|
|
'valor_ganado': round(valor_ganado, 2),
|
|
},
|
|
'criterios': {
|
|
'primera_respuesta': 'Minutos entre el primer mensaje entrante del cliente y el primer mensaje saliente posterior en la misma conversación. En WACRM el bot se registra como "agent", así que cuenta como respuesta del equipo.',
|
|
'sin_responder': f'Conversaciones cuyo último mensaje es del cliente y lleva más de {horas:g} h sin respuesta.',
|
|
'leads': 'Deals con status won/lost y updated_at dentro del mes en curso.',
|
|
},
|
|
})
|
|
except Exception as e:
|
|
_logger.exception('Error calculando métricas WACRM')
|
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
|
|
|
def _deal_to_dict(self, d):
|
|
return {
|
|
'id': d.id,
|
|
'external_id': d.external_id,
|
|
'title': d.title,
|
|
'contact_id': d.contact_id.external_id if d.contact_id else None,
|
|
'contact_name': d.contact_name,
|
|
'contact_phone': d.contact_phone,
|
|
'pipeline': d.pipeline_id.name if d.pipeline_id else '',
|
|
'pipeline_id': d.pipeline_id.id if d.pipeline_id else None,
|
|
'stage': d.stage_id.name if d.stage_id else '',
|
|
'stage_id': d.stage_id.id if d.stage_id else None,
|
|
'stage_color': d.stage_id.color if d.stage_id else '#3b82f6',
|
|
'value': d.value,
|
|
'currency': d.currency,
|
|
'status': d.status,
|
|
'expected_close_date': d.expected_close_date.strftime('%Y-%m-%d') if d.expected_close_date else None,
|
|
'notes': d.notes or '',
|
|
'assigned_to': d.assigned_to or '',
|
|
'partner_id': d.partner_id.id if d.partner_id else None,
|
|
'conversation_id': d.conversation_id or '',
|
|
'created_at': d.created_at.strftime('%Y-%m-%d %H:%M:%S') if d.created_at else None,
|
|
}
|