# -*- 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' # Defaults NEUTROS a propósito: cada instancia configura su propio perfil en # Configuración › Tickets. Si el nombre queda vacío, el frontend usa el nombre # del brand de la clínica. Antes estos defaults traían los datos de SKEEN, que # se filtraban a cualquier instancia nueva (dirección, teléfono y correo ajenos # impresos en el ticket). DEFAULT_CLINICA = { 'nombre': '', 'direccion': '', 'telefono': '', 'email': '', 'sitio': '', '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()}