Files
SKEEN-Proyecto/odoo-addons/skeen_whatsapp/models/ticket_config.py
Consultoría Alcaraz Salazar 72f12946c3 Punto 11: editor de diseno de tickets en Configuracion > Tickets
- Nuevo modelo skeen.ticket.config (AbstractModel) con layout, datos de clinica
  y logo en ir.config_parameter; _mezclar() hace que layouts viejos hereden las
  secciones nuevas; _validar() rechaza valores imposibles y textos largos.
- Endpoints GET/PUT /ticket_config, PUT /ticket_config/clinica,
  POST /ticket_config/reset (reset solo admin) con auditoria en skeen_config_audit.
- Los datos de la clinica pasan de localStorage (cada caja imprimia distinto)
  a configuracion compartida en el servidor.
- TicketDoc.tsx renderiza segun el diseno: ancho 58/80mm, fuente, tamano de letra,
  separador, mayusculas, orden de bloques y 29 secciones activables.
- TicketDesigner.tsx: editor completo con vista previa en vivo, reorden de bloques,
  textos personalizables y carga de logo.
- TicketPrint.tsx ahora es un wrapper de TicketDoc; ReciboPrint, EstadoCuentaPrint,
  RecetaPrint, CortePrint y ConteoPrint usan useClinicaPrint().
- _sale_to_dict expone telefono del paciente, medico que atendio y datos de paquete;
  pos_checkout devuelve recibido/cambio en MXN y USD para imprimirlos.
2026-09-24 18:19:51 +00:00

