Mejoras integrales: visitas, POS, WACRM, reportes, temas y módulos legacy
- Módulo Visitas completo (auto desde agenda, insumos con descargo de inventario, fotos antes/después y documentos, receta imprimible) - Punto de Venta (catálogo + ticket sticky, cobro con cambio, pago con puntos monedero, ticket imprimible) - WACRM: leads automáticos desde WhatsApp, asignación de conversaciones y leads a agentes, conversión lead→paciente, ficha del paciente en chat - Pacientes: completitud de expediente, alertas clínicas, historial unificado con detalle, foto, WhatsApp, estado de cuenta, filtros rápidos (VIP/recientes/médico), documentos (expediente escaneado + galería) - Agenda: vistas por médico y por hora, filtros rápidos (libres, primera vez, check-in, no-show), modal de acciones, bloqueos por médico, drag&drop para mover citas - Reportes: 18 pestañas (diario, cortes, ingresos, inventario, adeudos, comisiones, pagos, devoluciones, top clientes, horas, paquetes, vendedores, concentrado, recomendaciones, KPIs) con exportación Excel - Temas: nuevo tema Clásico (look legacy AdminLTE) con submenús tipo treeview, selector de tema; accesos rápidos personalizables con 3 presentaciones; búsqueda global; notificaciones reales - Configuración: secciones (clínica, usuarios con permisos por sección, recetas, catálogos de diagnósticos y procedimientos) - Inventario: alertas de caducidad y sugerencia de compra, cron diario que descuenta artículos caducados, compras/bajas - Consultas Médicas, página Expedientes, importadores delta (citas/visitas legacy idempotentes), depuración de duplicados - Infra: tema Tailwind conectado (@config), gzip en nginx, secuencias Odoo corregidas (noupdate, company_id), rollback en validaciones
This commit is contained in:
137
migracion/detectar_duplicados.py
Normal file
137
migracion/detectar_duplicados.py
Normal file
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Detecta duplicados de pacientes por telefono + similitud de nombre.
|
||||
Solo lee; genera /root/migracion/reporte_duplicados.csv para revision."""
|
||||
|
||||
import csv
|
||||
import re
|
||||
import unicodedata
|
||||
import difflib
|
||||
import xmlrpc.client
|
||||
|
||||
c = xmlrpc.client.ServerProxy('http://localhost:8069/xmlrpc/2/common')
|
||||
uid = c.authenticate('skeen_odoo', 'admin', 'skeen_admin_2026', {})
|
||||
m = xmlrpc.client.ServerProxy('http://localhost:8069/xmlrpc/2/object')
|
||||
|
||||
|
||||
def kw(model, method, args, kwar=None):
|
||||
return m.execute_kw('skeen_odoo', uid, 'skeen_admin_2026', model, method, args, kwar or {})
|
||||
|
||||
|
||||
def norm_name(s):
|
||||
s = unicodedata.normalize('NFKD', s or '')
|
||||
s = ''.join(ch for ch in s if not unicodedata.combining(ch))
|
||||
s = re.sub(r'[^a-z0-9 ]', ' ', s.lower())
|
||||
return ' '.join(sorted(s.split())) # orden independiente
|
||||
|
||||
|
||||
def norm_phone(p):
|
||||
return re.sub(r'\D', '', p or '')
|
||||
|
||||
|
||||
FIELDS = ['id', 'name', 'phone', 'email', 'birth_date', 'legacy_id',
|
||||
'total_visits', 'last_visit', 'create_date', 'active']
|
||||
|
||||
patients = []
|
||||
offset = 0
|
||||
while True:
|
||||
batch = kw('res.partner', 'search_read',
|
||||
[[['is_patient', '=', True]]],
|
||||
{'fields': FIELDS, 'limit': 1000, 'offset': offset, 'order': 'id'})
|
||||
if not batch:
|
||||
break
|
||||
patients.extend(batch)
|
||||
offset += len(batch)
|
||||
print(f'Pacientes leidos: {len(patients)}')
|
||||
|
||||
# Agrupar por telefono
|
||||
by_phone = {}
|
||||
for p in patients:
|
||||
ph = norm_phone(p['phone'])
|
||||
if ph:
|
||||
by_phone.setdefault(ph, []).append(p)
|
||||
|
||||
reporte = []
|
||||
grupos_dup = 0
|
||||
grupos_familia = 0
|
||||
for phone, group in sorted(by_phone.items()):
|
||||
if len(group) < 2:
|
||||
continue
|
||||
names = [norm_name(p['name']) for p in group]
|
||||
# similitud maxima dentro del grupo
|
||||
max_ratio = 0.0
|
||||
for i in range(len(names)):
|
||||
for j in range(i + 1, len(names)):
|
||||
max_ratio = max(max_ratio, difflib.SequenceMatcher(None, names[i], names[j]).ratio())
|
||||
tipo = 'duplicado_probable' if max_ratio >= 0.80 else 'telefono_compartido'
|
||||
if tipo == 'duplicado_probable':
|
||||
grupos_dup += 1
|
||||
else:
|
||||
grupos_familia += 1
|
||||
# candidato a conservar: mas visitas, luego mas antiguo
|
||||
canon = sorted(group, key=lambda p: (-(p['total_visits'] or 0), p['create_date']))[0]
|
||||
for p in group:
|
||||
reporte.append({
|
||||
'telefono': phone,
|
||||
'tipo': tipo,
|
||||
'similitud_nombre': f'{max_ratio:.2f}',
|
||||
'conservar': 'SI' if p['id'] == canon['id'] else '',
|
||||
'id': p['id'],
|
||||
'nombre': p['name'],
|
||||
'email': p['email'] or '',
|
||||
'fecha_nac': p['birth_date'] or '',
|
||||
'visitas': p['total_visits'] or 0,
|
||||
'ultima_visita': p['last_visit'] or '',
|
||||
'legacy_id': p['legacy_id'] or '',
|
||||
'creado': p['create_date'],
|
||||
})
|
||||
|
||||
# Nombres identicos con telefonos distintos (posible duplicado extra)
|
||||
by_name = {}
|
||||
for p in patients:
|
||||
n = norm_name(p['name'])
|
||||
if len(n) >= 8:
|
||||
by_name.setdefault(n, []).append(p)
|
||||
extra = 0
|
||||
for name, group in by_name.items():
|
||||
phones = {norm_phone(p['phone']) for p in group}
|
||||
if len(group) > 1 and len(phones) > 1:
|
||||
extra += 1
|
||||
canon = sorted(group, key=lambda p: (-(p['total_visits'] or 0), p['create_date']))[0]
|
||||
for p in group:
|
||||
reporte.append({
|
||||
'telefono': norm_phone(p['phone']),
|
||||
'tipo': 'mismo_nombre_distinto_telefono',
|
||||
'similitud_nombre': '1.00',
|
||||
'conservar': 'SI' if p['id'] == canon['id'] else '',
|
||||
'id': p['id'],
|
||||
'nombre': p['name'],
|
||||
'email': p['email'] or '',
|
||||
'fecha_nac': p['birth_date'] or '',
|
||||
'visitas': p['total_visits'] or 0,
|
||||
'ultima_visita': p['last_visit'] or '',
|
||||
'legacy_id': p['legacy_id'] or '',
|
||||
'creado': p['create_date'],
|
||||
})
|
||||
|
||||
out = '/root/migracion/reporte_duplicados.csv'
|
||||
with open(out, 'w', encoding='utf-8', newline='') as f:
|
||||
w = csv.DictWriter(f, fieldnames=['telefono', 'tipo', 'similitud_nombre', 'conservar',
|
||||
'id', 'nombre', 'email', 'fecha_nac', 'visitas',
|
||||
'ultima_visita', 'legacy_id', 'creado'])
|
||||
w.writeheader()
|
||||
w.writerows(reporte)
|
||||
|
||||
dup_rows = [r for r in reporte if r['tipo'] == 'duplicado_probable']
|
||||
print(f'\nGrupos por telefono con duplicado probable: {grupos_dup} ({len(dup_rows)} registros)')
|
||||
print(f'Grupos telefono compartido (familia): {grupos_familia}')
|
||||
print(f'Grupos mismo nombre, distinto telefono: {extra}')
|
||||
print(f'\nReporte: {out} ({len(reporte)} filas)')
|
||||
print('\nMuestra de duplicados probables:')
|
||||
shown = set()
|
||||
for r in dup_rows[:20]:
|
||||
key = r['telefono']
|
||||
if key not in shown:
|
||||
print(f" tel {key}:")
|
||||
shown.add(key)
|
||||
print(f" [{'CONSERVAR' if r['conservar'] else 'fusionar '}] id={r['id']} {r['nombre']} (visitas={r['visitas']})")
|
||||
Reference in New Issue
Block a user