Apuntes DAM
Volver al inicio

Horas trabajadas con nocturnidad usando DateTime

Ejercicio de PHPDifícilUnos 70 minutos

Calcula las horas y el importe de cada trabajador a partir de sus fichajes: turnos que cruzan la medianoche, horas nocturnas de 22:00 a 06:00 con un plus del 25 % y validación de fechas imposibles, turnos de más de 12 horas y solapes. DateTimeImmutable, intervalos y ordenación.

  • DateTimeImmutable
  • createFromFormat
  • modify y setTime
  • Intersección de intervalos
  • Destructuring en foreach
  • continue 2

Enunciado

Un hospital paga a su personal de guardias por horas, con un plus del 25 % por cada hora trabajada de noche (de 22:00 a 06:00). Los fichajes se exportan del control de presencia con la entrada y la salida de cada turno, y a fin de mes hay que calcular cuánto cobra cada persona.

Las fechas son la parte delicada: un turno de 22:00 a 06:30 cruza la medianoche, y uno de 20:00 a 08:00 tiene unas horas nocturnas y otras no. Calcularlo con horas sueltas lleva a errores; con objetos de fecha y la intersección de intervalos sale solo.

El control de presencia comete errores: fechas que no existen, salidas anteriores a la entrada, turnos que se olvidaron de cerrar (de más de 12 horas) o fichajes duplicados que se solapan. Esos turnos se rechazan con un mensaje y no se pagan.

Qué tiene que hacer el programa

  1. La primera línea es TARIFA euros (euros por hora, con hasta dos decimales); si no lo es, escribe Falta la tarifa y nada más. Cada línea siguiente es un turno: nombre;AAAA-MM-DD HH:MM;AAAA-MM-DD HH:MM (entrada y salida).
  2. Un turno se rechaza, escribiendo Línea N: motivo (N es el número de línea de la entrada), si: no tiene ese formato o alguna fecha no existe (formato no válido); la salida no es posterior a la entrada (la salida no es posterior a la entrada); dura más de 12 horas (un turno no puede pasar de 12 horas); o se solapa con un turno ya aceptado de la misma persona (se solapa con otro turno de nombre). Dos turnos que solo se tocan (uno termina cuando empieza el otro) no se solapan.
  3. Horas nocturnas: los minutos del turno que caen entre las 22:00 y las 06:00 de cualquier noche.
  4. Importe de cada persona: todos sus minutos a la tarifa más un 25 % de la tarifa por cada minuto nocturno, redondeado al céntimo.
  5. Escribe una línea por persona, por orden alfabético: nombre: T turnos, D (nocturnas N) · importe, con «1 turno» en singular y las duraciones como 38 h 30 min o 8 h. Al final, Total a pagar: X. Los importes con punto de millar, coma decimal y €.

Entrada

Línea 1: TARIFA euros. Resto: nombre;entrada;salida con fechas AAAA-MM-DD HH:MM.

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.

Guardias de una semana

Entrada

TARIFA 14.50
Ana Gil;2026-09-28 22:00;2026-09-29 06:30
Luis Mora;2026-09-28 08:00;2026-09-28 15:00
Ana Gil;2026-09-30 20:00;2026-10-01 08:00
Luis Mora;2026-09-29 15:00;2026-09-29 23:15

Salida por consola

Ana Gil: 2 turnos, 20 h 30 min (nocturnas 16 h) · 355,25 €
Luis Mora: 2 turnos, 15 h 15 min (nocturnas 1 h 15 min) · 225,66 €
Total a pagar: 580,91 €

Turnos rechazados

Entrada

TARIFA 12
Eva;2026-09-31 08:00;2026-09-31 14:00
Eva;2026-10-01 14:00;2026-10-01 08:00
Eva;2026-10-01 08:00;2026-10-01 21:00
Eva;2026-10-02 08:00;2026-10-02 14:00
Eva;2026-10-02 13:00;2026-10-02 18:00
Eva;2026-10-02 14:00;2026-10-02 18:00
Eva sin fechas

