Migración de clientes al ERP: limpieza, validación y duplicados
Prepara los clientes de un programa antiguo para importarlos en el ERP: normaliza nombres, NIF, emails y teléfonos, valida DNI, NIE y CIF con sus dígitos de control, fusiona duplicados por NIF o email completando los huecos y genera el CSV de importación con un informe. ETL con regex.
- Migración de datos (ETL)
- Normalización
- Dígito de control de DNI, NIE y CIF
- Detección de duplicados
- Expresiones regulares
- CSV
Enunciado
La parte más ingrata (y más importante) de implantar un ERP es la migración de datos: lo que viene del programa antiguo está lleno de espacios de más, NIF con puntos y guiones, emails en mayúsculas, teléfonos escritos de mil maneras y el mismo cliente dado de alta dos veces. Si se importa tal cual, el ERP nace sucio: facturas con NIF incorrectos, clientes duplicados con su historial partido en dos.
Antes de importar se limpia: se normaliza cada campo, se valida el NIF con su dígito de control, se fusionan los duplicados y se rechaza lo que no tiene arreglo, dejando un informe para revisarlo a mano.
Qué tiene que hacer el programa
- La entrada es un CSV con cabecera
codigo;nombre;nif;email;telefono;poblacion. Una fila con otro número de campos:Rechazado: «fila» no tiene 6 campos. - Normalización: espacios repetidos y de los extremos fuera en el nombre y la población (la población, además, con mayúscula inicial en cada palabra,
str.title, salvo las palabras de enlace de, del, la, las, los, el e y, que van en minúscula si no son la primera); NIF sin espacios, puntos ni guiones y en mayúsculas; email sin espacios y en minúsculas; teléfono sin espacios, puntos, paréntesis ni guiones, con+34delante si son 9 cifras que empiezan por 6, 7, 8 o 9, y0034…como+34…. - Validación del NIF: DNI (8 cifras y la letra
TRWAGMYFPDXBNJZSQVHLCKE[número % 23]); NIE (X, Y o Z como 0, 1 o 2 delante de las 7 cifras, misma letra); CIF (letra deABCDEFGHJNPQRSUVW, 7 cifras y un control). Control del CIF: suma de las cifras en posiciones pares (2.ª, 4.ª y 6.ª) más, por cada cifra impar (1.ª, 3.ª, 5.ª y 7.ª), la suma de los dígitos de su doble; control = (10 − suma % 10) % 10; con P, Q, R, S, N o W va la letraJABCDEFGHI[control]; con A, B, E o H, el dígito; con las demás vale cualquiera de los dos. Si no es válido:Rechazado código (nombre): NIF «X» no válido. - Email (si no está vacío) que no encaje con
algo@algo.dominio:Aviso código: email «x» no válido, se deja vacío. Teléfono (si no está vacío) que no quede como+y de 9 a 13 cifras:Aviso código: teléfono «x» no válido, se deja vacío. - Duplicados: un cliente con el mismo NIF que uno ya aceptado, o con el mismo email (no vacío), se fusiona en el primero: los campos email, teléfono y población vacíos del primero se completan con los del duplicado.
Duplicado código → código_original (mismo NIF|mismo email)y, si se completó algo,; se completa email, telefono. - Después:
--- Clientes para importar ---, la cabecera y los clientes aceptados ordenados por NIF. Última línea:Leídos L · importados I · fusionados F · rechazados R · normalizados N, donde N cuenta las filas de 6 campos en las que la normalización cambió algún campo.
Entrada
Un CSV separado por ; con la cabecera codigo;nombre;nif;email;telefono;poblacion.
Ejemplos de ejecución
Tu programa debe escribir exactamente esta salida para estas entradas. Las pruebas del editor incluyen estos ejemplos y otros casos ocultos.
Clientes de un programa antiguo
Entrada
codigo;nombre;nif;email;telefono;poblacion C001; Talleres Ruiz S.L. ;B-12345674;INFO@TALLERESRUIZ.ES;912 345 678;madrid C002;Ana Gil;12.345.678-z;ana.gil@correo.es;600-11-22-33; alcalá de henares C003;Luis Mora;x1234567l;luis@correo;0034 699 123 456;Getafe C004;Ana Gil Pérez;12345678Z;;;Alcalá C005;Hotel Mar;A58818501;reservas@hotelmar.com;;Valencia C006;Pedro;12345678A;pedro@x.es;611222333;Toledo
Salida por consola
Aviso C003: email «luis@correo» no v álido, se deja vacío Duplicado C004 → C002 (mismo NIF) Rechazado C006 (Pedro): NIF «12345678A» no válido --- Clientes para importar --- codigo;nombre;nif;email;telefono;poblacion C002;Ana Gil;12345678Z;ana.gil@correo.es;+34600112233;Alcalá de Henares C005;Hotel Mar;A58818501;reservas@hotelmar.com;;Valencia C001;Talleres Ruiz S.L.;B12345674;info@talleresruiz.es;+34912345678;Madrid C003;Luis Mora;X1234567L;;+34699123456;Getafe Leídos 6 · importados 4 · fusionados 1 · rechazados 1 · normalizados 4
Duplicado por email y CIF con letra
Entrada
codigo;nombre;nif;email;telefono;poblacion D1;Eva Sanz;00000023T;eva@sanz.es;;Sevilla D2;Eva S.;Y2345678Z;eva@sanz.es;955 11 22 33; D3;Club Náutico;Q2826004J;club@nautico.org;12345;Cádiz D4;fila mal;sin campos
Salida por consola
Duplicado D2 → D1 (mismo email); se completa telefono Aviso D3: teléfono «12345» no válido, se deja vacío Rechazado: «D4;fila mal;sin campos» no tiene 6 campos --- Clientes para importar --- codigo;nombre;nif;email;telefono;poblacion D1;Eva Sanz;00000023T;eva@sanz.es;+34955112233;Sevilla D3;Club Náutico;Q2826004J;club@nautico.org;;Cádiz Leídos 4 · importados 2 · fusionados 1 · rechazados 1 · normalizados 1
Guía paso a paso
Intenta resolverlo por tu cuenta y abre un paso solo cuando te atasques: cada uno te acerca a la solución sin dártela entera.
1. Normaliza cada campo
Con re.sub quita lo que sobra y con split() y join colapsa los espacios: " ".join(texto.split()).
nif = re.sub(r"[\s.-]", "", fila["nif"]).upper()2. La letra del DNI y del NIE
El resto de dividir el número entre 23 indexa la cadena de letras. En el NIE, la X vale 0, la Y 1 y la Z 2, delante de las siete cifras.
3. El control del CIF
Recorre las 7 cifras: las de posición par se suman tal cual y las impares se doblan y se suman sus dígitos (sum(divmod(n * 2, 10))). Después mira la letra inicial para saber si el control es dígito, letra o cualquiera.
4. Fusionar duplicados
Guarda los aceptados en una lista. Para cada cliente nuevo, busca uno con el mismo NIF o el mismo email; si existe, completa sus huecos y no lo añadas.
Resuélvelo aquí
El editor trae el esqueleto del programa. Pulsa «Ejecutar» para comprobarlo con los ejemplos y con 2 casos ocultos que buscan los errores típicos.
Ejemplo
codigo;nombre;nif;email;telefono;poblacion C001; Talleres Ruiz S.L. ;B-12345674;INFO@TALLERESRUIZ.ES;912 345 678;madrid C002;Ana Gil;12.345.678-z;ana.gil@correo.es;600-11-22-33; alcalá de henares C003;Luis Mora;x1234567l;luis@correo;0034 699 123 456;Getafe C004;Ana Gil Pérez;12345678Z;;;Alcalá C005;Hotel Mar;A58818501;reservas@hotelmar.com;;Valencia C006;Pedro;12345678A;pedro@x.es;611222333;Toledo
Aviso C003: email «luis@correo» no válido, se deja vacío Duplicado C004 → C002 (mismo NIF) Rechazado C006 (Pedro): NIF «12345678A» no válido --- Clientes para importar --- codigo;nombre;nif;email;telefono;poblacion C002;Ana Gil;12345678Z;ana.gil@correo.es;+34600112233;Alcalá de Henares C005;Hotel Mar;A58818501;reservas@hotelmar.com;;Valencia C001;Talleres Ruiz S.L.;B12345674;info@talleresruiz.es;+34912345678;Madrid C003;Luis Mora;X1234567L;;+34699123456;Getafe Leídos 6 · importados 4 · fusionados 1 · rechazados 1 · normalizados 4
Solución explicada
Ver la solución completa
1import re
2import sys
3
4LETRAS_DNI = "TRWAGMYFPDXBNJZSQVHLCKE"
5ENLACE = {"de", "del", "la", "las", "los", "el", "y"}
6
7
8def nif_valido(nif):
9 """DNI (8 cifras y letra), NIE (X, Y o Z, 7 cifras y letra) o CIF (letra, 7 cifras y control)."""
10 if re.fullmatch(r"\d{8}[A-Z]", nif):
11 return LETRAS_DNI[int(nif[:8]) % 23] == nif[8]
12 if re.fullmatch(r"[XYZ]\d{7}[A-Z]", nif):
13 numero = "XYZ".index(nif[0]) * 10 ** 7 + int(nif[1:8])
14 return LETRAS_DNI[numero % 23] == nif[8]
15 if re.fullmatch(r"[ABCDEFGHJNPQRSUVW]\d{7}[0-9A-J]", nif):
16 cifras = nif[1:8]
17 pares = sum(int(cifras[i]) for i in (1, 3, 5))
18 impares = sum(sum(divmod(int(cifras[i]) * 2, 10)) for i in (0, 2, 4, 6))
19 control = (10 - (pares + impares) % 10) % 10
20 letra = "JABCDEFGHI"[control]
21 if nif[0] in "PQRSNW":
22 return nif[8] == letra
23 if nif[0] in "ABEH":
24 return nif[8] == str(control)
25 return nif[8] in (str(control), letra)
26 return False
27
28
29def normalizar(fila):
30 nombre = " ".join(fila["nombre"].split())
31 nif = re.sub(r"[\s.-]", "", fila["nif"]).upper()
32 email = fila["email"].strip().lower()
33 telefono = re.sub(r"[\s.()-]", "", fila["telefono"])
34 if re.fullmatch(r"[6789]\d{8}", telefono):
35 telefono = "+34" + telefono
36 elif telefono.startswith("0034"):
37 telefono = "+" + telefono[2:]
38 palabras = " ".join(fila["poblacion"].split()).title().split(" ")
39 # las palabras de enlace van en minúscula, salvo al principio: Alcalá de Henares
40 poblacion = " ".join(w.lower() if i > 0 and w.lower() in ENLACE else w for i, w in enumerate(palabras))
41 return {"codigo": fila["codigo"].strip(), "nombre": nombre, "nif": nif, "email": email, "telefono": telefono, "poblacion": poblacion}
42
43
44def main():
45 lineas = [l for l in sys.stdin.read().split("\n") if l.strip()]
46 cabecera = lineas[0].split(";")
47 buenos = []
48 rechazados = 0
49 fusionados = 0
50 cambios = 0
51 for linea in lineas[1:]:
52 valores = linea.split(";")
53 if len(valores) != len(cabecera):
54 print(f"Rechazado: «{linea}» no tiene {len(cabecera)} campos")
55 rechazados += 1
56 continue
57 original = dict(zip(cabecera, valores))
58 c = normalizar(original)
59 if c != {k: original[k] for k in c}:
60 cambios += 1
61 if not nif_valido(c["nif"]):
62 print(f"Rechazado {c['codigo']} ({c['nombre']}): NIF «{c['nif']}» no válido")
63 rechazados += 1
64 continue
65 if c["email"] and not re.fullmatch(r"[^@\s]+@[^@\s]+\.[a-z]{2,}", c["email"]):
66 print(f"Aviso {c['codigo']}: email «{c['email']}» no válido, se deja vacío")
67 c["email"] = ""
68 if c["telefono"] and not re.fullmatch(r"\+\d{9,13}", c["telefono"]):
69 print(f"Aviso {c['codigo']}: teléfono «{c['telefono']}» no válido, se deja vacío")
70 c["telefono"] = ""
71 igual = next((b for b in buenos if b["nif"] == c["nif"] or (c["email"] and b["email"] == c["email"])), None)
72 if igual:
73 motivo = "mismo NIF" if igual["nif"] == c["nif"] else "mismo email"
74 completados = [k for k in ("email", "telefono", "poblacion") if not igual[k] and c[k]]
75 for k in completados:
76 igual[k] = c[k]
77 extra = f"; se completa {', '.join(completados)}" if completados else ""
78 print(f"Duplicado {c['codigo']} → {igual['codigo']} ({motivo}){extra}")
79 fusionados += 1
80 continue
81 buenos.append(c)
82 print("--- Clientes para importar ---")
83 print("codigo;nombre;nif;email;telefono;poblacion")
84 for b in sorted(buenos, key=lambda b: b["nif"]):
85 print(";".join(b[k] for k in ("codigo", "nombre", "nif", "email", "telefono", "poblacion")))
86 print(f"Leídos {len(lineas) - 1} · importados {len(buenos)} · fusionados {fusionados} · rechazados {rechazados} · normalizados {cambios}")
87
88
89main()El dígito de control existe precisamente para detectar errores de tecleo: cambiar una cifra o intercambiar dos casi siempre cambia la letra. Validarlo al migrar evita facturas que Hacienda rechazaría.
Normalizar antes de comparar es lo que permite encontrar duplicados: 12.345.678-Z y 12345678z son el mismo NIF, pero no son iguales como texto.
Fusionar completando los huecos (en lugar de quedarse con uno y tirar el otro) conserva la información: el primer alta puede tener el teléfono y la segunda el email.
Lo que no tiene arreglo automático se rechaza con un motivo y se revisa a mano: una migración nunca debe inventar datos.
Para ir más allá
- Detecta duplicados probables por nombre parecido (distancia de Levenshtein) y márcalos para revisión.
- Genera también un CSV de rechazados con el motivo, para devolverlo al cliente.
- Valida los códigos postales con la provincia de la población.