Tiempo de lectura: 12 min
Valencia, 02/11/2026, G.B.
Continúo con el cuaderno técnico del plugin Control y Reserva de Recursos (CRR). En la primera parte vimos la cabecera, las tablas y los permisos. Hoy toca el motor de las reservas: cómo se comprueba que un recurso está libre, cómo se calcula el precio, cómo se guarda una reserva, cómo se avisa por email y cómo se gestionan el inventario y las cancelaciones.
Table of Contents
Bloque 4: Disponibilidad y precio estimado
¿Cómo sabe el plugin si un recurso está libre?
Un recurso está libre si, sumando las reservas que se solapan con la nueva, no se supera su stock. Esto permite tanto una sala (stock 1) como un lote de portátiles (stock 20) con la misma lógica:
function crr_comprobar_disponibilidad( $recurso_id, $fecha_inicio, $fecha_fin, $hora_inicio, $hora_fin, $cantidad, $reserva_id_excluir = 0 ) {
global $wpdb;
$tabla_reservas = $wpdb->prefix . 'crr_reservas';
$tabla_recursos = $wpdb->prefix . 'crr_recursos';
$recurso = $wpdb->get_row( $wpdb->prepare( "SELECT stock FROM $tabla_recursos WHERE id = %d", $recurso_id ) );
if ( ! $recurso ) {
return false;
}
$sql = "SELECT SUM(cantidad) FROM $tabla_reservas
WHERE recurso_id = %d
AND id != %d
AND (fecha_inicio <= %s AND fecha_fin >= %s)
AND (hora_inicio < %s AND hora_fin > %s)";
$ocupacion = $wpdb->get_var( $wpdb->prepare( $sql, $recurso_id, $reserva_id_excluir, $fecha_fin, $fecha_inicio, $hora_fin, $hora_inicio ) );
$ocupacion_actual = $ocupacion ? intval( $ocupacion ) : 0;
return ( $ocupacion_actual + $cantidad ) <= intval( $recurso->stock );
}¿Qué ocurre paso a paso en esta función?
- Recurso inexistente: si el
idno existe en el inventario, devuelvefalsey no se reserva nada. - Solape de fechas: una reserva existente choca con la nueva si empieza antes de que la nueva termine y acaba después de que la nueva empiece. Son dos comparaciones, no cuatro.
- Solape de horas: misma idea con las horas, pero con desigualdades estrictas (
<y>). Así, una reserva que termina a las 12:00 no bloquea otra que empieza a las 12:00. id != %d: al editar una reserva se excluye a sí misma del cálculo. Sin esto, cambiar la hora de tu propia reserva te daría «no disponible» por chocar contigo mismo.SUM(cantidad): suma las unidades ya ocupadas. Si más lo que pides no supera elstock, hay hueco.- Consulta preparada: todos los valores van con
$wpdb->prepare(), nunca concatenados.
¿Cómo se calcula el precio estimado?
Es una cuenta sencilla: días, por horas de cada día, por el precio por hora, por la cantidad. Se calcula en el servidor, no en el navegador, para que no se pueda manipular desde el formulario:
function crr_calcular_precio_estimado( $recurso_id, $fecha_inicio, $fecha_fin, $hora_inicio, $hora_fin, $cantidad ) {
global $wpdb;
$tabla_recursos = $wpdb->prefix . 'crr_recursos';
$recurso = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $tabla_recursos WHERE id = %d", $recurso_id ) );
if ( ! $recurso ) {
return 0;
}
$f_inicio = new DateTime( $fecha_inicio );
$f_fin = new DateTime( $fecha_fin );
$dias = $f_inicio->diff( $f_fin )->days + 1;
$h_inicio = new DateTime( $hora_inicio );
$h_fin = new DateTime( $hora_fin );
$intervalo = $h_inicio->diff( $h_fin );
$horas = $intervalo->h + ( $intervalo->i / 60 );
if ( $horas <= 0 ) {
$horas = 1;
}
$total = $dias * $horas * floatval( $recurso->precio_hora ) * absint( $cantidad );
return round( $total, 2 );
}diff()->days + 1: la diferencia entre el día 10 y el 10 es 0, pero una reserva de un solo día cuenta como 1, de ahí el+ 1.- Horas con decimales: 9:00 a 13:30 son 4,5 horas (
hmás los minutos entre 60). - Mínimo de una hora: si el intervalo sale a cero o negativo, se factura una hora en lugar de devolver un precio de cero.
- Cantidad saneada:
absint()evita cantidades negativas.
Una limitación que conviene tener presente: el cálculo toma las horas de un mismo día, así que no contempla horarios que crucen la medianoche. Para salas y equipos de oficina no me ha hecho falta, pero queda anotado como posible mejora.
Bloque 5: Guardar una reserva y avisar por email
¿Cómo se crea o se edita una reserva?
Una única función se ocupa de las dos cosas. Si recibe un $reserva_id mayor que cero, edita; si no, inserta. Siempre devuelve un array con un mensaje y un tipo (success, warning o error), que luego se pinta como aviso en pantalla:
function crr_procesar_reserva( $recurso_id, $email_solicitante, $fecha_inicio, $fecha_fin, $hora_inicio, $hora_fin, $cantidad, $observaciones = '', $reserva_id = 0, $enviar_presupuesto = false ) {
global $wpdb;
$tabla_reservas = $wpdb->prefix . 'crr_reservas';
$current_user = wp_get_current_user();
if ( ! crr_comprobar_disponibilidad( $recurso_id, $fecha_inicio, $fecha_fin, $hora_inicio, $hora_fin, $cantidad, $reserva_id ) ) {
return array( 'mensaje' => 'El recurso no está disponible en el horario y cantidad seleccionados.', 'tipo' => 'error' );
}
$precio_estimado = crr_calcular_precio_estimado( $recurso_id, $fecha_inicio, $fecha_fin, $hora_inicio, $hora_fin, $cantidad );
$datos = array(
'recurso_id' => $recurso_id,
'user_id' => $current_user->ID,
'email_solicitante' => $email_solicitante,
'fecha_inicio' => $fecha_inicio,
'fecha_fin' => $fecha_fin,
'hora_inicio' => $hora_inicio,
'hora_fin' => $hora_fin,
'cantidad' => $cantidad,
'observaciones' => $observaciones,
'precio_estimado' => $precio_estimado,
);
$formato = array( '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%f' );
if ( $reserva_id > 0 ) {
$reserva_existente = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $tabla_reservas WHERE id = %d", $reserva_id ) );
if ( ! $reserva_existente || ( ! current_user_can( 'manage_options' ) && ! crr_es_usuario_autorizado() && (int) $reserva_existente->user_id !== $current_user->ID ) ) {
return array( 'mensaje' => 'No tienes permisos para editar esta reserva.', 'tipo' => 'error' );
}
$resultado = $wpdb->update( $tabla_reservas, $datos, array( 'id' => $reserva_id ), $formato, array( '%d' ) );
if ( false !== $resultado ) {
crr_enviar_notificaciones( $reserva_id, true, $enviar_presupuesto );
return array( 'mensaje' => 'Reserva #' . $reserva_id . ' actualizada correctamente.', 'tipo' => 'success' );
}
} else {
$resultado = $wpdb->insert( $tabla_reservas, $datos, $formato );
if ( false !== $resultado ) {
$nuevo_id = $wpdb->insert_id;
crr_enviar_notificaciones( $nuevo_id, false, $enviar_presupuesto );
return array( 'mensaje' => 'Reserva #' . $nuevo_id . ' realizada correctamente.', 'tipo' => 'success' );
}
}
return array( 'mensaje' => 'Error al procesar la reserva en la base de datos.', 'tipo' => 'error' );
}¿Qué ocurre paso a paso en esta función?
- Primero disponibilidad: si no hay hueco, se corta aquí con un error y no se toca la base de datos.
- Precio en servidor: se recalcula siempre, aunque el formulario muestre una estimación.
- Array de formatos:
$formatotiene un marcador por cada campo de$datos, en el mismo orden (%denteros,%stextos,%fdecimal).$wpdblos usa para escapar cada valor. - Quién puede editar: un administrador, un usuario autorizado (la función de la parte I) o el propio autor de la reserva. Nadie más.
false !== $resultado:update()devuelve 0 si no cambió ninguna fila, y eso no es un error. Por eso se compara confalseestricto.- Aviso por email: solo se envía cuando el guardado ha ido bien.
¿Cómo se envían los emails de la reserva?
Cada reserva genera un correo en HTML para el solicitante y otro para el responsable del recurso. Se recupera la reserva con un LEFT JOIN para traer en una sola consulta el nombre del recurso y el email del responsable:
function crr_enviar_notificaciones( $reserva_id, $es_edicion = false, $incluir_precio = true ) {
global $wpdb;
$tabla_reservas = $wpdb->prefix . 'crr_reservas';
$tabla_recursos = $wpdb->prefix . 'crr_recursos';
$reserva = $wpdb->get_row( $wpdb->prepare( "SELECT r.*, rec.nombre as recurso_nombre, rec.email_propietario FROM $tabla_reservas r LEFT JOIN $tabla_recursos rec ON r.recurso_id = rec.id WHERE r.id = %d", $reserva_id ) );
if ( ! $reserva ) {
return;
}
$asunto = ( $es_edicion ? '[Actualización] ' : '[Nueva Reserva] ' ) . 'Reserva #' . $reserva->id . ' - ' . $reserva->recurso_nombre;
$headers = array('Content-Type: text/html; charset=UTF-8');
$mensaje = "<h2>Detalles de la Reserva #" . esc_html( $reserva->id ) . "</h2>";
$mensaje .= "<p><strong>Recurso:</strong> " . esc_html( $reserva->recurso_nombre ) . "</p>";
$mensaje .= "<p><strong>Solicitante:</strong> " . esc_html( $reserva->email_solicitante ) . "</p>";
$mensaje .= "<p><strong>Fechas:</strong> Del " . esc_html( $reserva->fecha_inicio ) . " al " . esc_html( $reserva->fecha_fin ) . "</p>";
$mensaje .= "<p><strong>Horario:</strong> De " . esc_html( $reserva->hora_inicio ) . " a " . esc_html( $reserva->hora_fin ) . "</p>";
$mensaje .= "<p><strong>Cantidad:</strong> " . esc_html( $reserva->cantidad ) . "</p>";
if ( $incluir_precio ) {
$mensaje .= "<p><strong>Precio Estimado:</strong> " . esc_html( $reserva->precio_estimado ) . " €</p>";
}
if ( ! empty( $reserva->observaciones ) ) {
$mensaje .= "<p><strong>Observaciones:</strong> " . esc_html( $reserva->observaciones ) . "</p>";
}
wp_mail( $reserva->email_solicitante, $asunto, $mensaje, $headers );
if ( ! empty( $reserva->email_propietario ) && $reserva->email_propietario !== $reserva->email_solicitante ) {
wp_mail( $reserva->email_propietario, $asunto, $mensaje, $headers );
}
}- Asunto según el caso:
[Nueva Reserva]o[Actualización], seguido del número y el nombre del recurso, para localizarlos rápido en la bandeja de entrada. - Cabecera HTML:
Content-Type: text/htmlhace quewp_mail()envíe el cuerpo con formato en lugar de texto plano. - Todo escapado: cada dato que viene de la base de datos pasa por
esc_html()antes de entrar en el correo. - Presupuesto opcional: la línea del precio solo aparece si
$incluir_precioes verdadero, que es lo que decide la casilla «Enviar desglose de presupuesto» del formulario. - Sin duplicados: si el solicitante es también el responsable del recurso, solo recibe un correo.
Bloque 6: Inventario y cancelaciones
¿Cómo se modifican y se eliminan los recursos del inventario?
Solo los administradores (manage_options) tocan el inventario. La edición valida los campos obligatorios y comprueba que el recurso existe. El borrado, además, se niega si el recurso tiene reservas asociadas, para no dejar reservas huérfanas apuntando a algo que ya no existe:
function crr_actualizar_recurso( $recurso_id, $datos ) {
global $wpdb;
if ( ! current_user_can( 'manage_options' ) ) {
return array( 'mensaje' => 'No tienes permisos para modificar el inventario.', 'tipo' => 'error' );
}
if ( empty( $datos['nombre'] ) || empty( $datos['email_propietario'] ) ) {
return array( 'mensaje' => 'Por favor, rellena todos los campos obligatorios.', 'tipo' => 'error' );
}
$tabla_recursos = $wpdb->prefix . 'crr_recursos';
$existe = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $tabla_recursos WHERE id = %d", $recurso_id ) );
if ( ! $existe ) {
return array( 'mensaje' => 'El recurso especificado no existe.', 'tipo' => 'error' );
}
$resultado = $wpdb->update(
$tabla_recursos,
$datos,
array( 'id' => $recurso_id ),
array( '%s', '%s', '%d', '%f', '%s' ),
array( '%d' )
);
if ( false === $resultado ) {
return array( 'mensaje' => 'Error al actualizar el recurso. ' . esc_html( $wpdb->last_error ), 'tipo' => 'error' );
}
return array( 'mensaje' => 'Recurso #' . $recurso_id . ' actualizado correctamente.', 'tipo' => 'success' );
}
function crr_eliminar_recurso( $recurso_id ) {
global $wpdb;
if ( ! current_user_can( 'manage_options' ) ) {
return array( 'mensaje' => 'No tienes permisos para modificar el inventario.', 'tipo' => 'error' );
}
$tabla_recursos = $wpdb->prefix . 'crr_recursos';
$tabla_reservas = $wpdb->prefix . 'crr_reservas';
$existe = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $tabla_recursos WHERE id = %d", $recurso_id ) );
if ( ! $existe ) {
return array( 'mensaje' => 'El recurso especificado no existe.', 'tipo' => 'error' );
}
$con_reservas = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $tabla_reservas WHERE recurso_id = %d", $recurso_id ) );
if ( $con_reservas > 0 ) {
return array(
'mensaje' => 'No se puede eliminar el recurso #' . $recurso_id . ': tiene ' . $con_reservas . ' reserva(s) asociada(s). Cancélalas primero.',
'tipo' => 'error',
);
}
$borrado = $wpdb->delete( $tabla_recursos, array( 'id' => $recurso_id ), array( '%d' ) );
if ( $borrado ) {
return array( 'mensaje' => 'Recurso #' . $recurso_id . ' eliminado del inventario.', 'tipo' => 'warning' );
}
return array( 'mensaje' => 'Error al intentar eliminar el recurso.', 'tipo' => 'error' );
}- Permisos dentro de la función: aunque el formulario solo se muestre a administradores, la comprobación está también aquí, por si alguien llama a la función por otra vía.
- Formatos en el
update(): cinco marcadores (%s %s %d %f %s) para nombre, tipo, stock, precio por hora y email del responsable, en el mismo orden en que el formulario construye el array. - Error legible: si la actualización falla, se muestra
$wpdb->last_errorescapado, lo que ayuda mucho a depurar. - Borrado protegido: si hay reservas asociadas, el mensaje dice cuántas son y qué hacer (cancelarlas primero).
¿Quién puede cancelar una reserva?
La misma regla que para editar: administrador, usuario autorizado o el autor de la reserva. La cancelación borra la fila, y devuelve un aviso de tipo warning en vez de success, porque es una acción destructiva:
function crr_cancelar_reserva( $reserva_id ) {
global $wpdb;
$tabla_reservas = $wpdb->prefix . 'crr_reservas';
$current_user = wp_get_current_user();
$reserva = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $tabla_reservas WHERE id = %d", $reserva_id ) );
if ( ! $reserva ) {
return array( 'mensaje' => 'La reserva especificada no existe.', 'tipo' => 'error' );
}
if ( ! current_user_can( 'manage_options' ) && ! crr_es_usuario_autorizado() && (int) $reserva->user_id !== $current_user->ID ) {
return array( 'mensaje' => 'No tienes permiso para cancelar esta reserva.', 'tipo' => 'error' );
}
$borrado = $wpdb->delete( $reserva_id ? $tabla_reservas : $tabla_reservas, array( 'id' => $reserva_id ), array( '%d' ) );
if ( $borrado ) {
return array( 'mensaje' => 'Reserva #' . $reserva_id . ' cancelada con éxito.', 'tipo' => 'warning' );
}
return array( 'mensaje' => 'Error al intentar cancelar la reserva.', 'tipo' => 'error' );
}Fíjate en el patrón que se repite en todo el bloque: cada función comprueba permisos, valida, actúa y devuelve el mismo formato de array. Esa uniformidad es lo que permite que el formulario muestre cualquier resultado con un único bloque de aviso, algo que veremos en la tercera parte.
En la próxima entrega cierro la serie con la exportación a Excel y CSV con filtros, el calendario mensual, el formulario con selector de recursos, los shortcodes y el panel de administración.
Continuará en la parte III.
