Cuaderno Técnico: Plugin «Control y Reserva de Recursos» (CRR v1.12.0), parte II

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.

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?

  1. Recurso inexistente: si el id no existe en el inventario, devuelve false y no se reserva nada.
  2. 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.
  3. 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.
  4. 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.
  5. SUM(cantidad): suma las unidades ya ocupadas. Si más lo que pides no supera el stock, hay hueco.
  6. 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 );
}
  1. 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.
  2. Horas con decimales: 9:00 a 13:30 son 4,5 horas (h más los minutos entre 60).
  3. 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.
  4. 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?

  1. Primero disponibilidad: si no hay hueco, se corta aquí con un error y no se toca la base de datos.
  2. Precio en servidor: se recalcula siempre, aunque el formulario muestre una estimación.
  3. Array de formatos: $formato tiene un marcador por cada campo de $datos, en el mismo orden (%d enteros, %s textos, %f decimal). $wpdb los usa para escapar cada valor.
  4. 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.
  5. false !== $resultado: update() devuelve 0 si no cambió ninguna fila, y eso no es un error. Por eso se compara con false estricto.
  6. 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 );
	}
}
  1. 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.
  2. Cabecera HTML: Content-Type: text/html hace que wp_mail() envíe el cuerpo con formato en lugar de texto plano.
  3. Todo escapado: cada dato que viene de la base de datos pasa por esc_html() antes de entrar en el correo.
  4. Presupuesto opcional: la línea del precio solo aparece si $incluir_precio es verdadero, que es lo que decide la casilla «Enviar desglose de presupuesto» del formulario.
  5. 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' );
}
  1. 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.
  2. 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.
  3. Error legible: si la actualización falla, se muestra $wpdb->last_error escapado, lo que ayuda mucho a depurar.
  4. 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.

Control y Reserva de Recursos (CRR v1.12.0): cimientos, lógica de negocio y paneles de Control (cuaderno técnico, parte I)

Valencia, 31/10/2026, G.B.
El plugin Control y Reserva de Recursos (CRR v1.12.0) es una herramienta propia para gestionar salas, equipos y servicios con reservas, presupuesto automático, calendario y paneles de control, sin dependencias externas. Continuando con la serie de cuadernos técnicos, hoy hago un primer recorrido general por el plugin. Mi filosofía es siempre la misma: herramientas 100 % nativas, sin librerías pesadas de terceros y con los datos en mi propia base de datos. En las siguientes partes iré profundizando en cada pieza.

Bloque 1: Cabecera y seguridad del archivo

Lo primero es la cabecera que lee WordPress para mostrar el plugin en el listado:

<?php
/**
 * Plugin Name: Control y Reserva de Recursos (CRR)
 * Plugin URI:  https://indaga.net
 * Description: Sistema completo e independiente para la gestión y reserva de recursos, salas y servicios con presupuestos automáticos, vista de calendario, ficha desplegable de detalles, selector por tipo con buscador, gestión de inventario y panel frontal de filtrado por responsable y exportación avanzada.
 * Version:     1.12.0
 * Author:      Guillermo Beltrán Pilato
 * Author URI:  https://indaga.net
 * Text Domain: crr
 */

WordPress identifica un plugin por su ruta (carpeta y archivo principal), no por su nombre. Por eso mantengo siempre la misma carpeta y el mismo archivo entre versiones: si la ruta cambia, WordPress lo trata como un plugin distinto y no como una actualización. Entre versiones lo único que cambia es Version. El Text Domain crr está declarado, aunque ahora mismo el plugin no usa funciones de traducción.

Para evitar que alguien abra el archivo directamente, aplico la comprobación de siempre: si la constante ABSPATH no existe, el archivo se ha pedido por fuera de WordPress y corto la ejecución. A continuación defino una constante con la versión, que uso para controlar la base de datos:

if ( ! defined( 'ABSPATH' ) ) {
	exit; // Seguridad: Salir si se accede directamente
}

define( 'CRR_VERSION', '1.12.0' );

A diferencia de otros desarrollos, como Agenda Indaga, en el CRR no uso una clase con patrón Singleton. Es un conjunto de funciones con el prefijo crr_, lo que reduce muchísimo el riesgo de choques de nombres con otros plugins y mantiene todo directo. Expliqué mis motivos en este apunte sobre el patrón Singleton.

