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