C01 · Analiza antes de programar: la tabla Entrada – Proceso – Salida

LECTURA Y RESUMEN DEL ENUNCIADO

En el cuaderno lo resumimos así:

Que recibo: un número que escribe el usuario; la cadena de 23 letras; el divisor 23. Que debo mostrar: el DNI completo (8 cifras + letra), un aviso de error o, en la primera visita, el formulario vacío. Que condiciones debo verificar:

  • Que sea un entero positivo (entre 1 y 99.999.999).
  • Que si el número es menor de 8 cifras, rellenar con ceros a la izquierda.
  • Como el formulario se envía a la misma página, comprobar si se ha enviado para mostrar el número escrito y procesar el formulario.

Condiciones Internas

Una condición que falta en el enunciado Un DNI tiene como máximo 8 cifras, pero el enunciado solo dice «entero positivo». Pero nosotros sabemos que el número debe estar entre 1 y 99 999 999. Esa condición la hemos añadido nosotros y debemos indicarlo en los comentarios.

Paso 2. Tabla de Entrada, Proceso y Salida.

Entrada (¿qué tengo?) Proceso (¿qué hago?) Salida (¿qué muestro?)
ENVIAR (POST): se hace una REQUEST mediante el método POST Comprobar si se ha enviado el formulario
  • Formulario con el número que escribió el usuario (persistente).
  • Salida procesada.
  • Botón Borrar
numero (POST): campo del formulario
  • Recoger numero y quitarle los espacios.
  • Validar: entero entre 1 y 99.999.999.
  • Rellenar con ceros hasta 8 cifras y añadir la letra
  • Mensaje de error, solo si hay error.
  • DNI completo con 8 cifras y letra, solo si es válido.
Dato fijo: cadena TRWAGMYFPDXBNJZSQVHLCKE Calcular la letra con calcularLetraDNI() DNI completo con 8 cifras y letra,
Dato fijo: divisor 23 Calcular la letra con calcularLetraDNI() DNI completo con 8 cifras y letra,

Paso 3. División del problema en trozos

Estos son los trozos en el orden en que se van a programar. Cada uno se prueba y se guarda con un commit antes de pasar al siguiente:

  • Trozo 1. La función calcularLetraDNI(), probada sola.
  • Trozo 2. Saber si se ha enviado el formulario y recoger el dato.
  • Trozo 3. Validar el dato y preparar el mensaje de error.
  • Trozo 4. Calcular la letra y construir el DNI de 8 cifras.
  • Trozo 5. Mostrarlo todo en el HTML: formulario persistente, error, resultado y botón Borrar.

Paso 4. Pseudocódigo y traza

4.1. Cálculo a mano

Antes de escribir pseudocódigo, comprobamos que entendemos el cálculo con un ejemplo. Copia en el cuaderno la cadena numerando cada letra. Cuidado: la primera posición es la 0.

Posición:  0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16 17 18 19 20 21 22
Letra:     T  R  W  A  G  M  Y  F  P  D  X  B  N  J  Z  S  Q  V  H  L  C  K  E

Para el número 12345678:

12345678 / 23 = 536768,17…   →  cociente entero: 536768
536768 · 23   = 12345664
resto         = 12345678 − 12345664 = 14
posición 14   →  Z

Resultado: 12345678Z, que coincide con el ejemplo del enunciado. Repite el cálculo con tu propio DNI: si te sale tu letra, has entendido el algoritmo.

4.2. Pseudocódigo

FUNCIÓN calcularLetraDNI(numero)
    resto ← numero MOD 23
    devolver el carácter de la posición resto en "TRWAGMYFPDXBNJZSQVHLCKE"
FIN FUNCIÓN

texto ← ""          error ← nada          resultado ← nada

SI se ha enviado el formulario ENTONCES
    texto ← dato "numero" sin espacios al principio ni al final
    SI texto no es un entero entre 1 y 99999999 ENTONCES
        error ← "Escribe un número entero positivo de 8 cifras como máximo."
    SI NO
        letra ← calcularLetraDNI(número)
        resultado ← número rellenado con ceros hasta 8 cifras + letra
    FIN SI
FIN SI

mostrar formulario con texto
SI hay error ENTONCES mostrar error
SI hay resultado ENTONCES mostrar resultado

4.3. Traza

Seguimos el pseudocódigo a mano con tres situaciones distintas:

Situación ¿Enviado? texto ¿Válido? error resultado Se ve
Primera visita No "" — nada nada Formulario vacío
Escribe 12345678 Sí "12345678" Sí nada "12345678Z" Formulario con 12345678 y el DNI
Escribe abc Sí "abc" No "ERROR: Escribe un…" nada Formulario con abc y el error

La traza da lo esperado en los tres casos. El algoritmo está bien: ya solo queda traducirlo a PHP.


Paso 5. Programa poco a poco

Commits

Hay que hacer un commit en cada uno de los pasos donde escribamos un trozo de código para poder volver atrás y tener un histórico práctico que nos pueda servir de referencia.

