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:
@@ -4587,6 +4587,9 @@ class SkeenFrontendController(http.Controller):
|
||||
'name': v.name,
|
||||
'patient_id': v.partner_id.id,
|
||||
'patient': v.partner_id.name,
|
||||
'patient_phone': v.partner_phone or '',
|
||||
'doctor': next((l.prescribed_by_id.name for l in v.line_ids
|
||||
if l.prescribed_by_id), ''),
|
||||
'date': v.date.strftime('%Y-%m-%d') if v.date else None,
|
||||
'subtotal': v.subtotal,
|
||||
'discount': v.discount,
|
||||
@@ -4791,8 +4794,10 @@ class SkeenFrontendController(http.Controller):
|
||||
cambio = 0.0
|
||||
cambio_usd = 0.0
|
||||
recibido_usd = 0.0
|
||||
recibido = 0.0
|
||||
if restante > 0:
|
||||
received = float(data.get('amount_received', restante) or restante)
|
||||
recibido = received
|
||||
reference = ''
|
||||
if currency == 'USD':
|
||||
# Recibido en dólares: convertir a MXN con el TC vigente (ya validado arriba)
|
||||
@@ -4830,6 +4835,7 @@ class SkeenFrontendController(http.Controller):
|
||||
'sale': self._sale_to_dict(venta),
|
||||
'payment': self._payment_to_dict(pago) if pago else None,
|
||||
'cambio': cambio,
|
||||
'recibido': recibido,
|
||||
'cambio_usd': cambio_usd,
|
||||
'rate_used': rate_used,
|
||||
'recibido_usd': recibido_usd,
|
||||
@@ -6156,6 +6162,105 @@ class SkeenFrontendController(http.Controller):
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
# ============================================================
|
||||
# Diseño de tickets (Configuración › Tickets)
|
||||
# ============================================================
|
||||
@http.route('/skeen/frontend/v1/ticket_config', type='http',
|
||||
auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
def get_ticket_config(self, **kw):
|
||||
"""Layout + perfil de la clínica + logo, para el impresor del POS.
|
||||
|
||||
Lectura abierta a cualquier rol autenticado: quien imprime (recepción,
|
||||
farmacia, gerencia) necesita el mismo diseño sin depender de admin.
|
||||
"""
|
||||
try:
|
||||
return json_response({'status': 'success',
|
||||
**request.env['skeen.ticket.config'].sudo().config_completa()})
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/ticket_config', type='http',
|
||||
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
||||
@require_role('admin')
|
||||
def put_ticket_config(self, **kw):
|
||||
"""Guarda el diseño del ticket. Requiere motivo para la bitácora."""
|
||||
try:
|
||||
if request.frontend_user.role not in ('admin', 'gerencia'):
|
||||
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
|
||||
data = _parse_json_body()
|
||||
layout = data.get('layout')
|
||||
if layout is None:
|
||||
return json_response({'status': 'error',
|
||||
'message': 'Falta el diseño del ticket'}, 400)
|
||||
Config = request.env['skeen.ticket.config'].sudo()
|
||||
antes = Config.get_layout()
|
||||
nuevo = Config.set_layout(layout)
|
||||
self._auditar('clinica', 'ticket:layout', 'layout',
|
||||
old_value=json.dumps(antes, ensure_ascii=False)[:500],
|
||||
new_value=json.dumps(nuevo, ensure_ascii=False)[:500],
|
||||
entity_label='Diseño del ticket impreso',
|
||||
notes=(data.get('motivo') or 'actualizado').strip())
|
||||
return json_response({'status': 'success', 'layout': nuevo,
|
||||
'message': 'Diseño del ticket actualizado'})
|
||||
except ValidationError as e:
|
||||
return json_response({'status': 'error',
|
||||
'message': e.args[0] if e.args else str(e)}, 400)
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/ticket_config/clinica', type='http',
|
||||
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
||||
@require_role('admin')
|
||||
def put_ticket_clinica(self, **kw):
|
||||
"""Perfil de la clínica que sale en el encabezado del ticket (y logo)."""
|
||||
try:
|
||||
if request.frontend_user.role not in ('admin', 'gerencia'):
|
||||
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
|
||||
data = _parse_json_body()
|
||||
Config = request.env['skeen.ticket.config'].sudo()
|
||||
clinica = None
|
||||
if data.get('clinica') is not None:
|
||||
antes = Config.get_clinica()
|
||||
clinica = Config.set_clinica(data['clinica'])
|
||||
self._auditar('clinica', 'ticket:clinica', 'perfil',
|
||||
old_value=json.dumps(antes, ensure_ascii=False)[:500],
|
||||
new_value=json.dumps(clinica, ensure_ascii=False)[:500],
|
||||
entity_label='Datos de la clínica en el ticket',
|
||||
notes=(data.get('motivo') or 'actualizado').strip())
|
||||
if 'logo' in data:
|
||||
Config.set_logo(data.get('logo') or '')
|
||||
self._auditar('clinica', 'ticket:logo', 'logo',
|
||||
new_value='presente' if (data.get('logo') or '') else 'borrado',
|
||||
entity_label='Logo del ticket')
|
||||
return json_response({'status': 'success',
|
||||
**Config.config_completa()})
|
||||
except ValidationError as e:
|
||||
return json_response({'status': 'error',
|
||||
'message': e.args[0] if e.args else str(e)}, 400)
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/ticket_config/reset', type='http',
|
||||
auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('admin')
|
||||
def reset_ticket_config(self, **kw):
|
||||
"""Devuelve el diseño al formato original (el del ticket de siempre)."""
|
||||
try:
|
||||
if request.frontend_user.role != 'admin':
|
||||
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
|
||||
Config = request.env['skeen.ticket.config'].sudo()
|
||||
layout = Config.reset_layout()
|
||||
self._auditar('clinica', 'ticket:layout', 'layout',
|
||||
new_value='por omisión',
|
||||
entity_label='Diseño del ticket impreso',
|
||||
notes='restaurado al formato original')
|
||||
return json_response({'status': 'success', 'layout': layout})
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
# ============================================================
|
||||
# Reporte de cumpleañeros
|
||||
# ============================================================
|
||||
|
||||
@@ -3,3 +3,4 @@ from . import wacrm_sync
|
||||
from . import frontend_user
|
||||
from . import config_audit
|
||||
from . import wacrm_notes
|
||||
from . import ticket_config
|
||||
|
||||
239
odoo-addons/skeen_whatsapp/models/ticket_config.py
Normal file
239
odoo-addons/skeen_whatsapp/models/ticket_config.py
Normal 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()}
|
||||
Reference in New Issue
Block a user