Guía visual para entender la estructura: React + Node.js + Sequelize + Docker
Ubicación: frontend/
Es lo que ve el cliente. Aquí vive React, los componentes visuales (botones, formularios, menús) y las páginas. Su trabajo es pedirle datos al backend y mostrarlos bonitos.
Ubicación: backend/
Es donde se hace el trabajo real. Aquí vive Node.js con Express. Su trabajo es recibir las peticiones, procesarlas (leer/escribir en BD, hacer cálculos) y devolver una respuesta (JSON).
Archivos: docker-compose.yml / Dockerfile
Se asegura de que la cocina y el comedor funcionen. Le dice a Docker cómo construir y ejecutar tanto el frontend como el backend para que trabajen juntos.
El frontend nunca toca la base de datos directamente. Hace una llamada a una API y el backend devuelve la información en formato JSON.
El proyecto está dividido en dos aplicaciones independientes, cada una con su propio Dockerfile y package.json. El archivo central docker-compose.yml las une en una sola red para que trabajen como un equipo.
Esta carpeta contiene TODO el servidor del restaurante. Es una aplicación pura de Node.js que expone las llamadas de API.
Sigue el patrón de diseño Modelo-Vista-Controlador (MVC). De esta manera, cada archivo tiene una única y clara responsabilidad, facilitando su mantenimiento:
Esta carpeta contiene la aplicación SPA de React compilada y optimizada con Vite que tus usuarios y tu personal verán en sus dispositivos.
Sigue los estándares modernos de desarrollo en React: mantenemos **componentes visuales pequeños y modulares** dentro de components/, y las **vistas completas asociadas a rutas** dentro de pages/. Organizar las páginas por roles (cliente, mesero, cocina, gerente) hace que el flujo de trabajo de cada usuario sea modular e independiente.
Con este mapa de archivos, ya tienes los tres pilares del restaurante sumamente claros:
Ubicado en backend/src/. El código está organizado por su responsabilidad.
Aquí se configura Express, se conecta a la base de datos y se "montan" las rutas. Es el punto de partida del servidor.
Su ÚNICO trabajo es recibir la petición (ej. GET /api/mesas) y decidir qué "controller" debe encargarse. Archivos simples y perfectos para empezar a leer.
Aquí está la lógica real. Recibe la petición del "camarero", procesa lo necesario (consulta la BD, valida datos) y devuelve la respuesta. Son el corazón del backend.
Definen la estructura de datos (tablas). Un modelo como Mesa.js le dice a Sequelize que una mesa tiene un número, capacidad, etc. El controller los usa para no escribir SQL directamente.
Funciones que se ejecutan antes de llegar al controller. Se usan para verificar autenticación (auth.js), permisos (authorize.js) o validar datos (validate.js).
Funciones de ayuda usadas en varios controladores. Ej: promociones.js puede tener la lógica para calcular descuentos usada tanto en pedidos como en carrito.
Este archivo es la cocina principal. Configura todo Express y le dice qué hacer con cada petición. Te lo explico línea por línea.
const express = require('express');
const cors = require('cors');
const path = require('path');
// Importa todos los archivos de rutas que vimos antes
const authRoutes = require('./routes/auth.routes');
const bienvenidaRoutes = require('./routes/bienvenida.routes');
// ... etc ...
const app = express(); // Crea la aplicación Express
Aquí no hay magia: importa las herramientas (express, cors, path) y los archivos de rutas que tú me enseñaste (mesas.routes, etc.).
// Habilita CORS para que el frontend (en otro puerto) pueda llamar a la API
app.use(cors({ origin: process.env.CORS_ORIGIN }));
// Permite que Express entienda JSON en las peticiones (req.body)
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
Esto es configuración estándar. cors evita errores de seguridad en el navegador.
// Sirve las imágenes de productos desde la carpeta /public/media
app.use('/media', express.static(path.join(__dirname, '../public/media')));
Importante: Esta línea hace que las imágenes de tu carpeta public/media/images/ sean accesibles desde el navegador en la ruta /media. Por ejemplo, /media/matchalatte.jpg apuntará a public/media/images/matchalatte.jpg. Así funciona igual que en Django.
// Monta las rutas. Cada prefijo se conecta con su archivo de rutas correspondiente
app.use('/api/auth', authRoutes); // Login, logout
app.use('/api/bienvenida', bienvenidaRoutes); // Flujo QR inicial del cliente
app.use('/api/mesas', mesasRoutes); // CRUD de mesas y sesiones
app.use('/api/menu', menuRoutes); // Productos, categorías, etc.
app.use('/api/pedidos', pedidosRoutes); // Crear pedidos, cambiar estado
app.use('/api/cocina', cocinaRoutes); // Pantalla KDS
app.use('/api/gerente', gerenteRoutes); // CRUDs administrativos, reportes
app.use('/api/cliente', clienteRoutes); // Vistas del cliente (carrito, pedidos)
app.use('/api/mesero', meseroRoutes); // Panel del mesero (cobro, mapa)
app.use('/api/catalogo', catalogoRoutes); // Catálogos (métodos de pago, etc.)
Aquí está la clave. Cuando el frontend llama a GET /api/mesas/estado, Express busca en el archivo mesas.routes.js una ruta que coincida con /estado.
// Si ninguna ruta coincide, devuelve un error 404 en JSON
app.use((req, res) => {
res.status(404).json({ error: 'Ruta no encontrada' });
});
// Si algo falla en el código, este middleware captura el error
app.use((err, req, res, next) => {
console.error(err.message);
res.status(500).json({ error: 'Error interno del servidor' });
});
module.exports = app;
Los middlewares de error son los "salvavidas". Si un controller falla (por ejemplo, la base de datos no responde), este código evita que el servidor entero se caiga y devuelve un error claro.
Te preguntabas qué es la carpeta services. Es una utilidad, no un controller ni un modelo. Este archivo en concreto ayuda al KDS (Pantalla de Cocina) a decidir qué pedidos mostrar.
// 'cocina' ve cocina+ambos; 'bar' ve bar+ambos; sin area → todos.
function areasDe(area) {
if (area === 'bar') return new Set(['bar', 'ambos']);
if (area === 'cocina') return new Set(['cocina', 'ambos']);
return new Set(['cocina', 'bar', 'ambos']);
}
// Retorna true si el detalle (producto) pertenece al área del KDS.
function detalleEsDeArea(detalle, areasSet) {
return areasSet.has(detalle.producto?.categoria?.area);
}
module.exports = { areasDe, detalleEsDeArea };
Explicación:
area (cocina, bar o ambos)./cocina/kds/?area=cocina) pide los pedidos, solo quiere ver los de cocina y los que son "ambos".Es un ejemplo perfecto de "utilidad": código que se usa en varios sitios, no recibe peticiones HTTP directamente.
Los archivos en utils/ son lo mismo que services/: funciones auxiliares. En tu proyecto tienes:
Probablemente registra acciones importantes (quién pagó, quién canceló un pedido).
La lógica de descuentos que se aplica a los pedidos. Se usa tanto cuando el cliente pide desde su app como cuando el mesero hace un pedido asistido.
Lee la configuración general del restaurante (horarios, umbrales del semáforo KDS).
Posamente maneja la lógica de elegir la mejor imagen para un producto.
Esto es lo que cubriremos en este bloque interactivo:
Este archivo es un gran ejemplo de un controlador. Contiene la lógica real para las operaciones con mesas que vimos en las rutas.
exports.listar - Obtener todas las mesas para el mapaexports.listar = async (req, res, next) => {
try {
// 1. Va a la base de datos y pide TODAS las mesas (findAll).
// Pero no solo las mesas: con 'include' le pide que también traiga
// la información relacionada: ubicación, mesero asignado, alertas activas
// y las sesiones activas con sus pedidos "listos".
const mesas = await Mesa.findAll({
include: [
{ model: UbicacionMesa, as: 'ubicacion' },
{ model: Empleado, as: 'mesero', attributes: ['id_empleado', 'nombre'] },
{ model: AlertaMesero, as: 'alertas', where: { atendida: false }, required: false },
{
model: SesionCliente, as: 'sesiones',
where: { estado: 'activa' }, required: false,
include: [{
model: Pedido, as: 'pedidos',
where: { estado: 'listo' }, required: false,
}],
},
],
order: [['numero_mesa', 'ASC']], // Ordenadas por número
});
// 2. Consulta aparte: busca todas las solicitudes de pago que están "pendientes"
// para saber qué mesas están esperando cobro.
const estadoPendiente = await EstadoSolicitud.findOne({ where: { descripcion: 'pendiente' } });
const solicitudesPendientes = estadoPendiente
? await SolicitudPago.findAll({ where: { id_estado_solicitud: estadoPendiente.id } })
: [];
const mesasConSolicitud = new Set(solicitudesPendientes.map(s => s.id_mesa));
// 3. Procesa los datos para el frontend.
// Toma los datos crudos y los convierte en un objeto más simple.
const result = mesas.map(m => {
const sesiones = m.sesiones || [];
const alertas = m.alertas || [];
const pedidosListos = sesiones.reduce((acc, s) => acc + (s.pedidos || []).length, 0);
const tieneSolicitud = mesasConSolicitud.has(m.id);
// Lógica para decidir el color y etiqueta de la mesa en el mapa
let estado_visual;
if (m.estado === 'libre') estado_visual = 'libre';
else if (alertas.length > 0) estado_visual = 'alerta';
else if (tieneSolicitud) estado_visual = 'cobrando';
else if (pedidosListos > 0) estado_visual = 'listo';
else estado_visual = 'ocupada';
return {
id: m.id,
numero_mesa: m.numero_mesa,
estado: m.estado,
estado_visual,
pin_actual: m.pin_actual,
capacidad: m.capacidad,
ubicacion: m.ubicacion,
alertas,
pedidosListos,
nota_cierre: m.nota_cierre || '',
};
});
// 4. Envía la respuesta al "camarero" (ruta).
res.json(result);
} catch (err) { next(err); } // Si algo falla, pasa el error al middleware de errores
};
Explicación sencilla: Esta función es la que se ejecuta cuando el mesero ve el mapa de mesas. Recopila información de muchas tablas a la vez, la procesa y devuelve un JSON limpio listo para pintar en la pantalla.
Este archivo configura Axios, una librería para hacer llamadas HTTP desde el frontend de forma sencilla.
import axios from 'ajax';
const api = axios.create({ baseURL: '/api' });
// Interceptor de REQUEST: Se ejecuta ANTES de cada llamada.
// Inyecta automáticamente el token JWT del staff en los headers.
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
// Interceptor de RESPONSE: Se ejecuta al recibir la respuesta.
// Si el servidor dice 401 (no autorizado), borra la sesión local y redirige al login.
api.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('empleado');
window.location.href = '/login';
}
return Promise.reject(err);
}
);
export default api;
Explicación sencilla: Imagina que api es un mensajero. Antes de salir a hacer una entrega (request), siempre mete en el bolsillo la tarjeta de identificación (token). Si cuando vuelve le dicen que la tarjeta no es válida (error 401), borra todo y te manda a la puerta de entrada (/login).
Este es un componente de React que define la estructura común de todas las pantallas del cliente (menú, carrito, pedidos).
export default function ClienteLayout({ children, idSesion, carritoCount = 0 }) {
// ... hooks de navegación ...
// Función para llamar al mesero (botón de ayuda)
const solicitarAyuda = async (e) => {
e.preventDefault();
const token_sesion = sessionStorage.getItem('token_sesion');
if (!token_sesion) return;
try {
// Llama al endpoint que crea una alerta en la mesa
await api.post('/cliente/alerta', { token_sesion, tipo: 'ayuda', mensaje: '' });
} catch { /* ignore */ }
};
return (
{/* Contenedor principal con estilo de teléfono */}
{children} {/* Aquí se renderiza el contenido de cada página */}
{/* Barra de navegación inferior */}
{idSesion && (
)}
);
}
Explicación sencilla: Este componente es como el "molde" de una pantalla de cliente. Tiene un área de contenido (donde va cada página) y una barra de navegación inferior fija. El botón de "Ayuda" llama directamente al backend usando api.js.
Este middleware verifica que el usuario que hace la petición tenga el rol adecuado.
const authorize = (...roles) => (req, res, next) => {
// 'req.user' fue añadido por el middleware 'auth.js' (que se ejecutó antes)
if (!req.user) return res.status(401).json({ error: 'No autenticado' });
// Comprueba si el rol del usuario está en la lista de roles permitidos
if (!roles.includes(req.user.rol)) return res.status(403).json({ error: 'Acceso denegado' });
next(); // Todo bien, pasa al siguiente paso (el controlador)
};
// Atajos para los roles más comunes
const requireMesero = authorize('mesero', 'gerente', 'admin');
const requireCocina = authorize('cocina', 'gerente', 'admin');
const requireGerente = authorize('gerente', 'admin');
module.exports = Object.assign(authorize, { requireMesero, requireCocina, requireGerente });
Explicación sencilla: Este es el guardia de la puerta de la cocina. Cuando llega una petición de un mesero, revisa su gafete (req.user.rol) y solo lo deja pasar si su rol está en la lista de invitados. Por ejemplo, requireMesero deja pasar a meseros, gerentes y admins.
Este middleware autentica a los clientes (no al staff). Los clientes no usan JWT, sino una cookie especial llamada mm_session.
const cookie = require('cookie');
const { SesionCliente } = require('../models');
module.exports = async (req, res, next) => {
try {
// Lee la cookie 'mm_session' del navegador
const cookies = cookie.parse(req.headers.cookie || '');
const token = cookies.mm_session;
if (!token) return res.status(401).json({ ok: false, error: 'Sesión requerida' });
// Busca la sesión del cliente en la base de datos
const sesion = await SesionCliente.findOne({ where: { token_cookie: token } });
if (!sesion) return res.status(401).json({ ok: false, error: 'Sesión inválida' });
// Si todo es correcto, deja la sesión disponible para el controlador
req.sesionCliente = sesion;
req.mmToken = token;
next();
} catch (err) { next(err); }
};
Explicación sencilla: Cuando un cliente escanea el QR, se le da una "pulsera" con un código (la cookie mm_session). Este middleware es el portero que lee esa pulsera, verifica que sea válida y, si es correcta, deja pasar al cliente al área de pedidos.
mesas y empleados en tu base de datos, usando Sequelize. No son rutas ni controladores, sino el "molde" que Sequelize usa para crear las tablas y leer/escribir datos.
Este archivo define exactamente qué columnas tiene la tabla mesas en tu base de datos.
const { DataTypes } = require('sequelize');
const { v4: uuidv4 } = require('uuid');
module.exports = (sequelize) => {
return sequelize.define('Mesa', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
numero_mesa: { type: DataTypes.INTEGER, allowNull: false, unique: true },
capacidad: { type: DataTypes.INTEGER, allowNull: false },
codigo_qr: { type: DataTypes.STRING(255), unique: true },
pin_actual: { type: DataTypes.STRING(60), allowNull: true },
estado: {
type: DataTypes.ENUM('libre', 'ocupada'),
defaultValue: 'libre',
},
nota_cierre: { type: DataTypes.STRING(255), defaultValue: '' },
}, {
tableName: 'mesas',
hooks: {
beforeCreate(m) {
if (!m.codigo_qr) {
m.codigo_qr = `mesa-${m.numero_mesa}-${uuidv4().replace(/-/g, '').slice(0, 8)}`;
}
},
},
});
};
Explicación línea por línea:
sequelize.define('Mesa', { ... }) – Le dice a Sequelize: "Crea una tabla llamada mesas con estas columnas". El primer argumento ('Mesa') es el nombre interno del modelo, y con tableName: 'mesas' le decimos que la tabla en la base de datos se llama mesas.id: Número único que identifica cada mesa. autoIncrement: true significa que la base de datos genera automáticamente 1, 2, 3...numero_mesa: El número visible (Mesa 1, Mesa 2). unique: true impide que haya dos mesas con el mismo número.capacidad: Cuántas personas caben.codigo_qr: Un código único para el QR de la mesa. No se pide al crear la mesa, se genera solo (lo verás abajo en hooks).pin_actual: El PIN de 4 dígitos que los clientes usan para unirse a la mesa. Puede ser nulo si la mesa está libre.estado: Puede ser 'libre' o 'ocupada'. defaultValue: 'libre' significa que una mesa nueva empieza libre.nota_cierre: Un campo de texto para poner una nota cuando la mesa se cierra manualmente.hooks – Son funciones que se ejecutan automáticamente en ciertos momentos:
beforeCreate(m): Justo antes de que una mesa se guarde por primera vez. Si no se le ha puesto un codigo_qr, aquí se genera uno automáticamente usando el número de mesa y un código aleatorio. Esto asegura que cada mesa tenga un QR único.¿Cómo se conecta esto con el controlador listar que vimos antes?
Cuando haces Mesa.findAll({ include: [...] }), Sequelize lee este archivo para saber qué columnas buscar en la tabla mesas y cómo se relacionan con otras tablas (como SesionCliente o UbicacionMesa). Las relaciones no están definidas en este archivo, sino en un archivo central models/index.js, pero eso lo veremos después.
Este modelo es muy similar, pero incluye un detalle importante: seguridad de contraseñas.
const { DataTypes } = require('sequelize');
const bcrypt = require('bcryptjs');
module.exports = (sequelize) => {
const Empleado = sequelize.define('Empleado', {
id_empleado: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
nombre: { type: DataTypes.STRING(100), allowNull: false },
usuario: { type: DataTypes.STRING(50), allowNull: false, unique: true },
password:{ type: DataTypes.STRING(255), allowNull: false },
rol: {
type: DataTypes.ENUM('mesero', 'cocina', 'gerente', 'admin'),
defaultValue: 'mesero',
},
activo: { type: DataTypes.BOOLEAN, defaultValue: true },
is_active: { type: DataTypes.BOOLEAN, defaultValue: true },
is_staff: { type: DataTypes.BOOLEAN, defaultValue: false },
}, {
tableName: 'empleados',
hooks: {
beforeSave(e) {
if (e.nombre) e.nombre = e.nombre.toUpperCase();
},
async beforeCreate(e) {
e.password = await bcrypt.hash(e.password, 10);
},
},
});
Empleado.prototype.verificarPassword = function (plain) {
return bcrypt.compare(plain, this.password);
};
return Empleado;
};
Explicación adicional sobre Empleado.js:
hooks de Empleado:
beforeSave(e): Antes de guardar cualquier empleado, su nombre se convierte automáticamente a MAYÚSCULAS. Así siempre se guarda igual, sin importar cómo lo escriba el gerente.beforeCreate(e): Antes de crear un empleado nuevo, su contraseña se encripta con bcrypt. Así nunca se guarda la contraseña real, solo una versión cifrada por motivos de seguridad informática.prototype.verificarPassword: Es una función que se añade al prototipo del modelo Empleado. Cuando alguien intenta iniciar sesión, el controlador llama a esta función para comparar la contraseña que escribe el usuario (en texto plano) con la versión cifrada guardada en la base de datos usando bcrypt.compare.Este archivo es el "directorio telefónico" de todos tus modelos. Su función principal es doble:
Mesa.js, Empleado.js) y los activa con la conexión a la base de datos.Mesa tiene muchas SesionCliente).Es la pieza central que permite que los include que viste en los controladores (mesas.controller.js) funcionen. Sin este archivo, no podrías hacer consultas que unan varias tablas.
const sequelize = require('../config/database');
// Catálogos
const ModalidadIngreso = require('./catalogs/ModalidadIngreso')(sequelize);
const MetodoPago = require('./catalogs/MetodoPago')(sequelize);
// ... etc.
// Modelos principales
const Empleado = require('./Empleado')(sequelize);
const Mesa = require('./Mesa')(sequelize);
// ... etc.
require('../config/database'): Obtiene la conexión a la base de datos que configuramos en config/database.js.require('./Mesa')(sequelize): Aquí ocurre la magia. Cada modelo es una función que recibe la conexión sequelize y devuelve la definición de la tabla. Es como decirle: "Mesa, aquí tienes la conexión a la base de datos, defínete".Al hacer esto, todos los modelos quedan listos para usarse, y Sequelize ya sabe que existen tablas como mesas, empleados, etc.
Aquí es donde se le dice a Sequelize cómo se relacionan las tablas. Te explico los tipos principales con ejemplos concretos de tu proyecto.
Mesa y SesionCliente// Una Mesa tiene MUCHAS Sesiones de Cliente
Mesa.hasMany(SesionCliente, { foreignKey: 'id_mesa', onDelete: 'RESTRICT', as: 'sesiones' });
// Una Sesión de Cliente pertenece a UNA Mesa
SesionCliente.belongsTo(Mesa, { foreignKey: 'id_mesa', as: 'mesa' });
Explicación:
Mesa.hasMany(SesionCliente, ...): Significa que una mesa (ej. la Mesa 1) puede tener muchas sesiones de cliente. En la base de datos, la tabla sesiones_cliente tiene una columna id_mesa que apunta a la mesa correspondiente.SesionCliente.belongsTo(Mesa, ...): Es el lado inverso. Una sesión de cliente siempre pertenece a una mesa. El foreignKey: 'id_mesa' le dice a Sequelize qué columna usar para la conexión.as: 'sesiones': Este es el alias. Es el nombre que usarás en los controladores para incluir los datos relacionados. Si miras el controlador mesas.controller.js, verás include: [{ model: SesionCliente, as: 'sesiones' }]. Ese as: 'sesiones' coincide con el definido aquí.onDelete: 'RESTRICT': Esta es una regla de integridad. Significa que no se puede borrar una mesa si tiene sesiones activas. Primero debes cerrar las sesiones. Así se evitan errores graves.SET NULL: Empleado y MesaEmpleado.hasMany(Mesa, { foreignKey: 'id_mesero_asignado', onDelete: 'SET NULL' });
Mesa.belongsTo(Empleado, { foreignKey: 'id_mesero_asignado', as: 'mesero' });
Explicación:
onDelete: 'SET NULL' simplemente deja el campo id_mesero_asignado como NULL en la mesa, indicando que ya no tiene mesero asignado.as: 'mesero' permite que en el controlador puedas hacer include: { model: Empleado, as: 'mesero' } para obtener los datos del mesero asignado a la mesa.Producto y GrupoModificadorProducto.belongsToMany(GrupoModificador, {
through: 'producto_grupos',
foreignKey: 'id_producto',
otherKey: 'id_grupo',
as: 'grupos_modificadores',
});
GrupoModificador.belongsToMany(Producto, {
through: 'producto_grupos',
foreignKey: 'id_grupo',
otherKey: 'id_producto',
as: 'productos',
});
Explicación:
through: 'producto_grupos' indica que esta relación necesita una tabla intermedia en la base de datos (que Sequelize crea automáticamente) para almacenar las conexiones.as: 'grupos_modificadores' o as: 'productos' para incluir estos datos.Ahora ya puedes ver el círculo completo:
models/index.js define que Mesa tiene sesiones (alias).controllers/mesas.controller.js usa ese alias:
const mesas = await Mesa.findAll({
include: [{ model: SesionCliente, as: 'sesiones' }]
});
index.js, sabe que sesiones usa la columna id_mesa, y arma una consulta SQL que une las dos tablas.Vamos a seguir el camino exacto, desde que el cliente toca el botón en su celular hasta que el mesero ve la notificación.
Todo empieza en el frontend, en la página de Pedidos del cliente.
Archivo: frontend/src/pages/cliente/Pedidos.jsx
const pedirCuenta = async () => {
const res = await api.post('/cliente/cuenta', {
tipo: 'individual', // o 'grupal'
});
// Muestra un mensaje de éxito
};
Explicación:
api.post es el teléfono que configuramos en services/api.js. Llama al backend con el prefijo /api automáticamente.POST /api/cliente/cuenta.mm_session.Antes de que el controlador haga algo, el middleware mmSession.js intercepta la petición.
Archivo: backend/src/middleware/mmSession.js
const cookies = cookie.parse(req.headers.cookie || '');
const token = cookies.mm_session;
const sesion = await SesionCliente.findOne({ where: { token_cookie: token } });
req.sesionCliente = sesion; // ← El controlador usará esto
Explicación:
mm_session enviada por el navegador.req.sesionCliente) dejándola disponible para el controlador.Archivo: backend/src/routes/cliente.routes.js
router.post('/cuenta', mmSession, ctrl.pedirCuenta);
Explicación:
POST /api/cliente/cuenta.mmSession para autenticar y validar la pulsera de identificación del cliente.ctrl.pedirCuenta.Archivo: backend/src/controllers/cliente.controller.js
exports.pedirCuenta = async (req, res, next) => {
try {
const sesion = req.sesionCliente; // Gracias al middleware
const tipo = req.body.tipo; // 'individual' o 'grupal'
// 1. Buscar el estado "pendiente" en el catálogo
const estadoPendiente = await EstadoSolicitud.findOne({
where: { descripcion: 'pendiente' }
});
// 2. Calcular el total según el tipo
let total = 0;
if (tipo === 'individual') {
const pedidos = await Pedido.findAll({
where: { id_sesion: sesion.id, estado: { [Op.ne]: 'cancelado' } },
include: [{ model: DetallePedido, as: 'detalles' }]
});
total = pedidos.reduce((acc, p) =>
acc + p.detalles.reduce((s, d) => s + parseFloat(d.subtotal_calculado), 0), 0
);
} else {
// grupal: sumar todas las sesiones activas de la mesa
// ...
}
// 3. Crear la solicitud de pago
await SolicitudPago.create({
id_mesa: sesion.id_mesa,
id_sesion: tipo === 'individual' ? sesion.id : null,
tipo,
total_mesa: total,
id_estado_solicitud: estadoPendiente.id,
});
res.json({ ok: true, mensaje: 'Solicitud enviada' });
} catch (err) { next(err); }
};
Explicación:
req.sesionCliente previamente resueltos por el middleware de sesión.EstadoSolicitud, Pedido y DetallePedido.SolicitudPago para la base de datos.El panel del mesero realiza un sondeo constante (*polling*) de estado cada 3 segundos.
Archivo: frontend/src/pages/mesero/MeseroPanel.jsx
const cargarSolicitudes = async () => {
const res = await api.get('/mesero/mapa/estado');
// res.data contiene las mesas con sus solicitudes de cobro vigentes
};
Y el controlador encargado de resolver esta actualización en tiempo real:
Archivo: backend/src/controllers/mesero.controller.js
exports.mesasEstado = async (req, res) => {
const mesas = await Mesa.findAll({
include: [
{ model: SesionCliente, as: 'sesiones', where: { estado: 'activa' } },
{ model: SolicitudPago, as: 'solicitudes', where: { id_estado_solicitud: estadoPendiente.id } }
]
});
res.json(mesas);
};
Seguiremos el camino desde que el mesero selecciona el método de pago hasta que la mesa queda lista para cerrar.
Todo empieza en la página de cobro del mesero.
Archivo: frontend/src/pages/mesero/Pago.jsx
El componente carga los datos de la mesa y sus sesiones, y muestra un formulario completo donde el mesero puede:
Toda la mesa (grupal): se cobrará el total de todas las sesiones activas.Una sesión (individual): se selecciona una sesión concreta de la lista.const handleSubmit = async (e) => {
e.preventDefault();
// ... validaciones de montos mínimos ...
const { data } = await api.post('/mesero/pago', {
mesa_id: parseInt(mesaId, 10),
sesion_id: tipoCobro === 'individual' ? parseInt(sesionSel, 10) : null,
metodo_pago_id: metodoSel?.id,
propina: propinaMonto.toFixed(2),
monto_recibido: isEfectivo ? montoRecibido : undefined,
monto_efectivo: isMixto ? montoEfectivo : undefined,
monto_tarjeta: isMixto ? montoTarjeta : undefined,
});
// Redirige al ticket
navigate(`/mesero/ticket?mesa_id=${mesaId}&sol_id=${data.sol_id || ''}`);
};
Explicación: El frontend solo envía los datos que el mesero ingresó. El backend nunca confía en estos montos para el cálculo final.
Antes de que el controlador haga nada, los middlewares auth.js y authorize.js verifican que la petición tenga un JWT válido y que el usuario tenga rol mesero, gerente o admin.
Si el token no es válido o el rol no tiene permisos, la petición se rechaza inmediatamente con un código de estado HTTP 401 o 403.
Archivo: backend/src/routes/mesero.routes.js
router.post('/pago',
validate({ body: { mesa_id: 'required|int:1,' } }),
authorize('mesero', 'gerente', 'admin'), ctrl.procesarPago);
El "camarero" (ruta) valida que mesa_id esté presente en el cuerpo de la petición y sea un entero válido, luego pasa al controlador procesarPago.
Archivo: backend/src/controllers/mesero.controller.js → función procesarPago
Este es el punto más importante de todo el sistema. Lo dividiremos en tres fases clave:
// 1. Verificar que el método de pago existe
const metodo = await MetodoPago.findByPk(metodo_pago_id);
// 2. Verificar que la mesa existe
const mesa = await Mesa.findByPk(mesa_id);
// 3. Pre-validar que la sesión/mesa no esté ya pagada
if (sesion_id) {
const sPre = await SesionCliente.findOne({ where: { id: sesion_id, id_mesa: mesa.id } });
if (sPre.estado !== 'activa') {
return res.status(409).json({ error: 'La cuenta ya fue saldada.' });
}
} else {
const algunaActiva = await SesionCliente.findOne({ where: { id_mesa: mesa.id, estado: 'activa' } });
if (!algunaActiva) {
return res.status(409).json({ error: 'La cuenta de esta mesa ya fue saldada.' });
}
}
Explicación: Es una primera comprobación rápida para rechazar cobros duplicados antes de hacer bloqueos en las transacciones.
Aquí empieza la magia. Todo lo que sigue está dentro de una transacción gestionada por Sequelize. Esto garantiza que o todo se ejecuta con éxito, o nada se guarda (Rollback automático en caso de fallo).
await sequelize.transaction(async (t) => {
// 1. Bloquear las sesiones para evitar condiciones de carrera (Race Conditions)
const sLock = await SesionCliente.findOne({
where: { id: sesion_id, id_mesa: mesa.id },
lock: t.LOCK.UPDATE, // ← Bloquea la fila hasta que termine la transacción
transaction: t,
});
// 2. Re-validar el estado bajo el lock de seguridad
if (!sLock || sLock.estado !== 'activa') {
bizError = { status: 409, body: { error: 'Esta cuenta ya fue saldada por otro usuario.' } };
return; // Sale de la transacción
}
// 3. Calcular el total REAL consultando la base de datos directamente
total = await _sumarConsumo({ idSesion: sLock.id }, t);
// 4. Validar montos de entrada según el método de pago elegido
if (detalle_pago === 'EFECTIVO') {
if (montoRecibido < totalConPropina) {
bizError = { status: 400, body: { error: 'Monto insuficiente.' } };
return;
}
cambio = montoRecibido - totalConPropina;
} else if (detalle_pago === 'MIXTO') {
// Validar que efectivo + tarjeta >= total + propina
}
// 5. Buscar o crear la SolicitudPago correspondiente
solicitud = await SolicitudPago.findOne({
where: { id_sesion: sesion_id, id_estado_solicitud: estadoPendiente.id },
order: [['fecha_hora', 'ASC']],
transaction: t,
});
if (!solicitud) {
solicitud = await SolicitudPago.create({...}, { transaction: t });
}
// 6. Actualizar la SolicitudPago con los detalles consolidados del cobro
await solicitud.update({
id_estado_solicitud: estadoProcesada.id,
id_metodo_pago: metodo.id,
propina_sugerida: propinaNum,
detalle_pago,
monto_recibido: montoRecibido,
cambio,
}, { transaction: t });
// 7. Marcar las sesiones correspondientes como "pagada"
for (const s of sesionesLocked) {
await s.update({ estado: 'pagada' }, { transaction: t });
}
// 8. Relación M2M: Registrar qué sesiones cubrió este pago
await solicitud.setSesiones_cubiertas(
sesionesLocked.map((s) => s.id),
{ transaction: t }
);
// 9. Cerrar otras solicitudes de pago pendientes de la mesa
await SolicitudPago.update(
{ id_estado_solicitud: estadoProcesada.id },
{ where: { id_mesa: mesa.id, id_estado_solicitud: estadoPendiente.id, id: { [Op.ne]: solicitud.id } }, transaction: t }
);
// 10. Post-pago: actualizar la mesa en base de datos sin liberarla
await _postPagoMesa(mesa, req.user?.id, t);
});
Detalles clave de la Transacción:
lock: t.LOCK.UPDATE: Si dos meseros intentan cobrar la misma mesa al mismo tiempo, uno tendrá que esperar a que el otro finalice la transacción, evitando cobros dobles en paralelo._sumarConsumo: Consulta todos los pedidos no cancelados directamente de la base de datos. No se confía en el monto enviado por el frontend para evitar alteraciones maliciosas del cliente._postPagoMesa: Esta función auxiliar actualiza la nota de cierre de la mesa ("Cuenta saldada — lista para cerrar" si ya no quedan consumiciones, o el detalle con el saldo restante si quedan sesiones activas).await Auditoria.create({
accion: 'Pago procesado',
detalle: `Mesa ${mesa.numero_mesa} | Método: ${metodo.descripcion} | Total: $${totalFinal.toFixed(2)} | Propina: $${propinaNum.toFixed(2)}`,
id_empleado: req.user?.id || null,
id_mesa: mesa.id,
});
La auditoría se crea después de la transacción para no ralentizar la BD principal de cobros. Si por algún motivo la auditoría fallara, el pago se mantiene seguro pero queda el registro del intento en los registros del servidor.
SolicitudPagoArchivo: backend/src/models/SolicitudPago.js
Este modelo define la tabla solicitudes_pago en Sequelize con el desglose de montos y variables de cambio:
Tras completar de forma segura la transacción, el backend responde al frontend:
res.json({
ok: true,
solicitud_id: solicitudId,
ticket_url: `/mesero/ticket/${solicitudId}/`,
});
El frontend de React redirige automáticamente al mesero a la vista del Ticket, permitiéndole revisar el desglose total, verificar el cambio y opcionalmente imprimir o descargar el archivo PDF.
LOCK.UPDATE, recalcula totales del consumo directamente en BD, actualiza la SolicitudPago, asocia las sesiones liquidadas y procesa las notas de cierre con _postPagoMesa.¡Con esto garantizamos la integridad del dinero y del sistema frente a cualquier error o duplicidad de cobros!