Punto 7: gerencia puede corregir tickets sin cancelarlos (lineas, precios, descuento y notas) con ajuste automatico de monedero e inventario
This commit is contained in:
@@ -6,6 +6,16 @@ from odoo.exceptions import ValidationError
|
||||
from markupsafe import Markup
|
||||
|
||||
|
||||
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 _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'
|
||||
|
||||
|
||||
class SkeenVenta(models.Model):
|
||||
_name = 'skeen.venta'
|
||||
_description = 'Venta SKEEN'
|
||||
@@ -247,6 +257,346 @@ class SkeenVenta(models.Model):
|
||||
'stock_moves': len(movimientos),
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# Edición de un ticket ya emitido (gerencia / administrador)
|
||||
# ============================================================
|
||||
|
||||
def _wallet_points_expected(self):
|
||||
"""Puntos que DEBIÓ generar esta venta con sus líneas y total actuales.
|
||||
|
||||
Es la misma fórmula de action_mark_paid: el total se reparte
|
||||
proporcionalmente entre las líneas que generan monedero ($10 = 1 pt).
|
||||
"""
|
||||
self.ensure_one()
|
||||
subtotal_total = sum(l.subtotal for l in self.line_ids)
|
||||
elegible = sum(
|
||||
l.subtotal for l in self.line_ids
|
||||
if (l.service_id and l.service_id.genera_monedero)
|
||||
or (l.item_id and l.item_id.genera_monedero)
|
||||
)
|
||||
return int(self.total * (elegible / subtotal_total) / 10) if subtotal_total else 0
|
||||
|
||||
def _wallet_points_accrued(self):
|
||||
"""Puntos realmente acumulados por esta venta (suma de abonos a su referencia)."""
|
||||
self.ensure_one()
|
||||
txs = self.env['skeen.monedero.transaction'].search([
|
||||
('monedero_id.partner_id', '=', self.partner_id.id),
|
||||
('reference', '=', self.name),
|
||||
('type', '=', 'accrual'),
|
||||
])
|
||||
return sum(t.points or 0 for t in txs)
|
||||
|
||||
def _sync_wallet_after_edit(self, prev_accrued, editor=''):
|
||||
"""Ajusta el monedero a los puntos que corresponde tras editar la venta.
|
||||
|
||||
Solo actúa si la venta ya había generado puntos (una venta no pagada no
|
||||
tiene nada que sincronizar). Devuelve el delta aplicado.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if prev_accrued <= 0:
|
||||
return 0
|
||||
monedero = self.env['skeen.monedero'].search(
|
||||
[('partner_id', '=', self.partner_id.id)], limit=1)
|
||||
if not monedero:
|
||||
return 0
|
||||
delta = self._wallet_points_expected() - prev_accrued
|
||||
if not delta:
|
||||
return 0
|
||||
desc = f'Ajuste de puntos por edición del ticket {self.name}'
|
||||
if delta > 0:
|
||||
monedero.add_points(delta, description=desc, reference=self.name,
|
||||
operator=editor)
|
||||
else:
|
||||
monedero.adjust_points(delta, description=desc, reference=self.name,
|
||||
operator=editor)
|
||||
return delta
|
||||
|
||||
def _sync_stock_after_edit(self, prev_sold):
|
||||
"""Iguala el inventario a las líneas nuevas de la venta editada.
|
||||
|
||||
Los movimientos de esta venta se identifican por reference == folio
|
||||
(misma convención que el POS y la cancelación). Devuelve el número de
|
||||
movimientos compensatorios creados.
|
||||
"""
|
||||
self.ensure_one()
|
||||
Move = self.env['skeen.inventario.move']
|
||||
want = {}
|
||||
for line in self.line_ids:
|
||||
if line.item_id:
|
||||
want[line.item_id.id] = want.get(line.item_id.id, 0.0) + (line.quantity or 0.0)
|
||||
creados = Move.browse()
|
||||
for item_id in set(list(want) + list(prev_sold)):
|
||||
diff = want.get(item_id, 0.0) - prev_sold.get(item_id, 0.0)
|
||||
if abs(diff) < 0.0001:
|
||||
continue
|
||||
if diff > 0:
|
||||
mtype, qty = 'venta', diff
|
||||
else:
|
||||
mtype, qty = 'cancelacion', -diff
|
||||
creados |= Move.create({
|
||||
'item_id': item_id,
|
||||
'type': mtype,
|
||||
'qty': qty,
|
||||
'reference': self.name,
|
||||
'notes': f'Ajuste por edición del ticket {self.name}',
|
||||
})
|
||||
return creados
|
||||
|
||||
def _sold_by_item(self):
|
||||
"""Neto descontado en inventario por esta venta, por artículo."""
|
||||
self.ensure_one()
|
||||
moves = self.env['skeen.inventario.move'].search([
|
||||
('reference', '=', self.name),
|
||||
('type', 'in', ('venta', 'cancelacion')),
|
||||
])
|
||||
out = {}
|
||||
for m in moves:
|
||||
out[m.item_id.id] = out.get(m.item_id.id, 0.0) + (
|
||||
m.qty if m.type == 'venta' else -m.qty)
|
||||
return out
|
||||
|
||||
def action_edit(self, *, reason='', lines=None, discount=None, tax=None,
|
||||
notes=None, date=None, editor=''):
|
||||
"""Reescribe un ticket ya emitido corrigiendo líneas/importes/notas.
|
||||
|
||||
Uso: recepción se equivocó al cobrar y gerencia corrige el ticket sin
|
||||
cancelarlo y rehacerlo (que implicaría devolver dinero en caja).
|
||||
|
||||
Mantiene la coherencia de lo que la venta tocó al crearse:
|
||||
- Monedero: recalcula los puntos que debió generar y ajusta la
|
||||
diferencia con un movimiento 'adjustment' a la misma referencia.
|
||||
- Inventario: crea movimientos compensatorios por la diferencia de
|
||||
cantidades entre lo vendido y lo que queda tras la edición.
|
||||
- Paquetes: si cambia o se quita una línea de paquete, sincroniza o
|
||||
anula el saldo del paciente. Se niega si ya se consumieron sesiones.
|
||||
- Estados: si el nuevo total ya no está cubierto, el ticket pasa de
|
||||
'paid' a 'partial' (y se avisa); si sobra dinero, queda el sobre
|
||||
pago como aviso para devolverlo por separado.
|
||||
|
||||
Nada se borra: queda registro en el chatter con el motivo.
|
||||
"""
|
||||
self.ensure_one()
|
||||
reason = (reason or '').strip()
|
||||
if not reason:
|
||||
raise ValidationError(_('Indica el motivo de la corrección del ticket.'))
|
||||
if self.state == 'cancelled':
|
||||
raise ValidationError(_(
|
||||
'El ticket %s está cancelado y no se puede editar.', self.name))
|
||||
if lines is not None and not lines:
|
||||
raise ValidationError(_('El ticket debe conservar al menos un concepto.'))
|
||||
|
||||
# ---------- foto de antes ----------
|
||||
prev_total, prev_subtotal = self.total, self.subtotal
|
||||
prev_accrued = self._wallet_points_accrued()
|
||||
prev_sold = self._sold_by_item()
|
||||
prev_lines = {
|
||||
l.id: {'quantity': l.quantity, 'price_unit': l.price_unit,
|
||||
'paquete_id': l.paquete_id.id if l.paquete_id else None,
|
||||
'description': _etiqueta_line(l)}
|
||||
for l in self.line_ids
|
||||
}
|
||||
|
||||
# ---------- paquetes: validar y liberar los que salen ----------
|
||||
paquetes_afectados = self.env['skeen.paquete.compra']
|
||||
keep_ids = {int(p.get('id')) for p in (lines or []) if p.get('id')}
|
||||
if lines is not None:
|
||||
for line in self.line_ids:
|
||||
if line.paquete_id and line.id not in keep_ids:
|
||||
compras = self.env['skeen.paquete.compra'].search(
|
||||
[('sale_line_id', '=', line.id)])
|
||||
if any(c.sessions_used for c in compras):
|
||||
raise ValidationError(_(
|
||||
'No puedes quitar la línea "%s": el paciente ya '
|
||||
'consumió sesiones de ese paquete. Cancela el '
|
||||
'paquete desde el módulo Paquetes.',
|
||||
_etiqueta_line(line)))
|
||||
paquetes_afectados |= compras
|
||||
for payload in lines:
|
||||
if not payload.get('id') or not payload.get('paquete_id'):
|
||||
continue
|
||||
line = self.env['skeen.venta.line'].browse(payload['id'])
|
||||
if line not in self.line_ids or not line.paquete_id:
|
||||
continue
|
||||
compras = self.env['skeen.paquete.compra'].search(
|
||||
[('sale_line_id', '=', line.id)])
|
||||
for c in compras:
|
||||
if c.sessions_used and int(payload.get('quantity') or 1) != \
|
||||
(line.quantity or 0):
|
||||
raise ValidationError(_(
|
||||
'No puedes cambiar la cantidad de "%s": el paciente '
|
||||
'ya consumió sesiones de ese paquete.',
|
||||
_etiqueta_line(line)))
|
||||
|
||||
# ---------- líneas ----------
|
||||
# Solo se sobrescriben los campos que el payload trae: así una edición
|
||||
# que no menciona `paquete_id` no rompe el vínculo del saldo del
|
||||
# paciente, y el service_id (related en las líneas de paquete) sobrevive.
|
||||
if lines is not None:
|
||||
Linea = self.env['skeen.venta.line']
|
||||
current = {l.id: l for l in self.line_ids}
|
||||
vistos = set()
|
||||
for payload in lines:
|
||||
lid = payload.get('id')
|
||||
vieja = current.get(int(lid)) if lid else None
|
||||
if lid and not vieja:
|
||||
raise ValidationError(_('Ese concepto ya no existe en el ticket.'))
|
||||
|
||||
def valor(campo, por_defecto=None):
|
||||
if campo in payload:
|
||||
return payload[campo]
|
||||
return getattr(vieja, campo, por_defecto) if vieja else por_defecto
|
||||
|
||||
service_id = valor('service_id') or False
|
||||
item_id = valor('item_id') or False
|
||||
paquete_id = valor('paquete_id') or False
|
||||
if paquete_id and not service_id:
|
||||
service_id = (self.env['skeen.paquete'].browse(paquete_id)
|
||||
.service_id.id or False)
|
||||
description = valor('description') or ''
|
||||
quantity = float(valor('quantity') or 0.0)
|
||||
price_unit = float(valor('price_unit') or 0.0)
|
||||
prescribed = valor('prescribed_by_id') or False
|
||||
|
||||
if not service_id and not item_id:
|
||||
raise ValidationError(_(
|
||||
'Cada concepto necesita un servicio o un artículo.'))
|
||||
etiqueta = description or 'el concepto'
|
||||
if quantity <= 0:
|
||||
raise ValidationError(_(
|
||||
'La cantidad de "%s" debe ser mayor a cero.', etiqueta))
|
||||
if price_unit < 0:
|
||||
raise ValidationError(_(
|
||||
'El precio de "%s" no puede ser negativo.', etiqueta))
|
||||
|
||||
vals = {
|
||||
'service_id': service_id,
|
||||
'item_id': item_id,
|
||||
'paquete_id': paquete_id,
|
||||
'description': description,
|
||||
'quantity': quantity,
|
||||
'price_unit': price_unit,
|
||||
'prescribed_by_id': prescribed,
|
||||
'is_prescribed': bool(prescribed),
|
||||
}
|
||||
if vieja:
|
||||
vieja.write(vals)
|
||||
vistos.add(vieja.id)
|
||||
else:
|
||||
vals['venta_id'] = self.id
|
||||
vistos.add(Linea.create(vals).id)
|
||||
for line in self.line_ids:
|
||||
if line.id not in vistos:
|
||||
line.unlink()
|
||||
|
||||
# ---------- encabezado ----------
|
||||
head = {}
|
||||
if discount is not None:
|
||||
head['discount'] = float(discount or 0.0)
|
||||
if tax is not None:
|
||||
head['tax'] = float(tax or 0.0)
|
||||
if notes is not None:
|
||||
head['notes'] = notes or ''
|
||||
if date:
|
||||
head['date'] = date
|
||||
if head:
|
||||
self.write(head)
|
||||
|
||||
for c in paquetes_afectados:
|
||||
c.action_cancel()
|
||||
# action_cancel libera los consumos; sessions_total en 0 es lo que
|
||||
# deja el saldo en 'Anulado' (ver _compute_state).
|
||||
c.write({'sessions_total': 0})
|
||||
|
||||
# ---------- sincronizar efectos ----------
|
||||
puntos = self._sync_wallet_after_edit(prev_accrued, editor=editor)
|
||||
movimientos = self._sync_stock_after_edit(prev_sold)
|
||||
|
||||
warnings = []
|
||||
# El estado tiene que seguir a los números: si el total corregido ya no
|
||||
# está cubierto el ticket deja de estar Pagado, y si lo que se cobró ya
|
||||
# alcanza, un ticket Parcial vuelve a Pagado. No se re-acumulan puntos
|
||||
# (eso lo hace _sync_wallet_after_edit, no action_mark_paid).
|
||||
if self.state in ('paid', 'partial'):
|
||||
cubierto = self.amount_paid >= self.total - 0.005
|
||||
if not cubierto and self.state == 'paid':
|
||||
self.write({'state': 'partial'})
|
||||
warnings.append(_(
|
||||
'El nuevo total (%s) es mayor a lo pagado (%s): el ticket pasó '
|
||||
'a Parcial y quedó un saldo de %s.'
|
||||
) % (_fmt_mxn(self.total), _fmt_mxn(self.amount_paid),
|
||||
_fmt_mxn(self.amount_due)))
|
||||
elif cubierto and self.state == 'partial':
|
||||
self.write({'state': 'paid'})
|
||||
warnings.append(_(
|
||||
'Lo cobrado (%s) alcanza para el nuevo total (%s): el ticket '
|
||||
'volvió a Pagado.'
|
||||
) % (_fmt_mxn(self.amount_paid), _fmt_mxn(self.total)))
|
||||
if self.amount_paid - self.total > 0.005 and self.amount_paid:
|
||||
warnings.append(_(
|
||||
'El ticket quedó pagado de más por %s. Ese dinero sigue en caja '
|
||||
'y hay que devolverlo o dejarlo como saldo a favor del paciente.'
|
||||
) % _fmt_mxn(self.amount_paid - self.total))
|
||||
if prev_accrued and puntos < 0:
|
||||
warnings.append(_(
|
||||
'Se quitaron %d puntos del monedero del paciente. Si ya los '
|
||||
'gastó, el ajuste se limitó al saldo disponible.'
|
||||
) % -puntos)
|
||||
if paquetes_afectados:
|
||||
warnings.append(_(
|
||||
'Se anularon %d paquete(s) del saldo del paciente.',
|
||||
len(paquetes_afectados)))
|
||||
|
||||
# ---------- auditoría ----------
|
||||
detalle = self._edit_detalle(prev_lines)
|
||||
author = self.env.ref('base.user_admin', raise_if_not_found=False)
|
||||
if not author:
|
||||
author = self.env['res.users'].sudo().search(
|
||||
[('login', '=', 'admin')], limit=1)
|
||||
auditor = self.with_user(author) if author else self
|
||||
auditor.message_post(body=Markup(
|
||||
'<b>Ticket corregido</b><br/>'
|
||||
'Motivo: {}<br/>'
|
||||
'Corrigió: {}<br/>'
|
||||
'Total anterior: {} → nuevo total: {}<br/>'
|
||||
'Puntos del monedero ajustados: {}<br/>'
|
||||
'Movimientos de inventario compensatorios: {}<br/>'
|
||||
'{}'
|
||||
).format(
|
||||
reason, editor or 'sistema',
|
||||
_fmt_mxn(prev_total), _fmt_mxn(self.total),
|
||||
puntos, len(movimientos), Markup('<br/>').join(detalle),
|
||||
))
|
||||
|
||||
return {
|
||||
'prev_total': prev_total,
|
||||
'total': self.total,
|
||||
'diff': self.total - prev_total,
|
||||
'points_adjusted': puntos,
|
||||
'stock_moves': len(movimientos),
|
||||
'packages_cancelled': len(paquetes_afectados),
|
||||
'warnings': warnings,
|
||||
}
|
||||
|
||||
def _edit_detalle(self, prev_lines):
|
||||
"""Líneas 'antes -> después' para el chatter (solo lo que cambió)."""
|
||||
self.ensure_one()
|
||||
out = []
|
||||
for line in self.line_ids:
|
||||
p = prev_lines.get(line.id)
|
||||
if not p:
|
||||
out.append(Markup('+ {} · {} × {} = {}').format(
|
||||
_etiqueta_line(line), line.quantity,
|
||||
_fmt_mxn(line.price_unit), _fmt_mxn(line.subtotal)))
|
||||
elif (abs(p['quantity'] - line.quantity) > 0.0001
|
||||
or abs(p['price_unit'] - line.price_unit) > 0.005):
|
||||
out.append(Markup('~ {} → {} × {} = {}').format(
|
||||
p['description'], line.quantity,
|
||||
_fmt_mxn(line.price_unit), _fmt_mxn(line.subtotal)))
|
||||
for lid, p in prev_lines.items():
|
||||
if lid not in self.line_ids.ids:
|
||||
out.append(Markup('- {}').format(p['description']))
|
||||
return out
|
||||
|
||||
def action_refund(self, amount=None, reason=''):
|
||||
"""Devolución total o parcial de una venta. Reversible: no borra nada.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user