Este archivo proporciona orientación a Claude Code (claude.ai/code) cuando trabaja con código en este repositorio.
HotelFlow es una aplicación PHP para administración hotelera usando arquitectura MVC. Gestiona habitaciones, huéspedes, check-in/out, servicios de baño, almacenamiento de equipaje, ventas, compras y permisos de usuarios.
Stack Tecnológico: PHP 8.2+, MariaDB 10.4+, Bootstrap 4, AdminLTE 3, jQuery 3, DataTables, TCPDF para generación de PDF
# Importar esquema de base de datos (solo estructura, sin datos)
mysql -u root -p < database/db_hotel_flow.sql
# Cargar datos de ejemplo (seeder opcional)
mysql -u root -p db_hotel_flow < database/seed.sql
# Copiar configuración de entorno
cp .env.example .env
# Editar .env con tus credenciales y URL
# Establecer permisos (Unix)
chmod -R 755 public/uploads/
chmod -R 755 libs/TCPDF-main/
# Login por defecto: admin@hotelflow.local / admin123 (el campo de login pide correo o número de documento, no username)Nota:
chmod 755solo funciona si el usuario que ejecuta PHP (p. ej.daemonen XAMPP,www-dataen Apache/Debian) es el propietario o pertenece al grupo propietario depublic/uploads/. Si el directorio pertenece a tu usuario de desarrollo y el servidor web corre como otro usuario/grupo, las subidas fallarán en silencio (move_uploaded_fileretorna false,ImagenService::procesarImagenresponde "Error al procesar la imagen" aunque el archivo sea válido). Verifica conps aux | grep httpdqué usuario corre Apache y ajusta el propietario/grupo depublic/uploads/en consecuencia, o usachmod -R 777solo en entornos de desarrollo local.
No se usa sistema de build, gestor de paquetes ni framework de testing. Los archivos PHP usan require/include directamente.
- Modelos (
/models/): Operaciones de base de datos via PDO con prepared statements. Todos los modelos usan la conexión Singleton deconfig/conexion.php. - Vistas (
/views/): Templates PHP. Layouts comunes enviews/layouts/(header.php, footer.php, session.php). - Controladores (
/controllers/): Un directorio por módulo. Cada módulo tiene su controlador principal más archivosajax_*.phpseparados para operaciones AJAX.
Conexión Singleton a Base de Datos:
$this->conexion = Conexion::getInstance()->getConnection();Endpoints AJAX: Los controladores exponen archivos separados para operaciones asíncronas:
crear_[entidad]_ajax.php- Operaciones de creaciónactualizar_[entidad]_ajax.php- Operaciones de actualizacióncambiar_estado_ajax.php- Cambios de estado
Control de Acceso Basado en Roles: Tres roles (Administrador, Recepcionista, Limpieza). Permisos verificados via AuthorizationService.php. El rol Administrador omite todas las verificaciones de permisos.
AuthorizationService.php- Verificación de permisos y RBACImagenService.php- Subida de imágenes, validación (límite 5MB), redimensionado con proporciónliteral.php- Conversión de números a letras para moneda boliviana
index.php actúa como front controller: carga sesión, verifica auth via requireLogin(), instancia controlador de dashboard según rol, incluye header/vista/footer.
Cada módulo funcional sigue el mismo patrón:
- Controlador:
controllers/[modulo]/[Modulo]Controller.php - Modelo:
models/[Entidad].php - Vistas:
views/[modulo]/(index.php, create.php, update.php, show.php) - JS:
public/js/modules/[modulo]/(index-[modulo].js, create-[modulo].js, update-[modulo].js) - CSS:
public/css/modules/[modulo].css
Módulos: auth, habitaciones, recepcion, servicios-bano, almacenamiento-equipaje, ventas, compras, productos, usuarios, limpieza, pisos, tipohabitaciones, tarifas, precios-equipaje, categorias, banos, dashboard
22 tablas usando InnoDB con codificación utf8mb4. Esquema en database/db_hotel_flow.sql (solo estructura y relaciones). Datos de ejemplo opcionales en database/seed.sql.
Tablas principales: usuarios, habitaciones, tipo_habitacion, pisos, persona, recepcion, productos, categoria, venta, detalleventa, compra, detallecompra, servicio_bano, almacenamiento_equipaje, asignaciones_limpieza, tarifas, permiso, permiso_usuario, intentos_login
Todos los assets en /public/:
css/lib/- Bootstrap, AdminLTE, FontAwesomecss/core/- Estilos personalizados de la aplicaciónjs/lib/- jQuery, Bootstrap JS, DataTables, SweetAlert2, Select2js/core/common-utils.js- Utilidades compartidasuploads/- Archivos subidos por usuarios (subdirectorios: habitaciones, productos, personas, usuarios)
Assets propios del módulo ($module_styles/$module_scripts), nunca <link>/<script> inline: toda vista debe declarar sus CSS/JS de módulo como arrays antes de include_once '../layouts/header.php' (o, si la vista se renderiza vía index.php como front controller —ver dashboards—, antes del include 'views/layouts/header.php' en index.php), nunca como <link>/<script> embebido en el cuerpo de la vista:
$module_styles = ['modulo/archivo']; // → public/css/modules/modulo/archivo.css, en el <head>
$module_scripts = ['modulo/archivo']; // → public/js/modules/modulo/archivo.js, antes de </body>header.php itera $module_styles para el <head> y footer.php itera $module_scripts al final del documento — ambos son opcionales e independientes (una vista puede tener solo uno de los dos). Soportan múltiples entradas (['servicios-bano/index-servicios-bano', 'servicios-bano/servicio-bano-rapido']). Antes de esta convención, ~26 vistas declaraban su CSS vía $module_styles pero su JS con un <script src="..."> suelto al final del archivo (o viceversa) — inconsistencia corregida en 2026-07-27 estandarizando ambas a array.
No dejar <style> embebido en las vistas: igual que con el JS inline, un <style> con reglas CSS dentro de un .php de views/ debe extraerse a su propio archivo en public/css/modules/[modulo]/[archivo].css y registrarse en $module_styles (creando el archivo si aún no existe para ese módulo/vista). Si el módulo ya tiene un CSS compartido entre varias vistas (create/update/show), añadir ahí en vez de crear uno nuevo por vista — seguir el mismo criterio "un archivo por módulo o por vista" que ya tenga ese módulo. Excepción: páginas standalone que no usan views/layouts/header.php/footer.php (ej. login.php, recepcion/recibo.php, almacenamiento-equipaje/recibo.php — recibos imprimibles), donde el <style> embebido es aceptable porque no hay compartición de layout que aplique la convención de módulos. En 2026-07-27 se extrajeron 6 bloques <style> de compras/create.php, ventas/create.php, almacenamiento-equipaje/show.php, recepcion/update.php, productos/show.php y habitaciones/show.php; de paso se detectó que recepcion/update.php ya declaraba $module_styles = ['recepciones/update-recepcion'] apuntando a un archivo CSS que nunca se había creado (link roto silencioso).
Pasar datos complejos de PHP a JS vía data-*, no <script> con múltiples variables: bloques que arman un objeto grande o declaran varias variables (dashboardData, productosDisponibles + clientesDisponibles + baseUrl) deben serializarse como JSON en uno o más atributos data-* de un elemento del DOM (htmlspecialchars(json_encode(...), ENT_QUOTES)), y el módulo JS los lee con JSON.parse(elemento.dataset.x) — igual que el caso de una sola constante (ver "No dejar JS inline..."), no hay excepción por tamaño del bloque. Se corrigió en views/dashboard/administrador_dashboard.php (objeto dashboardData completo → data-dashboard en un <div id="dashboard-admin-root">, leído por dashboard-admin.js) y en views/ventas/create.php (data-productos/data-clientes en el <form id="form-venta">, leídos por create-venta.js; la variable baseUrl no se usaba en el JS y se eliminó).
Opt-out de librerías pesadas por vista (views/layouts/header.php + footer.php): DataTables, Select2 y ChartJS se cargan por defecto en toda vista (para no romper nada existente), pero cada vista puede declarar, antes de incluir header.php, una variable booleana para omitir la librería que no usa:
$skip_datatables = true; // vista sin <table>/DataTable()
$skip_select2 = true; // vista sin class="select2" ni .select2()
$skip_chartjs = true; // vista sin gráficos Chart.jsheader.php calcula $cargar_datatables/$cargar_select2/$cargar_chartjs (CSS + JS del <head>) y footer.php reutiliza esas mismas variables para el JS del final del documento — el CSS y el JS de una librería siempre se cargan/omiten juntos, nunca por separado. Antes de agregar un $skip_* nuevo a una vista, verificar (grep) que ningún JS de módulo que esa vista incluya use la librería, incluyendo JS reusado entre create/update. Actualmente $skip_chartjs solo se omite en index.php (dashboard) cuando el rol no es Administrador — es el único lugar que usa Chart.js (dashboard-admin.js).
Grupos del sidebar (views/layouts/sidebar.php, incluido desde header.php): cada <li> de primer nivel con submenú (Recepción, Housekeeping/Limpieza, Servicios, Inventario y Compras, Ventas, Configuración, Administración) debe agrupar módulos afines por dominio funcional del PMS (no por "cuántos enlaces tiene"), y mantenerse en un puñado de enlaces (idealmente ≤4-5); si un grupo crece más allá de eso, dividirlo en grupos temáticos nuevos en vez de seguir apilando enlaces — pero un grupo con 1 solo enlace fuerte (rol dedicado, ej. Limpieza) también puede vivir como <li> de primer nivel sin submenú (link directo, sin <ul class="nav nav-treeview">) en vez de forzarlo dentro de otro grupo solo por tener pocos enlaces. El grupo original "Inventario y Ventas" llegó a acumular 8 enlaces (productos, categorías, ventas, clientes, compras) y se dividió en Productos, Ventas y Compras; en una reorganización posterior (2026-08-12) se alineó a la convención estándar de un PMS (triada Front Desk/Housekeeping/POS): Limpieza salió de "Servicios" a su propio grupo de primer nivel (rol dedicado), y Productos+Compras se fusionaron en Inventario y Compras (Compras alimenta el stock de Productos, no tenía sentido como grupo aparte de solo 2 enlaces). Cada grupo mantiene su propio <?php if (...) : ?> de visibilidad basado en los permisos reales de sus enlaces internos (no reutilizar la condición combinada de un grupo anterior).
views/layouts/sidebar.php (extraído de header.php el 2026-08-12): el sidebar vive en su propio archivo, incluido vía <?php require __DIR__ . '/sidebar.php'; ?> dentro de header.php — depende de $URL, $authService, $idusuariosesion y $APP_NAME, ya definidos por header.php antes del require (documentado en el docblock del archivo; los "undefined variable" que marca el linter estático ahí son falsos positivos, no un bug). Resalta el link/grupo activo automáticamente comparando la ruta actual ($_SERVER['REQUEST_URI'], normalizada con sidebarNormalizarRuta(): sin query string, sin / final, sin sufijo /index.php) contra la ruta de cada href — no contra el rol o el nombre del módulo. Cada enlace hoja usa sidebarClaseActiva($url, $sidebarRutaActual) para su clase active; cada grupo con submenú precalcula un booleano $sidebarGrupo*Activo (OR de sidebarEsActivo() sobre las rutas de sus hijos) para añadir menu-open/active al <li>/<a> del grupo, de forma que el submenú correcto aparezca ya expandido al cargar la página (no depende del clic para abrirse). Al añadir un enlace nuevo a un grupo existente, hay que sumarlo también al booleano $sidebarGrupo*Activo de ese grupo — si se omite, el link resalta bien pero el grupo no se auto-expande al entrar directo por URL. sidebarEsActivo()/sidebarClaseActiva() hacen match exacto de ruta; para un enlace cuya sección tiene subpáginas propias (create.php/update.php/show.php, ej. "Estado de habitaciones") usar sidebarEnSeccion($hrefBase, $sidebarRutaActual) (match por prefijo: $base o $base/…) tanto en la clase del enlace hoja como en el booleano del grupo, si no el resaltado/auto-expansión se pierde al entrar directo a una subpágina.
No dejar JS inline en las vistas (<script> con lógica de negocio o datos): toda vista debe delegar su JavaScript a un archivo dedicado en public/js/modules/[modulo]/[vista]-[modulo].js (ver "Organización de Módulos"), no a un <script> embebido con jQuery/validaciones/lógica de UI. Sin excepción: ni siquiera un <script> corto que solo declara una constante (const x = <?= json_encode(...) ?>) es aceptable — siempre existe un elemento del DOM natural (el <form>, el botón que dispara la acción, un <section data-module="...">) donde exponer el dato como atributo data-* y leerlo desde el módulo JS con dataset/JSON.parse. En una migración de 2026-07-27 se sacó el JS inline de 25+ vistas (habitaciones, productos, compras, ventas, tarifas, tipohabitacion, clientes, recepcion, dashboards de limpieza/recepcionista, login, etc.) a módulos dedicados; en una segunda pasada el mismo día se eliminaron también los últimos 5 <script> de una sola constante que se habían dejado como "excepción aceptable" (compras/create.php, servicios-bano/create.php, productos/update.php, productos/show.php, productos/buscar_codigo.php) — el dato se movió a data-* en el <form> o en el elemento disparador (ej. .cambiar-estado-link/#btnCambiarEstado con data-id/data-estado/data-nombre) y el JS lo lee de this.dataset/elemento.dataset en vez de una constante global. No queda ninguna excepción vigente para este patrón.
No poner lógica de negocio/consultas en las vistas: una vista solo debe llamar al controlador y pintar el resultado, nunca definir funciones PHP propias (function agruparHabitacionesPorPiso() {...} dentro de un .php de views/) ni instanciar modelos directamente para hacer cálculos. Se encontró y corrigió este patrón en views/recepcion/create.php: agrupaba habitaciones por piso con una función local y consultaba Recepcion::getHabitacionesDisponibles() directamente. Se trasladó a RecepcionController::crear() (que ahora devuelve habitaciones_disponibles/habitaciones_por_piso/pisos_unicos) y al método estático RecepcionController::agruparHabitacionesPorPiso().
El sistema de env personalizado en config/env.php soporta conversión de tipos:
true/falsese convierten a booleanonullse convierte a nullemptyse convierte a cadena vacía- Usar
env('KEY', 'default')para obtener valores
Tras una pasada de endurecimiento de seguridad en todo el código, todo endpoint o modelo nuevo debe seguir estos patrones:
-
Autorización en cada endpoint AJAX/acción: todo archivo
crear_*,actualizar_*,cambiar_estado_*,desactivar_*y similares debe llamar arequireLogin()y verificar permisos explícitamente:$idusuario = $_SESSION['usuario_id']; $auth = new AuthorizationService(); if (!$auth->esAdministrador($idusuario) && !$auth->puedeAccederModulo($idusuario, 'modulo')) { // responder con error (JSON si es AJAX, redirect+mensaje si es vista) }
No asumir que
requireLogin()sola es suficiente — la verificación de permisos por módulo es obligatoria. -
Permisos por cargo definidos por nombre, no por ID:
AuthorizationService::permisosPorCargo()es la única fuente de verdad (usada porcargoTienePermiso,obtenerPermisosPorCargoyobtenerPermisosAgrupados). Los cargos válidos sonAdministrador,Recepcionista,Limpieza(noadmin/recepcionista/vendedor— esos nombres no existen en la BD). Los permisos se listan por sunombreen la tablapermiso, no poridpermisohardcodeado. -
CSRF obligatorio, no opcional: no usar
if (isset($_POST['csrf_token']))como condición para verificar — el token debe verificarse siempre que el POST/GET provenga de una acción de escritura, tratando ausencia de token como inválido. Usar siempre las funciones globalesverifyCSRFToken()/generateCSRFToken()/regenerateCSRFToken()deviews/layouts/session.php(comparación conhash_equals()) — no duplicar esta lógica en controladores. Acciones de anulación por GET (ej.anular_venta.php) también deben llevarcsrf_tokenen la URL. -
Rate-limiting de login:
AuthController::login()usamodels/IntentoLogin.php(tablaintentos_login, append-only, sin FK ausuarios) para bloquear poridentificador(correo/documento, normalizado a lower/trim) e IP dentro de una ventana deslizante. El conteo es soloINSERT+SELECT COUNT, nunca un contador mutado in-place, por lo que no requiereSELECT ... FOR UPDATE. La verificación de bloqueo es fail-open: si la consulta de conteo falla por error de BD, no se bloquea el login (evita DoS por fallo de infraestructura), pero el error se loguea igual. Cada intento (éxito o fallo) se registra víaregistrar();limpiarAntiguos()purga filas antiguas y se invoca probabilísticamente (1/100) en cada request de login para evitar depender de un cron.- Umbrales configurables vía
.env, expuestos enconfig/config.phpbajo la clavelogin_rate_limit(mismo patrón queapp.timezoneenConexion:config.phpes la única fuente de verdad, los modelos no llaman aenv()directamente) y leídos en el constructor deIntentoLogin(no comoconst, para poder variar por entorno):LOGIN_RATE_LIMIT_VENTANA_MINUTOS(default15)LOGIN_RATE_LIMIT_MAX_FALLOS_IDENTIFICADOR(default5)LOGIN_RATE_LIMIT_MAX_FALLOS_IP(default20)LOGIN_RATE_LIMIT_PURGA_HORAS(default24)
- Umbrales configurables vía
-
Nunca confiar en valores del cliente para estado o dinero:
- Estados (activo/inactivo, etc.) se recalculan a partir del valor actual en BD (
$this->modelo->getById($id)), nunca a partir de unestado_actualrecibido del formulario/JS. - Totales de venta/compra (
totalventa,totalcompra) se recalculan en el modelo a partir de precios y cantidades reales de la BD (SELECT ... FOR UPDATE), y se persisten con unUPDATEtras insertar los detalles — no se confía en el total enviado por el cliente. - Al vender, se bloquea la fila del producto (
FOR UPDATE) para verificar stock y tomar el precio real antes de insertar el detalle. - Al anular una venta, se bloquea la fila de la venta (
FOR UPDATE) antes de leer su estado, para evitar anulaciones concurrentes duplicadas.
- Estados (activo/inactivo, etc.) se recalculan a partir del valor actual en BD (
-
Validación de archivos subidos por contenido real, no por metadata del cliente:
ImagenServiceverifica el tipo real conmime_content_type()+getimagesize()(no eltypeMIME enviado por el navegador), y la extensión final se deriva del tipo de imagen detectado (IMAGETYPE_*), no del nombre de archivo del cliente. -
No filtrar mensajes de excepción al usuario: en catches de controladores, loguear el detalle con
error_log()/un helperlogError()interno y devolver al usuario un mensaje genérico ("Ocurrió un error inesperado. Intente nuevamente."), nunca$e->getMessage()directamente en la respuesta. Esta regla también aplica a los modelos: en todo bloquecatch (PDOException $e)de/models/*.php,$this->lastErrornunca debe asignarse directamente desde$e->getMessage()ni desde$stmt->errorInfo()— hacerlo filtra nombres de tablas/columnas/constraints al cliente vía JSON, ya que los controladores concatenangetLastError()en la respuesta ('message' => 'Error al crear... ' . $this->modelo->getLastError()). Patrón correcto en cada catch:} catch (PDOException $e) { error_log('[' . static::class . '] ' . $e->getMessage()); $this->lastError = 'Ocurrió un error inesperado. Intente nuevamente.'; return false; // o [] / null según el método }
Los mensajes de negocio ya genéricos y escritos a mano (ej. "Stock insuficiente...", "No se puede eliminar la habitación porque tiene registros relacionados.") sí pueden asignarse directamente a
$this->lastError— no son fuga de información, describen una regla de negocio. -
Escapar salida HTML dinámica: usar
htmlspecialchars()al imprimir valores que vienen de BD/usuario en vistas (ej.$venta['metodopago']). -
date_default_timezone_sety zona horaria:config/config.phpes la única fuente de verdad paraTIMEZONE(con defaultAmerica/La_Pazvíaenv('TIMEZONE', 'America/La_Paz'));config/conexion.phpreutiliza$config['app']['timezone']sin duplicar el default. -
switchexhaustivos: añadir siempredefaulten losswitchsobre estados/tipos conocidos, aunque sea un no-op, para que el comportamiento sea explícito ante valores inesperados. (Se encontró y corrigió un caso sindefaultenRecepcionController::cambiarEstado()— protegido por unin_arrayprevio, pero incumplía la regla igual.) -
La regla de "nunca filtrar
getMessage()" también aplica a arreglos de errores acumulados, no solo a la última excepción: un controlador puede agregar un error genérico a un arreglo (agregarError()/$this->errores[]) y aun así filtrar información si en algún punto ese arreglo se llena con$e->getMessage()crudo y luego una vista lo concatena y lo muestra (p. ej. condie()). Se encontró este patrón enAlmacenamientoEquipajeController::getDatosParaRecibo()→views/almacenamiento-equipaje/recibo.php. Al añadir un mensaje a un arreglo de errores que después se muestra al usuario, tratarlo con el mismo cuidado que un$this->lastError: nunca crudo desde$e->getMessage(). -
Verificación de autorización explícita, no solo por composición interna: todo endpoint debe escribir literalmente
!$auth->esAdministrador($idusuario) && !$auth->puedeAccederModulo($idusuario, 'modulo'), incluso sipuedeAccederModulo()ya contempla administrador internamente (víatienePermisoNombre). Omitir elesAdministrador()explícito no es un agujero de seguridad hoy, pero rompe la convención documentada y dificulta auditar el código a futuro sin releer la implementación interna deAuthorizationService. Se corrigieron tres endpoints que solo verificabanpuedeAccederModulo()(controllers/limpieza/crear_asignacion_ajax.php,controllers/limpieza/actualizar_asignacion_ajax.php,controllers/servicios-bano/crear_servicio_rapido.php). En una auditoría posterior de todo el proyecto se encontró el mismo patrón (uso detieneAccesoCritico()/tieneAccesoModulo()en vez del patrón documentado) en las vistas de tarifas, tipohabitacion, ventas y compras — no solo en controladores AJAX, también en vistas (index.php/create.php/show.php/update.php); el patrón explícito aplica igual en vistas que en endpoints AJAX. En una pasada posterior (2026-08-12) se extendió el mismo barrido ahabitaciones,almacenamiento-equipaje,clientes,productos,servicios-bano,recepcion,limpieza,banos,categoriasyprecios-equipaje; de paso se reemplazó el patrón manualif (session_status() == PHP_SESSION_NONE) { session_start(); }(presente al inicio de varias vistas) porrequireLogin()—requireLogin()ya inicia la sesión si hace falta además de verificar autenticación, así que el check manual era redundante y, al no verificar auth, dejaba una ventana donde$_SESSION['usuario_id']podía no existir. -
Scoping por usuario + IDOR en módulos con dueño (ventas, compras): un Recepcionista/Vendedor no-administrador solo debe ver y operar sobre sus propios registros. Patrón: (1)
index.phpfiltra porWHERE idusuario = :idUsuariocuando el usuario no es administrador (los modelosVenta/Compraya exponían este query, solo faltaba usarlo desde la vista); (2)show.phpdebe verificar$registro['idusuario'] != $idusuariopara un no-administrador y redirigir con error — sin este chequeo, cambiar eliden la URL permite ver registros ajenos (IDOR); (3) acciones críticas (anular venta, cancelar/completar compra) restringidas aesAdministrador(). Los flujos duplicadosviews/ventas/nueva.phpyviews/compras/ingresar.php(creados originalmente para que un vendedor/comprador cree y quede en su propia vista) se eliminaron por ser casi idénticos acreate.php— el aislamiento por rol ahora lo da el scoping deindex.php/show.php, no una vista de creación separada. -
Orden de validación vs.
include header.phpen vistas conheader('Location: ...'): toda vista que redirija condicionalmente (ID inválido, registro no encontrado, IDOR, falta de permiso) debe completar todas esas validaciones con susheader('Location: ...'); exit;antes deinclude_once '../layouts/header.php', no después.header.phpya emite HTML (el<head>/navbar), así que cualquierheader()posterior falla en runtime con "headers already sent" y deja al usuario con una página rota en vez de redirigirlo. Se encontró y corrigió este bug enviews/compras/show.php(el chequeo IDOR de propiedad del registro estaba después delinclude);views/ventas/show.phpya seguía el orden correcto y sirvió de referencia. -
No redefinir clases utilitarias de Bootstrap (
.d-none,.d-flex, etc.) en un<style>de página: un<style>inline en una vista tiene la misma especificidad CSS que la hoja de Bootstrap, pero al cargar después en el DOM gana el empate — si una vista define su propia.d-none { display: none !important; }para uso local, rompe silenciosamente cualquier otro elemento de la página (incluyendo el layout/navbar) que dependa de la clase real de Bootstrap, incluidas sus variantes responsive (.d-sm-inline-block, etc.). Se encontró enviews/compras/create.php: una regla.d-nonelocal (sin uso real en esa vista) ocultaba permanentemente el texto "Sistema de Gestión" del navbar. Si una vista necesita mostrar/ocultar algo con clases propias, usar un nombre de clase que no choque con las utilidades de Bootstrap (ej..producto-oculto, prefijado por módulo). -
Folio de recepción (
pagos) como libro mayor,recepcion.montototal/montopagadocomo cache: desde el refactor PMS derecepcion(verREFACTOR_RECEPCION_PMS.md), la tablapagos(columnastipocargo|pago|reverso,concepto,idusuario,id_pago_reversado) es la fuente de verdad del folio de un huésped — append-only, nunca se haceUPDATEsobre un monto ya escrito; anular es insertar un reverso.recepcion.montototal/montopagadoson un cache recalculado dentro de la misma transacción cada vez que se inserta una línea enpagos(víaPago::registrarLinea()/registrarPago()/registrarCargo(), y también inline enRecepcion::cambiarHabitacion()al cargar la diferencia de tarifa). Ninguna escritura a esas dos columnas debe hacerse fuera de ese flujo — el método genéricoactualizar()deRecepcion.phptodavía aceptamontototal/montopagadoen su mapeo de campos dinámico (deuda técnica preexistente, no introducida por el refactor del folio); no pasar esas claves aactualizar(), usar siempre el camino depagos. -
Orden canónico de bloqueo en operaciones concurrentes de
recepcion: toda operación que toque el estado de una recepción y el de su(s) habitación(es) en la misma transacción bloquea primerorecepcion(FOR UPDATEvíaRecepcion::getByIdForUpdate()) y despuéshabitaciones— nunca al revés, para no deadlockear entrecrear(),actualizar(),cambiarEstado()ycambiarHabitacion(). Cuando se bloquean dos habitaciones a la vez (cambio de habitación), se bloquean en orden ascendente porid_habitacion, no en el orden origen→destino, por la misma razón. -
Partials de vista con docblock de dependencias: los partials en
views/[modulo]/partials/*.php(patrón usado primero ensidebar.php, luego enfolio.php,cambio-habitacion.php,tile-habitacion.php,tab-*.php,fila-movimiento.php,kpi-bar.phpderecepcion) documentan en un docblock al inicio del archivo qué variables debe definir el include-r ANTES delinclude/require(globals como$URL, y las variables de datos ya resueltas por el controlador). Un partial solo pinta HTML, nunca contiene lógica de negocio ni hace queries niswitch/usort/agrupaciones — eso vive en el controlador que arma los datos. -
Estado de recepción: helper único, no
switchen la vista. Etiqueta, clase de color (sufijo AdminLTE), clase de badge e icono FontAwesome de cualquier estado (de BD o derivado) salen solo deRecepcionController::estadoRecepcion(string $estado): array(['label','clase','badge','icono','orden']). Los estados derivados (no_show,salida_vencida) los resuelveRecepcionController::estadoDerivado(array $recepcion): stringcontra la hora actual. Ninguna vista/partial derecepcionvuelve a escribir unswitchsobre$recepcion['estado']ni a hardcodear unbadge-*de estado — se pasó de 3 vocabularios de color incoherentes (lista/show/tile) a uno solo en el refactor frontend de 2026-08-27. Lenguaje de color canónico: verde=disponible, amarillo=ocupada, azul=reservada, gris=por limpiar, rojo=mantenimiento/vencido. -
Módulo
recepcion= una vista con tabs, no varias.views/recepcion/index.phpes la única vista de panel (tabs Bootstrap#hoy/#mapa/#historial, hash persistido ensessionStorageporindex-recepciones.js);lista-recepciones.phpse eliminó y su DataTable espartials/tab-historial.php. El controlador exponepanel()(orquestahoy()+mapa()+kpis()+historial(), único que disparaliberarNoShows()), llamado una sola vez por carga. El room rack denso espartials/tile-habitacion.php(un tile parametrizadocol-6 col-sm-4 col-md-3 col-lg-2, doble dimensión ocupación+housekeeping) — reemplazó las 5 ramas decard-habitacion.php. -
Check-out y dinero de recepción salen siempre por el folio. Un solo camino de check-out:
controllers/recepcion/checkout_ajax.php→RecepcionController::checkout(), que verifica saldo víaPago::calcularSaldo()y, si hay saldo, cobra la líneapagoy finaliza en la misma transacción.update.phpno tiene ningún campo de dinero (montototal/montopagado/cambio) — la edición de estancia va por whitelist enactualizarEstancia(). No debe reaparecer ningúnhrefacheckout.php(archivo que nunca existió). -
Tipo de habitación y tarifa en las vistas de recepción: claves reales de
getById()+ helperetiquetaTarifa().Recepcion::getById()seleccionath.nombre as tipo_nombreyt.precio as precio_tarifa(además det.tipo_estancia). Las vistas (show.php,tarjeta-registro.php,recibo.php) usan$recepcion['tipo_nombre']para el tipo de habitación — NOtipo_habitacion(clave que nunca existió: hasta 2026-08-30 caía siempre al fallback "Estándar"). La etiqueta de tarifa ("1 día(s) · Bs 150.00") sale solo deRecepcionController::etiquetaTarifa(array $r): string(mismo patrón de helper único queestadoRecepcion()), nunca imprimiendotipo_estanciacrudo ("dias") en la vista. -
El proyecto es single-theme deliberado (sin dark-mode). HotelFlow es un PMS de back-office; no hay toggle de tema ni
body.dark-mode. Las reglas globales de "todo override de contraste necesita su contraparte dark" NO aplican aquí — un fix de contraste con un solo valor validado ≥4.5:1 sobre el fondo claro de AdminLTE está completo. No agregar bloques@media (prefers-color-scheme: dark)ni tokens dark "por si acaso". -
Áreas táctiles ≥44px en botones sin
.input-group: clase.rec-touch. Para botones de acción enbtn-group(historial) o sueltos (folio) donde no aplica el wrapper.input-group-text, usar.rec-touch(pseudo-elemento::beforeabsoluto conmax(100%,44px), mismo patrón que.rec-tile__action/.rec-actionbar) +min-width:2.5rem. No forzarmin-heighten la caja visible. El módulohabitacionestiene su equivalente.hab-touchenpublic/css/modules/habitaciones/habitaciones.css(mismo::before,min-width:44pxpara cumplir también el ancho AAA 2.5.5 en los botones-ícono delbtn-groupdel listado). -
Estado de habitación: helper único
HabitacionController::estadoHabitacion(string): array. Mismo patrón queRecepcionController::estadoRecepcion(): etiqueta, clase de color (sufijo AdminLTE),badge, icono FontAwesome yordende cualquier estado (disponible/ocupada/limpieza/mantenimiento) salen solo de ese helper — ninguna vista/JS del módulo vuelve a escribir unswitchsobre$habitacion['estado']ni a hardcodear unbadge-*.estadosHabitacion()devuelve el mapa completo para pintar filtros y acciones. Los estados del historial deshow.php(ocupación:reservado/en_curso/finalizado/cancelado; limpieza:pendiente/enprogreso/completada/verificada) salen deHabitacionController::badgeEstadoOcupacion()/badgeEstadoLimpieza()(['badge','label']), no de unswitchen la vista. Vocabulario de color alineado conrecepcion: verde=disponible, amarillo=ocupada, gris=por limpiar, rojo=mantenimiento. Es un helper aparte deestadoRecepcion()a propósito (vocabularios distintos). -
Cambio de estado de habitación: flujo único
public/js/modules/habitaciones/cambiar-estado-habitaciones.js. Listado (index.php) y detalle (show.php) comparten el mismo flujo: handler delegado sobre.cambiar-estado(condata-id/data-estado/data-estado-actual) → confirmación SweetAlert →POSTacontrollers/habitaciones/cambiar_estado.php→window.location.reload()en éxito (no se reconstruye la fila a mano). No debe reaparecer el modal#modalCambiarEstadoni unshow-habitaciones.jscon su propia copia del flujo. -
Cache-busting de assets de módulo (
views/layouts/header.php+footer.php): cada entrada de$module_styles/$module_scriptsse sirve con?v=<filemtime del archivo>(fallback$APP_VERSION) para que el navegador no sirva CSS/JS viejo tras una edición. No añadir un?v=manual en las vistas ni versionar a mano — el layout lo hace por archivo. -
Ventas = POS con
pagoventacomo ledger yventa.metodopago/pagorecibido/cambiocomo cache derivado: la tablapagoventa(append-only, CHECKmetodopago IN ('Efectivo','QR','Otros','Mixto')) es la fuente de verdad del cobro; las columnas de resumen deventasolo la reflejan y se recalculan en la misma transacción que inserta las líneas (Venta::crear(),Venta::anular()— que también marcaestado=0en suspagoventa). Igual que conrecepcion/pagos, ninguna escritura ametodopago/pagorecibido/cambiofuera de ese flujo. Vocabulario de métodos = Efectivo / QR / Otros (más el derivado'Mixto'); no inventar tarjeta/transferencia. -
Helpers únicos de ventas (patrón
estadoRecepcion/estadoHabitacion):VentaController::calcularTotales(),calcularInfoMetodoPago()(['texto','clase','es_mixto']),obtenerIconoMetodoPago(),esPagoMixto(),calcularDesgloseDetalles(),calcularResumenPagos(). Ninguna vista deventasvuelve a escribir unswitchde badge/total ni imprimetipo_estancia/metodopagocrudo. El listado adjuntainfo_metodo_pagoen batch víaVenta::getMetodosPagoPorVentas()(sin N+1 por fila), no consultando pagos por venta individual. -
idusuarioen ventas siempre de$_SESSION['usuario_id']: el endpoint de creación (yanular_venta.php) no aceptanidusuariodel cliente. El reciboviews/ventas/recibo.php(página standalone imprimible TCPDF 80mm, permitida por la excepción de<style>) verificarequireLogin()+ permisoventas+ propiedad (admin OR$venta['idusuario'] === $_SESSION['usuario_id']) antes de generar el PDF y muestra el estado de la venta (incl. anulada con aviso).
- Toda la interfaz está en Español
- Zona horaria: America/La_Paz (Bolivia)
- Generación de PDF usa TCPDF en
libs/TCPDF-main/ - Subida de imágenes soporta jpg, png, gif, webp con redimensionado automático
- Manejo de sesión y verificación de auth están en
views/layouts/session.php - Variable global
$URLcontiene la ruta base de la aplicación