fase 2.2: impuestos configurables por instancia (tasa por servicio/articulo, calculo por linea, desglose en POS/tickets/estado de cuenta, reporte de impuestos, permiso ventas.impuestos) con defaults apagados

This commit is contained in:
2026-10-11 08:04:11 +00:00
parent 28a690aa0f
commit 358b85afcd
22 changed files with 1105 additions and 30 deletions

View File

@@ -29,6 +29,135 @@ CAMPOS = (
'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
@@ -68,6 +197,7 @@ def get_settings(env):
'url_publica': '',
'moneda_base': MONEDA_DEFAULT,
'nota_ticket': '',
'impuestos': dict(IMPUESTOS_DEFAULT),
}
raw = env['ir.config_parameter'].sudo().get_param(SETTINGS_KEY)
if raw:
@@ -79,6 +209,7 @@ def get_settings(env):
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
@@ -90,6 +221,11 @@ def set_settings(env, vals):
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)

View File

@@ -50,6 +50,15 @@ class SkeenServicio(models.Model):
genera_monedero = fields.Boolean(string='Genera monedero', default=True,
help='Si está activo, la venta de este servicio acumula puntos monedero.')
tag_ids = fields.Many2many('skeen.service.tag', string='Etiquetas')
# Impuesto propio del servicio (%). Solo se usa cuando la configuración de
# la clínica tiene los impuestos activos. Con 'Usar tasa por defecto' el
# servicio NO congela una tasa: al vender se toma la de la configuración
# vigente en ese momento.
tax_rate = fields.Float(string='Impuesto (%)', default=0.0,
help='Tasa de impuesto del servicio. Se ignora si usa la tasa por defecto.')
tax_use_default = fields.Boolean(string='Usar tasa de impuesto por defecto', default=True,
help='Si está activo, se aplica la tasa por defecto de la configuración de la clínica.')
# Configuración
active = fields.Boolean(string='Activo', default=True)