840 lines
37 KiB
Python
840 lines
37 KiB
Python
# -*- coding: utf-8 -*-
|
||
|
||
from odoo import models, fields, api, _
|
||
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'
|
||
_order = 'create_date desc'
|
||
_inherit = ['mail.thread']
|
||
|
||
# Referencia
|
||
name = fields.Char(string='Referencia', readonly=True, copy=False)
|
||
|
||
# Paciente
|
||
partner_id = fields.Many2one('res.partner', string='Paciente', required=True,
|
||
domain=[('is_patient', '=', True)])
|
||
partner_phone = fields.Char(related='partner_id.phone', string='Teléfono', readonly=True)
|
||
|
||
# Origen
|
||
cita_id = fields.Many2one('skeen.cita', string='Cita Origen')
|
||
|
||
# Líneas de venta
|
||
line_ids = fields.One2many('skeen.venta.line', 'venta_id', string='Líneas')
|
||
|
||
# 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)
|
||
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)
|
||
card_amount = fields.Float(
|
||
string='Pagado con tarjeta', default=0.0,
|
||
help="Monto pagado con terminal física (payment_method='card'). "
|
||
"Stripe y MercadoPago NO cuentan como tarjeta aquí.")
|
||
|
||
# Estado
|
||
state = fields.Selection([
|
||
('draft', 'Borrador'),
|
||
('confirmed', 'Confirmada'),
|
||
('paid', 'Pagada'),
|
||
('partial', 'Parcial'),
|
||
('cancelled', 'Cancelada'),
|
||
], string='Estado', default='draft', tracking=True)
|
||
|
||
# Monedero
|
||
points_redeemed = fields.Integer(string='Puntos Redimidos', default=0)
|
||
points_discount = fields.Float(string='Descuento por Puntos', default=0.0)
|
||
|
||
# Devolución
|
||
refunded = fields.Boolean(string='Devuelta', default=False)
|
||
refund_amount = fields.Float(string='Monto Devuelto', default=0.0)
|
||
refund_reason = fields.Text(string='Motivo Devolución')
|
||
refunded_at = fields.Datetime(string='Fecha Devolución')
|
||
|
||
# Cancelación (nota de crédito): motivo obligatorio y auditoría
|
||
cancel_reason = fields.Text(string='Motivo Cancelación')
|
||
cancelled_by = fields.Char(string='Cancelada por', readonly=True, copy=False)
|
||
cancelled_at = fields.Datetime(string='Fecha Cancelación', readonly=True, copy=False)
|
||
cancel_points_reversed = fields.Integer(string='Puntos Revertidos', default=0, readonly=True, copy=False)
|
||
cancel_points_restored = fields.Integer(string='Puntos Restaurados', default=0, readonly=True, copy=False)
|
||
|
||
# Cobro: quién cobró y con qué método (para el módulo de Tickets).
|
||
# user_login se llena desde el usuario del frontend (los controladores
|
||
# auth='none' no dejan uid de Odoo en create_uid).
|
||
user_login = fields.Char(string='Cobró (usuario)', index=True)
|
||
payment_method = fields.Selection([
|
||
('cash', 'Efectivo'),
|
||
('card', 'Tarjeta'),
|
||
('transfer', 'Transferencia'),
|
||
('stripe', 'Stripe'),
|
||
('mercadopago', 'MercadoPago'),
|
||
('points', 'Puntos / saldo a favor'),
|
||
('mixed', 'Mixto'),
|
||
('none', 'Sin pago'),
|
||
], string='Método de pago', index=True)
|
||
|
||
# Pagos cobrados por esta venta (el campo está en skeen_ventas para no
|
||
# crear un ciclo de dependencias con skeen_pagos)
|
||
pago_ids = fields.One2many('skeen.pago', 'venta_id', string='Pagos')
|
||
|
||
# Fechas
|
||
date = fields.Date(string='Fecha', required=True, default=fields.Date.today)
|
||
create_date = fields.Datetime(string='Fecha Creación', readonly=True, default=fields.Datetime.now)
|
||
|
||
# Notas
|
||
notes = fields.Text(string='Notas')
|
||
|
||
@api.depends('line_ids.subtotal', '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
|
||
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.model_create_multi
|
||
def create(self, vals_list):
|
||
for vals in vals_list:
|
||
if not vals.get('name'):
|
||
vals['name'] = self.env['ir.sequence'].next_by_code('skeen.venta') or 'VENTA-001'
|
||
return super(SkeenVenta, self).create(vals_list)
|
||
|
||
def action_confirm(self):
|
||
self.write({'state': 'confirmed'})
|
||
|
||
def action_mark_paid(self):
|
||
for venta in self:
|
||
venta.write({
|
||
'state': 'paid',
|
||
'amount_paid': venta.total,
|
||
})
|
||
if venta.cita_id:
|
||
venta.cita_id.write({
|
||
'payment_state': 'paid',
|
||
'amount_paid': venta.total,
|
||
})
|
||
# Acumular puntos (proporcional a líneas que generan monedero)
|
||
monedero = self.env['skeen.monedero'].search([('partner_id', '=', venta.partner_id.id)], limit=1)
|
||
if not monedero:
|
||
monedero = self.env['skeen.monedero'].create({'partner_id': venta.partner_id.id})
|
||
subtotal_total = sum(l.subtotal for l in venta.line_ids)
|
||
elegible = sum(
|
||
l.subtotal for l in venta.line_ids
|
||
if (l.service_id and l.service_id.genera_monedero) or (l.item_id and l.item_id.genera_monedero)
|
||
)
|
||
points = int(venta.total * (elegible / subtotal_total) / 10) if subtotal_total else 0
|
||
if points > 0:
|
||
monedero.add_points(points, description=f'Venta {venta.name}', reference=venta.name)
|
||
|
||
def action_cancel(self):
|
||
self.write({'state': 'cancelled'})
|
||
|
||
def _reverse_wallet_for_cancel(self):
|
||
"""Revierte los puntos que ESTA venta otorgó y devuelve los que canjeó.
|
||
|
||
Se identifican por `reference == venta.name` (misma convención que usa
|
||
action_mark_paid). Los ajustes negativos hacen piso en 0 para no dejar
|
||
saldo negativo si el paciente ya gastó los puntos.
|
||
Devuelve (puntos_revertidos, puntos_restaurados).
|
||
"""
|
||
self.ensure_one()
|
||
monedero = self.env['skeen.monedero'].search([('partner_id', '=', self.partner_id.id)], limit=1)
|
||
if not monedero:
|
||
return 0, 0
|
||
txs = self.env['skeen.monedero.transaction'].search([
|
||
('monedero_id', '=', monedero.id),
|
||
('reference', '=', self.name),
|
||
])
|
||
revertidos = 0
|
||
restaurados = 0
|
||
for tx in txs:
|
||
if tx.type == 'accrual' and tx.points > 0:
|
||
applied = monedero.adjust_points(
|
||
-tx.points,
|
||
description=f'Cancelación venta {self.name}',
|
||
reference=self.name,
|
||
)
|
||
revertidos += -applied
|
||
elif tx.type == 'redemption' and tx.points < 0:
|
||
monedero.adjust_points(
|
||
-tx.points,
|
||
description=f'Reintegro de puntos por cancelación {self.name}',
|
||
reference=self.name,
|
||
)
|
||
restaurados += -tx.points
|
||
return revertidos, restaurados
|
||
|
||
def _restore_stock_for_cancel(self):
|
||
"""Devuelve al inventario lo que esta venta descontó.
|
||
|
||
Determinista: el cobro POS crea un `skeen.inventario.move` type='venta'
|
||
con reference = venta.name por cada línea de artículo. Se registra la
|
||
entrada inversa (type='cancelacion') con la misma referencia.
|
||
Devuelve la lista de movimientos creados.
|
||
"""
|
||
self.ensure_one()
|
||
Move = self.env['skeen.inventario.move']
|
||
salidas = Move.search([('reference', '=', self.name), ('type', '=', 'venta')])
|
||
creados = Move
|
||
for m in salidas:
|
||
creados |= Move.create({
|
||
'item_id': m.item_id.id,
|
||
'type': 'cancelacion',
|
||
'qty': m.qty,
|
||
'reference': self.name,
|
||
'notes': f'Cancelación de venta {self.name}',
|
||
})
|
||
return creados
|
||
|
||
def action_cancel_with_reason(self, reason='', cancelled_by='', restore_stock=True):
|
||
"""Cancela la venta (nota de crédito) con motivo obligatorio.
|
||
|
||
- Revierte los puntos otorgados y reintegra los canjeados por la venta.
|
||
- Devuelve al inventario los artículos descontados por la venta.
|
||
- Registra auditoría en el chatter y en los campos cancelled_*.
|
||
La venta NO se borra: queda en state='cancelled' y los reportes la excluyen.
|
||
"""
|
||
self.ensure_one()
|
||
if self.state == 'cancelled':
|
||
raise ValidationError(_('La venta ya está cancelada'))
|
||
reason = (reason or '').strip()
|
||
if not reason:
|
||
raise ValidationError(_('El motivo de cancelación es obligatorio'))
|
||
|
||
revertidos, restaurados = self._reverse_wallet_for_cancel()
|
||
movimientos = self._restore_stock_for_cancel() if restore_stock else self.env['skeen.inventario.move']
|
||
|
||
self.write({
|
||
'state': 'cancelled',
|
||
'cancel_reason': reason,
|
||
'cancelled_by': cancelled_by or '',
|
||
'cancelled_at': fields.Datetime.now(),
|
||
'cancel_points_reversed': revertidos,
|
||
'cancel_points_restored': restaurados,
|
||
})
|
||
|
||
# Auditoría en el chatter. En peticiones auth='none' (frontend) el uid
|
||
# de Odoo es nulo y message_post necesita un autor: se publica como el
|
||
# usuario administrador y el login real queda en el cuerpo y en
|
||
# cancelled_by.
|
||
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>Venta cancelada (nota de crédito)</b><br/>'
|
||
'Motivo: {}<br/>'
|
||
'Cancelada por: {}<br/>'
|
||
'Puntos revertidos: {} · Puntos reintegrados: {}<br/>'
|
||
'Artículos devueltos al inventario: {}'
|
||
).format(
|
||
reason,
|
||
cancelled_by or 'sistema',
|
||
revertidos,
|
||
restaurados,
|
||
len(movimientos),
|
||
))
|
||
|
||
return {
|
||
'points_reversed': revertidos,
|
||
'points_restored': restaurados,
|
||
'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.
|
||
|
||
- Reduce amount_paid en `amount` (o en todo lo pagado si amount es None).
|
||
- Revierte puntos del monedero en proporción ($10 = 1 pt), con piso en 0.
|
||
- Estado resultante: cancelled si no queda pagado; partial si queda parcial; paid si seguía cubierto.
|
||
"""
|
||
self.ensure_one()
|
||
if self.state not in ('confirmed', 'paid', 'partial'):
|
||
raise ValidationError(_('Solo se pueden devolver ventas confirmadas o pagadas'))
|
||
if self.amount_paid <= 0:
|
||
raise ValidationError(_('La venta no tiene monto pagado para devolver'))
|
||
|
||
refund_amount = float(amount or 0)
|
||
if refund_amount <= 0:
|
||
refund_amount = self.amount_paid # devolución total de lo pagado
|
||
refund_amount = min(refund_amount, self.amount_paid)
|
||
if refund_amount <= 0:
|
||
raise ValidationError(_('Monto de devolución inválido'))
|
||
|
||
new_amount_paid = round(self.amount_paid - refund_amount, 2)
|
||
|
||
# Revertir puntos acumulados en proporción a lo devuelto ($10 MXN = 1 punto)
|
||
points_to_reverse = int(refund_amount / 10)
|
||
if points_to_reverse > 0:
|
||
monedero = self.env['skeen.monedero'].search([('partner_id', '=', self.partner_id.id)], limit=1)
|
||
if monedero and monedero.points > 0:
|
||
monedero.adjust_points(
|
||
-points_to_reverse,
|
||
description=f'Devolución venta {self.name}',
|
||
reference=self.name,
|
||
)
|
||
|
||
# Estado resultante
|
||
if new_amount_paid <= 0:
|
||
new_state = 'cancelled'
|
||
elif new_amount_paid < self.total:
|
||
new_state = 'partial'
|
||
else:
|
||
new_state = 'paid'
|
||
|
||
self.write({
|
||
'amount_paid': new_amount_paid,
|
||
'state': new_state,
|
||
'refunded': True,
|
||
'refund_amount': self.refund_amount + refund_amount,
|
||
'refund_reason': reason or self.refund_reason or '',
|
||
'refunded_at': fields.Datetime.now(),
|
||
})
|
||
|
||
# Reflejar en la cita origen si existe
|
||
if self.cita_id:
|
||
if new_amount_paid <= 0:
|
||
cita_state = 'not_paid'
|
||
elif new_amount_paid < self.total:
|
||
cita_state = 'partial'
|
||
else:
|
||
cita_state = 'paid'
|
||
self.cita_id.write({'amount_paid': new_amount_paid, 'payment_state': cita_state})
|
||
|
||
return {
|
||
'refund_amount': refund_amount,
|
||
'points_reversed': points_to_reverse,
|
||
'new_state': new_state,
|
||
'new_amount_paid': new_amount_paid,
|
||
}
|
||
|
||
def action_apply_wallet(self, points):
|
||
"""Redime puntos del monedero como descuento en la venta"""
|
||
self.ensure_one()
|
||
monedero = self.env['skeen.monedero'].search([('partner_id', '=', self.partner_id.id)], limit=1)
|
||
if not monedero or monedero.points < points:
|
||
raise ValidationError(_('Puntos insuficientes'))
|
||
|
||
# Máximo 50% del total
|
||
max_discount = self.total * 0.5
|
||
point_value = min(points, int(max_discount))
|
||
|
||
monedero.redeem_points(point_value, description=f'Redención venta {self.name}', reference=self.name)
|
||
self.write({
|
||
'points_redeemed': point_value,
|
||
'points_discount': point_value * 1.0,
|
||
})
|
||
return True
|
||
|
||
|
||
class SkeenVentaLine(models.Model):
|
||
_name = 'skeen.venta.line'
|
||
_description = 'Línea de Venta SKEEN'
|
||
|
||
venta_id = fields.Many2one('skeen.venta', string='Venta', required=True, ondelete='cascade')
|
||
service_id = fields.Many2one('skeen.servicio', string='Servicio')
|
||
item_id = fields.Many2one('skeen.inventario.item', string='Artículo inventario')
|
||
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)
|
||
# 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).')
|
||
prescribed_by_id = fields.Many2one('hr.employee', string='Recetado por')
|
||
|
||
_sql_constraints = [
|
||
('service_o_item', 'CHECK(service_id IS NOT NULL OR item_id IS NOT NULL)',
|
||
'La línea debe tener un servicio o un artículo de inventario.')
|
||
]
|
||
|
||
@api.constrains('service_id', 'item_id')
|
||
def _check_service_o_item(self):
|
||
for line in self:
|
||
if bool(line.service_id) == bool(line.item_id):
|
||
raise ValidationError(_('La línea debe tener un servicio O un artículo, no ambos.'))
|
||
|
||
@api.constrains('prescribed_by_id')
|
||
def _check_prescribed_by_activo(self):
|
||
"""Las ventas no se le asignan a un médico dado de baja (ni a él ni a
|
||
sus comisiones). Solo corre al cambiar la asignación."""
|
||
for line in self:
|
||
if line.prescribed_by_id and not line.prescribed_by_id.active:
|
||
raise ValidationError(_(
|
||
'%(nombre)s está dado de baja y no se le pueden asignar '
|
||
'ventas.'
|
||
) % {'nombre': line.prescribed_by_id.name})
|
||
|
||
@api.depends('quantity', 'price_unit')
|
||
def _compute_subtotal(self):
|
||
for line in self:
|
||
line.subtotal = line.quantity * line.price_unit
|
||
|
||
@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
|
||
|
||
@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
|
||
|
||
|
||
class SkeenCorteCaja(models.Model):
|
||
_name = 'skeen.corte.caja'
|
||
_description = 'Corte de Caja SKEEN'
|
||
_order = 'date desc'
|
||
|
||
name = fields.Char(string='Referencia', readonly=True, copy=False)
|
||
date = fields.Date(string='Fecha', required=True, default=fields.Date.today, index=True)
|
||
user_id = fields.Many2one('res.users', string='Usuario', default=lambda self: self.env.uid)
|
||
|
||
# Apertura
|
||
opening_cash = fields.Float(string='Efectivo Apertura', default=0.0)
|
||
|
||
# Ventas del día
|
||
venta_ids = fields.One2many('skeen.venta', 'date', string='Ventas del Día',
|
||
domain=[('state', 'in', ('confirmed', 'paid', 'partial'))])
|
||
|
||
# Totales por método de pago
|
||
total_cash = fields.Float(string='Efectivo', compute='_compute_totals', store=True)
|
||
total_card = fields.Float(string='Tarjeta', compute='_compute_totals', store=True)
|
||
total_transfer = fields.Float(string='Transferencia', compute='_compute_totals', store=True)
|
||
total_other = fields.Float(string='Otros', compute='_compute_totals', store=True)
|
||
total_sales = fields.Float(string='Total Ventas', compute='_compute_totals', store=True)
|
||
|
||
# Cierre guiado: efectivo contado por el cajero y conteo informativo
|
||
# de los demás métodos.
|
||
counted_cash = fields.Float(string='Efectivo Contado', default=0.0)
|
||
counted_card = fields.Float(string='Tarjeta Contada', default=0.0)
|
||
counted_transfer = fields.Float(string='Transferencia Contada', default=0.0)
|
||
counted_other = fields.Float(string='Otros Contados', default=0.0)
|
||
closing_cash = fields.Float(string='Efectivo Cierre', default=0.0)
|
||
difference = fields.Float(string='Diferencia', compute='_compute_difference', store=True)
|
||
diff_cash = fields.Float(string='Diferencia Efectivo', compute='_compute_difference', store=True)
|
||
diff_card = fields.Float(string='Diferencia Tarjeta', compute='_compute_difference', store=True)
|
||
diff_transfer = fields.Float(string='Diferencia Transferencia', compute='_compute_difference', store=True)
|
||
diff_other = fields.Float(string='Diferencia Otros', compute='_compute_difference', store=True)
|
||
|
||
# Estado
|
||
state = fields.Selection([
|
||
('open', 'Abierto'),
|
||
('closed', 'Cerrado'),
|
||
], string='Estado', default='open')
|
||
|
||
notes = fields.Text(string='Notas')
|
||
|
||
@api.depends('date')
|
||
def _compute_totals(self):
|
||
for corte in self:
|
||
corte.update(corte._totals_from_payments())
|
||
|
||
def _totals_from_payments(self):
|
||
"""Sumatorias por método de pago para la fecha del corte."""
|
||
self.ensure_one()
|
||
pagos = self.env['skeen.pago'].search([
|
||
('payment_date', '>=', self.date.strftime('%Y-%m-%d 00:00:00')),
|
||
('payment_date', '<=', self.date.strftime('%Y-%m-%d 23:59:59')),
|
||
('state', '=', 'completed'),
|
||
])
|
||
cash = sum(p.amount for p in pagos if p.payment_method == 'cash')
|
||
card = sum(p.amount for p in pagos if p.payment_method in ('card', 'stripe', 'mercadopago'))
|
||
transfer = sum(p.amount for p in pagos if p.payment_method == 'transfer')
|
||
other = sum(p.amount for p in pagos if p.payment_method not in ('cash', 'card', 'stripe', 'mercadopago', 'transfer'))
|
||
return {
|
||
'total_cash': cash,
|
||
'total_card': card,
|
||
'total_transfer': transfer,
|
||
'total_other': other,
|
||
'total_sales': cash + card + transfer + other,
|
||
}
|
||
|
||
def refresh_totals(self):
|
||
"""Recalcula y persiste los totales del día (pagos pueden llegar después
|
||
de abrir la caja)."""
|
||
for corte in self:
|
||
corte.write(corte._totals_from_payments())
|
||
|
||
@api.depends('opening_cash', 'total_cash', 'total_card', 'total_transfer', 'total_other',
|
||
'closing_cash', 'counted_cash', 'counted_card', 'counted_transfer', 'counted_other')
|
||
def _compute_difference(self):
|
||
for corte in self:
|
||
expected = corte.opening_cash + corte.total_cash
|
||
corte.difference = corte.closing_cash - expected
|
||
corte.diff_cash = corte.counted_cash - expected
|
||
corte.diff_card = corte.counted_card - corte.total_card
|
||
corte.diff_transfer = corte.counted_transfer - corte.total_transfer
|
||
corte.diff_other = corte.counted_other - corte.total_other
|
||
|
||
@api.model_create_multi
|
||
def create(self, vals_list):
|
||
for vals in vals_list:
|
||
if not vals.get('name'):
|
||
vals['name'] = self.env['ir.sequence'].next_by_code('skeen.corte.caja') or 'CORTE-001'
|
||
return super(SkeenCorteCaja, self).create(vals_list)
|
||
|
||
def action_close(self):
|
||
self.write({'state': 'closed'})
|
||
|
||
def action_reopen(self):
|
||
self.write({'state': 'open'})
|