Chatbot de Cotizaciones en Oracle APEX con PL/SQL Puro
Si alguna vez te has preguntado si es posible construir un chatbot funcional sin frameworks externos, sin Python, sin Node.js, y sin servicios de inteligencia artificial, la respuesta es sí, y Oracle PL/SQL tiene todo lo que necesitas para hacerlo.
En este tutorial construimos paso a paso una función PL/SQL que actúa como el cerebro de un chatbot de cotizaciones integrado directamente en Oracle APEX. Sin dependencias externas. Solo SQL, PL/SQL y las utilidades que ya tienes instaladas.
¿Qué vamos a construir?
Un chatbot conversacional que permite a los usuarios crear cotizaciones, consultar folios existentes y recibir el resumen por correo electrónico, todo desde una interfaz de chat dentro de una aplicación APEX.
El flujo completo incluye captura del nombre del cliente, validación de correo electrónico, selección de productos desde un catálogo dinámico, acumulación de ítems con subtotales, confirmación con cálculo de IVA y generación del folio.
Todo el estado de la conversación se mantiene en la base de datos, lo que significa que la sesión sobrevive recargas de página, cambios de dispositivo y cualquier otra interrupción.
La arquitectura: una máquina de estados
El corazón del chatbot es una función llamada procesar_mensaje que implementa una máquina de estados finitos. Cada vez que el usuario envía un mensaje, la función consulta en qué estado se encuentra esa sesión y decide cómo responder.
Los estados del chatbot son: INICIO, MENU, CLIENTE, EMAIL, PRODUCTO, CANTIDAD, OTRO_PRODUCTO, CONFIRMAR y BUSCAR_FOLIO.
La transición entre estados se maneja con set_estado, un procedimiento auxiliar que actualiza la fila de sesión en la tabla chatbot_sesion y opcionalmente guarda el contexto acumulado de la conversación en formato JSON dentro de la columna datos_temp.
Este diseño tiene una ventaja enorme: la lógica de negocio vive completamente en la base de datos. APEX solo se encarga de presentar las respuestas y enviar los mensajes del usuario.
Por qué este enfoque vale la pena
La mayoría de los tutoriales de chatbots asumen que necesitas un LLM, un webhook o al menos un servidor dedicado. Este tutorial demuestra que para muchos casos de uso empresariales, una máquina de estados bien diseñada en PL/SQL es más robusta, más fácil de mantener y mucho más fácil de auditar que cualquier alternativa basada en IA.
Cada transición queda registrada en chatbot_sesion. Cada mensaje entrante y saliente queda en el log. Si algo falla, tienes el contexto completo en la base de datos sin necesidad de herramientas externas.
Además, al construirlo dentro de Oracle APEX tienes acceso inmediato a APEX_JSON, HTF, APEX_MAIL y toda la infraestructura de la plataforma sin instalar nada adicional.
Requisitos previos
Para seguir este tutorial necesitas Oracle APEX 22.1 o superior, acceso a una base de datos Oracle 19c o superior, y las tablas chatbot_sesion, chatbot_cotizacion y chatbot_producto que se crean al inicio del tutorial. No se requieren licencias adicionales ni componentes externos.
¿Listo para empezar?
El tutorial está disponible en Aprendiz Academy con el código completo de cada bloque, los scripts de creación de tablas y la configuración del componente de chat en APEX.
Si ya trabajas con Oracle APEX en el día a día, este proyecto te va a dar un patrón reutilizable que puedes adaptar a cualquier flujo conversacional: soporte técnico, registro de incidencias, encuestas, aprobaciones o lo que tu negocio necesite.
Código fuente
-- ============================================================
-- SEQUENCE
-- ============================================================
CREATE SEQUENCE chatbot_folio_seq
MINVALUE 1
MAXVALUE 9999999999999999999999999999
INCREMENT BY 1
START WITH 1060
CACHE 20
NOORDER
NOCYCLE;
-- ============================================================
-- TABLAS
-- ============================================================
CREATE TABLE chat_sesion (
id NUMBER GENERATED ALWAYS AS IDENTITY NOT NULL,
apex_session_id VARCHAR2(100 CHAR) NOT NULL,
estado VARCHAR2(50 CHAR) DEFAULT ON NULL 'INICIO' NOT NULL,
datos_temp VARCHAR2(4000 CHAR),
fecha_inicio TIMESTAMP(6),
fecha_ultimo_msg TIMESTAMP(6),
usuario_apex VARCHAR2(100 CHAR),
CONSTRAINT chat_sesion_pk PRIMARY KEY (id),
CONSTRAINT chat_session_uk UNIQUE (apex_session_id)
);
CREATE TABLE chat_producto (
id NUMBER GENERATED ALWAYS AS IDENTITY NOT NULL,
nombre VARCHAR2(200 CHAR) NOT NULL,
precio NUMBER NOT NULL,
activo VARCHAR2(1 CHAR) DEFAULT ON NULL 'S' NOT NULL,
CONSTRAINT chat_producto_pk PRIMARY KEY (id)
);
CREATE TABLE chat_mensaje (
id NUMBER GENERATED ALWAYS AS IDENTITY NOT NULL,
session_id NUMBER NOT NULL,
direccion VARCHAR2(10 CHAR) NOT NULL,
mensaje VARCHAR2(4000 CHAR) NOT NULL,
fecha TIMESTAMP(6) DEFAULT systimestamp,
CONSTRAINT chat_mensaje_pk PRIMARY KEY (id),
CONSTRAINT chat_mensaje_dir CHECK (direccion IN ('ENTRADA', 'SALIDA')),
CONSTRAINT chat_mensaje_fk FOREIGN KEY (session_id)
REFERENCES chat_sesion (id) ON DELETE CASCADE
);
CREATE TABLE chat_cotizacion (
id NUMBER GENERATED ALWAYS AS IDENTITY NOT NULL,
session_id NUMBER NOT NULL,
folio VARCHAR2(20 CHAR) NOT NULL,
cliente VARCHAR2(200 CHAR) NOT NULL,
producto VARCHAR2(200 CHAR) NOT NULL,
cantidad NUMBER NOT NULL,
precio_unitario NUMBER NOT NULL,
subtotal NUMBER GENERATED ALWAYS AS (cantidad * precio_unitario) VIRTUAL,
iva NUMBER GENERATED ALWAYS AS (cantidad * precio_unitario * 0.16) VIRTUAL,
total NUMBER GENERATED ALWAYS AS (cantidad * precio_unitario * 1.16) VIRTUAL,
estatus VARCHAR2(20 CHAR) DEFAULT 'BORRADOR',
fecha_creacion TIMESTAMP(6) DEFAULT ON NULL systimestamp NOT NULL,
CONSTRAINT chat_cotizacion_pk PRIMARY KEY (id),
CONSTRAINT chat_cotizacion_fk FOREIGN KEY (session_id)
REFERENCES chat_sesion (id) ON DELETE CASCADE
);
-- ============================================================
-- ÍNDICES
-- ============================================================
CREATE INDEX chat_msg_sesion_ix ON chat_mensaje (session_id, fecha);
-- ============================================================
-- PACKAGE PKG_CHAT
-- ============================================================
CREATE OR REPLACE EDITIONABLE PACKAGE pkg_chat AS
-- Punto de entrada principal: recibe el mensaje del usuario,
-- devuelve la respuesta del bot.
FUNCTION procesar_mensaje (
p_apex_session_id IN VARCHAR2,
p_mensaje_usuario IN VARCHAR2,
p_nuevo_session_id OUT VARCHAR2
) RETURN VARCHAR2;
-- Reinicia la sesión (botón "Nueva conversación")
PROCEDURE reiniciar_sesion (
p_apex_session_id IN VARCHAR2,
p_nuevo_session_id OUT VARCHAR2
);
-- Devuelve el historial como cursor para mostrarlo en la región Chat
FUNCTION get_historial (
p_apex_session_id IN VARCHAR2
) RETURN SYS_REFCURSOR;
FUNCTION get_o_generar_session_id (
p_item_value IN VARCHAR2
) RETURN VARCHAR2;
END pkg_chat;
/
CREATE OR REPLACE EDITIONABLE PACKAGE BODY pkg_chat AS
/* ----------------------------------------------------------
CONSTANTES DE ESTADO
---------------------------------------------------------- */
C_INICIO CONSTANT VARCHAR2(20) := 'INICIO';
C_MENU CONSTANT VARCHAR2(20) := 'MENU';
C_CLIENTE CONSTANT VARCHAR2(20) := 'CLIENTE';
C_EMAIL CONSTANT VARCHAR2(20) := 'EMAIL';
C_PRODUCTO CONSTANT VARCHAR2(20) := 'PRODUCTO';
C_CANTIDAD CONSTANT VARCHAR2(20) := 'CANTIDAD';
C_OTRO_PRODUCTO CONSTANT VARCHAR2(20) := 'OTRO_PRODUCTO';
C_CONFIRMAR CONSTANT VARCHAR2(20) := 'CONFIRMAR';
C_BUSCAR_FOLIO CONSTANT VARCHAR2(20) := 'BUSCAR_FOLIO';
C_FIN CONSTANT VARCHAR2(20) := 'FIN';
C_BR CONSTANT VARCHAR2(10) := '<br>';
C_EMAIL_EMPRESA CONSTANT VARCHAR2(100) := 'contacto@aprendiz.academy';
/* ----------------------------------------------------------
MÉTODOS PRIVADOS
---------------------------------------------------------- */
/**
* @brief Recupera el registro de sesión actual o crea uno nuevo si no existe.
* @param p_apex_session_id Identificador único de la sesión de APEX.
* @return chatbot_sesion%ROWTYPE Registro completo de la sesión.
*/
FUNCTION get_o_crear_sesion (
p_apex_session_id IN VARCHAR2
) RETURN chat_sesion%ROWTYPE IS
v_sesion chat_sesion%ROWTYPE;
BEGIN
BEGIN
SELECT *
INTO v_sesion
FROM chat_sesion
WHERE apex_session_id = p_apex_session_id;
EXCEPTION
WHEN no_data_found THEN
INSERT INTO chat_sesion (apex_session_id, estado, usuario_apex)
VALUES (p_apex_session_id, C_INICIO, v('APP_USER'))
RETURNING id, apex_session_id, estado, datos_temp,
fecha_inicio, fecha_ultimo_msg, usuario_apex
INTO v_sesion.id, v_sesion.apex_session_id, v_sesion.estado,
v_sesion.datos_temp, v_sesion.fecha_inicio,
v_sesion.fecha_ultimo_msg, v_sesion.usuario_apex;
END;
RETURN v_sesion;
END get_o_crear_sesion;
/**
* @brief Registra un mensaje (entrada o salida) en la tabla de historial.
* @param p_sesion_id ID numérico de la sesión (FK).
* @param p_direccion Dirección del mensaje (ENTRADA/SALIDA).
* @param p_mensaje Contenido del texto enviado o recibido.
*/
PROCEDURE log_mensaje (
p_sesion_id IN NUMBER,
p_direccion IN VARCHAR2,
p_mensaje IN VARCHAR2
) IS
BEGIN
IF p_mensaje IS NOT NULL THEN
INSERT INTO chat_mensaje (session_id, direccion, mensaje)
VALUES (p_sesion_id, p_direccion, p_mensaje);
END IF;
END log_mensaje;
/**
* @brief Actualiza el estado de la conversación y los datos temporales JSON.
* @param p_sesion_id ID numérico de la sesión.
* @param p_estado Nuevo estado de la máquina de estados.
* @param p_datos_temp Cadena JSON con los datos acumulados del flujo.
*/
PROCEDURE set_estado (
p_sesion_id IN NUMBER,
p_estado IN VARCHAR2,
p_datos_temp IN VARCHAR2 DEFAULT NULL
) IS
BEGIN
UPDATE chat_sesion
SET estado = p_estado,
datos_temp = p_datos_temp,
fecha_ultimo_msg = systimestamp
WHERE id = p_sesion_id;
END set_estado;
/**
* @brief Genera el mensaje de bienvenida con las opciones del menú principal.
* @return VARCHAR2 Cadena HTML con la bienvenida.
*/
FUNCTION msg_bienvenida RETURN VARCHAR2 IS
BEGIN
RETURN '👋 <b>¡Hola! Soy el asistente de cotizaciones.</b>' || C_BR ||
'Por favor elige una opción:' ||
'<ul style="margin:6px 0 6px 16px;padding:0">' ||
'<li>1️⃣ Nueva cotización</li>' ||
'<li>2️⃣ Consultar cotización por folio</li>' ||
'<li>3️⃣ Cancelar cotización</li>' ||
'<li>4️⃣ Reiniciar sesión</li>' ||
'</ul>' ||
'<i>Escribe el número de la opción.</i>';
END msg_bienvenida;
/**
* @brief Consulta los productos activos y genera una lista numerada en HTML.
* @return VARCHAR2 Lista de productos formateada.
*/
FUNCTION msg_catalogo RETURN VARCHAR2 IS
v_resp VARCHAR2(4000) := '🛒 <b>Elige un producto:</b><ol style="margin:6px 0 6px 16px;padding:0">';
BEGIN
FOR r IN (SELECT nombre, precio FROM chat_producto WHERE activo = 'S' ORDER BY id) LOOP
v_resp := v_resp ||
'<li>' || HTF.ESCAPE_SC(r.nombre) ||
' — <b>$' || TO_CHAR(r.precio, 'FM999,999,990.00') || '</b></li>';
END LOOP;
v_resp := v_resp || '</ol><i>Escribe el número del producto.</i>';
RETURN v_resp;
END msg_catalogo;
/**
* @brief Genera una tabla HTML con el desglose de productos, precios y totales.
* @param p_datos_temp Cadena JSON con los ítems agregados.
* @return VARCHAR2 Representación HTML del carrito actual.
*/
FUNCTION msg_items_acumulados (
p_datos_temp IN VARCHAR2
) RETURN VARCHAR2 IS
v_html VARCHAR2(4000);
v_total NUMBER := 0;
v_subtotal NUMBER;
v_n NUMBER;
BEGIN
v_n := APEX_JSON.get_count('items');
v_html := '<table style="width:100%;font-size:12px;border-collapse:collapse;margin:4px 0">' ||
'<tr style="border-bottom:1px solid #ccc">' ||
'<th align="left">Producto</th>' ||
'<th align="right">Cant.</th>' ||
'<th align="right">Precio u.</th>' ||
'<th align="right">Subtotal</th></tr>';
FOR i IN 1..v_n LOOP
DECLARE
v_prod VARCHAR2(200) := APEX_JSON.get_varchar2('items[%d].producto', i);
v_cant NUMBER := APEX_JSON.get_number('items[%d].cantidad', i);
v_prec NUMBER := APEX_JSON.get_number('items[%d].precio', i);
BEGIN
v_subtotal := v_cant * v_prec;
v_total := v_total + v_subtotal;
v_html := v_html ||
'<tr>' ||
'<td>' || HTF.ESCAPE_SC(v_prod) || '</td>' ||
'<td align="right">' || TO_CHAR(v_cant) || '</td>' ||
'<td align="right">$' || TO_CHAR(v_prec, 'FM999,999,990.00') || '</td>' ||
'<td align="right">$' || TO_CHAR(v_subtotal, 'FM999,999,990.00') || '</td>' ||
'</tr>';
END;
END LOOP;
DECLARE
v_iva NUMBER := v_total * 0.16;
v_gran NUMBER := v_total + v_iva;
BEGIN
v_html := v_html ||
'<tr><td colspan="4"><hr style="border:none;border-top:1px solid #ccc;margin:4px 0"></td></tr>' ||
'<tr><td colspan="3">Subtotal</td><td align="right">$' || TO_CHAR(v_total, 'FM999,999,990.00') || '</td></tr>' ||
'<tr><td colspan="3">IVA (16%)</td><td align="right">$' || TO_CHAR(v_iva, 'FM999,999,990.00') || '</td></tr>' ||
'<tr><td colspan="3"><b>Total</b></td><td align="right"><b>$' || TO_CHAR(v_gran, 'FM999,999,990.00') || '</b></td></tr>' ||
'</table>';
END;
RETURN v_html;
END msg_items_acumulados;
/**
* @brief Crea un resumen final con datos del cliente e ítems antes de confirmar.
* @param p_datos_temp Cadena JSON con toda la información de la cotización.
* @return VARCHAR2 Mensaje de confirmación final.
*/
FUNCTION msg_resumen (p_datos_temp IN VARCHAR2) RETURN VARCHAR2 IS
v_cliente VARCHAR2(200);
v_email VARCHAR2(200);
BEGIN
APEX_JSON.parse(p_datos_temp);
v_cliente := APEX_JSON.get_varchar2('cliente');
v_email := APEX_JSON.get_varchar2('email');
RETURN '📋 <b>Resumen de cotización</b>' || C_BR ||
'👤 Cliente: <b>' || HTF.ESCAPE_SC(v_cliente) || '</b>' || C_BR ||
'📧 Email: <b>' || HTF.ESCAPE_SC(v_email) || '</b>' || C_BR || C_BR ||
msg_items_acumulados(p_datos_temp) || C_BR ||
'¿Confirmas la cotización?' || C_BR ||
'✅ Escribe <b>SI</b> para guardar y enviar por correo' || C_BR ||
'❌ Escribe <b>NO</b> para cancelar';
END msg_resumen;
/**
* @brief Agrega un nuevo producto al arreglo JSON de la sesión.
* @param p_datos_temp JSON actual de la sesión.
* @param p_producto Nombre del producto a agregar.
* @param p_precio Precio unitario.
* @param p_cantidad Cantidad solicitada.
* @return VARCHAR2 Nuevo JSON con el ítem añadido.
*/
FUNCTION agregar_item (
p_datos_temp IN VARCHAR2,
p_producto IN VARCHAR2,
p_precio IN NUMBER,
p_cantidad IN NUMBER
) RETURN VARCHAR2 IS
v_cliente VARCHAR2(200);
v_email VARCHAR2(200);
v_n NUMBER;
v_json_out VARCHAR2(4000);
BEGIN
APEX_JSON.parse(p_datos_temp);
v_cliente := APEX_JSON.get_varchar2('cliente');
v_email := APEX_JSON.get_varchar2('email');
v_n := APEX_JSON.get_count('items');
APEX_JSON.initialize_clob_output;
APEX_JSON.open_object;
APEX_JSON.write('cliente', v_cliente);
APEX_JSON.write('email', v_email);
APEX_JSON.open_array('items');
FOR i IN 1..v_n LOOP
APEX_JSON.open_object;
APEX_JSON.write('producto', APEX_JSON.get_varchar2('items[%d].producto', i));
APEX_JSON.write('precio', APEX_JSON.get_number('items[%d].precio', i));
APEX_JSON.write('cantidad', APEX_JSON.get_number('items[%d].cantidad', i));
APEX_JSON.close_object;
END LOOP;
APEX_JSON.open_object;
APEX_JSON.write('producto', p_producto);
APEX_JSON.write('precio', p_precio);
APEX_JSON.write('cantidad', p_cantidad);
APEX_JSON.close_object;
APEX_JSON.close_array;
APEX_JSON.close_object;
v_json_out := APEX_JSON.get_clob_output;
APEX_JSON.free_output;
RETURN v_json_out;
END agregar_item;
/**
* @brief Inserta los datos de la sesión en la tabla definitiva de cotización.
* @param p_sesion_id ID de la sesión.
* @param p_datos_temp JSON con los datos finales.
* @return VARCHAR2 Número de folio generado (ej. COT-00001).
*/
FUNCTION guardar_cotizacion (
p_sesion_id IN NUMBER,
p_datos_temp IN VARCHAR2
) RETURN VARCHAR2 IS
v_folio VARCHAR2(20);
v_cliente VARCHAR2(200);
v_n NUMBER;
BEGIN
APEX_JSON.parse(p_datos_temp);
v_cliente := APEX_JSON.get_varchar2('cliente');
v_n := APEX_JSON.get_count('items');
v_folio := 'COT-' || TO_CHAR(chatbot_folio_seq.NEXTVAL, 'FM00000');
FOR i IN 1..v_n LOOP
INSERT INTO chat_cotizacion (
session_id, folio, cliente, producto,
cantidad, precio_unitario, estatus
) VALUES (
p_sesion_id,
v_folio,
v_cliente,
APEX_JSON.get_varchar2('items[%d].producto', i),
APEX_JSON.get_number('items[%d].cantidad', i),
APEX_JSON.get_number('items[%d].precio', i),
'CONFIRMADA'
);
END LOOP;
RETURN v_folio;
END guardar_cotizacion;
/**
* @brief Envía la cotización por correo al cliente y a la empresa.
* @param p_folio Folio de la cotización guardada.
* @param p_datos_temp JSON con los detalles para el cuerpo del correo.
*/
PROCEDURE enviar_correo_cotizacion (
p_folio IN VARCHAR2,
p_datos_temp IN VARCHAR2
) IS
BEGIN
-- TODO: implementar envío de correo
NULL;
END enviar_correo_cotizacion;
/* ----------------------------------------------------------
MÉTODOS PÚBLICOS
---------------------------------------------------------- */
/**
* @brief Punto de entrada principal. Procesa el texto del usuario y devuelve la respuesta del bot.
* @param p_apex_session_id Identificador de sesión enviado desde el cliente.
* @param p_mensaje_usuario Texto escrito por el usuario.
* @param p_nuevo_session_id Salida: nuevo ID en caso de reinicio de sesión.
* @return VARCHAR2 Respuesta formateada para mostrar en el chat.
*/
FUNCTION procesar_mensaje (
p_apex_session_id IN VARCHAR2,
p_mensaje_usuario IN VARCHAR2,
p_nuevo_session_id OUT VARCHAR2
) RETURN VARCHAR2 IS
v_sesion chat_sesion%ROWTYPE;
v_entrada VARCHAR2(4000) := TRIM(UPPER(p_mensaje_usuario));
v_respuesta VARCHAR2(4000);
v_prod_idx NUMBER;
v_prod_nom VARCHAR2(200);
v_prod_pre NUMBER;
v_idx NUMBER;
v_json VARCHAR2(4000);
v_folio VARCHAR2(20);
BEGIN
v_sesion := get_o_crear_sesion(p_apex_session_id);
log_mensaje(v_sesion.id, 'ENTRADA', NVL(p_mensaje_usuario, '(vacío)'));
IF v_sesion.estado = C_INICIO THEN
set_estado(v_sesion.id, C_MENU);
v_respuesta := msg_bienvenida;
ELSIF v_sesion.estado = C_MENU THEN
CASE v_entrada
WHEN '1' THEN
set_estado(v_sesion.id, C_CLIENTE);
v_respuesta := '✏️ <b>Nueva cotización.</b>' || C_BR ||
'¿Cuál es el nombre del cliente?';
WHEN '2' THEN
set_estado(v_sesion.id, C_BUSCAR_FOLIO);
v_respuesta := '🔍 <b>Consultar cotización</b>' || C_BR ||
'Escribe el número de folio:' || C_BR ||
'<i>Ejemplo: <code>COT-00001</code></i>';
WHEN '3' THEN
set_estado(v_sesion.id, C_INICIO);
v_respuesta := '❌ Sesión cancelada.' || C_BR ||
'<i>Escribe cualquier mensaje para reiniciar.</i>';
WHEN '4' THEN
reiniciar_sesion(p_apex_session_id, p_nuevo_session_id);
v_sesion := get_o_crear_sesion(p_nuevo_session_id);
v_respuesta := '🔄 <b>Sesión reiniciada.</b>' || C_BR || C_BR || msg_bienvenida;
WHEN 'MENU' THEN
v_respuesta := msg_bienvenida;
ELSE
v_respuesta := '⚠️ Opción no válida.' || C_BR || C_BR || msg_bienvenida;
END CASE;
ELSIF v_sesion.estado = C_CLIENTE THEN
IF LENGTH(TRIM(p_mensaje_usuario)) < 3 THEN
v_respuesta := '⚠️ Por favor ingresa un nombre válido <i>(mínimo 3 caracteres)</i>.';
ELSE
APEX_JSON.initialize_clob_output;
APEX_JSON.open_object;
APEX_JSON.write('cliente', TRIM(p_mensaje_usuario));
APEX_JSON.write('email', '');
APEX_JSON.open_array('items');
APEX_JSON.close_array;
APEX_JSON.close_object;
v_json := APEX_JSON.get_clob_output;
APEX_JSON.free_output;
set_estado(v_sesion.id, C_EMAIL, v_json);
v_respuesta := '👤 Cliente: <b>' || HTF.ESCAPE_SC(TRIM(p_mensaje_usuario)) || '</b>' || C_BR || C_BR ||
'📧 ¿Cuál es el correo electrónico del cliente?';
END IF;
ELSIF v_sesion.estado = C_EMAIL THEN
IF NOT REGEXP_LIKE(TRIM(p_mensaje_usuario),
'^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$') THEN
v_respuesta := '⚠️ Por favor ingresa un correo válido.' || C_BR ||
'<i>Ejemplo: cliente@correo.com</i>';
ELSE
APEX_JSON.parse(v_sesion.datos_temp);
APEX_JSON.initialize_clob_output;
APEX_JSON.open_object;
APEX_JSON.write('cliente', APEX_JSON.get_varchar2('cliente'));
APEX_JSON.write('email', TRIM(p_mensaje_usuario));
DECLARE
v_n NUMBER := APEX_JSON.get_count('items');
BEGIN
APEX_JSON.open_array('items');
FOR i IN 1..v_n LOOP
APEX_JSON.open_object;
APEX_JSON.write('producto', APEX_JSON.get_varchar2('items[%d].producto', i));
APEX_JSON.write('precio', APEX_JSON.get_number('items[%d].precio', i));
APEX_JSON.write('cantidad', APEX_JSON.get_number('items[%d].cantidad', i));
APEX_JSON.close_object;
END LOOP;
APEX_JSON.close_array;
END;
APEX_JSON.close_object;
v_json := APEX_JSON.get_clob_output;
APEX_JSON.free_output;
set_estado(v_sesion.id, C_PRODUCTO, v_json);
v_respuesta := '📧 Email: <b>' || HTF.ESCAPE_SC(TRIM(p_mensaje_usuario)) || '</b>' ||
C_BR || C_BR || msg_catalogo;
END IF;
ELSIF v_sesion.estado = C_PRODUCTO THEN
BEGIN
v_prod_idx := TO_NUMBER(v_entrada);
EXCEPTION
WHEN VALUE_ERROR THEN
v_respuesta := '⚠️ Escribe el <b>número</b> del producto.' || C_BR || C_BR || msg_catalogo;
GOTO fin_proceso;
END;
v_idx := 0;
v_prod_nom := NULL;
FOR r IN (SELECT nombre, precio FROM chat_producto WHERE activo = 'S' ORDER BY id) LOOP
v_idx := v_idx + 1;
IF v_idx = v_prod_idx THEN
v_prod_nom := r.nombre;
v_prod_pre := r.precio;
EXIT;
END IF;
END LOOP;
IF v_prod_nom IS NULL THEN
v_respuesta := '⚠️ Número fuera de rango.' || C_BR || C_BR || msg_catalogo;
ELSE
APEX_JSON.parse(v_sesion.datos_temp);
APEX_JSON.initialize_clob_output;
APEX_JSON.open_object;
APEX_JSON.write('cliente', APEX_JSON.get_varchar2('cliente'));
APEX_JSON.write('email', APEX_JSON.get_varchar2('email'));
APEX_JSON.write('prod_temp', v_prod_nom);
APEX_JSON.write('precio_temp', v_prod_pre);
DECLARE
v_n NUMBER := APEX_JSON.get_count('items');
BEGIN
APEX_JSON.open_array('items');
FOR i IN 1..v_n LOOP
APEX_JSON.open_object;
APEX_JSON.write('producto', APEX_JSON.get_varchar2('items[%d].producto', i));
APEX_JSON.write('precio', APEX_JSON.get_number('items[%d].precio', i));
APEX_JSON.write('cantidad', APEX_JSON.get_number('items[%d].cantidad', i));
APEX_JSON.close_object;
END LOOP;
APEX_JSON.close_array;
END;
APEX_JSON.close_object;
v_json := APEX_JSON.get_clob_output;
APEX_JSON.free_output;
set_estado(v_sesion.id, C_CANTIDAD, v_json);
v_respuesta := '📦 Producto: <b>' || HTF.ESCAPE_SC(v_prod_nom) || '</b>' || C_BR ||
'💲 Precio unitario: <b>$' ||
TO_CHAR(v_prod_pre, 'FM999,999,990.00') || '</b>' || C_BR || C_BR ||
'¿Cuántas unidades deseas cotizar?';
END IF;
ELSIF v_sesion.estado = C_CANTIDAD THEN
DECLARE
v_cant NUMBER;
BEGIN
v_cant := TO_NUMBER(v_entrada);
IF v_cant <= 0 THEN
RAISE VALUE_ERROR;
END IF;
APEX_JSON.parse(v_sesion.datos_temp);
v_json := agregar_item(
p_datos_temp => v_sesion.datos_temp,
p_producto => APEX_JSON.get_varchar2('prod_temp'),
p_precio => APEX_JSON.get_number('precio_temp'),
p_cantidad => v_cant
);
APEX_JSON.parse(v_json);
DECLARE
v_n NUMBER := APEX_JSON.get_count('items');
BEGIN
set_estado(v_sesion.id, C_OTRO_PRODUCTO, v_json);
v_respuesta :=
'✅ Producto agregado.' || C_BR || C_BR ||
'<b>Productos en cotización (' || v_n || '):</b>' || C_BR ||
msg_items_acumulados(v_json) || C_BR ||
'¿Deseas agregar otro producto?' || C_BR ||
'✅ Escribe <b>SI</b> para agregar otro' || C_BR ||
'📋 Escribe <b>NO</b> para ver resumen y confirmar';
END;
EXCEPTION
WHEN VALUE_ERROR THEN
v_respuesta := '⚠️ Ingresa un número de unidades válido <i>(mayor a 0)</i>.';
END;
ELSIF v_sesion.estado = C_OTRO_PRODUCTO THEN
IF v_entrada IN ('SI', 'SÍ', 'S', 'YES', 'Y') THEN
set_estado(v_sesion.id, C_PRODUCTO, v_sesion.datos_temp);
v_respuesta := msg_catalogo;
ELSIF v_entrada IN ('NO', 'N') THEN
set_estado(v_sesion.id, C_CONFIRMAR, v_sesion.datos_temp);
v_respuesta := msg_resumen(v_sesion.datos_temp);
END IF;
ELSIF v_sesion.estado = C_CONFIRMAR THEN
IF v_entrada IN ('SI', 'SÍ', 'S', 'YES', 'Y') THEN
v_folio := guardar_cotizacion(v_sesion.id, v_sesion.datos_temp);
set_estado(v_sesion.id, C_MENU, NULL);
v_respuesta := '✅ <b>¡Cotización guardada!</b>' || C_BR ||
'📄 Folio: <b><code>' || v_folio || '</code></b>' || C_BR ||
'<i>Escribe <b>MENU</b> para volver al inicio o' || C_BR ||
'<b>1</b> para generar otra cotización.</i>';
ELSIF v_entrada IN ('NO', 'N', 'CANCELAR') THEN
set_estado(v_sesion.id, C_MENU, NULL);
v_respuesta := '❌ Cotización cancelada.' || C_BR || C_BR || msg_bienvenida;
ELSE
v_respuesta := '⚠️ Por favor escribe <b>SI</b> para confirmar o <b>NO</b> para cancelar.' ||
C_BR || C_BR || msg_resumen(v_sesion.datos_temp);
END IF;
ELSE
set_estado(v_sesion.id, C_INICIO);
v_respuesta := msg_bienvenida;
END IF;
<<fin_proceso>>
log_mensaje(v_sesion.id, 'SALIDA', v_respuesta);
COMMIT;
RETURN v_respuesta;
END procesar_mensaje;
/**
* @brief Finaliza la sesión actual y genera una nueva para limpiar el flujo.
* @param p_apex_session_id ID de sesión a expirar.
* @param p_nuevo_session_id Salida: nuevo GUID generado.
*/
PROCEDURE reiniciar_sesion (
p_apex_session_id IN VARCHAR2,
p_nuevo_session_id OUT VARCHAR2
) IS
BEGIN
-- TODO: implementar lógica de reinicio
p_nuevo_session_id := p_apex_session_id;
END reiniciar_sesion;
/**
* @brief Obtiene el historial de mensajes de una sesión específica.
* @param p_apex_session_id Identificador de la sesión.
* @return SYS_REFCURSOR Cursor con los mensajes ordenados cronológicamente.
*/
FUNCTION get_historial (
p_apex_session_id IN VARCHAR2
) RETURN SYS_REFCURSOR IS
v_cur SYS_REFCURSOR;
BEGIN
-- TODO: implementar
RETURN v_cur;
END get_historial;
/**
* @brief Valida un ID de sesión existente o genera uno nuevo si es nulo o inválido.
* @param p_item_value Valor actual del item de sesión (Page Item).
* @return VARCHAR2 ID de sesión válido (32 caracteres).
*/
FUNCTION get_o_generar_session_id (
p_item_value IN VARCHAR2
) RETURN VARCHAR2 IS
BEGIN
IF p_item_value IS NOT NULL AND LENGTH(TRIM(p_item_value)) = 32 THEN
RETURN TRIM(p_item_value);
END IF;
RETURN REPLACE(SYS_GUID(), '-', '');
END get_o_generar_session_id;
END pkg_chat;
/

Social List