Bloque 2: Base de datos propia y actualización controlada

El sistema crea dos tablas propias, sin depender de nada externo: crr_recursos (el inventario de salas, equipos y servicios) y crr_reservas (cada reserva, enlazada a un recurso por recurso_id). Se despliegan al activar el plugin:

function crr_instalar() {
	global $wpdb;
	$charset_collate = $wpdb->get_charset_collate();

	$tabla_recursos = $wpdb->prefix . 'crr_recursos';
	$sql_recursos   = "CREATE TABLE $tabla_recursos (
		id bigint(20) NOT NULL AUTO_INCREMENT,
		nombre varchar(255) NOT NULL,
		tipo varchar(100) DEFAULT 'General' NOT NULL,
		stock int(11) DEFAULT 1 NOT NULL,
		precio_hora decimal(10,2) DEFAULT '0.00' NOT NULL,
		email_propietario varchar(100) NOT NULL,
		PRIMARY KEY  (id)
	) $charset_collate;";

	$tabla_reservas = $wpdb->prefix . 'crr_reservas';
	$sql_reservas   = "CREATE TABLE $tabla_reservas (
		id bigint(20) NOT NULL AUTO_INCREMENT,
		recurso_id bigint(20) NOT NULL,
		user_id bigint(20) NOT NULL,
		email_solicitante varchar(100) NOT NULL,
		fecha_inicio date NOT NULL,
		fecha_fin date NOT NULL,
		hora_inicio time NOT NULL,
		hora_fin time NOT NULL,
		cantidad int(11) DEFAULT 1 NOT NULL,
		observaciones text NULL,
		precio_estimado decimal(10,2) DEFAULT '0.00' NOT NULL,
		fecha_creacion datetime DEFAULT CURRENT_TIMESTAMP NOT NULL,
		PRIMARY KEY  (id)
	) $charset_collate;";

	require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
	dbDelta( $sql_recursos );
	dbDelta( $sql_reservas );

	update_option( 'crr_db_version', CRR_VERSION );
}
register_activation_hook( __FILE__, 'crr_instalar' );

Un detalle clave: dbDelta es muy estricto y espera exactamente dos espacios entre PRIMARY KEY y el paréntesis. Además, para no depender únicamente del gancho de activación (que no salta cuando actualizo el plugin por FTP), compruebo la versión en cada carga con la acción plugins_loaded:

function crr_comprobar_actualizacion_db() {
	if ( get_option( 'crr_db_version' ) !== CRR_VERSION ) {
		crr_instalar();
	}
}
add_action( 'plugins_loaded', 'crr_comprobar_actualizacion_db' );

Bloque 3: Lógica de reservas, validaciones y presupuesto automático

El punto de entrada del formulario valida el nonce, limpia cada dato de entrada y delega todo el trabajo en crr_procesar_reserva():

if ( isset( $_POST['crr_nueva_reserva'] ) && check_admin_referer( 'crr_reserva_nonce', 'crr_reserva_nonce' ) ) {
	$res_id      = isset( $_POST['reserva_id'] ) ? absint( $_POST['reserva_id'] ) : 0;
	$enviar_pres = isset( $_POST['enviar_presupuesto'] ) ? true : false;
	$alerta      = crr_procesar_reserva(
		absint( $_POST['recurso_id'] ),
		sanitize_email( $_POST['email_solicitante'] ),
		sanitize_text_field( $_POST['fecha_inicio'] ),
		sanitize_text_field( $_POST['fecha_fin'] ),
		sanitize_text_field( $_POST['hora_inicio'] ),
		sanitize_text_field( $_POST['hora_fin'] ),
		absint( $_POST['cantidad'] ),
		sanitize_textarea_field( $_POST['observaciones'] ),
		$res_id,
		$enviar_pres
	);
}

Antes de guardar nada, esa función comprueba la disponibilidad con crr_comprobar_disponibilidad(). Es la pieza que más me importa del plugin:

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 );
}

La consulta suma la cantidad de todas las reservas del mismo recurso que se solapan con la nueva, tanto en fechas como en horas, y comprueba que esa suma más lo solicitado no supere el stock. El parámetro $reserva_id_excluir permite editar una reserva sin que choque consigo misma.

