Horas trabajadas con nocturnidad usando DateTime
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
- La primera línea es
TARIFA euros(euros por hora, con hasta dos decimales); si no lo es, escribeFalta la tarifay nada más. Cada línea siguiente es un turno:nombre;AAAA-MM-DD HH:MM;AAAA-MM-DD HH:MM(entrada y salida). - 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. - Horas nocturnas: los minutos del turno que caen entre las 22:00 y las 06:00 de cualquier noche.
- 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.
- 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 como38 h 30 mino8 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.
$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.
Ejemplo
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
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 €
Solución explicada
Ver la solución completa
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.