|
|
|
|
@@ -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
|
|
|
|
|
# ============================================================
|
|
|
|
|
|