POS vista lista, modulo Tickets para gerencia con cancelacion auditada, y saldo a favor (recargas de anticipo desde expediente y agenda, canje en POS)

This commit is contained in:
2026-09-18 04:25:33 +00:00
parent cebf8d663d
commit d75411f0d9
19 changed files with 1278 additions and 30 deletions

View File

@@ -1718,6 +1718,152 @@ class SkeenFrontendController(http.Controller):
('amount_due', '>', 0),
], order='date asc, id asc')
@http.route('/skeen/frontend/v1/patients/<int:patient_id>/wallet', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def patient_wallet(self, patient_id, **kw):
"""Saldo a favor del paciente + sus movimientos narrados.
El saldo es el del monedero (1 pt = $1 MXN): incluye depósitos
(anticipos), puntos ganados por compra, canjes y ajustes.
"""
try:
partner = request.env['res.partner'].sudo().browse(patient_id)
if not partner.exists() or not partner.is_patient:
return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
monedero = self._monedero_de(partner, crear=False)
tc = request.env['skeen.tipo.cambio'].sudo().search([], order='date desc, id desc', limit=1)
rate = tc.rate if tc else None
saldo = monedero.points if monedero else 0
txs = monedero.transaction_ids.sorted('id', reverse=True)[:200] if monedero else []
refs = {t.reference for t in txs if t.reference and t.reference.startswith('VENTA-')}
totales = {}
if refs:
for v in request.env['skeen.venta'].sudo().search([('name', 'in', list(refs))]):
totales[v.name] = v.total
return json_response({
'status': 'success',
'patient_id': partner.id,
'patient': partner.name,
'wallet_id': monedero.id if monedero else None,
'points': saldo,
'equivalent_mxn': float(saldo),
'equivalent_usd': round(saldo / rate, 2) if rate else None,
'rate': rate,
'depositado': float(sum(t.points for t in (monedero.transaction_ids if monedero else []) if t.type == 'deposito')),
'movimientos': [{
'id': t.id,
'type': t.type,
'type_label': dict(
request.env['skeen.monedero.transaction']._fields['type'].selection).get(t.type, t.type),
'points': t.points,
'narration': self._tx_narration(t, totales),
'description': t.description or '',
'reference': t.reference or '',
'operator': t.operator or '',
'date': t.create_date.strftime('%Y-%m-%d %H:%M') if t.create_date else None,
} for t in txs],
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/patients/<int:patient_id>/deposit', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@forbid_roles('marketing')
def patient_deposit(self, patient_id, **kw):
"""Recarga de saldo a favor (anticipo): dinero a cuenta del paciente.
Registra el cobro real (skeen.pago) y el movimiento `deposito` en el
monedero, y devuelve el recibo imprimible. Permiso: recepción o más
(recepción cobra anticipos); el ajuste manual sigue siendo admin/gerencia.
"""
try:
partner = request.env['res.partner'].sudo().browse(patient_id)
if not partner.exists() or not partner.is_patient:
return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
data = _parse_json_body()
try:
amount = float(data.get('amount') or 0)
except (TypeError, ValueError):
return json_response({'status': 'error', 'message': 'Monto inválido'}, 400)
if amount <= 0:
return json_response({'status': 'error', 'message': 'El monto debe ser mayor a 0'}, 400)
method = data.get('payment_method') or ''
metodos = ('cash', 'card', 'transfer', 'stripe', 'mercadopago')
if method not in metodos:
return json_response({
'status': 'error',
'message': 'Método de pago inválido (efectivo, tarjeta, transferencia, stripe o mercadopago)',
}, 400)
usuario = getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else ''
nota = (data.get('notes') or '').strip()
cita_id = int(data.get('cita_id') or 0) or False
pago = request.env['skeen.pago'].sudo().create({
'partner_id': partner.id,
'cita_id': cita_id,
'amount': amount,
'payment_method': method,
'state': 'completed',
'payment_date': fields.Datetime.now(),
'provider_reference': nota or '',
})
monedero = self._monedero_de(partner, crear=True)
tx = monedero.add_deposit(
amount,
description=nota or 'Recarga de saldo a favor',
reference=pago.name,
operator=usuario,
)
self._auditar('monedero', f'paciente:{partner.id}', 'deposito', 0, amount,
entity_label=partner.name, notes=nota or 'Recarga de saldo a favor')
tc = request.env['skeen.tipo.cambio'].sudo().search([], order='date desc, id desc', limit=1)
rate = tc.rate if tc else None
return json_response({
'status': 'success',
'movimiento': {
'id': tx.id,
'type': tx.type,
'points': tx.points,
'narration': self._tx_narration(tx, {}),
'operator': tx.operator or '',
},
'wallet': {
'id': monedero.id,
'points': monedero.points,
'equivalent_mxn': monedero.equivalent_mxn,
'equivalent_usd': round(monedero.points / rate, 2) if rate else None,
},
'receipt': {
'tipo': 'deposito',
'folio': pago.name,
'date': fields.Datetime.now().strftime('%Y-%m-%d %H:%M'),
'patient_id': partner.id,
'patient': partner.name,
'patient_code': partner.patient_id or '',
'phone': partner.phone or '',
'amount': amount,
'payment_method': method,
'adeudo_anterior': 0.0,
'saldo_restante': 0.0,
'saldo_favor': monedero.points,
'aplicado': [],
'nota': nota,
'operator': usuario,
'wallet_points': monedero.points,
},
}, 201)
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
def _monedero_de(self, partner, crear=False):
"""Monedero del paciente (opcionalmente lo crea si no existe)."""
Monedero = request.env['skeen.monedero'].sudo()
monedero = Monedero.search([('partner_id', '=', partner.id)], limit=1)
if not monedero and crear:
monedero = Monedero.create({'partner_id': partner.id})
return monedero
@http.route('/skeen/frontend/v1/patients/<int:patient_id>/pay_adeudo', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@forbid_roles('marketing')
@@ -1764,12 +1910,17 @@ class SkeenFrontendController(http.Controller):
restante = round(amount, 2)
aplicado = []
primera_asignacion = True
for venta in ventas:
if restante <= 0:
break
por_aplicar = round(min(restante, venta.amount_due), 2)
if por_aplicar <= 0:
continue
if primera_asignacion and por_aplicar == round(amount, 2):
# El pago cubre una sola venta: se liga para poder verlo en Tickets
payment.write({'venta_id': venta.id})
primera_asignacion = False
nuevo_pagado = round(venta.amount_paid + por_aplicar, 2)
if nuevo_pagado >= venta.total - 0.005:
venta.write({'amount_paid': nuevo_pagado})
@@ -3159,6 +3310,12 @@ class SkeenFrontendController(http.Controller):
if tx.type == 'expiration':
return f'Vencieron {abs_pts} pts de la venta {ref}'
if tx.type == 'deposito':
extra = f' · {desc}' if desc and desc_low not in ('recarga de saldo a favor', 'depósito') else ''
return f'Recargó ${abs_pts:,.2f} de saldo a favor{extra}{operador}'
if tx.type == 'reversion':
base = desc or f'Reversión de {abs_pts} pts'
return f'{base}{operador}'
if tx.type == 'adjustment':
if desc_low.startswith('ajuste manual'):
return f'{desc}{operador}'
@@ -3185,7 +3342,7 @@ class SkeenFrontendController(http.Controller):
return json_response({'status': 'error', 'message': 'Monedero no encontrado'}, 404)
domain = [('monedero_id', '=', monedero.id)]
ttype = kw.get('type')
if ttype in ('accrual', 'redemption', 'adjustment', 'expiration'):
if ttype in ('deposito', 'accrual', 'redemption', 'adjustment', 'reversion', 'expiration'):
domain.append(('type', '=', ttype))
if kw.get('date_from'):
domain.append(('create_date', '>=', f"{kw['date_from']} 00:00:00"))
@@ -3362,6 +3519,10 @@ class SkeenFrontendController(http.Controller):
domain.append(('date', '<=', kw.get('date_to')))
if kw.get('state'):
domain.append(('state', '=', kw.get('state')))
if kw.get('payment_method'):
domain.append(('payment_method', '=', kw['payment_method']))
if kw.get('user'):
domain.append(('user_login', 'ilike', kw['user']))
if kw.get('search'):
s = kw.get('search')
domain += ['|', ('name', 'ilike', s), ('partner_id.name', 'ilike', s)]
@@ -3371,9 +3532,12 @@ class SkeenFrontendController(http.Controller):
total = Ventas.search_count(domain)
offset = (page - 1) * page_size
sales = Ventas.search(domain, order='create_date desc', limit=page_size, offset=offset)
pagos = {}
for p in request.env['skeen.pago'].sudo().search([('venta_id', 'in', sales.ids)]):
pagos.setdefault(p.venta_id.id, []).append(self._payment_to_dict(p))
return json_response({
'status': 'success',
'sales': [self._sale_to_dict(v) for v in sales],
'sales': [{**self._sale_to_dict(v), 'pagos': pagos.get(v.id, [])} for v in sales],
'total': total,
'page': page,
'page_size': page_size,
@@ -3437,6 +3601,7 @@ class SkeenFrontendController(http.Controller):
# Crear pago asociado
payment = request.env['skeen.pago'].sudo().create({
'partner_id': sale.partner_id.id,
'venta_id': sale.id,
'cita_id': sale.cita_id.id if sale.cita_id else False,
'amount': amount,
'payment_method': method,
@@ -3445,6 +3610,10 @@ class SkeenFrontendController(http.Controller):
})
# 'card' = terminal física; stripe/mercadopago no cuentan como tarjeta aquí
vals = {'amount_paid': sale.amount_paid + amount}
if not sale.payment_method:
vals['payment_method'] = method
elif sale.payment_method != method:
vals['payment_method'] = 'mixed'
if method == 'card':
vals['card_amount'] = sale.card_amount + amount
sale.write(vals)
@@ -3600,6 +3769,8 @@ class SkeenFrontendController(http.Controller):
'cancelled_at': v.cancelled_at.strftime('%Y-%m-%d %H:%M') if v.cancelled_at else None,
'cancel_points_reversed': v.cancel_points_reversed,
'cancel_points_restored': v.cancel_points_restored,
'payment_method': v.payment_method or '',
'user_login': v.user_login or '',
'lines': [{
'id': line.id,
'service_id': line.service_id.id if line.service_id else None,
@@ -3707,10 +3878,12 @@ class SkeenFrontendController(http.Controller):
if method not in ('cash', 'card', 'transfer', 'stripe', 'mercadopago'):
return json_response({'status': 'error', 'message': 'Método de pago inválido'}, 400)
cobrador = getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else ''
venta = request.env['skeen.venta'].sudo().create({
'partner_id': partner.id,
'discount': discount,
'line_ids': line_vals,
'user_login': cobrador,
})
venta.action_confirm()
@@ -3738,6 +3911,14 @@ class SkeenFrontendController(http.Controller):
# Pago monetario del restante
restante = round(venta.amount_due, 2)
# Método del ticket: puntos si el saldo cubrió todo, mixto si fue
# parte en saldo y parte en dinero
if puntos_usados and restante <= 0:
venta.write({'payment_method': 'points'})
elif puntos_usados and restante > 0:
venta.write({'payment_method': 'mixed'})
else:
venta.write({'payment_method': method})
pago = None
cambio = 0.0
cambio_usd = 0.0
@@ -3756,6 +3937,7 @@ class SkeenFrontendController(http.Controller):
cambio_usd = round(cambio / rate_used, 2)
pago = request.env['skeen.pago'].sudo().create({
'partner_id': partner.id,
'venta_id': venta.id,
'amount': pay_amount,
'payment_method': method,
'state': 'completed',
@@ -4804,6 +4986,10 @@ class SkeenFrontendController(http.Controller):
['service_id'], orderby='subtotal desc', limit=limit,
)
for g in grouped:
# Las ventas de solo artículos no tienen servicio: read_group
# agrupa esas líneas bajo la clave False y no son "top servicios".
if not g.get('service_id'):
continue
rows.append({
'service_id': g['service_id'][0],
'service': g['service_id'][1],

View File

@@ -60,6 +60,7 @@ DEFAULT_MENU_ROLES = {
'/servicios': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'],
'/productos': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/ventas': ['admin', 'recepcion', 'gerencia'],
'/tickets': ['admin', 'gerencia'],
'/pagos': ['admin', 'recepcion', 'gerencia'],
'/monedero': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/inventario': ['admin', 'recepcion', 'lectura', 'gerencia'],