Puntos 16 y 10: modulo de paquetes de sesiones (catalogo, saldo por paciente, descuento en agenda, venta en POS) + fix de auditoria silenciosa

This commit is contained in:
2026-09-24 17:17:29 +00:00
parent 09b4f25bb8
commit dcf2c367f1
18 changed files with 1892 additions and 17 deletions

View File

@@ -2114,13 +2114,52 @@ class SkeenFrontendController(http.Controller):
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)
Fuente principal: `skeen.paquete.compra`, el saldo que crea el POS al
vender un paquete y que la cita descuenta al atenderse.
De respaldo se mantiene la derivación antigua para pacientes migrados
del sistema legacy, donde el paquete nunca se registró como compra y
solo existe como las citas del servicio:
total = citas no canceladas del paciente con ese servicio
usadas = citas ya atendidas (llegó / en curso / completada)
restantes = total - usadas
"""
try:
Compras = request.env['skeen.paquete.compra'].sudo()
compras = Compras.search([
('partner_id', '=', patient_id),
('state', 'in', ('active', 'finished', 'expired')),
], order='date asc')
reales = []
servicios_con_compra = set()
for c in compras:
servicios_con_compra.add(c.service_id.id if c.service_id else None)
reales.append({
'id': c.id,
'compra': True,
'service_id': c.service_id.id if c.service_id else None,
'name': c.paquete_id.name if c.paquete_id else (c.service_id.name if c.service_id else ''),
'package_sessions': c.sessions_total,
'total': c.sessions_total,
'used': c.sessions_used,
'remaining': c.sessions_remaining,
'next_date': c.expiry_date.strftime('%Y-%m-%d') if c.expiry_date else None,
'state': c.state,
})
if reales:
heredados = self._paquetes_heredados(patient_id, servicios_con_compra)
return json_response({
'status': 'success',
'packages': sorted(reales + heredados,
key=lambda r: (-r['remaining'], r['name'])),
})
return json_response({'status': 'success',
'packages': self._paquetes_heredados(patient_id, set())})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
def _paquetes_heredados(self, patient_id, excluir_servicios):
"""Saldos de paquete derivados de las citas (pacientes del legacy)."""
try:
Citas = request.env['skeen.cita'].sudo()
citas = Citas.search([
@@ -2130,6 +2169,8 @@ class SkeenFrontendController(http.Controller):
])
grupos = {}
for c in citas:
if excluir_servicios and c.servicio_id.id in excluir_servicios:
continue
g = grupos.setdefault(c.servicio_id.id, {
'service_id': c.servicio_id.id,
'name': c.servicio_id.name,
@@ -2146,12 +2187,288 @@ class SkeenFrontendController(http.Controller):
rows = []
for g in grupos.values():
g['remaining'] = max(0, g['total'] - g['used'])
g['compra'] = False
g['id'] = None
g['state'] = 'active' if g['remaining'] else 'finished'
rows.append(g)
rows.sort(key=lambda r: (-r['remaining'], r['name']))
return json_response({'status': 'success', 'packages': rows})
return rows
except Exception:
return []
# ============================================================
# Paquetes de sesiones
# ============================================================
def _paquete_to_dict(self, p):
servicio = p.service_id
return {
'id': p.id,
'name': p.name,
'code': p.code or '',
'service_id': servicio.id if servicio else None,
'service': servicio.name if servicio else '',
'sessions': p.sessions,
'price': p.price,
'price_per_session': round(p.price / p.sessions, 2) if p.sessions else 0.0,
'session_price': servicio.price if servicio else 0.0,
'duration_min': p.duration_min or (servicio.duration_min if servicio else 0),
'validity_days': p.validity_days or 0,
'notes': p.notes or '',
'color': p.color or '#1abc9c',
'genera_monedero': p.genera_monedero,
'active': p.active,
'sequence': p.sequence or 0,
'compras_count': p.compras_count,
}
def _paquete_compra_to_dict(self, c):
return {
'id': c.id,
'date': c.date.strftime('%Y-%m-%d') if c.date else None,
'patient_id': c.partner_id.id,
'patient': c.partner_id.name or '',
'phone': c.partner_id.phone or '',
'paquete_id': c.paquete_id.id if c.paquete_id else None,
'paquete': c.paquete_id.name if c.paquete_id else '',
'service_id': c.service_id.id if c.service_id else None,
'service': c.service_id.name if c.service_id else '',
'sessions_total': c.sessions_total,
'sessions_used': c.sessions_used,
'sessions_remaining': c.sessions_remaining,
'price': c.price,
'expiry_date': c.expiry_date.strftime('%Y-%m-%d') if c.expiry_date else None,
'state': c.state,
'venta_id': c.venta_id.id if c.venta_id else None,
'venta': c.venta_id.name if c.venta_id else '',
'user_login': c.user_login or '',
}
@http.route('/skeen/frontend/v1/paquetes', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def list_paquetes(self, **kw):
"""Catálogo de paquetes. `include_inactive=1` para ver los dados de baja."""
try:
domain = []
if not (kw.get('include_inactive') or '').strip():
domain.append(('active', '=', True))
if kw.get('search'):
s = kw['search']
domain += ['|', ('name', 'ilike', s), ('code', 'ilike', s)]
if kw.get('service_id'):
domain.append(('service_id', '=', int(kw['service_id'])))
rows = request.env['skeen.paquete'].sudo().with_context(
active_test=False).search(domain, order='sequence asc, name asc')
return json_response({'status': 'success', 'paquetes': [
self._paquete_to_dict(p) for p in rows]})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/paquetes', type='http', auth='none',
methods=['POST'], csrf=False)
@require_role('admin')
def create_paquete(self, **kw):
try:
data = _parse_json_body()
name = (data.get('name') or '').strip()
if not name:
return json_response({'status': 'error',
'message': 'El nombre es obligatorio'}, 400)
if not data.get('service_id'):
return json_response({'status': 'error',
'message': 'El servicio es obligatorio'}, 400)
Paq = request.env['skeen.paquete'].sudo()
if Paq.search([('name', '=ilike', name)], limit=1):
return json_response({'status': 'error',
'message': f'Ya existe un paquete "{name}".'}, 409)
vals = {
'name': name,
'code': (data.get('code') or '').strip(),
'service_id': int(data['service_id']),
'sessions': int(data.get('sessions') or 1),
'price': float(data.get('price') or 0.0),
'duration_min': int(data.get('duration_min') or 0),
'validity_days': int(data.get('validity_days') or 0),
'notes': data.get('notes') or '',
'color': data.get('color') or '#1abc9c',
'genera_monedero': bool(data.get('genera_monedero', True)),
}
try:
p = Paq.create(vals)
except ValidationError as e:
return json_response({'status': 'error',
'message': e.args[0] if e.args else str(e)}, 400)
self._auditar('paquetes', 'skeen.paquete', 'create',
new_value=p.name, entity_label=p.name,
notes=f'{p.sessions} sesiones · {p.price}')
return json_response({'status': 'success', 'paquete': self._paquete_to_dict(p)}, 201)
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/paquetes/<int:paquete_id>', type='http', auth='none',
methods=['PUT', 'PATCH'], csrf=False)
@require_role('admin')
def update_paquete(self, paquete_id, **kw):
try:
p = request.env['skeen.paquete'].sudo().with_context(active_test=False).browse(paquete_id)
if not p.exists():
return json_response({'status': 'error', 'message': 'Paquete no encontrado'}, 404)
data = _parse_json_body()
mapping = {
'name': 'name', 'code': 'code', 'notes': 'notes', 'color': 'color',
}
vals = {}
for key, field in mapping.items():
if key in data:
vals[field] = (data[key] or '').strip() if field != 'notes' else (data[key] or '')
if 'service_id' in data and data['service_id']:
vals['service_id'] = int(data['service_id'])
for key in ('sessions', 'duration_min', 'validity_days', 'sequence'):
if key in data:
vals[key] = int(data[key] or 0)
for key in ('price',):
if key in data:
vals[key] = float(data[key] or 0.0)
for key in ('genera_monedero', 'active'):
if key in data:
vals[key] = bool(data[key])
if 'active' in vals and vals['active'] != p.active:
self._auditar('paquetes', 'skeen.paquete', 'active',
old_value='activo' if p.active else 'inactivo',
new_value='activo' if vals['active'] else 'inactivo',
entity_label=p.name,
notes='Alta de baja' if not vals['active'] else 'Reactivación')
try:
p.write(vals)
except ValidationError as e:
return json_response({'status': 'error',
'message': e.args[0] if e.args else str(e)}, 400)
self._auditar('paquetes', 'skeen.paquete', 'edit',
new_value=', '.join(vals.keys()), entity_label=p.name)
return json_response({'status': 'success', 'paquete': self._paquete_to_dict(p)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/paquetes/compras', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def list_paquetes_compras(self, **kw):
"""Paquetes vendidos con su saldo.
Filtros: patient_id, state (active|finished|expired|cancelled),
doctor_id, search (paciente o paquete), start/end por fecha de compra.
"""
try:
domain = []
if kw.get('patient_id'):
domain.append(('partner_id', '=', int(kw['patient_id'])))
if kw.get('state'):
domain.append(('state', '=', kw['state']))
elif not (kw.get('include_all') or '').strip():
domain.append(('state', '!=', 'cancelled'))
if kw.get('search'):
s = f"%{kw['search']}%"
domain += ['|', ('partner_id.name', 'ilike', s),
('paquete_id.name', 'ilike', s)]
if kw.get('start'):
domain.append(('date', '>=', kw['start']))
if kw.get('end'):
domain.append(('date', '<=', kw['end']))
if kw.get('doctor_id'):
domain.append(('line_ids.cita_id.doctor_id', '=', int(kw['doctor_id'])))
limit = min(max(int(kw.get('limit', 300) or 300), 1), 1000)
rows = request.env['skeen.paquete.compra'].sudo().search(
domain, order='date desc, id desc', limit=limit)
totales = {
'vigentes': sum(1 for c in rows if c.state == 'active'),
'completados': sum(1 for c in rows if c.state == 'finished'),
'vencidos': sum(1 for c in rows if c.state == 'expired'),
'sesiones_restantes': sum(c.sessions_remaining for c in rows
if c.state == 'active'),
'importe': sum(c.price for c in rows),
}
return json_response({'status': 'success',
'compras': [self._paquete_compra_to_dict(c) for c in rows],
'totales': totales})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/paquetes/compras/<int:compra_id>/consumos',
type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def paquete_compra_consumos(self, compra_id, **kw):
"""Bitácora de sesiones consumidas por un paquete (con su cita)."""
try:
c = request.env['skeen.paquete.compra'].sudo().browse(compra_id)
if not c.exists():
return json_response({'status': 'error',
'message': 'Paquete no encontrado'}, 404)
filas = []
for mov in c.line_ids:
cita = mov.cita_id
filas.append({
'id': mov.id,
'date': mov.date.strftime('%Y-%m-%d %H:%M') if mov.date else None,
'state': mov.state,
'cita_id': cita.id if cita else None,
'cita_fecha': cita.date.strftime('%Y-%m-%d') if (cita and cita.date) else None,
'cita_hora': cita.time_str if cita else '',
'doctor': cita.doctor_id.name if (cita and cita.doctor_id) else '',
'estado_cita': cita.state if cita else '',
})
filas.sort(key=lambda f: (f['cita_fecha'] or '', f['cita_hora'] or ''))
return json_response({'status': 'success', 'compra': self._paquete_compra_to_dict(c),
'consumos': filas})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/paquetes/compras/<int:compra_id>', type='http',
auth='none', methods=['PUT', 'PATCH'], csrf=False)
@require_role('recepcion')
def update_paquete_compra(self, compra_id, **kw):
"""Ajuste manual del saldo (sesiones compradas / usadas)."""
try:
c = request.env['skeen.paquete.compra'].sudo().browse(compra_id)
if not c.exists():
return json_response({'status': 'error', 'message': 'Paquete no encontrado'}, 404)
data = _parse_json_body()
before = f"{c.sessions_total}/{c.sessions_used}"
try:
c.action_adjust_sessions(data.get('sessions_total'), data.get('sessions_used'))
except (ValueError, TypeError):
return json_response({'status': 'error',
'message': 'Las sesiones deben ser números enteros'}, 400)
self._auditar('paquetes', 'skeen.paquete.compra', 'sessions',
old_value=before,
new_value=f"{c.sessions_total}/{c.sessions_used}",
entity_label=f'{c.partner_id.name} · {c.paquete_id.name}',
notes='Ajuste manual de saldo')
return json_response({'status': 'success', 'compra': self._paquete_compra_to_dict(c)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/paquetes/compras/<int:compra_id>/anular',
type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('gerencia')
def anular_paquete_compra(self, compra_id, **kw):
try:
c = request.env['skeen.paquete.compra'].sudo().browse(compra_id)
if not c.exists():
return json_response({'status': 'error', 'message': 'Paquete no encontrado'}, 404)
data = _parse_json_body()
motivo = (data.get('reason') or '').strip()
if not motivo:
return json_response({'status': 'error',
'message': 'Indica el motivo de la anulación'}, 400)
c.action_cancel()
c.write({'sessions_total': 0})
self._auditar('paquetes', 'skeen.paquete.compra', 'state',
old_value='activo', new_value='anulado',
entity_label=f'{c.partner_id.name} · {c.paquete_id.name}',
notes=motivo)
return json_response({'status': 'success', 'compra': self._paquete_compra_to_dict(c)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Adeudos: cobro con recibo y plan de pagos
# ============================================================
@@ -4270,6 +4587,7 @@ class SkeenFrontendController(http.Controller):
discount = float(data.get('discount', 0) or 0)
line_vals = []
item_lines = [] # líneas de artículo: para el move 'venta' tras confirmar
paquete_lines = [] # (paquete_id, qty, price): materializan el saldo del paciente
stock_warnings = []
subtotal = 0.0
for l in lines:
@@ -4279,6 +4597,12 @@ class SkeenFrontendController(http.Controller):
return json_response({'status': 'error', 'message': 'Cantidad inválida en una línea'}, 400)
service_id = int(l.get('service_id', 0) or 0)
item_id = int(l.get('item_id', 0) or 0)
paquete_id = int(l.get('paquete_id', 0) or 0)
if paquete_id and not service_id:
# Una línea de paquete también consume servicio (el del paquete)
# para que el reporte de comisiones no se rompa.
service_id = int(request.env['skeen.paquete'].sudo().browse(
paquete_id).service_id.id or 0)
if bool(service_id) == bool(item_id):
return json_response({'status': 'error', 'message': 'Cada línea debe tener un servicio o un artículo'}, 400)
subtotal += qty * price
@@ -4308,7 +4632,10 @@ class SkeenFrontendController(http.Controller):
'price_unit': price,
'is_prescribed': bool(l.get('prescribed_by_id')),
'prescribed_by_id': l.get('prescribed_by_id') or False,
'paquete_id': paquete_id or False,
}))
if paquete_id:
paquete_lines.append((paquete_id, qty, price))
total_est = max(0.0, subtotal - discount)
# Moneda y tipo de cambio: validar ANTES de crear nada
@@ -4343,6 +4670,25 @@ class SkeenFrontendController(http.Controller):
})
venta.action_confirm()
# Paquetes vendidos: materializar el saldo del paciente. Se enlazan
# las compras a las líneas reales de la venta para poder revertirlas.
compras_creadas = []
if paquete_lines:
Compra = request.env['skeen.paquete.compra'].sudo()
lineas_venta = venta.line_ids.filtered(lambda ln: ln.paquete_id)
for line in lineas_venta:
for _i in range(int(line.quantity or 1)):
compras_creadas.append(Compra.create({
'partner_id': partner.id,
'paquete_id': line.paquete_id.id,
'venta_id': venta.id,
'sale_line_id': line.id,
'date': venta.date or fields.Date.today(),
'sessions_total': line.paquete_id.sessions,
'price': line.price_unit,
'user_login': cobrador,
}))
# Descargo de inventario por cada artículo (el create del move ajusta el stock)
if item_lines:
Move = request.env['skeen.inventario.move'].sudo()
@@ -4425,6 +4771,14 @@ class SkeenFrontendController(http.Controller):
'puntos_ganados': int(venta.total / 10) if venta.state == 'paid' else 0,
'wallet_points': partner.wallet_points,
'stock_warnings': stock_warnings,
'packages': [{
'id': c.id,
'paquete': c.paquete_id.name,
'service_id': c.service_id.id if c.service_id else None,
'sessions_total': c.sessions_total,
'sessions_remaining': c.sessions_remaining,
'expiry_date': c.expiry_date.strftime('%Y-%m-%d') if c.expiry_date else None,
} for c in compras_creadas],
}, 201)
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)