Oleada UX 3: POS (repetir ultima venta, aviso de paquetes, atajos, caja desde POS), Ventas (cancelacion auditada con reversion de monedero y stock, detalle inline, presets de fecha), Cortes (historial comparativo y export)

This commit is contained in:
2026-09-17 19:26:02 +00:00
parent b4f91527f1
commit ac5896b1fc
10 changed files with 1328 additions and 57 deletions

View File

@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
from odoo import http
from odoo.exceptions import ValidationError
from odoo.http import request, Response
import base64
import json
@@ -64,6 +65,21 @@ class SkeenFrontendController(http.Controller):
emp = Emp.search([('name', '=ilike', user.name)], limit=1)
return emp
def _frontend_res_user(self):
"""res.users equivalente al usuario del frontend que hace la petición.
Los controladores con auth='none' crean registros sin uid de Odoo; para
que el usuario quede atribuido (p. ej. `user_id` del corte de caja) se
busca el res.users con el mismo login y, si no existe, se usa el admin.
"""
login = getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else ''
User = request.env['res.users'].sudo()
if login:
u = User.search([('login', '=', login)], limit=1)
if u:
return u
return request.env.ref('base.user_admin', raise_if_not_found=False) or User.browse()
def _frontend_user_dict(self, user):
"""to_public_dict + datos del empleado vinculado (rol medico)."""
d = user.to_public_dict()
@@ -409,7 +425,12 @@ class SkeenFrontendController(http.Controller):
"""Crear una o más citas (soporta paquetes de sesiones)"""
try:
data = _parse_json_body()
servicio = request.env['skeen.servicio'].sudo().browse(int(data.get('servicio_id', 0)))
# Acepta ambos estilos de clave (el frontend usa patient_id/service_id)
partner_id = data.get('partner_id', data.get('patient_id'))
servicio_id = data.get('servicio_id', data.get('service_id'))
if not partner_id or not servicio_id:
return json_response({'status': 'error', 'message': 'Paciente y servicio son obligatorios'}, 400)
servicio = request.env['skeen.servicio'].sudo().browse(int(servicio_id))
sessions = int(data.get('sessions', 1) or 1)
if sessions < 1:
sessions = 1
@@ -417,8 +438,8 @@ class SkeenFrontendController(http.Controller):
# Si el servicio tiene paquete y no se especificó sessions, default al paquete
sessions = sessions if data.get('sessions') else servicio.package_sessions
base_vals = {
'partner_id': data.get('partner_id'),
'servicio_id': data.get('servicio_id'),
'partner_id': partner_id,
'servicio_id': servicio_id,
'doctor_id': data.get('doctor_id'),
'branch': data.get('branch', 'rosarito'),
'medium': data.get('medium', 'onsite'),
@@ -446,6 +467,19 @@ class SkeenFrontendController(http.Controller):
'appointments': [self._appointment_to_dict(c) for c in created],
'sessions': sessions,
}, 201)
except ValidationError as e:
# Conflicto de agenda (solape o bloqueo): responder 409 con la cita
# en conflicto cuando se pueda identificar.
request.env.cr.rollback()
resp = {'status': 'error', 'message': str(e)}
resp.update(self._agenda_conflict_payload({
'date': data.get('date'),
'time': data.get('time'),
'doctor_id': data.get('doctor_id'),
'servicio_id': servicio_id,
'state': data.get('state', 'pending'),
}))
return json_response(resp, 409)
except Exception as e:
# Rollback explícito: evita persistir citas cuyas validaciones fallaron
request.env.cr.rollback()
@@ -534,6 +568,17 @@ class SkeenFrontendController(http.Controller):
'patient': self._patient_to_dict(patient),
'patient_created': patient_created,
}, 201)
except ValidationError as e:
request.env.cr.rollback()
resp = {'status': 'error', 'message': str(e)}
resp.update(self._agenda_conflict_payload({
'date': data.get('date'),
'time': data.get('time'),
'doctor_id': data.get('doctor_id'),
'servicio_id': data.get('servicio_id'),
'state': data.get('state', 'pending'),
}))
return json_response(resp, 409)
except Exception as e:
# Rollback explícito: evita persistir paciente/cita con validación rechazada
request.env.cr.rollback()
@@ -563,7 +608,21 @@ class SkeenFrontendController(http.Controller):
if 'time' in vals and not self._valid_agenda_time(vals['time']):
return json_response(
{'status': 'error', 'message': 'La hora debe estar dentro del horario de agenda (8:00 a 19:00)'}, 400)
cita.write(vals)
try:
cita.write(vals)
except ValidationError as e:
# Conflicto de agenda: valores efectivos = cita actual + cambios
request.env.cr.rollback()
effective = {
'date': vals.get('date') or (cita.date.strftime('%Y-%m-%d') if cita.date else None),
'time': vals.get('time', cita.time),
'doctor_id': vals.get('doctor_id', cita.doctor_id.id if cita.doctor_id else False),
'servicio_id': vals.get('servicio_id', cita.servicio_id.id),
'state': vals.get('state', cita.state),
}
resp = {'status': 'error', 'message': str(e)}
resp.update(self._agenda_conflict_payload(effective, exclude_id=cita.id))
return json_response(resp, 409)
return json_response({'status': 'success', 'appointment': self._appointment_to_dict(cita)})
except Exception as e:
# Rollback explícito: evita persistir writes parciales (validación rechazada)
@@ -660,6 +719,88 @@ class SkeenFrontendController(http.Controller):
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/appointments/bulk_phone_confirm', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
def bulk_phone_confirm(self, **kw):
"""Confirmación telefónica en lote.
Body: {"ids": [..], "confirmed": true|false}. Valida que todas las
citas existan y no estén canceladas / no_show / done; confirma el resto
y devuelve conteos. Permiso: recepción+ (igual que phone_confirm).
"""
try:
data = _parse_json_body()
raw_ids = data.get('ids') or []
confirmed = bool(data.get('confirmed', True))
if not isinstance(raw_ids, list) or not raw_ids:
return json_response({'status': 'error', 'message': 'Falta la lista de ids'}, 400)
try:
ids = [int(i) for i in raw_ids]
except (TypeError, ValueError):
return json_response({'status': 'error', 'message': 'Ids inválidos'}, 400)
Citas = request.env['skeen.cita'].sudo()
citas = Citas.browse(ids).exists()
found = set(citas.ids)
not_found = [i for i in dict.fromkeys(ids) if i not in found]
elegibles = citas.filtered(lambda c: c.state not in ('cancelled', 'no_show', 'done'))
skipped_invalid_state = [c.id for c in citas if c.state in ('cancelled', 'no_show', 'done')]
if confirmed:
elegibles.write({
'phone_confirmed': True,
'phone_confirmed_at': fields.Datetime.now(),
'phone_confirmed_by': request.frontend_user.login,
})
else:
elegibles.write({'phone_confirmed': False, 'phone_confirmed_at': False, 'phone_confirmed_by': False})
return json_response({
'status': 'success',
'confirmed_count': len(elegibles),
'skipped_invalid_state': skipped_invalid_state,
'not_found': not_found,
})
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
def _agenda_conflict_payload(self, vals, exclude_id=None):
"""Detecta el origen del conflicto de agenda para los valores intentados.
Devuelve (payload_extra, http_status): {'conflicting_appointment': ...}
cuando hay solapamiento con otra cita, o {} si no se pudo identificar.
Replica la lógica de _check_availability de skeen.cita.
"""
try:
date_str = vals.get('date')
time_f = float(vals.get('time'))
state = vals.get('state') or 'pending'
if not date_str or state in ('cancelled', 'no_show'):
return {}
servicio = request.env['skeen.servicio'].sudo().browse(int(vals.get('servicio_id') or 0))
dur = (servicio.duration_min if servicio.exists() and servicio.duration_min else 30) or 30
hh, mm = int(time_f), int(round((float(time_f) % 1) * 60))
start_dt = datetime.strptime(f'{date_str} {hh:02d}:{mm:02d}', '%Y-%m-%d %H:%M')
end_dt = start_dt + timedelta(minutes=dur)
doctor_id = int(vals.get('doctor_id') or 0) or False
domain = [
('id', '!=', exclude_id),
('date', '=', date_str),
('state', 'not in', ('cancelled', 'no_show')),
('doctor_id', '=', doctor_id),
('datetime_start', '<', end_dt),
('datetime_end', '>', start_dt),
]
other = request.env['skeen.cita'].sudo().search(domain, order='time asc', limit=1)
if not other:
return {}
return {'conflicting_appointment': {
'id': other.id,
'time': other.time_str,
'patient': other.partner_id.name,
'doctor': other.doctor_id.name if other.doctor_id else 'Sin médico',
}}
except Exception:
return {}
@http.route('/skeen/frontend/v1/appointments/slots', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
def available_slots(self, **kw):
try:
@@ -1267,6 +1408,8 @@ class SkeenFrontendController(http.Controller):
citas = request.env['skeen.cita'].sudo().search([('partner_id', '=', patient_id)], order='date desc')
ventas = request.env['skeen.venta'].sudo().search([('partner_id', '=', patient_id)], order='date desc')
visitas = request.env['skeen.visita'].sudo().search([('partner_id', '=', patient_id)], order='date_start desc')
pagos = request.env['skeen.pago'].sudo().search(
[('partner_id', '=', patient_id)], order='payment_date desc, id desc', limit=200)
# Estado de cuenta: monedero, gasto total, adeudo y visitas
monedero = request.env['skeen.monedero'].sudo().search([('partner_id', '=', patient_id)], limit=1)
adeudo_rows = request.env['skeen.venta'].sudo().read_group(
@@ -1283,12 +1426,106 @@ class SkeenFrontendController(http.Controller):
'appointments': [self._appointment_to_dict(c) for c in citas],
'sales': [self._sale_to_dict(v) for v in ventas],
'visitas': [self._visita_to_dict(v) for v in visitas],
'pagos': [self._payment_to_dict(p) for p in pagos],
'estado_cuenta': estado_cuenta,
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/expedientes-recientes', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@http.route('/skeen/frontend/v1/patients/check_phone', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('lectura')
def patients_check_phone(self, **kw):
"""¿Ya existe un paciente activo con este teléfono (phone o mobile)?
Body: {"phone": "...", "exclude_id": 123 opcional}. Comparación sobre
dígitos normalizados (los últimos N dígitos coinciden), misma convención
que la cita rápida: tolera '+52 ', espacios y guiones.
"""
try:
data = _parse_json_body()
phone = (data.get('phone') or '').strip()
try:
exclude_id = int(data.get('exclude_id') or 0)
except (TypeError, ValueError):
exclude_id = 0
digits = re.sub(r'\D', '', phone)
if len(digits) < 7:
return json_response({'status': 'success', 'exists': False, 'patient': None})
candidates = request.env['res.partner'].sudo().search_read(
[('is_patient', '=', True), '|', ('phone', '!=', False), ('mobile', '!=', False)],
['phone', 'mobile'], limit=10000)
match_id = None
for c in candidates:
if exclude_id and c['id'] == exclude_id:
continue
for f in ('phone', 'mobile'):
c_digits = re.sub(r'\D', '', c.get(f) or '')
n = min(len(c_digits), len(digits))
if n >= 7 and c_digits[-n:] == digits[-n:]:
match_id = c['id']
break
if match_id:
break
if not match_id:
return json_response({'status': 'success', 'exists': False, 'patient': None})
patient = request.env['res.partner'].sudo().browse(match_id)
return json_response({
'status': 'success',
'exists': True,
'patient': {
'id': patient.id,
'name': patient.name,
'patient_id': patient.patient_id or '',
'phone': patient.phone or '',
'mobile': patient.mobile or '',
},
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/patients/<int:patient_id>/packages', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def patient_packages(self, patient_id, **kw):
"""Paquetes del paciente con sesiones restantes.
El sistema no guarda un contador de sesiones: el paquete se materializa
como las citas creadas para un servicio con `package_sessions > 0`
(create_appointment genera una cita por sesión). Por eso:
total = citas no canceladas del paciente con ese servicio
usadas = citas ya atendidas (llegó / en curso / completada)
restantes = total - usadas
"""
try:
Citas = request.env['skeen.cita'].sudo()
citas = Citas.search([
('partner_id', '=', patient_id),
('state', 'not in', ('cancelled', 'no_show')),
('servicio_id.package_sessions', '>', 0),
])
grupos = {}
for c in citas:
g = grupos.setdefault(c.servicio_id.id, {
'service_id': c.servicio_id.id,
'name': c.servicio_id.name,
'package_sessions': c.servicio_id.package_sessions,
'total': 0,
'used': 0,
'next_date': None,
})
g['total'] += 1
if c.state in ('arrived', 'in_progress', 'done'):
g['used'] += 1
elif c.date and (g['next_date'] is None or c.date.strftime('%Y-%m-%d') < g['next_date']):
g['next_date'] = c.date.strftime('%Y-%m-%d')
rows = []
for g in grupos.values():
g['remaining'] = max(0, g['total'] - g['used'])
rows.append(g)
rows.sort(key=lambda r: (-r['remaining'], r['name']))
return json_response({'status': 'success', 'packages': rows})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@require_role('lectura')
def expedientes_recientes(self, **kw):
"""Pacientes con visitas recientes (últimos 30 días por default)"""
@@ -2525,6 +2762,109 @@ class SkeenFrontendController(http.Controller):
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/sales/<int:sale_id>/cancel', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
def cancel_sale(self, sale_id, **kw):
"""Cancelación de venta con nota de crédito (motivo obligatorio).
Permiso: admin y gerencia (el rango de 'gerencia' no alcanza a
require_role('admin'), por eso el chequeo explícito).
"""
try:
if request.frontend_user.role not in ('admin', 'gerencia'):
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
sale = request.env['skeen.venta'].sudo().browse(sale_id)
if not sale.exists():
return json_response({'status': 'error', 'message': 'Venta no encontrada'}, 404)
data = _parse_json_body()
reason = (data.get('reason') or '').strip()
if not reason:
return json_response({'status': 'error', 'message': 'El motivo de cancelación es obligatorio'}, 400)
result = sale.action_cancel_with_reason(
reason=reason,
cancelled_by=request.frontend_user.login,
restore_stock=bool(data.get('restore_stock', True)),
)
return json_response({'status': 'success', 'sale': self._sale_to_dict(sale), 'result': result})
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/sales/last', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def last_sale(self, **kw):
"""Última venta del paciente, con precios ACTUALES del catálogo.
Pensado para "Repetir última venta" en el POS: devuelve las líneas con
la cantidad vendida la última vez y el precio vigente hoy, marcando
precio cambiado / artículo faltante o inactivo.
"""
try:
patient_id = int(kw.get('patient_id', 0) or 0)
if not patient_id:
return json_response({'status': 'error', 'message': 'Falta patient_id'}, 400)
Ventas = request.env['skeen.venta'].sudo()
last = Ventas.search(
[('partner_id', '=', patient_id), ('state', '!=', 'cancelled')],
order='create_date desc, id desc', limit=1)
if not last:
return json_response({'status': 'success', 'sale': None, 'lines': [], 'warnings': []})
lines = []
warnings = []
for line in last.line_ids:
row = {
'service_id': line.service_id.id if line.service_id else None,
'item_id': line.item_id.id if line.item_id else None,
'name': line.service_id.name if line.service_id else (line.item_id.name if line.item_id else line.description),
'description': line.description or '',
'kind': 'service' if line.service_id else 'item',
'quantity': line.quantity,
'price_last': line.price_unit,
'price_current': None,
'price_changed': False,
'missing': False,
'stock': None,
'unit': None,
'active': True,
}
if line.service_id:
svc = line.service_id
row.update({
'price_current': svc.price,
'price_changed': round(svc.price, 2) != round(line.price_unit, 2),
'active': svc.active,
'missing': not svc.active,
})
if not svc.active:
warnings.append(f'{svc.name}: el servicio ya no está activo')
elif row['price_changed']:
warnings.append(f'{svc.name}: el precio cambió de ${line.price_unit:g} a ${svc.price:g}')
else:
item = line.item_id
row.update({
'price_current': item.price,
'price_changed': round((item.price or 0), 2) != round(line.price_unit, 2),
'stock': item.qty,
'unit': item.unit,
'active': item.active,
'missing': not item.active,
})
if not item.active:
warnings.append(f'{item.name}: el artículo ya no está activo')
elif row['price_changed']:
warnings.append(f'{item.name}: el precio cambió de ${line.price_unit:g} a ${(item.price or 0):g}')
lines.append(row)
return json_response({
'status': 'success',
'sale': self._sale_to_dict(last),
'lines': lines,
'warnings': warnings,
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
def _sale_to_dict(self, v):
return {
'id': v.id,
@@ -2543,6 +2883,12 @@ class SkeenFrontendController(http.Controller):
'refund_amount': v.refund_amount,
'refund_reason': v.refund_reason or '',
'refunded_at': v.refunded_at.strftime('%Y-%m-%d %H:%M') if v.refunded_at else None,
'notes': v.notes or '',
'cancel_reason': v.cancel_reason or '',
'cancelled_by': v.cancelled_by or '',
'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,
'lines': [{
'id': line.id,
'service_id': line.service_id.id if line.service_id else None,
@@ -2763,6 +3109,8 @@ class SkeenFrontendController(http.Controller):
'date': data.get('date', fields.Date.today()),
'opening_cash': data.get('opening_cash', 0.0),
'notes': data.get('notes', ''),
# Atribución explícita: con auth='none' el uid de Odoo es nulo
'user_id': self._frontend_res_user().id,
}
closing = request.env['skeen.corte.caja'].sudo().create(vals)
return json_response({'status': 'success', 'cash_closing': self._cash_closing_to_dict(closing)}, 201)
@@ -2835,7 +3183,10 @@ class SkeenFrontendController(http.Controller):
start = kw.get('start', (date.today().replace(day=1)).strftime('%Y-%m-%d'))
end = kw.get('end', date.today().strftime('%Y-%m-%d'))
Ventas = request.env['skeen.venta'].sudo()
ventas = Ventas.search([('date', '>=', start), ('date', '<=', end)])
# Las ventas canceladas (nota de crédito) no cuentan como ingreso
ventas = Ventas.search([
('date', '>=', start), ('date', '<=', end), ('state', '!=', 'cancelled'),
])
# Ingresos agrupados por día (clave nueva; lo demás no cambia)
by_day_map = {}
for v in ventas:
@@ -2901,8 +3252,10 @@ class SkeenFrontendController(http.Controller):
citas_by_state = {}
for c in citas:
citas_by_state[c.state] = citas_by_state.get(c.state, 0) + 1
# Ventas del día
ventas = request.env['skeen.venta'].sudo().search([('date', '=', date_str)])
# Ventas del día (excluye canceladas: no son ingreso)
ventas = request.env['skeen.venta'].sudo().search([
('date', '=', date_str), ('state', '!=', 'cancelled'),
])
# Cobros del día por método (misma convención que report_cash)
pagos = request.env['skeen.pago'].sudo().search([
('payment_date', '>=', f'{date_str} 00:00:00'),
@@ -4098,7 +4451,7 @@ class SkeenFrontendController(http.Controller):
"""Movimientos de inventario (filtros: tipo, rango de fechas, paginado)"""
try:
domain = []
if kw.get('type') in ('compra', 'venta', 'baja', 'ajuste'):
if kw.get('type') in ('compra', 'venta', 'baja', 'ajuste', 'cancelacion'):
domain.append(('type', '=', kw['type']))
if kw.get('date_from'):
domain.append(('date', '>=', f"{kw['date_from']} 00:00:00"))