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:
@@ -96,6 +96,7 @@ class SkeenInventarioMove(models.Model):
|
||||
('venta', 'Venta (salida)'),
|
||||
('baja', 'Baja (merma)'),
|
||||
('ajuste', 'Ajuste (conteo físico)'),
|
||||
('cancelacion', 'Cancelación de venta (entrada)'),
|
||||
], string='Tipo', required=True)
|
||||
qty = fields.Float(string='Cantidad', required=True, default=0.0)
|
||||
before_qty = fields.Float(string='Existencia anterior', readonly=True)
|
||||
@@ -113,7 +114,7 @@ class SkeenInventarioMove(models.Model):
|
||||
continue
|
||||
before = item.qty or 0.0
|
||||
q = m.qty or 0.0
|
||||
if m.type == 'compra':
|
||||
if m.type in ('compra', 'cancelacion'):
|
||||
after = before + q
|
||||
elif m.type in ('venta', 'baja'):
|
||||
after = before - q
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
|
||||
class SkeenVenta(models.Model):
|
||||
_name = 'skeen.venta'
|
||||
@@ -55,6 +57,13 @@ class SkeenVenta(models.Model):
|
||||
refund_reason = fields.Text(string='Motivo Devolución')
|
||||
refunded_at = fields.Datetime(string='Fecha Devolución')
|
||||
|
||||
# Cancelación (nota de crédito): motivo obligatorio y auditoría
|
||||
cancel_reason = fields.Text(string='Motivo Cancelación')
|
||||
cancelled_by = fields.Char(string='Cancelada por', readonly=True, copy=False)
|
||||
cancelled_at = fields.Datetime(string='Fecha Cancelación', readonly=True, copy=False)
|
||||
cancel_points_reversed = fields.Integer(string='Puntos Revertidos', default=0, readonly=True, copy=False)
|
||||
cancel_points_restored = fields.Integer(string='Puntos Restaurados', default=0, readonly=True, copy=False)
|
||||
|
||||
# Fechas
|
||||
date = fields.Date(string='Fecha', required=True, default=fields.Date.today)
|
||||
create_date = fields.Datetime(string='Fecha Creación', readonly=True, default=fields.Datetime.now)
|
||||
@@ -107,6 +116,118 @@ class SkeenVenta(models.Model):
|
||||
def action_cancel(self):
|
||||
self.write({'state': 'cancelled'})
|
||||
|
||||
def _reverse_wallet_for_cancel(self):
|
||||
"""Revierte los puntos que ESTA venta otorgó y devuelve los que canjeó.
|
||||
|
||||
Se identifican por `reference == venta.name` (misma convención que usa
|
||||
action_mark_paid). Los ajustes negativos hacen piso en 0 para no dejar
|
||||
saldo negativo si el paciente ya gastó los puntos.
|
||||
Devuelve (puntos_revertidos, puntos_restaurados).
|
||||
"""
|
||||
self.ensure_one()
|
||||
monedero = self.env['skeen.monedero'].search([('partner_id', '=', self.partner_id.id)], limit=1)
|
||||
if not monedero:
|
||||
return 0, 0
|
||||
txs = self.env['skeen.monedero.transaction'].search([
|
||||
('monedero_id', '=', monedero.id),
|
||||
('reference', '=', self.name),
|
||||
])
|
||||
revertidos = 0
|
||||
restaurados = 0
|
||||
for tx in txs:
|
||||
if tx.type == 'accrual' and tx.points > 0:
|
||||
applied = monedero.adjust_points(
|
||||
-tx.points,
|
||||
description=f'Cancelación venta {self.name}',
|
||||
reference=self.name,
|
||||
)
|
||||
revertidos += -applied
|
||||
elif tx.type == 'redemption' and tx.points < 0:
|
||||
monedero.adjust_points(
|
||||
-tx.points,
|
||||
description=f'Reintegro de puntos por cancelación {self.name}',
|
||||
reference=self.name,
|
||||
)
|
||||
restaurados += -tx.points
|
||||
return revertidos, restaurados
|
||||
|
||||
def _restore_stock_for_cancel(self):
|
||||
"""Devuelve al inventario lo que esta venta descontó.
|
||||
|
||||
Determinista: el cobro POS crea un `skeen.inventario.move` type='venta'
|
||||
con reference = venta.name por cada línea de artículo. Se registra la
|
||||
entrada inversa (type='cancelacion') con la misma referencia.
|
||||
Devuelve la lista de movimientos creados.
|
||||
"""
|
||||
self.ensure_one()
|
||||
Move = self.env['skeen.inventario.move']
|
||||
salidas = Move.search([('reference', '=', self.name), ('type', '=', 'venta')])
|
||||
creados = Move
|
||||
for m in salidas:
|
||||
creados |= Move.create({
|
||||
'item_id': m.item_id.id,
|
||||
'type': 'cancelacion',
|
||||
'qty': m.qty,
|
||||
'reference': self.name,
|
||||
'notes': f'Cancelación de venta {self.name}',
|
||||
})
|
||||
return creados
|
||||
|
||||
def action_cancel_with_reason(self, reason='', cancelled_by='', restore_stock=True):
|
||||
"""Cancela la venta (nota de crédito) con motivo obligatorio.
|
||||
|
||||
- Revierte los puntos otorgados y reintegra los canjeados por la venta.
|
||||
- Devuelve al inventario los artículos descontados por la venta.
|
||||
- Registra auditoría en el chatter y en los campos cancelled_*.
|
||||
La venta NO se borra: queda en state='cancelled' y los reportes la excluyen.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.state == 'cancelled':
|
||||
raise ValidationError(_('La venta ya está cancelada'))
|
||||
reason = (reason or '').strip()
|
||||
if not reason:
|
||||
raise ValidationError(_('El motivo de cancelación es obligatorio'))
|
||||
|
||||
revertidos, restaurados = self._reverse_wallet_for_cancel()
|
||||
movimientos = self._restore_stock_for_cancel() if restore_stock else self.env['skeen.inventario.move']
|
||||
|
||||
self.write({
|
||||
'state': 'cancelled',
|
||||
'cancel_reason': reason,
|
||||
'cancelled_by': cancelled_by or '',
|
||||
'cancelled_at': fields.Datetime.now(),
|
||||
'cancel_points_reversed': revertidos,
|
||||
'cancel_points_restored': restaurados,
|
||||
})
|
||||
|
||||
# Auditoría en el chatter. En peticiones auth='none' (frontend) el uid
|
||||
# de Odoo es nulo y message_post necesita un autor: se publica como el
|
||||
# usuario administrador y el login real queda en el cuerpo y en
|
||||
# cancelled_by.
|
||||
author = self.env.ref('base.user_admin', raise_if_not_found=False)
|
||||
if not author:
|
||||
author = self.env['res.users'].sudo().search([('login', '=', 'admin')], limit=1)
|
||||
auditor = self.with_user(author) if author else self
|
||||
auditor.message_post(body=Markup(
|
||||
'<b>Venta cancelada (nota de crédito)</b><br/>'
|
||||
'Motivo: {}<br/>'
|
||||
'Cancelada por: {}<br/>'
|
||||
'Puntos revertidos: {} · Puntos reintegrados: {}<br/>'
|
||||
'Artículos devueltos al inventario: {}'
|
||||
).format(
|
||||
reason,
|
||||
cancelled_by or 'sistema',
|
||||
revertidos,
|
||||
restaurados,
|
||||
len(movimientos),
|
||||
))
|
||||
|
||||
return {
|
||||
'points_reversed': revertidos,
|
||||
'points_restored': restaurados,
|
||||
'stock_moves': len(movimientos),
|
||||
}
|
||||
|
||||
def action_refund(self, amount=None, reason=''):
|
||||
"""Devolución total o parcial de una venta. Reversible: no borra nada.
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user