Salida por consola

Línea 2: formato no válido
Línea 3: la salida no es posterior a la entrada
Línea 4: un turno no puede pasar de 12 horas
Línea 6: se solapa con otro turno de Eva
Línea 8: formato no válido
Eva: 2 turnos, 10 h (nocturnas 0 h) · 120,00 €
Total a pagar: 120,00 €

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. Leer fechas sin sorpresas

DateTimeImmutable::createFromFormat("!Y-m-d H:i", $texto) lee una fecha con un formato exacto (el ! pone a cero lo que no se indica). Pero acepta el 31 de septiembre y lo convierte en el 1 de octubre: vuelve a formatearla y compara con el texto original para detectarlo.

2. Duraciones con timestamps

Restar getTimestamp() de dos fechas da los segundos entre ellas; divide entre 60 con intdiv para trabajar en minutos enteros.

3. Minutos nocturnos: intersección de intervalos

Cada noche es un intervalo de 22:00 a 06:00. Recorre las noches que pueden tocar el turno (desde la anterior a la entrada) y suma lo que se cruzan: el máximo de los inicios y el mínimo de los finales.

php
$noche = $inicio->modify("-1 day")->setTime(22, 0);
while ($noche < $fin) {
    $desde = max($inicio, $noche);
    $hasta = min($fin, $noche->modify("+8 hours"));
    if ($hasta > $desde) $total += intdiv($hasta->getTimestamp() - $desde->getTimestamp(), 60);
    $noche = $noche->modify("+1 day");
}
4. Solapamientos

Dos intervalos [a, b) y [c, d) se solapan si a < d && c < b. Compara el turno nuevo con los aceptados de esa persona y, si se solapa con alguno, salta al siguiente turno con continue 2 (sale del bucle interior y continúa el exterior).

5. Por qué Immutable

modify en un DateTimeImmutable devuelve un objeto nuevo y deja el original intacto. Con DateTime (mutable), $noche->modify("+8 hours") cambiaría $noche y el bucle se descontrolaría.

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.

🐘PHPHoras trabajadas con nocturnidad usando DateTimeDifícil

Ejemplo

