# -*- coding: utf-8 -*- """Ajustes de la clínica por instancia (white-label). Vive en `skeen.clinic.settings` (JSON) y no tiene modelo propio. Antes estos datos solo existían en el localStorage del navegador de cada equipo; ahora son compartidos por toda la instancia y los consumen el frontend, la impresión de tickets y las URLs de pago. El nombre por defecto es el del brand (`skeen.clinic.brand` -> 'nombre') para no cambiar lo que ya se ve en producción cuando el parámetro no está guardado. """ import json from urllib.parse import urlparse SETTINGS_KEY = 'skeen.clinic.settings' BRAND_KEY = 'skeen.clinic.brand' BRAND_NOMBRE_DEFAULT = 'SKEEN Derma Experts' HORA_APERTURA_DEFAULT = '08:00' HORA_CIERRE_DEFAULT = '19:00' ZONA_HORARIA_DEFAULT = 'America/Tijuana' MONEDA_DEFAULT = 'MXN' # Campos del contrato del endpoint. CAMPOS = ( 'nombre', 'telefono', 'email', 'direccion', 'web', 'rfc', 'horario_apertura', 'horario_cierre', 'zona_horaria', 'url_publica', 'moneda_base', 'nota_ticket', ) # --------------------------------------------------------------------------- # Impuestos (bloque anidado dentro del mismo JSON de ajustes). # # El impuesto es propio y simple: `account`/`l10n_mx` están desinstalados, así # que no hay motor fiscal de Odoo. Con `activo=False` TODO el sistema se # comporta como antes (sin cálculo, sin desglose, sin renglón en tickets); esa # es la regla de oro y por eso los defaults son apagados y en cero. # # activo: enciende el cálculo en POS, tickets y reportes # nombre: etiqueta del impuesto (p. ej. 'IVA') # tasa_default: % que se aplica cuando el catálogo no fija una # precios_incluyen_impuesto: el precio capturado YA trae impuesto; el # subtotal se desglosa hacia atrás # tasa_opciones: tasas ofrecidas en los formularios (%) # --------------------------------------------------------------------------- IMPUESTOS_DEFAULT = { 'activo': False, 'nombre': 'IVA', 'tasa_default': 0.0, 'precios_incluyen_impuesto': False, 'tasa_opciones': [0, 8, 16], } def _num(valor, por_defecto=0.0): """Número finito de un valor JSON, sin explotar con None/texto.""" try: n = float(valor) except (TypeError, ValueError): return por_defecto if n != n or n in (float('inf'), float('-inf')): # NaN / infinitos return por_defecto return n def _bool(valor, por_defecto=False): if isinstance(valor, bool): return valor if valor in ('1', 'true', 'True', 'si', 'sí', 1): return True if valor in ('0', 'false', 'False', 'no', 0): return False return por_defecto def normalizar_impuestos(raw): """Bloque `impuestos` completo, con defaults, a partir de lo guardado. Tolerante: nunca lanza. Lo inválido cae al default (una instancia sin configurar debe verse EXACTAMENTE como hoy). """ out = dict(IMPUESTOS_DEFAULT) if not isinstance(raw, dict): return out out['activo'] = _bool(raw.get('activo'), IMPUESTOS_DEFAULT['activo']) out['precios_incluyen_impuesto'] = _bool( raw.get('precios_incluyen_impuesto'), IMPUESTOS_DEFAULT['precios_incluyen_impuesto']) nombre = str(raw.get('nombre') or '').strip() out['nombre'] = (nombre or IMPUESTOS_DEFAULT['nombre'])[:40] out['tasa_default'] = min(max(_num(raw.get('tasa_default')), 0.0), 100.0) opciones = raw.get('tasa_opciones') if isinstance(opciones, list): limpias = [] for o in opciones: n = _num(o, None) if n is None: continue n = min(max(n, 0.0), 100.0) if n not in limpias: limpias.append(n) if limpias: out['tasa_opciones'] = sorted(limpias) return out def validar_impuestos(raw): """Valida el bloque `impuestos` de un PUT. Lanza ValueError si algo no cuadra (se traduce a 400 en el controlador). Devuelve SOLO las claves presentes (ya normalizadas) para que el merge no reinicie lo que el PUT no tocó.""" if raw is None: return None if not isinstance(raw, dict): raise ValueError('El bloque de impuestos debe ser un objeto') out = {} if 'activo' in raw: if not isinstance(raw['activo'], bool): raise ValueError('El campo activo de impuestos debe ser verdadero o falso') out['activo'] = raw['activo'] if 'precios_incluyen_impuesto' in raw: if not isinstance(raw['precios_incluyen_impuesto'], bool): raise ValueError('precios_incluyen_impuesto debe ser verdadero o falso') out['precios_incluyen_impuesto'] = raw['precios_incluyen_impuesto'] if 'nombre' in raw: if not isinstance(raw['nombre'], str) or not raw['nombre'].strip(): raise ValueError('El nombre del impuesto no puede estar vacío') if len(raw['nombre'].strip()) > 40: raise ValueError('El nombre del impuesto es demasiado largo (máx. 40)') out['nombre'] = raw['nombre'].strip()[:40] if 'tasa_default' in raw: n = _num(raw['tasa_default'], None) if n is None: raise ValueError('La tasa por defecto debe ser un número') if not (0 <= n <= 100): raise ValueError('La tasa por defecto debe estar entre 0 y 100') out['tasa_default'] = n if 'tasa_opciones' in raw: opciones = raw['tasa_opciones'] if not isinstance(opciones, list) or not opciones: raise ValueError('tasa_opciones debe ser una lista de números') limpias = [] for o in opciones: n = _num(o, None) if n is None: raise ValueError('Cada opción de tasa debe ser un número') if not (0 <= n <= 100): raise ValueError('Las opciones de tasa deben estar entre 0 y 100') if n not in limpias: limpias.append(n) out['tasa_opciones'] = sorted(limpias) return out def get_impuestos(env): """Bloque de impuestos vigente (copia). Fuente única para modelos y API.""" return normalizar_impuestos(get_settings(env).get('impuestos')) # Horas de agenda de respaldo: el comportamiento de siempre (8:00-19:00). AGENDA_START_DEFAULT = 8.0 AGENDA_END_DEFAULT = 19.0 def brand_dict(env): """Brand vigente (subconjunto suficiente), sin defaults de logos.""" raw = env['ir.config_parameter'].sudo().get_param(BRAND_KEY) if raw: try: guardado = json.loads(raw) except (ValueError, TypeError): guardado = None if isinstance(guardado, dict): return guardado return {} def brand_nombre(env): nombre = brand_dict(env).get('nombre') nombre = str(nombre).strip() if nombre is not None else '' return nombre or BRAND_NOMBRE_DEFAULT def get_settings(env): """Objeto completo de ajustes, con defaults si no hay nada guardado.""" out = { 'nombre': brand_nombre(env), 'telefono': '', 'email': '', 'direccion': '', 'web': '', 'rfc': '', 'horario_apertura': HORA_APERTURA_DEFAULT, 'horario_cierre': HORA_CIERRE_DEFAULT, 'zona_horaria': ZONA_HORARIA_DEFAULT, 'url_publica': '', 'moneda_base': MONEDA_DEFAULT, 'nota_ticket': '', 'impuestos': dict(IMPUESTOS_DEFAULT), } raw = env['ir.config_parameter'].sudo().get_param(SETTINGS_KEY) if raw: try: guardado = json.loads(raw) except (ValueError, TypeError): guardado = None if isinstance(guardado, dict): for k in CAMPOS: if k in guardado and guardado[k] is not None: out[k] = str(guardado[k]) out['impuestos'] = normalizar_impuestos(guardado.get('impuestos')) return out def set_settings(env, vals): """Guarda mergeando sobre lo actual. Devuelve el objeto resultante.""" if not isinstance(vals, dict): raise ValueError('Los ajustes deben ser un objeto') actual = get_settings(env) for k in CAMPOS: if k in vals and vals[k] is not None: actual[k] = str(vals[k]).strip() if 'impuestos' in vals: bloque = validar_impuestos(vals['impuestos']) if bloque is not None: # Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto. actual['impuestos'] = {**actual['impuestos'], **bloque} env['ir.config_parameter'].sudo().set_param( SETTINGS_KEY, json.dumps(actual, ensure_ascii=False)) return get_settings(env) def _hhmm_to_float(valor): """'08:00' -> 8.0. None si el formato no es válido.""" try: partes = str(valor).strip().split(':') h = int(partes[0]) m = int(partes[1]) if len(partes) > 1 else 0 if not (0 <= h <= 24 and 0 <= m <= 59): return None return h + m / 60.0 except (ValueError, TypeError, IndexError): return None def agenda_bounds(env): """(inicio, fin) de la agenda en horas float. Lee horario_apertura/horario_cierre de los ajustes; si faltan o son incoherentes usa el horario de siempre (8:00-19:00), de modo que una instancia sin configurar se comporta igual que hoy. """ s = get_settings(env) inicio = _hhmm_to_float(s.get('horario_apertura')) fin = _hhmm_to_float(s.get('horario_cierre')) if inicio is None: inicio = AGENDA_START_DEFAULT if fin is None: fin = AGENDA_END_DEFAULT if fin <= inicio: return AGENDA_START_DEFAULT, AGENDA_END_DEFAULT return inicio, fin def _es_url_privada(url): """True si el host es local/privado (no sirve para URLs de pago públicas).""" try: host = (urlparse(url).hostname or '').lower() except ValueError: return True if not host or host in ('localhost', '127.0.0.1', '::1'): return True if host.startswith('10.') or host.startswith('192.168.'): return True if host.startswith('172.'): try: segundo = int(host.split('.')[1]) except (IndexError, ValueError): return True if 16 <= segundo <= 31: return True return False def url_publica(env, fallback=''): """URL pública base de la instancia, sin barra final. Orden: ajustes de la clínica -> brand -> `web.base.url` (solo si es público) -> `fallback` (el valor que usaba el código antes). El último escalón preserva el comportamiento actual en instancias sin configurar. """ url = (get_settings(env).get('url_publica') or '').strip() if not url: url = str(brand_dict(env).get('url_publica') or '').strip() if not url: base = env['ir.config_parameter'].sudo().get_param('web.base.url') or '' if base and not _es_url_privada(base): url = base if not url: url = fallback return (url or '').rstrip('/')