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)

View File

@@ -32,6 +32,14 @@ class SkeenInventarioItem(models.Model):
help='Si está activo, la venta de este artículo acumula puntos monedero.')
notes = fields.Text(string='Notas')
# Impuesto propio del artículo (%). Mismo criterio que el catálogo de
# servicios: con la bandera activa se toma la tasa por defecto de la
# configuración al momento de vender.
tax_rate = fields.Float(string='Impuesto (%)', default=0.0,
help='Tasa de impuesto del artículo. 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.')
move_ids = fields.One2many('skeen.inventario.move', 'item_id', string='Movimientos')
stock_level = fields.Selection([
('out', 'Sin existencias'),

View File

@@ -5,12 +5,36 @@ from odoo.exceptions import ValidationError
from markupsafe import Markup
from odoo.addons.skeen_citas.clinic_settings import get_impuestos
def _fmt_mxn(v):
"""Importe con formato mexicano ($1,234.56) para los avisos del chatter."""
return '$%s' % format(round(float(v or 0.0), 2), ',.2f')
def impuestos_activos(env):
"""True si la instancia tiene el cálculo de impuestos encendido."""
return bool(get_impuestos(env).get('activo'))
def tasa_efectiva(record, env=None):
"""Tasa (%) que corresponde a un servicio/artículo al momento de vender.
- Con 'usar tasa por defecto' (o sin registro) se toma la tasa por defecto
de la configuración vigente: NO se congela al crear el catálogo.
- Con una tasa propia se respeta ese valor.
Devuelve (tasa, usa_default).
"""
env = env or record.env
cfg = get_impuestos(env)
if record is None or not getattr(record, 'exists', lambda: False)():
return float(cfg.get('tasa_default') or 0.0), True
if getattr(record, 'tax_use_default', False):
return float(cfg.get('tasa_default') or 0.0), True
return float(record.tax_rate or 0.0), False
def _etiqueta_line(line):
"""Nombre legible de una línea de venta (descripción, servicio o artículo)."""
return line.description or line.service_id.name or line.item_id.name or 'la línea'
@@ -43,10 +67,20 @@ class SkeenVenta(models.Model):
# Totales
subtotal = fields.Float(string='Subtotal', compute='_compute_totals', store=True)
discount = fields.Float(string='Descuento', default=0.0)
tax = fields.Float(string='Impuesto', default=0.0)
tax = fields.Float(
string='Impuesto', default=0.0,
help='Con los impuestos de la clínica ACTIVOS, este campo refleja la '
'suma de los impuestos de las líneas (se recalcula al vender). '
'Con los impuestos apagados se conserva el monto capturado a mano.')
total = fields.Float(string='Total', compute='_compute_totals', store=True)
amount_paid = fields.Float(string='Pagado', default=0.0)
amount_due = fields.Float(string='Adeudo', compute='_compute_totals', store=True)
# Desglose consultable para reportes (base sin impuesto + impuesto de las
# líneas). Con los impuestos apagados quedan en 0/igual al subtotal, así
# que no alteran ninguna cifra existente.
tax_total = fields.Float(string='Impuesto (líneas)', compute='_compute_tax_breakdown', store=True)
subtotal_sin_impuesto = fields.Float(string='Base sin impuesto', compute='_compute_tax_breakdown', store=True)
card_amount = fields.Float(
string='Pagado con tarjeta', default=0.0,
help="Monto pagado con terminal física (payment_method='card'). "
@@ -105,14 +139,37 @@ class SkeenVenta(models.Model):
# Notas
notes = fields.Text(string='Notas')
@api.depends('line_ids.subtotal', 'discount', 'tax', 'amount_paid', 'points_discount')
@api.depends('line_ids.subtotal', 'line_ids.tax_amount', 'discount', 'tax',
'amount_paid', 'points_discount')
def _compute_totals(self):
for venta in self:
subtotal = sum(line.subtotal for line in venta.line_ids)
venta.subtotal = subtotal
# La fórmula de siempre: subtotal - descuento - puntos + impuesto.
# Con impuestos apagados `tax` conserva el valor manual, así que el
# total sale idéntico al de antes.
venta.total = max(0.0, subtotal - venta.discount - venta.points_discount + venta.tax)
venta.amount_due = max(0.0, venta.total - venta.amount_paid)
@api.depends('line_ids.subtotal', 'line_ids.tax_amount')
def _compute_tax_breakdown(self):
for venta in self:
venta.subtotal_sin_impuesto = sum(line.subtotal for line in venta.line_ids)
venta.tax_total = sum(line.tax_amount for line in venta.line_ids)
def _sync_tax_from_lines(self):
"""Impuestos ACTIVOS: `tax` = suma de los impuestos de las líneas.
Con los impuestos apagados NO se toca: el monto capturado a mano
(compatibilidad con clientes que lo mandaban por API) se conserva igual.
"""
if not impuestos_activos(self.env):
return
for venta in self:
impuesto = sum((line.tax_amount or 0.0) for line in venta.line_ids)
if abs((venta.tax or 0.0) - impuesto) > 0.000001:
venta.write({'tax': impuesto})
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
@@ -497,7 +554,9 @@ class SkeenVenta(models.Model):
head = {}
if discount is not None:
head['discount'] = float(discount or 0.0)
if tax is not None:
# Con impuestos ACTIVOS el impuesto se deriva de las líneas: no se
# acepta un monto manual (el renglón de cada concepto manda).
if tax is not None and not impuestos_activos(self.env):
head['tax'] = float(tax or 0.0)
if notes is not None:
head['notes'] = notes or ''
@@ -698,7 +757,12 @@ class SkeenVentaLine(models.Model):
description = fields.Char(string='Descripción')
quantity = fields.Float(string='Cantidad', default=1.0, required=True)
price_unit = fields.Float(string='Precio Unitario', required=True)
subtotal = fields.Float(string='Subtotal', compute='_compute_subtotal', store=True)
subtotal = fields.Float(string='Subtotal', compute='_compute_amounts', store=True)
# Impuesto propio de la línea: tasa (%) e importe. Se resuelve al vender
# (tasa del catálogo o la por defecto de la configuración); con los
# impuestos apagados la tasa queda en 0 y el importe en 0.
tax_rate = fields.Float(string='Impuesto (%)', default=0.0)
tax_amount = fields.Float(string='Impuesto', compute='_compute_amounts', store=True)
# Artículo recetado por médico (base para comisiones)
is_prescribed = fields.Boolean(string='Recetado', default=False,
help='Marcar si este artículo fue recetado por un médico (entra al cálculo de comisiones).')
@@ -726,22 +790,82 @@ class SkeenVentaLine(models.Model):
'ventas.'
) % {'nombre': line.prescribed_by_id.name})
@api.depends('quantity', 'price_unit')
def _compute_subtotal(self):
@api.depends('quantity', 'price_unit', 'tax_rate')
def _compute_amounts(self):
"""Subtotal e impuesto de la línea.
Con los impuestos apagados (o tasa 0) el subtotal es cantidad × precio
y el impuesto 0: idéntico a como se calculaba antes. Con
'precios_incluyen_impuesto' el precio capturado ya trae impuesto, así
que el subtotal se desglosa hacia atrás.
"""
cfg = get_impuestos(self.env)
activo = bool(cfg.get('activo'))
incluye = bool(cfg.get('precios_incluyen_impuesto'))
for line in self:
line.subtotal = line.quantity * line.price_unit
bruto = (line.quantity or 0.0) * (line.price_unit or 0.0)
tasa = (line.tax_rate or 0.0) if activo else 0.0
if tasa <= 0:
line.subtotal = bruto
line.tax_amount = 0.0
elif incluye:
base = bruto / (1.0 + tasa / 100.0)
line.subtotal = base
line.tax_amount = bruto - base
else:
line.subtotal = bruto
line.tax_amount = bruto * tasa / 100.0
def _tasa_para_linea(self, vals):
"""Tasa (%) a aplicar a una línea nueva cuando el cliente no la manda."""
cfg = get_impuestos(self.env)
if not cfg.get('activo'):
return 0.0
if vals.get('service_id'):
servicio = self.env['skeen.servicio'].browse(vals['service_id'])
if servicio.exists() and not servicio.tax_use_default:
return float(servicio.tax_rate or 0.0)
if vals.get('item_id'):
item = self.env['skeen.inventario.item'].browse(vals['item_id'])
if item.exists() and not item.tax_use_default:
return float(item.tax_rate or 0.0)
return float(cfg.get('tasa_default') or 0.0)
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('tax_rate') is None:
vals['tax_rate'] = self._tasa_para_linea(vals)
lines = super(SkeenVentaLine, self).create(vals_list)
lines.mapped('venta_id')._sync_tax_from_lines()
return lines
def write(self, vals):
res = super(SkeenVentaLine, self).write(vals)
self.mapped('venta_id')._sync_tax_from_lines()
return res
def unlink(self):
ventas = self.mapped('venta_id')
res = super(SkeenVentaLine, self).unlink()
ventas._sync_tax_from_lines()
return res
@api.onchange('service_id')
def _onchange_service(self):
if self.service_id:
self.price_unit = self.service_id.price
self.description = self.service_id.name
if impuestos_activos(self.env):
self.tax_rate = self._tasa_para_linea({'service_id': self.service_id.id})
@api.onchange('item_id')
def _onchange_item(self):
if self.item_id:
self.price_unit = self.item_id.price
self.description = self.item_id.name
if impuestos_activos(self.env):
self.tax_rate = self._tasa_para_linea({'item_id': self.item_id.id})
class SkeenCorteCaja(models.Model):

View File

@@ -19,6 +19,7 @@ from odoo.addons.skeen_citas.clinic_settings import (
agenda_bounds as clinic_agenda_bounds,
get_settings as get_clinic_settings,
set_settings as set_clinic_settings,
get_impuestos,
)
from odoo.addons.skeen_citas.glosario import (
GLOSARIO_KEYS as GLOSARIO_KEY_SET,
@@ -3763,6 +3764,10 @@ class SkeenFrontendController(http.Controller):
'service_group': data.get('service_group', ''),
'is_favorite': data.get('is_favorite', False),
}
if 'tax_use_default' in data:
vals['tax_use_default'] = bool(data.get('tax_use_default'))
if 'tax_rate' in data and data.get('tax_rate') is not None:
vals['tax_rate'] = float(data.get('tax_rate') or 0)
service = Servicio.create(vals)
return json_response({'status': 'success', 'service': self._service_to_dict(service)}, 201)
except Exception as e:
@@ -3819,9 +3824,13 @@ class SkeenFrontendController(http.Controller):
if not service.exists():
return json_response({'status': 'error', 'message': 'Servicio no encontrado'}, 404)
data = _parse_json_body()
allowed = ['name', 'code', 'category', 'price', 'duration_min', 'package_price', 'package_sessions', 'package_notes', 'description', 'color', 'service_group', 'is_favorite', 'genera_monedero', 'active', 'sequence']
allowed = ['name', 'code', 'category', 'price', 'duration_min', 'package_price', 'package_sessions', 'package_notes', 'description', 'color', 'service_group', 'is_favorite', 'genera_monedero', 'active', 'sequence', 'tax_rate', 'tax_use_default']
vals = {k: data[k] for k in allowed if k in data}
Servicio = request.env['skeen.servicio'].sudo()
if 'tax_rate' in vals:
vals['tax_rate'] = float(vals['tax_rate'] or 0)
if 'tax_use_default' in vals:
vals['tax_use_default'] = bool(vals['tax_use_default'])
if 'sequence' in vals:
try:
vals['sequence'] = int(vals['sequence'])
@@ -3858,6 +3867,8 @@ class SkeenFrontendController(http.Controller):
'sequence': s.sequence or 0,
'sold_90d': (ventas_90d or {}).get(s.id, 0.0) if ventas_90d is not None else None,
'genera_monedero': s.genera_monedero,
'tax_rate': s.tax_rate,
'tax_use_default': s.tax_use_default,
}
# ============================================================
@@ -4991,6 +5002,8 @@ class SkeenFrontendController(http.Controller):
'subtotal': v.subtotal,
'discount': v.discount,
'tax': v.tax,
'tax_total': v.tax_total,
'subtotal_sin_impuesto': v.subtotal_sin_impuesto,
'total': v.total,
'amount_paid': v.amount_paid,
'amount_due': v.amount_due,
@@ -5017,6 +5030,8 @@ class SkeenFrontendController(http.Controller):
'quantity': line.quantity,
'price_unit': line.price_unit,
'subtotal': line.subtotal,
'tax_rate': line.tax_rate,
'tax_amount': line.tax_amount,
'is_prescribed': line.is_prescribed,
'prescribed_by_id': line.prescribed_by_id.id if line.prescribed_by_id else None,
'prescribed_by': line.prescribed_by_id.name if line.prescribed_by_id else None,
@@ -5061,7 +5076,37 @@ class SkeenFrontendController(http.Controller):
item_lines = [] # líneas de artículo: para el move 'venta' tras confirmar
paquete_lines = [] # (paquete_id, qty, price): materializan el saldo del paciente
stock_warnings = []
subtotal = 0.0
# Impuestos (config de la instancia). Apagados => todo idéntico a hoy.
cfg_imp = get_impuestos(request.env)
imp_activo = bool(cfg_imp.get('activo'))
imp_incluye = bool(cfg_imp.get('precios_incluyen_impuesto'))
puede_tax = has_perm(request.frontend_user, 'ventas.impuestos')
sale_tax = data.get('tax_rate') # tasa por venta (opcional)
base_total = 0.0
tax_total = 0.0
bruto_total = 0.0
def _tasa_linea(servicio=None, item=None, pedida=None):
"""Tasa (%) efectiva de una línea: catálogo -> default -> pedida."""
if servicio is not None:
cat = float(cfg_imp.get('tasa_default') or 0.0) if servicio.tax_use_default \
else float(servicio.tax_rate or 0.0)
elif item is not None:
cat = float(cfg_imp.get('tasa_default') or 0.0) if item.tax_use_default \
else float(item.tax_rate or 0.0)
else:
cat = float(cfg_imp.get('tasa_default') or 0.0)
if not imp_activo:
return 0.0
if pedida is None:
return cat
pedida = float(pedida)
# Sin permiso de impuestos se ignora el ajuste y manda el
# catálogo (la UI ya lo deshabilita; el backend es la autoridad).
if abs(pedida - cat) > 0.000001 and not puede_tax:
return cat
return pedida
for l in lines:
qty = float(l.get('quantity', 0) or 0)
price = float(l.get('price_unit', 0) or 0)
@@ -5077,16 +5122,19 @@ class SkeenFrontendController(http.Controller):
paquete_id).service_id.id or 0)
if bool(service_id) == bool(item_id):
return json_response({'status': 'error', 'message': 'Cada línea debe tener un servicio o un artículo'}, 400)
subtotal += qty * price
pedida = l.get('tax_rate', sale_tax)
bruto = qty * price
if item_id:
item = request.env['skeen.inventario.item'].sudo().browse(item_id)
if not item.exists() or not item.active:
return json_response({'status': 'error', 'message': 'Artículo no encontrado o inactivo'}, 404)
tasa = _tasa_linea(item=item, pedida=pedida)
line_vals.append((0, 0, {
'item_id': item.id,
'description': l.get('description') or item.name,
'quantity': qty,
'price_unit': price,
'tax_rate': tasa,
'is_prescribed': bool(l.get('prescribed_by_id')),
'prescribed_by_id': l.get('prescribed_by_id') or False,
}))
@@ -5097,18 +5145,31 @@ class SkeenFrontendController(http.Controller):
servicio = request.env['skeen.servicio'].sudo().browse(service_id)
if not servicio.exists():
return json_response({'status': 'error', 'message': 'Servicio no encontrado'}, 404)
tasa = _tasa_linea(servicio=servicio, pedida=pedida)
line_vals.append((0, 0, {
'service_id': servicio.id,
'description': l.get('description') or servicio.name,
'quantity': qty,
'price_unit': price,
'tax_rate': tasa,
'is_prescribed': bool(l.get('prescribed_by_id')),
'prescribed_by_id': l.get('prescribed_by_id') or False,
'paquete_id': paquete_id or False,
}))
if paquete_id:
paquete_lines.append((paquete_id, qty, price))
total_est = max(0.0, subtotal - discount)
# Desglose por línea (mismo cálculo que el modelo).
bruto_total += bruto
if tasa <= 0:
base_total += bruto
elif imp_incluye:
base = bruto / (1.0 + tasa / 100.0)
base_total += base
tax_total += bruto - base
else:
base_total += bruto
tax_total += bruto * tasa / 100.0
total_est = max(0.0, base_total - discount + tax_total)
# Moneda y tipo de cambio: validar ANTES de crear nada
currency = data.get('currency', 'MXN')
@@ -5240,10 +5301,30 @@ class SkeenFrontendController(http.Controller):
venta.action_mark_paid()
partner.invalidate_recordset()
# Desglose de impuestos cobrados (con impuestos apagados: base =
# subtotal, impuesto = 0, total = el de siempre).
desglose = {
'activo': imp_activo,
'nombre': cfg_imp.get('nombre') or 'Impuesto',
'incluido': imp_incluye,
'subtotal': venta.subtotal_sin_impuesto,
'descuento': venta.discount,
'impuesto': venta.tax_total,
'total': venta.total,
}
if rate_used:
desglose['subtotal_usd'] = round(desglose['subtotal'] / rate_used, 2)
desglose['impuesto_usd'] = round(desglose['impuesto'] / rate_used, 2)
desglose['total_usd'] = round(desglose['total'] / rate_used, 2)
return json_response({
'status': 'success',
'sale': self._sale_to_dict(venta),
'payment': self._payment_to_dict(pago) if pago else None,
'subtotal': desglose['subtotal'],
'descuento': desglose['descuento'],
'impuesto': desglose['impuesto'],
'total': desglose['total'],
'impuestos': desglose,
'cambio': cambio,
'recibido': recibido,
'cambio_usd': cambio_usd,
@@ -5262,6 +5343,10 @@ class SkeenFrontendController(http.Controller):
'expiry_date': c.expiry_date.strftime('%Y-%m-%d') if c.expiry_date else None,
} for c in compras_creadas],
}, 201)
except ValidationError as e:
msg = e.args[0] if e.args else str(e)
status = 403 if str(msg).startswith('Sin permiso') else 400
return json_response({'status': 'error', 'message': msg}, status)
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@@ -6317,6 +6402,76 @@ class SkeenFrontendController(http.Controller):
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/reports/taxes', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('reportes.ver')
def report_taxes(self, **kw):
"""Impuestos del periodo, agrupables por mes, servicio y sucursal.
Con los impuestos apagados las tasas de las líneas son 0, así que la
respuesta sale en ceros (no altera ninguna cifra existente). Solo cuenta
ventas no canceladas, igual que el resto de reportes de dinero.
"""
try:
start = kw.get('start', (date.today().replace(day=1)).strftime('%Y-%m-%d'))
end = kw.get('end', date.today().strftime('%Y-%m-%d'))
cfg = get_impuestos(request.env)
ventas = request.env['skeen.venta'].sudo().search([
('state', '!=', 'cancelled'),
('date', '>=', start), ('date', '<=', end),
])
por_mes = {}
por_servicio = {}
por_sucursal = {}
total_base = 0.0
total_impuesto = 0.0
for v in ventas:
mes = v.date.strftime('%Y-%m') if v.date else ''
sucursal = v.cita_id.branch if v.cita_id and v.cita_id.branch else (v.branch if hasattr(v, 'branch') else '') or 'sin_sucursal'
for line in v.line_ids:
base = line.subtotal or 0.0
impuesto = line.tax_amount or 0.0
total_base += base
total_impuesto += impuesto
m = por_mes.setdefault(mes, {'base': 0.0, 'impuesto': 0.0, 'ventas': 0})
m['base'] += base
m['impuesto'] += impuesto
nombre = (line.service_id.name if line.service_id
else line.item_id.name if line.item_id
else line.description or 'Sin concepto')
s = por_servicio.setdefault(nombre, {'base': 0.0, 'impuesto': 0.0, 'cantidad': 0.0})
s['base'] += base
s['impuesto'] += impuesto
s['cantidad'] += line.quantity or 0.0
su = por_sucursal.setdefault(sucursal, {'base': 0.0, 'impuesto': 0.0})
su['base'] += base
su['impuesto'] += impuesto
# 'ventas' por mes: cuántos tickets del mes tocaron impuestos
por_mes[mes]['ventas'] += 1
return json_response({
'status': 'success',
'start': start,
'end': end,
'activo': bool(cfg.get('activo')),
'nombre': cfg.get('nombre') or 'Impuesto',
'incluido': bool(cfg.get('precios_incluyen_impuesto')),
'total_base': total_base,
'total_impuesto': total_impuesto,
'total': total_base + total_impuesto,
'por_mes': [
{'mes': k, **v} for k, v in sorted(por_mes.items())
],
'por_servicio': [
{'servicio': k, **v}
for k, v in sorted(por_servicio.items(), key=lambda kv: -kv[1]['impuesto'])
],
'por_sucursal': [
{'sucursal': k, **v} for k, v in por_sucursal.items()
],
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/reports/inventory', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def report_inventory(self, **kw):
@@ -6950,6 +7105,9 @@ class SkeenFrontendController(http.Controller):
notes=(data.get('motivo') or 'actualizado').strip())
return json_response({'status': 'success', 'settings': nuevo,
'message': 'Ajustes actualizados'})
except ValueError as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 400)
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@@ -7501,6 +7659,8 @@ class SkeenFrontendController(http.Controller):
'last_count_date': it.last_count_date.strftime('%Y-%m-%d') if it.last_count_date else None,
'active': it.active,
'genera_monedero': it.genera_monedero,
'tax_rate': it.tax_rate,
'tax_use_default': it.tax_use_default,
'notes': it.notes or '',
'stock_level': it.stock_level,
}
@@ -7573,6 +7733,10 @@ class SkeenFrontendController(http.Controller):
'expiry_date': data.get('expiry_date') or False,
'notes': data.get('notes', ''),
}
if 'tax_use_default' in data:
vals['tax_use_default'] = bool(data.get('tax_use_default'))
if 'tax_rate' in data and data.get('tax_rate') is not None:
vals['tax_rate'] = float(data.get('tax_rate') or 0)
item = request.env['skeen.inventario.item'].sudo().create(vals)
return json_response({'status': 'success', 'item': self._item_to_dict(item)}, 201)
except Exception as e:
@@ -7589,14 +7753,17 @@ class SkeenFrontendController(http.Controller):
return json_response({'status': 'error', 'message': 'Item no encontrado'}, 404)
data = _parse_json_body()
allowed = ['name', 'kind', 'sku', 'category', 'unit', 'qty_optimal', 'qty_min',
'cost', 'price', 'expiry_date', 'notes', 'active', 'genera_monedero']
'cost', 'price', 'expiry_date', 'notes', 'active', 'genera_monedero',
'tax_rate', 'tax_use_default']
vals = {}
for k in allowed:
if k in data:
vals[k] = data[k]
for f in ('qty_optimal', 'qty_min', 'cost', 'price'):
for f in ('qty_optimal', 'qty_min', 'cost', 'price', 'tax_rate'):
if f in vals:
vals[f] = float(vals[f] or 0)
if 'tax_use_default' in vals:
vals['tax_use_default'] = bool(vals['tax_use_default'])
item.write(vals)
return json_response({'status': 'success', 'item': self._item_to_dict(item)})
except Exception as e:

View File

@@ -35,6 +35,7 @@ DEFAULT_TICKET_LAYOUT = {
'subtotal': True,
'descuento': True,
'puntos_canjeados': True,
'impuesto': True,
'total': True,
'total_usd': True,
'metodo_pago': True,

View File

@@ -58,6 +58,7 @@ PERMISSION_GROUPS = [
('ventas.vender', 'Vender (punto de venta)'),
('ventas.cobrar', 'Cobrar y registrar pagos'),
('ventas.descuentos', 'Aplicar descuentos'),
('ventas.impuestos', 'Ajustar el impuesto por línea en la venta'),
('ventas.cancelar', 'Cancelar tickets / ventas'),
('ventas.devolver', 'Hacer devoluciones'),
('ventas.reimprimir', 'Reimprimir tickets'),
@@ -164,6 +165,7 @@ DEFAULT_PERM_ROLES = {
'ventas.vender': MONEY,
'ventas.cobrar': WN, # medico también cobra depósitos de paciente hoy
'ventas.descuentos': MONEY,
'ventas.impuestos': MONEY,
'ventas.cancelar': AG, # hoy: admin/gerencia (chequeo explícito en el endpoint)
'ventas.devolver': MONEY,
'ventas.reimprimir': ALL,