Entrada (lo que se escribe por teclado)
TARIFA 14.50
Ana Gil;2026-09-28 22:00;2026-09-29 06:30
Luis Mora;2026-09-28 08:00;2026-09-28 15:00
Ana Gil;2026-09-30 20:00;2026-10-01 08:00
Luis Mora;2026-09-29 15:00;2026-09-29 23:15
Salida esperada
Ana Gil: 2 turnos, 20 h 30 min (nocturnas 16 h) · 355,25 €
Luis Mora: 2 turnos, 15 h 15 min (nocturnas 1 h 15 min) · 225,66 €
Total a pagar: 580,91 €
⏳
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
php
1<?php
2/** Minutos de [$inicio, $fin) que caen entre las 22:00 y las 06:00. */
3function minutosNocturnos(DateTimeImmutable $inicio, DateTimeImmutable $fin): int {
4    $total = 0;
5    // Cada noche va de las 22:00 de un día a las 06:00 del siguiente; se empieza por la noche anterior al inicio
6    $noche = $inicio->modify("-1 day")->setTime(22, 0);
7    while ($noche < $fin) {
8        $desde = max($inicio, $noche);
9        $hasta = min($fin, $noche->modify("+8 hours"));
10        if ($hasta > $desde) $total += intdiv($hasta->getTimestamp() - $desde->getTimestamp(), 60);
11        $noche = $noche->modify("+1 day");
12    }
13    return $total;
14}
15
16function duracion(int $minutos): string {
17    $h = intdiv($minutos, 60);
18    $m = $minutos % 60;
19    return $m === 0 ? "$h h" : "$h h $m min";
20}
21
22function euros(int $c): string {
23    return number_format($c / 100, 2, ",", ".") . " €";
24}
25
26$lineas = explode("\n", stream_get_contents(STDIN));
27if (!preg_match('/^TARIFA (\d+(\.\d{1,2})?)$/', trim($lineas[0]), $m)) {
28    echo "Falta la tarifa\n";
29    exit;
30}
31$tarifa = (int) round((float) $m[1] * 100);   // céntimos por hora
32
33$turnos = [];   // nombre → lista de [inicio, fin]
34foreach (array_slice($lineas, 1) as $i => $linea) {
35    $linea = trim($linea);
36    if ($linea === "") continue;
37    $num = $i + 2;
38    $campos = array_map("trim", explode(";", $linea));
39    $inicio = count($campos) === 3 ? DateTimeImmutable::createFromFormat("!Y-m-d H:i", $campos[1]) : false;
40    $fin = count($campos) === 3 ? DateTimeImmutable::createFromFormat("!Y-m-d H:i", $campos[2]) : false;
41    // createFromFormat acepta el 31 de septiembre (lo pasa al 1 de octubre): se comprueba que la fecha no cambie
42    if (!$inicio || !$fin || $inicio->format("Y-m-d H:i") !== $campos[1] || $fin->format("Y-m-d H:i") !== $campos[2] || $campos[0] === "") {
43        echo "Línea $num: formato no válido\n";
44        continue;
45    }
46    $nombre = $campos[0];
47    $minutos = intdiv($fin->getTimestamp() - $inicio->getTimestamp(), 60);
48    if ($minutos <= 0) { echo "Línea $num: la salida no es posterior a la entrada\n"; continue; }
49    if ($minutos > 12 * 60) { echo "Línea $num: un turno no puede pasar de 12 horas\n"; continue; }
50    foreach ($turnos[$nombre] ?? [] as [$a, $b]) {
51        if ($inicio < $b && $a < $fin) { echo "Línea $num: se solapa con otro turno de $nombre\n"; continue 2; }
52    }
53    $turnos[$nombre][] = [$inicio, $fin];
54}
55
56ksort($turnos, SORT_STRING);
57$total = 0;
58foreach ($turnos as $nombre => $lista) {
59    $minutos = $nocturnos = 0;
60    foreach ($lista as [$a, $b]) {
61        $minutos += intdiv($b->getTimestamp() - $a->getTimestamp(), 60);
62        $nocturnos += minutosNocturnos($a, $b);
63    }
64    // Todas las horas a la tarifa, y las nocturnas con un 25 % más
65    $importe = (int) round(($minutos * $tarifa + $nocturnos * $tarifa * 0.25) / 60);
66    $total += $importe;
67    $turnosTexto = count($lista) === 1 ? "1 turno" : count($lista) . " turnos";
68    echo "$nombre: $turnosTexto, " . duracion($minutos) . " (nocturnas " . duracion($nocturnos) . ") · " . euros($importe) . "\n";
69}
70echo "Total a pagar: " . euros($total) . "\n";

La clave del ejercicio es tratar los turnos y las noches como intervalos de tiempo: la parte nocturna de un turno es su intersección con cada noche. El mismo razonamiento sirve para tarifas eléctricas por franjas, aparcamientos o reservas de salas.

DateTimeImmutable hace las cuentas de calendario (cambio de día, de mes o de año) y su inmutabilidad evita los errores de modificar sin querer una fecha que se sigue usando. Comparar objetos de fecha con < y usar max/min funciona directamente.

La validación de fechas con la vuelta al texto original detecta lo que createFromFormat corrige en silencio. Es el mismo problema que con Date en JavaScript: las librerías de fechas prefieren «arreglar» antes que fallar.

El importe se calcula en céntimos con un único redondeo por persona, para que la suma de las nóminas sea exactamente el total que se escribe.

Para ir más allá

  • Añade un plus por festivo o domingo (otro 50 %) con una lista de festivos.
  • Ten en cuenta los cambios de hora de marzo y octubre: con la zona horaria Europe/Madrid, una noche de octubre dura una hora más.
  • Agrupa las horas por semana con format("o-W") y avisa de quien pase de 40 horas semanales.

Dónde se explica