# -*- coding: utf-8 -*- from odoo import models, fields, api, _ from odoo.exceptions import ValidationError from markupsafe import Markup 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) # 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( 'Venta cancelada (nota de crédito)
' 'Motivo: {}
' 'Cancelada por: {}
' 'Puntos revertidos: {} · Puntos reintegrados: {}
' '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), } 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 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'})