Eliminar payment + waiting (legacy): el bot toma pedidos, no cobra
El bot conversacional no maneja pagos. Su trabajo: pedidos, datos de entrega, dejar la orden anotada en Woo (status=pending). El cobro lo gestiona el comercio offline. Todo lo de payment_type / is_paid / PAYMENT / WAITING_WEBHOOKS era legacy de un flow viejo que se baja. Nuevo flow: IDLE → CART → SHIPPING → IDLE (con orden creada). Cuando el usuario completa shipping (pickup elegido OR delivery+address), shipping.js emite create_order y el bot cierra con order.confirmed. - fsm.js: 4 estados (IDLE/CART/SHIPPING/AWAITING_HUMAN). hasPaymentInfo e isPaid eliminados. deriveNextState gira SHIPPING→IDLE en vez de →PAYMENT→WAITING. ALLOWED transitions actualizadas. - orderModel.js: createEmptyOrder() sin payment_type/is_paid. migrateOldContext deja de leer payment_method / mp.payment_status. - stateHandlers: payment.js y waiting.js eliminados. shipping.js gana finalizeOrder() que emite create_order action y vuelve a IDLE. - replyTemplates: payment.* y waiting.* fuera. order.confirmed nuevo, con 3 variantes y rewriter habilitado. - NLU openai.js + nlu/schemas.js: select_payment fuera del enum, payment_method fuera de entities. Prompt sin la regla de SELECCIONAR PAGO. - nlu/router.js + nlu/index.js: dominio "payment" eliminado. shouldSkipRouter ya no chequea PAYMENT. - nlu/specialists/payment.js: eliminado. - promptsRepo.js + promptLoader.js: PROMPT_KEYS sin "payment". - turnEngineV3.js: switch ya no dispatcha a PAYMENT/WAITING. normalizeState mapea estados legacy (PAYMENT/WAITING_WEBHOOKS/COMPLETED) a IDLE. context_patch ya no emite payment_method. - wooOrders.createOrder: paymentMethod param eliminado. Order queda en status=pending sin payment_method (cobro offline). - pipeline.js: paymentMethod fuera del create_order glue. Invariant "no_checkout_without_payment_link" eliminado. signal payment_selected reemplazado por shipping_completed. - XState machine: top-level PAYMENT y WAITING eliminados. SELECT_PAYMENT event fuera. SHIPPING ahora cierra con enqueueWooCreateOrder + replyOrderConfirmed → IDLE. Guards hasPayment/isPaid borrados. - Tests fsm.test.js / orderModel.test.js / machine/index.test.js actualizados al nuevo contrato. 188 tests pasando. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@ import { pool } from "../../shared/db/pool.js";
|
|||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Prompt keys válidos
|
// 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
|
// Modelos por defecto para cada prompt
|
||||||
export const DEFAULT_MODELS = {
|
export const DEFAULT_MODELS = {
|
||||||
@@ -13,7 +13,6 @@ export const DEFAULT_MODELS = {
|
|||||||
greeting: "gpt-4-turbo",
|
greeting: "gpt-4-turbo",
|
||||||
orders: "gpt-4-turbo",
|
orders: "gpt-4-turbo",
|
||||||
shipping: "gpt-4o-mini",
|
shipping: "gpt-4o-mini",
|
||||||
payment: "gpt-4o-mini",
|
|
||||||
browse: "gpt-4-turbo",
|
browse: "gpt-4-turbo",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -205,7 +205,6 @@ const runStatus = llmMeta?.error ? "warn" : "ok";
|
|||||||
ok: true,
|
ok: true,
|
||||||
checks: [
|
checks: [
|
||||||
{ name: "required_keys_present", ok: true },
|
{ name: "required_keys_present", ok: true },
|
||||||
{ name: "no_checkout_without_payment_link", ok: true },
|
|
||||||
{ name: "no_order_action_without_items", ok: true },
|
{ name: "no_order_action_without_items", ok: true },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -257,16 +256,14 @@ const runStatus = llmMeta?.error ? "warn" : "ok";
|
|||||||
...baseAddress,
|
...baseAddress,
|
||||||
phone: baseAddress.phone || phoneFromWa,
|
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 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({
|
const order = await createOrder({
|
||||||
tenantId,
|
tenantId,
|
||||||
wooCustomerId: externalCustomerId,
|
wooCustomerId: externalCustomerId,
|
||||||
basket: basketToUse,
|
basket: basketToUse,
|
||||||
address: addressWithPhone,
|
address: addressWithPhone,
|
||||||
shippingMethod,
|
shippingMethod,
|
||||||
paymentMethod,
|
|
||||||
run_id: null,
|
run_id: null,
|
||||||
});
|
});
|
||||||
actionPatch.woo_order_id = order?.id || null;
|
actionPatch.woo_order_id = order?.id || null;
|
||||||
@@ -391,7 +388,7 @@ const runStatus = llmMeta?.error ? "warn" : "ok";
|
|||||||
const orderForFsm = context?.order || context?.order_basket || {};
|
const orderForFsm = context?.order || context?.order_basket || {};
|
||||||
const signals = {
|
const signals = {
|
||||||
confirm_order: plan.intent === "confirm_order",
|
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;
|
const nextState = safeNextState(prev_state, orderForFsm, signals).next_state;
|
||||||
plan.next_state = nextState;
|
plan.next_state = nextState;
|
||||||
@@ -448,7 +445,6 @@ const runStatus = llmMeta?.error ? "warn" : "ok";
|
|||||||
invariants,
|
invariants,
|
||||||
final_reply: plan.reply,
|
final_reply: plan.reply,
|
||||||
order_id: actionPatch.woo_order_id || null,
|
order_id: actionPatch.woo_order_id || null,
|
||||||
payment_link: actionPatch.payment_link || null,
|
|
||||||
latency_ms: end_to_end_ms,
|
latency_ms: end_to_end_ms,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* FSM simplificada para el flujo conversacional.
|
* 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.
|
* Regla universal: add_to_cart SIEMPRE vuelve a CART desde cualquier estado.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -9,8 +10,6 @@ export const ConversationState = Object.freeze({
|
|||||||
IDLE: "IDLE",
|
IDLE: "IDLE",
|
||||||
CART: "CART",
|
CART: "CART",
|
||||||
SHIPPING: "SHIPPING",
|
SHIPPING: "SHIPPING",
|
||||||
PAYMENT: "PAYMENT",
|
|
||||||
WAITING_WEBHOOKS: "WAITING_WEBHOOKS",
|
|
||||||
AWAITING_HUMAN: "AWAITING_HUMAN", // Esperando respuesta de un humano
|
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
|
// Intents válidos por estado
|
||||||
export const INTENTS_BY_STATE = Object.freeze({
|
export const INTENTS_BY_STATE = Object.freeze({
|
||||||
[ConversationState.IDLE]: [
|
[ConversationState.IDLE]: [
|
||||||
"greeting", "add_to_cart", "browse", "price_query", "recommend", "other"
|
"greeting", "add_to_cart", "browse", "price_query", "recommend", "other",
|
||||||
],
|
],
|
||||||
[ConversationState.CART]: [
|
[ConversationState.CART]: [
|
||||||
"add_to_cart", "remove_from_cart", "browse", "price_query",
|
"add_to_cart", "remove_from_cart", "browse", "price_query",
|
||||||
"recommend", "view_cart", "confirm_order", "other"
|
"recommend", "view_cart", "confirm_order", "other",
|
||||||
],
|
],
|
||||||
[ConversationState.SHIPPING]: [
|
[ConversationState.SHIPPING]: [
|
||||||
"provide_address", "select_shipping", "add_to_cart", "view_cart", "other"
|
"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"
|
|
||||||
],
|
],
|
||||||
[ConversationState.AWAITING_HUMAN]: [
|
[ConversationState.AWAITING_HUMAN]: [
|
||||||
"other" // En este estado, el bot no procesa - espera respuesta humana
|
"other",
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -44,21 +37,18 @@ export const INTENTS_BY_STATE = Object.freeze({
|
|||||||
*/
|
*/
|
||||||
export function shouldReturnToCart(state, nlu, text = "") {
|
export function shouldReturnToCart(state, nlu, text = "") {
|
||||||
if (state === ConversationState.CART || state === ConversationState.IDLE) {
|
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
|
// En SHIPPING, números solos son selecciones de opción, no productos
|
||||||
const isCheckoutState = state === ConversationState.SHIPPING || state === ConversationState.PAYMENT;
|
const isCheckoutState = state === ConversationState.SHIPPING;
|
||||||
const isJustNumber = /^\s*\d+([.,]\d+)?\s*$/.test(text || "");
|
const isJustNumber = /^\s*\d+([.,]\d+)?\s*$/.test(text || "");
|
||||||
if (isCheckoutState && isJustNumber) {
|
if (isCheckoutState && isJustNumber) {
|
||||||
return false; // No redirigir, es una selección de opción
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const intent = nlu?.intent;
|
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)) {
|
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;
|
String(nlu.entities.product_query).trim().length > 2;
|
||||||
const hasRealItems = Array.isArray(nlu?.entities?.items) &&
|
const hasRealItems = Array.isArray(nlu?.entities?.items) &&
|
||||||
@@ -66,11 +56,9 @@ export function shouldReturnToCart(state, nlu, text = "") {
|
|||||||
if (hasRealProduct || hasRealItems) {
|
if (hasRealProduct || hasRealItems) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Si no hay producto real, no redirigir (probablemente es una selección numérica mal interpretada)
|
|
||||||
return false;
|
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 (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;
|
if (Array.isArray(nlu?.entities?.items) && nlu.entities.items.some(i => i?.product_query?.trim().length > 2)) return true;
|
||||||
|
|
||||||
@@ -100,43 +88,27 @@ export function hasShippingInfo(order) {
|
|||||||
return false;
|
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.
|
* Deriva el siguiente estado basado en el contexto y signals.
|
||||||
*
|
*
|
||||||
* signals: {
|
* signals: {
|
||||||
* confirm_order: boolean, // Usuario quiere cerrar pedido
|
* confirm_order: boolean, // Usuario quiere cerrar pedido
|
||||||
* shipping_selected: boolean, // Usuario seleccionó delivery/pickup
|
* shipping_completed: boolean, // Shipping info quedó completa (gatilla create_order + IDLE)
|
||||||
* payment_selected: boolean, // Usuario seleccionó método de pago
|
|
||||||
* return_to_cart: boolean, // Forzar volver a CART
|
* return_to_cart: boolean, // Forzar volver a CART
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
export function deriveNextState(prevState, order = {}, signals = {}) {
|
export function deriveNextState(prevState, order = {}, signals = {}) {
|
||||||
// Regla 0: Si se fuerza volver a CART
|
|
||||||
if (signals.return_to_cart) {
|
if (signals.return_to_cart) {
|
||||||
return ConversationState.CART;
|
return ConversationState.CART;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regla 1: Si está pagado, completado (volver a IDLE para nueva conversación)
|
// Si la orden ya fue creada en Woo, volvemos a IDLE para nueva conversación.
|
||||||
if (isPaid(order)) {
|
if (order?.woo_order_id) {
|
||||||
return ConversationState.IDLE;
|
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
|
// Desde IDLE
|
||||||
if (prevState === ConversationState.IDLE) {
|
if (prevState === ConversationState.IDLE) {
|
||||||
// Si hay cart o pending items, ir a CART
|
|
||||||
if (hasCartItems(order) || hasPendingItems(order)) {
|
if (hasCartItems(order) || hasPendingItems(order)) {
|
||||||
return ConversationState.CART;
|
return ConversationState.CART;
|
||||||
}
|
}
|
||||||
@@ -145,11 +117,9 @@ export function deriveNextState(prevState, order = {}, signals = {}) {
|
|||||||
|
|
||||||
// Desde CART
|
// Desde CART
|
||||||
if (prevState === ConversationState.CART) {
|
if (prevState === ConversationState.CART) {
|
||||||
// Si hay pending items sin resolver, quedarse en CART
|
|
||||||
if (hasPendingItems(order)) {
|
if (hasPendingItems(order)) {
|
||||||
return ConversationState.CART;
|
return ConversationState.CART;
|
||||||
}
|
}
|
||||||
// Si usuario confirma orden y hay items en cart, ir a SHIPPING
|
|
||||||
if (signals.confirm_order && hasCartItems(order)) {
|
if (signals.confirm_order && hasCartItems(order)) {
|
||||||
return ConversationState.SHIPPING;
|
return ConversationState.SHIPPING;
|
||||||
}
|
}
|
||||||
@@ -158,69 +128,38 @@ export function deriveNextState(prevState, order = {}, signals = {}) {
|
|||||||
|
|
||||||
// Desde SHIPPING
|
// Desde SHIPPING
|
||||||
if (prevState === ConversationState.SHIPPING) {
|
if (prevState === ConversationState.SHIPPING) {
|
||||||
// Si ya tiene shipping info completa, ir a PAYMENT
|
// Una vez completado el shipping, la orden se crea y vuelve a IDLE.
|
||||||
if (hasShippingInfo(order)) {
|
if (signals.shipping_completed || hasShippingInfo(order)) {
|
||||||
return ConversationState.PAYMENT;
|
return ConversationState.IDLE;
|
||||||
}
|
}
|
||||||
return ConversationState.SHIPPING;
|
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;
|
return prevState || ConversationState.IDLE;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transiciones permitidas (para validación)
|
|
||||||
const ALLOWED = Object.freeze({
|
const ALLOWED = Object.freeze({
|
||||||
[ConversationState.IDLE]: [
|
[ConversationState.IDLE]: [
|
||||||
ConversationState.IDLE,
|
ConversationState.IDLE,
|
||||||
ConversationState.CART,
|
ConversationState.CART,
|
||||||
ConversationState.AWAITING_HUMAN, // Puede ir a esperar humano
|
ConversationState.AWAITING_HUMAN,
|
||||||
],
|
],
|
||||||
[ConversationState.CART]: [
|
[ConversationState.CART]: [
|
||||||
ConversationState.CART,
|
ConversationState.CART,
|
||||||
ConversationState.SHIPPING,
|
ConversationState.SHIPPING,
|
||||||
ConversationState.IDLE, // Si vacía el carrito
|
ConversationState.IDLE,
|
||||||
ConversationState.AWAITING_HUMAN, // Producto no encontrado
|
ConversationState.AWAITING_HUMAN,
|
||||||
],
|
],
|
||||||
[ConversationState.SHIPPING]: [
|
[ConversationState.SHIPPING]: [
|
||||||
ConversationState.SHIPPING,
|
ConversationState.SHIPPING,
|
||||||
ConversationState.PAYMENT,
|
ConversationState.IDLE,
|
||||||
ConversationState.CART, // Volver a agregar productos
|
ConversationState.CART,
|
||||||
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.AWAITING_HUMAN,
|
ConversationState.AWAITING_HUMAN,
|
||||||
],
|
],
|
||||||
[ConversationState.AWAITING_HUMAN]: [
|
[ConversationState.AWAITING_HUMAN]: [
|
||||||
ConversationState.AWAITING_HUMAN, // Sigue esperando
|
ConversationState.AWAITING_HUMAN,
|
||||||
ConversationState.CART, // Humano respondió, volver a procesar
|
ConversationState.CART,
|
||||||
ConversationState.IDLE, // Humano canceló
|
ConversationState.IDLE,
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -237,7 +176,5 @@ export function safeNextState(prevState, order, signals) {
|
|||||||
const desired = deriveNextState(prevState, order, signals);
|
const desired = deriveNextState(prevState, order, signals);
|
||||||
const v = validateTransition(prevState, desired);
|
const v = validateTransition(prevState, desired);
|
||||||
if (v.ok) return { next_state: desired, validation: v };
|
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 } };
|
return { next_state: ConversationState.CART, validation: { ...v, forced_to_cart: true } };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { describe, it, expect } from 'vitest';
|
||||||
import {
|
import {
|
||||||
@@ -11,555 +11,210 @@ import {
|
|||||||
hasPendingItems,
|
hasPendingItems,
|
||||||
hasReadyPendingItems,
|
hasReadyPendingItems,
|
||||||
hasShippingInfo,
|
hasShippingInfo,
|
||||||
hasPaymentInfo,
|
|
||||||
isPaid,
|
|
||||||
deriveNextState,
|
deriveNextState,
|
||||||
validateTransition,
|
validateTransition,
|
||||||
safeNextState,
|
safeNextState,
|
||||||
} from './fsm.js';
|
} from './fsm.js';
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
// Constants
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('ConversationState', () => {
|
describe('ConversationState', () => {
|
||||||
it('tiene todos los estados definidos', () => {
|
it('tiene los estados del flujo (sin payment/waiting)', () => {
|
||||||
expect(ConversationState.IDLE).toBe('IDLE');
|
expect(ConversationState.IDLE).toBe('IDLE');
|
||||||
expect(ConversationState.CART).toBe('CART');
|
expect(ConversationState.CART).toBe('CART');
|
||||||
expect(ConversationState.SHIPPING).toBe('SHIPPING');
|
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.AWAITING_HUMAN).toBe('AWAITING_HUMAN');
|
||||||
|
expect(ConversationState.PAYMENT).toBeUndefined();
|
||||||
|
expect(ConversationState.WAITING_WEBHOOKS).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ALL_STATES contiene todos', () => {
|
it('ALL_STATES contiene 4 estados', () => {
|
||||||
expect(ALL_STATES).toContain('IDLE');
|
expect(ALL_STATES).toEqual(expect.arrayContaining(['IDLE', 'CART', 'SHIPPING', 'AWAITING_HUMAN']));
|
||||||
expect(ALL_STATES).toContain('CART');
|
expect(ALL_STATES).toHaveLength(4);
|
||||||
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('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.IDLE]).toContain('greeting');
|
||||||
expect(INTENTS_BY_STATE[ConversationState.CART]).toContain('add_to_cart');
|
expect(INTENTS_BY_STATE[ConversationState.CART]).toContain('add_to_cart');
|
||||||
expect(INTENTS_BY_STATE[ConversationState.SHIPPING]).toContain('provide_address');
|
expect(INTENTS_BY_STATE[ConversationState.SHIPPING]).toContain('provide_address');
|
||||||
expect(INTENTS_BY_STATE[ConversationState.PAYMENT]).toContain('select_payment');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
// hasCartItems
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('hasCartItems', () => {
|
describe('hasCartItems', () => {
|
||||||
it('retorna true si cart tiene items', () => {
|
it('retorna true si cart tiene items', () => {
|
||||||
const order = { cart: [{ woo_id: 1, qty: 1 }] };
|
expect(hasCartItems({ cart: [{ woo_id: 1, qty: 1 }] })).toBe(true);
|
||||||
expect(hasCartItems(order)).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('retorna false si cart está vacío', () => {
|
it('retorna false si cart está vacío', () => {
|
||||||
const order = { cart: [] };
|
expect(hasCartItems({ cart: [] })).toBe(false);
|
||||||
expect(hasCartItems(order)).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('retorna false si cart es undefined', () => {
|
it('retorna false si cart es undefined', () => {
|
||||||
const order = {};
|
expect(hasCartItems({})).toBe(false);
|
||||||
expect(hasCartItems(order)).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
it('retorna false si order es null/undefined', () => {
|
||||||
it('retorna false si order es null', () => {
|
|
||||||
expect(hasCartItems(null)).toBe(false);
|
expect(hasCartItems(null)).toBe(false);
|
||||||
});
|
|
||||||
|
|
||||||
it('retorna false si order es undefined', () => {
|
|
||||||
expect(hasCartItems(undefined)).toBe(false);
|
expect(hasCartItems(undefined)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
// hasPendingItems
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('hasPendingItems', () => {
|
describe('hasPendingItems', () => {
|
||||||
it('retorna true si hay NEEDS_TYPE', () => {
|
it('retorna true si hay NEEDS_TYPE', () => {
|
||||||
const order = { pending: [{ status: 'NEEDS_TYPE' }] };
|
expect(hasPendingItems({ pending: [{ status: 'NEEDS_TYPE' }] })).toBe(true);
|
||||||
expect(hasPendingItems(order)).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('retorna true si hay NEEDS_QUANTITY', () => {
|
it('retorna true si hay NEEDS_QUANTITY', () => {
|
||||||
const order = { pending: [{ status: 'NEEDS_QUANTITY' }] };
|
expect(hasPendingItems({ pending: [{ status: 'NEEDS_QUANTITY' }] })).toBe(true);
|
||||||
expect(hasPendingItems(order)).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('retorna false si solo hay READY', () => {
|
it('retorna false si solo hay READY', () => {
|
||||||
const order = { pending: [{ status: 'READY' }] };
|
expect(hasPendingItems({ pending: [{ status: 'READY' }] })).toBe(false);
|
||||||
expect(hasPendingItems(order)).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', () => {
|
it('detecta entre múltiples items', () => {
|
||||||
const order = {
|
expect(hasPendingItems({ pending: [{ status: 'READY' }, { status: 'NEEDS_TYPE' }] })).toBe(true);
|
||||||
pending: [
|
|
||||||
{ status: 'READY' },
|
|
||||||
{ status: 'NEEDS_TYPE' },
|
|
||||||
]
|
|
||||||
};
|
|
||||||
expect(hasPendingItems(order)).toBe(true);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
// hasReadyPendingItems
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('hasReadyPendingItems', () => {
|
describe('hasReadyPendingItems', () => {
|
||||||
it('retorna true si hay READY', () => {
|
it('retorna true si hay READY', () => {
|
||||||
const order = { pending: [{ status: 'READY' }] };
|
expect(hasReadyPendingItems({ pending: [{ status: 'READY' }] })).toBe(true);
|
||||||
expect(hasReadyPendingItems(order)).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('retorna false si no hay READY', () => {
|
it('retorna false si no hay READY', () => {
|
||||||
const order = { pending: [{ status: 'NEEDS_TYPE' }] };
|
expect(hasReadyPendingItems({ pending: [{ status: 'NEEDS_TYPE' }] })).toBe(false);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
// hasShippingInfo
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('hasShippingInfo', () => {
|
describe('hasShippingInfo', () => {
|
||||||
it('retorna true para pickup (no necesita dirección)', () => {
|
it('retorna true para pickup (no necesita dirección)', () => {
|
||||||
const order = { is_delivery: false };
|
expect(hasShippingInfo({ is_delivery: false })).toBe(true);
|
||||||
expect(hasShippingInfo(order)).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('retorna true para delivery con dirección', () => {
|
it('retorna true para delivery con dirección', () => {
|
||||||
const order = { is_delivery: true, shipping_address: 'Calle Falsa 123' };
|
expect(hasShippingInfo({ is_delivery: true, shipping_address: 'Calle Falsa 123' })).toBe(true);
|
||||||
expect(hasShippingInfo(order)).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('retorna false para delivery sin dirección', () => {
|
it('retorna false para delivery sin dirección', () => {
|
||||||
const order = { is_delivery: true, shipping_address: null };
|
expect(hasShippingInfo({ is_delivery: true, shipping_address: null })).toBe(false);
|
||||||
expect(hasShippingInfo(order)).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('retorna false si is_delivery es null', () => {
|
it('retorna false si is_delivery es null', () => {
|
||||||
const order = { is_delivery: null };
|
expect(hasShippingInfo({ is_delivery: null })).toBe(false);
|
||||||
expect(hasShippingInfo(order)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('retorna false para order vacío', () => {
|
|
||||||
expect(hasShippingInfo({})).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('shouldReturnToCart', () => {
|
||||||
describe('no redirige si ya está en CART o IDLE', () => {
|
it('no redirige si ya está en CART o IDLE', () => {
|
||||||
it('retorna false en CART', () => {
|
const nlu = { intent: 'add_to_cart', entities: { product_query: 'provoleta' } };
|
||||||
const nlu = { intent: 'add_to_cart', entities: { product_query: 'provoleta' } };
|
expect(shouldReturnToCart(ConversationState.CART, nlu)).toBe(false);
|
||||||
expect(shouldReturnToCart(ConversationState.CART, nlu)).toBe(false);
|
expect(shouldReturnToCart(ConversationState.IDLE, 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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('redirige desde otros estados', () => {
|
it('redirige add_to_cart desde SHIPPING con producto real', () => {
|
||||||
it('redirige add_to_cart desde SHIPPING', () => {
|
const nlu = { intent: 'add_to_cart', entities: { product_query: 'provoleta' } };
|
||||||
const nlu = { intent: 'add_to_cart', entities: { product_query: 'provoleta' } };
|
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(true);
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('no redirige números solos en checkout', () => {
|
it('redirige browse desde SHIPPING', () => {
|
||||||
it('no redirige "1" en SHIPPING', () => {
|
const nlu = { intent: 'browse', entities: { product_query: 'carnes' } };
|
||||||
const nlu = { intent: 'add_to_cart', entities: {} };
|
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(true);
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('requiere producto real', () => {
|
it('no redirige números solos en SHIPPING (selección de opción)', () => {
|
||||||
it('no redirige sin product_query', () => {
|
const nlu = { intent: 'add_to_cart', entities: {} };
|
||||||
const nlu = { intent: 'add_to_cart', entities: {} };
|
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu, '1')).toBe(false);
|
||||||
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu, 'algo')).toBe(false);
|
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu, '1.5')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('no redirige con product_query muy corto', () => {
|
it('no redirige sin producto real', () => {
|
||||||
const nlu = { intent: 'add_to_cart', entities: { product_query: 'ab' } };
|
const nlu = { intent: 'add_to_cart', entities: {} };
|
||||||
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(false);
|
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', () => {
|
it('redirige con items array que tenga producto real', () => {
|
||||||
const nlu = {
|
const nlu = { intent: 'add_to_cart', entities: { items: [{ product_query: 'provoleta' }] } };
|
||||||
intent: 'add_to_cart',
|
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(true);
|
||||||
entities: { items: [{ product_query: 'provoleta' }] }
|
|
||||||
};
|
|
||||||
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
// deriveNextState
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('deriveNextState', () => {
|
describe('deriveNextState', () => {
|
||||||
describe('return_to_cart signal', () => {
|
it('return_to_cart fuerza CART', () => {
|
||||||
it('fuerza CART si return_to_cart', () => {
|
expect(deriveNextState(ConversationState.SHIPPING, {}, { return_to_cart: true })).toBe(ConversationState.CART);
|
||||||
const result = deriveNextState(
|
|
||||||
ConversationState.PAYMENT,
|
|
||||||
{},
|
|
||||||
{ return_to_cart: true }
|
|
||||||
);
|
|
||||||
expect(result).toBe(ConversationState.CART);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('pagado', () => {
|
it('IDLE va a CART si hay cart o pending', () => {
|
||||||
it('va a IDLE si está pagado', () => {
|
expect(deriveNextState(ConversationState.IDLE, { cart: [{ woo_id: 1 }], pending: [] }, {})).toBe(ConversationState.CART);
|
||||||
const order = { is_paid: true };
|
expect(deriveNextState(ConversationState.IDLE, { cart: [], pending: [{ status: 'NEEDS_TYPE' }] }, {})).toBe(ConversationState.CART);
|
||||||
const result = deriveNextState(ConversationState.WAITING_WEBHOOKS, order, {});
|
|
||||||
expect(result).toBe(ConversationState.IDLE);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('esperando pago', () => {
|
it('IDLE queda en IDLE si vacío', () => {
|
||||||
it('va a WAITING_WEBHOOKS si tiene woo_order_id', () => {
|
expect(deriveNextState(ConversationState.IDLE, { cart: [], pending: [] }, {})).toBe(ConversationState.IDLE);
|
||||||
const order = { woo_order_id: 123, is_paid: false };
|
|
||||||
const result = deriveNextState(ConversationState.PAYMENT, order, {});
|
|
||||||
expect(result).toBe(ConversationState.WAITING_WEBHOOKS);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('IDLE -> CART', () => {
|
it('CART queda en CART con pending', () => {
|
||||||
it('va a CART si hay cart items', () => {
|
const order = { cart: [{ woo_id: 1 }], pending: [{ status: 'NEEDS_TYPE' }] };
|
||||||
const order = { cart: [{ woo_id: 1 }], pending: [] };
|
expect(deriveNextState(ConversationState.CART, order, { confirm_order: true })).toBe(ConversationState.CART);
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('CART -> SHIPPING', () => {
|
it('CART → SHIPPING con confirm + items', () => {
|
||||||
it('queda en CART si hay pending items', () => {
|
const order = { cart: [{ woo_id: 1 }], pending: [] };
|
||||||
const order = { cart: [{ woo_id: 1 }], pending: [{ status: 'NEEDS_TYPE' }] };
|
expect(deriveNextState(ConversationState.CART, order, { confirm_order: true })).toBe(ConversationState.SHIPPING);
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('SHIPPING -> PAYMENT', () => {
|
it('SHIPPING → IDLE cuando shipping queda completo (orden creada offline)', () => {
|
||||||
it('va a PAYMENT con shipping info (pickup)', () => {
|
expect(deriveNextState(ConversationState.SHIPPING, { is_delivery: false }, {})).toBe(ConversationState.IDLE);
|
||||||
const order = { is_delivery: false };
|
expect(deriveNextState(ConversationState.SHIPPING, { is_delivery: true, shipping_address: 'Calle 1' }, {})).toBe(ConversationState.IDLE);
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('PAYMENT -> WAITING_WEBHOOKS', () => {
|
it('SHIPPING queda en SHIPPING sin info completa', () => {
|
||||||
it('va a WAITING con payment_selected', () => {
|
expect(deriveNextState(ConversationState.SHIPPING, { is_delivery: true }, {})).toBe(ConversationState.SHIPPING);
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('WAITING_WEBHOOKS', () => {
|
it('woo_order_id existente vuelve a IDLE (orden ya creada)', () => {
|
||||||
it('va a IDLE si está pagado', () => {
|
expect(deriveNextState(ConversationState.SHIPPING, { woo_order_id: 999 }, {})).toBe(ConversationState.IDLE);
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('default', () => {
|
it('default sin estado previo retorna IDLE', () => {
|
||||||
it('retorna IDLE si no hay estado previo', () => {
|
expect(deriveNextState(null, {}, {})).toBe(ConversationState.IDLE);
|
||||||
const result = deriveNextState(null, {}, {});
|
|
||||||
expect(result).toBe(ConversationState.IDLE);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
// validateTransition
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('validateTransition', () => {
|
describe('validateTransition', () => {
|
||||||
describe('transiciones válidas', () => {
|
it('IDLE → CART es válida', () => {
|
||||||
it('IDLE -> IDLE es válido', () => {
|
expect(validateTransition(ConversationState.IDLE, ConversationState.CART).ok).toBe(true);
|
||||||
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('CART → SHIPPING es válida', () => {
|
||||||
describe('transiciones inválidas', () => {
|
expect(validateTransition(ConversationState.CART, ConversationState.SHIPPING).ok).toBe(true);
|
||||||
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('SHIPPING → IDLE es válida (cierre de orden)', () => {
|
||||||
describe('estados desconocidos', () => {
|
expect(validateTransition(ConversationState.SHIPPING, ConversationState.IDLE).ok).toBe(true);
|
||||||
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 → CART (volver a agregar) es válida', () => {
|
||||||
describe('maneja null/undefined', () => {
|
expect(validateTransition(ConversationState.SHIPPING, ConversationState.CART).ok).toBe(true);
|
||||||
it('prevState null se trata como IDLE', () => {
|
});
|
||||||
const result = validateTransition(null, ConversationState.CART);
|
it('IDLE → SHIPPING es inválida (debe pasar por CART)', () => {
|
||||||
expect(result.ok).toBe(true);
|
const r = validateTransition(ConversationState.IDLE, ConversationState.SHIPPING);
|
||||||
});
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.reason).toBe('invalid_transition');
|
||||||
it('nextState null se trata como IDLE', () => {
|
});
|
||||||
const result = validateTransition(ConversationState.IDLE, null);
|
it('estado previo desconocido', () => {
|
||||||
expect(result.ok).toBe(true);
|
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', () => {
|
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 order = { cart: [{ woo_id: 1 }], pending: [] };
|
||||||
const result = safeNextState(ConversationState.CART, order, { confirm_order: true });
|
const r = safeNextState(ConversationState.CART, order, { confirm_order: true });
|
||||||
|
expect(r.next_state).toBe(ConversationState.SHIPPING);
|
||||||
expect(result.next_state).toBe(ConversationState.SHIPPING);
|
expect(r.validation.ok).toBe(true);
|
||||||
expect(result.validation.ok).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fuerza CART si transición inválida', () => {
|
it('flow IDLE → CART → SHIPPING → IDLE', () => {
|
||||||
// Forzar una situación donde deriveNextState retornaría un estado inválido
|
let r = safeNextState(ConversationState.IDLE, { cart: [{ woo_id: 1 }], pending: [] }, {});
|
||||||
// Esto es difícil de provocar porque deriveNextState ya es bastante seguro
|
expect(r.next_state).toBe(ConversationState.CART);
|
||||||
// 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)
|
r = safeNextState(ConversationState.CART, { cart: [{ woo_id: 1 }], pending: [] }, { confirm_order: true });
|
||||||
expect(result.next_state).toBe(ConversationState.IDLE);
|
expect(r.next_state).toBe(ConversationState.SHIPPING);
|
||||||
expect(result.validation.ok).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('incluye validation en resultado', () => {
|
r = safeNextState(ConversationState.SHIPPING, { is_delivery: false }, { shipping_completed: true });
|
||||||
const order = { is_delivery: false };
|
expect(r.next_state).toBe(ConversationState.IDLE);
|
||||||
const result = safeNextState(ConversationState.SHIPPING, order, {});
|
|
||||||
|
|
||||||
expect(result).toHaveProperty('next_state');
|
|
||||||
expect(result).toHaveProperty('validation');
|
|
||||||
expect(result.validation).toHaveProperty('ok');
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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({
|
export const enqueueWooCreateOrder = assign({
|
||||||
pending_actions: ({ context }) => [
|
pending_actions: ({ context }) => [
|
||||||
...(context.pending_actions || []),
|
...(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 }) => ({
|
export const replyShippingAddressRecorded = makeReplyAction("shipping.address_recorded", ({ context }) => ({
|
||||||
address: context.order?.shipping_address || "",
|
address: context.order?.shipping_address || "",
|
||||||
}));
|
}));
|
||||||
export const replyShippingPickupToPayment = makeReplyAction("shipping.pickup_to_payment");
|
export const replyOrderConfirmed = makeReplyAction("order.confirmed");
|
||||||
export const replyPaymentAskMethod = makeReplyAction("payment.ask_method");
|
|
||||||
export const replyPaymentConfirmed = makeReplyAction("payment.confirmed");
|
|
||||||
export const replyWaitingInProgress = makeReplyAction("waiting.in_progress");
|
|
||||||
|
|
||||||
// View cart: necesita armar reply con cartDisplay + ask_more
|
// View cart: necesita armar reply con cartDisplay + ask_more
|
||||||
export const replyViewCart = assign({
|
export const replyViewCart = assign({
|
||||||
@@ -372,7 +361,6 @@ export const actions = {
|
|||||||
capturePeopleCount,
|
capturePeopleCount,
|
||||||
setShipping,
|
setShipping,
|
||||||
setAddress,
|
setAddress,
|
||||||
setPayment,
|
|
||||||
enqueueWooCreateOrder,
|
enqueueWooCreateOrder,
|
||||||
enqueueAddToCart,
|
enqueueAddToCart,
|
||||||
enqueueRemoveFromCart,
|
enqueueRemoveFromCart,
|
||||||
@@ -390,10 +378,7 @@ export const actions = {
|
|||||||
replyShippingAskMethod,
|
replyShippingAskMethod,
|
||||||
replyShippingAskAddress,
|
replyShippingAskAddress,
|
||||||
replyShippingAddressRecorded,
|
replyShippingAddressRecorded,
|
||||||
replyShippingPickupToPayment,
|
replyOrderConfirmed,
|
||||||
replyPaymentAskMethod,
|
|
||||||
replyPaymentConfirmed,
|
|
||||||
replyWaitingInProgress,
|
|
||||||
replyViewCart,
|
replyViewCart,
|
||||||
replyOptions,
|
replyOptions,
|
||||||
replyAskQuantity,
|
replyAskQuantity,
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import {
|
|||||||
hasPendingItems as hasPending,
|
hasPendingItems as hasPending,
|
||||||
hasReadyPendingItems as hasReadyPending,
|
hasReadyPendingItems as hasReadyPending,
|
||||||
hasShippingInfo as hasShipping,
|
hasShippingInfo as hasShipping,
|
||||||
hasPaymentInfo as hasPayment,
|
|
||||||
isPaid,
|
|
||||||
} from "../fsm.js";
|
} from "../fsm.js";
|
||||||
import {
|
import {
|
||||||
parseIndexSelection,
|
parseIndexSelection,
|
||||||
@@ -24,8 +22,6 @@ export const guards = {
|
|||||||
hasPending: ({ context }) => hasPending(context.order),
|
hasPending: ({ context }) => hasPending(context.order),
|
||||||
hasReadyPending: ({ context }) => hasReadyPending(context.order),
|
hasReadyPending: ({ context }) => hasReadyPending(context.order),
|
||||||
hasShipping: ({ context }) => hasShipping(context.order),
|
hasShipping: ({ context }) => hasShipping(context.order),
|
||||||
hasPayment: ({ context }) => hasPayment(context.order),
|
|
||||||
isPaid: ({ context }) => isPaid(context.order),
|
|
||||||
|
|
||||||
noCart: ({ context }) => !hasCart(context.order),
|
noCart: ({ context }) => !hasCart(context.order),
|
||||||
noShipping: ({ context }) => !hasShipping(context.order),
|
noShipping: ({ context }) => !hasShipping(context.order),
|
||||||
|
|||||||
@@ -24,8 +24,6 @@ export const ConversationStates = Object.freeze({
|
|||||||
IDLE: "idle",
|
IDLE: "idle",
|
||||||
CART: "cart",
|
CART: "cart",
|
||||||
SHIPPING: "shipping",
|
SHIPPING: "shipping",
|
||||||
PAYMENT: "payment",
|
|
||||||
WAITING: "waiting",
|
|
||||||
AWAITING_HUMAN: "awaiting_human",
|
AWAITING_HUMAN: "awaiting_human",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -289,8 +287,8 @@ export const machine = setup({
|
|||||||
SELECT_SHIPPING: [
|
SELECT_SHIPPING: [
|
||||||
{
|
{
|
||||||
guard: ({ event }) => event.method === "pickup",
|
guard: ({ event }) => event.method === "pickup",
|
||||||
actions: ["setShipping", "replyShippingPickupToPayment"],
|
actions: ["setShipping", "enqueueWooCreateOrder", "replyOrderConfirmed", "resetFailedSearch"],
|
||||||
target: ConversationStates.PAYMENT,
|
target: ConversationStates.IDLE,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
guard: ({ event }) => event.method === "delivery",
|
guard: ({ event }) => event.method === "delivery",
|
||||||
@@ -303,8 +301,8 @@ export const machine = setup({
|
|||||||
PROVIDE_ADDRESS: [
|
PROVIDE_ADDRESS: [
|
||||||
{
|
{
|
||||||
guard: ({ context }) => context.order?.is_delivery === true,
|
guard: ({ context }) => context.order?.is_delivery === true,
|
||||||
actions: ["setAddress", "replyShippingAddressRecorded"],
|
actions: ["setAddress", "enqueueWooCreateOrder", "replyOrderConfirmed", "resetFailedSearch"],
|
||||||
target: ConversationStates.PAYMENT,
|
target: ConversationStates.IDLE,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
actions: ["replyShippingAskMethod"],
|
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]: {
|
[ConversationStates.AWAITING_HUMAN]: {
|
||||||
// Estado terminal hasta que un humano resuelva. No emite reply propio.
|
// Estado terminal hasta que un humano resuelva. No emite reply propio.
|
||||||
@@ -353,15 +327,11 @@ export function xstateToLegacyState(value) {
|
|||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
if (value === "idle") return "IDLE";
|
if (value === "idle") return "IDLE";
|
||||||
if (value === "shipping") return "SHIPPING";
|
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 === "awaiting_human") return "AWAITING_HUMAN";
|
||||||
}
|
}
|
||||||
if (value && typeof value === "object") {
|
if (value && typeof value === "object") {
|
||||||
if (value.cart) return "CART";
|
if (value.cart) return "CART";
|
||||||
if (value.shipping) return "SHIPPING";
|
if (value.shipping) return "SHIPPING";
|
||||||
if (value.payment) return "PAYMENT";
|
|
||||||
if (value.waiting) return "WAITING_WEBHOOKS";
|
|
||||||
}
|
}
|
||||||
return "IDLE";
|
return "IDLE";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,18 +168,20 @@ describe("machine — checkout flow", () => {
|
|||||||
a.stop();
|
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();
|
const a = makeActor();
|
||||||
a.start();
|
a.start();
|
||||||
await buildCartWithItem(a);
|
await buildCartWithItem(a);
|
||||||
a.send({ type: "CONFIRM_ORDER" });
|
a.send({ type: "CONFIRM_ORDER" });
|
||||||
a.send({ type: "SELECT_SHIPPING", method: "pickup" });
|
a.send({ type: "SELECT_SHIPPING", method: "pickup" });
|
||||||
expect(a.getSnapshot().value).toBe("payment");
|
const snap = a.getSnapshot();
|
||||||
expect(a.getSnapshot().context.order.is_delivery).toBe(false);
|
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();
|
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();
|
const a = makeActor();
|
||||||
a.start();
|
a.start();
|
||||||
await buildCartWithItem(a);
|
await buildCartWithItem(a);
|
||||||
@@ -187,21 +189,10 @@ describe("machine — checkout flow", () => {
|
|||||||
a.send({ type: "SELECT_SHIPPING", method: "delivery" });
|
a.send({ type: "SELECT_SHIPPING", method: "delivery" });
|
||||||
expect(a.getSnapshot().value).toBe("shipping");
|
expect(a.getSnapshot().value).toBe("shipping");
|
||||||
a.send({ type: "PROVIDE_ADDRESS", address: "Corrientes 1234" });
|
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();
|
const snap = a.getSnapshot();
|
||||||
expect(snap.value).toBe("waiting");
|
expect(snap.value).toBe("idle");
|
||||||
expect(snap.context.pending_actions.some((a) => a.type === "create_order")).toBe(true);
|
expect(snap.context.order.shipping_address).toBe("Corrientes 1234");
|
||||||
|
expect(snap.context.pending_actions.some((x) => x.type === "create_order")).toBe(true);
|
||||||
a.stop();
|
a.stop();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -229,11 +220,9 @@ describe("machine — snapshot persistence", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("xstateToLegacyState", () => {
|
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("idle")).toBe("IDLE");
|
||||||
expect(xstateToLegacyState("shipping")).toBe("SHIPPING");
|
expect(xstateToLegacyState("shipping")).toBe("SHIPPING");
|
||||||
expect(xstateToLegacyState("payment")).toBe("PAYMENT");
|
|
||||||
expect(xstateToLegacyState("waiting")).toBe("WAITING_WEBHOOKS");
|
|
||||||
});
|
});
|
||||||
it("maps cart sub-states to CART", () => {
|
it("maps cart sub-states to CART", () => {
|
||||||
expect(xstateToLegacyState({ cart: "idle" })).toBe("CART");
|
expect(xstateToLegacyState({ cart: "idle" })).toBe("CART");
|
||||||
|
|||||||
@@ -40,9 +40,6 @@ export function nluToEvent(nlu, text) {
|
|||||||
case "provide_address":
|
case "provide_address":
|
||||||
return { type: "PROVIDE_ADDRESS", address: entities.address || text };
|
return { type: "PROVIDE_ADDRESS", address: entities.address || text };
|
||||||
|
|
||||||
case "select_payment":
|
|
||||||
return { type: "SELECT_PAYMENT", method: entities.payment_method || null };
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return { type: "OTHER", text };
|
return { type: "OTHER", text };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,7 +132,6 @@ function buildContextPatch(snapshot, recentReplies, finalTemplateId, persistedSn
|
|||||||
unit: p.unit,
|
unit: p.unit,
|
||||||
status: p.status?.toLowerCase() || "needs_type",
|
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,
|
: order.is_delivery === false ? "pickup" : null,
|
||||||
delivery_address: order.shipping_address ? { text: order.shipping_address } : null,
|
delivery_address: order.shipping_address ? { text: order.shipping_address } : null,
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { routerClassify, quickDomainDetect } from "./router.js";
|
|||||||
import { greetingNlu } from "./specialists/greeting.js";
|
import { greetingNlu } from "./specialists/greeting.js";
|
||||||
import { ordersNlu } from "./specialists/orders.js";
|
import { ordersNlu } from "./specialists/orders.js";
|
||||||
import { shippingNlu } from "./specialists/shipping.js";
|
import { shippingNlu } from "./specialists/shipping.js";
|
||||||
import { paymentNlu } from "./specialists/payment.js";
|
|
||||||
import { browseNlu } from "./specialists/browse.js";
|
import { browseNlu } from "./specialists/browse.js";
|
||||||
import { createEmptyNlu } from "./schemas.js";
|
import { createEmptyNlu } from "./schemas.js";
|
||||||
|
|
||||||
@@ -50,7 +49,7 @@ export async function llmNluModular({ input, tenantId, storeConfig = {} } = {})
|
|||||||
|
|
||||||
// Casos donde podemos saltar el router:
|
// Casos donde podemos saltar el router:
|
||||||
// - Saludos simples
|
// - Saludos simples
|
||||||
// - Números solos (1, 2) en estados SHIPPING/PAYMENT
|
// - Números solos (1, 2) en estado SHIPPING
|
||||||
// - Patrones muy claros
|
// - Patrones muy claros
|
||||||
const skipRouter = shouldSkipRouter(text, state, quickDomain);
|
const skipRouter = shouldSkipRouter(text, state, quickDomain);
|
||||||
|
|
||||||
@@ -86,11 +85,6 @@ export async function llmNluModular({ input, tenantId, storeConfig = {} } = {})
|
|||||||
result = await shippingNlu({ tenantId, text, storeConfig });
|
result = await shippingNlu({ tenantId, text, storeConfig });
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "payment":
|
|
||||||
routing.specialist_used = "payment";
|
|
||||||
result = await paymentNlu({ tenantId, text, storeConfig });
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "browse":
|
case "browse":
|
||||||
routing.specialist_used = "browse";
|
routing.specialist_used = "browse";
|
||||||
result = await browseNlu({ tenantId, text, storeConfig });
|
result = await browseNlu({ tenantId, text, storeConfig });
|
||||||
@@ -145,15 +139,8 @@ function shouldSkipRouter(text, state, quickDomain) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Números solos en estados específicos
|
// Números solos en estado SHIPPING (selección 1/2)
|
||||||
if (/^[12]$/.test(t)) {
|
if (/^[12]$/.test(t) && state === "SHIPPING") {
|
||||||
if (state === "SHIPPING" || state === "PAYMENT") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// "efectivo" o "tarjeta" solos en estado PAYMENT
|
|
||||||
if (state === "PAYMENT" && /^(efectivo|tarjeta|link|transfer)$/i.test(t)) {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ export function getCacheStats() {
|
|||||||
* Pre-carga todos los prompts de un tenant (útil al inicio)
|
* Pre-carga todos los prompts de un tenant (útil al inicio)
|
||||||
*/
|
*/
|
||||||
export async function preloadPrompts({ tenantId, storeConfig = {} }) {
|
export async function preloadPrompts({ tenantId, storeConfig = {} }) {
|
||||||
const promptKeys = ["router", "greeting", "orders", "shipping", "payment", "browse"];
|
const promptKeys = ["router", "greeting", "orders", "shipping", "browse"];
|
||||||
const results = {};
|
const results = {};
|
||||||
|
|
||||||
for (const key of promptKeys) {
|
for (const key of promptKeys) {
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ function detectDomainByPatterns(text, state) {
|
|||||||
return "greeting";
|
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 (state === "SHIPPING") {
|
||||||
if (/delivery|env[ií]o|retiro|buscar|sucursal|direcci[oó]n/i.test(t)) {
|
if (/delivery|env[ií]o|retiro|buscar|sucursal|direcci[oó]n/i.test(t)) {
|
||||||
return "shipping";
|
return "shipping";
|
||||||
@@ -114,16 +114,6 @@ function detectDomainByPatterns(text, state) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
// Orders patterns
|
||||||
const orderPatterns = [
|
const orderPatterns = [
|
||||||
/\b(quiero|dame|anotame|poneme|agregame|necesito)\b/i,
|
/\b(quiero|dame|anotame|poneme|agregame|necesito)\b/i,
|
||||||
@@ -153,15 +143,9 @@ function detectDomainByPatterns(text, state) {
|
|||||||
return "shipping";
|
return "shipping";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Payment patterns
|
|
||||||
if (/\b(efectivo|tarjeta|link|transfer|mercadopago)\b/i.test(t)) {
|
|
||||||
return "payment";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default basado en estado
|
// Default basado en estado
|
||||||
if (state === "CART") return "orders";
|
if (state === "CART") return "orders";
|
||||||
if (state === "SHIPPING") return "shipping";
|
if (state === "SHIPPING") return "shipping";
|
||||||
if (state === "PAYMENT") return "payment";
|
|
||||||
|
|
||||||
return "other";
|
return "other";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const RouterSchema = {
|
|||||||
properties: {
|
properties: {
|
||||||
domain: {
|
domain: {
|
||||||
type: "string",
|
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);
|
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
|
// Schema: Browse
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
@@ -164,7 +142,7 @@ export const UnifiedNluSchema = {
|
|||||||
type: "string",
|
type: "string",
|
||||||
enum: [
|
enum: [
|
||||||
"price_query", "browse", "add_to_cart", "remove_from_cart",
|
"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"
|
"provide_address", "greeting", "recommend", "view_cart", "other"
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -194,7 +172,6 @@ export const UnifiedNluSchema = {
|
|||||||
},
|
},
|
||||||
attributes: { type: "array", items: { type: "string" } },
|
attributes: { type: "array", items: { type: "string" } },
|
||||||
preparation: { 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" }] },
|
shipping_method: { anyOf: [{ type: "string", enum: ["delivery", "pickup"] }, { type: "null" }] },
|
||||||
address: { anyOf: [{ type: "string" }, { type: "null" }] },
|
address: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||||
items: {
|
items: {
|
||||||
@@ -267,7 +244,6 @@ export function createEmptyNlu() {
|
|||||||
selection: null,
|
selection: null,
|
||||||
attributes: [],
|
attributes: [],
|
||||||
preparation: [],
|
preparation: [],
|
||||||
payment_method: null,
|
|
||||||
shipping_method: null,
|
shipping_method: null,
|
||||||
address: null,
|
address: null,
|
||||||
items: null,
|
items: 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 },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -76,7 +76,7 @@ const NluV3JsonSchema = {
|
|||||||
properties: {
|
properties: {
|
||||||
intent: {
|
intent: {
|
||||||
type: "string",
|
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 },
|
confidence: { type: "number", minimum: 0, maximum: 1 },
|
||||||
language: { type: "string" },
|
language: { type: "string" },
|
||||||
@@ -104,8 +104,7 @@ const NluV3JsonSchema = {
|
|||||||
},
|
},
|
||||||
attributes: { type: "array", items: { type: "string" } },
|
attributes: { type: "array", items: { type: "string" } },
|
||||||
preparation: { type: "array", items: { type: "string" } },
|
preparation: { type: "array", items: { type: "string" } },
|
||||||
// Checkout: método de pago, envío, dirección
|
// Checkout: envío y dirección. (El bot no maneja pagos.)
|
||||||
payment_method: { anyOf: [{ type: "string", enum: ["cash", "link"] }, { type: "null" }] },
|
|
||||||
shipping_method: { anyOf: [{ type: "string", enum: ["delivery", "pickup"] }, { type: "null" }] },
|
shipping_method: { anyOf: [{ type: "string", enum: ["delivery", "pickup"] }, { type: "null" }] },
|
||||||
address: { anyOf: [{ type: "string" }, { type: "null" }] },
|
address: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||||
// Soporte para múltiples productos en un mensaje
|
// Soporte para múltiples productos en un mensaje
|
||||||
@@ -236,8 +235,7 @@ function normalizeNluOutput(parsed, input) {
|
|||||||
selection: entities.selection ?? null,
|
selection: entities.selection ?? null,
|
||||||
attributes: Array.isArray(entities.attributes) ? entities.attributes : [],
|
attributes: Array.isArray(entities.attributes) ? entities.attributes : [],
|
||||||
preparation: Array.isArray(entities.preparation) ? entities.preparation : [],
|
preparation: Array.isArray(entities.preparation) ? entities.preparation : [],
|
||||||
// Checkout entities (opcionales)
|
// Checkout entities (opcionales). El bot NO maneja pagos.
|
||||||
payment_method: entities.payment_method ?? null,
|
|
||||||
shipping_method: entities.shipping_method ?? null,
|
shipping_method: entities.shipping_method ?? null,
|
||||||
address: entities.address ?? null,
|
address: entities.address ?? null,
|
||||||
items: normalizedItems,
|
items: normalizedItems,
|
||||||
@@ -282,7 +280,6 @@ function nluV3Fallback() {
|
|||||||
selection: null,
|
selection: null,
|
||||||
attributes: [],
|
attributes: [],
|
||||||
preparation: [],
|
preparation: [],
|
||||||
payment_method: null,
|
|
||||||
shipping_method: null,
|
shipping_method: null,
|
||||||
address: null,
|
address: null,
|
||||||
items: 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" +
|
"- 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" +
|
"- 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" +
|
"- 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" +
|
"- 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" +
|
"- 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" +
|
"- 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" +
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
* Modelo unificado de orden para el contexto de conversación.
|
* Modelo unificado de orden para el contexto de conversación.
|
||||||
*
|
*
|
||||||
* Reemplaza: order_basket, pending_items, pending_clarification, pending_item,
|
* 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
|
// Status de items pendientes
|
||||||
@@ -19,11 +22,9 @@ export function createEmptyOrder() {
|
|||||||
return {
|
return {
|
||||||
cart: [], // Items confirmados: [{ woo_id, qty, unit, name, price }]
|
cart: [], // Items confirmados: [{ woo_id, qty, unit, name, price }]
|
||||||
pending: [], // Items por clarificar
|
pending: [], // Items por clarificar
|
||||||
payment_type: null, // "link" | "cash" | null
|
|
||||||
is_delivery: null, // true | false | null
|
is_delivery: null, // true | false | null
|
||||||
shipping_address: null,
|
shipping_address: null,
|
||||||
woo_order_id: null,
|
woo_order_id: null,
|
||||||
is_paid: false,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,14 +295,10 @@ export function migrateOldContext(ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Migrar checkout info
|
// Migrar checkout info
|
||||||
order.payment_type = ctx.payment_method || null;
|
|
||||||
order.is_delivery = ctx.shipping_method === "delivery" ? true :
|
order.is_delivery = ctx.shipping_method === "delivery" ? true :
|
||||||
ctx.shipping_method === "pickup" ? false : null;
|
ctx.shipping_method === "pickup" ? false : null;
|
||||||
order.shipping_address = ctx.delivery_address?.text || ctx.address?.text || ctx.address_text || 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.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;
|
return order;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,15 +23,15 @@ import {
|
|||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('createEmptyOrder', () => {
|
describe('createEmptyOrder', () => {
|
||||||
it('crea orden con estructura correcta', () => {
|
it('crea orden con estructura correcta (sin payment)', () => {
|
||||||
const order = createEmptyOrder();
|
const order = createEmptyOrder();
|
||||||
expect(order).toHaveProperty('cart');
|
expect(order).toHaveProperty('cart');
|
||||||
expect(order).toHaveProperty('pending');
|
expect(order).toHaveProperty('pending');
|
||||||
expect(order).toHaveProperty('payment_type');
|
|
||||||
expect(order).toHaveProperty('is_delivery');
|
expect(order).toHaveProperty('is_delivery');
|
||||||
expect(order).toHaveProperty('shipping_address');
|
expect(order).toHaveProperty('shipping_address');
|
||||||
expect(order).toHaveProperty('woo_order_id');
|
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', () => {
|
it('inicializa arrays vacíos', () => {
|
||||||
@@ -40,13 +40,11 @@ describe('createEmptyOrder', () => {
|
|||||||
expect(order.pending).toEqual([]);
|
expect(order.pending).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('inicializa valores null/false', () => {
|
it('inicializa valores null', () => {
|
||||||
const order = createEmptyOrder();
|
const order = createEmptyOrder();
|
||||||
expect(order.payment_type).toBeNull();
|
|
||||||
expect(order.is_delivery).toBeNull();
|
expect(order.is_delivery).toBeNull();
|
||||||
expect(order.shipping_address).toBeNull();
|
expect(order.shipping_address).toBeNull();
|
||||||
expect(order.woo_order_id).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);
|
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 = {
|
const ctx = {
|
||||||
payment_method: 'cash',
|
|
||||||
shipping_method: 'delivery',
|
shipping_method: 'delivery',
|
||||||
delivery_address: { text: 'Calle Falsa 123' },
|
delivery_address: { text: 'Calle Falsa 123' },
|
||||||
woo_order_id: 456,
|
woo_order_id: 456,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = migrateOldContext(ctx);
|
const result = migrateOldContext(ctx);
|
||||||
expect(result.payment_type).toBe('cash');
|
|
||||||
expect(result.is_delivery).toBe(true);
|
expect(result.is_delivery).toBe(true);
|
||||||
expect(result.shipping_address).toBe('Calle Falsa 123');
|
expect(result.shipping_address).toBe('Calle Falsa 123');
|
||||||
expect(result.woo_order_id).toBe(456);
|
expect(result.woo_order_id).toBe(456);
|
||||||
|
expect(result.payment_type).toBeUndefined();
|
||||||
|
expect(result.is_paid).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('migra shipping_method pickup', () => {
|
it('migra shipping_method pickup', () => {
|
||||||
|
|||||||
@@ -99,25 +99,11 @@ export const DEFAULTS = {
|
|||||||
"Anotado: {{address}}.",
|
"Anotado: {{address}}.",
|
||||||
"Listo, dirección guardada: {{address}}.",
|
"Listo, dirección guardada: {{address}}.",
|
||||||
],
|
],
|
||||||
"shipping.pickup_to_payment": [
|
// ---------------- ORDER CLOSE ----------------
|
||||||
"Genial, lo pasás a buscar. ¿Cómo abonás?",
|
"order.confirmed": [
|
||||||
"Pickup confirmado. ¿Pago en efectivo o link?",
|
"¡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á.",
|
||||||
// ---------------- 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?",
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -282,7 +268,7 @@ const REWRITE_KEYS = new Set([
|
|||||||
"idle.greeting", // se filtra adicionalmente: solo en 1er turno
|
"idle.greeting", // se filtra adicionalmente: solo en 1er turno
|
||||||
"shipping.ask_method",
|
"shipping.ask_method",
|
||||||
"shipping.ask_address",
|
"shipping.ask_address",
|
||||||
"payment.ask_method",
|
"order.confirmed",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function shouldRewrite(templateKey, history) {
|
function shouldRewrite(templateKey, history) {
|
||||||
|
|||||||
@@ -1,28 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* State Handlers - Re-export desde módulo refactorizado
|
* State Handlers - Re-export desde módulo refactorizado.
|
||||||
*
|
*
|
||||||
* Este archivo mantiene compatibilidad con imports existentes.
|
* Estructura:
|
||||||
* La implementación real está en ./stateHandlers/
|
* - stateHandlers/utils.js - Utilidades de parseo y detección de texto
|
||||||
*
|
|
||||||
* Estructura del módulo:
|
|
||||||
* - stateHandlers/utils.js - Utilidades de parseo y detección de texto
|
|
||||||
* - stateHandlers/cartHelpers.js - Helpers para manejo del carrito
|
* - stateHandlers/cartHelpers.js - Helpers para manejo del carrito
|
||||||
* - stateHandlers/idle.js - Handler estado IDLE
|
* - stateHandlers/idle.js - Handler estado IDLE
|
||||||
* - stateHandlers/cart.js - Handler estado CART
|
* - stateHandlers/cart.js - Handler estado CART
|
||||||
* - stateHandlers/shipping.js - Handler estado SHIPPING
|
* - stateHandlers/shipping.js - Handler estado SHIPPING (cierra orden)
|
||||||
* - stateHandlers/payment.js - Handler estado PAYMENT
|
|
||||||
* - stateHandlers/waiting.js - Handler estado WAITING_WEBHOOKS
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export {
|
export {
|
||||||
// Handlers principales
|
|
||||||
handleIdleState,
|
handleIdleState,
|
||||||
handleCartState,
|
handleCartState,
|
||||||
handleShippingState,
|
handleShippingState,
|
||||||
handlePaymentState,
|
|
||||||
handleWaitingState,
|
|
||||||
|
|
||||||
// Utilidades
|
|
||||||
inferDefaultUnit,
|
inferDefaultUnit,
|
||||||
parseIndexSelection,
|
parseIndexSelection,
|
||||||
isShowMoreRequest,
|
isShowMoreRequest,
|
||||||
@@ -32,7 +23,6 @@ export {
|
|||||||
normalizeUnit,
|
normalizeUnit,
|
||||||
unitAskFor,
|
unitAskFor,
|
||||||
|
|
||||||
// Helpers de carrito
|
|
||||||
extractProductQueries,
|
extractProductQueries,
|
||||||
createPendingItemFromSearch,
|
createPendingItemFromSearch,
|
||||||
processPendingClarification,
|
processPendingClarification,
|
||||||
|
|||||||
@@ -9,8 +9,6 @@
|
|||||||
export { handleIdleState } from "./idle.js";
|
export { handleIdleState } from "./idle.js";
|
||||||
export { handleCartState } from "./cart.js";
|
export { handleCartState } from "./cart.js";
|
||||||
export { handleShippingState } from "./shipping.js";
|
export { handleShippingState } from "./shipping.js";
|
||||||
export { handlePaymentState } from "./payment.js";
|
|
||||||
export { handleWaitingState } from "./waiting.js";
|
|
||||||
|
|
||||||
// Utilidades (para uso interno principalmente)
|
// Utilidades (para uso interno principalmente)
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -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] },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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 { createEmptyOrder, formatCartForDisplay } from "../orderModel.js";
|
||||||
import { parseIndexSelection } from "./utils.js";
|
import { parseIndexSelection } from "./utils.js";
|
||||||
import { renderReply } from "../replyTemplates.js";
|
import { renderReply } from "../replyTemplates.js";
|
||||||
import { buildStoreContextVars } from "../storeContext.js";
|
import { buildStoreContextVars } from "../storeContext.js";
|
||||||
|
|
||||||
const SHIPPING_OPTIONS_TAIL = "\n\n1) Delivery (te lo llevamos)\n2) Retiro en sucursal";
|
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 }) {
|
export async function handleShippingState({ tenantId, text, nlu, order, audit, recentReplies, storeConfig, conversation_history }) {
|
||||||
const intent = nlu?.intent || "other";
|
const intent = nlu?.intent || "other";
|
||||||
let currentOrder = order || createEmptyOrder();
|
let currentOrder = order || createEmptyOrder();
|
||||||
@@ -37,18 +37,8 @@ export async function handleShippingState({ tenantId, text, nlu, order, audit, r
|
|||||||
currentOrder = { ...currentOrder, is_delivery: shippingMethod === "delivery" };
|
currentOrder = { ...currentOrder, is_delivery: shippingMethod === "delivery" };
|
||||||
|
|
||||||
if (shippingMethod === "pickup") {
|
if (shippingMethod === "pickup") {
|
||||||
const { next_state } = safeNextState(ConversationState.SHIPPING, currentOrder, {});
|
// Pickup: orden lista, cerrarla.
|
||||||
const r = await renderReply({ tenantId, templateKey: "shipping.pickup_to_payment", vars: storeVars, recentReplies, ...rewriteCtx });
|
return finalizeOrder({ tenantId, currentOrder, audit, recentReplies, storeVars, 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] },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delivery: pedir dirección si no la tiene
|
// Delivery: pedir dirección si no la tiene
|
||||||
@@ -73,29 +63,7 @@ export async function handleShippingState({ tenantId, text, nlu, order, audit, r
|
|||||||
|
|
||||||
if (address) {
|
if (address) {
|
||||||
currentOrder = { ...currentOrder, shipping_address: address };
|
currentOrder = { ...currentOrder, shipping_address: address };
|
||||||
const { next_state } = safeNextState(ConversationState.SHIPPING, currentOrder, {});
|
return finalizeOrder({ tenantId, currentOrder, audit, recentReplies, storeVars, rewriteCtx, addressJustRecorded: true });
|
||||||
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],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const r = await renderReply({ tenantId, templateKey: "shipping.ask_address", vars: storeVars, recentReplies, ...rewriteCtx });
|
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] },
|
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),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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] },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Turn Engine V3 - Dispatcher basado en estados
|
* 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.
|
* 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.
|
* Feature flag USE_MODULAR_NLU=true para usar el nuevo sistema NLU modular.
|
||||||
@@ -15,8 +15,6 @@ import {
|
|||||||
handleIdleState,
|
handleIdleState,
|
||||||
handleCartState,
|
handleCartState,
|
||||||
handleShippingState,
|
handleShippingState,
|
||||||
handlePaymentState,
|
|
||||||
handleWaitingState,
|
|
||||||
} from "./stateHandlers.js";
|
} from "./stateHandlers.js";
|
||||||
import { getStoreConfig } from "../0-ui/db/settingsRepo.js";
|
import { getStoreConfig } from "../0-ui/db/settingsRepo.js";
|
||||||
import { pushRecent } from "./replyTemplates.js";
|
import { pushRecent } from "./replyTemplates.js";
|
||||||
@@ -200,14 +198,6 @@ export async function runTurnV3({
|
|||||||
result = await handleShippingState(handlerParams);
|
result = await handleShippingState(handlerParams);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case ConversationState.PAYMENT:
|
|
||||||
result = await handlePaymentState(handlerParams);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case ConversationState.WAITING_WEBHOOKS:
|
|
||||||
result = await handleWaitingState(handlerParams);
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Estado desconocido, tratar como IDLE
|
// Estado desconocido, tratar como IDLE
|
||||||
result = await handleIdleState(handlerParams);
|
result = await handleIdleState(handlerParams);
|
||||||
@@ -242,20 +232,16 @@ function normalizeState(state) {
|
|||||||
if (s === "IDLE") return ConversationState.IDLE;
|
if (s === "IDLE") return ConversationState.IDLE;
|
||||||
if (s === "CART") return ConversationState.CART;
|
if (s === "CART") return ConversationState.CART;
|
||||||
if (s === "SHIPPING") return ConversationState.SHIPPING;
|
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)) {
|
if (["CART_ACTIVE", "BROWSING", "CLARIFYING_ITEMS", "AWAITING_QUANTITY"].includes(s)) {
|
||||||
return ConversationState.CART;
|
return ConversationState.CART;
|
||||||
}
|
}
|
||||||
|
if (s === "CLARIFYING_SHIPPING" || s === "AWAITING_ADDRESS") return ConversationState.SHIPPING;
|
||||||
// Estados de checkout viejos
|
// Estados que ya no existen (payment / waiting / completed) vuelven a IDLE
|
||||||
if (s === "CLARIFYING_PAYMENT") return ConversationState.PAYMENT;
|
if (["PAYMENT", "WAITING_WEBHOOKS", "CLARIFYING_PAYMENT", "AWAITING_PAYMENT", "COMPLETED"].includes(s)) {
|
||||||
if (s === "CLARIFYING_SHIPPING") return ConversationState.SHIPPING;
|
return ConversationState.IDLE;
|
||||||
if (s === "AWAITING_ADDRESS") return ConversationState.SHIPPING;
|
}
|
||||||
if (s === "AWAITING_PAYMENT") return ConversationState.WAITING_WEBHOOKS;
|
|
||||||
if (s === "COMPLETED") return ConversationState.IDLE; // Nuevo ciclo
|
|
||||||
|
|
||||||
return ConversationState.IDLE;
|
return ConversationState.IDLE;
|
||||||
}
|
}
|
||||||
@@ -311,7 +297,6 @@ function formatResult(result, prevContext, recentReplies = [], failedSearches =
|
|||||||
unit: p.unit,
|
unit: p.unit,
|
||||||
status: p.status?.toLowerCase() || "needs_type",
|
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,
|
order.is_delivery === false ? "pickup" : null,
|
||||||
delivery_address: order.shipping_address ? { text: order.shipping_address } : null,
|
delivery_address: order.shipping_address ? { text: order.shipping_address } : null,
|
||||||
|
|||||||
@@ -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"}`;
|
const lockKey = `${tenantId}:${wooCustomerId || "anon"}`;
|
||||||
return withLock(lockKey, async () => {
|
return withLock(lockKey, async () => {
|
||||||
const client = await getWooClient({ tenantId });
|
const client = await getWooClient({ tenantId });
|
||||||
const lineItems = await buildLineItems({ tenantId, basket });
|
const lineItems = await buildLineItems({ tenantId, basket });
|
||||||
if (!lineItems.length) throw new Error("order_empty_basket");
|
if (!lineItems.length) throw new Error("order_empty_basket");
|
||||||
const addr = mapAddress(address);
|
const addr = mapAddress(address);
|
||||||
|
// Estado "pending" en Woo = el pago/cobro lo gestiona el comercio offline.
|
||||||
const payload = {
|
const payload = {
|
||||||
status: "pending",
|
status: "pending",
|
||||||
customer_id: wooCustomerId || undefined,
|
customer_id: wooCustomerId || undefined,
|
||||||
line_items: lineItems,
|
line_items: lineItems,
|
||||||
...(addr ? { billing: addr, shipping: addr } : {}),
|
...(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: [
|
meta_data: [
|
||||||
{ key: "source", value: "whatsapp" },
|
{ key: "source", value: "whatsapp" },
|
||||||
...(run_id ? [{ key: "run_id", value: run_id }] : []),
|
...(run_id ? [{ key: "run_id", value: run_id }] : []),
|
||||||
// Guardar shipping_method como metadata para poder leerlo después
|
|
||||||
...(shippingMethod ? [{ key: "shipping_method", value: shippingMethod }] : []),
|
...(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`;
|
const url = `${client.base}/orders`;
|
||||||
|
|||||||
Reference in New Issue
Block a user