Si hay disponibilidad, se calcula el presupuesto estimado con crr_calcular_precio_estimado(). Este es el extracto con la fórmula:

$dias  = $f_inicio->diff( $f_fin )->days + 1;
$horas = $intervalo->h + ( $intervalo->i / 60 );

if ( $horas <= 0 ) {
	$horas = 1;
}

$total = $dias * $horas * floatval( $recurso->precio_hora ) * absint( $cantidad );
return round( $total, 2 );

Es decir: días × horas diarias × tarifa por hora × cantidad. El resultado se guarda en el campo precio_estimado. Tengo pendiente revisar este cálculo: ahora mismo no valido que la fecha de fin sea posterior a la de inicio ni que la hora de fin sea posterior a la de inicio, y con las horas invertidas DateTime::diff devuelve igualmente un valor positivo, así que el precio saldría incorrecto.

  • Seguridad y sanitización: nonces con check_admin_referer, limpieza de variables con funciones nativas de WordPress (absint, sanitize_email, sanitize_text_field) y consultas preparadas con $wpdb->prepare
  • Alta y edición: la misma función sirve para ambas, según venga o no el ID de la reserva
  • Permisos: solo puede editar o cancelar una reserva quien la creó, un administrador o un usuario autorizado
  • Control de stock: se contrasta lo solicitado con las reservas solapadas y el stock del recurso antes de insertar o actualizar

Bloque 4: Visualización en calendario interactivo y paneles avanzados

Con el shortcode [crr_calendario] cualquier página se convierte en una cuadrícula mensual con las reservas del mes. Al hacer clic en una de ellas se abre una ficha flotante (modal) con los detalles clave, sin recargar la página, porque los datos viajan en un atributo data-reserva que lee JavaScript. El cambio de mes sí recarga la página, ya que navega mediante parámetros por URL (crr_mes y crr_anio).

Por otro lado, el panel frontal de gestión lo ven los administradores y los usuarios autorizados (una lista de emails que configuro en la pestaña «Usuarios Autorizados» del panel de administración). Permite filtrar por fechas, categoría, responsable del recurso y texto libre, con soporte de comodín *.

La exportación admite dos formatos: un CSV estándar y una variante «Excel», que en realidad es también un CSV, pero con codificación UTF-8 con BOM y punto y coma como delimitador, para que Excel muestre bien los acentos. Y la descarga respeta los filtros que tenga aplicados en ese momento.

Para mostrar el calendario en cualquier página basta con insertar el shortcode, sin parámetros:

[crr_calendario]

Y para el formulario de reservas con su panel de gestión, el shortcode es [crr_reservas].

Con esto cerramos este primer recorrido técnico por el CRR v1.12.0. Continuaré…

Qué es el patrón Singleton y porqué se usa en el plugin CRR

Valencia, 30/10/2026, G.B.
El patrón Singleton garantiza que una clase tenga una única instancia en toda la aplicación y ofrece un punto de acceso global a ella. A raíz de la preparación del cuaderno técnico del plugin Control y Reserva de Recursos (CRR), que publicaré en breve, cuento que, a diferencia de otros desarrollos anteriores como Agenda Indaga, en el CRR no utilizo una clase con patrón Singleton, sino un conjunto de funciones planas con el prefijo crr_ en un único archivo. Vamos a ver qué es este patrón y por qué decidí prescindir de él en este plugin.

Pero, ¿qué es eso del patrón Singleton?

El patrón Singleton (o patrón de instancia única) es un patrón de diseño de software pensado para que una clase tenga una sola instancia y para dar acceso global a ella.

Normalmente, en PHP puedes crear tantos objetos de una clase como quieras con la palabra clave new. El Singleton restringe esto con tres reglas clave:

  1. Constructor privado (private function __construct): impide que cualquier código externo cree nuevos objetos con new MiClase().
  2. Propiedad estática (private static $instance): almacena dentro de la propia clase la única instancia permitida.
  3. Método de acceso público estático (public static function get_instance()): es el único punto de entrada. Si la instancia no existe, la crea; si ya existe, devuelve la que estaba guardada.
class Mi_Plugin_Singleton {
    private static $instance = null;

