-
{clinic.name}
+
{clinic.nombre}
CONTEO FÍSICO DE INVENTARIO
{conteo.name} · {conteo.date || '—'} · Realizó: {conteo.user_name || '—'}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index a4d9580..b280ed2 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -273,12 +273,11 @@ p {
left: 0;
width: 100%;
}
- /* Ticket térmico ~80mm, monoespaciado */
+ /* Ticket térmico: el ancho, la fuente y el tamaño los decide el diseño
+ guardado en Configuración › Tickets, que viajan como style en línea del
+ documento. Aquí solo se quitan adornos para el papel. */
.print-ticket {
- width: 76mm;
- max-width: 76mm;
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
- font-size: 11px;
+ width: auto;
border: none;
border-radius: 0;
padding: 0;
diff --git a/frontend/src/lib/ticketConfig.ts b/frontend/src/lib/ticketConfig.ts
new file mode 100644
index 0000000..a5c7608
--- /dev/null
+++ b/frontend/src/lib/ticketConfig.ts
@@ -0,0 +1,197 @@
+import { useEffect, useState } from 'react';
+import { odooApi } from '../services/odoo';
+
+// Configuración del ticket (diseño + datos de la clínica + logo).
+//
+// Vive en el servidor (ir.config_parameter) para que todas las cajas impriman
+// igual. Antes los datos de la clínica se guardaban en el localStorage de cada
+// navegador (clave skeen_clinic_settings), así que cada equipo imprimía una
+// dirección distinta. Este módulo cachea la configuración en memoria, la
+// comparte con quien la pida y deja el localStorage como respaldo de arranque.
+
+export interface TicketClinica {
+ nombre: string;
+ direccion: string;
+ telefono: string;
+ email: string;
+ sitio: string;
+ rfc: string;
+ sucursal: string;
+ tiene_logo: boolean;
+}
+
+export interface TicketLayout {
+ ancho: '58mm' | '80mm';
+ fuente: 'monoespaciada' | 'sans';
+ letra: 'compacta' | 'normal' | 'grande';
+ separador: 'guiones' | 'solida' | 'ninguna';
+ mayusculas: boolean;
+ bloques: string[];
+ secciones: Record;
+ textos: { encabezado: string; pie: string; politica: string };
+}
+
+export interface TicketConfig {
+ layout: TicketLayout;
+ clinica: TicketClinica;
+ logo: string;
+}
+
+const CACHE_KEY = 'skeen_ticket_config';
+
+// Respaldo por si el API no responde (sin sesión, red caída): el último config
+// leído, o el formato histórico del ticket si nunca se leyó nada.
+export const FALLBACK_CONFIG: TicketConfig = {
+ layout: {
+ ancho: '80mm',
+ fuente: 'monoespaciada',
+ letra: 'normal',
+ separador: 'guiones',
+ 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,
+ 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.',
+ },
+ },
+ 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: '',
+};
+
+const clonar = (v: T): T => JSON.parse(JSON.stringify(v)) as T;
+
+const leerLocal = (): TicketConfig | null => {
+ try {
+ const raw = localStorage.getItem(CACHE_KEY);
+ if (!raw) return null;
+ const parsed = JSON.parse(raw) as TicketConfig;
+ if (!parsed?.layout?.secciones || !parsed?.clinica) return null;
+ return parsed;
+ } catch {
+ return null;
+ }
+};
+
+let actual: TicketConfig = leerLocal() || clonar(FALLBACK_CONFIG);
+const oyentes = new Set<(c: TicketConfig) => void>();
+let peticion: Promise | null = null;
+let yaCargo = false;
+
+const publicar = (c: TicketConfig) => {
+ actual = c;
+ try {
+ localStorage.setItem(CACHE_KEY, JSON.stringify(c));
+ } catch {
+ // localStorage lleno o bloqueado: seguimos en memoria
+ }
+ oyentes.forEach((f) => f(c));
+};
+
+/** Fuerza la recarga (tras guardar en Configuración). */
+export const recargarTicketConfig = async (): Promise => {
+ peticion = null;
+ yaCargo = false;
+ return cargarTicketConfig();
+};
+
+export const cargarTicketConfig = (): Promise => {
+ if (peticion) return peticion;
+ if (yaCargo) return Promise.resolve(actual);
+ peticion = odooApi.getTicketConfig()
+ .then((res) => {
+ if (res.status === 'success' && res.layout && res.clinica) {
+ publicar({
+ layout: res.layout as TicketConfig['layout'],
+ clinica: res.clinica as TicketConfig['clinica'],
+ logo: res.logo || '',
+ });
+ }
+ return actual;
+ })
+ .catch(() => actual)
+ .finally(() => {
+ peticion = null;
+ yaCargo = true;
+ });
+ return peticion;
+};
+
+/** Config actual dentro de React; se refresca sola cuando cambia. */
+export const useTicketConfig = (): TicketConfig => {
+ const [cfg, setCfg] = useState(actual);
+ useEffect(() => {
+ const f = (c: TicketConfig) => setCfg(clonar(c));
+ oyentes.add(f);
+ cargarTicketConfig().then((c) => setCfg(clonar(c)));
+ return () => {
+ oyentes.delete(f);
+ };
+ }, []);
+ return cfg;
+};
+
+/** Versión no-React para componentes que solo leen (impresión puntual). */
+export const ticketConfigActual = (): TicketConfig => actual;
+
+/**
+ * Datos de la clínica para los documentos impresos (ticket, recibo, estado de
+ * cuenta, receta, corte, conteo). Sale de la configuración del servidor; si
+ * nunca se configuró, cae al skeen_clinic_settings de este navegador y de ahí
+ * a los valores históricos de SKEEN.
+ */
+export const useClinicaPrint = () => {
+ const c = useTicketConfig().clinica;
+ const local = leerLocal()?.clinica;
+ const mix = (campo: keyof TicketClinica, porDefecto = '') =>
+ String(c[campo] || local?.[campo] || porDefecto || '');
+ return {
+ nombre: mix('nombre', 'SKEEN Derma Experts'),
+ direccion: mix('direccion', 'Playas de Rosarito, Baja California'),
+ telefono: mix('telefono'),
+ email: mix('email'),
+ sitio: mix('sitio'),
+ rfc: mix('rfc'),
+ sucursal: mix('sucursal'),
+ };
+};
diff --git a/frontend/src/pages/Configuracion.tsx b/frontend/src/pages/Configuracion.tsx
index 5f902da..f0318c0 100644
--- a/frontend/src/pages/Configuracion.tsx
+++ b/frontend/src/pages/Configuracion.tsx
@@ -8,6 +8,7 @@ import RolesPanel from '../components/RolesPanel';
import RecetasPanel from '../components/RecetasPanel';
import CatalogoPanel from '../components/CatalogoPanel';
import AuditoriaPanel from '../components/AuditoriaPanel';
+import TicketDesigner from '../components/TicketDesigner';
import ImpactoModal from '../components/ImpactoModal';
import { Card, Button, Input, PageHeader, toast } from '../components/ui';
import { odooApi, type Doctor } from '../services/odoo';
@@ -19,6 +20,7 @@ const TABS = [
{ key: 'roles', label: 'Roles' },
{ key: 'recetas', label: 'Recetas' },
{ key: 'catalogos', label: 'Catálogos' },
+ { key: 'tickets', label: 'Tickets' },
{ key: 'auditoria', label: 'Auditoría' },
] as const;
@@ -51,7 +53,9 @@ const Configuracion: FC = () => {
// comisiones) queda para administración del sistema.
const esAdmin = user?.role === 'admin';
const tabsVisibles = useMemo(
- () => (esAdmin ? TABS : TABS.filter((t) => t.key === 'usuarios' || t.key === 'roles')),
+ () => (esAdmin
+ ? TABS
+ : TABS.filter((t) => t.key === 'usuarios' || t.key === 'roles' || t.key === 'tickets')),
[esAdmin],
);
const [searchParams, setSearchParams] = useSearchParams();
@@ -228,6 +232,8 @@ const Configuracion: FC = () => {
+ ) : tab === 'tickets' ? (
+
) : tab === 'auditoria' ? (
) : tab === 'catalogos' ? (
diff --git a/frontend/src/pages/Pos.tsx b/frontend/src/pages/Pos.tsx
index 0287f52..a5be7db 100644
--- a/frontend/src/pages/Pos.tsx
+++ b/frontend/src/pages/Pos.tsx
@@ -1408,9 +1408,14 @@ const Pos: FC = () => {
puntos_ganados: resultado.puntos_ganados,
wallet_points: resultado.wallet_points,
cambio: resultado.cambio,
+ recibido: resultado.recibido,
cambio_usd: resultado.cambio_usd,
recibido_usd: resultado.recibido_usd,
rate_used: resultado.rate_used,
+ cajero: resultado.sale.user_login,
+ doctor: resultado.sale.doctor,
+ telefono_paciente: resultado.sale.patient_phone,
+ notas: resultado.sale.notes,
}}
/>
diff --git a/frontend/src/pages/Tickets.tsx b/frontend/src/pages/Tickets.tsx
index f907807..0c412a8 100644
--- a/frontend/src/pages/Tickets.tsx
+++ b/frontend/src/pages/Tickets.tsx
@@ -513,6 +513,11 @@ const Tickets: FC = () => {
total: reimprimir.total,
amount_paid: reimprimir.amount_paid,
amount_due: reimprimir.amount_due,
+ metodo: METODO_LABELS[reimprimir.payment_method || ''] || null,
+ cajero: reimprimir.user_login,
+ doctor: reimprimir.doctor,
+ telefono_paciente: reimprimir.patient_phone,
+ notas: reimprimir.notes,
}}
/>
diff --git a/frontend/src/pages/Ventas.tsx b/frontend/src/pages/Ventas.tsx
index 0052fd5..51c8bc6 100644
--- a/frontend/src/pages/Ventas.tsx
+++ b/frontend/src/pages/Ventas.tsx
@@ -906,6 +906,10 @@ const Ventas: FC = () => {
total: printSale.total,
amount_paid: printSale.amount_paid,
amount_due: printSale.amount_due,
+ cajero: printSale.user_login,
+ doctor: printSale.doctor,
+ telefono_paciente: printSale.patient_phone,
+ notas: printSale.notes,
}}
/>
diff --git a/frontend/src/services/odoo.ts b/frontend/src/services/odoo.ts
index 38a4135..f009c47 100644
--- a/frontend/src/services/odoo.ts
+++ b/frontend/src/services/odoo.ts
@@ -521,6 +521,9 @@ export interface Sale {
name: string;
patient_id: number;
patient: string;
+ /** Teléfono y médico del ticket: los muestra el diseño impreso si están activos */
+ patient_phone?: string;
+ doctor?: string;
date: string;
subtotal: number;
discount: number;
@@ -1227,6 +1230,8 @@ export interface PosCheckoutResult {
sale: Sale;
payment: Payment | null;
cambio: number;
+ /** Efectivo recibido en caja (MXN), para imprimir "Recibido" en el ticket */
+ recibido?: number;
cambio_usd?: number;
rate_used?: number | null;
recibido_usd?: number;
@@ -1814,6 +1819,34 @@ export const odooApi = {
return data;
},
+ // ---- Diseño del ticket impreso (Configuración › Tickets) ----
+ async getTicketConfig(): Promise<{
+ status: string; layout?: unknown; clinica?: unknown; logo?: string; message?: string;
+ }> {
+ const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/ticket_config`);
+ return data;
+ },
+
+ async putTicketConfig(layout: unknown, motivo?: string): Promise<{
+ status: string; layout?: unknown; message?: string;
+ }> {
+ const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/ticket_config`,
+ { layout, motivo });
+ return data;
+ },
+
+ async putTicketClinica(payload: {
+ clinica?: Record; logo?: string; motivo?: string;
+ }): Promise<{ status: string; layout?: unknown; clinica?: unknown; logo?: string; message?: string }> {
+ const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/ticket_config/clinica`, payload);
+ return data;
+ },
+
+ async resetTicketConfig(): Promise<{ status: string; layout?: unknown; message?: string }> {
+ const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/ticket_config/reset`, {});
+ return data;
+ },
+
async getDoctorHistory(id: number): Promise {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/doctors/${id}/history`);
return data;
diff --git a/odoo-addons/skeen_whatsapp/controllers/frontend.py b/odoo-addons/skeen_whatsapp/controllers/frontend.py
index 17caf30..b4b3755 100644
--- a/odoo-addons/skeen_whatsapp/controllers/frontend.py
+++ b/odoo-addons/skeen_whatsapp/controllers/frontend.py
@@ -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
# ============================================================
diff --git a/odoo-addons/skeen_whatsapp/models/__init__.py b/odoo-addons/skeen_whatsapp/models/__init__.py
index 1b73286..b90e98f 100644
--- a/odoo-addons/skeen_whatsapp/models/__init__.py
+++ b/odoo-addons/skeen_whatsapp/models/__init__.py
@@ -3,3 +3,4 @@ from . import wacrm_sync
from . import frontend_user
from . import config_audit
from . import wacrm_notes
+from . import ticket_config
diff --git a/odoo-addons/skeen_whatsapp/models/ticket_config.py b/odoo-addons/skeen_whatsapp/models/ticket_config.py
new file mode 100644
index 0000000..5dcf503
--- /dev/null
+++ b/odoo-addons/skeen_whatsapp/models/ticket_config.py
@@ -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()}