Files
SKEEN-Proyecto/odoo-addons/skeen_ventas/models/venta.py
Consultoría Alcaraz Salazar 4c3eb0b11d Médicos: ventas 30d por médico (count, total MXN/USD, pagado tarjeta)
- card_amount en ventas (pagos con terminal física)
- Stats por médico vía líneas recetadas, métrica consistente con comisiones
- 4 columnas nuevas en la página de Médicos
2026-08-14 09:02:22 +00:00

318 lines
13 KiB
Python

# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
from odoo.exceptions import ValidationError
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')
# 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 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.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
closing_cash = fields.Float(string='Efectivo Cierre', default=0.0)
difference = fields.Float(string='Diferencia', 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:
ventas = self.env['skeen.venta'].search([
('date', '=', corte.date),
('state', 'in', ('confirmed', 'paid', 'partial')),
])
# Sumar también pagos por método
pagos = self.env['skeen.pago'].search([
('payment_date', '>=', corte.date.strftime('%Y-%m-%d 00:00:00')),
('payment_date', '<=', corte.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'))
corte.total_cash = cash
corte.total_card = card
corte.total_transfer = transfer
corte.total_other = other
corte.total_sales = cash + card + transfer + other
@api.depends('opening_cash', 'total_cash', 'closing_cash')
def _compute_difference(self):
for corte in self:
expected = corte.opening_cash + corte.total_cash
corte.difference = corte.closing_cash - expected
@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'})