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:
@@ -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:
|
||||
|
||||
@@ -35,6 +35,7 @@ DEFAULT_TICKET_LAYOUT = {
|
||||
'subtotal': True,
|
||||
'descuento': True,
|
||||
'puntos_canjeados': True,
|
||||
'impuesto': True,
|
||||
'total': True,
|
||||
'total_usd': True,
|
||||
'metodo_pago': True,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user