Trozo 1 · La función, probada sola

Creamos ut03p03.php con solo la función y dos pruebas:

<?php
function calcularLetraDNI(int $numero): string
{
    $letras = 'TRWAGMYFPDXBNJZSQVHLCKE';
    return $letras[$numero % 23];
}

// Pruebas provisionales (se borrarán)
echo calcularLetraDNI(12345678);   // debe mostrar Z
echo calcularLetraDNI(1);          // debe mostrar R

Abrimos la página en el navegador: aparece ZR. Coincide con lo calculado a mano (el 1 entre 23 da resto 1 → R).

En PHP una cadena se puede leer como un array de caracteres: $letras[14] es la Z. No hace falta crear un array con las 23 letras.

git commit -m "P03: función calcularLetraDNI"

Trozo 2 · ¿Se ha enviado? Recoger el dato

Borramos los dos echo de prueba. Añadimos debajo de la función la zona de lógica (patrón A), con las variables que usará el HTML ya inicializadas, y un formulario mínimo para poder enviar algo:

// Variables que usa el HTML (valores de la primera visita)
$texto     = '';
$error     = null;
$resultado = null;

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $texto = trim($_POST['numero'] ?? '');

    echo '<pre>';            // depuración provisional
    var_dump($_POST);
    echo '</pre>';
}
?>
<form method="post">
    <input type="text" name="numero">
    <button type="submit">Calcular letra</button>
</form>

Enviamos 12345678 y vemos:

array(1) {
  ["numero"]=>
  string(8) "12345678"
}

Fíjate: llega como string, no como número. Todo lo que viene de un formulario es texto. Por eso hay que validarlo y convertirlo antes de pasárselo a una función que espera un int.

El <form> no lleva atributo action: así se envía a la misma URL, que es lo que significa autoprocesada.

Trozo 3 · Validar

Sustituimos el var_dump() por la validación (patrón C):

    $numero = filter_var($texto, FILTER_VALIDATE_INT, [
        'options' => ['min_range' => 1, 'max_range' => 99999999],
    ]);

    if ($numero === false) {
        $error = 'Escribe un número entero positivo de 8 cifras como máximo.';
    }

Comprobamos con un var_dump($numero) provisional:

Escribimos var_dump($numero)
12345678 int(12345678)
abc bool(false)
0 bool(false) (fuera de rango)

filter_var() hace dos cosas a la vez: comprueba el dato y, si es válido, lo devuelve convertido a int.

¿Por qué === false y no !$numero?

Si algún día el rango admitiera el 0, filter_var() devolvería el entero 0, que también se evalúa como falso. Con !$numero lo trataríamos como un error. === false solo es cierto cuando la validación ha fallado de verdad.

Trozo 4 · Calcular y formatear

Completamos el if con la rama else:

    } else {
        $letra     = calcularLetraDNI($numero);
        $resultado = str_pad((string) $numero, 8, '0', STR_PAD_LEFT) . $letra;
    }

str_pad() recibe:

  • la cadena;
  • la longitud final (8);
  • el carácter de relleno ('0');
  • el lado por donde rellenar (STR_PAD_LEFT).

Con 1 devuelve 00000001, y al añadir la letra queda 00000001R.

git commit -am "P03: validación y cálculo del DNI completo"

Trozo 5 · El HTML

Ahora la zona de presentación. Solo muestra variables que ya están calculadas; aquí no se calcula nada:

  • Formulario persistente (patrón D): el value del campo lleva $texto, escapado con htmlspecialchars() (patrón E). Si alguien escribe <b>hola</b>, debe verse tal cual en el campo.
  • Error y resultado con if en sintaxis alternativa, para que solo aparezcan cuando existen.
  • Botón Borrar: es un enlace a la propia página. Al pulsarlo, el navegador hace una petición GET: no se cumple REQUEST_METHOD === 'POST' y la página se muestra como en la primera visita.

¿Por qué no un <button type="reset">?

Un botón reset devuelve los campos al valor que tienen en el HTML. Después de enviar, ese valor es el número que escribió el usuario (el formulario es persistente), así que el campo no se vaciaría. Además, el resultado seguiría en pantalla.

El campo es type="text" y no type="number" a propósito. Con number, el navegador impediría escribir letras y no podríamos comprobar la validación del servidor. Y esa validación es la que de verdad protege la aplicación: cualquiera puede saltarse la del navegador con F12.

git commit -am "P03: formulario persistente, mensajes y botón Borrar"

Paso 6. Prueba con casos pensados

Probamos los casos de la ficha de la guía y anotamos lo que obtenemos al lado de lo que esperamos:

Tipo Entrada Esperado Obtenido
Normal 12345678 12345678Z 12345678Z ✔
Normal 87654321 87654321X 87654321X ✔
Límite 1 00000001R 00000001R ✔
Límite 99999999 99999999R 99999999R ✔
Límite 00000001 00000001R Error ✘
Erróneo 0, -5, abc, 12.5, vacío Error Error ✔
Erróneo 123456789 Error (más de 8 cifras) Error ✔
Erróneo <b>hola</b> Error, y en el campo se ve el texto literal ✔
Erróneo Abrir la página por primera vez Formulario vacío, sin errores ni warnings ✔

