# -*- coding: utf-8 -*- from odoo import models, fields, api, _ from odoo.exceptions import ValidationError from markupsafe import Markup from odoo.addons.skeen_citas.clinic_settings import get_impuestos, get_lealtad, get_propinas from odoo.addons.skeen_monedero.lealtad import ( get_lealtad as _get_lealtad, nivel_por_codigo, puntos_venta, regla_multiplicador) 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 impuestos_activos(env): """True si la instancia tiene el cálculo de impuestos encendido.""" return bool(get_impuestos(env).get('activo')) def tasa_efectiva(record, env=None): """Tasa (%) que corresponde a un servicio/artículo al momento de vender. - Con 'usar tasa por defecto' (o sin registro) se toma la tasa por defecto de la configuración vigente: NO se congela al crear el catálogo. - Con una tasa propia se respeta ese valor. Devuelve (tasa, usa_default). """ env = env or record.env cfg = get_impuestos(env) if record is None or not getattr(record, 'exists', lambda: False)(): return float(cfg.get('tasa_default') or 0.0), True if getattr(record, 'tax_use_default', False): return float(cfg.get('tasa_default') or 0.0), True return float(record.tax_rate or 0.0), False 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) # ID en plataforma legacy (migración) legacy_id = fields.Integer(string='ID Legacy', index=True, copy=False, help='ID en plataforma legacy (migración)') # 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, help='Con los impuestos de la clínica ACTIVOS, este campo refleja la ' 'suma de los impuestos de las líneas (se recalcula al vender). ' 'Con los impuestos apagados se conserva el monto capturado a mano.') 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) # Desglose consultable para reportes (base sin impuesto + impuesto de las # líneas). Con los impuestos apagados quedan en 0/igual al subtotal, así # que no alteran ninguna cifra existente. tax_total = fields.Float(string='Impuesto (líneas)', compute='_compute_tax_breakdown', store=True) subtotal_sin_impuesto = fields.Float(string='Base sin impuesto', compute='_compute_tax_breakdown', 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) # Lealtad: descuento automático por el nivel VIP del cliente (0 si no hay # niveles o la lealtad está en modo clásico). nivel_codigo = fields.Char(string='Nivel del cliente', copy=False) nivel_discount = fields.Float(string='Descuento por nivel', default=0.0) # Propinas (opcionales; 0 = comportamiento de siempre). La propina NO se # mezcla con el importe de los servicios: va en su propio campo y se suma # al total cobrado al cliente. `propina_employee_id` es a quién corresponde # cuando el reparto está en 'ninguno'. propina = fields.Float(string='Propina', default=0.0) propina_moneda = fields.Char(string='Moneda de la propina', default='MXN') propina_employee_id = fields.Many2one('hr.employee', string='Propina para') reparto_ids = fields.One2many('skeen.propina.reparto', 'venta_id', string='Reparto de propina') # 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'), ('saldo_favor', 'Saldo a favor'), ('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', 'line_ids.tax_amount', 'discount', 'tax', 'amount_paid', 'points_discount', 'nivel_discount', 'propina') def _compute_totals(self): for venta in self: subtotal = sum(line.subtotal for line in venta.line_ids) venta.subtotal = subtotal # La fórmula de siempre: subtotal - descuento - puntos + impuesto. # Con impuestos apagados `tax` conserva el valor manual, así que el # total sale idéntico al de antes. `nivel_discount` y `propina` son # 0 por defecto: no alteran ninguna cifra existente. venta.total = max( 0.0, subtotal - venta.discount - venta.nivel_discount - venta.points_discount + venta.tax + (venta.propina or 0.0)) venta.amount_due = max(0.0, venta.total - venta.amount_paid) @api.depends('line_ids.subtotal', 'line_ids.tax_amount') def _compute_tax_breakdown(self): for venta in self: venta.subtotal_sin_impuesto = sum(line.subtotal for line in venta.line_ids) venta.tax_total = sum(line.tax_amount for line in venta.line_ids) def _sync_tax_from_lines(self): """Impuestos ACTIVOS: `tax` = suma de los impuestos de las líneas. Con los impuestos apagados NO se toca: el monto capturado a mano (compatibilidad con clientes que lo mandaban por API) se conserva igual. """ if not impuestos_activos(self.env): return for venta in self: impuesto = sum((line.tax_amount or 0.0) for line in venta.line_ids) if abs((venta.tax or 0.0) - impuesto) > 0.000001: venta.write({'tax': impuesto}) @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 según la configuración de lealtad vigente # (clásico: 1 pt por cada $10; configurable: bloque `lealtad`). 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}) points = venta._wallet_points_expected() if points > 0: monedero.add_points(points, description=f'Venta {venta.name}', reference=venta.name) # El nivel VIP del cliente se recalcula al cerrar la venta. venta.partner_id._lealtad_recalcular() 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), } # ============================================================ # 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. En modo clásico es la misma fórmula de action_mark_paid (el total se reparte proporcionalmente entre las líneas que generan monedero, $10 = 1 pt). En modo configurable la calcula el motor de lealtad. """ self.ensure_one() return puntos_venta(self.env, self)[0] def contexto_lealtad(self, line): """Contexto de una línea para resolver multiplicadores y nivel.""" self.ensure_one() fecha = self.date return { 'servicio_id': line.service_id.id or None, 'articulo_id': line.item_id.id or None, 'categoria': line.service_id.category if line.service_id else '', 'sucursal': self.cita_id.branch if self.cita_id else '', 'nivel_codigo': self.partner_id.lealtad_nivel_codigo or '', 'fecha': fecha, 'dia_semana': fecha.weekday() if fecha else None, } def _aplicar_descuento_nivel(self, partner=None): """Aplica el descuento (%) del nivel VIP del cliente. Solo con lealtad configurable y niveles; en clásico no hay descuento (queda en 0).""" self.ensure_one() cfg = _get_lealtad(self.env) partner = partner or self.partner_id if not cfg.get('activo') or cfg.get('modo') != 'configurable': return 0.0 nivel = nivel_por_codigo(cfg, partner.lealtad_nivel_codigo) pct = float((nivel or {}).get('descuento_pct') or 0.0) if pct <= 0: self.write({'nivel_codigo': (nivel or {}).get('codigo') or False}) return 0.0 descuento = round((self.subtotal - self.discount) * pct / 100.0, 2) descuento = max(0.0, descuento) self.write({'nivel_discount': descuento, 'nivel_codigo': (nivel or {}).get('codigo') or False}) return descuento def _generar_reparto_propina(self, cfg=None, cobrador_employee=None, cobrador_login=''): """Reparte la propina de la venta entre el personal implicado. Con `reparto='ninguno'` no se crea ningún registro: la propina queda en la venta (y en `propina_employee_id` si llega). Devuelve los registros creados (vacío si no aplica). """ self.ensure_one() cfg = cfg or get_propinas(self.env) Reparto = self.env['skeen.propina.reparto'] if not cfg.get('activo') or (self.propina or 0.0) <= 0: return Reparto if cfg.get('reparto') not in ('equitativo', 'por_participacion'): return Reparto self.reparto_ids.unlink() atendio = self.cita_id.doctor_id if self.cita_id else self.env['hr.employee'] participantes = [] if atendio: participantes.append((atendio, 'atendio')) if cobrador_employee and cobrador_employee != atendio: participantes.append((cobrador_employee, 'cobro')) if not participantes: participantes = [(None, 'cobro')] montos = {} if cfg.get('reparto') == 'por_participacion' and self.line_ids: pesos = {} for emp, rol in participantes: if not emp: continue pesos[emp.id] = pesos.get(emp.id, 0.0) total_lineas = 0.0 for line in self.line_ids: total_lineas += line.subtotal if line.prescribed_by_id and line.prescribed_by_id.id in pesos: pesos[line.prescribed_by_id.id] += line.subtotal elif atendio and atendio.id in pesos and not line.prescribed_by_id: pesos[atendio.id] += line.subtotal suma = sum(pesos.values()) if suma > 0: for emp, rol in participantes: if emp: montos[emp.id] = round(self.propina * pesos[emp.id] / suma, 2) if not montos: # equitativo (o respaldo de por_participacion sin pesos) cupo = round(self.propina / len(participantes), 2) for emp, rol in participantes: montos[emp.id if emp else 0] = cupo creados = Reparto repartido = 0.0 for emp, rol in participantes: monto = montos.get(emp.id if emp else 0, 0.0) if monto <= 0: continue creados |= Reparto.create({ 'venta_id': self.id, 'partner_id': self.partner_id.id, 'employee_id': emp.id if emp else False, 'rol': rol, 'monto': monto, 'moneda': self.propina_moneda or 'MXN', 'cuenta': cfg.get('cuenta_propinas') or '', 'user_login': cobrador_login or self.user_login or '', 'date': self.date, }) repartido += monto # Ajuste de centavos: el primero absorbe la diferencia. if creados and abs(repartido - self.propina) >= 0.005: creados[0].monto = round(creados[0].monto + (self.propina - repartido), 2) return creados 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) # Con impuestos ACTIVOS el impuesto se deriva de las líneas: no se # acepta un monto manual (el renglón de cada concepto manda). if tax is not None and not impuestos_activos(self.env): 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( 'Ticket corregido
' 'Motivo: {}
' 'Corrigió: {}
' 'Total anterior: {} → nuevo total: {}
' 'Puntos del monedero ajustados: {}
' 'Movimientos de inventario compensatorios: {}
' '{}' ).format( reason, editor or 'sistema', _fmt_mxn(prev_total), _fmt_mxn(self.total), puntos, len(movimientos), Markup('
').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. Clásico: # $10 MXN = 1 punto. Configurable: según `puntos_por_unidad`. cfg_lea = _get_lealtad(self.env) if cfg_lea.get('modo') == 'configurable': points_to_reverse = int(refund_amount * float(cfg_lea.get('puntos_por_unidad') or 0.0)) else: 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. El tope de canje (`canje_max_pct`) y el valor del punto (`valor_punto`) salen de la configuración de lealtad; en modo clásico equivalen al tope de 50% y 1 punto = $1 de siempre. """ 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')) cfg = _get_lealtad(self.env) valor = float(cfg.get('valor_punto') or 1.0) or 1.0 max_discount = self.total * float(cfg.get('canje_max_pct') or 0.0) / 100.0 point_value = min(points, int(max_discount / valor)) 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 * valor, }) 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_amounts', store=True) # Impuesto propio de la línea: tasa (%) e importe. Se resuelve al vender # (tasa del catálogo o la por defecto de la configuración); con los # impuestos apagados la tasa queda en 0 y el importe en 0. tax_rate = fields.Float(string='Impuesto (%)', default=0.0) tax_amount = fields.Float(string='Impuesto', compute='_compute_amounts', 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') # Clasificación para el programa de lealtad (`aplicar_a` y multiplicadores). tipo_monedero = fields.Selection([ ('servicios', 'Servicios'), ('productos', 'Productos'), ('membresias', 'Membresías'), ('paquetes', 'Paquetes'), ], string='Tipo (lealtad)', compute='_compute_lealtad_linea') genera_monedero = fields.Boolean( string='Genera monedero', compute='_compute_lealtad_linea', help='Igual que el indicador del catálogo: si el servicio/artículo acumula puntos.') @api.depends('service_id', 'item_id', 'paquete_id', 'service_id.genera_monedero', 'item_id.genera_monedero') def _compute_lealtad_linea(self): planes = None if 'skeen.membresia.plan' in self.env: planes = set(self.env['skeen.membresia.plan'].sudo().search( [('servicio_id', '!=', False)]).mapped('servicio_id').ids) for line in self: if line.item_id: line.tipo_monedero = 'productos' elif line.paquete_id: line.tipo_monedero = 'paquetes' elif line.service_id and planes is not None and line.service_id.id in planes: line.tipo_monedero = 'membresias' else: line.tipo_monedero = 'servicios' line.genera_monedero = bool( (line.service_id and line.service_id.genera_monedero) or (line.item_id and line.item_id.genera_monedero)) _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', 'tax_rate') def _compute_amounts(self): """Subtotal e impuesto de la línea. Con los impuestos apagados (o tasa 0) el subtotal es cantidad × precio y el impuesto 0: idéntico a como se calculaba antes. Con 'precios_incluyen_impuesto' el precio capturado ya trae impuesto, así que el subtotal se desglosa hacia atrás. """ cfg = get_impuestos(self.env) activo = bool(cfg.get('activo')) incluye = bool(cfg.get('precios_incluyen_impuesto')) for line in self: bruto = (line.quantity or 0.0) * (line.price_unit or 0.0) tasa = (line.tax_rate or 0.0) if activo else 0.0 if tasa <= 0: line.subtotal = bruto line.tax_amount = 0.0 elif incluye: base = bruto / (1.0 + tasa / 100.0) line.subtotal = base line.tax_amount = bruto - base else: line.subtotal = bruto line.tax_amount = bruto * tasa / 100.0 def _tasa_para_linea(self, vals): """Tasa (%) a aplicar a una línea nueva cuando el cliente no la manda.""" cfg = get_impuestos(self.env) if not cfg.get('activo'): return 0.0 if vals.get('service_id'): servicio = self.env['skeen.servicio'].browse(vals['service_id']) if servicio.exists() and not servicio.tax_use_default: return float(servicio.tax_rate or 0.0) if vals.get('item_id'): item = self.env['skeen.inventario.item'].browse(vals['item_id']) if item.exists() and not item.tax_use_default: return float(item.tax_rate or 0.0) return float(cfg.get('tasa_default') or 0.0) @api.model_create_multi def create(self, vals_list): for vals in vals_list: if vals.get('tax_rate') is None: vals['tax_rate'] = self._tasa_para_linea(vals) lines = super(SkeenVentaLine, self).create(vals_list) lines.mapped('venta_id')._sync_tax_from_lines() return lines def write(self, vals): res = super(SkeenVentaLine, self).write(vals) self.mapped('venta_id')._sync_tax_from_lines() return res def unlink(self): ventas = self.mapped('venta_id') res = super(SkeenVentaLine, self).unlink() ventas._sync_tax_from_lines() return res @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 if impuestos_activos(self.env): self.tax_rate = self._tasa_para_linea({'service_id': self.service_id.id}) @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 if impuestos_activos(self.env): self.tax_rate = self._tasa_para_linea({'item_id': self.item_id.id}) class SkeenPropinaReparto(models.Model): """Detalle del reparto de una propina entre el personal. Una fila por persona con lo que le toca de la propina de una venta. Se usa en el reporte de propinas por empleado y, si la configuración lo pide, se suma a su liquidación de comisiones. Con `activo=False` (default) no se genera ninguna fila. """ _name = 'skeen.propina.reparto' _description = 'Reparto de Propina' _order = 'date desc, id desc' venta_id = fields.Many2one('skeen.venta', string='Venta', required=True, ondelete='cascade', index=True) venta_name = fields.Char(related='venta_id.name', string='Folio', readonly=True) partner_id = fields.Many2one('res.partner', string='Paciente', index=True) employee_id = fields.Many2one('hr.employee', string='Empleado', index=True) rol = fields.Selection([ ('atendio', 'Atendió la cita'), ('cobro', 'Cobró'), ('otro', 'Otro'), ], string='Rol', default='otro') monto = fields.Float(string='Monto', default=0.0) moneda = fields.Char(string='Moneda', default='MXN') cuenta = fields.Char(string='Cuenta destino') user_login = fields.Char(string='Registrado por') date = fields.Date(string='Fecha', default=fields.Date.today, index=True) notes = fields.Char(string='Notas') 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) # Desglose de propinas del día (0 cuando el módulo está apagado). No altera # ninguna cifra existente: es un renglón aparte. total_propinas = fields.Float(string='Propinas', compute='_compute_totals', store=True) total_servicios = fields.Float(string='Servicios (sin propina)', 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')) ventas = self.env['skeen.venta'].sudo().search([ ('date', '=', self.date), ('state', '!=', 'cancelled'), ]) propinas = sum(v.propina or 0.0 for v in ventas) return { 'total_cash': cash, 'total_card': card, 'total_transfer': transfer, 'total_other': other, 'total_sales': cash + card + transfer + other, 'total_propinas': propinas, 'total_servicios': (cash + card + transfer + other) - propinas, } 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'})