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:
@@ -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],
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user