    // 1. Constructor privado para evitar instanciación externa
    private function __construct() {
        // Inicialización del plugin (ganchos, acciones, etc.)
    }

    // 2. Método global para obtener la única instancia
    public static function get_instance() {
        if ( self::$instance === null ) {
            self::$instance = new self();
        }
        return self::$instance;
    }
}

// Arranque del plugin
Mi_Plugin_Singleton::get_instance();

Por qué se usa habitualmente en WordPress

  • Organización orientada a objetos: permite agrupar la lógica, las propiedades y los métodos del plugin en una estructura limpia y encapsulada
  • Ayuda a evitar duplicidades: reduce el riesgo de que los ganchos de WordPress (add_action, add_filter) se registren más de una vez si la clase se carga en distintos puntos del ciclo de vida
  • Punto de acceso único: cualquier parte del plugin puede obtener el mismo objeto con get_instance(), sin pasarlo de un sitio a otro

Por qué decidí no usarlo en el CRR

En el plugin Control y Reserva de Recursos (CRR) opté por un enfoque más directo: un conjunto de funciones con el prefijo único crr_ dentro de un único archivo. Así se ve el arranque en el código real del plugin:

function crr_instalar() {
    // Creación de las tablas con dbDelta, versión de la base de datos, etc.
}
register_activation_hook( __FILE__, 'crr_instalar' );

function crr_comprobar_actualizacion_db() {
    if ( get_option( 'crr_db_version' ) !== CRR_VERSION ) {
        crr_instalar();
    }
}
add_action( 'plugins_loaded', 'crr_comprobar_actualizacion_db' );
  • Simplicidad y legibilidad: se trata de un sistema autocontenido, centrado en la gestión de recursos y reservas mediante formularios e inventario. Un único archivo de funciones se lee de arriba abajo, sin saltar entre clases, y es fácil de mantener
  • Colisiones de nombres prácticamente eliminadas: al usar el prefijo crr_ en todas las funciones y constantes, el riesgo de choque con WordPress o con otros plugins activos es mínimo. No es una garantía absoluta, pero en la práctica basta

Los inconvenientes del Singleton

Conviene ser honestos: el Singleton tampoco está libre de críticas, y muchos desarrolladores lo consideran un antipatrón. Introduce estado global, dificulta las pruebas automatizadas y aumenta el acoplamiento entre componentes. Que sea habitual en WordPress no significa que sea siempre la mejor opción.

Cuándo sí y cuándo no

  • Singleton (o clases en general): si el plugin crece, tiene estado compartido o se compone de varios componentes que deben colaborar
  • Funciones planas con prefijo: si el plugin es pequeño, autocontenido y no necesita compartir estado entre piezas

Este post es, en sí, un apunte técnico; la primera parte del cuaderno técnico del CRR llegará en breve.

Singleton explicado en Refactoring Guru

Control y Reserva de Recursos (CRR): un sistema para la gestión de salas, equipos y servicios en WordPress

Valencia, 14/10/2026, G.B.
Os presento la versión 1.12.0 de mi plugin Control y Reserva de Recursos (CRR) para WordPress
. Una solución 100% nativa y completa diseñada para la gestión integral de inventarios, control de disponibilidad en tiempo real y reserva de espacios, materiales y servicios de forma sencilla e intuitiva desde la web.

Aquí va un ejemplo del sistema principal:

Debes iniciar sesión para realizar, editar o gestionar tus reservas.

Para ver la ocupación mensual en la vista de calendario:

« Anterior

Noviembre 2026

Siguiente »
Lun
Mar
Mié
Jue
Vie
Sáb
Dom
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
×

Ficha de Reserva

Recurso:
Solicitante:
Fechas:
Horario:
Cantidad:
Precio Estimado:
Observaciones:

Acerca de CRR

La gestión eficiente de espacios compartidos, equipamiento de alta gama o servicios especializados suele requerir herramientas complejas o plataformas de terceros que escapan al control directo del sitio web. Con el objetivo de mantener una solución 100% nativa, ligera y sin dependencias externas, nace Control y Reserva de Recursos (CRR), un plugin diseñado a medida para centralizar todo el ciclo de reservas de forma independiente y segura.

¿Qué ofrece Control y Reserva de Recursos (CRR)?

