/** * Handlers por estado para el flujo conversacional simplificado. * Cada handler recibe params y retorna { plan, decision } */ import { retrieveCandidates } from "./catalogRetrieval.js"; import { ConversationState, safeNextState, hasCartItems, hasPendingItems } from "./fsm.js"; import { createEmptyOrder, createPendingItem, createCartItem, PendingStatus, moveReadyToCart, getNextPendingItem, updatePendingItem, addPendingItem, migrateOldContext, formatCartForDisplay, formatOptionsForDisplay, } from "./orderModel.js"; import { handleRecommend } from "./recommendations.js"; import { getProductQtyRules } from "../0-ui/db/repo.js"; // ───────────────────────────────────────────────────────────── // Utilidades // ───────────────────────────────────────────────────────────── function inferDefaultUnit({ name, categories }) { const n = String(name || "").toLowerCase(); const cats = Array.isArray(categories) ? categories : []; const hay = (re) => cats.some((c) => re.test(String(c?.name || "")) || re.test(String(c?.slug || ""))) || re.test(n); if (hay(/\b(chimichurri|provoleta|queso|pan|salsa|aderezo|condimento|especia|especias)\b/i)) { return "unit"; } if (hay(/\b(proveedur[ií]a|almac[eé]n|almacen|sal\s+pimienta|aderezos)\b/i)) { return "unit"; } if (hay(/\b(vino|vinos|bebida|bebidas|cerveza|cervezas|gaseosa|gaseosas|whisky|ron|gin|vodka|fernet)\b/i)) { return "unit"; } return "kg"; } function parseIndexSelection(text) { const t = String(text || "").toLowerCase(); const m = /\b(\d{1,2})\b/.exec(t); if (m) return parseInt(m[1], 10); if (/\bprimera\b|\bprimero\b/.test(t)) return 1; if (/\bsegunda\b|\bsegundo\b/.test(t)) return 2; if (/\btercera\b|\btercero\b/.test(t)) return 3; if (/\bcuarta\b|\bcuarto\b/.test(t)) return 4; if (/\bquinta\b|\bquinto\b/.test(t)) return 5; if (/\bsexta\b|\bsexto\b/.test(t)) return 6; if (/\bs[eé]ptima\b|\bs[eé]ptimo\b/.test(t)) return 7; if (/\boctava\b|\boctavo\b/.test(t)) return 8; if (/\bnovena\b|\bnoveno\b/.test(t)) return 9; if (/\bd[eé]cima\b|\bd[eé]cimo\b/.test(t)) return 10; return null; } function isShowMoreRequest(text) { const t = String(text || "").toLowerCase(); return ( /\bmostr(a|ame)\s+m[aá]s\b/.test(t) || /\bmas\s+opciones\b/.test(t) || (/\bm[aá]s\b/.test(t) && /\b(opciones|productos|variedades|tipos)\b/.test(t)) || /\bsiguiente(s)?\b/.test(t) ); } function normalizeUnit(unit) { if (!unit) return null; const u = String(unit).toLowerCase(); if (u === "kg" || u === "kilo" || u === "kilos") return "kg"; if (u === "g" || u === "gramo" || u === "gramos") return "g"; if (u === "unidad" || u === "unidades" || u === "unit") return "unit"; return null; } function unitAskFor(displayUnit) { if (displayUnit === "unit") return "¿Cuántas unidades querés?"; if (displayUnit === "g") return "¿Cuántos gramos querés?"; return "¿Cuántos kilos querés?"; } // ───────────────────────────────────────────────────────────── // Handler: IDLE // ───────────────────────────────────────────────────────────── export async function handleIdleState({ tenantId, text, nlu, order, audit }) { const intent = nlu?.intent || "other"; const actions = []; // Greeting if (intent === "greeting") { return { plan: { reply: "¡Hola! ¿En qué te puedo ayudar hoy?", next_state: ConversationState.IDLE, intent: "greeting", missing_fields: [], order_action: "none", }, decision: { actions: [], order, audit }, }; } // Cualquier intent relacionado con productos → ir a CART if (["add_to_cart", "browse", "price_query", "recommend"].includes(intent)) { // Delegar a handleCartState return handleCartState({ tenantId, text, nlu, order, audit, fromIdle: true }); } // Other return { plan: { reply: "¿En qué te puedo ayudar? Podés preguntarme por productos o agregar cosas al carrito.", next_state: ConversationState.IDLE, intent: "other", missing_fields: [], order_action: "none", }, decision: { actions: [], order, audit }, }; } // ───────────────────────────────────────────────────────────── // Handler: CART // ───────────────────────────────────────────────────────────── export async function handleCartState({ tenantId, text, nlu, order, audit, fromIdle = false }) { const intent = nlu?.intent || "other"; let currentOrder = order || createEmptyOrder(); const actions = []; // 1) Si hay pending items sin resolver, procesar clarificación const pendingItem = getNextPendingItem(currentOrder); if (pendingItem) { const result = await processPendingClarification({ tenantId, text, nlu, order: currentOrder, pendingItem, audit }); if (result) return result; } // 2) view_cart: mostrar carrito actual if (intent === "view_cart") { const cartDisplay = formatCartForDisplay(currentOrder); const pendingCount = currentOrder.pending?.filter(p => p.status !== PendingStatus.READY).length || 0; let reply = cartDisplay; if (pendingCount > 0) { reply += `\n\n(Tenés ${pendingCount} producto(s) pendiente(s) de confirmar)`; } reply += "\n\n¿Algo más?"; return { plan: { reply, next_state: ConversationState.CART, intent: "view_cart", missing_fields: [], order_action: "none", }, decision: { actions: [], order: currentOrder, audit }, }; } // 3) confirm_order: ir a SHIPPING si hay items if (intent === "confirm_order") { // Primero mover pending READY a cart currentOrder = moveReadyToCart(currentOrder); if (!hasCartItems(currentOrder)) { return { plan: { reply: "Tu carrito está vacío. ¿Qué querés agregar?", next_state: ConversationState.CART, intent: "confirm_order", missing_fields: ["cart_items"], order_action: "none", }, decision: { actions: [], order: currentOrder, audit }, }; } // Si hay pending items sin resolver, clarificarlos primero if (hasPendingItems(currentOrder)) { const nextPending = getNextPendingItem(currentOrder); const { question } = formatOptionsForDisplay(nextPending); return { plan: { reply: `Antes de cerrar, tenemos que confirmar algunos productos.\n\n${question}`, next_state: ConversationState.CART, intent: "confirm_order", missing_fields: ["pending_items"], order_action: "none", }, decision: { actions: [], order: currentOrder, audit }, }; } // Todo listo, ir a SHIPPING const { next_state } = safeNextState(ConversationState.CART, currentOrder, { confirm_order: true }); return { plan: { reply: "Perfecto. ¿Es para delivery o pasás a retirar?\n\n1) Delivery (te lo llevamos)\n2) Retiro en sucursal", next_state, intent: "confirm_order", missing_fields: ["shipping_method"], order_action: "none", }, decision: { actions: [], order: currentOrder, audit }, }; } // 4) recommend if (intent === "recommend") { try { const recoResult = await handleRecommend({ tenantId, text, nlu, order: currentOrder, prevContext: { order: currentOrder }, audit }); if (recoResult?.plan?.reply) { // Merge context_patch si existe const newOrder = recoResult.decision?.order || currentOrder; const contextPatch = recoResult.decision?.context_patch || {}; return { plan: { ...recoResult.plan, next_state: ConversationState.CART, }, decision: { actions: recoResult.decision?.actions || [], order: newOrder, audit, context_patch: contextPatch, }, }; } } catch (e) { audit.recommend_error = String(e?.message || e); } } // 4.5) price_query - consulta de precios if (intent === "price_query") { const productQueries = extractProductQueries(nlu); if (productQueries.length === 0) { return { plan: { reply: "¿De qué producto querés saber el precio?", next_state: ConversationState.CART, intent: "price_query", missing_fields: ["product_query"], order_action: "none", }, decision: { actions: [], order: currentOrder, audit }, }; } // Buscar productos y mostrar precios const priceResults = []; for (const pq of productQueries.slice(0, 5)) { const searchResult = await retrieveCandidates({ tenantId, query: pq.query, limit: 3 }); const candidates = searchResult?.candidates || []; audit.price_search = audit.price_search || []; audit.price_search.push({ query: pq.query, count: candidates.length }); for (const c of candidates.slice(0, 2)) { const unit = inferDefaultUnit({ name: c.name, categories: c.categories }); const priceStr = c.price != null ? `$${c.price}` : "consultar"; const unitStr = unit === "unit" ? "/unidad" : "/kg"; priceResults.push(`• ${c.name}: ${priceStr}${unitStr}`); } } if (priceResults.length === 0) { return { plan: { reply: "No encontré ese producto. ¿Podés ser más específico?", next_state: ConversationState.CART, intent: "price_query", missing_fields: ["product_query"], order_action: "none", }, decision: { actions: [], order: currentOrder, audit }, }; } const reply = "Estos son los precios:\n\n" + priceResults.join("\n") + "\n\n¿Querés agregar alguno al carrito?"; return { plan: { reply, next_state: ConversationState.CART, intent: "price_query", missing_fields: [], order_action: "none", }, decision: { actions: [], order: currentOrder, audit }, }; } // 5) add_to_cart / browse / price_query: buscar productos if (["add_to_cart", "browse", "price_query"].includes(intent) || fromIdle) { const productQueries = extractProductQueries(nlu); if (productQueries.length === 0) { return { plan: { reply: "¿Qué producto querés agregar?", next_state: ConversationState.CART, intent, missing_fields: ["product_query"], order_action: "none", }, decision: { actions: [], order: currentOrder, audit }, }; } // Buscar candidatos para cada query for (const pq of productQueries) { const searchResult = await retrieveCandidates({ tenantId, query: pq.query, limit: 20 }); const candidates = searchResult?.candidates || []; audit.catalog_search = audit.catalog_search || []; audit.catalog_search.push({ query: pq.query, count: candidates.length }); const pendingItem = createPendingItemFromSearch({ query: pq.query, quantity: pq.quantity, unit: pq.unit, candidates, }); currentOrder = addPendingItem(currentOrder, pendingItem); } // Mover items READY directamente al cart currentOrder = moveReadyToCart(currentOrder); // Si hay pending items, pedir clarificación del primero const nextPending = getNextPendingItem(currentOrder); if (nextPending) { if (nextPending.status === PendingStatus.NEEDS_TYPE) { const { question } = formatOptionsForDisplay(nextPending); return { plan: { reply: question, next_state: ConversationState.CART, intent, missing_fields: ["product_selection"], order_action: "none", }, decision: { actions: [], order: currentOrder, audit }, }; } if (nextPending.status === PendingStatus.NEEDS_QUANTITY) { // Detectar "para X personas" en el texto original ANTES de preguntar cantidad const personasMatch = /(?:para\s+)?(\d+)\s*(personas?|comensales?|invitados?)/i.exec(text || "") || /\bpara\s+(\d+)\b/i.exec(text || "") || /\bcomo\s+para\s+(\d+)\b/i.exec(text || ""); if (personasMatch && nextPending.selected_woo_id) { const peopleCount = parseInt(personasMatch[1], 10); if (peopleCount > 0 && peopleCount <= 100) { // Buscar reglas de cantidad por persona para este producto let qtyRules = []; try { qtyRules = await getProductQtyRules({ tenantId, wooProductId: nextPending.selected_woo_id }); } catch (e) { audit.qty_rules_error = e?.message; } // Calcular cantidad recomendada let calculatedQty; let calculatedUnit = nextPending.selected_unit || "kg"; const rule = qtyRules[0]; if (rule && rule.qty_per_person > 0) { calculatedQty = rule.qty_per_person * peopleCount; calculatedUnit = rule.unit || calculatedUnit; audit.qty_from_rule = { rule_id: rule.id, qty_per_person: rule.qty_per_person, people: peopleCount }; } else { // Fallback: 0.3 kg por persona para carnes calculatedQty = 0.3 * peopleCount; audit.qty_fallback = { default_per_person: 0.3, people: peopleCount }; } // Actualizar el pending item y mover al cart const updatedOrder = updatePendingItem(currentOrder, nextPending.id, { qty: calculatedQty, unit: calculatedUnit, status: PendingStatus.READY, }); const finalOrder = moveReadyToCart(updatedOrder); const qtyStr = calculatedUnit === "unit" ? calculatedQty : `${calculatedQty}${calculatedUnit}`; return { plan: { reply: `Para ${peopleCount} personas, te recomiendo ${qtyStr} de ${nextPending.selected_name}. Ya lo anoté. ¿Algo más?`, next_state: ConversationState.CART, intent: "add_to_cart", missing_fields: [], order_action: "add_to_cart", }, decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit }, }; } } // Si no hay "para X personas", preguntar cantidad normalmente const unitQuestion = unitAskFor(nextPending.selected_unit || "kg"); return { plan: { reply: `Para ${nextPending.selected_name || nextPending.query}, ${unitQuestion}`, next_state: ConversationState.CART, intent, missing_fields: ["quantity"], order_action: "none", }, decision: { actions: [], order: currentOrder, audit }, }; } } // Todo resuelto, confirmar agregado const lastAdded = currentOrder.cart[currentOrder.cart.length - 1]; if (lastAdded) { const qtyStr = lastAdded.unit === "unit" ? lastAdded.qty : `${lastAdded.qty}${lastAdded.unit}`; return { plan: { reply: `Perfecto, anoto ${qtyStr} de ${lastAdded.name}. ¿Algo más?`, next_state: ConversationState.CART, intent: "add_to_cart", missing_fields: [], order_action: "add_to_cart", }, decision: { actions: [{ type: "add_to_cart", payload: lastAdded }], order: currentOrder, audit }, }; } } // Default return { plan: { reply: "¿Qué más querés agregar?", next_state: ConversationState.CART, intent: "other", missing_fields: [], order_action: "none", }, decision: { actions: [], order: currentOrder, audit }, }; } // ───────────────────────────────────────────────────────────── // Handler: SHIPPING // ───────────────────────────────────────────────────────────── export async function handleShippingState({ tenantId, text, nlu, order, audit }) { const intent = nlu?.intent || "other"; let currentOrder = order || createEmptyOrder(); // Detectar selección de shipping (delivery/pickup) let shippingMethod = nlu?.entities?.shipping_method; // Detectar por número o texto if (!shippingMethod) { const t = String(text || "").toLowerCase(); const idx = parseIndexSelection(text); if (idx === 1 || /delivery|envío|envio|traigan|llev/i.test(t)) { shippingMethod = "delivery"; } else if (idx === 2 || /retiro|retira|buscar|sucursal|paso/i.test(t)) { shippingMethod = "pickup"; } } if (shippingMethod) { currentOrder = { ...currentOrder, is_delivery: shippingMethod === "delivery" }; if (shippingMethod === "pickup") { // Pickup: ir directo a PAYMENT const { next_state } = safeNextState(ConversationState.SHIPPING, currentOrder, {}); return { plan: { reply: "Perfecto, retiro en sucursal. ¿Cómo preferís pagar?\n\n1) Efectivo\n2) Link de pago (tarjeta/transferencia)\n\nRespondé con el número o decime directamente.", next_state, intent: "select_shipping", missing_fields: ["payment_method"], order_action: "none", }, decision: { actions: [], order: currentOrder, audit }, }; } // Delivery: pedir dirección si no la tiene if (!currentOrder.shipping_address) { return { plan: { reply: "Perfecto, delivery. ¿Me pasás la dirección de entrega?", next_state: ConversationState.SHIPPING, intent: "select_shipping", missing_fields: ["address"], order_action: "none", }, decision: { actions: [], order: currentOrder, audit }, }; } } // Si ya eligió delivery y ahora da dirección if (currentOrder.is_delivery === true && !currentOrder.shipping_address) { // Extraer dirección del texto (el usuario probablemente escribió la dirección) const address = nlu?.entities?.address || (text?.length > 5 ? text.trim() : null); if (address) { currentOrder = { ...currentOrder, shipping_address: address }; const { next_state } = safeNextState(ConversationState.SHIPPING, currentOrder, {}); return { plan: { reply: `Anotado: ${address}.\n\n¿Cómo preferís pagar?\n\n1) Efectivo\n2) Link de pago (tarjeta/transferencia)\n\nRespondé con el número o decime directamente.`, next_state, intent: "provide_address", missing_fields: ["payment_method"], order_action: "none", }, decision: { actions: [], order: currentOrder, audit }, }; } return { plan: { reply: "Necesito la dirección de entrega. ¿Me la pasás?", next_state: ConversationState.SHIPPING, intent: "other", missing_fields: ["address"], order_action: "none", }, decision: { actions: [], order: currentOrder, audit }, }; } // view_cart if (intent === "view_cart") { const cartDisplay = formatCartForDisplay(currentOrder); return { plan: { reply: cartDisplay + "\n\n¿Es para delivery o retiro?", next_state: ConversationState.SHIPPING, intent: "view_cart", missing_fields: ["shipping_method"], order_action: "none", }, decision: { actions: [], order: currentOrder, audit }, }; } // Default: preguntar de nuevo return { plan: { reply: "¿Es para delivery o pasás a retirar?\n\n1) Delivery (te lo llevamos)\n2) Retiro en sucursal", next_state: ConversationState.SHIPPING, intent: "other", missing_fields: ["shipping_method"], order_action: "none", }, decision: { actions: [], order: currentOrder, audit }, }; } // ───────────────────────────────────────────────────────────── // Handler: PAYMENT // ───────────────────────────────────────────────────────────── export async function handlePaymentState({ tenantId, text, nlu, order, audit }) { const intent = nlu?.intent || "other"; let currentOrder = order || createEmptyOrder(); const actions = []; // Detectar selección de pago 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 } }); if (paymentMethod === "link") { actions.push({ type: "send_payment_link", payload: {} }); } 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 paso el link de pago en un momento." : "Pagás en " + (currentOrder.is_delivery ? "la entrega" : "sucursal") + "."; return { plan: { reply: `Listo, pagás en ${paymentLabel}. ${deliveryInfo}\n\n${paymentInfo}\n\n¡Gracias por tu pedido!`, next_state, intent: "select_payment", missing_fields: [], order_action: "create_order", }, decision: { actions, order: currentOrder, audit }, }; } // view_cart if (intent === "view_cart") { const cartDisplay = formatCartForDisplay(currentOrder); return { plan: { reply: cartDisplay + "\n\n¿Cómo preferís pagar?", next_state: ConversationState.PAYMENT, intent: "view_cart", missing_fields: ["payment_method"], order_action: "none", }, decision: { actions: [], order: currentOrder, audit }, }; } // Default return { plan: { reply: "¿Cómo preferís pagar?\n\n1) Efectivo\n2) Link de pago (tarjeta/transferencia)\n\nRespondé con el número o decime directamente.", next_state: ConversationState.PAYMENT, intent: "other", missing_fields: ["payment_method"], order_action: "none", }, decision: { actions: [], order: currentOrder, audit }, }; } // ───────────────────────────────────────────────────────────── // Handler: WAITING_WEBHOOKS // ───────────────────────────────────────────────────────────── export async function handleWaitingState({ tenantId, text, nlu, order, audit }) { 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 }, }; } // Default const reply = currentOrder.payment_type === "link" ? "Tu pedido está en proceso. Avisame si necesitás algo más o esperá la confirmación de pago." : "Tu pedido está listo. Avisame si necesitás algo más."; return { plan: { reply, next_state: ConversationState.WAITING_WEBHOOKS, intent: "other", missing_fields: [], order_action: "none", }, decision: { actions: [], order: currentOrder, audit }, }; } // ───────────────────────────────────────────────────────────── // Helpers internos // ───────────────────────────────────────────────────────────── function extractProductQueries(nlu) { const queries = []; // Multi-items if (Array.isArray(nlu?.entities?.items) && nlu.entities.items.length > 0) { for (const item of nlu.entities.items) { if (item.product_query) { queries.push({ query: item.product_query, quantity: item.quantity, unit: item.unit, }); } } return queries; } // Single item if (nlu?.entities?.product_query) { queries.push({ query: nlu.entities.product_query, quantity: nlu.entities.quantity, unit: nlu.entities.unit, }); } return queries; } function createPendingItemFromSearch({ query, quantity, unit, candidates }) { const cands = (candidates || []).filter(c => c && c.woo_product_id); if (cands.length === 0) { return createPendingItem({ query, candidates: [], status: PendingStatus.NEEDS_TYPE, // Will show "not found" message }); } // Check for strong match const best = cands[0]; const second = cands[1]; const isStrong = cands.length === 1 || (best?._score >= 0.9 && (!second || best._score - (second?._score || 0) >= 0.2)); if (isStrong) { // Usar sell_unit del producto si está configurado, sino inferir por heurística const displayUnit = normalizeUnit(best.sell_unit) || inferDefaultUnit({ name: best.name, categories: best.categories }); const hasQty = quantity != null && Number.isFinite(Number(quantity)) && Number(quantity) > 0; const sellsByWeight = displayUnit !== "unit"; const hasExplicitUnit = unit != null && unit !== ""; const quantityIsGeneric = hasQty && Number(quantity) <= 2 && !hasExplicitUnit; const needsQuantity = sellsByWeight && (!hasQty || quantityIsGeneric); return createPendingItem({ query, candidates: [], selected_woo_id: best.woo_product_id, selected_name: best.name, selected_price: best.price, selected_unit: displayUnit, qty: needsQuantity ? null : (hasQty ? Number(quantity) : 1), unit: normalizeUnit(unit) || displayUnit, status: needsQuantity ? PendingStatus.NEEDS_QUANTITY : PendingStatus.READY, }); } // Multiple candidates, needs selection return createPendingItem({ query, candidates: cands.slice(0, 20).map(c => ({ woo_id: c.woo_product_id, name: c.name, price: c.price, sell_unit: c.sell_unit || null, display_unit: normalizeUnit(c.sell_unit) || inferDefaultUnit({ name: c.name, categories: c.categories }), })), status: PendingStatus.NEEDS_TYPE, }); } async function processPendingClarification({ tenantId, text, nlu, order, pendingItem, audit }) { // Si necesita seleccionar tipo if (pendingItem.status === PendingStatus.NEEDS_TYPE) { const idx = parseIndexSelection(text); // Show more if (isShowMoreRequest(text)) { // TODO: implement pagination const { question } = formatOptionsForDisplay(pendingItem); return { plan: { reply: question, next_state: ConversationState.CART, intent: "other", missing_fields: ["product_selection"], order_action: "none", }, decision: { actions: [], order, audit }, }; } // Selection by index if (idx && pendingItem.candidates && idx <= pendingItem.candidates.length) { const selected = pendingItem.candidates[idx - 1]; const displayUnit = selected.display_unit || inferDefaultUnit({ name: selected.name, categories: [] }); const needsQuantity = displayUnit !== "unit"; const updatedOrder = updatePendingItem(order, pendingItem.id, { selected_woo_id: selected.woo_id, selected_name: selected.name, selected_price: selected.price, selected_unit: displayUnit, candidates: [], status: needsQuantity ? PendingStatus.NEEDS_QUANTITY : PendingStatus.READY, qty: needsQuantity ? null : 1, unit: displayUnit, }); // Si necesita cantidad, preguntar if (needsQuantity) { const unitQuestion = unitAskFor(displayUnit); return { plan: { reply: `Para ${selected.name}, ${unitQuestion}`, next_state: ConversationState.CART, intent: "add_to_cart", missing_fields: ["quantity"], order_action: "none", }, decision: { actions: [], order: updatedOrder, audit }, }; } // Listo, mover al cart const finalOrder = moveReadyToCart(updatedOrder); return { plan: { reply: `Perfecto, anoto 1 ${selected.name}. ¿Algo más?`, next_state: ConversationState.CART, intent: "add_to_cart", missing_fields: [], order_action: "add_to_cart", }, decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit }, }; } // No entendió, volver a preguntar const { question } = formatOptionsForDisplay(pendingItem); return { plan: { reply: "No entendí. " + question, next_state: ConversationState.CART, intent: "other", missing_fields: ["product_selection"], order_action: "none", }, decision: { actions: [], order, audit }, }; } // Si necesita cantidad if (pendingItem.status === PendingStatus.NEEDS_QUANTITY) { const qty = nlu?.entities?.quantity; const unit = nlu?.entities?.unit; // Try to parse quantity from text let parsedQty = qty; if (parsedQty == null) { const m = /(\d+(?:[.,]\d+)?)\s*(kg|kilo|kilos|g|gramo|gramos|unidad|unidades)?/i.exec(text || ""); if (m) { parsedQty = parseFloat(m[1].replace(",", ".")); } } if (parsedQty != null && Number.isFinite(parsedQty) && parsedQty > 0) { const finalUnit = normalizeUnit(unit) || pendingItem.selected_unit || "kg"; const updatedOrder = updatePendingItem(order, pendingItem.id, { qty: parsedQty, unit: finalUnit, status: PendingStatus.READY, }); const finalOrder = moveReadyToCart(updatedOrder); const qtyStr = finalUnit === "unit" ? parsedQty : `${parsedQty}${finalUnit}`; return { plan: { reply: `Perfecto, anoto ${qtyStr} de ${pendingItem.selected_name}. ¿Algo más?`, next_state: ConversationState.CART, intent: "add_to_cart", missing_fields: [], order_action: "add_to_cart", }, decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit }, }; } // Detectar "para X personas" y calcular cantidad automáticamente const personasMatch = /(?:para\s+)?(\d+)\s*(personas?|comensales?|invitados?)/i.exec(text || "") || /\bpara\s+(\d+)\b/i.exec(text || "") || /\bcomo\s+para\s+(\d+)\b/i.exec(text || ""); if (personasMatch && pendingItem.selected_woo_id) { const peopleCount = parseInt(personasMatch[1], 10); if (peopleCount > 0 && peopleCount <= 100) { // Buscar reglas de cantidad por persona para este producto let qtyRules = []; try { qtyRules = await getProductQtyRules({ tenantId, wooProductId: pendingItem.selected_woo_id }); } catch (e) { audit.qty_rules_error = e?.message; } // Buscar regla para evento "asado" o genérica (null) const rule = qtyRules.find(r => r.event_type === "asado" && r.person_type === "adult") || qtyRules.find(r => r.event_type === null && r.person_type === "adult") || qtyRules.find(r => r.person_type === "adult") || qtyRules[0]; let calculatedQty; let calculatedUnit = pendingItem.selected_unit || "kg"; if (rule && rule.qty_per_person > 0) { // Usar regla de BD calculatedQty = rule.qty_per_person * peopleCount; calculatedUnit = rule.unit || calculatedUnit; audit.qty_rule_used = { rule_id: rule.id, qty_per_person: rule.qty_per_person, unit: rule.unit }; } else { // Fallback: 300g por persona para productos por peso const fallbackPerPerson = calculatedUnit === "unit" ? 1 : 0.3; calculatedQty = fallbackPerPerson * peopleCount; audit.qty_fallback_used = { per_person: fallbackPerPerson, unit: calculatedUnit }; } // Redondear a 1 decimal para kg, entero para unidades if (calculatedUnit === "unit") { calculatedQty = Math.ceil(calculatedQty); } else { calculatedQty = Math.round(calculatedQty * 10) / 10; } const updatedOrder = updatePendingItem(order, pendingItem.id, { qty: calculatedQty, unit: calculatedUnit, status: PendingStatus.READY, }); const finalOrder = moveReadyToCart(updatedOrder); const qtyStr = calculatedUnit === "unit" ? calculatedQty : `${calculatedQty}${calculatedUnit}`; return { plan: { reply: `Para ${peopleCount} personas, te recomiendo ${qtyStr} de ${pendingItem.selected_name}. Ya lo anoté. ¿Algo más?`, next_state: ConversationState.CART, intent: "add_to_cart", missing_fields: [], order_action: "add_to_cart", }, decision: { actions: [{ type: "add_to_cart" }], order: finalOrder, audit }, }; } } // No entendió cantidad const unitQuestion = unitAskFor(pendingItem.selected_unit || "kg"); return { plan: { reply: `No entendí la cantidad. ${unitQuestion}`, next_state: ConversationState.CART, intent: "other", missing_fields: ["quantity"], order_action: "none", }, decision: { actions: [], order, audit }, }; } return null; }