240 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
from odoo.exceptions import ValidationError
import json
# Parámetro del sistema donde vive el diseño del ticket (JSON).
TICKET_LAYOUT_KEY = 'skeen.ticket_layout'
# Diseño por omisión: es el ticket que siempre se imprimió (80 mm, monoespaciado,
# con todos los rubros). El editor de Configuración parte de aquí.
DEFAULT_TICKET_LAYOUT = {
'ancho': '80mm', # 58mm | 80mm
'fuente': 'monoespaciada', # monoespaciada | sans
'letra': 'normal', # compacta | normal | grande
'separador': 'guiones', # guiones | solida | ninguna
'mayusculas': True,
'bloques': ['encabezado', 'cliente', 'conceptos', 'totales', 'pago', 'monedero', 'pie'],
'secciones': {
'nombre_clinica': True,
'direccion': True,
'telefono_clinica': True,
'sucursal': True,
'rfc': False,
'mensaje_encabezado': True,
'folio': True,
'fecha': True,
'cajero': True,
'paciente': True,
'telefono_paciente': False,
'doctor': True,
'conceptos': True,
'detalle_conceptos': True, # muestra "1 × $250.00" además del subtotal
'subtotal': True,
'descuento': True,
'puntos_canjeados': True,
'total': True,
'total_usd': True,
'metodo_pago': True,
'recibido': True,
'cambio': True,
'pagado': True,
'saldo_pendiente': True,
'puntos_ganados': True,
'monedero': True,
'notas': True,
'politica': False,
'mensaje_pie': True,
},
'textos': {
'encabezado': '',
'pie': '¡Gracias por su visita!',
'politica': 'Conserve este ticket. No tiene validez fiscal.',
},
}
# Perfil de la clínica que sale impreso en el ticket. Antes vivía únicamente en
# localStorage del navegador de cada caja (clave skeen_clinic_settings), así que
# cada equipo imprimía una dirección o un teléfono distinto.
TICKET_CLINICA_KEY = 'skeen.ticket_clinica'
DEFAULT_CLINICA = {
'nombre': 'SKEEN Derma Experts',
'direccion': 'Playas de Rosarito, Baja California',
'telefono': '+52 (661) 100-2172',
'email': 'hola@skeen.mx',
'sitio': 'https://skeen.mx',
'rfc': '',
'sucursal': '',
'tiene_logo': False,
}
# Logo en base64 (data URL). Se guarda aparte para no arrastrar cientos de KB
# en cada lectura del perfil.
TICKET_LOGO_KEY = 'skeen.ticket_logo'
# ~250 KB de imagen: de ahí arriba un térmica no imprime bien y solo infla la BD.
LOGO_MAX_BYTES = 250 * 1024
# Valores admitidos: se validan al guardar para no romper el render.
LAYOUT_ANCHOS = ('58mm', '80mm')
LAYOUT_FUENTES = ('monoespaciada', 'sans')
LAYOUT_LETRAS = ('compacta', 'normal', 'grande')
LAYOUT_SEPARADORES = ('guiones', 'solida', 'ninguna')
LAYOUT_BLOQUES = ('encabezado', 'cliente', 'conceptos', 'totales', 'pago', 'monedero', 'pie')
class SkeenTicketConfig(models.AbstractModel):
"""Acceso al diseño del ticket guardado en ir.config_parameter.
Es AbstractModel porque no tiene tabla propia: solo centraliza leer/validar/
escribir el JSON para que el controlador del frontend y cualquier otro
módulo usen la misma lógica.
"""
_name = 'skeen.ticket.config'
_description = 'Diseño de ticket de SKEEN'
@api.model
def get_layout(self):
raw = self.env['ir.config_parameter'].sudo().get_param(TICKET_LAYOUT_KEY)
if not raw:
return dict(DEFAULT_TICKET_LAYOUT)
try:
guardado = json.loads(raw)
except (ValueError, TypeError):
return dict(DEFAULT_TICKET_LAYOUT)
return self._mezclar(guardado)
@api.model
def _mezclar(self, guardado):
"""Une el layout guardado sobre el default (para agregar secciones nuevas).
Si mañana se agrega una sección al catálogo, un layout guardado antes
de ese cambio no la pierde: hereda el valor por omisión.
"""
out = json.loads(json.dumps(DEFAULT_TICKET_LAYOUT))
if not isinstance(guardado, dict):
return out
for k, v in guardado.items():
if k == 'secciones' and isinstance(v, dict):
out['secciones'].update({kk: bool(vv) for kk, vv in v.items()
if kk in out['secciones']})
elif k == 'textos' and isinstance(v, dict):
out['textos'].update({kk: str(vv) for kk, vv in v.items()
if kk in out['textos']})
elif k == 'bloques' and isinstance(v, list):
bloques = [b for b in v if b in LAYOUT_BLOQUES]
faltan = [b for b in LAYOUT_BLOQUES if b not in bloques]
out['bloques'] = bloques + faltan
elif k in out and k != 'secciones' and k != 'textos' and k != 'bloques':
out[k] = v
return out
@api.model
def _validar(self, layout):
if not isinstance(layout, dict):
raise ValidationError(_('El diseño del ticket debe ser un objeto.'))
if layout.get('ancho') not in LAYOUT_ANCHOS:
raise ValidationError(_('Ancho de ticket no válido.'))
if layout.get('fuente') not in LAYOUT_FUENTES:
raise ValidationError(_('Fuente del ticket no válida.'))
if layout.get('letra') not in LAYOUT_LETRAS:
raise ValidationError(_('Tamaño de letra no válido.'))
if layout.get('separador') not in LAYOUT_SEPARADORES:
raise ValidationError(_('Tipo de separador no válido.'))
if not isinstance(layout.get('secciones'), dict):
raise ValidationError(_('Faltan las secciones del ticket.'))
for clave, visible in layout['secciones'].items():
if clave not in DEFAULT_TICKET_LAYOUT['secciones']:
raise ValidationError(_('Sección desconocida: %s', clave))
if not isinstance(visible, bool):
raise ValidationError(_('La sección %s debe ser visible u oculta.', clave))
textos = layout.get('textos') or {}
for clave, valor in textos.items():
if clave not in DEFAULT_TICKET_LAYOUT['textos']:
raise ValidationError(_('Texto desconocido: %s', clave))
if len(str(valor)) > 240:
raise ValidationError(_('El texto "%s" es demasiado largo (máx. 240 caracteres).', clave))
return True
@api.model
def set_layout(self, layout):
self._validar(layout)
compacto = self._mezclar(layout)
self.env['ir.config_parameter'].sudo().set_param(
TICKET_LAYOUT_KEY, json.dumps(compacto, ensure_ascii=False))
return compacto
@api.model
def reset_layout(self):
self.env['ir.config_parameter'].sudo().set_param(TICKET_LAYOUT_KEY, '')
return dict(DEFAULT_TICKET_LAYOUT)
# ---------------- perfil de la clínica ----------------
@api.model
def get_clinica(self):
raw = self.env['ir.config_parameter'].sudo().get_param(TICKET_CLINICA_KEY)
out = dict(DEFAULT_CLINICA)
if raw:
try:
guardado = json.loads(raw)
if isinstance(guardado, dict):
for k in DEFAULT_CLINICA:
if k in guardado and k != 'tiene_logo':
out[k] = str(guardado[k] or '')
except (ValueError, TypeError):
pass
out['tiene_logo'] = bool(
self.env['ir.config_parameter'].sudo().get_param(TICKET_LOGO_KEY))
return out
@api.model
def set_clinica(self, vals):
if not isinstance(vals, dict):
raise ValidationError(_('El perfil de la clínica debe ser un objeto.'))
actual = self.get_clinica()
for k in DEFAULT_CLINICA:
if k == 'tiene_logo' or k not in vals:
continue
v = vals[k]
v = '' if v is None else str(v).strip()
if len(v) > 160:
raise ValidationError(_('El campo %s es demasiado largo.', k))
actual[k] = v
actual.pop('tiene_logo', None)
self.env['ir.config_parameter'].sudo().set_param(
TICKET_CLINICA_KEY, json.dumps(actual, ensure_ascii=False))
return self.get_clinica()
# ---------------- logo ----------------
@api.model
def get_logo(self):
return self.env['ir.config_parameter'].sudo().get_param(TICKET_LOGO_KEY) or ''
@api.model
def set_logo(self, data_url):
"""Guarda el logo como data URL (png/jpg/svg en base64). Vacío = lo borra."""
Params = self.env['ir.config_parameter'].sudo()
data_url = (data_url or '').strip()
if not data_url:
Params.set_param(TICKET_LOGO_KEY, '')
return False
if not data_url.startswith('data:image/'):
raise ValidationError(_('El logo debe ser una imagen (PNG, JPG o SVG).'))
if ';base64,' not in data_url:
raise ValidationError(_('El logo debe venir codificado en base64.'))
if len(data_url) > LOGO_MAX_BYTES * 1.4: # base64 infla ~33%
raise ValidationError(_(
'El logo pesa demasiado (máximo 250 KB). Usa una imagen pequeña.'))
Params.set_param(TICKET_LOGO_KEY, data_url)
return True
@api.model
def config_completa(self):
"""Todo lo que el impresor necesita en una sola llamada."""
return {'layout': self.get_layout(), 'clinica': self.get_clinica(),
'logo': self.get_logo()}