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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user