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

@@ -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,