Apuntes DAM
Volver al inicio

Migración de clientes al ERP: limpieza, validación y duplicados

Ejercicio de PythonMuy difícilUnos 100 minutos

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

  1. 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.
  2. 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 +34 delante si son 9 cifras que empiezan por 6, 7, 8 o 9, y 0034… como +34….
  3. 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 de ABCDEFGHJNPQRSUVW, 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 letra JABCDEFGHI[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.
  4. 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.
  5. 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.
  6. 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()).

python
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.

🐍PythonMigración de clientes al ERP: limpieza, validación y duplicadosMuy difícil

Ejemplo

Entrada (lo que se escribe por teclado)
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 esperada
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
⏳
Test oculto #3
⏳
Test oculto #4
0/4 tests pasados · pulsa un test para ver su entrada y su salida esperada

Solución explicada

Ver la solución completa
python
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.

Dónde se explica