Initial commit: SKEEN Derma Experts - Sistema Integral de Gestión Clínica
- Frontend React (SKEEN Brand) con Vite, TypeScript, Tailwind - Frontend Homenest (versión alternativa) - Módulos Odoo 17 custom (citas, pacientes, monedero, pagos, ventas, inventario, whatsapp) - WACRM fork (Next.js 16 + Supabase) - Hermes + Bridge + Skills (Qwen3.6 via Nan Builders) - Scripts de migración y operación - Documentación extensiva en docs/
This commit is contained in:
3
odoo-addons/skeen_citas/__init__.py
Normal file
3
odoo-addons/skeen_citas/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import models
|
||||
24
odoo-addons/skeen_citas/__manifest__.py
Normal file
24
odoo-addons/skeen_citas/__manifest__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': 'SKEEN Citas',
|
||||
'version': '1.0.0',
|
||||
'category': 'Healthcare',
|
||||
'summary': 'Agendamiento de citas para SKEEN Derma Experts',
|
||||
'description': """
|
||||
Sistema de agendamiento de citas para clínica dermatológica.
|
||||
Gestiona disponibilidad, reservas, cancelaciones y recordatorios.
|
||||
""",
|
||||
'author': 'Consultoria Alcaraz Salazar, S.A.S.',
|
||||
'website': 'https://skeen.mx',
|
||||
'depends': ['base', 'skeen_pacientes', 'hr'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'data/sequences.xml',
|
||||
'views/cita_views.xml',
|
||||
'views/menu_views.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': True,
|
||||
'auto_install': False,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
22
odoo-addons/skeen_citas/data/sequences.xml
Normal file
22
odoo-addons/skeen_citas/data/sequences.xml
Normal file
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Secuencia para citas -->
|
||||
<record id="seq_skeen_cita" model="ir.sequence">
|
||||
<field name="name">Cita SKEEN</field>
|
||||
<field name="code">skeen.cita</field>
|
||||
<field name="prefix">CITA-</field>
|
||||
<field name="padding">4</field>
|
||||
<field name="number_next">1</field>
|
||||
<field name="number_increment">1</field>
|
||||
</record>
|
||||
|
||||
<!-- Secuencia para pacientes -->
|
||||
<record id="seq_skeen_patient" model="ir.sequence">
|
||||
<field name="name">Paciente SKEEN</field>
|
||||
<field name="code">skeen.patient</field>
|
||||
<field name="prefix">PAT-</field>
|
||||
<field name="padding">4</field>
|
||||
<field name="number_next">1</field>
|
||||
<field name="number_increment">1</field>
|
||||
</record>
|
||||
</odoo>
|
||||
3
odoo-addons/skeen_citas/models/__init__.py
Normal file
3
odoo-addons/skeen_citas/models/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import cita
|
||||
266
odoo-addons/skeen_citas/models/cita.py
Normal file
266
odoo-addons/skeen_citas/models/cita.py
Normal file
@@ -0,0 +1,266 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import ValidationError
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
class SkeenServicio(models.Model):
|
||||
_name = 'skeen.servicio'
|
||||
_description = 'Servicio Dermatológico'
|
||||
_order = 'sequence, name'
|
||||
|
||||
name = fields.Char(string='Nombre', required=True)
|
||||
code = fields.Char(string='Código', required=True, copy=False)
|
||||
description = fields.Text(string='Descripción')
|
||||
|
||||
# Precio y duración
|
||||
price = fields.Float(string='Precio', required=True, default=0.0)
|
||||
duration_min = fields.Integer(string='Duración (min)', required=True, default=30)
|
||||
|
||||
# Precio por paquete (Opción B)
|
||||
package_price = fields.Float(string='Precio Paquete', default=0.0)
|
||||
package_sessions = fields.Integer(string='Sesiones en Paquete', default=0)
|
||||
package_notes = fields.Char(string='Notas del Paquete')
|
||||
|
||||
# Categoría
|
||||
category = fields.Selection([
|
||||
('consulta', 'Consulta'),
|
||||
('tratamiento', 'Tratamiento'),
|
||||
('procedimiento', 'Procedimiento'),
|
||||
('paquete', 'Paquete'),
|
||||
], string='Categoría', default='tratamiento', required=True)
|
||||
|
||||
# Visual / agrupación
|
||||
color = fields.Char(string='Color', default='#1abc9c')
|
||||
service_group = fields.Char(string='Grupo')
|
||||
is_favorite = fields.Boolean(string='Favorito', default=False)
|
||||
tag_ids = fields.Many2many('skeen.service.tag', string='Etiquetas')
|
||||
|
||||
# Configuración
|
||||
active = fields.Boolean(string='Activo', default=True)
|
||||
sequence = fields.Integer(string='Secuencia', default=10)
|
||||
|
||||
# Relaciones
|
||||
cita_ids = fields.One2many('skeen.cita', 'servicio_id', string='Citas')
|
||||
|
||||
_sql_constraints = [
|
||||
('unique_code', 'unique(code)', 'El código debe ser único!')
|
||||
]
|
||||
|
||||
|
||||
class SkeenServiceTag(models.Model):
|
||||
_name = 'skeen.service.tag'
|
||||
_description = 'Etiqueta de Servicio'
|
||||
|
||||
name = fields.Char(string='Nombre', required=True)
|
||||
color = fields.Integer(string='Color')
|
||||
service_ids = fields.Many2many('skeen.servicio', string='Servicios')
|
||||
|
||||
|
||||
class SkeenCita(models.Model):
|
||||
_name = 'skeen.cita'
|
||||
_description = 'Cita Médica'
|
||||
_order = 'date, time'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
|
||||
# Identificación
|
||||
name = fields.Char(string='Referencia', readonly=True, copy=False)
|
||||
|
||||
# Paciente
|
||||
partner_id = fields.Many2one('res.partner', string='Paciente', required=True,
|
||||
domain=[('is_patient', '=', True)])
|
||||
partner_phone = fields.Char(related='partner_id.phone', string='Teléfono', readonly=True)
|
||||
partner_email = fields.Char(related='partner_id.email', string='Email', readonly=True)
|
||||
|
||||
# Servicio
|
||||
servicio_id = fields.Many2one('skeen.servicio', string='Servicio', required=True)
|
||||
servicio_price = fields.Float(related='servicio_id.price', string='Precio Servicio', readonly=True)
|
||||
servicio_duration = fields.Integer(related='servicio_id.duration_min', string='Duración', readonly=True)
|
||||
|
||||
# Fecha y hora
|
||||
date = fields.Date(string='Fecha', required=True, index=True)
|
||||
time = fields.Float(string='Hora', required=True)
|
||||
time_str = fields.Char(string='Hora Texto', compute='_compute_time_str', store=True)
|
||||
datetime_start = fields.Datetime(string='Inicio', compute='_compute_datetime', store=True)
|
||||
datetime_end = fields.Datetime(string='Fin', compute='_compute_datetime', store=True)
|
||||
|
||||
# Ubicación y médico
|
||||
branch = fields.Selection([
|
||||
('rosarito', 'Rosarito'),
|
||||
('tijuana', 'Tijuana'),
|
||||
], string='Sucursal', default='rosarito')
|
||||
doctor_id = fields.Many2one('hr.employee', string='Médico / Especialista')
|
||||
medium = fields.Selection([
|
||||
('videocall', 'Videollamada'),
|
||||
('onsite', 'Presencial'),
|
||||
], string='Medio', default='onsite')
|
||||
|
||||
# Estado de flujo del sistema original
|
||||
flow_state = fields.Selection([
|
||||
('preconsulta', 'Pre-consulta'),
|
||||
('link_sent', 'Link enviado'),
|
||||
('approval', 'Aprobación'),
|
||||
('assign_record', 'Asignar expediente'),
|
||||
('scheduled', 'Agendar'),
|
||||
], string='Estado de Flujo', default='scheduled')
|
||||
|
||||
# Estado
|
||||
state = fields.Selection([
|
||||
('pending', 'Pendiente'),
|
||||
('confirmed', 'Confirmada'),
|
||||
('arrived', 'Llegó'),
|
||||
('in_progress', 'En Progreso'),
|
||||
('done', 'Completada'),
|
||||
('cancelled', 'Cancelada'),
|
||||
('no_show', 'No Show'),
|
||||
], string='Estado', default='pending', tracking=True)
|
||||
|
||||
# Notas
|
||||
notes = fields.Text(string='Notas')
|
||||
cancellation_reason = fields.Text(string='Motivo Cancelación')
|
||||
|
||||
# Recordatorio
|
||||
reminder_sent = fields.Boolean(string='Recordatorio Enviado', default=False)
|
||||
reminder_date = fields.Datetime(string='Fecha Recordatorio')
|
||||
|
||||
# Pago
|
||||
payment_state = fields.Selection([
|
||||
('not_paid', 'No Pagado'),
|
||||
('partial', 'Parcial'),
|
||||
('paid', 'Pagado'),
|
||||
], string='Estado de Pago', default='not_paid')
|
||||
amount_paid = fields.Float(string='Monto Pagado', default=0.0)
|
||||
|
||||
# WACRM
|
||||
wacrm_conversation_id = fields.Char(string='WACRM Conversation ID')
|
||||
|
||||
# Paquete terminado (para servicios con categoría 'paquete')
|
||||
package_finished = fields.Boolean(string='Paquete Terminado', default=False)
|
||||
package_finished_date = fields.Datetime(string='Fecha Término Paquete')
|
||||
|
||||
@api.depends('date', 'time', 'servicio_id')
|
||||
def _compute_datetime(self):
|
||||
for cita in self:
|
||||
if cita.date and cita.time is not False:
|
||||
hours = int(cita.time)
|
||||
minutes = int((cita.time - hours) * 60)
|
||||
cita.datetime_start = datetime.combine(cita.date, datetime.min.time().replace(hour=hours, minute=minutes))
|
||||
if cita.servicio_id:
|
||||
cita.datetime_end = cita.datetime_start + timedelta(minutes=cita.servicio_id.duration_min)
|
||||
else:
|
||||
cita.datetime_end = cita.datetime_start + timedelta(minutes=30)
|
||||
|
||||
@api.depends('time')
|
||||
def _compute_time_str(self):
|
||||
for cita in self:
|
||||
if cita.time is not False:
|
||||
hours = int(cita.time)
|
||||
minutes = int((cita.time - hours) * 60)
|
||||
cita.time_str = f"{hours:02d}:{minutes:02d}"
|
||||
else:
|
||||
cita.time_str = ""
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if not vals.get('name'):
|
||||
vals['name'] = self.env['ir.sequence'].next_by_code('skeen.cita') or 'CITA-001'
|
||||
return super(SkeenCita, self).create(vals_list)
|
||||
|
||||
def action_confirm(self):
|
||||
self.write({'state': 'confirmed'})
|
||||
|
||||
def action_arrive(self):
|
||||
self.write({'state': 'arrived'})
|
||||
|
||||
def action_start(self):
|
||||
self.write({'state': 'in_progress'})
|
||||
|
||||
def action_done(self):
|
||||
self.write({'state': 'done'})
|
||||
# Actualizar última visita del paciente
|
||||
self.partner_id.write({'last_visit': fields.Date.today()})
|
||||
|
||||
def action_cancel(self):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Cancelar Cita',
|
||||
'res_model': 'skeen.cita.cancel.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {'default_cita_id': self.id}
|
||||
}
|
||||
|
||||
def action_no_show(self):
|
||||
self.write({'state': 'no_show'})
|
||||
|
||||
def action_package_finished(self):
|
||||
"""Marca la cita como paquete terminado (servicios de categoría 'paquete').
|
||||
|
||||
Reversible: se puede desmarcar escribiendo package_finished=False.
|
||||
También deja la cita en 'done' y actualiza la última visita del paciente.
|
||||
"""
|
||||
for cita in self:
|
||||
vals = {
|
||||
'package_finished': True,
|
||||
'package_finished_date': fields.Datetime.now(),
|
||||
}
|
||||
if cita.state not in ('done', 'cancelled', 'no_show'):
|
||||
vals['state'] = 'done'
|
||||
cita.write(vals)
|
||||
if cita.partner_id:
|
||||
cita.partner_id.write({'last_visit': fields.Date.today()})
|
||||
return True
|
||||
|
||||
def action_send_reminder(self):
|
||||
self.write({'reminder_sent': True, 'reminder_date': fields.Datetime.now()})
|
||||
|
||||
@api.constrains('date', 'time', 'servicio_id')
|
||||
def _check_availability(self):
|
||||
for cita in self:
|
||||
if cita.state in ('cancelled', 'no_show'):
|
||||
continue
|
||||
# Verificar solapamiento
|
||||
domain = [
|
||||
('id', '!=', cita.id),
|
||||
('date', '=', cita.date),
|
||||
('state', 'not in', ('cancelled', 'no_show')),
|
||||
('datetime_start', '<', cita.datetime_end),
|
||||
('datetime_end', '>', cita.datetime_start),
|
||||
]
|
||||
overlapping = self.search(domain)
|
||||
if overlapping:
|
||||
raise ValidationError(_('Ya existe una cita en ese horario!'))
|
||||
|
||||
def get_available_slots(self, date, servicio_id):
|
||||
"""Retorna slots disponibles para una fecha y servicio"""
|
||||
servicio = self.env['skeen.servicio'].browse(servicio_id)
|
||||
duration = servicio.duration_min if servicio else 30
|
||||
|
||||
# Horario de la clínica
|
||||
start_time = 9.0 # 9:00 AM
|
||||
end_time = 18.0 # 6:00 PM
|
||||
|
||||
# Generar slots
|
||||
slots = []
|
||||
current = start_time
|
||||
while current + (duration / 60.0) <= end_time:
|
||||
# Verificar si está ocupado
|
||||
slot_start = datetime.combine(date, datetime.min.time().replace(hour=int(current), minute=int((current % 1) * 60)))
|
||||
slot_end = slot_start + timedelta(minutes=duration)
|
||||
|
||||
domain = [
|
||||
('date', '=', date),
|
||||
('state', 'not in', ('cancelled', 'no_show')),
|
||||
('datetime_start', '<', slot_end),
|
||||
('datetime_end', '>', slot_start),
|
||||
]
|
||||
if not self.search(domain):
|
||||
slots.append({
|
||||
'time': current,
|
||||
'time_str': f"{int(current):02d}:{int((current % 1) * 60):02d}",
|
||||
})
|
||||
current += 0.5 # Slots cada 30 min
|
||||
|
||||
return slots
|
||||
4
odoo-addons/skeen_citas/security/ir.model.access.csv
Normal file
4
odoo-addons/skeen_citas/security/ir.model.access.csv
Normal file
@@ -0,0 +1,4 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_skeen_servicio_user,Acceso a Servicios SKEEN,model_skeen_servicio,base.group_user,1,1,1,1
|
||||
access_skeen_service_tag_user,Acceso a Etiquetas de Servicio SKEEN,model_skeen_service_tag,base.group_user,1,1,1,1
|
||||
access_skeen_cita_user,Acceso a Citas SKEEN,model_skeen_cita,base.group_user,1,1,1,1
|
||||
|
142
odoo-addons/skeen_citas/views/cita_views.xml
Normal file
142
odoo-addons/skeen_citas/views/cita_views.xml
Normal file
@@ -0,0 +1,142 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Vista de lista de citas -->
|
||||
<record id="view_skeen_cita_tree" model="ir.ui.view">
|
||||
<field name="name">skeen.cita.tree</field>
|
||||
<field name="model">skeen.cita</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree decoration-success="state=='done'" decoration-danger="state=='cancelled'" decoration-warning="state=='pending'">
|
||||
<field name="name"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="partner_phone"/>
|
||||
<field name="servicio_id"/>
|
||||
<field name="date"/>
|
||||
<field name="time" widget="float_time"/>
|
||||
<field name="state" widget="badge" decoration-success="state=='done'" decoration-danger="state=='cancelled'" decoration-warning="state=='pending'" decoration-info="state=='confirmed'"/>
|
||||
<field name="payment_state" widget="badge"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Vista de formulario de citas -->
|
||||
<record id="view_skeen_cita_form" model="ir.ui.view">
|
||||
<field name="name">skeen.cita.form</field>
|
||||
<field name="model">skeen.cita</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<header>
|
||||
<button name="action_confirm" string="Confirmar" type="object" invisible="state != 'pending'" class="oe_highlight"/>
|
||||
<button name="action_arrive" string="Llegó" type="object" invisible="state != 'confirmed'" class="oe_highlight"/>
|
||||
<button name="action_start" string="Iniciar" type="object" invisible="state != 'arrived'" class="oe_highlight"/>
|
||||
<button name="action_done" string="Completar" type="object" invisible="state != 'in_progress'" class="oe_highlight"/>
|
||||
<button name="action_cancel" string="Cancelar" type="object" invisible="state not in ('pending', 'confirmed', 'arrived', 'in_progress')"/>
|
||||
<button name="action_no_show" string="No Show" type="object" invisible="state not in ('confirmed', 'arrived')"/>
|
||||
<field name="state" widget="statusbar" statusbar_visible="pending,confirmed,arrived,in_progress,done"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="name" readonly="1"/>
|
||||
<field name="partner_id" options="{'no_create': True}"/>
|
||||
<field name="partner_phone" readonly="1"/>
|
||||
<field name="partner_email" readonly="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="servicio_id"/>
|
||||
<field name="servicio_price" readonly="1"/>
|
||||
<field name="servicio_duration" readonly="1"/>
|
||||
<field name="date"/>
|
||||
<field name="time" widget="float_time"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Notas">
|
||||
<field name="notes"/>
|
||||
</page>
|
||||
<page string="Pago">
|
||||
<group>
|
||||
<field name="payment_state"/>
|
||||
<field name="amount_paid"/>
|
||||
</group>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Vista calendario de citas -->
|
||||
<record id="view_skeen_cita_calendar" model="ir.ui.view">
|
||||
<field name="name">skeen.cita.calendar</field>
|
||||
<field name="model">skeen.cita</field>
|
||||
<field name="arch" type="xml">
|
||||
<calendar string="Citas" date_start="datetime_start" date_stop="datetime_end" color="partner_id">
|
||||
<field name="name"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="servicio_id"/>
|
||||
<field name="state"/>
|
||||
</calendar>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Acción para citas -->
|
||||
<record id="action_skeen_cita_tree" model="ir.actions.act_window">
|
||||
<field name="name">Citas</field>
|
||||
<field name="res_model">skeen.cita</field>
|
||||
<field name="view_mode">tree,form,calendar</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
¡Crea tu primera cita!
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Vista de lista de servicios -->
|
||||
<record id="view_skeen_servicio_tree" model="ir.ui.view">
|
||||
<field name="name">skeen.servicio.tree</field>
|
||||
<field name="model">skeen.servicio</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree>
|
||||
<field name="code"/>
|
||||
<field name="name"/>
|
||||
<field name="category"/>
|
||||
<field name="price"/>
|
||||
<field name="duration_min"/>
|
||||
<field name="active"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Vista de formulario de servicios -->
|
||||
<record id="view_skeen_servicio_form" model="ir.ui.view">
|
||||
<field name="name">skeen.servicio.form</field>
|
||||
<field name="model">skeen.servicio</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="name"/>
|
||||
<field name="code"/>
|
||||
<field name="category"/>
|
||||
<field name="active"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="price"/>
|
||||
<field name="duration_min"/>
|
||||
<field name="sequence"/>
|
||||
</group>
|
||||
</group>
|
||||
<field name="description"/>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Acción para servicios -->
|
||||
<record id="action_skeen_servicio_tree" model="ir.actions.act_window">
|
||||
<field name="name">Servicios</field>
|
||||
<field name="res_model">skeen.servicio</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
</record>
|
||||
</odoo>
|
||||
10
odoo-addons/skeen_citas/views/menu_views.xml
Normal file
10
odoo-addons/skeen_citas/views/menu_views.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Menú principal -->
|
||||
<menuitem id="menu_skeen_root" name="SKEEN" sequence="10"/>
|
||||
|
||||
<!-- Menú Citas -->
|
||||
<menuitem id="menu_skeen_citas" name="Citas" parent="menu_skeen_root" sequence="10"/>
|
||||
<menuitem id="menu_skeen_citas_list" name="Todas las Citas" parent="menu_skeen_citas"
|
||||
action="action_skeen_cita_tree" sequence="10"/>
|
||||
</odoo>
|
||||
2
odoo-addons/skeen_inventario/__init__.py
Normal file
2
odoo-addons/skeen_inventario/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import models
|
||||
17
odoo-addons/skeen_inventario/__manifest__.py
Normal file
17
odoo-addons/skeen_inventario/__manifest__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': 'SKEEN Inventario y Consumibles',
|
||||
'version': '1.0.0',
|
||||
'category': 'Healthcare',
|
||||
'summary': 'Inventario de productos y consumibles con niveles y movimientos',
|
||||
'author': 'Consultoria Alcaraz Salazar, S.A.S.',
|
||||
'website': 'https://skeen.mx',
|
||||
'depends': ['base'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
],
|
||||
'installable': True,
|
||||
'application': True,
|
||||
'auto_install': False,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
2
odoo-addons/skeen_inventario/models/__init__.py
Normal file
2
odoo-addons/skeen_inventario/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import inventario
|
||||
98
odoo-addons/skeen_inventario/models/inventario.py
Normal file
98
odoo-addons/skeen_inventario/models/inventario.py
Normal file
@@ -0,0 +1,98 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class SkeenInventarioItem(models.Model):
|
||||
_name = 'skeen.inventario.item'
|
||||
_description = 'Item de Inventario / Consumible SKEEN'
|
||||
_order = 'name asc'
|
||||
|
||||
name = fields.Char(string='Nombre', required=True, index=True)
|
||||
kind = fields.Selection([
|
||||
('producto', 'Producto'),
|
||||
('consumible', 'Consumible'),
|
||||
], string='Tipo', required=True, default='producto', index=True)
|
||||
sku = fields.Char(string='SKU / Código', index=True)
|
||||
category = fields.Char(string='Línea / Categoría')
|
||||
unit = fields.Char(string='Unidad', default='pieza')
|
||||
|
||||
qty = fields.Float(string='Existencia', default=0.0)
|
||||
qty_optimal = fields.Float(string='Nivel óptimo', default=0.0)
|
||||
qty_min = fields.Float(string='Stock mínimo', default=0.0)
|
||||
|
||||
cost = fields.Float(string='Costo unitario', default=0.0)
|
||||
inventory_value = fields.Float(string='Valor inventario', compute='_compute_value', store=True)
|
||||
|
||||
expiry_date = fields.Date(string='Caducidad')
|
||||
last_count_date = fields.Date(string='Último conteo físico')
|
||||
active = fields.Boolean(string='Activo', default=True)
|
||||
notes = fields.Text(string='Notas')
|
||||
|
||||
move_ids = fields.One2many('skeen.inventario.move', 'item_id', string='Movimientos')
|
||||
stock_level = fields.Selection([
|
||||
('out', 'Sin existencias'),
|
||||
('critical', 'Crítico'),
|
||||
('low', 'Bajo'),
|
||||
('optimal', 'Óptimo'),
|
||||
], string='Nivel', compute='_compute_level', store=True)
|
||||
|
||||
@api.depends('qty', 'cost')
|
||||
def _compute_value(self):
|
||||
for rec in self:
|
||||
rec.inventory_value = (rec.qty or 0.0) * (rec.cost or 0.0)
|
||||
|
||||
@api.depends('qty', 'qty_min', 'qty_optimal')
|
||||
def _compute_level(self):
|
||||
for rec in self:
|
||||
q = rec.qty or 0.0
|
||||
if q <= 0:
|
||||
rec.stock_level = 'out'
|
||||
elif rec.qty_min and q <= rec.qty_min:
|
||||
rec.stock_level = 'critical'
|
||||
elif rec.qty_optimal and q < rec.qty_optimal:
|
||||
rec.stock_level = 'low'
|
||||
else:
|
||||
rec.stock_level = 'optimal'
|
||||
|
||||
|
||||
class SkeenInventarioMove(models.Model):
|
||||
_name = 'skeen.inventario.move'
|
||||
_description = 'Movimiento de Inventario SKEEN'
|
||||
_order = 'date desc, id desc'
|
||||
|
||||
item_id = fields.Many2one('skeen.inventario.item', string='Item', required=True, ondelete='cascade', index=True)
|
||||
type = fields.Selection([
|
||||
('compra', 'Compra (entrada)'),
|
||||
('venta', 'Venta (salida)'),
|
||||
('baja', 'Baja (merma)'),
|
||||
('ajuste', 'Ajuste (conteo físico)'),
|
||||
], string='Tipo', required=True)
|
||||
qty = fields.Float(string='Cantidad', required=True, default=0.0)
|
||||
before_qty = fields.Float(string='Existencia anterior', readonly=True)
|
||||
after_qty = fields.Float(string='Existencia resultante', readonly=True)
|
||||
date = fields.Datetime(string='Fecha', default=fields.Datetime.now)
|
||||
reference = fields.Char(string='Referencia')
|
||||
notes = fields.Char(string='Notas')
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
moves = super().create(vals_list)
|
||||
for m in moves:
|
||||
item = m.item_id
|
||||
if not item:
|
||||
continue
|
||||
before = item.qty or 0.0
|
||||
q = m.qty or 0.0
|
||||
if m.type == 'compra':
|
||||
after = before + q
|
||||
elif m.type in ('venta', 'baja'):
|
||||
after = before - q
|
||||
elif m.type == 'ajuste':
|
||||
after = q # ajuste fija la existencia al valor del conteo
|
||||
else:
|
||||
after = before
|
||||
after = max(0.0, after)
|
||||
item.write({'qty': after, 'last_count_date': fields.Date.today() if m.type == 'ajuste' else item.last_count_date})
|
||||
m.write({'before_qty': before, 'after_qty': after})
|
||||
return moves
|
||||
@@ -0,0 +1,3 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_skeen_inventario_item_user,Acceso a Items de Inventario SKEEN,model_skeen_inventario_item,base.group_user,1,1,1,1
|
||||
access_skeen_inventario_move_user,Acceso a Movimientos de Inventario SKEEN,model_skeen_inventario_move,base.group_user,1,1,1,1
|
||||
|
1
odoo-addons/skeen_monedero/__init__.py
Normal file
1
odoo-addons/skeen_monedero/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import models
|
||||
23
odoo-addons/skeen_monedero/__manifest__.py
Normal file
23
odoo-addons/skeen_monedero/__manifest__.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': 'SKEEN Monedero',
|
||||
'version': '1.0.0',
|
||||
'category': 'Healthcare',
|
||||
'summary': 'Monedero digital de fidelidad para SKEEN Derma Experts',
|
||||
'description': """
|
||||
Sistema de puntos de fidelidad para pacientes.
|
||||
1 punto = $1 MXN. Acumulación por compras, redención por descuentos.
|
||||
""",
|
||||
'author': 'Consultoria Alcaraz Salazar, S.A.S.',
|
||||
'website': 'https://skeen.mx',
|
||||
'depends': ['base', 'skeen_pacientes'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/monedero_views.xml',
|
||||
'views/menu_views.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': True,
|
||||
'auto_install': False,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
4
odoo-addons/skeen_monedero/models/__init__.py
Normal file
4
odoo-addons/skeen_monedero/models/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import monedero
|
||||
from . import res_partner
|
||||
131
odoo-addons/skeen_monedero/models/monedero.py
Normal file
131
odoo-addons/skeen_monedero/models/monedero.py
Normal file
@@ -0,0 +1,131 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class SkeenMonedero(models.Model):
|
||||
_name = 'skeen.monedero'
|
||||
_description = 'Monedero Digital de Fidelidad'
|
||||
_order = 'create_date desc'
|
||||
|
||||
# Paciente
|
||||
partner_id = fields.Many2one('res.partner', string='Paciente', required=True,
|
||||
domain=[('is_patient', '=', True)])
|
||||
partner_phone = fields.Char(related='partner_id.phone', string='Teléfono', readonly=True)
|
||||
|
||||
# Puntos
|
||||
points = fields.Integer(string='Puntos', default=0)
|
||||
equivalent_mxn = fields.Float(string='Equivalente MXN', compute='_compute_equivalent')
|
||||
|
||||
# Historial de transacciones
|
||||
transaction_ids = fields.One2many('skeen.monedero.transaction', 'monedero_id', string='Transacciones')
|
||||
|
||||
# Estado
|
||||
active = fields.Boolean(string='Activo', default=True)
|
||||
|
||||
@api.depends('points')
|
||||
def _compute_equivalent(self):
|
||||
for monedero in self:
|
||||
monedero.equivalent_mxn = monedero.points * 1.0 # 1 punto = $1 MXN
|
||||
|
||||
def get_balance(self, phone):
|
||||
"""Obtener saldo por teléfono (para API)"""
|
||||
partner = self.env['res.partner'].search([('phone', '=', phone)], limit=1)
|
||||
if not partner:
|
||||
return {'points': 0, 'equivalent_mxn': 0, 'history': []}
|
||||
|
||||
monedero = self.search([('partner_id', '=', partner.id)], limit=1)
|
||||
if not monedero:
|
||||
return {'points': 0, 'equivalent_mxn': 0, 'history': []}
|
||||
|
||||
history = []
|
||||
for tx in monedero.transaction_ids:
|
||||
history.append({
|
||||
'date': tx.create_date.strftime('%d/%m/%Y'),
|
||||
'type': tx.type,
|
||||
'points': tx.points,
|
||||
'description': tx.description,
|
||||
})
|
||||
|
||||
return {
|
||||
'points': monedero.points,
|
||||
'equivalent_mxn': monedero.equivalent_mxn,
|
||||
'history': history,
|
||||
}
|
||||
|
||||
def add_points(self, points, description='', reference=''):
|
||||
"""Agregar puntos al monedero"""
|
||||
self.ensure_one()
|
||||
self.write({'points': self.points + points})
|
||||
self.env['skeen.monedero.transaction'].create({
|
||||
'monedero_id': self.id,
|
||||
'type': 'accrual',
|
||||
'points': points,
|
||||
'description': description or 'Acumulación de puntos',
|
||||
'reference': reference,
|
||||
})
|
||||
return True
|
||||
|
||||
def redeem_points(self, points, description='', reference=''):
|
||||
"""Redimir puntos del monedero"""
|
||||
self.ensure_one()
|
||||
if points > self.points:
|
||||
raise ValidationError(_('No tiene suficientes puntos!'))
|
||||
|
||||
self.write({'points': self.points - points})
|
||||
self.env['skeen.monedero.transaction'].create({
|
||||
'monedero_id': self.id,
|
||||
'type': 'redemption',
|
||||
'points': -points,
|
||||
'description': description or 'Redención de puntos',
|
||||
'reference': reference,
|
||||
})
|
||||
return True
|
||||
|
||||
def adjust_points(self, delta, description='', reference=''):
|
||||
"""Ajuste de puntos (puede ser negativo). Hace piso en 0 para no dejar saldo negativo.
|
||||
|
||||
Usado por devoluciones para revertir puntos acumulados por una venta.
|
||||
Devuelve el delta efectivamente aplicado (puede ser menor si se pisó en 0).
|
||||
"""
|
||||
self.ensure_one()
|
||||
delta = int(delta or 0)
|
||||
new_points = self.points + delta
|
||||
effective_delta = delta
|
||||
if new_points < 0:
|
||||
effective_delta = -self.points # solo revertir hasta 0
|
||||
new_points = 0
|
||||
self.write({'points': new_points})
|
||||
self.env['skeen.monedero.transaction'].create({
|
||||
'monedero_id': self.id,
|
||||
'type': 'adjustment',
|
||||
'points': effective_delta,
|
||||
'description': description or 'Ajuste de puntos',
|
||||
'reference': reference,
|
||||
})
|
||||
return effective_delta
|
||||
|
||||
|
||||
class SkeenMonederoTransaction(models.Model):
|
||||
_name = 'skeen.monedero.transaction'
|
||||
_description = 'Transacción de Monedero'
|
||||
_order = 'create_date desc'
|
||||
|
||||
monedero_id = fields.Many2one('skeen.monedero', string='Monedero', required=True, ondelete='cascade')
|
||||
partner_id = fields.Many2one(related='monedero_id.partner_id', string='Paciente', readonly=True)
|
||||
|
||||
# Transacción
|
||||
type = fields.Selection([
|
||||
('accrual', 'Acumulación'),
|
||||
('redemption', 'Redención'),
|
||||
('adjustment', 'Ajuste'),
|
||||
('expiration', 'Vencimiento'),
|
||||
], string='Tipo', required=True)
|
||||
|
||||
points = fields.Integer(string='Puntos', required=True)
|
||||
description = fields.Text(string='Descripción')
|
||||
reference = fields.Char(string='Referencia')
|
||||
|
||||
# Fecha
|
||||
create_date = fields.Datetime(string='Fecha', readonly=True, default=fields.Datetime.now)
|
||||
17
odoo-addons/skeen_monedero/models/res_partner.py
Normal file
17
odoo-addons/skeen_monedero/models/res_partner.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class ResPartner(models.Model):
|
||||
_inherit = 'res.partner'
|
||||
|
||||
# Relación inversa con monedero
|
||||
monedero_ids = fields.One2many('skeen.monedero', 'partner_id', string='Monederos')
|
||||
wallet_points = fields.Integer(string='Puntos Monedero', compute='_compute_wallet_points', store=True)
|
||||
|
||||
@api.depends('monedero_ids.points')
|
||||
def _compute_wallet_points(self):
|
||||
for partner in self:
|
||||
monedero = partner.monedero_ids[:1]
|
||||
partner.wallet_points = monedero.points if monedero else 0
|
||||
3
odoo-addons/skeen_monedero/security/ir.model.access.csv
Normal file
3
odoo-addons/skeen_monedero/security/ir.model.access.csv
Normal file
@@ -0,0 +1,3 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_skeen_monedero_user,Acceso a Monedero SKEEN,model_skeen_monedero,base.group_user,1,1,1,1
|
||||
access_skeen_monedero_transaction_user,Acceso a Transacciones Monedero SKEEN,model_skeen_monedero_transaction,base.group_user,1,1,1,1
|
||||
|
8
odoo-addons/skeen_monedero/views/menu_views.xml
Normal file
8
odoo-addons/skeen_monedero/views/menu_views.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Menú Monedero -->
|
||||
<menuitem id="menu_skeen_monedero" name="Monedero" parent="skeen_citas.menu_skeen_root" sequence="20"/>
|
||||
|
||||
<menuitem id="menu_skeen_monedero_list" name="Monederos" parent="menu_skeen_monedero"
|
||||
action="action_skeen_monedero_tree" sequence="10"/>
|
||||
</odoo>
|
||||
56
odoo-addons/skeen_monedero/views/monedero_views.xml
Normal file
56
odoo-addons/skeen_monedero/views/monedero_views.xml
Normal file
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Vista de lista de monederos -->
|
||||
<record id="view_skeen_monedero_tree" model="ir.ui.view">
|
||||
<field name="name">skeen.monedero.tree</field>
|
||||
<field name="model">skeen.monedero</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree>
|
||||
<field name="partner_id"/>
|
||||
<field name="partner_phone"/>
|
||||
<field name="points"/>
|
||||
<field name="equivalent_mxn"/>
|
||||
<field name="active"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Vista de formulario de monedero -->
|
||||
<record id="view_skeen_monedero_form" model="ir.ui.view">
|
||||
<field name="name">skeen.monedero.form</field>
|
||||
<field name="model">skeen.monedero</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="partner_id"/>
|
||||
<field name="partner_phone" readonly="1"/>
|
||||
<field name="points"/>
|
||||
<field name="equivalent_mxn" readonly="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="active"/>
|
||||
</group>
|
||||
</group>
|
||||
<field name="transaction_ids">
|
||||
<tree>
|
||||
<field name="create_date"/>
|
||||
<field name="type"/>
|
||||
<field name="points"/>
|
||||
<field name="description"/>
|
||||
<field name="reference"/>
|
||||
</tree>
|
||||
</field>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Acción para monederos -->
|
||||
<record id="action_skeen_monedero_tree" model="ir.actions.act_window">
|
||||
<field name="name">Monederos</field>
|
||||
<field name="res_model">skeen.monedero</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
</record>
|
||||
</odoo>
|
||||
3
odoo-addons/skeen_pacientes/__init__.py
Normal file
3
odoo-addons/skeen_pacientes/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import models
|
||||
22
odoo-addons/skeen_pacientes/__manifest__.py
Normal file
22
odoo-addons/skeen_pacientes/__manifest__.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': 'SKEEN Pacientes',
|
||||
'version': '1.0.0',
|
||||
'category': 'Healthcare',
|
||||
'summary': 'Gestión de pacientes para SKEEN Derma Experts',
|
||||
'description': """
|
||||
Módulo de gestión de pacientes para clínica dermatológica.
|
||||
Extiende res.partner con campos específicos para pacientes.
|
||||
""",
|
||||
'author': 'Consultoria Alcaraz Salazar, S.A.S.',
|
||||
'website': 'https://skeen.mx',
|
||||
'depends': ['base', 'contacts', 'hr'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/paciente_views.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': True,
|
||||
'auto_install': False,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
4
odoo-addons/skeen_pacientes/models/__init__.py
Normal file
4
odoo-addons/skeen_pacientes/models/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import res_partner
|
||||
from . import hr_employee
|
||||
13
odoo-addons/skeen_pacientes/models/hr_employee.py
Normal file
13
odoo-addons/skeen_pacientes/models/hr_employee.py
Normal file
@@ -0,0 +1,13 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class HrEmployee(models.Model):
|
||||
_inherit = 'hr.employee'
|
||||
|
||||
commission_pct = fields.Float(
|
||||
string='% Comisión',
|
||||
default=0.0,
|
||||
help='Porcentaje de comisión sobre la venta de artículos recetados por este médico.',
|
||||
)
|
||||
145
odoo-addons/skeen_pacientes/models/res_partner.py
Normal file
145
odoo-addons/skeen_pacientes/models/res_partner.py
Normal file
@@ -0,0 +1,145 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class ResPartner(models.Model):
|
||||
_inherit = 'res.partner'
|
||||
|
||||
# Campos de paciente
|
||||
is_patient = fields.Boolean(string='Es Paciente', default=False)
|
||||
patient_id = fields.Char(string='ID Paciente', readonly=True, copy=False)
|
||||
legacy_id = fields.Char(string='ID Legacy', help='ID del sistema legacy SKEEN', index=True, copy=False)
|
||||
legacy_ref = fields.Char(string='Referencia Legacy', help='Referencia externa del sistema legacy', index=True, copy=False)
|
||||
|
||||
# Información médica básica
|
||||
birth_date = fields.Date(string='Fecha de Nacimiento')
|
||||
age = fields.Integer(string='Edad', compute='_compute_age', store=True)
|
||||
gender = fields.Selection([
|
||||
('male', 'Masculino'),
|
||||
('female', 'Femenino'),
|
||||
('other', 'Otro'),
|
||||
], string='Género')
|
||||
blood_type = fields.Selection([
|
||||
('a+', 'A+'), ('a-', 'A-'),
|
||||
('b+', 'B+'), ('b-', 'B-'),
|
||||
('ab+', 'AB+'), ('ab-', 'AB-'),
|
||||
('o+', 'O+'), ('o-', 'O-'),
|
||||
], string='Tipo de Sangre')
|
||||
|
||||
allergies = fields.Text(string='Alergias')
|
||||
medical_history = fields.Text(string='Historial Médico')
|
||||
current_medication = fields.Text(string='Medicación Actual')
|
||||
|
||||
# Información de contacto preferida
|
||||
preferred_contact = fields.Selection([
|
||||
('whatsapp', 'WhatsApp'),
|
||||
('phone', 'Teléfono'),
|
||||
('email', 'Email'),
|
||||
], string='Contacto Preferido', default='whatsapp')
|
||||
|
||||
# Datos personales extendidos (basado en expediente SKEEN actual)
|
||||
birthplace = fields.Char(string='Lugar de Nacimiento')
|
||||
occupation = fields.Char(string='Empleo / Ocupación')
|
||||
marital_status = fields.Selection([
|
||||
('single', 'Soltero/a'),
|
||||
('married', 'Casado/a'),
|
||||
('divorced', 'Divorciado/a'),
|
||||
('widowed', 'Viudo/a'),
|
||||
('union', 'Unión libre'),
|
||||
('other', 'Otro'),
|
||||
], string='Estado Civil')
|
||||
emergency_contact = fields.Char(string='Contacto de Emergencia')
|
||||
emergency_phone = fields.Char(string='Teléfono de Emergencia')
|
||||
home_phone = fields.Char(string='Teléfono Casa')
|
||||
mobile = fields.Char(string='Celular')
|
||||
address_notes = fields.Text(string='Dirección Completa')
|
||||
referred_by = fields.Char(string='Recomendado Por')
|
||||
patient_comments = fields.Text(string='Comentarios del Paciente')
|
||||
internal_notes = fields.Text(string='Notas Internas', help='Notas internas del equipo (no visibles para el paciente)')
|
||||
|
||||
# Historia clínica — cuestionario SKEEN
|
||||
is_pregnant = fields.Boolean(string='¿Está embarazada?')
|
||||
is_breastfeeding = fields.Boolean(string='¿Está lactando?')
|
||||
uses_contraceptives = fields.Boolean(string='¿Usa anticonceptivos?')
|
||||
children_count = fields.Integer(string='Número de Hijos', default=0)
|
||||
|
||||
# Historia clínica — antecedentes patológicos
|
||||
kidney_problems = fields.Boolean(string='Problemas de riñón')
|
||||
back_pain = fields.Boolean(string='Dolor de espalda')
|
||||
heart_disease = fields.Boolean(string='Enfermedades cardíacas')
|
||||
respiratory_problems = fields.Boolean(string='Problemas respiratorios')
|
||||
blood_pressure = fields.Boolean(string='Presión arterial')
|
||||
diabetes = fields.Boolean(string='Diabetes')
|
||||
thyroid = fields.Boolean(string='Problemas de tiroides')
|
||||
colitis = fields.Boolean(string='Colitis')
|
||||
constipation = fields.Boolean(string='Estreñimiento')
|
||||
liver_problems = fields.Boolean(string='Problemas de hígado')
|
||||
surgeries = fields.Boolean(string='Cirugías')
|
||||
surgeries_notes = fields.Text(string='Detalle de cirugías')
|
||||
varicose_veins = fields.Boolean(string='Varices')
|
||||
migraine = fields.Boolean(string='Migraña')
|
||||
faints_with_needles = fields.Boolean(string='¿Se desmaya con agujas?')
|
||||
medical_notes = fields.Text(string='Notas Médicas Adicionales')
|
||||
|
||||
# Campos para sincronización WACRM
|
||||
wacrm_contact_id = fields.Char(string='WACRM Contact ID')
|
||||
wacrm_synced = fields.Boolean(string='Sincronizado con WACRM', default=False)
|
||||
|
||||
# Médico principal y etiquetas
|
||||
primary_doctor_id = fields.Many2one('hr.employee', string='Médico Principal')
|
||||
tag_ids = fields.Many2many('skeen.patient.tag', string='Etiquetas')
|
||||
is_vip = fields.Boolean(string='VIP', default=False)
|
||||
|
||||
# Estadísticas
|
||||
last_visit = fields.Date(string='Última Visita')
|
||||
total_visits = fields.Integer(string='Total Visitas', default=0)
|
||||
total_spent = fields.Float(string='Total Gastado', default=0.0)
|
||||
|
||||
# Fuente de captación
|
||||
source = fields.Selection([
|
||||
('whatsapp', 'WhatsApp'),
|
||||
('facebook', 'Facebook'),
|
||||
('instagram', 'Instagram'),
|
||||
('google', 'Google'),
|
||||
('referral', 'Referido'),
|
||||
('walkin', 'Presencial'),
|
||||
('other', 'Otro'),
|
||||
], string='Fuente', default='whatsapp')
|
||||
|
||||
@api.depends('birth_date')
|
||||
def _compute_age(self):
|
||||
for rec in self:
|
||||
if rec.birth_date:
|
||||
today = fields.Date.today()
|
||||
rec.age = today.year - rec.birth_date.year - (
|
||||
(today.month, today.day) < (rec.birth_date.month, rec.birth_date.day)
|
||||
)
|
||||
else:
|
||||
rec.age = 0
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if vals.get('is_patient') and not vals.get('patient_id'):
|
||||
vals['patient_id'] = self.env['ir.sequence'].next_by_code('skeen.patient') or 'PAT-001'
|
||||
return super(ResPartner, self).create(vals_list)
|
||||
|
||||
def action_view_appointments(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Citas',
|
||||
'res_model': 'skeen.cita',
|
||||
'domain': [('partner_id', '=', self.id)],
|
||||
'view_mode': 'tree,form',
|
||||
}
|
||||
|
||||
|
||||
class SkeenPatientTag(models.Model):
|
||||
_name = 'skeen.patient.tag'
|
||||
_description = 'Etiqueta de Paciente'
|
||||
|
||||
name = fields.Char(string='Nombre', required=True)
|
||||
color = fields.Integer(string='Color')
|
||||
patient_ids = fields.Many2many('res.partner', string='Pacientes')
|
||||
2
odoo-addons/skeen_pacientes/security/ir.model.access.csv
Normal file
2
odoo-addons/skeen_pacientes/security/ir.model.access.csv
Normal file
@@ -0,0 +1,2 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_skeen_patient_tag_user,Acceso a Etiquetas Paciente SKEEN,model_skeen_patient_tag,base.group_user,1,1,1,1
|
||||
|
92
odoo-addons/skeen_pacientes/views/paciente_views.xml
Normal file
92
odoo-addons/skeen_pacientes/views/paciente_views.xml
Normal file
@@ -0,0 +1,92 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Vista de formulario de paciente (res.partner) -->
|
||||
<record id="view_partner_form_skeen" model="ir.ui.view">
|
||||
<field name="name">res.partner.form.skeen</field>
|
||||
<field name="model">res.partner</field>
|
||||
<field name="inherit_id" ref="base.view_partner_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='vat']" position="after">
|
||||
<field name="is_patient" />
|
||||
<field name="patient_id" invisible="not is_patient" />
|
||||
<field name="age" invisible="not is_patient" />
|
||||
<field name="birth_date" invisible="not is_patient" />
|
||||
<field name="birthplace" invisible="not is_patient" />
|
||||
<field name="gender" invisible="not is_patient" />
|
||||
<field name="blood_type" invisible="not is_patient" />
|
||||
<field name="marital_status" invisible="not is_patient" />
|
||||
<field name="occupation" invisible="not is_patient" />
|
||||
<field name="home_phone" invisible="not is_patient" />
|
||||
<field name="mobile" invisible="not is_patient" />
|
||||
<field name="emergency_contact" invisible="not is_patient" />
|
||||
<field name="emergency_phone" invisible="not is_patient" />
|
||||
<field name="address_notes" invisible="not is_patient" />
|
||||
<field name="referred_by" invisible="not is_patient" />
|
||||
<field name="preferred_contact" invisible="not is_patient" />
|
||||
<field name="source" invisible="not is_patient" />
|
||||
<field name="primary_doctor_id" invisible="not is_patient" />
|
||||
<field name="tag_ids" invisible="not is_patient" widget="many2many_tags" />
|
||||
<field name="is_vip" invisible="not is_patient" />
|
||||
<field name="patient_comments" invisible="not is_patient" />
|
||||
</xpath>
|
||||
<xpath expr="//page[@name='internal_notes']" position="after">
|
||||
<page string="Historia Clínica" invisible="not is_patient">
|
||||
<group>
|
||||
<group string="Antecedentes Gineco-Obstétricos">
|
||||
<field name="is_pregnant" />
|
||||
<field name="is_breastfeeding" />
|
||||
<field name="uses_contraceptives" />
|
||||
<field name="children_count" />
|
||||
</group>
|
||||
<group string="Antecedentes Patológicos">
|
||||
<field name="kidney_problems" />
|
||||
<field name="back_pain" />
|
||||
<field name="heart_disease" />
|
||||
<field name="respiratory_problems" />
|
||||
<field name="blood_pressure" />
|
||||
<field name="diabetes" />
|
||||
<field name="thyroid" />
|
||||
<field name="colitis" />
|
||||
<field name="constipation" />
|
||||
<field name="liver_problems" />
|
||||
<field name="surgeries" />
|
||||
<field name="varicose_veins" />
|
||||
<field name="migraine" />
|
||||
<field name="faints_with_needles" />
|
||||
</group>
|
||||
<group string="Medicación y Alergias" colspan="2">
|
||||
<field name="allergies" />
|
||||
<field name="medical_history" />
|
||||
<field name="current_medication" />
|
||||
<field name="surgeries_notes" />
|
||||
<field name="medical_notes" />
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
<page string="Estadísticas SKEEN" invisible="not is_patient">
|
||||
<group>
|
||||
<field name="last_visit" />
|
||||
<field name="total_visits" />
|
||||
<field name="total_spent" />
|
||||
</group>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Vista de lista de pacientes -->
|
||||
<record id="view_partner_tree_skeen" model="ir.ui.view">
|
||||
<field name="name">res.partner.tree.skeen</field>
|
||||
<field name="model">res.partner</field>
|
||||
<field name="inherit_id" ref="base.view_partner_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='email']" position="after">
|
||||
<field name="is_patient"/>
|
||||
<field name="patient_id"/>
|
||||
<field name="age"/>
|
||||
<field name="last_visit"/>
|
||||
<field name="total_visits"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
1
odoo-addons/skeen_pagos/__init__.py
Normal file
1
odoo-addons/skeen_pagos/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import models
|
||||
22
odoo-addons/skeen_pagos/__manifest__.py
Normal file
22
odoo-addons/skeen_pagos/__manifest__.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': 'SKEEN Pagos',
|
||||
'version': '1.0.0',
|
||||
'category': 'Healthcare',
|
||||
'summary': 'Integración de pagos Stripe y MercadoPago para SKEEN',
|
||||
'description': """
|
||||
Gestión de pagos en línea para SKEEN Derma Experts.
|
||||
Integración con Stripe (internacional) y MercadoPago (México).
|
||||
""",
|
||||
'author': 'Consultoria Alcaraz Salazar, S.A.S.',
|
||||
'website': 'https://skeen.mx',
|
||||
'depends': ['base', 'skeen_citas'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/pago_views.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': True,
|
||||
'auto_install': False,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
3
odoo-addons/skeen_pagos/models/__init__.py
Normal file
3
odoo-addons/skeen_pagos/models/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import pago
|
||||
197
odoo-addons/skeen_pagos/models/pago.py
Normal file
197
odoo-addons/skeen_pagos/models/pago.py
Normal file
@@ -0,0 +1,197 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import ValidationError
|
||||
import requests
|
||||
import json
|
||||
|
||||
|
||||
class SkeenPago(models.Model):
|
||||
_name = 'skeen.pago'
|
||||
_description = 'Pago de Servicio'
|
||||
_order = 'create_date desc'
|
||||
|
||||
# Referencia
|
||||
name = fields.Char(string='Referencia', readonly=True, copy=False)
|
||||
|
||||
# Paciente y Cita
|
||||
partner_id = fields.Many2one('res.partner', string='Paciente', required=True)
|
||||
cita_id = fields.Many2one('skeen.cita', string='Cita')
|
||||
|
||||
# Monto
|
||||
amount = fields.Float(string='Monto', required=True)
|
||||
currency_id = fields.Many2one('res.currency', string='Moneda', default=lambda self: self.env.ref('base.MXN'))
|
||||
|
||||
# Método de pago
|
||||
payment_method = fields.Selection([
|
||||
('stripe', 'Stripe'),
|
||||
('mercadopago', 'MercadoPago'),
|
||||
('cash', 'Efectivo'),
|
||||
('card', 'Tarjeta'),
|
||||
('transfer', 'Transferencia'),
|
||||
], string='Método de Pago', required=True)
|
||||
|
||||
# Estado
|
||||
state = fields.Selection([
|
||||
('pending', 'Pendiente'),
|
||||
('processing', 'Procesando'),
|
||||
('completed', 'Completado'),
|
||||
('failed', 'Fallido'),
|
||||
('refunded', 'Reembolsado'),
|
||||
], string='Estado', default='pending')
|
||||
|
||||
# Datos del proveedor de pago
|
||||
provider_reference = fields.Char(string='Referencia Proveedor')
|
||||
provider_response = fields.Text(string='Respuesta Proveedor')
|
||||
|
||||
# URLs
|
||||
payment_url = fields.Char(string='URL de Pago')
|
||||
success_url = fields.Char(string='URL de Éxito')
|
||||
cancel_url = fields.Char(string='URL de Cancelación')
|
||||
|
||||
# Fechas
|
||||
create_date = fields.Datetime(string='Fecha Creación', readonly=True, default=fields.Datetime.now)
|
||||
payment_date = fields.Datetime(string='Fecha de Pago')
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if not vals.get('name'):
|
||||
vals['name'] = self.env['ir.sequence'].next_by_code('skeen.pago') or 'PAGO-001'
|
||||
return super(SkeenPago, self).create(vals_list)
|
||||
|
||||
def action_generate_stripe_link(self):
|
||||
"""Generar link de pago Stripe"""
|
||||
self.ensure_one()
|
||||
stripe_key = self.env['ir.config_parameter'].sudo().get_param('skeen.stripe_secret_key')
|
||||
if not stripe_key:
|
||||
raise ValidationError(_('Stripe no está configurado!'))
|
||||
|
||||
# Crear sesión de checkout
|
||||
headers = {
|
||||
'Authorization': f'Bearer {stripe_key}',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
}
|
||||
data = {
|
||||
'payment_method_types[]': 'card',
|
||||
'line_items[0][price_data][currency]': 'mxn',
|
||||
'line_items[0][price_data][product_data][name]': f'Servicio SKEEN - {self.cita_id.servicio_id.name}',
|
||||
'line_items[0][price_data][unit_amount]': int(self.amount * 100),
|
||||
'line_items[0][quantity]': 1,
|
||||
'mode': 'payment',
|
||||
'success_url': f'https://app.skeen.mx/pagos/exitoso?ref={self.name}',
|
||||
'cancel_url': f'https://app.skeen.mx/pagos/cancelado?ref={self.name}',
|
||||
'metadata[payment_id]': str(self.id),
|
||||
'metadata[patient_phone]': self.partner_id.phone or '',
|
||||
}
|
||||
|
||||
response = requests.post('https://api.stripe.com/v1/checkout/sessions', headers=headers, data=data)
|
||||
if response.status_code == 200:
|
||||
session = response.json()
|
||||
self.write({
|
||||
'payment_url': session['url'],
|
||||
'provider_reference': session['id'],
|
||||
'provider_response': json.dumps(session),
|
||||
'state': 'processing',
|
||||
})
|
||||
else:
|
||||
self.write({
|
||||
'provider_response': response.text,
|
||||
'state': 'failed',
|
||||
})
|
||||
|
||||
return True
|
||||
|
||||
def action_generate_mercadopago_link(self):
|
||||
"""Generar link de pago MercadoPago"""
|
||||
self.ensure_one()
|
||||
mp_token = self.env['ir.config_parameter'].sudo().get_param('skeen.mercadopago_access_token')
|
||||
if not mp_token:
|
||||
raise ValidationError(_('MercadoPago no está configurado!'))
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bearer {mp_token}',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
data = {
|
||||
'items': [{
|
||||
'title': f'Servicio SKEEN - {self.cita_id.servicio_id.name}',
|
||||
'quantity': 1,
|
||||
'unit_price': int(self.amount),
|
||||
}],
|
||||
'back_urls': {
|
||||
'success': f'https://app.skeen.mx/pagos/exitoso?ref={self.name}',
|
||||
'failure': f'https://app.skeen.mx/pagos/fallido?ref={self.name}',
|
||||
'pending': f'https://app.skeen.mx/pagos/pendiente?ref={self.name}',
|
||||
},
|
||||
'notification_url': 'https://skeen.mx/webhooks/mercadopago',
|
||||
'metadata': {
|
||||
'payment_id': str(self.id),
|
||||
'patient_phone': self.partner_id.phone or '',
|
||||
},
|
||||
}
|
||||
|
||||
response = requests.post('https://api.mercadopago.com/checkout/preferences', headers=headers, json=data)
|
||||
if response.status_code == 201:
|
||||
preference = response.json()
|
||||
self.write({
|
||||
'payment_url': preference['init_point'],
|
||||
'provider_reference': preference['id'],
|
||||
'provider_response': json.dumps(preference),
|
||||
'state': 'processing',
|
||||
})
|
||||
else:
|
||||
self.write({
|
||||
'provider_response': response.text,
|
||||
'state': 'failed',
|
||||
})
|
||||
|
||||
return True
|
||||
|
||||
def action_confirm_payment(self):
|
||||
"""Confirmar pago recibido"""
|
||||
self.ensure_one()
|
||||
self.write({
|
||||
'state': 'completed',
|
||||
'payment_date': fields.Datetime.now(),
|
||||
})
|
||||
|
||||
# Actualizar estado de pago de la cita
|
||||
if self.cita_id:
|
||||
self.cita_id.write({
|
||||
'payment_state': 'paid',
|
||||
'amount_paid': self.amount,
|
||||
})
|
||||
|
||||
# Acumular puntos en monedero
|
||||
monedero = self.env['skeen.monedero'].search([('partner_id', '=', self.partner_id.id)], limit=1)
|
||||
if not monedero:
|
||||
monedero = self.env['skeen.monedero'].create({
|
||||
'partner_id': self.partner_id.id,
|
||||
})
|
||||
|
||||
points = int(self.amount / 10) # 1 punto por cada $10
|
||||
monedero.add_points(
|
||||
points=points,
|
||||
description=f'Pago de servicio - {self.cita_id.servicio_id.name if self.cita_id else "N/A"}',
|
||||
reference=self.name,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
def action_refund(self):
|
||||
"""Procesar reembolso"""
|
||||
self.ensure_one()
|
||||
self.write({'state': 'refunded'})
|
||||
|
||||
# Restar puntos del monedero
|
||||
monedero = self.env['skeen.monedero'].search([('partner_id', '=', self.partner_id.id)], limit=1)
|
||||
if monedero:
|
||||
points = int(self.amount / 10)
|
||||
monedero.redeem_points(
|
||||
points=points,
|
||||
description=f'Reembolso - {self.name}',
|
||||
reference=self.name,
|
||||
)
|
||||
|
||||
return True
|
||||
2
odoo-addons/skeen_pagos/security/ir.model.access.csv
Normal file
2
odoo-addons/skeen_pagos/security/ir.model.access.csv
Normal file
@@ -0,0 +1,2 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_skeen_pago_user,Acceso a Pagos SKEEN,model_skeen_pago,base.group_user,1,1,1,1
|
||||
|
58
odoo-addons/skeen_pagos/views/pago_views.xml
Normal file
58
odoo-addons/skeen_pagos/views/pago_views.xml
Normal file
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Vista de lista de pagos -->
|
||||
<record id="view_skeen_pago_tree" model="ir.ui.view">
|
||||
<field name="name">skeen.pago.tree</field>
|
||||
<field name="model">skeen.pago</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree>
|
||||
<field name="name"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="amount"/>
|
||||
<field name="payment_method"/>
|
||||
<field name="state"/>
|
||||
<field name="create_date"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Vista de formulario de pagos -->
|
||||
<record id="view_skeen_pago_form" model="ir.ui.view">
|
||||
<field name="name">skeen.pago.form</field>
|
||||
<field name="model">skeen.pago</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<header>
|
||||
<button name="action_confirm_payment" string="Confirmar Pago" type="object" invisible="state != 'processing'" class="oe_highlight"/>
|
||||
<button name="action_refund" string="Reembolsar" type="object" invisible="state != 'completed'"/>
|
||||
<field name="state" widget="statusbar" statusbar_visible="pending,processing,completed,failed,refunded"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="name" readonly="1"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="cita_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="amount"/>
|
||||
<field name="currency_id"/>
|
||||
<field name="payment_method"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<field name="provider_reference" readonly="1"/>
|
||||
<field name="payment_url" readonly="1"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Acción para pagos -->
|
||||
<record id="action_skeen_pago_tree" model="ir.actions.act_window">
|
||||
<field name="name">Pagos</field>
|
||||
<field name="res_model">skeen.pago</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
</record>
|
||||
</odoo>
|
||||
2
odoo-addons/skeen_ventas/__init__.py
Normal file
2
odoo-addons/skeen_ventas/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import models
|
||||
25
odoo-addons/skeen_ventas/__manifest__.py
Normal file
25
odoo-addons/skeen_ventas/__manifest__.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': 'SKEEN Ventas y Cortes de Caja',
|
||||
'version': '1.0.0',
|
||||
'category': 'Healthcare',
|
||||
'summary': 'Ventas, adeudos y cortes de caja para SKEEN Derma Experts',
|
||||
'description': """
|
||||
Módulo de ventas y cortes de caja para clínica dermatológica.
|
||||
Gestiona órdenes de venta, adeudos y cierres de caja diarios.
|
||||
""",
|
||||
'author': 'Consultoria Alcaraz Salazar, S.A.S.',
|
||||
'website': 'https://skeen.mx',
|
||||
'depends': ['base', 'skeen_citas', 'skeen_pagos', 'skeen_monedero'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'data/sequences.xml',
|
||||
'views/venta_views.xml',
|
||||
'views/corte_views.xml',
|
||||
'views/menu_views.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': True,
|
||||
'auto_install': False,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
22
odoo-addons/skeen_ventas/data/sequences.xml
Normal file
22
odoo-addons/skeen_ventas/data/sequences.xml
Normal file
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<record id="seq_skeen_venta" model="ir.sequence">
|
||||
<field name="name">SKEEN Venta</field>
|
||||
<field name="code">skeen.venta</field>
|
||||
<field name="prefix">VENTA-</field>
|
||||
<field name="padding">4</field>
|
||||
<field name="number_next">1</field>
|
||||
<field name="number_increment">1</field>
|
||||
</record>
|
||||
|
||||
<record id="seq_skeen_corte_caja" model="ir.sequence">
|
||||
<field name="name">SKEEN Corte de Caja</field>
|
||||
<field name="code">skeen.corte.caja</field>
|
||||
<field name="prefix">CORTE-</field>
|
||||
<field name="padding">4</field>
|
||||
<field name="number_next">1</field>
|
||||
<field name="number_increment">1</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
3
odoo-addons/skeen_ventas/models/__init__.py
Normal file
3
odoo-addons/skeen_ventas/models/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import venta
|
||||
from . import tipo_cambio
|
||||
25
odoo-addons/skeen_ventas/models/tipo_cambio.py
Normal file
25
odoo-addons/skeen_ventas/models/tipo_cambio.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class SkeenTipoCambio(models.Model):
|
||||
_name = 'skeen.tipo.cambio'
|
||||
_description = 'Tipo de Cambio USD/MXN'
|
||||
_order = 'date desc, id desc'
|
||||
_rec_name = 'rate'
|
||||
|
||||
date = fields.Date(string='Fecha', required=True, default=fields.Date.today, index=True)
|
||||
rate = fields.Float(string='Tipo de Cambio (MXN por 1 USD)', required=True, digits=(12, 4))
|
||||
source = fields.Char(string='Fuente', default='manual', help='manual, banxico, dof, etc.')
|
||||
notes = fields.Char(string='Notas')
|
||||
|
||||
_sql_constraints = [
|
||||
('date_unique', 'unique(date)', 'Ya existe un tipo de cambio registrado para esa fecha.'),
|
||||
('rate_positive', 'CHECK(rate > 0)', 'El tipo de cambio debe ser mayor a cero.'),
|
||||
]
|
||||
|
||||
@api.model
|
||||
def get_current_rate(self):
|
||||
rec = self.search([], order='date desc, id desc', limit=1)
|
||||
return rec.rate if rec else 0.0
|
||||
290
odoo-addons/skeen_ventas/models/venta.py
Normal file
290
odoo-addons/skeen_ventas/models/venta.py
Normal file
@@ -0,0 +1,290 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class SkeenVenta(models.Model):
|
||||
_name = 'skeen.venta'
|
||||
_description = 'Venta SKEEN'
|
||||
_order = 'create_date desc'
|
||||
_inherit = ['mail.thread']
|
||||
|
||||
# Referencia
|
||||
name = fields.Char(string='Referencia', readonly=True, copy=False)
|
||||
|
||||
# Paciente
|
||||
partner_id = fields.Many2one('res.partner', string='Paciente', required=True,
|
||||
domain=[('is_patient', '=', True)])
|
||||
partner_phone = fields.Char(related='partner_id.phone', string='Teléfono', readonly=True)
|
||||
|
||||
# Origen
|
||||
cita_id = fields.Many2one('skeen.cita', string='Cita Origen')
|
||||
|
||||
# Líneas de venta
|
||||
line_ids = fields.One2many('skeen.venta.line', 'venta_id', string='Líneas')
|
||||
|
||||
# Totales
|
||||
subtotal = fields.Float(string='Subtotal', compute='_compute_totals', store=True)
|
||||
discount = fields.Float(string='Descuento', default=0.0)
|
||||
tax = fields.Float(string='Impuesto', default=0.0)
|
||||
total = fields.Float(string='Total', compute='_compute_totals', store=True)
|
||||
amount_paid = fields.Float(string='Pagado', default=0.0)
|
||||
amount_due = fields.Float(string='Adeudo', compute='_compute_totals', store=True)
|
||||
|
||||
# Estado
|
||||
state = fields.Selection([
|
||||
('draft', 'Borrador'),
|
||||
('confirmed', 'Confirmada'),
|
||||
('paid', 'Pagada'),
|
||||
('partial', 'Parcial'),
|
||||
('cancelled', 'Cancelada'),
|
||||
], string='Estado', default='draft', tracking=True)
|
||||
|
||||
# Monedero
|
||||
points_redeemed = fields.Integer(string='Puntos Redimidos', default=0)
|
||||
points_discount = fields.Float(string='Descuento por Puntos', default=0.0)
|
||||
|
||||
# Devolución
|
||||
refunded = fields.Boolean(string='Devuelta', default=False)
|
||||
refund_amount = fields.Float(string='Monto Devuelto', default=0.0)
|
||||
refund_reason = fields.Text(string='Motivo Devolución')
|
||||
refunded_at = fields.Datetime(string='Fecha Devolución')
|
||||
|
||||
# Fechas
|
||||
date = fields.Date(string='Fecha', required=True, default=fields.Date.today)
|
||||
create_date = fields.Datetime(string='Fecha Creación', readonly=True, default=fields.Datetime.now)
|
||||
|
||||
# Notas
|
||||
notes = fields.Text(string='Notas')
|
||||
|
||||
@api.depends('line_ids.subtotal', 'discount', 'tax', 'amount_paid', 'points_discount')
|
||||
def _compute_totals(self):
|
||||
for venta in self:
|
||||
subtotal = sum(line.subtotal for line in venta.line_ids)
|
||||
venta.subtotal = subtotal
|
||||
venta.total = max(0.0, subtotal - venta.discount - venta.points_discount + venta.tax)
|
||||
venta.amount_due = max(0.0, venta.total - venta.amount_paid)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if not vals.get('name'):
|
||||
vals['name'] = self.env['ir.sequence'].next_by_code('skeen.venta') or 'VENTA-001'
|
||||
return super(SkeenVenta, self).create(vals_list)
|
||||
|
||||
def action_confirm(self):
|
||||
self.write({'state': 'confirmed'})
|
||||
|
||||
def action_mark_paid(self):
|
||||
for venta in self:
|
||||
venta.write({
|
||||
'state': 'paid',
|
||||
'amount_paid': venta.total,
|
||||
})
|
||||
if venta.cita_id:
|
||||
venta.cita_id.write({
|
||||
'payment_state': 'paid',
|
||||
'amount_paid': venta.total,
|
||||
})
|
||||
# Acumular puntos
|
||||
monedero = self.env['skeen.monedero'].search([('partner_id', '=', venta.partner_id.id)], limit=1)
|
||||
if not monedero:
|
||||
monedero = self.env['skeen.monedero'].create({'partner_id': venta.partner_id.id})
|
||||
points = int(venta.total / 10)
|
||||
if points > 0:
|
||||
monedero.add_points(points, description=f'Venta {venta.name}', reference=venta.name)
|
||||
|
||||
def action_cancel(self):
|
||||
self.write({'state': 'cancelled'})
|
||||
|
||||
def action_refund(self, amount=None, reason=''):
|
||||
"""Devolución total o parcial de una venta. Reversible: no borra nada.
|
||||
|
||||
- Reduce amount_paid en `amount` (o en todo lo pagado si amount es None).
|
||||
- Revierte puntos del monedero en proporción ($10 = 1 pt), con piso en 0.
|
||||
- Estado resultante: cancelled si no queda pagado; partial si queda parcial; paid si seguía cubierto.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.state not in ('confirmed', 'paid', 'partial'):
|
||||
raise ValidationError(_('Solo se pueden devolver ventas confirmadas o pagadas'))
|
||||
if self.amount_paid <= 0:
|
||||
raise ValidationError(_('La venta no tiene monto pagado para devolver'))
|
||||
|
||||
refund_amount = float(amount or 0)
|
||||
if refund_amount <= 0:
|
||||
refund_amount = self.amount_paid # devolución total de lo pagado
|
||||
refund_amount = min(refund_amount, self.amount_paid)
|
||||
if refund_amount <= 0:
|
||||
raise ValidationError(_('Monto de devolución inválido'))
|
||||
|
||||
new_amount_paid = round(self.amount_paid - refund_amount, 2)
|
||||
|
||||
# Revertir puntos acumulados en proporción a lo devuelto ($10 MXN = 1 punto)
|
||||
points_to_reverse = int(refund_amount / 10)
|
||||
if points_to_reverse > 0:
|
||||
monedero = self.env['skeen.monedero'].search([('partner_id', '=', self.partner_id.id)], limit=1)
|
||||
if monedero and monedero.points > 0:
|
||||
monedero.adjust_points(
|
||||
-points_to_reverse,
|
||||
description=f'Devolución venta {self.name}',
|
||||
reference=self.name,
|
||||
)
|
||||
|
||||
# Estado resultante
|
||||
if new_amount_paid <= 0:
|
||||
new_state = 'cancelled'
|
||||
elif new_amount_paid < self.total:
|
||||
new_state = 'partial'
|
||||
else:
|
||||
new_state = 'paid'
|
||||
|
||||
self.write({
|
||||
'amount_paid': new_amount_paid,
|
||||
'state': new_state,
|
||||
'refunded': True,
|
||||
'refund_amount': self.refund_amount + refund_amount,
|
||||
'refund_reason': reason or self.refund_reason or '',
|
||||
'refunded_at': fields.Datetime.now(),
|
||||
})
|
||||
|
||||
# Reflejar en la cita origen si existe
|
||||
if self.cita_id:
|
||||
if new_amount_paid <= 0:
|
||||
cita_state = 'not_paid'
|
||||
elif new_amount_paid < self.total:
|
||||
cita_state = 'partial'
|
||||
else:
|
||||
cita_state = 'paid'
|
||||
self.cita_id.write({'amount_paid': new_amount_paid, 'payment_state': cita_state})
|
||||
|
||||
return {
|
||||
'refund_amount': refund_amount,
|
||||
'points_reversed': points_to_reverse,
|
||||
'new_state': new_state,
|
||||
'new_amount_paid': new_amount_paid,
|
||||
}
|
||||
|
||||
def action_apply_wallet(self, points):
|
||||
"""Redime puntos del monedero como descuento en la venta"""
|
||||
self.ensure_one()
|
||||
monedero = self.env['skeen.monedero'].search([('partner_id', '=', self.partner_id.id)], limit=1)
|
||||
if not monedero or monedero.points < points:
|
||||
raise ValidationError(_('Puntos insuficientes'))
|
||||
|
||||
# Máximo 50% del total
|
||||
max_discount = self.total * 0.5
|
||||
point_value = min(points, int(max_discount))
|
||||
|
||||
monedero.redeem_points(point_value, description=f'Redención venta {self.name}', reference=self.name)
|
||||
self.write({
|
||||
'points_redeemed': point_value,
|
||||
'points_discount': point_value * 1.0,
|
||||
})
|
||||
return True
|
||||
|
||||
|
||||
class SkeenVentaLine(models.Model):
|
||||
_name = 'skeen.venta.line'
|
||||
_description = 'Línea de Venta SKEEN'
|
||||
|
||||
venta_id = fields.Many2one('skeen.venta', string='Venta', required=True, ondelete='cascade')
|
||||
service_id = fields.Many2one('skeen.servicio', string='Servicio', required=True)
|
||||
description = fields.Char(string='Descripción')
|
||||
quantity = fields.Float(string='Cantidad', default=1.0, required=True)
|
||||
price_unit = fields.Float(string='Precio Unitario', required=True)
|
||||
subtotal = fields.Float(string='Subtotal', compute='_compute_subtotal', store=True)
|
||||
# Artículo recetado por médico (base para comisiones)
|
||||
is_prescribed = fields.Boolean(string='Recetado', default=False,
|
||||
help='Marcar si este artículo fue recetado por un médico (entra al cálculo de comisiones).')
|
||||
prescribed_by_id = fields.Many2one('hr.employee', string='Recetado por')
|
||||
|
||||
@api.depends('quantity', 'price_unit')
|
||||
def _compute_subtotal(self):
|
||||
for line in self:
|
||||
line.subtotal = line.quantity * line.price_unit
|
||||
|
||||
@api.onchange('service_id')
|
||||
def _onchange_service(self):
|
||||
if self.service_id:
|
||||
self.price_unit = self.service_id.price
|
||||
self.description = self.service_id.name
|
||||
|
||||
|
||||
class SkeenCorteCaja(models.Model):
|
||||
_name = 'skeen.corte.caja'
|
||||
_description = 'Corte de Caja SKEEN'
|
||||
_order = 'date desc'
|
||||
|
||||
name = fields.Char(string='Referencia', readonly=True, copy=False)
|
||||
date = fields.Date(string='Fecha', required=True, default=fields.Date.today, index=True)
|
||||
user_id = fields.Many2one('res.users', string='Usuario', default=lambda self: self.env.uid)
|
||||
|
||||
# Apertura
|
||||
opening_cash = fields.Float(string='Efectivo Apertura', default=0.0)
|
||||
|
||||
# Ventas del día
|
||||
venta_ids = fields.One2many('skeen.venta', 'date', string='Ventas del Día',
|
||||
domain=[('state', 'in', ('confirmed', 'paid', 'partial'))])
|
||||
|
||||
# Totales por método de pago
|
||||
total_cash = fields.Float(string='Efectivo', compute='_compute_totals', store=True)
|
||||
total_card = fields.Float(string='Tarjeta', compute='_compute_totals', store=True)
|
||||
total_transfer = fields.Float(string='Transferencia', compute='_compute_totals', store=True)
|
||||
total_other = fields.Float(string='Otros', compute='_compute_totals', store=True)
|
||||
total_sales = fields.Float(string='Total Ventas', compute='_compute_totals', store=True)
|
||||
|
||||
# Cierre
|
||||
closing_cash = fields.Float(string='Efectivo Cierre', default=0.0)
|
||||
difference = fields.Float(string='Diferencia', compute='_compute_difference', store=True)
|
||||
|
||||
# Estado
|
||||
state = fields.Selection([
|
||||
('open', 'Abierto'),
|
||||
('closed', 'Cerrado'),
|
||||
], string='Estado', default='open')
|
||||
|
||||
notes = fields.Text(string='Notas')
|
||||
|
||||
@api.depends('date')
|
||||
def _compute_totals(self):
|
||||
for corte in self:
|
||||
ventas = self.env['skeen.venta'].search([
|
||||
('date', '=', corte.date),
|
||||
('state', 'in', ('confirmed', 'paid', 'partial')),
|
||||
])
|
||||
# Sumar también pagos por método
|
||||
pagos = self.env['skeen.pago'].search([
|
||||
('payment_date', '>=', corte.date.strftime('%Y-%m-%d 00:00:00')),
|
||||
('payment_date', '<=', corte.date.strftime('%Y-%m-%d 23:59:59')),
|
||||
('state', '=', 'completed'),
|
||||
])
|
||||
cash = sum(p.amount for p in pagos if p.payment_method == 'cash')
|
||||
card = sum(p.amount for p in pagos if p.payment_method in ('card', 'stripe', 'mercadopago'))
|
||||
transfer = sum(p.amount for p in pagos if p.payment_method == 'transfer')
|
||||
other = sum(p.amount for p in pagos if p.payment_method not in ('cash', 'card', 'stripe', 'mercadopago', 'transfer'))
|
||||
|
||||
corte.total_cash = cash
|
||||
corte.total_card = card
|
||||
corte.total_transfer = transfer
|
||||
corte.total_other = other
|
||||
corte.total_sales = cash + card + transfer + other
|
||||
|
||||
@api.depends('opening_cash', 'total_cash', 'closing_cash')
|
||||
def _compute_difference(self):
|
||||
for corte in self:
|
||||
expected = corte.opening_cash + corte.total_cash
|
||||
corte.difference = corte.closing_cash - expected
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if not vals.get('name'):
|
||||
vals['name'] = self.env['ir.sequence'].next_by_code('skeen.corte.caja') or 'CORTE-001'
|
||||
return super(SkeenCorteCaja, self).create(vals_list)
|
||||
|
||||
def action_close(self):
|
||||
self.write({'state': 'closed'})
|
||||
|
||||
def action_reopen(self):
|
||||
self.write({'state': 'open'})
|
||||
5
odoo-addons/skeen_ventas/security/ir.model.access.csv
Normal file
5
odoo-addons/skeen_ventas/security/ir.model.access.csv
Normal file
@@ -0,0 +1,5 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_skeen_venta_user,Acceso a Ventas SKEEN,model_skeen_venta,base.group_user,1,1,1,1
|
||||
access_skeen_venta_line_user,Acceso a Líneas de Venta SKEEN,model_skeen_venta_line,base.group_user,1,1,1,1
|
||||
access_skeen_corte_caja_user,Acceso a Cortes de Caja SKEEN,model_skeen_corte_caja,base.group_user,1,1,1,1
|
||||
access_skeen_tipo_cambio_user,Acceso a Tipo de Cambio SKEEN,model_skeen_tipo_cambio,base.group_user,1,1,1,1
|
||||
|
63
odoo-addons/skeen_ventas/views/corte_views.xml
Normal file
63
odoo-addons/skeen_ventas/views/corte_views.xml
Normal file
@@ -0,0 +1,63 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<record id="view_skeen_corte_caja_tree" model="ir.ui.view">
|
||||
<field name="name">skeen.corte.caja.tree</field>
|
||||
<field name="model">skeen.corte.caja</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree decoration-muted="state=='closed'">
|
||||
<field name="name" />
|
||||
<field name="date" />
|
||||
<field name="user_id" />
|
||||
<field name="opening_cash" />
|
||||
<field name="total_cash" />
|
||||
<field name="total_card" />
|
||||
<field name="total_sales" />
|
||||
<field name="closing_cash" />
|
||||
<field name="difference" />
|
||||
<field name="state" />
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_skeen_corte_caja_form" model="ir.ui.view">
|
||||
<field name="name">skeen.corte.caja.form</field>
|
||||
<field name="model">skeen.corte.caja</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<header>
|
||||
<button name="action_close" string="Cerrar Caja" type="object" invisible="state != 'open'" class="oe_highlight" />
|
||||
<button name="action_reopen" string="Reabrir" type="object" invisible="state != 'closed'" />
|
||||
<field name="state" widget="statusbar" />
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="name" />
|
||||
<field name="date" />
|
||||
<field name="user_id" />
|
||||
<field name="opening_cash" />
|
||||
<field name="closing_cash" />
|
||||
</group>
|
||||
<group>
|
||||
<field name="total_cash" />
|
||||
<field name="total_card" />
|
||||
<field name="total_transfer" />
|
||||
<field name="total_other" />
|
||||
<field name="total_sales" />
|
||||
<field name="difference" />
|
||||
</group>
|
||||
</group>
|
||||
<field name="notes" />
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_skeen_corte_caja" model="ir.actions.act_window">
|
||||
<field name="name">Cortes de Caja</field>
|
||||
<field name="res_model">skeen.corte.caja</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
8
odoo-addons/skeen_ventas/views/menu_views.xml
Normal file
8
odoo-addons/skeen_ventas/views/menu_views.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<menuitem id="menu_skeen_ventas_root" name="Ventas" parent="skeen_citas.menu_skeen_root" sequence="30" />
|
||||
<menuitem id="menu_skeen_venta" name="Ventas" parent="menu_skeen_ventas_root" action="action_skeen_venta" sequence="10" />
|
||||
<menuitem id="menu_skeen_corte_caja" name="Cortes de Caja" parent="menu_skeen_ventas_root" action="action_skeen_corte_caja" sequence="20" />
|
||||
</data>
|
||||
</odoo>
|
||||
73
odoo-addons/skeen_ventas/views/venta_views.xml
Normal file
73
odoo-addons/skeen_ventas/views/venta_views.xml
Normal file
@@ -0,0 +1,73 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<!-- Vista tree de ventas -->
|
||||
<record id="view_skeen_venta_tree" model="ir.ui.view">
|
||||
<field name="name">skeen.venta.tree</field>
|
||||
<field name="model">skeen.venta</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree decoration-success="state=='paid'" decoration-warning="state=='partial'" decoration-muted="state=='cancelled'">
|
||||
<field name="name" />
|
||||
<field name="partner_id" />
|
||||
<field name="date" />
|
||||
<field name="total" />
|
||||
<field name="amount_paid" />
|
||||
<field name="amount_due" />
|
||||
<field name="state" />
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Vista form de ventas -->
|
||||
<record id="view_skeen_venta_form" model="ir.ui.view">
|
||||
<field name="name">skeen.venta.form</field>
|
||||
<field name="model">skeen.venta</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<header>
|
||||
<button name="action_confirm" string="Confirmar" type="object" invisible="state != 'draft'" class="oe_highlight" />
|
||||
<button name="action_mark_paid" string="Marcar Pagada" type="object" invisible="state not in ('confirmed', 'partial')" class="oe_highlight" />
|
||||
<button name="action_cancel" string="Cancelar" type="object" invisible="state not in ('draft', 'confirmed', 'partial')" />
|
||||
<field name="state" widget="statusbar" />
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="name" />
|
||||
<field name="partner_id" />
|
||||
<field name="cita_id" />
|
||||
<field name="date" />
|
||||
</group>
|
||||
<group>
|
||||
<field name="subtotal" />
|
||||
<field name="discount" />
|
||||
<field name="points_discount" />
|
||||
<field name="tax" />
|
||||
<field name="total" />
|
||||
<field name="amount_paid" />
|
||||
<field name="amount_due" />
|
||||
</group>
|
||||
</group>
|
||||
<field name="line_ids">
|
||||
<tree editable="bottom">
|
||||
<field name="service_id" />
|
||||
<field name="description" />
|
||||
<field name="quantity" />
|
||||
<field name="price_unit" />
|
||||
<field name="subtotal" />
|
||||
</tree>
|
||||
</field>
|
||||
<field name="notes" />
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action -->
|
||||
<record id="action_skeen_venta" model="ir.actions.act_window">
|
||||
<field name="name">Ventas</field>
|
||||
<field name="res_model">skeen.venta</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
4
odoo-addons/skeen_whatsapp/__init__.py
Normal file
4
odoo-addons/skeen_whatsapp/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import models
|
||||
from . import controllers
|
||||
22
odoo-addons/skeen_whatsapp/__manifest__.py
Normal file
22
odoo-addons/skeen_whatsapp/__manifest__.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': 'SKEEN WhatsApp',
|
||||
'version': '1.0.0',
|
||||
'category': 'Healthcare',
|
||||
'summary': 'Integración WhatsApp para SKEEN Derma Experts',
|
||||
'description': """
|
||||
Controladores API para integración WACRM + Hermes con Odoo.
|
||||
Expone endpoints JSON-RPC para consultas de disponibilidad, citas, etc.
|
||||
""",
|
||||
'author': 'Consultoria Alcaraz Salazar, S.A.S.',
|
||||
'website': 'https://skeen.mx',
|
||||
'depends': ['base', 'skeen_citas', 'skeen_monedero', 'skeen_pagos', 'skeen_ventas'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'data/frontend_users.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
5
odoo-addons/skeen_whatsapp/controllers/__init__.py
Normal file
5
odoo-addons/skeen_whatsapp/controllers/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import main
|
||||
from . import frontend
|
||||
from . import wacrm_proxy
|
||||
127
odoo-addons/skeen_whatsapp/controllers/auth.py
Normal file
127
odoo-addons/skeen_whatsapp/controllers/auth.py
Normal file
@@ -0,0 +1,127 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Autenticación ligera (token HMAC) para el frontend React de SKEEN.
|
||||
|
||||
Token = base64url(payload_json) + "." + firma HMAC-SHA256, sin tabla de sesiones.
|
||||
El secreto vive en ir.config_parameter 'skeen.frontend.secret' (se genera al primer uso).
|
||||
"""
|
||||
|
||||
import base64
|
||||
import functools
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from odoo.http import request, Response
|
||||
|
||||
TTL_SECONDS = 60 * 60 * 12 # 12 horas
|
||||
SECRET_KEY = 'skeen.frontend.secret'
|
||||
|
||||
RANK = {
|
||||
'lectura': 10,
|
||||
'medico': 50,
|
||||
'recepcion': 50,
|
||||
'admin': 100,
|
||||
}
|
||||
|
||||
|
||||
def _cors_headers():
|
||||
return {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
}
|
||||
|
||||
|
||||
def _json(data, status=200):
|
||||
return Response(
|
||||
json.dumps(data, default=str),
|
||||
status=status,
|
||||
mimetype='application/json',
|
||||
headers=_cors_headers(),
|
||||
)
|
||||
|
||||
|
||||
def _b64url_encode(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii')
|
||||
|
||||
|
||||
def _b64url_decode(s: str) -> bytes:
|
||||
pad = '=' * (-len(s) % 4)
|
||||
return base64.urlsafe_b64decode(s + pad)
|
||||
|
||||
|
||||
def _get_secret(env) -> str:
|
||||
ICP = env['ir.config_parameter'].sudo()
|
||||
secret = ICP.get_param(SECRET_KEY)
|
||||
if not secret:
|
||||
secret = secrets.token_urlsafe(48)
|
||||
ICP.set_param(SECRET_KEY, secret)
|
||||
return secret
|
||||
|
||||
|
||||
def sign_token(env, user) -> str:
|
||||
payload = {
|
||||
'uid': user.id,
|
||||
'login': user.login,
|
||||
'role': user.role,
|
||||
'exp': int(time.time()) + TTL_SECONDS,
|
||||
}
|
||||
body = _b64url_encode(json.dumps(payload, separators=(',', ':')).encode('utf-8'))
|
||||
sig = hmac.new(_get_secret(env).encode('utf-8'), body.encode('ascii'), hashlib.sha256).hexdigest()
|
||||
return f'{body}.{sig}'
|
||||
|
||||
|
||||
def verify_token(env, token: str):
|
||||
if not token or '.' not in token:
|
||||
return None
|
||||
body, sig = token.rsplit('.', 1)
|
||||
expected = hmac.new(_get_secret(env).encode('utf-8'), body.encode('ascii'), hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(sig, expected):
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(_b64url_decode(body).decode('utf-8'))
|
||||
except Exception:
|
||||
return None
|
||||
if int(payload.get('exp', 0)) < int(time.time()):
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
def current_user(env):
|
||||
"""Devuelve el usuario autenticado (record) o None."""
|
||||
auth = request.httprequest.headers.get('Authorization', '') or ''
|
||||
if not auth.lower().startswith('bearer '):
|
||||
return None
|
||||
token = auth[7:].strip()
|
||||
payload = verify_token(env, token)
|
||||
if not payload:
|
||||
return None
|
||||
user = env['skeen.frontend.user'].sudo().browse(payload.get('uid'))
|
||||
if not user.exists() or not user.active:
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
def require_role(min_role='lectura'):
|
||||
"""Decorator: exige token válido y un rol con rango >= min_role.
|
||||
|
||||
Deja pasar OPTIONS (preflight CORS) sin autenticación.
|
||||
"""
|
||||
required = RANK.get(min_role, 10)
|
||||
|
||||
def decorator(func):
|
||||
@functools.wraps(func)
|
||||
def wrapper(self, *args, **kw):
|
||||
if request.httprequest.method == 'OPTIONS':
|
||||
return _json({}, 200)
|
||||
user = current_user(request.env)
|
||||
if not user:
|
||||
return _json({'status': 'error', 'message': 'No autenticado'}, 401)
|
||||
if user.role_rank() < required:
|
||||
return _json({'status': 'error', 'message': 'Sin permiso'}, 403)
|
||||
request.frontend_user = user
|
||||
return func(self, *args, **kw)
|
||||
return wrapper
|
||||
return decorator
|
||||
1654
odoo-addons/skeen_whatsapp/controllers/frontend.py
Normal file
1654
odoo-addons/skeen_whatsapp/controllers/frontend.py
Normal file
File diff suppressed because it is too large
Load Diff
182
odoo-addons/skeen_whatsapp/controllers/main.py
Normal file
182
odoo-addons/skeen_whatsapp/controllers/main.py
Normal file
@@ -0,0 +1,182 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
import json
|
||||
|
||||
|
||||
class SkeenWhatsAppController(http.Controller):
|
||||
"""Controladores API para integración con Hermes/WACRM"""
|
||||
|
||||
@http.route('/skeen/api/v1/available_slots', type='json', auth='api_key', methods=['POST'], csrf=False)
|
||||
def get_available_slots(self, service_id, date_from, date_to, **kw):
|
||||
"""Consultar slots disponibles para un servicio en un rango de fechas"""
|
||||
try:
|
||||
servicio = request.env['skeen.servicio'].browse(int(service_id))
|
||||
if not servicio.exists():
|
||||
return {'status': 'error', 'message': 'Servicio no encontrado'}
|
||||
|
||||
slots = []
|
||||
from datetime import datetime, timedelta
|
||||
current_date = datetime.strptime(date_from, '%Y-%m-%d').date()
|
||||
end_date = datetime.strptime(date_to, '%Y-%m-%d').date()
|
||||
|
||||
while current_date <= end_date:
|
||||
day_slots = request.env['skeen.cita'].get_available_slots(current_date, int(service_id))
|
||||
for slot in day_slots:
|
||||
slots.append({
|
||||
'date': current_date.strftime('%Y-%m-%d'),
|
||||
'time': slot['time_str'],
|
||||
'datetime': f"{current_date.strftime('%Y-%m-%d')} {slot['time_str']}",
|
||||
})
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
return {'status': 'success', 'slots': slots}
|
||||
except Exception as e:
|
||||
return {'status': 'error', 'message': str(e)}
|
||||
|
||||
@http.route('/skeen/api/v1/create_appointment', type='json', auth='api_key', methods=['POST'], csrf=False)
|
||||
def create_appointment(self, phone, name, service_id, date, time, **kw):
|
||||
"""Crear una nueva cita"""
|
||||
try:
|
||||
# Buscar o crear paciente
|
||||
partner = request.env['res.partner'].search([('phone', '=', phone)], limit=1)
|
||||
if not partner:
|
||||
partner = request.env['res.partner'].create({
|
||||
'name': name,
|
||||
'phone': phone,
|
||||
'is_patient': True,
|
||||
'source': 'whatsapp',
|
||||
})
|
||||
|
||||
# Convertir time a float
|
||||
time_parts = time.split(':')
|
||||
time_float = float(time_parts[0]) + float(time_parts[1]) / 60.0
|
||||
|
||||
# Crear cita
|
||||
cita = request.env['skeen.cita'].create({
|
||||
'partner_id': partner.id,
|
||||
'servicio_id': int(service_id),
|
||||
'date': date,
|
||||
'time': time_float,
|
||||
'state': 'confirmed',
|
||||
})
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'booking_id': cita.id,
|
||||
'reference': cita.name,
|
||||
'patient_id': partner.patient_id,
|
||||
}
|
||||
except Exception as e:
|
||||
return {'status': 'error', 'message': str(e)}
|
||||
|
||||
@http.route('/skeen/api/v1/patient_balance', type='json', auth='api_key', methods=['POST'], csrf=False)
|
||||
def get_patient_balance(self, phone, **kw):
|
||||
"""Obtener saldo pendiente y monedero del paciente"""
|
||||
try:
|
||||
partner = request.env['res.partner'].search([('phone', '=', phone)], limit=1)
|
||||
if not partner:
|
||||
return {'status': 'error', 'message': 'Paciente no encontrado'}
|
||||
|
||||
# Citas pendientes de pago
|
||||
pending_citas = request.env['skeen.cita'].search([
|
||||
('partner_id', '=', partner.id),
|
||||
('payment_state', '!=', 'paid'),
|
||||
('state', 'in', ('confirmed', 'arrived', 'in_progress', 'done')),
|
||||
])
|
||||
|
||||
pending_balance = sum(c.servicio_price for c in pending_citas)
|
||||
|
||||
# Monedero
|
||||
monedero = request.env['skeen.monedero'].search([('partner_id', '=', partner.id)], limit=1)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'pending_balance': pending_balance,
|
||||
'pending_invoices': [{
|
||||
'id': c.id,
|
||||
'reference': c.name,
|
||||
'service': c.servicio_id.name,
|
||||
'amount': c.servicio_price,
|
||||
'date': c.date.strftime('%Y-%m-%d'),
|
||||
} for c in pending_citas],
|
||||
'wallet_points': monedero.points if monedero else 0,
|
||||
'wallet_mxn': monedero.equivalent_mxn if monedero else 0,
|
||||
}
|
||||
except Exception as e:
|
||||
return {'status': 'error', 'message': str(e)}
|
||||
|
||||
@http.route('/skeen/api/v1/wallet_redeem', type='json', auth='api_key', methods=['POST'], csrf=False)
|
||||
def redeem_wallet(self, phone, points, appointment_id=None, **kw):
|
||||
"""Redimir puntos del monedero"""
|
||||
try:
|
||||
partner = request.env['res.partner'].search([('phone', '=', phone)], limit=1)
|
||||
if not partner:
|
||||
return {'status': 'error', 'message': 'Paciente no encontrado'}
|
||||
|
||||
monedero = request.env['skeen.monedero'].search([('partner_id', '=', partner.id)], limit=1)
|
||||
if not monedero:
|
||||
return {'status': 'error', 'message': 'Monedero no encontrado'}
|
||||
|
||||
monedero.redeem_points(int(points))
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'remaining_points': monedero.points,
|
||||
'remaining_mxn': monedero.equivalent_mxn,
|
||||
}
|
||||
except Exception as e:
|
||||
return {'status': 'error', 'message': str(e)}
|
||||
|
||||
@http.route('/skeen/api/v1/cancel_appointment', type='json', auth='api_key', methods=['POST'], csrf=False)
|
||||
def cancel_appointment(self, phone, booking_id=None, **kw):
|
||||
"""Cancelar una cita"""
|
||||
try:
|
||||
domain = [
|
||||
('partner_id.phone', '=', phone),
|
||||
('state', 'in', ('pending', 'confirmed')),
|
||||
]
|
||||
if booking_id:
|
||||
domain.append(('id', '=', int(booking_id)))
|
||||
|
||||
cita = request.env['skeen.cita'].search(domain, order='date asc', limit=1)
|
||||
if not cita:
|
||||
return {'status': 'error', 'message': 'No se encontró cita activa'}
|
||||
|
||||
cita.write({'state': 'cancelled'})
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'cancelled_booking': cita.name,
|
||||
}
|
||||
except Exception as e:
|
||||
return {'status': 'error', 'message': str(e)}
|
||||
|
||||
@http.route('/skeen/api/v1/services', type='json', auth='api_key', methods=['GET', 'POST'], csrf=False)
|
||||
def list_services(self, **kw):
|
||||
"""Listar todos los servicios disponibles"""
|
||||
try:
|
||||
services = request.env['skeen.servicio'].search([('active', '=', True)])
|
||||
return {
|
||||
'status': 'success',
|
||||
'services': [{
|
||||
'id': s.id,
|
||||
'code': s.code,
|
||||
'name': s.name,
|
||||
'category': s.category,
|
||||
'price': s.price,
|
||||
'package_price': s.package_price,
|
||||
'package_sessions': s.package_sessions,
|
||||
'package_notes': s.package_notes or '',
|
||||
'duration_min': s.duration_min,
|
||||
'description': s.description,
|
||||
} for s in services],
|
||||
}
|
||||
except Exception as e:
|
||||
return {'status': 'error', 'message': str(e)}
|
||||
|
||||
@http.route('/skeen/api/v1/health', type='json', auth='none', methods=['GET'], csrf=False)
|
||||
def health_check(self, **kw):
|
||||
"""Health check para monitoreo"""
|
||||
return {'status': 'ok', 'service': 'skeen-odoo-api', 'version': '1.0.0'}
|
||||
550
odoo-addons/skeen_whatsapp/controllers/wacrm_proxy.py
Normal file
550
odoo-addons/skeen_whatsapp/controllers/wacrm_proxy.py
Normal file
@@ -0,0 +1,550 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Proxy/caché entre WACRM (Supabase) y Odoo.
|
||||
Expone endpoints REST para el frontend SKEEN.
|
||||
"""
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
from .frontend import json_response, _parse_json_body
|
||||
import os
|
||||
import logging
|
||||
import requests
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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_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
|
||||
|
||||
|
||||
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 contactos, conversaciones, mensajes, pipelines y deals desde Supabase hacia Odoo."""
|
||||
try:
|
||||
result = self._do_sync()
|
||||
return json_response({'status': 'success', 'result': result})
|
||||
except Exception as e:
|
||||
_logger.exception('Error sincronizando WACRM')
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
def _do_sync(self):
|
||||
account_id = None
|
||||
|
||||
# 1. Contacts (paginado por si PostgREST limita a 1000 filas)
|
||||
contacts_data = _supabase_request_paginated('contacts', {'select': '*'})
|
||||
contacts_map = {}
|
||||
for item in contacts_data:
|
||||
if not account_id and item.get('account_id'):
|
||||
account_id = item['account_id']
|
||||
vals = {
|
||||
'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')),
|
||||
}
|
||||
existing = request.env['skeen.wacrm.contact'].sudo().search([('external_id', '=', item['id'])], limit=1)
|
||||
if existing:
|
||||
existing.write(vals)
|
||||
contacts_map[item['id']] = existing
|
||||
else:
|
||||
contacts_map[item['id']] = request.env['skeen.wacrm.contact'].sudo().create(vals)
|
||||
|
||||
# 2. Conversations
|
||||
conversations_data = _supabase_request_paginated('conversations', {'select': '*'})
|
||||
conversations_map = {}
|
||||
for item in conversations_data:
|
||||
vals = {
|
||||
'external_id': item['id'],
|
||||
'contact_id': contacts_map.get(item.get('contact_id'), request.env['skeen.wacrm.contact']).id if contacts_map.get(item.get('contact_id')) else False,
|
||||
'status': item.get('status', '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')),
|
||||
}
|
||||
existing = request.env['skeen.wacrm.conversation'].sudo().search([('external_id', '=', item['id'])], limit=1)
|
||||
if existing:
|
||||
existing.write(vals)
|
||||
conversations_map[item['id']] = existing
|
||||
else:
|
||||
conversations_map[item['id']] = request.env['skeen.wacrm.conversation'].sudo().create(vals)
|
||||
|
||||
# 3. Messages (últimos 5000 paginados)
|
||||
messages_data = _supabase_request_paginated(
|
||||
'messages',
|
||||
{'select': '*', 'order': 'created_at.desc'},
|
||||
page_size=1000
|
||||
)[:5000]
|
||||
for item in messages_data:
|
||||
conversation = conversations_map.get(item.get('conversation_id'))
|
||||
if not conversation:
|
||||
continue
|
||||
vals = {
|
||||
'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', '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')),
|
||||
}
|
||||
existing = request.env['skeen.wacrm.message'].sudo().search([('external_id', '=', item['id'])], limit=1)
|
||||
if existing:
|
||||
existing.write(vals)
|
||||
else:
|
||||
request.env['skeen.wacrm.message'].sudo().create(vals)
|
||||
|
||||
# 4. Pipelines & Stages
|
||||
pipelines_data = _supabase_request_paginated('pipelines', {'select': '*'})
|
||||
pipelines_map = {}
|
||||
stages_map = {}
|
||||
for item in pipelines_data:
|
||||
vals = {
|
||||
'external_id': item['id'],
|
||||
'name': item.get('name'),
|
||||
'created_at': _parse_datetime(item.get('created_at')),
|
||||
}
|
||||
existing = request.env['skeen.wacrm.pipeline'].sudo().search([('external_id', '=', item['id'])], limit=1)
|
||||
if existing:
|
||||
existing.write(vals)
|
||||
pipelines_map[item['id']] = existing
|
||||
else:
|
||||
pipelines_map[item['id']] = request.env['skeen.wacrm.pipeline'].sudo().create(vals)
|
||||
|
||||
stages_data = _supabase_request_paginated('pipeline_stages', {'select': '*'})
|
||||
for item in stages_data:
|
||||
vals = {
|
||||
'external_id': item['id'],
|
||||
'pipeline_id': pipelines_map.get(item.get('pipeline_id'), request.env['skeen.wacrm.pipeline']).id if pipelines_map.get(item.get('pipeline_id')) else False,
|
||||
'name': item.get('name'),
|
||||
'position': item.get('position', 0),
|
||||
'color': item.get('color', '#3b82f6'),
|
||||
}
|
||||
existing = request.env['skeen.wacrm.stage'].sudo().search([('external_id', '=', item['id'])], limit=1)
|
||||
if existing:
|
||||
existing.write(vals)
|
||||
stages_map[item['id']] = existing
|
||||
else:
|
||||
stages_map[item['id']] = request.env['skeen.wacrm.stage'].sudo().create(vals)
|
||||
|
||||
# 5. Deals
|
||||
deals_data = _supabase_request_paginated('deals', {'select': '*'})
|
||||
for item in deals_data:
|
||||
vals = {
|
||||
'external_id': item['id'],
|
||||
'title': item.get('title') or 'Sin título',
|
||||
'contact_id': contacts_map.get(item.get('contact_id'), request.env['skeen.wacrm.contact']).id if contacts_map.get(item.get('contact_id')) else False,
|
||||
'conversation_id': item.get('conversation_id'),
|
||||
'pipeline_id': pipelines_map.get(item.get('pipeline_id'), request.env['skeen.wacrm.pipeline']).id if pipelines_map.get(item.get('pipeline_id')) else False,
|
||||
'stage_id': stages_map.get(item.get('stage_id'), request.env['skeen.wacrm.stage']).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': item.get('expected_close_date'),
|
||||
'notes': item.get('notes'),
|
||||
'assigned_to': str(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')),
|
||||
}
|
||||
existing = request.env['skeen.wacrm.deal'].sudo().search([('external_id', '=', item['id'])], limit=1)
|
||||
if existing:
|
||||
existing.write(vals)
|
||||
else:
|
||||
request.env['skeen.wacrm.deal'].sudo().create(vals)
|
||||
|
||||
return {
|
||||
'contacts': len(contacts_data),
|
||||
'conversations': len(conversations_data),
|
||||
'messages': len(messages_data),
|
||||
'pipelines': len(pipelines_data),
|
||||
'stages': len(stages_data),
|
||||
'deals': len(deals_data),
|
||||
'account_id': account_id,
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# Mensajes
|
||||
# ============================================================
|
||||
@http.route('/skeen/frontend/v1/wacrm/messages', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||
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)
|
||||
|
||||
@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],
|
||||
})
|
||||
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'),
|
||||
'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)
|
||||
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)
|
||||
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()
|
||||
if data.get('status') in ('open', 'won', 'lost'):
|
||||
deal.write({'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})
|
||||
return json_response({'status': 'success', 'lead': self._deal_to_dict(deal)})
|
||||
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,
|
||||
}
|
||||
|
||||
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 '',
|
||||
'stage': d.stage_id.name if d.stage_id else '',
|
||||
'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 '',
|
||||
'created_at': d.created_at.strftime('%Y-%m-%d %H:%M:%S') if d.created_at else None,
|
||||
}
|
||||
14
odoo-addons/skeen_whatsapp/data/frontend_users.xml
Normal file
14
odoo-addons/skeen_whatsapp/data/frontend_users.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
<!-- Usuario administrador inicial del frontend React.
|
||||
login: admin / password: SkeenAdmin2026!
|
||||
Cámbiala en cuanto se tenga pantalla de usuarios. -->
|
||||
<record id="seed_admin" model="skeen.frontend.user">
|
||||
<field name="login">admin</field>
|
||||
<field name="name">Administrador SKEEN</field>
|
||||
<field name="password_hash">scrypt:32768:8:1$qUN8LJrWj2F6a5Fe$699e6d72d42004abb0fb401843c2ccb497eccc39600ea2e7160eda276148fbc246c3a1e777eb3d44161cfe1cd86bc490e35c062cabf8c3d753cd170c478ae06a</field>
|
||||
<field name="role">admin</field>
|
||||
<field name="active" eval="True"/>
|
||||
<field name="must_change_password" eval="False"/>
|
||||
</record>
|
||||
</odoo>
|
||||
3
odoo-addons/skeen_whatsapp/models/__init__.py
Normal file
3
odoo-addons/skeen_whatsapp/models/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from . import main
|
||||
from . import wacrm_sync
|
||||
from . import frontend_user
|
||||
73
odoo-addons/skeen_whatsapp/models/frontend_user.py
Normal file
73
odoo-addons/skeen_whatsapp/models/frontend_user.py
Normal file
@@ -0,0 +1,73 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
try:
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
except Exception: # pragma: no cover
|
||||
check_password_hash = None
|
||||
generate_password_hash = None
|
||||
|
||||
|
||||
class SkeenFrontendUser(models.Model):
|
||||
_name = 'skeen.frontend.user'
|
||||
_description = 'Usuario del Frontend React'
|
||||
_order = 'name, login'
|
||||
_rec_name = 'name'
|
||||
|
||||
login = fields.Char(string='Usuario', required=True, index=True)
|
||||
name = fields.Char(string='Nombre', required=True)
|
||||
password_hash = fields.Char(string='Hash de contraseña', required=True)
|
||||
role = fields.Selection([
|
||||
('admin', 'Administrador'),
|
||||
('recepcion', 'Recepción'),
|
||||
('medico', 'Médico'),
|
||||
('lectura', 'Solo lectura'),
|
||||
], string='Rol', default='recepcion', required=True)
|
||||
active = fields.Boolean(string='Activo', default=True)
|
||||
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')
|
||||
|
||||
_sql_constraints = [
|
||||
('unique_login', 'unique(login)', 'El usuario ya existe.'),
|
||||
]
|
||||
|
||||
def set_password(self, plain):
|
||||
self.ensure_one()
|
||||
if not generate_password_hash:
|
||||
raise ValidationError(_('Hash de contraseña no disponible'))
|
||||
if not plain or len(plain) < 8:
|
||||
raise ValidationError(_('La contraseña debe tener al menos 8 caracteres'))
|
||||
self.password_hash = generate_password_hash(plain)
|
||||
|
||||
def check_password(self, plain):
|
||||
self.ensure_one()
|
||||
if not self.password_hash or not check_password_hash:
|
||||
return False
|
||||
try:
|
||||
return check_password_hash(self.password_hash, plain or '')
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def role_rank(self):
|
||||
"""Jerarquía simple para permisos: mayor número = más acceso."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'lectura': 10,
|
||||
'medico': 50,
|
||||
'recepcion': 50,
|
||||
'admin': 100,
|
||||
}.get(self.role, 0)
|
||||
|
||||
def to_public_dict(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'id': self.id,
|
||||
'login': self.login,
|
||||
'name': self.name,
|
||||
'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,
|
||||
}
|
||||
2
odoo-addons/skeen_whatsapp/models/main.py
Normal file
2
odoo-addons/skeen_whatsapp/models/main.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Placeholder para evitar errores de importación."""
|
||||
141
odoo-addons/skeen_whatsapp/models/wacrm_sync.py
Normal file
141
odoo-addons/skeen_whatsapp/models/wacrm_sync.py
Normal file
@@ -0,0 +1,141 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Modelos para cachear datos de WACRM (Supabase) en Odoo.
|
||||
Permiten al frontend mostrar mensajes y leads sincronizados con WACRM.
|
||||
"""
|
||||
|
||||
from odoo import models, fields, api
|
||||
from odoo.exceptions import UserError
|
||||
import requests
|
||||
import json
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SkeenWacrmContact(models.Model):
|
||||
_name = 'skeen.wacrm.contact'
|
||||
_description = 'Contacto WACRM'
|
||||
_order = 'updated_at desc'
|
||||
|
||||
external_id = fields.Char('ID WACRM', required=True, index=True)
|
||||
account_id = fields.Char('Account ID')
|
||||
phone = fields.Char('Teléfono', index=True)
|
||||
name = fields.Char('Nombre')
|
||||
email = fields.Char('Correo')
|
||||
company = fields.Char('Compañía')
|
||||
avatar_url = fields.Char('Avatar URL')
|
||||
source = fields.Char('Origen')
|
||||
created_at = fields.Datetime('Creado en WACRM')
|
||||
updated_at = fields.Datetime('Actualizado en WACRM')
|
||||
|
||||
conversation_ids = fields.One2many('skeen.wacrm.conversation', 'contact_id', string='Conversaciones')
|
||||
deal_ids = fields.One2many('skeen.wacrm.deal', 'contact_id', string='Deals')
|
||||
|
||||
|
||||
class SkeenWacrmConversation(models.Model):
|
||||
_name = 'skeen.wacrm.conversation'
|
||||
_description = 'Conversación WACRM'
|
||||
_order = 'last_message_at desc'
|
||||
|
||||
external_id = fields.Char('ID WACRM', required=True, index=True)
|
||||
contact_id = fields.Many2one('skeen.wacrm.contact', string='Contacto', index=True, ondelete='cascade')
|
||||
contact_phone = fields.Char(related='contact_id.phone', string='Teléfono', store=True)
|
||||
contact_name = fields.Char(related='contact_id.name', string='Nombre', store=True)
|
||||
status = fields.Selection([
|
||||
('open', 'Abierta'),
|
||||
('pending', 'Pendiente'),
|
||||
('closed', 'Cerrada'),
|
||||
], string='Estado', default='open')
|
||||
assigned_agent = fields.Char('Agente asignado')
|
||||
assigned_agent_id = fields.Char('ID Agente WACRM')
|
||||
last_message_text = fields.Text('Texto del último mensaje')
|
||||
last_message_at = fields.Datetime('Último mensaje')
|
||||
unread_count = fields.Integer('No leídos', default=0)
|
||||
created_at = fields.Datetime('Creado en WACRM')
|
||||
updated_at = fields.Datetime('Actualizado en WACRM')
|
||||
|
||||
message_ids = fields.One2many('skeen.wacrm.message', 'conversation_id', string='Mensajes')
|
||||
|
||||
|
||||
class SkeenWacrmMessage(models.Model):
|
||||
_name = 'skeen.wacrm.message'
|
||||
_description = 'Mensaje WACRM'
|
||||
_order = 'created_at desc'
|
||||
|
||||
external_id = fields.Char('ID WACRM', required=True, index=True)
|
||||
conversation_id = fields.Many2one('skeen.wacrm.conversation', string='Conversación', index=True, ondelete='cascade')
|
||||
contact_id = fields.Many2one(related='conversation_id.contact_id', string='Contacto', store=True)
|
||||
sender_type = fields.Selection([
|
||||
('customer', 'Cliente'),
|
||||
('agent', 'Agente'),
|
||||
('bot', 'Bot'),
|
||||
], string='Remitente', required=True)
|
||||
content_type = fields.Selection([
|
||||
('text', 'Texto'),
|
||||
('image', 'Imagen'),
|
||||
('document', 'Documento'),
|
||||
('audio', 'Audio'),
|
||||
('video', 'Video'),
|
||||
('location', 'Ubicación'),
|
||||
('template', 'Plantilla'),
|
||||
('interactive', 'Interactivo'),
|
||||
], string='Tipo de contenido', default='text')
|
||||
content_text = fields.Text('Contenido')
|
||||
media_url = fields.Char('URL de medio')
|
||||
template_name = fields.Char('Nombre de plantilla')
|
||||
status = fields.Selection([
|
||||
('sending', 'Enviando'),
|
||||
('sent', 'Enviado'),
|
||||
('delivered', 'Entregado'),
|
||||
('read', 'Leído'),
|
||||
('failed', 'Fallido'),
|
||||
], string='Estado', default='sent')
|
||||
created_at = fields.Datetime('Creado en WACRM')
|
||||
|
||||
|
||||
class SkeenWacrmPipeline(models.Model):
|
||||
_name = 'skeen.wacrm.pipeline'
|
||||
_description = 'Pipeline WACRM'
|
||||
|
||||
external_id = fields.Char('ID WACRM', required=True, index=True)
|
||||
name = fields.Char('Nombre', required=True)
|
||||
created_at = fields.Datetime('Creado en WACRM')
|
||||
|
||||
|
||||
class SkeenWacrmStage(models.Model):
|
||||
_name = 'skeen.wacrm.stage'
|
||||
_description = 'Etapa de Pipeline WACRM'
|
||||
|
||||
external_id = fields.Char('ID WACRM', required=True, index=True)
|
||||
pipeline_id = fields.Many2one('skeen.wacrm.pipeline', string='Pipeline', index=True, ondelete='cascade')
|
||||
name = fields.Char('Nombre', required=True)
|
||||
position = fields.Integer('Posición', default=0)
|
||||
color = fields.Char('Color', default='#3b82f6')
|
||||
|
||||
|
||||
class SkeenWacrmDeal(models.Model):
|
||||
_name = 'skeen.wacrm.deal'
|
||||
_description = 'Deal / Lead WACRM'
|
||||
_order = 'created_at desc'
|
||||
|
||||
external_id = fields.Char('ID WACRM', required=True, index=True)
|
||||
title = fields.Char('Título', required=True)
|
||||
contact_id = fields.Many2one('skeen.wacrm.contact', string='Contacto', index=True)
|
||||
contact_name = fields.Char(related='contact_id.name', string='Nombre contacto', store=True)
|
||||
contact_phone = fields.Char(related='contact_id.phone', string='Teléfono contacto', store=True)
|
||||
conversation_id = fields.Char('ID Conversación WACRM')
|
||||
pipeline_id = fields.Many2one('skeen.wacrm.pipeline', string='Pipeline')
|
||||
stage_id = fields.Many2one('skeen.wacrm.stage', string='Etapa')
|
||||
value = fields.Float('Valor', default=0.0)
|
||||
currency = fields.Char('Moneda', default='USD')
|
||||
status = fields.Selection([
|
||||
('open', 'Abierto'),
|
||||
('won', 'Ganado'),
|
||||
('lost', 'Perdido'),
|
||||
], string='Estado', default='open')
|
||||
expected_close_date = fields.Date('Cierre esperado')
|
||||
notes = fields.Text('Notas')
|
||||
assigned_to = fields.Char('Asignado a')
|
||||
created_at = fields.Datetime('Creado en WACRM')
|
||||
updated_at = fields.Datetime('Actualizado en WACRM')
|
||||
8
odoo-addons/skeen_whatsapp/security/ir.model.access.csv
Normal file
8
odoo-addons/skeen_whatsapp/security/ir.model.access.csv
Normal file
@@ -0,0 +1,8 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_skeen_wacrm_contact,skeen.wacrm.contact,model_skeen_wacrm_contact,base.group_user,1,1,1,1
|
||||
access_skeen_wacrm_conversation,skeen.wacrm.conversation,model_skeen_wacrm_conversation,base.group_user,1,1,1,1
|
||||
access_skeen_wacrm_message,skeen.wacrm.message,model_skeen_wacrm_message,base.group_user,1,1,1,1
|
||||
access_skeen_wacrm_pipeline,skeen.wacrm.pipeline,model_skeen_wacrm_pipeline,base.group_user,1,1,1,1
|
||||
access_skeen_wacrm_stage,skeen.wacrm.stage,model_skeen_wacrm_stage,base.group_user,1,1,1,1
|
||||
access_skeen_wacrm_deal,skeen.wacrm.deal,model_skeen_wacrm_deal,base.group_user,1,1,1,1
|
||||
access_skeen_frontend_user,skeen.frontend.user,model_skeen_frontend_user,base.group_user,1,1,1,1
|
||||
|
Reference in New Issue
Block a user