Un error encontrado: los ceros a la izquierda

Un usuario que copia su DNI con los ceros (00000001) recibe un error, y no debería. Investigamos con var_dump():

var_dump(filter_var('00000001', FILTER_VALIDATE_INT));   // bool(false)
var_dump(filter_var('1', FILTER_VALIDATE_INT));          // int(1)

FILTER_VALIDATE_INT rechaza los números que empiezan por 0. En programación, un 0 inicial puede indicar que el número está en base 8 (octal), así que el filtro no se arriesga a interpretarlo.

Solución: normalizar antes de validar, quitando los ceros de la izquierda con ltrim():

    $texto    = trim($_POST['numero'] ?? '');
    $sinCeros = ltrim($texto, '0');          // "00000001" → "1"

    $numero = filter_var($sinCeros, FILTER_VALIDATE_INT, [ … ]);

¿Rompe algo este cambio? Repasamos los casos:

  • 0 y 0000 se quedan en cadena vacía, que sigue dando error: correcto.
  • Al formulario le seguimos devolviendo $texto, no $sinCeros: el usuario ve exactamente lo que escribió.

Repetimos todas las pruebas (no solo la que fallaba) y ya pasan todas.

git commit -am "P03: admitir ceros a la izquierda en el número"

Lo que enseña este error

Los casos límite son los que encuentran los fallos. Si solo hubiéramos probado 12345678, el programa habría parecido perfecto. Cada vez que corrijas un error, vuelve a pasar todas las pruebas: un arreglo puede estropear otra cosa.


Código completo

ut03p03.php
<?php
/**
 * P03 · Calculadora de la letra del DNI
 * Página autoprocesada: muestra el formulario y, al enviarlo,
 * valida el número y calcula su letra.
 */

// ── Funciones ────────────────────────────────────────────────

/**
 * Devuelve la letra que corresponde a un número de DNI.
 * La letra es la posición (numero % 23) de la cadena de control.
 */
function calcularLetraDNI(int $numero): string
{
    $letras = 'TRWAGMYFPDXBNJZSQVHLCKE';
    return $letras[$numero % 23];
}

// ── Zona de lógica ──────────────────────────────────────────

// Variables que usa el HTML (valores de la primera visita)
$texto     = '';     // lo que escribió el usuario (formulario persistente)
$error     = null;   // mensaje de error, si lo hay
$resultado = null;   // DNI completo, si el número es válido

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    // Recoger el dato y normalizarlo
    $texto    = trim($_POST['numero'] ?? '');
    $sinCeros = ltrim($texto, '0');   // FILTER_VALIDATE_INT rechaza ceros iniciales

    // Validar: entero entre 1 y 99 999 999 (8 cifras como máximo)
    $numero = filter_var($sinCeros, FILTER_VALIDATE_INT, [
        'options' => ['min_range' => 1, 'max_range' => 99999999],
    ]);

    if ($numero === false) {
        $error = 'Escribe un número entero positivo de 8 cifras como máximo.';
    } else {
        // Calcular la letra y rellenar con ceros hasta 8 cifras
        $letra     = calcularLetraDNI($numero);
        $resultado = str_pad((string) $numero, 8, '0', STR_PAD_LEFT) . $letra;
    }
}
?>
<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>P03 · Letra del DNI</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
</head>
<body>
<section class="section">
    <div class="container" style="max-width: 32rem;">
        <h1 class="title">Calculadora de la letra del DNI</h1>

        <!-- Sin "action": el formulario se envía a esta misma página -->
        <form method="post">
            <div class="field">
                <label class="label" for="numero">Número del DNI</label>
                <div class="control">
                    <input class="input<?= $error !== null ? ' is-danger' : '' ?>"
                           type="text" id="numero" name="numero"
                           inputmode="numeric" placeholder="12345678"
                           value="<?= htmlspecialchars($texto) ?>">
                </div>
                <?php if ($error !== null): ?>
                    <p class="help is-danger"><?= $error ?></p>
                <?php endif; ?>
            </div>

            <div class="field is-grouped">
                <div class="control">
                    <button type="submit" class="button is-primary">Calcular letra</button>
                </div>
                <div class="control">
                    <!-- Enlace = petición GET: la página se carga vacía -->
                    <a href="ut03p03.php" class="button is-light">Borrar</a>
                </div>
            </div>
        </form>

        <?php if ($resultado !== null): ?>
            <div class="notification is-success mt-5">
                El DNI completo es <strong><?= $resultado ?></strong>
            </div>
        <?php endif; ?>
    </div>
</section>
</body>
</html>

$error y $resultado se muestran sin htmlspecialchars() porque no contienen texto del usuario:

  • el error es un mensaje fijo;
  • el resultado se construye a partir de un entero ya validado y de una letra de nuestra cadena.

$texto sí viene del usuario, y por eso se escapa.