diff --git a/src/modules/0-ui/db/promptsRepo.js b/src/modules/0-ui/db/promptsRepo.js index 8be0f97..e9b1fb9 100644 --- a/src/modules/0-ui/db/promptsRepo.js +++ b/src/modules/0-ui/db/promptsRepo.js @@ -5,7 +5,7 @@ import { pool } from "../../shared/db/pool.js"; // ───────────────────────────────────────────────────────────── // Prompt keys válidos -export const PROMPT_KEYS = ["router", "greeting", "orders", "shipping", "payment", "browse"]; +export const PROMPT_KEYS = ["router", "greeting", "orders", "shipping", "browse"]; // Modelos por defecto para cada prompt export const DEFAULT_MODELS = { @@ -13,7 +13,6 @@ export const DEFAULT_MODELS = { greeting: "gpt-4-turbo", orders: "gpt-4-turbo", shipping: "gpt-4o-mini", - payment: "gpt-4o-mini", browse: "gpt-4-turbo", }; diff --git a/src/modules/2-identity/services/pipeline.js b/src/modules/2-identity/services/pipeline.js index 5531d12..9210fb2 100644 --- a/src/modules/2-identity/services/pipeline.js +++ b/src/modules/2-identity/services/pipeline.js @@ -205,7 +205,6 @@ const runStatus = llmMeta?.error ? "warn" : "ok"; ok: true, checks: [ { name: "required_keys_present", ok: true }, - { name: "no_checkout_without_payment_link", ok: true }, { name: "no_order_action_without_items", ok: true }, ], }; @@ -257,16 +256,14 @@ const runStatus = llmMeta?.error ? "warn" : "ok"; ...baseAddress, phone: baseAddress.phone || phoneFromWa, }; - // Obtener shipping_method y payment_method del contexto (preferir decision que es el resultado del turn) + // shipping_method del contexto (delivery|pickup). El cobro se gestiona offline. const shippingMethod = decision?.context_patch?.shipping_method || reducedContext?.shipping_method || null; - const paymentMethod = decision?.context_patch?.payment_method || reducedContext?.payment_method || null; const order = await createOrder({ tenantId, wooCustomerId: externalCustomerId, basket: basketToUse, address: addressWithPhone, shippingMethod, - paymentMethod, run_id: null, }); actionPatch.woo_order_id = order?.id || null; @@ -389,9 +386,9 @@ const runStatus = llmMeta?.error ? "warn" : "ok"; }; // El nuevo FSM usa context.order, extraerlo para safeNextState const orderForFsm = context?.order || context?.order_basket || {}; - const signals = { + const signals = { confirm_order: plan.intent === "confirm_order", - payment_selected: plan.intent === "select_payment", + shipping_completed: plan.order_action === "create_order", }; const nextState = safeNextState(prev_state, orderForFsm, signals).next_state; plan.next_state = nextState; @@ -448,7 +445,6 @@ const runStatus = llmMeta?.error ? "warn" : "ok"; invariants, final_reply: plan.reply, order_id: actionPatch.woo_order_id || null, - payment_link: actionPatch.payment_link || null, latency_ms: end_to_end_ms, }); diff --git a/src/modules/3-turn-engine/fsm.js b/src/modules/3-turn-engine/fsm.js index 1e8abca..1c04662 100644 --- a/src/modules/3-turn-engine/fsm.js +++ b/src/modules/3-turn-engine/fsm.js @@ -1,7 +1,8 @@ /** * FSM simplificada para el flujo conversacional. * - * Estados lineales: IDLE → CART → SHIPPING → PAYMENT → WAITING_WEBHOOKS + * Estados lineales: IDLE → CART → SHIPPING → IDLE (post-confirmación). + * El bot toma pedidos y datos de entrega; el cobro se gestiona offline. * Regla universal: add_to_cart SIEMPRE vuelve a CART desde cualquier estado. */ @@ -9,8 +10,6 @@ export const ConversationState = Object.freeze({ IDLE: "IDLE", CART: "CART", SHIPPING: "SHIPPING", - PAYMENT: "PAYMENT", - WAITING_WEBHOOKS: "WAITING_WEBHOOKS", AWAITING_HUMAN: "AWAITING_HUMAN", // Esperando respuesta de un humano }); @@ -19,23 +18,17 @@ export const ALL_STATES = Object.freeze(Object.values(ConversationState)); // Intents válidos por estado export const INTENTS_BY_STATE = Object.freeze({ [ConversationState.IDLE]: [ - "greeting", "add_to_cart", "browse", "price_query", "recommend", "other" + "greeting", "add_to_cart", "browse", "price_query", "recommend", "other", ], [ConversationState.CART]: [ "add_to_cart", "remove_from_cart", "browse", "price_query", - "recommend", "view_cart", "confirm_order", "other" + "recommend", "view_cart", "confirm_order", "other", ], [ConversationState.SHIPPING]: [ - "provide_address", "select_shipping", "add_to_cart", "view_cart", "other" - ], - [ConversationState.PAYMENT]: [ - "select_payment", "add_to_cart", "view_cart", "other" - ], - [ConversationState.WAITING_WEBHOOKS]: [ - "add_to_cart", "view_cart", "other" + "provide_address", "select_shipping", "add_to_cart", "view_cart", "other", ], [ConversationState.AWAITING_HUMAN]: [ - "other" // En este estado, el bot no procesa - espera respuesta humana + "other", ], }); @@ -44,36 +37,31 @@ export const INTENTS_BY_STATE = Object.freeze({ */ export function shouldReturnToCart(state, nlu, text = "") { if (state === ConversationState.CART || state === ConversationState.IDLE) { - return false; // Ya está en CART o IDLE (IDLE irá a CART naturalmente) + return false; } - - // En SHIPPING/PAYMENT, números solos son selecciones de opción, no productos - const isCheckoutState = state === ConversationState.SHIPPING || state === ConversationState.PAYMENT; + + // En SHIPPING, números solos son selecciones de opción, no productos + const isCheckoutState = state === ConversationState.SHIPPING; const isJustNumber = /^\s*\d+([.,]\d+)?\s*$/.test(text || ""); if (isCheckoutState && isJustNumber) { - return false; // No redirigir, es una selección de opción + return false; } - + const intent = nlu?.intent; - // Si el intent es add_to_cart, browse, price_query, o recommend → volver a CART - // Pero solo si hay una query de producto real (no vacía) if (["add_to_cart", "browse", "price_query", "recommend"].includes(intent)) { - // Verificar que hay un producto real mencionado - const hasRealProduct = nlu?.entities?.product_query && + const hasRealProduct = nlu?.entities?.product_query && String(nlu.entities.product_query).trim().length > 2; - const hasRealItems = Array.isArray(nlu?.entities?.items) && + const hasRealItems = Array.isArray(nlu?.entities?.items) && nlu.entities.items.some(i => i?.product_query?.trim().length > 2); if (hasRealProduct || hasRealItems) { return true; } - // Si no hay producto real, no redirigir (probablemente es una selección numérica mal interpretada) return false; } - - // Si hay menciones de producto en entities (con contenido real) + if (nlu?.entities?.product_query && String(nlu.entities.product_query).trim().length > 2) return true; if (Array.isArray(nlu?.entities?.items) && nlu.entities.items.some(i => i?.product_query?.trim().length > 2)) return true; - + return false; } @@ -100,43 +88,27 @@ export function hasShippingInfo(order) { return false; } -export function hasPaymentInfo(order) { - return order?.payment_type === "cash" || order?.payment_type === "link"; -} - -export function isPaid(order) { - return order?.is_paid === true; -} - /** * Deriva el siguiente estado basado en el contexto y signals. - * - * signals: { + * + * signals: { * confirm_order: boolean, // Usuario quiere cerrar pedido - * shipping_selected: boolean, // Usuario seleccionó delivery/pickup - * payment_selected: boolean, // Usuario seleccionó método de pago + * shipping_completed: boolean, // Shipping info quedó completa (gatilla create_order + IDLE) * return_to_cart: boolean, // Forzar volver a CART * } */ export function deriveNextState(prevState, order = {}, signals = {}) { - // Regla 0: Si se fuerza volver a CART if (signals.return_to_cart) { return ConversationState.CART; } - // Regla 1: Si está pagado, completado (volver a IDLE para nueva conversación) - if (isPaid(order)) { + // Si la orden ya fue creada en Woo, volvemos a IDLE para nueva conversación. + if (order?.woo_order_id) { return ConversationState.IDLE; } - // Regla 2: Si tiene woo_order_id y espera pago - if (order?.woo_order_id && !isPaid(order)) { - return ConversationState.WAITING_WEBHOOKS; - } - // Desde IDLE if (prevState === ConversationState.IDLE) { - // Si hay cart o pending items, ir a CART if (hasCartItems(order) || hasPendingItems(order)) { return ConversationState.CART; } @@ -145,11 +117,9 @@ export function deriveNextState(prevState, order = {}, signals = {}) { // Desde CART if (prevState === ConversationState.CART) { - // Si hay pending items sin resolver, quedarse en CART if (hasPendingItems(order)) { return ConversationState.CART; } - // Si usuario confirma orden y hay items en cart, ir a SHIPPING if (signals.confirm_order && hasCartItems(order)) { return ConversationState.SHIPPING; } @@ -158,69 +128,38 @@ export function deriveNextState(prevState, order = {}, signals = {}) { // Desde SHIPPING if (prevState === ConversationState.SHIPPING) { - // Si ya tiene shipping info completa, ir a PAYMENT - if (hasShippingInfo(order)) { - return ConversationState.PAYMENT; + // Una vez completado el shipping, la orden se crea y vuelve a IDLE. + if (signals.shipping_completed || hasShippingInfo(order)) { + return ConversationState.IDLE; } return ConversationState.SHIPPING; } - // Desde PAYMENT - if (prevState === ConversationState.PAYMENT) { - // Si ya tiene payment info, ir a WAITING_WEBHOOKS - if (signals.payment_selected || hasPaymentInfo(order)) { - return ConversationState.WAITING_WEBHOOKS; - } - return ConversationState.PAYMENT; - } - - // Desde WAITING_WEBHOOKS - if (prevState === ConversationState.WAITING_WEBHOOKS) { - if (isPaid(order)) { - return ConversationState.IDLE; - } - return ConversationState.WAITING_WEBHOOKS; - } - - // Default return prevState || ConversationState.IDLE; } -// Transiciones permitidas (para validación) const ALLOWED = Object.freeze({ [ConversationState.IDLE]: [ ConversationState.IDLE, ConversationState.CART, - ConversationState.AWAITING_HUMAN, // Puede ir a esperar humano + ConversationState.AWAITING_HUMAN, ], [ConversationState.CART]: [ ConversationState.CART, ConversationState.SHIPPING, - ConversationState.IDLE, // Si vacía el carrito - ConversationState.AWAITING_HUMAN, // Producto no encontrado + ConversationState.IDLE, + ConversationState.AWAITING_HUMAN, ], [ConversationState.SHIPPING]: [ ConversationState.SHIPPING, - ConversationState.PAYMENT, - ConversationState.CART, // Volver a agregar productos - ConversationState.AWAITING_HUMAN, - ], - [ConversationState.PAYMENT]: [ - ConversationState.PAYMENT, - ConversationState.WAITING_WEBHOOKS, - ConversationState.CART, // Volver a agregar productos - ConversationState.AWAITING_HUMAN, - ], - [ConversationState.WAITING_WEBHOOKS]: [ - ConversationState.WAITING_WEBHOOKS, - ConversationState.IDLE, // Pago completado - ConversationState.CART, // Agregar más productos + ConversationState.IDLE, + ConversationState.CART, ConversationState.AWAITING_HUMAN, ], [ConversationState.AWAITING_HUMAN]: [ - ConversationState.AWAITING_HUMAN, // Sigue esperando - ConversationState.CART, // Humano respondió, volver a procesar - ConversationState.IDLE, // Humano canceló + ConversationState.AWAITING_HUMAN, + ConversationState.CART, + ConversationState.IDLE, ], }); @@ -237,7 +176,5 @@ export function safeNextState(prevState, order, signals) { const desired = deriveNextState(prevState, order, signals); const v = validateTransition(prevState, desired); if (v.ok) return { next_state: desired, validation: v }; - // Si la transición no es válida, forzar a un estado seguro - // En el nuevo modelo, siempre podemos ir a CART return { next_state: ConversationState.CART, validation: { ...v, forced_to_cart: true } }; } diff --git a/src/modules/3-turn-engine/fsm.test.js b/src/modules/3-turn-engine/fsm.test.js index d2bea34..483eaf5 100644 --- a/src/modules/3-turn-engine/fsm.test.js +++ b/src/modules/3-turn-engine/fsm.test.js @@ -1,5 +1,5 @@ /** - * Tests para fsm.js + * Tests para fsm.js (sin payment / waiting — el bot no maneja pagos). */ import { describe, it, expect } from 'vitest'; import { @@ -11,555 +11,210 @@ import { hasPendingItems, hasReadyPendingItems, hasShippingInfo, - hasPaymentInfo, - isPaid, deriveNextState, validateTransition, safeNextState, } from './fsm.js'; -// ───────────────────────────────────────────────────────────── -// Constants -// ───────────────────────────────────────────────────────────── - describe('ConversationState', () => { - it('tiene todos los estados definidos', () => { + it('tiene los estados del flujo (sin payment/waiting)', () => { expect(ConversationState.IDLE).toBe('IDLE'); expect(ConversationState.CART).toBe('CART'); expect(ConversationState.SHIPPING).toBe('SHIPPING'); - expect(ConversationState.PAYMENT).toBe('PAYMENT'); - expect(ConversationState.WAITING_WEBHOOKS).toBe('WAITING_WEBHOOKS'); expect(ConversationState.AWAITING_HUMAN).toBe('AWAITING_HUMAN'); + expect(ConversationState.PAYMENT).toBeUndefined(); + expect(ConversationState.WAITING_WEBHOOKS).toBeUndefined(); }); - it('ALL_STATES contiene todos', () => { - expect(ALL_STATES).toContain('IDLE'); - expect(ALL_STATES).toContain('CART'); - expect(ALL_STATES).toContain('SHIPPING'); - expect(ALL_STATES).toContain('PAYMENT'); - expect(ALL_STATES).toContain('WAITING_WEBHOOKS'); - expect(ALL_STATES).toContain('AWAITING_HUMAN'); - expect(ALL_STATES).toHaveLength(6); + it('ALL_STATES contiene 4 estados', () => { + expect(ALL_STATES).toEqual(expect.arrayContaining(['IDLE', 'CART', 'SHIPPING', 'AWAITING_HUMAN'])); + expect(ALL_STATES).toHaveLength(4); }); - it('INTENTS_BY_STATE define intents para cada estado', () => { + it('INTENTS_BY_STATE define intents por estado', () => { expect(INTENTS_BY_STATE[ConversationState.IDLE]).toContain('greeting'); expect(INTENTS_BY_STATE[ConversationState.CART]).toContain('add_to_cart'); expect(INTENTS_BY_STATE[ConversationState.SHIPPING]).toContain('provide_address'); - expect(INTENTS_BY_STATE[ConversationState.PAYMENT]).toContain('select_payment'); }); }); -// ───────────────────────────────────────────────────────────── -// hasCartItems -// ───────────────────────────────────────────────────────────── - describe('hasCartItems', () => { it('retorna true si cart tiene items', () => { - const order = { cart: [{ woo_id: 1, qty: 1 }] }; - expect(hasCartItems(order)).toBe(true); + expect(hasCartItems({ cart: [{ woo_id: 1, qty: 1 }] })).toBe(true); }); - it('retorna false si cart está vacío', () => { - const order = { cart: [] }; - expect(hasCartItems(order)).toBe(false); + expect(hasCartItems({ cart: [] })).toBe(false); }); - it('retorna false si cart es undefined', () => { - const order = {}; - expect(hasCartItems(order)).toBe(false); + expect(hasCartItems({})).toBe(false); }); - - it('retorna false si order es null', () => { + it('retorna false si order es null/undefined', () => { expect(hasCartItems(null)).toBe(false); - }); - - it('retorna false si order es undefined', () => { expect(hasCartItems(undefined)).toBe(false); }); }); -// ───────────────────────────────────────────────────────────── -// hasPendingItems -// ───────────────────────────────────────────────────────────── - describe('hasPendingItems', () => { it('retorna true si hay NEEDS_TYPE', () => { - const order = { pending: [{ status: 'NEEDS_TYPE' }] }; - expect(hasPendingItems(order)).toBe(true); + expect(hasPendingItems({ pending: [{ status: 'NEEDS_TYPE' }] })).toBe(true); }); - it('retorna true si hay NEEDS_QUANTITY', () => { - const order = { pending: [{ status: 'NEEDS_QUANTITY' }] }; - expect(hasPendingItems(order)).toBe(true); + expect(hasPendingItems({ pending: [{ status: 'NEEDS_QUANTITY' }] })).toBe(true); }); - it('retorna false si solo hay READY', () => { - const order = { pending: [{ status: 'READY' }] }; - expect(hasPendingItems(order)).toBe(false); + expect(hasPendingItems({ pending: [{ status: 'READY' }] })).toBe(false); }); - - it('retorna false si pending está vacío', () => { - const order = { pending: [] }; - expect(hasPendingItems(order)).toBe(false); - }); - - it('retorna false si order es null', () => { - expect(hasPendingItems(null)).toBe(false); - }); - it('detecta entre múltiples items', () => { - const order = { - pending: [ - { status: 'READY' }, - { status: 'NEEDS_TYPE' }, - ] - }; - expect(hasPendingItems(order)).toBe(true); + expect(hasPendingItems({ pending: [{ status: 'READY' }, { status: 'NEEDS_TYPE' }] })).toBe(true); }); }); -// ───────────────────────────────────────────────────────────── -// hasReadyPendingItems -// ───────────────────────────────────────────────────────────── - describe('hasReadyPendingItems', () => { it('retorna true si hay READY', () => { - const order = { pending: [{ status: 'READY' }] }; - expect(hasReadyPendingItems(order)).toBe(true); + expect(hasReadyPendingItems({ pending: [{ status: 'READY' }] })).toBe(true); }); - it('retorna false si no hay READY', () => { - const order = { pending: [{ status: 'NEEDS_TYPE' }] }; - expect(hasReadyPendingItems(order)).toBe(false); - }); - - it('retorna false si pending vacío', () => { - const order = { pending: [] }; - expect(hasReadyPendingItems(order)).toBe(false); - }); - - it('retorna false si order es null', () => { - expect(hasReadyPendingItems(null)).toBe(false); + expect(hasReadyPendingItems({ pending: [{ status: 'NEEDS_TYPE' }] })).toBe(false); }); }); -// ───────────────────────────────────────────────────────────── -// hasShippingInfo -// ───────────────────────────────────────────────────────────── - describe('hasShippingInfo', () => { it('retorna true para pickup (no necesita dirección)', () => { - const order = { is_delivery: false }; - expect(hasShippingInfo(order)).toBe(true); + expect(hasShippingInfo({ is_delivery: false })).toBe(true); }); - it('retorna true para delivery con dirección', () => { - const order = { is_delivery: true, shipping_address: 'Calle Falsa 123' }; - expect(hasShippingInfo(order)).toBe(true); + expect(hasShippingInfo({ is_delivery: true, shipping_address: 'Calle Falsa 123' })).toBe(true); }); - it('retorna false para delivery sin dirección', () => { - const order = { is_delivery: true, shipping_address: null }; - expect(hasShippingInfo(order)).toBe(false); + expect(hasShippingInfo({ is_delivery: true, shipping_address: null })).toBe(false); }); - it('retorna false si is_delivery es null', () => { - const order = { is_delivery: null }; - expect(hasShippingInfo(order)).toBe(false); - }); - - it('retorna false para order vacío', () => { - expect(hasShippingInfo({})).toBe(false); + expect(hasShippingInfo({ is_delivery: null })).toBe(false); }); }); -// ───────────────────────────────────────────────────────────── -// hasPaymentInfo -// ───────────────────────────────────────────────────────────── - -describe('hasPaymentInfo', () => { - it('retorna true para cash', () => { - const order = { payment_type: 'cash' }; - expect(hasPaymentInfo(order)).toBe(true); - }); - - it('retorna true para link', () => { - const order = { payment_type: 'link' }; - expect(hasPaymentInfo(order)).toBe(true); - }); - - it('retorna false para null', () => { - const order = { payment_type: null }; - expect(hasPaymentInfo(order)).toBe(false); - }); - - it('retorna false para undefined', () => { - const order = {}; - expect(hasPaymentInfo(order)).toBe(false); - }); - - it('retorna false para otros valores', () => { - const order = { payment_type: 'bitcoin' }; - expect(hasPaymentInfo(order)).toBe(false); - }); -}); - -// ───────────────────────────────────────────────────────────── -// isPaid -// ───────────────────────────────────────────────────────────── - -describe('isPaid', () => { - it('retorna true si is_paid es true', () => { - const order = { is_paid: true }; - expect(isPaid(order)).toBe(true); - }); - - it('retorna false si is_paid es false', () => { - const order = { is_paid: false }; - expect(isPaid(order)).toBe(false); - }); - - it('retorna false si is_paid es undefined', () => { - const order = {}; - expect(isPaid(order)).toBe(false); - }); - - it('retorna false si order es null', () => { - expect(isPaid(null)).toBe(false); - }); -}); - -// ───────────────────────────────────────────────────────────── -// shouldReturnToCart -// ───────────────────────────────────────────────────────────── - describe('shouldReturnToCart', () => { - describe('no redirige si ya está en CART o IDLE', () => { - it('retorna false en CART', () => { - const nlu = { intent: 'add_to_cart', entities: { product_query: 'provoleta' } }; - expect(shouldReturnToCart(ConversationState.CART, nlu)).toBe(false); - }); - - it('retorna false en IDLE', () => { - const nlu = { intent: 'add_to_cart', entities: { product_query: 'provoleta' } }; - expect(shouldReturnToCart(ConversationState.IDLE, nlu)).toBe(false); - }); + it('no redirige si ya está en CART o IDLE', () => { + const nlu = { intent: 'add_to_cart', entities: { product_query: 'provoleta' } }; + expect(shouldReturnToCart(ConversationState.CART, nlu)).toBe(false); + expect(shouldReturnToCart(ConversationState.IDLE, nlu)).toBe(false); }); - describe('redirige desde otros estados', () => { - it('redirige add_to_cart desde SHIPPING', () => { - const nlu = { intent: 'add_to_cart', entities: { product_query: 'provoleta' } }; - expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(true); - }); - - it('redirige add_to_cart desde PAYMENT', () => { - const nlu = { intent: 'add_to_cart', entities: { product_query: 'vacío' } }; - expect(shouldReturnToCart(ConversationState.PAYMENT, nlu)).toBe(true); - }); - - it('redirige browse desde SHIPPING', () => { - const nlu = { intent: 'browse', entities: { product_query: 'carnes' } }; - expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(true); - }); + it('redirige add_to_cart desde SHIPPING con producto real', () => { + const nlu = { intent: 'add_to_cart', entities: { product_query: 'provoleta' } }; + expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(true); }); - describe('no redirige números solos en checkout', () => { - it('no redirige "1" en SHIPPING', () => { - const nlu = { intent: 'add_to_cart', entities: {} }; - expect(shouldReturnToCart(ConversationState.SHIPPING, nlu, '1')).toBe(false); - }); - - it('no redirige "2" en PAYMENT', () => { - const nlu = { intent: 'other', entities: {} }; - expect(shouldReturnToCart(ConversationState.PAYMENT, nlu, '2')).toBe(false); - }); - - it('no redirige "1.5" en SHIPPING', () => { - const nlu = { intent: 'add_to_cart', entities: {} }; - expect(shouldReturnToCart(ConversationState.SHIPPING, nlu, '1.5')).toBe(false); - }); + it('redirige browse desde SHIPPING', () => { + const nlu = { intent: 'browse', entities: { product_query: 'carnes' } }; + expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(true); }); - describe('requiere producto real', () => { - it('no redirige sin product_query', () => { - const nlu = { intent: 'add_to_cart', entities: {} }; - expect(shouldReturnToCart(ConversationState.SHIPPING, nlu, 'algo')).toBe(false); - }); + it('no redirige números solos en SHIPPING (selección de opción)', () => { + const nlu = { intent: 'add_to_cart', entities: {} }; + expect(shouldReturnToCart(ConversationState.SHIPPING, nlu, '1')).toBe(false); + expect(shouldReturnToCart(ConversationState.SHIPPING, nlu, '1.5')).toBe(false); + }); - it('no redirige con product_query muy corto', () => { - const nlu = { intent: 'add_to_cart', entities: { product_query: 'ab' } }; - expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(false); - }); + it('no redirige sin producto real', () => { + const nlu = { intent: 'add_to_cart', entities: {} }; + expect(shouldReturnToCart(ConversationState.SHIPPING, nlu, 'algo')).toBe(false); + expect(shouldReturnToCart(ConversationState.SHIPPING, { intent: 'add_to_cart', entities: { product_query: 'ab' } })).toBe(false); + }); - it('redirige con items array', () => { - const nlu = { - intent: 'add_to_cart', - entities: { items: [{ product_query: 'provoleta' }] } - }; - expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(true); - }); + it('redirige con items array que tenga producto real', () => { + const nlu = { intent: 'add_to_cart', entities: { items: [{ product_query: 'provoleta' }] } }; + expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(true); }); }); -// ───────────────────────────────────────────────────────────── -// deriveNextState -// ───────────────────────────────────────────────────────────── - describe('deriveNextState', () => { - describe('return_to_cart signal', () => { - it('fuerza CART si return_to_cart', () => { - const result = deriveNextState( - ConversationState.PAYMENT, - {}, - { return_to_cart: true } - ); - expect(result).toBe(ConversationState.CART); - }); + it('return_to_cart fuerza CART', () => { + expect(deriveNextState(ConversationState.SHIPPING, {}, { return_to_cart: true })).toBe(ConversationState.CART); }); - describe('pagado', () => { - it('va a IDLE si está pagado', () => { - const order = { is_paid: true }; - const result = deriveNextState(ConversationState.WAITING_WEBHOOKS, order, {}); - expect(result).toBe(ConversationState.IDLE); - }); + it('IDLE va a CART si hay cart o pending', () => { + expect(deriveNextState(ConversationState.IDLE, { cart: [{ woo_id: 1 }], pending: [] }, {})).toBe(ConversationState.CART); + expect(deriveNextState(ConversationState.IDLE, { cart: [], pending: [{ status: 'NEEDS_TYPE' }] }, {})).toBe(ConversationState.CART); }); - describe('esperando pago', () => { - it('va a WAITING_WEBHOOKS si tiene woo_order_id', () => { - const order = { woo_order_id: 123, is_paid: false }; - const result = deriveNextState(ConversationState.PAYMENT, order, {}); - expect(result).toBe(ConversationState.WAITING_WEBHOOKS); - }); + it('IDLE queda en IDLE si vacío', () => { + expect(deriveNextState(ConversationState.IDLE, { cart: [], pending: [] }, {})).toBe(ConversationState.IDLE); }); - describe('IDLE -> CART', () => { - it('va a CART si hay cart items', () => { - const order = { cart: [{ woo_id: 1 }], pending: [] }; - const result = deriveNextState(ConversationState.IDLE, order, {}); - expect(result).toBe(ConversationState.CART); - }); - - it('va a CART si hay pending items', () => { - const order = { cart: [], pending: [{ status: 'NEEDS_TYPE' }] }; - const result = deriveNextState(ConversationState.IDLE, order, {}); - expect(result).toBe(ConversationState.CART); - }); - - it('queda en IDLE si vacío', () => { - const order = { cart: [], pending: [] }; - const result = deriveNextState(ConversationState.IDLE, order, {}); - expect(result).toBe(ConversationState.IDLE); - }); + it('CART queda en CART con pending', () => { + const order = { cart: [{ woo_id: 1 }], pending: [{ status: 'NEEDS_TYPE' }] }; + expect(deriveNextState(ConversationState.CART, order, { confirm_order: true })).toBe(ConversationState.CART); }); - describe('CART -> SHIPPING', () => { - it('queda en CART si hay pending items', () => { - const order = { cart: [{ woo_id: 1 }], pending: [{ status: 'NEEDS_TYPE' }] }; - const result = deriveNextState(ConversationState.CART, order, { confirm_order: true }); - expect(result).toBe(ConversationState.CART); - }); - - it('va a SHIPPING con confirm_order y cart items', () => { - const order = { cart: [{ woo_id: 1 }], pending: [] }; - const result = deriveNextState(ConversationState.CART, order, { confirm_order: true }); - expect(result).toBe(ConversationState.SHIPPING); - }); - - it('queda en CART sin confirm_order', () => { - const order = { cart: [{ woo_id: 1 }], pending: [] }; - const result = deriveNextState(ConversationState.CART, order, {}); - expect(result).toBe(ConversationState.CART); - }); + it('CART → SHIPPING con confirm + items', () => { + const order = { cart: [{ woo_id: 1 }], pending: [] }; + expect(deriveNextState(ConversationState.CART, order, { confirm_order: true })).toBe(ConversationState.SHIPPING); }); - describe('SHIPPING -> PAYMENT', () => { - it('va a PAYMENT con shipping info (pickup)', () => { - const order = { is_delivery: false }; - const result = deriveNextState(ConversationState.SHIPPING, order, {}); - expect(result).toBe(ConversationState.PAYMENT); - }); - - it('va a PAYMENT con shipping info (delivery + address)', () => { - const order = { is_delivery: true, shipping_address: 'Calle 123' }; - const result = deriveNextState(ConversationState.SHIPPING, order, {}); - expect(result).toBe(ConversationState.PAYMENT); - }); - - it('queda en SHIPPING sin info completa', () => { - const order = { is_delivery: true, shipping_address: null }; - const result = deriveNextState(ConversationState.SHIPPING, order, {}); - expect(result).toBe(ConversationState.SHIPPING); - }); + it('SHIPPING → IDLE cuando shipping queda completo (orden creada offline)', () => { + expect(deriveNextState(ConversationState.SHIPPING, { is_delivery: false }, {})).toBe(ConversationState.IDLE); + expect(deriveNextState(ConversationState.SHIPPING, { is_delivery: true, shipping_address: 'Calle 1' }, {})).toBe(ConversationState.IDLE); }); - describe('PAYMENT -> WAITING_WEBHOOKS', () => { - it('va a WAITING con payment_selected', () => { - const order = {}; - const result = deriveNextState( - ConversationState.PAYMENT, - order, - { payment_selected: true } - ); - expect(result).toBe(ConversationState.WAITING_WEBHOOKS); - }); - - it('va a WAITING si ya tiene payment_type', () => { - const order = { payment_type: 'cash' }; - const result = deriveNextState(ConversationState.PAYMENT, order, {}); - expect(result).toBe(ConversationState.WAITING_WEBHOOKS); - }); + it('SHIPPING queda en SHIPPING sin info completa', () => { + expect(deriveNextState(ConversationState.SHIPPING, { is_delivery: true }, {})).toBe(ConversationState.SHIPPING); }); - describe('WAITING_WEBHOOKS', () => { - it('va a IDLE si está pagado', () => { - const order = { is_paid: true }; - const result = deriveNextState(ConversationState.WAITING_WEBHOOKS, order, {}); - expect(result).toBe(ConversationState.IDLE); - }); - - it('queda en WAITING si no está pagado', () => { - const order = { is_paid: false }; - const result = deriveNextState(ConversationState.WAITING_WEBHOOKS, order, {}); - expect(result).toBe(ConversationState.WAITING_WEBHOOKS); - }); + it('woo_order_id existente vuelve a IDLE (orden ya creada)', () => { + expect(deriveNextState(ConversationState.SHIPPING, { woo_order_id: 999 }, {})).toBe(ConversationState.IDLE); }); - describe('default', () => { - it('retorna IDLE si no hay estado previo', () => { - const result = deriveNextState(null, {}, {}); - expect(result).toBe(ConversationState.IDLE); - }); + it('default sin estado previo retorna IDLE', () => { + expect(deriveNextState(null, {}, {})).toBe(ConversationState.IDLE); }); }); -// ───────────────────────────────────────────────────────────── -// validateTransition -// ───────────────────────────────────────────────────────────── - describe('validateTransition', () => { - describe('transiciones válidas', () => { - it('IDLE -> IDLE es válido', () => { - const result = validateTransition(ConversationState.IDLE, ConversationState.IDLE); - expect(result.ok).toBe(true); - }); - - it('IDLE -> CART es válido', () => { - const result = validateTransition(ConversationState.IDLE, ConversationState.CART); - expect(result.ok).toBe(true); - }); - - it('CART -> SHIPPING es válido', () => { - const result = validateTransition(ConversationState.CART, ConversationState.SHIPPING); - expect(result.ok).toBe(true); - }); - - it('SHIPPING -> PAYMENT es válido', () => { - const result = validateTransition(ConversationState.SHIPPING, ConversationState.PAYMENT); - expect(result.ok).toBe(true); - }); - - it('PAYMENT -> WAITING_WEBHOOKS es válido', () => { - const result = validateTransition(ConversationState.PAYMENT, ConversationState.WAITING_WEBHOOKS); - expect(result.ok).toBe(true); - }); - - it('SHIPPING -> CART (volver) es válido', () => { - const result = validateTransition(ConversationState.SHIPPING, ConversationState.CART); - expect(result.ok).toBe(true); - }); + it('IDLE → CART es válida', () => { + expect(validateTransition(ConversationState.IDLE, ConversationState.CART).ok).toBe(true); }); - - describe('transiciones inválidas', () => { - it('IDLE -> PAYMENT es inválido', () => { - const result = validateTransition(ConversationState.IDLE, ConversationState.PAYMENT); - expect(result.ok).toBe(false); - expect(result.reason).toBe('invalid_transition'); - }); - - it('CART -> WAITING_WEBHOOKS es inválido', () => { - const result = validateTransition(ConversationState.CART, ConversationState.WAITING_WEBHOOKS); - expect(result.ok).toBe(false); - }); + it('CART → SHIPPING es válida', () => { + expect(validateTransition(ConversationState.CART, ConversationState.SHIPPING).ok).toBe(true); }); - - describe('estados desconocidos', () => { - it('estado previo desconocido', () => { - const result = validateTransition('UNKNOWN', ConversationState.CART); - expect(result.ok).toBe(false); - expect(result.reason).toBe('unknown_prev_state'); - }); - - it('estado siguiente desconocido', () => { - const result = validateTransition(ConversationState.IDLE, 'UNKNOWN'); - expect(result.ok).toBe(false); - expect(result.reason).toBe('unknown_next_state'); - }); + it('SHIPPING → IDLE es válida (cierre de orden)', () => { + expect(validateTransition(ConversationState.SHIPPING, ConversationState.IDLE).ok).toBe(true); }); - - describe('maneja null/undefined', () => { - it('prevState null se trata como IDLE', () => { - const result = validateTransition(null, ConversationState.CART); - expect(result.ok).toBe(true); - }); - - it('nextState null se trata como IDLE', () => { - const result = validateTransition(ConversationState.IDLE, null); - expect(result.ok).toBe(true); - }); + it('SHIPPING → CART (volver a agregar) es válida', () => { + expect(validateTransition(ConversationState.SHIPPING, ConversationState.CART).ok).toBe(true); + }); + it('IDLE → SHIPPING es inválida (debe pasar por CART)', () => { + const r = validateTransition(ConversationState.IDLE, ConversationState.SHIPPING); + expect(r.ok).toBe(false); + expect(r.reason).toBe('invalid_transition'); + }); + it('estado previo desconocido', () => { + const r = validateTransition('UNKNOWN', ConversationState.CART); + expect(r.ok).toBe(false); + expect(r.reason).toBe('unknown_prev_state'); + }); + it('null se trata como IDLE', () => { + expect(validateTransition(null, ConversationState.CART).ok).toBe(true); + expect(validateTransition(ConversationState.IDLE, null).ok).toBe(true); }); }); -// ───────────────────────────────────────────────────────────── -// safeNextState -// ───────────────────────────────────────────────────────────── - describe('safeNextState', () => { - it('retorna estado derivado si transición válida', () => { + it('retorna estado derivado si la transición es válida', () => { const order = { cart: [{ woo_id: 1 }], pending: [] }; - const result = safeNextState(ConversationState.CART, order, { confirm_order: true }); - - expect(result.next_state).toBe(ConversationState.SHIPPING); - expect(result.validation.ok).toBe(true); + const r = safeNextState(ConversationState.CART, order, { confirm_order: true }); + expect(r.next_state).toBe(ConversationState.SHIPPING); + expect(r.validation.ok).toBe(true); }); - it('fuerza CART si transición inválida', () => { - // Forzar una situación donde deriveNextState retornaría un estado inválido - // Esto es difícil de provocar porque deriveNextState ya es bastante seguro - // Pero podemos verificar que la lógica de fallback existe - const order = {}; - const result = safeNextState(ConversationState.IDLE, order, {}); - - // Debería quedarse en IDLE (transición válida) - expect(result.next_state).toBe(ConversationState.IDLE); - expect(result.validation.ok).toBe(true); - }); + it('flow IDLE → CART → SHIPPING → IDLE', () => { + let r = safeNextState(ConversationState.IDLE, { cart: [{ woo_id: 1 }], pending: [] }, {}); + expect(r.next_state).toBe(ConversationState.CART); - it('incluye validation en resultado', () => { - const order = { is_delivery: false }; - const result = safeNextState(ConversationState.SHIPPING, order, {}); - - expect(result).toHaveProperty('next_state'); - expect(result).toHaveProperty('validation'); - expect(result.validation).toHaveProperty('ok'); - }); + r = safeNextState(ConversationState.CART, { cart: [{ woo_id: 1 }], pending: [] }, { confirm_order: true }); + expect(r.next_state).toBe(ConversationState.SHIPPING); - it('maneja transition IDLE -> CART -> SHIPPING flow', () => { - // Paso 1: IDLE con cart items -> CART - let result = safeNextState(ConversationState.IDLE, { cart: [{ woo_id: 1 }], pending: [] }, {}); - expect(result.next_state).toBe(ConversationState.CART); - - // Paso 2: CART con confirm -> SHIPPING - result = safeNextState(ConversationState.CART, { cart: [{ woo_id: 1 }], pending: [] }, { confirm_order: true }); - expect(result.next_state).toBe(ConversationState.SHIPPING); - - // Paso 3: SHIPPING con pickup -> PAYMENT - result = safeNextState(ConversationState.SHIPPING, { is_delivery: false }, {}); - expect(result.next_state).toBe(ConversationState.PAYMENT); - - // Paso 4: PAYMENT con payment_selected -> WAITING - result = safeNextState(ConversationState.PAYMENT, {}, { payment_selected: true }); - expect(result.next_state).toBe(ConversationState.WAITING_WEBHOOKS); + r = safeNextState(ConversationState.SHIPPING, { is_delivery: false }, { shipping_completed: true }); + expect(r.next_state).toBe(ConversationState.IDLE); }); }); diff --git a/src/modules/3-turn-engine/machine/actions.js b/src/modules/3-turn-engine/machine/actions.js index e5686d2..3650c84 100644 --- a/src/modules/3-turn-engine/machine/actions.js +++ b/src/modules/3-turn-engine/machine/actions.js @@ -235,18 +235,10 @@ export const setAddress = assign({ }, }); -export const setPayment = assign({ - order: ({ context, event }) => { - const method = event.method; - if (method !== "cash" && method !== "link") return context.order; - return { ...context.order, payment_type: method }; - }, -}); - export const enqueueWooCreateOrder = assign({ pending_actions: ({ context }) => [ ...(context.pending_actions || []), - { type: "create_order", payload: { payment: context.order?.payment_type } }, + { type: "create_order", payload: { source: "wa_bot" } }, ], }); @@ -324,10 +316,7 @@ export const replyShippingAskAddress = makeReplyAction("shipping.ask_address"); export const replyShippingAddressRecorded = makeReplyAction("shipping.address_recorded", ({ context }) => ({ address: context.order?.shipping_address || "", })); -export const replyShippingPickupToPayment = makeReplyAction("shipping.pickup_to_payment"); -export const replyPaymentAskMethod = makeReplyAction("payment.ask_method"); -export const replyPaymentConfirmed = makeReplyAction("payment.confirmed"); -export const replyWaitingInProgress = makeReplyAction("waiting.in_progress"); +export const replyOrderConfirmed = makeReplyAction("order.confirmed"); // View cart: necesita armar reply con cartDisplay + ask_more export const replyViewCart = assign({ @@ -372,7 +361,6 @@ export const actions = { capturePeopleCount, setShipping, setAddress, - setPayment, enqueueWooCreateOrder, enqueueAddToCart, enqueueRemoveFromCart, @@ -390,10 +378,7 @@ export const actions = { replyShippingAskMethod, replyShippingAskAddress, replyShippingAddressRecorded, - replyShippingPickupToPayment, - replyPaymentAskMethod, - replyPaymentConfirmed, - replyWaitingInProgress, + replyOrderConfirmed, replyViewCart, replyOptions, replyAskQuantity, diff --git a/src/modules/3-turn-engine/machine/guards.js b/src/modules/3-turn-engine/machine/guards.js index f87606a..26454b0 100644 --- a/src/modules/3-turn-engine/machine/guards.js +++ b/src/modules/3-turn-engine/machine/guards.js @@ -8,8 +8,6 @@ import { hasPendingItems as hasPending, hasReadyPendingItems as hasReadyPending, hasShippingInfo as hasShipping, - hasPaymentInfo as hasPayment, - isPaid, } from "../fsm.js"; import { parseIndexSelection, @@ -24,8 +22,6 @@ export const guards = { hasPending: ({ context }) => hasPending(context.order), hasReadyPending: ({ context }) => hasReadyPending(context.order), hasShipping: ({ context }) => hasShipping(context.order), - hasPayment: ({ context }) => hasPayment(context.order), - isPaid: ({ context }) => isPaid(context.order), noCart: ({ context }) => !hasCart(context.order), noShipping: ({ context }) => !hasShipping(context.order), diff --git a/src/modules/3-turn-engine/machine/index.js b/src/modules/3-turn-engine/machine/index.js index 0df7fb2..4480c1e 100644 --- a/src/modules/3-turn-engine/machine/index.js +++ b/src/modules/3-turn-engine/machine/index.js @@ -24,8 +24,6 @@ export const ConversationStates = Object.freeze({ IDLE: "idle", CART: "cart", SHIPPING: "shipping", - PAYMENT: "payment", - WAITING: "waiting", AWAITING_HUMAN: "awaiting_human", }); @@ -289,8 +287,8 @@ export const machine = setup({ SELECT_SHIPPING: [ { guard: ({ event }) => event.method === "pickup", - actions: ["setShipping", "replyShippingPickupToPayment"], - target: ConversationStates.PAYMENT, + actions: ["setShipping", "enqueueWooCreateOrder", "replyOrderConfirmed", "resetFailedSearch"], + target: ConversationStates.IDLE, }, { guard: ({ event }) => event.method === "delivery", @@ -303,8 +301,8 @@ export const machine = setup({ PROVIDE_ADDRESS: [ { guard: ({ context }) => context.order?.is_delivery === true, - actions: ["setAddress", "replyShippingAddressRecorded"], - target: ConversationStates.PAYMENT, + actions: ["setAddress", "enqueueWooCreateOrder", "replyOrderConfirmed", "resetFailedSearch"], + target: ConversationStates.IDLE, }, { actions: ["replyShippingAskMethod"], @@ -315,30 +313,6 @@ export const machine = setup({ }, }, - // ───────────────────────────────────────────────────────── - [ConversationStates.PAYMENT]: { - entry: ({ context }) => { context.fsmState = "PAYMENT"; }, - on: { - SELECT_PAYMENT: { - guard: ({ event }) => event.method === "cash" || event.method === "link", - actions: ["setPayment", "enqueueWooCreateOrder", "replyPaymentConfirmed"], - target: ConversationStates.WAITING, - }, - VIEW_CART: { actions: "replyPaymentAskMethod" }, - OTHER: { actions: "replyPaymentAskMethod" }, - }, - }, - - // ───────────────────────────────────────────────────────── - [ConversationStates.WAITING]: { - entry: ({ context }) => { context.fsmState = "WAITING_WEBHOOKS"; }, - on: { - WEBHOOK_PAID: { target: ConversationStates.IDLE }, - VIEW_CART: { actions: "replyWaitingInProgress" }, - OTHER: { actions: "replyWaitingInProgress" }, - }, - }, - // ───────────────────────────────────────────────────────── [ConversationStates.AWAITING_HUMAN]: { // Estado terminal hasta que un humano resuelva. No emite reply propio. @@ -353,15 +327,11 @@ export function xstateToLegacyState(value) { if (typeof value === "string") { if (value === "idle") return "IDLE"; if (value === "shipping") return "SHIPPING"; - if (value === "payment") return "PAYMENT"; - if (value === "waiting") return "WAITING_WEBHOOKS"; if (value === "awaiting_human") return "AWAITING_HUMAN"; } if (value && typeof value === "object") { if (value.cart) return "CART"; if (value.shipping) return "SHIPPING"; - if (value.payment) return "PAYMENT"; - if (value.waiting) return "WAITING_WEBHOOKS"; } return "IDLE"; } diff --git a/src/modules/3-turn-engine/machine/index.test.js b/src/modules/3-turn-engine/machine/index.test.js index dba6ba6..61e4392 100644 --- a/src/modules/3-turn-engine/machine/index.test.js +++ b/src/modules/3-turn-engine/machine/index.test.js @@ -168,18 +168,20 @@ describe("machine — checkout flow", () => { a.stop(); }); - it("SELECT_SHIPPING pickup goes to payment", async () => { + it("SELECT_SHIPPING pickup cierra la orden y vuelve a IDLE", async () => { const a = makeActor(); a.start(); await buildCartWithItem(a); a.send({ type: "CONFIRM_ORDER" }); a.send({ type: "SELECT_SHIPPING", method: "pickup" }); - expect(a.getSnapshot().value).toBe("payment"); - expect(a.getSnapshot().context.order.is_delivery).toBe(false); + const snap = a.getSnapshot(); + expect(snap.value).toBe("idle"); + expect(snap.context.order.is_delivery).toBe(false); + expect(snap.context.pending_actions.some((x) => x.type === "create_order")).toBe(true); a.stop(); }); - it("SELECT_SHIPPING delivery + PROVIDE_ADDRESS goes to payment", async () => { + it("SELECT_SHIPPING delivery + PROVIDE_ADDRESS cierra y vuelve a IDLE", async () => { const a = makeActor(); a.start(); await buildCartWithItem(a); @@ -187,21 +189,10 @@ describe("machine — checkout flow", () => { a.send({ type: "SELECT_SHIPPING", method: "delivery" }); expect(a.getSnapshot().value).toBe("shipping"); a.send({ type: "PROVIDE_ADDRESS", address: "Corrientes 1234" }); - expect(a.getSnapshot().value).toBe("payment"); - expect(a.getSnapshot().context.order.shipping_address).toBe("Corrientes 1234"); - a.stop(); - }); - - it("SELECT_PAYMENT cash goes to waiting and enqueues create_order", async () => { - const a = makeActor(); - a.start(); - await buildCartWithItem(a); - a.send({ type: "CONFIRM_ORDER" }); - a.send({ type: "SELECT_SHIPPING", method: "pickup" }); - a.send({ type: "SELECT_PAYMENT", method: "cash" }); const snap = a.getSnapshot(); - expect(snap.value).toBe("waiting"); - expect(snap.context.pending_actions.some((a) => a.type === "create_order")).toBe(true); + expect(snap.value).toBe("idle"); + expect(snap.context.order.shipping_address).toBe("Corrientes 1234"); + expect(snap.context.pending_actions.some((x) => x.type === "create_order")).toBe(true); a.stop(); }); }); @@ -229,11 +220,9 @@ describe("machine — snapshot persistence", () => { }); describe("xstateToLegacyState", () => { - it("maps top-level idle/shipping/payment/waiting", () => { + it("maps top-level idle/shipping (sin payment/waiting)", () => { expect(xstateToLegacyState("idle")).toBe("IDLE"); expect(xstateToLegacyState("shipping")).toBe("SHIPPING"); - expect(xstateToLegacyState("payment")).toBe("PAYMENT"); - expect(xstateToLegacyState("waiting")).toBe("WAITING_WEBHOOKS"); }); it("maps cart sub-states to CART", () => { expect(xstateToLegacyState({ cart: "idle" })).toBe("CART"); diff --git a/src/modules/3-turn-engine/machine/nluToEvent.js b/src/modules/3-turn-engine/machine/nluToEvent.js index 72eb5a0..a06276f 100644 --- a/src/modules/3-turn-engine/machine/nluToEvent.js +++ b/src/modules/3-turn-engine/machine/nluToEvent.js @@ -40,9 +40,6 @@ export function nluToEvent(nlu, text) { case "provide_address": return { type: "PROVIDE_ADDRESS", address: entities.address || text }; - case "select_payment": - return { type: "SELECT_PAYMENT", method: entities.payment_method || null }; - default: return { type: "OTHER", text }; } diff --git a/src/modules/3-turn-engine/machine/runner.js b/src/modules/3-turn-engine/machine/runner.js index 75cc1bb..a62d8a4 100644 --- a/src/modules/3-turn-engine/machine/runner.js +++ b/src/modules/3-turn-engine/machine/runner.js @@ -132,7 +132,6 @@ function buildContextPatch(snapshot, recentReplies, finalTemplateId, persistedSn unit: p.unit, status: p.status?.toLowerCase() || "needs_type", })), - payment_method: order.payment_type, shipping_method: order.is_delivery === true ? "delivery" : order.is_delivery === false ? "pickup" : null, delivery_address: order.shipping_address ? { text: order.shipping_address } : null, diff --git a/src/modules/3-turn-engine/nlu/index.js b/src/modules/3-turn-engine/nlu/index.js index a5af1d4..ccf0adf 100644 --- a/src/modules/3-turn-engine/nlu/index.js +++ b/src/modules/3-turn-engine/nlu/index.js @@ -9,7 +9,6 @@ import { routerClassify, quickDomainDetect } from "./router.js"; import { greetingNlu } from "./specialists/greeting.js"; import { ordersNlu } from "./specialists/orders.js"; import { shippingNlu } from "./specialists/shipping.js"; -import { paymentNlu } from "./specialists/payment.js"; import { browseNlu } from "./specialists/browse.js"; import { createEmptyNlu } from "./schemas.js"; @@ -50,7 +49,7 @@ export async function llmNluModular({ input, tenantId, storeConfig = {} } = {}) // Casos donde podemos saltar el router: // - Saludos simples - // - Números solos (1, 2) en estados SHIPPING/PAYMENT + // - Números solos (1, 2) en estado SHIPPING // - Patrones muy claros const skipRouter = shouldSkipRouter(text, state, quickDomain); @@ -85,12 +84,7 @@ export async function llmNluModular({ input, tenantId, storeConfig = {} } = {}) routing.specialist_used = "shipping"; result = await shippingNlu({ tenantId, text, storeConfig }); break; - - case "payment": - routing.specialist_used = "payment"; - result = await paymentNlu({ tenantId, text, storeConfig }); - break; - + case "browse": routing.specialist_used = "browse"; result = await browseNlu({ tenantId, text, storeConfig }); @@ -145,18 +139,11 @@ function shouldSkipRouter(text, state, quickDomain) { return true; } - // Números solos en estados específicos - if (/^[12]$/.test(t)) { - if (state === "SHIPPING" || state === "PAYMENT") { - return true; - } - } - - // "efectivo" o "tarjeta" solos en estado PAYMENT - if (state === "PAYMENT" && /^(efectivo|tarjeta|link|transfer)$/i.test(t)) { + // Números solos en estado SHIPPING (selección 1/2) + if (/^[12]$/.test(t) && state === "SHIPPING") { return true; } - + // "delivery" o "retiro" solos en estado SHIPPING if (state === "SHIPPING" && /^(delivery|retiro|buscar|sucursal)$/i.test(t)) { return true; diff --git a/src/modules/3-turn-engine/nlu/promptLoader.js b/src/modules/3-turn-engine/nlu/promptLoader.js index 4aa7515..3b319fc 100644 --- a/src/modules/3-turn-engine/nlu/promptLoader.js +++ b/src/modules/3-turn-engine/nlu/promptLoader.js @@ -183,7 +183,7 @@ export function getCacheStats() { * Pre-carga todos los prompts de un tenant (útil al inicio) */ export async function preloadPrompts({ tenantId, storeConfig = {} }) { - const promptKeys = ["router", "greeting", "orders", "shipping", "payment", "browse"]; + const promptKeys = ["router", "greeting", "orders", "shipping", "browse"]; const results = {}; for (const key of promptKeys) { diff --git a/src/modules/3-turn-engine/nlu/router.js b/src/modules/3-turn-engine/nlu/router.js index 873eb34..48159c7 100644 --- a/src/modules/3-turn-engine/nlu/router.js +++ b/src/modules/3-turn-engine/nlu/router.js @@ -103,7 +103,7 @@ function detectDomainByPatterns(text, state) { return "greeting"; } - // Si el estado ya es SHIPPING o PAYMENT, priorizar esos dominios + // Si el estado ya es SHIPPING, priorizar ese dominio if (state === "SHIPPING") { if (/delivery|env[ií]o|retiro|buscar|sucursal|direcci[oó]n/i.test(t)) { return "shipping"; @@ -113,17 +113,7 @@ function detectDomainByPatterns(text, state) { return "shipping"; } } - - if (state === "PAYMENT") { - if (/efectivo|cash|tarjeta|link|transfer|mercadopago|mp|qr/i.test(t)) { - return "payment"; - } - // Números simples (1 o 2) en estado PAYMENT - if (/^[12]$/.test(t.trim())) { - return "payment"; - } - } - + // Orders patterns const orderPatterns = [ /\b(quiero|dame|anotame|poneme|agregame|necesito)\b/i, @@ -152,17 +142,11 @@ function detectDomainByPatterns(text, state) { if (/\b(delivery|env[ií]o|retiro|buscar|sucursal)\b/i.test(t)) { return "shipping"; } - - // Payment patterns - if (/\b(efectivo|tarjeta|link|transfer|mercadopago)\b/i.test(t)) { - return "payment"; - } - + // Default basado en estado if (state === "CART") return "orders"; if (state === "SHIPPING") return "shipping"; - if (state === "PAYMENT") return "payment"; - + return "other"; } diff --git a/src/modules/3-turn-engine/nlu/schemas.js b/src/modules/3-turn-engine/nlu/schemas.js index 70a0187..908a317 100644 --- a/src/modules/3-turn-engine/nlu/schemas.js +++ b/src/modules/3-turn-engine/nlu/schemas.js @@ -18,7 +18,7 @@ export const RouterSchema = { properties: { domain: { type: "string", - enum: ["greeting", "orders", "shipping", "payment", "browse", "other"], + enum: ["greeting", "orders", "shipping", "browse", "other"], }, }, }; @@ -106,28 +106,6 @@ export const ShippingSchema = { export const validateShipping = ajv.compile(ShippingSchema); -// ───────────────────────────────────────────────────────────── -// Schema: Payment -// ───────────────────────────────────────────────────────────── - -export const PaymentSchema = { - $id: "Payment", - type: "object", - additionalProperties: false, - required: ["intent"], - properties: { - intent: { - type: "string", - enum: ["select_payment"], - }, - payment_method: { - anyOf: [{ type: "string", enum: ["cash", "link"] }, { type: "null" }], - }, - }, -}; - -export const validatePayment = ajv.compile(PaymentSchema); - // ───────────────────────────────────────────────────────────── // Schema: Browse // ───────────────────────────────────────────────────────────── @@ -164,7 +142,7 @@ export const UnifiedNluSchema = { type: "string", enum: [ "price_query", "browse", "add_to_cart", "remove_from_cart", - "checkout", "confirm_order", "select_payment", "select_shipping", + "checkout", "confirm_order", "select_shipping", "provide_address", "greeting", "recommend", "view_cart", "other" ], }, @@ -194,7 +172,6 @@ export const UnifiedNluSchema = { }, attributes: { type: "array", items: { type: "string" } }, preparation: { type: "array", items: { type: "string" } }, - payment_method: { anyOf: [{ type: "string", enum: ["cash", "link"] }, { type: "null" }] }, shipping_method: { anyOf: [{ type: "string", enum: ["delivery", "pickup"] }, { type: "null" }] }, address: { anyOf: [{ type: "string" }, { type: "null" }] }, items: { @@ -267,7 +244,6 @@ export function createEmptyNlu() { selection: null, attributes: [], preparation: [], - payment_method: null, shipping_method: null, address: null, items: null, diff --git a/src/modules/3-turn-engine/nlu/specialists/payment.js b/src/modules/3-turn-engine/nlu/specialists/payment.js deleted file mode 100644 index 2c24a38..0000000 --- a/src/modules/3-turn-engine/nlu/specialists/payment.js +++ /dev/null @@ -1,135 +0,0 @@ -/** - * Payment Specialist - Extracción de método de pago - */ - -import OpenAI from "openai"; -import { loadPrompt } from "../promptLoader.js"; -import { validatePayment, getValidationErrors, createEmptyNlu } from "../schemas.js"; - -let _client = null; - -function getClient() { - const apiKey = process.env.OPENAI_API_KEY || process.env.OPENAI_APIKEY; - if (!apiKey) { - throw new Error("OPENAI_API_KEY is not set"); - } - if (!_client) { - _client = new OpenAI({ apiKey }); - } - return _client; -} - -function extractJson(text) { - const s = String(text || ""); - const i = s.indexOf("{"); - const j = s.lastIndexOf("}"); - if (i >= 0 && j > i) { - try { - return JSON.parse(s.slice(i, j + 1)); - } catch { - return null; - } - } - return null; -} - -/** - * Detecta método de pago por patrones simples - */ -function detectPaymentMethod(text) { - const t = String(text || "").toLowerCase().trim(); - - // Números (asumiendo 1=efectivo, 2=link del contexto) - if (/^1$/.test(t)) return "cash"; - if (/^2$/.test(t)) return "link"; - - // Cash patterns - if (/\b(efectivo|cash|plata|billete|cuando (llega|llegue)|en mano)\b/i.test(t)) { - return "cash"; - } - - // Link patterns - if (/\b(tarjeta|link|transfer|qr|mercadopago|mp|d[eé]bito|cr[eé]dito)\b/i.test(t)) { - return "link"; - } - - return null; -} - -/** - * Procesa un mensaje de pago - * - * @param {Object} params - * @param {number} params.tenantId - ID del tenant - * @param {string} params.text - Mensaje del usuario - * @param {Object} params.storeConfig - Config de la tienda - * @returns {Object} NLU unificado - */ -export async function paymentNlu({ tenantId, text, storeConfig = {} }) { - // Intentar detección rápida primero - const quickMethod = detectPaymentMethod(text); - - // Si es claramente un número o patrón simple, no llamar al LLM - if (quickMethod && text.trim().length < 30) { - const nlu = createEmptyNlu(); - nlu.intent = "select_payment"; - nlu.confidence = 0.9; - nlu.entities.payment_method = quickMethod; - - return { - nlu, - raw_text: "", - model: null, - usage: null, - validation: { ok: true, skipped_llm: true }, - }; - } - - const openai = getClient(); - - // Cargar prompt de payment - const { content: systemPrompt, model } = await loadPrompt({ - tenantId, - promptKey: "payment", - variables: storeConfig, - }); - - // Hacer la llamada al LLM - const response = await openai.chat.completions.create({ - model: model || "gpt-4o-mini", - temperature: 0.1, - max_tokens: 100, - response_format: { type: "json_object" }, - messages: [ - { role: "system", content: systemPrompt }, - { role: "user", content: text }, - ], - }); - - const rawText = response?.choices?.[0]?.message?.content || ""; - let parsed = extractJson(rawText); - - // Validar - if (!parsed || !validatePayment(parsed)) { - // Fallback con detección por patrones - parsed = { - intent: "select_payment", - payment_method: quickMethod, - }; - } - - // Convertir a formato NLU unificado - const nlu = createEmptyNlu(); - nlu.intent = "select_payment"; - nlu.confidence = 0.85; - nlu.entities.payment_method = parsed.payment_method || null; - nlu.needs.catalog_lookup = false; - - return { - nlu, - raw_text: rawText, - model, - usage: response?.usage || null, - validation: { ok: true }, - }; -} diff --git a/src/modules/3-turn-engine/openai.js b/src/modules/3-turn-engine/openai.js index def865e..90ca3e4 100644 --- a/src/modules/3-turn-engine/openai.js +++ b/src/modules/3-turn-engine/openai.js @@ -76,7 +76,7 @@ const NluV3JsonSchema = { properties: { intent: { type: "string", - enum: ["price_query", "browse", "add_to_cart", "remove_from_cart", "checkout", "confirm_order", "select_payment", "select_shipping", "provide_address", "greeting", "recommend", "view_cart", "other"], + enum: ["price_query", "browse", "add_to_cart", "remove_from_cart", "checkout", "confirm_order", "select_shipping", "provide_address", "greeting", "recommend", "view_cart", "other"], }, confidence: { type: "number", minimum: 0, maximum: 1 }, language: { type: "string" }, @@ -104,8 +104,7 @@ const NluV3JsonSchema = { }, attributes: { type: "array", items: { type: "string" } }, preparation: { type: "array", items: { type: "string" } }, - // Checkout: método de pago, envío, dirección - payment_method: { anyOf: [{ type: "string", enum: ["cash", "link"] }, { type: "null" }] }, + // Checkout: envío y dirección. (El bot no maneja pagos.) shipping_method: { anyOf: [{ type: "string", enum: ["delivery", "pickup"] }, { type: "null" }] }, address: { anyOf: [{ type: "string" }, { type: "null" }] }, // Soporte para múltiples productos en un mensaje @@ -236,8 +235,7 @@ function normalizeNluOutput(parsed, input) { selection: entities.selection ?? null, attributes: Array.isArray(entities.attributes) ? entities.attributes : [], preparation: Array.isArray(entities.preparation) ? entities.preparation : [], - // Checkout entities (opcionales) - payment_method: entities.payment_method ?? null, + // Checkout entities (opcionales). El bot NO maneja pagos. shipping_method: entities.shipping_method ?? null, address: entities.address ?? null, items: normalizedItems, @@ -282,7 +280,6 @@ function nluV3Fallback() { selection: null, attributes: [], preparation: [], - payment_method: null, shipping_method: null, address: null, items: null, @@ -352,7 +349,6 @@ export async function llmNluV3({ input, model } = {}) { "- SALUDOS: Si el usuario SOLO saluda sin mencionar productos (hola, buen día, buenas tardes, buenas noches, qué tal, hey, hi), usá intent='greeting'. needs.catalog_lookup=false.\n" + "- VER CARRITO: Si el usuario pregunta qué tiene anotado/pedido/en el carrito (ej: 'qué tengo?', 'qué llevó?', 'qué anoté?', 'mostrame mi pedido'), usá intent='view_cart'. needs.catalog_lookup=false.\n" + "- CONFIRMAR ORDEN: Si el usuario quiere cerrar/confirmar el pedido (ej: 'listo', 'eso es todo', 'cerrar pedido', 'ya está', 'nada más'), usá intent='confirm_order'. needs.catalog_lookup=false.\n" + - "- SELECCIONAR PAGO: Si el usuario elige método de pago (ej: 'efectivo', 'tarjeta', 'link de pago', 'transferencia'), usá intent='select_payment'. Extraer entities.payment_method='cash'|'link'.\n" + "- SELECCIONAR ENVÍO: Si el usuario elige envío (ej: 'delivery', 'envío', 'que me lo traigan', 'retiro', 'paso a buscar'), usá intent='select_shipping'. Extraer entities.shipping_method='delivery'|'pickup'.\n" + "- DAR DIRECCIÓN: Si el usuario da una dirección de entrega, usá intent='provide_address'. Extraer entities.address con el texto de la dirección.\n" + "- MULTI-ITEMS: Si el usuario menciona MÚLTIPLES productos en un mensaje (ej: '1 chimichurri y 2 provoletas'), usá entities.items con array de objetos.\n" + diff --git a/src/modules/3-turn-engine/orderModel.js b/src/modules/3-turn-engine/orderModel.js index ce5ff0d..2a3dcd6 100644 --- a/src/modules/3-turn-engine/orderModel.js +++ b/src/modules/3-turn-engine/orderModel.js @@ -1,8 +1,11 @@ /** * Modelo unificado de orden para el contexto de conversación. - * + * * Reemplaza: order_basket, pending_items, pending_clarification, pending_item, - * checkout_step, payment_method, shipping_method, delivery_address + * checkout_step, shipping_method, delivery_address. + * + * El bot toma pedidos y datos de entrega; NO maneja pagos. El cobro se + * coordina offline. */ // Status de items pendientes @@ -19,11 +22,9 @@ export function createEmptyOrder() { return { cart: [], // Items confirmados: [{ woo_id, qty, unit, name, price }] pending: [], // Items por clarificar - payment_type: null, // "link" | "cash" | null is_delivery: null, // true | false | null shipping_address: null, woo_order_id: null, - is_paid: false, }; } @@ -294,15 +295,11 @@ export function migrateOldContext(ctx) { } // Migrar checkout info - order.payment_type = ctx.payment_method || null; order.is_delivery = ctx.shipping_method === "delivery" ? true : ctx.shipping_method === "pickup" ? false : null; order.shipping_address = ctx.delivery_address?.text || ctx.address?.text || ctx.address_text || null; order.woo_order_id = ctx.woo_order_id || ctx.last_order_id || null; - order.is_paid = ctx.mp?.payment_status === "approved" || - ctx.payment?.status === "paid" || - ctx.payment_status === "approved" || false; - + return order; } diff --git a/src/modules/3-turn-engine/orderModel.test.js b/src/modules/3-turn-engine/orderModel.test.js index 6f5ff78..2d5cb3f 100644 --- a/src/modules/3-turn-engine/orderModel.test.js +++ b/src/modules/3-turn-engine/orderModel.test.js @@ -23,15 +23,15 @@ import { // ───────────────────────────────────────────────────────────── describe('createEmptyOrder', () => { - it('crea orden con estructura correcta', () => { + it('crea orden con estructura correcta (sin payment)', () => { const order = createEmptyOrder(); expect(order).toHaveProperty('cart'); expect(order).toHaveProperty('pending'); - expect(order).toHaveProperty('payment_type'); expect(order).toHaveProperty('is_delivery'); expect(order).toHaveProperty('shipping_address'); expect(order).toHaveProperty('woo_order_id'); - expect(order).toHaveProperty('is_paid'); + expect(order).not.toHaveProperty('payment_type'); + expect(order).not.toHaveProperty('is_paid'); }); it('inicializa arrays vacíos', () => { @@ -40,13 +40,11 @@ describe('createEmptyOrder', () => { expect(order.pending).toEqual([]); }); - it('inicializa valores null/false', () => { + it('inicializa valores null', () => { const order = createEmptyOrder(); - expect(order.payment_type).toBeNull(); expect(order.is_delivery).toBeNull(); expect(order.shipping_address).toBeNull(); expect(order.woo_order_id).toBeNull(); - expect(order.is_paid).toBe(false); }); }); @@ -569,19 +567,19 @@ describe('migrateOldContext', () => { expect(result.pending[0].status).toBe(PendingStatus.NEEDS_TYPE); }); - it('migra checkout info', () => { + it('migra checkout info (shipping/address/order_id, no payment)', () => { const ctx = { - payment_method: 'cash', shipping_method: 'delivery', delivery_address: { text: 'Calle Falsa 123' }, woo_order_id: 456, }; - + const result = migrateOldContext(ctx); - expect(result.payment_type).toBe('cash'); expect(result.is_delivery).toBe(true); expect(result.shipping_address).toBe('Calle Falsa 123'); expect(result.woo_order_id).toBe(456); + expect(result.payment_type).toBeUndefined(); + expect(result.is_paid).toBeUndefined(); }); it('migra shipping_method pickup', () => { diff --git a/src/modules/3-turn-engine/replyTemplates.js b/src/modules/3-turn-engine/replyTemplates.js index 323091b..d0e85d9 100644 --- a/src/modules/3-turn-engine/replyTemplates.js +++ b/src/modules/3-turn-engine/replyTemplates.js @@ -99,25 +99,11 @@ export const DEFAULTS = { "Anotado: {{address}}.", "Listo, dirección guardada: {{address}}.", ], - "shipping.pickup_to_payment": [ - "Genial, lo pasás a buscar. ¿Cómo abonás?", - "Pickup confirmado. ¿Pago en efectivo o link?", - ], - - // ---------------- PAYMENT ---------------- - "payment.ask_method": [ - "¿Cómo querés abonar? Efectivo o link de pago.", - "Para cerrar, ¿pagás en efectivo o con link?", - ], - "payment.confirmed": [ - "Listo, te paso los datos del pedido.", - "Perfecto, queda armado. Te paso los datos.", - ], - - // ---------------- WAITING ---------------- - "waiting.in_progress": [ - "Tu pedido está en proceso. Cualquier cosa avisame.", - "Esperando confirmación del pago. ¿Necesitás algo más?", + // ---------------- ORDER CLOSE ---------------- + "order.confirmed": [ + "¡Listo! Anotamos tu pedido. Te coordinamos por acá la entrega y el pago.", + "Perfecto, ya quedó registrado. Te confirmamos en breve los detalles de entrega.", + "Genial, anotado. Cualquier ajuste avisame por acá.", ], }; @@ -282,7 +268,7 @@ const REWRITE_KEYS = new Set([ "idle.greeting", // se filtra adicionalmente: solo en 1er turno "shipping.ask_method", "shipping.ask_address", - "payment.ask_method", + "order.confirmed", ]); function shouldRewrite(templateKey, history) { diff --git a/src/modules/3-turn-engine/stateHandlers.js b/src/modules/3-turn-engine/stateHandlers.js index 7e67d3f..36322a6 100644 --- a/src/modules/3-turn-engine/stateHandlers.js +++ b/src/modules/3-turn-engine/stateHandlers.js @@ -1,28 +1,19 @@ /** - * State Handlers - Re-export desde módulo refactorizado - * - * Este archivo mantiene compatibilidad con imports existentes. - * La implementación real está en ./stateHandlers/ - * - * Estructura del módulo: - * - stateHandlers/utils.js - Utilidades de parseo y detección de texto + * State Handlers - Re-export desde módulo refactorizado. + * + * Estructura: + * - stateHandlers/utils.js - Utilidades de parseo y detección de texto * - stateHandlers/cartHelpers.js - Helpers para manejo del carrito - * - stateHandlers/idle.js - Handler estado IDLE - * - stateHandlers/cart.js - Handler estado CART - * - stateHandlers/shipping.js - Handler estado SHIPPING - * - stateHandlers/payment.js - Handler estado PAYMENT - * - stateHandlers/waiting.js - Handler estado WAITING_WEBHOOKS + * - stateHandlers/idle.js - Handler estado IDLE + * - stateHandlers/cart.js - Handler estado CART + * - stateHandlers/shipping.js - Handler estado SHIPPING (cierra orden) */ export { - // Handlers principales handleIdleState, handleCartState, handleShippingState, - handlePaymentState, - handleWaitingState, - - // Utilidades + inferDefaultUnit, parseIndexSelection, isShowMoreRequest, @@ -31,8 +22,7 @@ export { isEscapeRequest, normalizeUnit, unitAskFor, - - // Helpers de carrito + extractProductQueries, createPendingItemFromSearch, processPendingClarification, diff --git a/src/modules/3-turn-engine/stateHandlers/index.js b/src/modules/3-turn-engine/stateHandlers/index.js index 2ff2a92..7781229 100644 --- a/src/modules/3-turn-engine/stateHandlers/index.js +++ b/src/modules/3-turn-engine/stateHandlers/index.js @@ -9,8 +9,6 @@ export { handleIdleState } from "./idle.js"; export { handleCartState } from "./cart.js"; export { handleShippingState } from "./shipping.js"; -export { handlePaymentState } from "./payment.js"; -export { handleWaitingState } from "./waiting.js"; // Utilidades (para uso interno principalmente) export { diff --git a/src/modules/3-turn-engine/stateHandlers/payment.js b/src/modules/3-turn-engine/stateHandlers/payment.js deleted file mode 100644 index ab04faa..0000000 --- a/src/modules/3-turn-engine/stateHandlers/payment.js +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Handler para el estado PAYMENT - */ - -import { ConversationState, safeNextState } from "../fsm.js"; -import { createEmptyOrder, formatCartForDisplay } from "../orderModel.js"; -import { parseIndexSelection } from "./utils.js"; -import { renderReply } from "../replyTemplates.js"; - -const PAYMENT_OPTIONS_TAIL = "\n\n1) Efectivo\n2) Link de pago (tarjeta/transferencia)\n\nRespondé con el número o decime directamente."; - -export async function handlePaymentState({ tenantId, text, nlu, order, audit, recentReplies, conversation_history }) { - const intent = nlu?.intent || "other"; - let currentOrder = order || createEmptyOrder(); - const actions = []; - const rewriteCtx = { conversation_history, state: "PAYMENT", userText: text }; - - let paymentMethod = nlu?.entities?.payment_method; - - if (!paymentMethod) { - const t = String(text || "").toLowerCase(); - const idx = parseIndexSelection(text); - if (idx === 1 || /efectivo|cash|plata/i.test(t)) { - paymentMethod = "cash"; - } else if (idx === 2 || /link|tarjeta|transfer|qr|mercado\s*pago/i.test(t)) { - paymentMethod = "link"; - } - } - - if (paymentMethod) { - currentOrder = { ...currentOrder, payment_type: paymentMethod }; - actions.push({ type: "create_order", payload: { payment: paymentMethod } }); - - const { next_state } = safeNextState(ConversationState.PAYMENT, currentOrder, { payment_selected: true }); - - const paymentLabel = paymentMethod === "cash" ? "efectivo" : "link de pago"; - const deliveryInfo = currentOrder.is_delivery - ? `Te lo llevamos a ${currentOrder.shipping_address || "la dirección indicada"}.` - : "Retiro en sucursal."; - const paymentInfo = paymentMethod === "link" - ? "Te contactamos para coordinar el pago." - : "Pagás en " + (currentOrder.is_delivery ? "la entrega" : "sucursal") + "."; - - const r = await renderReply({ tenantId, templateKey: "payment.confirmed", recentReplies, ...rewriteCtx }); - return { - plan: { - reply: `${r.reply} Pagás en ${paymentLabel}. ${deliveryInfo}\n\n${paymentInfo}`, - next_state, - intent: "select_payment", - missing_fields: [], - order_action: "create_order", - }, - decision: { actions, order: currentOrder, audit, template_ids_used: [r.template_id] }, - }; - } - - if (intent === "view_cart") { - const cartDisplay = formatCartForDisplay(currentOrder); - const r = await renderReply({ tenantId, templateKey: "payment.ask_method", recentReplies, ...rewriteCtx }); - return { - plan: { - reply: `${cartDisplay}\n\n${r.reply}`, - next_state: ConversationState.PAYMENT, - intent: "view_cart", - missing_fields: ["payment_method"], - order_action: "none", - }, - decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] }, - }; - } - - const r = await renderReply({ tenantId, templateKey: "payment.ask_method", recentReplies, ...rewriteCtx }); - return { - plan: { - reply: `${r.reply}${PAYMENT_OPTIONS_TAIL}`, - next_state: ConversationState.PAYMENT, - intent: "other", - missing_fields: ["payment_method"], - order_action: "none", - }, - decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] }, - }; -} diff --git a/src/modules/3-turn-engine/stateHandlers/shipping.js b/src/modules/3-turn-engine/stateHandlers/shipping.js index 60a2b7e..bc79a30 100644 --- a/src/modules/3-turn-engine/stateHandlers/shipping.js +++ b/src/modules/3-turn-engine/stateHandlers/shipping.js @@ -1,19 +1,19 @@ /** - * Handler para el estado SHIPPING + * Handler para el estado SHIPPING. + * + * Pide modo de entrega (delivery / pickup) y, si es delivery, la dirección. + * Cuando completa, emite acción `create_order` y vuelve a IDLE. + * El bot NO maneja pago — el cobro se gestiona offline. */ -import { ConversationState, safeNextState } from "../fsm.js"; +import { ConversationState } from "../fsm.js"; import { createEmptyOrder, formatCartForDisplay } from "../orderModel.js"; import { parseIndexSelection } from "./utils.js"; import { renderReply } from "../replyTemplates.js"; import { buildStoreContextVars } from "../storeContext.js"; const SHIPPING_OPTIONS_TAIL = "\n\n1) Delivery (te lo llevamos)\n2) Retiro en sucursal"; -const PAYMENT_OPTIONS_TAIL = "\n\n1) Efectivo\n2) Link de pago (tarjeta/transferencia)\n\nRespondé con el número o decime directamente."; -/** - * Maneja el estado SHIPPING (selección de envío) - */ export async function handleShippingState({ tenantId, text, nlu, order, audit, recentReplies, storeConfig, conversation_history }) { const intent = nlu?.intent || "other"; let currentOrder = order || createEmptyOrder(); @@ -37,18 +37,8 @@ export async function handleShippingState({ tenantId, text, nlu, order, audit, r currentOrder = { ...currentOrder, is_delivery: shippingMethod === "delivery" }; if (shippingMethod === "pickup") { - const { next_state } = safeNextState(ConversationState.SHIPPING, currentOrder, {}); - const r = await renderReply({ tenantId, templateKey: "shipping.pickup_to_payment", vars: storeVars, recentReplies, ...rewriteCtx }); - return { - plan: { - reply: `${r.reply}${PAYMENT_OPTIONS_TAIL}`, - next_state, - intent: "select_shipping", - missing_fields: ["payment_method"], - order_action: "none", - }, - decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] }, - }; + // Pickup: orden lista, cerrarla. + return finalizeOrder({ tenantId, currentOrder, audit, recentReplies, storeVars, rewriteCtx }); } // Delivery: pedir dirección si no la tiene @@ -73,29 +63,7 @@ export async function handleShippingState({ tenantId, text, nlu, order, audit, r if (address) { currentOrder = { ...currentOrder, shipping_address: address }; - const { next_state } = safeNextState(ConversationState.SHIPPING, currentOrder, {}); - const recorded = await renderReply({ - tenantId, - templateKey: "shipping.address_recorded", - vars: { address }, - recentReplies, - }); - const askPay = await renderReply({ tenantId, templateKey: "payment.ask_method", vars: storeVars, recentReplies, ...rewriteCtx }); - return { - plan: { - reply: `${recorded.reply}\n\n${askPay.reply}${PAYMENT_OPTIONS_TAIL}`, - next_state, - intent: "provide_address", - missing_fields: ["payment_method"], - order_action: "none", - }, - decision: { - actions: [], - order: currentOrder, - audit, - template_ids_used: [recorded.template_id, askPay.template_id], - }, - }; + return finalizeOrder({ tenantId, currentOrder, audit, recentReplies, storeVars, rewriteCtx, addressJustRecorded: true }); } const r = await renderReply({ tenantId, templateKey: "shipping.ask_address", vars: storeVars, recentReplies, ...rewriteCtx }); @@ -140,3 +108,39 @@ export async function handleShippingState({ tenantId, text, nlu, order, audit, r decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] }, }; } + +/** + * Cierra la orden: emite acción create_order y vuelve a IDLE. + */ +async function finalizeOrder({ tenantId, currentOrder, audit, recentReplies, storeVars, rewriteCtx, addressJustRecorded = false }) { + const cartDisplay = formatCartForDisplay(currentOrder); + const confirmed = await renderReply({ tenantId, templateKey: "order.confirmed", vars: storeVars, recentReplies, ...rewriteCtx }); + const addressEcho = addressJustRecorded + ? await renderReply({ tenantId, templateKey: "shipping.address_recorded", vars: { ...storeVars, address: currentOrder.shipping_address }, recentReplies }) + : null; + + const reply = [ + addressEcho?.reply, + cartDisplay, + confirmed.reply, + ].filter(Boolean).join("\n\n"); + + return { + plan: { + reply, + next_state: ConversationState.IDLE, + intent: "confirm_order", + missing_fields: [], + order_action: "create_order", + }, + decision: { + actions: [{ type: "create_order", payload: { source: "wa_bot" } }], + order: currentOrder, + audit, + template_ids_used: [ + addressEcho?.template_id, + confirmed.template_id, + ].filter(Boolean), + }, + }; +} diff --git a/src/modules/3-turn-engine/stateHandlers/waiting.js b/src/modules/3-turn-engine/stateHandlers/waiting.js deleted file mode 100644 index ee24bfb..0000000 --- a/src/modules/3-turn-engine/stateHandlers/waiting.js +++ /dev/null @@ -1,133 +0,0 @@ -/** - * Handler para el estado WAITING_WEBHOOKS - */ - -import { ConversationState } from "../fsm.js"; -import { createEmptyOrder, formatCartForDisplay } from "../orderModel.js"; -import { renderReply } from "../replyTemplates.js"; - -/** - * Detecta si el usuario pregunta por horarios/días de entrega - */ -function isDeliveryInfoQuestion(text) { - const t = String(text || "").toLowerCase(); - const patterns = [ - /cu[aá]ndo\s+(entrega|llega|env[ií])/i, - /qu[eé]\s+d[ií]as?\s+(entrega|env[ií]|repart)/i, - /d[ií]as?\s+de\s+(entrega|env[ií]|reparto)/i, - /horario\s+de\s+(entrega|env[ií]|reparto)/i, - /qu[eé]\s+horarios?/i, - /a\s+qu[eé]\s+hora/i, - /en\s+qu[eé]\s+horario/i, - /cuando\s+(me\s+)?lo\s+traen/i, - /d[ií]a\s+y\s+hora/i, - ]; - return patterns.some(p => p.test(t)); -} - -/** - * Detecta si el usuario pregunta por horarios de retiro - */ -function isPickupInfoQuestion(text) { - const t = String(text || "").toLowerCase(); - const patterns = [ - /horario.*(retir|buscar|pasar)/i, - /cu[aá]ndo.*(retir|buscar|pasar)/i, - /a\s+qu[eé]\s+hora.*(retir|buscar)/i, - /d[ií]as?.*(retir|buscar)/i, - ]; - return patterns.some(p => p.test(t)); -} - -/** - * Maneja el estado WAITING_WEBHOOKS (esperando confirmación de pago) - */ -export async function handleWaitingState({ tenantId, text, nlu, order, audit, storeConfig = {}, recentReplies }) { - const intent = nlu?.intent || "other"; - const currentOrder = order || createEmptyOrder(); - - // view_cart - if (intent === "view_cart") { - const cartDisplay = formatCartForDisplay(currentOrder); - const status = currentOrder.is_paid ? "✓ Pagado" : "Esperando pago..."; - return { - plan: { - reply: `${cartDisplay}\n\nEstado: ${status}`, - next_state: ConversationState.WAITING_WEBHOOKS, - intent: "view_cart", - missing_fields: [], - order_action: "none", - }, - decision: { actions: [], order: currentOrder, audit }, - }; - } - - // Preguntas sobre horarios/días de entrega - if (isDeliveryInfoQuestion(text)) { - // Usar deliveryHours que ya viene formateado desde getStoreConfig - // (agrupa días con mismos horarios: "Lunes a Viernes de 9:00 a 14:00, Sábado de 9:00 a 13:00") - const deliveryHours = storeConfig.deliveryHours; - - let reply = ""; - if (deliveryHours && deliveryHours !== "No disponible") { - reply = `Hacemos entregas: ${deliveryHours}. `; - } else if (storeConfig.deliveryEnabled === false) { - reply = "Por el momento no ofrecemos delivery. "; - } else { - reply = "Todavía no tengo configurados los horarios de entrega. "; - } - - reply += "Tu pedido ya está en proceso, avisame cualquier cosa."; - - return { - plan: { - reply, - next_state: ConversationState.WAITING_WEBHOOKS, - intent: "delivery_info", - missing_fields: [], - order_action: "none", - }, - decision: { actions: [], order: currentOrder, audit }, - }; - } - - // Preguntas sobre horarios de retiro - if (isPickupInfoQuestion(text)) { - const pickupHours = storeConfig.pickupHours; - - let reply = ""; - if (pickupHours && pickupHours !== "No disponible") { - reply = `Podés retirar: ${pickupHours}. `; - } else if (storeConfig.pickupEnabled === false) { - reply = "Por el momento no ofrecemos retiro en tienda. "; - } else { - reply = "Todavía no tengo configurados los horarios de retiro. "; - } - - reply += "Tu pedido ya está en proceso, avisame cualquier cosa."; - - return { - plan: { - reply, - next_state: ConversationState.WAITING_WEBHOOKS, - intent: "pickup_info", - missing_fields: [], - order_action: "none", - }, - decision: { actions: [], order: currentOrder, audit }, - }; - } - - // Default - const r = await renderReply({ tenantId, templateKey: "waiting.in_progress", recentReplies }); - return { - plan: { - reply: r.reply, - next_state: ConversationState.WAITING_WEBHOOKS, - intent: "other", - missing_fields: [], - order_action: "none", - }, - decision: { actions: [], order: currentOrder, audit, template_ids_used: [r.template_id] }, - }; -} diff --git a/src/modules/3-turn-engine/turnEngineV3.js b/src/modules/3-turn-engine/turnEngineV3.js index eea81fa..56e0008 100644 --- a/src/modules/3-turn-engine/turnEngineV3.js +++ b/src/modules/3-turn-engine/turnEngineV3.js @@ -1,7 +1,7 @@ /** * Turn Engine V3 - Dispatcher basado en estados * - * Flujo: IDLE → CART → SHIPPING → PAYMENT → WAITING_WEBHOOKS + * Flujo: IDLE → CART → SHIPPING → IDLE (orden creada offline) * Regla universal: add_to_cart SIEMPRE vuelve a CART desde cualquier estado. * * Feature flag USE_MODULAR_NLU=true para usar el nuevo sistema NLU modular. @@ -15,8 +15,6 @@ import { handleIdleState, handleCartState, handleShippingState, - handlePaymentState, - handleWaitingState, } from "./stateHandlers.js"; import { getStoreConfig } from "../0-ui/db/settingsRepo.js"; import { pushRecent } from "./replyTemplates.js"; @@ -200,14 +198,6 @@ export async function runTurnV3({ result = await handleShippingState(handlerParams); break; - case ConversationState.PAYMENT: - result = await handlePaymentState(handlerParams); - break; - - case ConversationState.WAITING_WEBHOOKS: - result = await handleWaitingState(handlerParams); - break; - default: // Estado desconocido, tratar como IDLE result = await handleIdleState(handlerParams); @@ -242,21 +232,17 @@ function normalizeState(state) { if (s === "IDLE") return ConversationState.IDLE; if (s === "CART") return ConversationState.CART; if (s === "SHIPPING") return ConversationState.SHIPPING; - if (s === "PAYMENT") return ConversationState.PAYMENT; - if (s === "WAITING_WEBHOOKS") return ConversationState.WAITING_WEBHOOKS; - - // Estados viejos → CART + + // Estados viejos / payment-flow legacy → mapeos seguros if (["CART_ACTIVE", "BROWSING", "CLARIFYING_ITEMS", "AWAITING_QUANTITY"].includes(s)) { return ConversationState.CART; } - - // Estados de checkout viejos - if (s === "CLARIFYING_PAYMENT") return ConversationState.PAYMENT; - if (s === "CLARIFYING_SHIPPING") return ConversationState.SHIPPING; - if (s === "AWAITING_ADDRESS") return ConversationState.SHIPPING; - if (s === "AWAITING_PAYMENT") return ConversationState.WAITING_WEBHOOKS; - if (s === "COMPLETED") return ConversationState.IDLE; // Nuevo ciclo - + if (s === "CLARIFYING_SHIPPING" || s === "AWAITING_ADDRESS") return ConversationState.SHIPPING; + // Estados que ya no existen (payment / waiting / completed) vuelven a IDLE + if (["PAYMENT", "WAITING_WEBHOOKS", "CLARIFYING_PAYMENT", "AWAITING_PAYMENT", "COMPLETED"].includes(s)) { + return ConversationState.IDLE; + } + return ConversationState.IDLE; } @@ -311,8 +297,7 @@ function formatResult(result, prevContext, recentReplies = [], failedSearches = unit: p.unit, status: p.status?.toLowerCase() || "needs_type", })), - payment_method: order.payment_type, - shipping_method: order.is_delivery === true ? "delivery" : + shipping_method: order.is_delivery === true ? "delivery" : order.is_delivery === false ? "pickup" : null, delivery_address: order.shipping_address ? { text: order.shipping_address } : null, woo_order_id: order.woo_order_id, diff --git a/src/modules/4-woo-orders/wooOrders.js b/src/modules/4-woo-orders/wooOrders.js index 62d7416..11038e4 100644 --- a/src/modules/4-woo-orders/wooOrders.js +++ b/src/modules/4-woo-orders/wooOrders.js @@ -193,27 +193,23 @@ function mapAddress(address) { }; } -export async function createOrder({ tenantId, wooCustomerId, basket, address, shippingMethod, paymentMethod, run_id }) { +export async function createOrder({ tenantId, wooCustomerId, basket, address, shippingMethod, run_id }) { const lockKey = `${tenantId}:${wooCustomerId || "anon"}`; return withLock(lockKey, async () => { const client = await getWooClient({ tenantId }); const lineItems = await buildLineItems({ tenantId, basket }); if (!lineItems.length) throw new Error("order_empty_basket"); const addr = mapAddress(address); + // Estado "pending" en Woo = el pago/cobro lo gestiona el comercio offline. const payload = { status: "pending", customer_id: wooCustomerId || undefined, line_items: lineItems, ...(addr ? { billing: addr, shipping: addr } : {}), - // Si es cash, usar payment_method "cod" (Cash On Delivery) de WooCommerce - ...(paymentMethod === "cash" ? { payment_method: "cod", payment_method_title: "Efectivo" } : {}), meta_data: [ { key: "source", value: "whatsapp" }, ...(run_id ? [{ key: "run_id", value: run_id }] : []), - // Guardar shipping_method como metadata para poder leerlo después ...(shippingMethod ? [{ key: "shipping_method", value: shippingMethod }] : []), - // Guardar payment_method como metadata también - ...(paymentMethod ? [{ key: "payment_method_wa", value: paymentMethod }] : []), ], }; const url = `${client.base}/orders`;