Oleada UX 6: Reportes agrupados en familias con rango global persistente y favoritos; Configuracion con auditoria de cambios y simulador de comisiones

This commit is contained in:
2026-09-17 22:06:11 +00:00
parent cd945df6a5
commit 31c92ac419
21 changed files with 1325 additions and 137 deletions

View File

@@ -49,6 +49,33 @@ class SkeenFrontendController(http.Controller):
return False
return self.AGENDA_START <= t < self.AGENDA_END
# ------------------------------------------------------------------
# Auditoría de configuración
# ------------------------------------------------------------------
def _fu_user(self):
"""Usuario del frontend que hizo la petición (o None)."""
return getattr(request, 'frontend_user', None)
def _auditar(self, module, entity, field, old_value=None, new_value=None,
entity_label='', notes=''):
"""Registra un cambio de configuración en skeen.config.audit.
Nunca debe tumbar la operación principal: si la bitácora falla, se
ignora el error y el cambio de configuración sigue adelante.
"""
try:
request.env['skeen.config.audit'].sudo().log_change(
module=module, entity=entity, field=field,
old_value=old_value, new_value=new_value,
entity_label=entity_label, notes=notes, user=self._fu_user())
except Exception:
import logging
logging.getLogger(__name__).exception('No se pudo registrar la auditoría')
def _fmt_horas(self, h):
h = float(h or 0)
return '%d:%02d' % (int(h), int(round((h - int(h)) * 60)))
def _linked_employee(self, user):
"""hr.employee vinculado a un usuario frontend (rol medico).
@@ -164,6 +191,8 @@ class SkeenFrontendController(http.Controller):
return json_response({'status': 'error', 'message': 'El usuario ya existe'}, 409)
user = User.create({'login': login, 'name': name, 'role': role, 'password_hash': 'pending'})
user.set_password(password)
self._auditar('usuarios', f'usuario:{user.id}', 'alta', '', f'{login} ({name})',
entity_label=name, notes=f'Rol {role}')
return json_response({'status': 'success', 'user': user.to_public_dict()}, 201)
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@@ -193,11 +222,20 @@ class SkeenFrontendController(http.Controller):
return json_response({'status': 'error', 'message': 'allowed_menus debe ser una lista'}, 400)
vals['allowed_menus'] = json.dumps(menus) if menus else False
if vals:
previos = {k: getattr(user, k) for k in vals}
user.write(vals)
for campo, nuevo in vals.items():
anterior = previos.get(campo)
if str(anterior or '') == str(nuevo or ''):
continue
self._auditar('usuarios', f'usuario:{user.id}', campo, anterior, nuevo,
entity_label=user.name or user.login)
if data.get('password'):
if len(data['password']) < 8:
return json_response({'status': 'error', 'message': 'La contraseña debe tener al menos 8 caracteres'}, 400)
user.set_password(data['password'])
self._auditar('usuarios', f'usuario:{user.id}', 'password', '(oculto)', '(cambiada)',
entity_label=user.name or user.login)
return json_response({'status': 'success', 'user': user.to_public_dict() | {'active': user.active}})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@@ -211,7 +249,10 @@ class SkeenFrontendController(http.Controller):
return json_response({'status': 'error', 'message': 'Usuario no encontrado'}, 404)
if request.frontend_user and request.frontend_user.id == user.id:
return json_response({'status': 'error', 'message': 'No puedes eliminar tu propio usuario'}, 400)
etiqueta = f'{user.login} ({user.name})'
user.unlink()
self._auditar('usuarios', f'usuario:{user_id}', 'baja', etiqueta, '',
entity_label=etiqueta, notes='Usuario eliminado')
return json_response({'status': 'success', 'message': 'Usuario eliminado'})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@@ -248,7 +289,13 @@ class SkeenFrontendController(http.Controller):
clean[href] = list(dict.fromkeys(roles))
if 'admin' not in clean.get('/configuracion', []):
return json_response({'status': 'error', 'message': "No se puede quitar '/configuracion' al rol admin"}, 400)
request.env['ir.config_parameter'].sudo().set_param(MENU_ROLES_KEY, json.dumps(clean))
ICP = request.env['ir.config_parameter'].sudo()
anterior = ICP.get_param(MENU_ROLES_KEY, '') or ''
ICP.set_param(MENU_ROLES_KEY, json.dumps(clean))
self._auditar('roles', 'menu_roles', 'permisos',
(anterior[:2000] or '(por defecto)'), json.dumps(clean)[:2000],
entity_label='Matriz de menús por rol',
notes=f'{len(clean)} rutas')
return json_response({'status': 'success', 'matrix': clean})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@@ -2514,7 +2561,15 @@ class SkeenFrontendController(http.Controller):
return json_response({'status': 'error', 'message': "staff_type debe ser 'doctor' o 'cosmetologa'"}, 400)
vals['staff_type'] = data['staff_type']
if vals:
previos = {k: getattr(doctor, k) for k in vals}
doctor.write(vals)
for campo, nuevo in vals.items():
anterior = previos.get(campo)
if str(anterior or '') == str(nuevo or ''):
continue
modulo = 'metas' if campo == 'monthly_goal' else 'comisiones'
self._auditar(modulo, f'doctor:{doctor.id}', campo,
anterior, nuevo, entity_label=doctor.name)
return json_response({
'status': 'success',
'doctor': {
@@ -2587,7 +2642,12 @@ class SkeenFrontendController(http.Controller):
}, 400)
vistos.add(dia)
vals.append((0, 0, {'day_of_week': dia, 'hour_from': hf, 'hour_to': ht}))
previo = doctor._horario_resumen() or '(sin horario)'
doctor.write({'horario_ids': [(5, 0, 0)] + vals})
nuevo = doctor._horario_resumen() or '(sin horario)'
if previo != nuevo:
self._auditar('horarios', f'doctor:{doctor.id}', 'horario',
previo, nuevo, entity_label=doctor.name)
return json_response({
'status': 'success',
'horario': [{
@@ -2617,7 +2677,11 @@ class SkeenFrontendController(http.Controller):
return json_response({'status': 'error', 'message': 'commission_pct debe ser numérico'}, 400)
if not 0 <= pct <= 100:
return json_response({'status': 'error', 'message': 'El porcentaje debe estar entre 0 y 100'}, 400)
prev = doctor.commission_pct or 0.0
doctor.write({'commission_pct': pct})
if prev != pct:
self._auditar('comisiones', f'doctor:{doctor.id}', 'commission_pct',
prev, pct, entity_label=doctor.name)
return json_response({
'status': 'success',
'doctor': {'id': doctor.id, 'name': doctor.name, 'commission_pct': doctor.commission_pct},
@@ -4644,12 +4708,16 @@ class SkeenFrontendController(http.Controller):
today = fields.Date.today()
TC = request.env['skeen.tipo.cambio'].sudo()
existing = TC.search([('date', '=', today)], limit=1)
prev_rate = existing.rate if existing else None
vals = {'rate': rate, 'source': data.get('source', 'manual'), 'notes': data.get('notes', '')}
if existing:
existing.write(vals)
rec = existing
else:
rec = TC.create({'date': today, **vals})
self._auditar('tipo_cambio', f'tipo_cambio:{rec.id}', 'rate',
prev_rate, rate, entity_label=f'TC {today}',
notes='Alta' if prev_rate is None else 'Actualización')
return json_response({
'status': 'success',
'exchange_rate': {'id': rec.id, 'date': rec.date.strftime('%Y-%m-%d'), 'rate': rec.rate, 'source': rec.source},
@@ -4721,11 +4789,163 @@ class SkeenFrontendController(http.Controller):
return json_response({'status': 'error', 'message': 'commission_pct debe ser numérico'}, 400)
if not 0 <= pct <= 100:
return json_response({'status': 'error', 'message': 'El porcentaje debe estar entre 0 y 100'}, 400)
request.env['ir.config_parameter'].sudo().set_param('skeen.commission_pct', pct)
ICP = request.env['ir.config_parameter'].sudo()
prev = float(ICP.get_param('skeen.commission_pct', 0) or 0)
ICP.set_param('skeen.commission_pct', pct)
self._auditar('comisiones', 'commission_default', 'commission_pct',
prev, pct, entity_label='Comisión global')
return json_response({'status': 'success', 'commission_pct': pct})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Simulación de impacto y auditoría de configuración
# ============================================================
@http.route('/skeen/frontend/v1/config/impact_simulation', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('admin')
def config_impact_simulation(self, **kw):
"""Qué habría pasado con los valores configurados (o unos propuestos).
Calcula, con las ventas atribuidas REALES del periodo, cuánto gana cada
médico por comisión. Parámetros opcionales `commission_pct` y `rate`
permiten probar valores candidatos sin guardarlos.
"""
try:
start = kw.get('date_from') or kw.get('start')
end = kw.get('date_to') or kw.get('end')
ICP = request.env['ir.config_parameter'].sudo()
pct_global = float(ICP.get_param('skeen.commission_pct', 0) or 0)
propuesto = kw.get('commission_pct')
pct_propuesto = None
if propuesto not in (None, ''):
try:
pct_propuesto = float(propuesto)
except (TypeError, ValueError):
return json_response({'status': 'error', 'message': 'commission_pct debe ser numérico'}, 400)
if not 0 <= pct_propuesto <= 100:
return json_response({'status': 'error', 'message': 'El porcentaje debe estar entre 0 y 100'}, 400)
vdomain = [('state', '!=', 'cancelled')]
if start:
vdomain.append(('date', '>=', start))
if end:
vdomain.append(('date', '<=', end))
ventas = request.env['skeen.venta'].sudo().search(vdomain)
lineas = request.env['skeen.venta.line'].sudo().search([
('venta_id', 'in', ventas.ids), ('prescribed_by_id', '!=', False),
])
base_por_doc = {}
for l in lineas:
did = l.prescribed_by_id.id
base_por_doc[did] = base_por_doc.get(did, 0.0) + (l.subtotal or 0.0)
Emp = request.env['hr.employee'].sudo()
filas = []
total_base = 0.0
total_comision = 0.0
for did, base in base_por_doc.items():
emp = Emp.browse(did)
propio = emp.commission_pct or 0.0
if pct_propuesto is not None:
pct, origen = pct_propuesto, 'propuesto'
elif propio > 0:
pct, origen = propio, 'propio'
else:
pct, origen = pct_global, 'global'
comision = base * pct / 100.0
total_base += base
total_comision += comision
filas.append({
'doctor_id': did,
'doctor': emp.name,
'job_title': emp.job_title or '',
'base': round(base, 2),
'commission_pct_propio': propio,
'commission_pct_efectivo': pct,
'origen_pct': origen,
'commission': round(comision, 2),
})
filas.sort(key=lambda f: -f['commission'])
# Equivalente USD del periodo con el TC vigente (o uno propuesto)
TC = request.env['skeen.tipo.cambio'].sudo().search([], order='date desc, id desc', limit=1)
rate_actual = TC.rate if TC else None
rate = rate_actual
if kw.get('rate') not in (None, ''):
try:
rate = float(kw.get('rate'))
except (TypeError, ValueError):
return json_response({'status': 'error', 'message': 'rate debe ser numérico'}, 400)
if rate <= 0:
return json_response({'status': 'error', 'message': 'rate debe ser mayor a 0'}, 400)
return json_response({
'status': 'success',
'date_from': start,
'date_to': end,
'commission_pct_global': pct_global,
'commission_pct_propuesto': pct_propuesto,
'doctores': filas,
'total_base': round(total_base, 2),
'total_commission': round(total_comision, 2),
'ventas': len(ventas),
'rate': rate,
'rate_actual': rate_actual,
'total_usd': round(total_base / rate, 2) if rate else None,
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/config/audit', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('admin')
def config_audit_list(self, **kw):
"""Bitácora de cambios de configuración (filtrable y paginada)."""
try:
domain = []
if kw.get('date_from'):
domain.append(('date', '>=', f"{kw['date_from']} 00:00:00"))
if kw.get('date_to'):
domain.append(('date', '<=', f"{kw['date_to']} 23:59:59"))
if kw.get('module'):
domain.append(('module', '=', kw['module']))
if kw.get('user'):
domain += ['|', ('user_login', 'ilike', kw['user']), ('user_name', 'ilike', kw['user'])]
if kw.get('entity'):
e = kw['entity']
domain += ['|', ('entity', 'ilike', e), ('entity_label', 'ilike', e)]
page = max(int(kw.get('page', 1) or 1), 1)
page_size = min(max(int(kw.get('page_size', 50) or 50), 1), 200)
Audit = request.env['skeen.config.audit'].sudo()
total = Audit.search_count(domain)
recs = Audit.search(domain, order='date desc, id desc',
limit=page_size, offset=(page - 1) * page_size)
return json_response({
'status': 'success',
'registros': [{
'id': r.id,
'date': r.date.strftime('%Y-%m-%d %H:%M') if r.date else None,
'user_login': r.user_login or '',
'user_name': r.user_name or '',
'user_role': r.user_role or '',
'module': r.module,
'module_label': dict(
request.env['skeen.config.audit']._fields['module'].selection).get(r.module, r.module),
'entity': r.entity or '',
'entity_label': r.entity_label or '',
'field': r.field or '',
'old_value': r.old_value or '',
'new_value': r.new_value or '',
'notes': r.notes or '',
} for r in recs],
'total': total,
'page': page,
'page_size': page_size,
'total_pages': (total + page_size - 1) // page_size,
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Reporte de cumpleañeros
# ============================================================