Office Address

  • 123/A, Miranda City Prikano
  • +0989 7876 9865 9
  • info@example.com

Social List

Chatbot de Cotizaciones en Oracle APEX con PL/SQL Puro

Lectura: 11 minutos

Curso gratis https://www.aprendiz.academy/ords/r/aprendiz/aprendiz-academy/detalles-del-curso?g_course_selected=441&session=3846283956139

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) ||
                      ' &mdash; <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;
/

Post a Comment

Your email address will not be published. Required fields are marked *