Files
SKEEN-Proyecto/odoo-addons/skeen_whatsapp/models/ticket_config.py

241 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,
'impuesto': 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()}