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

@@ -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):