Mejoras integrales: visitas, POS, WACRM, reportes, temas y módulos legacy
- Módulo Visitas completo (auto desde agenda, insumos con descargo de inventario, fotos antes/después y documentos, receta imprimible) - Punto de Venta (catálogo + ticket sticky, cobro con cambio, pago con puntos monedero, ticket imprimible) - WACRM: leads automáticos desde WhatsApp, asignación de conversaciones y leads a agentes, conversión lead→paciente, ficha del paciente en chat - Pacientes: completitud de expediente, alertas clínicas, historial unificado con detalle, foto, WhatsApp, estado de cuenta, filtros rápidos (VIP/recientes/médico), documentos (expediente escaneado + galería) - Agenda: vistas por médico y por hora, filtros rápidos (libres, primera vez, check-in, no-show), modal de acciones, bloqueos por médico, drag&drop para mover citas - Reportes: 18 pestañas (diario, cortes, ingresos, inventario, adeudos, comisiones, pagos, devoluciones, top clientes, horas, paquetes, vendedores, concentrado, recomendaciones, KPIs) con exportación Excel - Temas: nuevo tema Clásico (look legacy AdminLTE) con submenús tipo treeview, selector de tema; accesos rápidos personalizables con 3 presentaciones; búsqueda global; notificaciones reales - Configuración: secciones (clínica, usuarios con permisos por sección, recetas, catálogos de diagnósticos y procedimientos) - Inventario: alertas de caducidad y sugerencia de compra, cron diario que descuenta artículos caducados, compras/bajas - Consultas Médicas, página Expedientes, importadores delta (citas/visitas legacy idempotentes), depuración de duplicados - Infra: tema Tailwind conectado (@config), gzip en nginx, secuencias Odoo corregidas (noupdate, company_id), rollback en validaciones
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ import os
|
||||
import logging
|
||||
import requests
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
@@ -244,6 +245,14 @@ class SkeenWacrmProxyController(http.Controller):
|
||||
stages_map[item['id']] = request.env['skeen.wacrm.stage'].sudo().create(vals)
|
||||
|
||||
# 5. Deals
|
||||
# Mapa profiles.id → user_id (Supabase guarda assigned_to como profiles.id;
|
||||
# localmente usamos user_id, consistente con conversations.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
|
||||
deals_data = _supabase_request_paginated('deals', {'select': '*'})
|
||||
for item in deals_data:
|
||||
vals = {
|
||||
@@ -258,7 +267,7 @@ class SkeenWacrmProxyController(http.Controller):
|
||||
'status': item.get('status', 'open'),
|
||||
'expected_close_date': item.get('expected_close_date'),
|
||||
'notes': item.get('notes'),
|
||||
'assigned_to': str(item.get('assigned_to')) if item.get('assigned_to') else None,
|
||||
'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')),
|
||||
}
|
||||
@@ -268,6 +277,64 @@ class SkeenWacrmProxyController(http.Controller):
|
||||
else:
|
||||
request.env['skeen.wacrm.deal'].sudo().create(vals)
|
||||
|
||||
# 6. 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 abierto por contacto.
|
||||
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:
|
||||
Deal = request.env['skeen.wacrm.deal'].sudo()
|
||||
# Un deal por contacto: no crear si ya tiene cualquier deal (open o ganado)
|
||||
contacts_with_deals = {str(d.get('contact_id')) for d in deals_data 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)
|
||||
|
||||
return {
|
||||
'contacts': len(contacts_data),
|
||||
'conversations': len(conversations_data),
|
||||
@@ -275,6 +342,7 @@ class SkeenWacrmProxyController(http.Controller):
|
||||
'pipelines': len(pipelines_data),
|
||||
'stages': len(stages_data),
|
||||
'deals': len(deals_data),
|
||||
'deals_created': deals_created,
|
||||
'account_id': account_id,
|
||||
}
|
||||
|
||||
@@ -376,6 +444,7 @@ class SkeenWacrmProxyController(http.Controller):
|
||||
)
|
||||
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'),
|
||||
@@ -487,16 +556,89 @@ class SkeenWacrmProxyController(http.Controller):
|
||||
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)
|
||||
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,
|
||||
@@ -538,7 +680,9 @@ class SkeenWacrmProxyController(http.Controller):
|
||||
'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,
|
||||
@@ -546,5 +690,7 @@ class SkeenWacrmProxyController(http.Controller):
|
||||
'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,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import json
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
@@ -29,6 +31,8 @@ class SkeenFrontendUser(models.Model):
|
||||
must_change_password = fields.Boolean(string='Debe cambiar contraseña', default=False)
|
||||
last_login = fields.Datetime(string='Último acceso', readonly=True)
|
||||
notes = fields.Text(string='Notas')
|
||||
allowed_menus = fields.Text(string='Menús Permitidos',
|
||||
help='JSON array de hrefs visibles; vacío = defaults por rol')
|
||||
|
||||
_sql_constraints = [
|
||||
('unique_login', 'unique(login)', 'El usuario ya existe.'),
|
||||
@@ -61,6 +65,17 @@ class SkeenFrontendUser(models.Model):
|
||||
'admin': 100,
|
||||
}.get(self.role, 0)
|
||||
|
||||
def _allowed_menus_list(self):
|
||||
"""Devuelve la lista de hrefs permitidos o None (usar defaults por rol)"""
|
||||
self.ensure_one()
|
||||
if not self.allowed_menus:
|
||||
return None
|
||||
try:
|
||||
val = json.loads(self.allowed_menus)
|
||||
except Exception:
|
||||
return None
|
||||
return val if isinstance(val, list) and val else None
|
||||
|
||||
def to_public_dict(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
@@ -70,4 +85,5 @@ class SkeenFrontendUser(models.Model):
|
||||
'role': self.role,
|
||||
'must_change_password': self.must_change_password,
|
||||
'last_login': self.last_login.strftime('%Y-%m-%d %H:%M') if self.last_login else None,
|
||||
'allowed_menus': self._allowed_menus_list(),
|
||||
}
|
||||
|
||||
@@ -137,5 +137,7 @@ class SkeenWacrmDeal(models.Model):
|
||||
expected_close_date = fields.Date('Cierre esperado')
|
||||
notes = fields.Text('Notas')
|
||||
assigned_to = fields.Char('Asignado a')
|
||||
partner_id = fields.Many2one('res.partner', string='Paciente', index=True,
|
||||
help='Paciente SKEEN ligado al convertir el lead')
|
||||
created_at = fields.Datetime('Creado en WACRM')
|
||||
updated_at = fields.Datetime('Actualizado en WACRM')
|
||||
|
||||
Reference in New Issue
Block a user