Este desarrollo unifica en un único paquete todo lo necesario para que tanto los usuarios como los administradores gestionen la disponibilidad de recursos de manera fluida:

1. Gestión de Inventario y tipologías flexibles

El administrador cuenta con un panel completo de inventario integrado directamente en la base de datos de WordPress. Los recursos se pueden clasificar en tres grandes categorías (ampliables):

  • Espacios / Salas

  • Material / Equipamiento

  • Servicios Cada recurso incluye control de stock o capacidad simultánea, precio por hora configurable y un correo electrónico asignado para el responsable directo del recurso.

2. Selector inteligente con buscador y filtros en tiempo real

A la hora de solicitar una reserva, los usuarios disfrutan de un selector visual moderno. Permite filtrar los recursos por tipo mediante botones interactivos y refinar la búsqueda al instante escribiendo en un cajón de texto inteligente que filtra por nombre o tipología sin necesidad de recargar la página.

3. Cálculo automático de presupuestos y notificaciones

El sistema calcula de manera automática el coste estimado de la reserva en función de los días, las horas exactas de inicio/fin y la cantidad seleccionada. Además, envía de forma automatizada correos de confirmación y actualización tanto al solicitante como al responsable del recurso, incluyendo opcionalmente el desglose detallado del presupuesto.

4. Vista de calendario mensual interactiva

Mediante el shortcode crr_calendario, cualquier página de la web puede transformarse en un calendario de ocupación mensual. Las reservas se listan ordenadas por horario y, al hacer clic sobre cualquiera de ellas, se despliega una ficha flotante de detalles con toda la información clave de la reserva.

5. Panel de control frontend para usuarios autorizados

No todo depende del panel de administración de WordPress. El plugin permite definir en los ajustes generales una lista de correos electrónicos autorizados. Estos usuarios, sin necesidad de ser administradores, podrán acceder desde el frontal a un Panel de Gestión Avanzado que incluye:

  • Filtros por rango de fechas (Desde / Hasta).

  • Filtro por categorías de recursos.

  • Búsqueda de texto libre compatible con comodines (por ejemplo, utilizando asteriscos como *proyector* o Servicio de*).

6. Exportación dual: Excel y CSV

Para la labor administrativa y de reporting, el panel incluye opciones de exportación instantánea (tanto de todo el histórico como de los datos filtrados en pantalla). El usuario puede elegir entre:

  • 📊 Excel (.csv optimizado): Formato configurado con delimitador de punto y coma (;) y codificación UTF-8 con BOM, garantizando que tildes, eñes y caracteres especiales se abran perfectamente al hacer doble clic en Microsoft Excel o LibreOffice Calc.

  • 📄 CSV estándar: Para integraciones universales.

Compromiso técnico de desarrollo

Siguiendo las directrices de desarrollo nativo de Indaga.net, CRR destaca por:

  • Cero dependencias externas: Sin librerías JavaScript pesadas ni llamadas a APIs de terceros.

  • Seguridad rigurosa: Uso estricto de consultas preparadas ($wpdb), sanitización de entradas, escapado de salidas y validación mediante Nonces de WordPress.

  • Experiencia de usuario optimizada: Incorporación de persistencia de scroll mediante sessionStorage, evitando los molestos saltos al principio de la página tras enviar formularios o aplicar filtros.

  • 📋 Ficha Técnica del Plugin

    • Nombre del Plugin: Control y Reserva de Recursos (CRR)

    • Versión: 1.12.0

    • Autor: Guillermo Beltrán Pilato

    • Sitio Web: Indaga.net

    • Compatibilidad WordPress: 6.0 o superior

    • Licencia: Software libre / Independiente

    • Descripción Breve: Sistema completo e independiente para la gestión y reserva de recursos, salas y servicios con presupuestos automáticos, vista de calendario interactiva, selector avanzado por categorías con buscador instantáneo, panel frontal de filtrado dinámico y exportación dual (Excel optimizado o CSV estándar).

    • Shortcodes Disponibles:

      • crr_reservas (entre corchetes []): Muestra el formulario de solicitud, inventario y panel de gestión frontal.

      • crr_calendario (entre corchetes []): Renderiza la vista de calendario mensual de ocupación.

  • Descargar plugin