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.
This commit is contained in:
2026-09-24 18:19:51 +00:00
parent e89bcdca3a
commit 72f12946c3
18 changed files with 1433 additions and 228 deletions

View File

@@ -0,0 +1,239 @@
# -*- 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()}