Initial commit: SKEEN Derma Experts - Sistema Integral de Gestión Clínica
- Frontend React (SKEEN Brand) con Vite, TypeScript, Tailwind - Frontend Homenest (versión alternativa) - Módulos Odoo 17 custom (citas, pacientes, monedero, pagos, ventas, inventario, whatsapp) - WACRM fork (Next.js 16 + Supabase) - Hermes + Bridge + Skills (Qwen3.6 via Nan Builders) - Scripts de migración y operación - Documentación extensiva en docs/
This commit is contained in:
94
migracion/README.md
Normal file
94
migracion/README.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Migración de datos del sistema legacy de SKEEN a Odoo 17
|
||||
|
||||
## Requisito previo
|
||||
|
||||
Obtener una **exportación autorizada** del sistema actual (`https://sistema.skeenmx.app/`).
|
||||
|
||||
> **Nota importante:** No se realizará scraping ni extracción masiva no autorizada desde el sistema en producción. El cliente debe proporcionar los datos en formato CSV/Excel o un dump SQL autorizado.
|
||||
|
||||
## Formato de CSVs
|
||||
|
||||
Colocar todos los archivos en `/root/migracion/datos/`.
|
||||
|
||||
### pacientes.csv
|
||||
```csv
|
||||
nombre,apellido_paterno,apellido_materno,telefono,email,fecha_nacimiento,sexo,medico_principal,etiquetas,vip,fuente
|
||||
```
|
||||
|
||||
### servicios.csv
|
||||
```csv
|
||||
codigo,nombre,categoria,precio,duracion_min,descripcion,color,grupo,favorito,activo
|
||||
```
|
||||
|
||||
### productos.csv
|
||||
```csv
|
||||
codigo,nombre,categoria,descripcion,precio,inventario_minimo,activo
|
||||
```
|
||||
|
||||
### medicos.csv
|
||||
```csv
|
||||
nombre,apellido_paterno,apellido_materno,telefono,email,especialidad,rol
|
||||
```
|
||||
|
||||
### citas.csv
|
||||
```csv
|
||||
paciente_telefono,paciente_nombre,servicio_codigo,fecha,hora,estado,doctor_nombre,sucursal,medio,notas,estado_pago,monto_pagado
|
||||
```
|
||||
|
||||
- `estado`: pendiente, confirmada, llego, en_progreso, completada, cancelada, no_show
|
||||
- `sucursal`: rosarito, tijuana
|
||||
- `medio`: onsite, videollamada
|
||||
- `estado_pago`: no_pagado, parcial, pagado
|
||||
|
||||
### ventas.csv
|
||||
```csv
|
||||
paciente_telefono,fecha,servicio_codigo,cantidad,precio_unitario,descuento,impuesto,estado,notas
|
||||
```
|
||||
|
||||
### pagos.csv
|
||||
```csv
|
||||
paciente_telefono,cita_referencia,monto,metodo,estado,fecha_pago
|
||||
```
|
||||
|
||||
- `metodo`: efectivo, tarjeta, transferencia, stripe, mercadopago
|
||||
- `estado`: pendiente, procesando, completado, fallido, reembolsado
|
||||
|
||||
### adeudos.csv
|
||||
```csv
|
||||
telefono,concepto,monto,moneda,fecha_vencimiento
|
||||
```
|
||||
|
||||
### monederos.csv
|
||||
```csv
|
||||
telefono,puntos,activo
|
||||
```
|
||||
|
||||
## Ejecución
|
||||
|
||||
```bash
|
||||
source /root/odoo-venv/bin/activate
|
||||
python3 /root/migracion/importar_a_odoo.py
|
||||
```
|
||||
|
||||
El script es **idempotente**: si vuelves a ejecutarlo, actualizará registros existentes (buscando por teléfono o código) en lugar de duplicarlos.
|
||||
|
||||
## Verificación
|
||||
|
||||
Después de la importación, revisa en:
|
||||
|
||||
- Odoo backend: `http://192.168.10.114:8069/web`
|
||||
- Frontend: `http://192.168.10.114/`
|
||||
|
||||
## Proceso recomendado con datos reales
|
||||
|
||||
1. SKEEN exporta CSVs desde su sistema actual (o proporciona dump SQL).
|
||||
2. Colocar CSVs en `/root/migracion/datos/`.
|
||||
3. Ajustar columnas si es necesario (mapeo de nombres de campo).
|
||||
4. Ejecutar script en entorno de prueba.
|
||||
5. Validar datos con el cliente.
|
||||
6. Ejecutar en producción durante ventana de mantenimiento.
|
||||
7. Sincronizar WACRM con Odoo para que conversaciones se vinculen a pacientes.
|
||||
|
||||
## Archivos de ejemplo
|
||||
|
||||
En `/root/migracion/datos/` hay archivos CSV de demostración. Reemplazarlos con los datos reales antes de la migración oficial.
|
||||
33
migracion/borrar_datos_demo.py
Normal file
33
migracion/borrar_datos_demo.py
Normal file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Borra datos demo de Odoo antes de migración."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ODOO_PATH = Path('/root/skeen-odoo')
|
||||
sys.path.insert(0, str(ODOO_PATH))
|
||||
|
||||
import odoo
|
||||
from odoo import api, SUPERUSER_ID
|
||||
|
||||
odoo.tools.config.parse_config(['-c', '/root/skeen-odoo/odoo.conf'])
|
||||
db = odoo.sql_db.db_connect('skeen_odoo')
|
||||
with db.cursor() as cr:
|
||||
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||
|
||||
# Borrar en orden respetando dependencias
|
||||
print('Borrando ventas...')
|
||||
env['skeen.venta'].sudo().search([]).unlink()
|
||||
print('Borrando pagos...')
|
||||
env['skeen.pago'].sudo().search([]).unlink()
|
||||
print('Borrando monederos...')
|
||||
env['skeen.monedero'].sudo().search([]).unlink()
|
||||
print('Borrando citas...')
|
||||
env['skeen.cita'].sudo().search([]).unlink()
|
||||
print('Borrando pacientes...')
|
||||
env['res.partner'].sudo().search([('is_patient', '=', True)]).unlink()
|
||||
print('Borrando servicios custom (excepto genéricos)...')
|
||||
env['skeen.servicio'].sudo().search([('code', 'not in', ['GENERICO', 'ADEUDO'])]).unlink()
|
||||
|
||||
cr.commit()
|
||||
print('Datos demo borrados.')
|
||||
27
migracion/ejecutar_migracion.sh
Executable file
27
migracion/ejecutar_migracion.sh
Executable file
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
# Ejecuta la migración completa: extracción ya debe estar hecha.
|
||||
set -e
|
||||
|
||||
cd /root/migracion
|
||||
mkdir -p datos
|
||||
|
||||
echo "[1/6] Copiando CSVs extraídos..."
|
||||
cp extraccion/pacientes.csv datos/
|
||||
cp extraccion/servicios.csv datos/
|
||||
cp extraccion/monederos.csv datos/
|
||||
cp extraccion/citas.csv datos/
|
||||
cp extraccion/ventas.csv datos/
|
||||
|
||||
echo "[2/6] Post-procesando citas (médicos)..."
|
||||
/root/odoo-venv/bin/python /root/migracion/postprocesar_citas.py
|
||||
|
||||
echo "[3/6] Importando a Odoo..."
|
||||
/root/odoo-venv/bin/python /root/migracion/importar_a_odoo.py
|
||||
|
||||
echo "[4/6] Sincronizando contactos a WACRM..."
|
||||
/root/odoo-venv/bin/python /root/migracion/sync_odoo_to_wacrm.py
|
||||
|
||||
echo "[5/6] Refrescando cache WACRM en Odoo..."
|
||||
curl -s -X POST http://localhost:8069/skeen/frontend/v1/wacrm/sync || true
|
||||
|
||||
echo "[6/6] Migración completada."
|
||||
3600
migracion/extraccion/agenda.html
Normal file
3600
migracion/extraccion/agenda.html
Normal file
File diff suppressed because one or more lines are too long
46457
migracion/extraccion/citas.csv
Normal file
46457
migracion/extraccion/citas.csv
Normal file
File diff suppressed because it is too large
Load Diff
1396
migracion/extraccion/crm.html
Normal file
1396
migracion/extraccion/crm.html
Normal file
File diff suppressed because it is too large
Load Diff
17585
migracion/extraccion/crm_prospectos.csv
Normal file
17585
migracion/extraccion/crm_prospectos.csv
Normal file
File diff suppressed because it is too large
Load Diff
92820
migracion/extraccion/detalle_pacientes.json
Normal file
92820
migracion/extraccion/detalle_pacientes.json
Normal file
File diff suppressed because it is too large
Load Diff
857
migracion/extraccion/medicos.html
Normal file
857
migracion/extraccion/medicos.html
Normal file
@@ -0,0 +1,857 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<title></title>
|
||||
<meta name="description" content="">
|
||||
<meta name="csrf-token" content="aqnBBFsD1z3cGfklS6J3LMwvO4NJFahNkuMduIyl"/>
|
||||
<!-- Tell the browser to be responsive to screen width -->
|
||||
<meta content="width=device-width" name="viewport">
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.2.0/css/all.min.css">
|
||||
<!-- Theme style -->
|
||||
<link rel="stylesheet" href="https://sistema.skeenmx.app/vendor/css/AdminLTE-2.3.5/build/less/AdminLTE.css">
|
||||
<link rel="stylesheet" href="https://sistema.skeenmx.app/vendor/css/panel.css?077"/>
|
||||
|
||||
<script src="https://sistema.skeenmx.app/vendor/css/AdminLTE-2.3.5/plugins/jQuery/jquery-2.2.3.min.js"></script>
|
||||
|
||||
<!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
|
||||
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
|
||||
<!--[if lt IE 9]>
|
||||
<script src="https://oss.maxcdn.com/html5shiv/3.7.3/html5shiv.min.js"></script>r
|
||||
<script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
|
||||
<![endif]-->
|
||||
|
||||
</head>
|
||||
<body id="medicos_consulta"
|
||||
class="hold-transition skin-theme sidebar-mini ">
|
||||
<div class="wrapper">
|
||||
|
||||
<!-- Main Header -->
|
||||
<header class="main-header">
|
||||
|
||||
<!-- Logo -->
|
||||
<a href="https://sistema.skeenmx.app/dashboard" class="logo">
|
||||
<!-- mini logo for sidebar mini 50x50 pixels -->
|
||||
<span class="logo-mini">
|
||||
<img class="img-responsive" src="https://sistema.skeenmx.app/images/icono_negro.png" alt="">
|
||||
</span>
|
||||
<!-- logo for regular state and mobile devices -->
|
||||
<span class="logo-lg">
|
||||
<img class="img-responsive" src="https://sistema.skeenmx.app/images/logo_skeen.png" alt=""> </span>
|
||||
</a>
|
||||
|
||||
<!-- Header Navbar -->
|
||||
<nav class="navbar navbar-static-top" role="navigation">
|
||||
<!-- Sidebar toggle button-->
|
||||
<a href="#" class="sidebar-toggle" data-toggle="offcanvas" role="button">
|
||||
<span class="sr-only">Menú</span>
|
||||
</a>
|
||||
<div class="hidden">
|
||||
<ul class="nav navbar-nav">
|
||||
<!-- User Account Menu -->
|
||||
<li class="dropdown">
|
||||
<!-- Menu Toggle Button -->
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown">
|
||||
<i class="fa-solid fa-headset"></i>
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<a href="/crm/medio/1">
|
||||
<i class="fa-solid fa-phone fa-2x"></i> Llamada
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/crm/medio/2">
|
||||
<i class="fa-brands fa-whatsapp fa-2x"></i> Whatsapp
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/crm/medio/3">
|
||||
<i class="fa-brands fa-facebook fa-2x"></i> Facebook
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/crm/medio/4">
|
||||
<i class="fa-brands fa-instagram fa-2x"></i> Instagram
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/crm/medio/5">
|
||||
<i class="fa-solid fa-envelope fa-2x"></i> Email
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/crm/medio/6">
|
||||
<i class="fa-solid fa-globe fa-2x"></i> Sitio web
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Navbar Right Menu -->
|
||||
<div class="navbar-custom-menu">
|
||||
<ul class="nav navbar-nav">
|
||||
<li>
|
||||
<a href="#" class="">
|
||||
TC: <span class="badge bg-black">16.50</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="">
|
||||
<a href="#" class="" data-toggle="modal"
|
||||
data-target="#modal_valoracion_express">
|
||||
<i class="fa fa-fw fa-bolt "></i> Valoración Express
|
||||
</a>
|
||||
</li>
|
||||
<!-- User Account Menu -->
|
||||
<li class="dropdown user user-menu">
|
||||
<!-- Menu Toggle Button -->
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown">
|
||||
<!-- The user image in the navbar-->
|
||||
<img src="https://sistema.skeenmx.app/images/profile.png" class="user-image" alt="User Image">
|
||||
<!-- hidden-xs hides the username on small devices so only the image appears. -->
|
||||
<span class="hidden-xs">José</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<!-- The user image in the menu -->
|
||||
<li class="user-header">
|
||||
<img src="https://sistema.skeenmx.app/images/profile.png" class="img-circle" alt="User Image">
|
||||
|
||||
<p>
|
||||
José
|
||||
<small></small>
|
||||
</p>
|
||||
</li>
|
||||
<!-- Menu Body -->
|
||||
<li class="user-body hidden">
|
||||
<div class="row">
|
||||
<div class="col-xs-4 text-center">
|
||||
<a href="#">Followers</a>
|
||||
</div>
|
||||
<div class="col-xs-4 text-center">
|
||||
<a href="#">Sales</a>
|
||||
</div>
|
||||
<div class="col-xs-4 text-center">
|
||||
<a href="#">Friends</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- /.row -->
|
||||
</li>
|
||||
<!-- Menu Footer-->
|
||||
<li class="user-footer">
|
||||
<div class="row row-no-padding">
|
||||
<div class="col-xs-5">
|
||||
<a href="https://sistema.skeenmx.app/perfil" class="btn btn-primary btn-flat btn-block"><i
|
||||
class="fa fa-fw fa-user"></i> Perfil</a>
|
||||
</div>
|
||||
<div class="col-xs-1"></div>
|
||||
<div class="col-xs-6 text-right">
|
||||
<a href="https://sistema.skeenmx.app/logout" class="btn btn-default btn-flat">Cerrar sesión</a>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="dropdown user user-menu hidden">
|
||||
<!-- Menu Toggle Button -->
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown">
|
||||
<i class="fa fa-trophy"></i>
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li class="text-center container-fluid">
|
||||
<div>Meta Noviembre 2020</div>
|
||||
<div class="progress">
|
||||
<div class="progress-bar" role="progressbar" aria-valuenow="60" aria-valuemin="0" aria-valuemax="100" style="width: 60%;">
|
||||
<span class="sr-only">60% Complete</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
600 USD / 1000 USD
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="">
|
||||
<a class="" href="#" data-toggle="modal" data-target="#modal_notas">
|
||||
<i class="fa fa-sticky-note"></i>
|
||||
</a>
|
||||
</li>
|
||||
<!-- Control Sidebar Toggle Button -->
|
||||
<li class="">
|
||||
<a class="total_no_leidas" href="#" data-toggle="control-sidebar">
|
||||
<i class="fa fa-comments"></i>
|
||||
</a>
|
||||
</li>
|
||||
<li><a href="https://sistema.skeenmx.app/consumibles/suministrar"><i class="fa fa-flask"></i></a></li>
|
||||
<li class="">
|
||||
<a class="" href="https://sistema.skeenmx.app/admin/sesiones/agregar">
|
||||
<i class="fa fa-diamond"></i>
|
||||
</a>
|
||||
</li>
|
||||
<li class="">
|
||||
<a class="" href="https://sistema.skeenmx.app/recomendar">
|
||||
<i class="fa fa-star"></i>
|
||||
</a>
|
||||
</li>
|
||||
<li class="">
|
||||
<a class="" href="https://sistema.skeenmx.app/admin/pedidos/agregar">
|
||||
<i class="fa fa-shopping-cart"></i>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<!-- Left side column. contains the logo and sidebar -->
|
||||
<aside class="main-sidebar">
|
||||
|
||||
<!-- sidebar: style can be found in sidebar.less -->
|
||||
<section class="sidebar">
|
||||
<div>
|
||||
<!-- Tab panes -->
|
||||
<div class="tab-content">
|
||||
<div role="tabpanel" class="tab-pane active " id="tab_menu_default">
|
||||
<ul class="sidebar-menu">
|
||||
<li class="header" style="color: #fff; font-size: 16px; text-align: center; font-weight: bold; border-bottom: solid 1px #666;">
|
||||
Rosarito
|
||||
</li>
|
||||
|
||||
<!-- Optionally, you can add icons to the links -->
|
||||
|
||||
<li><a href="https://sistema.skeenmx.app/dashboard"><i class="fa fa-home"></i> <span>Inicio</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/agenda"><i class="fa-regular fa-fw fa-calendar"></i> <span>Agenda</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/visitas"><i class="fa-solid fa-fw fa-users"></i> <span>Visitas</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/expedientes"><i class="fa fa-folder-open"></i>
|
||||
<span>Expedientes</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/medicos"><i class="fa fa-user-md"></i>
|
||||
<span>Médicos</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/cosmetologos"><i class="fa fa-bed"></i>
|
||||
<span>Cosmetología</span></a></li>
|
||||
<li class="hidden">
|
||||
<a href="https://sistema.skeenmx.app/consultas" class="">
|
||||
<i class="fa fa-fw fa-user-md"></i> <span>Consultas Médicas</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="treeview">
|
||||
<a href="#">
|
||||
<i class="fa fa-cogs"></i> <span>Configuración</span> <i
|
||||
class="fa fa-angle-left pull-right"></i>
|
||||
</a>
|
||||
<ul class="treeview-menu">
|
||||
<li><a href="https://sistema.skeenmx.app/usuarios/consulta/activados"><i class="fa fa-user-md"></i> <span>Usuarios</span></a>
|
||||
</li>
|
||||
<li><a href="https://sistema.skeenmx.app/recetas"><i class="fa fa-list-alt"></i> <span>Recetas</span></a>
|
||||
</li>
|
||||
<li><a href="https://sistema.skeenmx.app/diagnosticos"><i class="fa fa-medkit"></i>
|
||||
<span>Diagnósticos</span></a></li>
|
||||
<li class="hidden"><a href="https://sistema.skeenmx.app/procedimientos"><i class="fa fa-stethoscope"></i> <span>Procedimientos médicos</span></a>
|
||||
<li><a href="https://sistema.skeenmx.app/servicios"><i class="fa fa-star"></i> <span>Servicios</span></a>
|
||||
<li><a href="https://sistema.skeenmx.app/etiquetas"><i class="fa fa-tags"></i> <span>Etiquetas</span></a>
|
||||
<li><a href="https://sistema.skeenmx.app/productos"><i class="fa fa-flask"></i> <span>Productos</span></a>
|
||||
</li>
|
||||
<li><a href="https://sistema.skeenmx.app/inventarios"><i class="fa fa-cube"></i>
|
||||
<span>Inventarios</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/tipo_cambio"><i class="fa fa-usd"></i> <span>Tipo de cambio</span></a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="treeview">
|
||||
<a href="#">
|
||||
<i class="fa fa-pie-chart"></i> <span>Reportes</span> <i
|
||||
class="fa fa-angle-left pull-right"></i>
|
||||
</a>
|
||||
<ul class="treeview-menu">
|
||||
<li><a href="https://sistema.skeenmx.app/reportes/diario"><i class="fa fa-calendar"></i>
|
||||
<span>Mov. diario</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/reportes/medicos"><i class="fa fa-calendar"></i> <span>Mov. diario - Médicos</span></a>
|
||||
</li>
|
||||
<li><a href="https://sistema.skeenmx.app/reportes/recepcion"><i class="fa fa-calendar"></i> <span>Mov. diario - Recepción</span></a>
|
||||
</li>
|
||||
<li><a href="https://sistema.skeenmx.app/reportes/cortes"><i class="fa fa-book"></i> <span>Cortes de caja</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/reportes/ingresos"><i class="fa fa-line-chart"></i> <span>Ingresos</span></a></li>
|
||||
<li class="hidden"><a href="#"><i class="fa fa-star-o"></i> <span>Especialidades</span></a></li>
|
||||
<li class="hidden"><a href="https://sistema.skeenmx.app/reportes/comisiones"><i class="fa-solid fa-money-bill"></i>
|
||||
<span>Comisiones</span></a></li>
|
||||
<li class="hidden"><a href="#"><i class="fa fa-check"></i> <span>Ventas</span></a></li>
|
||||
<li class="hidden"><a href="#"><i class="fa fa-line-chart"></i> <span>Estadisticas</span></a></li>
|
||||
<li class=""><a href="https://sistema.skeenmx.app/correos " target="_blank"><i class="fa-solid fa-file-excel"></i> <span>Exportar Correos</span></a></li>
|
||||
<li class=""><a href="https://sistema.skeenmx.app/correos/vip " target="_blank"><i class="fa-solid fa-file-excel"></i> <span>Exportar Correos ( VIP )</span></a></li>
|
||||
<li class=""><a href="https://sistema.skeenmx.app/exportar/lista_precios " target="_blank"><i class="fa-solid fa-file-excel"></i> <span>Exportar lista de precios</span></a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="treeview">
|
||||
<a href="#">
|
||||
<i class="fa fa-usd"></i> <span>Administrativo</span> <i
|
||||
class="fa fa-angle-left pull-right"></i>
|
||||
</a>
|
||||
<ul class="treeview-menu">
|
||||
<li><a href="https://sistema.skeenmx.app/reportes/adeudos"><i class="fa fa-credit-card"></i> <span>Adeudos clientes</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/reportes/pagos"><i class="fa-solid fa-money-bill"></i> <span>Pagos | Servicios</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/reportes/clientes"><i class="fa fa-folder-open"></i> <span>Pagos | Clientes</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/reportes/visitas"><i class="fa fa-ban"></i> <span>Visitas</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/reportes/devoluciones"><i class="fa fa-retweet"></i> <span>Devoluciones</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/reportes/vendedores"><i class="fa fa-shopping-basket"></i> <span>Vendedores</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/reportes/recomendaciones"><i class="fa fa-star"></i> <span>Recomendaciones</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/reportes/comisiones"><i class="fa fa-pie-chart"></i> <span>Comisiones</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/reportes/concentrado"><i class="fa fa-line-chart"></i> <span>Concentrado</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/reportes/top/clientes"><i class="fa fa-line-chart"></i> <span>Top ventas</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/reportes/agenda"><i class="fa fa-calendar"></i> <span>Horas agenda</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/reportes/paquetes"><i class="fa fa-box"></i> <span>Paquetes</span></a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="treeview">
|
||||
<a href="#">
|
||||
<i class="fa-regular fa-id-card fa-fw"></i> <span>Monedero</span> <i class="fa fa-angle-left pull-right"></i>
|
||||
</a>
|
||||
<ul class="treeview-menu">
|
||||
<li><a href="https://sistema.skeenmx.app/monederos/consulta"><i class="fa fa-id-card"></i> <span>Monederos</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/reportes/monedero"><i class="fa fa-line-chart"></i> <span>Reporte puntos</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/configuracion/monedero"><i class="fa fa-cogs"></i> <span> Lista de precios</span></a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="treeview">
|
||||
<a href="#">
|
||||
<i class="fa-regular fa-handshake fa-fw"></i> <span>Comunicación</span> <i
|
||||
class="fa fa-angle-left pull-right"></i>
|
||||
</a>
|
||||
<ul class="treeview-menu">
|
||||
<li><a href="https://sistema.skeenmx.app/reportes/cumple"><i class="fa fa-birthday-cake"></i> <span>Cumpleañeros</span></a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class=""><a href="https://sistema.skeenmx.app/comprobantes/ver"><i class="fa fa-ticket"></i> <span>Comprobantes del día</span></a></li>
|
||||
<li class="hidden"><a href="https://sistema.skeenmx.app/comprobantes"><i class="fa fa-ticket"></i> <span>Comprobantes</span></a></li>
|
||||
<li class="hidden"><a href="#"><i class="fa fa-envelope-o"></i> <span>Soporte</span></a></li>
|
||||
|
||||
|
||||
<li class="treeview">
|
||||
<a href="#">
|
||||
<i class="fa fa-solid fa-flask"></i> <span>Consumibles</span> <i
|
||||
class="fa fa-angle-left pull-right"></i>
|
||||
</a>
|
||||
<ul class="treeview-menu">
|
||||
<li><a href="https://sistema.skeenmx.app/consumibles/inventario"><i class="fa fa-flask fa-solid"></i> <span>Inventario actual</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/consumibles/ventas/consulta"><i class="fa fa-fw fa-ticket"></i> <span>Comprobantes</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/consumibles/compras/consulta"><i class="fa-fw fa-solid fa-shopping-bag"></i> <span>Compras</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/consumibles/bajas"><i class="fa-fw fa-solid fa-trash"></i> <span>Bajas</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/consumibles/consulta"><i class="fa-fw fa-solid fa-cog"></i> <span>Consumibles</span></a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li class="hidden treeview">
|
||||
<a href="#">
|
||||
<i class="fa fa-solid fa-handshake"></i> <span>Crm</span> <i
|
||||
class="fa fa-angle-left pull-right"></i>
|
||||
</a>
|
||||
<ul class="treeview-menu">
|
||||
<li><a href="https://sistema.skeenmx.app/crm"><i class="fa fa-solid fa-handshake"></i> <span>Inicio</span></a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<li><a href="https://sistema.skeenmx.app/admin/pedidos/dia"><i class="fa fa-shopping-basket"></i> <span>Ventas del día</span></a></li>
|
||||
<li class="treeview hidden">
|
||||
<a href="#">
|
||||
<i class="fa fa-shopping-basket"></i> <span>Ventas</span> <i
|
||||
class="fa fa-angle-left pull-right"></i>
|
||||
</a>
|
||||
<ul class="treeview-menu">
|
||||
<li>
|
||||
<a href="https://sistema.skeenmx.app/admin/pedidos/factura">Factura</a>
|
||||
<a href="https://sistema.skeenmx.app/admin/pedidos/anticipo">Anticipo</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a href="https://sistema.skeenmx.app/admin/pedidos/clientes"><i class="fa fa-id-card"></i> <span>Clientes</span></a></li>
|
||||
|
||||
</ul>
|
||||
<!-- /.sidebar-menu -->
|
||||
</div>
|
||||
<div role="tabpanel" class="hidden tab-pane " id="tab_menu_expediente">
|
||||
<ul class="sidebar-menu">
|
||||
<li>
|
||||
<a id="btn-tab_menu_default" href="#tab_menu_default" aria-controls="tab_menu_default" role="tab" data-toggle="tab">
|
||||
<i class="fa fa-bars"></i> <span>Menú principal</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- /.sidebar -->
|
||||
</aside>
|
||||
|
||||
<!-- Content Wrapper. Contains page content -->
|
||||
<div class="content-wrapper">
|
||||
<!-- Content Header (Page header) -->
|
||||
<section class="content-header">
|
||||
<h1>
|
||||
<i class="fa fa-fw fa-user-md"></i> Médicos </h1>
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="https://sistema.skeenmx.app/dashboard"><i class="fa fa-home"></i> Inicio</a></li>
|
||||
<li>Médicos</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<!-- Main content -->
|
||||
<section class="content">
|
||||
<!-- Your Page Content Here -->
|
||||
<div id="container">
|
||||
<div class="container">
|
||||
|
||||
<div class="form-group">
|
||||
<a class="btn btn-success" href="https://sistema.skeenmx.app/medicos/nuevo">
|
||||
<i class="fa fa-fw fa-plus"></i> Nuevo
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="">
|
||||
<table id="tabla_consulta" class="table table-hover table-condensed" cellspacing="0" width="100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th><i class="fa-fw fa-regular fa-calendar"></i></th>
|
||||
<th>Nombre</th>
|
||||
<th>Últimos 30 días</th>
|
||||
<th style="width: 145px">Opciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="modal fade modal-theme" id="modal_valoracion_express" tabindex="-1" role="dialog"
|
||||
aria-labelledby="modal_valoracion_expressLabel">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Cerrar">
|
||||
<span aria-hidden="true">×</span></button>
|
||||
<h4 class="modal-title" id="myModalLabel"><i class="fa fa-fw fa-bolt"></i> Valoración Express</h4>
|
||||
</div>
|
||||
<form method="POST" action="https://sistema.skeenmx.app/expedientes/express" accept-charset="UTF-8"><input name="_token" type="hidden" value="aqnBBFsD1z3cGfklS6J3LMwvO4NJFahNkuMduIyl">
|
||||
<div class="modal-body">
|
||||
<input name="status" type="hidden" value="0">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-4">
|
||||
<div class="form-group ">
|
||||
<label for="input_apellido_paterno" class="control-label">Apellido paterno</label>
|
||||
<div class="form-input has-feedback">
|
||||
<input id="input_apellido_paterno" class="form-control" name="apellido_paterno" type="text">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="form-group ">
|
||||
<label for="input_apellido_materno" class="control-label">Apellido materno</label>
|
||||
<div class="form-input has-feedback">
|
||||
<input id="input_apellido_materno" class="form-control" name="apellido_materno" type="text">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="form-group ">
|
||||
<label for="input_nombre" class="control-label">Nombre</label>
|
||||
<div class="form-input has-feedback">
|
||||
<input id="input_nombre" class="form-control" name="nombre" type="text">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-4">
|
||||
<div class="form-group ">
|
||||
<label for="input_celular" class="control-label"><i class="fa fa-fw fa-mobile"></i> Celular</label>
|
||||
<div class="form-input has-feedback">
|
||||
<input id="input_celular" class="form-control" name="celular" type="text">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="form-group ">
|
||||
<label for="input_whatsapp">Whatsapp</label>
|
||||
<div class="form-input has-feedback">
|
||||
<div class="btn-group" data-toggle="buttons">
|
||||
<div class="btn btn-sm btn-primary">
|
||||
<span class="selected"><i class="fa fa-fw fa-check-square"></i></span>
|
||||
<span class="unselected"><i class="fa fa-fw fa-square"></i></span>
|
||||
<input id="input_whatsapp" autocomplete="off" checked="checked" name="whatsapp" type="radio" value="1">
|
||||
<i class="fa-fw fa-brands fa-whatsapp"></i> Whatsapp
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="submit_bar">
|
||||
<div class="row">
|
||||
<div class="col-xs-8 col-sm-4 col-sm-offset-4">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="row">
|
||||
<div class="col-sm-4 col-sm-offset-4">
|
||||
<button class="btn btn-primary btn-block" type="submit">
|
||||
<i class="fa fa-fw fa-check"></i> Enviar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- /.content -->
|
||||
</div>
|
||||
<!-- /.content-wrapper -->
|
||||
|
||||
<!-- Main Footer -->
|
||||
<footer class="main-footer">
|
||||
<!-- To the right -->
|
||||
<div class="pull-right hidden-xs">
|
||||
</div>
|
||||
<!-- Default to the left -->
|
||||
<strong>
|
||||
<a target="_blank" href="https://sistema.skeenmx.app">
|
||||
SKEEN
|
||||
</a>
|
||||
</strong>
|
||||
</footer>
|
||||
|
||||
<!-- Control Sidebar -->
|
||||
<aside class="control-sidebar control-sidebar-light">
|
||||
<div id="chat_usuarios" class="container-fluid">
|
||||
<div class="usuario row hidden " data-id="1" data-nombre="corporativo"
|
||||
data-avatar="https://sistema.skeenmx.app/images/profile.png"
|
||||
data-no_leidas="0">
|
||||
<div class="col-xs-3 avatar">
|
||||
<img src="https://sistema.skeenmx.app/images/profile.png" alt="" class="img-responsive img-circle">
|
||||
</div>
|
||||
<div class="col-xs-9">
|
||||
<div class="nombre">corporativo</div>
|
||||
<div class="departamento">Administrador</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usuario row hidden " data-id="2" data-nombre="soporte prographik"
|
||||
data-avatar="https://sistema.skeenmx.app/images/profile.png"
|
||||
data-no_leidas="0">
|
||||
<div class="col-xs-3 avatar">
|
||||
<img src="https://sistema.skeenmx.app/images/profile.png" alt="" class="img-responsive img-circle">
|
||||
</div>
|
||||
<div class="col-xs-9">
|
||||
<div class="nombre">soporte prographik</div>
|
||||
<div class="departamento">Administrador</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usuario row hidden " data-id="30" data-nombre="Ramos Alejandra"
|
||||
data-avatar="https://sistema.skeenmx.app/images/profile.png"
|
||||
data-no_leidas="0">
|
||||
<div class="col-xs-3 avatar">
|
||||
<img src="https://sistema.skeenmx.app/images/profile.png" alt="" class="img-responsive img-circle">
|
||||
</div>
|
||||
<div class="col-xs-9">
|
||||
<div class="nombre">Ramos Alejandra</div>
|
||||
<div class="departamento">Administrador</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usuario row hidden " data-id="31" data-nombre="Ramirez Neváres Patricia"
|
||||
data-avatar="https://sistema.skeenmx.app/images/profile.png"
|
||||
data-no_leidas="0">
|
||||
<div class="col-xs-3 avatar">
|
||||
<img src="https://sistema.skeenmx.app/images/profile.png" alt="" class="img-responsive img-circle">
|
||||
</div>
|
||||
<div class="col-xs-9">
|
||||
<div class="nombre">Ramirez Neváres Patricia</div>
|
||||
<div class="departamento">Administrador</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usuario row hidden " data-id="32" data-nombre="Ramos Villasaldo Dra. Alejandra"
|
||||
data-avatar="https://sistema.skeenmx.app/images/profile.png"
|
||||
data-no_leidas="0">
|
||||
<div class="col-xs-3 avatar">
|
||||
<img src="https://sistema.skeenmx.app/images/profile.png" alt="" class="img-responsive img-circle">
|
||||
</div>
|
||||
<div class="col-xs-9">
|
||||
<div class="nombre">Ramos Villasaldo Dra. Alejandra</div>
|
||||
<div class="departamento">Médico</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usuario row hidden " data-id="33" data-nombre="Martínez Hernández Dra. Lidia Danira"
|
||||
data-avatar="https://sistema.skeenmx.app/images/profile.png"
|
||||
data-no_leidas="0">
|
||||
<div class="col-xs-3 avatar">
|
||||
<img src="https://sistema.skeenmx.app/images/profile.png" alt="" class="img-responsive img-circle">
|
||||
</div>
|
||||
<div class="col-xs-9">
|
||||
<div class="nombre">Martínez Hernández Dra. Lidia Danira</div>
|
||||
<div class="departamento">Médico</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usuario row hidden " data-id="41" data-nombre="Lara Magaña Luisabelle"
|
||||
data-avatar="https://sistema.skeenmx.app/images/profile.png"
|
||||
data-no_leidas="0">
|
||||
<div class="col-xs-3 avatar">
|
||||
<img src="https://sistema.skeenmx.app/images/profile.png" alt="" class="img-responsive img-circle">
|
||||
</div>
|
||||
<div class="col-xs-9">
|
||||
<div class="nombre">Lara Magaña Luisabelle</div>
|
||||
<div class="departamento">Recepción</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usuario row hidden " data-id="46" data-nombre="Dra. Fernanda Cerecer"
|
||||
data-avatar="https://sistema.skeenmx.app/images/profile.png"
|
||||
data-no_leidas="0">
|
||||
<div class="col-xs-3 avatar">
|
||||
<img src="https://sistema.skeenmx.app/images/profile.png" alt="" class="img-responsive img-circle">
|
||||
</div>
|
||||
<div class="col-xs-9">
|
||||
<div class="nombre">Dra. Fernanda Cerecer</div>
|
||||
<div class="departamento">Médico</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usuario row hidden " data-id="47" data-nombre="SKEEN RECEPCION"
|
||||
data-avatar="https://sistema.skeenmx.app/images/profile.png"
|
||||
data-no_leidas="0">
|
||||
<div class="col-xs-3 avatar">
|
||||
<img src="https://sistema.skeenmx.app/images/profile.png" alt="" class="img-responsive img-circle">
|
||||
</div>
|
||||
<div class="col-xs-9">
|
||||
<div class="nombre">SKEEN RECEPCION</div>
|
||||
<div class="departamento">Recepción</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usuario row hidden " data-id="49" data-nombre="RODRIGUEZ FRIDA"
|
||||
data-avatar="https://sistema.skeenmx.app/images/profile.png"
|
||||
data-no_leidas="0">
|
||||
<div class="col-xs-3 avatar">
|
||||
<img src="https://sistema.skeenmx.app/images/profile.png" alt="" class="img-responsive img-circle">
|
||||
</div>
|
||||
<div class="col-xs-9">
|
||||
<div class="nombre">RODRIGUEZ FRIDA</div>
|
||||
<div class="departamento">Cosmetología</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usuario row hidden " data-id="50" data-nombre="xx RECEPCION1"
|
||||
data-avatar="https://sistema.skeenmx.app/images/profile.png"
|
||||
data-no_leidas="0">
|
||||
<div class="col-xs-3 avatar">
|
||||
<img src="https://sistema.skeenmx.app/images/profile.png" alt="" class="img-responsive img-circle">
|
||||
</div>
|
||||
<div class="col-xs-9">
|
||||
<div class="nombre">xx RECEPCION1</div>
|
||||
<div class="departamento">Recepción</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usuario row hidden " data-id="54" data-nombre="Vega Guzmán Dr. Benjamín Adrián"
|
||||
data-avatar="https://sistema.skeenmx.app/images/profile.png"
|
||||
data-no_leidas="0">
|
||||
<div class="col-xs-3 avatar">
|
||||
<img src="https://sistema.skeenmx.app/images/profile.png" alt="" class="img-responsive img-circle">
|
||||
</div>
|
||||
<div class="col-xs-9">
|
||||
<div class="nombre">Vega Guzmán Dr. Benjamín Adrián</div>
|
||||
<div class="departamento">Médico</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usuario row hidden " data-id="55" data-nombre=". Tablet"
|
||||
data-avatar="https://sistema.skeenmx.app/images/profile.png"
|
||||
data-no_leidas="0">
|
||||
<div class="col-xs-3 avatar">
|
||||
<img src="https://sistema.skeenmx.app/images/profile.png" alt="" class="img-responsive img-circle">
|
||||
</div>
|
||||
<div class="col-xs-9">
|
||||
<div class="nombre">. Tablet</div>
|
||||
<div class="departamento">Tablet comprobantes</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usuario row hidden " data-id="80" data-nombre="CHABOLLA ELIZABETH"
|
||||
data-avatar="https://sistema.skeenmx.app/images/profile.png"
|
||||
data-no_leidas="0">
|
||||
<div class="col-xs-3 avatar">
|
||||
<img src="https://sistema.skeenmx.app/images/profile.png" alt="" class="img-responsive img-circle">
|
||||
</div>
|
||||
<div class="col-xs-9">
|
||||
<div class="nombre">CHABOLLA ELIZABETH</div>
|
||||
<div class="departamento">Cosmetología</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usuario row hidden " data-id="79" data-nombre="VERDUGO XIMENA"
|
||||
data-avatar="https://sistema.skeenmx.app/images/profile.png"
|
||||
data-no_leidas="0">
|
||||
<div class="col-xs-3 avatar">
|
||||
<img src="https://sistema.skeenmx.app/images/profile.png" alt="" class="img-responsive img-circle">
|
||||
</div>
|
||||
<div class="col-xs-9">
|
||||
<div class="nombre">VERDUGO XIMENA</div>
|
||||
<div class="departamento">Cosmetología</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usuario row hidden " data-id="72" data-nombre="GUERREO JASMIN"
|
||||
data-avatar="https://sistema.skeenmx.app/images/profile.png"
|
||||
data-no_leidas="0">
|
||||
<div class="col-xs-3 avatar">
|
||||
<img src="https://sistema.skeenmx.app/images/profile.png" alt="" class="img-responsive img-circle">
|
||||
</div>
|
||||
<div class="col-xs-9">
|
||||
<div class="nombre">GUERREO JASMIN</div>
|
||||
<div class="departamento">Recepción</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<!-- /.control-sidebar -->
|
||||
<!-- Add the sidebar's background. This div must be placed
|
||||
immediately after the control sidebar -->
|
||||
<div class="control-sidebar-bg"></div>
|
||||
</div>
|
||||
<!-- ./wrapper -->
|
||||
|
||||
<div id="chat_conversaciones"></div>
|
||||
<div class="modal fade modal-secondary" id="modal_notas" tabindex="-1" role="dialog"
|
||||
aria-labelledby="modal_notas_label">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Cerrar"><span
|
||||
aria-hidden="true">×</span></button>
|
||||
<h4 class="modal-title" id="modal_notas_label"><i class="fa fa-fw fa-sticky-note"></i> Notas</h4>
|
||||
</div>
|
||||
<div class="formulario_notas_wrap container-fluid">
|
||||
<form method="POST" action="https://sistema.skeenmx.app" accept-charset="UTF-8" id="formulario_notas"><input name="_token" type="hidden" value="aqnBBFsD1z3cGfklS6J3LMwvO4NJFahNkuMduIyl">
|
||||
<div class="row">
|
||||
<div class="col-sm-9">
|
||||
<div class="form-group">
|
||||
<div class="form-input has-feedback">
|
||||
<textarea id="input_nota" class="form-control" rows="2" placeholder="Contenido de la nota:" name="nota" cols="50"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<button class="btn btn-lg btn-primary btn-block" type="submit">
|
||||
<i class="fa fa-fw fa-plus"></i> Agregar nota
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="">
|
||||
<table id="tabla_notas" class="" cellspacing="0" width="100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th><i class="fa fa-fw fa-circle"></i> Ordenar por status</th>
|
||||
<th>Ordenar por fecha</th>
|
||||
<th></th>
|
||||
<th>Ordenar por contenido</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Cerrar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script id="chat_conversation_template" type="text/template">
|
||||
<!-- Construct the box with style you want. Here we are using box-danger -->
|
||||
<!-- Then add the class direct-chat and choose the direct-chat-* contexual class -->
|
||||
<!-- The contextual class should match the box, so we are using direct-chat-danger -->
|
||||
<div class="box box-danger direct-chat direct-chat-danger">
|
||||
<div class="box-header with-border">
|
||||
<h3 class="box-title" data-widget="collapse">
|
||||
<img src="" alt="" class="avatar img-responsive img-circle">
|
||||
<span class="name"></span>
|
||||
</h3>
|
||||
<div class="box-tools pull-right">
|
||||
<span data-toggle="tooltip" title="" class="badge bg-red"></span>
|
||||
<button class="btn btn-sm btn-box-tool" data-widget="remove"><i class="fa fa-times"></i></button>
|
||||
</div>
|
||||
</div><!-- /.box-header -->
|
||||
<div class="box-body">
|
||||
<!-- Conversations are loaded here -->
|
||||
<div class="direct-chat-messages">
|
||||
|
||||
</div><!--/.direct-chat-messages-->
|
||||
</div><!-- /.box-body -->
|
||||
<div class="box-footer">
|
||||
<div class="input-group">
|
||||
<input type="text" name="message" placeholder="Escribe tu mensaje ..."
|
||||
class="form-control input-message">
|
||||
<span class="input-group-btn btn-danger">
|
||||
<button type="button" class="send-message btn btn-danger btn-flat"><i class="fa-fw fa-solid fa-check"></i></button>
|
||||
</span>
|
||||
</div>
|
||||
</div><!-- /.box-footer-->
|
||||
</div><!--/.direct-chat -->
|
||||
</script>
|
||||
<script id="chat_message_template" type="text/template">
|
||||
<div class="direct-chat-msg" data-id="">
|
||||
<div class="direct-chat-info clearfix">
|
||||
<span class="direct-chat-name pull-left"></span>
|
||||
<span class="direct-chat-timestamp pull-right"></span>
|
||||
</div><!-- /.direct-chat-info -->
|
||||
<div class="direct-chat-text"></div><!-- /.direct-chat-text -->
|
||||
</div><!-- /.direct-chat-msg -->
|
||||
</script>
|
||||
|
||||
<script type="text/javascript">
|
||||
var myself_id =78;
|
||||
</script>
|
||||
|
||||
<!-- REQUIRED JS SCRIPTS -->
|
||||
<script type="text/javascript" src="https://sistema.skeenmx.app/vendor/js/moment/moment-with-locales.min.js?001"></script>
|
||||
<script src='https://cdn.rawgit.com/admsev/jquery-play-sound/master/jquery.playSound.js'></script>
|
||||
<script src="https://sistema.skeenmx.app/vendor/js/panel.js?015" type="text/javascript"></script>
|
||||
<!-- AdminLTE App -->
|
||||
<script src="https://sistema.skeenmx.app/vendor/css/AdminLTE-2.3.5/dist/js/app.min.js"></script>
|
||||
|
||||
<!-- Optionally, you can add Slimscroll and FastClick plugins.
|
||||
Both of these plugins are recommended to enhance the
|
||||
user experience. Slimscroll is required when using the
|
||||
fixed layout. -->
|
||||
<script src="https://sistema.skeenmx.app/vendor/js/DataTables/datatables.min.js" type="text/javascript"></script>
|
||||
<script type="text/javascript" src="https://sistema.skeenmx.app/vendor/js/moment/moment-with-locales.min.js"></script>
|
||||
<script src="https://sistema.skeenmx.app/vendor/js/DataTables/datatables.min.js" type="text/javascript"></script>
|
||||
<script>
|
||||
var datatable_json_data =[{"id":0,"created_at":"0000-00-00 00:00:00","status":0,"nombre":"CLINICA","total_recomendaciones":0,"total_servicios":152,"total_pagado_usd":974.29,"total_pagado_mn":45887,"total_pagado_tarjeta_mn":56212.87},{"id":32,"created_at":"2022-11-04 07:22:08","status":null,"nombre":"Ramos Villasaldo Dra. Alejandra","total_recomendaciones":0,"total_servicios":1393,"total_pagado_usd":727.88,"total_pagado_mn":34385,"total_pagado_tarjeta_mn":267939},{"id":33,"created_at":"2022-11-04 07:22:08","status":null,"nombre":"Mart\u00ednez Hern\u00e1ndez Dra. Lidia Danira","total_recomendaciones":0,"total_servicios":894,"total_pagado_usd":811.22,"total_pagado_mn":42400,"total_pagado_tarjeta_mn":131270},{"id":46,"created_at":"2023-09-06 14:53:25","status":null,"nombre":"Dra. Fernanda Cerecer","total_recomendaciones":0,"total_servicios":2,"total_pagado_usd":0,"total_pagado_mn":1600,"total_pagado_tarjeta_mn":1500},{"id":54,"created_at":"2024-04-02 13:11:42","status":null,"nombre":"Vega Guzm\u00e1n Dr. Benjam\u00edn Adri\u00e1n","total_recomendaciones":0,"total_servicios":1272,"total_pagado_usd":3165.2,"total_pagado_mn":89176,"total_pagado_tarjeta_mn":234349}];
|
||||
var datatable_domain_url = "https://sistema.skeenmx.app/";
|
||||
var datatable_user_depto = 1
|
||||
</script>
|
||||
<script src="https://sistema.skeenmx.app/vendor/js/data/datatable_medicos.js?004" type="text/javascript"></script>
|
||||
|
||||
<script>
|
||||
var notas_datatable_json_data =[];
|
||||
var notas_datatable_domain_url = "https://sistema.skeenmx.app/";
|
||||
</script>
|
||||
<script src="https://sistema.skeenmx.app/vendor/js/data/datatable_notas.js?000" type="text/javascript"></script>
|
||||
<script type="text/javascript">
|
||||
$('#btn-tab_menu_default').on('shown.bs.tab', function (e) {
|
||||
$('#btn-tab_menu_expediente').parent().removeClass("active");
|
||||
});
|
||||
$('#btn-tab_menu_expediente').on('shown.bs.tab', function (e) {
|
||||
$('#btn-tab_menu_default').parent().removeClass("active");
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
4001
migracion/extraccion/monederos.csv
Normal file
4001
migracion/extraccion/monederos.csv
Normal file
File diff suppressed because it is too large
Load Diff
845
migracion/extraccion/monederos.html
Normal file
845
migracion/extraccion/monederos.html
Normal file
@@ -0,0 +1,845 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<title></title>
|
||||
<meta name="description" content="">
|
||||
<meta name="csrf-token" content="aqnBBFsD1z3cGfklS6J3LMwvO4NJFahNkuMduIyl"/>
|
||||
<!-- Tell the browser to be responsive to screen width -->
|
||||
<meta content="width=device-width" name="viewport">
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.2.0/css/all.min.css">
|
||||
<!-- Theme style -->
|
||||
<link rel="stylesheet" href="https://sistema.skeenmx.app/vendor/css/AdminLTE-2.3.5/build/less/AdminLTE.css">
|
||||
<link rel="stylesheet" href="https://sistema.skeenmx.app/vendor/css/panel.css?077"/>
|
||||
|
||||
<script src="https://sistema.skeenmx.app/vendor/css/AdminLTE-2.3.5/plugins/jQuery/jquery-2.2.3.min.js"></script>
|
||||
|
||||
<!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
|
||||
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
|
||||
<!--[if lt IE 9]>
|
||||
<script src="https://oss.maxcdn.com/html5shiv/3.7.3/html5shiv.min.js"></script>r
|
||||
<script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
|
||||
<![endif]-->
|
||||
|
||||
</head>
|
||||
<body id="monederos_consulta"
|
||||
class="hold-transition skin-theme sidebar-mini ">
|
||||
<div class="wrapper">
|
||||
|
||||
<!-- Main Header -->
|
||||
<header class="main-header">
|
||||
|
||||
<!-- Logo -->
|
||||
<a href="https://sistema.skeenmx.app/dashboard" class="logo">
|
||||
<!-- mini logo for sidebar mini 50x50 pixels -->
|
||||
<span class="logo-mini">
|
||||
<img class="img-responsive" src="https://sistema.skeenmx.app/images/icono_negro.png" alt="">
|
||||
</span>
|
||||
<!-- logo for regular state and mobile devices -->
|
||||
<span class="logo-lg">
|
||||
<img class="img-responsive" src="https://sistema.skeenmx.app/images/logo_skeen.png" alt=""> </span>
|
||||
</a>
|
||||
|
||||
<!-- Header Navbar -->
|
||||
<nav class="navbar navbar-static-top" role="navigation">
|
||||
<!-- Sidebar toggle button-->
|
||||
<a href="#" class="sidebar-toggle" data-toggle="offcanvas" role="button">
|
||||
<span class="sr-only">Menú</span>
|
||||
</a>
|
||||
<div class="hidden">
|
||||
<ul class="nav navbar-nav">
|
||||
<!-- User Account Menu -->
|
||||
<li class="dropdown">
|
||||
<!-- Menu Toggle Button -->
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown">
|
||||
<i class="fa-solid fa-headset"></i>
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<a href="/crm/medio/1">
|
||||
<i class="fa-solid fa-phone fa-2x"></i> Llamada
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/crm/medio/2">
|
||||
<i class="fa-brands fa-whatsapp fa-2x"></i> Whatsapp
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/crm/medio/3">
|
||||
<i class="fa-brands fa-facebook fa-2x"></i> Facebook
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/crm/medio/4">
|
||||
<i class="fa-brands fa-instagram fa-2x"></i> Instagram
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/crm/medio/5">
|
||||
<i class="fa-solid fa-envelope fa-2x"></i> Email
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/crm/medio/6">
|
||||
<i class="fa-solid fa-globe fa-2x"></i> Sitio web
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Navbar Right Menu -->
|
||||
<div class="navbar-custom-menu">
|
||||
<ul class="nav navbar-nav">
|
||||
<li>
|
||||
<a href="#" class="">
|
||||
TC: <span class="badge bg-black">16.50</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="">
|
||||
<a href="#" class="" data-toggle="modal"
|
||||
data-target="#modal_valoracion_express">
|
||||
<i class="fa fa-fw fa-bolt "></i> Valoración Express
|
||||
</a>
|
||||
</li>
|
||||
<!-- User Account Menu -->
|
||||
<li class="dropdown user user-menu">
|
||||
<!-- Menu Toggle Button -->
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown">
|
||||
<!-- The user image in the navbar-->
|
||||
<img src="https://sistema.skeenmx.app/images/profile.png" class="user-image" alt="User Image">
|
||||
<!-- hidden-xs hides the username on small devices so only the image appears. -->
|
||||
<span class="hidden-xs">José</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<!-- The user image in the menu -->
|
||||
<li class="user-header">
|
||||
<img src="https://sistema.skeenmx.app/images/profile.png" class="img-circle" alt="User Image">
|
||||
|
||||
<p>
|
||||
José
|
||||
<small></small>
|
||||
</p>
|
||||
</li>
|
||||
<!-- Menu Body -->
|
||||
<li class="user-body hidden">
|
||||
<div class="row">
|
||||
<div class="col-xs-4 text-center">
|
||||
<a href="#">Followers</a>
|
||||
</div>
|
||||
<div class="col-xs-4 text-center">
|
||||
<a href="#">Sales</a>
|
||||
</div>
|
||||
<div class="col-xs-4 text-center">
|
||||
<a href="#">Friends</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- /.row -->
|
||||
</li>
|
||||
<!-- Menu Footer-->
|
||||
<li class="user-footer">
|
||||
<div class="row row-no-padding">
|
||||
<div class="col-xs-5">
|
||||
<a href="https://sistema.skeenmx.app/perfil" class="btn btn-primary btn-flat btn-block"><i
|
||||
class="fa fa-fw fa-user"></i> Perfil</a>
|
||||
</div>
|
||||
<div class="col-xs-1"></div>
|
||||
<div class="col-xs-6 text-right">
|
||||
<a href="https://sistema.skeenmx.app/logout" class="btn btn-default btn-flat">Cerrar sesión</a>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="dropdown user user-menu hidden">
|
||||
<!-- Menu Toggle Button -->
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown">
|
||||
<i class="fa fa-trophy"></i>
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li class="text-center container-fluid">
|
||||
<div>Meta Noviembre 2020</div>
|
||||
<div class="progress">
|
||||
<div class="progress-bar" role="progressbar" aria-valuenow="60" aria-valuemin="0" aria-valuemax="100" style="width: 60%;">
|
||||
<span class="sr-only">60% Complete</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
600 USD / 1000 USD
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="">
|
||||
<a class="" href="#" data-toggle="modal" data-target="#modal_notas">
|
||||
<i class="fa fa-sticky-note"></i>
|
||||
</a>
|
||||
</li>
|
||||
<!-- Control Sidebar Toggle Button -->
|
||||
<li class="">
|
||||
<a class="total_no_leidas" href="#" data-toggle="control-sidebar">
|
||||
<i class="fa fa-comments"></i>
|
||||
</a>
|
||||
</li>
|
||||
<li><a href="https://sistema.skeenmx.app/consumibles/suministrar"><i class="fa fa-flask"></i></a></li>
|
||||
<li class="">
|
||||
<a class="" href="https://sistema.skeenmx.app/admin/sesiones/agregar">
|
||||
<i class="fa fa-diamond"></i>
|
||||
</a>
|
||||
</li>
|
||||
<li class="">
|
||||
<a class="" href="https://sistema.skeenmx.app/recomendar">
|
||||
<i class="fa fa-star"></i>
|
||||
</a>
|
||||
</li>
|
||||
<li class="">
|
||||
<a class="" href="https://sistema.skeenmx.app/admin/pedidos/agregar">
|
||||
<i class="fa fa-shopping-cart"></i>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<!-- Left side column. contains the logo and sidebar -->
|
||||
<aside class="main-sidebar">
|
||||
|
||||
<!-- sidebar: style can be found in sidebar.less -->
|
||||
<section class="sidebar">
|
||||
<div>
|
||||
<!-- Tab panes -->
|
||||
<div class="tab-content">
|
||||
<div role="tabpanel" class="tab-pane active " id="tab_menu_default">
|
||||
<ul class="sidebar-menu">
|
||||
<li class="header" style="color: #fff; font-size: 16px; text-align: center; font-weight: bold; border-bottom: solid 1px #666;">
|
||||
Rosarito
|
||||
</li>
|
||||
|
||||
<!-- Optionally, you can add icons to the links -->
|
||||
|
||||
<li><a href="https://sistema.skeenmx.app/dashboard"><i class="fa fa-home"></i> <span>Inicio</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/agenda"><i class="fa-regular fa-fw fa-calendar"></i> <span>Agenda</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/visitas"><i class="fa-solid fa-fw fa-users"></i> <span>Visitas</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/expedientes"><i class="fa fa-folder-open"></i>
|
||||
<span>Expedientes</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/medicos"><i class="fa fa-user-md"></i>
|
||||
<span>Médicos</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/cosmetologos"><i class="fa fa-bed"></i>
|
||||
<span>Cosmetología</span></a></li>
|
||||
<li class="hidden">
|
||||
<a href="https://sistema.skeenmx.app/consultas" class="">
|
||||
<i class="fa fa-fw fa-user-md"></i> <span>Consultas Médicas</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="treeview">
|
||||
<a href="#">
|
||||
<i class="fa fa-cogs"></i> <span>Configuración</span> <i
|
||||
class="fa fa-angle-left pull-right"></i>
|
||||
</a>
|
||||
<ul class="treeview-menu">
|
||||
<li><a href="https://sistema.skeenmx.app/usuarios/consulta/activados"><i class="fa fa-user-md"></i> <span>Usuarios</span></a>
|
||||
</li>
|
||||
<li><a href="https://sistema.skeenmx.app/recetas"><i class="fa fa-list-alt"></i> <span>Recetas</span></a>
|
||||
</li>
|
||||
<li><a href="https://sistema.skeenmx.app/diagnosticos"><i class="fa fa-medkit"></i>
|
||||
<span>Diagnósticos</span></a></li>
|
||||
<li class="hidden"><a href="https://sistema.skeenmx.app/procedimientos"><i class="fa fa-stethoscope"></i> <span>Procedimientos médicos</span></a>
|
||||
<li><a href="https://sistema.skeenmx.app/servicios"><i class="fa fa-star"></i> <span>Servicios</span></a>
|
||||
<li><a href="https://sistema.skeenmx.app/etiquetas"><i class="fa fa-tags"></i> <span>Etiquetas</span></a>
|
||||
<li><a href="https://sistema.skeenmx.app/productos"><i class="fa fa-flask"></i> <span>Productos</span></a>
|
||||
</li>
|
||||
<li><a href="https://sistema.skeenmx.app/inventarios"><i class="fa fa-cube"></i>
|
||||
<span>Inventarios</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/tipo_cambio"><i class="fa fa-usd"></i> <span>Tipo de cambio</span></a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="treeview">
|
||||
<a href="#">
|
||||
<i class="fa fa-pie-chart"></i> <span>Reportes</span> <i
|
||||
class="fa fa-angle-left pull-right"></i>
|
||||
</a>
|
||||
<ul class="treeview-menu">
|
||||
<li><a href="https://sistema.skeenmx.app/reportes/diario"><i class="fa fa-calendar"></i>
|
||||
<span>Mov. diario</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/reportes/medicos"><i class="fa fa-calendar"></i> <span>Mov. diario - Médicos</span></a>
|
||||
</li>
|
||||
<li><a href="https://sistema.skeenmx.app/reportes/recepcion"><i class="fa fa-calendar"></i> <span>Mov. diario - Recepción</span></a>
|
||||
</li>
|
||||
<li><a href="https://sistema.skeenmx.app/reportes/cortes"><i class="fa fa-book"></i> <span>Cortes de caja</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/reportes/ingresos"><i class="fa fa-line-chart"></i> <span>Ingresos</span></a></li>
|
||||
<li class="hidden"><a href="#"><i class="fa fa-star-o"></i> <span>Especialidades</span></a></li>
|
||||
<li class="hidden"><a href="https://sistema.skeenmx.app/reportes/comisiones"><i class="fa-solid fa-money-bill"></i>
|
||||
<span>Comisiones</span></a></li>
|
||||
<li class="hidden"><a href="#"><i class="fa fa-check"></i> <span>Ventas</span></a></li>
|
||||
<li class="hidden"><a href="#"><i class="fa fa-line-chart"></i> <span>Estadisticas</span></a></li>
|
||||
<li class=""><a href="https://sistema.skeenmx.app/correos " target="_blank"><i class="fa-solid fa-file-excel"></i> <span>Exportar Correos</span></a></li>
|
||||
<li class=""><a href="https://sistema.skeenmx.app/correos/vip " target="_blank"><i class="fa-solid fa-file-excel"></i> <span>Exportar Correos ( VIP )</span></a></li>
|
||||
<li class=""><a href="https://sistema.skeenmx.app/exportar/lista_precios " target="_blank"><i class="fa-solid fa-file-excel"></i> <span>Exportar lista de precios</span></a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="treeview">
|
||||
<a href="#">
|
||||
<i class="fa fa-usd"></i> <span>Administrativo</span> <i
|
||||
class="fa fa-angle-left pull-right"></i>
|
||||
</a>
|
||||
<ul class="treeview-menu">
|
||||
<li><a href="https://sistema.skeenmx.app/reportes/adeudos"><i class="fa fa-credit-card"></i> <span>Adeudos clientes</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/reportes/pagos"><i class="fa-solid fa-money-bill"></i> <span>Pagos | Servicios</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/reportes/clientes"><i class="fa fa-folder-open"></i> <span>Pagos | Clientes</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/reportes/visitas"><i class="fa fa-ban"></i> <span>Visitas</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/reportes/devoluciones"><i class="fa fa-retweet"></i> <span>Devoluciones</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/reportes/vendedores"><i class="fa fa-shopping-basket"></i> <span>Vendedores</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/reportes/recomendaciones"><i class="fa fa-star"></i> <span>Recomendaciones</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/reportes/comisiones"><i class="fa fa-pie-chart"></i> <span>Comisiones</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/reportes/concentrado"><i class="fa fa-line-chart"></i> <span>Concentrado</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/reportes/top/clientes"><i class="fa fa-line-chart"></i> <span>Top ventas</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/reportes/agenda"><i class="fa fa-calendar"></i> <span>Horas agenda</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/reportes/paquetes"><i class="fa fa-box"></i> <span>Paquetes</span></a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="treeview">
|
||||
<a href="#">
|
||||
<i class="fa-regular fa-id-card fa-fw"></i> <span>Monedero</span> <i class="fa fa-angle-left pull-right"></i>
|
||||
</a>
|
||||
<ul class="treeview-menu">
|
||||
<li><a href="https://sistema.skeenmx.app/monederos/consulta"><i class="fa fa-id-card"></i> <span>Monederos</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/reportes/monedero"><i class="fa fa-line-chart"></i> <span>Reporte puntos</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/configuracion/monedero"><i class="fa fa-cogs"></i> <span> Lista de precios</span></a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="treeview">
|
||||
<a href="#">
|
||||
<i class="fa-regular fa-handshake fa-fw"></i> <span>Comunicación</span> <i
|
||||
class="fa fa-angle-left pull-right"></i>
|
||||
</a>
|
||||
<ul class="treeview-menu">
|
||||
<li><a href="https://sistema.skeenmx.app/reportes/cumple"><i class="fa fa-birthday-cake"></i> <span>Cumpleañeros</span></a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class=""><a href="https://sistema.skeenmx.app/comprobantes/ver"><i class="fa fa-ticket"></i> <span>Comprobantes del día</span></a></li>
|
||||
<li class="hidden"><a href="https://sistema.skeenmx.app/comprobantes"><i class="fa fa-ticket"></i> <span>Comprobantes</span></a></li>
|
||||
<li class="hidden"><a href="#"><i class="fa fa-envelope-o"></i> <span>Soporte</span></a></li>
|
||||
|
||||
|
||||
<li class="treeview">
|
||||
<a href="#">
|
||||
<i class="fa fa-solid fa-flask"></i> <span>Consumibles</span> <i
|
||||
class="fa fa-angle-left pull-right"></i>
|
||||
</a>
|
||||
<ul class="treeview-menu">
|
||||
<li><a href="https://sistema.skeenmx.app/consumibles/inventario"><i class="fa fa-flask fa-solid"></i> <span>Inventario actual</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/consumibles/ventas/consulta"><i class="fa fa-fw fa-ticket"></i> <span>Comprobantes</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/consumibles/compras/consulta"><i class="fa-fw fa-solid fa-shopping-bag"></i> <span>Compras</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/consumibles/bajas"><i class="fa-fw fa-solid fa-trash"></i> <span>Bajas</span></a></li>
|
||||
<li><a href="https://sistema.skeenmx.app/consumibles/consulta"><i class="fa-fw fa-solid fa-cog"></i> <span>Consumibles</span></a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li class="hidden treeview">
|
||||
<a href="#">
|
||||
<i class="fa fa-solid fa-handshake"></i> <span>Crm</span> <i
|
||||
class="fa fa-angle-left pull-right"></i>
|
||||
</a>
|
||||
<ul class="treeview-menu">
|
||||
<li><a href="https://sistema.skeenmx.app/crm"><i class="fa fa-solid fa-handshake"></i> <span>Inicio</span></a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<li><a href="https://sistema.skeenmx.app/admin/pedidos/dia"><i class="fa fa-shopping-basket"></i> <span>Ventas del día</span></a></li>
|
||||
<li class="treeview hidden">
|
||||
<a href="#">
|
||||
<i class="fa fa-shopping-basket"></i> <span>Ventas</span> <i
|
||||
class="fa fa-angle-left pull-right"></i>
|
||||
</a>
|
||||
<ul class="treeview-menu">
|
||||
<li>
|
||||
<a href="https://sistema.skeenmx.app/admin/pedidos/factura">Factura</a>
|
||||
<a href="https://sistema.skeenmx.app/admin/pedidos/anticipo">Anticipo</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a href="https://sistema.skeenmx.app/admin/pedidos/clientes"><i class="fa fa-id-card"></i> <span>Clientes</span></a></li>
|
||||
|
||||
</ul>
|
||||
<!-- /.sidebar-menu -->
|
||||
</div>
|
||||
<div role="tabpanel" class="hidden tab-pane " id="tab_menu_expediente">
|
||||
<ul class="sidebar-menu">
|
||||
<li>
|
||||
<a id="btn-tab_menu_default" href="#tab_menu_default" aria-controls="tab_menu_default" role="tab" data-toggle="tab">
|
||||
<i class="fa fa-bars"></i> <span>Menú principal</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- /.sidebar -->
|
||||
</aside>
|
||||
|
||||
<!-- Content Wrapper. Contains page content -->
|
||||
<div class="content-wrapper">
|
||||
<!-- Content Header (Page header) -->
|
||||
<section class="content-header">
|
||||
<h1>
|
||||
<i class="fa fa-fw fa-id-card"></i> Monederos electrónicos </h1>
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="https://sistema.skeenmx.app/dashboard"><i class="fa fa-home"></i> Inicio</a></li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<!-- Main content -->
|
||||
<section class="content">
|
||||
<!-- Your Page Content Here -->
|
||||
<div id="container">
|
||||
<div class="container">
|
||||
<div id="totales"></div>
|
||||
|
||||
<div class="">
|
||||
<table id="tabla_consulta" class="table table-hover table-condensed" cellspacing="0" width="100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th><i class="fa fa-fw fa-calendar-o"></i> Fecha activación</th>
|
||||
<th>Nombre</th>
|
||||
<th><i class="fa fa-fw fa-mobile"></i></th>
|
||||
<th>Saldo</th>
|
||||
<th><i class="fa fa-fw fa-toggle-on"></i> Activado</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="modal fade modal-theme" id="modal_valoracion_express" tabindex="-1" role="dialog"
|
||||
aria-labelledby="modal_valoracion_expressLabel">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Cerrar">
|
||||
<span aria-hidden="true">×</span></button>
|
||||
<h4 class="modal-title" id="myModalLabel"><i class="fa fa-fw fa-bolt"></i> Valoración Express</h4>
|
||||
</div>
|
||||
<form method="POST" action="https://sistema.skeenmx.app/expedientes/express" accept-charset="UTF-8"><input name="_token" type="hidden" value="aqnBBFsD1z3cGfklS6J3LMwvO4NJFahNkuMduIyl">
|
||||
<div class="modal-body">
|
||||
<input name="status" type="hidden" value="0">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-4">
|
||||
<div class="form-group ">
|
||||
<label for="input_apellido_paterno" class="control-label">Apellido paterno</label>
|
||||
<div class="form-input has-feedback">
|
||||
<input id="input_apellido_paterno" class="form-control" name="apellido_paterno" type="text">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="form-group ">
|
||||
<label for="input_apellido_materno" class="control-label">Apellido materno</label>
|
||||
<div class="form-input has-feedback">
|
||||
<input id="input_apellido_materno" class="form-control" name="apellido_materno" type="text">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="form-group ">
|
||||
<label for="input_nombre" class="control-label">Nombre</label>
|
||||
<div class="form-input has-feedback">
|
||||
<input id="input_nombre" class="form-control" name="nombre" type="text">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-4">
|
||||
<div class="form-group ">
|
||||
<label for="input_celular" class="control-label"><i class="fa fa-fw fa-mobile"></i> Celular</label>
|
||||
<div class="form-input has-feedback">
|
||||
<input id="input_celular" class="form-control" name="celular" type="text">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="form-group ">
|
||||
<label for="input_whatsapp">Whatsapp</label>
|
||||
<div class="form-input has-feedback">
|
||||
<div class="btn-group" data-toggle="buttons">
|
||||
<div class="btn btn-sm btn-primary">
|
||||
<span class="selected"><i class="fa fa-fw fa-check-square"></i></span>
|
||||
<span class="unselected"><i class="fa fa-fw fa-square"></i></span>
|
||||
<input id="input_whatsapp" autocomplete="off" checked="checked" name="whatsapp" type="radio" value="1">
|
||||
<i class="fa-fw fa-brands fa-whatsapp"></i> Whatsapp
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="submit_bar">
|
||||
<div class="row">
|
||||
<div class="col-xs-8 col-sm-4 col-sm-offset-4">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="row">
|
||||
<div class="col-sm-4 col-sm-offset-4">
|
||||
<button class="btn btn-primary btn-block" type="submit">
|
||||
<i class="fa fa-fw fa-check"></i> Enviar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- /.content -->
|
||||
</div>
|
||||
<!-- /.content-wrapper -->
|
||||
|
||||
<!-- Main Footer -->
|
||||
<footer class="main-footer">
|
||||
<!-- To the right -->
|
||||
<div class="pull-right hidden-xs">
|
||||
</div>
|
||||
<!-- Default to the left -->
|
||||
<strong>
|
||||
<a target="_blank" href="https://sistema.skeenmx.app">
|
||||
SKEEN
|
||||
</a>
|
||||
</strong>
|
||||
</footer>
|
||||
|
||||
<!-- Control Sidebar -->
|
||||
<aside class="control-sidebar control-sidebar-light">
|
||||
<div id="chat_usuarios" class="container-fluid">
|
||||
<div class="usuario row hidden " data-id="1" data-nombre="corporativo"
|
||||
data-avatar="https://sistema.skeenmx.app/images/profile.png"
|
||||
data-no_leidas="0">
|
||||
<div class="col-xs-3 avatar">
|
||||
<img src="https://sistema.skeenmx.app/images/profile.png" alt="" class="img-responsive img-circle">
|
||||
</div>
|
||||
<div class="col-xs-9">
|
||||
<div class="nombre">corporativo</div>
|
||||
<div class="departamento">Administrador</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usuario row hidden " data-id="2" data-nombre="soporte prographik"
|
||||
data-avatar="https://sistema.skeenmx.app/images/profile.png"
|
||||
data-no_leidas="0">
|
||||
<div class="col-xs-3 avatar">
|
||||
<img src="https://sistema.skeenmx.app/images/profile.png" alt="" class="img-responsive img-circle">
|
||||
</div>
|
||||
<div class="col-xs-9">
|
||||
<div class="nombre">soporte prographik</div>
|
||||
<div class="departamento">Administrador</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usuario row hidden " data-id="30" data-nombre="Ramos Alejandra"
|
||||
data-avatar="https://sistema.skeenmx.app/images/profile.png"
|
||||
data-no_leidas="0">
|
||||
<div class="col-xs-3 avatar">
|
||||
<img src="https://sistema.skeenmx.app/images/profile.png" alt="" class="img-responsive img-circle">
|
||||
</div>
|
||||
<div class="col-xs-9">
|
||||
<div class="nombre">Ramos Alejandra</div>
|
||||
<div class="departamento">Administrador</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usuario row hidden " data-id="31" data-nombre="Ramirez Neváres Patricia"
|
||||
data-avatar="https://sistema.skeenmx.app/images/profile.png"
|
||||
data-no_leidas="0">
|
||||
<div class="col-xs-3 avatar">
|
||||
<img src="https://sistema.skeenmx.app/images/profile.png" alt="" class="img-responsive img-circle">
|
||||
</div>
|
||||
<div class="col-xs-9">
|
||||
<div class="nombre">Ramirez Neváres Patricia</div>
|
||||
<div class="departamento">Administrador</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usuario row hidden " data-id="32" data-nombre="Ramos Villasaldo Dra. Alejandra"
|
||||
data-avatar="https://sistema.skeenmx.app/images/profile.png"
|
||||
data-no_leidas="0">
|
||||
<div class="col-xs-3 avatar">
|
||||
<img src="https://sistema.skeenmx.app/images/profile.png" alt="" class="img-responsive img-circle">
|
||||
</div>
|
||||
<div class="col-xs-9">
|
||||
<div class="nombre">Ramos Villasaldo Dra. Alejandra</div>
|
||||
<div class="departamento">Médico</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usuario row hidden " data-id="33" data-nombre="Martínez Hernández Dra. Lidia Danira"
|
||||
data-avatar="https://sistema.skeenmx.app/images/profile.png"
|
||||
data-no_leidas="0">
|
||||
<div class="col-xs-3 avatar">
|
||||
<img src="https://sistema.skeenmx.app/images/profile.png" alt="" class="img-responsive img-circle">
|
||||
</div>
|
||||
<div class="col-xs-9">
|
||||
<div class="nombre">Martínez Hernández Dra. Lidia Danira</div>
|
||||
<div class="departamento">Médico</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usuario row hidden " data-id="41" data-nombre="Lara Magaña Luisabelle"
|
||||
data-avatar="https://sistema.skeenmx.app/images/profile.png"
|
||||
data-no_leidas="0">
|
||||
<div class="col-xs-3 avatar">
|
||||
<img src="https://sistema.skeenmx.app/images/profile.png" alt="" class="img-responsive img-circle">
|
||||
</div>
|
||||
<div class="col-xs-9">
|
||||
<div class="nombre">Lara Magaña Luisabelle</div>
|
||||
<div class="departamento">Recepción</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usuario row hidden " data-id="46" data-nombre="Dra. Fernanda Cerecer"
|
||||
data-avatar="https://sistema.skeenmx.app/images/profile.png"
|
||||
data-no_leidas="0">
|
||||
<div class="col-xs-3 avatar">
|
||||
<img src="https://sistema.skeenmx.app/images/profile.png" alt="" class="img-responsive img-circle">
|
||||
</div>
|
||||
<div class="col-xs-9">
|
||||
<div class="nombre">Dra. Fernanda Cerecer</div>
|
||||
<div class="departamento">Médico</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usuario row hidden " data-id="47" data-nombre="SKEEN RECEPCION"
|
||||
data-avatar="https://sistema.skeenmx.app/images/profile.png"
|
||||
data-no_leidas="0">
|
||||
<div class="col-xs-3 avatar">
|
||||
<img src="https://sistema.skeenmx.app/images/profile.png" alt="" class="img-responsive img-circle">
|
||||
</div>
|
||||
<div class="col-xs-9">
|
||||
<div class="nombre">SKEEN RECEPCION</div>
|
||||
<div class="departamento">Recepción</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usuario row hidden " data-id="49" data-nombre="RODRIGUEZ FRIDA"
|
||||
data-avatar="https://sistema.skeenmx.app/images/profile.png"
|
||||
data-no_leidas="0">
|
||||
<div class="col-xs-3 avatar">
|
||||
<img src="https://sistema.skeenmx.app/images/profile.png" alt="" class="img-responsive img-circle">
|
||||
</div>
|
||||
<div class="col-xs-9">
|
||||
<div class="nombre">RODRIGUEZ FRIDA</div>
|
||||
<div class="departamento">Cosmetología</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usuario row hidden " data-id="50" data-nombre="xx RECEPCION1"
|
||||
data-avatar="https://sistema.skeenmx.app/images/profile.png"
|
||||
data-no_leidas="0">
|
||||
<div class="col-xs-3 avatar">
|
||||
<img src="https://sistema.skeenmx.app/images/profile.png" alt="" class="img-responsive img-circle">
|
||||
</div>
|
||||
<div class="col-xs-9">
|
||||
<div class="nombre">xx RECEPCION1</div>
|
||||
<div class="departamento">Recepción</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usuario row hidden " data-id="54" data-nombre="Vega Guzmán Dr. Benjamín Adrián"
|
||||
data-avatar="https://sistema.skeenmx.app/images/profile.png"
|
||||
data-no_leidas="0">
|
||||
<div class="col-xs-3 avatar">
|
||||
<img src="https://sistema.skeenmx.app/images/profile.png" alt="" class="img-responsive img-circle">
|
||||
</div>
|
||||
<div class="col-xs-9">
|
||||
<div class="nombre">Vega Guzmán Dr. Benjamín Adrián</div>
|
||||
<div class="departamento">Médico</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usuario row hidden " data-id="55" data-nombre=". Tablet"
|
||||
data-avatar="https://sistema.skeenmx.app/images/profile.png"
|
||||
data-no_leidas="0">
|
||||
<div class="col-xs-3 avatar">
|
||||
<img src="https://sistema.skeenmx.app/images/profile.png" alt="" class="img-responsive img-circle">
|
||||
</div>
|
||||
<div class="col-xs-9">
|
||||
<div class="nombre">. Tablet</div>
|
||||
<div class="departamento">Tablet comprobantes</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usuario row hidden " data-id="80" data-nombre="CHABOLLA ELIZABETH"
|
||||
data-avatar="https://sistema.skeenmx.app/images/profile.png"
|
||||
data-no_leidas="0">
|
||||
<div class="col-xs-3 avatar">
|
||||
<img src="https://sistema.skeenmx.app/images/profile.png" alt="" class="img-responsive img-circle">
|
||||
</div>
|
||||
<div class="col-xs-9">
|
||||
<div class="nombre">CHABOLLA ELIZABETH</div>
|
||||
<div class="departamento">Cosmetología</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usuario row hidden " data-id="79" data-nombre="VERDUGO XIMENA"
|
||||
data-avatar="https://sistema.skeenmx.app/images/profile.png"
|
||||
data-no_leidas="0">
|
||||
<div class="col-xs-3 avatar">
|
||||
<img src="https://sistema.skeenmx.app/images/profile.png" alt="" class="img-responsive img-circle">
|
||||
</div>
|
||||
<div class="col-xs-9">
|
||||
<div class="nombre">VERDUGO XIMENA</div>
|
||||
<div class="departamento">Cosmetología</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usuario row hidden " data-id="72" data-nombre="GUERREO JASMIN"
|
||||
data-avatar="https://sistema.skeenmx.app/images/profile.png"
|
||||
data-no_leidas="0">
|
||||
<div class="col-xs-3 avatar">
|
||||
<img src="https://sistema.skeenmx.app/images/profile.png" alt="" class="img-responsive img-circle">
|
||||
</div>
|
||||
<div class="col-xs-9">
|
||||
<div class="nombre">GUERREO JASMIN</div>
|
||||
<div class="departamento">Recepción</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<!-- /.control-sidebar -->
|
||||
<!-- Add the sidebar's background. This div must be placed
|
||||
immediately after the control sidebar -->
|
||||
<div class="control-sidebar-bg"></div>
|
||||
</div>
|
||||
<!-- ./wrapper -->
|
||||
|
||||
<div id="chat_conversaciones"></div>
|
||||
<div class="modal fade modal-secondary" id="modal_notas" tabindex="-1" role="dialog"
|
||||
aria-labelledby="modal_notas_label">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Cerrar"><span
|
||||
aria-hidden="true">×</span></button>
|
||||
<h4 class="modal-title" id="modal_notas_label"><i class="fa fa-fw fa-sticky-note"></i> Notas</h4>
|
||||
</div>
|
||||
<div class="formulario_notas_wrap container-fluid">
|
||||
<form method="POST" action="https://sistema.skeenmx.app" accept-charset="UTF-8" id="formulario_notas"><input name="_token" type="hidden" value="aqnBBFsD1z3cGfklS6J3LMwvO4NJFahNkuMduIyl">
|
||||
<div class="row">
|
||||
<div class="col-sm-9">
|
||||
<div class="form-group">
|
||||
<div class="form-input has-feedback">
|
||||
<textarea id="input_nota" class="form-control" rows="2" placeholder="Contenido de la nota:" name="nota" cols="50"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<button class="btn btn-lg btn-primary btn-block" type="submit">
|
||||
<i class="fa fa-fw fa-plus"></i> Agregar nota
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="">
|
||||
<table id="tabla_notas" class="" cellspacing="0" width="100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th><i class="fa fa-fw fa-circle"></i> Ordenar por status</th>
|
||||
<th>Ordenar por fecha</th>
|
||||
<th></th>
|
||||
<th>Ordenar por contenido</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Cerrar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script id="chat_conversation_template" type="text/template">
|
||||
<!-- Construct the box with style you want. Here we are using box-danger -->
|
||||
<!-- Then add the class direct-chat and choose the direct-chat-* contexual class -->
|
||||
<!-- The contextual class should match the box, so we are using direct-chat-danger -->
|
||||
<div class="box box-danger direct-chat direct-chat-danger">
|
||||
<div class="box-header with-border">
|
||||
<h3 class="box-title" data-widget="collapse">
|
||||
<img src="" alt="" class="avatar img-responsive img-circle">
|
||||
<span class="name"></span>
|
||||
</h3>
|
||||
<div class="box-tools pull-right">
|
||||
<span data-toggle="tooltip" title="" class="badge bg-red"></span>
|
||||
<button class="btn btn-sm btn-box-tool" data-widget="remove"><i class="fa fa-times"></i></button>
|
||||
</div>
|
||||
</div><!-- /.box-header -->
|
||||
<div class="box-body">
|
||||
<!-- Conversations are loaded here -->
|
||||
<div class="direct-chat-messages">
|
||||
|
||||
</div><!--/.direct-chat-messages-->
|
||||
</div><!-- /.box-body -->
|
||||
<div class="box-footer">
|
||||
<div class="input-group">
|
||||
<input type="text" name="message" placeholder="Escribe tu mensaje ..."
|
||||
class="form-control input-message">
|
||||
<span class="input-group-btn btn-danger">
|
||||
<button type="button" class="send-message btn btn-danger btn-flat"><i class="fa-fw fa-solid fa-check"></i></button>
|
||||
</span>
|
||||
</div>
|
||||
</div><!-- /.box-footer-->
|
||||
</div><!--/.direct-chat -->
|
||||
</script>
|
||||
<script id="chat_message_template" type="text/template">
|
||||
<div class="direct-chat-msg" data-id="">
|
||||
<div class="direct-chat-info clearfix">
|
||||
<span class="direct-chat-name pull-left"></span>
|
||||
<span class="direct-chat-timestamp pull-right"></span>
|
||||
</div><!-- /.direct-chat-info -->
|
||||
<div class="direct-chat-text"></div><!-- /.direct-chat-text -->
|
||||
</div><!-- /.direct-chat-msg -->
|
||||
</script>
|
||||
|
||||
<script type="text/javascript">
|
||||
var myself_id =78;
|
||||
</script>
|
||||
|
||||
<!-- REQUIRED JS SCRIPTS -->
|
||||
<script type="text/javascript" src="https://sistema.skeenmx.app/vendor/js/moment/moment-with-locales.min.js?001"></script>
|
||||
<script src='https://cdn.rawgit.com/admsev/jquery-play-sound/master/jquery.playSound.js'></script>
|
||||
<script src="https://sistema.skeenmx.app/vendor/js/panel.js?015" type="text/javascript"></script>
|
||||
<!-- AdminLTE App -->
|
||||
<script src="https://sistema.skeenmx.app/vendor/css/AdminLTE-2.3.5/dist/js/app.min.js"></script>
|
||||
|
||||
<!-- Optionally, you can add Slimscroll and FastClick plugins.
|
||||
Both of these plugins are recommended to enhance the
|
||||
user experience. Slimscroll is required when using the
|
||||
fixed layout. -->
|
||||
<script src="https://sistema.skeenmx.app/vendor/js/DataTables/datatables.min.js" type="text/javascript"></script>
|
||||
<script src="https://sistema.skeenmx.app/vendor/js/data/datatable_monederos.js?003" type="text/javascript"></script>
|
||||
|
||||
<script>
|
||||
var notas_datatable_json_data =[];
|
||||
var notas_datatable_domain_url = "https://sistema.skeenmx.app/";
|
||||
</script>
|
||||
<script src="https://sistema.skeenmx.app/vendor/js/data/datatable_notas.js?000" type="text/javascript"></script>
|
||||
<script type="text/javascript">
|
||||
$('#btn-tab_menu_default').on('shown.bs.tab', function (e) {
|
||||
$('#btn-tab_menu_expediente').parent().removeClass("active");
|
||||
});
|
||||
$('#btn-tab_menu_expediente').on('shown.bs.tab', function (e) {
|
||||
$('#btn-tab_menu_default').parent().removeClass("active");
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
8146
migracion/extraccion/pacientes.csv
Normal file
8146
migracion/extraccion/pacientes.csv
Normal file
File diff suppressed because it is too large
Load Diff
1269
migracion/extraccion/pacientes.html
Normal file
1269
migracion/extraccion/pacientes.html
Normal file
File diff suppressed because it is too large
Load Diff
1063
migracion/extraccion/productos.html
Normal file
1063
migracion/extraccion/productos.html
Normal file
File diff suppressed because it is too large
Load Diff
294
migracion/extraccion/servicios.csv
Normal file
294
migracion/extraccion/servicios.csv
Normal file
@@ -0,0 +1,294 @@
|
||||
codigo,nombre,categoria,precio,duracion_min,descripcion,color,grupo,favorito,activo
|
||||
S0001,Consulta,servicios_medicos,1500.0,30,Tratamientos,c119,Tratamientos,1,1
|
||||
S0002,Acné 1ra vez,servicios_medicos,900.0,30,Tratamientos,c40,Tratamientos,0,1
|
||||
S0003,Nutrición consulta,servicios_medicos,800.0,30,Tratamientos,verdelimon03,Tratamientos,0,1
|
||||
S0004,Asesoria skincare,servicios_medicos,600.0,30,Tratamientos,verdelimon03,Tratamientos,0,1
|
||||
S0005,Valoración,servicios_medicos,600.0,30,Tratamientos,verdelimon03,Tratamientos,0,1
|
||||
S0006,Crioterapia,servicios_medicos,2200.0,30,Tratamientos,verdelimon03,Tratamientos,0,1
|
||||
S0007,Retiro de verrugas,servicios_medicos,2500.0,30,Tratamientos,verdelimon03,Tratamientos,0,1
|
||||
S0008,Revisión,servicios_medicos,0.0,30,Tratamientos,c73,Tratamientos,0,1
|
||||
S0009,Retiro de quistes,servicios_medicos,10000.0,30,Tratamientos,verdelimon03,Tratamientos,0,1
|
||||
S0010,Biopsia por sacabocado,servicios_medicos,4200.0,30,Tratamientos,verdelimon03,Tratamientos,0,1
|
||||
S0011,Biopsias,servicios_medicos,5200.0,30,Tratamientos,verdelimon03,Tratamientos,0,1
|
||||
S0012,Procedimiento,servicios_medicos,1980.0,30,Tratamientos,verdelimon03,Tratamientos,0,1
|
||||
S0013,ANESTESIOLOGA SESION,servicios_medicos,4000.0,30,Tratamientos,verdelimon03,Tratamientos,0,1
|
||||
S0014,ANESTESIOLOGA NO SHOW,servicios_medicos,2000.0,30,Tratamientos,verdelimon03,Tratamientos,0,1
|
||||
S0015,otro,servicios_medicos,2000.0,30,Tratamientos,verdelimon03,Tratamientos,0,1
|
||||
S0016,Toxina botulínica DYSPORT,procedimientos_cosmeticos,125.0,30,Tratamientos,c90,Tratamientos,1,1
|
||||
S0017,Toxina botulínica BOTOX,procedimientos_cosmeticos,120.0,30,Tratamientos,verdelimon03,Tratamientos,0,1
|
||||
S0018,Toxina botulínica XEOMEEN,procedimientos_cosmeticos,120.0,30,Tratamientos,c90,Tratamientos,0,1
|
||||
S0019,Relleno YVOIRE CLASSIC,procedimientos_cosmeticos,8600.0,30,Tratamientos,c90,Tratamientos,0,1
|
||||
S0020,Relleno YVOIRE VOLUME,procedimientos_cosmeticos,8000.0,30,Tratamientos,c90,Tratamientos,0,1
|
||||
S0021,Relleno RESTYLANE KYSSE,procedimientos_cosmeticos,8600.0,30,Tratamientos,c90,Tratamientos,0,1
|
||||
S0022,Relleno BELOTERO BALANCE,procedimientos_cosmeticos,8600.0,30,Tratamientos,c90,Tratamientos,0,1
|
||||
S0023,Relleno BELOTERO INTENSE,procedimientos_cosmeticos,8000.0,30,Tratamientos,c90,Tratamientos,0,1
|
||||
S0024,Relleno VOLUMA,procedimientos_cosmeticos,9000.0,30,Tratamientos,c90,Tratamientos,0,1
|
||||
S0025,Relleno PERFECTHA SUBSKIN,procedimientos_cosmeticos,8000.0,30,Tratamientos,c90,Tratamientos,0,1
|
||||
S0026,Relleno ELLANSE M,procedimientos_cosmeticos,12000.0,30,Tratamientos,c90,Tratamientos,0,1
|
||||
S0027,Relleno RADIESSE,procedimientos_cosmeticos,12000.0,30,Tratamientos,c90,Tratamientos,0,1
|
||||
S0028,NCTF,procedimientos_cosmeticos,3600.0,30,Tratamientos,c90,Tratamientos,0,1
|
||||
S0029,RELLENO SCULPTRA,procedimientos_cosmeticos,20000.0,30,Tratamientos,c90,Tratamientos,0,1
|
||||
S0030,Peeling médico,procedimientos_cosmeticos,2400.0,30,Tratamientos,c90,Tratamientos,0,1
|
||||
S0031,Mesoterapia capilar,procedimientos_cosmeticos,3000.0,30,Tratamientos,c90,Tratamientos,0,1
|
||||
S0032,Mesoterapia facial o corporal,procedimientos_cosmeticos,3000.0,30,Tratamientos,c90,Tratamientos,0,1
|
||||
S0033,Exosomas facial,procedimientos_cosmeticos,5600.0,30,Tratamientos,c90,Tratamientos,0,1
|
||||
S0034,Exosomas capilar,procedimientos_cosmeticos,5600.0,30,Tratamientos,c90,Tratamientos,0,1
|
||||
S0035,Bioestimulacion capilar,procedimientos_cosmeticos,3500.0,30,Tratamientos,c90,Tratamientos,0,1
|
||||
S0036,Bioestimulacion facial,procedimientos_cosmeticos,3500.0,30,Tratamientos,c90,Tratamientos,0,1
|
||||
S0037,Microinyecciones Dutasteride,procedimientos_cosmeticos,4000.0,30,Tratamientos,c90,Tratamientos,0,1
|
||||
S0038,Enzimas plus ,procedimientos_cosmeticos,4000.0,30,Tratamientos,c90,Tratamientos,0,1
|
||||
S0039,Enzimas HA 1.5,procedimientos_cosmeticos,6900.0,30,Tratamientos,c90,Tratamientos,0,1
|
||||
S0040,F CELLS,procedimientos_cosmeticos,39500.0,30,Tratamientos,c90,Tratamientos,0,1
|
||||
S0041,LASER CO2 FRACCIONADO ENERGIAS SUAVES A MEDIAS,tecnologia_medica,12500.0,30,Tratamientos,c105,Tratamientos,0,1
|
||||
S0047,FACIALES Facial SKEEN,cosmetologia_basica,900.0,30,Cabinas,c106,Cabinas,0,1
|
||||
S0065,FACIALES Facial SKEEN,cosmetologia_basica,3440.0,30,,c106,,0,0
|
||||
S0048,FACIALES Micro,cosmetologia_basica,900.0,30,Cabinas,c106,Cabinas,0,1
|
||||
S0049,FACIALES Facial + micro o espalda,cosmetologia_basica,1400.0,30,Cabinas,c106,Cabinas,0,1
|
||||
S0050,FotoSkeen cara,cosmetologia_avanzada,2550.0,30,Cabinas,c108,Cabinas,0,1
|
||||
S0004,FotoSkeen cara,cosmetologia_avanzada,12700.0,30,,c108,,0,1
|
||||
S0051,FotoSkeen espalda ,cosmetologia_avanzada,2700.0,30,Cabinas,c108,Cabinas,0,1
|
||||
S0199,FotoSkeen espalda ,cosmetologia_avanzada,13500.0,30,,c108,,0,1
|
||||
S0052,"FotoSkeen cara, cuello, escote",cosmetologia_avanzada,3000.0,30,Cabinas,c108,Cabinas,0,1
|
||||
S0006,"FotoSkeen cara, cuello, escote",cosmetologia_avanzada,15000.0,30,,c108,,0,1
|
||||
S0053,LumiSKEEN CARA O GLUTEOS,cosmetologia_avanzada,2000.0,30,Cabinas,c29,Cabinas,0,1
|
||||
S0201,LumiSKEEN CARA O GLUTEOS,cosmetologia_avanzada,9990.0,30,,c29,,0,1
|
||||
S0054,LumiSKEEN espalda ,cosmetologia_avanzada,2500.0,30,Cabinas,c29,Cabinas,0,1
|
||||
S0202,LumiSKEEN espalda ,cosmetologia_avanzada,12400.0,30,,c29,,0,1
|
||||
S0055,"LumiSKEEN cara, cuello, escote",cosmetologia_avanzada,2800.0,30,Cabinas,c29,Cabinas,0,1
|
||||
S0009,"LumiSKEEN cara, cuello, escote",cosmetologia_avanzada,14000.0,30,,c29,,0,1
|
||||
S0056,Lumiflash cara,cosmetologia_avanzada,1000.0,30,Cabinas,c29,Cabinas,0,1
|
||||
S0057,"FotoSkeen Glow cara, cuello y escote",cosmetologia_avanzada,5000.0,30,Cabinas,c108,Cabinas,0,1
|
||||
S0011,"FotoSkeen Glow cara, cuello y escote",cosmetologia_avanzada,24900.0,30,,c108,,0,1
|
||||
S0058,QUANTA Hollywood skeen cara,cosmetologia_avanzada,2550.0,30,Cabinas,c116,Cabinas,0,1
|
||||
S0054,QUANTA Hollywood skeen cara,cosmetologia_avanzada,10580.0,30,,c116,,0,1
|
||||
S0059,OxySKEEN cara,cosmetologia_avanzada,2600.0,30,Cabinas,c94,Cabinas,0,1
|
||||
S0013,OxySKEEN cara,cosmetologia_avanzada,6470.0,30,,c94,,0,1
|
||||
S0060,Hydrafacial cara,cosmetologia_avanzada,2650.0,30,,c78,,0,1
|
||||
S0014,Hydrafacial cara,cosmetologia_avanzada,6590.0,30,,c112,,0,1
|
||||
S0061,Hydrafacial cara plus booster,cosmetologia_avanzada,3400.0,30,,c78,,0,1
|
||||
S0015,Hydrafacial cara plus booster,cosmetologia_avanzada,8460.0,30,,c112,,0,1
|
||||
S0062,Nanopore cara,cosmetologia_avanzada,3150.0,30,Cabinas,c119,Cabinas,0,1
|
||||
S0016,Nanopore cara,cosmetologia_avanzada,7840.0,30,,c119,,0,1
|
||||
S0063,Cool Liftng,cosmetologia_avanzada,1920.0,30,Cabinas,c78,Cabinas,0,0
|
||||
S0017,Cool Liftng,cosmetologia_avanzada,4780.0,30,,c78,,0,0
|
||||
S0069,DEPILACION LASER o IPL CARA COMPLETA,cosmetologia_avanzada,890.0,30,Cabinas,c114,Cabinas,0,1
|
||||
S0022,DEPILACION LASER o IPL CARA COMPLETA,cosmetologia_avanzada,4440.0,30,,c114,,0,1
|
||||
S0176,DEPILACION LASER o IPL CARA COMPLETA,cosmetologia_avanzada,3570.0,30,,c114,,0,0
|
||||
S0070,DEPILACION LASER o IPL MEDIA CARA Ó HOMBRO Ó MANOS,cosmetologia_avanzada,565.0,30,Cabinas,c114,Cabinas,0,1
|
||||
S0023,DEPILACION LASER o IPL MEDIA CARA Ó BARBA Ó HOMBRO Ó MANOS,cosmetologia_avanzada,2820.0,30,,c114,,0,1
|
||||
S0177,DEPILACION LASER o IPL MEDIA CARA Ó HOMBRO Ó MANOS,cosmetologia_avanzada,2268.0,30,,c114,,0,0
|
||||
S0071,DEPILACION LASER o IPL ZONA PEQUENA Ó BIGOTE Ó CUELLO Ó PIES,cosmetologia_avanzada,260.0,30,Cabinas,c114,Cabinas,0,1
|
||||
S0024,DEPILACION LASER o IPL ZONA PEQUENA Ó BIGOTE Ó PATILLAS Ó CUELLO Ó PIES,cosmetologia_avanzada,1300.0,30,,c114,,0,1
|
||||
S0178,DEPILACION LASER o IPL ZONA PEQUENA Ó BIGOTE Ó CUELLO Ó PIES,cosmetologia_avanzada,1050.0,30,,c114,,0,0
|
||||
S0072,DEPILACION LASER o IPL 1/2 TRONCO ANTERIOR O POSTERIOR,cosmetologia_avanzada,890.0,30,Cabinas,c114,Cabinas,0,1
|
||||
S0025,DEPILACION LASER o IPL 1/2 TRONCO ANTERIOR O POSTERIOR,cosmetologia_avanzada,4445.0,30,,c114,,0,1
|
||||
S0179,DEPILACION LASER o IPL 1/2 TRONCO ANTERIOR O POSTERIOR,cosmetologia_avanzada,3570.0,30,,c114,,0,0
|
||||
S0073,DEPILACION LASER o IPL BRAZO COMPLETO,cosmetologia_avanzada,1260.0,30,Cabinas,c114,Cabinas,0,1
|
||||
S0026,DEPILACION LASER o IPL BRAZO COMPLETO,cosmetologia_avanzada,6275.0,30,,c114,,0,1
|
||||
S0180,DEPILACION LASER o IPL BRAZO COMPLETO,cosmetologia_avanzada,5040.0,30,,c114,,0,0
|
||||
S0074,DEPILACION LASER o IPL MEDIO BRAZO ,cosmetologia_avanzada,995.0,30,Cabinas,c114,Cabinas,0,1
|
||||
S0027,DEPILACION LASER o IPL MEDIO BRAZO O PANTORRILLA,cosmetologia_avanzada,4970.0,30,,c114,,0,1
|
||||
S0181,DEPILACION LASER o IPL MEDIO BRAZO ,cosmetologia_avanzada,3990.0,30,,c114,,0,0
|
||||
S0075,DEPILACION LASER o IPL BIKINI BRASILENO,cosmetologia_avanzada,945.0,30,Cabinas,c114,Cabinas,0,1
|
||||
S0028,DEPILACION LASER o IPL BIKINI BRASILENO,cosmetologia_avanzada,4700.0,30,,c114,,0,1
|
||||
S0182,DEPILACION LASER o IPL BIKINI BRASILENO,cosmetologia_avanzada,3780.0,30,,c114,,0,1
|
||||
S0076,DEPILACION LASER o IPL PUBIS Ó INGLE Ó LABIOS ,cosmetologia_avanzada,630.0,30,Cabinas,c114,Cabinas,0,1
|
||||
S0029,DEPILACION LASER o IPL PUBIS Ó INGLE Ó LABIOS Ó AXILA,cosmetologia_avanzada,3130.0,30,,c114,,0,1
|
||||
S0183,DEPILACION LASER o IPL PUBIS Ó INGLE Ó LABIOS ,cosmetologia_avanzada,2520.0,30,,c114,,0,1
|
||||
S0077,DEPILACION LASER o IPL PIERNAS,cosmetologia_avanzada,1890.0,30,Cabinas,c114,Cabinas,0,1
|
||||
S0030,DEPILACION LASER o IPL PIERNAS,cosmetologia_avanzada,9410.0,30,,c114,,0,1
|
||||
S0184,DEPILACION LASER o IPL PIERNAS,cosmetologia_avanzada,7560.0,30,,c114,,0,1
|
||||
S0078,DEPILACION LASER o IPL MEDIA PIERNA SUPERIOR O INFERIOR,cosmetologia_avanzada,1260.0,30,Cabinas,c114,Cabinas,0,1
|
||||
S0031,DEPILACION LASER o IPL MEDIA PIERNA SUPERIOR O INFERIOR,cosmetologia_avanzada,6275.0,30,,c114,,0,1
|
||||
S0185,DEPILACION LASER o IPL MEDIA PIERNA SUPERIOR O INFERIOR,cosmetologia_avanzada,5040.0,30,,c114,,0,0
|
||||
S0079,DEPILACION LASER o IPL GLUTEOS | ZONA ALBA,cosmetologia_avanzada,890.0,30,Cabinas,c114,Cabinas,0,1
|
||||
S0032,DEPILACION LASER o IPL GLUTEOS | ZONA ALBA,cosmetologia_avanzada,4445.0,30,,c114,,0,1
|
||||
S0186,DEPILACION LASER o IPL GLUTEOS | ZONA ALBA,cosmetologia_avanzada,3570.0,30,,c114,,0,0
|
||||
S0080,DEPILACION LASER o IPL CUERPO COMPLETO PRECIO REGULAR,cosmetologia_avanzada,7350.0,30,Cabinas,c114,Cabinas,0,1
|
||||
S0033,DEPILACION LASER o IPL CUERPO COMPLETO PRECIO REGULAR,cosmetologia_avanzada,36600.0,30,,c114,,0,1
|
||||
S0187,DEPILACION LASER o IPL CUERPO COMPLETO PRECIO REGULAR,cosmetologia_avanzada,29400.0,30,,c114,,0,0
|
||||
S0081,RF | Venus Freeze ZONA PEQUEÑA,cosmetologia_avanzada,1300.0,30,Cabinas,c82,Cabinas,0,1
|
||||
S0034,RF | Venus Freeze ZONA PEQUEÑA,cosmetologia_avanzada,6470.0,30,,c82,,0,1
|
||||
S0082,RF | Venus Freeze MEDIA CARA Ó CUELLO+ESCOTE,cosmetologia_avanzada,1400.0,30,Cabinas,c82,Cabinas,0,1
|
||||
S0035,RF | Venus Freeze MEDIA CARA Ó CUELLO+ESCOTE,cosmetologia_avanzada,6970.0,30,,c82,,0,1
|
||||
S0083,RF | Venus Freeze Cara + Papada,cosmetologia_avanzada,2100.0,30,Cabinas,c82,Cabinas,0,1
|
||||
S0036,RF | Venus Freeze Cara + Papada,cosmetologia_avanzada,10450.0,30,,c82,,0,1
|
||||
S0084,RF | Venus Freeze CARA + CUELLO + ESCOTE,cosmetologia_avanzada,2950.0,30,Cabinas,c82,Cabinas,0,1
|
||||
S0037,RF | Venus Freeze CARA + CUELLO + ESCOTE,cosmetologia_avanzada,14690.0,30,,c82,,0,1
|
||||
S0085,RF | Venus Freeze BRAZOS Ó AXILA POSTERIOR Ó RODILLAS,cosmetologia_avanzada,1800.0,30,Cabinas,c82,Cabinas,0,1
|
||||
S0038,RF | Venus Freeze BRAZOS Ó AXILA POSTERIOR Ó RODILLAS,cosmetologia_avanzada,8960.0,30,,c82,,0,1
|
||||
S0086,RF | Venus Freeze ABDOMEN Ó PIERNAS,cosmetologia_avanzada,3200.0,30,Cabinas,c82,Cabinas,0,1
|
||||
S0039,RF | Venus Freeze ABDOMEN Ó PIERNAS,cosmetologia_avanzada,15930.0,30,,c82,,0,1
|
||||
S0087,RF | Venus Freeze GLUTEOS + MUSLOS,cosmetologia_avanzada,3000.0,30,Cabinas,c82,Cabinas,0,1
|
||||
S0040,RF | Venus Freeze GLUTEOS + MUSLOS,cosmetologia_avanzada,14940.0,30,,c82,,0,1
|
||||
S0088,RF | Venus Freeze GLUTEOS Ó MUSLO,cosmetologia_avanzada,1800.0,30,Cabinas,c82,Cabinas,0,1
|
||||
S0041,RF | Venus Freeze GLUTEOS Ó MUSLO,cosmetologia_avanzada,8960.0,30,,c115,,0,1
|
||||
S0089,QUANTA TATUAJES MENOS DE 4 CM,cosmetologia_avanzada,2100.0,30,Cabinas,c116,Cabinas,0,1
|
||||
S0057,QUANTA TATUAJES MENOS DE 4 CM,cosmetologia_avanzada,8700.0,30,,c116,,0,1
|
||||
S0090,QUANTA TATUAJES DE 4 CM A 8 CM,cosmetologia_avanzada,3200.0,30,Cabinas,c116,Cabinas,0,1
|
||||
S0058,QUANTA TATUAJES DE 4 CM A 8 CM,cosmetologia_avanzada,13280.0,30,,c116,,0,1
|
||||
S0091,QUANTA TATUAJES DE 8 CM A 15 CM,cosmetologia_avanzada,4200.0,30,Cabinas,c116,Cabinas,0,1
|
||||
S0059,QUANTA TATUAJES DE 8 CM A 15 CM,cosmetologia_avanzada,17400.0,30,,c116,,0,1
|
||||
S0092,QUANTA TATUAJES MAS DE 15 CM,cosmetologia_avanzada,5500.0,30,Cabinas,c116,Cabinas,0,1
|
||||
S0060,QUANTA TATUAJES MAS DE 15 CM,cosmetologia_avanzada,22800.0,30,,c116,,0,1
|
||||
S0093,QUANTA EFELIDES | LENTIGOS x zona,cosmetologia_avanzada,2700.0,30,Cabinas,c116,Cabinas,0,1
|
||||
S0053,QUANTA EFELIDES | LENTIGOS x zona,cosmetologia_avanzada,11200.0,30,,c116,,0,1
|
||||
S0068,QUANTA EFELIDES | LENTIGOS x zona,cosmetologia_avanzada,6700.0,30,,c116,,0,1
|
||||
S0094,Mesoject gun,cosmetologia_avanzada,2900.0,30,Cabinas,c42,Cabinas,0,1
|
||||
S0050,Mesoject gun,cosmetologia_avanzada,14940.0,30,,c42,,0,1
|
||||
S0095,Consulta primera vez,servicios_medicos,1600.0,30,Tratamientos,verdelimon03,Tratamientos,1,1
|
||||
S0096,LASER EXCIMER,servicios_medicos,1400.0,30,Tratamientos,verdelimon03,Tratamientos,0,1
|
||||
S0097,Lumiacne,cosmetologia_avanzada,1600.0,30,Cabinas,c29,Cabinas,0,1
|
||||
S0052,LUMI ACNE,cosmetologia_avanzada,7950.0,30,,c29,,0,1
|
||||
S0099,NCTF área pequeña,procedimientos_cosmeticos,2600.0,30,Tratamientos,c90,Tratamientos,0,1
|
||||
S0100,Toxina Botulínica PROMO,procedimientos_cosmeticos,100.0,30,Tratamientos,c90,Tratamientos,1,1
|
||||
S0102,Relleno,procedimientos_cosmeticos,7000.0,30,Tratamientos,c90,Tratamientos,0,1
|
||||
S0103,LUMI FLASH MARTES Y JUEVES,cosmetologia_avanzada,500.0,30,,c29,,0,1
|
||||
S0104,QUANTA FRACCIONADO,cosmetologia_avanzada,6000.0,30,,c116,,0,1
|
||||
S0055,QUANTA FRACCIONADO,cosmetologia_avanzada,15000.0,30,,c116,,0,1
|
||||
S0105,QUANTA TONING+ FRACCIONADO,cosmetologia_avanzada,7350.0,30,,c116,,0,1
|
||||
S0163,QUANTA TONING+ FRACCIONADO,cosmetologia_avanzada,18300.0,30,,c116,,0,1
|
||||
S0106,QUANTA ONICOMICOSIS MENOS DE 4,cosmetologia_avanzada,2100.0,30,,c116,,0,1
|
||||
S0061,QUANTA ONICOMICOSIS MENOS DE 4,cosmetologia_avanzada,5200.0,30,,c116,,0,1
|
||||
S0107,QUANTA ONICOMICOSIS MAS DE 4,cosmetologia_avanzada,3000.0,30,,c116,,0,1
|
||||
S0062,QUANTA ONICOMICOSIS MAS DE 4,cosmetologia_avanzada,7450.0,30,,c116,,0,1
|
||||
S0108,QUANTA ESTRIAS,cosmetologia_avanzada,3200.0,30,,c116,,0,1
|
||||
S0063,QUANTA ESTRIAS,cosmetologia_avanzada,13250.0,30,,c116,,0,1
|
||||
S0109,LUMISKEEN GLOW CARA,cosmetologia_avanzada,3200.0,30,,c29,,0,1
|
||||
S0203,LUMISKEEN GLOW CARA,cosmetologia_avanzada,15930.0,30,,c29,,0,1
|
||||
S0110,FOTO SKEEN GLOW CARA,cosmetologia_avanzada,3200.0,30,,c108,,0,1
|
||||
S0066,FOTO SKEEN GLOW CARA,cosmetologia_avanzada,15950.0,30,,c108,,0,1
|
||||
S0111,LUMI SKEEN GLOW CARA CUELLO Y ESCOTE,cosmetologia_avanzada,4000.0,30,,c29,,0,1
|
||||
S0067,LUMI SKEEN GLOW CARA CUELLO Y ESCOTE,cosmetologia_avanzada,19900.0,30,,c29,,0,1
|
||||
S0112,CALECIM MESOTERAPIA FACIAL,procedimientos_cosmeticos,3000.0,30,Tratamientos,c90,Tratamientos,0,1
|
||||
S0113,CALECIM MESOTERAPIA CAPILAR,procedimientos_cosmeticos,3000.0,30,Tratamientos,c90,Tratamientos,0,1
|
||||
S0115,HAIR FILLER,procedimientos_cosmeticos,5500.0,30,Tratamientos,c90,Tratamientos,0,1
|
||||
S0117,BOOSTR EXOSOMAS,procedimientos_cosmeticos,1500.0,30,Tratamientos,c90,Tratamientos,0,1
|
||||
S0118,"LASER CO2 CUELLO, ESCOTE O MANOS",tecnologia_medica,6000.0,30,,c108,,0,1
|
||||
S0119,LASER CO2 AREA ADICIONAL ,tecnologia_medica,4000.0,30,,c108,,0,1
|
||||
S0120,RELLENO AQUA SHINE,procedimientos_cosmeticos,6300.0,30,Tratamientos,c90,Tratamientos,0,1
|
||||
S0121,RELLENO STYLE 600,procedimientos_cosmeticos,7590.0,30,Tratamientos,c90,Tratamientos,0,1
|
||||
S0122,COSMELAN,procedimientos_cosmeticos,9000.0,30,,c93,,0,1
|
||||
S0123,BIOESTIMULACION CON APEE,procedimientos_cosmeticos,4400.0,30,,c93,,0,1
|
||||
S0124,FOTOHYDRAFACIAL,cosmetologia_avanzada,4000.0,30,,c78,,0,1
|
||||
S0127,FOTOHYDRAFACIAL,cosmetologia_avanzada,9960.0,30,,c78,,0,1
|
||||
S0247,enzimas promo 2 kits ,procedimientos_cosmeticos,5000.0,30,,c93,,0,0
|
||||
S0248,QUANTA CICATRIZ 4CM ,cosmetologia_avanzada,3000.0,30,,c116,,0,1
|
||||
S0137,QUANTA CICATRIZ 4CM ,cosmetologia_avanzada,7500.0,30,,c116,,0,1
|
||||
S0251,RELLENO RESTYLANE LYFT,procedimientos_cosmeticos,8000.0,30,,c90,,0,1
|
||||
S0254,Mesoject BTX promo,cosmetologia_avanzada,1800.0,30,Cabinas,c117,Cabinas,0,1
|
||||
S0129,Mesoject BTX promo,cosmetologia_avanzada,4500.0,30,,c117,,0,1
|
||||
S0255,Mesoject Hair,cosmetologia_avanzada,2300.0,30,Cabinas,c117,Cabinas,0,1
|
||||
S0128,Mesoject Hair,cosmetologia_avanzada,7200.0,30,,c117,,0,1
|
||||
S0256,HYDRAFACIAL PLATINUM,cosmetologia_avanzada,3800.0,30,,c78,,0,1
|
||||
S0257,HYDRAFACIAL CUELLO Y ESCOTE,cosmetologia_avanzada,1955.0,30,,c78,,0,1
|
||||
S0258,DUO PERK OJOS + 3 SESIONES DE NCTF,procedimientos_cosmeticos,8000.0,30,,c90,,0,1
|
||||
S0259,DUO PERK LABIOS + RESTYLANE KYSSE,procedimientos_cosmeticos,8000.0,30,,c90,,0,1
|
||||
S0260,PERK OJOS,cosmetologia_avanzada,1300.0,30,,c78,,0,1
|
||||
S0261,PERK LABIOS,cosmetologia_avanzada,1000.0,30,,c78,,0,1
|
||||
S0263,RELLENO ULTRADEEP,procedimientos_cosmeticos,8000.0,30,,c90,,0,1
|
||||
S0264,RELLENO GLOBAL ACTION,procedimientos_cosmeticos,7000.0,30,,c90,,0,1
|
||||
S0265,RELLENO MAILI,procedimientos_cosmeticos,8600.0,30,,c90,,0,1
|
||||
S0273,QUANTA CICATRIZ 5CM O MAS,cosmetologia_avanzada,5000.0,30,,c116,,0,1
|
||||
S0138,QUANTA CICATRIZ 5CM O MAS,cosmetologia_avanzada,13000.0,30,,c116,,0,1
|
||||
S0274,QUANTA TONNING CARA ,cosmetologia_avanzada,3000.0,30,,c116,,0,1
|
||||
S0139,QUANTA TONNING CARA ,cosmetologia_avanzada,7500.0,30,,c116,,0,1
|
||||
S0275,QUANTA FRACCIONADO CARA Y CUELLO,cosmetologia_avanzada,7000.0,30,,c116,,0,1
|
||||
S0140,QUANTA FRACCIONADO CARA Y CUELLO,cosmetologia_avanzada,17500.0,30,,c116,,0,1
|
||||
S0276,REDTOUCH CARA,cosmetologia_avanzada,3000.0,30,,c44,,0,1
|
||||
S0193,REDTOUCH CARA,cosmetologia_avanzada,14940.0,30,,c44,,0,1
|
||||
S0277,REDTOUCH PELO,cosmetologia_avanzada,1500.0,30,,c44,,0,1
|
||||
S0194,REDTOUCH PELO,cosmetologia_avanzada,9960.0,30,,c44,,0,1
|
||||
S0195,REDTOUCH PELO,cosmetologia_avanzada,4980.0,30,,c44,,0,1
|
||||
S0278,REDTOUCH LABIOS,cosmetologia_avanzada,1200.0,30,,c44,,0,1
|
||||
S0196,REDTOUCH LABIOS,cosmetologia_avanzada,2980.0,30,,c44,,0,1
|
||||
S0279,RED TOUCH CARA PROMO FEB-ABRIL,cosmetologia_avanzada,3000.0,30,,c44,,0,0
|
||||
S0150,RED TOUCH CARA PROMO FEB-ABRIL,cosmetologia_avanzada,14400.0,30,,c44,,0,0
|
||||
S0280,RED TOUCH PELO PROMO FEB-ABRIL,cosmetologia_avanzada,1120.0,30,,c44,,0,0
|
||||
S0142,RED TOUCH PELO PROMO FEB-ABRIL,cosmetologia_avanzada,8960.0,30,,c44,,0,0
|
||||
S0143,RED TOUCH PELO PROMO FEB-ABRIL,cosmetologia_avanzada,4480.0,30,,c44,,0,0
|
||||
S0281,RED TOUCH LABIOS PROMO FEB-ABRIL,cosmetologia_avanzada,800.0,30,,c44,,0,0
|
||||
S0145,RED TOUCH LABIOS PROMO FEB-ABRIL,cosmetologia_avanzada,2400.0,30,,c44,,0,0
|
||||
S0282,ENZIMAS 2 KITS + 4 VENUS EN PAPADA PROMO JULIO-SEPT,cosmetologia_avanzada,9600.0,30,,c113,,0,0
|
||||
S0283,ENZIMAS 3 KITS + 6 VENUS ABDOMEN PROMO JULIO-SEPT,cosmetologia_avanzada,19000.0,30,,c113,,0,0
|
||||
S0284,HOLLYWOOD + RED TOUCH,cosmetologia_avanzada,4500.0,30,,c44,,0,1
|
||||
S0146,HOLLYWOOD + RED TOUCH,cosmetologia_avanzada,25110.0,30,,c44,,0,1
|
||||
S0285,QUANTA FRACCIONADO + RED TOUCH CARA,cosmetologia_avanzada,6700.0,30,,c44,,0,1
|
||||
S0148,QUANTA FRACCIONADO + RED TOUCH CARA,cosmetologia_avanzada,16700.0,30,,c44,,0,1
|
||||
S0286,QUANTA FRACCIONADO + RED TOUCH CARA CUELLO,cosmetologia_avanzada,8500.0,30,,c44,,0,1
|
||||
S0149,QUANTA FRACCIONADO + RED TOUCH CARA CUELLO,cosmetologia_avanzada,21150.0,30,,c44,,0,1
|
||||
S0289,DEPILACION LASER o IPL BARBA,cosmetologia_avanzada,420.0,30,,c114,,0,1
|
||||
S0151,DEPILACION LASER o IPL BARBA,cosmetologia_avanzada,2090.0,30,,c114,,0,1
|
||||
S0188,DEPILACION LASER o IPL BARBA,cosmetologia_avanzada,1680.0,30,,c114,,0,0
|
||||
S0290,DEPILACION LASER o IPL PANTORRILLA,cosmetologia_avanzada,735.0,30,,c114,,0,1
|
||||
S0152,DEPILACION LASER o IPL PANTORRILLA,cosmetologia_avanzada,3660.0,30,,c114,,0,1
|
||||
S0189,DEPILACION LASER o IPL PANTORRILLA,cosmetologia_avanzada,2940.0,30,,c114,,0,0
|
||||
S0291,DEPILACION LASER o IPL AXILA,cosmetologia_avanzada,525.0,30,,c114,,0,1
|
||||
S0153,DEPILACION LASER o IPL AXILA,cosmetologia_avanzada,2615.0,30,,c114,,0,1
|
||||
S0190,DEPILACION LASER o IPL AXILA,cosmetologia_avanzada,2100.0,30,,c114,,0,0
|
||||
S0292,DEPILACION LASER o IPL PATILLAS,cosmetologia_avanzada,420.0,30,,c114,,0,1
|
||||
S0154,DEPILACION LASER o IPL PATILLAS,cosmetologia_avanzada,2090.0,30,,c114,,0,1
|
||||
S0191,DEPILACION LASER o IPL PATILLAS,cosmetologia_avanzada,1050.0,30,,c114,,0,1
|
||||
S0298,DEPILACION LASER o IPL PECHO,cosmetologia_avanzada,1260.0,30,,c114,,0,1
|
||||
S0160,DEPILACION LASER o IPL PECHO,cosmetologia_avanzada,6275.0,30,,c114,,0,1
|
||||
S0192,DEPILACION LASER o IPL PECHO,cosmetologia_avanzada,5040.0,30,,c114,,0,1
|
||||
S0300,NATURAL PEEL,cosmetologia_avanzada,3500.0,30,,c116,,0,1
|
||||
S0164,NATURAL PEEL,cosmetologia_avanzada,15000.0,30,,c116,,0,1
|
||||
S0301,RED TOUCH CARA CUELLO Y ESCOTE,cosmetologia_avanzada,4500.0,30,,c44,,0,1
|
||||
S0165,RED TOUCH CARA CUELLO Y ESCOTE,cosmetologia_avanzada,22410.0,30,,c101,,0,1
|
||||
S0302,MORPHEUS CARA Y CUELLO,tecnologia_medica,19000.0,30,,c105,,0,1
|
||||
S0168,MORPHEUS CARA Y CUELLO,tecnologia_medica,47310.0,30,,c105,,0,1
|
||||
S0303,MORPHEUS CARA CUELLO Y ESCOTE,tecnologia_medica,22000.0,30,,c105,,0,1
|
||||
S0167,MORPHEUS CARA CUELLO Y ESCOTE,tecnologia_medica,54780.0,30,,c105,,0,1
|
||||
S0304,MORPHEUS CUERPO,tecnologia_medica,24000.0,30,,c105,,0,1
|
||||
S0169,MORPHEUS CUERPO,tecnologia_medica,59760.0,30,,c105,,0,1
|
||||
S0307, Morpheus 8 NO SHOW,tecnologia_medica,2000.0,30,,c105,,0,0
|
||||
S0308, MORPHEUS 3 SESIONES CARA Y CUELLO UN SOLO PAGO,tecnologia_medica,40000.0,30,,c105,,0,0
|
||||
S0309,MORPHEUS ABDOMEN CUERPO UN SOLO PAGO 3 SESIONES,tecnologia_medica,50000.0,30,,c105,,0,0
|
||||
S0310, Lumecca estrías por zona,cosmetologia_avanzada,2200.0,30,,c82,,0,1
|
||||
S0311, Lumecca,cosmetologia_avanzada,4200.0,30,,c82,,0,1
|
||||
S0170, Lumecca,cosmetologia_avanzada,10450.0,30,,c82,,0,1
|
||||
S0312,Morpheus8 Cara,tecnologia_medica,16000.0,30,,c105,,0,1
|
||||
S0171,Morpheus8 Cara,tecnologia_medica,39840.0,30,,c105,,0,1
|
||||
S0314,QUANTA CARA CUELLO Y ESCOTE,cosmetologia_avanzada,7500.0,30,,c116,,0,1
|
||||
S0172,QUANTA CARA CUELLO Y ESCOTE,cosmetologia_avanzada,18650.0,30,,c116,,0,1
|
||||
S0315,QUANTA + RED TOUCH CARA CUELLO Y ESCOTE,cosmetologia_avanzada,9500.0,30,,c44,,0,1
|
||||
S0173,QUANTA + RED TOUCH CARA CUELLO Y ESCOTE,cosmetologia_avanzada,23650.0,30,,c44,,0,1
|
||||
S0316,RED TOUCH CARA Y CUELLO,cosmetologia_avanzada,4200.0,30,,c44,,0,1
|
||||
S0174,RED TOUCH CARA Y CUELLO,cosmetologia_avanzada,20900.0,30,,c44,,0,1
|
||||
S0317,HOLLYWOOD + RED TOUCH CARA Y CUELLO,cosmetologia_avanzada,5500.0,30,,c44,,0,1
|
||||
S0197,HOLLYWOOD + RED TOUCH CARA Y CUELLO,cosmetologia_avanzada,27390.0,30,,c44,,0,1
|
||||
S0318,HOLLYWOOD CARA Y CUELLO,cosmetologia_avanzada,3000.0,30,,c44,,0,1
|
||||
S0198,HOLLYWOOD CARA Y CUELLO,cosmetologia_avanzada,12000.0,30,,c44,,1,1
|
||||
S0320,FOTOSKEEN GLUTEOS,cosmetologia_avanzada,2700.0,30,,c108,,0,1
|
||||
S0200,FOTOSKEEN GLUTEOS,cosmetologia_avanzada,13500.0,30,,c108,,0,1
|
||||
S0321,LASER CO2 CARA ENERGIA ALTA,tecnologia_medica,20000.0,30,,c105,,0,1
|
||||
S0322,LASER CO2 CARA CUELLO Y ESCOTE ENERGIA MEDIA,tecnologia_medica,20000.0,30,,c105,,0,1
|
||||
S0323,LASAER CO2 CUELLO,tecnologia_medica,6000.0,30,,c105,,0,1
|
||||
S0324,LASER CO2 PARPADOS,tecnologia_medica,6000.0,30,,c105,,0,1
|
||||
S0325,LASER CO2 BRAZOS COMPLETOS,tecnologia_medica,15000.0,30,,c105,,0,1
|
||||
S0326,BOOSTER PBSERUM,cosmetologia_avanzada,2200.0,30,,c35,,0,1
|
||||
S0327,BIOESTUMULACION CON PBSERUM ,procedimientos_cosmeticos,3000.0,30,,c35,,0,1
|
||||
S0328,HYDRAGLOW,cosmetologia_avanzada,4000.0,30,,c78,,0,1
|
||||
S0329,HYDRAFLSH,cosmetologia_avanzada,2000.0,30,,c78,,0,1
|
||||
S0330,CONSULTA + LUMISKEEN,servicios_medicos,1800.0,30,,c119,,0,1
|
||||
S0331,Consulta primera vez 1 sesión Dra Ramos,servicios_medicos,2000.0,30,,verdelimon03,,0,1
|
||||
S0332,CONSULTA DE PELO DRA RAMOS O DR VEGA PRIMERA VEZ,servicios_medicos,2000.0,30,,verdelimon03,,0,1
|
||||
S0333,CONSULTA PELO DRA RAMOS O DR VEGA SUBSECUENTES,servicios_medicos,1500.0,30,,c119,,0,1
|
||||
S0334,Consulta primera vez 1 sesión PRECIO 2025,servicios_medicos,1500.0,30,,verdelimon03,,0,1
|
||||
S0335,CONSULTA SUBSECUENTE PRECIO 2025,servicios_medicos,1400.0,30,,c119,,0,1
|
||||
S0336,LASER CO2 ENERGIAS ALTAS O CON SEDACION,tecnologia_medica,20000.0,30,,c108,,0,1
|
||||
S0337,LASER CO2 CUELLO O ESCOTE O MANOS,tecnologia_medica,6500.0,30,,c108,,0,1
|
||||
S0338,LASER CO2 ADICIONAL ,tecnologia_medica,4000.0,30,,c108,,0,1
|
||||
S0339,LASER CO2 PARPADOS,tecnologia_medica,5000.0,30,,c108,,0,1
|
||||
S0340,FACIAL + PDRN,cosmetologia_basica,3500.0,30,,c48,,0,1
|
||||
S0204,FACIAL + PDRN,cosmetologia_basica,8400.0,30,,c106,,0,1
|
||||
S0341,QUANTA FRACCIONADO + PDRN,cosmetologia_avanzada,6600.0,30,,c116,,0,1
|
||||
S0342,HOLLYWOOD + PDRN,cosmetologia_avanzada,4500.0,30,,c116,,0,1
|
||||
S0343,KENALOG,servicios_medicos,0.01,30,,rosapalo02,,0,1
|
||||
S0344,BIOESTIMULACION ADN SALMON,procedimientos_cosmeticos,3500.0,30,,c90,,0,1
|
||||
S0345,Consulta primera vez Dr. Cerecer,servicios_medicos,1600.0,30,,verdelimon03,,0,1
|
||||
S0346,Consulta Dr. Cerecer,servicios_medicos,1500.0,30,,c119,,0,1
|
||||
S0347,ANTICIPO CREMA HORMONAL,procedimientos_cosmeticos,1000.0,30,,verdelimon02,,0,1
|
||||
S0348,LIQUIDACION CREMA HORMONAL ,procedimientos_cosmeticos,0.0,30,,verdelimon02,,0,1
|
||||
S0349,NANOPOR + PDRN SALMON,cosmetologia_avanzada,4000.0,30,,c42,,0,1
|
||||
|
1589
migracion/extraccion/servicios.html
Normal file
1589
migracion/extraccion/servicios.html
Normal file
File diff suppressed because it is too large
Load Diff
21078
migracion/extraccion/ventas.csv
Normal file
21078
migracion/extraccion/ventas.csv
Normal file
File diff suppressed because it is too large
Load Diff
702
migracion/extraer_skeen.py
Executable file
702
migracion/extraer_skeen.py
Executable file
@@ -0,0 +1,702 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Extracción read-only de datos del sistema legacy SKEEN.
|
||||
Uso autorizado por el responsable del proyecto con credenciales proporcionadas.
|
||||
Extrae pacientes (con expediente clínico completo), citas, ventas, monederos, servicios, etc.
|
||||
"""
|
||||
|
||||
import requests
|
||||
import csv
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
BASE = 'https://sistema.skeenmx.app'
|
||||
USERNAME = 'JoseSkeen'
|
||||
PASSWORD = 'SKr#902_88'
|
||||
OUTDIR = Path('/root/migracion/extraccion')
|
||||
OUTDIR.mkdir(parents=True, exist_ok=True)
|
||||
CACHE = OUTDIR / 'detalle_pacientes.json'
|
||||
|
||||
session = requests.Session()
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(f'[{datetime.now().strftime("%H:%M:%S")}] {msg}')
|
||||
|
||||
|
||||
def login():
|
||||
log('Iniciando sesión...')
|
||||
r = session.get(f'{BASE}/login', timeout=30)
|
||||
r.raise_for_status()
|
||||
soup = BeautifulSoup(r.text, 'html.parser')
|
||||
token = soup.find('input', {'name': '_token'})
|
||||
token = token['value'] if token else ''
|
||||
data = {
|
||||
'_token': token,
|
||||
'username': USERNAME,
|
||||
'password': PASSWORD,
|
||||
}
|
||||
r = session.post(f'{BASE}/login', data=data, timeout=30, allow_redirects=True)
|
||||
r.raise_for_status()
|
||||
if '/login' in r.url or 'estas credenciales' in r.text.lower():
|
||||
raise Exception('Login fallido. Verifica credenciales.')
|
||||
log('Login exitoso')
|
||||
return True
|
||||
|
||||
|
||||
def dt_params(start=0, length=100, search=''):
|
||||
return {
|
||||
'draw': 1,
|
||||
'columns[0][data]': 'id',
|
||||
'columns[0][name]': '',
|
||||
'columns[0][searchable]': 'true',
|
||||
'columns[0][orderable]': 'true',
|
||||
'columns[0][search][value]': '',
|
||||
'columns[0][search][regex]': 'false',
|
||||
'start': start,
|
||||
'length': length,
|
||||
'search[value]': search,
|
||||
'search[regex]': 'false',
|
||||
'order[0][column]': '0',
|
||||
'order[0][dir]': 'asc',
|
||||
'_': int(time.time() * 1000),
|
||||
}
|
||||
|
||||
|
||||
def fetch_json(url, params=None):
|
||||
headers = {'X-Requested-With': 'XMLHttpRequest'}
|
||||
r = session.get(f'{BASE}{url}', params=params or {}, headers=headers, timeout=120)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def paginate(url, params_builder, label, page_size=500, max_pages=None):
|
||||
log(f'Extrayendo {label} desde {url}...')
|
||||
all_rows = []
|
||||
start = 0
|
||||
page = 0
|
||||
while True:
|
||||
params = params_builder(start, page_size)
|
||||
try:
|
||||
data = fetch_json(url, params)
|
||||
except Exception as e:
|
||||
log(f' ERROR página {page}: {e}')
|
||||
break
|
||||
rows = data.get('data', [])
|
||||
if not rows:
|
||||
break
|
||||
all_rows.extend(rows)
|
||||
total = data.get('recordsTotal') or data.get('recordsFiltered') or 0
|
||||
log(f' página {page + 1}: {len(rows)} filas (total acumulado: {len(all_rows)}/{total})')
|
||||
if len(rows) < page_size:
|
||||
break
|
||||
start += page_size
|
||||
page += 1
|
||||
if max_pages and page >= max_pages:
|
||||
break
|
||||
time.sleep(0.3)
|
||||
log(f'{label}: {len(all_rows)} registros extraídos')
|
||||
return all_rows
|
||||
|
||||
|
||||
def save_csv(name, rows, fieldnames):
|
||||
path = OUTDIR / f'{name}.csv'
|
||||
with open(path, 'w', newline='', encoding='utf-8') as f:
|
||||
writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction='ignore')
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
log(f'Guardado {path}: {len(rows)} registros')
|
||||
|
||||
|
||||
MESES_ES = {
|
||||
'enero': '01', 'febrero': '02', 'marzo': '03', 'abril': '04',
|
||||
'mayo': '05', 'junio': '06', 'julio': '07', 'agosto': '08',
|
||||
'septiembre': '09', 'octubre': '10', 'noviembre': '11', 'diciembre': '12',
|
||||
}
|
||||
|
||||
|
||||
def parse_spanish_date(text):
|
||||
text = (text or '').strip().lower()
|
||||
if not text:
|
||||
return ''
|
||||
m = re.search(r'(\d{1,2})\s+de\s+([a-záéíóúñ]+)\s+(?:del|de)\s+(\d{4})', text)
|
||||
if not m:
|
||||
return ''
|
||||
dia, mes, anio = m.groups()
|
||||
mes_num = MESES_ES.get(mes)
|
||||
if not mes_num:
|
||||
return ''
|
||||
return f'{anio}-{mes_num}-{int(dia):02d}'
|
||||
|
||||
|
||||
def normalize_phone(phone):
|
||||
phone = re.sub(r'\D', '', (phone or ''))
|
||||
if phone and not phone.startswith('52') and len(phone) == 10:
|
||||
phone = '52' + phone
|
||||
return phone
|
||||
|
||||
|
||||
def parse_patient_detail(html, fallback_name=''):
|
||||
"""Extrae datos personales e historia clínica completa del HTML del expediente."""
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
text = soup.get_text(separator='\n')
|
||||
lines = [l.strip() for l in text.split('\n') if l.strip()]
|
||||
|
||||
def find_line_after(keywords):
|
||||
for i, line in enumerate(lines):
|
||||
low = line.lower()
|
||||
if any(k in low for k in keywords):
|
||||
if i + 1 < len(lines):
|
||||
return lines[i + 1]
|
||||
return ''
|
||||
|
||||
def find_pair(keywords):
|
||||
for i, line in enumerate(lines):
|
||||
low = line.lower()
|
||||
if any(k in low for k in keywords):
|
||||
# Si la respuesta está en la misma línea separada por ':' o espacio
|
||||
if ':' in line:
|
||||
return line.split(':', 1)[1].strip()
|
||||
if i + 1 < len(lines):
|
||||
return lines[i + 1]
|
||||
return ''
|
||||
|
||||
def yes_no(value):
|
||||
v = (value or '').lower()
|
||||
if v.startswith('s') or v == 'yes':
|
||||
return True
|
||||
return False
|
||||
|
||||
# Extraer email con regex del texto completo
|
||||
emails = re.findall(r'[\w.\-]+@[\w.\-]+', text)
|
||||
email = emails[0] if emails else ''
|
||||
if email.lower() in ('no@no.com', 'no@no'):
|
||||
email = ''
|
||||
|
||||
data = {
|
||||
'celular': '',
|
||||
'whatsapp': '',
|
||||
'email': email,
|
||||
'fecha_nacimiento': '',
|
||||
'sexo': '',
|
||||
'lugar_nacimiento': '',
|
||||
'empleo': '',
|
||||
'estado_civil': '',
|
||||
'contacto_emergencia': '',
|
||||
'telefono_emergencia': '',
|
||||
'telefono_casa': '',
|
||||
'direccion': '',
|
||||
'recomendado_por': '',
|
||||
'comentarios': '',
|
||||
'hijos': 0,
|
||||
'embarazada': False,
|
||||
'lactancia': False,
|
||||
'anticonceptivos': False,
|
||||
'ejercicio': False,
|
||||
'alimentacion': False,
|
||||
'peso_normal': False,
|
||||
'enfermedad_seria': False,
|
||||
'alcohol': False,
|
||||
'problemas_rinon': False,
|
||||
'problemas_espalda': False,
|
||||
'problemas_cardiacos': False,
|
||||
'problemas_respiratorios': False,
|
||||
'alta_presion': False,
|
||||
'baja_presion': False,
|
||||
'diabetes': False,
|
||||
'hipertiroidismo': False,
|
||||
'hipotiroidismo': False,
|
||||
'colitis': False,
|
||||
'estrenimiento': False,
|
||||
'problemas_higado': False,
|
||||
'enfermedades_cronicas': False,
|
||||
'cirugias': False,
|
||||
'varices': False,
|
||||
'migrana': False,
|
||||
'desmaya_agujas': False,
|
||||
'alergias': '',
|
||||
'medicamentos': '',
|
||||
'tratamientos_previos': '',
|
||||
}
|
||||
|
||||
# Recorrer líneas buscando pares pregunta/respuesta
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
low = line.lower()
|
||||
nxt = lines[i + 1] if i + 1 < len(lines) else ''
|
||||
|
||||
# Datos personales
|
||||
if 'celular' in low and len(low) < 20:
|
||||
data['celular'] = re.sub(r'\D', '', nxt)
|
||||
elif 'whatsapp' in low and len(low) < 20:
|
||||
data['whatsapp'] = re.sub(r'\D', '', nxt)
|
||||
elif 'correo electrónico' in low or 'email' in low:
|
||||
data['email'] = nxt if '@' in nxt else data['email']
|
||||
elif 'fecha de nacimiento' in low:
|
||||
data['fecha_nacimiento'] = parse_spanish_date(nxt or line)
|
||||
elif 'lugar de nacimiento' in low:
|
||||
data['lugar_nacimiento'] = nxt
|
||||
elif low == 'sexo':
|
||||
data['sexo'] = 'female' if 'femenino' in nxt.lower() else ('male' if 'masculino' in nxt.lower() else '')
|
||||
elif 'empleo' in low:
|
||||
data['empleo'] = nxt
|
||||
elif 'estado civil' in low:
|
||||
data['estado_civil'] = nxt
|
||||
elif 'en caso de emergencia llamar a' in low:
|
||||
parts = nxt.split()
|
||||
if parts:
|
||||
data['contacto_emergencia'] = parts[0]
|
||||
data['telefono_emergencia'] = normalize_phone(' '.join(parts[1:]))
|
||||
elif 'teléfono casa' in low or 'telefono casa' in low:
|
||||
data['telefono_casa'] = re.sub(r'\D', '', nxt)
|
||||
elif low == 'dirección' or low == 'direccion':
|
||||
data['direccion'] = nxt
|
||||
elif 'recomendado por' in low:
|
||||
data['recomendado_por'] = nxt
|
||||
elif 'comentarios adicionales' in low:
|
||||
data['comentarios'] = nxt
|
||||
|
||||
# Historia clínica
|
||||
elif 'tiene hijos' in low:
|
||||
val = nxt.lower()
|
||||
if val.isdigit():
|
||||
data['hijos'] = int(val)
|
||||
elif val.startswith('s'):
|
||||
data['hijos'] = 1
|
||||
elif 'embarazada' in low:
|
||||
data['embarazada'] = yes_no(nxt)
|
||||
elif 'lactancia' in low:
|
||||
data['lactancia'] = yes_no(nxt)
|
||||
elif 'anticonceptivo' in low:
|
||||
data['anticonceptivos'] = yes_no(nxt)
|
||||
elif 'hace ejercicio' in low:
|
||||
data['ejercicio'] = yes_no(nxt)
|
||||
elif 'come en forma adecuada' in low:
|
||||
data['alimentacion'] = yes_no(nxt)
|
||||
elif 'peso corporal normal' in low:
|
||||
data['peso_normal'] = yes_no(nxt)
|
||||
elif 'enfermedad seria' in low or 'incapacidad física' in low:
|
||||
data['enfermedad_seria'] = yes_no(nxt)
|
||||
elif 'bebidas alcohólicas' in low:
|
||||
data['alcohol'] = yes_no(nxt)
|
||||
elif 'riñones' in low or 'rinon' in low:
|
||||
data['problemas_rinon'] = yes_no(nxt)
|
||||
elif 'espalda' in low:
|
||||
data['problemas_espalda'] = yes_no(nxt)
|
||||
elif 'cardiacos' in low or 'cardíacos' in low:
|
||||
data['problemas_cardiacos'] = yes_no(nxt)
|
||||
elif 'respiratorios' in low:
|
||||
data['problemas_respiratorios'] = yes_no(nxt)
|
||||
elif 'alta presión' in low or 'alta presion' in low:
|
||||
data['alta_presion'] = yes_no(nxt)
|
||||
elif 'baja presión' in low or 'baja presion' in low:
|
||||
data['baja_presion'] = yes_no(nxt)
|
||||
elif 'diabetes' in low:
|
||||
data['diabetes'] = yes_no(nxt)
|
||||
elif 'hipertiroidismo' in low:
|
||||
data['hipertiroidismo'] = yes_no(nxt)
|
||||
elif 'hipotiroidismo' in low:
|
||||
data['hipotiroidismo'] = yes_no(nxt)
|
||||
elif 'colitis' in low:
|
||||
data['colitis'] = yes_no(nxt)
|
||||
elif 'estreñimiento' in low:
|
||||
data['estrenimiento'] = yes_no(nxt)
|
||||
elif 'hígado' in low or 'higado' in low or 'vesícula' in low:
|
||||
data['problemas_higado'] = yes_no(nxt)
|
||||
elif 'enfermedades crónicas' in low or 'enfermedades cronicas' in low:
|
||||
data['enfermedades_cronicas'] = yes_no(nxt)
|
||||
elif 'realizado cirugías' in low or 'realizado cirugias' in low:
|
||||
data['cirugias'] = yes_no(nxt)
|
||||
elif 'varicosas' in low or 'varices' in low:
|
||||
data['varices'] = yes_no(nxt)
|
||||
elif 'migraña' in low or 'migrana' in low:
|
||||
data['migrana'] = yes_no(nxt)
|
||||
elif 'desmaya' in low and 'agujas' in low:
|
||||
data['desmaya_agujas'] = yes_no(nxt)
|
||||
elif low == 'alergias':
|
||||
data['alergias'] = nxt if nxt.lower() != 'no' else ''
|
||||
elif 'medicamentos actualmente' in low:
|
||||
data['medicamentos'] = nxt if nxt.lower() != 'no' else ''
|
||||
elif 'tratamientos médico' in low or 'tratamientos medico' in low or 'tratamientos cosméticos previos' in low:
|
||||
data['tratamientos_previos'] = nxt if nxt.lower() != 'no' else ''
|
||||
|
||||
i += 1
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def load_detail_cache():
|
||||
if CACHE.exists():
|
||||
try:
|
||||
with open(CACHE, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
log(f'No se pudo cargar caché: {e}')
|
||||
return {}
|
||||
|
||||
|
||||
def save_detail_cache(cache):
|
||||
with open(CACHE, 'w', encoding='utf-8') as f:
|
||||
json.dump(cache, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def fetch_one_detail(legacy_id, fallback_name):
|
||||
try:
|
||||
resp = session.get(f'{BASE}/expediente/ver/{legacy_id}', timeout=30)
|
||||
resp.raise_for_status()
|
||||
return str(legacy_id), parse_patient_detail(resp.text, fallback_name)
|
||||
except Exception as e:
|
||||
return str(legacy_id), {'_error': str(e)}
|
||||
|
||||
|
||||
def extract_paciente_details(rows, cache, max_workers=10):
|
||||
to_fetch = []
|
||||
for r in rows:
|
||||
legacy_id = r.get('id')
|
||||
if not legacy_id:
|
||||
continue
|
||||
sid = str(legacy_id)
|
||||
if sid in cache and cache[sid].get('celular'):
|
||||
continue
|
||||
to_fetch.append((legacy_id, r.get('nombre', '')))
|
||||
|
||||
log(f'Extrayendo detalles de {len(to_fetch)} pacientes con {max_workers} workers...')
|
||||
total = len(to_fetch)
|
||||
completed = 0
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as ex:
|
||||
future_to_id = {ex.submit(fetch_one_detail, lid, name): lid for lid, name in to_fetch}
|
||||
for future in as_completed(future_to_id):
|
||||
lid, detail = future.result()
|
||||
if '_error' not in detail:
|
||||
cache[lid] = detail
|
||||
completed += 1
|
||||
if completed % 500 == 0:
|
||||
log(f' {completed}/{total} detalles procesados')
|
||||
save_detail_cache(cache)
|
||||
time.sleep(0.01)
|
||||
save_detail_cache(cache)
|
||||
log('Detalles de pacientes completados')
|
||||
|
||||
|
||||
def extract_expedientes():
|
||||
rows = paginate('/expedientes/json', lambda s, l: dt_params(s, l), 'pacientes')
|
||||
cache = load_detail_cache()
|
||||
extract_paciente_details(rows, cache)
|
||||
|
||||
out = []
|
||||
for r in rows:
|
||||
nombre = (r.get('nombre') or '').strip()
|
||||
if not nombre or nombre.upper() == '. . NO CITAR':
|
||||
continue
|
||||
detail = cache.get(str(r.get('id', '')), {})
|
||||
phone = detail.get('celular') or detail.get('whatsapp') or (r.get('telefono') or '').strip()
|
||||
phone = normalize_phone(phone)
|
||||
email = detail.get('email', '')
|
||||
if email.lower() in ('no@no.com', 'no@no'):
|
||||
email = ''
|
||||
|
||||
out.append({
|
||||
'legacy_id': r.get('id', ''),
|
||||
'folio': r.get('folio', ''),
|
||||
'nombre': nombre,
|
||||
'apellido_paterno': '',
|
||||
'apellido_materno': '',
|
||||
'telefono': phone,
|
||||
'email': email,
|
||||
'fecha_nacimiento': detail.get('fecha_nacimiento', ''),
|
||||
'sexo': detail.get('sexo', ''),
|
||||
'lugar_nacimiento': detail.get('lugar_nacimiento', ''),
|
||||
'empleo': detail.get('empleo', ''),
|
||||
'estado_civil': detail.get('estado_civil', ''),
|
||||
'contacto_emergencia': detail.get('contacto_emergencia', ''),
|
||||
'telefono_emergencia': detail.get('telefono_emergencia', ''),
|
||||
'telefono_casa': detail.get('telefono_casa', ''),
|
||||
'direccion': detail.get('direccion', ''),
|
||||
'recomendado_por': detail.get('recomendado_por', ''),
|
||||
'comentarios': detail.get('comentarios', ''),
|
||||
'medico_principal': r.get('medico_principal') or '',
|
||||
'etiquetas': ','.join(str(c) for c in (r.get('condiciones') or [])) if r.get('condiciones') else '',
|
||||
'vip': '1' if r.get('vip') else '0',
|
||||
'fuente': 'legacy',
|
||||
# Historia clínica
|
||||
'hijos': detail.get('hijos', 0),
|
||||
'embarazada': '1' if detail.get('embarazada') else '0',
|
||||
'lactancia': '1' if detail.get('lactancia') else '0',
|
||||
'anticonceptivos': '1' if detail.get('anticonceptivos') else '0',
|
||||
'ejercicio': '1' if detail.get('ejercicio') else '0',
|
||||
'alimentacion': '1' if detail.get('alimentacion') else '0',
|
||||
'peso_normal': '1' if detail.get('peso_normal') else '0',
|
||||
'enfermedad_seria': '1' if detail.get('enfermedad_seria') else '0',
|
||||
'alcohol': '1' if detail.get('alcohol') else '0',
|
||||
'problemas_rinon': '1' if detail.get('problemas_rinon') else '0',
|
||||
'problemas_espalda': '1' if detail.get('problemas_espalda') else '0',
|
||||
'problemas_cardiacos': '1' if detail.get('problemas_cardiacos') else '0',
|
||||
'problemas_respiratorios': '1' if detail.get('problemas_respiratorios') else '0',
|
||||
'alta_presion': '1' if detail.get('alta_presion') else '0',
|
||||
'baja_presion': '1' if detail.get('baja_presion') else '0',
|
||||
'diabetes': '1' if detail.get('diabetes') else '0',
|
||||
'hipertiroidismo': '1' if detail.get('hipertiroidismo') else '0',
|
||||
'hipotiroidismo': '1' if detail.get('hipotiroidismo') else '0',
|
||||
'colitis': '1' if detail.get('colitis') else '0',
|
||||
'estrenimiento': '1' if detail.get('estrenimiento') else '0',
|
||||
'problemas_higado': '1' if detail.get('problemas_higado') else '0',
|
||||
'enfermedades_cronicas': '1' if detail.get('enfermedades_cronicas') else '0',
|
||||
'cirugias': '1' if detail.get('cirugias') else '0',
|
||||
'varices': '1' if detail.get('varices') else '0',
|
||||
'migrana': '1' if detail.get('migrana') else '0',
|
||||
'desmaya_agujas': '1' if detail.get('desmaya_agujas') else '0',
|
||||
'alergias': detail.get('alergias', ''),
|
||||
'medicamentos': detail.get('medicamentos', ''),
|
||||
'tratamientos_previos': detail.get('tratamientos_previos', ''),
|
||||
})
|
||||
|
||||
save_csv('pacientes', out, [
|
||||
'legacy_id', 'folio', 'nombre', 'apellido_paterno', 'apellido_materno',
|
||||
'telefono', 'email', 'fecha_nacimiento', 'sexo', 'lugar_nacimiento',
|
||||
'empleo', 'estado_civil', 'contacto_emergencia', 'telefono_emergencia',
|
||||
'telefono_casa', 'direccion', 'recomendado_por', 'comentarios',
|
||||
'medico_principal', 'etiquetas', 'vip', 'fuente',
|
||||
'hijos', 'embarazada', 'lactancia', 'anticonceptivos', 'ejercicio',
|
||||
'alimentacion', 'peso_normal', 'enfermedad_seria', 'alcohol',
|
||||
'problemas_rinon', 'problemas_espalda', 'problemas_cardiacos',
|
||||
'problemas_respiratorios', 'alta_presion', 'baja_presion', 'diabetes',
|
||||
'hipertiroidismo', 'hipotiroidismo', 'colitis', 'estrenimiento',
|
||||
'problemas_higado', 'enfermedades_cronicas', 'cirugias', 'varices',
|
||||
'migrana', 'desmaya_agujas', 'alergias', 'medicamentos', 'tratamientos_previos'
|
||||
])
|
||||
return out
|
||||
|
||||
|
||||
def extract_monederos():
|
||||
rows = paginate('/monederos/json', lambda s, l: dt_params(s, l), 'monederos')
|
||||
out = []
|
||||
for r in rows:
|
||||
saldo = r.get('saldo') or {}
|
||||
phone = normalize_phone(r.get('celular') or '')
|
||||
out.append({
|
||||
'telefono': phone,
|
||||
'puntos': float(saldo.get('mn') or 0),
|
||||
'activo': '1' if r.get('activado') else '0',
|
||||
})
|
||||
save_csv('monederos', out, ['telefono', 'puntos', 'activo'])
|
||||
return out
|
||||
|
||||
|
||||
def extract_crm():
|
||||
rows = paginate('/crm/prospectos/json', lambda s, l: dt_params(s, l), 'crm prospectos')
|
||||
out = []
|
||||
for r in rows:
|
||||
out.append({
|
||||
'nombre': r.get('nombre') or '',
|
||||
'folio': r.get('folio') or '',
|
||||
'status': r.get('status') or 0,
|
||||
'total_citas': r.get('total_citas') or 0,
|
||||
'siguiente_cita': r.get('siguiente_cita') or '',
|
||||
})
|
||||
save_csv('crm_prospectos', out, ['nombre', 'folio', 'status', 'total_citas', 'siguiente_cita'])
|
||||
return out
|
||||
|
||||
|
||||
def extract_servicios():
|
||||
rows = paginate('/servicios/json', lambda s, l: dt_params(s, l), 'servicios')
|
||||
out = []
|
||||
for r in rows:
|
||||
precio_str = (r.get('precio_minimo') or '0').replace('$', '').replace('m.n.', '').replace(',', '').strip()
|
||||
try:
|
||||
precio = float(precio_str.split()[0])
|
||||
except Exception:
|
||||
precio = 0
|
||||
out.append({
|
||||
'codigo': f"S{r.get('id', 0):04d}",
|
||||
'nombre': r.get('servicio') or '',
|
||||
'categoria': (r.get('categoria') or '').lower().replace(' ', '_'),
|
||||
'precio': precio,
|
||||
'duracion_min': 30,
|
||||
'descripcion': r.get('grupo_texto') or '',
|
||||
'color': r.get('color') or '#1abc9c',
|
||||
'grupo': r.get('grupo_texto') or '',
|
||||
'favorito': '1' if r.get('favorito') else '0',
|
||||
'activo': '1' if r.get('activado') else '0',
|
||||
})
|
||||
save_csv('servicios', out, [
|
||||
'codigo', 'nombre', 'categoria', 'precio', 'duracion_min',
|
||||
'descripcion', 'color', 'grupo', 'favorito', 'activo'
|
||||
])
|
||||
return out
|
||||
|
||||
|
||||
def extract_ventas(start_date='2022-01-01', end_date='2026-07-06'):
|
||||
log(f'Extrayendo ventas de {start_date} a {end_date}...')
|
||||
phone_by_legacy = {}
|
||||
pac_path = OUTDIR / 'pacientes.csv'
|
||||
if pac_path.exists():
|
||||
with open(pac_path, newline='', encoding='utf-8') as f:
|
||||
for row in csv.DictReader(f):
|
||||
phone_by_legacy[row.get('legacy_id', '')] = row.get('telefono', '')
|
||||
|
||||
all_rows = []
|
||||
current = datetime.strptime(start_date, '%Y-%m-%d').date()
|
||||
end = datetime.strptime(end_date, '%Y-%m-%d').date()
|
||||
while current <= end:
|
||||
chunk_end = min(current + timedelta(days=30), end)
|
||||
url = f"/ventas/json/{current.strftime('%Y-%m-%d')}/{chunk_end.strftime('%Y-%m-%d')}"
|
||||
try:
|
||||
data = fetch_json(url, dt_params(0, 10000))
|
||||
rows = data.get('data', [])
|
||||
all_rows.extend(rows)
|
||||
log(f' {current} - {chunk_end}: {len(rows)} ventas (total: {len(all_rows)})')
|
||||
except Exception as e:
|
||||
log(f' ERROR {current}-{chunk_end}: {e}')
|
||||
current = chunk_end + timedelta(days=1)
|
||||
time.sleep(0.5)
|
||||
|
||||
out = []
|
||||
for r in all_rows:
|
||||
expediente_id = r.get('expedientes_id') or ''
|
||||
phone = normalize_phone(phone_by_legacy.get(str(expediente_id), ''))
|
||||
conceptos = r.get('conceptos') or []
|
||||
if not conceptos:
|
||||
conceptos = [{'titulo': r.get('motivo') or 'Servicio', 'subtotal': r.get('subtotal') or 0}]
|
||||
for c in conceptos:
|
||||
precio = 0
|
||||
try:
|
||||
precio = float(c.get('subtotal') or c.get('pagado') or 0)
|
||||
except Exception:
|
||||
pass
|
||||
out.append({
|
||||
'legacy_id': r.get('id', ''),
|
||||
'expedientes_id': expediente_id,
|
||||
'paciente_telefono': phone,
|
||||
'paciente_nombre': r.get('nombre') or '',
|
||||
'fecha': (r.get('created_at') or '')[:10],
|
||||
'servicio_codigo': '',
|
||||
'servicio_nombre': c.get('titulo') or '',
|
||||
'cantidad': c.get('cantidad', 1) or 1,
|
||||
'precio_unitario': precio,
|
||||
'descuento': 0,
|
||||
'impuesto': 0,
|
||||
'estado': 'confirmed' if not r.get('cancelada') else 'cancelled',
|
||||
'notas': r.get('motivo') or '',
|
||||
})
|
||||
save_csv('ventas', out, [
|
||||
'legacy_id', 'expedientes_id', 'paciente_telefono', 'paciente_nombre', 'fecha',
|
||||
'servicio_codigo', 'servicio_nombre', 'cantidad', 'precio_unitario',
|
||||
'descuento', 'impuesto', 'estado', 'notas'
|
||||
])
|
||||
return out
|
||||
|
||||
|
||||
def extract_citas(start_date='2022-11-01', end_date='2026-07-06'):
|
||||
log(f'Extrayendo citas de {start_date} a {end_date}...')
|
||||
phone_by_legacy = {}
|
||||
pac_path = OUTDIR / 'pacientes.csv'
|
||||
if pac_path.exists():
|
||||
with open(pac_path, newline='', encoding='utf-8') as f:
|
||||
for row in csv.DictReader(f):
|
||||
phone_by_legacy[row.get('legacy_id', '')] = row.get('telefono', '')
|
||||
|
||||
medicos_map = {
|
||||
'32': 'Dra. Alejandra Ramos',
|
||||
'33': 'Dra. Lidia Martinez',
|
||||
'46': 'Dra. Fernanda Cerecer',
|
||||
'49': 'RODRIGUEZ FRIDA',
|
||||
'54': 'Dr. Benjamín Adrián',
|
||||
'79': 'XIMENA',
|
||||
'80': 'ELY',
|
||||
}
|
||||
|
||||
all_citas = []
|
||||
current = datetime.strptime(start_date, '%Y-%m-%d').date()
|
||||
end = datetime.strptime(end_date, '%Y-%m-%d').date()
|
||||
days_with = 0
|
||||
while current <= end:
|
||||
url = f"/agenda/citas/{current.strftime('%Y-%m-%d')}"
|
||||
try:
|
||||
data = fetch_json(url)
|
||||
citas = data.get('citas', [])
|
||||
if citas:
|
||||
days_with += 1
|
||||
all_citas.extend(citas)
|
||||
if current.day == 1 or len(citas) > 0:
|
||||
log(f' {current}: {len(citas)} citas (total acumulado: {len(all_citas)})')
|
||||
except Exception as e:
|
||||
log(f' ERROR {current}: {e}')
|
||||
current += timedelta(days=1)
|
||||
time.sleep(0.05)
|
||||
|
||||
out = []
|
||||
for c in all_citas:
|
||||
nombre = (c.get('nombre') or '').strip()
|
||||
if not nombre or nombre.upper().startswith('. . NO CITAR'):
|
||||
continue
|
||||
expediente_id = c.get('expedientes_id') or ''
|
||||
phone = normalize_phone(c.get('telefono') or phone_by_legacy.get(str(expediente_id), ''))
|
||||
if not phone and c.get('whatsapp'):
|
||||
phone = normalize_phone(c.get('whatsapp'))
|
||||
email = (c.get('correo') or '').strip()
|
||||
if email.lower() in ('no@no.com', 'no@no'):
|
||||
email = ''
|
||||
inicio = c.get('inicio') or ''
|
||||
fin = c.get('fin') or ''
|
||||
hora_str = ''
|
||||
if len(inicio) == 4:
|
||||
hora_str = f"{inicio[:2]}:{inicio[2:]}"
|
||||
elif len(inicio) == 3:
|
||||
hora_str = f"0{inicio[0]}:{inicio[1:]}"
|
||||
medico_id = str(c.get('medicos_id') or '')
|
||||
doctor_nombre = medicos_map.get(medico_id, medico_id if medico_id and medico_id != '0' else '')
|
||||
out.append({
|
||||
'legacy_id': c.get('id', ''),
|
||||
'expedientes_id': expediente_id,
|
||||
'paciente_telefono': phone,
|
||||
'paciente_nombre': nombre,
|
||||
'servicio_codigo': '',
|
||||
'servicio_nombre': c.get('titulo') or '',
|
||||
'fecha': c.get('fecha', ''),
|
||||
'hora': hora_str,
|
||||
'hora_fin': f"{fin[:2]}:{fin[2:]}" if len(fin) == 4 else '',
|
||||
'estado': 'confirmed',
|
||||
'doctor_nombre': doctor_nombre,
|
||||
'sucursal': 'rosarito',
|
||||
'medio': 'onsite',
|
||||
'notas': c.get('observaciones') or '',
|
||||
'estado_pago': 'not_paid',
|
||||
'monto_pagado': 0,
|
||||
})
|
||||
save_csv('citas', out, [
|
||||
'legacy_id', 'expedientes_id', 'paciente_telefono', 'paciente_nombre',
|
||||
'servicio_codigo', 'servicio_nombre', 'fecha', 'hora', 'hora_fin',
|
||||
'estado', 'doctor_nombre', 'sucursal', 'medio', 'notas',
|
||||
'estado_pago', 'monto_pagado'
|
||||
])
|
||||
log(f'Citas: {len(out)} registros útiles en {days_with} días con citas')
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
login()
|
||||
except Exception as e:
|
||||
log(f'ERROR login: {e}')
|
||||
sys.exit(1)
|
||||
|
||||
extract_expedientes()
|
||||
extract_monederos()
|
||||
extract_crm()
|
||||
extract_servicios()
|
||||
extract_ventas()
|
||||
extract_citas()
|
||||
|
||||
log('Extracción completada. Archivos en /root/migracion/extraccion/')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
517
migracion/extraer_skeen.py.bak
Executable file
517
migracion/extraer_skeen.py.bak
Executable file
@@ -0,0 +1,517 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Extracción read-only de datos del sistema legacy SKEEN.
|
||||
Uso autorizado por el responsable del proyecto con credenciales proporcionadas.
|
||||
Solo lee datos; no modifica nada en https://sistema.skeenmx.app/
|
||||
"""
|
||||
|
||||
import requests
|
||||
import csv
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
BASE = 'https://sistema.skeenmx.app'
|
||||
USERNAME = 'JoseSkeen'
|
||||
PASSWORD = 'SKr#902_88'
|
||||
OUTDIR = Path('/root/migracion/extraccion')
|
||||
OUTDIR.mkdir(parents=True, exist_ok=True)
|
||||
CACHE = OUTDIR / 'detalle_pacientes.json'
|
||||
|
||||
session = requests.Session()
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(f'[{datetime.now().strftime("%H:%M:%S")}] {msg}')
|
||||
|
||||
|
||||
def login():
|
||||
log('Iniciando sesión...')
|
||||
r = session.get(f'{BASE}/login', timeout=30)
|
||||
r.raise_for_status()
|
||||
soup = BeautifulSoup(r.text, 'html.parser')
|
||||
token = soup.find('input', {'name': '_token'})
|
||||
token = token['value'] if token else ''
|
||||
data = {
|
||||
'_token': token,
|
||||
'username': USERNAME,
|
||||
'password': PASSWORD,
|
||||
}
|
||||
r = session.post(f'{BASE}/login', data=data, timeout=30, allow_redirects=True)
|
||||
r.raise_for_status()
|
||||
if '/login' in r.url or 'estas credenciales' in r.text.lower():
|
||||
raise Exception('Login fallido. Verifica credenciales.')
|
||||
log('Login exitoso')
|
||||
return True
|
||||
|
||||
|
||||
def dt_params(start=0, length=100, search=''):
|
||||
"""Genera parámetros DataTables server-side."""
|
||||
return {
|
||||
'draw': 1,
|
||||
'columns[0][data]': 'id',
|
||||
'columns[0][name]': '',
|
||||
'columns[0][searchable]': 'true',
|
||||
'columns[0][orderable]': 'true',
|
||||
'columns[0][search][value]': '',
|
||||
'columns[0][search][regex]': 'false',
|
||||
'start': start,
|
||||
'length': length,
|
||||
'search[value]': search,
|
||||
'search[regex]': 'false',
|
||||
'order[0][column]': '0',
|
||||
'order[0][dir]': 'asc',
|
||||
'_': int(time.time() * 1000),
|
||||
}
|
||||
|
||||
|
||||
def fetch_json(url, params=None):
|
||||
headers = {'X-Requested-With': 'XMLHttpRequest'}
|
||||
r = session.get(f'{BASE}{url}', params=params or {}, headers=headers, timeout=120)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def paginate(url, params_builder, label, page_size=500, max_pages=None):
|
||||
"""Recorre un endpoint DataTables paginado y devuelve todas las filas."""
|
||||
log(f'Extrayendo {label} desde {url}...')
|
||||
all_rows = []
|
||||
start = 0
|
||||
page = 0
|
||||
while True:
|
||||
params = params_builder(start, page_size)
|
||||
try:
|
||||
data = fetch_json(url, params)
|
||||
except Exception as e:
|
||||
log(f' ERROR página {page}: {e}')
|
||||
break
|
||||
rows = data.get('data', [])
|
||||
if not rows:
|
||||
break
|
||||
all_rows.extend(rows)
|
||||
total = data.get('recordsTotal') or data.get('recordsFiltered') or 0
|
||||
log(f' página {page + 1}: {len(rows)} filas (total acumulado: {len(all_rows)}/{total})')
|
||||
if len(rows) < page_size:
|
||||
break
|
||||
start += page_size
|
||||
page += 1
|
||||
if max_pages and page >= max_pages:
|
||||
break
|
||||
time.sleep(0.3)
|
||||
log(f'{label}: {len(all_rows)} registros extraídos')
|
||||
return all_rows
|
||||
|
||||
|
||||
def save_csv(name, rows, fieldnames):
|
||||
path = OUTDIR / f'{name}.csv'
|
||||
with open(path, 'w', newline='', encoding='utf-8') as f:
|
||||
writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction='ignore')
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
log(f'Guardado {path}: {len(rows)} registros')
|
||||
|
||||
|
||||
MESES_ES = {
|
||||
'enero': '01', 'febrero': '02', 'marzo': '03', 'abril': '04',
|
||||
'mayo': '05', 'junio': '06', 'julio': '07', 'agosto': '08',
|
||||
'septiembre': '09', 'octubre': '10', 'noviembre': '11', 'diciembre': '12',
|
||||
}
|
||||
|
||||
|
||||
def parse_spanish_date(text):
|
||||
"""Convierte '05 de Septiembre del 1998' → '1998-09-05'."""
|
||||
text = (text or '').strip().lower()
|
||||
if not text:
|
||||
return ''
|
||||
m = re.search(r'(\d{1,2})\s+de\s+([a-záéíóúñ]+)\s+(?:del|de)\s+(\d{4})', text)
|
||||
if not m:
|
||||
return ''
|
||||
dia, mes, anio = m.groups()
|
||||
mes_num = MESES_ES.get(mes)
|
||||
if not mes_num:
|
||||
return ''
|
||||
return f'{anio}-{mes_num}-{int(dia):02d}'
|
||||
|
||||
|
||||
def parse_patient_detail(html, fallback_name=''):
|
||||
"""Extrae teléfono, email, fecha de nacimiento y sexo del detalle."""
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
data = {
|
||||
'celular': '',
|
||||
'whatsapp': '',
|
||||
'email': '',
|
||||
'fecha_nacimiento': '',
|
||||
'sexo': '',
|
||||
}
|
||||
# Campos de tablas
|
||||
for tr in soup.find_all('tr'):
|
||||
th = tr.find('th')
|
||||
td = tr.find('td')
|
||||
if not th or not td:
|
||||
continue
|
||||
key = ' '.join(th.get_text(strip=True).split()).lower().rstrip(':')
|
||||
val = ' '.join(td.get_text(strip=True).split())
|
||||
if key == 'celular':
|
||||
data['celular'] = re.sub(r'\D', '', val)
|
||||
elif key == 'whatsapp':
|
||||
data['whatsapp'] = re.sub(r'\D', '', val)
|
||||
elif key == 'sexo':
|
||||
data['sexo'] = 'female' if 'femenino' in val.lower() else ('male' if 'masculino' in val.lower() else val)
|
||||
elif key == 'fecha de nacimiento':
|
||||
data['fecha_nacimiento'] = parse_spanish_date(val)
|
||||
|
||||
# Email suele aparecer como texto suelto o en un mailto
|
||||
emails = re.findall(r'[\w.\-]+@[\w.\-]+', soup.get_text())
|
||||
if emails:
|
||||
data['email'] = emails[0]
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def load_detail_cache():
|
||||
if CACHE.exists():
|
||||
try:
|
||||
with open(CACHE, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
log(f'No se pudo cargar caché: {e}')
|
||||
return {}
|
||||
|
||||
|
||||
def save_detail_cache(cache):
|
||||
with open(CACHE, 'w', encoding='utf-8') as f:
|
||||
json.dump(cache, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def fetch_one_detail(legacy_id, fallback_name):
|
||||
try:
|
||||
resp = session.get(f'{BASE}/expediente/ver/{legacy_id}', timeout=30)
|
||||
resp.raise_for_status()
|
||||
return str(legacy_id), parse_patient_detail(resp.text, fallback_name)
|
||||
except Exception as e:
|
||||
return str(legacy_id), {'_error': str(e)}
|
||||
|
||||
|
||||
def extract_paciente_details(rows, cache, max_workers=10):
|
||||
"""Recorre /expediente/ver/{id} para enriquecer teléfono/email/etc."""
|
||||
to_fetch = []
|
||||
for r in rows:
|
||||
legacy_id = r.get('id')
|
||||
if not legacy_id:
|
||||
continue
|
||||
sid = str(legacy_id)
|
||||
# Solo re-extraer si no tenemos teléfono o no está cacheado
|
||||
if sid in cache and (cache[sid].get('celular') or cache[sid].get('whatsapp')):
|
||||
continue
|
||||
to_fetch.append((legacy_id, r.get('nombre', '')))
|
||||
|
||||
log(f'Extrayendo detalles de {len(to_fetch)} pacientes con {max_workers} workers...')
|
||||
total = len(to_fetch)
|
||||
completed = 0
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as ex:
|
||||
future_to_id = {ex.submit(fetch_one_detail, lid, name): lid for lid, name in to_fetch}
|
||||
for future in as_completed(future_to_id):
|
||||
lid, detail = future.result()
|
||||
if '_error' not in detail:
|
||||
cache[lid] = detail
|
||||
completed += 1
|
||||
if completed % 500 == 0:
|
||||
log(f' {completed}/{total} detalles procesados')
|
||||
save_detail_cache(cache)
|
||||
time.sleep(0.01) # pequeña pausa entre completados
|
||||
save_detail_cache(cache)
|
||||
log('Detalles de pacientes completados')
|
||||
|
||||
|
||||
def extract_expedientes():
|
||||
rows = paginate('/expedientes/json', lambda s, l: dt_params(s, l), 'pacientes')
|
||||
cache = load_detail_cache()
|
||||
extract_paciente_details(rows, cache)
|
||||
|
||||
out = []
|
||||
for r in rows:
|
||||
nombre = (r.get('nombre') or '').strip()
|
||||
if not nombre or nombre.upper() == '. . NO CITAR':
|
||||
continue
|
||||
detail = cache.get(str(r.get('id', '')), {})
|
||||
# Prioridad: celular > whatsapp > teléfono del listado
|
||||
phone = detail.get('celular') or detail.get('whatsapp') or (r.get('telefono') or '').strip()
|
||||
# Limpieza: quitar todo excepto dígitos; si empieza con 52 y tiene 12+ dígitos dejarlo, sino agregar prefijo MX si aplica
|
||||
phone = re.sub(r'\D', '', phone)
|
||||
if phone and not phone.startswith('52') and len(phone) == 10:
|
||||
phone = '52' + phone
|
||||
email = detail.get('email', '')
|
||||
# Algunos correos dummy los ignoramos
|
||||
if email.lower() in ('no@no.com', 'no@no'):
|
||||
email = ''
|
||||
out.append({
|
||||
'legacy_id': r.get('id', ''),
|
||||
'folio': r.get('folio', ''),
|
||||
'nombre': nombre,
|
||||
'apellido_paterno': '',
|
||||
'apellido_materno': '',
|
||||
'telefono': phone,
|
||||
'email': email,
|
||||
'fecha_nacimiento': detail.get('fecha_nacimiento', ''),
|
||||
'sexo': detail.get('sexo', ''),
|
||||
'medico_principal': r.get('medico_principal') or '',
|
||||
'etiquetas': ','.join(str(c) for c in (r.get('condiciones') or [])) if r.get('condiciones') else '',
|
||||
'vip': '1' if r.get('vip') else '0',
|
||||
'fuente': 'legacy',
|
||||
})
|
||||
save_csv('pacientes', out, [
|
||||
'legacy_id', 'folio', 'nombre', 'apellido_paterno', 'apellido_materno',
|
||||
'telefono', 'email', 'fecha_nacimiento', 'sexo', 'medico_principal',
|
||||
'etiquetas', 'vip', 'fuente'
|
||||
])
|
||||
return out
|
||||
|
||||
|
||||
def extract_monederos():
|
||||
rows = paginate('/monederos/json', lambda s, l: dt_params(s, l), 'monederos')
|
||||
out = []
|
||||
for r in rows:
|
||||
saldo = r.get('saldo') or {}
|
||||
phone = re.sub(r'\D', '', (r.get('celular') or '').strip())
|
||||
if phone and not phone.startswith('52') and len(phone) == 10:
|
||||
phone = '52' + phone
|
||||
out.append({
|
||||
'telefono': phone,
|
||||
'puntos': float(saldo.get('mn') or 0),
|
||||
'activo': '1' if r.get('activado') else '0',
|
||||
})
|
||||
save_csv('monederos', out, ['telefono', 'puntos', 'activo'])
|
||||
return out
|
||||
|
||||
|
||||
def extract_crm():
|
||||
rows = paginate('/crm/prospectos/json', lambda s, l: dt_params(s, l), 'crm prospectos')
|
||||
out = []
|
||||
for r in rows:
|
||||
out.append({
|
||||
'nombre': r.get('nombre') or '',
|
||||
'folio': r.get('folio') or '',
|
||||
'status': r.get('status') or 0,
|
||||
'total_citas': r.get('total_citas') or 0,
|
||||
'siguiente_cita': r.get('siguiente_cita') or '',
|
||||
})
|
||||
save_csv('crm_prospectos', out, ['nombre', 'folio', 'status', 'total_citas', 'siguiente_cita'])
|
||||
return out
|
||||
|
||||
|
||||
def extract_servicios():
|
||||
rows = paginate('/servicios/json', lambda s, l: dt_params(s, l), 'servicios')
|
||||
out = []
|
||||
for r in rows:
|
||||
precio_str = (r.get('precio_minimo') or '0').replace('$', '').replace('m.n.', '').replace(',', '').strip()
|
||||
try:
|
||||
precio = float(precio_str.split()[0])
|
||||
except Exception:
|
||||
precio = 0
|
||||
out.append({
|
||||
'codigo': f"S{r.get('id', 0):04d}",
|
||||
'nombre': r.get('servicio') or '',
|
||||
'categoria': (r.get('categoria') or '').lower().replace(' ', '_'),
|
||||
'precio': precio,
|
||||
'duracion_min': 30,
|
||||
'descripcion': r.get('grupo_texto') or '',
|
||||
'color': r.get('color') or '#1abc9c',
|
||||
'grupo': r.get('grupo_texto') or '',
|
||||
'favorito': '1' if r.get('favorito') else '0',
|
||||
'activo': '1' if r.get('activado') else '0',
|
||||
})
|
||||
save_csv('servicios', out, [
|
||||
'codigo', 'nombre', 'categoria', 'precio', 'duracion_min',
|
||||
'descripcion', 'color', 'grupo', 'favorito', 'activo'
|
||||
])
|
||||
return out
|
||||
|
||||
|
||||
def normalize_phone(phone):
|
||||
phone = re.sub(r'\D', '', (phone or ''))
|
||||
if phone and not phone.startswith('52') and len(phone) == 10:
|
||||
phone = '52' + phone
|
||||
return phone
|
||||
|
||||
|
||||
def extract_ventas(start_date='2022-01-01', end_date='2026-07-06'):
|
||||
"""Extrae ventas por mes y enriquece con teléfono del paciente."""
|
||||
log(f'Extrayendo ventas de {start_date} a {end_date}...')
|
||||
# Cargar mapeo legacy_id → teléfono desde pacientes.csv si ya existe
|
||||
phone_by_legacy = {}
|
||||
pac_path = OUTDIR / 'pacientes.csv'
|
||||
if pac_path.exists():
|
||||
with open(pac_path, newline='', encoding='utf-8') as f:
|
||||
for row in csv.DictReader(f):
|
||||
phone_by_legacy[row.get('legacy_id', '')] = row.get('telefono', '')
|
||||
|
||||
all_rows = []
|
||||
current = datetime.strptime(start_date, '%Y-%m-%d').date()
|
||||
end = datetime.strptime(end_date, '%Y-%m-%d').date()
|
||||
while current <= end:
|
||||
chunk_end = min(current + timedelta(days=30), end)
|
||||
url = f"/ventas/json/{current.strftime('%Y-%m-%d')}/{chunk_end.strftime('%Y-%m-%d')}"
|
||||
try:
|
||||
data = fetch_json(url, dt_params(0, 10000))
|
||||
rows = data.get('data', [])
|
||||
all_rows.extend(rows)
|
||||
log(f' {current} - {chunk_end}: {len(rows)} ventas (total: {len(all_rows)})')
|
||||
except Exception as e:
|
||||
log(f' ERROR {current}-{chunk_end}: {e}')
|
||||
current = chunk_end + timedelta(days=1)
|
||||
time.sleep(0.5)
|
||||
|
||||
out = []
|
||||
for r in all_rows:
|
||||
expediente_id = r.get('expedientes_id') or ''
|
||||
phone = normalize_phone(phone_by_legacy.get(str(expediente_id), ''))
|
||||
# Servicios vendidos: tomar del primer concepto; si hay varios, crear una línea por concepto
|
||||
conceptos = r.get('conceptos') or []
|
||||
if not conceptos:
|
||||
conceptos = [{'titulo': r.get('motivo') or 'Servicio', 'subtotal': r.get('subtotal') or 0}]
|
||||
for c in conceptos:
|
||||
precio = 0
|
||||
try:
|
||||
precio = float(c.get('subtotal') or c.get('pagado') or 0)
|
||||
except Exception:
|
||||
pass
|
||||
out.append({
|
||||
'legacy_id': r.get('id', ''),
|
||||
'expedientes_id': expediente_id,
|
||||
'paciente_telefono': phone,
|
||||
'paciente_nombre': r.get('nombre') or '',
|
||||
'fecha': (r.get('created_at') or '')[:10],
|
||||
'servicio_codigo': '',
|
||||
'servicio_nombre': c.get('titulo') or '',
|
||||
'cantidad': c.get('cantidad', 1) or 1,
|
||||
'precio_unitario': precio,
|
||||
'descuento': 0,
|
||||
'impuesto': 0,
|
||||
'estado': 'confirmed' if not r.get('cancelada') else 'cancelled',
|
||||
'notas': r.get('motivo') or '',
|
||||
})
|
||||
save_csv('ventas', out, [
|
||||
'legacy_id', 'expedientes_id', 'paciente_telefono', 'paciente_nombre', 'fecha',
|
||||
'servicio_codigo', 'servicio_nombre', 'cantidad', 'precio_unitario',
|
||||
'descuento', 'impuesto', 'estado', 'notas'
|
||||
])
|
||||
return out
|
||||
|
||||
|
||||
def extract_citas(start_date='2022-11-01', end_date='2026-07-06'):
|
||||
"""Itera día a día sobre /agenda/citas/YYYY-MM-DD para obtener todas las citas."""
|
||||
log(f'Extrayendo citas de {start_date} a {end_date}...')
|
||||
phone_by_legacy = {}
|
||||
pac_path = OUTDIR / 'pacientes.csv'
|
||||
if pac_path.exists():
|
||||
with open(pac_path, newline='', encoding='utf-8') as f:
|
||||
for row in csv.DictReader(f):
|
||||
phone_by_legacy[row.get('legacy_id', '')] = row.get('telefono', '')
|
||||
|
||||
# Mapeo de IDs de médicos del sistema legacy a nombres (de /agenda)
|
||||
medicos_map = {
|
||||
'32': 'Dra. Alejandra Ramos',
|
||||
'33': 'Dra. Lidia Martinez',
|
||||
'46': 'Dra. Fernanda Cerecer',
|
||||
'49': 'RODRIGUEZ FRIDA',
|
||||
'54': 'Dr. Benjamín Adrián',
|
||||
'79': 'XIMENA',
|
||||
'80': 'ELY',
|
||||
}
|
||||
|
||||
all_citas = []
|
||||
current = datetime.strptime(start_date, '%Y-%m-%d').date()
|
||||
end = datetime.strptime(end_date, '%Y-%m-%d').date()
|
||||
days_with = 0
|
||||
while current <= end:
|
||||
url = f"/agenda/citas/{current.strftime('%Y-%m-%d')}"
|
||||
try:
|
||||
data = fetch_json(url)
|
||||
citas = data.get('citas', [])
|
||||
if citas:
|
||||
days_with += 1
|
||||
all_citas.extend(citas)
|
||||
if current.day == 1 or len(citas) > 0:
|
||||
log(f' {current}: {len(citas)} citas (total acumulado: {len(all_citas)})')
|
||||
except Exception as e:
|
||||
log(f' ERROR {current}: {e}')
|
||||
current += timedelta(days=1)
|
||||
time.sleep(0.05)
|
||||
|
||||
out = []
|
||||
for c in all_citas:
|
||||
nombre = (c.get('nombre') or '').strip()
|
||||
if not nombre or nombre.upper().startswith('. . NO CITAR'):
|
||||
continue
|
||||
expediente_id = c.get('expedientes_id') or ''
|
||||
# El teléfono de la cita suele estar en el registro; si no, buscamos en pacientes
|
||||
phone = normalize_phone(c.get('telefono') or phone_by_legacy.get(str(expediente_id), ''))
|
||||
if not phone and c.get('whatsapp'):
|
||||
phone = normalize_phone(c.get('whatsapp'))
|
||||
email = (c.get('correo') or '').strip()
|
||||
if email.lower() in ('no@no.com', 'no@no'):
|
||||
email = ''
|
||||
inicio = c.get('inicio') or ''
|
||||
fin = c.get('fin') or ''
|
||||
# inicio/fin vienen como '1430' (HHMM)
|
||||
hora_str = ''
|
||||
if len(inicio) == 4:
|
||||
hora_str = f"{inicio[:2]}:{inicio[2:]}"
|
||||
elif len(inicio) == 3:
|
||||
hora_str = f"0{inicio[0]}:{inicio[1:]}"
|
||||
# Nombre del médico
|
||||
medico_id = str(c.get('medicos_id') or '')
|
||||
doctor_nombre = medicos_map.get(medico_id, medico_id if medico_id and medico_id != '0' else '')
|
||||
out.append({
|
||||
'legacy_id': c.get('id', ''),
|
||||
'expedientes_id': expediente_id,
|
||||
'paciente_telefono': phone,
|
||||
'paciente_nombre': nombre,
|
||||
'servicio_codigo': '',
|
||||
'servicio_nombre': c.get('titulo') or '',
|
||||
'fecha': c.get('fecha', ''),
|
||||
'hora': hora_str,
|
||||
'hora_fin': f"{fin[:2]}:{fin[2:]}" if len(fin) == 4 else '',
|
||||
'estado': 'confirmed',
|
||||
'doctor_nombre': doctor_nombre,
|
||||
'sucursal': 'rosarito',
|
||||
'medio': 'onsite',
|
||||
'notas': c.get('observaciones') or '',
|
||||
'estado_pago': 'not_paid',
|
||||
'monto_pagado': 0,
|
||||
})
|
||||
save_csv('citas', out, [
|
||||
'legacy_id', 'expedientes_id', 'paciente_telefono', 'paciente_nombre',
|
||||
'servicio_codigo', 'servicio_nombre', 'fecha', 'hora', 'hora_fin',
|
||||
'estado', 'doctor_nombre', 'sucursal', 'medio', 'notas',
|
||||
'estado_pago', 'monto_pagado'
|
||||
])
|
||||
log(f'Citas: {len(out)} registros útiles en {days_with} días con citas')
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
login()
|
||||
except Exception as e:
|
||||
log(f'ERROR login: {e}')
|
||||
sys.exit(1)
|
||||
|
||||
extract_expedientes()
|
||||
extract_monederos()
|
||||
extract_crm()
|
||||
extract_servicios()
|
||||
extract_ventas()
|
||||
extract_citas()
|
||||
|
||||
log('Extracción completada. Archivos en /root/migracion/extraccion/')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
666
migracion/importar_a_odoo.py
Executable file
666
migracion/importar_a_odoo.py
Executable file
@@ -0,0 +1,666 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Scripts de migración de datos del sistema Laravel de SKEEN a Odoo 17.
|
||||
|
||||
Uso:
|
||||
1. Exportar CSVs del sistema Laravel (pacientes, servicios, productos, médicos, etc.)
|
||||
o generar CSVs con el formato esperado.
|
||||
2. Colocar los archivos en /root/migracion/datos/
|
||||
3. Ejecutar: source /root/odoo-venv/bin/activate && python3 /root/migracion/importar_a_odoo.py
|
||||
|
||||
Estructura esperada de CSVs:
|
||||
- pacientes.csv: nombre, apellido_paterno, apellido_materno, telefono, email, fecha_nacimiento, sexo, medico_principal, etiquetas, vip, fuente
|
||||
- servicios.csv: codigo, nombre, categoria, precio, duracion_min, descripcion, color, grupo, favorito, activo
|
||||
- productos.csv: codigo, nombre, categoria, descripcion, precio, inventario_minimo, activo
|
||||
- medicos.csv: nombre, apellido_paterno, apellido_materno, telefono, email, especialidad, rol
|
||||
- usuarios.csv: nombre, login, email, password, rol, activo
|
||||
- citas.csv: paciente_telefono, paciente_nombre, servicio_codigo, fecha, hora, estado, doctor_nombre, sucursal, medio, notas, estado_pago, monto_pagado
|
||||
- ventas.csv: paciente_telefono, fecha, servicio_codigo, cantidad, precio_unitario, descuento, impuesto, estado, notas
|
||||
- pagos.csv: paciente_telefono, cita_referencia, monto, metodo, estado, fecha_pago
|
||||
- adeudos.csv: paciente_telefono, concepto, monto, moneda, fecha_vencimiento
|
||||
- monederos.csv: telefono, puntos, activo
|
||||
|
||||
El script es idempotente: busca por teléfono / código y actualiza si ya existe.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
# Odoo bootstrap
|
||||
ODOO_PATH = Path('/root/skeen-odoo')
|
||||
sys.path.insert(0, str(ODOO_PATH))
|
||||
|
||||
import odoo
|
||||
from odoo import api, fields, SUPERUSER_ID
|
||||
|
||||
DATA_DIR = Path('/root/migracion/datos')
|
||||
|
||||
|
||||
def _normalize_name(name):
|
||||
return ' '.join(str(name).split())
|
||||
|
||||
|
||||
def get_or_create_doctor(env, doctor_name):
|
||||
"""Busca o crea un empleado (médico/especialista)."""
|
||||
name = _normalize_name(doctor_name or '')
|
||||
if not name:
|
||||
return False
|
||||
# Normalizar títulos para búsqueda
|
||||
clean = name.lower().replace('dra.', '').replace('dr.', '').replace('dr ', '').replace('dra ', '').strip()
|
||||
doctor = env['hr.employee'].sudo().search([
|
||||
'|', ('name', 'ilike', name), ('name', 'ilike', clean)
|
||||
], limit=1)
|
||||
if not doctor:
|
||||
doctor = env['hr.employee'].sudo().create({'name': name})
|
||||
return doctor
|
||||
|
||||
|
||||
def get_or_create_service(env, service_code='', service_name=''):
|
||||
"""Busca servicio por código o nombre; si no existe crea uno genérico."""
|
||||
service_code = (service_code or '').strip()
|
||||
service_name = _normalize_name(service_name or '')
|
||||
servicio = False
|
||||
if service_code:
|
||||
servicio = env['skeen.servicio'].sudo().with_context(active_test=False).search([('code', '=', service_code)], limit=1)
|
||||
if not servicio and service_name:
|
||||
servicio = env['skeen.servicio'].sudo().with_context(active_test=False).search([('name', 'ilike', service_name)], limit=1)
|
||||
if not servicio and service_name:
|
||||
# Crear servicio genérico basado en el nombre histórico
|
||||
import hashlib
|
||||
name_hash = hashlib.md5(service_name.encode('utf-8')).hexdigest()[:12]
|
||||
code = f'HIST_{name_hash}'
|
||||
servicio = env['skeen.servicio'].sudo().with_context(active_test=False).search([('code', '=', code)], limit=1)
|
||||
if not servicio:
|
||||
servicio = env['skeen.servicio'].sudo().create({
|
||||
'name': service_name,
|
||||
'code': code,
|
||||
'category': 'tratamiento',
|
||||
'price': 0,
|
||||
'duration_min': 30,
|
||||
})
|
||||
if not servicio:
|
||||
servicio = env['skeen.servicio'].sudo().with_context(active_test=False).search([('code', '=', 'GENERICO')], limit=1)
|
||||
if not servicio:
|
||||
servicio = env['skeen.servicio'].sudo().create({
|
||||
'name': 'Servicio genérico',
|
||||
'code': 'GENERICO',
|
||||
'category': 'tratamiento',
|
||||
'price': 0,
|
||||
'duration_min': 30,
|
||||
})
|
||||
return servicio
|
||||
|
||||
|
||||
def get_or_create_patient(env, phone, name, **vals):
|
||||
"""Busca o crea un paciente por teléfono."""
|
||||
partner = env['res.partner'].sudo().search([('phone', '=', phone)], limit=1)
|
||||
if partner:
|
||||
return partner
|
||||
create_vals = {
|
||||
'name': name,
|
||||
'phone': phone,
|
||||
'is_patient': True,
|
||||
}
|
||||
create_vals.update(vals)
|
||||
return env['res.partner'].sudo().create(create_vals)
|
||||
|
||||
|
||||
def importar_pacientes(env, filepath):
|
||||
"""Importa pacientes desde CSV con expediente clínico completo."""
|
||||
with open(filepath, newline='', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
count = 0
|
||||
for row in reader:
|
||||
name = ' '.join(filter(None, [
|
||||
row.get('nombre', ''),
|
||||
row.get('apellido_paterno', ''),
|
||||
row.get('apellido_materno', '')
|
||||
])).strip()
|
||||
phone = (row.get('telefono') or '').strip()
|
||||
if not name or not phone:
|
||||
continue
|
||||
|
||||
vals = {
|
||||
'email': row.get('email', ''),
|
||||
}
|
||||
source = (row.get('fuente') or 'other').strip().lower()
|
||||
source_map = {
|
||||
'whatsapp': 'whatsapp',
|
||||
'facebook': 'facebook',
|
||||
'instagram': 'instagram',
|
||||
'google': 'google',
|
||||
'referral': 'referral',
|
||||
'walkin': 'walkin',
|
||||
'other': 'other',
|
||||
}
|
||||
vals['source'] = source_map.get(source, 'other')
|
||||
if row.get('vip', '').lower() in ('1', 'true', 'si', 'sí'):
|
||||
vals['is_vip'] = True
|
||||
if row.get('fecha_nacimiento'):
|
||||
vals['birth_date'] = row.get('fecha_nacimiento')
|
||||
if row.get('sexo'):
|
||||
vals['gender'] = row.get('sexo')
|
||||
|
||||
# Datos personales extendidos
|
||||
if row.get('lugar_nacimiento'):
|
||||
vals['birthplace'] = row.get('lugar_nacimiento')
|
||||
if row.get('empleo'):
|
||||
vals['occupation'] = row.get('empleo')
|
||||
if row.get('estado_civil'):
|
||||
vals['marital_status'] = row.get('estado_civil')
|
||||
if row.get('contacto_emergencia'):
|
||||
vals['emergency_contact'] = row.get('contacto_emergencia')
|
||||
if row.get('telefono_emergencia'):
|
||||
vals['emergency_phone'] = row.get('telefono_emergencia')
|
||||
if row.get('telefono_casa'):
|
||||
vals['home_phone'] = row.get('telefono_casa')
|
||||
if row.get('direccion'):
|
||||
vals['address_notes'] = row.get('direccion')
|
||||
if row.get('recomendado_por'):
|
||||
vals['referred_by'] = row.get('recomendado_por')
|
||||
if row.get('comentarios'):
|
||||
vals['patient_comments'] = row.get('comentarios')
|
||||
|
||||
# Historia clínica
|
||||
try:
|
||||
hijos = int(row.get('hijos', 0) or 0)
|
||||
vals['children_count'] = hijos
|
||||
except Exception:
|
||||
vals['children_count'] = 0
|
||||
vals['is_pregnant'] = row.get('embarazada', '').lower() in ('1', 'true', 'si', 'sí')
|
||||
vals['is_breastfeeding'] = row.get('lactancia', '').lower() in ('1', 'true', 'si', 'sí')
|
||||
vals['uses_contraceptives'] = row.get('anticonceptivos', '').lower() in ('1', 'true', 'si', 'sí')
|
||||
vals['kidney_problems'] = row.get('problemas_rinon', '').lower() in ('1', 'true', 'si', 'sí')
|
||||
vals['back_pain'] = row.get('problemas_espalda', '').lower() in ('1', 'true', 'si', 'sí')
|
||||
vals['heart_disease'] = row.get('problemas_cardiacos', '').lower() in ('1', 'true', 'si', 'sí')
|
||||
vals['respiratory_problems'] = row.get('problemas_respiratorios', '').lower() in ('1', 'true', 'si', 'sí')
|
||||
vals['blood_pressure'] = (
|
||||
row.get('alta_presion', '').lower() in ('1', 'true', 'si', 'sí') or
|
||||
row.get('baja_presion', '').lower() in ('1', 'true', 'si', 'sí')
|
||||
)
|
||||
vals['diabetes'] = row.get('diabetes', '').lower() in ('1', 'true', 'si', 'sí')
|
||||
vals['thyroid'] = (
|
||||
row.get('hipertiroidismo', '').lower() in ('1', 'true', 'si', 'sí') or
|
||||
row.get('hipotiroidismo', '').lower() in ('1', 'true', 'si', 'sí')
|
||||
)
|
||||
vals['colitis'] = row.get('colitis', '').lower() in ('1', 'true', 'si', 'sí')
|
||||
vals['constipation'] = row.get('estrenimiento', '').lower() in ('1', 'true', 'si', 'sí')
|
||||
vals['liver_problems'] = row.get('problemas_higado', '').lower() in ('1', 'true', 'si', 'sí')
|
||||
vals['surgeries'] = row.get('cirugias', '').lower() in ('1', 'true', 'si', 'sí')
|
||||
vals['varicose_veins'] = row.get('varices', '').lower() in ('1', 'true', 'si', 'sí')
|
||||
vals['migraine'] = row.get('migrana', '').lower() in ('1', 'true', 'si', 'sí')
|
||||
vals['faints_with_needles'] = row.get('desmaya_agujas', '').lower() in ('1', 'true', 'si', 'sí')
|
||||
|
||||
if row.get('alergias'):
|
||||
vals['allergies'] = row.get('alergias')
|
||||
if row.get('medicamentos'):
|
||||
vals['current_medication'] = row.get('medicamentos')
|
||||
if row.get('tratamientos_previos'):
|
||||
vals['medical_history'] = row.get('tratamientos_previos')
|
||||
|
||||
medico_nombre = row.get('medico_principal', '').strip()
|
||||
if medico_nombre:
|
||||
medico = get_or_create_doctor(env, medico_nombre)
|
||||
if medico:
|
||||
vals['primary_doctor_id'] = medico.id
|
||||
|
||||
etiquetas = row.get('etiquetas', '').strip()
|
||||
if etiquetas:
|
||||
tag_names = [t.strip() for t in etiquetas.split(',') if t.strip()]
|
||||
tags = env['skeen.patient.tag'].sudo()
|
||||
tag_ids = []
|
||||
for tag_name in tag_names:
|
||||
tag = tags.search([('name', '=', tag_name)], limit=1)
|
||||
if not tag:
|
||||
tag = tags.create({'name': tag_name})
|
||||
tag_ids.append(tag.id)
|
||||
vals['tag_ids'] = [(6, 0, tag_ids)]
|
||||
|
||||
get_or_create_patient(env, phone, name, **vals)
|
||||
count += 1
|
||||
if count % 500 == 0:
|
||||
env.cr.commit()
|
||||
print(f' pacientes: {count} procesados')
|
||||
env.cr.commit()
|
||||
return count
|
||||
|
||||
|
||||
def importar_servicios(env, filepath):
|
||||
"""Importa catálogo de servicios."""
|
||||
category_map = {
|
||||
'consulta': 'consulta',
|
||||
'tratamiento': 'tratamiento',
|
||||
'procedimiento': 'procedimiento',
|
||||
'paquete': 'paquete',
|
||||
'servicios_medicos': 'consulta',
|
||||
'procedimientos_cosmeticos': 'procedimiento',
|
||||
'tecnologia_medica': 'tratamiento',
|
||||
'cosmetologia_basica': 'tratamiento',
|
||||
'cosmetologia_avanzada': 'tratamiento',
|
||||
}
|
||||
seen = set()
|
||||
with open(filepath, newline='', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
count = 0
|
||||
for row in reader:
|
||||
code = (row.get('codigo') or '').strip()
|
||||
name = (row.get('nombre') or '').strip()
|
||||
if not code or not name or code in seen:
|
||||
continue
|
||||
seen.add(code)
|
||||
existing = env['skeen.servicio'].sudo().with_context(active_test=False).search([('code', '=', code)], limit=1)
|
||||
if existing:
|
||||
continue
|
||||
cat = category_map.get((row.get('categoria') or '').strip().lower(), 'tratamiento')
|
||||
env['skeen.servicio'].sudo().create({
|
||||
'name': name,
|
||||
'code': code,
|
||||
'category': cat,
|
||||
'price': float(row.get('precio', 0) or 0),
|
||||
'duration_min': int(row.get('duracion_min', 30) or 30),
|
||||
'description': row.get('descripcion', ''),
|
||||
'active': row.get('activo', '1').lower() in ('1', 'true', 'si'),
|
||||
})
|
||||
count += 1
|
||||
if count % 100 == 0:
|
||||
env.cr.commit()
|
||||
print(f' servicios: {count} procesados')
|
||||
env.cr.commit()
|
||||
return count
|
||||
|
||||
|
||||
def importar_productos(env, filepath):
|
||||
"""Importa productos / inventario."""
|
||||
with open(filepath, newline='', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
count = 0
|
||||
for row in reader:
|
||||
name = (row.get('nombre') or '').strip()
|
||||
if not name:
|
||||
continue
|
||||
# Crear como product.template básico de Odoo + campos custom si aplica
|
||||
existing = env['product.template'].sudo().search([('name', '=', name)], limit=1)
|
||||
if existing:
|
||||
continue
|
||||
env['product.template'].sudo().create({
|
||||
'name': name,
|
||||
'list_price': float(row.get('precio', 0) or 0),
|
||||
'type': 'product',
|
||||
'default_code': row.get('codigo', ''),
|
||||
'description_sale': row.get('descripcion', ''),
|
||||
})
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def importar_medicos(env, filepath):
|
||||
"""Importa médicos/cosmetólogos como empleados."""
|
||||
with open(filepath, newline='', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
count = 0
|
||||
for row in reader:
|
||||
name = ' '.join(filter(None, [
|
||||
row.get('nombre', ''),
|
||||
row.get('apellido_paterno', ''),
|
||||
row.get('apellido_materno', '')
|
||||
])).strip()
|
||||
if not name:
|
||||
continue
|
||||
existing = env['hr.employee'].sudo().search([('name', '=', name)], limit=1)
|
||||
if existing:
|
||||
continue
|
||||
env['hr.employee'].sudo().create({
|
||||
'name': name,
|
||||
'work_phone': row.get('telefono', ''),
|
||||
'work_email': row.get('email', ''),
|
||||
'job_title': row.get('especialidad', ''),
|
||||
})
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def importar_monederos(env, filepath):
|
||||
"""Importa saldos de monedero por teléfono de paciente."""
|
||||
with open(filepath, newline='', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
count = 0
|
||||
for row in reader:
|
||||
phone = (row.get('telefono') or '').strip()
|
||||
points = int(float(row.get('puntos', 0) or 0))
|
||||
if not phone:
|
||||
continue
|
||||
partner = env['res.partner'].sudo().search([('phone', '=', phone)], limit=1)
|
||||
if not partner:
|
||||
partner = get_or_create_patient(env, phone, 'Paciente ' + phone)
|
||||
monedero = env['skeen.monedero'].sudo().search([
|
||||
('partner_id', '=', partner.id)
|
||||
], limit=1)
|
||||
if monedero:
|
||||
monedero.write({'points': points})
|
||||
else:
|
||||
env['skeen.monedero'].sudo().create({
|
||||
'partner_id': partner.id,
|
||||
'points': points,
|
||||
})
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def _parse_time(value):
|
||||
"""Convierte '09:30' o '9.5' a float de horas."""
|
||||
if not value:
|
||||
return 9.0
|
||||
value = str(value).strip()
|
||||
if ':' in value:
|
||||
h, m = value.split(':')[:2]
|
||||
return float(h) + float(m) / 60.0
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError:
|
||||
return 9.0
|
||||
|
||||
|
||||
def importar_citas(env, filepath):
|
||||
"""Importa citas históricas y futuras."""
|
||||
# Desactivar temporalmente la validación de solapamiento para la migración masiva
|
||||
CitaModel = env.registry['skeen.cita']
|
||||
original_constraint = CitaModel._check_availability
|
||||
CitaModel._check_availability = lambda self: None
|
||||
|
||||
try:
|
||||
with open(filepath, newline='', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
count = 0
|
||||
for row in reader:
|
||||
phone = (row.get('paciente_telefono') or '').strip()
|
||||
name = (row.get('paciente_nombre') or '').strip()
|
||||
date_str = (row.get('fecha') or '').strip()
|
||||
time_str = (row.get('hora') or '').strip()
|
||||
if not phone or not date_str:
|
||||
continue
|
||||
|
||||
partner = env['res.partner'].sudo().search([('phone', '=', phone)], limit=1)
|
||||
if not partner:
|
||||
partner = get_or_create_patient(env, phone, name or 'Paciente ' + phone)
|
||||
|
||||
servicio = get_or_create_service(
|
||||
env,
|
||||
row.get('servicio_codigo', ''),
|
||||
row.get('servicio_nombre', '')
|
||||
)
|
||||
|
||||
state_map = {
|
||||
'pendiente': 'pending',
|
||||
'confirmada': 'confirmed',
|
||||
'llego': 'arrived',
|
||||
'en_progreso': 'in_progress',
|
||||
'completada': 'done',
|
||||
'cancelada': 'cancelled',
|
||||
'no_show': 'no_show',
|
||||
}
|
||||
state = state_map.get((row.get('estado') or '').strip().lower(), 'confirmed')
|
||||
|
||||
payment_state_map = {
|
||||
'no_pagado': 'not_paid',
|
||||
'parcial': 'partial',
|
||||
'pagado': 'paid',
|
||||
}
|
||||
payment_state = payment_state_map.get((row.get('estado_pago') or '').strip().lower(), 'not_paid')
|
||||
|
||||
doctor = get_or_create_doctor(env, row.get('doctor_nombre', ''))
|
||||
|
||||
branch = 'rosarito'
|
||||
if (row.get('sucursal') or '').strip().lower() in ('tijuana', 'tj'):
|
||||
branch = 'tijuana'
|
||||
|
||||
medium = 'onsite'
|
||||
if (row.get('medio') or '').strip().lower() in ('videollamada', 'video', 'remota'):
|
||||
medium = 'videocall'
|
||||
|
||||
vals = {
|
||||
'partner_id': partner.id,
|
||||
'servicio_id': servicio.id,
|
||||
'date': date_str,
|
||||
'time': _parse_time(time_str),
|
||||
'state': state,
|
||||
'payment_state': payment_state,
|
||||
'amount_paid': float(row.get('monto_pagado', 0) or 0),
|
||||
'doctor_id': doctor.id if doctor else False,
|
||||
'branch': branch,
|
||||
'medium': medium,
|
||||
'notes': row.get('notas', ''),
|
||||
}
|
||||
env['skeen.cita'].sudo().create(vals)
|
||||
count += 1
|
||||
if count % 500 == 0:
|
||||
env.cr.commit()
|
||||
print(f' citas: {count} procesadas')
|
||||
env.cr.commit()
|
||||
return count
|
||||
finally:
|
||||
CitaModel._check_availability = original_constraint
|
||||
|
||||
|
||||
def importar_ventas(env, filepath):
|
||||
"""Importa ventas históricas."""
|
||||
with open(filepath, newline='', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
count = 0
|
||||
for row in reader:
|
||||
phone = (row.get('paciente_telefono') or '').strip()
|
||||
if not phone:
|
||||
continue
|
||||
|
||||
partner = env['res.partner'].sudo().search([('phone', '=', phone)], limit=1)
|
||||
if not partner:
|
||||
partner = get_or_create_patient(env, phone, 'Paciente ' + phone)
|
||||
|
||||
servicio = get_or_create_service(
|
||||
env,
|
||||
row.get('servicio_codigo', ''),
|
||||
row.get('servicio_nombre', '')
|
||||
)
|
||||
|
||||
qty = float(row.get('cantidad', 1) or 1)
|
||||
price = float(row.get('precio_unitario', servicio.price) or servicio.price)
|
||||
discount = float(row.get('descuento', 0) or 0)
|
||||
tax = float(row.get('impuesto', 0) or 0)
|
||||
|
||||
state_map = {
|
||||
'borrador': 'draft',
|
||||
'confirmada': 'confirmed',
|
||||
'pagada': 'paid',
|
||||
'parcial': 'partial',
|
||||
'cancelada': 'cancelled',
|
||||
}
|
||||
state = state_map.get((row.get('estado') or '').strip().lower(), 'confirmed')
|
||||
|
||||
venta = env['skeen.venta'].sudo().create({
|
||||
'partner_id': partner.id,
|
||||
'date': row.get('fecha') or fields.Date.today(),
|
||||
'discount': discount,
|
||||
'tax': tax,
|
||||
'state': state,
|
||||
'notes': row.get('notas', ''),
|
||||
'line_ids': [(0, 0, {
|
||||
'service_id': servicio.id,
|
||||
'description': servicio.name,
|
||||
'quantity': qty,
|
||||
'price_unit': price,
|
||||
})],
|
||||
})
|
||||
|
||||
# Si está pagada, marcar como pagada para que acumule puntos
|
||||
if state == 'paid':
|
||||
venta.action_mark_paid()
|
||||
|
||||
count += 1
|
||||
if count % 500 == 0:
|
||||
env.cr.commit()
|
||||
print(f' ventas: {count} procesadas')
|
||||
env.cr.commit()
|
||||
return count
|
||||
|
||||
|
||||
def importar_pagos(env, filepath):
|
||||
"""Importa pagos históricos vinculados a pacientes y citas."""
|
||||
with open(filepath, newline='', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
count = 0
|
||||
for row in reader:
|
||||
phone = (row.get('paciente_telefono') or '').strip()
|
||||
amount = float(row.get('monto', 0) or 0)
|
||||
if not phone or amount <= 0:
|
||||
continue
|
||||
|
||||
partner = env['res.partner'].sudo().search([('phone', '=', phone)], limit=1)
|
||||
if not partner:
|
||||
partner = get_or_create_patient(env, phone, 'Paciente ' + phone)
|
||||
|
||||
cita_ref = (row.get('cita_referencia') or '').strip()
|
||||
cita_id = False
|
||||
if cita_ref:
|
||||
cita = env['skeen.cita'].sudo().search([('name', '=', cita_ref)], limit=1)
|
||||
cita_id = cita.id if cita else False
|
||||
|
||||
method_map = {
|
||||
'efectivo': 'cash',
|
||||
'tarjeta': 'card',
|
||||
'transferencia': 'transfer',
|
||||
'stripe': 'stripe',
|
||||
'mercadopago': 'mercadopago',
|
||||
}
|
||||
method = method_map.get((row.get('metodo') or '').strip().lower(), 'cash')
|
||||
|
||||
state_map = {
|
||||
'pendiente': 'pending',
|
||||
'procesando': 'processing',
|
||||
'completado': 'completed',
|
||||
'fallido': 'failed',
|
||||
'reembolsado': 'refunded',
|
||||
}
|
||||
state = state_map.get((row.get('estado') or '').strip().lower(), 'completed')
|
||||
|
||||
vals = {
|
||||
'partner_id': partner.id,
|
||||
'cita_id': cita_id,
|
||||
'amount': amount,
|
||||
'payment_method': method,
|
||||
'state': state,
|
||||
}
|
||||
if row.get('fecha_pago'):
|
||||
vals['payment_date'] = row.get('fecha_pago')
|
||||
|
||||
pago = env['skeen.pago'].sudo().create(vals)
|
||||
if state == 'completed':
|
||||
pago.action_confirm_payment()
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def importar_adeudos(env, filepath):
|
||||
"""Importa adeudos como ventas en estado draft/con adeudo."""
|
||||
with open(filepath, newline='', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
count = 0
|
||||
for row in reader:
|
||||
phone = (row.get('telefono') or '').strip()
|
||||
concepto = (row.get('concepto') or 'Adeudo').strip()
|
||||
monto = float(row.get('monto', 0) or 0)
|
||||
if not phone or monto <= 0:
|
||||
continue
|
||||
|
||||
partner = env['res.partner'].sudo().search([('phone', '=', phone)], limit=1)
|
||||
if not partner:
|
||||
partner = get_or_create_patient(env, phone, 'Paciente ' + phone)
|
||||
|
||||
# Crear un servicio genérico de adeudo si no existe
|
||||
servicio = env['skeen.servicio'].sudo().with_context(active_test=False).search([('code', '=', 'ADEUDO')], limit=1)
|
||||
if not servicio:
|
||||
servicio = env['skeen.servicio'].sudo().create({
|
||||
'name': 'Adeudo histórico',
|
||||
'code': 'ADEUDO',
|
||||
'category': 'consulta',
|
||||
'price': 0,
|
||||
'duration_min': 0,
|
||||
})
|
||||
|
||||
env['skeen.venta'].sudo().create({
|
||||
'partner_id': partner.id,
|
||||
'date': row.get('fecha_vencimiento') or fields.Date.today(),
|
||||
'state': 'confirmed',
|
||||
'notes': concepto,
|
||||
'line_ids': [(0, 0, {
|
||||
'service_id': servicio.id,
|
||||
'description': concepto,
|
||||
'quantity': 1,
|
||||
'price_unit': monto,
|
||||
})],
|
||||
})
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Importar datos de SKEEN a Odoo')
|
||||
parser.add_argument('--odoo-config', default='/root/skeen-odoo/odoo.conf')
|
||||
parser.add_argument('--database', default='skeen_odoo')
|
||||
args = parser.parse_args()
|
||||
|
||||
odoo.tools.config.parse_config(['-c', args.odoo_config])
|
||||
db = odoo.sql_db.db_connect(args.database)
|
||||
with db.cursor() as cr:
|
||||
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||
|
||||
resultados = {}
|
||||
|
||||
pacientes_path = DATA_DIR / 'pacientes.csv'
|
||||
if pacientes_path.exists():
|
||||
resultados['pacientes'] = importar_pacientes(env, pacientes_path)
|
||||
|
||||
servicios_path = DATA_DIR / 'servicios.csv'
|
||||
if servicios_path.exists():
|
||||
resultados['servicios'] = importar_servicios(env, servicios_path)
|
||||
|
||||
productos_path = DATA_DIR / 'productos.csv'
|
||||
if productos_path.exists():
|
||||
resultados['productos'] = importar_productos(env, productos_path)
|
||||
|
||||
medicos_path = DATA_DIR / 'medicos.csv'
|
||||
if medicos_path.exists():
|
||||
resultados['medicos'] = importar_medicos(env, medicos_path)
|
||||
|
||||
citas_path = DATA_DIR / 'citas.csv'
|
||||
if citas_path.exists():
|
||||
resultados['citas'] = importar_citas(env, citas_path)
|
||||
|
||||
ventas_path = DATA_DIR / 'ventas.csv'
|
||||
if ventas_path.exists():
|
||||
resultados['ventas'] = importar_ventas(env, ventas_path)
|
||||
|
||||
pagos_path = DATA_DIR / 'pagos.csv'
|
||||
if pagos_path.exists():
|
||||
resultados['pagos'] = importar_pagos(env, pagos_path)
|
||||
|
||||
adeudos_path = DATA_DIR / 'adeudos.csv'
|
||||
if adeudos_path.exists():
|
||||
resultados['adeudos'] = importar_adeudos(env, adeudos_path)
|
||||
|
||||
monederos_path = DATA_DIR / 'monederos.csv'
|
||||
if monederos_path.exists():
|
||||
resultados['monederos'] = importar_monederos(env, monederos_path)
|
||||
|
||||
cr.commit()
|
||||
|
||||
print('Importación completada:')
|
||||
for k, v in resultados.items():
|
||||
print(f' {k}: {v} registros')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
609
migracion/importar_a_odoo.py.bak
Executable file
609
migracion/importar_a_odoo.py.bak
Executable file
@@ -0,0 +1,609 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Scripts de migración de datos del sistema Laravel de SKEEN a Odoo 17.
|
||||
|
||||
Uso:
|
||||
1. Exportar CSVs del sistema Laravel (pacientes, servicios, productos, médicos, etc.)
|
||||
o generar CSVs con el formato esperado.
|
||||
2. Colocar los archivos en /root/migracion/datos/
|
||||
3. Ejecutar: source /root/odoo-venv/bin/activate && python3 /root/migracion/importar_a_odoo.py
|
||||
|
||||
Estructura esperada de CSVs:
|
||||
- pacientes.csv: nombre, apellido_paterno, apellido_materno, telefono, email, fecha_nacimiento, sexo, medico_principal, etiquetas, vip, fuente
|
||||
- servicios.csv: codigo, nombre, categoria, precio, duracion_min, descripcion, color, grupo, favorito, activo
|
||||
- productos.csv: codigo, nombre, categoria, descripcion, precio, inventario_minimo, activo
|
||||
- medicos.csv: nombre, apellido_paterno, apellido_materno, telefono, email, especialidad, rol
|
||||
- usuarios.csv: nombre, login, email, password, rol, activo
|
||||
- citas.csv: paciente_telefono, paciente_nombre, servicio_codigo, fecha, hora, estado, doctor_nombre, sucursal, medio, notas, estado_pago, monto_pagado
|
||||
- ventas.csv: paciente_telefono, fecha, servicio_codigo, cantidad, precio_unitario, descuento, impuesto, estado, notas
|
||||
- pagos.csv: paciente_telefono, cita_referencia, monto, metodo, estado, fecha_pago
|
||||
- adeudos.csv: paciente_telefono, concepto, monto, moneda, fecha_vencimiento
|
||||
- monederos.csv: telefono, puntos, activo
|
||||
|
||||
El script es idempotente: busca por teléfono / código y actualiza si ya existe.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
# Odoo bootstrap
|
||||
ODOO_PATH = Path('/root/skeen-odoo')
|
||||
sys.path.insert(0, str(ODOO_PATH))
|
||||
|
||||
import odoo
|
||||
from odoo import api, fields, SUPERUSER_ID
|
||||
|
||||
DATA_DIR = Path('/root/migracion/datos')
|
||||
|
||||
|
||||
def _normalize_name(name):
|
||||
return ' '.join(str(name).split())
|
||||
|
||||
|
||||
def get_or_create_doctor(env, doctor_name):
|
||||
"""Busca o crea un empleado (médico/especialista)."""
|
||||
name = _normalize_name(doctor_name or '')
|
||||
if not name:
|
||||
return False
|
||||
# Normalizar títulos para búsqueda
|
||||
clean = name.lower().replace('dra.', '').replace('dr.', '').replace('dr ', '').replace('dra ', '').strip()
|
||||
doctor = env['hr.employee'].sudo().search([
|
||||
'|', ('name', 'ilike', name), ('name', 'ilike', clean)
|
||||
], limit=1)
|
||||
if not doctor:
|
||||
doctor = env['hr.employee'].sudo().create({'name': name})
|
||||
return doctor
|
||||
|
||||
|
||||
def get_or_create_service(env, service_code='', service_name=''):
|
||||
"""Busca servicio por código o nombre; si no existe crea uno genérico."""
|
||||
service_code = (service_code or '').strip()
|
||||
service_name = _normalize_name(service_name or '')
|
||||
servicio = False
|
||||
if service_code:
|
||||
servicio = env['skeen.servicio'].sudo().with_context(active_test=False).search([('code', '=', service_code)], limit=1)
|
||||
if not servicio and service_name:
|
||||
servicio = env['skeen.servicio'].sudo().with_context(active_test=False).search([('name', 'ilike', service_name)], limit=1)
|
||||
if not servicio and service_name:
|
||||
# Crear servicio genérico basado en el nombre histórico
|
||||
import hashlib
|
||||
name_hash = hashlib.md5(service_name.encode('utf-8')).hexdigest()[:12]
|
||||
code = f'HIST_{name_hash}'
|
||||
servicio = env['skeen.servicio'].sudo().with_context(active_test=False).search([('code', '=', code)], limit=1)
|
||||
if not servicio:
|
||||
servicio = env['skeen.servicio'].sudo().create({
|
||||
'name': service_name,
|
||||
'code': code,
|
||||
'category': 'tratamiento',
|
||||
'price': 0,
|
||||
'duration_min': 30,
|
||||
})
|
||||
if not servicio:
|
||||
servicio = env['skeen.servicio'].sudo().with_context(active_test=False).search([('code', '=', 'GENERICO')], limit=1)
|
||||
if not servicio:
|
||||
servicio = env['skeen.servicio'].sudo().create({
|
||||
'name': 'Servicio genérico',
|
||||
'code': 'GENERICO',
|
||||
'category': 'tratamiento',
|
||||
'price': 0,
|
||||
'duration_min': 30,
|
||||
})
|
||||
return servicio
|
||||
|
||||
|
||||
def get_or_create_patient(env, phone, name, **vals):
|
||||
"""Busca o crea un paciente por teléfono."""
|
||||
partner = env['res.partner'].sudo().search([('phone', '=', phone)], limit=1)
|
||||
if partner:
|
||||
return partner
|
||||
create_vals = {
|
||||
'name': name,
|
||||
'phone': phone,
|
||||
'is_patient': True,
|
||||
}
|
||||
create_vals.update(vals)
|
||||
return env['res.partner'].sudo().create(create_vals)
|
||||
|
||||
|
||||
def importar_pacientes(env, filepath):
|
||||
"""Importa pacientes desde CSV."""
|
||||
with open(filepath, newline='', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
count = 0
|
||||
for row in reader:
|
||||
name = ' '.join(filter(None, [
|
||||
row.get('nombre', ''),
|
||||
row.get('apellido_paterno', ''),
|
||||
row.get('apellido_materno', '')
|
||||
])).strip()
|
||||
phone = (row.get('telefono') or '').strip()
|
||||
if not name or not phone:
|
||||
continue
|
||||
|
||||
vals = {
|
||||
'email': row.get('email', ''),
|
||||
}
|
||||
source = (row.get('fuente') or 'other').strip().lower()
|
||||
source_map = {
|
||||
'whatsapp': 'whatsapp',
|
||||
'facebook': 'facebook',
|
||||
'instagram': 'instagram',
|
||||
'google': 'google',
|
||||
'referral': 'referral',
|
||||
'walkin': 'walkin',
|
||||
'other': 'other',
|
||||
}
|
||||
vals['source'] = source_map.get(source, 'other')
|
||||
if row.get('vip', '').lower() in ('1', 'true', 'si', 'sí'):
|
||||
vals['is_vip'] = True
|
||||
if row.get('fecha_nacimiento'):
|
||||
vals['birth_date'] = row.get('fecha_nacimiento')
|
||||
if row.get('sexo'):
|
||||
vals['gender'] = row.get('sexo')
|
||||
|
||||
medico_nombre = row.get('medico_principal', '').strip()
|
||||
if medico_nombre:
|
||||
medico = get_or_create_doctor(env, medico_nombre)
|
||||
if medico:
|
||||
vals['primary_doctor_id'] = medico.id
|
||||
|
||||
etiquetas = row.get('etiquetas', '').strip()
|
||||
if etiquetas:
|
||||
tag_names = [t.strip() for t in etiquetas.split(',') if t.strip()]
|
||||
tags = env['skeen.patient.tag'].sudo()
|
||||
tag_ids = []
|
||||
for tag_name in tag_names:
|
||||
tag = tags.search([('name', '=', tag_name)], limit=1)
|
||||
if not tag:
|
||||
tag = tags.create({'name': tag_name})
|
||||
tag_ids.append(tag.id)
|
||||
vals['tag_ids'] = [(6, 0, tag_ids)]
|
||||
|
||||
get_or_create_patient(env, phone, name, **vals)
|
||||
count += 1
|
||||
if count % 500 == 0:
|
||||
env.cr.commit()
|
||||
print(f' pacientes: {count} procesados')
|
||||
env.cr.commit()
|
||||
return count
|
||||
|
||||
|
||||
def importar_servicios(env, filepath):
|
||||
"""Importa catálogo de servicios."""
|
||||
category_map = {
|
||||
'consulta': 'consulta',
|
||||
'tratamiento': 'tratamiento',
|
||||
'procedimiento': 'procedimiento',
|
||||
'paquete': 'paquete',
|
||||
'servicios_medicos': 'consulta',
|
||||
'procedimientos_cosmeticos': 'procedimiento',
|
||||
'tecnologia_medica': 'tratamiento',
|
||||
'cosmetologia_basica': 'tratamiento',
|
||||
'cosmetologia_avanzada': 'tratamiento',
|
||||
}
|
||||
seen = set()
|
||||
with open(filepath, newline='', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
count = 0
|
||||
for row in reader:
|
||||
code = (row.get('codigo') or '').strip()
|
||||
name = (row.get('nombre') or '').strip()
|
||||
if not code or not name or code in seen:
|
||||
continue
|
||||
seen.add(code)
|
||||
existing = env['skeen.servicio'].sudo().with_context(active_test=False).search([('code', '=', code)], limit=1)
|
||||
if existing:
|
||||
continue
|
||||
cat = category_map.get((row.get('categoria') or '').strip().lower(), 'tratamiento')
|
||||
env['skeen.servicio'].sudo().create({
|
||||
'name': name,
|
||||
'code': code,
|
||||
'category': cat,
|
||||
'price': float(row.get('precio', 0) or 0),
|
||||
'duration_min': int(row.get('duracion_min', 30) or 30),
|
||||
'description': row.get('descripcion', ''),
|
||||
'active': row.get('activo', '1').lower() in ('1', 'true', 'si'),
|
||||
})
|
||||
count += 1
|
||||
if count % 100 == 0:
|
||||
env.cr.commit()
|
||||
print(f' servicios: {count} procesados')
|
||||
env.cr.commit()
|
||||
return count
|
||||
|
||||
|
||||
def importar_productos(env, filepath):
|
||||
"""Importa productos / inventario."""
|
||||
with open(filepath, newline='', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
count = 0
|
||||
for row in reader:
|
||||
name = (row.get('nombre') or '').strip()
|
||||
if not name:
|
||||
continue
|
||||
# Crear como product.template básico de Odoo + campos custom si aplica
|
||||
existing = env['product.template'].sudo().search([('name', '=', name)], limit=1)
|
||||
if existing:
|
||||
continue
|
||||
env['product.template'].sudo().create({
|
||||
'name': name,
|
||||
'list_price': float(row.get('precio', 0) or 0),
|
||||
'type': 'product',
|
||||
'default_code': row.get('codigo', ''),
|
||||
'description_sale': row.get('descripcion', ''),
|
||||
})
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def importar_medicos(env, filepath):
|
||||
"""Importa médicos/cosmetólogos como empleados."""
|
||||
with open(filepath, newline='', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
count = 0
|
||||
for row in reader:
|
||||
name = ' '.join(filter(None, [
|
||||
row.get('nombre', ''),
|
||||
row.get('apellido_paterno', ''),
|
||||
row.get('apellido_materno', '')
|
||||
])).strip()
|
||||
if not name:
|
||||
continue
|
||||
existing = env['hr.employee'].sudo().search([('name', '=', name)], limit=1)
|
||||
if existing:
|
||||
continue
|
||||
env['hr.employee'].sudo().create({
|
||||
'name': name,
|
||||
'work_phone': row.get('telefono', ''),
|
||||
'work_email': row.get('email', ''),
|
||||
'job_title': row.get('especialidad', ''),
|
||||
})
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def importar_monederos(env, filepath):
|
||||
"""Importa saldos de monedero por teléfono de paciente."""
|
||||
with open(filepath, newline='', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
count = 0
|
||||
for row in reader:
|
||||
phone = (row.get('telefono') or '').strip()
|
||||
points = int(float(row.get('puntos', 0) or 0))
|
||||
if not phone:
|
||||
continue
|
||||
partner = env['res.partner'].sudo().search([('phone', '=', phone)], limit=1)
|
||||
if not partner:
|
||||
partner = get_or_create_patient(env, phone, 'Paciente ' + phone)
|
||||
monedero = env['skeen.monedero'].sudo().search([
|
||||
('partner_id', '=', partner.id)
|
||||
], limit=1)
|
||||
if monedero:
|
||||
monedero.write({'points': points})
|
||||
else:
|
||||
env['skeen.monedero'].sudo().create({
|
||||
'partner_id': partner.id,
|
||||
'points': points,
|
||||
})
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def _parse_time(value):
|
||||
"""Convierte '09:30' o '9.5' a float de horas."""
|
||||
if not value:
|
||||
return 9.0
|
||||
value = str(value).strip()
|
||||
if ':' in value:
|
||||
h, m = value.split(':')[:2]
|
||||
return float(h) + float(m) / 60.0
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError:
|
||||
return 9.0
|
||||
|
||||
|
||||
def importar_citas(env, filepath):
|
||||
"""Importa citas históricas y futuras."""
|
||||
# Desactivar temporalmente la validación de solapamiento para la migración masiva
|
||||
CitaModel = env.registry['skeen.cita']
|
||||
original_constraint = CitaModel._check_availability
|
||||
CitaModel._check_availability = lambda self: None
|
||||
|
||||
try:
|
||||
with open(filepath, newline='', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
count = 0
|
||||
for row in reader:
|
||||
phone = (row.get('paciente_telefono') or '').strip()
|
||||
name = (row.get('paciente_nombre') or '').strip()
|
||||
date_str = (row.get('fecha') or '').strip()
|
||||
time_str = (row.get('hora') or '').strip()
|
||||
if not phone or not date_str:
|
||||
continue
|
||||
|
||||
partner = env['res.partner'].sudo().search([('phone', '=', phone)], limit=1)
|
||||
if not partner:
|
||||
partner = get_or_create_patient(env, phone, name or 'Paciente ' + phone)
|
||||
|
||||
servicio = get_or_create_service(
|
||||
env,
|
||||
row.get('servicio_codigo', ''),
|
||||
row.get('servicio_nombre', '')
|
||||
)
|
||||
|
||||
state_map = {
|
||||
'pendiente': 'pending',
|
||||
'confirmada': 'confirmed',
|
||||
'llego': 'arrived',
|
||||
'en_progreso': 'in_progress',
|
||||
'completada': 'done',
|
||||
'cancelada': 'cancelled',
|
||||
'no_show': 'no_show',
|
||||
}
|
||||
state = state_map.get((row.get('estado') or '').strip().lower(), 'confirmed')
|
||||
|
||||
payment_state_map = {
|
||||
'no_pagado': 'not_paid',
|
||||
'parcial': 'partial',
|
||||
'pagado': 'paid',
|
||||
}
|
||||
payment_state = payment_state_map.get((row.get('estado_pago') or '').strip().lower(), 'not_paid')
|
||||
|
||||
doctor = get_or_create_doctor(env, row.get('doctor_nombre', ''))
|
||||
|
||||
branch = 'rosarito'
|
||||
if (row.get('sucursal') or '').strip().lower() in ('tijuana', 'tj'):
|
||||
branch = 'tijuana'
|
||||
|
||||
medium = 'onsite'
|
||||
if (row.get('medio') or '').strip().lower() in ('videollamada', 'video', 'remota'):
|
||||
medium = 'videocall'
|
||||
|
||||
vals = {
|
||||
'partner_id': partner.id,
|
||||
'servicio_id': servicio.id,
|
||||
'date': date_str,
|
||||
'time': _parse_time(time_str),
|
||||
'state': state,
|
||||
'payment_state': payment_state,
|
||||
'amount_paid': float(row.get('monto_pagado', 0) or 0),
|
||||
'doctor_id': doctor.id if doctor else False,
|
||||
'branch': branch,
|
||||
'medium': medium,
|
||||
'notes': row.get('notas', ''),
|
||||
}
|
||||
env['skeen.cita'].sudo().create(vals)
|
||||
count += 1
|
||||
if count % 500 == 0:
|
||||
env.cr.commit()
|
||||
print(f' citas: {count} procesadas')
|
||||
env.cr.commit()
|
||||
return count
|
||||
finally:
|
||||
CitaModel._check_availability = original_constraint
|
||||
|
||||
|
||||
def importar_ventas(env, filepath):
|
||||
"""Importa ventas históricas."""
|
||||
with open(filepath, newline='', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
count = 0
|
||||
for row in reader:
|
||||
phone = (row.get('paciente_telefono') or '').strip()
|
||||
if not phone:
|
||||
continue
|
||||
|
||||
partner = env['res.partner'].sudo().search([('phone', '=', phone)], limit=1)
|
||||
if not partner:
|
||||
partner = get_or_create_patient(env, phone, 'Paciente ' + phone)
|
||||
|
||||
servicio = get_or_create_service(
|
||||
env,
|
||||
row.get('servicio_codigo', ''),
|
||||
row.get('servicio_nombre', '')
|
||||
)
|
||||
|
||||
qty = float(row.get('cantidad', 1) or 1)
|
||||
price = float(row.get('precio_unitario', servicio.price) or servicio.price)
|
||||
discount = float(row.get('descuento', 0) or 0)
|
||||
tax = float(row.get('impuesto', 0) or 0)
|
||||
|
||||
state_map = {
|
||||
'borrador': 'draft',
|
||||
'confirmada': 'confirmed',
|
||||
'pagada': 'paid',
|
||||
'parcial': 'partial',
|
||||
'cancelada': 'cancelled',
|
||||
}
|
||||
state = state_map.get((row.get('estado') or '').strip().lower(), 'confirmed')
|
||||
|
||||
venta = env['skeen.venta'].sudo().create({
|
||||
'partner_id': partner.id,
|
||||
'date': row.get('fecha') or fields.Date.today(),
|
||||
'discount': discount,
|
||||
'tax': tax,
|
||||
'state': state,
|
||||
'notes': row.get('notas', ''),
|
||||
'line_ids': [(0, 0, {
|
||||
'service_id': servicio.id,
|
||||
'description': servicio.name,
|
||||
'quantity': qty,
|
||||
'price_unit': price,
|
||||
})],
|
||||
})
|
||||
|
||||
# Si está pagada, marcar como pagada para que acumule puntos
|
||||
if state == 'paid':
|
||||
venta.action_mark_paid()
|
||||
|
||||
count += 1
|
||||
if count % 500 == 0:
|
||||
env.cr.commit()
|
||||
print(f' ventas: {count} procesadas')
|
||||
env.cr.commit()
|
||||
return count
|
||||
|
||||
|
||||
def importar_pagos(env, filepath):
|
||||
"""Importa pagos históricos vinculados a pacientes y citas."""
|
||||
with open(filepath, newline='', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
count = 0
|
||||
for row in reader:
|
||||
phone = (row.get('paciente_telefono') or '').strip()
|
||||
amount = float(row.get('monto', 0) or 0)
|
||||
if not phone or amount <= 0:
|
||||
continue
|
||||
|
||||
partner = env['res.partner'].sudo().search([('phone', '=', phone)], limit=1)
|
||||
if not partner:
|
||||
partner = get_or_create_patient(env, phone, 'Paciente ' + phone)
|
||||
|
||||
cita_ref = (row.get('cita_referencia') or '').strip()
|
||||
cita_id = False
|
||||
if cita_ref:
|
||||
cita = env['skeen.cita'].sudo().search([('name', '=', cita_ref)], limit=1)
|
||||
cita_id = cita.id if cita else False
|
||||
|
||||
method_map = {
|
||||
'efectivo': 'cash',
|
||||
'tarjeta': 'card',
|
||||
'transferencia': 'transfer',
|
||||
'stripe': 'stripe',
|
||||
'mercadopago': 'mercadopago',
|
||||
}
|
||||
method = method_map.get((row.get('metodo') or '').strip().lower(), 'cash')
|
||||
|
||||
state_map = {
|
||||
'pendiente': 'pending',
|
||||
'procesando': 'processing',
|
||||
'completado': 'completed',
|
||||
'fallido': 'failed',
|
||||
'reembolsado': 'refunded',
|
||||
}
|
||||
state = state_map.get((row.get('estado') or '').strip().lower(), 'completed')
|
||||
|
||||
vals = {
|
||||
'partner_id': partner.id,
|
||||
'cita_id': cita_id,
|
||||
'amount': amount,
|
||||
'payment_method': method,
|
||||
'state': state,
|
||||
}
|
||||
if row.get('fecha_pago'):
|
||||
vals['payment_date'] = row.get('fecha_pago')
|
||||
|
||||
pago = env['skeen.pago'].sudo().create(vals)
|
||||
if state == 'completed':
|
||||
pago.action_confirm_payment()
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def importar_adeudos(env, filepath):
|
||||
"""Importa adeudos como ventas en estado draft/con adeudo."""
|
||||
with open(filepath, newline='', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
count = 0
|
||||
for row in reader:
|
||||
phone = (row.get('telefono') or '').strip()
|
||||
concepto = (row.get('concepto') or 'Adeudo').strip()
|
||||
monto = float(row.get('monto', 0) or 0)
|
||||
if not phone or monto <= 0:
|
||||
continue
|
||||
|
||||
partner = env['res.partner'].sudo().search([('phone', '=', phone)], limit=1)
|
||||
if not partner:
|
||||
partner = get_or_create_patient(env, phone, 'Paciente ' + phone)
|
||||
|
||||
# Crear un servicio genérico de adeudo si no existe
|
||||
servicio = env['skeen.servicio'].sudo().with_context(active_test=False).search([('code', '=', 'ADEUDO')], limit=1)
|
||||
if not servicio:
|
||||
servicio = env['skeen.servicio'].sudo().create({
|
||||
'name': 'Adeudo histórico',
|
||||
'code': 'ADEUDO',
|
||||
'category': 'consulta',
|
||||
'price': 0,
|
||||
'duration_min': 0,
|
||||
})
|
||||
|
||||
env['skeen.venta'].sudo().create({
|
||||
'partner_id': partner.id,
|
||||
'date': row.get('fecha_vencimiento') or fields.Date.today(),
|
||||
'state': 'confirmed',
|
||||
'notes': concepto,
|
||||
'line_ids': [(0, 0, {
|
||||
'service_id': servicio.id,
|
||||
'description': concepto,
|
||||
'quantity': 1,
|
||||
'price_unit': monto,
|
||||
})],
|
||||
})
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Importar datos de SKEEN a Odoo')
|
||||
parser.add_argument('--odoo-config', default='/root/skeen-odoo/odoo.conf')
|
||||
parser.add_argument('--database', default='skeen_odoo')
|
||||
args = parser.parse_args()
|
||||
|
||||
odoo.tools.config.parse_config(['-c', args.odoo_config])
|
||||
db = odoo.sql_db.db_connect(args.database)
|
||||
with db.cursor() as cr:
|
||||
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||
|
||||
resultados = {}
|
||||
|
||||
pacientes_path = DATA_DIR / 'pacientes.csv'
|
||||
if pacientes_path.exists():
|
||||
resultados['pacientes'] = importar_pacientes(env, pacientes_path)
|
||||
|
||||
servicios_path = DATA_DIR / 'servicios.csv'
|
||||
if servicios_path.exists():
|
||||
resultados['servicios'] = importar_servicios(env, servicios_path)
|
||||
|
||||
productos_path = DATA_DIR / 'productos.csv'
|
||||
if productos_path.exists():
|
||||
resultados['productos'] = importar_productos(env, productos_path)
|
||||
|
||||
medicos_path = DATA_DIR / 'medicos.csv'
|
||||
if medicos_path.exists():
|
||||
resultados['medicos'] = importar_medicos(env, medicos_path)
|
||||
|
||||
citas_path = DATA_DIR / 'citas.csv'
|
||||
if citas_path.exists():
|
||||
resultados['citas'] = importar_citas(env, citas_path)
|
||||
|
||||
ventas_path = DATA_DIR / 'ventas.csv'
|
||||
if ventas_path.exists():
|
||||
resultados['ventas'] = importar_ventas(env, ventas_path)
|
||||
|
||||
pagos_path = DATA_DIR / 'pagos.csv'
|
||||
if pagos_path.exists():
|
||||
resultados['pagos'] = importar_pagos(env, pagos_path)
|
||||
|
||||
adeudos_path = DATA_DIR / 'adeudos.csv'
|
||||
if adeudos_path.exists():
|
||||
resultados['adeudos'] = importar_adeudos(env, adeudos_path)
|
||||
|
||||
monederos_path = DATA_DIR / 'monederos.csv'
|
||||
if monederos_path.exists():
|
||||
resultados['monederos'] = importar_monederos(env, monederos_path)
|
||||
|
||||
cr.commit()
|
||||
|
||||
print('Importación completada:')
|
||||
for k, v in resultados.items():
|
||||
print(f' {k}: {v} registros')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
181
migracion/importar_pacientes_rapido.py
Normal file
181
migracion/importar_pacientes_rapido.py
Normal file
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Importador rápido de pacientes SKEEN a Odoo usando legacy_id como clave única."""
|
||||
import csv
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ODOO_PATH = Path('/root/skeen-odoo')
|
||||
sys.path.insert(0, str(ODOO_PATH))
|
||||
|
||||
import odoo
|
||||
from odoo import api, SUPERUSER_ID
|
||||
|
||||
DATA_DIR = Path('/root/migracion/datos')
|
||||
|
||||
|
||||
def normalize_bool(v):
|
||||
return str(v).lower() in ('1', 'true', 'si', 'sí')
|
||||
|
||||
|
||||
def main():
|
||||
odoo.tools.config.parse_config(['-c', '/root/skeen-odoo/odoo.conf'])
|
||||
db = odoo.sql_db.db_connect('skeen_odoo')
|
||||
cr = db.cursor()
|
||||
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||
|
||||
Partner = env['res.partner'].sudo()
|
||||
Tag = env['skeen.patient.tag'].sudo()
|
||||
Employee = env['hr.employee'].sudo()
|
||||
|
||||
# Cachés en memoria
|
||||
existing_legacy = {p.legacy_id: p.id for p in Partner.search([('legacy_id', '!=', False)]) if p.legacy_id}
|
||||
doctor_cache = {}
|
||||
tag_cache = {}
|
||||
|
||||
def get_or_create_doctor(name):
|
||||
name = ' '.join(str(name).split())
|
||||
if not name:
|
||||
return False
|
||||
if name in doctor_cache:
|
||||
return doctor_cache[name]
|
||||
clean = name.lower().replace('dra.', '').replace('dr.', '').replace('dr ', '').replace('dra ', '').strip()
|
||||
doctor = Employee.search(['|', ('name', 'ilike', name), ('name', 'ilike', clean)], limit=1)
|
||||
if not doctor:
|
||||
doctor = Employee.create({'name': name})
|
||||
doctor_cache[name] = doctor
|
||||
return doctor
|
||||
|
||||
def get_tag(name):
|
||||
if name in tag_cache:
|
||||
return tag_cache[name]
|
||||
tag = Tag.search([('name', '=', name)], limit=1)
|
||||
if not tag:
|
||||
tag = Tag.create({'name': name})
|
||||
tag_cache[name] = tag.id
|
||||
return tag.id
|
||||
|
||||
rows = []
|
||||
with open(DATA_DIR / 'pacientes.csv', newline='', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
rows.append(row)
|
||||
|
||||
print(f'Importando {len(rows)} pacientes...')
|
||||
count = 0
|
||||
skipped = 0
|
||||
batch = []
|
||||
BATCH_SIZE = 500
|
||||
|
||||
for row in rows:
|
||||
legacy_id = (row.get('legacy_id') or '').strip()
|
||||
name = ' '.join(filter(None, [
|
||||
row.get('nombre', ''),
|
||||
row.get('apellido_paterno', ''),
|
||||
row.get('apellido_materno', '')
|
||||
])).strip()
|
||||
phone = (row.get('telefono') or '').strip()
|
||||
if not name or not phone:
|
||||
skipped += 1
|
||||
continue
|
||||
if legacy_id and legacy_id in existing_legacy:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
vals = {
|
||||
'name': name,
|
||||
'phone': phone,
|
||||
'email': row.get('email', ''),
|
||||
'is_patient': True,
|
||||
'source': 'other',
|
||||
'is_vip': normalize_bool(row.get('vip')),
|
||||
}
|
||||
if legacy_id:
|
||||
vals['legacy_id'] = legacy_id
|
||||
|
||||
if row.get('fecha_nacimiento'):
|
||||
vals['birth_date'] = row.get('fecha_nacimiento')
|
||||
if row.get('sexo'):
|
||||
vals['gender'] = row.get('sexo')
|
||||
|
||||
# Datos personales
|
||||
if row.get('lugar_nacimiento'):
|
||||
vals['birthplace'] = row.get('lugar_nacimiento')
|
||||
if row.get('empleo'):
|
||||
vals['occupation'] = row.get('empleo')
|
||||
if row.get('estado_civil'):
|
||||
vals['marital_status'] = row.get('estado_civil')
|
||||
if row.get('contacto_emergencia'):
|
||||
vals['emergency_contact'] = row.get('contacto_emergencia')
|
||||
if row.get('telefono_emergencia'):
|
||||
vals['emergency_phone'] = row.get('telefono_emergencia')
|
||||
if row.get('telefono_casa'):
|
||||
vals['home_phone'] = row.get('telefono_casa')
|
||||
if row.get('direccion'):
|
||||
vals['address_notes'] = row.get('direccion')
|
||||
if row.get('recomendado_por'):
|
||||
vals['referred_by'] = row.get('recomendado_por')
|
||||
if row.get('comentarios'):
|
||||
vals['patient_comments'] = row.get('comentarios')
|
||||
|
||||
# Historia clínica
|
||||
try:
|
||||
vals['children_count'] = int(row.get('hijos', 0) or 0)
|
||||
except Exception:
|
||||
vals['children_count'] = 0
|
||||
vals['is_pregnant'] = normalize_bool(row.get('embarazada'))
|
||||
vals['is_breastfeeding'] = normalize_bool(row.get('lactancia'))
|
||||
vals['uses_contraceptives'] = normalize_bool(row.get('anticonceptivos'))
|
||||
vals['kidney_problems'] = normalize_bool(row.get('problemas_rinon'))
|
||||
vals['back_pain'] = normalize_bool(row.get('problemas_espalda'))
|
||||
vals['heart_disease'] = normalize_bool(row.get('problemas_cardiacos'))
|
||||
vals['respiratory_problems'] = normalize_bool(row.get('problemas_respiratorios'))
|
||||
vals['blood_pressure'] = normalize_bool(row.get('alta_presion')) or normalize_bool(row.get('baja_presion'))
|
||||
vals['diabetes'] = normalize_bool(row.get('diabetes'))
|
||||
vals['thyroid'] = normalize_bool(row.get('hipertiroidismo')) or normalize_bool(row.get('hipotiroidismo'))
|
||||
vals['colitis'] = normalize_bool(row.get('colitis'))
|
||||
vals['constipation'] = normalize_bool(row.get('estrenimiento'))
|
||||
vals['liver_problems'] = normalize_bool(row.get('problemas_higado'))
|
||||
vals['surgeries'] = normalize_bool(row.get('cirugias'))
|
||||
vals['varicose_veins'] = normalize_bool(row.get('varices'))
|
||||
vals['migraine'] = normalize_bool(row.get('migrana'))
|
||||
vals['faints_with_needles'] = normalize_bool(row.get('desmaya_agujas'))
|
||||
if row.get('alergias'):
|
||||
vals['allergies'] = row.get('alergias')
|
||||
if row.get('medicamentos'):
|
||||
vals['current_medication'] = row.get('medicamentos')
|
||||
if row.get('tratamientos_previos'):
|
||||
vals['medical_history'] = row.get('tratamientos_previos')
|
||||
|
||||
medico = get_or_create_doctor(row.get('medico_principal', ''))
|
||||
if medico:
|
||||
vals['primary_doctor_id'] = medico.id
|
||||
|
||||
etiquetas = row.get('etiquetas', '').strip()
|
||||
if etiquetas:
|
||||
tag_ids = [get_tag(t) for t in etiquetas.split(',') if t.strip()]
|
||||
vals['tag_ids'] = [(6, 0, tag_ids)]
|
||||
|
||||
batch.append(vals)
|
||||
if legacy_id:
|
||||
existing_legacy[legacy_id] = True
|
||||
|
||||
if len(batch) >= BATCH_SIZE:
|
||||
Partner.create(batch)
|
||||
count += len(batch)
|
||||
batch = []
|
||||
cr.commit()
|
||||
print(f' {count} pacientes creados')
|
||||
|
||||
if batch:
|
||||
Partner.create(batch)
|
||||
count += len(batch)
|
||||
cr.commit()
|
||||
|
||||
cr.commit()
|
||||
cr.close()
|
||||
print(f'Importación completada: {count} pacientes creados, {skipped} omitidos')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
415
migracion/importar_resto_rapido.py
Normal file
415
migracion/importar_resto_rapido.py
Normal file
@@ -0,0 +1,415 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Importador rápido de servicios, citas, ventas y monederos SKEEN a Odoo."""
|
||||
import csv
|
||||
import sys
|
||||
import re
|
||||
import html
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
ODOO_PATH = Path('/root/skeen-odoo')
|
||||
sys.path.insert(0, str(ODOO_PATH))
|
||||
|
||||
import odoo
|
||||
from odoo import api, SUPERUSER_ID
|
||||
|
||||
DATA_DIR = Path('/root/migracion/datos')
|
||||
|
||||
|
||||
def normalize_bool(v):
|
||||
return str(v).lower() in ('1', 'true', 'si', 'sí')
|
||||
|
||||
|
||||
def parse_time(value):
|
||||
if not value:
|
||||
return 9.0
|
||||
value = str(value).strip()
|
||||
if ':' in value:
|
||||
h, m = value.split(':')[:2]
|
||||
return float(h) + float(m) / 60.0
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError:
|
||||
return 9.0
|
||||
|
||||
|
||||
def extract_service(text, patient_name):
|
||||
"""Extrae el nombre del servicio del campo servicio_nombre."""
|
||||
text = html.unescape(text or '')
|
||||
if patient_name:
|
||||
text = re.sub(r'^' + re.escape(patient_name) + r'\s*[-–—]\s*', '', text, flags=re.IGNORECASE)
|
||||
text = re.sub(r'\s*\(\d+\s*(sesión|sesiones|unidad|unidades)\s*\)\s*$', '', text, flags=re.IGNORECASE)
|
||||
text = ' '.join(text.split())
|
||||
return text.strip()
|
||||
|
||||
|
||||
def normalize_name(name):
|
||||
return ' '.join(str(name).split()).lower()
|
||||
|
||||
|
||||
def main():
|
||||
odoo.tools.config.parse_config(['-c', '/root/skeen-odoo/odoo.conf'])
|
||||
db = odoo.sql_db.db_connect('skeen_odoo')
|
||||
cr = db.cursor()
|
||||
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||
|
||||
Partner = env['res.partner'].sudo()
|
||||
Servicio = env['skeen.servicio'].sudo()
|
||||
Employee = env['hr.employee'].sudo()
|
||||
CitaModel = env.registry['skeen.cita']
|
||||
Cita = env['skeen.cita'].sudo()
|
||||
Venta = env['skeen.venta'].sudo()
|
||||
Monedero = env['skeen.monedero'].sudo()
|
||||
MonederoTx = env['skeen.monedero.transaction'].sudo()
|
||||
|
||||
# Cachés en memoria (SQL directo para velocidad)
|
||||
print('Cargando cachés...')
|
||||
partner_by_phone = {}
|
||||
partner_by_name = {}
|
||||
partner_by_legacy = {}
|
||||
cr.execute("SELECT id, phone, name, legacy_id FROM res_partner WHERE is_patient = true")
|
||||
for pid, phone, name, legacy_id in cr.fetchall():
|
||||
if phone:
|
||||
partner_by_phone.setdefault(phone, pid)
|
||||
if name:
|
||||
partner_by_name.setdefault(normalize_name(name), pid)
|
||||
if legacy_id:
|
||||
partner_by_legacy[legacy_id] = pid
|
||||
|
||||
service_by_code = {}
|
||||
service_by_name = {}
|
||||
cr.execute("SELECT id, code, name FROM skeen_servicio")
|
||||
for sid, code, name in cr.fetchall():
|
||||
if code:
|
||||
service_by_code[code] = sid
|
||||
if name:
|
||||
service_by_name[normalize_name(name)] = sid
|
||||
|
||||
doctor_by_name = {}
|
||||
cr.execute("SELECT id, name FROM hr_employee")
|
||||
for eid, name in cr.fetchall():
|
||||
if name:
|
||||
doctor_by_name[normalize_name(name)] = eid
|
||||
print(f' Pacientes: {len(partner_by_phone)} por teléfono, {len(partner_by_name)} por nombre')
|
||||
print(f' Servicios: {len(service_by_name)}')
|
||||
print(f' Doctores: {len(doctor_by_name)}')
|
||||
|
||||
skipped_reasons = {'no_patient': 0, 'no_service': 0, 'no_date': 0}
|
||||
|
||||
def resolve_partner(row, phone_field='paciente_telefono', name_field='paciente_nombre', legacy_field='expedientes_id'):
|
||||
legacy_id = (row.get(legacy_field) or '').strip()
|
||||
if legacy_id and legacy_id in partner_by_legacy:
|
||||
return partner_by_legacy[legacy_id]
|
||||
phone = (row.get(phone_field) or '').strip()
|
||||
if phone and phone in partner_by_phone:
|
||||
return partner_by_phone[phone]
|
||||
name = normalize_name(row.get(name_field, ''))
|
||||
if name and name in partner_by_name:
|
||||
return partner_by_name[name]
|
||||
return False
|
||||
|
||||
# Desactivar validación de solapamiento de citas
|
||||
original_check = CitaModel._check_availability
|
||||
CitaModel._check_availability = lambda self: None
|
||||
|
||||
resultados = {}
|
||||
|
||||
# 1. Servicios
|
||||
print('Importando servicios...')
|
||||
category_map = {
|
||||
'consulta': 'consulta',
|
||||
'tratamiento': 'tratamiento',
|
||||
'procedimiento': 'procedimiento',
|
||||
'paquete': 'paquete',
|
||||
'servicios_medicos': 'consulta',
|
||||
'procedimientos_cosmeticos': 'procedimiento',
|
||||
'tecnologia_medica': 'tratamiento',
|
||||
'cosmetologia_basica': 'tratamiento',
|
||||
'cosmetologia_avanzada': 'tratamiento',
|
||||
}
|
||||
seen_codes = set()
|
||||
batch = []
|
||||
count = 0
|
||||
with open(DATA_DIR / 'servicios.csv', newline='', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
code = (row.get('codigo') or '').strip()
|
||||
name = (row.get('nombre') or '').strip()
|
||||
if not code or not name or code in seen_codes:
|
||||
continue
|
||||
seen_codes.add(code)
|
||||
if code in service_by_code:
|
||||
continue
|
||||
cat = category_map.get((row.get('categoria') or '').strip().lower(), 'tratamiento')
|
||||
batch.append({
|
||||
'name': name,
|
||||
'code': code,
|
||||
'category': cat,
|
||||
'price': float(row.get('precio', 0) or 0),
|
||||
'duration_min': int(row.get('duracion_min', 30) or 30),
|
||||
'description': row.get('descripcion', ''),
|
||||
'color': row.get('color', '#1abc9c'),
|
||||
'service_group': row.get('grupo', ''),
|
||||
'is_favorite': normalize_bool(row.get('favorito')),
|
||||
'active': normalize_bool(row.get('activo', '1')),
|
||||
})
|
||||
if len(batch) >= 100:
|
||||
created = Servicio.create(batch)
|
||||
count += len(created)
|
||||
cr.commit()
|
||||
batch = []
|
||||
if batch:
|
||||
created = Servicio.create(batch)
|
||||
count += len(created)
|
||||
cr.commit()
|
||||
# Recargar caché de servicios (SQL directo)
|
||||
service_by_code = {}
|
||||
service_by_name = {}
|
||||
cr.execute("SELECT id, code, name FROM skeen_servicio")
|
||||
for sid, code, name in cr.fetchall():
|
||||
if code:
|
||||
service_by_code[code] = sid
|
||||
if name:
|
||||
service_by_name[normalize_name(name)] = sid
|
||||
resultados['servicios'] = count
|
||||
print(f' {count} servicios creados')
|
||||
|
||||
# Helper: obtener o crear servicio por nombre
|
||||
service_name_cache = dict(service_by_name)
|
||||
|
||||
def get_or_create_service_by_name(name):
|
||||
name = name.strip()
|
||||
if not name:
|
||||
name = 'Servicio genérico'
|
||||
key = normalize_name(name)
|
||||
if key in service_name_cache:
|
||||
return service_name_cache[key]
|
||||
# Crear nuevo servicio
|
||||
code_hash = hashlib.md5(key.encode('utf-8')).hexdigest()[:12]
|
||||
code = f'HIST_{code_hash}'
|
||||
new_s = Servicio.create({
|
||||
'name': name,
|
||||
'code': code,
|
||||
'category': 'tratamiento',
|
||||
'price': 0,
|
||||
'duration_min': 30,
|
||||
})
|
||||
service_name_cache[key] = new_s.id
|
||||
return new_s.id
|
||||
|
||||
# 2. Citas
|
||||
print('Importando citas...')
|
||||
state_map = {
|
||||
'pendiente': 'pending',
|
||||
'confirmada': 'confirmed',
|
||||
'llego': 'arrived',
|
||||
'en_progreso': 'in_progress',
|
||||
'completada': 'done',
|
||||
'cancelada': 'cancelled',
|
||||
'no_show': 'no_show',
|
||||
}
|
||||
payment_state_map = {
|
||||
'no_pagado': 'not_paid',
|
||||
'parcial': 'partial',
|
||||
'pagado': 'paid',
|
||||
}
|
||||
batch = []
|
||||
count = 0
|
||||
skipped = 0
|
||||
BATCH_SIZE = 500
|
||||
with open(DATA_DIR / 'citas.csv', newline='', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
partner_id = resolve_partner(row)
|
||||
date_str = (row.get('fecha') or '').strip()
|
||||
if not partner_id:
|
||||
skipped += 1
|
||||
skipped_reasons['no_patient'] += 1
|
||||
continue
|
||||
if not date_str:
|
||||
skipped += 1
|
||||
skipped_reasons['no_date'] += 1
|
||||
continue
|
||||
|
||||
service_name = extract_service(row.get('servicio_nombre', ''), row.get('paciente_nombre', ''))
|
||||
if not service_name:
|
||||
service_name = row.get('servicio_nombre', '').strip() or 'Servicio genérico'
|
||||
servicio_id = get_or_create_service_by_name(service_name)
|
||||
|
||||
doctor_name = (row.get('doctor_nombre') or '').strip()
|
||||
doctor_id = doctor_by_name.get(normalize_name(doctor_name)) if doctor_name else False
|
||||
|
||||
branch = 'rosarito'
|
||||
if (row.get('sucursal') or '').strip().lower() in ('tijuana', 'tj'):
|
||||
branch = 'tijuana'
|
||||
|
||||
medium = 'onsite'
|
||||
if (row.get('medio') or '').strip().lower() in ('videollamada', 'video', 'remota'):
|
||||
medium = 'videocall'
|
||||
|
||||
state = state_map.get((row.get('estado') or '').strip().lower(), 'confirmed')
|
||||
payment_state = payment_state_map.get((row.get('estado_pago') or '').strip().lower(), 'not_paid')
|
||||
|
||||
batch.append({
|
||||
'partner_id': partner_id,
|
||||
'servicio_id': servicio_id,
|
||||
'date': date_str,
|
||||
'time': parse_time(row.get('hora')),
|
||||
'state': state,
|
||||
'payment_state': payment_state,
|
||||
'amount_paid': float(row.get('monto_pagado', 0) or 0),
|
||||
'doctor_id': doctor_id,
|
||||
'branch': branch,
|
||||
'medium': medium,
|
||||
'notes': row.get('notas', ''),
|
||||
})
|
||||
|
||||
if len(batch) >= BATCH_SIZE:
|
||||
Cita.create(batch)
|
||||
count += len(batch)
|
||||
batch = []
|
||||
cr.commit()
|
||||
print(f' {count} citas creadas')
|
||||
if batch:
|
||||
Cita.create(batch)
|
||||
count += len(batch)
|
||||
cr.commit()
|
||||
resultados['citas'] = count
|
||||
print(f' {count} citas creadas, {skipped} omitidas ({skipped_reasons})')
|
||||
|
||||
# 3. Ventas
|
||||
print('Importando ventas...')
|
||||
state_map_venta = {
|
||||
'borrador': 'draft',
|
||||
'confirmada': 'confirmed',
|
||||
'pagada': 'paid',
|
||||
'parcial': 'partial',
|
||||
'cancelada': 'cancelled',
|
||||
}
|
||||
count = 0
|
||||
skipped = 0
|
||||
batch = []
|
||||
BATCH_SIZE = 500
|
||||
with open(DATA_DIR / 'ventas.csv', newline='', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
partner_id = resolve_partner(row)
|
||||
if not partner_id:
|
||||
skipped += 1
|
||||
skipped_reasons['no_patient'] += 1
|
||||
continue
|
||||
|
||||
service_name = extract_service(row.get('servicio_nombre', ''), row.get('paciente_nombre', ''))
|
||||
if not service_name:
|
||||
service_name = row.get('servicio_nombre', '').strip() or 'Servicio genérico'
|
||||
servicio_id = get_or_create_service_by_name(service_name)
|
||||
|
||||
qty = float(row.get('cantidad', 1) or 1)
|
||||
price = float(row.get('precio_unitario', 0) or 0)
|
||||
discount = float(row.get('descuento', 0) or 0)
|
||||
tax = float(row.get('impuesto', 0) or 0)
|
||||
state = state_map_venta.get((row.get('estado') or '').strip().lower(), 'confirmed')
|
||||
date = row.get('fecha') or odoo.fields.Date.today()
|
||||
|
||||
batch.append({
|
||||
'partner_id': partner_id,
|
||||
'date': date,
|
||||
'discount': discount,
|
||||
'tax': tax,
|
||||
'state': state,
|
||||
'notes': row.get('notas', ''),
|
||||
'line_ids': [(0, 0, {
|
||||
'service_id': servicio_id,
|
||||
'description': service_name or 'Servicio',
|
||||
'quantity': qty,
|
||||
'price_unit': price,
|
||||
})],
|
||||
})
|
||||
|
||||
if len(batch) >= BATCH_SIZE:
|
||||
Venta.create(batch)
|
||||
count += len(batch)
|
||||
batch = []
|
||||
cr.commit()
|
||||
print(f' {count} ventas creadas')
|
||||
if batch:
|
||||
Venta.create(batch)
|
||||
count += len(batch)
|
||||
cr.commit()
|
||||
resultados['ventas'] = count
|
||||
print(f' {count} ventas creadas, {skipped} omitidas')
|
||||
|
||||
# 4. Monederos
|
||||
print('Importando monederos...')
|
||||
count = 0
|
||||
skipped = 0
|
||||
batch_monedero = []
|
||||
batch_tx = []
|
||||
BATCH_SIZE = 500
|
||||
with open(DATA_DIR / 'monederos.csv', newline='', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
phone = (row.get('telefono') or '').strip()
|
||||
points = int(float(row.get('puntos', 0) or 0))
|
||||
if not phone or phone not in partner_by_phone:
|
||||
skipped += 1
|
||||
continue
|
||||
partner_id = partner_by_phone[phone]
|
||||
existing = Monedero.search([('partner_id', '=', partner_id)], limit=1)
|
||||
if existing:
|
||||
existing.write({'points': points})
|
||||
count += 1
|
||||
continue
|
||||
|
||||
batch_monedero.append({
|
||||
'partner_id': partner_id,
|
||||
'points': points,
|
||||
})
|
||||
|
||||
if len(batch_monedero) >= BATCH_SIZE:
|
||||
created = Monedero.create(batch_monedero)
|
||||
for m in created:
|
||||
batch_tx.append({
|
||||
'monedero_id': m.id,
|
||||
'type': 'adjustment',
|
||||
'points': m.points,
|
||||
'description': 'Saldo inicial migrado de SKEEN',
|
||||
'reference': 'MIGRACION',
|
||||
})
|
||||
if batch_tx:
|
||||
MonederoTx.create(batch_tx)
|
||||
batch_tx = []
|
||||
count += len(batch_monedero)
|
||||
batch_monedero = []
|
||||
cr.commit()
|
||||
print(f' {count} monederos creados')
|
||||
if batch_monedero:
|
||||
created = Monedero.create(batch_monedero)
|
||||
for m in created:
|
||||
batch_tx.append({
|
||||
'monedero_id': m.id,
|
||||
'type': 'adjustment',
|
||||
'points': m.points,
|
||||
'description': 'Saldo inicial migrado de SKEEN',
|
||||
'reference': 'MIGRACION',
|
||||
})
|
||||
if batch_tx:
|
||||
MonederoTx.create(batch_tx)
|
||||
count += len(batch_monedero)
|
||||
cr.commit()
|
||||
resultados['monederos'] = count
|
||||
print(f' {count} monederos creados, {skipped} omitidos')
|
||||
|
||||
# Restaurar validación
|
||||
CitaModel._check_availability = original_check
|
||||
cr.commit()
|
||||
cr.close()
|
||||
|
||||
print('Importación completada:')
|
||||
for k, v in resultados.items():
|
||||
print(f' {k}: {v} registros')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
365
migracion/importar_servicios_inventario.py
Normal file
365
migracion/importar_servicios_inventario.py
Normal file
@@ -0,0 +1,365 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Importador de servicios e inventario para SKEEN Derma Experts.
|
||||
|
||||
Uso:
|
||||
python3 importar_servicios_inventario.py \
|
||||
--servicios servicios.csv \
|
||||
--inventario inventario.csv \
|
||||
[--purge-citas] \
|
||||
[--generar-hermes]
|
||||
|
||||
Formato CSV servicios:
|
||||
name,code,category,price,duration_min,package_price,package_sessions,package_notes,description,service_group,color,is_favorite
|
||||
|
||||
category: consulta | tratamiento | procedimiento | paquete
|
||||
price: precio por sesion (float)
|
||||
package_price: precio total del paquete (float, 0 si no aplica)
|
||||
package_sessions: numero de sesiones del paquete (int, 0 si no aplica)
|
||||
|
||||
Formato CSV inventario:
|
||||
name,kind,sku,category,unit,qty,qty_optimal,qty_min,cost,expiry_date,notes
|
||||
|
||||
kind: producto | consumible
|
||||
expiry_date: opcional, formato YYYY-MM-DD
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import xmlrpc.client
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
ODOO_URL = os.environ.get('ODOO_URL', 'http://localhost:8069')
|
||||
ODOO_DB = os.environ.get('ODOO_DB', 'skeen_odoo')
|
||||
ODOO_USER = os.environ.get('ODOO_USER', 'admin')
|
||||
ODOO_PASSWORD = os.environ.get('ODOO_PASSWORD', 'skeen_admin_2026')
|
||||
|
||||
|
||||
def conectar():
|
||||
common = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/common')
|
||||
uid = common.authenticate(ODOO_DB, ODOO_USER, ODOO_PASSWORD, {})
|
||||
if not uid:
|
||||
raise Exception("Autenticacion fallida. Verifica ODOO_USER / ODOO_PASSWORD.")
|
||||
models = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/object')
|
||||
return uid, models
|
||||
|
||||
|
||||
def leer_csv(path):
|
||||
if not path or not os.path.exists(path):
|
||||
return []
|
||||
with open(path, 'r', encoding='utf-8-sig', newline='') as f:
|
||||
reader = csv.DictReader(f)
|
||||
rows = [row for row in reader]
|
||||
return rows
|
||||
|
||||
|
||||
def _run_sql(command):
|
||||
"""Ejecuta un comando SQL via psql como usuario postgres."""
|
||||
import subprocess
|
||||
full = f"sudo -u postgres psql -d {ODOO_DB} -c \"{command}\""
|
||||
result = subprocess.run(full, shell=True, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"SQL error: {result.stderr}\n{result.stdout}")
|
||||
return result.stdout
|
||||
|
||||
|
||||
def limpiar_inventario(models, uid, use_sql=False):
|
||||
if use_sql:
|
||||
print("[INVENTARIO] Eliminando movimientos e items via SQL...")
|
||||
_run_sql("TRUNCATE TABLE skeen_inventario_move RESTART IDENTITY CASCADE")
|
||||
_run_sql("TRUNCATE TABLE skeen_inventario_item RESTART IDENTITY CASCADE")
|
||||
print(" -> Inventario limpio")
|
||||
return
|
||||
|
||||
print("[INVENTARIO] Eliminando movimientos...")
|
||||
move_ids = models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD,
|
||||
'skeen.inventario.move', 'search', [[]])
|
||||
if move_ids:
|
||||
models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD,
|
||||
'skeen.inventario.move', 'unlink', [move_ids])
|
||||
print(f" -> Eliminados {len(move_ids)} movimientos")
|
||||
|
||||
print("[INVENTARIO] Eliminando items...")
|
||||
item_ids = models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD,
|
||||
'skeen.inventario.item', 'search', [[]])
|
||||
if item_ids:
|
||||
models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD,
|
||||
'skeen.inventario.item', 'unlink', [item_ids])
|
||||
print(f" -> Eliminados {len(item_ids)} items")
|
||||
|
||||
|
||||
def limpiar_servicios(models, uid, purge_citas=False, use_sql=False):
|
||||
if use_sql and purge_citas:
|
||||
print("[SERVICIOS] Eliminando citas y servicios via SQL...")
|
||||
_run_sql("TRUNCATE TABLE skeen_cita RESTART IDENTITY CASCADE")
|
||||
_run_sql("TRUNCATE TABLE skeen_service_tag_skeen_servicio_rel RESTART IDENTITY CASCADE")
|
||||
_run_sql("TRUNCATE TABLE skeen_service_tag RESTART IDENTITY CASCADE")
|
||||
_run_sql("TRUNCATE TABLE skeen_servicio RESTART IDENTITY CASCADE")
|
||||
print(" -> Servicios y citas limpios")
|
||||
return
|
||||
|
||||
if purge_citas:
|
||||
print("[SERVICIOS] Eliminando citas existentes...")
|
||||
cita_ids = models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD,
|
||||
'skeen.cita', 'search', [[]])
|
||||
if cita_ids:
|
||||
models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD,
|
||||
'skeen.cita', 'unlink', [cita_ids])
|
||||
print(f" -> Eliminadas {len(cita_ids)} citas")
|
||||
|
||||
print("[SERVICIOS] Eliminando servicios...")
|
||||
servicio_ids = models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD,
|
||||
'skeen.servicio', 'search', [[]])
|
||||
if servicio_ids:
|
||||
if purge_citas:
|
||||
models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD,
|
||||
'skeen.servicio', 'unlink', [servicio_ids])
|
||||
print(f" -> Eliminados {len(servicio_ids)} servicios")
|
||||
else:
|
||||
# Intentar eliminar uno a uno; los que tengan citas fallaran
|
||||
eliminados = 0
|
||||
fallidos = []
|
||||
for sid in servicio_ids:
|
||||
try:
|
||||
models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD,
|
||||
'skeen.servicio', 'unlink', [[sid]])
|
||||
eliminados += 1
|
||||
except Exception as e:
|
||||
serv = models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD,
|
||||
'skeen.servicio', 'read',
|
||||
[[sid], ['name', 'code']])
|
||||
fallidos.append((sid, serv[0] if serv else {}, str(e)))
|
||||
print(f" -> Eliminados {eliminados} servicios")
|
||||
if fallidos:
|
||||
print(f" -> No se pudieron eliminar {len(fallidos)} servicios (tienen citas/historial):")
|
||||
for sid, info, err in fallidos[:10]:
|
||||
print(f" - {info.get('name', sid)} ({info.get('code', '')}): {err[:120]}")
|
||||
|
||||
print("[SERVICIOS] Eliminando tags de servicios...")
|
||||
tag_ids = models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD,
|
||||
'skeen.service.tag', 'search', [[]])
|
||||
if tag_ids:
|
||||
models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD,
|
||||
'skeen.service.tag', 'unlink', [tag_ids])
|
||||
print(f" -> Eliminados {len(tag_ids)} tags")
|
||||
|
||||
|
||||
def parse_float(val, default=0.0):
|
||||
if val is None:
|
||||
return default
|
||||
val = str(val).strip().replace(',', '').replace('$', '')
|
||||
if val == '':
|
||||
return default
|
||||
try:
|
||||
return float(val)
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def parse_int(val, default=0):
|
||||
if val is None:
|
||||
return default
|
||||
val = str(val).strip()
|
||||
if val == '':
|
||||
return default
|
||||
try:
|
||||
return int(float(val))
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def importar_servicios(models, uid, rows):
|
||||
print(f"[SERVICIOS] Importando {len(rows)} servicios...")
|
||||
creados = 0
|
||||
for row in rows:
|
||||
name = (row.get('name') or '').strip()
|
||||
code = (row.get('code') or '').strip()
|
||||
if not name or not code:
|
||||
print(f" -> Saltando fila sin nombre o codigo: {row}")
|
||||
continue
|
||||
|
||||
category = (row.get('category') or 'tratamiento').strip().lower()
|
||||
if category not in ('consulta', 'tratamiento', 'procedimiento', 'paquete'):
|
||||
category = 'tratamiento'
|
||||
|
||||
vals = {
|
||||
'name': name,
|
||||
'code': code,
|
||||
'category': category,
|
||||
'price': parse_float(row.get('price')),
|
||||
'duration_min': parse_int(row.get('duration_min'), 30),
|
||||
'package_price': parse_float(row.get('package_price')),
|
||||
'package_sessions': parse_int(row.get('package_sessions')),
|
||||
'package_notes': (row.get('package_notes') or '').strip(),
|
||||
'description': (row.get('description') or '').strip(),
|
||||
'service_group': (row.get('service_group') or '').strip(),
|
||||
'color': (row.get('color') or '#1abc9c').strip(),
|
||||
'is_favorite': str(row.get('is_favorite', '')).strip().lower() in ('1', 'true', 'si', 'sí', 'yes'),
|
||||
}
|
||||
|
||||
# Si es categoria paquete pero no tiene sesiones, forzar consulta
|
||||
if category == 'paquete' and vals['package_sessions'] <= 0:
|
||||
vals['category'] = 'tratamiento'
|
||||
|
||||
existing = models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD,
|
||||
'skeen.servicio', 'search',
|
||||
[[['code', '=', code]]])
|
||||
if existing:
|
||||
models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD,
|
||||
'skeen.servicio', 'write', [existing, vals])
|
||||
print(f" -> Actualizado: {name} ({code})")
|
||||
else:
|
||||
models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD,
|
||||
'skeen.servicio', 'create', [vals])
|
||||
creados += 1
|
||||
print(f" -> Creado: {name} ({code})")
|
||||
print(f"[SERVICIOS] {creados} nuevos creados.")
|
||||
|
||||
|
||||
def importar_inventario(models, uid, rows):
|
||||
print(f"[INVENTARIO] Importando {len(rows)} items...")
|
||||
creados = 0
|
||||
for row in rows:
|
||||
name = (row.get('name') or '').strip()
|
||||
if not name:
|
||||
continue
|
||||
kind = (row.get('kind') or 'producto').strip().lower()
|
||||
if kind not in ('producto', 'consumible'):
|
||||
kind = 'producto'
|
||||
|
||||
sku = (row.get('sku') or '').strip()
|
||||
expiry = (row.get('expiry_date') or '').strip()
|
||||
|
||||
vals = {
|
||||
'name': name,
|
||||
'kind': kind,
|
||||
'sku': sku,
|
||||
'category': (row.get('category') or '').strip(),
|
||||
'unit': (row.get('unit') or 'pieza').strip(),
|
||||
'qty': parse_float(row.get('qty')),
|
||||
'qty_optimal': parse_float(row.get('qty_optimal')),
|
||||
'qty_min': parse_float(row.get('qty_min')),
|
||||
'cost': parse_float(row.get('cost')),
|
||||
'notes': (row.get('notes') or '').strip(),
|
||||
}
|
||||
if expiry:
|
||||
try:
|
||||
datetime.strptime(expiry, '%Y-%m-%d')
|
||||
vals['expiry_date'] = expiry
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
existing = models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD,
|
||||
'skeen.inventario.item', 'search',
|
||||
[[['sku', '=', sku]]] if sku else [[['name', '=', name]]])
|
||||
if existing:
|
||||
models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD,
|
||||
'skeen.inventario.item', 'write', [existing, vals])
|
||||
print(f" -> Actualizado: {name}")
|
||||
else:
|
||||
models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD,
|
||||
'skeen.inventario.item', 'create', [vals])
|
||||
creados += 1
|
||||
print(f" -> Creado: {name}")
|
||||
print(f"[INVENTARIO] {creados} nuevos items creados.")
|
||||
|
||||
|
||||
def generar_hermes_services(models, uid):
|
||||
print("[HERMES] Generando catalogo para skills...")
|
||||
servicios = models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD,
|
||||
'skeen.servicio', 'search_read',
|
||||
[[['active', '=', True]]],
|
||||
{'fields': ['name', 'code', 'category', 'price',
|
||||
'duration_min', 'package_price',
|
||||
'package_sessions', 'package_notes',
|
||||
'description']})
|
||||
|
||||
payload = {
|
||||
"clinica": {
|
||||
"nombre": "SKEEN Derma Experts",
|
||||
"direccion": "Blvd. Popotla 123, Playas de Rosarito, BC, Mexico",
|
||||
"telefono": "+52 (664) 123-4567",
|
||||
"horario": {
|
||||
"lunes_viernes": "9:00 - 18:00",
|
||||
"sabado": "9:00 - 14:00",
|
||||
"domingo": "Cerrado"
|
||||
}
|
||||
},
|
||||
"servicios": []
|
||||
}
|
||||
|
||||
for s in servicios:
|
||||
payload['servicios'].append({
|
||||
"id": s['code'].lower().replace(' ', '-'),
|
||||
"nombre": s['name'],
|
||||
"categoria": s['category'],
|
||||
"precio": s['price'],
|
||||
"duracion_min": s['duration_min'],
|
||||
"precio_paquete": s['package_price'] or 0,
|
||||
"sesiones_paquete": s['package_sessions'] or 0,
|
||||
"notas_paquete": s['package_notes'] or '',
|
||||
"descripcion": s['description'] or '',
|
||||
"contraindicaciones": []
|
||||
})
|
||||
|
||||
paths = [
|
||||
'/root/.hermes/skills/skeen-services.json',
|
||||
'/root/skeen-roadmap-hermes/hermes/skills/skeen-services.json',
|
||||
]
|
||||
for p in paths:
|
||||
try:
|
||||
with open(p, 'w', encoding='utf-8') as f:
|
||||
json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||
print(f" -> Escrito: {p}")
|
||||
except Exception as e:
|
||||
print(f" -> Error escribiendo {p}: {e}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Importar servicios e inventario a Odoo')
|
||||
parser.add_argument('--servicios', help='CSV de servicios')
|
||||
parser.add_argument('--inventario', help='CSV de inventario')
|
||||
parser.add_argument('--purge-citas', action='store_true',
|
||||
help='Eliminar citas existentes antes de borrar servicios')
|
||||
parser.add_argument('--purge-sql', action='store_true',
|
||||
help='Usar TRUNCATE SQL directo para limpieza rapida (miles de registros)')
|
||||
parser.add_argument('--generar-hermes', action='store_true',
|
||||
help='Generar skeen-services.json para Hermes')
|
||||
parser.add_argument('--solo-hermes', action='store_true',
|
||||
help='Solo generar skeen-services.json desde Odoo actual')
|
||||
args = parser.parse_args()
|
||||
|
||||
uid, models = conectar()
|
||||
print(f"[OK] Conectado a Odoo {ODOO_URL} como {ODOO_USER} (uid={uid})")
|
||||
|
||||
if args.solo_hermes:
|
||||
generar_hermes_services(models, uid)
|
||||
return
|
||||
|
||||
if not args.servicios and not args.inventario:
|
||||
print("[INFO] No se especificaron CSVs. Usa --servicios y/o --inventario.")
|
||||
return
|
||||
|
||||
if args.inventario:
|
||||
limpiar_inventario(models, uid, use_sql=args.purge_sql)
|
||||
rows_inv = leer_csv(args.inventario)
|
||||
importar_inventario(models, uid, rows_inv)
|
||||
|
||||
if args.servicios:
|
||||
limpiar_servicios(models, uid, purge_citas=args.purge_citas, use_sql=args.purge_sql)
|
||||
rows_srv = leer_csv(args.servicios)
|
||||
importar_servicios(models, uid, rows_srv)
|
||||
|
||||
if args.generar_hermes:
|
||||
generar_hermes_services(models, uid)
|
||||
|
||||
print("[OK] Proceso completado.")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
43
migracion/inspeccionar_expediente.py
Normal file
43
migracion/inspeccionar_expediente.py
Normal file
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env python3
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
BASE = 'https://sistema.skeenmx.app'
|
||||
USERNAME = 'JoseSkeen'
|
||||
PASSWORD = 'SKr#902_88'
|
||||
|
||||
session = requests.Session()
|
||||
r = session.get(f'{BASE}/login', timeout=30)
|
||||
soup = BeautifulSoup(r.text, 'html.parser')
|
||||
token = soup.find('input', {'name': '_token'})
|
||||
token = token['value'] if token else ''
|
||||
|
||||
r = session.post(f'{BASE}/login', data={
|
||||
'_token': token,
|
||||
'username': USERNAME,
|
||||
'password': PASSWORD,
|
||||
}, timeout=30, allow_redirects=True)
|
||||
|
||||
r = session.get(f'{BASE}/expediente/ver/1', timeout=30)
|
||||
soup = BeautifulSoup(r.text, 'html.parser')
|
||||
|
||||
# Extraer tablas con th/td
|
||||
for table in soup.find_all('table'):
|
||||
for tr in table.find_all('tr'):
|
||||
ths = tr.find_all('th')
|
||||
tds = tr.find_all('td')
|
||||
if ths and tds and len(ths) == len(tds):
|
||||
for th, td in zip(ths, tds):
|
||||
key = ' '.join(th.get_text(strip=True).split())
|
||||
val = ' '.join(td.get_text(strip=True).split())
|
||||
if key and val:
|
||||
print(f'{key}: {val}')
|
||||
print('--- FIN TABLA ---')
|
||||
|
||||
# Buscar inputs/checkboxes
|
||||
for inp in soup.find_all(['input', 'textarea', 'select']):
|
||||
name = inp.get('name') or inp.get('id') or ''
|
||||
val = inp.get('value') or ''
|
||||
checked = inp.get('checked')
|
||||
if name:
|
||||
print(f'INPUT {name}: value={val} checked={checked}')
|
||||
29
migracion/inspeccionar_expediente2.py
Normal file
29
migracion/inspeccionar_expediente2.py
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env python3
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
import re
|
||||
|
||||
BASE = 'https://sistema.skeenmx.app'
|
||||
USERNAME = 'JoseSkeen'
|
||||
PASSWORD = 'SKr#902_88'
|
||||
|
||||
session = requests.Session()
|
||||
r = session.get(f'{BASE}/login', timeout=30)
|
||||
soup = BeautifulSoup(r.text, 'html.parser')
|
||||
token = soup.find('input', {'name': '_token'})
|
||||
token = token['value'] if token else ''
|
||||
|
||||
session.post(f'{BASE}/login', data={
|
||||
'_token': token,
|
||||
'username': USERNAME,
|
||||
'password': PASSWORD,
|
||||
}, timeout=30, allow_redirects=True)
|
||||
|
||||
r = session.get(f'{BASE}/expediente/ver/1', timeout=30)
|
||||
text = r.text
|
||||
|
||||
# Encontrar todas las etiquetas de formulario
|
||||
for m in re.finditer(r'<label[^>]*>(.*?)</label>', text, re.S):
|
||||
label = re.sub(r'<[^>]+>', '', m.group(1)).strip()
|
||||
if label:
|
||||
print(f'LABEL: {label}')
|
||||
38
migracion/inspeccionar_expediente3.py
Normal file
38
migracion/inspeccionar_expediente3.py
Normal file
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env python3
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
import re
|
||||
|
||||
BASE = 'https://sistema.skeenmx.app'
|
||||
USERNAME = 'JoseSkeen'
|
||||
PASSWORD = 'SKr#902_88'
|
||||
|
||||
session = requests.Session()
|
||||
r = session.get(f'{BASE}/login', timeout=30)
|
||||
soup = BeautifulSoup(r.text, 'html.parser')
|
||||
token = soup.find('input', {'name': '_token'})
|
||||
token = token['value'] if token else ''
|
||||
|
||||
session.post(f'{BASE}/login', data={
|
||||
'_token': token,
|
||||
'username': USERNAME,
|
||||
'password': PASSWORD,
|
||||
}, timeout=30, allow_redirects=True)
|
||||
|
||||
# Probar varios IDs directamente
|
||||
for pid in [1, 2, 3, 5, 10, 20, 50, 100, 200, 500, 1000]:
|
||||
try:
|
||||
r = session.get(f'{BASE}/expediente/ver/{pid}', timeout=30)
|
||||
soup = BeautifulSoup(r.text, 'html.parser')
|
||||
text = soup.get_text(separator='\n')
|
||||
if any(k in text.lower() for k in ['riñón', 'rinon', 'embarazada', 'alergias', 'medicamentos', 'cirugías']):
|
||||
print(f'\n=== PACIENTE {pid} tiene historia clínica ===')
|
||||
lines = [l.strip() for l in text.split('\n') if l.strip()]
|
||||
for i, line in enumerate(lines):
|
||||
if any(k in line.lower() for k in ['riñón', 'rinon', 'embarazada', 'alergias', 'medicamentos', 'cirugías', 'antecedentes', 'patológicos', 'historia']):
|
||||
print(f' {i}: {line}')
|
||||
for j in range(max(0, i-3), min(len(lines), i+6)):
|
||||
print(f' {j}: {lines[j]}')
|
||||
print('---')
|
||||
except Exception as e:
|
||||
print(f'Error {pid}: {e}')
|
||||
33
migracion/inspeccionar_expediente4.py
Normal file
33
migracion/inspeccionar_expediente4.py
Normal file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
import re
|
||||
|
||||
BASE = 'https://sistema.skeenmx.app'
|
||||
USERNAME = 'JoseSkeen'
|
||||
PASSWORD = 'SKr#902_88'
|
||||
|
||||
session = requests.Session()
|
||||
r = session.get(f'{BASE}/login', timeout=30)
|
||||
soup = BeautifulSoup(r.text, 'html.parser')
|
||||
token = soup.find('input', {'name': '_token'})
|
||||
token = token['value'] if token else ''
|
||||
|
||||
session.post(f'{BASE}/login', data={
|
||||
'_token': token,
|
||||
'username': USERNAME,
|
||||
'password': PASSWORD,
|
||||
}, timeout=30, allow_redirects=True)
|
||||
|
||||
# Paciente 5 tiene historia clínica más completa
|
||||
r = session.get(f'{BASE}/expediente/ver/5', timeout=30)
|
||||
soup = BeautifulSoup(r.text, 'html.parser')
|
||||
|
||||
# Buscar divs/cards con clase que contengan historia clínica
|
||||
# Imprimir estructura de inputs/checkboxes con sus labels cercanos
|
||||
for div in soup.find_all(['div', 'section']):
|
||||
text = div.get_text(separator=' ', strip=True)
|
||||
if any(k in text.lower() for k in ['embarazada', 'lactancia', 'anticonceptivo', 'hijos', 'cirugías', 'varicosas', 'migraña', 'agujas', 'alergias', 'medicamentos', 'enfermedades crónicas']):
|
||||
print('---DIV---')
|
||||
print(text[:1000])
|
||||
print('---')
|
||||
6
migracion/inventario_ejemplo.csv
Normal file
6
migracion/inventario_ejemplo.csv
Normal file
@@ -0,0 +1,6 @@
|
||||
name,kind,sku,category,unit,qty,qty_optimal,qty_min,cost,expiry_date,notes
|
||||
Suero Vitamina C,producto,PRD-001,Skincare,pieza,24,30,5,320,2027-06-15,Suero antioxidante
|
||||
Protector Solar SPF 50,producto,PRD-002,Skincare,pieza,18,25,5,280,2027-08-20,Protector solar facial
|
||||
Acido Hialuronico,consumible,CON-001,Inyecciones,ampolla,45,60,15,120,2026-12-01,Para rellenos
|
||||
Toxina Botulinica,consumible,CON-002,Inyecciones,unidad,12,20,5,850,2026-11-30,Unidades de toxina
|
||||
Guantes Estériles,consumible,CON-003,Instrumental,caja,8,15,3,95,,Caja de 100 unidades
|
||||
|
121
migracion/limpiar_nombres.py
Normal file
121
migracion/limpiar_nombres.py
Normal file
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Limpieza de nombres de pacientes (reversible).
|
||||
|
||||
Pasos:
|
||||
1) Respaldo CSV id -> nombre original.
|
||||
2) Normaliza: control chars, frases marcador (no usar exp...), barras, espacios, signos.
|
||||
3) Title Case con particulas en minuscula (de, del, de la, los, las, y, etc.).
|
||||
4) Dry-run por defecto; con --apply actualiza solo las filas que cambian.
|
||||
"""
|
||||
import csv
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import unicodedata
|
||||
from datetime import datetime
|
||||
|
||||
import psycopg2
|
||||
|
||||
DSN = "host=localhost dbname=skeen_odoo user=skeen password=skeen_dev_2026"
|
||||
|
||||
PARTICULAS = {
|
||||
'de', 'del', 'la', 'las', 'los', 'y', 'e', 'da', 'do', 'das', 'dos',
|
||||
'di', 'van', 'von', 'der', 'den', 'san', 'santa', 'mc',
|
||||
}
|
||||
|
||||
# Frases marcador inyectadas por el personal dentro del nombre
|
||||
MARKER_RE = re.compile(
|
||||
r'\bno\s*usar(\s+(este\s+)?exp(?:ediente)?)?\b[!.,;:/\\-]*',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
ERROR_RE = re.compile(r'\berror\b[!.,;:/\\-]*', re.IGNORECASE)
|
||||
SLASHES_RE = re.compile(r'[/\\]+')
|
||||
MULTISPACE_RE = re.compile(r'\s{2,}')
|
||||
LEAD_TRAIL_JUNK_RE = re.compile(r'^[\s.\-_,;:]+|[\s.\-_,;:]+$')
|
||||
CONTROL_RE = re.compile(r'[\x00-\x1f\x7f]')
|
||||
|
||||
|
||||
def cap_token(tok, first):
|
||||
low = tok.lower()
|
||||
if not first and low in PARTICULAS:
|
||||
return low
|
||||
# Mantener siglas cortas (iniciales) en mayuscula: J, GPE -> J, Gpe
|
||||
if len(tok) <= 1:
|
||||
return tok.upper()
|
||||
return low.capitalize()
|
||||
|
||||
|
||||
def clean_name(raw):
|
||||
if raw is None:
|
||||
return raw
|
||||
s = CONTROL_RE.sub('', raw)
|
||||
s = SLASHES_RE.sub(' ', s) # barras -> espacio
|
||||
s = MARKER_RE.sub(' ', s) # quita "no usar exp..."
|
||||
s = ERROR_RE.sub(' ', s) # quita "ERROR" suelto
|
||||
s = s.replace('.', ' ') # puntos (iniciales, GPE.) -> espacio
|
||||
s = MULTISPACE_RE.sub(' ', s)
|
||||
s = LEAD_TRAIL_JUNK_RE.sub('', s).strip()
|
||||
s = MULTISPACE_RE.sub(' ', s)
|
||||
if not s:
|
||||
return s
|
||||
toks = s.split(' ')
|
||||
toks = [cap_token(t, i == 0) for i, t in enumerate(toks) if t]
|
||||
return ' '.join(toks)
|
||||
|
||||
|
||||
def main():
|
||||
apply = '--apply' in sys.argv
|
||||
conn = psycopg2.connect(DSN)
|
||||
conn.autocommit = False
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT id, name FROM res_partner WHERE is_patient ORDER BY id")
|
||||
rows = cur.fetchall()
|
||||
|
||||
changes = []
|
||||
for rid, name in rows:
|
||||
new = clean_name(name)
|
||||
if new != name:
|
||||
changes.append((rid, name, new))
|
||||
|
||||
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
backup_path = f'/root/migracion/datos/name_backup_{ts}.csv'
|
||||
with open(backup_path, 'w', newline='', encoding='utf-8') as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow(['id', 'name_original'])
|
||||
for rid, name, _ in changes:
|
||||
w.writerow([rid, name])
|
||||
|
||||
print(f'Total pacientes: {len(rows)}')
|
||||
print(f'A modificar: {len(changes)}')
|
||||
print(f'Sin cambios: {len(rows) - len(changes)}')
|
||||
print(f'Respaldo: {backup_path}')
|
||||
print()
|
||||
print('--- Ejemplos antes -> despues (30) ---')
|
||||
shown = 0
|
||||
# prioriza los que tenian marcador o signos
|
||||
def score(c):
|
||||
n = c[1] or ''
|
||||
return (('no usar' in n.lower()) * 10) + (('/' in n) * 5) + ((' ' in n) * 2)
|
||||
for rid, old, new in sorted(changes, key=score, reverse=True)[:30]:
|
||||
print(f' [{rid}] "{old}" -> "{new}"')
|
||||
shown += 1
|
||||
print(f' ({shown} ejemplos)')
|
||||
|
||||
if not apply:
|
||||
print('\nDRY-RUN: no se aplicaron cambios. Ejecuta con --apply para aplicar.')
|
||||
conn.rollback()
|
||||
cur.close(); conn.close()
|
||||
return
|
||||
|
||||
updated = 0
|
||||
for rid, old, new in changes:
|
||||
cur.execute("UPDATE res_partner SET name = %s WHERE id = %s", (new, rid))
|
||||
updated += 1
|
||||
conn.commit()
|
||||
print(f'\nAPLICADO: {updated} nombres actualizados.')
|
||||
cur.close(); conn.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
43
migracion/postprocesar_citas.py
Executable file
43
migracion/postprocesar_citas.py
Executable file
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Post-procesa citas.csv para mapear IDs numéricos de médicos a nombres reales.
|
||||
"""
|
||||
import csv
|
||||
from pathlib import Path
|
||||
|
||||
DATA_DIR = Path('/root/migracion/datos')
|
||||
|
||||
medicos_map = {
|
||||
'32': 'Dra. Alejandra Ramos',
|
||||
'33': 'Dra. Lidia Martinez',
|
||||
'46': 'Dra. Fernanda Cerecer',
|
||||
'49': 'RODRIGUEZ FRIDA',
|
||||
'54': 'Dr. Benjamín Adrián',
|
||||
'79': 'XIMENA',
|
||||
'80': 'ELY',
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
path = DATA_DIR / 'citas.csv'
|
||||
if not path.exists():
|
||||
print(f'No existe {path}')
|
||||
return
|
||||
rows = []
|
||||
with open(path, newline='', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
doctor = (row.get('doctor_nombre') or '').strip()
|
||||
if doctor in medicos_map:
|
||||
row['doctor_nombre'] = medicos_map[doctor]
|
||||
rows.append(row)
|
||||
with open(path, 'w', newline='', encoding='utf-8') as f:
|
||||
writer = csv.DictWriter(f, fieldnames=rows[0].keys())
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
print(f'Post-procesadas {len(rows)} citas')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
35
migracion/recalcular_citas.py
Normal file
35
migracion/recalcular_citas.py
Normal file
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Recalcula campos compute de skeen.cita tras importación masiva."""
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, '/root/skeen-odoo')
|
||||
|
||||
import odoo
|
||||
from odoo.tools import config
|
||||
|
||||
config.parse_config([
|
||||
'-c', '/root/skeen-odoo/odoo.conf',
|
||||
'--no-http',
|
||||
'--log-level', 'error',
|
||||
])
|
||||
|
||||
odoo.service.server.load_server_wide_modules()
|
||||
registry = odoo.registry(config['db_name'])
|
||||
|
||||
with registry.cursor() as cr:
|
||||
env = odoo.api.Environment(cr, odoo.SUPERUSER_ID, {})
|
||||
Citas = env['skeen.cita']
|
||||
total = Citas.search_count([])
|
||||
print(f'Total citas: {total}')
|
||||
|
||||
# Recalcular campos compute para todas las citas
|
||||
todas = Citas.search([])
|
||||
todas._compute_datetime()
|
||||
todas._compute_time_str()
|
||||
cr.commit()
|
||||
|
||||
nulls = Citas.search_count([('datetime_start', '=', False)])
|
||||
print(f'Citas con datetime_start NULL tras recálculo: {nulls}')
|
||||
|
||||
print('Recálculo completado.')
|
||||
28
migracion/reset_completo.py
Normal file
28
migracion/reset_completo.py
Normal file
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Reset completo de datos migrados (pacientes, citas, ventas, pagos, monederos y servicios genéricos)."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
ODOO_PATH = Path('/root/skeen-odoo')
|
||||
sys.path.insert(0, str(ODOO_PATH))
|
||||
import odoo
|
||||
from odoo import api, SUPERUSER_ID
|
||||
odoo.tools.config.parse_config(['-c', '/root/skeen-odoo/odoo.conf'])
|
||||
db = odoo.sql_db.db_connect('skeen_odoo')
|
||||
with db.cursor() as cr:
|
||||
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||
print('Borrando citas...')
|
||||
env['skeen.cita'].sudo().search([]).unlink()
|
||||
print('Borrando ventas...')
|
||||
env['skeen.venta'].sudo().search([]).unlink()
|
||||
print('Borrando pagos...')
|
||||
env['skeen.pago'].sudo().search([]).unlink()
|
||||
print('Borrando transacciones de monedero...')
|
||||
env['skeen.monedero.transaction'].sudo().search([]).unlink()
|
||||
print('Borrando monederos...')
|
||||
env['skeen.monedero'].sudo().search([]).unlink()
|
||||
print('Borrando pacientes...')
|
||||
env['res.partner'].sudo().search([('is_patient', '=', True)]).unlink()
|
||||
print('Borrando servicios genéricos (HIST_)...')
|
||||
env['skeen.servicio'].sudo().search([('code', 'like', 'HIST_%')]).unlink()
|
||||
cr.commit()
|
||||
print('Datos reseteados completamente')
|
||||
18
migracion/reset_pacientes.py
Normal file
18
migracion/reset_pacientes.py
Normal file
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env python3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
ODOO_PATH = Path('/root/skeen-odoo')
|
||||
sys.path.insert(0, str(ODOO_PATH))
|
||||
import odoo
|
||||
from odoo import api, SUPERUSER_ID
|
||||
odoo.tools.config.parse_config(['-c', '/root/skeen-odoo/odoo.conf'])
|
||||
db = odoo.sql_db.db_connect('skeen_odoo')
|
||||
with db.cursor() as cr:
|
||||
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||
env['skeen.cita'].sudo().search([]).unlink()
|
||||
env['skeen.venta'].sudo().search([]).unlink()
|
||||
env['skeen.pago'].sudo().search([]).unlink()
|
||||
env['skeen.monedero'].sudo().search([]).unlink()
|
||||
env['res.partner'].sudo().search([('is_patient', '=', True)]).unlink()
|
||||
cr.commit()
|
||||
print('Datos reseteados')
|
||||
8
migracion/servicios_ejemplo.csv
Normal file
8
migracion/servicios_ejemplo.csv
Normal file
@@ -0,0 +1,8 @@
|
||||
name,code,category,price,duration_min,package_price,package_sessions,package_notes,description,service_group,color,is_favorite
|
||||
Consulta Dermatologica,CONS-DERM,consulta,800,30,0,0,,Evaluacion completa de la piel con diagnostico y plan de tratamiento personalizado.,Consultas,#1abc9c,true
|
||||
Consulta de Valoracion,CONS-VAL,consulta,0,15,0,0,,Evaluacion breve para recomendar el tratamiento mas adecuado. Sin compromiso.,Consultas,#1abc9c,false
|
||||
Tratamiento Facial para Acne,ACNE-FAC,tratamiento,1500,60,0,0,,Limpieza profunda extraccion de comedones y mascarilla calmante.,Faciales,#3498db,false
|
||||
Peel Quimico,PEEL-QUI,tratamiento,1500,30,0,0,,Exfoliacion quimica para renovar la piel tratar manchas y mejorar textura.,Faciales,#9b59b6,false
|
||||
Depilacion Laser Axila,LASER-AXI,tratamiento,500,15,0,0,,Depilacion laser por zona pequena.,Corporal,#e74c3c,false
|
||||
Depilacion Laser Full Body,LASER-FUL,tratamiento,2500,90,7000,3,Ahorro de $500,Depilacion laser full body. Precio por sesion o paquete de 3 sesiones.,Corporal,#e74c3c,true
|
||||
Tratamiento Anti-Edad,ANTI-EDAD,tratamiento,2500,75,6500,3,Ahorro de $1000,Combinacion de microdermoabrasion vitaminas y mascarilla reafirmante.,Faciales,#f39c12,false
|
||||
|
125
migracion/sync_odoo_to_wacrm.py
Executable file
125
migracion/sync_odoo_to_wacrm.py
Executable file
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Sincroniza pacientes de Odoo → WACRM (Supabase self-hosted).
|
||||
Inserta/actualiza contactos en la tabla public.contacts.
|
||||
|
||||
Uso:
|
||||
source /root/odoo-venv/bin/activate && python3 /root/migracion/sync_odoo_to_wacrm.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ODOO_PATH = Path('/root/skeen-odoo')
|
||||
sys.path.insert(0, str(ODOO_PATH))
|
||||
|
||||
import odoo
|
||||
from odoo import api, SUPERUSER_ID
|
||||
import psycopg2
|
||||
from psycopg2.extras import execute_values
|
||||
|
||||
# Configuración de Supabase / WACRM
|
||||
SUPABASE_HOST = 'localhost'
|
||||
SUPABASE_PORT = 6543
|
||||
SUPABASE_USER = 'postgres.your-tenant-id'
|
||||
SUPABASE_PASSWORD = '8bbf201d9d60488a1e86e5396b31e22d'
|
||||
SUPABASE_DB = 'postgres'
|
||||
|
||||
WACRM_ACCOUNT_ID = 'a2dc0db3-dea0-4bd8-8733-1d62975c5f89'
|
||||
WACRM_OWNER_USER_ID = '00000000-0000-0000-0000-000000000001'
|
||||
|
||||
|
||||
def get_odoo_env(database='skeen_odoo'):
|
||||
odoo.tools.config.parse_config(['-c', '/root/skeen-odoo/odoo.conf'])
|
||||
db = odoo.sql_db.db_connect(database)
|
||||
cr = db.cursor()
|
||||
return api.Environment(cr, SUPERUSER_ID, {}), cr
|
||||
|
||||
|
||||
def get_wacrm_connection():
|
||||
return psycopg2.connect(
|
||||
host=SUPABASE_HOST,
|
||||
port=SUPABASE_PORT,
|
||||
user=SUPABASE_USER,
|
||||
password=SUPABASE_PASSWORD,
|
||||
dbname=SUPABASE_DB,
|
||||
)
|
||||
|
||||
|
||||
def normalize_phone(phone):
|
||||
digits = ''.join(c for c in (phone or '') if c.isdigit())
|
||||
if digits and not digits.startswith('52') and len(digits) == 10:
|
||||
digits = '52' + digits
|
||||
return digits
|
||||
|
||||
|
||||
def sync_contacts(limit=None):
|
||||
env, cr = get_odoo_env()
|
||||
try:
|
||||
domain = [('is_patient', '=', True), ('phone', '!=', False), ('phone', '!=', '')]
|
||||
partners = env['res.partner'].sudo().search(domain)
|
||||
if limit:
|
||||
partners = partners[:limit]
|
||||
|
||||
rows = []
|
||||
for p in partners:
|
||||
phone = normalize_phone(p.phone)
|
||||
if not phone:
|
||||
continue
|
||||
rows.append((
|
||||
WACRM_OWNER_USER_ID,
|
||||
phone,
|
||||
p.name or 'Paciente',
|
||||
p.email or '',
|
||||
'', # company
|
||||
'', # avatar_url
|
||||
WACRM_ACCOUNT_ID,
|
||||
))
|
||||
|
||||
if not rows:
|
||||
print('No hay pacientes para sincronizar')
|
||||
return
|
||||
|
||||
conn = get_wacrm_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# UPSERT por (account_id, phone_normalized)
|
||||
execute_values(cur, """
|
||||
INSERT INTO public.contacts
|
||||
(user_id, phone, name, email, company, avatar_url, account_id)
|
||||
VALUES %s
|
||||
ON CONFLICT (account_id, phone_normalized) WHERE phone_normalized <> ''
|
||||
DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
email = EXCLUDED.email,
|
||||
company = EXCLUDED.company,
|
||||
avatar_url = EXCLUDED.avatar_url,
|
||||
user_id = EXCLUDED.user_id,
|
||||
updated_at = NOW()
|
||||
RETURNING id, phone
|
||||
""", rows, page_size=1000)
|
||||
|
||||
result = cur.fetchall()
|
||||
phone_to_id = {phone: str(cid) for cid, phone in result}
|
||||
|
||||
# Actualizar referencia en Odoo
|
||||
for p in partners:
|
||||
phone = normalize_phone(p.phone)
|
||||
if phone in phone_to_id:
|
||||
p.write({
|
||||
'wacrm_synced': True,
|
||||
'wacrm_contact_id': phone_to_id[phone],
|
||||
})
|
||||
|
||||
conn.commit()
|
||||
print(f'Sincronizados {len(rows)} contactos a WACRM')
|
||||
finally:
|
||||
conn.close()
|
||||
finally:
|
||||
cr.commit()
|
||||
cr.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sync_contacts()
|
||||
Reference in New Issue
Block a user